umple-lsp-server 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/completions.scm +21 -6
  2. package/definitions.scm +4 -0
  3. package/out/completionAnalysis.d.ts +44 -0
  4. package/out/completionAnalysis.js +391 -0
  5. package/out/completionAnalysis.js.map +1 -0
  6. package/out/completionBuilder.d.ts +28 -0
  7. package/out/completionBuilder.js +251 -0
  8. package/out/completionBuilder.js.map +1 -0
  9. package/out/documentSymbolBuilder.d.ts +13 -0
  10. package/out/documentSymbolBuilder.js +95 -0
  11. package/out/documentSymbolBuilder.js.map +1 -0
  12. package/out/formatRules.d.ts +27 -0
  13. package/out/formatRules.js +114 -0
  14. package/out/formatRules.js.map +1 -0
  15. package/out/formatter.d.ts +53 -0
  16. package/out/formatter.js +380 -0
  17. package/out/formatter.js.map +1 -0
  18. package/out/hoverBuilder.d.ts +21 -0
  19. package/out/hoverBuilder.js +308 -0
  20. package/out/hoverBuilder.js.map +1 -0
  21. package/out/importGraph.d.ts +28 -0
  22. package/out/importGraph.js +91 -0
  23. package/out/importGraph.js.map +1 -0
  24. package/out/referenceSearch.d.ts +22 -0
  25. package/out/referenceSearch.js +271 -0
  26. package/out/referenceSearch.js.map +1 -0
  27. package/out/resolver.d.ts +21 -0
  28. package/out/resolver.js +174 -0
  29. package/out/resolver.js.map +1 -0
  30. package/out/server.js +560 -327
  31. package/out/server.js.map +1 -1
  32. package/out/symbolIndex.d.ts +100 -94
  33. package/out/symbolIndex.js +392 -399
  34. package/out/symbolIndex.js.map +1 -1
  35. package/out/symbolTypes.d.ts +34 -0
  36. package/out/symbolTypes.js +9 -0
  37. package/out/symbolTypes.js.map +1 -0
  38. package/out/tokenAnalysis.d.ts +24 -0
  39. package/out/tokenAnalysis.js +195 -0
  40. package/out/tokenAnalysis.js.map +1 -0
  41. package/out/tokenTypes.d.ts +46 -0
  42. package/out/tokenTypes.js +28 -0
  43. package/out/tokenTypes.js.map +1 -0
  44. package/out/treeUtils.d.ts +32 -0
  45. package/out/treeUtils.js +89 -0
  46. package/out/treeUtils.js.map +1 -0
  47. package/package.json +4 -2
  48. package/references.scm +78 -10
  49. package/tree-sitter-umple.wasm +0 -0
@@ -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
- * 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
+ 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.
@@ -116,6 +79,41 @@ class SymbolIndex {
116
79
  isReady() {
117
80
  return this.initialized && this.parser !== null;
118
81
  }
82
+ /**
83
+ * Check if a file has been fully indexed (has symbols + tree).
84
+ */
85
+ isFileIndexed(filePath) {
86
+ return this.files.has(filePath);
87
+ }
88
+ /**
89
+ * Remove import graph edges for a file (e.g., when it's deleted from disk).
90
+ */
91
+ removeImportEdges(filePath) {
92
+ this.importGraph.removeEdges(filePath);
93
+ }
94
+ /**
95
+ * Update import graph edges for a file from its use statements,
96
+ * WITHOUT full symbol indexing. Used by the async workspace use-graph scanner.
97
+ * Skips files that are already fully indexed (their edges are fresh from didOpen/didChange).
98
+ */
99
+ updateUseGraphEdges(filePath, useStatements) {
100
+ // Don't overwrite edges for already-indexed files
101
+ if (this.files.has(filePath))
102
+ return;
103
+ const fileDir = path.dirname(filePath);
104
+ const resolvedImports = new Set();
105
+ for (const usePath of useStatements) {
106
+ if (!usePath.endsWith(".ump"))
107
+ continue;
108
+ const resolved = path.isAbsolute(usePath)
109
+ ? path.normalize(usePath)
110
+ : path.normalize(path.resolve(fileDir, usePath));
111
+ if (fs.existsSync(resolved)) {
112
+ resolvedImports.add(resolved);
113
+ }
114
+ }
115
+ this.importGraph.setEdges(filePath, resolvedImports);
116
+ }
119
117
  /**
120
118
  * Index a file or update its index if content changed.
121
119
  * @param filePath Absolute path to the file
@@ -134,14 +132,32 @@ class SymbolIndex {
134
132
  // Content unchanged, skip re-indexing
135
133
  return false;
136
134
  }
137
- // Remove old symbols for this file from the name index
135
+ // Parse the file
136
+ const tree = this.parser.parse(fileContent);
137
+ // Remove old symbols for this file from the container index
138
138
  if (existing) {
139
139
  this.removeFileSymbols(filePath);
140
140
  }
141
- // Parse the file
142
- const tree = this.parser.parse(fileContent);
143
- // Extract symbols from the AST
144
- const symbols = this.extractSymbols(filePath, tree.rootNode);
141
+ const newSymbols = this.extractSymbols(filePath, tree.rootNode);
142
+ // Kind-sensitive error preservation: when the tree has errors,
143
+ // live-update recovery-safe kinds (whose identity doesn't depend
144
+ // on AST nesting) but preserve recovery-fragile kinds (state,
145
+ // statemachine) from the last clean snapshot.
146
+ let symbols;
147
+ if (tree.rootNode.hasError) {
148
+ const LIVE_KINDS = new Set([
149
+ "class", "interface", "trait", "enum",
150
+ "mixset", "attribute", "const",
151
+ ]);
152
+ const liveSymbols = newSymbols.filter((s) => LIVE_KINDS.has(s.kind));
153
+ const preservedSymbols = existing
154
+ ? existing.symbols.filter((s) => !LIVE_KINDS.has(s.kind))
155
+ : [];
156
+ symbols = [...liveSymbols, ...preservedSymbols];
157
+ }
158
+ else {
159
+ symbols = newSymbols;
160
+ }
145
161
  // Extract isA relationships
146
162
  const isAMap = this.extractIsARelationships(tree.rootNode);
147
163
  this.isAByFile.set(filePath, isAMap);
@@ -160,11 +176,12 @@ class SymbolIndex {
160
176
  this.symbolsByContainer.set(symbol.container, containerSyms);
161
177
  }
162
178
  }
179
+ // Update forward/reverse import maps
180
+ this.updateImportMaps(filePath, fileContent);
163
181
  return true;
164
182
  }
165
183
  /**
166
- * Update a file with new content.
167
- * For web-tree-sitter, we do a full reparse but the index diffing is still efficient.
184
+ * Update a file with new content from the live editor.
168
185
  */
169
186
  updateFile(filePath, content) {
170
187
  return this.indexFile(filePath, content);
@@ -211,6 +228,22 @@ class SymbolIndex {
211
228
  }
212
229
  return result;
213
230
  }
231
+ /**
232
+ * Get all symbols defined in a specific file (for document outline).
233
+ */
234
+ getFileSymbols(filePath) {
235
+ return this.files.get(filePath)?.symbols ?? [];
236
+ }
237
+ /**
238
+ * Get the parsed tree for a file (for formatting, etc.).
239
+ */
240
+ getTree(filePath) {
241
+ return this.files.get(filePath)?.tree ?? null;
242
+ }
243
+ /** Get the direct isA parents for a class name. */
244
+ getIsAParents(className) {
245
+ return this.isAGraph.get(className) ?? [];
246
+ }
214
247
  collectFromContainerChain(container, kindSet, name, visited, result) {
215
248
  if (visited.has(container))
216
249
  return;
@@ -256,8 +289,8 @@ class SymbolIndex {
256
289
  const usePaths = [];
257
290
  const visit = (node) => {
258
291
  if (node.type === "use_statement") {
259
- const pathNode = node.childForFieldName("path");
260
- if (pathNode) {
292
+ const pathNodes = node.childrenForFieldName("path");
293
+ for (const pathNode of pathNodes) {
261
294
  usePaths.push(pathNode.text);
262
295
  }
263
296
  }
@@ -298,8 +331,8 @@ class SymbolIndex {
298
331
  const useStatements = [];
299
332
  const visit = (node) => {
300
333
  if (node.type === "use_statement") {
301
- const pathNode = node.childForFieldName("path");
302
- if (pathNode) {
334
+ const pathNodes = node.childrenForFieldName("path");
335
+ for (const pathNode of pathNodes) {
303
336
  useStatements.push({
304
337
  path: pathNode.text,
305
338
  line: node.startPosition.row,
@@ -318,17 +351,8 @@ class SymbolIndex {
318
351
  return useStatements;
319
352
  }
320
353
  /**
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
354
+ * Get completion information at a position.
355
+ * Delegates to the pure analyzeCompletion() function after tree acquisition.
332
356
  */
333
357
  getCompletionInfo(content, line, column) {
334
358
  const empty = {
@@ -339,96 +363,22 @@ class SymbolIndex {
339
363
  isComment: false,
340
364
  prefix: "",
341
365
  };
342
- if (!this.initialized || !this.parser || !this.language) {
366
+ if (!this.initialized || !this.parser || !this.language || !this.completionsQuery) {
343
367
  return empty;
344
368
  }
345
- // Parse original text (no dummy insertion)
346
369
  const tree = this.parser.parse(content);
347
370
  if (!tree)
348
371
  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
- };
372
+ return (0, completionAnalysis_1.analyzeCompletion)(tree, this.language, this.completionsQuery, content, line, column);
422
373
  }
423
374
  /**
424
375
  * Get the token (identifier) at a position using tree-sitter, along with
425
- * an optional SymbolKind filter based on the surrounding context.
376
+ * context information for symbol resolution.
426
377
  *
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.
378
+ * Delegates to the pure analyzeToken() function after tree acquisition.
429
379
  */
430
380
  getTokenAtPosition(filePath, content, line, column) {
431
- if (!this.initialized || !this.parser) {
381
+ if (!this.initialized || !this.parser || !this.referencesQuery) {
432
382
  return null;
433
383
  }
434
384
  const fileIndex = this.files.get(filePath);
@@ -440,14 +390,39 @@ class SymbolIndex {
440
390
  else {
441
391
  tree = this.parser.parse(content);
442
392
  }
393
+ return (0, tokenAnalysis_1.analyzeToken)(tree, this.referencesQuery, line, column);
394
+ }
395
+ /**
396
+ * Get the exact range of the token node at a position.
397
+ * Used by prepareRename to return the precise rename range.
398
+ * Accepts the same node types as getTokenAtPosition(): identifier,
399
+ * use_path, and filter_pattern.
400
+ */
401
+ getNodeRangeAtPosition(filePath, content, line, column) {
402
+ if (!this.initialized || !this.parser)
403
+ return null;
404
+ const fileIndex = this.files.get(filePath);
405
+ let tree;
406
+ if (fileIndex?.tree &&
407
+ fileIndex.contentHash === this.hashContent(content)) {
408
+ tree = fileIndex.tree;
409
+ }
410
+ else {
411
+ tree = this.parser.parse(content);
412
+ }
443
413
  const node = tree.rootNode.descendantForPosition({ row: line, column });
444
- if (!node || (node.type !== "identifier" && node.type !== "use_path")) {
414
+ if (!node ||
415
+ (node.type !== "identifier" &&
416
+ node.type !== "use_path" &&
417
+ node.type !== "filter_pattern")) {
445
418
  return null;
446
419
  }
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 };
420
+ return {
421
+ startLine: node.startPosition.row,
422
+ startColumn: node.startPosition.column,
423
+ endLine: node.endPosition.row,
424
+ endColumn: node.endPosition.column,
425
+ };
451
426
  }
452
427
  /**
453
428
  * Use the references.scm query to determine which symbol kinds an
@@ -457,263 +432,83 @@ class SymbolIndex {
457
432
  * encoding the valid symbol kinds directly. This replaces the old parent-chain
458
433
  * walking approach with a declarative .scm file.
459
434
  */
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
435
  /**
502
- * Find the previous non-extra leaf node before the cursor position.
503
- * Skips the current partial word being typed and any whitespace.
436
+ * Get the names of direct child states of a given state path within a state machine.
437
+ * Uses pre-computed statePath on each SymbolEntry for O(n) lookup without AST walking.
504
438
  *
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
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).
559
- */
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 };
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.
439
+ * @param parentPath Path segments to the parent state (e.g., ["EEE", "Open"])
440
+ * @param smContainer Qualified SM container (e.g., "ClassName.smName")
441
+ * @returns Names of direct child states of the resolved parent
617
442
  */
618
- resolveEnclosingScope(tree, line, column) {
619
- let node = tree.rootNode.descendantForPosition({
620
- row: line,
621
- column,
443
+ getChildStateNames(parentPath, smContainer, reachableFiles) {
444
+ if (parentPath.length === 0)
445
+ return [];
446
+ const effectivePath = (0, treeUtils_1.stripSmPrefix)(parentPath, smContainer);
447
+ if (effectivePath.length === 0)
448
+ return [];
449
+ const allStates = this.getSymbols({
450
+ container: smContainer,
451
+ kind: "state",
622
452
  });
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;
630
- }
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
- }
453
+ const names = new Set();
454
+ for (const s of allStates) {
455
+ if (reachableFiles && !reachableFiles.has(path.normalize(s.file)))
456
+ continue;
457
+ if (!s.statePath || s.statePath.length < effectivePath.length + 1)
458
+ continue;
459
+ // Suffix match: check if effectivePath matches the segments ending
460
+ // just before the child name (e.g., ["Closed"] matches tail of ["EEE","Closed","Inner"])
461
+ const suffixStart = s.statePath.length - effectivePath.length - 1;
462
+ let match = true;
463
+ for (let i = 0; i < effectivePath.length; i++) {
464
+ if (s.statePath[suffixStart + i] !== effectivePath[i]) {
465
+ match = false;
466
+ break;
672
467
  }
673
- break;
674
468
  }
675
- node = node.parent;
469
+ if (match)
470
+ names.add(s.name);
676
471
  }
677
- return false;
472
+ return [...names];
678
473
  }
679
474
  /**
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.
475
+ * Resolve a state within a dotted path context, returning the matching SymbolEntry.
476
+ * Uses pre-computed statePath for exact path matching without AST walking.
683
477
  *
684
- * Examples (| = cursor):
685
- * "class |" → "class"
686
- * "class Fo|" → "class"
687
- * "class\n Fo|" "class"
478
+ * @param precedingPath Path segments before the target (e.g., ["EEE", "Open"])
479
+ * @param targetName The target state name (e.g., "Inner")
480
+ * @param smContainer Qualified SM container (e.g., "ClassName.smName")
481
+ * @returns The SymbolEntry for the target state, or undefined if not found
688
482
  */
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);
483
+ resolveStateInPath(precedingPath, targetName, smContainer, reachableFiles) {
484
+ if (precedingPath.length === 0)
485
+ return undefined;
486
+ const effectivePath = (0, treeUtils_1.stripSmPrefix)(precedingPath, smContainer);
487
+ const targetPath = [...effectivePath, targetName];
488
+ let candidates = this.getSymbols({
489
+ container: smContainer,
490
+ kind: "state",
491
+ name: targetName,
492
+ });
493
+ if (reachableFiles) {
494
+ candidates = candidates.filter((s) => reachableFiles.has(path.normalize(s.file)));
495
+ }
496
+ // Suffix match: targetPath may be a partial path (e.g., ["Closed","Inner"])
497
+ // that matches the tail of a full statePath (e.g., ["EEE","Closed","Inner"])
498
+ return candidates.find((s) => {
499
+ if (!s.statePath || s.statePath.length < targetPath.length)
500
+ return false;
501
+ const offset = s.statePath.length - targetPath.length;
502
+ for (let i = 0; i < targetPath.length; i++) {
503
+ if (s.statePath[offset + i] !== targetPath[i])
504
+ return false;
505
+ }
506
+ return true;
507
+ });
716
508
  }
509
+ // =====================
510
+ // Private methods
511
+ // =====================
717
512
  readFileSafe(filePath) {
718
513
  try {
719
514
  return fs.readFileSync(filePath, "utf-8");
@@ -838,6 +633,7 @@ class SymbolIndex {
838
633
  */
839
634
  resolveStateMachineContainer(node) {
840
635
  let rootSmName;
636
+ let className;
841
637
  let current = node.parent;
842
638
  while (current) {
843
639
  if (current.type === "state_machine") {
@@ -846,10 +642,36 @@ class SymbolIndex {
846
642
  if (current.type === "statemachine_definition") {
847
643
  rootSmName = current.childForFieldName("name")?.text ?? rootSmName;
848
644
  }
645
+ if (!className &&
646
+ [
647
+ "class_definition",
648
+ "trait_definition",
649
+ "interface_definition",
650
+ "association_class_definition",
651
+ ].includes(current.type)) {
652
+ className = current.childForFieldName("name")?.text;
653
+ }
849
654
  current = current.parent;
850
655
  }
851
- return rootSmName;
656
+ if (!rootSmName)
657
+ return undefined;
658
+ return className ? `${className}.${rootSmName}` : rootSmName;
852
659
  }
660
+ /** For enum values: walk up to find the enclosing enum_definition name. */
661
+ resolveEnumContainer(node) {
662
+ let current = node.parent;
663
+ while (current) {
664
+ if (current.type === "enum_definition") {
665
+ return current.childForFieldName("name")?.text;
666
+ }
667
+ current = current.parent;
668
+ }
669
+ return undefined;
670
+ }
671
+ /**
672
+ * Build the nesting path for a state by walking up parent state nodes.
673
+ * E.g., for Inner inside Open inside EEE: ["EEE", "Open", "Inner"]
674
+ */
853
675
  /**
854
676
  * Extract symbol definitions from the AST using the definitions.scm query.
855
677
  * Falls back to an empty list if the query isn't loaded.
@@ -870,15 +692,22 @@ class SymbolIndex {
870
692
  container = this.resolveStateMachineContainer(node);
871
693
  }
872
694
  else if (kind === "attribute" ||
695
+ kind === "const" ||
873
696
  kind === "method" ||
874
- kind === "template") {
697
+ kind === "template" ||
698
+ kind === "tracecase") {
875
699
  container = this.resolveClassContainer(node);
876
700
  }
701
+ else if (kind === "enum_value") {
702
+ // Enum values belong to their enclosing enum
703
+ container = this.resolveEnumContainer(node);
704
+ }
877
705
  else {
878
706
  // Top-level symbols (class, interface, trait, enum, etc.) are self-containers
879
707
  container = node.text;
880
708
  }
881
- symbols.push({
709
+ const defNode = node.parent;
710
+ const entry = {
882
711
  name: node.text,
883
712
  kind,
884
713
  file: filePath,
@@ -887,10 +716,174 @@ class SymbolIndex {
887
716
  endLine: node.endPosition.row,
888
717
  endColumn: node.endPosition.column,
889
718
  container,
890
- });
719
+ defLine: defNode?.startPosition.row,
720
+ defColumn: defNode?.startPosition.column,
721
+ defEndLine: defNode?.endPosition.row,
722
+ defEndColumn: defNode?.endPosition.column,
723
+ };
724
+ if (kind === "state") {
725
+ entry.statePath = (0, treeUtils_1.resolveStatePath)(node);
726
+ }
727
+ symbols.push(entry);
891
728
  }
892
729
  return symbols;
893
730
  }
731
+ // ── Workspace indexing & import graph ──────────────────────────────────
732
+ /**
733
+ * Scan workspace roots for all .ump files, follow use chains to external
734
+ * files, and index everything. Content-hash skips unchanged files.
735
+ *
736
+ * @param workspaceRoots Workspace root directories
737
+ * @param getOpenDocContent Returns in-memory editor content if the file is open, undefined otherwise
738
+ */
739
+ indexWorkspace(workspaceRoots, getOpenDocContent) {
740
+ if (!this.initialized || !this.parser)
741
+ return;
742
+ // 1. Discover all .ump files under workspace roots
743
+ const discoveredFiles = new Set();
744
+ for (const root of workspaceRoots) {
745
+ this.globUmpFiles(root, discoveredFiles);
746
+ }
747
+ // 2. Index each discovered file (open-doc content takes precedence)
748
+ for (const filePath of discoveredFiles) {
749
+ const openContent = getOpenDocContent(filePath);
750
+ if (openContent !== undefined) {
751
+ this.indexFile(filePath, openContent);
752
+ }
753
+ else {
754
+ this.indexFile(filePath);
755
+ }
756
+ }
757
+ // 3. Follow use chains to index external files (outside workspace roots)
758
+ // Only treat an external file as live if it's open in the editor or exists on disk.
759
+ // This prevents stale forward-import edges from re-adding deleted files.
760
+ const externalFiles = new Set();
761
+ for (const filePath of discoveredFiles) {
762
+ const imports = this.importGraph.getForward(filePath);
763
+ if (imports) {
764
+ for (const imp of imports) {
765
+ if (!discoveredFiles.has(imp) &&
766
+ !externalFiles.has(imp) &&
767
+ (getOpenDocContent(imp) !== undefined || fs.existsSync(imp))) {
768
+ externalFiles.add(imp);
769
+ }
770
+ }
771
+ }
772
+ }
773
+ // Index external files and follow their chains too (transitive)
774
+ const queue = [...externalFiles];
775
+ while (queue.length > 0) {
776
+ const filePath = queue.pop();
777
+ const openContent = getOpenDocContent(filePath);
778
+ if (openContent !== undefined) {
779
+ this.indexFile(filePath, openContent);
780
+ }
781
+ else {
782
+ this.indexFile(filePath);
783
+ }
784
+ const imports = this.importGraph.getForward(filePath);
785
+ if (imports) {
786
+ for (const imp of imports) {
787
+ if (!discoveredFiles.has(imp) &&
788
+ !externalFiles.has(imp) &&
789
+ (getOpenDocContent(imp) !== undefined || fs.existsSync(imp))) {
790
+ externalFiles.add(imp);
791
+ queue.push(imp);
792
+ }
793
+ }
794
+ }
795
+ }
796
+ // 4. Remove stale indexed files.
797
+ // A file is removed if it's not in the discovered/external set AND not open in the editor.
798
+ // Note: fs.existsSync is intentionally NOT checked here — a file that still exists on disk
799
+ // but is no longer reachable (e.g., use statement removed) should be dropped from the index.
800
+ const allKnownFiles = new Set([...discoveredFiles, ...externalFiles]);
801
+ for (const filePath of this.files.keys()) {
802
+ if (!allKnownFiles.has(filePath) &&
803
+ getOpenDocContent(filePath) === undefined) {
804
+ this.removeFile(filePath);
805
+ }
806
+ }
807
+ }
808
+ /**
809
+ * Get all files whose use chain can reach any of the given declaration files.
810
+ * Transitive reverse closure.
811
+ */
812
+ getReverseImporters(declarationFiles) {
813
+ return this.importGraph.getReverseImporters(declarationFiles);
814
+ }
815
+ /**
816
+ * Find all references to a symbol across the given files.
817
+ * Delegates to the extracted searchReferences() function.
818
+ */
819
+ findReferences(declarations, filesToSearch, includeDeclaration) {
820
+ if (!this.referencesQuery || declarations.length === 0)
821
+ return [];
822
+ // Build filePath→tree map for the files to search
823
+ const fileTreeMap = new Map();
824
+ for (const fp of filesToSearch) {
825
+ const tree = this.files.get(fp)?.tree;
826
+ if (tree)
827
+ fileTreeMap.set(fp, tree);
828
+ }
829
+ return (0, referenceSearch_1.searchReferences)(declarations, includeDeclaration, this.referencesQuery, fileTreeMap, this.isAGraph);
830
+ }
831
+ /**
832
+ * Update forward/reverse import maps for a file.
833
+ */
834
+ updateImportMaps(filePath, content) {
835
+ // Compute resolved imports from use statements
836
+ const usePaths = this.extractUseStatements(filePath, content);
837
+ const fileDir = path.dirname(filePath);
838
+ const newImports = new Set();
839
+ for (const usePath of usePaths) {
840
+ if (!usePath.endsWith(".ump"))
841
+ continue;
842
+ const resolved = path.isAbsolute(usePath)
843
+ ? path.normalize(usePath)
844
+ : path.normalize(path.resolve(fileDir, usePath));
845
+ if (fs.existsSync(resolved)) {
846
+ newImports.add(resolved);
847
+ }
848
+ }
849
+ // Delegate edge management to ImportGraph
850
+ this.importGraph.setEdges(filePath, newImports);
851
+ }
852
+ /**
853
+ * Fully remove a file from the index (symbols, imports, isA).
854
+ */
855
+ removeFile(filePath) {
856
+ this.removeFileSymbols(filePath);
857
+ this.files.delete(filePath);
858
+ this.importGraph.removeEdges(filePath);
859
+ this.isAByFile.delete(filePath);
860
+ this.rebuildIsAGraph();
861
+ }
862
+ /**
863
+ * Recursively find all .ump files under a directory.
864
+ */
865
+ globUmpFiles(dir, result) {
866
+ let entries;
867
+ try {
868
+ entries = fs.readdirSync(dir, { withFileTypes: true });
869
+ }
870
+ catch {
871
+ return; // permission denied, etc.
872
+ }
873
+ for (const entry of entries) {
874
+ const fullPath = path.join(dir, entry.name);
875
+ if (entry.isDirectory()) {
876
+ // Skip common non-source directories
877
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "out") {
878
+ continue;
879
+ }
880
+ this.globUmpFiles(fullPath, result);
881
+ }
882
+ else if (entry.name.endsWith(".ump")) {
883
+ result.add(path.normalize(fullPath));
884
+ }
885
+ }
886
+ }
894
887
  }
895
888
  exports.SymbolIndex = SymbolIndex;
896
889
  // Singleton instance