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/bin.d.ts +2 -0
- package/out/bin.js +5 -0
- package/out/bin.js.map +1 -0
- package/out/keywords.d.ts +38 -0
- package/out/keywords.js +205 -0
- package/out/keywords.js.map +1 -0
- package/out/server.d.ts +1 -0
- package/out/server.js +1117 -0
- package/out/server.js.map +1 -0
- package/out/symbolIndex.d.ts +170 -0
- package/out/symbolIndex.js +705 -0
- package/out/symbolIndex.js.map +1 -0
- package/package.json +39 -0
- package/tree-sitter-umple.wasm +0 -0
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
|