umple-lsp-server 0.2.0 → 0.2.2
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/completions.scm +21 -6
- package/definitions.scm +4 -0
- package/out/completionAnalysis.d.ts +44 -0
- package/out/completionAnalysis.js +391 -0
- package/out/completionAnalysis.js.map +1 -0
- package/out/completionBuilder.d.ts +28 -0
- package/out/completionBuilder.js +251 -0
- package/out/completionBuilder.js.map +1 -0
- package/out/documentSymbolBuilder.d.ts +13 -0
- package/out/documentSymbolBuilder.js +95 -0
- package/out/documentSymbolBuilder.js.map +1 -0
- package/out/formatter.d.ts +31 -0
- package/out/formatter.js +96 -0
- package/out/formatter.js.map +1 -0
- package/out/hoverBuilder.d.ts +21 -0
- package/out/hoverBuilder.js +308 -0
- package/out/hoverBuilder.js.map +1 -0
- package/out/importGraph.d.ts +28 -0
- package/out/importGraph.js +91 -0
- package/out/importGraph.js.map +1 -0
- package/out/referenceSearch.d.ts +22 -0
- package/out/referenceSearch.js +271 -0
- package/out/referenceSearch.js.map +1 -0
- package/out/resolver.d.ts +21 -0
- package/out/resolver.js +174 -0
- package/out/resolver.js.map +1 -0
- package/out/server.js +350 -328
- package/out/server.js.map +1 -1
- package/out/symbolIndex.d.ts +86 -94
- package/out/symbolIndex.js +357 -399
- package/out/symbolIndex.js.map +1 -1
- package/out/symbolTypes.d.ts +34 -0
- package/out/symbolTypes.js +9 -0
- package/out/symbolTypes.js.map +1 -0
- package/out/tokenAnalysis.d.ts +24 -0
- package/out/tokenAnalysis.js +195 -0
- package/out/tokenAnalysis.js.map +1 -0
- package/out/tokenTypes.d.ts +46 -0
- package/out/tokenTypes.js +28 -0
- package/out/tokenTypes.js.map +1 -0
- package/out/treeUtils.d.ts +32 -0
- package/out/treeUtils.js +89 -0
- package/out/treeUtils.js.map +1 -0
- package/package.json +4 -2
- package/references.scm +78 -10
- package/tree-sitter-umple.wasm +0 -0
package/out/symbolIndex.js
CHANGED
|
@@ -12,50 +12,11 @@ const path = require("path");
|
|
|
12
12
|
// web-tree-sitter types and module
|
|
13
13
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
14
|
const TreeSitter = require("web-tree-sitter");
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
"class",
|
|
21
|
-
"interface",
|
|
22
|
-
"trait",
|
|
23
|
-
"enum",
|
|
24
|
-
"mixset",
|
|
25
|
-
"req",
|
|
26
|
-
"associationClass",
|
|
27
|
-
"statemachine",
|
|
28
|
-
"namespace",
|
|
29
|
-
"generate",
|
|
30
|
-
// State machine modifiers — next token is SM name (or another modifier)
|
|
31
|
-
"queued",
|
|
32
|
-
"pooled",
|
|
33
|
-
// Emit methods — next token is the method name
|
|
34
|
-
"emit",
|
|
35
|
-
]);
|
|
36
|
-
/** Structural tokens that should NOT appear in completions. */
|
|
37
|
-
const STRUCTURAL_TOKENS = new Set([
|
|
38
|
-
"{",
|
|
39
|
-
"}",
|
|
40
|
-
"(",
|
|
41
|
-
")",
|
|
42
|
-
"[",
|
|
43
|
-
"]",
|
|
44
|
-
";",
|
|
45
|
-
",",
|
|
46
|
-
".",
|
|
47
|
-
"<",
|
|
48
|
-
">",
|
|
49
|
-
"=",
|
|
50
|
-
"/",
|
|
51
|
-
"[]",
|
|
52
|
-
"*",
|
|
53
|
-
"||",
|
|
54
|
-
]);
|
|
55
|
-
function isOperatorToken(name) {
|
|
56
|
-
// Matches association/transition arrows: --, ->, <-, <@>-, -<@>, >->, <-<
|
|
57
|
-
return /^[<>-]/.test(name) && name.length > 1;
|
|
58
|
-
}
|
|
15
|
+
const treeUtils_1 = require("./treeUtils");
|
|
16
|
+
const tokenAnalysis_1 = require("./tokenAnalysis");
|
|
17
|
+
const completionAnalysis_1 = require("./completionAnalysis");
|
|
18
|
+
const referenceSearch_1 = require("./referenceSearch");
|
|
19
|
+
const importGraph_1 = require("./importGraph");
|
|
59
20
|
class SymbolIndex {
|
|
60
21
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
22
|
parser = null;
|
|
@@ -69,6 +30,8 @@ class SymbolIndex {
|
|
|
69
30
|
isAByFile = new Map();
|
|
70
31
|
// Global isA graph: className → parent names (merged from all files)
|
|
71
32
|
isAGraph = new Map();
|
|
33
|
+
// Import graph: forward/reverse edge management
|
|
34
|
+
importGraph = new importGraph_1.ImportGraph();
|
|
72
35
|
initialized = false;
|
|
73
36
|
/**
|
|
74
37
|
* Initialize the tree-sitter parser with the Umple grammar.
|
|
@@ -134,14 +97,32 @@ class SymbolIndex {
|
|
|
134
97
|
// Content unchanged, skip re-indexing
|
|
135
98
|
return false;
|
|
136
99
|
}
|
|
137
|
-
//
|
|
100
|
+
// Parse the file
|
|
101
|
+
const tree = this.parser.parse(fileContent);
|
|
102
|
+
// Remove old symbols for this file from the container index
|
|
138
103
|
if (existing) {
|
|
139
104
|
this.removeFileSymbols(filePath);
|
|
140
105
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
|
|
106
|
+
const newSymbols = this.extractSymbols(filePath, tree.rootNode);
|
|
107
|
+
// Kind-sensitive error preservation: when the tree has errors,
|
|
108
|
+
// live-update recovery-safe kinds (whose identity doesn't depend
|
|
109
|
+
// on AST nesting) but preserve recovery-fragile kinds (state,
|
|
110
|
+
// statemachine) from the last clean snapshot.
|
|
111
|
+
let symbols;
|
|
112
|
+
if (tree.rootNode.hasError) {
|
|
113
|
+
const LIVE_KINDS = new Set([
|
|
114
|
+
"class", "interface", "trait", "enum",
|
|
115
|
+
"mixset", "attribute", "const",
|
|
116
|
+
]);
|
|
117
|
+
const liveSymbols = newSymbols.filter((s) => LIVE_KINDS.has(s.kind));
|
|
118
|
+
const preservedSymbols = existing
|
|
119
|
+
? existing.symbols.filter((s) => !LIVE_KINDS.has(s.kind))
|
|
120
|
+
: [];
|
|
121
|
+
symbols = [...liveSymbols, ...preservedSymbols];
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
symbols = newSymbols;
|
|
125
|
+
}
|
|
145
126
|
// Extract isA relationships
|
|
146
127
|
const isAMap = this.extractIsARelationships(tree.rootNode);
|
|
147
128
|
this.isAByFile.set(filePath, isAMap);
|
|
@@ -160,11 +141,12 @@ class SymbolIndex {
|
|
|
160
141
|
this.symbolsByContainer.set(symbol.container, containerSyms);
|
|
161
142
|
}
|
|
162
143
|
}
|
|
144
|
+
// Update forward/reverse import maps
|
|
145
|
+
this.updateImportMaps(filePath, fileContent);
|
|
163
146
|
return true;
|
|
164
147
|
}
|
|
165
148
|
/**
|
|
166
|
-
* Update a file with new content.
|
|
167
|
-
* For web-tree-sitter, we do a full reparse but the index diffing is still efficient.
|
|
149
|
+
* Update a file with new content from the live editor.
|
|
168
150
|
*/
|
|
169
151
|
updateFile(filePath, content) {
|
|
170
152
|
return this.indexFile(filePath, content);
|
|
@@ -211,6 +193,22 @@ class SymbolIndex {
|
|
|
211
193
|
}
|
|
212
194
|
return result;
|
|
213
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Get all symbols defined in a specific file (for document outline).
|
|
198
|
+
*/
|
|
199
|
+
getFileSymbols(filePath) {
|
|
200
|
+
return this.files.get(filePath)?.symbols ?? [];
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Get the parsed tree for a file (for formatting, etc.).
|
|
204
|
+
*/
|
|
205
|
+
getTree(filePath) {
|
|
206
|
+
return this.files.get(filePath)?.tree ?? null;
|
|
207
|
+
}
|
|
208
|
+
/** Get the direct isA parents for a class name. */
|
|
209
|
+
getIsAParents(className) {
|
|
210
|
+
return this.isAGraph.get(className) ?? [];
|
|
211
|
+
}
|
|
214
212
|
collectFromContainerChain(container, kindSet, name, visited, result) {
|
|
215
213
|
if (visited.has(container))
|
|
216
214
|
return;
|
|
@@ -256,8 +254,8 @@ class SymbolIndex {
|
|
|
256
254
|
const usePaths = [];
|
|
257
255
|
const visit = (node) => {
|
|
258
256
|
if (node.type === "use_statement") {
|
|
259
|
-
const
|
|
260
|
-
|
|
257
|
+
const pathNodes = node.childrenForFieldName("path");
|
|
258
|
+
for (const pathNode of pathNodes) {
|
|
261
259
|
usePaths.push(pathNode.text);
|
|
262
260
|
}
|
|
263
261
|
}
|
|
@@ -298,8 +296,8 @@ class SymbolIndex {
|
|
|
298
296
|
const useStatements = [];
|
|
299
297
|
const visit = (node) => {
|
|
300
298
|
if (node.type === "use_statement") {
|
|
301
|
-
const
|
|
302
|
-
|
|
299
|
+
const pathNodes = node.childrenForFieldName("path");
|
|
300
|
+
for (const pathNode of pathNodes) {
|
|
303
301
|
useStatements.push({
|
|
304
302
|
path: pathNode.text,
|
|
305
303
|
line: node.startPosition.row,
|
|
@@ -318,17 +316,8 @@ class SymbolIndex {
|
|
|
318
316
|
return useStatements;
|
|
319
317
|
}
|
|
320
318
|
/**
|
|
321
|
-
* Get completion information at a position
|
|
322
|
-
*
|
|
323
|
-
* Operates on the ORIGINAL parse tree (no dummy insertion). Uses:
|
|
324
|
-
* 1. Previous leaf node's nextParseState → LookaheadIterator for keywords
|
|
325
|
-
* 2. completions.scm query for symbol kind detection
|
|
326
|
-
* 3. Simple tree checks for comments, definition names
|
|
327
|
-
*
|
|
328
|
-
* @param content The document text (original)
|
|
329
|
-
* @param line 0-indexed cursor line
|
|
330
|
-
* @param column 0-indexed cursor column
|
|
331
|
-
* @returns CompletionInfo with keywords, operators, and symbol kinds
|
|
319
|
+
* Get completion information at a position.
|
|
320
|
+
* Delegates to the pure analyzeCompletion() function after tree acquisition.
|
|
332
321
|
*/
|
|
333
322
|
getCompletionInfo(content, line, column) {
|
|
334
323
|
const empty = {
|
|
@@ -339,96 +328,22 @@ class SymbolIndex {
|
|
|
339
328
|
isComment: false,
|
|
340
329
|
prefix: "",
|
|
341
330
|
};
|
|
342
|
-
if (!this.initialized || !this.parser || !this.language) {
|
|
331
|
+
if (!this.initialized || !this.parser || !this.language || !this.completionsQuery) {
|
|
343
332
|
return empty;
|
|
344
333
|
}
|
|
345
|
-
// Parse original text (no dummy insertion)
|
|
346
334
|
const tree = this.parser.parse(content);
|
|
347
335
|
if (!tree)
|
|
348
336
|
return empty;
|
|
349
|
-
|
|
350
|
-
// Use column - 1 to land inside the token when cursor is at its end boundary
|
|
351
|
-
// (tree-sitter uses half-open intervals [start, end))
|
|
352
|
-
const nodeAtCursor = tree.rootNode.descendantForPosition({
|
|
353
|
-
row: line,
|
|
354
|
-
column: Math.max(0, column - 1),
|
|
355
|
-
});
|
|
356
|
-
if (nodeAtCursor && this.isInsideComment(nodeAtCursor)) {
|
|
357
|
-
return { ...empty, isComment: true };
|
|
358
|
-
}
|
|
359
|
-
// --- Extract prefix from the token at cursor ---
|
|
360
|
-
let prefix = "";
|
|
361
|
-
if (column > 0 &&
|
|
362
|
-
nodeAtCursor &&
|
|
363
|
-
(nodeAtCursor.type === "identifier" || nodeAtCursor.type === "use_path")) {
|
|
364
|
-
const nodeStartCol = nodeAtCursor.startPosition.row === line
|
|
365
|
-
? nodeAtCursor.startPosition.column
|
|
366
|
-
: 0;
|
|
367
|
-
prefix = nodeAtCursor.text.substring(0, column - nodeStartCol);
|
|
368
|
-
}
|
|
369
|
-
// --- Definition name check ---
|
|
370
|
-
const lastToken = this.lastTokenBeforeCursor(content, line, column);
|
|
371
|
-
if ((lastToken && DEFINITION_KEYWORDS.has(lastToken)) ||
|
|
372
|
-
this.isAtAttributeNamePosition(tree, content, line, column)) {
|
|
373
|
-
return { ...empty, isDefinitionName: true };
|
|
374
|
-
}
|
|
375
|
-
// --- LookaheadIterator for keywords ---
|
|
376
|
-
const prevLeaf = this.findPreviousLeaf(tree, content, line, column);
|
|
377
|
-
// When no previous token exists (file start / after only comments),
|
|
378
|
-
// use the node at cursor's parseState instead of state 0.
|
|
379
|
-
// State 0 is the initial LR state which is overly broad.
|
|
380
|
-
const stateId = prevLeaf
|
|
381
|
-
? prevLeaf.nextParseState
|
|
382
|
-
: (nodeAtCursor?.parseState ?? 0);
|
|
383
|
-
const keywords = [];
|
|
384
|
-
const operators = [];
|
|
385
|
-
const iter = this.language.lookaheadIterator(stateId);
|
|
386
|
-
if (iter) {
|
|
387
|
-
try {
|
|
388
|
-
for (const symbolName of iter) {
|
|
389
|
-
const typeId = iter.currentTypeId;
|
|
390
|
-
// Skip named nodes (identifier, type_name, etc.)
|
|
391
|
-
if (this.language.nodeTypeIsNamed(typeId))
|
|
392
|
-
continue;
|
|
393
|
-
// Skip structural tokens
|
|
394
|
-
if (STRUCTURAL_TOKENS.has(symbolName))
|
|
395
|
-
continue;
|
|
396
|
-
if (isOperatorToken(symbolName)) {
|
|
397
|
-
operators.push(symbolName);
|
|
398
|
-
}
|
|
399
|
-
else if (/^[a-zA-Z]/.test(symbolName)) {
|
|
400
|
-
keywords.push(symbolName);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
finally {
|
|
405
|
-
iter.delete(); // MUST free WASM memory
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
// --- Scope query for symbol kinds ---
|
|
409
|
-
const symbolKinds = this.resolveCompletionScope(tree, line, column);
|
|
410
|
-
// --- Enclosing scope for scoped lookups ---
|
|
411
|
-
const { enclosingClass, enclosingStateMachine } = this.resolveEnclosingScope(tree, line, column);
|
|
412
|
-
return {
|
|
413
|
-
keywords,
|
|
414
|
-
operators,
|
|
415
|
-
symbolKinds,
|
|
416
|
-
isDefinitionName: false,
|
|
417
|
-
isComment: false,
|
|
418
|
-
prefix,
|
|
419
|
-
enclosingClass,
|
|
420
|
-
enclosingStateMachine,
|
|
421
|
-
};
|
|
337
|
+
return (0, completionAnalysis_1.analyzeCompletion)(tree, this.language, this.completionsQuery, content, line, column);
|
|
422
338
|
}
|
|
423
339
|
/**
|
|
424
340
|
* Get the token (identifier) at a position using tree-sitter, along with
|
|
425
|
-
*
|
|
341
|
+
* context information for symbol resolution.
|
|
426
342
|
*
|
|
427
|
-
*
|
|
428
|
-
* for the cursor's context. See queries/references.scm for supported patterns.
|
|
343
|
+
* Delegates to the pure analyzeToken() function after tree acquisition.
|
|
429
344
|
*/
|
|
430
345
|
getTokenAtPosition(filePath, content, line, column) {
|
|
431
|
-
if (!this.initialized || !this.parser) {
|
|
346
|
+
if (!this.initialized || !this.parser || !this.referencesQuery) {
|
|
432
347
|
return null;
|
|
433
348
|
}
|
|
434
349
|
const fileIndex = this.files.get(filePath);
|
|
@@ -440,14 +355,39 @@ class SymbolIndex {
|
|
|
440
355
|
else {
|
|
441
356
|
tree = this.parser.parse(content);
|
|
442
357
|
}
|
|
358
|
+
return (0, tokenAnalysis_1.analyzeToken)(tree, this.referencesQuery, line, column);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Get the exact range of the token node at a position.
|
|
362
|
+
* Used by prepareRename to return the precise rename range.
|
|
363
|
+
* Accepts the same node types as getTokenAtPosition(): identifier,
|
|
364
|
+
* use_path, and filter_pattern.
|
|
365
|
+
*/
|
|
366
|
+
getNodeRangeAtPosition(filePath, content, line, column) {
|
|
367
|
+
if (!this.initialized || !this.parser)
|
|
368
|
+
return null;
|
|
369
|
+
const fileIndex = this.files.get(filePath);
|
|
370
|
+
let tree;
|
|
371
|
+
if (fileIndex?.tree &&
|
|
372
|
+
fileIndex.contentHash === this.hashContent(content)) {
|
|
373
|
+
tree = fileIndex.tree;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
tree = this.parser.parse(content);
|
|
377
|
+
}
|
|
443
378
|
const node = tree.rootNode.descendantForPosition({ row: line, column });
|
|
444
|
-
if (!node ||
|
|
379
|
+
if (!node ||
|
|
380
|
+
(node.type !== "identifier" &&
|
|
381
|
+
node.type !== "use_path" &&
|
|
382
|
+
node.type !== "filter_pattern")) {
|
|
445
383
|
return null;
|
|
446
384
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
385
|
+
return {
|
|
386
|
+
startLine: node.startPosition.row,
|
|
387
|
+
startColumn: node.startPosition.column,
|
|
388
|
+
endLine: node.endPosition.row,
|
|
389
|
+
endColumn: node.endPosition.column,
|
|
390
|
+
};
|
|
451
391
|
}
|
|
452
392
|
/**
|
|
453
393
|
* Use the references.scm query to determine which symbol kinds an
|
|
@@ -457,263 +397,83 @@ class SymbolIndex {
|
|
|
457
397
|
* encoding the valid symbol kinds directly. This replaces the old parent-chain
|
|
458
398
|
* walking approach with a declarative .scm file.
|
|
459
399
|
*/
|
|
460
|
-
resolveDefinitionKinds(tree, node) {
|
|
461
|
-
if (!this.referencesQuery)
|
|
462
|
-
return null;
|
|
463
|
-
// Run query with position filtering to find captures at this node
|
|
464
|
-
const captures = this.referencesQuery.captures(tree.rootNode, {
|
|
465
|
-
startPosition: node.startPosition,
|
|
466
|
-
endPosition: node.endPosition,
|
|
467
|
-
});
|
|
468
|
-
// Find the capture whose node best matches our target.
|
|
469
|
-
// When multiple patterns capture the same node (e.g. isA type matched
|
|
470
|
-
// by both type_name and isa_declaration), pick the most specific:
|
|
471
|
-
// 1. Smallest node size (fewest bytes)
|
|
472
|
-
// 2. Fewest kinds in the capture name (fewer = more specific)
|
|
473
|
-
let bestCapture = null;
|
|
474
|
-
let bestSize = Infinity;
|
|
475
|
-
let bestKindCount = Infinity;
|
|
476
|
-
for (const capture of captures) {
|
|
477
|
-
if (capture.node.startIndex <= node.startIndex &&
|
|
478
|
-
capture.node.endIndex >= node.endIndex) {
|
|
479
|
-
const size = capture.node.endIndex - capture.node.startIndex;
|
|
480
|
-
const kindCount = capture.name.split("_").length;
|
|
481
|
-
if (size < bestSize ||
|
|
482
|
-
(size === bestSize && kindCount < bestKindCount)) {
|
|
483
|
-
bestSize = size;
|
|
484
|
-
bestKindCount = kindCount;
|
|
485
|
-
bestCapture = capture;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
if (!bestCapture)
|
|
490
|
-
return null;
|
|
491
|
-
// Parse capture name: "reference.class_interface_trait" → ["class", "interface", "trait"]
|
|
492
|
-
const prefix = "reference.";
|
|
493
|
-
if (!bestCapture.name.startsWith(prefix))
|
|
494
|
-
return null;
|
|
495
|
-
const kindStr = bestCapture.name.substring(prefix.length);
|
|
496
|
-
return kindStr.split("_");
|
|
497
|
-
}
|
|
498
|
-
// =====================
|
|
499
|
-
// Private methods
|
|
500
|
-
// =====================
|
|
501
400
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
401
|
+
* Get the names of direct child states of a given state path within a state machine.
|
|
402
|
+
* Uses pre-computed statePath on each SymbolEntry for O(n) lookup without AST walking.
|
|
504
403
|
*
|
|
505
|
-
* @param
|
|
506
|
-
* @param
|
|
507
|
-
* @
|
|
508
|
-
* @param column 0-indexed cursor column
|
|
509
|
-
* @returns The previous leaf node, or null if cursor is at file start
|
|
510
|
-
*/
|
|
511
|
-
findPreviousLeaf(tree, content, line, column) {
|
|
512
|
-
// Convert (line, column) to absolute offset
|
|
513
|
-
const lines = content.split("\n");
|
|
514
|
-
let offset = 0;
|
|
515
|
-
for (let i = 0; i < line && i < lines.length; i++) {
|
|
516
|
-
offset += lines[i].length + 1; // +1 for newline
|
|
517
|
-
}
|
|
518
|
-
offset += Math.min(column, lines[line]?.length ?? 0);
|
|
519
|
-
// Skip the current partial identifier backwards
|
|
520
|
-
let pos = offset;
|
|
521
|
-
while (pos > 0 && /[a-zA-Z_0-9]/.test(content[pos - 1])) {
|
|
522
|
-
pos--;
|
|
523
|
-
}
|
|
524
|
-
// Skip whitespace backwards
|
|
525
|
-
while (pos > 0 && /\s/.test(content[pos - 1])) {
|
|
526
|
-
pos--;
|
|
527
|
-
}
|
|
528
|
-
if (pos === 0)
|
|
529
|
-
return null;
|
|
530
|
-
// Find the node at (pos - 1) — the last character before the gap
|
|
531
|
-
let node = tree.rootNode.descendantForIndex(pos - 1, pos - 1);
|
|
532
|
-
if (!node)
|
|
533
|
-
return null;
|
|
534
|
-
// Skip extra nodes (comments) by walking to previous siblings
|
|
535
|
-
while (node && node.isExtra) {
|
|
536
|
-
const prev = node.previousSibling;
|
|
537
|
-
if (prev) {
|
|
538
|
-
node = prev;
|
|
539
|
-
while (node.childCount > 0) {
|
|
540
|
-
node = node.lastChild;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
else if (node.parent) {
|
|
544
|
-
node = node.parent;
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
return null;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
// Walk to leaf
|
|
551
|
-
while (node && node.childCount > 0) {
|
|
552
|
-
node = node.lastChild;
|
|
553
|
-
}
|
|
554
|
-
return node;
|
|
555
|
-
}
|
|
556
|
-
/**
|
|
557
|
-
* Run completions.scm to find the innermost scope at the cursor position.
|
|
558
|
-
* Returns symbol kinds to offer, "suppress", "use_path", or null (keywords only).
|
|
404
|
+
* @param parentPath Path segments to the parent state (e.g., ["EEE", "Open"])
|
|
405
|
+
* @param smContainer Qualified SM container (e.g., "ClassName.smName")
|
|
406
|
+
* @returns Names of direct child states of the resolved parent
|
|
559
407
|
*/
|
|
560
|
-
|
|
561
|
-
if (
|
|
562
|
-
return
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// Find the innermost (smallest) scope that contains the cursor
|
|
570
|
-
let best = null;
|
|
571
|
-
for (const capture of captures) {
|
|
572
|
-
const node = capture.node;
|
|
573
|
-
const startOk = node.startPosition.row < line ||
|
|
574
|
-
(node.startPosition.row === line &&
|
|
575
|
-
node.startPosition.column <= column);
|
|
576
|
-
const endOk = node.endPosition.row > line ||
|
|
577
|
-
(node.endPosition.row === line && node.endPosition.column >= column);
|
|
578
|
-
if (startOk && endOk) {
|
|
579
|
-
const size = node.endIndex - node.startIndex;
|
|
580
|
-
if (!best || size < best.size) {
|
|
581
|
-
best = { name: capture.name, size };
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
if (!best)
|
|
586
|
-
return null;
|
|
587
|
-
const prefix = "scope.";
|
|
588
|
-
if (!best.name.startsWith(prefix))
|
|
589
|
-
return null;
|
|
590
|
-
const kindStr = best.name.substring(prefix.length);
|
|
591
|
-
if (kindStr === "suppress")
|
|
592
|
-
return "suppress";
|
|
593
|
-
if (kindStr === "use_path")
|
|
594
|
-
return "use_path";
|
|
595
|
-
if (kindStr === "own_attribute")
|
|
596
|
-
return "own_attribute";
|
|
597
|
-
if (kindStr === "none")
|
|
598
|
-
return null;
|
|
599
|
-
return kindStr.split("_");
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Check if a node is inside a comment (line_comment or block_comment).
|
|
603
|
-
*/
|
|
604
|
-
isInsideComment(node) {
|
|
605
|
-
let current = node;
|
|
606
|
-
while (current) {
|
|
607
|
-
if (current.type === "line_comment" || current.type === "block_comment") {
|
|
608
|
-
return true;
|
|
609
|
-
}
|
|
610
|
-
current = current.parent;
|
|
611
|
-
}
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
/**
|
|
615
|
-
* Resolve enclosing class and root state machine names at a position.
|
|
616
|
-
* For state machines, keeps walking to find the outermost (root) SM.
|
|
617
|
-
*/
|
|
618
|
-
resolveEnclosingScope(tree, line, column) {
|
|
619
|
-
let node = tree.rootNode.descendantForPosition({
|
|
620
|
-
row: line,
|
|
621
|
-
column,
|
|
408
|
+
getChildStateNames(parentPath, smContainer, reachableFiles) {
|
|
409
|
+
if (parentPath.length === 0)
|
|
410
|
+
return [];
|
|
411
|
+
const effectivePath = (0, treeUtils_1.stripSmPrefix)(parentPath, smContainer);
|
|
412
|
+
if (effectivePath.length === 0)
|
|
413
|
+
return [];
|
|
414
|
+
const allStates = this.getSymbols({
|
|
415
|
+
container: smContainer,
|
|
416
|
+
kind: "state",
|
|
622
417
|
});
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
[
|
|
638
|
-
"class_definition",
|
|
639
|
-
"trait_definition",
|
|
640
|
-
"interface_definition",
|
|
641
|
-
"association_class_definition",
|
|
642
|
-
].includes(node.type)) {
|
|
643
|
-
enclosingClass = node.childForFieldName("name")?.text;
|
|
644
|
-
}
|
|
645
|
-
node = node.parent;
|
|
646
|
-
}
|
|
647
|
-
return { enclosingClass, enclosingStateMachine };
|
|
648
|
-
}
|
|
649
|
-
/**
|
|
650
|
-
* Check if the cursor is at an attribute name position (after a type name).
|
|
651
|
-
* E.g., "Integer |" — the previous leaf is inside a type_name that is the
|
|
652
|
-
* "type" field of an attribute_declaration, const_declaration, method, or param.
|
|
653
|
-
*/
|
|
654
|
-
isAtAttributeNamePosition(tree, content, line, column) {
|
|
655
|
-
const prevLeaf = this.findPreviousLeaf(tree, content, line, column);
|
|
656
|
-
if (!prevLeaf)
|
|
657
|
-
return false;
|
|
658
|
-
// Walk up to find if prevLeaf is inside a type_name
|
|
659
|
-
let node = prevLeaf;
|
|
660
|
-
while (node) {
|
|
661
|
-
if (node.type === "type_name") {
|
|
662
|
-
const parent = node.parent;
|
|
663
|
-
if (parent) {
|
|
664
|
-
for (let i = 0; i < parent.childCount; i++) {
|
|
665
|
-
if (parent.child(i)?.id === node.id) {
|
|
666
|
-
const fieldName = parent.fieldNameForChild(i);
|
|
667
|
-
if (fieldName === "type" || fieldName === "return_type") {
|
|
668
|
-
return true;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
}
|
|
418
|
+
const names = new Set();
|
|
419
|
+
for (const s of allStates) {
|
|
420
|
+
if (reachableFiles && !reachableFiles.has(path.normalize(s.file)))
|
|
421
|
+
continue;
|
|
422
|
+
if (!s.statePath || s.statePath.length < effectivePath.length + 1)
|
|
423
|
+
continue;
|
|
424
|
+
// Suffix match: check if effectivePath matches the segments ending
|
|
425
|
+
// just before the child name (e.g., ["Closed"] matches tail of ["EEE","Closed","Inner"])
|
|
426
|
+
const suffixStart = s.statePath.length - effectivePath.length - 1;
|
|
427
|
+
let match = true;
|
|
428
|
+
for (let i = 0; i < effectivePath.length; i++) {
|
|
429
|
+
if (s.statePath[suffixStart + i] !== effectivePath[i]) {
|
|
430
|
+
match = false;
|
|
431
|
+
break;
|
|
672
432
|
}
|
|
673
|
-
break;
|
|
674
433
|
}
|
|
675
|
-
|
|
434
|
+
if (match)
|
|
435
|
+
names.add(s.name);
|
|
676
436
|
}
|
|
677
|
-
return
|
|
437
|
+
return [...names];
|
|
678
438
|
}
|
|
679
439
|
/**
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* then whitespace, then extracts the previous token.
|
|
440
|
+
* Resolve a state within a dotted path context, returning the matching SymbolEntry.
|
|
441
|
+
* Uses pre-computed statePath for exact path matching without AST walking.
|
|
683
442
|
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
443
|
+
* @param precedingPath Path segments before the target (e.g., ["EEE", "Open"])
|
|
444
|
+
* @param targetName The target state name (e.g., "Inner")
|
|
445
|
+
* @param smContainer Qualified SM container (e.g., "ClassName.smName")
|
|
446
|
+
* @returns The SymbolEntry for the target state, or undefined if not found
|
|
688
447
|
*/
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
return null; // Hit punctuation, not a word
|
|
715
|
-
return content.substring(start, pos);
|
|
448
|
+
resolveStateInPath(precedingPath, targetName, smContainer, reachableFiles) {
|
|
449
|
+
if (precedingPath.length === 0)
|
|
450
|
+
return undefined;
|
|
451
|
+
const effectivePath = (0, treeUtils_1.stripSmPrefix)(precedingPath, smContainer);
|
|
452
|
+
const targetPath = [...effectivePath, targetName];
|
|
453
|
+
let candidates = this.getSymbols({
|
|
454
|
+
container: smContainer,
|
|
455
|
+
kind: "state",
|
|
456
|
+
name: targetName,
|
|
457
|
+
});
|
|
458
|
+
if (reachableFiles) {
|
|
459
|
+
candidates = candidates.filter((s) => reachableFiles.has(path.normalize(s.file)));
|
|
460
|
+
}
|
|
461
|
+
// Suffix match: targetPath may be a partial path (e.g., ["Closed","Inner"])
|
|
462
|
+
// that matches the tail of a full statePath (e.g., ["EEE","Closed","Inner"])
|
|
463
|
+
return candidates.find((s) => {
|
|
464
|
+
if (!s.statePath || s.statePath.length < targetPath.length)
|
|
465
|
+
return false;
|
|
466
|
+
const offset = s.statePath.length - targetPath.length;
|
|
467
|
+
for (let i = 0; i < targetPath.length; i++) {
|
|
468
|
+
if (s.statePath[offset + i] !== targetPath[i])
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
472
|
+
});
|
|
716
473
|
}
|
|
474
|
+
// =====================
|
|
475
|
+
// Private methods
|
|
476
|
+
// =====================
|
|
717
477
|
readFileSafe(filePath) {
|
|
718
478
|
try {
|
|
719
479
|
return fs.readFileSync(filePath, "utf-8");
|
|
@@ -838,6 +598,7 @@ class SymbolIndex {
|
|
|
838
598
|
*/
|
|
839
599
|
resolveStateMachineContainer(node) {
|
|
840
600
|
let rootSmName;
|
|
601
|
+
let className;
|
|
841
602
|
let current = node.parent;
|
|
842
603
|
while (current) {
|
|
843
604
|
if (current.type === "state_machine") {
|
|
@@ -846,10 +607,36 @@ class SymbolIndex {
|
|
|
846
607
|
if (current.type === "statemachine_definition") {
|
|
847
608
|
rootSmName = current.childForFieldName("name")?.text ?? rootSmName;
|
|
848
609
|
}
|
|
610
|
+
if (!className &&
|
|
611
|
+
[
|
|
612
|
+
"class_definition",
|
|
613
|
+
"trait_definition",
|
|
614
|
+
"interface_definition",
|
|
615
|
+
"association_class_definition",
|
|
616
|
+
].includes(current.type)) {
|
|
617
|
+
className = current.childForFieldName("name")?.text;
|
|
618
|
+
}
|
|
849
619
|
current = current.parent;
|
|
850
620
|
}
|
|
851
|
-
|
|
621
|
+
if (!rootSmName)
|
|
622
|
+
return undefined;
|
|
623
|
+
return className ? `${className}.${rootSmName}` : rootSmName;
|
|
852
624
|
}
|
|
625
|
+
/** For enum values: walk up to find the enclosing enum_definition name. */
|
|
626
|
+
resolveEnumContainer(node) {
|
|
627
|
+
let current = node.parent;
|
|
628
|
+
while (current) {
|
|
629
|
+
if (current.type === "enum_definition") {
|
|
630
|
+
return current.childForFieldName("name")?.text;
|
|
631
|
+
}
|
|
632
|
+
current = current.parent;
|
|
633
|
+
}
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Build the nesting path for a state by walking up parent state nodes.
|
|
638
|
+
* E.g., for Inner inside Open inside EEE: ["EEE", "Open", "Inner"]
|
|
639
|
+
*/
|
|
853
640
|
/**
|
|
854
641
|
* Extract symbol definitions from the AST using the definitions.scm query.
|
|
855
642
|
* Falls back to an empty list if the query isn't loaded.
|
|
@@ -870,15 +657,22 @@ class SymbolIndex {
|
|
|
870
657
|
container = this.resolveStateMachineContainer(node);
|
|
871
658
|
}
|
|
872
659
|
else if (kind === "attribute" ||
|
|
660
|
+
kind === "const" ||
|
|
873
661
|
kind === "method" ||
|
|
874
|
-
kind === "template"
|
|
662
|
+
kind === "template" ||
|
|
663
|
+
kind === "tracecase") {
|
|
875
664
|
container = this.resolveClassContainer(node);
|
|
876
665
|
}
|
|
666
|
+
else if (kind === "enum_value") {
|
|
667
|
+
// Enum values belong to their enclosing enum
|
|
668
|
+
container = this.resolveEnumContainer(node);
|
|
669
|
+
}
|
|
877
670
|
else {
|
|
878
671
|
// Top-level symbols (class, interface, trait, enum, etc.) are self-containers
|
|
879
672
|
container = node.text;
|
|
880
673
|
}
|
|
881
|
-
|
|
674
|
+
const defNode = node.parent;
|
|
675
|
+
const entry = {
|
|
882
676
|
name: node.text,
|
|
883
677
|
kind,
|
|
884
678
|
file: filePath,
|
|
@@ -887,10 +681,174 @@ class SymbolIndex {
|
|
|
887
681
|
endLine: node.endPosition.row,
|
|
888
682
|
endColumn: node.endPosition.column,
|
|
889
683
|
container,
|
|
890
|
-
|
|
684
|
+
defLine: defNode?.startPosition.row,
|
|
685
|
+
defColumn: defNode?.startPosition.column,
|
|
686
|
+
defEndLine: defNode?.endPosition.row,
|
|
687
|
+
defEndColumn: defNode?.endPosition.column,
|
|
688
|
+
};
|
|
689
|
+
if (kind === "state") {
|
|
690
|
+
entry.statePath = (0, treeUtils_1.resolveStatePath)(node);
|
|
691
|
+
}
|
|
692
|
+
symbols.push(entry);
|
|
891
693
|
}
|
|
892
694
|
return symbols;
|
|
893
695
|
}
|
|
696
|
+
// ── Workspace indexing & import graph ──────────────────────────────────
|
|
697
|
+
/**
|
|
698
|
+
* Scan workspace roots for all .ump files, follow use chains to external
|
|
699
|
+
* files, and index everything. Content-hash skips unchanged files.
|
|
700
|
+
*
|
|
701
|
+
* @param workspaceRoots Workspace root directories
|
|
702
|
+
* @param getOpenDocContent Returns in-memory editor content if the file is open, undefined otherwise
|
|
703
|
+
*/
|
|
704
|
+
indexWorkspace(workspaceRoots, getOpenDocContent) {
|
|
705
|
+
if (!this.initialized || !this.parser)
|
|
706
|
+
return;
|
|
707
|
+
// 1. Discover all .ump files under workspace roots
|
|
708
|
+
const discoveredFiles = new Set();
|
|
709
|
+
for (const root of workspaceRoots) {
|
|
710
|
+
this.globUmpFiles(root, discoveredFiles);
|
|
711
|
+
}
|
|
712
|
+
// 2. Index each discovered file (open-doc content takes precedence)
|
|
713
|
+
for (const filePath of discoveredFiles) {
|
|
714
|
+
const openContent = getOpenDocContent(filePath);
|
|
715
|
+
if (openContent !== undefined) {
|
|
716
|
+
this.indexFile(filePath, openContent);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
this.indexFile(filePath);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// 3. Follow use chains to index external files (outside workspace roots)
|
|
723
|
+
// Only treat an external file as live if it's open in the editor or exists on disk.
|
|
724
|
+
// This prevents stale forward-import edges from re-adding deleted files.
|
|
725
|
+
const externalFiles = new Set();
|
|
726
|
+
for (const filePath of discoveredFiles) {
|
|
727
|
+
const imports = this.importGraph.getForward(filePath);
|
|
728
|
+
if (imports) {
|
|
729
|
+
for (const imp of imports) {
|
|
730
|
+
if (!discoveredFiles.has(imp) &&
|
|
731
|
+
!externalFiles.has(imp) &&
|
|
732
|
+
(getOpenDocContent(imp) !== undefined || fs.existsSync(imp))) {
|
|
733
|
+
externalFiles.add(imp);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Index external files and follow their chains too (transitive)
|
|
739
|
+
const queue = [...externalFiles];
|
|
740
|
+
while (queue.length > 0) {
|
|
741
|
+
const filePath = queue.pop();
|
|
742
|
+
const openContent = getOpenDocContent(filePath);
|
|
743
|
+
if (openContent !== undefined) {
|
|
744
|
+
this.indexFile(filePath, openContent);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
this.indexFile(filePath);
|
|
748
|
+
}
|
|
749
|
+
const imports = this.importGraph.getForward(filePath);
|
|
750
|
+
if (imports) {
|
|
751
|
+
for (const imp of imports) {
|
|
752
|
+
if (!discoveredFiles.has(imp) &&
|
|
753
|
+
!externalFiles.has(imp) &&
|
|
754
|
+
(getOpenDocContent(imp) !== undefined || fs.existsSync(imp))) {
|
|
755
|
+
externalFiles.add(imp);
|
|
756
|
+
queue.push(imp);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// 4. Remove stale indexed files.
|
|
762
|
+
// A file is removed if it's not in the discovered/external set AND not open in the editor.
|
|
763
|
+
// Note: fs.existsSync is intentionally NOT checked here — a file that still exists on disk
|
|
764
|
+
// but is no longer reachable (e.g., use statement removed) should be dropped from the index.
|
|
765
|
+
const allKnownFiles = new Set([...discoveredFiles, ...externalFiles]);
|
|
766
|
+
for (const filePath of this.files.keys()) {
|
|
767
|
+
if (!allKnownFiles.has(filePath) &&
|
|
768
|
+
getOpenDocContent(filePath) === undefined) {
|
|
769
|
+
this.removeFile(filePath);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Get all files whose use chain can reach any of the given declaration files.
|
|
775
|
+
* Transitive reverse closure.
|
|
776
|
+
*/
|
|
777
|
+
getReverseImporters(declarationFiles) {
|
|
778
|
+
return this.importGraph.getReverseImporters(declarationFiles);
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Find all references to a symbol across the given files.
|
|
782
|
+
* Delegates to the extracted searchReferences() function.
|
|
783
|
+
*/
|
|
784
|
+
findReferences(declarations, filesToSearch, includeDeclaration) {
|
|
785
|
+
if (!this.referencesQuery || declarations.length === 0)
|
|
786
|
+
return [];
|
|
787
|
+
// Build filePath→tree map for the files to search
|
|
788
|
+
const fileTreeMap = new Map();
|
|
789
|
+
for (const fp of filesToSearch) {
|
|
790
|
+
const tree = this.files.get(fp)?.tree;
|
|
791
|
+
if (tree)
|
|
792
|
+
fileTreeMap.set(fp, tree);
|
|
793
|
+
}
|
|
794
|
+
return (0, referenceSearch_1.searchReferences)(declarations, includeDeclaration, this.referencesQuery, fileTreeMap, this.isAGraph);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Update forward/reverse import maps for a file.
|
|
798
|
+
*/
|
|
799
|
+
updateImportMaps(filePath, content) {
|
|
800
|
+
// Compute resolved imports from use statements
|
|
801
|
+
const usePaths = this.extractUseStatements(filePath, content);
|
|
802
|
+
const fileDir = path.dirname(filePath);
|
|
803
|
+
const newImports = new Set();
|
|
804
|
+
for (const usePath of usePaths) {
|
|
805
|
+
if (!usePath.endsWith(".ump"))
|
|
806
|
+
continue;
|
|
807
|
+
const resolved = path.isAbsolute(usePath)
|
|
808
|
+
? path.normalize(usePath)
|
|
809
|
+
: path.normalize(path.resolve(fileDir, usePath));
|
|
810
|
+
if (fs.existsSync(resolved)) {
|
|
811
|
+
newImports.add(resolved);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// Delegate edge management to ImportGraph
|
|
815
|
+
this.importGraph.setEdges(filePath, newImports);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Fully remove a file from the index (symbols, imports, isA).
|
|
819
|
+
*/
|
|
820
|
+
removeFile(filePath) {
|
|
821
|
+
this.removeFileSymbols(filePath);
|
|
822
|
+
this.files.delete(filePath);
|
|
823
|
+
this.importGraph.removeEdges(filePath);
|
|
824
|
+
this.isAByFile.delete(filePath);
|
|
825
|
+
this.rebuildIsAGraph();
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Recursively find all .ump files under a directory.
|
|
829
|
+
*/
|
|
830
|
+
globUmpFiles(dir, result) {
|
|
831
|
+
let entries;
|
|
832
|
+
try {
|
|
833
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return; // permission denied, etc.
|
|
837
|
+
}
|
|
838
|
+
for (const entry of entries) {
|
|
839
|
+
const fullPath = path.join(dir, entry.name);
|
|
840
|
+
if (entry.isDirectory()) {
|
|
841
|
+
// Skip common non-source directories
|
|
842
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "out") {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
this.globUmpFiles(fullPath, result);
|
|
846
|
+
}
|
|
847
|
+
else if (entry.name.endsWith(".ump")) {
|
|
848
|
+
result.add(path.normalize(fullPath));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
894
852
|
}
|
|
895
853
|
exports.SymbolIndex = SymbolIndex;
|
|
896
854
|
// Singleton instance
|