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.
package/out/server.js ADDED
@@ -0,0 +1,1117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const child_process_1 = require("child_process");
4
+ const fs = require("fs");
5
+ const net = require("net");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const url_1 = require("url");
9
+ const node_1 = require("vscode-languageserver/node");
10
+ const vscode_languageserver_textdocument_1 = require("vscode-languageserver-textdocument");
11
+ const keywords_1 = require("./keywords");
12
+ const symbolIndex_1 = require("./symbolIndex");
13
+ const connection = (0, node_1.createConnection)(node_1.ProposedFeatures.all);
14
+ const documents = new Map();
15
+ const pendingValidations = new Map();
16
+ let workspaceRoots = [];
17
+ /**
18
+ * Normalize a file URI to a consistent key for the documents map.
19
+ * Converts URI to file path and back to ensure consistent encoding.
20
+ */
21
+ function normalizeUri(uri) {
22
+ if (!uri.startsWith("file:")) {
23
+ return uri;
24
+ }
25
+ try {
26
+ // Convert to path and back to normalize encoding
27
+ const filePath = (0, url_1.fileURLToPath)(uri);
28
+ return (0, url_1.pathToFileURL)(filePath).toString();
29
+ }
30
+ catch {
31
+ return uri;
32
+ }
33
+ }
34
+ /**
35
+ * Get a document by URI, using normalized lookup.
36
+ */
37
+ function getDocument(uri) {
38
+ return documents.get(normalizeUri(uri));
39
+ }
40
+ /**
41
+ * Set a document by URI, using normalized key.
42
+ */
43
+ function setDocument(uri, document) {
44
+ documents.set(normalizeUri(uri), document);
45
+ }
46
+ /**
47
+ * Delete a document by URI, using normalized key.
48
+ */
49
+ function deleteDocument(uri) {
50
+ documents.delete(normalizeUri(uri));
51
+ }
52
+ /**
53
+ * Safely read a file, returning null if it fails.
54
+ */
55
+ function readFileSafe(filePath) {
56
+ try {
57
+ return fs.readFileSync(filePath, "utf8");
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * Return the first path from candidates that exists on disk, or undefined.
65
+ */
66
+ function findFile(candidates) {
67
+ for (const p of candidates) {
68
+ if (fs.existsSync(p)) {
69
+ return p;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ let umpleSyncJarPath;
75
+ let umpleSyncHost = "localhost";
76
+ let umpleSyncPort = 5555;
77
+ let umpleSyncTimeoutMs = 50000;
78
+ let jarWarningShown = false;
79
+ let serverProcess;
80
+ let treeSitterWasmPath;
81
+ let symbolIndexReady = false;
82
+ const DEFAULT_UMPLESYNC_TIMEOUT_MS = 50000;
83
+ connection.onInitialize((params) => {
84
+ const initOptions = params.initializationOptions;
85
+ umpleSyncJarPath =
86
+ initOptions?.umpleSyncJarPath || process.env.UMPLESYNC_JAR_PATH;
87
+ umpleSyncHost =
88
+ initOptions?.umpleSyncHost || process.env.UMPLESYNC_HOST || "localhost";
89
+ if (typeof initOptions?.umpleSyncPort === "number") {
90
+ umpleSyncPort = initOptions.umpleSyncPort;
91
+ }
92
+ else if (process.env.UMPLESYNC_PORT) {
93
+ const parsed = Number(process.env.UMPLESYNC_PORT);
94
+ if (!Number.isNaN(parsed)) {
95
+ umpleSyncPort = parsed;
96
+ }
97
+ }
98
+ if (typeof initOptions?.umpleSyncTimeoutMs === "number") {
99
+ umpleSyncTimeoutMs = initOptions.umpleSyncTimeoutMs;
100
+ }
101
+ else if (process.env.UMPLESYNC_TIMEOUT_MS) {
102
+ const parsed = Number(process.env.UMPLESYNC_TIMEOUT_MS);
103
+ if (!Number.isNaN(parsed)) {
104
+ umpleSyncTimeoutMs = parsed;
105
+ }
106
+ }
107
+ else {
108
+ umpleSyncTimeoutMs = DEFAULT_UMPLESYNC_TIMEOUT_MS;
109
+ }
110
+ workspaceRoots = resolveWorkspaceRoots(params);
111
+ return {
112
+ capabilities: {
113
+ textDocumentSync: node_1.TextDocumentSyncKind.Incremental,
114
+ completionProvider: {
115
+ resolveProvider: false,
116
+ triggerCharacters: [" ", "."],
117
+ },
118
+ definitionProvider: true,
119
+ },
120
+ };
121
+ });
122
+ connection.onInitialized(async () => {
123
+ connection.console.info("Umple language server initialized.");
124
+ // Initialize tree-sitter symbol index for fast go-to-definition
125
+ treeSitterWasmPath =
126
+ process.env.UMPLE_TREE_SITTER_WASM_PATH ||
127
+ treeSitterWasmPath ||
128
+ findFile([
129
+ path.join(__dirname, "..", "tree-sitter-umple.wasm"), // npm package (wasm copied to server root)
130
+ path.join(__dirname, "..", "..", "tree-sitter-umple", "tree-sitter-umple.wasm"), // monorepo dev
131
+ ]);
132
+ if (treeSitterWasmPath && fs.existsSync(treeSitterWasmPath)) {
133
+ try {
134
+ symbolIndexReady = await symbolIndex_1.symbolIndex.initialize(treeSitterWasmPath);
135
+ if (symbolIndexReady) {
136
+ connection.console.info("Symbol index initialized with tree-sitter.");
137
+ }
138
+ }
139
+ catch (err) {
140
+ connection.console.warn(`Failed to initialize symbol index: ${err}`);
141
+ }
142
+ }
143
+ else {
144
+ connection.console.info(`Tree-sitter WASM not found at ${treeSitterWasmPath ?? "(no path configured)"}, using fallback go-to-definition.`);
145
+ }
146
+ });
147
+ // Create
148
+ connection.onDidOpenTextDocument((params) => {
149
+ const document = vscode_languageserver_textdocument_1.TextDocument.create(params.textDocument.uri, params.textDocument.languageId, params.textDocument.version, params.textDocument.text);
150
+ setDocument(params.textDocument.uri, document);
151
+ scheduleValidation(document);
152
+ // Index current file only; imports are indexed on-demand by
153
+ // ensureImportsIndexed() when completion or go-to-definition is triggered
154
+ if (symbolIndexReady) {
155
+ try {
156
+ const filePath = (0, url_1.fileURLToPath)(params.textDocument.uri);
157
+ symbolIndex_1.symbolIndex.updateFile(filePath, params.textDocument.text);
158
+ }
159
+ catch {
160
+ // Ignore errors for non-file URIs
161
+ }
162
+ }
163
+ });
164
+ /**
165
+ * Ensure all files reachable via use statements are indexed, and return
166
+ * the set of reachable file paths (including the current file).
167
+ * Used by both completion and go-to-definition.
168
+ */
169
+ function ensureImportsIndexed(docPath, text) {
170
+ const docDir = path.dirname(docPath);
171
+ const reachableFiles = collectReachableFiles(docPath, text, docDir);
172
+ reachableFiles.add(path.normalize(docPath));
173
+ for (const file of reachableFiles) {
174
+ // Prefer unsaved content from open editors over saved disk content
175
+ const uri = (0, url_1.pathToFileURL)(file).toString();
176
+ const openDoc = getDocument(uri);
177
+ if (openDoc) {
178
+ symbolIndex_1.symbolIndex.updateFile(file, openDoc.getText());
179
+ }
180
+ else if (fs.existsSync(file)) {
181
+ symbolIndex_1.symbolIndex.indexFile(file);
182
+ }
183
+ }
184
+ return reachableFiles;
185
+ }
186
+ connection.onDidChangeTextDocument((params) => {
187
+ const document = getDocument(params.textDocument.uri);
188
+ if (!document) {
189
+ return;
190
+ }
191
+ const updated = vscode_languageserver_textdocument_1.TextDocument.update(document, params.contentChanges, params.textDocument.version);
192
+ setDocument(params.textDocument.uri, updated);
193
+ scheduleValidation(updated);
194
+ // Re-validate other open documents that might depend on this file
195
+ scheduleDependentValidation(params.textDocument.uri);
196
+ });
197
+ connection.onDidCloseTextDocument((params) => {
198
+ const normalizedUri = normalizeUri(params.textDocument.uri);
199
+ deleteDocument(params.textDocument.uri);
200
+ const pendingValidation = pendingValidations.get(normalizedUri);
201
+ if (pendingValidation) {
202
+ clearTimeout(pendingValidation);
203
+ pendingValidations.delete(normalizedUri);
204
+ }
205
+ connection.sendDiagnostics({ uri: params.textDocument.uri, diagnostics: [] });
206
+ });
207
+ connection.onCompletion(async (params) => {
208
+ const document = getDocument(params.textDocument.uri);
209
+ if (!document) {
210
+ return [];
211
+ }
212
+ const docPath = getDocumentFilePath(document);
213
+ // Determine context using dummy identifier trick
214
+ let context = "unknown";
215
+ if (docPath && symbolIndexReady) {
216
+ context = symbolIndex_1.symbolIndex.getCompletionContext(docPath, document.getText(), params.position.line, params.position.character);
217
+ }
218
+ // Suppress completions in comments
219
+ if (context === "comment") {
220
+ return [];
221
+ }
222
+ // Use path completion: offer .ump file names
223
+ if (context === "use_path") {
224
+ const prefix = getUsePathPrefix(document, params.position.line, params.position.character);
225
+ return getUseFileCompletions(document, prefix, params.position.line, params.position.character);
226
+ }
227
+ // Ensure imported files are indexed so their symbols appear in completions
228
+ let reachableFiles;
229
+ if (docPath && symbolIndexReady) {
230
+ reachableFiles = ensureImportsIndexed(docPath, document.getText());
231
+ }
232
+ // All other contexts: keyword + symbol completions
233
+ const prefix = getCompletionPrefix(document, params.position.line, params.position.character);
234
+ return buildCompletionsForContext(context, prefix, reachableFiles);
235
+ });
236
+ connection.onDefinition(async (params) => {
237
+ const document = getDocument(params.textDocument.uri);
238
+ if (!document) {
239
+ return [];
240
+ }
241
+ const useLocation = resolveUseDefinitionFromLine(document, params.position);
242
+ if (useLocation) {
243
+ return [useLocation];
244
+ }
245
+ // Try symbol index, filtered by reachable files
246
+ if (symbolIndexReady) {
247
+ const docPath = getDocumentFilePath(document);
248
+ if (!docPath) {
249
+ return [];
250
+ }
251
+ // Skip if cursor is inside a comment
252
+ if (symbolIndex_1.symbolIndex.isPositionInComment(docPath, document.getText(), params.position.line, Math.max(0, params.position.character - 1))) {
253
+ return [];
254
+ }
255
+ const word = getWordAtPosition(document, params.position);
256
+ if (word) {
257
+ // Ensure imports are indexed and get reachable file set
258
+ const reachableFiles = ensureImportsIndexed(docPath, document.getText());
259
+ const allSymbols = symbolIndex_1.symbolIndex.findDefinition(word);
260
+ // Filter symbols to only those in reachable files
261
+ const filteredSymbols = allSymbols.filter((sym) => reachableFiles.has(path.normalize(sym.file)));
262
+ if (filteredSymbols.length > 0) {
263
+ const results = filteredSymbols.map((sym) => {
264
+ const uri = (0, url_1.pathToFileURL)(sym.file).toString();
265
+ return node_1.Location.create(uri, node_1.Range.create(node_1.Position.create(sym.line, sym.column), node_1.Position.create(sym.endLine, sym.endColumn)));
266
+ });
267
+ return results;
268
+ }
269
+ }
270
+ }
271
+ // No definition found in symbol index
272
+ return [];
273
+ });
274
+ function scheduleValidation(document) {
275
+ const existing = pendingValidations.get(document.uri);
276
+ if (existing) {
277
+ clearTimeout(existing);
278
+ }
279
+ const handle = setTimeout(() => {
280
+ pendingValidations.delete(document.uri);
281
+ void validateTextDocument(document);
282
+ }, 300);
283
+ pendingValidations.set(document.uri, handle);
284
+ }
285
+ // Debounce key for dependent validation
286
+ const dependentValidationKey = "__dependent__";
287
+ /**
288
+ * Schedule re-validation for open documents that actually import the changed file.
289
+ * Uses a longer debounce time to avoid excessive re-validation.
290
+ */
291
+ function scheduleDependentValidation(changedUri) {
292
+ // Clear any existing dependent validation timer
293
+ const existing = pendingValidations.get(dependentValidationKey);
294
+ if (existing) {
295
+ clearTimeout(existing);
296
+ }
297
+ const normalizedChangedUri = normalizeUri(changedUri);
298
+ const handle = setTimeout(() => {
299
+ pendingValidations.delete(dependentValidationKey);
300
+ // Get the changed file's basename for matching
301
+ let changedFilename = null;
302
+ try {
303
+ const changedPath = (0, url_1.fileURLToPath)(changedUri);
304
+ changedFilename = path.basename(changedPath);
305
+ }
306
+ catch {
307
+ return; // Can't process non-file URIs
308
+ }
309
+ // Re-validate open documents that import the changed file
310
+ for (const [uri, doc] of documents) {
311
+ if (uri === normalizedChangedUri || !uri.endsWith(".ump")) {
312
+ continue;
313
+ }
314
+ // Check if this document imports the changed file
315
+ if (documentImportsFile(doc, changedFilename)) {
316
+ scheduleValidation(doc);
317
+ }
318
+ }
319
+ }, 500); // Longer debounce for dependent files
320
+ pendingValidations.set(dependentValidationKey, handle);
321
+ }
322
+ /**
323
+ * Check if a document imports a specific file (directly or transitively).
324
+ */
325
+ function documentImportsFile(document, targetFilename) {
326
+ const docPath = getDocumentFilePath(document);
327
+ if (!docPath) {
328
+ return false;
329
+ }
330
+ const docDir = path.dirname(docPath);
331
+ const reachableFiles = collectReachableFiles(docPath, document.getText(), docDir);
332
+ // Check if any reachable file matches the target filename
333
+ for (const filePath of reachableFiles) {
334
+ if (path.basename(filePath) === targetFilename) {
335
+ return true;
336
+ }
337
+ }
338
+ return false;
339
+ }
340
+ async function validateTextDocument(document) {
341
+ const jarPath = resolveJarPath();
342
+ if (!jarPath) {
343
+ return;
344
+ }
345
+ try {
346
+ const diagnostics = await runUmpleSyncAndParseDiagnostics(jarPath, document);
347
+ connection.sendDiagnostics({ uri: document.uri, diagnostics });
348
+ }
349
+ catch (error) {
350
+ connection.console.error(`Diagnostics failed: ${String(error)}`);
351
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
352
+ }
353
+ }
354
+ function resolveJarPath() {
355
+ if (!umpleSyncJarPath) {
356
+ if (!jarWarningShown) {
357
+ connection.window.showWarningMessage("UmpleSync jar path not set. Configure initializationOptions.umpleSyncJarPath or UMPLESYNC_JAR.");
358
+ jarWarningShown = true;
359
+ }
360
+ return undefined;
361
+ }
362
+ if (!fs.existsSync(umpleSyncJarPath)) {
363
+ if (!jarWarningShown) {
364
+ connection.window.showWarningMessage(`UmpleSync jar not found at ${umpleSyncJarPath}. Update the path or UMPLESYNC_JAR.`);
365
+ jarWarningShown = true;
366
+ }
367
+ return undefined;
368
+ }
369
+ return umpleSyncJarPath;
370
+ }
371
+ async function runUmpleSyncAndParseDiagnostics(jarPath, document) {
372
+ const docPath = getDocumentFilePath(document);
373
+ if (!docPath) {
374
+ return [];
375
+ }
376
+ // Create shadow workspace with all unsaved documents
377
+ const shadow = await createShadowWorkspace(docPath);
378
+ if (!shadow) {
379
+ return [];
380
+ }
381
+ try {
382
+ // Write current document to shadow workspace with trailing newlines
383
+ let text = document.getText();
384
+ if (!text.endsWith("\n\n")) {
385
+ text = text.replace(/\n?$/, "\n\n");
386
+ }
387
+ await fs.promises.writeFile(shadow.targetFile, text, "utf8");
388
+ const commandLine = `-generate nothing ${formatUmpleArg(shadow.targetFile)}`;
389
+ const { stdout, stderr } = await sendUmpleSyncCommand(jarPath, commandLine);
390
+ const tempFilename = path.basename(shadow.targetFile);
391
+ const documentDir = getDocumentDirectory(document);
392
+ return parseUmpleDiagnostics(stderr, stdout, document, tempFilename, documentDir);
393
+ }
394
+ finally {
395
+ await shadow.cleanup();
396
+ }
397
+ }
398
+ /**
399
+ * Create a shadow workspace with only the files needed for compilation:
400
+ * the current document and all files it imports via `use` statements.
401
+ */
402
+ async function createShadowWorkspace(documentPath) {
403
+ const documentDir = path.dirname(documentPath);
404
+ const documentName = path.basename(documentPath);
405
+ // Get document content (from open doc or disk)
406
+ const fileUri = (0, url_1.pathToFileURL)(documentPath).toString();
407
+ const openDoc = getDocument(fileUri);
408
+ const documentContent = openDoc?.getText() ?? readFileSafe(documentPath);
409
+ if (!documentContent) {
410
+ return null;
411
+ }
412
+ // Create shadow directory
413
+ const shadowDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "umple-shadow-"));
414
+ try {
415
+ // Find only files reachable via use statements (lazy approach)
416
+ const reachableFiles = collectReachableFiles(documentPath, documentContent, documentDir);
417
+ // Also include the current document
418
+ reachableFiles.add(path.normalize(documentPath));
419
+ // Create directory structure and symlink/copy files
420
+ for (const filePath of reachableFiles) {
421
+ if (!fs.existsSync(filePath))
422
+ continue;
423
+ const relativePath = path.relative(documentDir, filePath);
424
+ const shadowPath = path.join(shadowDir, relativePath);
425
+ const shadowFileDir = path.dirname(shadowPath);
426
+ // Create directory structure
427
+ await fs.promises.mkdir(shadowFileDir, { recursive: true });
428
+ // Check if this file is open in the editor with unsaved changes
429
+ const uri = (0, url_1.pathToFileURL)(filePath).toString();
430
+ const doc = getDocument(uri);
431
+ if (doc) {
432
+ // Write unsaved content
433
+ await fs.promises.writeFile(shadowPath, doc.getText(), "utf8");
434
+ }
435
+ else {
436
+ // Symlink to original file
437
+ await fs.promises.symlink(filePath, shadowPath);
438
+ }
439
+ }
440
+ const targetFile = path.join(shadowDir, documentName);
441
+ return {
442
+ shadowDir,
443
+ targetFile,
444
+ cleanup: async () => {
445
+ await fs.promises.rm(shadowDir, { recursive: true, force: true });
446
+ },
447
+ };
448
+ }
449
+ catch (error) {
450
+ // Cleanup on error
451
+ await fs.promises.rm(shadowDir, { recursive: true, force: true });
452
+ throw error;
453
+ }
454
+ }
455
+ async function sendUmpleSyncCommand(jarPath, commandLine) {
456
+ try {
457
+ return await connectAndSend(commandLine);
458
+ }
459
+ catch (error) {
460
+ if (!isConnectionError(error)) {
461
+ throw error;
462
+ }
463
+ const started = await startUmpleSyncServer(jarPath);
464
+ if (!started) {
465
+ throw error;
466
+ }
467
+ for (let attempt = 0; attempt < 5; attempt += 1) {
468
+ try {
469
+ return await connectAndSend(commandLine);
470
+ }
471
+ catch (retryError) {
472
+ if (!isConnectionError(retryError)) {
473
+ throw retryError;
474
+ }
475
+ await delay(150);
476
+ }
477
+ }
478
+ throw error;
479
+ }
480
+ }
481
+ // Send command to UmpleSync.jar socket server and receive the output
482
+ function connectAndSend(commandLine) {
483
+ return new Promise((resolve, reject) => {
484
+ const socket = new net.Socket();
485
+ const chunks = [];
486
+ let settled = false;
487
+ const finishSuccess = (raw) => {
488
+ if (settled) {
489
+ return;
490
+ }
491
+ settled = true;
492
+ const { stdout, stderr } = splitUmpleSyncOutput(raw);
493
+ resolve({ stdout, stderr });
494
+ };
495
+ const finishError = (err) => {
496
+ if (settled) {
497
+ return;
498
+ }
499
+ settled = true;
500
+ socket.destroy();
501
+ reject(err);
502
+ };
503
+ socket.setEncoding("utf8");
504
+ socket.setTimeout(umpleSyncTimeoutMs);
505
+ socket.on("data", (chunk) => {
506
+ if (typeof chunk === "string") {
507
+ chunks.push(chunk);
508
+ }
509
+ else {
510
+ chunks.push(chunk.toString("utf8"));
511
+ }
512
+ });
513
+ socket.on("end", () => {
514
+ finishSuccess(chunks.join(""));
515
+ });
516
+ socket.on("error", (err) => {
517
+ const error = err instanceof Error ? err : new Error(String(err));
518
+ finishError(error);
519
+ });
520
+ socket.on("timeout", () => {
521
+ finishError(new Error("umplesync socket timeout"));
522
+ });
523
+ socket.connect(umpleSyncPort, umpleSyncHost, () => {
524
+ socket.end(commandLine);
525
+ });
526
+ });
527
+ }
528
+ async function startUmpleSyncServer(jarPath) {
529
+ if (serverProcess) {
530
+ return true;
531
+ }
532
+ return new Promise((resolve) => {
533
+ const child = (0, child_process_1.spawn)("java", ["-jar", jarPath, "-server", String(umpleSyncPort)], {
534
+ detached: true,
535
+ stdio: "ignore",
536
+ });
537
+ child.on("error", (err) => {
538
+ connection.console.error(`Failed to start umplesync: ${String(err)}`);
539
+ resolve(false);
540
+ });
541
+ child.unref();
542
+ serverProcess = child;
543
+ resolve(true);
544
+ });
545
+ }
546
+ function splitUmpleSyncOutput(raw) {
547
+ let stdout = "";
548
+ let stderr = "";
549
+ let index = 0;
550
+ while (index < raw.length) {
551
+ const start = raw.indexOf("ERROR!!", index);
552
+ if (start === -1) {
553
+ stdout += raw.slice(index);
554
+ break;
555
+ }
556
+ stdout += raw.slice(index, start);
557
+ const end = raw.indexOf("!!ERROR", start + 7);
558
+ if (end === -1) {
559
+ stderr += raw.slice(start + 7);
560
+ break;
561
+ }
562
+ stderr += raw.slice(start + 7, end);
563
+ index = end + 7;
564
+ }
565
+ return { stdout, stderr };
566
+ }
567
+ /**
568
+ * Collect all file paths reachable via transitive use statements.
569
+ * Used to filter go-to-definition results to only show symbols from imported files.
570
+ */
571
+ function collectReachableFiles(filePath, content, documentDir) {
572
+ const visited = new Set();
573
+ collectReachableFilesRecursive(filePath, content, documentDir, visited);
574
+ return visited;
575
+ }
576
+ /**
577
+ * Recursively collect reachable file paths.
578
+ */
579
+ function collectReachableFilesRecursive(filePath, content, documentDir, visited) {
580
+ const useStatements = symbolIndex_1.symbolIndex.extractUseStatements(filePath, content);
581
+ for (const usePath of useStatements) {
582
+ // Resolve the file path
583
+ let resolvedPath;
584
+ if (path.isAbsolute(usePath)) {
585
+ resolvedPath = usePath;
586
+ }
587
+ else {
588
+ resolvedPath = path.resolve(documentDir, usePath);
589
+ }
590
+ // Ensure .ump extension
591
+ if (!resolvedPath.endsWith(".ump")) {
592
+ resolvedPath += ".ump";
593
+ }
594
+ const normalizedPath = path.normalize(resolvedPath);
595
+ if (visited.has(normalizedPath)) {
596
+ continue; // Already visited, skip to avoid cycles
597
+ }
598
+ visited.add(normalizedPath);
599
+ // Recursively process this file's use statements
600
+ if (fs.existsSync(resolvedPath)) {
601
+ try {
602
+ const fileContent = fs.readFileSync(resolvedPath, "utf8");
603
+ const fileDir = path.dirname(resolvedPath);
604
+ collectReachableFilesRecursive(resolvedPath, fileContent, fileDir, visited);
605
+ }
606
+ catch {
607
+ // Ignore read errors
608
+ }
609
+ }
610
+ }
611
+ }
612
+ function isConnectionError(error) {
613
+ if (!error || typeof error !== "object") {
614
+ return false;
615
+ }
616
+ const maybeError = error;
617
+ return (maybeError.code === "ECONNREFUSED" ||
618
+ maybeError.code === "ECONNRESET" ||
619
+ maybeError.code === "EPIPE" ||
620
+ maybeError.code === "ETIMEDOUT" ||
621
+ (maybeError.message || "").includes("umplesync socket timeout"));
622
+ }
623
+ function delay(ms) {
624
+ return new Promise((resolve) => setTimeout(resolve, ms));
625
+ }
626
+ function parseUmpleDiagnostics(stderr, stdout, document, tempFilename, documentDir) {
627
+ const jsonDiagnostics = parseUmpleJsonDiagnostics(stderr, document, tempFilename, documentDir);
628
+ if (jsonDiagnostics.length === 0 && stdout.includes("Success")) {
629
+ connection.console.info("Umple compile succeeded.");
630
+ }
631
+ return jsonDiagnostics;
632
+ }
633
+ /**
634
+ * Build a map of direct import filename → use statement line number.
635
+ * Also builds a transitive map: direct filename → set of all transitive filenames.
636
+ */
637
+ function buildImportMaps(useStatements, documentDir) {
638
+ const directImports = new Map();
639
+ const transitiveMap = new Map();
640
+ for (const useStmt of useStatements) {
641
+ // Resolve the use path to a filename
642
+ let resolvedPath = useStmt.path;
643
+ if (!path.isAbsolute(resolvedPath)) {
644
+ resolvedPath = path.resolve(documentDir, resolvedPath);
645
+ }
646
+ if (!resolvedPath.endsWith(".ump")) {
647
+ resolvedPath += ".ump";
648
+ }
649
+ const filename = path.basename(resolvedPath);
650
+ // Map direct import filename to line
651
+ directImports.set(filename, useStmt.line);
652
+ // Collect transitive imports for this direct import
653
+ const transitiveFiles = new Set();
654
+ transitiveFiles.add(filename); // Include the direct import itself
655
+ collectTransitiveFilenames(resolvedPath, transitiveFiles);
656
+ transitiveMap.set(filename, transitiveFiles);
657
+ }
658
+ return { directImports, transitiveMap };
659
+ }
660
+ /**
661
+ * Recursively collect all filenames transitively imported by a file.
662
+ */
663
+ function collectTransitiveFilenames(filePath, collected, visited = new Set()) {
664
+ const normalizedPath = path.normalize(filePath);
665
+ if (visited.has(normalizedPath)) {
666
+ return; // Avoid cycles
667
+ }
668
+ visited.add(normalizedPath);
669
+ if (!fs.existsSync(filePath)) {
670
+ return;
671
+ }
672
+ try {
673
+ const content = fs.readFileSync(filePath, "utf8");
674
+ const fileDir = path.dirname(filePath);
675
+ const useStatements = symbolIndex_1.symbolIndex.extractUseStatements(filePath, content);
676
+ for (const usePath of useStatements) {
677
+ let resolvedPath = usePath;
678
+ if (!path.isAbsolute(resolvedPath)) {
679
+ resolvedPath = path.resolve(fileDir, resolvedPath);
680
+ }
681
+ if (!resolvedPath.endsWith(".ump")) {
682
+ resolvedPath += ".ump";
683
+ }
684
+ const filename = path.basename(resolvedPath);
685
+ collected.add(filename);
686
+ collectTransitiveFilenames(resolvedPath, collected, visited);
687
+ }
688
+ }
689
+ catch {
690
+ // Ignore read errors
691
+ }
692
+ }
693
+ /**
694
+ * Find the use statement line for an error from an imported file.
695
+ * Returns the line number if found, or undefined if the error doesn't match any import.
696
+ */
697
+ function findUseLineForError(errorFilename, directImports, transitiveMap) {
698
+ // Check if it's a direct import
699
+ if (directImports.has(errorFilename)) {
700
+ return directImports.get(errorFilename);
701
+ }
702
+ // Check transitive imports
703
+ for (const [directFilename, transitiveFiles] of transitiveMap) {
704
+ if (transitiveFiles.has(errorFilename)) {
705
+ return directImports.get(directFilename);
706
+ }
707
+ }
708
+ return undefined;
709
+ }
710
+ function parseUmpleJsonDiagnostics(stderr, document, tempFilename, documentDir) {
711
+ const trimmed = stderr.trim();
712
+ if (!trimmed) {
713
+ return [];
714
+ }
715
+ const jsonText = extractJson(trimmed);
716
+ if (!jsonText) {
717
+ return [];
718
+ }
719
+ try {
720
+ // Sanitize invalid JSON escapes from umplesync (e.g. \' is not valid JSON)
721
+ const sanitized = jsonText.replace(/\\'/g, "'");
722
+ const parsed = JSON.parse(sanitized);
723
+ if (!Array.isArray(parsed.results)) {
724
+ return [];
725
+ }
726
+ const lines = document.getText().split(/\r?\n/);
727
+ const diagnostics = [];
728
+ // Build import maps for mapping imported file errors to use statement lines
729
+ const docPath = getDocumentFilePath(document);
730
+ let directImports = new Map();
731
+ let transitiveMap = new Map();
732
+ if (docPath && documentDir) {
733
+ const useStatements = symbolIndex_1.symbolIndex.extractUseStatementsWithPositions(docPath, document.getText());
734
+ const maps = buildImportMaps(useStatements, documentDir);
735
+ directImports = maps.directImports;
736
+ transitiveMap = maps.transitiveMap;
737
+ }
738
+ for (const result of parsed.results) {
739
+ const severityValue = Number(result.severity ?? "3");
740
+ const severity = severityValue > 2
741
+ ? node_1.DiagnosticSeverity.Warning
742
+ : node_1.DiagnosticSeverity.Error;
743
+ // Check if error is from an imported file
744
+ if (result.filename && result.filename !== tempFilename) {
745
+ // Find the use statement line for this imported file error
746
+ const useLine = findUseLineForError(result.filename, directImports, transitiveMap);
747
+ if (useLine !== undefined) {
748
+ const useLineText = lines[useLine] ?? "";
749
+ const errorCode = result.errorCode
750
+ ? (severity === node_1.DiagnosticSeverity.Warning ? "W" : "E") +
751
+ result.errorCode
752
+ : "";
753
+ const message = errorCode
754
+ ? `In imported file (${result.filename}:${result.line}): ${errorCode}: ${result.message}`
755
+ : `In imported file (${result.filename}:${result.line}): ${result.message}`;
756
+ diagnostics.push({
757
+ severity,
758
+ range: node_1.Range.create(node_1.Position.create(useLine, 0), node_1.Position.create(useLine, useLineText.length)),
759
+ message,
760
+ source: "umple",
761
+ });
762
+ }
763
+ continue;
764
+ }
765
+ // Error in current file
766
+ const lineNumber = Math.max(Number(result.line ?? "1") - 1, 0);
767
+ const lineText = lines[lineNumber] ?? "";
768
+ const firstNonSpace = lineText.search(/\S/);
769
+ const startChar = firstNonSpace === -1 ? 0 : firstNonSpace;
770
+ const details = [
771
+ result.errorCode
772
+ ? (severity === node_1.DiagnosticSeverity.Warning ? "W" : "E") +
773
+ result.errorCode
774
+ : undefined,
775
+ result.message,
776
+ ].filter(Boolean);
777
+ diagnostics.push({
778
+ severity,
779
+ range: node_1.Range.create(node_1.Position.create(lineNumber, startChar), node_1.Position.create(lineNumber, lineText.length)),
780
+ message: details.join(": "),
781
+ source: "umple",
782
+ });
783
+ }
784
+ return diagnostics;
785
+ }
786
+ catch {
787
+ return [];
788
+ }
789
+ }
790
+ function resolveWorkspaceRoots(params) {
791
+ const roots = [];
792
+ if (Array.isArray(params.workspaceFolders)) {
793
+ for (const folder of params.workspaceFolders) {
794
+ if (folder.uri.startsWith("file:")) {
795
+ try {
796
+ roots.push(path.resolve((0, url_1.fileURLToPath)(folder.uri)));
797
+ }
798
+ catch {
799
+ // ignore invalid workspace uri
800
+ }
801
+ }
802
+ }
803
+ }
804
+ if (roots.length === 0 &&
805
+ params.rootUri &&
806
+ params.rootUri.startsWith("file:")) {
807
+ try {
808
+ roots.push(path.resolve((0, url_1.fileURLToPath)(params.rootUri)));
809
+ }
810
+ catch {
811
+ // ignore invalid root uri
812
+ }
813
+ }
814
+ return roots;
815
+ }
816
+ function getDocumentDirectory(document) {
817
+ const docPath = getDocumentFilePath(document);
818
+ if (!docPath) {
819
+ return null;
820
+ }
821
+ return path.dirname(docPath);
822
+ }
823
+ function getDocumentFilePath(document) {
824
+ if (!document.uri.startsWith("file:")) {
825
+ return null;
826
+ }
827
+ try {
828
+ return (0, url_1.fileURLToPath)(document.uri);
829
+ }
830
+ catch {
831
+ return null;
832
+ }
833
+ }
834
+ function resolveUseDefinitionFromLine(document, position) {
835
+ const docPath = getDocumentFilePath(document);
836
+ if (!docPath) {
837
+ return null;
838
+ }
839
+ // Use tree-sitter to find the use path at this position
840
+ const usePath = symbolIndex_1.symbolIndex.getUsePathAtPosition(docPath, document.getText(), position.line, Math.max(0, position.character - 1));
841
+ if (!usePath) {
842
+ return null;
843
+ }
844
+ // Ensure .ump extension
845
+ let fileRef = usePath;
846
+ if (!fileRef.endsWith(".ump")) {
847
+ fileRef += ".ump";
848
+ }
849
+ const baseDir = path.dirname(docPath);
850
+ const targetPath = path.isAbsolute(fileRef)
851
+ ? fileRef
852
+ : path.join(baseDir, fileRef);
853
+ const uri = (0, url_1.pathToFileURL)(targetPath).toString();
854
+ return node_1.Location.create(uri, node_1.Range.create(node_1.Position.create(0, 0), node_1.Position.create(0, 0)));
855
+ }
856
+ function getCompletionPrefix(document, line, character) {
857
+ const lineText = document.getText(node_1.Range.create(node_1.Position.create(line, 0), node_1.Position.create(line, character)));
858
+ const match = lineText.match(/[A-Za-z_][A-Za-z0-9_]*$/);
859
+ return match ? match[0] : "";
860
+ }
861
+ /**
862
+ * Get the prefix for use-path completion (allows dots, slashes, underscores).
863
+ */
864
+ function getUsePathPrefix(document, line, character) {
865
+ const lineText = document.getText(node_1.Range.create(node_1.Position.create(line, 0), node_1.Position.create(line, character)));
866
+ const match = lineText.match(/[A-Za-z_][A-Za-z0-9_.\/]*$/);
867
+ return match ? match[0] : "";
868
+ }
869
+ /**
870
+ * Get the word (identifier) at the given position.
871
+ * Used for go-to-definition symbol lookup.
872
+ */
873
+ function getWordAtPosition(document, position) {
874
+ const lineText = document.getText(node_1.Range.create(node_1.Position.create(position.line, 0), node_1.Position.create(position.line + 1, 0)));
875
+ // Find word boundaries around the cursor
876
+ let start = position.character;
877
+ let end = position.character;
878
+ // Expand left to find start of word
879
+ while (start > 0 && /[A-Za-z0-9_]/.test(lineText[start - 1])) {
880
+ start--;
881
+ }
882
+ // Expand right to find end of word
883
+ while (end < lineText.length && /[A-Za-z0-9_]/.test(lineText[end])) {
884
+ end++;
885
+ }
886
+ if (start === end) {
887
+ return null;
888
+ }
889
+ const word = lineText.substring(start, end);
890
+ // Only return valid identifiers (must start with letter or underscore)
891
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(word)) {
892
+ return word;
893
+ }
894
+ return null;
895
+ }
896
+ function filterCompletions(items, prefix) {
897
+ if (!prefix) {
898
+ return items;
899
+ }
900
+ const lowerPrefix = prefix.toLowerCase();
901
+ return items.filter((item) => item.label.toLowerCase().startsWith(lowerPrefix));
902
+ }
903
+ /**
904
+ * Build completion items for a given tree-sitter based context.
905
+ * Combines context-specific keywords with symbol-based completions.
906
+ */
907
+ function buildCompletionsForContext(context, prefix, reachableFiles) {
908
+ const items = [];
909
+ const seen = new Set();
910
+ // Helper: get symbols of a kind, filtered to reachable files
911
+ const getSymbols = (kind) => {
912
+ const all = symbolIndex_1.symbolIndex.getSymbolsByKind(kind);
913
+ if (!reachableFiles)
914
+ return all;
915
+ return all.filter((sym) => reachableFiles.has(path.normalize(sym.file)));
916
+ };
917
+ // Add context-specific keywords
918
+ const keywords = keywords_1.COMPLETION_KEYWORDS[context] ?? [];
919
+ for (const kw of keywords) {
920
+ if (!seen.has(`kw:${kw}`)) {
921
+ seen.add(`kw:${kw}`);
922
+ items.push({ label: kw, kind: node_1.CompletionItemKind.Keyword });
923
+ }
924
+ }
925
+ // Add symbol-based completions depending on context
926
+ if (symbolIndexReady) {
927
+ switch (context) {
928
+ case "top":
929
+ // No symbol completions at top level
930
+ break;
931
+ case "class_body": {
932
+ // Offer attribute modifiers and types
933
+ for (const mod of keywords_1.COMPLETION_KEYWORDS.attribute_modifiers) {
934
+ if (!seen.has(`kw:${mod}`)) {
935
+ seen.add(`kw:${mod}`);
936
+ items.push({ label: mod, kind: node_1.CompletionItemKind.Keyword });
937
+ }
938
+ }
939
+ for (const typ of keywords_1.COMPLETION_KEYWORDS.attribute_types) {
940
+ if (!seen.has(`type:${typ}`)) {
941
+ seen.add(`type:${typ}`);
942
+ items.push({
943
+ label: typ,
944
+ kind: node_1.CompletionItemKind.TypeParameter,
945
+ detail: "type",
946
+ });
947
+ }
948
+ }
949
+ // Offer class/interface/trait names (for isA, type references)
950
+ for (const sym of getSymbols("class")) {
951
+ if (!seen.has(`sym:${sym.name}`)) {
952
+ seen.add(`sym:${sym.name}`);
953
+ items.push({
954
+ label: sym.name,
955
+ kind: node_1.CompletionItemKind.Class,
956
+ detail: "class",
957
+ });
958
+ }
959
+ }
960
+ for (const sym of getSymbols("interface")) {
961
+ if (!seen.has(`sym:${sym.name}`)) {
962
+ seen.add(`sym:${sym.name}`);
963
+ items.push({
964
+ label: sym.name,
965
+ kind: node_1.CompletionItemKind.Interface,
966
+ detail: "interface",
967
+ });
968
+ }
969
+ }
970
+ for (const sym of getSymbols("trait")) {
971
+ if (!seen.has(`sym:${sym.name}`)) {
972
+ seen.add(`sym:${sym.name}`);
973
+ items.push({
974
+ label: sym.name,
975
+ kind: node_1.CompletionItemKind.Class,
976
+ detail: "trait",
977
+ });
978
+ }
979
+ }
980
+ break;
981
+ }
982
+ case "isa_type": {
983
+ // After "isA" keyword: only offer class/interface/trait names
984
+ for (const sym of getSymbols("class")) {
985
+ if (!seen.has(`sym:${sym.name}`)) {
986
+ seen.add(`sym:${sym.name}`);
987
+ items.push({
988
+ label: sym.name,
989
+ kind: node_1.CompletionItemKind.Class,
990
+ detail: "class",
991
+ });
992
+ }
993
+ }
994
+ for (const sym of getSymbols("interface")) {
995
+ if (!seen.has(`sym:${sym.name}`)) {
996
+ seen.add(`sym:${sym.name}`);
997
+ items.push({
998
+ label: sym.name,
999
+ kind: node_1.CompletionItemKind.Interface,
1000
+ detail: "interface",
1001
+ });
1002
+ }
1003
+ }
1004
+ for (const sym of getSymbols("trait")) {
1005
+ if (!seen.has(`sym:${sym.name}`)) {
1006
+ seen.add(`sym:${sym.name}`);
1007
+ items.push({
1008
+ label: sym.name,
1009
+ kind: node_1.CompletionItemKind.Class,
1010
+ detail: "trait",
1011
+ });
1012
+ }
1013
+ }
1014
+ break;
1015
+ }
1016
+ case "transition_target": {
1017
+ // After "->" in state: only offer state names
1018
+ for (const sym of getSymbols("state")) {
1019
+ if (!seen.has(`sym:${sym.name}`)) {
1020
+ seen.add(`sym:${sym.name}`);
1021
+ items.push({
1022
+ label: sym.name,
1023
+ kind: node_1.CompletionItemKind.EnumMember,
1024
+ detail: "state",
1025
+ });
1026
+ }
1027
+ }
1028
+ break;
1029
+ }
1030
+ case "association_type": {
1031
+ // Type position in association: only offer class names
1032
+ for (const sym of getSymbols("class")) {
1033
+ if (!seen.has(`sym:${sym.name}`)) {
1034
+ seen.add(`sym:${sym.name}`);
1035
+ items.push({
1036
+ label: sym.name,
1037
+ kind: node_1.CompletionItemKind.Class,
1038
+ detail: "class",
1039
+ });
1040
+ }
1041
+ }
1042
+ break;
1043
+ }
1044
+ case "state_machine":
1045
+ case "state": {
1046
+ // Offer state names from the index
1047
+ for (const sym of getSymbols("state")) {
1048
+ if (!seen.has(`sym:${sym.name}`)) {
1049
+ seen.add(`sym:${sym.name}`);
1050
+ items.push({
1051
+ label: sym.name,
1052
+ kind: node_1.CompletionItemKind.EnumMember,
1053
+ detail: "state",
1054
+ });
1055
+ }
1056
+ }
1057
+ break;
1058
+ }
1059
+ case "association": {
1060
+ // Offer class names for association endpoints
1061
+ for (const sym of getSymbols("class")) {
1062
+ if (!seen.has(`sym:${sym.name}`)) {
1063
+ seen.add(`sym:${sym.name}`);
1064
+ items.push({
1065
+ label: sym.name,
1066
+ kind: node_1.CompletionItemKind.Class,
1067
+ detail: "class",
1068
+ });
1069
+ }
1070
+ }
1071
+ break;
1072
+ }
1073
+ // depend_package, enum, method, comment, unknown: no additional symbol completions
1074
+ }
1075
+ }
1076
+ return filterCompletions(items, prefix);
1077
+ }
1078
+ function getUseFileCompletions(document, prefix, line, character) {
1079
+ const docDir = getDocumentDirectory(document);
1080
+ if (!docDir) {
1081
+ return [];
1082
+ }
1083
+ const docBasename = path.basename(getDocumentFilePath(document) ?? "");
1084
+ let files;
1085
+ try {
1086
+ files = fs
1087
+ .readdirSync(docDir)
1088
+ .filter((f) => f.endsWith(".ump") && f !== docBasename);
1089
+ }
1090
+ catch {
1091
+ return [];
1092
+ }
1093
+ // Replace range covers the entire prefix the user has typed
1094
+ const replaceRange = node_1.Range.create(node_1.Position.create(line, character - prefix.length), node_1.Position.create(line, character));
1095
+ const lowerPrefix = prefix.toLowerCase();
1096
+ return files
1097
+ .filter((f) => f.toLowerCase().startsWith(lowerPrefix))
1098
+ .map((f) => ({
1099
+ label: f,
1100
+ kind: node_1.CompletionItemKind.File,
1101
+ detail: "Umple file",
1102
+ textEdit: { range: replaceRange, newText: f },
1103
+ }));
1104
+ }
1105
+ function extractJson(text) {
1106
+ const start = text.indexOf("{");
1107
+ const end = text.lastIndexOf("}");
1108
+ if (start === -1 || end === -1 || end <= start) {
1109
+ return null;
1110
+ }
1111
+ return text.slice(start, end + 1);
1112
+ }
1113
+ function formatUmpleArg(filePath) {
1114
+ return JSON.stringify(filePath);
1115
+ }
1116
+ connection.listen();
1117
+ //# sourceMappingURL=server.js.map