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.
@@ -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
- const DUMMY_IDENTIFIER = "__CURSOR__";
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
- symbolsByName = new Map();
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 name index
155
+ // Add symbols to the container index
84
156
  for (const symbol of symbols) {
85
- const existing = this.symbolsByName.get(symbol.name) ?? [];
86
- existing.push(symbol);
87
- this.symbolsByName.set(symbol.name, existing);
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
- * Find definition of a symbol by name.
100
- * @param name Symbol name to look up
101
- * @param kind Optional kind filter
102
- * @returns Array of matching symbol entries
103
- */
104
- findDefinition(name, kind) {
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
- isPositionInComment(filePath, content, line, column) {
174
- if (!this.initialized || !this.parser) {
175
- return false;
176
- }
177
- // Get or create tree
178
- let tree = null;
179
- const fileIndex = this.files.get(filePath);
180
- if (fileIndex?.tree) {
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
- if (!tree) {
193
- return false;
194
- }
195
- const node = tree.rootNode.descendantForPosition({ row: line, column });
196
- if (!node) {
197
- return false;
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
- // Check if the node or any ancestor is a comment
200
- let current = node;
201
- while (current) {
202
- if (current.type === "line_comment" || current.type === "block_comment") {
203
- return true;
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 the use path at a specific position, if the cursor is on a use statement.
298
- * @param filePath Path to the file
299
- * @param content File content
300
- * @param line 0-indexed line number
301
- * @param column 0-indexed column number
302
- * @returns The use path (without quotes) or null if not on a use statement
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
- getUsePathAtPosition(filePath, content, line, column) {
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
- // Walk up to find if we're inside a use_statement
323
- let current = node;
324
- while (current) {
325
- if (current.type === "use_statement") {
326
- const pathNode = current.childForFieldName("path");
327
- if (pathNode) {
328
- return pathNode.text;
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
- return null;
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
- * Get the completion context at a specific position using the dummy identifier trick.
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
- * Inserts a dummy identifier (__CURSOR__) at the cursor position, parses the
340
- * modified text, then walks up from the dummy node to determine context.
341
- * This produces reliable results because the dummy forces the parser to place
342
- * it in a grammatically valid position (e.g. "isA __CURSOR__" parses as an
343
- * isa_declaration with type __CURSOR__).
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
- getCompletionContext(filePath, content, line, column) {
352
- if (!this.initialized || !this.parser) {
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
- if (line < 0 || line >= lines.length) {
358
- return "unknown";
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
- // Walk up from the dummy node to determine context
377
- let current = dummyNode;
378
- while (current) {
379
- switch (current.type) {
380
- // Comments: suppress all completions
381
- case "line_comment":
382
- case "block_comment":
383
- return "comment";
384
- // Specific keyword contexts (checked before structural)
385
- case "use_statement":
386
- return "use_path";
387
- case "isa_declaration":
388
- return "isa_type";
389
- case "transition": {
390
- const targetNode = current.childForFieldName("target");
391
- if (targetNode && targetNode.text.includes(DUMMY_IDENTIFIER)) {
392
- return "transition_target";
393
- }
394
- // Dummy is in event position — fall through to state context
395
- return "state";
396
- }
397
- case "depend_statement":
398
- return "depend_package";
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
- current = current.parent;
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 "unknown";
554
+ return node;
438
555
  }
439
556
  /**
440
- * Get all symbols of a specific kind from the index.
441
- * @param kind The kind of symbols to retrieve
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
- getSymbolsByKind(kind) {
445
- const result = [];
446
- for (const symbols of this.symbolsByName.values()) {
447
- for (const symbol of symbols) {
448
- if (symbol.kind === kind) {
449
- result.push(symbol);
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
- return result;
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
- * Get all symbols (useful for type completions).
602
+ * Check if a node is inside a comment (line_comment or block_comment).
457
603
  */
458
- getAllSymbols() {
459
- const result = [];
460
- for (const symbols of this.symbolsByName.values()) {
461
- result.push(...symbols);
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 result;
612
+ return false;
464
613
  }
465
- // =====================
466
- // Private methods
467
- // =====================
468
614
  /**
469
- * Debug helper: print a tree-sitter AST as an S-expression with positions.
470
- * Output matches the format used by `tree-sitter parse` and Neovim InspectTree.
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
- debugPrintTree(content) {
473
- if (!this.initialized || !this.parser) {
474
- return null;
475
- }
476
- const tree = this.parser.parse(content);
477
- const lines = [];
478
- const visit = (node, depth) => {
479
- const indent = " ".repeat(depth);
480
- const field = node.parent ? this.getFieldName(node.parent, node) : null;
481
- const prefix = field ? `${field}: ` : "";
482
- const pos = `[${node.startPosition.row}, ${node.startPosition.column}] - [${node.endPosition.row}, ${node.endPosition.column}]`;
483
- if (node.childCount === 0) {
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
- else {
487
- lines.push(`${indent}${prefix}(${node.type} ${pos}`);
488
- for (let i = 0; i < node.childCount; i++) {
489
- const child = node.child(i);
490
- if (child)
491
- visit(child, depth + 1);
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
- lines.push(`${indent})`);
673
+ break;
494
674
  }
495
- };
496
- visit(tree.rootNode, 0);
497
- return lines.join("\n");
675
+ node = node.parent;
676
+ }
677
+ return false;
498
678
  }
499
679
  /**
500
- * Get the field name for a child node within its parent.
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
- getFieldName(parent, child) {
503
- // Check common field names used in the Umple grammar
504
- const fieldNames = [
505
- "name",
506
- "path",
507
- "type",
508
- "return_type",
509
- "left_role",
510
- "right_role",
511
- "right_type",
512
- "left_type",
513
- "event",
514
- "target",
515
- "package",
516
- "language",
517
- ];
518
- for (const name of fieldNames) {
519
- const fieldNode = parent.childForFieldName(name);
520
- if (fieldNode && fieldNode.id === child.id) {
521
- return name;
522
- }
523
- }
524
- return null;
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
- const symbols = this.symbolsByName.get(symbol.name);
540
- if (symbols) {
541
- const filtered = symbols.filter((s) => s.file !== filePath);
542
- if (filtered.length === 0) {
543
- this.symbolsByName.delete(symbol.name);
544
- }
545
- else {
546
- this.symbolsByName.set(symbol.name, filtered);
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
- extractSymbols(filePath, rootNode) {
563
- const symbols = [];
564
- const visit = (node, parent) => {
565
- switch (node.type) {
566
- case "class_definition":
567
- case "interface_definition":
568
- case "trait_definition":
569
- case "enum_definition":
570
- case "external_definition": {
571
- const nameNode = node.childForFieldName("name");
572
- if (nameNode) {
573
- let kind = node.type.replace("_definition", "");
574
- if (node.type === "external_definition") {
575
- kind = "class";
576
- }
577
- symbols.push({
578
- name: nameNode.text,
579
- kind,
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
- case "state": {
635
- const nameNode = node.childForFieldName("name");
636
- if (nameNode && parent) {
637
- symbols.push({
638
- name: nameNode.text,
639
- kind: "state",
640
- file: filePath,
641
- line: nameNode.startPosition.row,
642
- column: nameNode.startPosition.column,
643
- endLine: nameNode.endPosition.row,
644
- endColumn: nameNode.endPosition.column,
645
- parent,
646
- });
647
- // Visit nested states
648
- for (let i = 0; i < node.childCount; i++) {
649
- const child = node.child(i);
650
- if (child && child.type === "state") {
651
- visit(child, nameNode.text);
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
- case "association_definition": {
675
- const nameNode = node.childForFieldName("name");
676
- if (nameNode) {
677
- symbols.push({
678
- name: nameNode.text,
679
- kind: "association",
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
  }