Skip to content

Commit 94e4d8c

Browse files
committed
minor(emitter-zod): emit namespace blocks
1 parent dcfc88d commit 94e4d8c

File tree

5 files changed

+127
-103
lines changed

5 files changed

+127
-103
lines changed

.changeset/giant-donuts-relate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typespec-tools/emitter-zod": minor
3+
---
4+
5+
Emit namespace blocks

packages/emitter-typescript/src/emitter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ export class TypescriptEmitter<
6969
}
7070
emitNamespace(scope: Scope<string>) {
7171
let ns = `export namespace ${scope.name} {\n`;
72-
ns += this.emitNamespaces(scope);
7372
for (const decl of scope.declarations) {
7473
ns += decl.value + "\n";
7574
}
75+
ns += this.emitNamespaces(scope);
7676
ns += `}\n`;
7777

7878
return ns;

packages/emitter-zod/src/emitter.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import {
55
Enum,
66
EnumMember,
77
getDoc,
8+
getNamespaceFullName,
89
Interface,
910
IntrinsicType,
1011
Model,
1112
ModelProperty,
13+
Namespace,
1214
NumericLiteral,
1315
Operation,
1416
Scalar,
@@ -53,6 +55,96 @@ export const intrinsicNameToTSType = new Map<string, string>([
5355
]);
5456

5557
export class ZodEmitter extends CodeTypeEmitter<EmitterOptions> {
58+
protected nsByName: Map<string, Scope<string>> = new Map();
59+
60+
emitNamespaces(scope: Scope<string>) {
61+
let res = "";
62+
for (const childScope of scope.childScopes) {
63+
res += this.emitNamespace(childScope);
64+
}
65+
return res;
66+
}
67+
emitNamespace(scope: Scope<string>) {
68+
let ns = `export namespace ${scope.name} {\n`;
69+
for (const decl of scope.declarations) {
70+
ns += decl.value + "\n";
71+
}
72+
ns += this.emitNamespaces(scope);
73+
ns += `}\n`;
74+
75+
return ns;
76+
}
77+
78+
declarationContext(decl: { namespace?: Namespace }): Context {
79+
const name = decl.namespace?.name;
80+
if (!name) return {};
81+
82+
const namespaceChain = decl.namespace
83+
? getNamespaceFullName(decl.namespace).split(".")
84+
: [];
85+
86+
let nsScope = this.nsByName.get(name);
87+
if (!nsScope) {
88+
// If there is no scope for the namespace, create one for each
89+
// namespace in the chain.
90+
let parentScope: Scope<string> | undefined;
91+
while (namespaceChain.length > 0) {
92+
const ns = namespaceChain.shift();
93+
if (!ns) {
94+
break;
95+
}
96+
nsScope = this.nsByName.get(ns);
97+
if (nsScope) {
98+
parentScope = nsScope;
99+
continue;
100+
}
101+
nsScope = this.emitter.createScope(
102+
{},
103+
ns,
104+
parentScope ?? this.emitter.getContext().scope
105+
);
106+
this.nsByName.set(ns, nsScope);
107+
parentScope = nsScope;
108+
}
109+
}
110+
111+
return {
112+
scope: nsScope,
113+
};
114+
}
115+
116+
modelDeclarationContext(model: Model): Context {
117+
return this.declarationContext(model);
118+
}
119+
120+
modelInstantiationContext(model: Model): Context {
121+
return this.declarationContext(model);
122+
}
123+
124+
unionDeclarationContext(union: Union): Context {
125+
return this.declarationContext(union);
126+
}
127+
128+
unionInstantiationContext(union: Union): Context {
129+
return this.declarationContext(union);
130+
}
131+
132+
enumDeclarationContext(en: Enum): Context {
133+
return this.declarationContext(en);
134+
}
135+
136+
arrayDeclarationContext(array: Model): Context {
137+
return this.declarationContext(array);
138+
}
139+
140+
interfaceDeclarationContext(iface: Interface): Context {
141+
return this.declarationContext(iface);
142+
}
143+
144+
operationDeclarationContext(operation: Operation): Context {
145+
return this.declarationContext(operation);
146+
}
147+
56148
// type literals
57149
booleanLiteral(boolean: BooleanLiteral): EmitterOutput<string> {
58150
return code`z.literal(${JSON.stringify(boolean.value)})`;
@@ -318,6 +410,8 @@ export class ZodEmitter extends CodeTypeEmitter<EmitterOptions> {
318410
emittedSourceFile.contents += decl.value + "\n";
319411
}
320412

413+
emittedSourceFile.contents += this.emitNamespaces(sourceFile.globalScope);
414+
321415
emittedSourceFile.contents = await prettier.format(
322416
emittedSourceFile.contents,
323417
{

packages/emitter-zod/test/emitter.test.ts

Lines changed: 20 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
import { Enum, Interface, Model, Operation, Union } from "@typespec/compiler";
22
import {
3-
AssetEmitter,
43
Context,
5-
EmittedSourceFile,
64
EmitterOutput,
7-
Scope,
8-
SourceFile,
95
TypeSpecDeclaration,
106
createAssetEmitter,
117
} from "@typespec/compiler/emitter-framework";
128

139
import assert from "assert";
14-
import * as prettier from "prettier";
1510
import { describe, it } from "vitest";
1611

1712
import { SingleFileZodEmitter, ZodEmitter } from "../src/emitter.js";
18-
import { EmitterOptions } from "../src/lib.js";
1913
import { emitTypeSpec, getHostForTypeSpecFile } from "./host.js";
2014

2115
const testCode = `
@@ -534,106 +528,36 @@ describe("emitter-framework: zod emitter", () => {
534528
});
535529
});
536530

537-
it("emits to namespaces", async () => {
538-
const host = await getHostForTypeSpecFile(testCode);
539-
540-
class NamespacedEmitter extends ZodEmitter {
541-
private nsByName: Map<string, Scope<string>> = new Map();
542-
programContext(): Context {
543-
const outputFile = emitter.createSourceFile("output.ts");
544-
return {
545-
scope: outputFile.globalScope,
546-
};
547-
}
548-
549-
modelDeclarationContext(model: Model): Context {
550-
const name = this.emitter.emitDeclarationName(model);
551-
if (!name) return {};
552-
const nsName = name.slice(0, 1);
553-
let nsScope = this.nsByName.get(nsName);
554-
if (!nsScope) {
555-
nsScope = this.emitter.createScope(
556-
{},
557-
nsName,
558-
this.emitter.getContext().scope
559-
);
560-
this.nsByName.set(nsName, nsScope);
561-
}
531+
it("emits namespaces", async () => {
532+
const contents = await emitTypeSpecToTs(`
533+
namespace A;
562534
563-
return {
564-
scope: nsScope,
565-
};
535+
enum MyEnum {
536+
a: "hi";
537+
b: "bye";
566538
}
567539
568-
async sourceFile(
569-
sourceFile: SourceFile<string>
570-
): Promise<EmittedSourceFile> {
571-
const emittedSourceFile = await super.sourceFile(sourceFile);
572-
emittedSourceFile.contents += emitNamespaces(sourceFile.globalScope);
573-
emittedSourceFile.contents = await prettier.format(
574-
emittedSourceFile.contents,
575-
{
576-
parser: "typescript",
540+
namespace B {
541+
namespace C {
542+
model X {
543+
x: string;
577544
}
578-
);
579-
return emittedSourceFile;
580-
581-
function emitNamespaces(scope: Scope<string>) {
582-
let res = "";
583-
for (const childScope of scope.childScopes) {
584-
res += emitNamespace(childScope);
585-
}
586-
return res;
587545
}
588-
function emitNamespace(scope: Scope<string>) {
589-
let ns = `export namespace ${scope.name} {\n`;
590-
ns += emitNamespaces(scope);
591-
for (const decl of scope.declarations) {
592-
ns += decl.value + ",\n";
593-
}
594-
ns += `}\n`;
546+
op SomeOp(x: string): string;
595547
596-
return ns;
548+
interface MyInterface {
549+
op get(): string;
597550
}
598551
}
599-
}
600-
const emitter = createAssetEmitter(host.program, NamespacedEmitter, {
601-
emitterOutputDir: host.program.compilerOptions.outputDir!,
602-
options: {},
603-
} as any);
604-
emitter.emitProgram();
605-
await emitter.writeOutput();
606-
const contents = (await host.compilerHost.readFile("tsp-output/output.ts"))
607-
.text;
608-
609-
assert.match(contents, /export namespace B \{/);
610-
assert.match(contents, /export namespace R \{/);
611-
assert.match(contents, /export namespace H \{/);
612-
assert.match(contents, /export namespace I \{/);
613-
assert.match(contents, /export namespace D \{/);
614-
assert.match(contents, /y: B\.BasicSchema/);
615-
assert.match(contents, /prop: B\.BasicSchema/);
616-
});
617-
618-
it("handles circular references", async () => {
619-
const host = await getHostForTypeSpecFile(`
620-
model Foo { prop: Baz }
621-
model Baz { prop: Foo }
622552
`);
623553

624-
const emitter: AssetEmitter<string, EmitterOptions> = createAssetEmitter(
625-
host.program,
626-
SingleFileZodEmitter,
627-
{
628-
emitterOutputDir: host.program.compilerOptions.outputDir!,
629-
options: {},
630-
} as any
554+
assert.match(
555+
contents,
556+
/export namespace A \{(\n|.)*export namespace B \{(\n|.)*export namespace C \{(\n|.)*\}(\n|.)*\}(\n|.)*\}/
557+
);
558+
assert.match(
559+
contents,
560+
/export namespace C \{(\n|.)*export const XSchema = z.object\((\n|.)*x: z.string\(\)(\n|.)*\)(\n|.)*\}/
631561
);
632-
emitter.emitProgram();
633-
await emitter.writeOutput();
634-
const contents = (await host.compilerHost.readFile("tsp-output/output.ts"))
635-
.text;
636-
assert.match(contents, /prop: Foo/);
637-
assert.match(contents, /prop: Baz/);
638562
});
639563
});

packages/integration-tests/tests/emitter-zod.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { assert, describe, it } from "vitest";
22
import { z } from "zod";
33

4-
import {
5-
PetSchema,
6-
getPetSchema,
7-
listPetsSchema,
4+
import { PetStore } from "../tsp-output/@typespec-tools/emitter-zod/output";
5+
6+
const {
87
petTypeEnum,
9-
} from "../tsp-output/@typespec-tools/emitter-zod/output";
8+
PetSchema,
9+
Pets: { getPetSchema, listPetsSchema },
10+
} = PetStore;
1011

1112
const validPet: z.infer<typeof PetSchema> = {
1213
id: 123,
@@ -31,7 +32,7 @@ describe("emitter-zod", () => {
3132
it("invalidates an invalid pet", () => {
3233
const result = PetSchema.safeParse(invalidPet);
3334
assert.isFalse(result.success);
34-
assert.equal(result.error.errors.length, 3);
35+
assert.equal(result.error?.errors.length, 3);
3536
});
3637
});
3738

0 commit comments

Comments
 (0)