@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.
- package/out/domain-lang-module.d.ts +2 -0
- package/out/domain-lang-module.js +21 -2
- package/out/domain-lang-module.js.map +1 -1
- package/out/lsp/domain-lang-completion.d.ts +142 -1
- package/out/lsp/domain-lang-completion.js +620 -22
- package/out/lsp/domain-lang-completion.js.map +1 -1
- package/out/lsp/domain-lang-document-symbol-provider.d.ts +79 -0
- package/out/lsp/domain-lang-document-symbol-provider.js +210 -0
- package/out/lsp/domain-lang-document-symbol-provider.js.map +1 -0
- package/out/lsp/domain-lang-index-manager.d.ts +34 -5
- package/out/lsp/domain-lang-index-manager.js +66 -27
- package/out/lsp/domain-lang-index-manager.js.map +1 -1
- package/out/lsp/domain-lang-node-kind-provider.d.ts +27 -0
- package/out/lsp/domain-lang-node-kind-provider.js +87 -0
- package/out/lsp/domain-lang-node-kind-provider.js.map +1 -0
- package/out/lsp/domain-lang-scope-provider.d.ts +53 -20
- package/out/lsp/domain-lang-scope-provider.js +119 -44
- package/out/lsp/domain-lang-scope-provider.js.map +1 -1
- package/out/lsp/domain-lang-workspace-manager.d.ts +23 -2
- package/out/lsp/domain-lang-workspace-manager.js +51 -6
- package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
- package/out/lsp/hover/domain-lang-hover.d.ts +16 -6
- package/out/lsp/hover/domain-lang-hover.js +160 -134
- package/out/lsp/hover/domain-lang-hover.js.map +1 -1
- package/out/lsp/hover/hover-builders.d.ts +57 -0
- package/out/lsp/hover/hover-builders.js +171 -0
- package/out/lsp/hover/hover-builders.js.map +1 -0
- package/out/main.js +2 -1
- package/out/main.js.map +1 -1
- package/out/sdk/index.d.ts +31 -11
- package/out/sdk/index.js +30 -11
- package/out/sdk/index.js.map +1 -1
- package/out/sdk/loader-node.d.ts +2 -0
- package/out/sdk/loader-node.js +3 -1
- package/out/sdk/loader-node.js.map +1 -1
- package/out/sdk/loader.d.ts +55 -2
- package/out/sdk/loader.js +87 -28
- package/out/sdk/loader.js.map +1 -1
- package/out/sdk/query.js +14 -11
- package/out/sdk/query.js.map +1 -1
- package/out/sdk/validator.d.ts +134 -0
- package/out/sdk/validator.js +249 -0
- package/out/sdk/validator.js.map +1 -0
- package/out/services/package-boundary-detector.d.ts +101 -0
- package/out/services/package-boundary-detector.js +211 -0
- package/out/services/package-boundary-detector.js.map +1 -0
- package/out/services/performance-optimizer.js +6 -2
- package/out/services/performance-optimizer.js.map +1 -1
- package/out/services/types.d.ts +24 -0
- package/out/services/types.js.map +1 -1
- package/out/services/workspace-manager.d.ts +73 -6
- package/out/services/workspace-manager.js +210 -57
- package/out/services/workspace-manager.js.map +1 -1
- package/out/utils/import-utils.d.ts +9 -6
- package/out/utils/import-utils.js +26 -15
- package/out/utils/import-utils.js.map +1 -1
- package/out/validation/constants.d.ts +7 -0
- package/out/validation/constants.js +21 -3
- package/out/validation/constants.js.map +1 -1
- package/out/validation/import.d.ts +11 -1
- package/out/validation/import.js +42 -14
- package/out/validation/import.js.map +1 -1
- package/out/validation/maps.js +50 -1
- package/out/validation/maps.js.map +1 -1
- package/package.json +8 -9
- package/src/domain-lang-module.ts +24 -3
- package/src/lsp/domain-lang-completion.ts +736 -27
- package/src/lsp/domain-lang-document-symbol-provider.ts +254 -0
- package/src/lsp/domain-lang-index-manager.ts +79 -27
- package/src/lsp/domain-lang-node-kind-provider.ts +119 -0
- package/src/lsp/domain-lang-scope-provider.ts +171 -55
- package/src/lsp/domain-lang-workspace-manager.ts +64 -6
- package/src/lsp/hover/domain-lang-hover.ts +189 -131
- package/src/lsp/hover/hover-builders.ts +208 -0
- package/src/main.ts +3 -1
- package/src/sdk/index.ts +33 -11
- package/src/sdk/loader-node.ts +6 -1
- package/src/sdk/loader.ts +125 -34
- package/src/sdk/query.ts +15 -11
- package/src/sdk/validator.ts +358 -0
- package/src/services/package-boundary-detector.ts +238 -0
- package/src/services/performance-optimizer.ts +6 -2
- package/src/services/types.ts +25 -0
- package/src/services/workspace-manager.ts +259 -62
- package/src/utils/import-utils.ts +27 -15
- package/src/validation/constants.ts +23 -6
- package/src/validation/import.ts +49 -14
- package/src/validation/maps.ts +59 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|