From e06095dcffcadf44a562af16ec385a9d10dee9d2 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sun, 31 Aug 2025 11:38:16 +0200 Subject: [PATCH 1/3] feat(compiler-config): Implement automatic script target detection and identifier validation The changes in this commit introduce a new `CompilerConfig` singleton that manages the TypeScript compiler options and script target configuration for the codegen system. The key changes are: 1. Automatic script target detection: The system now automatically determines the appropriate TypeScript script target from the `ts-morph` Project's compiler options. 2. Identifier validation: Property names in generated TypeBox objects are validated using TypeScript's built-in utilities (`ts.isIdentifierStart()` and `ts.isIdentifierPart()`). This ensures compatibility with the detected script target. 3. Configuration management: The `CompilerConfig` singleton provides a centralized way to manage the script target configuration, allowing it to be overridden per-project as needed. 4. Default behavior: When no explicit target is specified, the system falls back to `ts.ScriptTarget.Latest`, providing maximum compatibility with modern JavaScript features. 5. Integration points: The configuration system is integrated with various components of the codegen system, including the Input Handler, Code Generation, Identifier Utils, and Object Handlers. These changes improve the overall robustness and flexibility of the codegen system, ensuring that the generated code is compatible with the target JavaScript environment. --- docs/compiler-configuration.md | 46 +++++++ docs/handler-system.md | 8 ++ docs/overview.md | 1 + .../object/object-like-base-handler.ts | 43 +++++-- src/index.ts | 7 +- src/input-handler.ts | 5 + src/utils/compiler-config.ts | 85 +++++++++++++ src/utils/identifier-utils.ts | 22 ++++ tests/handlers/typebox/enums.test.ts | 50 ++++++++ .../interface-generics-consistency.test.ts | 2 - ...interface-generics-runtime-binding.test.ts | 32 +---- tests/handlers/typebox/objects.test.ts | 31 +++++ tests/import-resolution.test.ts | 2 +- tests/input-handler.test.ts | 3 +- .../script-target-integration.test.ts | 117 ++++++++++++++++++ tests/traverse/dependency-ordering.test.ts | 38 +++--- .../dependency-traversal.integration.test.ts | 6 +- tests/traverse/simple-dependency.test.ts | 20 +-- tests/utils/compiler-config.test.ts | 97 +++++++++++++++ tests/utils/identifier-utils.test.ts | 82 ++++++++++++ 20 files changed, 622 insertions(+), 75 deletions(-) create mode 100644 docs/compiler-configuration.md create mode 100644 src/utils/compiler-config.ts create mode 100644 src/utils/identifier-utils.ts create mode 100644 tests/integration/script-target-integration.test.ts create mode 100644 tests/utils/compiler-config.test.ts create mode 100644 tests/utils/identifier-utils.test.ts diff --git a/docs/compiler-configuration.md b/docs/compiler-configuration.md new file mode 100644 index 0000000..71aaf07 --- /dev/null +++ b/docs/compiler-configuration.md @@ -0,0 +1,46 @@ +# Compiler Configuration + +The codegen system automatically adapts to TypeScript compiler options to ensure generated code is compatible with the target JavaScript environment. + +## Script Target Detection + +The system automatically determines the appropriate TypeScript script target from the ts-morph Project's compiler options + +## Identifier Validation + +Property names in generated TypeBox objects are validated using TypeScript's built-in utilities: + +- `ts.isIdentifierStart()` - validates first character +- `ts.isIdentifierPart()` - validates remaining characters + +The validation respects the detected script target to ensure compatibility: + +```typescript +// With ES5 target +interface Example { + validName: string; // → validName: Type.String() + "invalid-name": number; // → "invalid-name": Type.Number() + "123invalid": boolean; // → "123invalid": Type.Boolean() +} +``` + +## Configuration Management + +The `CompilerConfig` singleton manages script target configuration. + +## Default Behavior + +When no explicit target is specified: + +- Falls back to `ts.ScriptTarget.Latest` +- Provides maximum compatibility with modern JavaScript features +- Can be overridden per-project as needed + +## Integration Points + +The configuration system integrates with: + +- **Input Handler** - Initializes config when creating source files +- **Code Generation** - Uses config for output file creation +- **Identifier Utils** - Validates property names with correct target +- **Object Handlers** - Determines property name formatting diff --git a/docs/handler-system.md b/docs/handler-system.md index 1fb5040..22cb6ec 100644 --- a/docs/handler-system.md +++ b/docs/handler-system.md @@ -30,6 +30,14 @@ export abstract class BaseTypeHandler { - `ObjectTypeHandler` - { prop: T } - `InterfaceTypeHandler` - interface references +Object property names are extracted using the TypeScript compiler API through `PropertySignature.getNameNode()`. The system handles different property name formats: + +- **Identifiers** (`prop`) - extracted using `nameNode.getText()` and preserved as identifiers +- **String literals** (`'prop-name'`, `"prop name"`) - extracted using `nameNode.getLiteralValue()` and validated for identifier compatibility +- **Numeric literals** (`123`) - extracted using `nameNode.getLiteralValue().toString()` and treated as identifiers + +The system uses TypeScript's built-in character validation utilities (`ts.isIdentifierStart` and `ts.isIdentifierPart`) with runtime-determined script targets to determine if property names can be safely used as unquoted identifiers in the generated code. The script target is automatically determined from the ts-morph Project's compiler options, ensuring compatibility with the target JavaScript environment while maintaining optimal output format. + ### Utility Types - `PartialTypeHandler` - Partial diff --git a/docs/overview.md b/docs/overview.md index 5255e2e..dff28ac 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -46,5 +46,6 @@ export type User = Static - [architecture.md](./architecture.md) - System architecture - [parser-system.md](./parser-system.md) - TypeScript parsing - [handler-system.md](./handler-system.md) - Type conversion +- [compiler-configuration.md](./compiler-configuration.md) - Compiler options and script targets - [dependency-management.md](./dependency-management.md) - Dependency analysis - [testing.md](./testing.md) - Testing diff --git a/src/handlers/typebox/object/object-like-base-handler.ts b/src/handlers/typebox/object/object-like-base-handler.ts index cfeac4d..c936110 100644 --- a/src/handlers/typebox/object/object-like-base-handler.ts +++ b/src/handlers/typebox/object/object-like-base-handler.ts @@ -1,20 +1,19 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' +import { isValidIdentifier } from '@daxserver/validation-schema-codegen/utils/identifier-utils' import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { PropertySignature, ts } from 'ts-morph' +import { Node, PropertySignature, ts } from 'ts-morph' export abstract class ObjectLikeBaseHandler extends BaseTypeHandler { protected processProperties(properties: PropertySignature[]): ts.PropertyAssignment[] { const propertyAssignments: ts.PropertyAssignment[] = [] for (const prop of properties) { - const propName = prop.getName() const propTypeNode = prop.getTypeNode() - if (!propTypeNode) { - continue - } + if (!propTypeNode) continue + const outputNameNode = this.extractPropertyNameInfo(prop) const valueExpr = getTypeBoxType(propTypeNode) const isAlreadyOptional = ts.isCallExpression(valueExpr) && @@ -26,11 +25,7 @@ export abstract class ObjectLikeBaseHandler extends BaseTypeHandler { ? makeTypeCall('Optional', [valueExpr]) : valueExpr - const nameNode = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propName) - ? ts.factory.createIdentifier(propName) - : ts.factory.createStringLiteral(propName) - - propertyAssignments.push(ts.factory.createPropertyAssignment(nameNode, maybeOptional)) + propertyAssignments.push(ts.factory.createPropertyAssignment(outputNameNode, maybeOptional)) } return propertyAssignments @@ -41,4 +36,32 @@ export abstract class ObjectLikeBaseHandler extends BaseTypeHandler { return makeTypeCall('Object', [objectLiteral]) } + + private extractPropertyNameInfo(prop: PropertySignature): ts.PropertyName { + const nameNode = prop.getNameNode() + let propName: string + let shouldUseIdentifier: boolean + + if (Node.isIdentifier(nameNode)) { + // If it was originally an identifier, keep it as an identifier + propName = nameNode.getText() + shouldUseIdentifier = true + } else if (Node.isStringLiteral(nameNode)) { + // For quoted properties, get the literal value and check if it can be an identifier + propName = nameNode.getLiteralValue() + shouldUseIdentifier = isValidIdentifier(propName) + } else if (Node.isNumericLiteral(nameNode)) { + // Numeric properties can be used as identifiers + propName = nameNode.getLiteralValue().toString() + shouldUseIdentifier = true + } else { + // Fallback for any other cases + propName = prop.getName() + shouldUseIdentifier = isValidIdentifier(propName) + } + + return shouldUseIdentifier + ? ts.factory.createIdentifier(propName) + : ts.factory.createStringLiteral(propName) + } } diff --git a/src/index.ts b/src/index.ts index c7c2d6a..7881e14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,16 @@ import { import { TypeBoxPrinter } from '@daxserver/validation-schema-codegen/printer/typebox-printer' import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types' +import { initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' import type { VisualizationOptions } from '@daxserver/validation-schema-codegen/utils/graph-visualizer' import { Node, Project, SourceFile, ts } from 'ts-morph' const createOutputFile = (hasGenericInterfaces: boolean) => { - const newSourceFile = new Project().createSourceFile('output.ts', '', { + const project = new Project() + + initializeCompilerConfig(project) + + const newSourceFile = project.createSourceFile('output.ts', '', { overwrite: true, }) diff --git a/src/input-handler.ts b/src/input-handler.ts index 1e90c0f..775e104 100644 --- a/src/input-handler.ts +++ b/src/input-handler.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from 'fs' import { dirname, isAbsolute, resolve } from 'path' import { Project, SourceFile } from 'ts-morph' +import { initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' export interface InputOptions { filePath?: string @@ -65,6 +66,10 @@ export const createSourceFileFromInput = (options: InputOptions): SourceFile => validateInputOptions(options) const project = options.project || new Project() + + // Initialize compiler configuration from the project + initializeCompilerConfig(project) + const { filePath, sourceCode, callerFile } = options if (sourceCode) { diff --git a/src/utils/compiler-config.ts b/src/utils/compiler-config.ts new file mode 100644 index 0000000..ecb06ea --- /dev/null +++ b/src/utils/compiler-config.ts @@ -0,0 +1,85 @@ +import { Project, ts } from 'ts-morph' + +/** + * Configuration utility for managing TypeScript compiler options and script targets + */ +export class CompilerConfig { + private static instance: CompilerConfig | null = null + private scriptTarget: ts.ScriptTarget = ts.ScriptTarget.Latest + + private constructor() {} + + /** + * Gets the singleton instance of CompilerConfig + */ + static getInstance(): CompilerConfig { + if (!CompilerConfig.instance) { + CompilerConfig.instance = new CompilerConfig() + } + return CompilerConfig.instance + } + + /** + * Initializes the compiler configuration from a ts-morph Project + */ + initializeFromProject(project: Project): void { + const compilerOptions = project.getCompilerOptions() + this.scriptTarget = this.determineScriptTarget(compilerOptions) + } + + /** + * Initializes the compiler configuration from TypeScript compiler options + */ + initializeFromCompilerOptions(compilerOptions: ts.CompilerOptions): void { + this.scriptTarget = this.determineScriptTarget(compilerOptions) + } + + /** + * Gets the current script target + */ + getScriptTarget(): ts.ScriptTarget { + return this.scriptTarget + } + + /** + * Sets the script target explicitly + */ + setScriptTarget(target: ts.ScriptTarget): void { + this.scriptTarget = target + } + + /** + * Determines the appropriate script target from compiler options + */ + private determineScriptTarget(compilerOptions: ts.CompilerOptions): ts.ScriptTarget { + // If target is explicitly set in compiler options, use it + if (compilerOptions.target !== undefined) { + return compilerOptions.target + } + + // Default fallback based on common configurations + // ESNext maps to Latest, ES2022+ maps to ES2022, etc. + return ts.ScriptTarget.Latest + } + + /** + * Resets the configuration to defaults + */ + reset(): void { + this.scriptTarget = ts.ScriptTarget.Latest + } +} + +/** + * Convenience function to get the current script target + */ +export const getScriptTarget = (): ts.ScriptTarget => { + return CompilerConfig.getInstance().getScriptTarget() +} + +/** + * Convenience function to initialize compiler config from a project + */ +export const initializeCompilerConfig = (project: Project): void => { + CompilerConfig.getInstance().initializeFromProject(project) +} diff --git a/src/utils/identifier-utils.ts b/src/utils/identifier-utils.ts new file mode 100644 index 0000000..c6c7e84 --- /dev/null +++ b/src/utils/identifier-utils.ts @@ -0,0 +1,22 @@ +import { ts } from 'ts-morph' +import { getScriptTarget } from '@daxserver/validation-schema-codegen/utils/compiler-config' + +/** + * Validates if a string can be used as a JavaScript identifier using TypeScript's built-in utilities + * Uses the runtime-determined script target for validation + */ +export const isValidIdentifier = (text: string, scriptTarget?: ts.ScriptTarget): boolean => { + if (text.length === 0) return false + + const target = scriptTarget ?? getScriptTarget() + + // First character must be valid identifier start + if (!ts.isIdentifierStart(text.charCodeAt(0), target)) return false + + // Remaining characters must be valid identifier parts + for (let i = 1; i < text.length; i++) { + if (!ts.isIdentifierPart(text.charCodeAt(i), target)) return false + } + + return true +} diff --git a/tests/handlers/typebox/enums.test.ts b/tests/handlers/typebox/enums.test.ts index 1bef710..8ea3e04 100644 --- a/tests/handlers/typebox/enums.test.ts +++ b/tests/handlers/typebox/enums.test.ts @@ -59,6 +59,31 @@ describe('Enum types', () => { `), ) }) + + test('with and without value', () => { + const sourceFile = createSourceFile( + project, + ` + enum A { + B, + C = 'c', + } + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export enum A { + B, + C = 'c', + } + + export const ASchema = Type.Enum(A); + + export type ASchema = Static; + `), + ) + }) }) describe('with exports', () => { @@ -111,5 +136,30 @@ describe('Enum types', () => { `), ) }) + + test('with and without value', () => { + const sourceFile = createSourceFile( + project, + ` + export enum A { + B, + C = 'c', + } + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export enum A { + B, + C = 'c', + } + + export const ASchema = Type.Enum(A); + + export type ASchema = Static; + `), + ) + }) }) }) diff --git a/tests/handlers/typebox/interface-generics-consistency.test.ts b/tests/handlers/typebox/interface-generics-consistency.test.ts index 6a08912..d8be675 100644 --- a/tests/handlers/typebox/interface-generics-consistency.test.ts +++ b/tests/handlers/typebox/interface-generics-consistency.test.ts @@ -54,8 +54,6 @@ describe('Interface Generic Consistency with Type Aliases', () => { }) test('complex generic interface should use GenericTypeUtils flow', () => { - // This test is designed to fail if the interface parser doesn't use - // the same GenericTypeUtils.createGenericArrowFunction flow as type aliases const sourceFile = createSourceFile( project, ` diff --git a/tests/handlers/typebox/interface-generics-runtime-binding.test.ts b/tests/handlers/typebox/interface-generics-runtime-binding.test.ts index 7a689fa..9e83d84 100644 --- a/tests/handlers/typebox/interface-generics-runtime-binding.test.ts +++ b/tests/handlers/typebox/interface-generics-runtime-binding.test.ts @@ -9,27 +9,21 @@ describe('Interface Generic Runtime Binding', () => { project = new Project() }) - test('generic interface should generate arrow function wrapper for runtime bindings', () => { + test('generic interface with single generic type', () => { const sourceFile = createSourceFile( project, ` interface Container { value: T; - id: string; } `, ) - const result = generateFormattedCode(sourceFile, true) - - // The generated code should be an arrow function that takes type parameters - // and returns the TypeBox expression, not just the raw TypeBox expression - expect(result).toBe( + expect(generateFormattedCode(sourceFile, true)).toBe( formatWithPrettier( ` export const Container = (T: T) => Type.Object({ value: T, - id: Type.String(), }); export type Container = Static>>; @@ -40,27 +34,23 @@ describe('Interface Generic Runtime Binding', () => { ) }) - test('generic interface with multiple type parameters should generate proper arrow function', () => { + test('generic interface with multiple type parameters', () => { const sourceFile = createSourceFile( project, ` interface Response { data: T; error: E; - timestamp: number; } `, ) - const result = generateFormattedCode(sourceFile, true) - - expect(result).toBe( + expect(generateFormattedCode(sourceFile, true)).toBe( formatWithPrettier( ` export const Response = (T: T, E: E) => Type.Object({ data: T, error: E, - timestamp: Type.Number(), }); export type Response = Static>>; @@ -71,9 +61,7 @@ describe('Interface Generic Runtime Binding', () => { ) }) - test('should fail with current implementation - demonstrates the issue', () => { - // This test is designed to fail with the current implementation - // to show that we need to fix the generic interface handling + test('generic interface with multiple generics and non-generics', () => { const sourceFile = createSourceFile( project, ` @@ -88,15 +76,7 @@ describe('Interface Generic Runtime Binding', () => { `, ) - const result = generateFormattedCode(sourceFile, true) - - // This should generate an arrow function, but if the current implementation - // is broken, it might generate something like: - // export const GenericContainer = Type.Object({...}) - // instead of: - // export const GenericContainer = (T: T, U: U) => Type.Object({...}) - - expect(result).toBe( + expect(generateFormattedCode(sourceFile, true)).toBe( formatWithPrettier( ` export const GenericContainer = (T: T, U: U) => Type.Object({ diff --git a/tests/handlers/typebox/objects.test.ts b/tests/handlers/typebox/objects.test.ts index aadd084..2c4ba3c 100644 --- a/tests/handlers/typebox/objects.test.ts +++ b/tests/handlers/typebox/objects.test.ts @@ -52,6 +52,37 @@ describe('Object types', () => { ) }) + test('object with various property name formats', () => { + const sourceFile = createSourceFile( + project, + ` + export type ComplexProps = { + identifier: string; + 'single-quoted': number; + "double-quoted": boolean; + 'with spaces': string; + 123: number; + 'normal': string; + } + `, + ) + + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier(` + export const ComplexProps = Type.Object({ + identifier: Type.String(), + 'single-quoted': Type.Number(), + 'double-quoted': Type.Boolean(), + 'with spaces': Type.String(), + 123: Type.Number(), + normal: Type.String(), + }); + + export type ComplexProps = Static; + `), + ) + }) + test('Tuple', () => { const sourceFile = createSourceFile(project, `export type T = [number, null];`) diff --git a/tests/import-resolution.test.ts b/tests/import-resolution.test.ts index cbaa149..5be6d7d 100644 --- a/tests/import-resolution.test.ts +++ b/tests/import-resolution.test.ts @@ -77,7 +77,7 @@ describe('ts-morph codegen with imports', () => { local: string; external: ExternalType; }; - `, + `, ) expect(generateFormattedCode(userFile)).toBe( diff --git a/tests/input-handler.test.ts b/tests/input-handler.test.ts index b250cb9..8cd9ed5 100644 --- a/tests/input-handler.test.ts +++ b/tests/input-handler.test.ts @@ -273,7 +273,8 @@ describe('Input Handler', () => { export type Test = { id: number name: string - }` + } + ` writeFileSync(testFilePath, code) const sourceFile = createSourceFileFromInput({ filePath: testFilePath }) diff --git a/tests/integration/script-target-integration.test.ts b/tests/integration/script-target-integration.test.ts new file mode 100644 index 0000000..17cb8bd --- /dev/null +++ b/tests/integration/script-target-integration.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, beforeEach, afterEach, test } from 'bun:test' +import { Project, ts } from 'ts-morph' +import { CompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' +import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' + +describe('Script Target Integration', () => { + let compilerConfig: CompilerConfig + + beforeEach(() => { + compilerConfig = CompilerConfig.getInstance() + compilerConfig.reset() + }) + + afterEach(() => { + compilerConfig.reset() + }) + + test('should use project compiler options for script target', () => { + const project = new Project({ + compilerOptions: { + target: ts.ScriptTarget.ES2015 + } + }) + + const sourceFile = createSourceFile(project, + ` + interface TestInterface { + validName: string; + "quoted-property": number; + "valid_identifier": string; + "invalid-property": string; + } + `) + + expect(generateFormattedCode(sourceFile)).toBe(formatWithPrettier( + ` + export const TestInterface = Type.Object({ + validName: Type.String(), + "quoted-property": Type.Number(), + valid_identifier: Type.String(), + "invalid-property": Type.String(), + }) + + export type TestInterface = Static + ` + )) + }) + + test('should work with different script targets', () => { + const testCases = [ + ts.ScriptTarget.ES5, + ts.ScriptTarget.ES2015, + ts.ScriptTarget.ES2020, + ts.ScriptTarget.Latest + ] + + for (const target of testCases) { + const project = new Project({ + compilerOptions: { + target + } + }) + + const sourceFile = createSourceFile(project, + ` + interface TestInterface { + validName: string; + "invalid-name": number; + } + ` + ) + + expect(generateFormattedCode(sourceFile)).toBe(formatWithPrettier( + ` + export const TestInterface = Type.Object({ + validName: Type.String(), + "invalid-name": Type.Number(), + }) + + export type TestInterface = Static + ` + )) + } + }) + + test('should handle numeric property names correctly', () => { + const project = new Project({ + compilerOptions: { + target: ts.ScriptTarget.Latest + } + }) + + const sourceFile = createSourceFile(project, + ` + interface T { + 123: string; + "456": number; + validName: boolean; + "valid_name": string; + } + ` + ) + + expect(generateFormattedCode(sourceFile)).toBe(formatWithPrettier( + ` + export const T = Type.Object({ + 123: Type.String(), + "456": Type.Number(), + validName: Type.Boolean(), + valid_name: Type.String(), + }) + + export type T = Static + ` + )) + }) +}) diff --git a/tests/traverse/dependency-ordering.test.ts b/tests/traverse/dependency-ordering.test.ts index 62472bb..2a47ddd 100644 --- a/tests/traverse/dependency-ordering.test.ts +++ b/tests/traverse/dependency-ordering.test.ts @@ -80,11 +80,11 @@ describe('Dependency ordering', () => { export type GeoShapeSnakDataValue = Static; export const DataValueByDataType = Type.Object({ - "'string'": StringSnakDataValue, - "'commonsMedia'": CommonsMediaSnakDataValue, - "'external-id'": ExternalIdSnakDataValue, - "'geo-shape'": GeoShapeSnakDataValue, - }); + string: StringSnakDataValue, + commonsMedia: CommonsMediaSnakDataValue, + 'external-id': ExternalIdSnakDataValue, + 'geo-shape': GeoShapeSnakDataValue, + }); export type DataValueByDataType = Static; `), @@ -96,9 +96,9 @@ describe('Dependency ordering', () => { const sourceFile = project.createSourceFile( 'test.ts', ` - export type TypeB = TypeA - export type TypeA = string - `, + export type TypeB = TypeA + export type TypeA = string + `, ) const traversal = new DependencyTraversal() @@ -118,20 +118,20 @@ describe('Dependency ordering', () => { const sourceFile = project.createSourceFile( 'test.ts', ` - export type EntityInfo = { - id: EntityId - name: string - } + export type EntityInfo = { + id: EntityId + name: string + } - export type EntityId = string + export type EntityId = string - export type Entities = Record + export type Entities = Record - export type Entity = { - info: EntityInfo - type: string - } - `, + export type Entity = { + info: EntityInfo + type: string + } + `, ) const traversal = new DependencyTraversal() diff --git a/tests/traverse/dependency-traversal.integration.test.ts b/tests/traverse/dependency-traversal.integration.test.ts index 8bcb4bd..354db8d 100644 --- a/tests/traverse/dependency-traversal.integration.test.ts +++ b/tests/traverse/dependency-traversal.integration.test.ts @@ -115,11 +115,7 @@ describe('Dependency Traversal', () => { }) test('should handle missing module specifier source file', () => { - const mainFile = createSourceFile( - project, - 'import { NonExistent } from "./non-existent";', - 'main.ts', - ) + const mainFile = createSourceFile(project, 'import { NonExistent } from "./non-existent";') traverser.startTraversal(mainFile) const dependencies = traverser.getNodesToPrint() diff --git a/tests/traverse/simple-dependency.test.ts b/tests/traverse/simple-dependency.test.ts index f726372..92d3796 100644 --- a/tests/traverse/simple-dependency.test.ts +++ b/tests/traverse/simple-dependency.test.ts @@ -1,4 +1,5 @@ import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal' +import { createSourceFile } from '@test-fixtures/utils' import { describe, expect, test } from 'bun:test' import { Project } from 'ts-morph' @@ -6,17 +7,16 @@ describe('Simple dependency ordering', () => { test('should order simple dependencies correctly', () => { const project = new Project() - // Create a simple test case with clear dependencies - const sourceFile = project.createSourceFile( - 'test.ts', + const sourceFile = createSourceFile( + project, ` - export type UserId = string - export type User = { - id: UserId - name: string - } - export type Users = Record - `, + export type UserId = string + export type User = { + id: UserId + name: string + } + export type Users = Record + `, ) const traversal = new DependencyTraversal() diff --git a/tests/utils/compiler-config.test.ts b/tests/utils/compiler-config.test.ts new file mode 100644 index 0000000..b775d9c --- /dev/null +++ b/tests/utils/compiler-config.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, beforeEach, afterEach, test } from 'bun:test' +import { Project, ts } from 'ts-morph' +import { CompilerConfig, getScriptTarget, initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' + +describe('compiler-config', () => { + let compilerConfig: CompilerConfig + + beforeEach(() => { + compilerConfig = CompilerConfig.getInstance() + compilerConfig.reset() + }) + + afterEach(() => { + compilerConfig.reset() + }) + + describe('CompilerConfig', () => { + test('should be a singleton', () => { + const instance1 = CompilerConfig.getInstance() + const instance2 = CompilerConfig.getInstance() + expect(instance1).toBe(instance2) + }) + + test('should have default script target', () => { + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + }) + + test('should allow setting script target explicitly', () => { + compilerConfig.setScriptTarget(ts.ScriptTarget.ES2015) + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES2015) + + compilerConfig.setScriptTarget(ts.ScriptTarget.ES5) + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES5) + }) + + test('should reset to defaults', () => { + compilerConfig.setScriptTarget(ts.ScriptTarget.ES5) + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES5) + + compilerConfig.reset() + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + }) + + test('should initialize from compiler options with explicit target', () => { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2015 + } + + compilerConfig.initializeFromCompilerOptions(compilerOptions) + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES2015) + }) + + test('should use default target when compiler options have no target', () => { + const compilerOptions: ts.CompilerOptions = {} + + compilerConfig.initializeFromCompilerOptions(compilerOptions) + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + }) + + test('should initialize from ts-morph Project', () => { + const project = new Project({ + compilerOptions: { + target: ts.ScriptTarget.ES2020 + } + }) + + compilerConfig.initializeFromProject(project) + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES2020) + }) + + test('should handle Project with no explicit compiler options', () => { + const project = new Project() + + compilerConfig.initializeFromProject(project) + // Should use default + expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + }) + }) + + describe('convenience functions', () => { + test('getScriptTarget should return current script target', () => { + compilerConfig.setScriptTarget(ts.ScriptTarget.ES2018) + expect(getScriptTarget()).toBe(ts.ScriptTarget.ES2018) + }) + + test('initializeCompilerConfig should initialize from project', () => { + const project = new Project({ + compilerOptions: { + target: ts.ScriptTarget.ES2017 + } + }) + + initializeCompilerConfig(project) + expect(getScriptTarget()).toBe(ts.ScriptTarget.ES2017) + }) + }) +}) diff --git a/tests/utils/identifier-utils.test.ts b/tests/utils/identifier-utils.test.ts new file mode 100644 index 0000000..abb24ac --- /dev/null +++ b/tests/utils/identifier-utils.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, beforeEach, afterEach, test } from 'bun:test' +import { ts } from 'ts-morph' +import { isValidIdentifier } from '@daxserver/validation-schema-codegen/utils/identifier-utils' +import { CompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' + +describe('identifier-utils', () => { + let compilerConfig: CompilerConfig + + beforeEach(() => { + compilerConfig = CompilerConfig.getInstance() + compilerConfig.reset() + }) + + afterEach(() => { + compilerConfig.reset() + }) + + describe('isValidIdentifier', () => { + test('should validate basic identifiers', () => { + expect(isValidIdentifier('validName')).toBe(true) + expect(isValidIdentifier('_underscore')).toBe(true) + expect(isValidIdentifier('$dollar')).toBe(true) + expect(isValidIdentifier('camelCase')).toBe(true) + expect(isValidIdentifier('PascalCase')).toBe(true) + }) + + test('should reject invalid identifiers', () => { + expect(isValidIdentifier('')).toBe(false) + expect(isValidIdentifier('123invalid')).toBe(false) + expect(isValidIdentifier('invalid-name')).toBe(false) + expect(isValidIdentifier('invalid.name')).toBe(false) + expect(isValidIdentifier('invalid name')).toBe(false) + expect(isValidIdentifier('invalid@name')).toBe(false) + }) + + test('should handle identifiers with numbers after first character', () => { + expect(isValidIdentifier('valid123')).toBe(true) + expect(isValidIdentifier('name2')).toBe(true) + expect(isValidIdentifier('test_123')).toBe(true) + }) + + test('should handle Unicode identifiers based on script target', () => { + // Test with explicit script target + expect(isValidIdentifier('validName', ts.ScriptTarget.ES5)).toBe(true) + expect(isValidIdentifier('validName', ts.ScriptTarget.ES2015)).toBe(true) + expect(isValidIdentifier('validName', ts.ScriptTarget.Latest)).toBe(true) + }) + + test('should use runtime script target when no explicit target provided', () => { + // Set a specific script target + compilerConfig.setScriptTarget(ts.ScriptTarget.ES2015) + + // Should use the configured target + expect(isValidIdentifier('validName')).toBe(true) + expect(isValidIdentifier('123invalid')).toBe(false) + }) + + test('should override runtime target when explicit target provided', () => { + // Set a different runtime target + compilerConfig.setScriptTarget(ts.ScriptTarget.ES5) + + // Should use the explicit target instead of runtime target + expect(isValidIdentifier('validName', ts.ScriptTarget.Latest)).toBe(true) + expect(isValidIdentifier('123invalid', ts.ScriptTarget.Latest)).toBe(false) + }) + + test('should handle edge cases', () => { + expect(isValidIdentifier('a')).toBe(true) + expect(isValidIdentifier('_')).toBe(true) + expect(isValidIdentifier('$')).toBe(true) + expect(isValidIdentifier('__proto__')).toBe(true) + expect(isValidIdentifier('constructor')).toBe(true) + }) + + test('should reject reserved words as identifiers', () => { + // They only check character validity. Reserved word checking is done elsewhere. + expect(isValidIdentifier('class')).toBe(true) // Valid characters, but reserved word + expect(isValidIdentifier('function')).toBe(true) // Valid characters, but reserved word + expect(isValidIdentifier('var')).toBe(true) // Valid characters, but reserved word + }) + }) +}) From ff0cbb76324d549b76f11e812ca6e2edafd39da5 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sun, 31 Aug 2025 12:44:53 +0200 Subject: [PATCH 2/3] chore: fix a bug, improve formatting --- docs/compiler-configuration.md | 6 +- docs/utilities.md | 12 ++++ .../typebox/collection/array-type-handler.ts | 6 +- .../collection/collection-base-handler.ts | 10 --- src/handlers/typebox/date-type-handler.ts | 3 +- .../typebox/indexed-access-type-handler.ts | 21 +++--- src/handlers/typebox/keyof-type-handler.ts | 12 ---- src/handlers/typebox/literal-type-handler.ts | 11 +--- .../typebox/object/interface-type-handler.ts | 37 ++--------- .../object/object-like-base-handler.ts | 3 +- .../typebox/readonly-array-type-handler.ts | 12 ---- .../typebox/reference/omit-type-handler.ts | 4 +- .../typebox/reference/pick-type-handler.ts | 4 +- .../{ => reference}/readonly-type-handler.ts | 0 .../reference/type-reference-base-handler.ts | 14 ++-- .../typebox/template-literal-type-handler.ts | 8 +-- .../typebox/type/keyof-type-handler.ts | 7 ++ .../type/readonly-array-type-handler.ts | 7 ++ .../{ => type}/type-operator-base-handler.ts | 8 +-- src/handlers/typebox/typebox-type-handlers.ts | 10 ++- src/input-handler.ts | 6 +- src/parsers/parse-function-declarations.ts | 23 +++---- src/parsers/parse-interfaces.ts | 5 +- src/parsers/parse-type-aliases.ts | 5 +- src/traverse/dependency-traversal.ts | 1 - src/traverse/file-graph.ts | 12 ++-- src/traverse/node-graph.ts | 18 +++-- src/traverse/types.ts | 4 +- src/utils/add-static-type-alias.ts | 8 +-- src/utils/compiler-config.ts | 26 +------- src/utils/generic-type-utils.ts | 1 + src/utils/identifier-utils.ts | 26 ++++++-- src/utils/interface-processing-order.ts | 16 ++--- src/utils/node-type-utils.ts | 32 ++------- src/utils/typebox-call.ts | 10 +-- .../script-target-integration.test.ts | 66 +++++++++++-------- tests/utils/compiler-config.test.ts | 32 +++------ tests/utils/identifier-utils.test.ts | 25 ++----- 38 files changed, 197 insertions(+), 314 deletions(-) delete mode 100644 src/handlers/typebox/keyof-type-handler.ts delete mode 100644 src/handlers/typebox/readonly-array-type-handler.ts rename src/handlers/typebox/{ => reference}/readonly-type-handler.ts (100%) create mode 100644 src/handlers/typebox/type/keyof-type-handler.ts create mode 100644 src/handlers/typebox/type/readonly-array-type-handler.ts rename src/handlers/typebox/{ => type}/type-operator-base-handler.ts (81%) diff --git a/docs/compiler-configuration.md b/docs/compiler-configuration.md index 71aaf07..b11cba6 100644 --- a/docs/compiler-configuration.md +++ b/docs/compiler-configuration.md @@ -18,9 +18,9 @@ The validation respects the detected script target to ensure compatibility: ```typescript // With ES5 target interface Example { - validName: string; // → validName: Type.String() - "invalid-name": number; // → "invalid-name": Type.Number() - "123invalid": boolean; // → "123invalid": Type.Boolean() + validName: string // → validName: Type.String() + 'invalid-name': number // → 'invalid-name': Type.Number() + '123invalid': boolean // → '123invalid': Type.Boolean() } ``` diff --git a/docs/utilities.md b/docs/utilities.md index bae6013..403b9e9 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -50,6 +50,18 @@ NodeTypeUtils.isTypeReference(node, 'Partial') // Check if node is Partial NodeTypeUtils.isReadonlyArrayType(node) // Check if readonly T[] ``` +### Identifier Validation + +`src/utils/identifier-utils.ts` - JavaScript identifier validation: + +```typescript +isValidIdentifier('validName') // true +isValidIdentifier('123invalid') // false +isValidIdentifier('𝒜') // true - supports Unicode characters +``` + +Validates JavaScript identifiers using TypeScript's built-in utilities with full Unicode support, including characters outside the Basic Multilingual Plane. + ### Template Literal Processing `src/utils/template-literal-type-processor.ts` - Processes template literal types: diff --git a/src/handlers/typebox/collection/array-type-handler.ts b/src/handlers/typebox/collection/array-type-handler.ts index 9993e71..cf6f41c 100644 --- a/src/handlers/typebox/collection/array-type-handler.ts +++ b/src/handlers/typebox/collection/array-type-handler.ts @@ -1,4 +1,6 @@ import { CollectionBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/collection/collection-base-handler' +import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' +import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' import { ArrayTypeNode, Node, ts } from 'ts-morph' export class ArrayTypeHandler extends CollectionBaseHandler { @@ -7,6 +9,8 @@ export class ArrayTypeHandler extends CollectionBaseHandler { } handle(node: ArrayTypeNode): ts.Expression { - return this.processSingleType(node.getElementTypeNode(), 'Array') + const typeboxType = getTypeBoxType(node.getElementTypeNode()) + + return makeTypeCall('Array', [typeboxType]) } } diff --git a/src/handlers/typebox/collection/collection-base-handler.ts b/src/handlers/typebox/collection/collection-base-handler.ts index dfa634d..ad5c621 100644 --- a/src/handlers/typebox/collection/collection-base-handler.ts +++ b/src/handlers/typebox/collection/collection-base-handler.ts @@ -10,14 +10,4 @@ export abstract class CollectionBaseHandler extends BaseTypeHandler { return makeTypeCall(typeBoxFunction, [arrayLiteral]) } - - protected processSingleType(node: Node, typeBoxFunction: string): ts.Expression { - return makeTypeCall(typeBoxFunction, [getTypeBoxType(node)]) - } - - protected validateNonEmptyCollection(nodes: Node[], typeName: string): void { - if (nodes.length === 0) { - throw new Error(`${typeName} must have at least one type`) - } - } } diff --git a/src/handlers/typebox/date-type-handler.ts b/src/handlers/typebox/date-type-handler.ts index bd95dcb..94f11ff 100644 --- a/src/handlers/typebox/date-type-handler.ts +++ b/src/handlers/typebox/date-type-handler.ts @@ -9,8 +9,7 @@ export class DateTypeHandler extends BaseTypeHandler { return Node.isIdentifier(typeName) && typeName.getText() === 'Date' } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - handle(_node: TypeReferenceNode): ts.Expression { + handle(): ts.Expression { return makeTypeCall('Date') } } diff --git a/src/handlers/typebox/indexed-access-type-handler.ts b/src/handlers/typebox/indexed-access-type-handler.ts index e71546e..e11669a 100644 --- a/src/handlers/typebox/indexed-access-type-handler.ts +++ b/src/handlers/typebox/indexed-access-type-handler.ts @@ -1,7 +1,7 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { IndexedAccessTypeNode, Node, ts } from 'ts-morph' +import { IndexedAccessTypeNode, Node, ts, TypeNode } from 'ts-morph' export class IndexedAccessTypeHandler extends BaseTypeHandler { canHandle(node: Node): boolean { @@ -14,8 +14,8 @@ export class IndexedAccessTypeHandler extends BaseTypeHandler { // Handle special case: typeof A[number] where A is a readonly tuple if ( - objectType?.isKind(ts.SyntaxKind.TypeQuery) && - indexType?.isKind(ts.SyntaxKind.NumberKeyword) + objectType.isKind(ts.SyntaxKind.TypeQuery) && + indexType.isKind(ts.SyntaxKind.NumberKeyword) ) { return this.handleTypeofArrayAccess(objectType, node) } @@ -42,31 +42,28 @@ export class IndexedAccessTypeHandler extends BaseTypeHandler { const typeAlias = sourceFile.getTypeAlias(typeName) if (typeAlias) { const tupleUnion = this.extractTupleUnion(typeAlias.getTypeNode()) - if (tupleUnion) { - return tupleUnion - } + if (tupleUnion) return tupleUnion } // Then try to find a variable declaration const variableDeclaration = sourceFile.getVariableDeclaration(typeName) if (variableDeclaration) { const tupleUnion = this.extractTupleUnion(variableDeclaration.getTypeNode()) - if (tupleUnion) { - return tupleUnion - } + if (tupleUnion) return tupleUnion } } // Fallback to default Index behavior const typeboxObjectType = getTypeBoxType(typeQuery) const typeboxIndexType = getTypeBoxType(indexedAccessType.getIndexTypeNode()) + return makeTypeCall('Index', [typeboxObjectType, typeboxIndexType]) } - private extractTupleUnion(typeNode: Node | undefined): ts.Expression | null { + private extractTupleUnion(typeNode: TypeNode | undefined): ts.Expression | null { if (!typeNode) return null - let actualTupleType: Node | undefined = typeNode + let actualTupleType: TypeNode = typeNode // Handle readonly modifier (TypeOperator) if (typeNode.isKind(ts.SyntaxKind.TypeOperator)) { @@ -75,7 +72,7 @@ export class IndexedAccessTypeHandler extends BaseTypeHandler { } // Check if it's a tuple type - if (actualTupleType?.isKind(ts.SyntaxKind.TupleType)) { + if (actualTupleType.isKind(ts.SyntaxKind.TupleType)) { const tupleType = actualTupleType.asKindOrThrow(ts.SyntaxKind.TupleType) const elements = tupleType.getElements() diff --git a/src/handlers/typebox/keyof-type-handler.ts b/src/handlers/typebox/keyof-type-handler.ts deleted file mode 100644 index 7ba87c5..0000000 --- a/src/handlers/typebox/keyof-type-handler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TypeOperatorBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type-operator-base-handler' -import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { SyntaxKind, ts } from 'ts-morph' - -export class KeyOfTypeHandler extends TypeOperatorBaseHandler { - protected readonly operatorKind = SyntaxKind.KeyOfKeyword - protected readonly typeBoxMethod = 'KeyOf' - - protected createTypeBoxCall(innerType: ts.Expression): ts.Expression { - return makeTypeCall('KeyOf', [innerType]) - } -} diff --git a/src/handlers/typebox/literal-type-handler.ts b/src/handlers/typebox/literal-type-handler.ts index b10180d..a556e0a 100644 --- a/src/handlers/typebox/literal-type-handler.ts +++ b/src/handlers/typebox/literal-type-handler.ts @@ -1,21 +1,16 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { Node, SyntaxKind, ts } from 'ts-morph' +import { LiteralTypeNode, Node, SyntaxKind, ts } from 'ts-morph' export class LiteralTypeHandler extends BaseTypeHandler { canHandle(node: Node): boolean { return Node.isLiteralTypeNode(node) || Node.isTrueLiteral(node) || Node.isFalseLiteral(node) } - handle(node: Node): ts.Expression { - if (!Node.isLiteralTypeNode(node)) { - return makeTypeCall('Any') - } - + handle(node: LiteralTypeNode): ts.Expression { const literal = node.getLiteral() - const literalKind = literal.getKind() - switch (literalKind) { + switch (literal.getKind()) { case SyntaxKind.StringLiteral: return makeTypeCall('Literal', [ ts.factory.createStringLiteral(literal.getText().slice(1, -1)), diff --git a/src/handlers/typebox/object/interface-type-handler.ts b/src/handlers/typebox/object/interface-type-handler.ts index dfc4d02..0e59caa 100644 --- a/src/handlers/typebox/object/interface-type-handler.ts +++ b/src/handlers/typebox/object/interface-type-handler.ts @@ -8,44 +8,19 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { } handle(node: InterfaceDeclaration): ts.Expression { - const typeParameters = node.getTypeParameters() const heritageClauses = node.getHeritageClauses() const baseObjectType = this.createObjectType(this.processProperties(node.getProperties())) - // For generic interfaces, return raw TypeBox expression - // The parser will handle wrapping it in an arrow function using GenericTypeUtils - if (typeParameters.length > 0) { - // For generic interfaces, handle inheritance here and return raw expression - if (heritageClauses.length === 0) { - return baseObjectType - } - - const extendedTypes = this.collectExtendedTypes(heritageClauses) - - if (extendedTypes.length === 0) { - return baseObjectType - } - - // Create composite with extended types first, then the current interface - const allTypes = [...extendedTypes, baseObjectType] - return makeTypeCall('Composite', [ts.factory.createArrayLiteralExpression(allTypes, true)]) - } - - // For non-generic interfaces, handle as before - if (heritageClauses.length === 0) { - return baseObjectType - } + if (heritageClauses.length === 0) return baseObjectType const extendedTypes = this.collectExtendedTypes(heritageClauses) - - if (extendedTypes.length === 0) { - return baseObjectType - } + if (extendedTypes.length === 0) return baseObjectType // Create composite with extended types first, then the current interface const allTypes = [...extendedTypes, baseObjectType] + const expression = ts.factory.createArrayLiteralExpression(allTypes, true) - return makeTypeCall('Composite', [ts.factory.createArrayLiteralExpression(allTypes, true)]) + return makeTypeCall('Composite', [expression]) } private parseGenericTypeCall(typeText: string): ts.Expression | null { @@ -82,9 +57,7 @@ export class InterfaceTypeHandler extends ObjectLikeBaseHandler { const extendedTypes: ts.Expression[] = [] for (const heritageClause of heritageClauses) { - if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) { - continue - } + if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) continue for (const typeNode of heritageClause.getTypeNodes()) { const typeText = typeNode.getText() diff --git a/src/handlers/typebox/object/object-like-base-handler.ts b/src/handlers/typebox/object/object-like-base-handler.ts index c936110..bba91b3 100644 --- a/src/handlers/typebox/object/object-like-base-handler.ts +++ b/src/handlers/typebox/object/object-like-base-handler.ts @@ -1,6 +1,6 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' -import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' import { isValidIdentifier } from '@daxserver/validation-schema-codegen/utils/identifier-utils' +import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' import { Node, PropertySignature, ts } from 'ts-morph' @@ -10,7 +10,6 @@ export abstract class ObjectLikeBaseHandler extends BaseTypeHandler { for (const prop of properties) { const propTypeNode = prop.getTypeNode() - if (!propTypeNode) continue const outputNameNode = this.extractPropertyNameInfo(prop) diff --git a/src/handlers/typebox/readonly-array-type-handler.ts b/src/handlers/typebox/readonly-array-type-handler.ts deleted file mode 100644 index c09aeda..0000000 --- a/src/handlers/typebox/readonly-array-type-handler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TypeOperatorBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type-operator-base-handler' -import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' -import { SyntaxKind, ts } from 'ts-morph' - -export class ReadonlyArrayTypeHandler extends TypeOperatorBaseHandler { - protected readonly operatorKind = SyntaxKind.ReadonlyKeyword - protected readonly typeBoxMethod = 'Readonly' - - protected createTypeBoxCall(innerType: ts.Expression): ts.Expression { - return makeTypeCall('Readonly', [innerType]) - } -} diff --git a/src/handlers/typebox/reference/omit-type-handler.ts b/src/handlers/typebox/reference/omit-type-handler.ts index 0606149..f56bc7d 100644 --- a/src/handlers/typebox/reference/omit-type-handler.ts +++ b/src/handlers/typebox/reference/omit-type-handler.ts @@ -15,9 +15,7 @@ export class OmitTypeHandler extends TypeReferenceBaseHandler { const typeRef = this.validateTypeReference(node) const [objectType, keysType] = this.extractTypeArguments(typeRef) - if (!keysType) { - return makeTypeCall('Any') - } + if (!keysType) return makeTypeCall('Any') const typeboxObjectType = getTypeBoxType(objectType) const omitKeys = extractStringKeys(keysType) diff --git a/src/handlers/typebox/reference/pick-type-handler.ts b/src/handlers/typebox/reference/pick-type-handler.ts index cb27d7a..b71f586 100644 --- a/src/handlers/typebox/reference/pick-type-handler.ts +++ b/src/handlers/typebox/reference/pick-type-handler.ts @@ -15,9 +15,7 @@ export class PickTypeHandler extends TypeReferenceBaseHandler { const typeRef = this.validateTypeReference(node) const [objectType, keysType] = this.extractTypeArguments(typeRef) - if (!keysType) { - return makeTypeCall('Any') - } + if (!keysType) return makeTypeCall('Any') const typeboxObjectType = getTypeBoxType(objectType) const pickKeys = extractStringKeys(keysType) diff --git a/src/handlers/typebox/readonly-type-handler.ts b/src/handlers/typebox/reference/readonly-type-handler.ts similarity index 100% rename from src/handlers/typebox/readonly-type-handler.ts rename to src/handlers/typebox/reference/readonly-type-handler.ts diff --git a/src/handlers/typebox/reference/type-reference-base-handler.ts b/src/handlers/typebox/reference/type-reference-base-handler.ts index a673521..e0f8819 100644 --- a/src/handlers/typebox/reference/type-reference-base-handler.ts +++ b/src/handlers/typebox/reference/type-reference-base-handler.ts @@ -1,14 +1,12 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' -import { Node, SyntaxKind, TypeReferenceNode } from 'ts-morph' +import { Node, SyntaxKind, TypeNode, TypeReferenceNode } from 'ts-morph' export abstract class TypeReferenceBaseHandler extends BaseTypeHandler { protected abstract readonly supportedTypeNames: string[] protected abstract readonly expectedArgumentCount: number canHandle(node: Node): boolean { - if (node.getKind() !== SyntaxKind.TypeReference) { - return false - } + if (node.getKind() !== SyntaxKind.TypeReference) return false const typeRef = node as TypeReferenceNode const typeName = typeRef.getTypeName().getText() @@ -24,8 +22,8 @@ export abstract class TypeReferenceBaseHandler extends BaseTypeHandler { return node as TypeReferenceNode } - protected extractTypeArguments(typeRef: TypeReferenceNode): Node[] { - const typeArgs = typeRef.getTypeArguments() + protected extractTypeArguments(node: TypeReferenceNode): TypeNode[] { + const typeArgs = node.getTypeArguments() if (typeArgs.length !== this.expectedArgumentCount) { throw new Error( @@ -36,7 +34,7 @@ export abstract class TypeReferenceBaseHandler extends BaseTypeHandler { return typeArgs } - protected getTypeName(typeRef: TypeReferenceNode): string { - return typeRef.getTypeName().getText() + protected getTypeName(node: TypeReferenceNode): string { + return node.getTypeName().getText() } } diff --git a/src/handlers/typebox/template-literal-type-handler.ts b/src/handlers/typebox/template-literal-type-handler.ts index 066c37f..e3857e1 100644 --- a/src/handlers/typebox/template-literal-type-handler.ts +++ b/src/handlers/typebox/template-literal-type-handler.ts @@ -26,13 +26,11 @@ export class TemplateLiteralTypeHandler extends BaseTypeHandler { const compilerNode = span.compilerNode as ts.TemplateLiteralTypeSpan // Add the type from the substitution - if (compilerNode.type) { - const processedType = TemplateLiteralTypeProcessor.processType(compilerNode.type) - parts.push(processedType) - } + const processedType = TemplateLiteralTypeProcessor.processType(compilerNode.type) + parts.push(processedType) // Add the literal part after the substitution - const literalText = compilerNode.literal?.text + const literalText = compilerNode.literal.text if (literalText) { parts.push(makeTypeCall('Literal', [ts.factory.createStringLiteral(literalText)])) } diff --git a/src/handlers/typebox/type/keyof-type-handler.ts b/src/handlers/typebox/type/keyof-type-handler.ts new file mode 100644 index 0000000..622f575 --- /dev/null +++ b/src/handlers/typebox/type/keyof-type-handler.ts @@ -0,0 +1,7 @@ +import { TypeOperatorBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type/type-operator-base-handler' +import { SyntaxKind } from 'ts-morph' + +export class KeyOfTypeHandler extends TypeOperatorBaseHandler { + protected readonly operatorKind = SyntaxKind.KeyOfKeyword + protected readonly typeBoxMethod = 'KeyOf' +} diff --git a/src/handlers/typebox/type/readonly-array-type-handler.ts b/src/handlers/typebox/type/readonly-array-type-handler.ts new file mode 100644 index 0000000..53e652c --- /dev/null +++ b/src/handlers/typebox/type/readonly-array-type-handler.ts @@ -0,0 +1,7 @@ +import { TypeOperatorBaseHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type/type-operator-base-handler' +import { SyntaxKind } from 'ts-morph' + +export class ReadonlyArrayTypeHandler extends TypeOperatorBaseHandler { + protected readonly operatorKind = SyntaxKind.ReadonlyKeyword + protected readonly typeBoxMethod = 'Readonly' +} diff --git a/src/handlers/typebox/type-operator-base-handler.ts b/src/handlers/typebox/type/type-operator-base-handler.ts similarity index 81% rename from src/handlers/typebox/type-operator-base-handler.ts rename to src/handlers/typebox/type/type-operator-base-handler.ts index 3c67e6d..9badf93 100644 --- a/src/handlers/typebox/type-operator-base-handler.ts +++ b/src/handlers/typebox/type/type-operator-base-handler.ts @@ -1,6 +1,7 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' import { isTypeOperatorWithOperator } from '@daxserver/validation-schema-codegen/utils/node-type-utils' import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call' +import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' import { Node, SyntaxKind, ts, TypeOperatorTypeNode } from 'ts-morph' /** @@ -17,9 +18,8 @@ export abstract class TypeOperatorBaseHandler extends BaseTypeHandler { handle(node: TypeOperatorTypeNode): ts.Expression { const innerType = node.getTypeNode() - const typeboxInnerType = getTypeBoxType(innerType) - return this.createTypeBoxCall(typeboxInnerType) - } + const typeboxType = getTypeBoxType(innerType) - protected abstract createTypeBoxCall(innerType: ts.Expression): ts.Expression + return makeTypeCall(this.typeBoxMethod, [typeboxType]) + } } diff --git a/src/handlers/typebox/typebox-type-handlers.ts b/src/handlers/typebox/typebox-type-handlers.ts index 59095e4..8285abe 100644 --- a/src/handlers/typebox/typebox-type-handlers.ts +++ b/src/handlers/typebox/typebox-type-handlers.ts @@ -6,22 +6,22 @@ import { UnionTypeHandler } from '@daxserver/validation-schema-codegen/handlers/ import { DateTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/date-type-handler' import { FunctionTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/function-type-handler' import { IndexedAccessTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/indexed-access-type-handler' -import { KeyOfTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/keyof-type-handler' import { KeyOfTypeofHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/keyof-typeof-handler' import { LiteralTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/literal-type-handler' import { InterfaceTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/object/interface-type-handler' import { ObjectTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/object/object-type-handler' -import { ReadonlyArrayTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/readonly-array-type-handler' -import { ReadonlyTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/readonly-type-handler' import { OmitTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/omit-type-handler' import { PartialTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/partial-type-handler' import { PickTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/pick-type-handler' +import { ReadonlyTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/readonly-type-handler' import { RecordTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/record-type-handler' import { RequiredTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/reference/required-type-handler' import { SimpleTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/simple-type-handler' import { TemplateLiteralTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/template-literal-type-handler' import { TypeQueryHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type-query-handler' import { TypeReferenceHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type-reference-handler' +import { KeyOfTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type/keyof-type-handler' +import { ReadonlyArrayTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/type/readonly-array-type-handler' import { TypeofTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/typeof-type-handler' import { Node, SyntaxKind } from 'ts-morph' @@ -107,9 +107,7 @@ export class TypeBoxTypeHandlers { const cacheKey = `${nodeKind}-${nodeText}` const cachedHandler = this.handlerCache.get(cacheKey) - if (cachedHandler) { - return cachedHandler - } + if (cachedHandler) return cachedHandler let handler: BaseTypeHandler | undefined diff --git a/src/input-handler.ts b/src/input-handler.ts index 775e104..9f93dc4 100644 --- a/src/input-handler.ts +++ b/src/input-handler.ts @@ -1,7 +1,7 @@ +import { initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' import { existsSync, statSync } from 'fs' import { dirname, isAbsolute, resolve } from 'path' import { Project, SourceFile } from 'ts-morph' -import { initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' export interface InputOptions { filePath?: string @@ -66,10 +66,10 @@ export const createSourceFileFromInput = (options: InputOptions): SourceFile => validateInputOptions(options) const project = options.project || new Project() - + // Initialize compiler configuration from the project initializeCompilerConfig(project) - + const { filePath, sourceCode, callerFile } = options if (sourceCode) { diff --git a/src/parsers/parse-function-declarations.ts b/src/parsers/parse-function-declarations.ts index 7259851..4fa9564 100644 --- a/src/parsers/parse-function-declarations.ts +++ b/src/parsers/parse-function-declarations.ts @@ -21,19 +21,20 @@ export class FunctionDeclarationParser extends BaseParser { const paramTypeNode = param.getTypeNode() const paramType = paramTypeNode ? getTypeBoxType(paramTypeNode) : makeTypeCall('Any') - // Check if parameter is optional - if (param.hasQuestionToken()) { - return ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('Type'), - ts.factory.createIdentifier('Optional'), - ), - undefined, - [paramType], - ) + // Check if parameter is optional or required + if (!param.hasQuestionToken()) { + return paramType } - return paramType + // Parameter is optional + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('Type'), + ts.factory.createIdentifier('Optional'), + ), + undefined, + [paramType], + ) }) // Convert return type to TypeBox type diff --git a/src/parsers/parse-interfaces.ts b/src/parsers/parse-interfaces.ts index 4143629..f30ecf3 100644 --- a/src/parsers/parse-interfaces.ts +++ b/src/parsers/parse-interfaces.ts @@ -8,10 +8,7 @@ export class InterfaceParser extends BaseParser { parse(interfaceDecl: InterfaceDeclaration): void { const interfaceName = interfaceDecl.getName() - if (this.processedTypes.has(interfaceName)) { - return - } - + if (this.processedTypes.has(interfaceName)) return this.processedTypes.add(interfaceName) const typeParameters = interfaceDecl.getTypeParameters() diff --git a/src/parsers/parse-type-aliases.ts b/src/parsers/parse-type-aliases.ts index 0c7fe74..18c53c4 100644 --- a/src/parsers/parse-type-aliases.ts +++ b/src/parsers/parse-type-aliases.ts @@ -9,10 +9,7 @@ export class TypeAliasParser extends BaseParser { parse(typeAlias: TypeAliasDeclaration): void { const typeName = typeAlias.getName() - if (this.processedTypes.has(typeName)) { - return - } - + if (this.processedTypes.has(typeName)) return this.processedTypes.add(typeName) const typeParameters = typeAlias.getTypeParameters() diff --git a/src/traverse/dependency-traversal.ts b/src/traverse/dependency-traversal.ts index b1406a2..0846dfa 100644 --- a/src/traverse/dependency-traversal.ts +++ b/src/traverse/dependency-traversal.ts @@ -159,7 +159,6 @@ export class DependencyTraversal { // Prevent infinite loops by tracking visited files if (this.fileGraph.hasNode(filePath)) continue - this.fileGraph.addFile(filePath, moduleSourceFile) const imports = moduleSourceFile.getImportDeclarations() diff --git a/src/traverse/file-graph.ts b/src/traverse/file-graph.ts index 2f564c1..3a89a61 100644 --- a/src/traverse/file-graph.ts +++ b/src/traverse/file-graph.ts @@ -15,11 +15,11 @@ export class FileGraph extends DirectedGraph { * Add a file to the graph */ addFile(filePath: string, sourceFile: SourceFile): void { - if (this.hasNode(filePath)) return - - this.addNode(filePath, { - type: 'file', - sourceFile, - }) + if (!this.hasNode(filePath)) { + this.addNode(filePath, { + type: 'file', + sourceFile, + }) + } } } diff --git a/src/traverse/node-graph.ts b/src/traverse/node-graph.ts index 4136200..cd64b06 100644 --- a/src/traverse/node-graph.ts +++ b/src/traverse/node-graph.ts @@ -10,9 +10,9 @@ export class NodeGraph extends DirectedGraph { * Add a type node to the graph */ addTypeNode(qualifiedName: string, node: TraversedNode): void { - if (this.hasNode(qualifiedName)) return - - this.addNode(qualifiedName, node) + if (!this.hasNode(qualifiedName)) { + this.addNode(qualifiedName, node) + } } /** @@ -27,14 +27,12 @@ export class NodeGraph extends DirectedGraph { */ addDependency(fromNode: string, toNode: string): void { if ( - !this.hasNode(fromNode) || - !this.hasNode(toNode) || - fromNode === toNode || - this.hasDirectedEdge(fromNode, toNode) + this.hasNode(fromNode) && + this.hasNode(toNode) && + fromNode !== toNode && + !this.hasDirectedEdge(fromNode, toNode) ) { - return + this.addDirectedEdge(fromNode, toNode) } - - this.addDirectedEdge(fromNode, toNode) } } diff --git a/src/traverse/types.ts b/src/traverse/types.ts index 884dd1b..3617f0d 100644 --- a/src/traverse/types.ts +++ b/src/traverse/types.ts @@ -1,10 +1,8 @@ import type { Node } from 'ts-morph' -export type SupportedNodeType = 'interface' | 'typeAlias' | 'enum' | 'function' - export interface TraversedNode { node: Node - type: SupportedNodeType + type: 'interface' | 'typeAlias' | 'enum' | 'function' originalName: string qualifiedName: string isImported: boolean diff --git a/src/utils/add-static-type-alias.ts b/src/utils/add-static-type-alias.ts index 1f4dcfb..27432ac 100644 --- a/src/utils/add-static-type-alias.ts +++ b/src/utils/add-static-type-alias.ts @@ -1,4 +1,3 @@ -import { TypeBoxStatic } from '@daxserver/validation-schema-codegen/utils/typebox-call' import { SourceFile, ts } from 'ts-morph' export const addStaticTypeAlias = ( @@ -7,10 +6,9 @@ export const addStaticTypeAlias = ( compilerNode: ts.SourceFile, printer: ts.Printer, ) => { - const staticTypeNode = ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier(TypeBoxStatic), - [ts.factory.createTypeQueryNode(ts.factory.createIdentifier(name))], - ) + const staticTypeNode = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Static'), [ + ts.factory.createTypeQueryNode(ts.factory.createIdentifier(name)), + ]) const staticType = printer.printNode(ts.EmitHint.Unspecified, staticTypeNode, compilerNode) diff --git a/src/utils/compiler-config.ts b/src/utils/compiler-config.ts index ecb06ea..7d4c800 100644 --- a/src/utils/compiler-config.ts +++ b/src/utils/compiler-config.ts @@ -7,8 +7,6 @@ export class CompilerConfig { private static instance: CompilerConfig | null = null private scriptTarget: ts.ScriptTarget = ts.ScriptTarget.Latest - private constructor() {} - /** * Gets the singleton instance of CompilerConfig */ @@ -23,15 +21,7 @@ export class CompilerConfig { * Initializes the compiler configuration from a ts-morph Project */ initializeFromProject(project: Project): void { - const compilerOptions = project.getCompilerOptions() - this.scriptTarget = this.determineScriptTarget(compilerOptions) - } - - /** - * Initializes the compiler configuration from TypeScript compiler options - */ - initializeFromCompilerOptions(compilerOptions: ts.CompilerOptions): void { - this.scriptTarget = this.determineScriptTarget(compilerOptions) + this.scriptTarget = project.getCompilerOptions().target ?? ts.ScriptTarget.Latest } /** @@ -48,20 +38,6 @@ export class CompilerConfig { this.scriptTarget = target } - /** - * Determines the appropriate script target from compiler options - */ - private determineScriptTarget(compilerOptions: ts.CompilerOptions): ts.ScriptTarget { - // If target is explicitly set in compiler options, use it - if (compilerOptions.target !== undefined) { - return compilerOptions.target - } - - // Default fallback based on common configurations - // ESNext maps to Latest, ES2022+ maps to ES2022, etc. - return ts.ScriptTarget.Latest - } - /** * Resets the configuration to defaults */ diff --git a/src/utils/generic-type-utils.ts b/src/utils/generic-type-utils.ts index 6a4ea9d..0547981 100644 --- a/src/utils/generic-type-utils.ts +++ b/src/utils/generic-type-utils.ts @@ -23,6 +23,7 @@ export class GenericTypeUtils { ], }) } + /** * Creates function parameters for generic type parameters */ diff --git a/src/utils/identifier-utils.ts b/src/utils/identifier-utils.ts index c6c7e84..2585341 100644 --- a/src/utils/identifier-utils.ts +++ b/src/utils/identifier-utils.ts @@ -1,21 +1,35 @@ -import { ts } from 'ts-morph' import { getScriptTarget } from '@daxserver/validation-schema-codegen/utils/compiler-config' +import { ts } from 'ts-morph' /** * Validates if a string can be used as a JavaScript identifier using TypeScript's built-in utilities * Uses the runtime-determined script target for validation + * Properly handles Unicode characters including those outside the Basic Multilingual Plane */ -export const isValidIdentifier = (text: string, scriptTarget?: ts.ScriptTarget): boolean => { +export const isValidIdentifier = (text: string): boolean => { if (text.length === 0) return false - const target = scriptTarget ?? getScriptTarget() + const target = getScriptTarget() // First character must be valid identifier start - if (!ts.isIdentifierStart(text.charCodeAt(0), target)) return false + const firstCodePoint = text.codePointAt(0) + if (firstCodePoint === undefined || !ts.isIdentifierStart(firstCodePoint, target)) { + return false + } // Remaining characters must be valid identifier parts - for (let i = 1; i < text.length; i++) { - if (!ts.isIdentifierPart(text.charCodeAt(i), target)) return false + // Use for...of to properly iterate over Unicode code points + let isFirst = true + for (const char of text) { + if (isFirst) { + isFirst = false + continue + } + + const codePoint = char.codePointAt(0) + if (codePoint === undefined || !ts.isIdentifierPart(codePoint, target)) { + return false + } } return true diff --git a/src/utils/interface-processing-order.ts b/src/utils/interface-processing-order.ts index 7cf938a..84905ec 100644 --- a/src/utils/interface-processing-order.ts +++ b/src/utils/interface-processing-order.ts @@ -9,8 +9,8 @@ export const getInterfaceProcessingOrder = ( const processingOrder: InterfaceDeclaration[] = [] // Build interface map - interfaces.forEach((iface) => { - interfaceMap.set(iface.getName(), iface) + interfaces.forEach((i) => { + interfaceMap.set(i.getName(), i) }) const visit = (interfaceName: string): void => { @@ -19,18 +19,14 @@ export const getInterfaceProcessingOrder = ( } const iface = interfaceMap.get(interfaceName) - if (!iface) { - return - } + if (!iface) return visiting.add(interfaceName) // Process heritage clauses (extends) const heritageClauses = iface.getHeritageClauses() heritageClauses.forEach((heritageClause) => { - if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) { - return - } + if (heritageClause.getToken() !== ts.SyntaxKind.ExtendsKeyword) return heritageClause.getTypeNodes().forEach((typeNode) => { const baseInterfaceName = typeNode.getText() @@ -46,8 +42,8 @@ export const getInterfaceProcessingOrder = ( } // Visit all interfaces - interfaces.forEach((iface) => { - visit(iface.getName()) + interfaces.forEach((i) => { + visit(i.getName()) }) return processingOrder diff --git a/src/utils/node-type-utils.ts b/src/utils/node-type-utils.ts index f5c68a6..09916cf 100644 --- a/src/utils/node-type-utils.ts +++ b/src/utils/node-type-utils.ts @@ -1,4 +1,4 @@ -import { Node, SyntaxKind, TypeReferenceNode } from 'ts-morph' +import { Node, SyntaxKind } from 'ts-morph' /** * Utility functions for common Node type checks used in canHandle methods @@ -29,12 +29,9 @@ export const isTypeOperatorWithOperator = (node: Node, operator: SyntaxKind): bo * Checks if a node is a TypeReference with a specific type name */ export const isTypeReferenceWithName = (node: Node, typeName: string): boolean => { - if (!Node.isTypeReference(node)) { - return false - } + if (!Node.isTypeReference(node)) return false - const typeRefNode = node as TypeReferenceNode - const typeNameNode = typeRefNode.getTypeName() + const typeNameNode = node.getTypeName() return Node.isIdentifier(typeNameNode) && typeNameNode.getText() === typeName } @@ -43,28 +40,9 @@ export const isTypeReferenceWithName = (node: Node, typeName: string): boolean = * Checks if a node is a TypeReference with any of the specified type names */ export const isTypeReferenceWithAnyName = (node: Node, typeNames: string[]): boolean => { - if (!Node.isTypeReference(node)) { - return false - } + if (!Node.isTypeReference(node)) return false - const typeRefNode = node as TypeReferenceNode - const typeNameNode = typeRefNode.getTypeName() + const typeNameNode = node.getTypeName() return Node.isIdentifier(typeNameNode) && typeNames.includes(typeNameNode.getText()) } - -/** - * Utility type operators - */ -export const UTILITY_TYPE_NAMES = [ - 'Partial', - 'Required', - 'Readonly', - 'Pick', - 'Omit', - 'Exclude', - 'Extract', - 'NonNullable', - 'ReturnType', - 'InstanceType', -] as const diff --git a/src/utils/typebox-call.ts b/src/utils/typebox-call.ts index 5de712c..d4e558d 100644 --- a/src/utils/typebox-call.ts +++ b/src/utils/typebox-call.ts @@ -2,20 +2,14 @@ import { TypeBoxTypeHandlers } from '@daxserver/validation-schema-codegen/handle import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils' import { Node, ts } from 'ts-morph' -export const TypeBoxStatic = 'Static' - let handlers: TypeBoxTypeHandlers | null = null export const getTypeBoxType = (node?: Node): ts.Expression => { - if (!node) { - return makeTypeCall('Any') - } + if (!node) return makeTypeCall('Any') if (!handlers) { handlers = new TypeBoxTypeHandlers() } - const handler = handlers.getHandler(node) - - return handler ? handler.handle(node) : makeTypeCall('Any') + return handlers.getHandler(node).handle(node) } diff --git a/tests/integration/script-target-integration.test.ts b/tests/integration/script-target-integration.test.ts index 17cb8bd..66bf2bc 100644 --- a/tests/integration/script-target-integration.test.ts +++ b/tests/integration/script-target-integration.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, beforeEach, afterEach, test } from 'bun:test' -import { Project, ts } from 'ts-morph' import { CompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' import { createSourceFile, formatWithPrettier, generateFormattedCode } from '@test-fixtures/utils' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { Project, ts } from 'ts-morph' describe('Script Target Integration', () => { let compilerConfig: CompilerConfig @@ -18,11 +18,12 @@ describe('Script Target Integration', () => { test('should use project compiler options for script target', () => { const project = new Project({ compilerOptions: { - target: ts.ScriptTarget.ES2015 - } + target: ts.ScriptTarget.ES2015, + }, }) - const sourceFile = createSourceFile(project, + const sourceFile = createSourceFile( + project, ` interface TestInterface { validName: string; @@ -30,10 +31,12 @@ describe('Script Target Integration', () => { "valid_identifier": string; "invalid-property": string; } - `) + `, + ) - expect(generateFormattedCode(sourceFile)).toBe(formatWithPrettier( - ` + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` export const TestInterface = Type.Object({ validName: Type.String(), "quoted-property": Type.Number(), @@ -42,8 +45,9 @@ describe('Script Target Integration', () => { }) export type TestInterface = Static - ` - )) + `, + ), + ) }) test('should work with different script targets', () => { @@ -51,46 +55,52 @@ describe('Script Target Integration', () => { ts.ScriptTarget.ES5, ts.ScriptTarget.ES2015, ts.ScriptTarget.ES2020, - ts.ScriptTarget.Latest + ts.ScriptTarget.Latest, ] for (const target of testCases) { const project = new Project({ compilerOptions: { - target - } + target, + }, }) - const sourceFile = createSourceFile(project, + const sourceFile = createSourceFile( + project, ` interface TestInterface { validName: string; "invalid-name": number; + π: boolean; } - ` + `, ) - expect(generateFormattedCode(sourceFile)).toBe(formatWithPrettier( - ` + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` export const TestInterface = Type.Object({ validName: Type.String(), "invalid-name": Type.Number(), + π: Type.Boolean(), }) export type TestInterface = Static - ` - )) + `, + ), + ) } }) test('should handle numeric property names correctly', () => { const project = new Project({ compilerOptions: { - target: ts.ScriptTarget.Latest - } + target: ts.ScriptTarget.Latest, + }, }) - const sourceFile = createSourceFile(project, + const sourceFile = createSourceFile( + project, ` interface T { 123: string; @@ -98,11 +108,12 @@ describe('Script Target Integration', () => { validName: boolean; "valid_name": string; } - ` + `, ) - expect(generateFormattedCode(sourceFile)).toBe(formatWithPrettier( - ` + expect(generateFormattedCode(sourceFile)).toBe( + formatWithPrettier( + ` export const T = Type.Object({ 123: Type.String(), "456": Type.Number(), @@ -111,7 +122,8 @@ describe('Script Target Integration', () => { }) export type T = Static - ` - )) + `, + ), + ) }) }) diff --git a/tests/utils/compiler-config.test.ts b/tests/utils/compiler-config.test.ts index b775d9c..e9ec1eb 100644 --- a/tests/utils/compiler-config.test.ts +++ b/tests/utils/compiler-config.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, beforeEach, afterEach, test } from 'bun:test' +import { + CompilerConfig, + getScriptTarget, + initializeCompilerConfig, +} from '@daxserver/validation-schema-codegen/utils/compiler-config' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { Project, ts } from 'ts-morph' -import { CompilerConfig, getScriptTarget, initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' describe('compiler-config', () => { let compilerConfig: CompilerConfig @@ -41,27 +45,11 @@ describe('compiler-config', () => { expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) }) - test('should initialize from compiler options with explicit target', () => { - const compilerOptions: ts.CompilerOptions = { - target: ts.ScriptTarget.ES2015 - } - - compilerConfig.initializeFromCompilerOptions(compilerOptions) - expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES2015) - }) - - test('should use default target when compiler options have no target', () => { - const compilerOptions: ts.CompilerOptions = {} - - compilerConfig.initializeFromCompilerOptions(compilerOptions) - expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) - }) - test('should initialize from ts-morph Project', () => { const project = new Project({ compilerOptions: { - target: ts.ScriptTarget.ES2020 - } + target: ts.ScriptTarget.ES2020, + }, }) compilerConfig.initializeFromProject(project) @@ -86,8 +74,8 @@ describe('compiler-config', () => { test('initializeCompilerConfig should initialize from project', () => { const project = new Project({ compilerOptions: { - target: ts.ScriptTarget.ES2017 - } + target: ts.ScriptTarget.ES2017, + }, }) initializeCompilerConfig(project) diff --git a/tests/utils/identifier-utils.test.ts b/tests/utils/identifier-utils.test.ts index abb24ac..290fe96 100644 --- a/tests/utils/identifier-utils.test.ts +++ b/tests/utils/identifier-utils.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, beforeEach, afterEach, test } from 'bun:test' -import { ts } from 'ts-morph' -import { isValidIdentifier } from '@daxserver/validation-schema-codegen/utils/identifier-utils' import { CompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config' +import { isValidIdentifier } from '@daxserver/validation-schema-codegen/utils/identifier-utils' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { ts } from 'ts-morph' describe('identifier-utils', () => { let compilerConfig: CompilerConfig @@ -39,13 +39,6 @@ describe('identifier-utils', () => { expect(isValidIdentifier('test_123')).toBe(true) }) - test('should handle Unicode identifiers based on script target', () => { - // Test with explicit script target - expect(isValidIdentifier('validName', ts.ScriptTarget.ES5)).toBe(true) - expect(isValidIdentifier('validName', ts.ScriptTarget.ES2015)).toBe(true) - expect(isValidIdentifier('validName', ts.ScriptTarget.Latest)).toBe(true) - }) - test('should use runtime script target when no explicit target provided', () => { // Set a specific script target compilerConfig.setScriptTarget(ts.ScriptTarget.ES2015) @@ -55,21 +48,15 @@ describe('identifier-utils', () => { expect(isValidIdentifier('123invalid')).toBe(false) }) - test('should override runtime target when explicit target provided', () => { - // Set a different runtime target - compilerConfig.setScriptTarget(ts.ScriptTarget.ES5) - - // Should use the explicit target instead of runtime target - expect(isValidIdentifier('validName', ts.ScriptTarget.Latest)).toBe(true) - expect(isValidIdentifier('123invalid', ts.ScriptTarget.Latest)).toBe(false) - }) - test('should handle edge cases', () => { expect(isValidIdentifier('a')).toBe(true) expect(isValidIdentifier('_')).toBe(true) expect(isValidIdentifier('$')).toBe(true) expect(isValidIdentifier('__proto__')).toBe(true) expect(isValidIdentifier('constructor')).toBe(true) + expect(isValidIdentifier('𝒜')).toBe(true) + expect(isValidIdentifier('A𝒜')).toBe(true) + expect(isValidIdentifier('𝒜A')).toBe(true) }) test('should reject reserved words as identifiers', () => { From ea9f73afaee9c6771a9731278a9012d5d34421c4 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sun, 31 Aug 2025 14:54:54 +0200 Subject: [PATCH 3/3] fix: read TS version from execution environment --- docs/compiler-configuration.md | 33 +++++++++++++++---- src/utils/compiler-config.ts | 51 +++++++++++++++++++++++++++-- tests/utils/compiler-config.test.ts | 46 +++++++++++++++++++++----- 3 files changed, 112 insertions(+), 18 deletions(-) diff --git a/docs/compiler-configuration.md b/docs/compiler-configuration.md index b11cba6..3da0f77 100644 --- a/docs/compiler-configuration.md +++ b/docs/compiler-configuration.md @@ -4,7 +4,10 @@ The codegen system automatically adapts to TypeScript compiler options to ensure ## Script Target Detection -The system automatically determines the appropriate TypeScript script target from the ts-morph Project's compiler options +The system automatically determines the appropriate TypeScript script target using a two-tier approach: + +1. **Project Configuration** - Uses the target specified in the ts-morph Project's compiler options +2. **Environment Detection** - When no explicit target is found, detects the appropriate target based on the runtime TypeScript version ## Identifier Validation @@ -26,15 +29,31 @@ interface Example { ## Configuration Management -The `CompilerConfig` singleton manages script target configuration. +The `CompilerConfig` singleton manages script target configuration: + +- **Singleton Pattern** - Ensures consistent configuration across the application +- **Environment Detection** - Automatically detects appropriate targets from TypeScript version +- **Project Override** - Respects explicit targets from ts-morph Project configuration +- **Runtime Configuration** - Allows manual target specification when needed + +## Environment-Based Target Detection -## Default Behavior +When no explicit target is specified in the project configuration, the system automatically detects an appropriate target based on the TypeScript version: -When no explicit target is specified: +- **TypeScript 5.2+** → ES2023 +- **TypeScript 5.0+** → ES2022 +- **TypeScript 4.9+** → ES2022 +- **TypeScript 4.7+** → ES2021 +- **TypeScript 4.5+** → ES2020 +- **TypeScript 4.2+** → ES2019 +- **TypeScript 4.1+** → ES2018 +- **TypeScript 4.0+** → ES2017 +- **TypeScript 3.8+** → ES2017 +- **TypeScript 3.6+** → ES2016 +- **TypeScript 3.4+** → ES2015 +- **Older versions** → ES5 -- Falls back to `ts.ScriptTarget.Latest` -- Provides maximum compatibility with modern JavaScript features -- Can be overridden per-project as needed +This ensures generated code uses language features that are supported by the available TypeScript compiler, avoiding compatibility issues. ## Integration Points diff --git a/src/utils/compiler-config.ts b/src/utils/compiler-config.ts index 7d4c800..15f549a 100644 --- a/src/utils/compiler-config.ts +++ b/src/utils/compiler-config.ts @@ -1,11 +1,56 @@ import { Project, ts } from 'ts-morph' +/** + * Detects the appropriate TypeScript ScriptTarget based on the environment's TypeScript version + */ +const detectEnvironmentScriptTarget = (): ts.ScriptTarget => { + // Get TypeScript version from the environment + const [major, minor] = ts.version.split('.').map(Number) + + // Ensure we have valid version numbers + if (typeof major !== 'number' || typeof minor !== 'number' || isNaN(major) || isNaN(minor)) { + return ts.ScriptTarget.ES2020 + } + + // Map TypeScript versions to appropriate ScriptTarget values + // Based on TypeScript release history and ECMAScript support + if (major >= 5) { + if (minor >= 2) return ts.ScriptTarget.ES2023 + if (minor >= 0) return ts.ScriptTarget.ES2022 + } + + if (major >= 4) { + if (minor >= 9) return ts.ScriptTarget.ES2022 + if (minor >= 7) return ts.ScriptTarget.ES2021 + if (minor >= 5) return ts.ScriptTarget.ES2020 + if (minor >= 2) return ts.ScriptTarget.ES2019 + if (minor >= 1) return ts.ScriptTarget.ES2018 + return ts.ScriptTarget.ES2017 + } + + if (major >= 3) { + if (minor >= 8) return ts.ScriptTarget.ES2017 + if (minor >= 6) return ts.ScriptTarget.ES2016 + if (minor >= 4) return ts.ScriptTarget.ES2015 + return ts.ScriptTarget.ES5 + } + + // Fallback for older versions + return ts.ScriptTarget.ES5 +} + /** * Configuration utility for managing TypeScript compiler options and script targets */ export class CompilerConfig { private static instance: CompilerConfig | null = null - private scriptTarget: ts.ScriptTarget = ts.ScriptTarget.Latest + private scriptTarget: ts.ScriptTarget + + private constructor() { + // Private constructor to prevent instantiation + // Initialize with environment-detected target + this.scriptTarget = detectEnvironmentScriptTarget() + } /** * Gets the singleton instance of CompilerConfig @@ -21,7 +66,7 @@ export class CompilerConfig { * Initializes the compiler configuration from a ts-morph Project */ initializeFromProject(project: Project): void { - this.scriptTarget = project.getCompilerOptions().target ?? ts.ScriptTarget.Latest + this.scriptTarget = project.getCompilerOptions().target ?? detectEnvironmentScriptTarget() } /** @@ -42,7 +87,7 @@ export class CompilerConfig { * Resets the configuration to defaults */ reset(): void { - this.scriptTarget = ts.ScriptTarget.Latest + this.scriptTarget = detectEnvironmentScriptTarget() } } diff --git a/tests/utils/compiler-config.test.ts b/tests/utils/compiler-config.test.ts index e9ec1eb..4f817ae 100644 --- a/tests/utils/compiler-config.test.ts +++ b/tests/utils/compiler-config.test.ts @@ -7,10 +7,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { Project, ts } from 'ts-morph' describe('compiler-config', () => { - let compilerConfig: CompilerConfig + const compilerConfig = CompilerConfig.getInstance() beforeEach(() => { - compilerConfig = CompilerConfig.getInstance() compilerConfig.reset() }) @@ -25,8 +24,12 @@ describe('compiler-config', () => { expect(instance1).toBe(instance2) }) - test('should have default script target', () => { - expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + test('should have environment-detected script target as default', () => { + // Should detect target based on TypeScript version, not use Latest + const target = compilerConfig.getScriptTarget() + expect(target).not.toBe(ts.ScriptTarget.Latest) + expect(target).toBeGreaterThanOrEqual(ts.ScriptTarget.ES5) + expect(target).toBeLessThan(ts.ScriptTarget.Latest) }) test('should allow setting script target explicitly', () => { @@ -37,12 +40,14 @@ describe('compiler-config', () => { expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES5) }) - test('should reset to defaults', () => { + test('should reset to environment-detected defaults', () => { + const originalTarget = compilerConfig.getScriptTarget() compilerConfig.setScriptTarget(ts.ScriptTarget.ES5) expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.ES5) compilerConfig.reset() - expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + expect(compilerConfig.getScriptTarget()).toBe(originalTarget) + expect(compilerConfig.getScriptTarget()).not.toBe(ts.ScriptTarget.Latest) }) test('should initialize from ts-morph Project', () => { @@ -60,8 +65,33 @@ describe('compiler-config', () => { const project = new Project() compilerConfig.initializeFromProject(project) - // Should use default - expect(compilerConfig.getScriptTarget()).toBe(ts.ScriptTarget.Latest) + // Should use environment-detected target, not Latest + const target = compilerConfig.getScriptTarget() + expect(target).not.toBe(ts.ScriptTarget.Latest) + expect(target).toBeGreaterThanOrEqual(ts.ScriptTarget.ES5) + expect(target).toBeLessThan(ts.ScriptTarget.Latest) + }) + }) + + describe('environment detection', () => { + test('should detect appropriate target based on TypeScript version', () => { + const target = compilerConfig.getScriptTarget() + + // For TypeScript 5.9.2, should detect ES2023 or higher + expect(target).toBeGreaterThanOrEqual(ts.ScriptTarget.ES2020) + expect(target).toBeLessThan(ts.ScriptTarget.Latest) + }) + + test('should use environment target when project has no explicit options', () => { + const project = new Project() + const originalTarget = compilerConfig.getScriptTarget() + + // Set to a different target first + compilerConfig.setScriptTarget(ts.ScriptTarget.ES5) + + // Initialize from project should use environment target + compilerConfig.initializeFromProject(project) + expect(compilerConfig.getScriptTarget()).toBe(originalTarget) }) })