diff --git a/example/heros.ts b/example/heros.ts index d7d65bc..20bc305 100644 --- a/example/heros.ts +++ b/example/heros.ts @@ -4,9 +4,12 @@ export enum EnemyPower { Speed = "speed", } -export type SpeedEnemy = { - power: EnemyPower.Speed; -}; +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Skills { + export type SpeedEnemy = { + power: EnemyPower.Speed; + }; +} export interface Enemy { name: string; diff --git a/example/heros.zod.ts b/example/heros.zod.ts index fe7bc1b..38ef106 100644 --- a/example/heros.zod.ts +++ b/example/heros.zod.ts @@ -4,7 +4,7 @@ import { EnemyPower, Villain } from "./heros"; export const enemyPowerSchema = z.nativeEnum(EnemyPower); -export const speedEnemySchema = z.object({ +export const skillsSpeedEnemySchema = z.object({ power: z.literal(EnemyPower.Speed), }); diff --git a/src/cli.ts b/src/cli.ts index dab9874..387c338 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -241,6 +241,7 @@ See more help with --help`, const { errors, + transformedSourceText, getZodSchemasFile, getIntegrationTestFile, hasCircularDependencies, @@ -261,7 +262,7 @@ See more help with --help`, if (flags.all) validatorSpinner.indent = 1; const generationErrors = await worker.validateGeneratedTypesInWorker({ sourceTypes: { - sourceText, + sourceText: transformedSourceText, relativePath: "./source.ts", }, integrationTests: { diff --git a/src/core/generate.test.ts b/src/core/generate.test.ts index 8b2e386..2ce3361 100644 --- a/src/core/generate.test.ts +++ b/src/core/generate.test.ts @@ -231,7 +231,7 @@ describe("generate", () => { keepComments: true, }); - it("should only generate superman schema", () => { + it("should generate superman schema", () => { expect(getZodSchemasFile("./hero")).toMatchInlineSnapshot(` "// Generated by ts-to-zod import { z } from \\"zod\\"; @@ -248,7 +248,7 @@ describe("generate", () => { }); describe("with non-exported types", () => { - it("should generate tests only for exported schemas", () => { + it("should generate tests for exported schemas", () => { const sourceText = ` export type Name = "superman" | "clark kent" | "kal-l"; @@ -294,4 +294,82 @@ describe("generate", () => { `); }); }); + + describe("with namespace", () => { + const sourceText = ` + export namespace Metropolis { + export type Name = "superman" | "clark kent" | "kal-l"; + + // Note that the Superman is declared after + export type BadassSuperman = Omit; + + export interface Superman { + name: Name; + age: number; + underKryptonite?: boolean; + /** + * @format email + **/ + email: string; + } + + const fly = () => console.log("I can fly!"); + } + `; + + const { getZodSchemasFile, getIntegrationTestFile, errors } = generate({ + sourceText, + }); + + it("should generate the zod schemas", () => { + expect(getZodSchemasFile("./hero")).toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from \\"zod\\"; + + export const metropolisNameSchema = z.union([z.literal(\\"superman\\"), z.literal(\\"clark kent\\"), z.literal(\\"kal-l\\")]); + + export const metropolisSupermanSchema = z.object({ + name: metropolisNameSchema, + age: z.number(), + underKryptonite: z.boolean().optional(), + email: z.string().email() + }); + + export const metropolisBadassSupermanSchema = metropolisSupermanSchema.omit({ \\"underKryptonite\\": true }); + " + `); + }); + + it("should generate the integration tests", () => { + expect(getIntegrationTestFile("./hero", "hero.zod")) + .toMatchInlineSnapshot(` + "// Generated by ts-to-zod + import { z } from \\"zod\\"; + + import * as spec from \\"./hero\\"; + import * as generated from \\"hero.zod\\"; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function expectType(_: T) { + /* noop */ + } + + export type metropolisNameSchemaInferredType = z.infer; + + export type metropolisSupermanSchemaInferredType = z.infer; + + export type metropolisBadassSupermanSchemaInferredType = z.infer; + expectType({} as metropolisNameSchemaInferredType) + expectType({} as spec.MetropolisName) + expectType({} as metropolisSupermanSchemaInferredType) + expectType({} as spec.MetropolisSuperman) + expectType({} as metropolisBadassSupermanSchemaInferredType) + expectType({} as spec.MetropolisBadassSuperman) + " + `); + }); + it("should not have any errors", () => { + expect(errors).toEqual([]); + }); + }); }); diff --git a/src/core/generate.ts b/src/core/generate.ts index 533c7b5..388b9ce 100644 --- a/src/core/generate.ts +++ b/src/core/generate.ts @@ -1,5 +1,6 @@ import { camel } from "case"; import ts from "typescript"; +import { resolveModules } from "../utils/resolveModules"; import { generateIntegrationTests } from "./generateIntegrationTests"; import { generateZodInferredType } from "./generateZodInferredType"; import { generateZodSchemaVariableStatement } from "./generateZodSchema"; @@ -45,12 +46,8 @@ export function generate({ getSchemaName = (id) => camel(id) + "Schema", keepComments = false, }: GenerateProps) { - // Create a source file - const sourceFile = ts.createSourceFile( - "index.ts", - sourceText, - ts.ScriptTarget.Latest - ); + // Create a source file and deal with modules + const sourceFile = resolveModules(sourceText); // Extract the nodes (interface declarations & type aliases) const nodes: Array< @@ -142,9 +139,16 @@ ${missingStatements.map(({ varName }) => `${varName}`).join("\n")}` newLine: ts.NewLineKind.LineFeed, removeComments: !keepComments, }); + + const printerWithComments = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + }); + const print = (node: ts.Node) => printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); + const transformedSourceText = printerWithComments.printFile(sourceFile); + const imports = Array.from(typeImports.values()); const getZodSchemasFile = ( typesImportPath: string @@ -200,6 +204,11 @@ ${testCases.map(print).join("\n")} `; return { + /** + * Source text with pre-process applied. + */ + transformedSourceText, + /** * Get the content of the zod schemas file. * diff --git a/src/utils/resolveModules.test.ts b/src/utils/resolveModules.test.ts new file mode 100644 index 0000000..bdb2875 --- /dev/null +++ b/src/utils/resolveModules.test.ts @@ -0,0 +1,103 @@ +import ts from "typescript"; +import { resolveModules } from "./resolveModules"; + +describe("resolveModules", () => { + it("should prefix interface", () => { + const sourceText = `export namespace Metropolis { + export interface Superman { + name: string; + hasPower: boolean; + } + }`; + + expect(print(resolveModules(sourceText))).toMatchInlineSnapshot(` + "export interface MetropolisSuperman { + name: string; + hasPower: boolean; + } + " + `); + }); + + it("should prefix type", () => { + const sourceText = `export namespace Metropolis { + export type Name = "superman" | "clark kent" | "kal-l"; + }`; + + expect(print(resolveModules(sourceText))).toMatchInlineSnapshot(` + "export type MetropolisName = \\"superman\\" | \\"clark kent\\" | \\"kal-l\\"; + " + `); + }); + + it("should prefix enum", () => { + const sourceText = `export namespace Metropolis { + export enum Superhero { + Superman = "superman", + ClarkKent = "clark_kent", + }; + }`; + + expect(print(resolveModules(sourceText))).toMatchInlineSnapshot(` + "export enum MetropolisSuperhero { + Superman = \\"superman\\", + ClarkKent = \\"clark_kent\\" + } + ; + " + `); + }); + + it("should prefix every type references", () => { + const sourceText = ` + export type Weakness = "krytonite" | "lois" + + export namespace Metropolis { + export type Name = string; + + export type BadassSuperman = Omit; + + export interface Superman { + fullName: Name; + name: { first: Name; last: Name }; + hasPower: boolean; + weakness: Weakness; + } + + export type SupermanBis = { + fullName: Name; + name: { first: Name; last: Name }; + hasPower: boolean; + weakness: Weakness; + } + }`; + + expect(print(resolveModules(sourceText))).toMatchInlineSnapshot(` + "export type Weakness = \\"krytonite\\" | \\"lois\\"; + export type MetropolisName = string; + export type MetropolisBadassSuperman = Omit; + export interface MetropolisSuperman { + fullName: MetropolisName; + name: { + first: MetropolisName; + last: MetropolisName; + }; + hasPower: boolean; + weakness: Weakness; + } + export type MetropolisSupermanBis = { + fullName: MetropolisName; + name: { + first: MetropolisName; + last: MetropolisName; + }; + hasPower: boolean; + weakness: Weakness; + }; + " + `); + }); +}); + +const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); +const print = (sourceFile: ts.SourceFile) => printer.printFile(sourceFile); diff --git a/src/utils/resolveModules.ts b/src/utils/resolveModules.ts new file mode 100644 index 0000000..c1e9682 --- /dev/null +++ b/src/utils/resolveModules.ts @@ -0,0 +1,156 @@ +import { pascal } from "case"; +import ts, { factory as f, SourceFile } from "typescript"; + +/** + * Resolve all modules from a source text. + * + * @param sourceText + */ +export function resolveModules(sourceText: string): SourceFile { + const sourceFile = ts.createSourceFile( + "index.ts", + sourceText, + ts.ScriptTarget.Latest + ); + + const declarations = getDeclarationNames(sourceFile); + const { transformed } = ts.transform(sourceFile, [ + moduleToPrefix(declarations), + ]); + + return transformed[0]; +} + +/** + * Extract all declarations under a namespace + * + * @param sourceFile + * @returns + */ +function getDeclarationNames(sourceFile: ts.SourceFile) { + const declarations = new Map(); + + const extractNamespacedTypesVisitor = (namespace: string) => ( + node: ts.Node + ) => { + if ( + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node) + ) { + const prev = declarations.get(namespace); + prev + ? declarations.set(namespace, [...prev, node.name.text]) + : declarations.set(namespace, [node.name.text]); + } + }; + + const topLevelVisitor = (node: ts.Node) => { + if (ts.isModuleDeclaration(node)) { + node.body?.forEachChild(extractNamespacedTypesVisitor(node.name.text)); + } + }; + + sourceFile.forEachChild(topLevelVisitor); + + return declarations; +} + +/** + * Apply namespace to every declarations + * + * @param declarationNames + * @returns + */ +const moduleToPrefix = ( + declarationNames: Map +): ts.TransformerFactory => (context) => (sourceFile) => { + const prefixInterfacesAndTypes = (moduleName: string) => ( + node: ts.Node + ): ts.Node | undefined => { + if ( + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + (declarationNames.get(moduleName) || []).includes(node.typeName.text) + ) { + return f.updateTypeReferenceNode( + node, + f.createIdentifier(pascal(moduleName) + pascal(node.typeName.text)), + node.typeArguments + ); + } + + if (ts.isTypeAliasDeclaration(node)) { + return f.updateTypeAliasDeclaration( + node, + node.decorators, + node.modifiers, + f.createIdentifier(pascal(moduleName) + pascal(node.name.text)), + node.typeParameters, + ts.isTypeLiteralNode(node.type) + ? f.updateTypeLiteralNode( + node.type, + ts.visitNodes( + node.type.members, + prefixInterfacesAndTypes(moduleName) + ) + ) + : ts.isTypeReferenceNode(node.type) + ? f.updateTypeReferenceNode( + node.type, + node.type.typeName, + ts.visitNodes( + node.type.typeArguments, + prefixInterfacesAndTypes(moduleName) + ) + ) + : node.type + ); + } + + if (ts.isInterfaceDeclaration(node)) { + return f.updateInterfaceDeclaration( + node, + node.decorators, + node.modifiers, + f.createIdentifier(pascal(moduleName) + pascal(node.name.text)), + node.typeParameters, + node.heritageClauses, + ts.visitNodes(node.members, prefixInterfacesAndTypes(moduleName)) + ); + } + + if (ts.isEnumDeclaration(node)) { + return f.updateEnumDeclaration( + node, + node.decorators, + node.modifiers, + f.createIdentifier(pascal(moduleName) + pascal(node.name.text)), + node.members + ); + } + + return ts.visitEachChild( + node, + prefixInterfacesAndTypes(moduleName), + context + ); + }; + + const flattenModuleDeclaration = (node: ts.Node): ts.Node | ts.Node[] => { + if ( + ts.isModuleDeclaration(node) && + node.body && + ts.isModuleBlock(node.body) + ) { + const transformedNodes = ts.visitNodes( + node.body.statements, + prefixInterfacesAndTypes(node.name.text) + ); + return [...transformedNodes]; + } + return ts.visitEachChild(node, flattenModuleDeclaration, context); + }; + + return ts.visitNode(sourceFile, flattenModuleDeclaration); +};