diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e34640c..92e081e 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -248,6 +248,206 @@ describe('fromCli', () => { expect(output).toContain('output: string') }) + test('invalid property keys are quoted', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + 'dry-run': z.boolean(), + nested: z.object({ 'output-file': z.string().optional() }), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"dry-run": boolean') + expect(output).toContain('nested: { "output-file"?: string | undefined }') + }) + + test('tuple schemas render as TypeScript tuples', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + point: z.tuple([z.number(), z.number()]), + range: z.tuple([z.string()]).rest(z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('point: [number, number]') + expect(output).toContain('range: [string, ...number[]]') + }) + + test('record and catchall schemas render indexable types', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.string(), z.number()), + flags: z.object({ required: z.boolean() }).catchall(z.string()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record') + expect(output).toContain('flags: { required: boolean } & Record') + }) + + test('catchall schemas widen record values for incompatible fixed properties', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + flags: z.object({ required: z.boolean() }).catchall(z.string()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + // TypeScript cannot express "only unknown extra keys" with a plain index signature. + // Widening keeps declared properties assignable while still preserving an indexable shape. + expect(output).toContain('flags: { required: boolean } & Record') + }) + + test('array item intersections are parenthesized', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + items: z.array(z.object({ id: z.number() }).catchall(z.string())), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('items: ({ id: number } & Record)[]') + }) + + test('partial enum records render optional keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.partialRecord(z.enum(['open', 'closed']), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Partial>') + }) + + test('partial literal records render optional keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.partialRecord(z.literal('open'), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Partial>') + }) + + test('partial numeric literal records render optional keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.partialRecord(z.literal(1), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Partial>') + }) + + test('partial union records render optional keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.partialRecord(z.union([z.literal('open'), z.literal('closed')]), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Partial>') + }) + + test('mixed finite and open key records render open keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.partialRecord(z.union([z.literal('open'), z.string()]), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record<"open" | string, number>') + }) + + test('required enum records render required keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.enum(['open', 'closed']), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record<"open" | "closed", number>') + }) + + test('required literal records render required keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.literal('open'), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record<"open", number>') + }) + + test('required numeric literal records render required keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.literal(1), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record<1, number>') + }) + + test('required union records render required keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.union([z.literal('open'), z.literal('closed')]), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record<"open" | "closed", number>') + }) + + test('required numeric union records render required keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.union([z.literal(1), z.literal(2)]), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record<1 | 2, number>') + }) + + test('record with unknown property names falls back to string keys', () => { + const cli = Cli.create('test').command('create', { + options: z.object({ + counts: z.record(z.any(), z.number()), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('counts: Record') + }) + test('mixed top-level and grouped commands', () => { const cli = Cli.create('test') cli.command('ping', { run: () => ({}) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 0903fe6..4469a07 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -88,28 +88,144 @@ function resolveType( case 'null': return 'null' case 'array': { + const prefixItems = schema.prefixItems as Record[] | undefined + if (prefixItems) { + const items = prefixItems.map((item) => resolveType(item, defs)) + const rest = schema.items as Record | undefined + if (rest) items.push(`...${arrayToType(resolveType(rest, defs))}`) + return `[${items.join(', ')}]` + } + const items = schema.items as Record | undefined const itemType = items ? resolveType(items, defs) : 'unknown' - return itemType.includes(' | ') ? `(${itemType})[]` : `${itemType}[]` - } - case 'object': { - const properties = schema.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' - const required = new Set((schema.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map(([key, value]) => { - const type = resolveType(value, defs) - if (required.has(key)) return `${propertyKey(key)}: ${type}` - return `${propertyKey(key)}?: ${type} | undefined` - }) - return `{ ${entries.join('; ')} }` + return arrayToType(itemType) } + case 'object': + return objectToType(schema, defs) default: return 'unknown' } } -function propertyKey(key: string) { - return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +function arrayToType(type: string): string { + return type.includes(' | ') || type.includes(' & ') ? `(${type})[]` : `${type}[]` +} + +function objectToType( + schema: Record, + defs: Record>, +): string { + const properties = schema.properties as Record> | undefined + const required = new Set((schema.required as unknown[] | undefined) ?? []) + const entries = Object.entries(properties ?? {}).map(([key, value]) => + propertyToType(key, value, required, defs), + ) + const object = entries.length > 0 ? `{ ${entries.join('; ')} }` : '{}' + const additional = schema.additionalProperties as Record | boolean | undefined + + if (!additional) return object + const value = typeof additional === 'object' ? resolveType(additional, defs) : 'unknown' + const propertyNames = schema.propertyNames as Record | undefined + const key = recordKeyToType(propertyNames, defs) + const propertyValues = Object.entries(properties ?? {}).map(([key, value]) => + propertyValueToType(key, value, required, defs), + ) + const recordValue = unionTypes([value, ...propertyValues]) + const record = recordToType(key, recordValue, propertyNames, required) + if (entries.length === 0) return record + return `${object} & ${record}` +} + +function recordKeyToType( + schema: Record | undefined, + defs: Record>, +): string { + if (!schema) return 'string' + const type = resolveType(schema, defs) + if (type === 'unknown') return 'string' + return type +} + +function recordToType( + key: string, + value: string, + propertyNames: Record | undefined, + required: Set, +): string { + const record = `Record<${key}, ${value}>` + const keys = propertyNames ? finitePropertyNames(propertyNames) : undefined + if (!keys) return record + + if (keys.every((key) => required.has(key))) return record + return `Partial<${record}>` +} + +function finitePropertyNames(schema: Record): unknown[] | undefined { + if ('const' in schema) return [schema.const] + if (schema.enum) return schema.enum as unknown[] + if (schema.anyOf) { + const keys = (schema.anyOf as Record[]).map(finitePropertyNames) + if (keys.every((key) => key)) return keys.flatMap((key) => key) + } + return undefined +} + +function propertyValueToType( + key: string, + schema: Record, + required: Set, + defs: Record>, +): string { + const type = resolveType(schema, defs) + if (required.has(key)) return type + return unionTypes([type, 'undefined']) +} + +function unionTypes(types: string[]): string { + const entries = types.flatMap(splitUnionType) + if (entries.includes('unknown')) return 'unknown' + return [...new Set(entries)].join(' | ') +} + +function splitUnionType(type: string): string[] { + const parts: string[] = [] + let depth = 0 + let quote = '' + let start = 0 + + for (let i = 0; i < type.length; i++) { + const char = type[i] + if (quote) { + if (char === '\\') i++ + else if (char === quote) quote = '' + } else if (char === '"' || char === "'") quote = char + else if (char === '(' || char === '[' || char === '{' || char === '<') depth++ + else if (char === ')' || char === ']' || char === '}' || char === '>') depth-- + else if (depth === 0 && type.slice(i, i + 3) === ' | ') { + parts.push(type.slice(start, i)) + start = i + 3 + i += 2 + } + } + + parts.push(type.slice(start)) + return parts +} + +function propertyToType( + key: string, + schema: Record, + required: Set, + defs: Record>, +): string { + const type = resolveType(schema, defs) + if (required.has(key)) return `${propertyKey(key)}: ${type}` + return `${propertyKey(key)}?: ${type} | undefined` +} + +function propertyKey(key: string): string { + if (/^[$A-Z_a-z][$\w]*$/.test(key)) return key + return JSON.stringify(key) } function isStream(command: Cli.CommandDefinition) {