···
const __dirname = dirname(fileURLToPath(import.meta.url));
9
-
function getAllTspFiles(dir, baseDir = dir) {
8
+
// Get all tsp and json files
9
+
function getAllFiles(dir, baseDir = dir) {
const entries = readdirSync(dir);
···
const stat = statSync(fullPath);
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")) {
files.push(relative(baseDir, fullPath));
···
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
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*\{/, "");
36
-
const matches = withoutDeclaration.matchAll(pattern);
37
-
for (const match of matches) {
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]*)$/);
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());
39
+
refs.get(ns).add(modelName);
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,})$/);
44
+
const ns = nsMatch[1];
45
+
if (!refs.has(ns)) {
46
+
refs.set(ns, new Set());
48
+
refs.get(ns).add("Main");
51
+
} else if (Array.isArray(obj)) {
52
+
for (const item of obj) {
53
+
extractRefsFromJson(item, refs);
55
+
} else if (obj && typeof obj === "object") {
56
+
for (const value of Object.values(obj)) {
57
+
extractRefsFromJson(value, refs);
41
-
return Array.from(deps);
44
-
const atprotoInputDir = join(
46
-
"../../emitter/test/integration/atproto/input",
48
-
const lexiconExamplesDir = join(
50
-
"../../emitter/test/integration/lexicon-examples/input",
53
-
const atprotoFiles = getAllTspFiles(atprotoInputDir);
54
-
const lexiconExampleFiles = getAllTspFiles(lexiconExamplesDir);
56
-
// Build dependency graph
57
-
const lexicons = new Map(); // namespace -> { file, content, deps }
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);
66
-
lexicons.set(namespace, { file: `atproto/${file}`, content, deps });
63
+
const integrationDir = join(__dirname, "../../emitter/test/integration");
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);
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(".");
76
-
lexicons.set(namespace, { file: `examples/${file}`, content, deps });
71
+
// Build lexicons with refs extracted from JSON
72
+
const lexicons = new Map(); // namespace -> { file, content, refs, suite }
79
-
// Recursively collect all dependencies (topological sort)
80
-
function collectDependencies(
82
-
collected = new Set(),
83
-
visiting = new Set(),
85
-
if (collected.has(namespace)) return;
86
-
if (visiting.has(namespace)) return; // circular dependency
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");
88
-
const lexicon = lexicons.get(namespace);
89
-
if (!lexicon) return;
79
+
const inputFiles = getAllFiles(inputDir).filter((f) => f.endsWith(".tsp"));
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, ".");
91
-
visiting.add(namespace);
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);
93
-
// First collect all dependencies
94
-
for (const dep of lexicon.deps) {
95
-
collectDependencies(dep, collected, visiting);
93
+
lexicons.set(namespace, { file, content, refs, suite });
98
-
visiting.delete(namespace);
99
-
collected.add(namespace);
97
+
// TypeSpec reserved keywords that need escaping
98
+
const TYPESPEC_KEYWORDS = new Set([
115
+
// Escape a namespace part if it's a reserved keyword
116
+
function escapeNamespacePart(part) {
117
+
return TYPESPEC_KEYWORDS.has(part) ? `\`${part}\`` : part;
102
-
// Bundle a lexicon with all its dependencies
103
-
function bundleLexicon(namespace) {
104
-
const collected = new Set();
105
-
collectDependencies(namespace, collected);
120
+
// Escape a full namespace path
121
+
function escapeNamespace(namespace) {
122
+
return namespace.split(".").map(escapeNamespacePart).join(".");
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);
125
+
// Get the JSON for a lexicon to check its definitions
126
+
function getLexiconJson(namespace) {
127
+
const lexicon = lexicons.get(namespace);
128
+
if (!lexicon) return null;
130
+
const jsonPath = join(
134
+
lexicon.file.replace(".tsp", ".json"),
138
+
return JSON.parse(readFileSync(jsonPath, "utf-8"));
111
-
let bundled = 'import "@typelex/emitter";\n\n';
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";
113
-
// Main lexicon first (so it shows in the playground)
115
-
const contentWithoutImport = mainLexicon.content.replace(
116
-
/^import "@typelex\/emitter";\s*\n/,
119
-
bundled += `// ${mainLexicon.file}\n${contentWithoutImport}\n`;
151
+
// Bundle a lexicon with stubs for referenced types (from JSON)
152
+
function bundleLexicon(namespace) {
153
+
const mainLexicon = lexicons.get(namespace);
154
+
if (!mainLexicon) return "";
156
+
let bundled = mainLexicon.content;
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;
122
-
// Then dependencies
123
-
for (const ns of deps) {
124
-
const lexicon = lexicons.get(ns);
125
-
if (!lexicon) continue;
168
+
if (hasExternalRefs) {
169
+
bundled += "\n// --- Externals ---\n";
127
-
const contentWithoutImport = lexicon.content.replace(
128
-
/^import "@typelex\/emitter";\s*\n/,
131
-
bundled += `// ${lexicon.file}\n${contentWithoutImport}\n`;
172
+
for (const [ns, models] of mainLexicon.refs) {
173
+
// Skip if this is the current namespace
174
+
if (ns === namespace) continue;
176
+
// Get the JSON for this referenced namespace to check for tokens
177
+
const refJson = getLexiconJson(ns);
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`;
187
+
bundled += ` model ${model} { }\n`;