@xdbml/parse 0.1.0-poc.1

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.
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Module-system support utilities.
3
+ *
4
+ * Per parser-design v2, the parser produces a provenance-preserving
5
+ * "Shape B" AST: `ModuleImportDirective` nodes keep imported declarations
6
+ * inside their `clone.statements` field rather than splicing them into the
7
+ * parent's statement list. This preserves the information about which file
8
+ * each declaration came from, useful for navigation, inspector panels,
9
+ * round-tripping back to source text, etc.
10
+ *
11
+ * Many downstream consumers (code generators, diagram renderers, simple
12
+ * walkers) just want a flat list of declarations and don't care about
13
+ * provenance. The `flatten()` helper produces a new XDbmlDocument where
14
+ * `ModuleImportDirective` nodes have been replaced by their `clone.statements`,
15
+ * recursively.
16
+ *
17
+ * Currently P4-only: handles clone blocks. Reference-only directives are
18
+ * rejected at parse time, so `flatten()` doesn't need to do file resolution.
19
+ * In P5, a separate `resolveModules()` function will populate clone blocks
20
+ * from referenced files before `flatten()` runs.
21
+ */
22
+ import { SCALAR_TYPES, BSON_TYPES } from "./keywords.js";
23
+ /**
24
+ * Produce a new XDbmlDocument with all module-system directives replaced
25
+ * by their clone-block content, recursively.
26
+ *
27
+ * At the top level, each `ModuleImportDirective` is replaced by its
28
+ * `clone.statements` (each statement appears at the same position the
29
+ * directive used to occupy).
30
+ *
31
+ * Inside a Container body, each `ModuleImportDirective` is replaced by its
32
+ * `clone.statements` as `ContainerBodyItem`s. Note that the spec table in
33
+ * §26.6 guarantees that clone-block content for entity/edge/view/enum
34
+ * imports is shape-compatible with `ContainerBodyItem`; other shapes
35
+ * (e.g., a TablePartial clone inside a Container directive) would be
36
+ * semantically invalid per the spec and would surface as a downstream
37
+ * type error rather than being caught here.
38
+ *
39
+ * Field-level imports (spec §26.8) get a special transform: the clone
40
+ * block holds a bare `FieldDeclaration`, which `flatten()` lifts into a
41
+ * synthetic `TypeDeclaration` at file scope. Downstream consumers see
42
+ * a normal Named Type and can use it as a field type without learning
43
+ * about the field-import construct.
44
+ *
45
+ * Provenance information is lost in the flattened view. Consumers that
46
+ * want to know where each declaration came from should walk the original
47
+ * (non-flattened) AST instead.
48
+ */
49
+ export function flatten(doc) {
50
+ const statements = [];
51
+ for (const stmt of doc.statements) {
52
+ flattenTopLevel(stmt, statements);
53
+ }
54
+ return {
55
+ ...doc,
56
+ statements,
57
+ };
58
+ }
59
+ function flattenTopLevel(stmt, out) {
60
+ if (stmt.kind === 'FieldDeclaration') {
61
+ // A bare FieldDeclaration only appears at the top level via the field-
62
+ // import path -- the parser only accepts it inside a clone block. Lift
63
+ // it to a synthetic TypeDeclaration so the name behaves like a Named
64
+ // Type for any downstream consumer (resolver, code generator, etc).
65
+ out.push(synthesizeTypeFromField(stmt));
66
+ return;
67
+ }
68
+ if (stmt.kind === 'ModuleImportDirective') {
69
+ // Replace the directive with its clone-block content. Each inner
70
+ // statement is itself flattened (a clone may itself contain Containers
71
+ // with nested directives, although the spec doesn't currently expect
72
+ // multi-level nesting in v0.2 phase 1).
73
+ if (stmt.clone) {
74
+ for (const inner of stmt.clone.statements) {
75
+ flattenTopLevel(inner, out);
76
+ }
77
+ }
78
+ return;
79
+ }
80
+ if (stmt.kind === 'ContainerDeclaration') {
81
+ out.push(flattenContainer(stmt));
82
+ return;
83
+ }
84
+ // All other top-level statement kinds pass through unchanged.
85
+ out.push(stmt);
86
+ }
87
+ /**
88
+ * Lift a bare FieldDeclaration (from a field-import clone block) into a
89
+ * synthetic TypeDeclaration. The synthesized Type takes the field's name
90
+ * (or alias, since the clone block already has the post-alias name) and
91
+ * its full settings array.
92
+ *
93
+ * - For SCALAR fields (any TypeExpression that isn't ObjectType): the
94
+ * Type is in scalar form with `scalarBase = field.type`. Equivalent
95
+ * to the v0.2 scalar-Named-Type form (spec §14.7).
96
+ *
97
+ * - For OBJECT-typed fields (`field foo object { ... }`): the Type is
98
+ * in object form with `body = field.type.fields`. Equivalent to the
99
+ * v0.1 object-Named-Type form (spec §14).
100
+ *
101
+ * Other type shapes (Array, Map, Set, Tuple, Union, polymorphic types)
102
+ * are valid as `scalarBase` of a Type and pass through unchanged. The
103
+ * "scalar" in `scalarBase` is a historical name; functionally it means
104
+ * "the base TypeExpression this Named Type aliases."
105
+ */
106
+ function synthesizeTypeFromField(field) {
107
+ if (field.type.kind === 'ObjectType') {
108
+ return {
109
+ kind: 'TypeDeclaration',
110
+ name: field.name,
111
+ scalarBase: undefined,
112
+ settings: field.settings,
113
+ body: field.type.fields,
114
+ span: field.span,
115
+ };
116
+ }
117
+ return {
118
+ kind: 'TypeDeclaration',
119
+ name: field.name,
120
+ scalarBase: field.type,
121
+ settings: field.settings,
122
+ body: [],
123
+ span: field.span,
124
+ };
125
+ }
126
+ function flattenContainer(c) {
127
+ const body = [];
128
+ for (const item of c.body) {
129
+ flattenContainerBodyItem(item, body);
130
+ }
131
+ return {
132
+ ...c,
133
+ body,
134
+ };
135
+ }
136
+ function flattenContainerBodyItem(item, out) {
137
+ if (item.kind === 'ModuleImportDirective') {
138
+ if (item.clone) {
139
+ // Each cloned statement becomes a ContainerBodyItem in the parent's
140
+ // body. The type narrowing is technically unsafe -- the spec implies
141
+ // that only EntityDeclaration, EdgeDeclaration, ViewDeclaration,
142
+ // EnumDeclaration, and NoteBlock can validly appear in a Container's
143
+ // clone block, but the parser is permissive about what was put there.
144
+ // Downstream consumers handle the type-mismatch case.
145
+ for (const inner of item.clone.statements) {
146
+ // `inner` is TopLevelStatement; we re-narrow at use site.
147
+ out.push(inner);
148
+ }
149
+ }
150
+ return;
151
+ }
152
+ out.push(item);
153
+ }
154
+ /**
155
+ * Resolve a directive's reference. Returns an `ImportResolution` indicating
156
+ * what happened. The caller decides how to act (set `clone`, fail, etc).
157
+ *
158
+ * Path resolution: the directive's `from` is relative to the importer's
159
+ * `filePath`. If `from` doesn't end with `.xdbml`, that extension is
160
+ * appended. Returns the absolute resolved path on success.
161
+ *
162
+ * Cycles: if the resolved path is already in `resolutionStack`, returns
163
+ * `kind: 'cycle'` without recursing.
164
+ *
165
+ * Depth: if `depth + 1 > maxDepth`, throws.
166
+ */
167
+ export function resolveImport(directive, options, resolutionStack, depth, parseFn) {
168
+ if (!options.readFile) {
169
+ return { kind: 'no-resolver' };
170
+ }
171
+ const resolvedPath = resolveModulePath(directive.from, options.filePath);
172
+ if (resolutionStack.has(resolvedPath)) {
173
+ return { kind: 'cycle', resolvedPath };
174
+ }
175
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
176
+ if (depth + 1 > maxDepth) {
177
+ throw new Error(`Module resolution depth limit (${maxDepth}) exceeded while resolving '${directive.from}' ` +
178
+ `from '${options.filePath ?? '<no filePath>'}'. ` +
179
+ `Increase maxDepth in ParseOptions if your module graph is genuinely this deep.`);
180
+ }
181
+ let referencedSource;
182
+ try {
183
+ referencedSource = options.readFile(resolvedPath);
184
+ }
185
+ catch (e) {
186
+ const inner = e.message ?? String(e);
187
+ throw new Error(`Failed to read referenced module file '${resolvedPath}' ` +
188
+ `(directive '${directive.mode} { ... } from \"${directive.from}\"' in '${options.filePath ?? '<no filePath>'}'). ` +
189
+ `Cause: ${inner}`);
190
+ }
191
+ // Recursively parse the referenced file. We pass the SAME options, but
192
+ // with `filePath` updated to the resolved path so any directives inside
193
+ // that file resolve relative to it. The resolution stack is widened to
194
+ // include this file, so cycles get caught.
195
+ const nextStack = new Set(resolutionStack);
196
+ nextStack.add(resolvedPath);
197
+ const nextOptions = { ...options, filePath: resolvedPath };
198
+ let referencedDoc;
199
+ try {
200
+ referencedDoc = parseFn(referencedSource, nextOptions, nextStack, depth + 1);
201
+ }
202
+ catch (e) {
203
+ const inner = e.message ?? String(e);
204
+ throw new Error(`Error while parsing referenced module file '${resolvedPath}' ` +
205
+ `(via directive '${directive.mode} { ... } from \"${directive.from}\"' in '${options.filePath ?? '<no filePath>'}'). ` +
206
+ `Cause: ${inner}`);
207
+ }
208
+ // Extract the declarations matching the directive's import spec.
209
+ const statements = extractImports(directive.spec, referencedDoc);
210
+ // Synthesize the clone block. The span uses the directive's own span as
211
+ // a placeholder; we don't have precise source positions for synthesized
212
+ // content, and downstream consumers that care about spans (Monaco
213
+ // markers, "go to definition") look at the inner declarations' own
214
+ // spans, which point into the referenced file. Spans on the wrapper
215
+ // CloneBlock node itself are rarely consulted.
216
+ const clone = {
217
+ kind: 'CloneBlock',
218
+ statements,
219
+ span: directive.span,
220
+ };
221
+ return { kind: 'resolved', clone, resolvedPath };
222
+ }
223
+ /**
224
+ * Walk the parsed source document and pick out declarations matching the
225
+ * import spec. Applies aliases by renaming the extracted declaration.
226
+ *
227
+ * For `ImportAll`: every TopLevelStatement except ProjectDeclaration
228
+ * (per spec §26.4, Project declarations cannot be imported).
229
+ *
230
+ * For `ImportList`: each item is matched by element-type + source path.
231
+ * Items not found in the source produce nothing (silent for P5; a
232
+ * future name-resolution pass should diagnose unresolved imports).
233
+ */
234
+ function extractImports(spec, sourceDoc) {
235
+ if (spec.kind === 'ImportAll') {
236
+ // ImportAll never matches field imports (the `*` form is for top-level
237
+ // declarations only per spec §26.2). Return the source's top-level
238
+ // statements minus Project.
239
+ return sourceDoc.statements
240
+ .filter((s) => s.kind !== 'ProjectDeclaration')
241
+ .map((s) => s); // shallow-clone-able; we don't mutate them
242
+ }
243
+ // ImportList: process each item.
244
+ const out = [];
245
+ for (const item of spec.items) {
246
+ const found = findImportTarget(item, sourceDoc);
247
+ if (found) {
248
+ out.push(applyAlias(found, item));
249
+ }
250
+ // Silent skip on not-found. The spec leaves diagnostics to name
251
+ // resolution (§26.13). In practice, users notice missing entities
252
+ // because their references downstream become unresolved.
253
+ }
254
+ return out;
255
+ }
256
+ /**
257
+ * Find the declaration matching an import item in the source document.
258
+ * Returns undefined when no match is found.
259
+ *
260
+ * Element-type to lookup:
261
+ * - 'entity' / 'table' / 'collection' / 'record':
262
+ * EntityDeclaration with matching name; supports dotted path
263
+ * like 'core.dim_customer' (Container 'core', Entity 'dim_customer').
264
+ * - 'type':
265
+ * TypeDeclaration with matching name (top-level only; spec doesn't
266
+ * support importing nested Types).
267
+ * - 'enum':
268
+ * EnumDeclaration with matching name; supports dotted path for
269
+ * container-scoped enums.
270
+ * - 'container' / 'schema':
271
+ * ContainerDeclaration with matching name (the whole container,
272
+ * including all its body items).
273
+ * - 'edge':
274
+ * EdgeDeclaration with matching name; supports dotted path.
275
+ * - 'view' / 'diagramview':
276
+ * ViewDeclaration with matching name; supports dotted path.
277
+ * - 'tablegroup':
278
+ * TableGroupDeclaration with matching name (top-level only).
279
+ * - 'tablepartial':
280
+ * TablePartialDeclaration with matching name (top-level only).
281
+ * - 'note':
282
+ * NoteDeclaration with matching name (top-level only).
283
+ * - 'field':
284
+ * FieldDeclaration found by walking a dotted path through
285
+ * containers, entities, and nested type expressions (object types,
286
+ * arrays via [*]/[N], maps via ['key'], tuples via [N]). Returned
287
+ * as a bare FieldDeclaration; the caller wraps it in a CloneBlock
288
+ * and `flatten()` later lifts it to a synthetic TypeDeclaration.
289
+ *
290
+ * The return type is widened to include `FieldDeclaration` solely for
291
+ * the field-import case; for every other element type the returned shape
292
+ * is a `TopLevelStatement`.
293
+ */
294
+ function findImportTarget(item, doc) {
295
+ const path = item.sourcePath;
296
+ const segments = path.split('.');
297
+ // Field imports go through their own walker. The path can be deeper
298
+ // than `container.entity.field` -- the walker handles nested objects,
299
+ // array wildcards, etc.
300
+ if (item.elementType === 'field') {
301
+ return findFieldTarget(segments, doc);
302
+ }
303
+ // Entity-shaped items: support container.entity dotted form.
304
+ if (item.elementType === 'entity' ||
305
+ item.elementType === 'table' ||
306
+ item.elementType === 'collection' ||
307
+ item.elementType === 'record') {
308
+ if (segments.length === 1) {
309
+ // Bare name -- match a top-level entity OR a container-scoped entity
310
+ // whose bare name is unique.
311
+ const topLevel = doc.statements.find((s) => s.kind === 'EntityDeclaration' && s.name === segments[0]);
312
+ if (topLevel)
313
+ return topLevel;
314
+ // Look inside containers.
315
+ const matches = [];
316
+ for (const stmt of doc.statements) {
317
+ if (stmt.kind === 'ContainerDeclaration') {
318
+ for (const body of stmt.body) {
319
+ if (body.kind === 'EntityDeclaration' && body.name === segments[0]) {
320
+ matches.push(body);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ if (matches.length === 1)
326
+ return matches[0];
327
+ // Ambiguous or missing -- silent skip.
328
+ return undefined;
329
+ }
330
+ if (segments.length === 2) {
331
+ // container.entity
332
+ const container = doc.statements.find((s) => s.kind === 'ContainerDeclaration' && s.name === segments[0]);
333
+ if (!container || container.kind !== 'ContainerDeclaration')
334
+ return undefined;
335
+ const entity = container.body.find((b) => b.kind === 'EntityDeclaration' && b.name === segments[1]);
336
+ return entity && entity.kind === 'EntityDeclaration' ? entity : undefined;
337
+ }
338
+ return undefined;
339
+ }
340
+ // Type, TableGroup, TablePartial, Note: top-level only, bare name.
341
+ if (item.elementType === 'type') {
342
+ return doc.statements.find((s) => s.kind === 'TypeDeclaration' && s.name === path);
343
+ }
344
+ if (item.elementType === 'tablegroup') {
345
+ return doc.statements.find((s) => s.kind === 'TableGroupDeclaration' && s.name === path);
346
+ }
347
+ if (item.elementType === 'tablepartial') {
348
+ return doc.statements.find((s) => s.kind === 'TablePartialDeclaration' && s.name === path);
349
+ }
350
+ if (item.elementType === 'note') {
351
+ return doc.statements.find((s) => s.kind === 'NoteDeclaration' && s.name === path);
352
+ }
353
+ // Container / Schema: top-level, bare name.
354
+ if (item.elementType === 'container' || item.elementType === 'schema') {
355
+ return doc.statements.find((s) => s.kind === 'ContainerDeclaration' && s.name === path);
356
+ }
357
+ // Edge: top-level OR container-scoped.
358
+ if (item.elementType === 'edge') {
359
+ if (segments.length === 1) {
360
+ return doc.statements.find((s) => s.kind === 'EdgeDeclaration' && s.name === segments[0]);
361
+ }
362
+ if (segments.length === 2) {
363
+ const container = doc.statements.find((s) => s.kind === 'ContainerDeclaration' && s.name === segments[0]);
364
+ if (!container || container.kind !== 'ContainerDeclaration')
365
+ return undefined;
366
+ const edge = container.body.find((b) => b.kind === 'EdgeDeclaration' && b.name === segments[1]);
367
+ return edge && edge.kind === 'EdgeDeclaration' ? edge : undefined;
368
+ }
369
+ return undefined;
370
+ }
371
+ // View / DiagramView: top-level OR container-scoped.
372
+ if (item.elementType === 'view' || item.elementType === 'diagramview') {
373
+ if (segments.length === 1) {
374
+ return doc.statements.find((s) => s.kind === 'ViewDeclaration' && s.name === segments[0]);
375
+ }
376
+ if (segments.length === 2) {
377
+ const container = doc.statements.find((s) => s.kind === 'ContainerDeclaration' && s.name === segments[0]);
378
+ if (!container || container.kind !== 'ContainerDeclaration')
379
+ return undefined;
380
+ const view = container.body.find((b) => b.kind === 'ViewDeclaration' && b.name === segments[1]);
381
+ return view && view.kind === 'ViewDeclaration' ? view : undefined;
382
+ }
383
+ return undefined;
384
+ }
385
+ // Enum: top-level OR container-scoped.
386
+ if (item.elementType === 'enum') {
387
+ if (segments.length === 1) {
388
+ return doc.statements.find((s) => s.kind === 'EnumDeclaration' && s.name === segments[0]);
389
+ }
390
+ if (segments.length === 2) {
391
+ const container = doc.statements.find((s) => s.kind === 'ContainerDeclaration' && s.name === segments[0]);
392
+ if (!container || container.kind !== 'ContainerDeclaration')
393
+ return undefined;
394
+ const enm = container.body.find((b) => b.kind === 'EnumDeclaration' && b.name === segments[1]);
395
+ return enm && enm.kind === 'EnumDeclaration' ? enm : undefined;
396
+ }
397
+ return undefined;
398
+ }
399
+ return undefined;
400
+ }
401
+ /**
402
+ * Walk a dotted path through containers, entities, and (optionally) nested
403
+ * object types to find a FieldDeclaration in a referenced source document.
404
+ * Returns undefined when any segment fails to resolve.
405
+ *
406
+ * Path shapes accepted (per spec §26.8):
407
+ * - `entity.field` -- top-level entity
408
+ * - `container.entity.field` -- container-qualified entity
409
+ * - `entity.field.sub` -- nested via ObjectType
410
+ * - `container.entity.field.sub.leaf` -- container + nested
411
+ *
412
+ * Bracketed segments (`[*]`, `[N]`, `['key']`) are not supported because
413
+ * the parser's ImportItem path grammar accepts only dotted identifiers.
414
+ * A field whose source is reached only through an array or map element
415
+ * needs to be re-declared as a Named Type at the source side instead.
416
+ *
417
+ * Named Type dereferencing: when walking a path like
418
+ * `entity.field.subfield` where `field`'s type is a Named Type defined
419
+ * in the source document, the walker looks up the Type and continues
420
+ * the walk through its body. Cycle detection caps recursion at depth 8
421
+ * (deep enough for realistic nesting, shallow enough that a malformed
422
+ * cyclic type declaration can't hang the parser).
423
+ */
424
+ function findFieldTarget(segments, doc) {
425
+ if (segments.length < 2)
426
+ return undefined;
427
+ // -- Step 1: identify the entity and how many leading segments it consumed.
428
+ let entity;
429
+ let entityPrefixLen = 0;
430
+ // Try 2-segment: container.entity (only if we have room for at least
431
+ // one field segment after).
432
+ if (segments.length >= 3) {
433
+ const container = doc.statements.find((s) => s.kind === 'ContainerDeclaration' && s.name === segments[0]);
434
+ if (container && container.kind === 'ContainerDeclaration') {
435
+ const ent = container.body.find((b) => b.kind === 'EntityDeclaration' && b.name === segments[1]);
436
+ if (ent && ent.kind === 'EntityDeclaration') {
437
+ entity = ent;
438
+ entityPrefixLen = 2;
439
+ }
440
+ }
441
+ }
442
+ // Try 1-segment: top-level entity (declared outside any Container).
443
+ if (!entity) {
444
+ const topLevel = doc.statements.find((s) => s.kind === 'EntityDeclaration' && s.name === segments[0]);
445
+ if (topLevel && topLevel.kind === 'EntityDeclaration') {
446
+ entity = topLevel;
447
+ entityPrefixLen = 1;
448
+ }
449
+ }
450
+ // Try 1-segment: bare entity name with unique match across containers.
451
+ // (Ambiguous bare names -- entity X exists in multiple containers --
452
+ // return undefined to force the importer to qualify the path.)
453
+ if (!entity) {
454
+ const matches = [];
455
+ for (const stmt of doc.statements) {
456
+ if (stmt.kind === 'ContainerDeclaration') {
457
+ for (const item of stmt.body) {
458
+ if (item.kind === 'EntityDeclaration' && item.name === segments[0]) {
459
+ matches.push(item);
460
+ }
461
+ }
462
+ }
463
+ }
464
+ if (matches.length === 1) {
465
+ entity = matches[0];
466
+ entityPrefixLen = 1;
467
+ }
468
+ }
469
+ if (!entity)
470
+ return undefined;
471
+ // -- Step 2: find the top-level field on the entity.
472
+ const fieldSegments = segments.slice(entityPrefixLen);
473
+ if (fieldSegments.length === 0)
474
+ return undefined;
475
+ let currentField;
476
+ for (const item of entity.body) {
477
+ if (item.kind === 'FieldDeclaration' && item.name === fieldSegments[0]) {
478
+ currentField = item;
479
+ break;
480
+ }
481
+ }
482
+ if (!currentField)
483
+ return undefined;
484
+ // If no more segments, we're done.
485
+ if (fieldSegments.length === 1)
486
+ return currentField;
487
+ // -- Step 3: walk nested segments through object-typed fields.
488
+ // Build a local Named-Type table from the source doc so the walker
489
+ // can dereference scalar-Named-Type and object-form Named Types when
490
+ // a path crosses a Named Type boundary.
491
+ const typeTable = new Map();
492
+ for (const s of doc.statements) {
493
+ if (s.kind === 'TypeDeclaration')
494
+ typeTable.set(s.name, s);
495
+ }
496
+ return walkObjectFieldPath(currentField, fieldSegments.slice(1), typeTable);
497
+ }
498
+ /**
499
+ * Walk through remaining path segments, each step expecting the current
500
+ * field's type to be an ObjectType (directly or after Named Type deref)
501
+ * and looking up the next segment as a field name in that object.
502
+ */
503
+ function walkObjectFieldPath(startField, remaining, typeTable) {
504
+ let current = startField;
505
+ for (const seg of remaining) {
506
+ const objType = derefToObject(current.type, typeTable, 0);
507
+ if (!objType)
508
+ return undefined;
509
+ let next;
510
+ for (const item of objType.fields) {
511
+ if (item.kind === 'FieldDeclaration' && item.name === seg) {
512
+ next = item;
513
+ break;
514
+ }
515
+ }
516
+ if (!next)
517
+ return undefined;
518
+ current = next;
519
+ }
520
+ return current;
521
+ }
522
+ const BUILTIN_TYPE_NAMES = new Set([
523
+ ...SCALAR_TYPES.map((t) => t.toLowerCase()),
524
+ ...BSON_TYPES.map((t) => t.toLowerCase()),
525
+ ]);
526
+ /**
527
+ * Dereference a TypeExpression to an ObjectType when possible. Returns
528
+ * undefined when the expression is a builtin scalar, an array/map/tuple
529
+ * (which the ImportItem path grammar can't navigate into), or a Named
530
+ * Type that the source document doesn't declare.
531
+ *
532
+ * `depth` guards against cyclic Named-Type chains (e.g., `Type A B`,
533
+ * `Type B A`). Realistic schemas won't approach the limit; the cap is
534
+ * defensive.
535
+ */
536
+ function derefToObject(type, typeTable, depth) {
537
+ if (depth > 8)
538
+ return undefined;
539
+ if (type.kind === 'ObjectType')
540
+ return type;
541
+ if (type.kind !== 'ScalarType')
542
+ return undefined;
543
+ // ScalarType might be a builtin or a reference to a declared Named
544
+ // Type -- the parser doesn't distinguish them at parse time. If the
545
+ // name matches a builtin, no Named-Type deref is possible.
546
+ if (BUILTIN_TYPE_NAMES.has(type.name.toLowerCase()))
547
+ return undefined;
548
+ const td = typeTable.get(type.name);
549
+ if (!td)
550
+ return undefined;
551
+ // Object-form Named Type: body holds the fields directly.
552
+ if (!td.scalarBase && td.body.length > 0) {
553
+ return {
554
+ kind: 'ObjectType',
555
+ keyword: 'object',
556
+ fields: td.body,
557
+ span: td.span,
558
+ };
559
+ }
560
+ // Scalar-form Named Type: recurse into the base.
561
+ if (td.scalarBase) {
562
+ return derefToObject(td.scalarBase, typeTable, depth + 1);
563
+ }
564
+ return undefined;
565
+ }
566
+ /**
567
+ * Apply the alias from an import item by renaming the extracted declaration.
568
+ * If no alias is present, returns the declaration unchanged.
569
+ *
570
+ * The rename is shallow: we update the top-level `name` field and leave
571
+ * everything else intact. References inside the declaration (e.g., field
572
+ * type expressions that name other Types) keep their original names --
573
+ * the user is expected to ensure aliases don't break internal references.
574
+ *
575
+ * Field imports are aliased the same way: the bare FieldDeclaration's
576
+ * `name` field becomes the alias. The synthesized TypeDeclaration that
577
+ * `flatten()` produces will then carry the alias as its Named Type name.
578
+ */
579
+ function applyAlias(stmt, item) {
580
+ if (!item.alias)
581
+ return stmt;
582
+ switch (stmt.kind) {
583
+ case 'EntityDeclaration':
584
+ return { ...stmt, name: item.alias };
585
+ case 'TypeDeclaration':
586
+ return { ...stmt, name: item.alias };
587
+ case 'EnumDeclaration':
588
+ return { ...stmt, name: item.alias };
589
+ case 'EdgeDeclaration':
590
+ return { ...stmt, name: item.alias };
591
+ case 'ViewDeclaration':
592
+ return { ...stmt, name: item.alias };
593
+ case 'ContainerDeclaration':
594
+ return { ...stmt, name: item.alias };
595
+ case 'TableGroupDeclaration':
596
+ return { ...stmt, name: item.alias };
597
+ case 'TablePartialDeclaration':
598
+ return { ...stmt, name: item.alias };
599
+ case 'NoteDeclaration':
600
+ return { ...stmt, name: item.alias };
601
+ case 'FieldDeclaration':
602
+ return { ...stmt, name: item.alias };
603
+ default:
604
+ return stmt;
605
+ }
606
+ }
607
+ /* -------------------------------------------------------------------------
608
+ * Module source classification (spec §26.14, "Remote module sources")
609
+ *
610
+ * A `from` source is recognized purely by its scheme. A source beginning
611
+ * with 'https://' is a remote (URL) source; anything else is a relative
612
+ * path, resolved exactly as in v0.2. Disallowed forms -- a non-https
613
+ * scheme, a protocol-relative '//host/...' source, embedded credentials,
614
+ * or a bare host such as 'github.com/owner/repo/...' -- are rejected here
615
+ * so the parser can surface a located error at the source string.
616
+ *
617
+ * This is pure, synchronous classification. The actual network fetch is
618
+ * delegated to ParseOptions.readFile (the host's resolver). The obligations
619
+ * on a fetcher (SSRF defenses, https-only redirects, size and time limits)
620
+ * live with that resolver, not here; see spec §26.14.5.
621
+ * ----------------------------------------------------------------------- */
622
+ /** Raised when a `from` source string is structurally disallowed. */
623
+ export class ModuleSourceError extends Error {
624
+ constructor(message) {
625
+ super(message);
626
+ this.name = 'ModuleSourceError';
627
+ }
628
+ }
629
+ const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;
630
+ // A scheme-less first path segment that looks like a public host. Used only
631
+ // to give a clearer error than "file not found" when someone pastes a URL
632
+ // without its scheme. Deliberately conservative: it requires a dot-separated
633
+ // label ending in an alphabetic TLD, so version directories like 'v1.2/...'
634
+ // and ordinary relative roots like 'lib/...' are NOT treated as hosts.
635
+ const BARE_HOST_RE = /^([a-z0-9-]+\.)+[a-z]{2,}$/i;
636
+ /** True when a resolved key is a remote (https) URL rather than a path. */
637
+ export function isUrlKey(s) {
638
+ return /^https:\/\//i.test(s);
639
+ }
640
+ /**
641
+ * Classify a directive's `from` source string. Returns a discriminated
642
+ * union; throws ModuleSourceError for disallowed forms. Pure and sync.
643
+ */
644
+ export function classifyModuleSource(from) {
645
+ // Protocol-relative: ambiguous (no scheme to resolve against). Rejected.
646
+ if (from.startsWith('//')) {
647
+ throw new ModuleSourceError(`Protocol-relative module source ${JSON.stringify(from)} is not allowed; ` +
648
+ `use an explicit 'https://' URL or a relative path.`);
649
+ }
650
+ const schemeMatch = SCHEME_RE.exec(from);
651
+ if (schemeMatch) {
652
+ const scheme = schemeMatch[1].toLowerCase();
653
+ if (scheme !== 'https') {
654
+ throw new ModuleSourceError(`Module source scheme '${scheme}:' is not allowed; remote sources must use 'https://' ` +
655
+ `(got ${JSON.stringify(from)}).`);
656
+ }
657
+ let url;
658
+ try {
659
+ url = new URL(from);
660
+ }
661
+ catch {
662
+ throw new ModuleSourceError(`Module source ${JSON.stringify(from)} is not a valid 'https://' URL.`);
663
+ }
664
+ if (url.username !== '' || url.password !== '') {
665
+ throw new ModuleSourceError(`Module source ${JSON.stringify(from)} embeds credentials in the URL, which is not allowed; ` +
666
+ `supply authentication through the resolver's configuration instead.`);
667
+ }
668
+ return { kind: 'url', href: url.href };
669
+ }
670
+ // No scheme. Reject an obvious bare host (domain-like first segment
671
+ // followed by a path) rather than silently treating it as a relative file.
672
+ const slash = from.indexOf('/');
673
+ if (slash > 0) {
674
+ const firstSegment = from.slice(0, slash);
675
+ if (BARE_HOST_RE.test(firstSegment)) {
676
+ throw new ModuleSourceError(`Module source ${JSON.stringify(from)} looks like a bare host; ` +
677
+ `prefix it with 'https://' to use it as a remote source, ` +
678
+ `or write './${from}' if you really mean a relative path.`);
679
+ }
680
+ }
681
+ return { kind: 'relative', from };
682
+ }
683
+ /**
684
+ * Resolve a `from` source against the importer's `filePath`, returning a
685
+ * stable key for `readFile` and for cycle detection.
686
+ *
687
+ * - A remote (https) source resolves to its normalized href. No '.xdbml'
688
+ * is appended (a raw-content URL may carry a query string).
689
+ * - A relative source whose importer is itself a remote module resolves
690
+ * against the importer's base URL per RFC 3986 (spec §26.14.1). A remote
691
+ * module therefore can never reach the local filesystem.
692
+ * - A relative source with a local importer resolves on the filesystem,
693
+ * exactly as in v0.2.
694
+ *
695
+ * Uses pure string / WHATWG-URL manipulation (no node:path) so the same
696
+ * code runs in Node and the browser. Forward slashes only; Windows paths
697
+ * with backslashes should be normalized before reaching the parser.
698
+ */
699
+ function resolveModulePath(fromClause, importerPath) {
700
+ const source = classifyModuleSource(fromClause);
701
+ // Remote (URL) source: the normalized href IS the resolution key.
702
+ if (source.kind === 'url') {
703
+ return source.href;
704
+ }
705
+ // Relative source under a remote importer: resolve against its base URL.
706
+ if (importerPath && isUrlKey(importerPath)) {
707
+ let rel = source.from;
708
+ if (!rel.endsWith('.xdbml'))
709
+ rel = `${rel}.xdbml`;
710
+ // WHATWG URL resolution normalizes host case and dot-segments, which is
711
+ // also what we want for the cycle-detection / de-duplication key.
712
+ return new URL(rel, importerPath).href;
713
+ }
714
+ // Local relative source (unchanged v0.2 behavior).
715
+ let withExt = source.from;
716
+ if (!withExt.endsWith('.xdbml')) {
717
+ withExt = `${withExt}.xdbml`;
718
+ }
719
+ // Absolute path: return as-is.
720
+ if (withExt.startsWith('/'))
721
+ return withExt;
722
+ // No importer context: return as-is (resolver handles it).
723
+ if (!importerPath)
724
+ return withExt;
725
+ // Resolve relative to importer's directory.
726
+ const importerDir = posixDirname(importerPath);
727
+ return posixJoin(importerDir, withExt);
728
+ }
729
+ function posixDirname(p) {
730
+ const idx = p.lastIndexOf('/');
731
+ if (idx < 0)
732
+ return '.';
733
+ if (idx === 0)
734
+ return '/';
735
+ return p.slice(0, idx);
736
+ }
737
+ function posixJoin(base, rel) {
738
+ // Split base into segments; consume `.`/`..` from rel.
739
+ const baseSegs = base === '/' ? [''] : base.split('/');
740
+ const relSegs = rel.split('/');
741
+ const out = baseSegs.slice();
742
+ for (const seg of relSegs) {
743
+ if (seg === '' || seg === '.')
744
+ continue;
745
+ if (seg === '..') {
746
+ // Pop unless we'd cross above root.
747
+ if (out.length > 0 && out[out.length - 1] !== '..' && out[out.length - 1] !== '') {
748
+ out.pop();
749
+ }
750
+ else {
751
+ out.push('..');
752
+ }
753
+ }
754
+ else {
755
+ out.push(seg);
756
+ }
757
+ }
758
+ // Re-join. Leading empty (from absolute base) preserves the leading slash.
759
+ let joined = out.join('/');
760
+ if (base.startsWith('/') && !joined.startsWith('/'))
761
+ joined = `/${joined}`;
762
+ if (joined === '')
763
+ joined = '.';
764
+ return joined;
765
+ }
766
+ /**
767
+ * Default recursion depth limit for module resolution. Enough for any
768
+ * realistic module graph; small enough to bound stack usage on
769
+ * pathological inputs.
770
+ */
771
+ export const DEFAULT_MAX_DEPTH = 8;