umple-lsp-server 0.1.0 → 0.2.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 CHANGED
@@ -113,7 +113,7 @@ connection.onInitialize((params) => {
113
113
  textDocumentSync: node_1.TextDocumentSyncKind.Incremental,
114
114
  completionProvider: {
115
115
  resolveProvider: false,
116
- triggerCharacters: [" ", "."],
116
+ triggerCharacters: ["/"],
117
117
  },
118
118
  definitionProvider: true,
119
119
  },
@@ -210,65 +210,193 @@ connection.onCompletion(async (params) => {
210
210
  return [];
211
211
  }
212
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);
213
+ if (!docPath || !symbolIndexReady) {
214
+ return [];
215
+ }
216
+ const text = document.getText();
217
+ const { line, character } = params.position;
218
+ // 1. Get completion info from LookaheadIterator + scope query
219
+ const info = symbolIndex_1.symbolIndex.getCompletionInfo(text, line, character);
220
+ // 2. Suppress completions
221
+ if (info.isComment || info.isDefinitionName) {
222
+ return [];
223
+ }
224
+ if (info.symbolKinds === "suppress") {
225
+ return [];
226
+ }
227
+ // 3. Ensure imported files are indexed
228
+ const reachableFiles = ensureImportsIndexed(docPath, text);
229
+ const items = [];
230
+ const seen = new Set();
231
+ // 4. Normalize symbolKinds: use_path → file completions + mixset symbols
232
+ // "/" trigger outside use_path is suppressed
233
+ let symbolKinds = info.symbolKinds;
234
+ if (symbolKinds === "use_path") {
235
+ for (const item of getUseFileCompletions(document, info.prefix, line, character)) {
236
+ seen.add(item.label);
237
+ items.push(item);
238
+ }
239
+ // Path prefix (contains /) → only file completions, no keywords/mixsets
240
+ if (info.prefix.includes("/")) {
241
+ return items;
242
+ }
243
+ symbolKinds = ["mixset"];
217
244
  }
218
- // Suppress completions in comments
219
- if (context === "comment") {
245
+ else if (params.context?.triggerCharacter === "/") {
220
246
  return [];
221
247
  }
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);
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
+ }
226
275
  }
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());
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;
231
292
  }
232
- // All other contexts: keyword + symbol completions
233
- const prefix = getCompletionPrefix(document, params.position.line, params.position.character);
234
- return buildCompletionsForContext(context, prefix, reachableFiles);
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
+ }
332
+ }
333
+ }
334
+ return items;
235
335
  });
236
336
  connection.onDefinition(async (params) => {
237
337
  const document = getDocument(params.textDocument.uri);
238
338
  if (!document) {
239
339
  return [];
240
340
  }
241
- const useLocation = resolveUseDefinitionFromLine(document, params.position);
242
- if (useLocation) {
243
- return [useLocation];
341
+ if (!symbolIndexReady) {
342
+ return [];
244
343
  }
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))) {
344
+ const docPath = getDocumentFilePath(document);
345
+ if (!docPath) {
346
+ return [];
347
+ }
348
+ const token = symbolIndex_1.symbolIndex.getTokenAtPosition(docPath, document.getText(), params.position.line, params.position.character);
349
+ if (!token) {
350
+ return [];
351
+ }
352
+ // use statement with .ump extension: resolve as file reference
353
+ if (token.word.endsWith(".ump")) {
354
+ const baseDir = path.dirname(docPath);
355
+ const targetPath = path.isAbsolute(token.word)
356
+ ? token.word
357
+ : path.join(baseDir, token.word);
358
+ if (!fs.existsSync(targetPath)) {
253
359
  return [];
254
360
  }
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
- }
361
+ return [
362
+ 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
+ ];
364
+ }
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([
369
+ "attribute",
370
+ "method",
371
+ "template",
372
+ "state",
373
+ ]);
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))));
270
399
  }
271
- // No definition found in symbol index
272
400
  return [];
273
401
  });
274
402
  function scheduleValidation(document) {
@@ -579,6 +707,10 @@ function collectReachableFiles(filePath, content, documentDir) {
579
707
  function collectReachableFilesRecursive(filePath, content, documentDir, visited) {
580
708
  const useStatements = symbolIndex_1.symbolIndex.extractUseStatements(filePath, content);
581
709
  for (const usePath of useStatements) {
710
+ // Skip mixset names (no .ump extension = not a file reference)
711
+ if (!usePath.endsWith(".ump")) {
712
+ continue;
713
+ }
582
714
  // Resolve the file path
583
715
  let resolvedPath;
584
716
  if (path.isAbsolute(usePath)) {
@@ -587,10 +719,6 @@ function collectReachableFilesRecursive(filePath, content, documentDir, visited)
587
719
  else {
588
720
  resolvedPath = path.resolve(documentDir, usePath);
589
721
  }
590
- // Ensure .ump extension
591
- if (!resolvedPath.endsWith(".ump")) {
592
- resolvedPath += ".ump";
593
- }
594
722
  const normalizedPath = path.normalize(resolvedPath);
595
723
  if (visited.has(normalizedPath)) {
596
724
  continue; // Already visited, skip to avoid cycles
@@ -638,14 +766,15 @@ function buildImportMaps(useStatements, documentDir) {
638
766
  const directImports = new Map();
639
767
  const transitiveMap = new Map();
640
768
  for (const useStmt of useStatements) {
769
+ // Skip mixset names (no .ump extension = not a file reference)
770
+ if (!useStmt.path.endsWith(".ump")) {
771
+ continue;
772
+ }
641
773
  // Resolve the use path to a filename
642
774
  let resolvedPath = useStmt.path;
643
775
  if (!path.isAbsolute(resolvedPath)) {
644
776
  resolvedPath = path.resolve(documentDir, resolvedPath);
645
777
  }
646
- if (!resolvedPath.endsWith(".ump")) {
647
- resolvedPath += ".ump";
648
- }
649
778
  const filename = path.basename(resolvedPath);
650
779
  // Map direct import filename to line
651
780
  directImports.set(filename, useStmt.line);
@@ -674,13 +803,14 @@ function collectTransitiveFilenames(filePath, collected, visited = new Set()) {
674
803
  const fileDir = path.dirname(filePath);
675
804
  const useStatements = symbolIndex_1.symbolIndex.extractUseStatements(filePath, content);
676
805
  for (const usePath of useStatements) {
806
+ // Skip mixset names (no .ump extension = not a file reference)
807
+ if (!usePath.endsWith(".ump")) {
808
+ continue;
809
+ }
677
810
  let resolvedPath = usePath;
678
811
  if (!path.isAbsolute(resolvedPath)) {
679
812
  resolvedPath = path.resolve(fileDir, resolvedPath);
680
813
  }
681
- if (!resolvedPath.endsWith(".ump")) {
682
- resolvedPath += ".ump";
683
- }
684
814
  const filename = path.basename(resolvedPath);
685
815
  collected.add(filename);
686
816
  collectTransitiveFilenames(resolvedPath, collected, visited);
@@ -831,276 +961,101 @@ function getDocumentFilePath(document) {
831
961
  return null;
832
962
  }
833
963
  }
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
964
  /**
870
- * Get the word (identifier) at the given position.
871
- * Used for go-to-definition symbol lookup.
965
+ * Map a SymbolKind to the appropriate LSP CompletionItemKind.
872
966
  */
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;
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;
899
995
  }
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
996
  }
1078
997
  function getUseFileCompletions(document, prefix, line, character) {
1079
998
  const docDir = getDocumentDirectory(document);
1080
999
  if (!docDir) {
1081
1000
  return [];
1082
1001
  }
1083
- const docBasename = path.basename(getDocumentFilePath(document) ?? "");
1084
- let files;
1002
+ // Split prefix into directory part and filename filter
1003
+ const lastSlash = prefix.lastIndexOf("/");
1004
+ const dirPart = lastSlash >= 0 ? prefix.substring(0, lastSlash + 1) : "";
1005
+ const filePart = lastSlash >= 0 ? prefix.substring(lastSlash + 1) : prefix;
1006
+ // Resolve target directory
1007
+ const targetDir = path.resolve(docDir, dirPart);
1008
+ let entries;
1085
1009
  try {
1086
- files = fs
1087
- .readdirSync(docDir)
1088
- .filter((f) => f.endsWith(".ump") && f !== docBasename);
1010
+ entries = fs.readdirSync(targetDir, { withFileTypes: true });
1089
1011
  }
1090
1012
  catch {
1091
1013
  return [];
1092
1014
  }
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
- }));
1015
+ const docBasename = path.basename(getDocumentFilePath(document) ?? "");
1016
+ const isSameDir = path.normalize(targetDir) === path.normalize(docDir);
1017
+ const lowerFilter = filePart.toLowerCase();
1018
+ // Replace range covers only the filePart (after the last '/').
1019
+ // The dirPart is already in the document and stays untouched.
1020
+ // This ensures VS Code filters items against the filePart, not the full prefix.
1021
+ const replaceRange = node_1.Range.create(node_1.Position.create(line, character - filePart.length), node_1.Position.create(line, character));
1022
+ const items = [];
1023
+ for (const entry of entries) {
1024
+ const name = entry.name;
1025
+ // Skip hidden files/dirs
1026
+ if (name.startsWith("."))
1027
+ continue;
1028
+ if (entry.isDirectory()) {
1029
+ const label = name + "/";
1030
+ if (!label.toLowerCase().startsWith(lowerFilter))
1031
+ continue;
1032
+ items.push({
1033
+ label,
1034
+ kind: node_1.CompletionItemKind.Folder,
1035
+ detail: "Directory",
1036
+ textEdit: { range: replaceRange, newText: label },
1037
+ // Re-trigger completions after inserting folder name
1038
+ command: {
1039
+ title: "Continue completion",
1040
+ command: "editor.action.triggerSuggest",
1041
+ },
1042
+ });
1043
+ }
1044
+ else if (name.endsWith(".ump")) {
1045
+ // Skip current file if listing the same directory
1046
+ if (isSameDir && name === docBasename)
1047
+ continue;
1048
+ if (!name.toLowerCase().startsWith(lowerFilter))
1049
+ continue;
1050
+ items.push({
1051
+ label: name,
1052
+ kind: node_1.CompletionItemKind.File,
1053
+ detail: "Umple file",
1054
+ textEdit: { range: replaceRange, newText: name },
1055
+ });
1056
+ }
1057
+ }
1058
+ return items;
1104
1059
  }
1105
1060
  function extractJson(text) {
1106
1061
  const start = text.indexOf("{");