An experimental TypeSpec syntax for Lexicon

replacing bundling with externals in playground

Changed files
+210 -86
packages
playground
samples
+1
DOCS.md
··· 253 253 @external 254 254 namespace com.atproto.label.defs { 255 255 model SelfLabels { } 256 + @token model SomeToken { } // use @token for tokens 256 257 } 257 258 ``` 258 259
+66 -3
packages/playground/samples/build.js
··· 1 1 // @ts-check 2 - import { writeFileSync, mkdirSync } from "fs"; 2 + import { writeFileSync, mkdirSync, readFileSync } from "fs"; 3 3 import { dirname, resolve, join } from "path"; 4 4 import { fileURLToPath } from "url"; 5 + import { deepStrictEqual } from "assert"; 5 6 import { lexicons, bundleLexicon } from "./index.js"; 7 + import { createTestHost, findTestPackageRoot, resolveVirtualPath } from "@typespec/compiler/testing"; 6 8 7 9 const __dirname = dirname(fileURLToPath(import.meta.url)); 8 10 const outputDir = resolve(__dirname, "dist"); 11 + const pkgRoot = await findTestPackageRoot(import.meta.url); 12 + 13 + // TypeSpec library setup for testing 14 + const TypelexTestLibrary = { 15 + name: "@typelex/emitter", 16 + packageRoot: pkgRoot.replace("/playground", "/emitter"), 17 + files: [ 18 + { realDir: "", pattern: "package.json", virtualPath: "./node_modules/@typelex/emitter" }, 19 + { realDir: "dist", pattern: "**/*.js", virtualPath: "./node_modules/@typelex/emitter/dist" }, 20 + { realDir: "lib/", pattern: "*.tsp", virtualPath: "./node_modules/@typelex/emitter/lib" }, 21 + ], 22 + }; 9 23 10 24 // Create output directory 11 25 mkdirSync(outputDir, { recursive: true }); 12 26 13 - // Write each bundled lexicon to disk 27 + // Write each bundled lexicon to disk and verify it compiles correctly 14 28 const samplesList = {}; 15 29 16 30 for (const [namespace, lexicon] of lexicons) { ··· 20 34 21 35 writeFileSync(filepath, bundled); 22 36 37 + const host = await createTestHost({ libraries: [TypelexTestLibrary] }); 38 + host.addTypeSpecFile("main.tsp", bundled); 39 + 40 + const baseOutputPath = resolveVirtualPath("test-output/"); 41 + const [, diagnostics] = await host.compileAndDiagnose("main.tsp", { 42 + outputDir: baseOutputPath, 43 + noEmit: false, 44 + emit: ["@typelex/emitter"], 45 + }); 46 + 47 + if (diagnostics.length > 0) { 48 + console.error(`❌ ${namespace}: Compilation errors`); 49 + diagnostics.forEach(d => console.error(` ${d.message}`)); 50 + process.exit(1); 51 + } 52 + 53 + // Get emitted JSON 54 + const outputFiles = [...host.fs.entries()] 55 + .filter(([name]) => name.startsWith(baseOutputPath)) 56 + .map(([name, value]) => { 57 + let relativePath = name.replace(baseOutputPath, ""); 58 + if (relativePath.startsWith("@typelex/emitter/")) { 59 + relativePath = relativePath.replace("@typelex/emitter/", ""); 60 + } 61 + return [relativePath, value]; 62 + }); 63 + 64 + const expectedJsonPath = namespace.replace(/\./g, "/") + ".json"; 65 + const emittedJson = outputFiles.find(([path]) => path === expectedJsonPath); 66 + 67 + if (!emittedJson) { 68 + console.error(`❌ ${namespace}: No JSON output found (expected ${expectedJsonPath})`); 69 + process.exit(1); 70 + } 71 + 72 + // Compare with expected JSON 73 + const expectedJsonFile = join( 74 + pkgRoot.replace("/playground", "/emitter"), 75 + "test/integration", 76 + lexicon.suite, 77 + "output", 78 + lexicon.file.replace(".tsp", ".json") 79 + ); 80 + 81 + const expectedJson = JSON.parse(readFileSync(expectedJsonFile, "utf-8")); 82 + const actualJson = JSON.parse(emittedJson[1]); 83 + 84 + deepStrictEqual(actualJson, expectedJson); 85 + 23 86 samplesList[namespace] = { 24 87 filename: `samples/dist/${filename}`, 25 88 preferredEmitter: "@typelex/emitter", ··· 30 93 const samplesIndex = `export default ${JSON.stringify(samplesList, null, 2)};`; 31 94 writeFileSync(join(outputDir, "samples.js"), samplesIndex); 32 95 33 - console.log(`Wrote ${Object.keys(samplesList).length} bundled samples to disk`); 96 + console.log(`\n✅ ${lexicons.size} samples verified successfully`);
+143 -83
packages/playground/samples/index.js
··· 5 5 6 6 const __dirname = dirname(fileURLToPath(import.meta.url)); 7 7 8 - // Get all tsp files 9 - function getAllTspFiles(dir, baseDir = dir) { 8 + // Get all tsp and json files 9 + function getAllFiles(dir, baseDir = dir) { 10 10 const files = []; 11 11 const entries = readdirSync(dir); 12 12 ··· 15 15 const stat = statSync(fullPath); 16 16 17 17 if (stat.isDirectory()) { 18 - files.push(...getAllTspFiles(fullPath, baseDir)); 19 - } else if (entry.endsWith(".tsp")) { 18 + files.push(...getAllFiles(fullPath, baseDir)); 19 + } else if (entry.endsWith(".tsp") || entry.endsWith(".json")) { 20 20 files.push(relative(baseDir, fullPath)); 21 21 } 22 22 } ··· 24 24 return files.sort(); 25 25 } 26 26 27 - // Extract dependencies from a file 28 - function extractDependencies(content) { 29 - const deps = new Set(); 30 - // Match namespace references like "com.atproto.label.defs.Label" or "com.atproto.repo.strongRef.Main" 31 - // Pattern: word.word.word... followed by dot and identifier starting with capital letter 32 - const pattern = 33 - /\b([a-z]+(?:\.[a-z]+)+(?:\.[a-z][a-zA-Z]*)*)\.[A-Z][a-zA-Z]*/g; 34 - const withoutDeclaration = content.replace(/namespace\s+[a-z.]+\s*\{/, ""); 27 + // Extract all refs from JSON (recursively search for strings with #) 28 + function extractRefsFromJson(obj, refs = new Map()) { 29 + if (typeof obj === "string") { 30 + // Match pattern like "foo.bar#baz" or "foo.barCamel#baz" (must have # to be a ref) 31 + const match = obj.match(/^([a-z][a-zA-Z.]+)#([a-z][a-zA-Z]*)$/); 32 + if (match) { 33 + const ns = match[1]; 34 + const def = match[2]; 35 + const modelName = def.charAt(0).toUpperCase() + def.slice(1); 36 + if (!refs.has(ns)) { 37 + refs.set(ns, new Set()); 38 + } 39 + refs.get(ns).add(modelName); 40 + } else { 41 + // Also match plain namespace refs like "foo.bar.baz" or "foo.bar.bazCamel" (must have at least 2 dots) 42 + const nsMatch = obj.match(/^([a-z][a-zA-Z]*(?:\.[a-z][a-zA-Z]*){2,})$/); 43 + if (nsMatch) { 44 + const ns = nsMatch[1]; 45 + if (!refs.has(ns)) { 46 + refs.set(ns, new Set()); 47 + } 48 + refs.get(ns).add("Main"); 49 + } 50 + } 51 + } else if (Array.isArray(obj)) { 52 + for (const item of obj) { 53 + extractRefsFromJson(item, refs); 54 + } 55 + } else if (obj && typeof obj === "object") { 56 + for (const value of Object.values(obj)) { 57 + extractRefsFromJson(value, refs); 58 + } 59 + } 60 + return refs; 61 + } 35 62 36 - const matches = withoutDeclaration.matchAll(pattern); 37 - for (const match of matches) { 38 - deps.add(match[1]); 39 - } 63 + const integrationDir = join(__dirname, "../../emitter/test/integration"); 64 + 65 + // Get all test suite directories 66 + const testSuites = readdirSync(integrationDir).filter((name) => { 67 + const fullPath = join(integrationDir, name); 68 + return statSync(fullPath).isDirectory() && !name.startsWith("."); 69 + }); 40 70 41 - return Array.from(deps); 42 - } 71 + // Build lexicons with refs extracted from JSON 72 + const lexicons = new Map(); // namespace -> { file, content, refs, suite } 43 73 44 - const atprotoInputDir = join( 45 - __dirname, 46 - "../../emitter/test/integration/atproto/input", 47 - ); 48 - const lexiconExamplesDir = join( 49 - __dirname, 50 - "../../emitter/test/integration/lexicon-examples/input", 51 - ); 74 + // Process all test suites 75 + for (const suite of testSuites) { 76 + const inputDir = join(integrationDir, suite, "input"); 77 + const outputDir = join(integrationDir, suite, "output"); 52 78 53 - const atprotoFiles = getAllTspFiles(atprotoInputDir); 54 - const lexiconExampleFiles = getAllTspFiles(lexiconExamplesDir); 79 + const inputFiles = getAllFiles(inputDir).filter((f) => f.endsWith(".tsp")); 55 80 56 - // Build dependency graph 57 - const lexicons = new Map(); // namespace -> { file, content, deps } 81 + for (const file of inputFiles) { 82 + const fullPath = join(inputDir, file); 83 + const content = readFileSync(fullPath, "utf-8"); 84 + const namespace = file.replace(/\.tsp$/, "").replace(/\//g, "."); 58 85 59 - // Process atproto files 60 - for (const file of atprotoFiles) { 61 - const fullPath = join(atprotoInputDir, file); 62 - const content = readFileSync(fullPath, "utf-8"); 63 - const namespace = file.replace(/\.tsp$/, "").replace(/\//g, "."); 64 - const deps = extractDependencies(content); 86 + // Find corresponding JSON output 87 + const jsonFile = file.replace(/\.tsp$/, ".json"); 88 + const jsonPath = join(outputDir, jsonFile); 89 + const jsonContent = readFileSync(jsonPath, "utf-8"); 90 + const jsonData = JSON.parse(jsonContent); 91 + const refs = extractRefsFromJson(jsonData); 65 92 66 - lexicons.set(namespace, { file: `atproto/${file}`, content, deps }); 93 + lexicons.set(namespace, { file, content, refs, suite }); 94 + } 67 95 } 68 96 69 - // Process lexicon-examples files 70 - for (const file of lexiconExampleFiles) { 71 - const fullPath = join(lexiconExamplesDir, file); 72 - const content = readFileSync(fullPath, "utf-8"); 73 - const namespace = file.replace(/\.tsp$/, "").replace(/\//g, "."); 74 - const deps = extractDependencies(content); 97 + // TypeSpec reserved keywords that need escaping 98 + const TYPESPEC_KEYWORDS = new Set([ 99 + "record", 100 + "pub", 101 + "interface", 102 + "model", 103 + "namespace", 104 + "op", 105 + "import", 106 + "export", 107 + "using", 108 + "alias", 109 + "enum", 110 + "union", 111 + "scalar", 112 + "extends", 113 + ]); 75 114 76 - lexicons.set(namespace, { file: `examples/${file}`, content, deps }); 115 + // Escape a namespace part if it's a reserved keyword 116 + function escapeNamespacePart(part) { 117 + return TYPESPEC_KEYWORDS.has(part) ? `\`${part}\`` : part; 77 118 } 78 119 79 - // Recursively collect all dependencies (topological sort) 80 - function collectDependencies( 81 - namespace, 82 - collected = new Set(), 83 - visiting = new Set(), 84 - ) { 85 - if (collected.has(namespace)) return; 86 - if (visiting.has(namespace)) return; // circular dependency 120 + // Escape a full namespace path 121 + function escapeNamespace(namespace) { 122 + return namespace.split(".").map(escapeNamespacePart).join("."); 123 + } 87 124 125 + // Get the JSON for a lexicon to check its definitions 126 + function getLexiconJson(namespace) { 88 127 const lexicon = lexicons.get(namespace); 89 - if (!lexicon) return; 128 + if (!lexicon) return null; 90 129 91 - visiting.add(namespace); 130 + const jsonPath = join( 131 + integrationDir, 132 + lexicon.suite, 133 + "output", 134 + lexicon.file.replace(".tsp", ".json"), 135 + ); 92 136 93 - // First collect all dependencies 94 - for (const dep of lexicon.deps) { 95 - collectDependencies(dep, collected, visiting); 137 + try { 138 + return JSON.parse(readFileSync(jsonPath, "utf-8")); 139 + } catch { 140 + return null; 96 141 } 142 + } 97 143 98 - visiting.delete(namespace); 99 - collected.add(namespace); 144 + // Check if a definition in JSON is a token 145 + function isToken(lexiconJson, defName) { 146 + if (!lexiconJson || !lexiconJson.defs) return false; 147 + const def = lexiconJson.defs[defName]; 148 + return def && def.type === "token"; 100 149 } 101 150 102 - // Bundle a lexicon with all its dependencies 151 + // Bundle a lexicon with stubs for referenced types (from JSON) 103 152 function bundleLexicon(namespace) { 104 - const collected = new Set(); 105 - collectDependencies(namespace, collected); 153 + const mainLexicon = lexicons.get(namespace); 154 + if (!mainLexicon) return ""; 106 155 107 - // Put the main lexicon FIRST, then its dependencies 108 - const mainLexicon = lexicons.get(namespace); 109 - const deps = Array.from(collected).filter((ns) => ns !== namespace); 156 + let bundled = mainLexicon.content; 110 157 111 - let bundled = 'import "@typelex/emitter";\n\n'; 158 + // Add stubs from refs extracted from JSON output (excluding self-references) 159 + if (mainLexicon.refs.size > 0) { 160 + let hasExternalRefs = false; 161 + for (const [ns] of mainLexicon.refs) { 162 + if (ns !== namespace) { 163 + hasExternalRefs = true; 164 + break; 165 + } 166 + } 112 167 113 - // Main lexicon first (so it shows in the playground) 114 - if (mainLexicon) { 115 - const contentWithoutImport = mainLexicon.content.replace( 116 - /^import "@typelex\/emitter";\s*\n/, 117 - "", 118 - ); 119 - bundled += `// ${mainLexicon.file}\n${contentWithoutImport}\n`; 120 - } 168 + if (hasExternalRefs) { 169 + bundled += "\n// --- Externals ---\n"; 170 + } 121 171 122 - // Then dependencies 123 - for (const ns of deps) { 124 - const lexicon = lexicons.get(ns); 125 - if (!lexicon) continue; 172 + for (const [ns, models] of mainLexicon.refs) { 173 + // Skip if this is the current namespace 174 + if (ns === namespace) continue; 126 175 127 - const contentWithoutImport = lexicon.content.replace( 128 - /^import "@typelex\/emitter";\s*\n/, 129 - "", 130 - ); 131 - bundled += `// ${lexicon.file}\n${contentWithoutImport}\n`; 176 + // Get the JSON for this referenced namespace to check for tokens 177 + const refJson = getLexiconJson(ns); 178 + 179 + const escapedNs = escapeNamespace(ns); 180 + bundled += `\n@external\nnamespace ${escapedNs} {\n`; 181 + for (const model of models) { 182 + // Check if this definition exists in the JSON and is a token 183 + const defName = model.charAt(0).toLowerCase() + model.slice(1); 184 + if (refJson && isToken(refJson, defName)) { 185 + bundled += ` @token model ${model} { }\n`; 186 + } else { 187 + bundled += ` model ${model} { }\n`; 188 + } 189 + } 190 + bundled += `}\n`; 191 + } 132 192 } 133 193 134 194 return bundled;