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