@reqlan/language 1.6.2 → 1.8.1

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 (109) hide show
  1. package/README.md +18 -0
  2. package/out/generated/ast.d.ts +31 -4
  3. package/out/generated/ast.js +28 -7
  4. package/out/generated/ast.js.map +1 -1
  5. package/out/generated/grammar.js +2 -3318
  6. package/out/generated/grammar.js.map +1 -1
  7. package/out/generated/module.d.ts +2 -2
  8. package/out/generated/module.js +2 -2
  9. package/out/generated/module.js.map +1 -1
  10. package/out/index.d.ts +4 -0
  11. package/out/index.js +4 -0
  12. package/out/index.js.map +1 -1
  13. package/out/reqlan-async-parser.d.ts +37 -0
  14. package/out/reqlan-async-parser.js +117 -0
  15. package/out/reqlan-async-parser.js.map +1 -0
  16. package/out/reqlan-code-action-provider.d.ts +15 -12
  17. package/out/reqlan-code-action-provider.js +55 -20
  18. package/out/reqlan-code-action-provider.js.map +1 -1
  19. package/out/reqlan-comment-resolver.js +5 -0
  20. package/out/reqlan-comment-resolver.js.map +1 -1
  21. package/out/reqlan-completion-provider.d.ts +10 -0
  22. package/out/reqlan-completion-provider.js +64 -30
  23. package/out/reqlan-completion-provider.js.map +1 -1
  24. package/out/reqlan-definition-provider.d.ts +7 -0
  25. package/out/reqlan-definition-provider.js +86 -19
  26. package/out/reqlan-definition-provider.js.map +1 -1
  27. package/out/reqlan-document-factory.d.ts +16 -0
  28. package/out/reqlan-document-factory.js +50 -0
  29. package/out/reqlan-document-factory.js.map +1 -0
  30. package/out/reqlan-document-link-provider.d.ts +2 -1
  31. package/out/reqlan-document-link-provider.js +4 -0
  32. package/out/reqlan-document-link-provider.js.map +1 -1
  33. package/out/reqlan-file-link-resolver.d.ts +17 -4
  34. package/out/reqlan-file-link-resolver.js +89 -14
  35. package/out/reqlan-file-link-resolver.js.map +1 -1
  36. package/out/reqlan-import-edits.d.ts +3 -1
  37. package/out/reqlan-import-edits.js.map +1 -1
  38. package/out/reqlan-inbound-reference-inlay-label.d.ts +11 -0
  39. package/out/reqlan-inbound-reference-inlay-label.js +60 -35
  40. package/out/reqlan-inbound-reference-inlay-label.js.map +1 -1
  41. package/out/reqlan-inlay-hint-provider.d.ts +6 -0
  42. package/out/reqlan-inlay-hint-provider.js +34 -6
  43. package/out/reqlan-inlay-hint-provider.js.map +1 -1
  44. package/out/reqlan-linker.js +2 -2
  45. package/out/reqlan-linker.js.map +1 -1
  46. package/out/reqlan-markdown-links.js +4 -0
  47. package/out/reqlan-markdown-links.js.map +1 -1
  48. package/out/reqlan-module.d.ts +2 -1
  49. package/out/reqlan-module.js +12 -2
  50. package/out/reqlan-module.js.map +1 -1
  51. package/out/reqlan-parse-budget.d.ts +50 -0
  52. package/out/reqlan-parse-budget.js +89 -0
  53. package/out/reqlan-parse-budget.js.map +1 -0
  54. package/out/reqlan-parse-worker.d.ts +1 -0
  55. package/out/reqlan-parse-worker.js +34 -0
  56. package/out/reqlan-parse-worker.js.map +1 -0
  57. package/out/reqlan-path-resolve.d.ts +8 -0
  58. package/out/reqlan-path-resolve.js +18 -4
  59. package/out/reqlan-path-resolve.js.map +1 -1
  60. package/out/reqlan-reference-search-site.d.ts +11 -0
  61. package/out/reqlan-reference-search-site.js +11 -0
  62. package/out/reqlan-reference-search-site.js.map +1 -1
  63. package/out/reqlan-scope.d.ts +11 -0
  64. package/out/reqlan-scope.js +28 -1
  65. package/out/reqlan-scope.js.map +1 -1
  66. package/out/reqlan-semantic-token-provider.js +6 -1
  67. package/out/reqlan-semantic-token-provider.js.map +1 -1
  68. package/out/reqlan-token-builder.d.ts +3 -2
  69. package/out/reqlan-token-builder.js +150 -26
  70. package/out/reqlan-token-builder.js.map +1 -1
  71. package/out/reqlan-validator.d.ts +10 -1
  72. package/out/reqlan-validator.js +36 -2
  73. package/out/reqlan-validator.js.map +1 -1
  74. package/out/reqlan-wildcard-resolve.d.ts +45 -0
  75. package/out/reqlan-wildcard-resolve.js +203 -0
  76. package/out/reqlan-wildcard-resolve.js.map +1 -0
  77. package/out/reqlan-workspace-manager.d.ts +18 -0
  78. package/out/reqlan-workspace-manager.js +31 -0
  79. package/out/reqlan-workspace-manager.js.map +1 -0
  80. package/package.json +1 -1
  81. package/src/generated/ast.ts +41 -10
  82. package/src/generated/grammar.ts +2 -3318
  83. package/src/generated/module.ts +2 -2
  84. package/src/index.ts +4 -0
  85. package/src/reqlan-async-parser.ts +178 -0
  86. package/src/reqlan-code-action-provider.ts +67 -32
  87. package/src/reqlan-comment-resolver.ts +5 -0
  88. package/src/reqlan-completion-provider.ts +82 -28
  89. package/src/reqlan-definition-provider.ts +91 -18
  90. package/src/reqlan-document-factory.ts +75 -0
  91. package/src/reqlan-document-link-provider.ts +9 -1
  92. package/src/reqlan-file-link-resolver.ts +112 -17
  93. package/src/reqlan-import-edits.ts +3 -1
  94. package/src/reqlan-inbound-reference-inlay-label.ts +93 -38
  95. package/src/reqlan-inlay-hint-provider.ts +36 -6
  96. package/src/reqlan-linker.ts +4 -2
  97. package/src/reqlan-markdown-links.ts +4 -0
  98. package/src/reqlan-module.ts +15 -3
  99. package/src/reqlan-parse-budget.ts +155 -0
  100. package/src/reqlan-parse-worker.ts +35 -0
  101. package/src/reqlan-path-resolve.ts +28 -5
  102. package/src/reqlan-reference-search-site.ts +25 -0
  103. package/src/reqlan-scope.ts +34 -1
  104. package/src/reqlan-semantic-token-provider.ts +6 -0
  105. package/src/reqlan-token-builder.ts +174 -28
  106. package/src/reqlan-validator.ts +56 -1
  107. package/src/reqlan-wildcard-resolve.ts +281 -0
  108. package/src/reqlan-workspace-manager.ts +47 -0
  109. package/src/reqlan.langium +17 -8
@@ -11,14 +11,14 @@ export const ReqlanLanguageMetaData = {
11
11
  languageId: 'reqlan',
12
12
  fileExtensions: ['.rq'],
13
13
  caseInsensitive: false,
14
- mode: 'development'
14
+ mode: 'production'
15
15
  } as const satisfies LanguageMetaData;
16
16
 
17
17
  export const ReqlanCommentLanguageMetaData = {
18
18
  languageId: 'reqlan-comment',
19
19
  fileExtensions: [],
20
20
  caseInsensitive: false,
21
- mode: 'development'
21
+ mode: 'production'
22
22
  } as const satisfies LanguageMetaData;
23
23
 
24
24
  export const ReqlanGeneratedSharedModule: Module<LangiumSharedCoreServices, LangiumGeneratedSharedCoreServices> = {
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from './reqlan-module.js';
2
+ export * from './reqlan-parse-budget.js';
3
+ export { ReqlanAsyncParser, resolveParseWorkerPath } from './reqlan-async-parser.js';
4
+ export { ReqlanLangiumDocumentFactory } from './reqlan-document-factory.js';
2
5
  export * from './reqlan-validator.js';
3
6
  export * from './reqlan-comment-resolver.js';
4
7
  export * from './reqlan-ignore-error.js';
@@ -20,6 +23,7 @@ export * from './reqlan-path-resolve.js';
20
23
  export * from './reqlan-imports.js';
21
24
  export * from './reqlan-quoted-strings.js';
22
25
  export * from './reqlan-references.js';
26
+ export * from './reqlan-wildcard-resolve.js';
23
27
  export * from './reqlan-reference-at-position.js';
24
28
  export * from './reqlan-attribute-catalog.js';
25
29
  export * from './reqlan-completion-context.js';
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Per-file parse budget via Langium's worker-thread async parser.
3
+ * Happy path is in-process sync parse; workers enforce a killable wall-clock budget
4
+ * for large files, sticky timeout escalation, or explicit force.
5
+ * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import {
11
+ type AstNode,
12
+ type LangiumCoreServices,
13
+ type LangiumParser
14
+ } from 'langium';
15
+ import { WorkerThreadAsyncParser } from 'langium/node';
16
+ import {
17
+ CancellationToken,
18
+ CancellationTokenSource
19
+ } from 'vscode-languageserver';
20
+ import {
21
+ createIncompleteParseResult,
22
+ DEFAULT_PARSE_BUDGET_MS,
23
+ DEFAULT_WORKER_PARSE_THRESHOLD_CHARS,
24
+ type ParseWorkerMode,
25
+ type ReqlanParseResult,
26
+ shouldEscalateToParseWorker
27
+ } from './reqlan-parse-budget.js';
28
+
29
+ export interface ReqlanAsyncParserOptions {
30
+ timeoutMs?: number;
31
+ /** Override worker script path (tests). */
32
+ workerPath?: string;
33
+ /**
34
+ * `auto` (default): sync below size threshold; worker when escalated.
35
+ * `true` / `false`: always / never use workers.
36
+ */
37
+ useWorker?: ParseWorkerMode;
38
+ /** Characters at or above which `auto` mode uses a worker. */
39
+ workerThresholdChars?: number;
40
+ /** Worker pool size (Langium default is 8; keep small for startup). */
41
+ threadCount?: number;
42
+ }
43
+
44
+ export interface ReqlanParseCallOptions {
45
+ /** Force the killable worker path for this call (sticky URI escalate, tests). */
46
+ forceWorker?: boolean;
47
+ }
48
+
49
+ /**
50
+ * Directory of this module (or of the CJS bundle that inlined it).
51
+ * Avoid static `import.meta.url`: esbuild format cjs + target es2017 empties it
52
+ * (see reqlan rq/extension/startup-performance.rq invalid_url_activation_failure).
53
+ */
54
+ declare const __dirname: string | undefined;
55
+
56
+ function moduleDirectory(): string {
57
+ if (typeof __dirname === 'string') {
58
+ return __dirname;
59
+ }
60
+ // Native ESM only — hide from esbuild's empty-import-meta rewrite.
61
+ const metaUrl = (0, eval)('import.meta.url') as string;
62
+ return dirname(fileURLToPath(metaUrl));
63
+ }
64
+
65
+ export function resolveParseWorkerPath(explicit?: string): string {
66
+ if (explicit) {
67
+ return explicit;
68
+ }
69
+ const dir = moduleDirectory();
70
+ const jsPath = join(dir, 'reqlan-parse-worker.js');
71
+ if (existsSync(jsPath)) {
72
+ return jsPath;
73
+ }
74
+ const cjsPath = join(dir, 'reqlan-parse-worker.cjs');
75
+ if (existsSync(cjsPath)) {
76
+ return cjsPath;
77
+ }
78
+ return jsPath;
79
+ }
80
+
81
+ export class ReqlanAsyncParser extends WorkerThreadAsyncParser {
82
+ private readonly timeoutMs: number;
83
+ private readonly useWorker: ParseWorkerMode;
84
+ private readonly workerThresholdChars: number;
85
+ private readonly syncParser: LangiumParser;
86
+
87
+ constructor(services: LangiumCoreServices, options: ReqlanAsyncParserOptions = {}) {
88
+ super(services, () => resolveParseWorkerPath(options.workerPath));
89
+ this.syncParser = services.parser.LangiumParser;
90
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_PARSE_BUDGET_MS;
91
+ this.useWorker = options.useWorker ?? 'auto';
92
+ this.workerThresholdChars = options.workerThresholdChars ?? DEFAULT_WORKER_PARSE_THRESHOLD_CHARS;
93
+ this.threadCount = options.threadCount ?? 2;
94
+ // Fail fast after cancel so the budget is close to timeoutMs wall-clock.
95
+ this.terminationDelay = 50;
96
+ }
97
+
98
+ override async parse<T extends AstNode>(
99
+ text: string,
100
+ cancelToken: CancellationToken,
101
+ callOptions: ReqlanParseCallOptions = {}
102
+ ): Promise<ReqlanParseResult<T>> {
103
+ if (cancelToken.isCancellationRequested) {
104
+ return createIncompleteParseResult<T>(this.syncParser, {
105
+ reason: 'failure',
106
+ cause: 'cancelled'
107
+ });
108
+ }
109
+
110
+ const escalate = shouldEscalateToParseWorker(text, {
111
+ useWorker: this.useWorker,
112
+ forceWorker: callOptions.forceWorker,
113
+ thresholdChars: this.workerThresholdChars
114
+ });
115
+ if (!escalate) {
116
+ return this.parseOnThread<T>(text);
117
+ }
118
+
119
+ return this.parseInWorker<T>(text, cancelToken);
120
+ }
121
+
122
+ /** Sync entry used by document factory / tests. */
123
+ parseSync<T extends AstNode>(text: string): ReqlanParseResult<T> {
124
+ return this.parseOnThread<T>(text);
125
+ }
126
+
127
+ private async parseInWorker<T extends AstNode>(
128
+ text: string,
129
+ cancelToken: CancellationToken
130
+ ): Promise<ReqlanParseResult<T>> {
131
+ const timeoutSource = new CancellationTokenSource();
132
+ const timer = setTimeout(() => timeoutSource.cancel(), this.timeoutMs);
133
+ const callerCancel = cancelToken.onCancellationRequested(() => {
134
+ timeoutSource.cancel();
135
+ });
136
+
137
+ try {
138
+ return await super.parse<T>(text, timeoutSource.token) as ReqlanParseResult<T>;
139
+ } catch (error) {
140
+ if (timeoutSource.token.isCancellationRequested) {
141
+ if (cancelToken.isCancellationRequested) {
142
+ return createIncompleteParseResult<T>(this.syncParser, {
143
+ reason: 'failure',
144
+ cause: 'cancelled'
145
+ });
146
+ }
147
+ return createIncompleteParseResult<T>(this.syncParser, {
148
+ reason: 'timeout',
149
+ timeoutMs: this.timeoutMs
150
+ });
151
+ }
152
+ return createIncompleteParseResult<T>(this.syncParser, {
153
+ reason: 'failure',
154
+ cause: error instanceof Error ? error.message : String(error)
155
+ });
156
+ } finally {
157
+ clearTimeout(timer);
158
+ callerCancel.dispose();
159
+ timeoutSource.dispose();
160
+ }
161
+ }
162
+
163
+ private parseOnThread<T extends AstNode>(text: string): ReqlanParseResult<T> {
164
+ try {
165
+ return this.syncParser.parse(text) as ReqlanParseResult<T>;
166
+ } catch (error) {
167
+ return createIncompleteParseResult<T>(this.syncParser, {
168
+ reason: 'failure',
169
+ cause: error instanceof Error ? error.message : String(error)
170
+ });
171
+ }
172
+ }
173
+
174
+ /** Exposed for tests that need the underlying sync parser. */
175
+ get langiumParser(): LangiumParser {
176
+ return this.syncParser;
177
+ }
178
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Quick fixes for unresolved references: add import, rewrite qualified ref, search, create.
3
3
  * Also offers a search code action when the cursor is inside a [reference] / [[wikilink]].
4
+ * File-level source actions include barrel page.
4
5
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_error]
5
6
  * rq:["../../../reqlan rq/extension/features-commands.rq".search_code_actions]
7
+ * rq:["../../../reqlan rq/extension/features-commands.rq".barrel_page]
8
+ * rq:["../../../reqlan rq/extension/features-commands.rq".file_based_code_actions]
6
9
  */
7
10
  import type { LangiumDocument, URI } from 'langium';
8
11
  import { AstUtils, DocumentValidator, URI as UriCtor, UriUtils } from 'langium';
@@ -27,13 +30,16 @@ import {
27
30
  type NameCatalogEntry,
28
31
  type NameCatalogKind
29
32
  } from './reqlan-name-catalog.js';
30
- import { findReferenceSearchSite, buildContextForRange } from './reqlan-reference-search-site.js';
33
+ import { findReferenceSearchSite } from './reqlan-reference-search-site.js';
31
34
 
32
35
  export const REQLAN_IMPORT_ERROR_SEARCH_COMMAND = 'reqlan.importError.search';
33
36
  export const REQLAN_IMPORT_ERROR_CREATE_COMMAND = 'reqlan.importError.createFile';
34
37
  export const REQLAN_SEARCH_REFERENCE_COMMAND = 'reqlan.searchReference';
35
38
  export const REQLAN_REFACTOR_DELETE_IDEA_COMMAND = 'reqlan.refactor.deleteIdea';
36
39
  export const REQLAN_REFACTOR_MOVE_IDEA_COMMAND = 'reqlan.refactor.moveIdea';
40
+ export const REQLAN_BARREL_PAGE_COMMAND = 'reqlan.barrelPage';
41
+ /** Source-action kind for whole-file barrel page. */
42
+ export const REQLAN_BARREL_PAGE_KIND = `${CodeActionKind.Source}.barrelPage`;
37
43
 
38
44
  /** Args for idea move/delete refactor commands. */
39
45
  export interface IdeaRefactorCommandArgs {
@@ -58,18 +64,7 @@ export interface ImportErrorCommandArgs {
58
64
  }
59
65
 
60
66
  /** Args for reference search webview (replace existing ref or wrap prose). */
61
- export interface SearchReferenceCommandArgs {
62
- documentUri: string;
63
- refText: string;
64
- range: Diagnostic['range'];
65
- mode: 'replace' | 'wrap';
66
- context?: {
67
- ideaName: string;
68
- before: string;
69
- target: string;
70
- after: string;
71
- };
72
- }
67
+ export type SearchReferenceCommandArgs = import('./reqlan-reference-search-site.js').ReferenceSearchSiteRequestResult;
73
68
 
74
69
  interface SymbolMatch {
75
70
  name: string;
@@ -99,9 +94,40 @@ export class ReqlanCodeActionProvider implements CodeActionProvider {
99
94
  actions.push(searchFromCursor);
100
95
  }
101
96
  actions.push(...this.createIdeaRefactorActions(document, params));
97
+ const barrelPage = this.createBarrelPageAction(document, params);
98
+ if (barrelPage) {
99
+ actions.push(barrelPage);
100
+ }
102
101
  return actions;
103
102
  }
104
103
 
104
+ /**
105
+ * Whole-file source action: barrel top-level ideas into sibling files + container.
106
+ * rq:["../../../reqlan rq/extension/features-commands.rq".barrel_page]
107
+ * rq:["../../../reqlan rq/extension/features-commands.rq".file_based_code_actions]
108
+ */
109
+ private createBarrelPageAction(
110
+ document: LangiumDocument,
111
+ params: CodeActionParams
112
+ ): CodeActionLike | undefined {
113
+ if (!actionKindAllowed(params.context.only, REQLAN_BARREL_PAGE_KIND)) {
114
+ return undefined;
115
+ }
116
+ if (!documentHasTopLevelIdeas(document)) {
117
+ return undefined;
118
+ }
119
+ // No command arguments: VS Code caches arg-bearing commands and may dispose
120
+ // them when code actions refresh. The extension resolves the active editor.
121
+ return {
122
+ title: 'Barrel page into container…',
123
+ kind: REQLAN_BARREL_PAGE_KIND,
124
+ command: {
125
+ title: 'Barrel page',
126
+ command: REQLAN_BARREL_PAGE_COMMAND
127
+ }
128
+ };
129
+ }
130
+
105
131
  /**
106
132
  * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_symbol_move]
107
133
  * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_symbol_delete]
@@ -163,13 +189,11 @@ export class ReqlanCodeActionProvider implements CodeActionProvider {
163
189
  if (alreadyHasSearch) {
164
190
  return undefined;
165
191
  }
166
- const args: SearchReferenceCommandArgs = {
167
- documentUri: document.textDocument.uri,
168
- refText: site.refText,
169
- range: site.range,
170
- mode: site.mode,
171
- context: site.context
172
- };
192
+ // No command arguments: VS Code caches arg-bearing commands and disposes
193
+ // them when code actions refresh (common under Langium revalidation),
194
+ // which surfaces as "Actual command not found, wanted to execute
195
+ // reqlan.searchReference /N". The extension resolves the site from the
196
+ // active editor when the command runs.
173
197
  const title = site.mode === 'wrap'
174
198
  ? (site.refText
175
199
  ? `Wrap '${site.refText}' as idea reference…`
@@ -182,8 +206,7 @@ export class ReqlanCodeActionProvider implements CodeActionProvider {
182
206
  kind: CodeActionKind.Refactor,
183
207
  command: {
184
208
  title: 'Search for idea reference',
185
- command: REQLAN_SEARCH_REFERENCE_COMMAND,
186
- arguments: [args]
209
+ command: REQLAN_SEARCH_REFERENCE_COMMAND
187
210
  }
188
211
  };
189
212
  }
@@ -335,25 +358,17 @@ export class ReqlanCodeActionProvider implements CodeActionProvider {
335
358
  }
336
359
 
337
360
  private createSearchCommand(
338
- document: LangiumDocument,
361
+ _document: LangiumDocument,
339
362
  diagnostic: Diagnostic,
340
363
  refText: string
341
364
  ): CodeActionLike {
342
- const args: SearchReferenceCommandArgs = {
343
- documentUri: document.textDocument.uri,
344
- refText,
345
- range: diagnostic.range,
346
- mode: 'replace',
347
- context: buildContextForRange(document, diagnostic.range)
348
- };
349
365
  return {
350
366
  title: `Search for idea to replace '${refText}'…`,
351
367
  kind: CodeActionKind.QuickFix,
352
368
  diagnostics: [diagnostic],
353
369
  command: {
354
370
  title: `Search for idea to replace '${refText}'`,
355
- command: REQLAN_SEARCH_REFERENCE_COMMAND,
356
- arguments: [args]
371
+ command: REQLAN_SEARCH_REFERENCE_COMMAND
357
372
  }
358
373
  };
359
374
  }
@@ -385,6 +400,26 @@ function isResolvableIdeaProperty(property: string | undefined): boolean {
385
400
  return property === 'idea' || property === 'ideaset' || property === 'members';
386
401
  }
387
402
 
403
+ /** True when `only` is unset or the action kind matches a requested kind (LSP subtype rules). */
404
+ export function actionKindAllowed(only: readonly string[] | undefined, kind: string): boolean {
405
+ if (!only || only.length === 0) {
406
+ return true;
407
+ }
408
+ return only.some(requested =>
409
+ kind === requested
410
+ || kind.startsWith(`${requested}.`)
411
+ || requested.startsWith(`${kind}.`)
412
+ );
413
+ }
414
+
415
+ function documentHasTopLevelIdeas(document: LangiumDocument): boolean {
416
+ const model = document.parseResult.value;
417
+ if (!isModel(model)) {
418
+ return false;
419
+ }
420
+ return model.elements.some(element => isRefactorIdeaDeclaration(element));
421
+ }
422
+
388
423
  function rangesOverlap(
389
424
  left: { start: { line: number; character: number }; end: { line: number; character: number } },
390
425
  right: { start: { line: number; character: number }; end: { line: number; character: number } }
@@ -169,6 +169,11 @@ export function findCommentSpansInText(text: string): CommentSpan[] {
169
169
  continue;
170
170
  }
171
171
  if (char === '/' && next === '*') {
172
+ // Empty slash-star-star-slash is a glob segment, not a block comment (same as .rq lexer).
173
+ if (text[index + 2] === '*' && text[index + 3] === '/') {
174
+ index += 4;
175
+ continue;
176
+ }
172
177
  blockComment = 'slash';
173
178
  lineCommentStart = index;
174
179
  index += 2;
@@ -6,10 +6,12 @@
6
6
  * rq:["../../../reqlan rq/extension/features-syntax-highlighting.rq".reference_code_completion_sequencing]
7
7
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_code_completion]
8
8
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_code_completion_ranking]
9
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_code_completion_rendering]
10
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_code_completion_auto_file_import]
9
11
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".anonymous_reference_code_completion]
10
12
  */
11
13
  import type { AstNode, AstNodeDescription, FileSystemProvider, LangiumDocument, LangiumDocuments } from 'langium';
12
- import { AstUtils, stream } from 'langium';
14
+ import { AstUtils, UriUtils, stream } from 'langium';
13
15
  import type { MaybePromise } from 'langium';
14
16
  import {
15
17
  DefaultCompletionProvider,
@@ -18,7 +20,7 @@ import {
18
20
  type CompletionProviderOptions
19
21
  } from 'langium/lsp';
20
22
  import { reqlanStringDelimiter } from './reqlan-quoted-strings.js';
21
- import type { CompletionList, CompletionParams } from 'vscode-languageserver';
23
+ import type { CompletionItem, CompletionList, CompletionParams } from 'vscode-languageserver';
22
24
  import { CompletionItemKind, CompletionList as LspCompletionList } from 'vscode-languageserver';
23
25
  import {
24
26
  isAttribute,
@@ -39,6 +41,7 @@ import {
39
41
  getCompletionSite,
40
42
  getReferencePrefixContext
41
43
  } from './reqlan-completion-context.js';
44
+ import { buildFromImportEdit, relativeRqImportPath } from './reqlan-import-edits.js';
42
45
  import {
43
46
  collectPathCompletionCandidates,
44
47
  comparePathCompletionCandidates,
@@ -51,6 +54,12 @@ import { referenceIdea } from './reqlan-references.js';
51
54
  import { collectWorkspaceAttributeCatalog } from './reqlan-workspace-attribute-catalog.js';
52
55
  import type { ReqlanServices } from './reqlan-module.js';
53
56
 
57
+ type ReferenceCompletionCandidate = {
58
+ name: string;
59
+ document: LangiumDocument;
60
+ importable: boolean;
61
+ };
62
+
54
63
  const UNREACHABLE_DISTANCE = 9999;
55
64
 
56
65
  export class ReqlanCompletionProvider extends DefaultCompletionProvider {
@@ -124,7 +133,7 @@ export class ReqlanCompletionProvider extends DefaultCompletionProvider {
124
133
  return [];
125
134
  }
126
135
  return model.elements
127
- .filter(element => {
136
+ .filter((element): element is typeof element & { name: string } => {
128
137
  if (refInfo.property === 'ideaset') {
129
138
  return isIdeaSet(element);
130
139
  }
@@ -194,47 +203,92 @@ export class ReqlanCompletionProvider extends DefaultCompletionProvider {
194
203
  if (!context) {
195
204
  return LspCompletionList.create([], true);
196
205
  }
197
- const names = new Set<string>();
198
206
  const docs = this.documents.all.toArray();
199
- if (!docs.some(doc => doc.uri.toString() === document.uri.toString())) {
207
+ if (!docs.some(doc => UriUtils.equals(doc.uri, document.uri))) {
200
208
  docs.push(document);
201
209
  }
210
+ const candidates = this.collectReferenceCompletionCandidates(docs, context.prefix);
211
+ const center = findContainingIdea(document, params.position)?.name;
212
+ const distances = center
213
+ ? hopDistancesFromCenter(center, buildIdeaReferenceAdjacency(docs))
214
+ : new Map<string, number>();
215
+ const items = candidates.map(candidate =>
216
+ this.toReferenceCompletionItem(document, candidate, context, distances)
217
+ ).sort((left, right) => {
218
+ const leftDistance = distances.get(String(left.label)) ?? UNREACHABLE_DISTANCE;
219
+ const rightDistance = distances.get(String(right.label)) ?? UNREACHABLE_DISTANCE;
220
+ return leftDistance - rightDistance
221
+ || String(left.label).localeCompare(String(right.label))
222
+ || String(left.detail ?? '').localeCompare(String(right.detail ?? ''));
223
+ });
224
+ return LspCompletionList.create(items, true);
225
+ }
226
+
227
+ /** Collect named ideas/ideasets across loaded documents for bracket/wikilink completion. */
228
+ private collectReferenceCompletionCandidates(
229
+ docs: LangiumDocument[],
230
+ prefix: string
231
+ ): ReferenceCompletionCandidate[] {
232
+ const byKey = new Map<string, ReferenceCompletionCandidate>();
202
233
  for (const doc of docs) {
203
234
  const model = doc.parseResult.value;
204
235
  if (!isModel(model)) {
205
236
  continue;
206
237
  }
207
238
  for (const element of model.elements) {
208
- if (isIdea(element) || isOneLinerIdea(element) || isIdeaSet(element)) {
209
- names.add(element.name);
239
+ const importable = isIdea(element) || isOneLinerIdea(element);
240
+ if (!importable && !isIdeaSet(element)) {
241
+ continue;
242
+ }
243
+ if (prefix && !element.name.startsWith(prefix)) {
244
+ continue;
245
+ }
246
+ const key = `${doc.uri.toString()}::${element.name}`;
247
+ if (!byKey.has(key)) {
248
+ byKey.set(key, { name: element.name, document: doc, importable });
210
249
  }
211
250
  }
212
251
  }
213
- const center = findContainingIdea(document, params.position)?.name;
214
- const distances = center
215
- ? hopDistancesFromCenter(center, buildIdeaReferenceAdjacency(docs))
216
- : new Map<string, number>();
217
- const items = [...names].flatMap(name => {
218
- if (context.prefix && !name.startsWith(context.prefix)) {
219
- return [];
220
- }
221
- const distance = distances.get(name) ?? UNREACHABLE_DISTANCE;
222
- return [{
223
- label: name,
224
- kind: CompletionItemKind.Reference,
225
- insertText: name,
226
- sortText: `${String(distance).padStart(4, '0')}_${name}`,
252
+ return [...byKey.values()];
253
+ }
254
+
255
+ /**
256
+ * Cross-file idea completions show their import path and may insert a from-import.
257
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_code_completion_rendering]
258
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_code_completion_auto_file_import]
259
+ */
260
+ private toReferenceCompletionItem(
261
+ document: LangiumDocument,
262
+ candidate: ReferenceCompletionCandidate,
263
+ context: NonNullable<ReturnType<typeof getReferencePrefixContext>>,
264
+ distances: Map<string, number>
265
+ ): CompletionItem {
266
+ const sameFile = UriUtils.equals(candidate.document.uri, document.uri);
267
+ const distance = distances.get(candidate.name) ?? UNREACHABLE_DISTANCE;
268
+ const item: CompletionItem = {
269
+ label: candidate.name,
270
+ kind: CompletionItemKind.Reference,
271
+ sortText: `${String(distance).padStart(4, '0')}_${candidate.name}`,
272
+ textEdit: {
273
+ newText: candidate.name,
227
274
  range: {
228
275
  start: context.replaceStart,
229
276
  end: context.replaceEnd
230
277
  }
231
- }];
232
- }).sort((left, right) => {
233
- const leftDistance = distances.get(String(left.label)) ?? UNREACHABLE_DISTANCE;
234
- const rightDistance = distances.get(String(right.label)) ?? UNREACHABLE_DISTANCE;
235
- return leftDistance - rightDistance || String(left.label).localeCompare(String(right.label));
236
- });
237
- return LspCompletionList.create(items, true);
278
+ }
279
+ };
280
+ if (sameFile) {
281
+ return item;
282
+ }
283
+ const importPath = relativeRqImportPath(document.uri, candidate.document.uri);
284
+ item.detail = importPath;
285
+ if (candidate.importable) {
286
+ const importEdit = buildFromImportEdit(document, importPath, candidate.name);
287
+ if (importEdit) {
288
+ item.additionalTextEdits = [importEdit];
289
+ }
290
+ }
291
+ return item;
238
292
  }
239
293
 
240
294
  private completeAnonymousImportPath(document: LangiumDocument, params: CompletionParams): CompletionList {