@birdcc/core 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxfmtrc.json +16 -0
- package/LICENSE +674 -0
- package/README.md +343 -0
- package/dist/cross-file.d.ts +5 -0
- package/dist/cross-file.d.ts.map +1 -0
- package/dist/cross-file.js +264 -0
- package/dist/cross-file.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/prefix.d.ts +2 -0
- package/dist/prefix.d.ts.map +1 -0
- package/dist/prefix.js +76 -0
- package/dist/prefix.js.map +1 -0
- package/dist/range.d.ts +3 -0
- package/dist/range.d.ts.map +1 -0
- package/dist/range.js +7 -0
- package/dist/range.js.map +1 -0
- package/dist/semantic-diagnostics.d.ts +4 -0
- package/dist/semantic-diagnostics.d.ts.map +1 -0
- package/dist/semantic-diagnostics.js +75 -0
- package/dist/semantic-diagnostics.js.map +1 -0
- package/dist/snapshot.d.ts +7 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +22 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/symbol-table.d.ts +9 -0
- package/dist/symbol-table.d.ts.map +1 -0
- package/dist/symbol-table.js +118 -0
- package/dist/symbol-table.js.map +1 -0
- package/dist/template-cycles.d.ts +9 -0
- package/dist/template-cycles.d.ts.map +1 -0
- package/dist/template-cycles.js +95 -0
- package/dist/template-cycles.js.map +1 -0
- package/dist/type-checker.d.ts +4 -0
- package/dist/type-checker.d.ts.map +1 -0
- package/dist/type-checker.js +390 -0
- package/dist/type-checker.js.map +1 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
- package/scripts/benchmark-node-vs-regex.js +86 -0
- package/src/cross-file.ts +412 -0
- package/src/index.ts +42 -0
- package/src/prefix.ts +94 -0
- package/src/range.ts +12 -0
- package/src/semantic-diagnostics.ts +93 -0
- package/src/snapshot.ts +32 -0
- package/src/symbol-table.ts +171 -0
- package/src/template-cycles.ts +142 -0
- package/src/type-checker.ts +595 -0
- package/src/types.ts +101 -0
- package/test/core.test.ts +503 -0
- package/test/prefix.test.ts +40 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, normalize, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import type { ParsedBirdDocument, SourceRange } from "@birdcc/parser";
|
|
5
|
+
import { parseBirdConfig } from "@birdcc/parser";
|
|
6
|
+
import type {
|
|
7
|
+
BirdDiagnostic,
|
|
8
|
+
CoreSnapshot,
|
|
9
|
+
CrossFileResolveOptions,
|
|
10
|
+
CrossFileResolutionResult,
|
|
11
|
+
CrossFileResolutionStats,
|
|
12
|
+
SymbolTable,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import { buildCoreSnapshotFromParsed } from "./snapshot.js";
|
|
15
|
+
import {
|
|
16
|
+
mergeSymbolTables,
|
|
17
|
+
pushSymbolTableDiagnostics,
|
|
18
|
+
} from "./symbol-table.js";
|
|
19
|
+
import { collectCircularTemplateDiagnostics } from "./template-cycles.js";
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_CROSS_FILE_MAX_DEPTH = 16;
|
|
22
|
+
export const DEFAULT_CROSS_FILE_MAX_FILES = 256;
|
|
23
|
+
|
|
24
|
+
const PARSED_DOCUMENT_CACHE_LIMIT = 512;
|
|
25
|
+
const parsedDocumentCache = new Map<
|
|
26
|
+
string,
|
|
27
|
+
{ text: string; parsed: ParsedBirdDocument }
|
|
28
|
+
>();
|
|
29
|
+
|
|
30
|
+
const DEFAULT_RANGE = {
|
|
31
|
+
line: 1,
|
|
32
|
+
column: 1,
|
|
33
|
+
endLine: 1,
|
|
34
|
+
endColumn: 1,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const isFileUri = (uri: string): boolean => uri.startsWith("file://");
|
|
38
|
+
|
|
39
|
+
const toFilePath = (uri: string): string | null => {
|
|
40
|
+
if (isFileUri(uri)) {
|
|
41
|
+
return fileURLToPath(uri);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (uri.startsWith("/")) {
|
|
45
|
+
return uri;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const normalizeUriForPrefixMatch = (uri: string): string =>
|
|
52
|
+
uri.replace(/\/+$/, "");
|
|
53
|
+
|
|
54
|
+
const toDefaultWorkspaceRootUri = (entryUri: string): string => {
|
|
55
|
+
const entryPath = toFilePath(entryUri);
|
|
56
|
+
if (entryPath) {
|
|
57
|
+
return pathToFileURL(dirname(entryPath)).toString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return normalize(dirname(entryUri));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isWithinWorkspaceRoot = (
|
|
64
|
+
candidateUri: string,
|
|
65
|
+
workspaceRootUri: string,
|
|
66
|
+
): boolean => {
|
|
67
|
+
const candidatePath = toFilePath(candidateUri);
|
|
68
|
+
const workspaceRootPath = toFilePath(workspaceRootUri);
|
|
69
|
+
|
|
70
|
+
if (candidatePath && workspaceRootPath) {
|
|
71
|
+
const resolvedRoot = normalize(workspaceRootPath);
|
|
72
|
+
const resolvedCandidate = normalize(candidatePath);
|
|
73
|
+
const relPath = relative(resolvedRoot, resolvedCandidate);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
relPath.length === 0 ||
|
|
77
|
+
(!relPath.startsWith("..") && !isAbsolute(relPath))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const normalizedRoot = normalizeUriForPrefixMatch(
|
|
82
|
+
normalize(workspaceRootUri),
|
|
83
|
+
);
|
|
84
|
+
const normalizedCandidate = normalizeUriForPrefixMatch(
|
|
85
|
+
normalize(candidateUri),
|
|
86
|
+
);
|
|
87
|
+
return (
|
|
88
|
+
normalizedCandidate === normalizedRoot ||
|
|
89
|
+
normalizedCandidate.startsWith(`${normalizedRoot}/`)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const defaultReadFileText = async (uri: string): Promise<string> => {
|
|
94
|
+
const filePath = toFilePath(uri);
|
|
95
|
+
if (!filePath) {
|
|
96
|
+
throw new Error(`Unsupported non-file URI '${uri}'`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return readFile(filePath, "utf8");
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolveIncludeUri = (baseUri: string, includePath: string): string => {
|
|
103
|
+
if (includePath.startsWith("file://")) {
|
|
104
|
+
return pathToFileURL(normalize(fileURLToPath(includePath))).toString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isFileUri(baseUri)) {
|
|
108
|
+
const basePath = fileURLToPath(baseUri);
|
|
109
|
+
const resolvedPath = resolve(dirname(basePath), includePath);
|
|
110
|
+
return pathToFileURL(resolvedPath).toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return normalize(resolve(dirname(baseUri), includePath));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const includeDiagnostic = (
|
|
117
|
+
uri: string,
|
|
118
|
+
message: string,
|
|
119
|
+
range: SourceRange = DEFAULT_RANGE,
|
|
120
|
+
): BirdDiagnostic => ({
|
|
121
|
+
code: "semantic/missing-include",
|
|
122
|
+
message,
|
|
123
|
+
severity: "warning",
|
|
124
|
+
source: "core",
|
|
125
|
+
uri,
|
|
126
|
+
range: {
|
|
127
|
+
line: range.line,
|
|
128
|
+
column: range.column,
|
|
129
|
+
endLine: range.endLine,
|
|
130
|
+
endColumn: range.endColumn,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const dedupeDiagnostics = (diagnostics: BirdDiagnostic[]): BirdDiagnostic[] => {
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
const output: BirdDiagnostic[] = [];
|
|
137
|
+
|
|
138
|
+
for (const diagnostic of diagnostics) {
|
|
139
|
+
const key = [
|
|
140
|
+
diagnostic.code,
|
|
141
|
+
diagnostic.message,
|
|
142
|
+
diagnostic.uri ?? "",
|
|
143
|
+
diagnostic.range.line,
|
|
144
|
+
diagnostic.range.column,
|
|
145
|
+
diagnostic.range.endLine,
|
|
146
|
+
diagnostic.range.endColumn,
|
|
147
|
+
].join(":");
|
|
148
|
+
|
|
149
|
+
if (seen.has(key)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
seen.add(key);
|
|
154
|
+
output.push(diagnostic);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return output;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const parseDocumentWithCache = async (
|
|
161
|
+
uri: string,
|
|
162
|
+
text: string,
|
|
163
|
+
stats: CrossFileResolutionStats,
|
|
164
|
+
): Promise<ParsedBirdDocument> => {
|
|
165
|
+
const cached = parsedDocumentCache.get(uri);
|
|
166
|
+
if (cached && cached.text === text) {
|
|
167
|
+
stats.parsedCacheHits += 1;
|
|
168
|
+
return cached.parsed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
stats.parsedCacheMisses += 1;
|
|
172
|
+
const parsed = await parseBirdConfig(text);
|
|
173
|
+
|
|
174
|
+
if (parsedDocumentCache.size >= PARSED_DOCUMENT_CACHE_LIMIT) {
|
|
175
|
+
const oldestKey = parsedDocumentCache.keys().next().value;
|
|
176
|
+
if (oldestKey) {
|
|
177
|
+
parsedDocumentCache.delete(oldestKey);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parsedDocumentCache.set(uri, { text, parsed });
|
|
182
|
+
return parsed;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
interface QueueItem {
|
|
186
|
+
uri: string;
|
|
187
|
+
depth: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const resolveCrossFileReferences = async (
|
|
191
|
+
options: CrossFileResolveOptions,
|
|
192
|
+
): Promise<CrossFileResolutionResult> => {
|
|
193
|
+
const maxDepth = options.maxDepth ?? DEFAULT_CROSS_FILE_MAX_DEPTH;
|
|
194
|
+
const maxFiles = options.maxFiles ?? DEFAULT_CROSS_FILE_MAX_FILES;
|
|
195
|
+
const loadFromFileSystem = options.loadFromFileSystem ?? true;
|
|
196
|
+
const readFileText = options.readFileText ?? defaultReadFileText;
|
|
197
|
+
const workspaceRootUri =
|
|
198
|
+
options.workspaceRootUri ?? toDefaultWorkspaceRootUri(options.entryUri);
|
|
199
|
+
const allowIncludeOutsideWorkspace =
|
|
200
|
+
options.allowIncludeOutsideWorkspace ?? false;
|
|
201
|
+
|
|
202
|
+
const stats: CrossFileResolutionStats = {
|
|
203
|
+
loadedFromMemory: options.documents?.length ?? 0,
|
|
204
|
+
loadedFromFileSystem: 0,
|
|
205
|
+
skippedByDepth: 0,
|
|
206
|
+
skippedByFileLimit: 0,
|
|
207
|
+
missingIncludes: 0,
|
|
208
|
+
parsedCacheHits: 0,
|
|
209
|
+
parsedCacheMisses: 0,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const documentMap = new Map(
|
|
213
|
+
(options.documents ?? []).map((document) => [
|
|
214
|
+
document.uri,
|
|
215
|
+
{ uri: document.uri, text: document.text },
|
|
216
|
+
]),
|
|
217
|
+
);
|
|
218
|
+
const parsedDocuments = new Map<string, ParsedBirdDocument>();
|
|
219
|
+
const snapshots: Record<string, CoreSnapshot> = {};
|
|
220
|
+
const queue: QueueItem[] = [{ uri: options.entryUri, depth: 0 }];
|
|
221
|
+
const queued = new Set<string>([options.entryUri]);
|
|
222
|
+
const visited = new Set<string>();
|
|
223
|
+
const diagnostics: BirdDiagnostic[] = [];
|
|
224
|
+
|
|
225
|
+
const ensureDocument = async (uri: string): Promise<boolean> => {
|
|
226
|
+
if (documentMap.has(uri)) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!loadFromFileSystem) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const text = await readFileText(uri);
|
|
236
|
+
documentMap.set(uri, { uri, text });
|
|
237
|
+
stats.loadedFromFileSystem += 1;
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (!(await ensureDocument(options.entryUri))) {
|
|
245
|
+
return {
|
|
246
|
+
entryUri: options.entryUri,
|
|
247
|
+
visitedUris: [],
|
|
248
|
+
symbolTable: { definitions: [], references: [] },
|
|
249
|
+
snapshots: {},
|
|
250
|
+
documents: {},
|
|
251
|
+
diagnostics: [
|
|
252
|
+
includeDiagnostic(
|
|
253
|
+
options.entryUri,
|
|
254
|
+
`Entry file not found or not readable: '${options.entryUri}'`,
|
|
255
|
+
),
|
|
256
|
+
],
|
|
257
|
+
stats,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
while (queue.length > 0) {
|
|
262
|
+
const current = queue.shift();
|
|
263
|
+
if (!current) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (visited.has(current.uri)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (current.depth > maxDepth) {
|
|
272
|
+
stats.skippedByDepth += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (visited.size >= maxFiles) {
|
|
277
|
+
stats.skippedByFileLimit += 1;
|
|
278
|
+
diagnostics.push(
|
|
279
|
+
includeDiagnostic(
|
|
280
|
+
current.uri,
|
|
281
|
+
`Cross-file analysis stopped after reaching max files limit (${maxFiles})`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
visited.add(current.uri);
|
|
288
|
+
|
|
289
|
+
const document = documentMap.get(current.uri);
|
|
290
|
+
if (!document) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const parsed = await parseDocumentWithCache(
|
|
295
|
+
current.uri,
|
|
296
|
+
document.text,
|
|
297
|
+
stats,
|
|
298
|
+
);
|
|
299
|
+
parsedDocuments.set(current.uri, parsed);
|
|
300
|
+
snapshots[current.uri] = buildCoreSnapshotFromParsed(parsed, {
|
|
301
|
+
uri: current.uri,
|
|
302
|
+
typeCheck: options.typeCheck,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
for (const declaration of parsed.program.declarations) {
|
|
306
|
+
if (declaration.kind !== "include" || declaration.path.length === 0) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const includeUri = resolveIncludeUri(current.uri, declaration.path);
|
|
311
|
+
const includeRange = declaration.pathRange;
|
|
312
|
+
|
|
313
|
+
if (
|
|
314
|
+
!allowIncludeOutsideWorkspace &&
|
|
315
|
+
!isWithinWorkspaceRoot(includeUri, workspaceRootUri)
|
|
316
|
+
) {
|
|
317
|
+
diagnostics.push(
|
|
318
|
+
includeDiagnostic(
|
|
319
|
+
current.uri,
|
|
320
|
+
`Include skipped outside workspace root '${workspaceRootUri}': '${declaration.path}'`,
|
|
321
|
+
includeRange,
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (visited.has(includeUri) || queued.has(includeUri)) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (current.depth + 1 > maxDepth) {
|
|
332
|
+
stats.skippedByDepth += 1;
|
|
333
|
+
diagnostics.push(
|
|
334
|
+
includeDiagnostic(
|
|
335
|
+
current.uri,
|
|
336
|
+
`Include skipped due to max depth (${maxDepth}): '${declaration.path}'`,
|
|
337
|
+
includeRange,
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (visited.size + queue.length >= maxFiles) {
|
|
344
|
+
stats.skippedByFileLimit += 1;
|
|
345
|
+
diagnostics.push(
|
|
346
|
+
includeDiagnostic(
|
|
347
|
+
current.uri,
|
|
348
|
+
`Include skipped due to max files limit (${maxFiles}): '${declaration.path}'`,
|
|
349
|
+
includeRange,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const loaded = await ensureDocument(includeUri);
|
|
356
|
+
if (!loaded) {
|
|
357
|
+
stats.missingIncludes += 1;
|
|
358
|
+
diagnostics.push(
|
|
359
|
+
includeDiagnostic(
|
|
360
|
+
current.uri,
|
|
361
|
+
`Included file not found in workspace: '${declaration.path}'`,
|
|
362
|
+
includeRange,
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
queue.push({ uri: includeUri, depth: current.depth + 1 });
|
|
369
|
+
queued.add(includeUri);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const mergedSymbolTable: SymbolTable = mergeSymbolTables(
|
|
374
|
+
Object.values(snapshots).map((snapshot) => snapshot.symbolTable),
|
|
375
|
+
);
|
|
376
|
+
pushSymbolTableDiagnostics(mergedSymbolTable, diagnostics);
|
|
377
|
+
|
|
378
|
+
for (const [uri, snapshot] of Object.entries(snapshots)) {
|
|
379
|
+
for (const diagnostic of snapshot.diagnostics) {
|
|
380
|
+
if (
|
|
381
|
+
diagnostic.code === "semantic/duplicate-definition" ||
|
|
382
|
+
diagnostic.code === "semantic/undefined-reference" ||
|
|
383
|
+
diagnostic.code === "semantic/circular-template"
|
|
384
|
+
) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
diagnostics.push({
|
|
389
|
+
...diagnostic,
|
|
390
|
+
uri,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
diagnostics.push(
|
|
396
|
+
...collectCircularTemplateDiagnostics(
|
|
397
|
+
[...parsedDocuments.entries()].map(([uri, parsed]) => ({ uri, parsed })),
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
entryUri: options.entryUri,
|
|
403
|
+
visitedUris: [...visited],
|
|
404
|
+
symbolTable: mergedSymbolTable,
|
|
405
|
+
snapshots,
|
|
406
|
+
documents: Object.fromEntries(
|
|
407
|
+
[...documentMap.entries()].map(([uri, document]) => [uri, document.text]),
|
|
408
|
+
),
|
|
409
|
+
diagnostics: dedupeDiagnostics(diagnostics),
|
|
410
|
+
stats,
|
|
411
|
+
};
|
|
412
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseBirdConfig } from "@birdcc/parser";
|
|
2
|
+
import { resolveCrossFileReferences } from "./cross-file.js";
|
|
3
|
+
import { buildCoreSnapshotFromParsed } from "./snapshot.js";
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
BirdDiagnostic,
|
|
7
|
+
BirdDiagnosticSeverity,
|
|
8
|
+
BirdRange,
|
|
9
|
+
BirdSymbolKind,
|
|
10
|
+
CoreSnapshot,
|
|
11
|
+
CrossFileDocumentInput,
|
|
12
|
+
CrossFileResolveOptions,
|
|
13
|
+
CrossFileResolutionResult,
|
|
14
|
+
CrossFileResolutionStats,
|
|
15
|
+
SymbolDefinition,
|
|
16
|
+
SymbolReference,
|
|
17
|
+
SymbolTable,
|
|
18
|
+
TypeCheckOptions,
|
|
19
|
+
TypeValue,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
export { checkTypes } from "./type-checker.js";
|
|
23
|
+
export {
|
|
24
|
+
DEFAULT_DOCUMENT_URI,
|
|
25
|
+
buildSymbolTableFromParsed,
|
|
26
|
+
mergeSymbolTables,
|
|
27
|
+
pushSymbolTableDiagnostics,
|
|
28
|
+
} from "./symbol-table.js";
|
|
29
|
+
export { buildCoreSnapshotFromParsed } from "./snapshot.js";
|
|
30
|
+
export {
|
|
31
|
+
DEFAULT_CROSS_FILE_MAX_DEPTH,
|
|
32
|
+
DEFAULT_CROSS_FILE_MAX_FILES,
|
|
33
|
+
resolveCrossFileReferences,
|
|
34
|
+
} from "./cross-file.js";
|
|
35
|
+
|
|
36
|
+
/** Parses and builds semantic snapshot in one async call. */
|
|
37
|
+
export const buildCoreSnapshot = async (text: string) => {
|
|
38
|
+
const parsed = await parseBirdConfig(text);
|
|
39
|
+
return buildCoreSnapshotFromParsed(parsed);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ResolveCrossFileReferences = typeof resolveCrossFileReferences;
|
package/src/prefix.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { parse as parseCidr } from "fast-cidr-tools";
|
|
3
|
+
|
|
4
|
+
const parsePrefixRange = (
|
|
5
|
+
suffix: string,
|
|
6
|
+
): { min: number; max: number } | null => {
|
|
7
|
+
if (!suffix.startsWith("{") || !suffix.endsWith("}")) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const inner = suffix.slice(1, -1);
|
|
12
|
+
const commaIndex = inner.indexOf(",");
|
|
13
|
+
if (commaIndex <= 0 || commaIndex >= inner.length - 1) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const min = Number(inner.slice(0, commaIndex));
|
|
18
|
+
const max = Number(inner.slice(commaIndex + 1));
|
|
19
|
+
|
|
20
|
+
if (!Number.isInteger(min) || !Number.isInteger(max)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { min, max };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const isValidPrefixLiteral = (literal: string): boolean => {
|
|
28
|
+
let value = literal.trim();
|
|
29
|
+
if (value.length === 0) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let range: { min: number; max: number } | null = null;
|
|
34
|
+
|
|
35
|
+
if (value.endsWith("+")) {
|
|
36
|
+
value = value.slice(0, -1);
|
|
37
|
+
} else if (value.endsWith("-")) {
|
|
38
|
+
value = value.slice(0, -1);
|
|
39
|
+
} else if (value.endsWith("}")) {
|
|
40
|
+
const braceStart = value.lastIndexOf("{");
|
|
41
|
+
if (braceStart === -1) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
range = parsePrefixRange(value.slice(braceStart));
|
|
46
|
+
if (!range) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
value = value.slice(0, braceStart);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const slashIndex = value.lastIndexOf("/");
|
|
54
|
+
if (slashIndex <= 0 || slashIndex >= value.length - 1) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ipPart = value.slice(0, slashIndex);
|
|
59
|
+
const prefixPart = value.slice(slashIndex + 1);
|
|
60
|
+
const prefix = Number(prefixPart);
|
|
61
|
+
|
|
62
|
+
if (!Number.isInteger(prefix)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const version = isIP(ipPart);
|
|
67
|
+
if (version === 0) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const maxBits = version === 4 ? 32 : 128;
|
|
72
|
+
if (prefix < 0 || prefix > maxBits) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (range) {
|
|
77
|
+
if (
|
|
78
|
+
range.min < prefix ||
|
|
79
|
+
range.min > maxBits ||
|
|
80
|
+
range.max < range.min ||
|
|
81
|
+
range.max > maxBits
|
|
82
|
+
) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
void parseCidr(value);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
};
|
package/src/range.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { isIP, isIPv4 } from "node:net";
|
|
2
|
+
import type { ParsedBirdDocument } from "@birdcc/parser";
|
|
3
|
+
import type { BirdDiagnostic } from "./types.js";
|
|
4
|
+
import { isValidPrefixLiteral } from "./prefix.js";
|
|
5
|
+
|
|
6
|
+
export const collectSemanticDiagnostics = (
|
|
7
|
+
parsed: ParsedBirdDocument,
|
|
8
|
+
): BirdDiagnostic[] => {
|
|
9
|
+
const diagnostics: BirdDiagnostic[] = [];
|
|
10
|
+
|
|
11
|
+
for (const declaration of parsed.program.declarations) {
|
|
12
|
+
if (declaration.kind === "router-id") {
|
|
13
|
+
const range = declaration.valueRange;
|
|
14
|
+
|
|
15
|
+
if (declaration.valueKind === "ip" && !isIPv4(declaration.value)) {
|
|
16
|
+
diagnostics.push({
|
|
17
|
+
code: "semantic/invalid-router-id",
|
|
18
|
+
message: `Invalid router id '${declaration.value}' (expected IPv4 address)`,
|
|
19
|
+
severity: "error",
|
|
20
|
+
source: "core",
|
|
21
|
+
range,
|
|
22
|
+
});
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
declaration.valueKind === "unknown" &&
|
|
28
|
+
declaration.value.length > 0 &&
|
|
29
|
+
declaration.value.toLowerCase() !== "from routing" &&
|
|
30
|
+
declaration.value.toLowerCase() !== "from dynamic"
|
|
31
|
+
) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
code: "semantic/invalid-router-id",
|
|
34
|
+
message: `Invalid router id value '${declaration.value}'`,
|
|
35
|
+
severity: "error",
|
|
36
|
+
source: "core",
|
|
37
|
+
range,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (declaration.kind === "protocol") {
|
|
45
|
+
for (const statement of declaration.statements) {
|
|
46
|
+
if (statement.kind !== "neighbor" || statement.addressKind !== "ip") {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isIP(statement.address) === 0) {
|
|
51
|
+
diagnostics.push({
|
|
52
|
+
code: "semantic/invalid-neighbor-address",
|
|
53
|
+
message: `Invalid neighbor address '${statement.address}'`,
|
|
54
|
+
severity: "error",
|
|
55
|
+
source: "core",
|
|
56
|
+
range: statement.addressRange,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (declaration.kind !== "filter" && declaration.kind !== "function") {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const literal of declaration.literals) {
|
|
69
|
+
if (literal.kind !== "prefix") {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isValidPrefixLiteral(literal.value)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
code: "semantic/invalid-cidr",
|
|
79
|
+
message: `Invalid CIDR/prefix literal '${literal.value}'`,
|
|
80
|
+
severity: "error",
|
|
81
|
+
source: "core",
|
|
82
|
+
range: {
|
|
83
|
+
line: literal.line,
|
|
84
|
+
column: literal.column,
|
|
85
|
+
endLine: literal.endLine,
|
|
86
|
+
endColumn: literal.endColumn,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return diagnostics;
|
|
93
|
+
};
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ParsedBirdDocument } from "@birdcc/parser";
|
|
2
|
+
import type { CoreSnapshot, TypeCheckOptions } from "./types.js";
|
|
3
|
+
import {
|
|
4
|
+
buildSymbolTableFromParsed,
|
|
5
|
+
pushSymbolTableDiagnostics,
|
|
6
|
+
} from "./symbol-table.js";
|
|
7
|
+
import { collectSemanticDiagnostics } from "./semantic-diagnostics.js";
|
|
8
|
+
import { collectCircularTemplateDiagnostics } from "./template-cycles.js";
|
|
9
|
+
import { checkTypes } from "./type-checker.js";
|
|
10
|
+
|
|
11
|
+
export const buildCoreSnapshotFromParsed = (
|
|
12
|
+
parsed: ParsedBirdDocument,
|
|
13
|
+
options: { uri?: string; typeCheck?: TypeCheckOptions } = {},
|
|
14
|
+
): CoreSnapshot => {
|
|
15
|
+
const symbolTable = buildSymbolTableFromParsed(parsed, { uri: options.uri });
|
|
16
|
+
const diagnostics = collectSemanticDiagnostics(parsed);
|
|
17
|
+
diagnostics.push(...collectCircularTemplateDiagnostics([parsed]));
|
|
18
|
+
pushSymbolTableDiagnostics(symbolTable, diagnostics);
|
|
19
|
+
|
|
20
|
+
const typeDiagnostics = checkTypes(parsed.program, symbolTable, {
|
|
21
|
+
...options.typeCheck,
|
|
22
|
+
uri: options.uri,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
symbols: symbolTable.definitions,
|
|
27
|
+
references: symbolTable.references,
|
|
28
|
+
symbolTable,
|
|
29
|
+
typeDiagnostics,
|
|
30
|
+
diagnostics: [...diagnostics, ...typeDiagnostics],
|
|
31
|
+
};
|
|
32
|
+
};
|