umple-lsp-server 0.2.1 → 0.2.2

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