Skip to content

Commit e06095d

Browse files
committed
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.
1 parent 12ca1af commit e06095d

20 files changed

+622
-75
lines changed

docs/compiler-configuration.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Compiler Configuration
2+
3+
The codegen system automatically adapts to TypeScript compiler options to ensure generated code is compatible with the target JavaScript environment.
4+
5+
## Script Target Detection
6+
7+
The system automatically determines the appropriate TypeScript script target from the ts-morph Project's compiler options
8+
9+
## Identifier Validation
10+
11+
Property names in generated TypeBox objects are validated using TypeScript's built-in utilities:
12+
13+
- `ts.isIdentifierStart()` - validates first character
14+
- `ts.isIdentifierPart()` - validates remaining characters
15+
16+
The validation respects the detected script target to ensure compatibility:
17+
18+
```typescript
19+
// With ES5 target
20+
interface Example {
21+
validName: string; // → validName: Type.String()
22+
"invalid-name": number; // → "invalid-name": Type.Number()
23+
"123invalid": boolean; // → "123invalid": Type.Boolean()
24+
}
25+
```
26+
27+
## Configuration Management
28+
29+
The `CompilerConfig` singleton manages script target configuration.
30+
31+
## Default Behavior
32+
33+
When no explicit target is specified:
34+
35+
- Falls back to `ts.ScriptTarget.Latest`
36+
- Provides maximum compatibility with modern JavaScript features
37+
- Can be overridden per-project as needed
38+
39+
## Integration Points
40+
41+
The configuration system integrates with:
42+
43+
- **Input Handler** - Initializes config when creating source files
44+
- **Code Generation** - Uses config for output file creation
45+
- **Identifier Utils** - Validates property names with correct target
46+
- **Object Handlers** - Determines property name formatting

docs/handler-system.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export abstract class BaseTypeHandler {
3030
- `ObjectTypeHandler` - { prop: T }
3131
- `InterfaceTypeHandler` - interface references
3232

33+
Object property names are extracted using the TypeScript compiler API through `PropertySignature.getNameNode()`. The system handles different property name formats:
34+
35+
- **Identifiers** (`prop`) - extracted using `nameNode.getText()` and preserved as identifiers
36+
- **String literals** (`'prop-name'`, `"prop name"`) - extracted using `nameNode.getLiteralValue()` and validated for identifier compatibility
37+
- **Numeric literals** (`123`) - extracted using `nameNode.getLiteralValue().toString()` and treated as identifiers
38+
39+
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.
40+
3341
### Utility Types
3442

3543
- `PartialTypeHandler` - Partial<T>

docs/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ export type User = Static<typeof User>
4646
- [architecture.md](./architecture.md) - System architecture
4747
- [parser-system.md](./parser-system.md) - TypeScript parsing
4848
- [handler-system.md](./handler-system.md) - Type conversion
49+
- [compiler-configuration.md](./compiler-configuration.md) - Compiler options and script targets
4950
- [dependency-management.md](./dependency-management.md) - Dependency analysis
5051
- [testing.md](./testing.md) - Testing
Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import { BaseTypeHandler } from '@daxserver/validation-schema-codegen/handlers/typebox/base-type-handler'
22
import { getTypeBoxType } from '@daxserver/validation-schema-codegen/utils/typebox-call'
3+
import { isValidIdentifier } from '@daxserver/validation-schema-codegen/utils/identifier-utils'
34
import { makeTypeCall } from '@daxserver/validation-schema-codegen/utils/typebox-codegen-utils'
4-
import { PropertySignature, ts } from 'ts-morph'
5+
import { Node, PropertySignature, ts } from 'ts-morph'
56

67
export abstract class ObjectLikeBaseHandler extends BaseTypeHandler {
78
protected processProperties(properties: PropertySignature[]): ts.PropertyAssignment[] {
89
const propertyAssignments: ts.PropertyAssignment[] = []
910

1011
for (const prop of properties) {
11-
const propName = prop.getName()
1212
const propTypeNode = prop.getTypeNode()
1313

14-
if (!propTypeNode) {
15-
continue
16-
}
14+
if (!propTypeNode) continue
1715

16+
const outputNameNode = this.extractPropertyNameInfo(prop)
1817
const valueExpr = getTypeBoxType(propTypeNode)
1918
const isAlreadyOptional =
2019
ts.isCallExpression(valueExpr) &&
@@ -26,11 +25,7 @@ export abstract class ObjectLikeBaseHandler extends BaseTypeHandler {
2625
? makeTypeCall('Optional', [valueExpr])
2726
: valueExpr
2827

29-
const nameNode = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propName)
30-
? ts.factory.createIdentifier(propName)
31-
: ts.factory.createStringLiteral(propName)
32-
33-
propertyAssignments.push(ts.factory.createPropertyAssignment(nameNode, maybeOptional))
28+
propertyAssignments.push(ts.factory.createPropertyAssignment(outputNameNode, maybeOptional))
3429
}
3530

3631
return propertyAssignments
@@ -41,4 +36,32 @@ export abstract class ObjectLikeBaseHandler extends BaseTypeHandler {
4136

4237
return makeTypeCall('Object', [objectLiteral])
4338
}
39+
40+
private extractPropertyNameInfo(prop: PropertySignature): ts.PropertyName {
41+
const nameNode = prop.getNameNode()
42+
let propName: string
43+
let shouldUseIdentifier: boolean
44+
45+
if (Node.isIdentifier(nameNode)) {
46+
// If it was originally an identifier, keep it as an identifier
47+
propName = nameNode.getText()
48+
shouldUseIdentifier = true
49+
} else if (Node.isStringLiteral(nameNode)) {
50+
// For quoted properties, get the literal value and check if it can be an identifier
51+
propName = nameNode.getLiteralValue()
52+
shouldUseIdentifier = isValidIdentifier(propName)
53+
} else if (Node.isNumericLiteral(nameNode)) {
54+
// Numeric properties can be used as identifiers
55+
propName = nameNode.getLiteralValue().toString()
56+
shouldUseIdentifier = true
57+
} else {
58+
// Fallback for any other cases
59+
propName = prop.getName()
60+
shouldUseIdentifier = isValidIdentifier(propName)
61+
}
62+
63+
return shouldUseIdentifier
64+
? ts.factory.createIdentifier(propName)
65+
: ts.factory.createStringLiteral(propName)
66+
}
4467
}

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import {
55
import { TypeBoxPrinter } from '@daxserver/validation-schema-codegen/printer/typebox-printer'
66
import { DependencyTraversal } from '@daxserver/validation-schema-codegen/traverse/dependency-traversal'
77
import type { TraversedNode } from '@daxserver/validation-schema-codegen/traverse/types'
8+
import { initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config'
89
import type { VisualizationOptions } from '@daxserver/validation-schema-codegen/utils/graph-visualizer'
910
import { Node, Project, SourceFile, ts } from 'ts-morph'
1011

1112
const createOutputFile = (hasGenericInterfaces: boolean) => {
12-
const newSourceFile = new Project().createSourceFile('output.ts', '', {
13+
const project = new Project()
14+
15+
initializeCompilerConfig(project)
16+
17+
const newSourceFile = project.createSourceFile('output.ts', '', {
1318
overwrite: true,
1419
})
1520

src/input-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, statSync } from 'fs'
22
import { dirname, isAbsolute, resolve } from 'path'
33
import { Project, SourceFile } from 'ts-morph'
4+
import { initializeCompilerConfig } from '@daxserver/validation-schema-codegen/utils/compiler-config'
45

56
export interface InputOptions {
67
filePath?: string
@@ -65,6 +66,10 @@ export const createSourceFileFromInput = (options: InputOptions): SourceFile =>
6566
validateInputOptions(options)
6667

6768
const project = options.project || new Project()
69+
70+
// Initialize compiler configuration from the project
71+
initializeCompilerConfig(project)
72+
6873
const { filePath, sourceCode, callerFile } = options
6974

7075
if (sourceCode) {

src/utils/compiler-config.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Project, ts } from 'ts-morph'
2+
3+
/**
4+
* Configuration utility for managing TypeScript compiler options and script targets
5+
*/
6+
export class CompilerConfig {
7+
private static instance: CompilerConfig | null = null
8+
private scriptTarget: ts.ScriptTarget = ts.ScriptTarget.Latest
9+
10+
private constructor() {}
11+
12+
/**
13+
* Gets the singleton instance of CompilerConfig
14+
*/
15+
static getInstance(): CompilerConfig {
16+
if (!CompilerConfig.instance) {
17+
CompilerConfig.instance = new CompilerConfig()
18+
}
19+
return CompilerConfig.instance
20+
}
21+
22+
/**
23+
* Initializes the compiler configuration from a ts-morph Project
24+
*/
25+
initializeFromProject(project: Project): void {
26+
const compilerOptions = project.getCompilerOptions()
27+
this.scriptTarget = this.determineScriptTarget(compilerOptions)
28+
}
29+
30+
/**
31+
* Initializes the compiler configuration from TypeScript compiler options
32+
*/
33+
initializeFromCompilerOptions(compilerOptions: ts.CompilerOptions): void {
34+
this.scriptTarget = this.determineScriptTarget(compilerOptions)
35+
}
36+
37+
/**
38+
* Gets the current script target
39+
*/
40+
getScriptTarget(): ts.ScriptTarget {
41+
return this.scriptTarget
42+
}
43+
44+
/**
45+
* Sets the script target explicitly
46+
*/
47+
setScriptTarget(target: ts.ScriptTarget): void {
48+
this.scriptTarget = target
49+
}
50+
51+
/**
52+
* Determines the appropriate script target from compiler options
53+
*/
54+
private determineScriptTarget(compilerOptions: ts.CompilerOptions): ts.ScriptTarget {
55+
// If target is explicitly set in compiler options, use it
56+
if (compilerOptions.target !== undefined) {
57+
return compilerOptions.target
58+
}
59+
60+
// Default fallback based on common configurations
61+
// ESNext maps to Latest, ES2022+ maps to ES2022, etc.
62+
return ts.ScriptTarget.Latest
63+
}
64+
65+
/**
66+
* Resets the configuration to defaults
67+
*/
68+
reset(): void {
69+
this.scriptTarget = ts.ScriptTarget.Latest
70+
}
71+
}
72+
73+
/**
74+
* Convenience function to get the current script target
75+
*/
76+
export const getScriptTarget = (): ts.ScriptTarget => {
77+
return CompilerConfig.getInstance().getScriptTarget()
78+
}
79+
80+
/**
81+
* Convenience function to initialize compiler config from a project
82+
*/
83+
export const initializeCompilerConfig = (project: Project): void => {
84+
CompilerConfig.getInstance().initializeFromProject(project)
85+
}

src/utils/identifier-utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ts } from 'ts-morph'
2+
import { getScriptTarget } from '@daxserver/validation-schema-codegen/utils/compiler-config'
3+
4+
/**
5+
* Validates if a string can be used as a JavaScript identifier using TypeScript's built-in utilities
6+
* Uses the runtime-determined script target for validation
7+
*/
8+
export const isValidIdentifier = (text: string, scriptTarget?: ts.ScriptTarget): boolean => {
9+
if (text.length === 0) return false
10+
11+
const target = scriptTarget ?? getScriptTarget()
12+
13+
// First character must be valid identifier start
14+
if (!ts.isIdentifierStart(text.charCodeAt(0), target)) return false
15+
16+
// Remaining characters must be valid identifier parts
17+
for (let i = 1; i < text.length; i++) {
18+
if (!ts.isIdentifierPart(text.charCodeAt(i), target)) return false
19+
}
20+
21+
return true
22+
}

tests/handlers/typebox/enums.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,31 @@ describe('Enum types', () => {
5959
`),
6060
)
6161
})
62+
63+
test('with and without value', () => {
64+
const sourceFile = createSourceFile(
65+
project,
66+
`
67+
enum A {
68+
B,
69+
C = 'c',
70+
}
71+
`,
72+
)
73+
74+
expect(generateFormattedCode(sourceFile)).toBe(
75+
formatWithPrettier(`
76+
export enum A {
77+
B,
78+
C = 'c',
79+
}
80+
81+
export const ASchema = Type.Enum(A);
82+
83+
export type ASchema = Static<typeof ASchema>;
84+
`),
85+
)
86+
})
6287
})
6388

6489
describe('with exports', () => {
@@ -111,5 +136,30 @@ describe('Enum types', () => {
111136
`),
112137
)
113138
})
139+
140+
test('with and without value', () => {
141+
const sourceFile = createSourceFile(
142+
project,
143+
`
144+
export enum A {
145+
B,
146+
C = 'c',
147+
}
148+
`,
149+
)
150+
151+
expect(generateFormattedCode(sourceFile)).toBe(
152+
formatWithPrettier(`
153+
export enum A {
154+
B,
155+
C = 'c',
156+
}
157+
158+
export const ASchema = Type.Enum(A);
159+
160+
export type ASchema = Static<typeof ASchema>;
161+
`),
162+
)
163+
})
114164
})
115165
})

tests/handlers/typebox/interface-generics-consistency.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,6 @@ describe('Interface Generic Consistency with Type Aliases', () => {
5454
})
5555

5656
test('complex generic interface should use GenericTypeUtils flow', () => {
57-
// This test is designed to fail if the interface parser doesn't use
58-
// the same GenericTypeUtils.createGenericArrowFunction flow as type aliases
5957
const sourceFile = createSourceFile(
6058
project,
6159
`

0 commit comments

Comments
 (0)