@domainlang/language 0.6.0 → 0.8.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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/out/domain-lang-module.d.ts +2 -0
  3. package/out/domain-lang-module.js +23 -2
  4. package/out/domain-lang-module.js.map +1 -1
  5. package/out/lsp/domain-lang-completion.d.ts +142 -1
  6. package/out/lsp/domain-lang-completion.js +620 -22
  7. package/out/lsp/domain-lang-completion.js.map +1 -1
  8. package/out/lsp/domain-lang-document-symbol-provider.d.ts +79 -0
  9. package/out/lsp/domain-lang-document-symbol-provider.js +210 -0
  10. package/out/lsp/domain-lang-document-symbol-provider.js.map +1 -0
  11. package/out/lsp/domain-lang-index-manager.d.ts +98 -1
  12. package/out/lsp/domain-lang-index-manager.js +214 -7
  13. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  14. package/out/lsp/domain-lang-node-kind-provider.d.ts +27 -0
  15. package/out/lsp/domain-lang-node-kind-provider.js +87 -0
  16. package/out/lsp/domain-lang-node-kind-provider.js.map +1 -0
  17. package/out/lsp/domain-lang-scope-provider.d.ts +100 -0
  18. package/out/lsp/domain-lang-scope-provider.js +170 -0
  19. package/out/lsp/domain-lang-scope-provider.js.map +1 -0
  20. package/out/lsp/domain-lang-workspace-manager.d.ts +46 -0
  21. package/out/lsp/domain-lang-workspace-manager.js +148 -4
  22. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  23. package/out/lsp/hover/domain-lang-hover.d.ts +16 -6
  24. package/out/lsp/hover/domain-lang-hover.js +160 -134
  25. package/out/lsp/hover/domain-lang-hover.js.map +1 -1
  26. package/out/lsp/hover/hover-builders.d.ts +57 -0
  27. package/out/lsp/hover/hover-builders.js +171 -0
  28. package/out/lsp/hover/hover-builders.js.map +1 -0
  29. package/out/main.js +116 -20
  30. package/out/main.js.map +1 -1
  31. package/out/sdk/index.d.ts +2 -1
  32. package/out/sdk/index.js +1 -1
  33. package/out/sdk/index.js.map +1 -1
  34. package/out/sdk/loader-node.js +1 -1
  35. package/out/sdk/loader-node.js.map +1 -1
  36. package/out/sdk/loader.d.ts +55 -2
  37. package/out/sdk/loader.js +87 -28
  38. package/out/sdk/loader.js.map +1 -1
  39. package/out/sdk/query.js +14 -11
  40. package/out/sdk/query.js.map +1 -1
  41. package/out/services/import-resolver.d.ts +29 -6
  42. package/out/services/import-resolver.js +48 -9
  43. package/out/services/import-resolver.js.map +1 -1
  44. package/out/services/package-boundary-detector.d.ts +101 -0
  45. package/out/services/package-boundary-detector.js +211 -0
  46. package/out/services/package-boundary-detector.js.map +1 -0
  47. package/out/services/performance-optimizer.js +6 -2
  48. package/out/services/performance-optimizer.js.map +1 -1
  49. package/out/services/types.d.ts +24 -0
  50. package/out/services/types.js.map +1 -1
  51. package/out/services/workspace-manager.d.ts +73 -6
  52. package/out/services/workspace-manager.js +210 -57
  53. package/out/services/workspace-manager.js.map +1 -1
  54. package/out/utils/import-utils.d.ts +9 -6
  55. package/out/utils/import-utils.js +26 -15
  56. package/out/utils/import-utils.js.map +1 -1
  57. package/out/validation/constants.d.ts +20 -0
  58. package/out/validation/constants.js +39 -3
  59. package/out/validation/constants.js.map +1 -1
  60. package/out/validation/import.d.ts +22 -1
  61. package/out/validation/import.js +104 -16
  62. package/out/validation/import.js.map +1 -1
  63. package/out/validation/maps.js +101 -3
  64. package/out/validation/maps.js.map +1 -1
  65. package/package.json +5 -5
  66. package/src/domain-lang-module.ts +26 -3
  67. package/src/lsp/domain-lang-completion.ts +736 -27
  68. package/src/lsp/domain-lang-document-symbol-provider.ts +254 -0
  69. package/src/lsp/domain-lang-index-manager.ts +250 -7
  70. package/src/lsp/domain-lang-node-kind-provider.ts +119 -0
  71. package/src/lsp/domain-lang-scope-provider.ts +250 -0
  72. package/src/lsp/domain-lang-workspace-manager.ts +187 -4
  73. package/src/lsp/hover/domain-lang-hover.ts +189 -131
  74. package/src/lsp/hover/hover-builders.ts +208 -0
  75. package/src/main.ts +156 -23
  76. package/src/sdk/index.ts +2 -1
  77. package/src/sdk/loader-node.ts +2 -1
  78. package/src/sdk/loader.ts +125 -34
  79. package/src/sdk/query.ts +15 -11
  80. package/src/services/import-resolver.ts +60 -9
  81. package/src/services/package-boundary-detector.ts +238 -0
  82. package/src/services/performance-optimizer.ts +6 -2
  83. package/src/services/types.ts +25 -0
  84. package/src/services/workspace-manager.ts +259 -62
  85. package/src/utils/import-utils.ts +27 -15
  86. package/src/validation/constants.ts +47 -6
  87. package/src/validation/import.ts +124 -16
  88. package/src/validation/maps.ts +118 -4
@@ -6,9 +6,11 @@
6
6
  * - Grammar-aligned: Completions match grammar structure exactly
7
7
  * - Simple: Uses parent node to determine context
8
8
  * - Maintainable: Clear mapping from grammar to completions
9
+ * - Import-aware: Provides completions for local paths, aliases, and dependencies
9
10
  */
11
+ import { AstUtils, GrammarAST } from 'langium';
10
12
  import { DefaultCompletionProvider } from 'langium/lsp';
11
- import { CompletionItemKind, InsertTextFormat } from 'vscode-languageserver';
13
+ import { CompletionItemKind, CompletionList, InsertTextFormat, TextEdit } from 'vscode-languageserver';
12
14
  import * as ast from '../generated/ast.js';
13
15
  /**
14
16
  * Top-level snippet templates for creating new AST nodes.
@@ -114,23 +116,373 @@ const TOP_LEVEL_SNIPPETS = [
114
116
  }
115
117
  ];
116
118
  export class DomainLangCompletionProvider extends DefaultCompletionProvider {
117
- completionFor(context, next, acceptor) {
119
+ constructor(services) {
120
+ super(services);
121
+ this.completionOptions = {
122
+ triggerCharacters: ['.']
123
+ };
124
+ this.workspaceManager = services.imports.WorkspaceManager;
125
+ }
126
+ /**
127
+ * Override getCompletion to handle import string completions for incomplete strings.
128
+ *
129
+ * **Why this override is necessary:**
130
+ * When the cursor sits inside an incomplete string token (e.g. `import "partial`)
131
+ * Langium's lexer cannot produce a valid STRING token, so `completionFor()` never
132
+ * fires for the `uri` property. This override detects the incomplete-string case
133
+ * via regex and returns completions directly. For all other positions the parent
134
+ * implementation (which routes through `completionFor`) is used.
135
+ */
136
+ async getCompletion(document, params, cancelToken) {
137
+ const text = document.textDocument.getText();
138
+ const offset = document.textDocument.offsetAt(params.position);
139
+ const textBefore = text.substring(0, offset);
140
+ // Pattern: import "partial_text (opening quote, no closing quote)
141
+ const importStringPattern = /\b(import|Import)\s+"([^"]*)$/;
142
+ const match = importStringPattern.exec(textBefore);
143
+ if (match) {
144
+ const currentInput = match[2];
145
+ const items = await this.collectImportItems(currentInput);
146
+ return CompletionList.create(items, true);
147
+ }
148
+ const result = await super.getCompletion(document, params, cancelToken);
149
+ return this.segmentDottedCompletions(result, text, offset, document);
150
+ }
151
+ /**
152
+ * Post-process completion results to replace full-FQN items with segmented items
153
+ * when the cursor is at a dotted path.
154
+ *
155
+ * **Why this is necessary:**
156
+ * Langium's `buildContexts` creates a "data type rule" context that triggers our
157
+ * `completionForCrossReference` override, which correctly produces segmented items.
158
+ * However, when the CST is broken (e.g., partial `Core.B` doesn't fully parse as
159
+ * a QualifiedName), `findDataTypeRuleStart` returns `undefined` and only token-based
160
+ * contexts fire. These contexts have features that are ID terminals (not cross-references),
161
+ * so `completionForCrossReference` is never called. Langium's default pipeline then
162
+ * produces full-FQN items like `Core.Baunwalls.Jannie`.
163
+ *
164
+ * This post-processing step catches those FQN items and segments them,
165
+ * ensuring consistent behavior regardless of parse state.
166
+ */
167
+ segmentDottedCompletions(result, text, offset, _document) {
168
+ if (!result?.items?.length)
169
+ return result;
170
+ // Detect dotted path at cursor by scanning backwards
171
+ const dottedPath = this.extractDottedPathAtCursor(text, offset);
172
+ if (!dottedPath)
173
+ return result;
174
+ const { fullTyped, fullStart } = dottedPath;
175
+ const lastDotIndex = fullTyped.lastIndexOf('.');
176
+ const prefix = fullTyped.substring(0, lastDotIndex + 1);
177
+ const partial = fullTyped.substring(lastDotIndex + 1);
178
+ // If completionForCrossReference already produced segmented items, just clean up
179
+ if (this.hasSegmentedItems(result.items, prefix)) {
180
+ return this.removeLeakedFqnItems(result, prefix);
181
+ }
182
+ // No segmented items — transform FQN items into segmented items
183
+ const positions = this.calculateTextPositions(text, offset, fullStart);
184
+ const newItems = this.transformToSegmentedItems(result.items, prefix, partial, fullTyped, positions);
185
+ return CompletionList.create(newItems, true);
186
+ }
187
+ /** Check if any items are already segmented by our completionForCrossReference. */
188
+ hasSegmentedItems(items, prefix) {
189
+ return items.some(item => !item.label.includes('.') && item.filterText?.startsWith(prefix));
190
+ }
191
+ /** Remove full-FQN items that leaked alongside segmented items. */
192
+ removeLeakedFqnItems(result, prefix) {
193
+ const prefixRoot = prefix.substring(0, prefix.length - 1);
194
+ result.items = result.items.filter(item => !item.label.includes('.') || !item.label.startsWith(prefixRoot));
195
+ return result;
196
+ }
197
+ /** Calculate line/character positions from text offsets. */
198
+ calculateTextPositions(text, offset, fullStart) {
199
+ const startPos = { line: 0, character: 0 };
200
+ const endPos = { line: 0, character: 0 };
201
+ let line = 0;
202
+ let col = 0;
203
+ for (let i = 0; i < text.length && i <= offset; i++) {
204
+ if (i === fullStart) {
205
+ startPos.line = line;
206
+ startPos.character = col;
207
+ }
208
+ if (i === offset) {
209
+ endPos.line = line;
210
+ endPos.character = col;
211
+ }
212
+ if (text[i] === '\n') {
213
+ line++;
214
+ col = 0;
215
+ }
216
+ else {
217
+ col++;
218
+ }
219
+ }
220
+ if (offset === text.length) {
221
+ endPos.line = line;
222
+ endPos.character = col;
223
+ }
224
+ return { startPos, endPos };
225
+ }
226
+ /** Transform FQN completion items into segmented (next-segment-only) items. */
227
+ transformToSegmentedItems(items, prefix, partial, fullTyped, positions) {
228
+ const seenSegments = new Set();
229
+ const newItems = [];
230
+ for (const item of items) {
231
+ const segmented = this.segmentSingleItem(item, prefix, partial, fullTyped, positions, seenSegments);
232
+ if (segmented)
233
+ newItems.push(segmented);
234
+ }
235
+ return newItems;
236
+ }
237
+ /** Transform a single FQN item into a segmented item, or keep non-matching items. */
238
+ segmentSingleItem(item, prefix, partial, fullTyped, positions, seenSegments) {
239
+ const itemName = item.label;
240
+ // Keep non-matching items (keywords, snippets, etc.)
241
+ if (!itemName.startsWith(prefix)) {
242
+ if (!itemName.includes('.') || !this.sharesDottedPrefix(itemName, fullTyped)) {
243
+ return item;
244
+ }
245
+ return undefined;
246
+ }
247
+ const remainder = itemName.substring(prefix.length);
248
+ const dotIndex = remainder.indexOf('.');
249
+ const segment = dotIndex === -1 ? remainder : remainder.substring(0, dotIndex);
250
+ if (!segment || seenSegments.has(segment))
251
+ return undefined;
252
+ if (partial && !segment.toLowerCase().startsWith(partial.toLowerCase()))
253
+ return undefined;
254
+ seenSegments.add(segment);
255
+ const isLeaf = dotIndex === -1;
256
+ const fullInsertText = prefix + segment;
257
+ return {
258
+ label: segment,
259
+ kind: isLeaf ? (item.kind ?? CompletionItemKind.Reference) : CompletionItemKind.Module,
260
+ detail: isLeaf ? itemName : 'Namespace',
261
+ sortText: segment,
262
+ filterText: fullInsertText,
263
+ textEdit: TextEdit.replace({ start: positions.startPos, end: positions.endPos }, fullInsertText),
264
+ };
265
+ }
266
+ /**
267
+ * Check if two dotted names share a common prefix up to the first differing segment.
268
+ */
269
+ sharesDottedPrefix(a, b) {
270
+ const aParts = a.split('.');
271
+ const bParts = b.split('.');
272
+ return aParts.length > 0 && bParts.length > 0 && aParts[0] === bParts[0];
273
+ }
274
+ /**
275
+ * Scan backwards from cursor to find a dotted identifier path.
276
+ * Returns the full typed text and start position, or undefined if no dots found.
277
+ *
278
+ * This is intentionally cursor-based (not tokenOffset-based) to be robust
279
+ * across all Langium completion contexts.
280
+ */
281
+ extractDottedPathAtCursor(text, offset) {
282
+ // Walk backwards from cursor through the current partial identifier
283
+ let pos = offset - 1;
284
+ while (pos >= 0 && /\w/.test(text[pos]))
285
+ pos--;
286
+ // Walk backwards through `.ID` pairs to find start of dotted path
287
+ pos = this.walkBackThroughDotIdPairs(text, pos);
288
+ const fullStart = pos + 1;
289
+ const fullTyped = text.substring(fullStart, offset);
290
+ if (!fullTyped.includes('.'))
291
+ return undefined;
292
+ return { fullTyped, fullStart };
293
+ }
294
+ /** Walk backwards through `.ID` pairs from the given position. */
295
+ walkBackThroughDotIdPairs(text, pos) {
296
+ while (pos >= 0) {
297
+ const preSpace = pos;
298
+ while (pos >= 0 && text[pos] === ' ')
299
+ pos--;
300
+ if (pos < 0 || text[pos] !== '.')
301
+ return preSpace;
302
+ pos--; // skip dot
303
+ while (pos >= 0 && text[pos] === ' ')
304
+ pos--;
305
+ if (pos < 0 || !/\w/.test(text[pos]))
306
+ return preSpace;
307
+ while (pos >= 0 && /\w/.test(text[pos]))
308
+ pos--;
309
+ }
310
+ return pos;
311
+ }
312
+ /**
313
+ * Collect import completion items for a given partial input string.
314
+ *
315
+ * Shared by both the `getCompletion` override (incomplete string case)
316
+ * and the `completionFor` path (normal Langium feature-based routing).
317
+ */
318
+ async collectImportItems(currentInput) {
319
+ let manifest;
320
+ try {
321
+ manifest = await this.workspaceManager.ensureManifestLoaded();
322
+ }
323
+ catch {
324
+ // Continue with undefined manifest – will show basic starters
325
+ }
326
+ const items = [];
327
+ const collector = (_ctx, item) => { items.push(item); };
328
+ // Re-use the acceptor-based helpers by wrapping the collector
329
+ // We pass `undefined as unknown` for context since the collector ignores it
330
+ const ctx = undefined;
331
+ const accept = ((_context, item) => {
332
+ collector(undefined, item);
333
+ });
334
+ if (currentInput === '' || !currentInput) {
335
+ this.addAllStarterOptions(ctx, accept, manifest);
336
+ }
337
+ else if (currentInput.startsWith('@')) {
338
+ this.addAliasCompletions(ctx, accept, currentInput, manifest);
339
+ }
340
+ else if (currentInput.startsWith('./') || currentInput.startsWith('../')) {
341
+ this.addLocalPathStarters(ctx, accept);
342
+ }
343
+ else if (currentInput.includes('/') && !currentInput.startsWith('.')) {
344
+ this.addDependencyCompletions(ctx, accept, currentInput, manifest);
345
+ }
346
+ else {
347
+ this.addFilteredOptions(ctx, accept, currentInput, manifest);
348
+ }
349
+ return items;
350
+ }
351
+ async completionFor(context, next, acceptor) {
118
352
  try {
119
- this.safeCompletionFor(context, next, acceptor);
353
+ await this.safeCompletionFor(context, next, acceptor);
120
354
  }
121
355
  catch (error) {
122
356
  console.error('Error in completionFor:', error);
123
357
  // Fall back to default completion on error
124
- super.completionFor(context, next, acceptor);
358
+ await super.completionFor(context, next, acceptor);
125
359
  }
126
360
  }
127
- safeCompletionFor(context, next, acceptor) {
361
+ /**
362
+ * Override cross-reference completion to provide dot-segmented completions.
363
+ *
364
+ * When the user types a dotted prefix (e.g., `Core.`), only the next
365
+ * namespace segment is shown instead of the full qualified name —
366
+ * matching how modern IDEs handle hierarchical completions.
367
+ *
368
+ * Example: Scope contains `Core.CoreDomain`, `Core.BaunWalls.Jannie`, `Core.BaunWalls.Anna`
369
+ * - No dots typed → default FQN labels: `Core.CoreDomain`, `Core.BaunWalls.Jannie`, ...
370
+ * - `Core.` typed → segmented: `CoreDomain`, `BaunWalls`
371
+ * - `Core.BaunWalls.` typed → segmented: `Jannie`, `Anna`
372
+ */
373
+ completionForCrossReference(context, next, acceptor) {
374
+ const text = context.textDocument.getText();
375
+ const fullStart = this.findDottedPathStart(text, context.tokenOffset);
376
+ const fullTyped = text.substring(fullStart, context.offset);
377
+ // Without dots, use Langium's default FQN-based completion
378
+ if (!fullTyped.includes('.')) {
379
+ return super.completionForCrossReference(context, next, acceptor);
380
+ }
381
+ // Build ReferenceInfo to query the scope — replicating what super does
382
+ // but without going through super's fuzzy-matching pipeline.
383
+ const assignment = AstUtils.getContainerOfType(next.feature, GrammarAST.isAssignment);
384
+ if (!assignment || !context.node)
385
+ return;
386
+ let node = context.node;
387
+ if (next.type) {
388
+ node = {
389
+ $type: next.type,
390
+ $container: node,
391
+ $containerProperty: next.property,
392
+ };
393
+ AstUtils.assignMandatoryProperties(this.astReflection, node);
394
+ }
395
+ const reference = { $refText: '' };
396
+ const refInfo = {
397
+ reference: reference,
398
+ container: node,
399
+ property: assignment.feature,
400
+ };
401
+ const candidates = this.getReferenceCandidates(refInfo, context);
402
+ this.acceptSegmentedCandidates(context, candidates, fullTyped, fullStart, acceptor);
403
+ }
404
+ /**
405
+ * Walk backwards from `tokenOffset` through preceding `.ID` pairs
406
+ * to find the start of the full dotted path.
407
+ * QualifiedName = ID ('.' ID)* — Langium tokenises each ID separately.
408
+ */
409
+ findDottedPathStart(text, tokenOffset) {
410
+ let fullStart = tokenOffset;
411
+ while (fullStart > 0) {
412
+ let pos = fullStart - 1;
413
+ while (pos >= 0 && text[pos] === ' ')
414
+ pos--;
415
+ if (pos < 0 || text[pos] !== '.')
416
+ break;
417
+ const idEnd = pos;
418
+ pos--;
419
+ while (pos >= 0 && text[pos] === ' ')
420
+ pos--;
421
+ while (pos >= 0 && /\w/.test(text[pos]))
422
+ pos--;
423
+ pos++;
424
+ if (pos >= idEnd)
425
+ break;
426
+ fullStart = pos;
427
+ }
428
+ return fullStart;
429
+ }
430
+ /**
431
+ * Iterate scope candidates and emit segmented completion items.
432
+ * Splits FQN candidates by the typed prefix, extracting only the next segment.
433
+ */
434
+ acceptSegmentedCandidates(context, candidates, fullTyped, fullStart, acceptor) {
435
+ const lastDotIndex = fullTyped.lastIndexOf('.');
436
+ const prefix = fullTyped.substring(0, lastDotIndex + 1);
437
+ const partial = fullTyped.substring(lastDotIndex + 1);
438
+ const seenSegments = new Set();
439
+ const startPos = context.textDocument.positionAt(fullStart);
440
+ const endPos = context.textDocument.positionAt(context.offset);
441
+ candidates.forEach((candidate) => {
442
+ const fullName = candidate.name;
443
+ if (!fullName.startsWith(prefix))
444
+ return;
445
+ const remainder = fullName.substring(prefix.length);
446
+ const dotIndex = remainder.indexOf('.');
447
+ const segment = dotIndex === -1 ? remainder : remainder.substring(0, dotIndex);
448
+ if (!segment || seenSegments.has(segment))
449
+ return;
450
+ if (partial && !segment.toLowerCase().startsWith(partial.toLowerCase()))
451
+ return;
452
+ seenSegments.add(segment);
453
+ const isLeaf = dotIndex === -1;
454
+ const fullInsertText = prefix + segment;
455
+ acceptor(context, {
456
+ label: segment,
457
+ kind: isLeaf ? this.nodeKindProvider.getCompletionItemKind(candidate) : CompletionItemKind.Module,
458
+ detail: isLeaf ? fullName : 'Namespace',
459
+ sortText: segment,
460
+ // filterText MUST include the full dotted prefix so VS Code's
461
+ // client-side filter can match "Core.B" against "Core.BaunWalls".
462
+ // Without this, VS Code uses `label` ('BaunWalls') for filtering,
463
+ // which fails to match the typed text 'Core.B'.
464
+ filterText: fullInsertText,
465
+ textEdit: {
466
+ newText: fullInsertText,
467
+ range: { start: startPos, end: endPos },
468
+ },
469
+ });
470
+ });
471
+ }
472
+ async safeCompletionFor(context, next, acceptor) {
128
473
  const node = context.node;
129
474
  if (!node) {
130
- super.completionFor(context, next, acceptor);
475
+ await super.completionFor(context, next, acceptor);
131
476
  return;
132
477
  }
133
478
  // Strategy: Check node type and container to determine what's allowed at cursor position
479
+ // Handle import statement completions
480
+ if (this.isImportUriCompletion(node, context, next)) {
481
+ // Add async import completions (ensures manifest is loaded)
482
+ await this.addImportCompletions(context, acceptor, node);
483
+ // Don't call super - we handle import string completions ourselves
484
+ return;
485
+ }
134
486
  // Check if cursor is after the node (for top-level positioning)
135
487
  const offset = context.offset;
136
488
  const nodeEnd = node.$cstNode?.end ?? 0;
@@ -139,85 +491,331 @@ export class DomainLangCompletionProvider extends DefaultCompletionProvider {
139
491
  if ((ast.isBoundedContext(node) || ast.isDomain(node)) && isAfterNode) {
140
492
  this.addTopLevelSnippets(acceptor, context);
141
493
  // Let Langium provide keywords like "bc", "Domain", etc.
142
- super.completionFor(context, next, acceptor);
494
+ await super.completionFor(context, next, acceptor);
143
495
  return;
144
496
  }
145
497
  // Handle node-level completions
146
- if (this.handleNodeCompletions(node, acceptor, context, next)) {
498
+ if (await this.handleNodeCompletions(node, acceptor, context, next)) {
147
499
  return;
148
500
  }
149
501
  // Handle container-level completions
150
502
  const container = node.$container;
151
- if (this.handleContainerCompletions(container, node, acceptor, context, next)) {
503
+ if (await this.handleContainerCompletions(container, node, acceptor, context, next)) {
152
504
  return;
153
505
  }
154
506
  // Let Langium handle default completions
155
- super.completionFor(context, next, acceptor);
507
+ await super.completionFor(context, next, acceptor);
508
+ }
509
+ /**
510
+ * Detect if we're completing inside an import statement's uri property.
511
+ *
512
+ * This checks:
513
+ * 1. The NextFeature's type and property (when completing STRING for uri)
514
+ * 2. The current AST node (when inside an ImportStatement)
515
+ * 3. Text-based pattern matching (fallback for edge cases)
516
+ */
517
+ isImportUriCompletion(node, context, next) {
518
+ // Check 1: NextFeature indicates we're completing uri property of ImportStatement
519
+ if (next.type === 'ImportStatement' && next.property === 'uri') {
520
+ return true;
521
+ }
522
+ // Check 2: The feature is an Assignment to 'uri' property
523
+ if (GrammarAST.isAssignment(next.feature) && next.feature.feature === 'uri') {
524
+ return true;
525
+ }
526
+ // Check 3: Any ancestor (including self) is ImportStatement
527
+ if (this.isInImportStatementHierarchy(node)) {
528
+ return true;
529
+ }
530
+ // Check 4: Text-based pattern matching (fallback for edge cases)
531
+ return this.isImportTextPattern(context);
532
+ }
533
+ /** Check if the node or any ancestor is an ImportStatement. */
534
+ isInImportStatementHierarchy(node) {
535
+ let current = node;
536
+ while (current) {
537
+ if (ast.isImportStatement(current))
538
+ return true;
539
+ current = current.$container;
540
+ }
541
+ return false;
542
+ }
543
+ /** Check if text before cursor matches an import string pattern. */
544
+ isImportTextPattern(context) {
545
+ if (typeof context.textDocument?.getText !== 'function')
546
+ return false;
547
+ try {
548
+ const textBefore = context.textDocument.getText().substring(0, context.offset);
549
+ return /\bimport\s+"[^"]*$/i.test(textBefore);
550
+ }
551
+ catch {
552
+ return false;
553
+ }
554
+ }
555
+ /**
556
+ * Add import completions asynchronously.
557
+ * This method ensures the manifest is loaded before providing completions.
558
+ */
559
+ async addImportCompletions(context, acceptor, _node) {
560
+ // Extract what user has typed inside the import string
561
+ const currentInput = this.extractImportInput(context);
562
+ // Ensure manifest is loaded (async)
563
+ let manifest;
564
+ try {
565
+ manifest = await this.workspaceManager.ensureManifestLoaded();
566
+ }
567
+ catch {
568
+ // Continue with undefined manifest – will show basic starters
569
+ }
570
+ if (currentInput.startsWith('@')) {
571
+ // Alias completions
572
+ this.addAliasCompletions(context, acceptor, currentInput, manifest);
573
+ }
574
+ else if (currentInput.startsWith('./') || currentInput.startsWith('../')) {
575
+ // Local path completions
576
+ this.addLocalPathStarters(context, acceptor);
577
+ }
578
+ else if (currentInput === '' || !currentInput) {
579
+ // Show all starter options
580
+ this.addAllStarterOptions(context, acceptor, manifest);
581
+ }
582
+ else if (currentInput.includes('/') && !currentInput.startsWith('.')) {
583
+ // External dependency - filter by partial input
584
+ this.addDependencyCompletions(context, acceptor, currentInput, manifest);
585
+ }
586
+ else {
587
+ // Show all options for partial input (e.g., typing 'l' should show matching items)
588
+ this.addFilteredOptions(context, acceptor, currentInput, manifest);
589
+ }
590
+ }
591
+ /**
592
+ * Extract the current input inside the import string.
593
+ */
594
+ extractImportInput(context) {
595
+ try {
596
+ const text = context.textDocument.getText();
597
+ const offset = context.offset;
598
+ const textBefore = text.substring(0, offset);
599
+ const importPattern = /\bimport\s+"([^"]*)$/i;
600
+ const match = importPattern.exec(textBefore);
601
+ return match ? match[1] : '';
602
+ }
603
+ catch {
604
+ return '';
605
+ }
606
+ }
607
+ /**
608
+ * Add local path starters.
609
+ */
610
+ addLocalPathStarters(context, acceptor) {
611
+ // Would need async fs access to list directories
612
+ // For now, just acknowledge the path exists
613
+ acceptor(context, {
614
+ label: '(type path)',
615
+ kind: CompletionItemKind.Text,
616
+ insertText: '',
617
+ documentation: 'Continue typing the file path',
618
+ sortText: 'z_'
619
+ });
620
+ }
621
+ /**
622
+ * Add all starter options when input is empty.
623
+ */
624
+ addAllStarterOptions(context, acceptor, manifest) {
625
+ // Local starters
626
+ acceptor(context, {
627
+ label: './',
628
+ kind: CompletionItemKind.Folder,
629
+ insertText: './',
630
+ documentation: 'Import from current directory',
631
+ sortText: '0_local_current'
632
+ });
633
+ acceptor(context, {
634
+ label: '../',
635
+ kind: CompletionItemKind.Folder,
636
+ insertText: '../',
637
+ documentation: 'Import from parent directory',
638
+ sortText: '0_local_parent'
639
+ });
640
+ // Add aliases if available
641
+ if (manifest?.paths) {
642
+ for (const alias of Object.keys(manifest.paths)) {
643
+ acceptor(context, {
644
+ label: alias,
645
+ kind: CompletionItemKind.Module,
646
+ detail: `→ ${manifest.paths[alias]}`,
647
+ documentation: `Path alias from model.yaml`,
648
+ insertText: alias,
649
+ sortText: `1_alias_${alias}`
650
+ });
651
+ }
652
+ }
653
+ // Add dependencies if available
654
+ if (manifest?.dependencies) {
655
+ for (const [depKey, depSpec] of Object.entries(manifest.dependencies)) {
656
+ const dep = depSpec;
657
+ const depName = typeof dep === 'string' ? depKey : (dep.source ?? depKey);
658
+ const version = typeof dep === 'string' ? dep : (dep.ref ?? 'latest');
659
+ acceptor(context, {
660
+ label: depName,
661
+ kind: CompletionItemKind.Module,
662
+ detail: `📦 ${version}`,
663
+ documentation: `External dependency from model.yaml`,
664
+ insertText: depName,
665
+ sortText: `2_dep_${depName}`
666
+ });
667
+ }
668
+ }
156
669
  }
157
- handleNodeCompletions(node, acceptor, context, next) {
670
+ /**
671
+ * Add alias completions that match the current input.
672
+ */
673
+ addAliasCompletions(context, acceptor, currentInput, manifest) {
674
+ if (!manifest?.paths) {
675
+ return;
676
+ }
677
+ const inputLower = currentInput.toLowerCase();
678
+ for (const [alias, targetPath] of Object.entries(manifest.paths)) {
679
+ if (alias.toLowerCase().startsWith(inputLower)) {
680
+ acceptor(context, {
681
+ label: alias,
682
+ kind: CompletionItemKind.Module,
683
+ detail: `→ ${targetPath}`,
684
+ documentation: `Path alias defined in model.yaml\nMaps to: ${targetPath}`,
685
+ insertText: alias,
686
+ sortText: `1_alias_${alias}`
687
+ });
688
+ }
689
+ }
690
+ }
691
+ /**
692
+ * Add dependency completions that match the current input.
693
+ */
694
+ addDependencyCompletions(context, acceptor, currentInput, manifest) {
695
+ if (!manifest?.dependencies) {
696
+ return;
697
+ }
698
+ const inputLower = currentInput.toLowerCase();
699
+ for (const [depKey, depSpec] of Object.entries(manifest.dependencies)) {
700
+ const dep = depSpec;
701
+ const depName = typeof dep === 'string' ? depKey : (dep.source ?? depKey);
702
+ const version = typeof dep === 'string' ? dep : (dep.ref ?? 'latest');
703
+ if (depName.toLowerCase().startsWith(inputLower)) {
704
+ acceptor(context, {
705
+ label: depName,
706
+ kind: CompletionItemKind.Module,
707
+ detail: `📦 ${version}`,
708
+ documentation: `External dependency from model.yaml\nVersion: ${version}`,
709
+ insertText: depName,
710
+ sortText: `2_dep_${depName}`
711
+ });
712
+ }
713
+ }
714
+ }
715
+ /**
716
+ * Add filtered options for partial input that doesn't start with special characters.
717
+ * Shows aliases and dependencies that match the user's partial input.
718
+ */
719
+ addFilteredOptions(context, acceptor, currentInput, manifest) {
720
+ // Offer local path starters when the partial input could match ./ or ../
721
+ if ('./'.startsWith(currentInput) || '../'.startsWith(currentInput)) {
722
+ acceptor(context, {
723
+ label: './',
724
+ kind: CompletionItemKind.Folder,
725
+ insertText: './',
726
+ documentation: 'Import from current directory',
727
+ sortText: '0_local_current'
728
+ });
729
+ acceptor(context, {
730
+ label: '../',
731
+ kind: CompletionItemKind.Folder,
732
+ insertText: '../',
733
+ documentation: 'Import from parent directory',
734
+ sortText: '0_local_parent'
735
+ });
736
+ }
737
+ // Delegate to existing helpers for alias and dependency filtering
738
+ this.addAliasCompletions(context, acceptor, currentInput, manifest);
739
+ this.addDependencyCompletions(context, acceptor, currentInput, manifest);
740
+ }
741
+ async handleNodeCompletions(node, acceptor, context, next) {
158
742
  // If we're AT a BoundedContext node: only BC documentation blocks
159
743
  if (ast.isBoundedContext(node)) {
160
744
  this.addBoundedContextCompletions(node, acceptor, context);
161
- super.completionFor(context, next, acceptor);
745
+ await super.completionFor(context, next, acceptor);
162
746
  return true;
163
747
  }
164
748
  // If we're AT a Domain node: only Domain documentation blocks
165
749
  if (ast.isDomain(node)) {
166
750
  this.addDomainCompletions(node, acceptor, context);
167
- super.completionFor(context, next, acceptor);
751
+ await super.completionFor(context, next, acceptor);
168
752
  return true;
169
753
  }
170
754
  // If we're AT a ContextMap node: relationships and contains
171
755
  if (ast.isContextMap(node)) {
172
756
  this.addContextMapCompletions(node, acceptor, context);
173
- super.completionFor(context, next, acceptor);
757
+ await super.completionFor(context, next, acceptor);
174
758
  return true;
175
759
  }
176
760
  // If we're AT a DomainMap node: contains
177
761
  if (ast.isDomainMap(node)) {
178
762
  this.addDomainMapCompletions(node, acceptor, context);
179
- super.completionFor(context, next, acceptor);
763
+ await super.completionFor(context, next, acceptor);
180
764
  return true;
181
765
  }
182
766
  // If we're AT the Model or NamespaceDeclaration level: all top-level constructs
183
767
  if (ast.isModel(node) || ast.isNamespaceDeclaration(node)) {
184
768
  this.addTopLevelSnippets(acceptor, context);
185
- super.completionFor(context, next, acceptor);
769
+ this.addImportSnippet(acceptor, context);
770
+ await super.completionFor(context, next, acceptor);
186
771
  return true;
187
772
  }
188
773
  return false;
189
774
  }
190
- handleContainerCompletions(container, node, acceptor, context, next) {
775
+ /**
776
+ * Add import statement snippet at top level.
777
+ */
778
+ addImportSnippet(acceptor, context) {
779
+ acceptor(context, {
780
+ label: '⚡ import',
781
+ kind: CompletionItemKind.Snippet,
782
+ insertText: 'import "${1:./path}"',
783
+ insertTextFormat: InsertTextFormat.Snippet,
784
+ documentation: '📝 Snippet: Import another DomainLang file',
785
+ sortText: '0_snippet_import'
786
+ });
787
+ }
788
+ async handleContainerCompletions(container, node, acceptor, context, next) {
191
789
  if (!container) {
192
790
  return false;
193
791
  }
194
792
  // Inside BoundedContext body: suggest missing scalar properties and collections
195
793
  if (ast.isBoundedContext(container)) {
196
794
  this.addBoundedContextCompletions(container, acceptor, context);
197
- super.completionFor(context, next, acceptor);
795
+ await super.completionFor(context, next, acceptor);
198
796
  return true;
199
797
  }
200
798
  // Inside Domain body: suggest missing scalar properties
201
799
  if (ast.isDomain(container)) {
202
800
  this.addDomainCompletions(container, acceptor, context);
203
- super.completionFor(context, next, acceptor);
801
+ await super.completionFor(context, next, acceptor);
204
802
  return true;
205
803
  }
206
804
  // Inside ContextMap body: relationships and contains
207
805
  if (ast.isContextMap(container)) {
208
806
  this.addContextMapCompletions(container, acceptor, context);
209
- super.completionFor(context, next, acceptor);
807
+ await super.completionFor(context, next, acceptor);
210
808
  return true;
211
809
  }
212
810
  // Inside DomainMap body: contains
213
811
  if (ast.isDomainMap(container)) {
214
812
  this.addDomainMapCompletions(container, acceptor, context);
215
- super.completionFor(context, next, acceptor);
813
+ await super.completionFor(context, next, acceptor);
216
814
  return true;
217
815
  }
218
816
  if (ast.isRelationship(node) || ast.isRelationship(container)) {
219
817
  this.addRelationshipCompletions(acceptor, context);
220
- super.completionFor(context, next, acceptor);
818
+ await super.completionFor(context, next, acceptor);
221
819
  return true;
222
820
  }
223
821
  // Top level container (Model or NamespaceDeclaration): all top-level constructs