diff --git a/docs/compiler-configuration.md b/docs/compiler-configuration.md new file mode 100644 index 0000000..3da0f77 --- /dev/null +++ b/docs/compiler-configuration.md @@ -0,0 +1,65 @@ +# 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 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 + +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: + +- **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 + +When no explicit target is specified in the project configuration, the system automatically detects an appropriate target based on the TypeScript version: + +- **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 + +This ensures generated code uses language features that are supported by the available TypeScript compiler, avoiding compatibility issues. + +## 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/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 cfeac4d..bba91b3 100644 --- a/src/handlers/typebox/object/object-like-base-handler.ts +++ b/src/handlers/typebox/object/object-like-base-handler.ts @@ -1,20 +1,18 @@ import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler' +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 { 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 +24,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 +35,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/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/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..9f93dc4 100644 --- a/src/input-handler.ts +++ b/src/input-handler.ts @@ -1,3 +1,4 @@ +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' @@ -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/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 new file mode 100644 index 0000000..15f549a --- /dev/null +++ b/src/utils/compiler-config.ts @@ -0,0 +1,106 @@ +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 + + private constructor() { + // Private constructor to prevent instantiation + // Initialize with environment-detected target + this.scriptTarget = detectEnvironmentScriptTarget() + } + + /** + * 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 { + this.scriptTarget = project.getCompilerOptions().target ?? detectEnvironmentScriptTarget() + } + + /** + * Gets the current script target + */ + getScriptTarget(): ts.ScriptTarget { + return this.scriptTarget + } + + /** + * Sets the script target explicitly + */ + setScriptTarget(target: ts.ScriptTarget): void { + this.scriptTarget = target + } + + /** + * Resets the configuration to defaults + */ + reset(): void { + this.scriptTarget = detectEnvironmentScriptTarget() + } +} + +/** + * 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/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 new file mode 100644 index 0000000..2585341 --- /dev/null +++ b/src/utils/identifier-utils.ts @@ -0,0 +1,36 @@ +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): boolean => { + if (text.length === 0) return false + + const target = getScriptTarget() + + // First character must be valid identifier start + const firstCodePoint = text.codePointAt(0) + if (firstCodePoint === undefined || !ts.isIdentifierStart(firstCodePoint, target)) { + return false + } + + // Remaining characters must be valid identifier parts + // 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/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..66bf2bc --- /dev/null +++ b/tests/integration/script-target-integration.test.ts @@ -0,0 +1,129 @@ +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 + + 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; + π: boolean; + } + `, + ) + + 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, + }, + }) + + 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..4f817ae --- /dev/null +++ b/tests/utils/compiler-config.test.ts @@ -0,0 +1,115 @@ +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' + +describe('compiler-config', () => { + const compilerConfig = CompilerConfig.getInstance() + + beforeEach(() => { + 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 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', () => { + 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 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(originalTarget) + expect(compilerConfig.getScriptTarget()).not.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 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) + }) + }) + + 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..290fe96 --- /dev/null +++ b/tests/utils/identifier-utils.test.ts @@ -0,0 +1,69 @@ +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 + + 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 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 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', () => { + // 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 + }) + }) +})