umple-lsp-server 0.1.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.
@@ -0,0 +1,705 @@
1
+ "use strict";
2
+ /**
3
+ * Symbol Index for fast go-to-definition lookups.
4
+ *
5
+ * Uses tree-sitter (via WASM) for incremental parsing and maintains an
6
+ * in-memory index of all symbol definitions in the workspace.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.symbolIndex = exports.SymbolIndex = void 0;
10
+ const fs = require("fs");
11
+ // web-tree-sitter types and module
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ const TreeSitter = require("web-tree-sitter");
14
+ const DUMMY_IDENTIFIER = "__CURSOR__";
15
+ class SymbolIndex {
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ parser = null;
18
+ language = null;
19
+ files = new Map();
20
+ symbolsByName = new Map();
21
+ initialized = false;
22
+ /**
23
+ * Initialize the tree-sitter parser with the Umple grammar.
24
+ * @param wasmPath Path to the tree-sitter-umple.wasm file
25
+ */
26
+ async initialize(wasmPath) {
27
+ try {
28
+ // Initialize the WASM module first
29
+ await TreeSitter.Parser.init();
30
+ // Create parser instance
31
+ this.parser = new TreeSitter.Parser();
32
+ // Load the WASM language
33
+ this.language = await TreeSitter.Language.load(wasmPath);
34
+ this.parser.setLanguage(this.language);
35
+ this.initialized = true;
36
+ return true;
37
+ }
38
+ catch (err) {
39
+ console.error("Failed to initialize tree-sitter parser:", err);
40
+ this.parser = null;
41
+ this.language = null;
42
+ return false;
43
+ }
44
+ }
45
+ /**
46
+ * Check if the symbol index is ready to use.
47
+ */
48
+ isReady() {
49
+ return this.initialized && this.parser !== null;
50
+ }
51
+ /**
52
+ * Index a file or update its index if content changed.
53
+ * @param filePath Absolute path to the file
54
+ * @param content File content (optional, will be read from disk if not provided)
55
+ * @returns true if the file was (re)indexed, false if cache hit
56
+ */
57
+ indexFile(filePath, content) {
58
+ if (!this.parser)
59
+ return false;
60
+ const fileContent = content ?? this.readFileSafe(filePath);
61
+ if (fileContent === null)
62
+ return false;
63
+ const hash = this.hashContent(fileContent);
64
+ const existing = this.files.get(filePath);
65
+ if (existing && existing.contentHash === hash) {
66
+ // Content unchanged, skip re-indexing
67
+ return false;
68
+ }
69
+ // Remove old symbols for this file from the name index
70
+ if (existing) {
71
+ this.removeFileSymbols(filePath);
72
+ }
73
+ // Parse the file
74
+ const tree = this.parser.parse(fileContent);
75
+ // Extract symbols from the AST
76
+ const symbols = this.extractSymbols(filePath, tree.rootNode);
77
+ // Store the file index
78
+ this.files.set(filePath, {
79
+ symbols,
80
+ tree,
81
+ contentHash: hash,
82
+ });
83
+ // Add symbols to the name index
84
+ for (const symbol of symbols) {
85
+ const existing = this.symbolsByName.get(symbol.name) ?? [];
86
+ existing.push(symbol);
87
+ this.symbolsByName.set(symbol.name, existing);
88
+ }
89
+ return true;
90
+ }
91
+ /**
92
+ * Update a file with new content.
93
+ * For web-tree-sitter, we do a full reparse but the index diffing is still efficient.
94
+ */
95
+ updateFile(filePath, content) {
96
+ return this.indexFile(filePath, content);
97
+ }
98
+ /**
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
172
+ */
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);
190
+ }
191
+ }
192
+ if (!tree) {
193
+ return false;
194
+ }
195
+ const node = tree.rootNode.descendantForPosition({ row: line, column });
196
+ if (!node) {
197
+ return false;
198
+ }
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;
204
+ }
205
+ current = current.parent;
206
+ }
207
+ return false;
208
+ }
209
+ /**
210
+ * Extract use statement paths from a file using tree-sitter.
211
+ * @param filePath Path to the file
212
+ * @param content File content (optional, will be read from disk if not provided)
213
+ * @returns Array of use paths (without quotes)
214
+ */
215
+ extractUseStatements(filePath, content) {
216
+ if (!this.initialized || !this.parser) {
217
+ return [];
218
+ }
219
+ const fileContent = content ?? this.readFileSafe(filePath);
220
+ if (!fileContent) {
221
+ return [];
222
+ }
223
+ // Use cached tree if available and content matches
224
+ const fileIndex = this.files.get(filePath);
225
+ let tree;
226
+ if (fileIndex?.tree && !content) {
227
+ tree = fileIndex.tree;
228
+ }
229
+ else {
230
+ tree = this.parser.parse(fileContent);
231
+ }
232
+ const usePaths = [];
233
+ const visit = (node) => {
234
+ if (node.type === "use_statement") {
235
+ const pathNode = node.childForFieldName("path");
236
+ if (pathNode) {
237
+ usePaths.push(pathNode.text);
238
+ }
239
+ }
240
+ else {
241
+ for (let i = 0; i < node.childCount; i++) {
242
+ const child = node.child(i);
243
+ if (child)
244
+ visit(child);
245
+ }
246
+ }
247
+ };
248
+ visit(tree.rootNode);
249
+ return usePaths;
250
+ }
251
+ /**
252
+ * Extract use statement paths with their positions from a file using tree-sitter.
253
+ * @param filePath Path to the file
254
+ * @param content File content (optional, will be read from disk if not provided)
255
+ * @returns Array of use statements with path and line number
256
+ */
257
+ extractUseStatementsWithPositions(filePath, content) {
258
+ if (!this.initialized || !this.parser) {
259
+ return [];
260
+ }
261
+ const fileContent = content ?? this.readFileSafe(filePath);
262
+ if (!fileContent) {
263
+ return [];
264
+ }
265
+ // Use cached tree if available and content matches
266
+ const fileIndex = this.files.get(filePath);
267
+ let tree;
268
+ if (fileIndex?.tree && !content) {
269
+ tree = fileIndex.tree;
270
+ }
271
+ else {
272
+ tree = this.parser.parse(fileContent);
273
+ }
274
+ const useStatements = [];
275
+ const visit = (node) => {
276
+ if (node.type === "use_statement") {
277
+ const pathNode = node.childForFieldName("path");
278
+ if (pathNode) {
279
+ useStatements.push({
280
+ path: pathNode.text,
281
+ line: node.startPosition.row,
282
+ });
283
+ }
284
+ }
285
+ else {
286
+ for (let i = 0; i < node.childCount; i++) {
287
+ const child = node.child(i);
288
+ if (child)
289
+ visit(child);
290
+ }
291
+ }
292
+ };
293
+ visit(tree.rootNode);
294
+ return useStatements;
295
+ }
296
+ /**
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
303
+ */
304
+ getUsePathAtPosition(filePath, content, line, column) {
305
+ if (!this.initialized || !this.parser) {
306
+ return null;
307
+ }
308
+ // Use cached tree if available
309
+ const fileIndex = this.files.get(filePath);
310
+ let tree;
311
+ if (fileIndex?.tree &&
312
+ fileIndex.contentHash === this.hashContent(content)) {
313
+ tree = fileIndex.tree;
314
+ }
315
+ else {
316
+ tree = this.parser.parse(content);
317
+ }
318
+ const node = tree.rootNode.descendantForPosition({ row: line, column });
319
+ if (!node) {
320
+ return null;
321
+ }
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;
329
+ }
330
+ return null;
331
+ }
332
+ current = current.parent;
333
+ }
334
+ return null;
335
+ }
336
+ /**
337
+ * Get the completion context at a specific position using the dummy identifier trick.
338
+ *
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
350
+ */
351
+ getCompletionContext(filePath, content, line, column) {
352
+ if (!this.initialized || !this.parser) {
353
+ return "unknown";
354
+ }
355
+ // Insert dummy identifier at cursor position
356
+ 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";
375
+ }
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";
415
+ }
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
+ }
435
+ current = current.parent;
436
+ }
437
+ return "unknown";
438
+ }
439
+ /**
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
443
+ */
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);
450
+ }
451
+ }
452
+ }
453
+ return result;
454
+ }
455
+ /**
456
+ * Get all symbols (useful for type completions).
457
+ */
458
+ getAllSymbols() {
459
+ const result = [];
460
+ for (const symbols of this.symbolsByName.values()) {
461
+ result.push(...symbols);
462
+ }
463
+ return result;
464
+ }
465
+ // =====================
466
+ // Private methods
467
+ // =====================
468
+ /**
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.
471
+ */
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}"`);
485
+ }
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);
492
+ }
493
+ lines.push(`${indent})`);
494
+ }
495
+ };
496
+ visit(tree.rootNode, 0);
497
+ return lines.join("\n");
498
+ }
499
+ /**
500
+ * Get the field name for a child node within its parent.
501
+ */
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;
525
+ }
526
+ readFileSafe(filePath) {
527
+ try {
528
+ return fs.readFileSync(filePath, "utf-8");
529
+ }
530
+ catch {
531
+ return null;
532
+ }
533
+ }
534
+ removeFileSymbols(filePath) {
535
+ const fileIndex = this.files.get(filePath);
536
+ if (!fileIndex)
537
+ return;
538
+ 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);
547
+ }
548
+ }
549
+ }
550
+ }
551
+ hashContent(content) {
552
+ // Simple hash for change detection
553
+ // For production, consider using crypto.createHash('sha256')
554
+ let hash = 0;
555
+ for (let i = 0; i < content.length; i++) {
556
+ const char = content.charCodeAt(i);
557
+ hash = (hash << 5) - hash + char;
558
+ hash = hash & hash; // Convert to 32bit integer
559
+ }
560
+ return hash.toString(16);
561
+ }
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;
633
+ }
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);
652
+ }
653
+ }
654
+ }
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
+ }
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;
688
+ }
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
+ }
697
+ };
698
+ visit(rootNode);
699
+ return symbols;
700
+ }
701
+ }
702
+ exports.SymbolIndex = SymbolIndex;
703
+ // Singleton instance
704
+ exports.symbolIndex = new SymbolIndex();
705
+ //# sourceMappingURL=symbolIndex.js.map