umple-lsp-server 0.1.0 → 0.2.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/bin/umple-lsp-server +2 -0
- package/completions.scm +72 -0
- package/definitions.scm +37 -0
- package/out/keywords.d.ts +4 -36
- package/out/keywords.js +14 -200
- package/out/keywords.js.map +1 -1
- package/out/log.d.ts +7 -0
- package/out/log.js +22 -0
- package/out/log.js.map +1 -0
- package/out/server.js +266 -311
- package/out/server.js.map +1 -1
- package/out/symbolIndex.d.ts +123 -90
- package/out/symbolIndex.js +619 -426
- package/out/symbolIndex.js.map +1 -1
- package/out/tsconfig.tsbuildinfo +1 -0
- package/out/utils/debug.d.ts +1 -0
- package/out/utils/debug.js +8 -0
- package/out/utils/debug.js.map +1 -0
- package/package.json +8 -5
- package/references.scm +99 -0
- package/tree-sitter-umple.wasm +0 -0
package/out/symbolIndex.js
CHANGED
|
@@ -8,16 +8,67 @@
|
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.symbolIndex = exports.SymbolIndex = void 0;
|
|
10
10
|
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
11
12
|
// web-tree-sitter types and module
|
|
12
13
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
14
|
const TreeSitter = require("web-tree-sitter");
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Keywords after which the next token is always a new name (definition).
|
|
17
|
+
* Completions are suppressed when the cursor immediately follows one of these.
|
|
18
|
+
*/
|
|
19
|
+
const DEFINITION_KEYWORDS = new Set([
|
|
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
59
|
class SymbolIndex {
|
|
16
60
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
61
|
parser = null;
|
|
18
62
|
language = null;
|
|
63
|
+
referencesQuery = null;
|
|
64
|
+
definitionsQuery = null;
|
|
65
|
+
completionsQuery = null;
|
|
19
66
|
files = new Map();
|
|
20
|
-
|
|
67
|
+
symbolsByContainer = new Map();
|
|
68
|
+
// Per-file isA relationships (for cleanup when a file is re-indexed)
|
|
69
|
+
isAByFile = new Map();
|
|
70
|
+
// Global isA graph: className → parent names (merged from all files)
|
|
71
|
+
isAGraph = new Map();
|
|
21
72
|
initialized = false;
|
|
22
73
|
/**
|
|
23
74
|
* Initialize the tree-sitter parser with the Umple grammar.
|
|
@@ -32,6 +83,23 @@ class SymbolIndex {
|
|
|
32
83
|
// Load the WASM language
|
|
33
84
|
this.language = await TreeSitter.Language.load(wasmPath);
|
|
34
85
|
this.parser.setLanguage(this.language);
|
|
86
|
+
// Load .scm queries (co-located with WASM after build)
|
|
87
|
+
const queryDir = path.dirname(wasmPath);
|
|
88
|
+
const referencesScmPath = path.join(queryDir, "references.scm");
|
|
89
|
+
if (fs.existsSync(referencesScmPath)) {
|
|
90
|
+
const src = fs.readFileSync(referencesScmPath, "utf-8");
|
|
91
|
+
this.referencesQuery = new TreeSitter.Query(this.language, src);
|
|
92
|
+
}
|
|
93
|
+
const definitionsScmPath = path.join(queryDir, "definitions.scm");
|
|
94
|
+
if (fs.existsSync(definitionsScmPath)) {
|
|
95
|
+
const src = fs.readFileSync(definitionsScmPath, "utf-8");
|
|
96
|
+
this.definitionsQuery = new TreeSitter.Query(this.language, src);
|
|
97
|
+
}
|
|
98
|
+
const completionsScmPath = path.join(queryDir, "completions.scm");
|
|
99
|
+
if (fs.existsSync(completionsScmPath)) {
|
|
100
|
+
const src = fs.readFileSync(completionsScmPath, "utf-8");
|
|
101
|
+
this.completionsQuery = new TreeSitter.Query(this.language, src);
|
|
102
|
+
}
|
|
35
103
|
this.initialized = true;
|
|
36
104
|
return true;
|
|
37
105
|
}
|
|
@@ -74,17 +142,23 @@ class SymbolIndex {
|
|
|
74
142
|
const tree = this.parser.parse(fileContent);
|
|
75
143
|
// Extract symbols from the AST
|
|
76
144
|
const symbols = this.extractSymbols(filePath, tree.rootNode);
|
|
145
|
+
// Extract isA relationships
|
|
146
|
+
const isAMap = this.extractIsARelationships(tree.rootNode);
|
|
147
|
+
this.isAByFile.set(filePath, isAMap);
|
|
148
|
+
this.rebuildIsAGraph();
|
|
77
149
|
// Store the file index
|
|
78
150
|
this.files.set(filePath, {
|
|
79
151
|
symbols,
|
|
80
152
|
tree,
|
|
81
153
|
contentHash: hash,
|
|
82
154
|
});
|
|
83
|
-
// Add symbols to the
|
|
155
|
+
// Add symbols to the container index
|
|
84
156
|
for (const symbol of symbols) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
157
|
+
if (symbol.container) {
|
|
158
|
+
const containerSyms = this.symbolsByContainer.get(symbol.container) ?? [];
|
|
159
|
+
containerSyms.push(symbol);
|
|
160
|
+
this.symbolsByContainer.set(symbol.container, containerSyms);
|
|
161
|
+
}
|
|
88
162
|
}
|
|
89
163
|
return true;
|
|
90
164
|
}
|
|
@@ -96,115 +170,65 @@ class SymbolIndex {
|
|
|
96
170
|
return this.indexFile(filePath, content);
|
|
97
171
|
}
|
|
98
172
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* @param
|
|
102
|
-
* @
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const symbols = this.symbolsByName.get(name) ?? [];
|
|
106
|
-
if (kind) {
|
|
107
|
-
return symbols.filter((s) => s.kind === kind);
|
|
108
|
-
}
|
|
109
|
-
return symbols;
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Find definition with context (for resolving attributes/states within a class/statemachine).
|
|
113
|
-
* @param name Symbol name
|
|
114
|
-
* @param parentName Parent symbol name (class or statemachine name)
|
|
115
|
-
*/
|
|
116
|
-
findDefinitionInContext(name, parentName) {
|
|
117
|
-
const symbols = this.symbolsByName.get(name) ?? [];
|
|
118
|
-
return symbols.filter((s) => s.parent === parentName);
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Get all symbols in a file.
|
|
122
|
-
*/
|
|
123
|
-
getFileSymbols(filePath) {
|
|
124
|
-
return this.files.get(filePath)?.symbols ?? [];
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Get all indexed files.
|
|
128
|
-
*/
|
|
129
|
-
getIndexedFiles() {
|
|
130
|
-
return Array.from(this.files.keys());
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Get all symbol names.
|
|
134
|
-
*/
|
|
135
|
-
getAllSymbolNames() {
|
|
136
|
-
return Array.from(this.symbolsByName.keys());
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Remove a file from the index.
|
|
140
|
-
*/
|
|
141
|
-
removeFile(filePath) {
|
|
142
|
-
this.removeFileSymbols(filePath);
|
|
143
|
-
this.files.delete(filePath);
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Clear the entire index.
|
|
147
|
-
*/
|
|
148
|
-
clear() {
|
|
149
|
-
this.files.clear();
|
|
150
|
-
this.symbolsByName.clear();
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Get index statistics.
|
|
154
|
-
*/
|
|
155
|
-
getStats() {
|
|
156
|
-
let totalSymbols = 0;
|
|
157
|
-
for (const fileIndex of this.files.values()) {
|
|
158
|
-
totalSymbols += fileIndex.symbols.length;
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
files: this.files.size,
|
|
162
|
-
symbols: totalSymbols,
|
|
163
|
-
uniqueNames: this.symbolsByName.size,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Check if a position is inside a comment.
|
|
168
|
-
* @param filePath Path to the file
|
|
169
|
-
* @param content File content (if available, otherwise reads from disk)
|
|
170
|
-
* @param line 0-indexed line number
|
|
171
|
-
* @param column 0-indexed column number
|
|
173
|
+
* Unified symbol lookup. All old getters are replaced by this method.
|
|
174
|
+
*
|
|
175
|
+
* @param opts.container Scope to this container (class name or SM name)
|
|
176
|
+
* @param opts.kind Filter by kind(s)
|
|
177
|
+
* @param opts.name Filter by symbol name
|
|
178
|
+
* @param opts.inherited Walk isA chain when container is specified
|
|
172
179
|
*/
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
tree = fileIndex.tree;
|
|
182
|
-
}
|
|
183
|
-
else if (content) {
|
|
184
|
-
tree = this.parser.parse(content);
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
const fileContent = this.readFileSafe(filePath);
|
|
188
|
-
if (fileContent) {
|
|
189
|
-
tree = this.parser.parse(fileContent);
|
|
180
|
+
getSymbols(opts) {
|
|
181
|
+
const kindSet = opts.kind
|
|
182
|
+
? new Set(Array.isArray(opts.kind) ? opts.kind : [opts.kind])
|
|
183
|
+
: null;
|
|
184
|
+
if (opts.container) {
|
|
185
|
+
const result = [];
|
|
186
|
+
if (opts.inherited) {
|
|
187
|
+
this.collectFromContainerChain(opts.container, kindSet, opts.name, new Set(), result);
|
|
190
188
|
}
|
|
189
|
+
else {
|
|
190
|
+
const syms = this.symbolsByContainer.get(opts.container) ?? [];
|
|
191
|
+
for (const s of syms) {
|
|
192
|
+
if (kindSet && !kindSet.has(s.kind))
|
|
193
|
+
continue;
|
|
194
|
+
if (opts.name && s.name !== opts.name)
|
|
195
|
+
continue;
|
|
196
|
+
result.push(s);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
191
200
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
201
|
+
// No container: iterate all containers
|
|
202
|
+
const result = [];
|
|
203
|
+
for (const syms of this.symbolsByContainer.values()) {
|
|
204
|
+
for (const s of syms) {
|
|
205
|
+
if (kindSet && !kindSet.has(s.kind))
|
|
206
|
+
continue;
|
|
207
|
+
if (opts.name && s.name !== opts.name)
|
|
208
|
+
continue;
|
|
209
|
+
result.push(s);
|
|
210
|
+
}
|
|
198
211
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
collectFromContainerChain(container, kindSet, name, visited, result) {
|
|
215
|
+
if (visited.has(container))
|
|
216
|
+
return;
|
|
217
|
+
visited.add(container);
|
|
218
|
+
const syms = this.symbolsByContainer.get(container) ?? [];
|
|
219
|
+
for (const s of syms) {
|
|
220
|
+
if (kindSet && !kindSet.has(s.kind))
|
|
221
|
+
continue;
|
|
222
|
+
if (name && s.name !== name)
|
|
223
|
+
continue;
|
|
224
|
+
result.push(s);
|
|
225
|
+
}
|
|
226
|
+
const parents = this.isAGraph.get(container);
|
|
227
|
+
if (parents) {
|
|
228
|
+
for (const parent of parents) {
|
|
229
|
+
this.collectFromContainerChain(parent, kindSet, name, visited, result);
|
|
204
230
|
}
|
|
205
|
-
current = current.parent;
|
|
206
231
|
}
|
|
207
|
-
return false;
|
|
208
232
|
}
|
|
209
233
|
/**
|
|
210
234
|
* Extract use statement paths from a file using tree-sitter.
|
|
@@ -294,18 +318,119 @@ class SymbolIndex {
|
|
|
294
318
|
return useStatements;
|
|
295
319
|
}
|
|
296
320
|
/**
|
|
297
|
-
* Get
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
321
|
+
* Get completion information at a position using LookaheadIterator + scope query.
|
|
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
|
|
332
|
+
*/
|
|
333
|
+
getCompletionInfo(content, line, column) {
|
|
334
|
+
const empty = {
|
|
335
|
+
keywords: [],
|
|
336
|
+
operators: [],
|
|
337
|
+
symbolKinds: null,
|
|
338
|
+
isDefinitionName: false,
|
|
339
|
+
isComment: false,
|
|
340
|
+
prefix: "",
|
|
341
|
+
};
|
|
342
|
+
if (!this.initialized || !this.parser || !this.language) {
|
|
343
|
+
return empty;
|
|
344
|
+
}
|
|
345
|
+
// Parse original text (no dummy insertion)
|
|
346
|
+
const tree = this.parser.parse(content);
|
|
347
|
+
if (!tree)
|
|
348
|
+
return empty;
|
|
349
|
+
// --- Comment check ---
|
|
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
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Get the token (identifier) at a position using tree-sitter, along with
|
|
425
|
+
* an optional SymbolKind filter based on the surrounding context.
|
|
426
|
+
*
|
|
427
|
+
* Uses the references.scm query to determine which symbol kinds are valid
|
|
428
|
+
* for the cursor's context. See queries/references.scm for supported patterns.
|
|
303
429
|
*/
|
|
304
|
-
|
|
430
|
+
getTokenAtPosition(filePath, content, line, column) {
|
|
305
431
|
if (!this.initialized || !this.parser) {
|
|
306
432
|
return null;
|
|
307
433
|
}
|
|
308
|
-
// Use cached tree if available
|
|
309
434
|
const fileIndex = this.files.get(filePath);
|
|
310
435
|
let tree;
|
|
311
436
|
if (fileIndex?.tree &&
|
|
@@ -316,212 +441,278 @@ class SymbolIndex {
|
|
|
316
441
|
tree = this.parser.parse(content);
|
|
317
442
|
}
|
|
318
443
|
const node = tree.rootNode.descendantForPosition({ row: line, column });
|
|
319
|
-
if (!node) {
|
|
444
|
+
if (!node || (node.type !== "identifier" && node.type !== "use_path")) {
|
|
320
445
|
return null;
|
|
321
446
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
447
|
+
const word = node.text;
|
|
448
|
+
const kinds = this.resolveDefinitionKinds(tree, node);
|
|
449
|
+
const { enclosingClass, enclosingStateMachine } = this.resolveEnclosingScope(tree, line, column);
|
|
450
|
+
return { word, kinds, enclosingClass, enclosingStateMachine };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Use the references.scm query to determine which symbol kinds an
|
|
454
|
+
* identifier can reference based on its position in the AST.
|
|
455
|
+
*
|
|
456
|
+
* The query captures identifiers with names like @reference.class_interface_trait,
|
|
457
|
+
* encoding the valid symbol kinds directly. This replaces the old parent-chain
|
|
458
|
+
* walking approach with a declarative .scm file.
|
|
459
|
+
*/
|
|
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;
|
|
329
486
|
}
|
|
330
|
-
return null;
|
|
331
487
|
}
|
|
332
|
-
current = current.parent;
|
|
333
488
|
}
|
|
334
|
-
|
|
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("_");
|
|
335
497
|
}
|
|
498
|
+
// =====================
|
|
499
|
+
// Private methods
|
|
500
|
+
// =====================
|
|
336
501
|
/**
|
|
337
|
-
*
|
|
502
|
+
* Find the previous non-extra leaf node before the cursor position.
|
|
503
|
+
* Skips the current partial word being typed and any whitespace.
|
|
338
504
|
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* @param filePath Path to the file
|
|
346
|
-
* @param content File content (original, without dummy)
|
|
347
|
-
* @param line 0-indexed line number
|
|
348
|
-
* @param column 0-indexed column number (raw cursor position, NOT column-1)
|
|
349
|
-
* @returns The completion context type
|
|
505
|
+
* @param tree The (original, un-modified) parse tree
|
|
506
|
+
* @param content The document text
|
|
507
|
+
* @param line 0-indexed cursor line
|
|
508
|
+
* @param column 0-indexed cursor column
|
|
509
|
+
* @returns The previous leaf node, or null if cursor is at file start
|
|
350
510
|
*/
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return "unknown";
|
|
354
|
-
}
|
|
355
|
-
// Insert dummy identifier at cursor position
|
|
511
|
+
findPreviousLeaf(tree, content, line, column) {
|
|
512
|
+
// Convert (line, column) to absolute offset
|
|
356
513
|
const lines = content.split("\n");
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const lineText = lines[line];
|
|
361
|
-
lines[line] =
|
|
362
|
-
lineText.substring(0, column) +
|
|
363
|
-
DUMMY_IDENTIFIER +
|
|
364
|
-
lineText.substring(column);
|
|
365
|
-
const modifiedText = lines.join("\n");
|
|
366
|
-
// Parse modified text (throwaway — do NOT update index)
|
|
367
|
-
const tree = this.parser.parse(modifiedText);
|
|
368
|
-
// Find the dummy node — it starts at (line, column) in the modified text
|
|
369
|
-
const dummyNode = tree.rootNode.descendantForPosition({
|
|
370
|
-
row: line,
|
|
371
|
-
column,
|
|
372
|
-
});
|
|
373
|
-
if (!dummyNode) {
|
|
374
|
-
return "unknown";
|
|
514
|
+
let offset = 0;
|
|
515
|
+
for (let i = 0; i < line && i < lines.length; i++) {
|
|
516
|
+
offset += lines[i].length + 1; // +1 for newline
|
|
375
517
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
case "association_inline": {
|
|
400
|
-
const rightType = current.childForFieldName("right_type");
|
|
401
|
-
if (rightType && rightType.text.includes(DUMMY_IDENTIFIER)) {
|
|
402
|
-
return "association_type";
|
|
403
|
-
}
|
|
404
|
-
// Dummy is in role or other position — fall through
|
|
405
|
-
break;
|
|
406
|
-
}
|
|
407
|
-
case "association_member": {
|
|
408
|
-
const leftType = current.childForFieldName("left_type");
|
|
409
|
-
const rightType = current.childForFieldName("right_type");
|
|
410
|
-
if ((leftType && leftType.text.includes(DUMMY_IDENTIFIER)) ||
|
|
411
|
-
(rightType && rightType.text.includes(DUMMY_IDENTIFIER))) {
|
|
412
|
-
return "association_type";
|
|
413
|
-
}
|
|
414
|
-
return "association";
|
|
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;
|
|
415
541
|
}
|
|
416
|
-
// Brace-delimited: always reliable
|
|
417
|
-
case "state":
|
|
418
|
-
return "state";
|
|
419
|
-
case "state_machine":
|
|
420
|
-
return "state_machine";
|
|
421
|
-
case "association_definition":
|
|
422
|
-
return "association";
|
|
423
|
-
case "enum_definition":
|
|
424
|
-
return "enum";
|
|
425
|
-
case "class_definition":
|
|
426
|
-
case "trait_definition":
|
|
427
|
-
case "interface_definition":
|
|
428
|
-
return "class_body";
|
|
429
|
-
case "source_file":
|
|
430
|
-
return "top";
|
|
431
|
-
case "code_content":
|
|
432
|
-
case "code_block":
|
|
433
|
-
return "method";
|
|
434
542
|
}
|
|
435
|
-
|
|
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;
|
|
436
553
|
}
|
|
437
|
-
return
|
|
554
|
+
return node;
|
|
438
555
|
}
|
|
439
556
|
/**
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* @returns Array of symbol entries of that kind
|
|
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).
|
|
443
559
|
*/
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
560
|
+
resolveCompletionScope(tree, line, column) {
|
|
561
|
+
if (!this.completionsQuery)
|
|
562
|
+
return null;
|
|
563
|
+
// Don't pass position filtering to the query — tree-sitter uses
|
|
564
|
+
// half-open intervals [start, end), so a point query at a node's
|
|
565
|
+
// exact end boundary misses it (e.g., `use Per|` at use_statement's
|
|
566
|
+
// end). The manual containment check below uses inclusive boundaries
|
|
567
|
+
// and handles this correctly.
|
|
568
|
+
const captures = this.completionsQuery.captures(tree.rootNode);
|
|
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 };
|
|
450
582
|
}
|
|
451
583
|
}
|
|
452
584
|
}
|
|
453
|
-
|
|
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("_");
|
|
454
600
|
}
|
|
455
601
|
/**
|
|
456
|
-
*
|
|
602
|
+
* Check if a node is inside a comment (line_comment or block_comment).
|
|
457
603
|
*/
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
462
611
|
}
|
|
463
|
-
return
|
|
612
|
+
return false;
|
|
464
613
|
}
|
|
465
|
-
// =====================
|
|
466
|
-
// Private methods
|
|
467
|
-
// =====================
|
|
468
614
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
615
|
+
* Resolve enclosing class and root state machine names at a position.
|
|
616
|
+
* For state machines, keeps walking to find the outermost (root) SM.
|
|
471
617
|
*/
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
lines.push(`${indent}${prefix}(${node.type} ${pos}) "${node.text}"`);
|
|
618
|
+
resolveEnclosingScope(tree, line, column) {
|
|
619
|
+
let node = tree.rootNode.descendantForPosition({
|
|
620
|
+
row: line,
|
|
621
|
+
column,
|
|
622
|
+
});
|
|
623
|
+
let enclosingClass;
|
|
624
|
+
let enclosingStateMachine;
|
|
625
|
+
while (node) {
|
|
626
|
+
// For state machines: keep overwriting to find the ROOT (outermost) SM
|
|
627
|
+
if (node.type === "state_machine") {
|
|
628
|
+
enclosingStateMachine =
|
|
629
|
+
node.childForFieldName("name")?.text ?? enclosingStateMachine;
|
|
485
630
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
631
|
+
if (node.type === "statemachine_definition") {
|
|
632
|
+
enclosingStateMachine =
|
|
633
|
+
node.childForFieldName("name")?.text ?? enclosingStateMachine;
|
|
634
|
+
}
|
|
635
|
+
// For class: stop at first (innermost is what we want)
|
|
636
|
+
if (!enclosingClass &&
|
|
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
|
+
}
|
|
492
672
|
}
|
|
493
|
-
|
|
673
|
+
break;
|
|
494
674
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return
|
|
675
|
+
node = node.parent;
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
498
678
|
}
|
|
499
679
|
/**
|
|
500
|
-
*
|
|
680
|
+
* Scan backwards from the cursor position to find the token before the
|
|
681
|
+
* word currently being typed. Skips the current partial identifier first,
|
|
682
|
+
* then whitespace, then extracts the previous token.
|
|
683
|
+
*
|
|
684
|
+
* Examples (| = cursor):
|
|
685
|
+
* "class |" → "class"
|
|
686
|
+
* "class Fo|" → "class"
|
|
687
|
+
* "class\n Fo|" → "class"
|
|
501
688
|
*/
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
689
|
+
lastTokenBeforeCursor(content, line, column) {
|
|
690
|
+
const lines = content.split("\n");
|
|
691
|
+
// Convert (line, column) to absolute offset
|
|
692
|
+
let offset = 0;
|
|
693
|
+
for (let i = 0; i < line && i < lines.length; i++) {
|
|
694
|
+
offset += lines[i].length + 1; // +1 for newline
|
|
695
|
+
}
|
|
696
|
+
offset += Math.min(column, lines[line]?.length ?? 0);
|
|
697
|
+
// Skip the current partial identifier (the word being typed)
|
|
698
|
+
let pos = offset;
|
|
699
|
+
while (pos > 0 && /[a-zA-Z_0-9]/.test(content[pos - 1])) {
|
|
700
|
+
pos--;
|
|
701
|
+
}
|
|
702
|
+
// Skip whitespace
|
|
703
|
+
while (pos > 0 && /\s/.test(content[pos - 1])) {
|
|
704
|
+
pos--;
|
|
705
|
+
}
|
|
706
|
+
if (pos === 0)
|
|
707
|
+
return null;
|
|
708
|
+
// Collect the previous token
|
|
709
|
+
let start = pos;
|
|
710
|
+
while (start > 0 && /[a-zA-Z_]/.test(content[start - 1])) {
|
|
711
|
+
start--;
|
|
712
|
+
}
|
|
713
|
+
if (start === pos)
|
|
714
|
+
return null; // Hit punctuation, not a word
|
|
715
|
+
return content.substring(start, pos);
|
|
525
716
|
}
|
|
526
717
|
readFileSafe(filePath) {
|
|
527
718
|
try {
|
|
@@ -536,14 +727,16 @@ class SymbolIndex {
|
|
|
536
727
|
if (!fileIndex)
|
|
537
728
|
return;
|
|
538
729
|
for (const symbol of fileIndex.symbols) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
730
|
+
if (symbol.container) {
|
|
731
|
+
const containerSyms = this.symbolsByContainer.get(symbol.container);
|
|
732
|
+
if (containerSyms) {
|
|
733
|
+
const filtered = containerSyms.filter((s) => s.file !== filePath);
|
|
734
|
+
if (filtered.length === 0) {
|
|
735
|
+
this.symbolsByContainer.delete(symbol.container);
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
this.symbolsByContainer.set(symbol.container, filtered);
|
|
739
|
+
}
|
|
547
740
|
}
|
|
548
741
|
}
|
|
549
742
|
}
|
|
@@ -559,143 +752,143 @@ class SymbolIndex {
|
|
|
559
752
|
}
|
|
560
753
|
return hash.toString(16);
|
|
561
754
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
file: filePath,
|
|
581
|
-
line: nameNode.startPosition.row,
|
|
582
|
-
column: nameNode.startPosition.column,
|
|
583
|
-
endLine: nameNode.endPosition.row,
|
|
584
|
-
endColumn: nameNode.endPosition.column,
|
|
585
|
-
});
|
|
586
|
-
// Visit children with this class as parent
|
|
587
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
588
|
-
const child = node.child(i);
|
|
589
|
-
if (child)
|
|
590
|
-
visit(child, nameNode.text);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
break;
|
|
594
|
-
}
|
|
595
|
-
case "attribute_declaration": {
|
|
596
|
-
const nameNode = node.childForFieldName("name");
|
|
597
|
-
if (nameNode && parent) {
|
|
598
|
-
symbols.push({
|
|
599
|
-
name: nameNode.text,
|
|
600
|
-
kind: "attribute",
|
|
601
|
-
file: filePath,
|
|
602
|
-
line: nameNode.startPosition.row,
|
|
603
|
-
column: nameNode.startPosition.column,
|
|
604
|
-
endLine: nameNode.endPosition.row,
|
|
605
|
-
endColumn: nameNode.endPosition.column,
|
|
606
|
-
parent,
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
case "state_machine": {
|
|
612
|
-
const nameNode = node.childForFieldName("name");
|
|
613
|
-
if (nameNode && parent) {
|
|
614
|
-
symbols.push({
|
|
615
|
-
name: nameNode.text,
|
|
616
|
-
kind: "statemachine",
|
|
617
|
-
file: filePath,
|
|
618
|
-
line: nameNode.startPosition.row,
|
|
619
|
-
column: nameNode.startPosition.column,
|
|
620
|
-
endLine: nameNode.endPosition.row,
|
|
621
|
-
endColumn: nameNode.endPosition.column,
|
|
622
|
-
parent,
|
|
623
|
-
});
|
|
624
|
-
// Visit states with this statemachine as parent
|
|
625
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
626
|
-
const child = node.child(i);
|
|
627
|
-
if (child && child.type === "state") {
|
|
628
|
-
visit(child, nameNode.text);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
break;
|
|
755
|
+
/**
|
|
756
|
+
* Extract isA relationships from the AST.
|
|
757
|
+
* Returns a map of className → parent names.
|
|
758
|
+
*/
|
|
759
|
+
extractIsARelationships(rootNode) {
|
|
760
|
+
const isAMap = new Map();
|
|
761
|
+
const visit = (node) => {
|
|
762
|
+
if (node.type === "isa_declaration") {
|
|
763
|
+
// Find enclosing class name
|
|
764
|
+
let parent = node.parent;
|
|
765
|
+
while (parent &&
|
|
766
|
+
![
|
|
767
|
+
"class_definition",
|
|
768
|
+
"trait_definition",
|
|
769
|
+
"interface_definition",
|
|
770
|
+
"association_class_definition",
|
|
771
|
+
].includes(parent.type)) {
|
|
772
|
+
parent = parent.parent;
|
|
633
773
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
774
|
+
const className = parent?.childForFieldName("name")?.text;
|
|
775
|
+
if (!className)
|
|
776
|
+
return;
|
|
777
|
+
// Extract parent names from type_list → type_name → qualified_name
|
|
778
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
779
|
+
const child = node.child(i);
|
|
780
|
+
if (child?.type === "type_list") {
|
|
781
|
+
for (let j = 0; j < child.childCount; j++) {
|
|
782
|
+
const tn = child.child(j);
|
|
783
|
+
if (tn?.type === "type_name") {
|
|
784
|
+
for (let k = 0; k < tn.childCount; k++) {
|
|
785
|
+
const qn = tn.child(k);
|
|
786
|
+
if (qn?.type === "qualified_name") {
|
|
787
|
+
const parents = isAMap.get(className) ?? [];
|
|
788
|
+
parents.push(qn.text);
|
|
789
|
+
isAMap.set(className, parents);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
652
792
|
}
|
|
653
793
|
}
|
|
654
794
|
}
|
|
655
|
-
break;
|
|
656
|
-
}
|
|
657
|
-
case "method_declaration":
|
|
658
|
-
case "method_signature": {
|
|
659
|
-
const nameNode = node.childForFieldName("name");
|
|
660
|
-
if (nameNode && parent) {
|
|
661
|
-
symbols.push({
|
|
662
|
-
name: nameNode.text,
|
|
663
|
-
kind: "method",
|
|
664
|
-
file: filePath,
|
|
665
|
-
line: nameNode.startPosition.row,
|
|
666
|
-
column: nameNode.startPosition.column,
|
|
667
|
-
endLine: nameNode.endPosition.row,
|
|
668
|
-
endColumn: nameNode.endPosition.column,
|
|
669
|
-
parent,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
break;
|
|
673
795
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
file: filePath,
|
|
681
|
-
line: nameNode.startPosition.row,
|
|
682
|
-
column: nameNode.startPosition.column,
|
|
683
|
-
endLine: nameNode.endPosition.row,
|
|
684
|
-
endColumn: nameNode.endPosition.column,
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
break;
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
799
|
+
const child = node.child(i);
|
|
800
|
+
if (child)
|
|
801
|
+
visit(child);
|
|
688
802
|
}
|
|
689
|
-
default:
|
|
690
|
-
// Visit all children for other node types
|
|
691
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
692
|
-
const child = node.child(i);
|
|
693
|
-
if (child)
|
|
694
|
-
visit(child, parent);
|
|
695
|
-
}
|
|
696
803
|
}
|
|
697
804
|
};
|
|
698
805
|
visit(rootNode);
|
|
806
|
+
return isAMap;
|
|
807
|
+
}
|
|
808
|
+
/** Rebuild the global isA graph from all per-file isA maps. */
|
|
809
|
+
rebuildIsAGraph() {
|
|
810
|
+
this.isAGraph.clear();
|
|
811
|
+
for (const fileIsA of this.isAByFile.values()) {
|
|
812
|
+
for (const [className, parents] of fileIsA) {
|
|
813
|
+
const existing = this.isAGraph.get(className) ?? [];
|
|
814
|
+
existing.push(...parents);
|
|
815
|
+
this.isAGraph.set(className, existing);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/** For attributes/methods: walk up to find the enclosing class name. */
|
|
820
|
+
resolveClassContainer(node) {
|
|
821
|
+
let current = node.parent;
|
|
822
|
+
while (current) {
|
|
823
|
+
if ([
|
|
824
|
+
"class_definition",
|
|
825
|
+
"trait_definition",
|
|
826
|
+
"interface_definition",
|
|
827
|
+
"association_class_definition",
|
|
828
|
+
].includes(current.type)) {
|
|
829
|
+
return current.childForFieldName("name")?.text;
|
|
830
|
+
}
|
|
831
|
+
current = current.parent;
|
|
832
|
+
}
|
|
833
|
+
return undefined;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* For states/statemachines: walk up to find the ROOT (outermost) state machine name.
|
|
837
|
+
* All states share the same container so nested states can target any state at any level.
|
|
838
|
+
*/
|
|
839
|
+
resolveStateMachineContainer(node) {
|
|
840
|
+
let rootSmName;
|
|
841
|
+
let current = node.parent;
|
|
842
|
+
while (current) {
|
|
843
|
+
if (current.type === "state_machine") {
|
|
844
|
+
rootSmName = current.childForFieldName("name")?.text ?? rootSmName;
|
|
845
|
+
}
|
|
846
|
+
if (current.type === "statemachine_definition") {
|
|
847
|
+
rootSmName = current.childForFieldName("name")?.text ?? rootSmName;
|
|
848
|
+
}
|
|
849
|
+
current = current.parent;
|
|
850
|
+
}
|
|
851
|
+
return rootSmName;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Extract symbol definitions from the AST using the definitions.scm query.
|
|
855
|
+
* Falls back to an empty list if the query isn't loaded.
|
|
856
|
+
*/
|
|
857
|
+
extractSymbols(filePath, rootNode) {
|
|
858
|
+
if (!this.definitionsQuery)
|
|
859
|
+
return [];
|
|
860
|
+
const captures = this.definitionsQuery.captures(rootNode);
|
|
861
|
+
const symbols = [];
|
|
862
|
+
for (const capture of captures) {
|
|
863
|
+
const prefix = "definition.";
|
|
864
|
+
if (!capture.name.startsWith(prefix))
|
|
865
|
+
continue;
|
|
866
|
+
const kind = capture.name.substring(prefix.length);
|
|
867
|
+
const node = capture.node;
|
|
868
|
+
let container;
|
|
869
|
+
if (kind === "state" || kind === "statemachine") {
|
|
870
|
+
container = this.resolveStateMachineContainer(node);
|
|
871
|
+
}
|
|
872
|
+
else if (kind === "attribute" ||
|
|
873
|
+
kind === "method" ||
|
|
874
|
+
kind === "template") {
|
|
875
|
+
container = this.resolveClassContainer(node);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
// Top-level symbols (class, interface, trait, enum, etc.) are self-containers
|
|
879
|
+
container = node.text;
|
|
880
|
+
}
|
|
881
|
+
symbols.push({
|
|
882
|
+
name: node.text,
|
|
883
|
+
kind,
|
|
884
|
+
file: filePath,
|
|
885
|
+
line: node.startPosition.row,
|
|
886
|
+
column: node.startPosition.column,
|
|
887
|
+
endLine: node.endPosition.row,
|
|
888
|
+
endColumn: node.endPosition.column,
|
|
889
|
+
container,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
699
892
|
return symbols;
|
|
700
893
|
}
|
|
701
894
|
}
|