umple-lsp-server 0.2.0 → 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
package/out/server.js CHANGED
@@ -2,14 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const child_process_1 = require("child_process");
4
4
  const fs = require("fs");
5
- const net = require("net");
6
5
  const os = require("os");
7
6
  const path = require("path");
8
7
  const url_1 = require("url");
9
8
  const node_1 = require("vscode-languageserver/node");
10
9
  const vscode_languageserver_textdocument_1 = require("vscode-languageserver-textdocument");
11
- const keywords_1 = require("./keywords");
12
10
  const symbolIndex_1 = require("./symbolIndex");
11
+ const resolver_1 = require("./resolver");
12
+ const completionBuilder_1 = require("./completionBuilder");
13
+ const hoverBuilder_1 = require("./hoverBuilder");
14
+ const documentSymbolBuilder_1 = require("./documentSymbolBuilder");
15
+ const formatter_1 = require("./formatter");
13
16
  const connection = (0, node_1.createConnection)(node_1.ProposedFeatures.all);
14
17
  const documents = new Map();
15
18
  const pendingValidations = new Map();
@@ -72,29 +75,17 @@ function findFile(candidates) {
72
75
  return undefined;
73
76
  }
74
77
  let umpleSyncJarPath;
75
- let umpleSyncHost = "localhost";
76
- let umpleSyncPort = 5555;
77
- let umpleSyncTimeoutMs = 50000;
78
+ let umpleSyncTimeoutMs = 30000;
78
79
  let jarWarningShown = false;
79
- let serverProcess;
80
80
  let treeSitterWasmPath;
81
81
  let symbolIndexReady = false;
82
- const DEFAULT_UMPLESYNC_TIMEOUT_MS = 50000;
82
+ const DEFAULT_UMPLESYNC_TIMEOUT_MS = 30000;
83
+ // Track in-flight validations so we can abort stale ones
84
+ const inFlightValidations = new Map();
83
85
  connection.onInitialize((params) => {
84
86
  const initOptions = params.initializationOptions;
85
87
  umpleSyncJarPath =
86
88
  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
89
  if (typeof initOptions?.umpleSyncTimeoutMs === "number") {
99
90
  umpleSyncTimeoutMs = initOptions.umpleSyncTimeoutMs;
100
91
  }
@@ -113,9 +104,16 @@ connection.onInitialize((params) => {
113
104
  textDocumentSync: node_1.TextDocumentSyncKind.Incremental,
114
105
  completionProvider: {
115
106
  resolveProvider: false,
116
- triggerCharacters: ["/"],
107
+ triggerCharacters: ["/", "."],
117
108
  },
118
109
  definitionProvider: true,
110
+ referencesProvider: true,
111
+ renameProvider: {
112
+ prepareProvider: true,
113
+ },
114
+ hoverProvider: true,
115
+ documentSymbolProvider: true,
116
+ documentFormattingProvider: true,
119
117
  },
120
118
  };
121
119
  });
@@ -154,7 +152,7 @@ connection.onDidOpenTextDocument((params) => {
154
152
  if (symbolIndexReady) {
155
153
  try {
156
154
  const filePath = (0, url_1.fileURLToPath)(params.textDocument.uri);
157
- symbolIndex_1.symbolIndex.updateFile(filePath, params.textDocument.text);
155
+ symbolIndex_1.symbolIndex.indexFile(filePath, params.textDocument.text);
158
156
  }
159
157
  catch {
160
158
  // Ignore errors for non-file URIs
@@ -190,6 +188,14 @@ connection.onDidChangeTextDocument((params) => {
190
188
  }
191
189
  const updated = vscode_languageserver_textdocument_1.TextDocument.update(document, params.contentChanges, params.textDocument.version);
192
190
  setDocument(params.textDocument.uri, updated);
191
+ // Keep the symbol index current so the clean baseline stays fresh.
192
+ // Without this, state symbols added during clean edits would be lost
193
+ // when the file later enters an errored state (error preservation
194
+ // would use a stale clean snapshot).
195
+ const changedPath = getDocumentFilePath(updated);
196
+ if (changedPath && symbolIndexReady) {
197
+ symbolIndex_1.symbolIndex.updateFile(changedPath, updated.getText());
198
+ }
193
199
  scheduleValidation(updated);
194
200
  // Re-validate other open documents that might depend on this file
195
201
  scheduleDependentValidation(params.textDocument.uri);
@@ -202,6 +208,12 @@ connection.onDidCloseTextDocument((params) => {
202
208
  clearTimeout(pendingValidation);
203
209
  pendingValidations.delete(normalizedUri);
204
210
  }
211
+ // Abort any in-flight validation so stale results aren't published after close
212
+ const inFlight = inFlightValidations.get(normalizedUri);
213
+ if (inFlight) {
214
+ inFlight.abort();
215
+ inFlightValidations.delete(normalizedUri);
216
+ }
205
217
  connection.sendDiagnostics({ uri: params.textDocument.uri, diagnostics: [] });
206
218
  });
207
219
  connection.onCompletion(async (params) => {
@@ -245,170 +257,261 @@ connection.onCompletion(async (params) => {
245
257
  else if (params.context?.triggerCharacter === "/") {
246
258
  return [];
247
259
  }
248
- // 5a. Keywords from LookaheadIterator
249
- for (const kw of info.keywords) {
250
- if (!seen.has(kw)) {
251
- seen.add(kw);
252
- items.push({ label: kw, kind: node_1.CompletionItemKind.Keyword });
253
- }
254
- }
255
- // 5b. Operators from LookaheadIterator
256
- for (const op of info.operators) {
257
- if (!seen.has(op)) {
258
- seen.add(op);
259
- items.push({ label: op, kind: node_1.CompletionItemKind.Operator });
260
- }
261
- }
262
- // 5c. Built-in types (when in type-compatible scope)
263
- if (Array.isArray(symbolKinds) &&
264
- symbolKinds.some((k) => ["class", "interface", "trait", "enum"].includes(k))) {
265
- for (const typ of keywords_1.BUILTIN_TYPES) {
266
- if (!seen.has(typ)) {
267
- seen.add(typ);
268
- items.push({
269
- label: typ,
270
- kind: node_1.CompletionItemKind.TypeParameter,
271
- detail: "type",
272
- });
273
- }
274
- }
275
- }
276
- // 5d. Constraint scope: only own attributes (Umple E28)
277
- if (symbolKinds === "own_attribute" && info.enclosingClass) {
278
- const symbols = symbolIndex_1.symbolIndex
279
- .getSymbols({ container: info.enclosingClass, kind: "attribute" })
280
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
281
- for (const sym of symbols) {
282
- if (!seen.has(sym.name)) {
283
- seen.add(sym.name);
284
- items.push({
285
- label: sym.name,
286
- kind: symbolKindToCompletionKind("attribute"),
287
- detail: "attribute",
288
- });
289
- }
290
- }
291
- return items;
292
- }
293
- // 5e. Symbol completions from index (scoped to reachable files)
294
- if (Array.isArray(symbolKinds)) {
295
- for (const symKind of symbolKinds) {
296
- let symbols;
297
- // Scoped lookups for container-aware kinds
298
- if (symKind === "attribute" && info.enclosingClass) {
299
- symbols = symbolIndex_1.symbolIndex
300
- .getSymbols({
301
- container: info.enclosingClass,
302
- kind: "attribute",
303
- inherited: true,
304
- })
305
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
306
- }
307
- else if (symKind === "state" && info.enclosingStateMachine) {
308
- symbols = symbolIndex_1.symbolIndex
309
- .getSymbols({ container: info.enclosingStateMachine, kind: "state" })
310
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
311
- }
312
- else if (symKind === "template" && info.enclosingClass) {
313
- symbols = symbolIndex_1.symbolIndex
314
- .getSymbols({ container: info.enclosingClass, kind: "template" })
315
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
316
- }
317
- else {
318
- symbols = symbolIndex_1.symbolIndex
319
- .getSymbols({ kind: symKind })
320
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
321
- }
322
- for (const sym of symbols) {
323
- if (!seen.has(sym.name)) {
324
- seen.add(sym.name);
325
- items.push({
326
- label: sym.name,
327
- kind: symbolKindToCompletionKind(symKind),
328
- detail: symKind,
329
- });
330
- }
331
- }
260
+ else if (params.context?.triggerCharacter === ".") {
261
+ if (!info.dottedStatePrefix)
262
+ return [];
263
+ // Dot-state completion: return only child state names, skip all
264
+ // generic phases (keywords, operators, types, other symbol kinds).
265
+ const childNames = info.enclosingStateMachine
266
+ ? symbolIndex_1.symbolIndex.getChildStateNames(info.dottedStatePrefix, info.enclosingStateMachine, reachableFiles)
267
+ : [];
268
+ return childNames.map((name) => ({
269
+ label: name,
270
+ kind: (0, completionBuilder_1.symbolKindToCompletionKind)("state"),
271
+ detail: "state",
272
+ sortText: `0_${name}`,
273
+ }));
274
+ }
275
+ // 5. Build semantic completion items (keywords, operators, types, symbols)
276
+ const semanticItems = (0, completionBuilder_1.buildSemanticCompletionItems)(info, symbolKinds, symbolIndex_1.symbolIndex, reachableFiles);
277
+ // Merge use_path items (if any) with semantic items, deduplicating
278
+ for (const item of semanticItems) {
279
+ if (!seen.has(item.label)) {
280
+ seen.add(item.label);
281
+ items.push(item);
332
282
  }
333
283
  }
334
284
  return items;
335
285
  });
286
+ // ── Shared symbol resolution (used by go-to-def and hover) ──────────────────
287
+ /**
288
+ * Resolve symbol(s) at a given position. Thin wrapper around the shared
289
+ * resolver that handles reachable-file computation from the document context.
290
+ */
291
+ function resolveSymbolAtPosition(docPath, content, line, col) {
292
+ const reachableFiles = ensureImportsIndexed(docPath, content);
293
+ return (0, resolver_1.resolveSymbolAtPosition)(symbolIndex_1.symbolIndex, docPath, content, line, col, reachableFiles);
294
+ }
336
295
  connection.onDefinition(async (params) => {
337
296
  const document = getDocument(params.textDocument.uri);
338
- if (!document) {
297
+ if (!document || !symbolIndexReady)
339
298
  return [];
340
- }
341
- if (!symbolIndexReady) {
342
- return [];
343
- }
344
299
  const docPath = getDocumentFilePath(document);
345
- if (!docPath) {
300
+ if (!docPath)
346
301
  return [];
347
- }
348
302
  const token = symbolIndex_1.symbolIndex.getTokenAtPosition(docPath, document.getText(), params.position.line, params.position.character);
349
- if (!token) {
303
+ if (!token)
350
304
  return [];
351
- }
352
305
  // use statement with .ump extension: resolve as file reference
353
306
  if (token.word.endsWith(".ump")) {
354
307
  const baseDir = path.dirname(docPath);
355
308
  const targetPath = path.isAbsolute(token.word)
356
309
  ? token.word
357
310
  : path.join(baseDir, token.word);
358
- if (!fs.existsSync(targetPath)) {
311
+ if (!fs.existsSync(targetPath))
359
312
  return [];
360
- }
361
313
  return [
362
314
  node_1.Location.create((0, url_1.pathToFileURL)(targetPath).toString(), node_1.Range.create(node_1.Position.create(0, 0), node_1.Position.create(0, 0))),
363
315
  ];
364
316
  }
365
- // Symbol lookup, filtered by reachable files
366
- const reachableFiles = ensureImportsIndexed(docPath, document.getText());
367
- // For container-scoped kinds, try scoped lookup first (with inheritance), then global fallback
368
- const containerKinds = new Set([
317
+ const resolved = resolveSymbolAtPosition(docPath, document.getText(), params.position.line, params.position.character);
318
+ if (!resolved || resolved.symbols.length === 0)
319
+ return [];
320
+ return resolved.symbols.map((sym) => node_1.Location.create((0, url_1.pathToFileURL)(sym.file).toString(), node_1.Range.create(node_1.Position.create(sym.line, sym.column), node_1.Position.create(sym.endLine, sym.endColumn))));
321
+ });
322
+ // ── Find References ──────────────────────────────────────────────────────────
323
+ connection.onReferences(async (params) => {
324
+ const document = getDocument(params.textDocument.uri);
325
+ if (!document || !symbolIndexReady)
326
+ return [];
327
+ const docPath = getDocumentFilePath(document);
328
+ if (!docPath)
329
+ return [];
330
+ // 1. Identify symbol (full declaration set)
331
+ const resolved = resolveSymbolAtPosition(docPath, document.getText(), params.position.line, params.position.character);
332
+ if (!resolved || resolved.symbols.length === 0)
333
+ return [];
334
+ // 2. Index all workspace files (content-hash skips unchanged files)
335
+ symbolIndex_1.symbolIndex.indexWorkspace(workspaceRoots, (filePath) => {
336
+ const uri = (0, url_1.pathToFileURL)(filePath).toString();
337
+ return getDocument(uri)?.getText();
338
+ });
339
+ // 3. Compute search scope: declaration files + reverse importers
340
+ const declFiles = new Set(resolved.symbols.map((s) => path.normalize(s.file)));
341
+ const filesToSearch = symbolIndex_1.symbolIndex.getReverseImporters(declFiles);
342
+ // Include declaration files themselves
343
+ for (const f of declFiles)
344
+ filesToSearch.add(f);
345
+ // 4. Find references
346
+ const refs = symbolIndex_1.symbolIndex.findReferences(resolved.symbols, filesToSearch, params.context.includeDeclaration);
347
+ // 5. Convert to Location[]
348
+ return refs.map((r) => node_1.Location.create((0, url_1.pathToFileURL)(r.file).toString(), node_1.Range.create(node_1.Position.create(r.line, r.column), node_1.Position.create(r.endLine, r.endColumn))));
349
+ });
350
+ // ── Rename ───────────────────────────────────────────────────────────────────
351
+ const RENAMEABLE_KINDS = new Set([
352
+ "class",
353
+ "interface",
354
+ "trait",
355
+ "enum",
356
+ "mixset",
357
+ "attribute",
358
+ "const",
359
+ "state",
360
+ "statemachine",
361
+ "tracecase",
362
+ ]);
363
+ function isUnambiguousRename(symbols) {
364
+ if (symbols.length <= 1)
365
+ return symbols.length === 1;
366
+ // All symbols must share the same kind
367
+ const kind = symbols[0].kind;
368
+ if (!symbols.every((s) => s.kind === kind))
369
+ return false;
370
+ // State: must share same statePath (different paths = different states)
371
+ if (kind === "state") {
372
+ const refPath = symbols[0].statePath?.join(".");
373
+ return symbols.every((s) => s.statePath?.join(".") === refPath);
374
+ }
375
+ // Container-scoped kinds: must share container + name
376
+ const containerScoped = new Set([
369
377
  "attribute",
378
+ "const",
370
379
  "method",
371
380
  "template",
372
- "state",
381
+ "statemachine",
373
382
  ]);
374
- const isScoped = token.kinds?.some((k) => containerKinds.has(k));
375
- let container;
376
- if (isScoped) {
377
- container = token.kinds?.some((k) => k === "state")
378
- ? token.enclosingStateMachine
379
- : token.enclosingClass;
380
- }
381
- let filteredSymbols = [];
382
- if (container) {
383
- filteredSymbols = symbolIndex_1.symbolIndex
384
- .getSymbols({
385
- name: token.word,
386
- kind: token.kinds ?? undefined,
387
- container,
388
- inherited: true,
389
- })
390
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
391
- }
392
- if (filteredSymbols.length === 0) {
393
- filteredSymbols = symbolIndex_1.symbolIndex
394
- .getSymbols({ name: token.word, kind: token.kinds ?? undefined })
395
- .filter((s) => reachableFiles.has(path.normalize(s.file)));
396
- }
397
- if (filteredSymbols.length > 0) {
398
- return filteredSymbols.map((sym) => node_1.Location.create((0, url_1.pathToFileURL)(sym.file).toString(), node_1.Range.create(node_1.Position.create(sym.line, sym.column), node_1.Position.create(sym.endLine, sym.endColumn))));
399
- }
400
- return [];
383
+ if (containerScoped.has(kind)) {
384
+ const { container, name } = symbols[0];
385
+ return symbols.every((s) => s.container === container && s.name === name);
386
+ }
387
+ // Top-level mergeable kinds (class, interface, trait, enum, mixset):
388
+ // same name = partial definitions of the same entity
389
+ const name = symbols[0].name;
390
+ return symbols.every((s) => s.name === name);
391
+ }
392
+ connection.onPrepareRename(async (params) => {
393
+ const document = getDocument(params.textDocument.uri);
394
+ if (!document || !symbolIndexReady)
395
+ return null;
396
+ const docPath = getDocumentFilePath(document);
397
+ if (!docPath)
398
+ return null;
399
+ // Full semantic resolution
400
+ const resolved = resolveSymbolAtPosition(docPath, document.getText(), params.position.line, params.position.character);
401
+ if (!resolved || resolved.symbols.length === 0)
402
+ return null;
403
+ // Kind must be in the renameable set
404
+ if (!RENAMEABLE_KINDS.has(resolved.symbols[0].kind))
405
+ return null;
406
+ // Identity must be unambiguous
407
+ if (!isUnambiguousRename(resolved.symbols))
408
+ return null;
409
+ // Get precise identifier range
410
+ const range = symbolIndex_1.symbolIndex.getNodeRangeAtPosition(docPath, document.getText(), params.position.line, params.position.character);
411
+ if (!range)
412
+ return null;
413
+ return {
414
+ range: node_1.Range.create(node_1.Position.create(range.startLine, range.startColumn), node_1.Position.create(range.endLine, range.endColumn)),
415
+ placeholder: resolved.token.word,
416
+ };
417
+ });
418
+ connection.onRenameRequest(async (params) => {
419
+ const document = getDocument(params.textDocument.uri);
420
+ if (!document || !symbolIndexReady)
421
+ return null;
422
+ const docPath = getDocumentFilePath(document);
423
+ if (!docPath)
424
+ return null;
425
+ // Validate new name is a legal identifier
426
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(params.newName))
427
+ return null;
428
+ // 1. Full semantic resolution (same checks as prepareRename)
429
+ const resolved = resolveSymbolAtPosition(docPath, document.getText(), params.position.line, params.position.character);
430
+ if (!resolved || resolved.symbols.length === 0)
431
+ return null;
432
+ if (!RENAMEABLE_KINDS.has(resolved.symbols[0].kind))
433
+ return null;
434
+ if (!isUnambiguousRename(resolved.symbols))
435
+ return null;
436
+ // 2. Index workspace
437
+ symbolIndex_1.symbolIndex.indexWorkspace(workspaceRoots, (filePath) => {
438
+ const uri = (0, url_1.pathToFileURL)(filePath).toString();
439
+ return getDocument(uri)?.getText();
440
+ });
441
+ // 3. Compute search scope
442
+ const declFiles = new Set(resolved.symbols.map((s) => path.normalize(s.file)));
443
+ const filesToSearch = symbolIndex_1.symbolIndex.getReverseImporters(declFiles);
444
+ for (const f of declFiles)
445
+ filesToSearch.add(f);
446
+ // 4. Find ALL references including declarations
447
+ const refs = symbolIndex_1.symbolIndex.findReferences(resolved.symbols, filesToSearch, true);
448
+ // 5. Build WorkspaceEdit
449
+ const changes = {};
450
+ for (const r of refs) {
451
+ const uri = (0, url_1.pathToFileURL)(r.file).toString();
452
+ if (!changes[uri])
453
+ changes[uri] = [];
454
+ changes[uri].push(node_1.TextEdit.replace(node_1.Range.create(node_1.Position.create(r.line, r.column), node_1.Position.create(r.endLine, r.endColumn)), params.newName));
455
+ }
456
+ return { changes };
457
+ });
458
+ // ── Hover ───────────────────────────────────────────────────────────────────
459
+ connection.onHover(async (params) => {
460
+ const document = getDocument(params.textDocument.uri);
461
+ if (!document || !symbolIndexReady)
462
+ return null;
463
+ const docPath = getDocumentFilePath(document);
464
+ if (!docPath)
465
+ return null;
466
+ const resolved = resolveSymbolAtPosition(docPath, document.getText(), params.position.line, params.position.character);
467
+ if (!resolved || resolved.symbols.length === 0)
468
+ return null;
469
+ const sym = resolved.symbols[0];
470
+ const markdown = (0, hoverBuilder_1.buildHoverMarkdown)(sym, resolved.symbols, {
471
+ getTree: (fp) => symbolIndex_1.symbolIndex.getTree(fp),
472
+ getIsAParents: (name) => symbolIndex_1.symbolIndex.getIsAParents(name),
473
+ });
474
+ if (!markdown)
475
+ return null;
476
+ return { contents: { kind: "markdown", value: markdown } };
477
+ });
478
+ // ── Document Symbols (Outline) ──────────────────────────────────────────────
479
+ connection.onDocumentSymbol(async (params) => {
480
+ const document = getDocument(params.textDocument.uri);
481
+ if (!document || !symbolIndexReady)
482
+ return [];
483
+ const docPath = getDocumentFilePath(document);
484
+ if (!docPath)
485
+ return [];
486
+ symbolIndex_1.symbolIndex.updateFile(docPath, document.getText());
487
+ return (0, documentSymbolBuilder_1.buildDocumentSymbolTree)(symbolIndex_1.symbolIndex.getFileSymbols(docPath));
488
+ });
489
+ // ── Formatting ──────────────────────────────────────────────────────────────
490
+ connection.onDocumentFormatting(async (params) => {
491
+ const document = getDocument(params.textDocument.uri);
492
+ if (!document)
493
+ return [];
494
+ const docPath = getDocumentFilePath(document);
495
+ if (!docPath || !symbolIndexReady)
496
+ return [];
497
+ symbolIndex_1.symbolIndex.updateFile(docPath, document.getText());
498
+ const tree = symbolIndex_1.symbolIndex.getTree(docPath);
499
+ if (!tree)
500
+ return [];
501
+ const skipRanges = (0, formatter_1.getCodeContentRanges)(tree);
502
+ return (0, formatter_1.computeIndentEdits)(document.getText(), params.options, skipRanges);
401
503
  });
402
504
  function scheduleValidation(document) {
403
- const existing = pendingValidations.get(document.uri);
505
+ const uriKey = normalizeUri(document.uri);
506
+ const existing = pendingValidations.get(uriKey);
404
507
  if (existing) {
405
508
  clearTimeout(existing);
406
509
  }
407
510
  const handle = setTimeout(() => {
408
- pendingValidations.delete(document.uri);
511
+ pendingValidations.delete(uriKey);
409
512
  void validateTextDocument(document);
410
513
  }, 300);
411
- pendingValidations.set(document.uri, handle);
514
+ pendingValidations.set(uriKey, handle);
412
515
  }
413
516
  // Debounce key for dependent validation
414
517
  const dependentValidationKey = "__dependent__";
@@ -470,33 +573,57 @@ async function validateTextDocument(document) {
470
573
  if (!jarPath) {
471
574
  return;
472
575
  }
576
+ const uriKey = normalizeUri(document.uri);
577
+ const docVersion = document.version;
578
+ // Abort any in-flight validation for this document
579
+ const previous = inFlightValidations.get(uriKey);
580
+ if (previous) {
581
+ previous.abort();
582
+ }
583
+ const abortController = new AbortController();
584
+ inFlightValidations.set(uriKey, abortController);
473
585
  try {
474
- const diagnostics = await runUmpleSyncAndParseDiagnostics(jarPath, document);
586
+ const diagnostics = await runUmpleSyncAndParseDiagnostics(jarPath, document, abortController.signal);
587
+ if (abortController.signal.aborted) {
588
+ return;
589
+ }
590
+ // Drop stale results if the document has been edited since we started
591
+ const current = getDocument(document.uri);
592
+ if (!current || current.version !== docVersion) {
593
+ return;
594
+ }
475
595
  connection.sendDiagnostics({ uri: document.uri, diagnostics });
476
596
  }
477
597
  catch (error) {
598
+ if (abortController.signal.aborted) {
599
+ return;
600
+ }
601
+ const current = getDocument(document.uri);
602
+ if (!current || current.version !== docVersion) {
603
+ return;
604
+ }
478
605
  connection.console.error(`Diagnostics failed: ${String(error)}`);
479
606
  connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
480
607
  }
481
- }
482
- function resolveJarPath() {
483
- if (!umpleSyncJarPath) {
484
- if (!jarWarningShown) {
485
- connection.window.showWarningMessage("UmpleSync jar path not set. Configure initializationOptions.umpleSyncJarPath or UMPLESYNC_JAR.");
486
- jarWarningShown = true;
608
+ finally {
609
+ if (inFlightValidations.get(uriKey) === abortController) {
610
+ inFlightValidations.delete(uriKey);
487
611
  }
488
- return undefined;
489
612
  }
490
- if (!fs.existsSync(umpleSyncJarPath)) {
613
+ }
614
+ function resolveJarPath() {
615
+ if (!umpleSyncJarPath || !fs.existsSync(umpleSyncJarPath)) {
491
616
  if (!jarWarningShown) {
492
- connection.window.showWarningMessage(`UmpleSync jar not found at ${umpleSyncJarPath}. Update the path or UMPLESYNC_JAR.`);
617
+ connection.window.showWarningMessage("Umple diagnostics are disabled: umplesync.jar was not found. " +
618
+ "Completion and go-to-definition still work. " +
619
+ "Reload the window to retry.");
493
620
  jarWarningShown = true;
494
621
  }
495
622
  return undefined;
496
623
  }
497
624
  return umpleSyncJarPath;
498
625
  }
499
- async function runUmpleSyncAndParseDiagnostics(jarPath, document) {
626
+ async function runUmpleSyncAndParseDiagnostics(jarPath, document, signal) {
500
627
  const docPath = getDocumentFilePath(document);
501
628
  if (!docPath) {
502
629
  return [];
@@ -513,8 +640,7 @@ async function runUmpleSyncAndParseDiagnostics(jarPath, document) {
513
640
  text = text.replace(/\n?$/, "\n\n");
514
641
  }
515
642
  await fs.promises.writeFile(shadow.targetFile, text, "utf8");
516
- const commandLine = `-generate nothing ${formatUmpleArg(shadow.targetFile)}`;
517
- const { stdout, stderr } = await sendUmpleSyncCommand(jarPath, commandLine);
643
+ const { stdout, stderr } = await runUmpleDirect(jarPath, shadow.targetFile, signal);
518
644
  const tempFilename = path.basename(shadow.targetFile);
519
645
  const documentDir = getDocumentDirectory(document);
520
646
  return parseUmpleDiagnostics(stderr, stdout, document, tempFilename, documentDir);
@@ -523,13 +649,39 @@ async function runUmpleSyncAndParseDiagnostics(jarPath, document) {
523
649
  await shadow.cleanup();
524
650
  }
525
651
  }
652
+ /**
653
+ * Run umplesync.jar directly as a subprocess (one process per request).
654
+ * This is simpler and more reliable than the socket server approach —
655
+ * no persistent state, no stuck connections between requests.
656
+ */
657
+ function runUmpleDirect(jarPath, filePath, signal) {
658
+ return new Promise((resolve, reject) => {
659
+ if (signal?.aborted) {
660
+ reject(new Error("aborted"));
661
+ return;
662
+ }
663
+ (0, child_process_1.execFile)("java", ["-jar", jarPath, "-generate", "nothing", filePath], { signal, timeout: umpleSyncTimeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
664
+ if (signal?.aborted) {
665
+ reject(new Error("aborted"));
666
+ return;
667
+ }
668
+ // Umplesync writes diagnostics to stderr and exits 0 on compile errors.
669
+ // Any non-null error here is a real execution failure (java not found,
670
+ // corrupt jar, runtime crash, timeout kill) — reject unconditionally.
671
+ if (error) {
672
+ reject(error);
673
+ return;
674
+ }
675
+ resolve({ stdout, stderr });
676
+ });
677
+ });
678
+ }
526
679
  /**
527
680
  * Create a shadow workspace with only the files needed for compilation:
528
681
  * the current document and all files it imports via `use` statements.
529
682
  */
530
683
  async function createShadowWorkspace(documentPath) {
531
684
  const documentDir = path.dirname(documentPath);
532
- const documentName = path.basename(documentPath);
533
685
  // Get document content (from open doc or disk)
534
686
  const fileUri = (0, url_1.pathToFileURL)(documentPath).toString();
535
687
  const openDoc = getDocument(fileUri);
@@ -543,12 +695,18 @@ async function createShadowWorkspace(documentPath) {
543
695
  // Find only files reachable via use statements (lazy approach)
544
696
  const reachableFiles = collectReachableFiles(documentPath, documentContent, documentDir);
545
697
  // Also include the current document
546
- reachableFiles.add(path.normalize(documentPath));
698
+ const normalizedDocPath = path.normalize(documentPath);
699
+ reachableFiles.add(normalizedDocPath);
700
+ // Compute a common ancestor directory so all relative paths stay inside
701
+ // the shadow workspace (no "../" escapes). Always include the document
702
+ // path in the ancestor calculation even if it doesn't exist on disk,
703
+ // since we always write it to the shadow workspace.
704
+ const allForBase = Array.from(reachableFiles);
705
+ const baseDir = findCommonAncestor(allForBase);
706
+ const allPaths = allForBase.filter((f) => fs.existsSync(f));
547
707
  // Create directory structure and symlink/copy files
548
- for (const filePath of reachableFiles) {
549
- if (!fs.existsSync(filePath))
550
- continue;
551
- const relativePath = path.relative(documentDir, filePath);
708
+ for (const filePath of allPaths) {
709
+ const relativePath = path.relative(baseDir, filePath);
552
710
  const shadowPath = path.join(shadowDir, relativePath);
553
711
  const shadowFileDir = path.dirname(shadowPath);
554
712
  // Create directory structure
@@ -565,7 +723,10 @@ async function createShadowWorkspace(documentPath) {
565
723
  await fs.promises.symlink(filePath, shadowPath);
566
724
  }
567
725
  }
568
- const targetFile = path.join(shadowDir, documentName);
726
+ const targetFile = path.join(shadowDir, path.relative(baseDir, normalizedDocPath));
727
+ // Ensure target directory exists (document may not be on disk,
728
+ // so the symlink/copy loop above may not have created it)
729
+ await fs.promises.mkdir(path.dirname(targetFile), { recursive: true });
569
730
  return {
570
731
  shadowDir,
571
732
  targetFile,
@@ -580,117 +741,28 @@ async function createShadowWorkspace(documentPath) {
580
741
  throw error;
581
742
  }
582
743
  }
583
- async function sendUmpleSyncCommand(jarPath, commandLine) {
584
- try {
585
- return await connectAndSend(commandLine);
586
- }
587
- catch (error) {
588
- if (!isConnectionError(error)) {
589
- throw error;
590
- }
591
- const started = await startUmpleSyncServer(jarPath);
592
- if (!started) {
593
- throw error;
594
- }
595
- for (let attempt = 0; attempt < 5; attempt += 1) {
596
- try {
597
- return await connectAndSend(commandLine);
598
- }
599
- catch (retryError) {
600
- if (!isConnectionError(retryError)) {
601
- throw retryError;
602
- }
603
- await delay(150);
604
- }
605
- }
606
- throw error;
607
- }
608
- }
609
- // Send command to UmpleSync.jar socket server and receive the output
610
- function connectAndSend(commandLine) {
611
- return new Promise((resolve, reject) => {
612
- const socket = new net.Socket();
613
- const chunks = [];
614
- let settled = false;
615
- const finishSuccess = (raw) => {
616
- if (settled) {
617
- return;
618
- }
619
- settled = true;
620
- const { stdout, stderr } = splitUmpleSyncOutput(raw);
621
- resolve({ stdout, stderr });
622
- };
623
- const finishError = (err) => {
624
- if (settled) {
625
- return;
626
- }
627
- settled = true;
628
- socket.destroy();
629
- reject(err);
630
- };
631
- socket.setEncoding("utf8");
632
- socket.setTimeout(umpleSyncTimeoutMs);
633
- socket.on("data", (chunk) => {
634
- if (typeof chunk === "string") {
635
- chunks.push(chunk);
636
- }
637
- else {
638
- chunks.push(chunk.toString("utf8"));
744
+ /**
745
+ * Find the deepest common ancestor directory of a list of file paths.
746
+ * Used to ensure all shadow workspace relative paths stay positive (no "../").
747
+ */
748
+ function findCommonAncestor(filePaths) {
749
+ if (filePaths.length === 0) {
750
+ return os.tmpdir();
751
+ }
752
+ const dirs = filePaths.map((f) => path.dirname(path.normalize(f)));
753
+ const segments = dirs[0].split(path.sep);
754
+ let commonLength = segments.length;
755
+ for (let i = 1; i < dirs.length; i++) {
756
+ const parts = dirs[i].split(path.sep);
757
+ commonLength = Math.min(commonLength, parts.length);
758
+ for (let j = 0; j < commonLength; j++) {
759
+ if (segments[j] !== parts[j]) {
760
+ commonLength = j;
761
+ break;
639
762
  }
640
- });
641
- socket.on("end", () => {
642
- finishSuccess(chunks.join(""));
643
- });
644
- socket.on("error", (err) => {
645
- const error = err instanceof Error ? err : new Error(String(err));
646
- finishError(error);
647
- });
648
- socket.on("timeout", () => {
649
- finishError(new Error("umplesync socket timeout"));
650
- });
651
- socket.connect(umpleSyncPort, umpleSyncHost, () => {
652
- socket.end(commandLine);
653
- });
654
- });
655
- }
656
- async function startUmpleSyncServer(jarPath) {
657
- if (serverProcess) {
658
- return true;
659
- }
660
- return new Promise((resolve) => {
661
- const child = (0, child_process_1.spawn)("java", ["-jar", jarPath, "-server", String(umpleSyncPort)], {
662
- detached: true,
663
- stdio: "ignore",
664
- });
665
- child.on("error", (err) => {
666
- connection.console.error(`Failed to start umplesync: ${String(err)}`);
667
- resolve(false);
668
- });
669
- child.unref();
670
- serverProcess = child;
671
- resolve(true);
672
- });
673
- }
674
- function splitUmpleSyncOutput(raw) {
675
- let stdout = "";
676
- let stderr = "";
677
- let index = 0;
678
- while (index < raw.length) {
679
- const start = raw.indexOf("ERROR!!", index);
680
- if (start === -1) {
681
- stdout += raw.slice(index);
682
- break;
683
763
  }
684
- stdout += raw.slice(index, start);
685
- const end = raw.indexOf("!!ERROR", start + 7);
686
- if (end === -1) {
687
- stderr += raw.slice(start + 7);
688
- break;
689
- }
690
- stderr += raw.slice(start + 7, end);
691
- index = end + 7;
692
764
  }
693
- return { stdout, stderr };
765
+ return segments.slice(0, commonLength).join(path.sep) || path.sep;
694
766
  }
695
767
  /**
696
768
  * Collect all file paths reachable via transitive use statements.
@@ -737,20 +809,6 @@ function collectReachableFilesRecursive(filePath, content, documentDir, visited)
737
809
  }
738
810
  }
739
811
  }
740
- function isConnectionError(error) {
741
- if (!error || typeof error !== "object") {
742
- return false;
743
- }
744
- const maybeError = error;
745
- return (maybeError.code === "ECONNREFUSED" ||
746
- maybeError.code === "ECONNRESET" ||
747
- maybeError.code === "EPIPE" ||
748
- maybeError.code === "ETIMEDOUT" ||
749
- (maybeError.message || "").includes("umplesync socket timeout"));
750
- }
751
- function delay(ms) {
752
- return new Promise((resolve) => setTimeout(resolve, ms));
753
- }
754
812
  function parseUmpleDiagnostics(stderr, stdout, document, tempFilename, documentDir) {
755
813
  const jsonDiagnostics = parseUmpleJsonDiagnostics(stderr, document, tempFilename, documentDir);
756
814
  if (jsonDiagnostics.length === 0 && stdout.includes("Success")) {
@@ -961,39 +1019,6 @@ function getDocumentFilePath(document) {
961
1019
  return null;
962
1020
  }
963
1021
  }
964
- /**
965
- * Map a SymbolKind to the appropriate LSP CompletionItemKind.
966
- */
967
- function symbolKindToCompletionKind(kind) {
968
- switch (kind) {
969
- case "class":
970
- return node_1.CompletionItemKind.Class;
971
- case "interface":
972
- return node_1.CompletionItemKind.Interface;
973
- case "trait":
974
- return node_1.CompletionItemKind.Class;
975
- case "enum":
976
- return node_1.CompletionItemKind.Enum;
977
- case "state":
978
- return node_1.CompletionItemKind.EnumMember;
979
- case "statemachine":
980
- return node_1.CompletionItemKind.Enum;
981
- case "attribute":
982
- return node_1.CompletionItemKind.Field;
983
- case "method":
984
- return node_1.CompletionItemKind.Method;
985
- case "association":
986
- return node_1.CompletionItemKind.Reference;
987
- case "mixset":
988
- return node_1.CompletionItemKind.Module;
989
- case "requirement":
990
- return node_1.CompletionItemKind.Reference;
991
- case "template":
992
- return node_1.CompletionItemKind.Property;
993
- default:
994
- return node_1.CompletionItemKind.Text;
995
- }
996
- }
997
1022
  function getUseFileCompletions(document, prefix, line, character) {
998
1023
  const docDir = getDocumentDirectory(document);
999
1024
  if (!docDir) {
@@ -1065,8 +1090,5 @@ function extractJson(text) {
1065
1090
  }
1066
1091
  return text.slice(start, end + 1);
1067
1092
  }
1068
- function formatUmpleArg(filePath) {
1069
- return JSON.stringify(filePath);
1070
- }
1071
1093
  connection.listen();
1072
1094
  //# sourceMappingURL=server.js.map