An experimental TypeSpec syntax for Lexicon
1import assert from "assert";
2import path from "path";
3import { describe, it } from "vitest";
4import {
5 formatDiagnostic,
6 resolvePath,
7 type Diagnostic,
8} from "@typespec/compiler";
9import {
10 TypeSpecTestLibrary,
11 createTestHost,
12 findTestPackageRoot,
13 resolveVirtualPath,
14} from "@typespec/compiler/testing";
15import { readdirSync, statSync } from "fs";
16import { readFile, readdir, stat } from "fs/promises";
17
18const pkgRoot = await findTestPackageRoot(import.meta.url);
19const TESTS_DIR = resolvePath(pkgRoot, "test/spec");
20
21const TypelexTestLibrary: TypeSpecTestLibrary = {
22 name: "@typelex/emitter",
23 packageRoot: await findTestPackageRoot(import.meta.url),
24 files: [
25 {
26 realDir: "",
27 pattern: "package.json",
28 virtualPath: "./node_modules/@typelex/emitter",
29 },
30 {
31 realDir: "dist",
32 pattern: "**/*.js",
33 virtualPath: "./node_modules/@typelex/emitter/dist",
34 },
35 {
36 realDir: "lib/",
37 pattern: "*.tsp",
38 virtualPath: "./node_modules/@typelex/emitter/lib",
39 },
40 ],
41};
42
43describe("lexicon spec", function () {
44 const scenarios = readdirSync(TESTS_DIR)
45 .map((dn) => path.join(TESTS_DIR, dn))
46 .filter((dn) => statSync(dn).isDirectory());
47
48 for (const scenario of scenarios) {
49 const scenarioName = path.basename(scenario);
50
51 describe(scenarioName, async function () {
52 const inputFiles = await readdirRecursive(path.join(scenario, "input"));
53 const expectedFiles = await readdirRecursive(
54 path.join(scenario, "output"),
55 );
56
57 // Compile all inputs together (for cross-references)
58 const tspFiles = Object.keys(inputFiles).filter((f) =>
59 f.endsWith(".tsp"),
60 );
61 let emitResult: EmitResult;
62
63 if (tspFiles.length > 0) {
64 // Create a virtual main.tsp that imports all other files
65 const mainContent =
66 'import "@typelex/emitter";\n' +
67 tspFiles.map((f) => `import "./${normalizePathToPosix(f)}";`).join("\n");
68 const filesWithMain = { ...inputFiles, "main.tsp": mainContent };
69 emitResult = await doEmit(filesWithMain, "main.tsp");
70 } else {
71 emitResult = { files: {}, diagnostics: [], inputFiles: {} };
72 }
73
74 // Generate a test for each expected output
75 for (const expectedPath of Object.keys(expectedFiles)) {
76 if (!expectedPath.endsWith(".json")) continue;
77
78 // Derive expected input path: output/app/bsky/feed/post.json -> input/app/bsky/feed/post.tsp
79 const inputPath = expectedPath.replace(/\.json$/, ".tsp");
80 const hasInput = Object.keys(inputFiles).includes(inputPath);
81
82 if (hasInput) {
83 it(`should emit ${expectedPath}`, function () {
84 // Check for compilation errors
85 if (emitResult.diagnostics.length > 0) {
86 const formattedDiagnostics = emitResult.diagnostics.map((diag) =>
87 formatDiagnostic(diag),
88 );
89 assert.fail(
90 `Expected no diagnostics but got:\n${formattedDiagnostics.join("\n\n")}`,
91 );
92 }
93
94 const normalizedExpectedPath = normalizePathToPosix(expectedPath);
95
96 assert.ok(
97 Object.prototype.hasOwnProperty.call(
98 emitResult.files,
99 normalizedExpectedPath,
100 ),
101 `Expected file ${expectedPath} was not produced`,
102 );
103
104 const actual = JSON.parse(emitResult.files[normalizedExpectedPath]);
105 const expected = JSON.parse(expectedFiles[expectedPath]);
106 assert.deepStrictEqual(actual, expected);
107 });
108 } else {
109 it(`should emit ${expectedPath}`, function () {
110 assert.fail(
111 `Expected output file ${expectedPath} has no corresponding input file ${inputPath}. ` +
112 `Either add the input file or remove the expected output.`
113 );
114 });
115 }
116 }
117
118 // Check for unexpected emitted files
119 it("should not emit unexpected files", function () {
120 const emittedFiles = Object.keys(emitResult.files).filter(f => f.endsWith(".json"));
121 const expectedPaths = Object.keys(expectedFiles)
122 .filter(f => f.endsWith(".json"))
123 .map(normalizePathToPosix);
124
125 const unexpected = emittedFiles.filter(f => !expectedPaths.includes(f));
126
127 if (unexpected.length > 0) {
128 assert.fail(
129 `Unexpected files were emitted: ${unexpected.join(", ")}. ` +
130 `Either add expected output files or ensure these should not be emitted.`
131 );
132 }
133 });
134 });
135 }
136});
137
138interface EmitResult {
139 files: Record<string, string>;
140 inputFiles: Record<string, string>;
141 diagnostics: readonly Diagnostic[];
142}
143
144async function doEmit(
145 files: Record<string, string>,
146 entryPoint: string,
147): Promise<EmitResult> {
148 const baseOutputPath = resolveVirtualPath("test-output/");
149
150 const host = await createTestHost({
151 libraries: [TypelexTestLibrary],
152 });
153
154 for (const [fileName, content] of Object.entries(files)) {
155 host.addTypeSpecFile(fileName, content);
156 }
157
158 const [, diagnostics] = await host.compileAndDiagnose(entryPoint, {
159 outputDir: baseOutputPath,
160 noEmit: false,
161 emit: ["@typelex/emitter"],
162 });
163
164 const outputFiles = Object.fromEntries(
165 [...host.fs.entries()]
166 .filter(([name]) => name.startsWith(baseOutputPath))
167 .map(([name, value]) => {
168 let relativePath = name.replace(baseOutputPath, "");
169 // Strip the @typelex/emitter/ prefix if present
170 if (relativePath.startsWith("@typelex/emitter/")) {
171 relativePath = relativePath.replace("@typelex/emitter/", "");
172 }
173 return [relativePath, value];
174 }),
175 );
176
177 return {
178 files: outputFiles,
179 inputFiles: files,
180 diagnostics: diagnostics,
181 };
182}
183
184async function readdirRecursive(dir: string): Promise<Record<string, string>> {
185 const result: Record<string, string> = {};
186
187 async function walk(currentDir: string, relativePath: string) {
188 const entries = await readdir(currentDir);
189
190 for (const entry of entries) {
191 const fullPath = path.join(currentDir, entry);
192 const stats = await stat(fullPath);
193
194 if (stats.isDirectory()) {
195 await walk(fullPath, path.join(relativePath, entry));
196 } else {
197 const content = await readFile(fullPath, "utf-8");
198 const key = path.join(relativePath, entry);
199 result[key] = content;
200 }
201 }
202 }
203
204 await walk(dir, "");
205 return result;
206}
207
208function normalizePathToPosix(thePath: string): string {
209 return thePath.replaceAll(path.sep, path.posix.sep);
210}