@reqlan/language 1.7.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +18 -0
  2. package/out/generated/ast.d.ts +28 -1
  3. package/out/generated/ast.js +27 -6
  4. package/out/generated/ast.js.map +1 -1
  5. package/out/generated/grammar.js +1 -1
  6. package/out/generated/grammar.js.map +1 -1
  7. package/out/index.d.ts +1 -0
  8. package/out/index.js +1 -0
  9. package/out/index.js.map +1 -1
  10. package/out/reqlan-code-action-provider.d.ts +15 -12
  11. package/out/reqlan-code-action-provider.js +55 -20
  12. package/out/reqlan-code-action-provider.js.map +1 -1
  13. package/out/reqlan-comment-resolver.js +5 -0
  14. package/out/reqlan-comment-resolver.js.map +1 -1
  15. package/out/reqlan-completion-provider.d.ts +10 -0
  16. package/out/reqlan-completion-provider.js +63 -29
  17. package/out/reqlan-completion-provider.js.map +1 -1
  18. package/out/reqlan-definition-provider.d.ts +7 -0
  19. package/out/reqlan-definition-provider.js +86 -19
  20. package/out/reqlan-definition-provider.js.map +1 -1
  21. package/out/reqlan-document-link-provider.d.ts +2 -1
  22. package/out/reqlan-document-link-provider.js +4 -0
  23. package/out/reqlan-document-link-provider.js.map +1 -1
  24. package/out/reqlan-file-link-resolver.d.ts +17 -4
  25. package/out/reqlan-file-link-resolver.js +89 -14
  26. package/out/reqlan-file-link-resolver.js.map +1 -1
  27. package/out/reqlan-import-edits.d.ts +3 -1
  28. package/out/reqlan-import-edits.js.map +1 -1
  29. package/out/reqlan-inbound-reference-inlay-label.d.ts +11 -0
  30. package/out/reqlan-inbound-reference-inlay-label.js +60 -35
  31. package/out/reqlan-inbound-reference-inlay-label.js.map +1 -1
  32. package/out/reqlan-inlay-hint-provider.d.ts +6 -0
  33. package/out/reqlan-inlay-hint-provider.js +34 -6
  34. package/out/reqlan-inlay-hint-provider.js.map +1 -1
  35. package/out/reqlan-markdown-links.js +4 -0
  36. package/out/reqlan-markdown-links.js.map +1 -1
  37. package/out/reqlan-path-resolve.d.ts +8 -0
  38. package/out/reqlan-path-resolve.js +18 -4
  39. package/out/reqlan-path-resolve.js.map +1 -1
  40. package/out/reqlan-reference-search-site.d.ts +11 -0
  41. package/out/reqlan-reference-search-site.js +11 -0
  42. package/out/reqlan-reference-search-site.js.map +1 -1
  43. package/out/reqlan-scope.d.ts +11 -0
  44. package/out/reqlan-scope.js +28 -1
  45. package/out/reqlan-scope.js.map +1 -1
  46. package/out/reqlan-semantic-token-provider.js +6 -1
  47. package/out/reqlan-semantic-token-provider.js.map +1 -1
  48. package/out/reqlan-token-builder.d.ts +3 -2
  49. package/out/reqlan-token-builder.js +48 -1
  50. package/out/reqlan-token-builder.js.map +1 -1
  51. package/out/reqlan-validator.d.ts +1 -0
  52. package/out/reqlan-validator.js +21 -1
  53. package/out/reqlan-validator.js.map +1 -1
  54. package/out/reqlan-wildcard-resolve.d.ts +45 -0
  55. package/out/reqlan-wildcard-resolve.js +203 -0
  56. package/out/reqlan-wildcard-resolve.js.map +1 -0
  57. package/out/reqlan-workspace-manager.d.ts +7 -0
  58. package/out/reqlan-workspace-manager.js +7 -0
  59. package/out/reqlan-workspace-manager.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/generated/ast.ts +38 -7
  62. package/src/generated/grammar.ts +1 -1
  63. package/src/index.ts +1 -0
  64. package/src/reqlan-code-action-provider.ts +67 -32
  65. package/src/reqlan-comment-resolver.ts +5 -0
  66. package/src/reqlan-completion-provider.ts +81 -27
  67. package/src/reqlan-definition-provider.ts +91 -18
  68. package/src/reqlan-document-link-provider.ts +9 -1
  69. package/src/reqlan-file-link-resolver.ts +112 -17
  70. package/src/reqlan-import-edits.ts +3 -1
  71. package/src/reqlan-inbound-reference-inlay-label.ts +93 -38
  72. package/src/reqlan-inlay-hint-provider.ts +36 -6
  73. package/src/reqlan-markdown-links.ts +4 -0
  74. package/src/reqlan-path-resolve.ts +28 -5
  75. package/src/reqlan-reference-search-site.ts +25 -0
  76. package/src/reqlan-scope.ts +34 -1
  77. package/src/reqlan-semantic-token-provider.ts +6 -0
  78. package/src/reqlan-token-builder.ts +52 -3
  79. package/src/reqlan-validator.ts +34 -0
  80. package/src/reqlan-wildcard-resolve.ts +281 -0
  81. package/src/reqlan-workspace-manager.ts +9 -0
  82. package/src/reqlan.langium +14 -7
@@ -28,43 +28,108 @@ export type ReferencedDeclaration = IdeaDeclaration | IdeaSet;
28
28
 
29
29
  const MAX_INLINE_NAMES = 3;
30
30
 
31
+ /** Target key: `${documentUri}#${astNodePath}` — matches IndexManager reference descriptions. */
32
+ export type InboundReferencerIndex = Map<string, InboundReferencer[]>;
33
+
31
34
  /**
32
- * Collect inbound idea referencers from the whole Langium workspace index
33
- * (every indexed `.rq` document), not only the declaration's file.
35
+ * Langium's IndexManager stores outbound refs per source document and scans every
36
+ * document for each `findAllReferences` call. Building the inverse once makes
37
+ * inlay hints O(workspace refs) instead of O(ideas × workspace refs).
38
+ * rq:["../../../reqlan rq/extension/syntax/features-syntax-highlighting.rq".inbound_inlay_index_performance]
34
39
  */
35
- export function collectInboundReferencers(
40
+ type IndexManagerReferenceIndex = {
41
+ referenceIndex: Map<string, Array<{
42
+ sourceUri: { toString(): string };
43
+ sourcePath: string;
44
+ targetUri: { toString(): string };
45
+ targetPath: string;
46
+ }>>;
47
+ };
48
+
49
+ export function declarationInboundKey(
36
50
  services: ReqlanServices,
37
51
  declaration: ReferencedDeclaration
38
- ): InboundReferencer[] {
52
+ ): string {
53
+ const document = AstUtils.getDocument(declaration);
54
+ const path = services.workspace.AstNodeLocator.getAstNodePath(declaration);
55
+ return `${document.uri.toString()}#${path}`;
56
+ }
57
+
58
+ /**
59
+ * Build inbound referencers for every idea/ideaset target in one pass.
60
+ */
61
+ export function buildInboundReferencerIndex(services: ReqlanServices): InboundReferencerIndex {
39
62
  const documents = services.shared.workspace.LangiumDocuments;
40
63
  const locator = services.workspace.AstNodeLocator;
41
- const referencers = new Map<string, InboundReferencer>();
42
-
43
- // findReferences without documentUri walks IndexManager across the workspace.
44
- for (const reference of services.references.References.findReferences(declaration, { includeDeclaration: false }).toArray()) {
45
- const sourceDocument = documents.getDocument(reference.sourceUri);
46
- if (!sourceDocument) {
47
- continue;
48
- }
49
- const sourceNode = locator.getAstNode(sourceDocument.parseResult.value, reference.sourcePath);
50
- if (!sourceNode) {
51
- continue;
52
- }
53
- const referrer = enclosingReferrerDeclaration(sourceNode);
54
- if (!referrer || isSameDeclaration(referrer, declaration)) {
55
- continue;
56
- }
57
- const location = locationForReferrer(referrer);
58
- if (!location) {
59
- continue;
64
+ const indexManager = services.shared.workspace.IndexManager as unknown as IndexManagerReferenceIndex;
65
+ const buckets = new Map<string, Map<string, InboundReferencer>>();
66
+
67
+ for (const docRefs of indexManager.referenceIndex.values()) {
68
+ for (const reference of docRefs) {
69
+ const sourceDocument = documents.getDocument(reference.sourceUri as never);
70
+ if (!sourceDocument) {
71
+ continue;
72
+ }
73
+ const sourceNode = locator.getAstNode(sourceDocument.parseResult.value, reference.sourcePath);
74
+ if (!sourceNode) {
75
+ continue;
76
+ }
77
+ const referrer = enclosingReferrerDeclaration(sourceNode);
78
+ if (!referrer) {
79
+ continue;
80
+ }
81
+ const location = locationForReferrer(referrer);
82
+ if (!location) {
83
+ continue;
84
+ }
85
+ const targetKey = `${reference.targetUri.toString()}#${reference.targetPath}`;
86
+ const referrerDoc = AstUtils.getDocument(referrer).uri.toString();
87
+ const referrerPath = locator.getAstNodePath(referrer);
88
+ if (targetKey === `${referrerDoc}#${referrerPath}`) {
89
+ continue;
90
+ }
91
+ let byLocation = buckets.get(targetKey);
92
+ if (!byLocation) {
93
+ byLocation = new Map();
94
+ buckets.set(targetKey, byLocation);
95
+ }
96
+ byLocation.set(locationKey(location), {
97
+ name: referrer.name,
98
+ location
99
+ });
60
100
  }
61
- referencers.set(locationKey(location), {
62
- name: referrer.name,
63
- location
64
- });
65
101
  }
66
102
 
67
- return [...referencers.values()].sort((left, right) => left.name.localeCompare(right.name));
103
+ const result: InboundReferencerIndex = new Map();
104
+ for (const [targetKey, byLocation] of buckets) {
105
+ result.set(
106
+ targetKey,
107
+ [...byLocation.values()].sort((left, right) => left.name.localeCompare(right.name))
108
+ );
109
+ }
110
+ return result;
111
+ }
112
+
113
+ export function lookupInboundReferencers(
114
+ index: InboundReferencerIndex,
115
+ services: ReqlanServices,
116
+ declaration: ReferencedDeclaration
117
+ ): InboundReferencer[] {
118
+ return index.get(declarationInboundKey(services, declaration)) ?? [];
119
+ }
120
+
121
+ /**
122
+ * Collect inbound idea referencers from the whole Langium workspace index
123
+ * (every indexed `.rq` document), not only the declaration's file.
124
+ *
125
+ * Prefer {@link buildInboundReferencerIndex} + {@link lookupInboundReferencers}
126
+ * when resolving many declarations (e.g. inlay hints).
127
+ */
128
+ export function collectInboundReferencers(
129
+ services: ReqlanServices,
130
+ declaration: ReferencedDeclaration
131
+ ): InboundReferencer[] {
132
+ return lookupInboundReferencers(buildInboundReferencerIndex(services), services, declaration);
68
133
  }
69
134
 
70
135
  /** @deprecated Use {@link collectInboundReferencers} */
@@ -86,16 +151,6 @@ function enclosingReferrerDeclaration(node: AstNode): ReferencedDeclaration | un
86
151
  return undefined;
87
152
  }
88
153
 
89
- function isSameDeclaration(left: ReferencedDeclaration, right: ReferencedDeclaration): boolean {
90
- if (left === right) {
91
- return true;
92
- }
93
- if (left.name !== right.name) {
94
- return false;
95
- }
96
- return AstUtils.getDocument(left).uri.toString() === AstUtils.getDocument(right).uri.toString();
97
- }
98
-
99
154
  function locationForReferrer(referrer: ReferencedDeclaration): Location | undefined {
100
155
  const nameNode = GrammarUtils.findNodeForProperty(referrer.$cstNode, 'name');
101
156
  if (!nameNode?.range) {
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Inlay hints showing inbound references on idea declarations as a computed attribute.
3
+ *
4
+ * Builds a single workspace inbound index per request so large `.rq` files stay responsive
5
+ * (avoids per-idea full-workspace `findAllReferences` scans).
6
+ * rq:["../../../reqlan rq/extension/syntax/features-syntax-highlighting.rq".inbound_inlay_index_performance]
7
+ * rq:["../../../reqlan rq/extension/syntax/features-syntax-highlighting.rq".view_references_as_inlay_hints]
3
8
  */
4
- import { AstUtils, GrammarUtils, type AstNode, type LangiumDocument } from 'langium';
9
+ import { AstUtils, GrammarUtils, interruptAndCheck, type AstNode, type LangiumDocument } from 'langium';
5
10
  import { AbstractInlayHintProvider } from 'langium/lsp';
6
11
  import type { CancellationToken } from 'vscode-languageserver';
7
- import { InlayHintKind, type InlayHint, type InlayHintParams } from 'vscode-languageserver';
12
+ import { CancellationToken as CancelToken, InlayHintKind, type InlayHint, type InlayHintParams } from 'vscode-languageserver';
8
13
  import {
9
14
  isIdea,
10
15
  isIdeaSet,
@@ -12,7 +17,9 @@ import {
12
17
  } from './generated/ast.js';
13
18
  import {
14
19
  buildInboundReferencesInlayLabel,
15
- collectInboundReferencers,
20
+ buildInboundReferencerIndex,
21
+ lookupInboundReferencers,
22
+ type InboundReferencerIndex,
16
23
  type ReferencedDeclaration
17
24
  } from './reqlan-inbound-reference-inlay-label.js';
18
25
  import {
@@ -25,6 +32,7 @@ import type { ReqlanServices } from './reqlan-module.js';
25
32
  export class ReqlanInlayHintProvider extends AbstractInlayHintProvider {
26
33
 
27
34
  private readonly services: ReqlanServices;
35
+ private inboundIndex: InboundReferencerIndex | undefined;
28
36
 
29
37
  constructor(services: ReqlanServices) {
30
38
  super();
@@ -34,7 +42,7 @@ export class ReqlanInlayHintProvider extends AbstractInlayHintProvider {
34
42
  override async getInlayHints(
35
43
  document: LangiumDocument,
36
44
  params: InlayHintParams,
37
- cancelToken?: CancellationToken
45
+ cancelToken: CancellationToken = CancelToken.None
38
46
  ): Promise<InlayHint[] | undefined> {
39
47
  const settings = await this.services.shared.workspace.ConfigurationProvider.getConfiguration(
40
48
  'reqlan',
@@ -43,7 +51,28 @@ export class ReqlanInlayHintProvider extends AbstractInlayHintProvider {
43
51
  if (!referenceInlayHintsEnabled(settings)) {
44
52
  return [];
45
53
  }
46
- return super.getInlayHints(document, params, cancelToken);
54
+ // Must throw Langium's OperationCancelled symbol so the LSP maps this to
55
+ // RequestCancelled (-32800), not InternalError (-32603) with "Operation cancelled".
56
+ await interruptAndCheck(cancelToken);
57
+ // One inverse index for the whole request — not findReferences per idea.
58
+ this.inboundIndex = buildInboundReferencerIndex(this.services);
59
+ try {
60
+ const root = document.parseResult.value;
61
+ const inlayHints: InlayHint[] = [];
62
+ const acceptor = (hint: InlayHint): void => {
63
+ inlayHints.push(hint);
64
+ };
65
+ let checked = 0;
66
+ for (const node of AstUtils.streamAst(root, { range: params.range })) {
67
+ if ((++checked & 31) === 0) {
68
+ await interruptAndCheck(cancelToken);
69
+ }
70
+ this.computeInlayHint(node, acceptor);
71
+ }
72
+ return inlayHints;
73
+ } finally {
74
+ this.inboundIndex = undefined;
75
+ }
47
76
  }
48
77
 
49
78
  computeInlayHint(node: AstNode, acceptor: (hint: InlayHint) => void): void {
@@ -64,7 +93,8 @@ export class ReqlanInlayHintProvider extends AbstractInlayHintProvider {
64
93
  if (!nameNode?.range) {
65
94
  return;
66
95
  }
67
- const referencers = collectInboundReferencers(this.services, declaration);
96
+ const index = this.inboundIndex ?? buildInboundReferencerIndex(this.services);
97
+ const referencers = lookupInboundReferencers(index, this.services, declaration);
68
98
  const document = AstUtils.getDocument(declaration);
69
99
  const formatted = buildInboundReferencesInlayLabel(
70
100
  referencers,
@@ -40,6 +40,8 @@ export function isMarkdownLinkLabelPosition(document: LangiumDocument, position:
40
40
  const offset = document.textDocument.offsetAt(position);
41
41
  const root = document.parseResult.value.$cstNode;
42
42
  if (root) {
43
+ // The CST covers the entire document. Walk up from the leaf; if no MarkdownLink ancestor
44
+ // is found, the position is definitively not inside a markdown label — no text scan needed.
43
45
  const leaf = CstUtils.findLeafNodeAtOffset(root, offset);
44
46
  let current: CstNode | undefined = leaf;
45
47
  while (current) {
@@ -52,7 +54,9 @@ export function isMarkdownLinkLabelPosition(document: LangiumDocument, position:
52
54
  }
53
55
  current = current.container;
54
56
  }
57
+ return false;
55
58
  }
59
+ // No CST (e.g. plain-text document) — fall back to text scan.
56
60
  return isMarkdownLinkLabelOffsetInText(document.textDocument.getText(), offset);
57
61
  }
58
62
 
@@ -60,6 +60,13 @@ export interface PathResolveContext {
60
60
  * `null` skips loading and uses defaults only.
61
61
  */
62
62
  config?: RqConfig | null;
63
+ /**
64
+ * Shared cache for resolved configs, keyed by directory URI string.
65
+ * When provided, `resolveRqConfig` reads and populates this cache instead of hitting the filesystem
66
+ * on every call. Lives on the workspace manager so it is scoped to a service instance and cleared
67
+ * on workspace reinitialisation.
68
+ */
69
+ configCache?: Map<string, RqConfig | undefined>;
63
70
  }
64
71
 
65
72
  export function defaultRqConfig(): RqConfig {
@@ -297,9 +304,21 @@ export function resolveRqConfig(document: LangiumDocument, context?: PathResolve
297
304
  return context.config;
298
305
  }
299
306
  if (context?.fileSystem) {
300
- const loaded = loadApplyingRqConfig(UriUtils.dirname(document.uri), context.fileSystem);
301
- if (loaded) {
302
- return loaded;
307
+ const dirKey = UriUtils.dirname(document.uri).toString();
308
+ const cache = context.configCache;
309
+ if (cache) {
310
+ if (!cache.has(dirKey)) {
311
+ cache.set(dirKey, loadApplyingRqConfig(UriUtils.dirname(document.uri), context.fileSystem));
312
+ }
313
+ const cached = cache.get(dirKey);
314
+ if (cached) {
315
+ return cached;
316
+ }
317
+ } else {
318
+ const loaded = loadApplyingRqConfig(UriUtils.dirname(document.uri), context.fileSystem);
319
+ if (loaded) {
320
+ return loaded;
321
+ }
303
322
  }
304
323
  }
305
324
  return defaultRqConfig();
@@ -351,7 +370,10 @@ export function workspaceFolderUrisFromManager(
351
370
  export function pathResolveContextFromServices(services: {
352
371
  shared: {
353
372
  workspace: {
354
- WorkspaceManager: { workspaceFolders?: ReadonlyArray<{ uri: string }> };
373
+ WorkspaceManager: {
374
+ workspaceFolders?: ReadonlyArray<{ uri: string }>;
375
+ rqConfigCache?: Map<string, RqConfig | undefined>;
376
+ };
355
377
  FileSystemProvider: FileSystemProvider;
356
378
  };
357
379
  };
@@ -360,6 +382,7 @@ export function pathResolveContextFromServices(services: {
360
382
  fileSystem: services.shared.workspace.FileSystemProvider,
361
383
  workspaceFolderUris: workspaceFolderUrisFromManager(
362
384
  services.shared.workspace.WorkspaceManager.workspaceFolders
363
- )
385
+ ),
386
+ configCache: services.shared.workspace.WorkspaceManager.rqConfigCache
364
387
  };
365
388
  }
@@ -15,6 +15,9 @@ import {
15
15
  } from './reqlan-idea-reference-site.js';
16
16
  import { isIdea, isOneLinerIdea, type IdeaDeclaration } from './generated/ast.js';
17
17
 
18
+ /** Custom LSP request: resolve search/wrap site at a range (no command-arg cache). */
19
+ export const REQLAN_REFERENCE_SEARCH_SITE_REQUEST = 'reqlan/referenceSearchSite';
20
+
18
21
  export type ReferenceSearchMode = 'replace' | 'wrap';
19
22
 
20
23
  export interface ReferenceSearchContext {
@@ -35,6 +38,28 @@ export interface ReferenceSearchSite {
35
38
  context?: ReferenceSearchContext;
36
39
  }
37
40
 
41
+ /** Result of {@link REQLAN_REFERENCE_SEARCH_SITE_REQUEST} / extension command payload. */
42
+ export interface ReferenceSearchSiteRequestResult {
43
+ documentUri: string;
44
+ refText: string;
45
+ range: Range;
46
+ mode: ReferenceSearchMode;
47
+ context?: ReferenceSearchContext;
48
+ }
49
+
50
+ export function toReferenceSearchCommandArgs(
51
+ documentUri: string,
52
+ site: ReferenceSearchSite
53
+ ): ReferenceSearchSiteRequestResult {
54
+ return {
55
+ documentUri,
56
+ refText: site.refText,
57
+ range: site.range,
58
+ mode: site.mode,
59
+ context: site.context
60
+ };
61
+ }
62
+
38
63
  /**
39
64
  * Prefer an existing [reference]/[[wikilink]] under the cursor; otherwise a
40
65
  * selection or word in idea-body prose that can be wrapped as `[name]`.
@@ -43,10 +43,25 @@ export class ReqlanScopeComputation extends DefaultScopeComputation {
43
43
  }
44
44
  }
45
45
 
46
+ interface ScopeEntry {
47
+ version: number | undefined;
48
+ scope: Scope;
49
+ }
50
+
46
51
  export class ReqlanScopeProvider extends DefaultScopeProvider {
47
52
 
48
53
  protected readonly documents: ReqlanServices['shared']['workspace']['LangiumDocuments'];
49
54
  private readonly services: ReqlanServices;
55
+ /**
56
+ * Per-document cache for `scopeForFileIdeas`. Key is the document URI string.
57
+ * Invalidated when the document version changes so edits always get a fresh scope.
58
+ */
59
+ private readonly fileIdeasCache = new Map<string, ScopeEntry>();
60
+ /**
61
+ * Per-(document, importPath) cache for `scopeForImportPath`. Key is the imported document URI string.
62
+ * The cached scope is keyed to the imported document's version.
63
+ */
64
+ private readonly importPathCache = new Map<string, ScopeEntry>();
50
65
 
51
66
  constructor(services: ReqlanServices) {
52
67
  super(services);
@@ -120,6 +135,17 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
120
135
  }
121
136
 
122
137
  private scopeForFileIdeas(document: LangiumDocument): Scope {
138
+ const key = document.uri.toString();
139
+ const cached = this.fileIdeasCache.get(key);
140
+ if (cached && cached.version === document.textDocument.version) {
141
+ return cached.scope;
142
+ }
143
+ const scope = this.buildFileIdeasScope(document);
144
+ this.fileIdeasCache.set(key, { version: document.textDocument.version, scope });
145
+ return scope;
146
+ }
147
+
148
+ private buildFileIdeasScope(document: LangiumDocument): Scope {
123
149
  const model = document.parseResult.value as Model;
124
150
  if (!isModel(model)) {
125
151
  return new StreamScope(stream([]));
@@ -279,6 +305,11 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
279
305
  if (!imported) {
280
306
  return undefined;
281
307
  }
308
+ const key = imported.uri.toString();
309
+ const cached = this.importPathCache.get(key);
310
+ if (cached && cached.version === imported.textDocument.version) {
311
+ return cached.scope;
312
+ }
282
313
  const model = imported.parseResult.value;
283
314
  if (!isModel(model)) {
284
315
  return undefined;
@@ -286,7 +317,9 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
286
317
  const descriptions = model.elements
287
318
  .filter(element => isIdea(element) || isOneLinerIdea(element))
288
319
  .map(idea => this.descriptions.createDescription(idea, idea.name, imported));
289
- return new StreamScope(stream(descriptions));
320
+ const scope = new StreamScope(stream(descriptions));
321
+ this.importPathCache.set(key, { version: imported.textDocument.version, scope });
322
+ return scope;
290
323
  }
291
324
 
292
325
  private findImportedDocument(path: string, document: LangiumDocument): LangiumDocument | undefined {
@@ -20,6 +20,7 @@ import {
20
20
  isQualifiedImport,
21
21
  isQualifiedReference,
22
22
  isOneLinerIdea,
23
+ isWildcardReference,
23
24
  type Import
24
25
  } from './generated/ast.js';
25
26
  import { isWellFormedFromImport } from './reqlan-import-bindings.js';
@@ -50,6 +51,11 @@ export class ReqlanSemanticTokenProvider extends AbstractSemanticTokenProvider {
50
51
  });
51
52
  return;
52
53
  }
54
+ if (isWildcardReference(node)) {
55
+ acceptor({ node, property: 'pathPattern', type: SemanticTokenTypes.string });
56
+ acceptor({ node, property: 'ideaPattern', type: SemanticTokenTypes.variable });
57
+ return;
58
+ }
53
59
  if (isQualifiedReference(node)) {
54
60
  if (node.qualifier) {
55
61
  acceptor({ node, property: 'qualifier', type: SemanticTokenTypes.namespace });
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Custom token builder so line comments do not match `//` in URLs or inside string literals,
3
- * and so `@` only introduces attributes at the start of a line.
2
+ * Custom token builder so line/block comments do not match inside string literals,
3
+ * empty slash-star-star-slash glob segments do not steal path text, and `@` only
4
+ * introduces attributes at the start of a line.
4
5
  */
5
6
  import type { Grammar } from 'langium';
6
7
  import { DefaultTokenBuilder, type TokenBuilderOptions } from 'langium';
7
8
 
8
9
  const SL_COMMENT_PATTERN = /\/\/[^\n\r]*/y;
10
+ const ML_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//y;
9
11
 
10
12
  function isInsideNakedQuote(text: string, offset: number): boolean {
11
13
  let inDouble = false;
@@ -53,6 +55,28 @@ const slCommentPattern = (text: string, offset: number): RegExpExecArray | null
53
55
  return SL_COMMENT_PATTERN.exec(text);
54
56
  };
55
57
 
58
+ // Block comments must not open inside quotes. Reject the empty four-character
59
+ // slash-star-star-slash form so recursive globs like ../mod/**/*.rq stay path text
60
+ // in prose and naked quotes (reference STRING paths already consume the whole literal).
61
+ const mlCommentPattern = (text: string, offset: number): RegExpExecArray | null => {
62
+ if (text.charCodeAt(offset) !== 47 || text.charCodeAt(offset + 1) !== 42) {
63
+ return null;
64
+ }
65
+ if (isInsideNakedQuote(text, offset)) {
66
+ return null;
67
+ }
68
+ ML_COMMENT_PATTERN.lastIndex = offset;
69
+ const match = ML_COMMENT_PATTERN.exec(text);
70
+ if (!match) {
71
+ return null;
72
+ }
73
+ // /* immediately followed by */ — recursive glob segment, not a comment.
74
+ if (match[0].length === 4) {
75
+ return null;
76
+ }
77
+ return match;
78
+ };
79
+
56
80
  function makeMatch(text: string, offset: number, length: number): RegExpExecArray {
57
81
  const image = text.slice(offset, offset + length);
58
82
  const match = [image] as unknown as RegExpExecArray;
@@ -445,8 +469,15 @@ function topLevelAsKeyword(text: string, offset: number): RegExpExecArray | null
445
469
  if (braceDepthBefore(text, offset) !== 0) {
446
470
  return null;
447
471
  }
472
+ // `as` is reserved only on from/import lines — not after arbitrary top-level identifiers
473
+ // (e.g. one-liner prose "such as this").
474
+ const linePrefix = text.slice(lineStartOffset(text, offset), offset);
475
+ if (!/^[ \t]*(?:from|import)\b/.test(linePrefix)) {
476
+ return null;
477
+ }
448
478
  const before = textBeforeOnLine(text, offset);
449
- if (/(?:[_a-zA-Z][\w-]*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(before)) {
479
+ // After import path (optionally qualified) or after a from-import specifier name.
480
+ if (/(?:(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')(?:\.[A-Za-z_][\w-]*)*|[A-Za-z_][\w-]*)$/.test(before)) {
450
481
  return makeMatch(text, offset, 2);
451
482
  }
452
483
  return null;
@@ -551,6 +582,16 @@ export class ReqlanTokenBuilder extends DefaultTokenBuilder {
551
582
  };
552
583
  other.LINE_BREAKS = false;
553
584
  }
585
+ // ID is a prefix of WILDCARD_NAME (e.g. import_ vs import_*) — require longer-alt check.
586
+ const idToken = tokens.find(entry => entry.name === 'ID');
587
+ const wildcardToken = tokens.find(entry => entry.name === 'WILDCARD_NAME');
588
+ if (idToken && wildcardToken) {
589
+ const existing = idToken.LONGER_ALT;
590
+ const alts = Array.isArray(existing) ? existing : existing ? [existing] : [];
591
+ if (!alts.includes(wildcardToken)) {
592
+ idToken.LONGER_ALT = [...alts, wildcardToken];
593
+ }
594
+ }
554
595
  return tokens;
555
596
  }
556
597
 
@@ -563,6 +604,14 @@ export class ReqlanTokenBuilder extends DefaultTokenBuilder {
563
604
  PATTERN: slCommentPattern
564
605
  };
565
606
  }
607
+ if (terminal.name === 'ML_COMMENT') {
608
+ return {
609
+ name: 'ML_COMMENT',
610
+ GROUP: 'hidden',
611
+ LINE_BREAKS: true,
612
+ PATTERN: mlCommentPattern
613
+ };
614
+ }
566
615
  if (terminal.name === 'MARKDOWN_LINK') {
567
616
  return {
568
617
  name: 'MARKDOWN_LINK',
@@ -7,6 +7,7 @@ import {
7
7
  isInvalidFromImport,
8
8
  isModel,
9
9
  isOneLinerIdea,
10
+ isWildcardReference,
10
11
  type AnonymousBlock,
11
12
  type Model
12
13
  } from './generated/ast.js';
@@ -23,6 +24,11 @@ import { isResolvableImportPath } from './reqlan-imports.js';
23
24
  import type { ReqlanServices } from './reqlan-module.js';
24
25
  import { pathResolveContextFromServices } from './reqlan-path-resolve.js';
25
26
  import { unquoteReqlanString } from './reqlan-quoted-strings.js';
27
+ import {
28
+ resolveWildcardReferenceMatches,
29
+ parseWildcardPathPattern,
30
+ wildcardReferenceLabel
31
+ } from './reqlan-wildcard-resolve.js';
26
32
 
27
33
  /**
28
34
  * Registers validation hooks for the requirement graph AST.
@@ -58,6 +64,7 @@ export class ReqlanValidator {
58
64
  this.checkImportSyntax(model, accept);
59
65
  this.checkImportTargets(model, accept);
60
66
  this.checkFileReferenceTargets(model, accept);
67
+ this.checkWildcardReferences(model, accept);
61
68
  }
62
69
 
63
70
  /**
@@ -204,6 +211,33 @@ export class ReqlanValidator {
204
211
  });
205
212
  }
206
213
  }
214
+
215
+ checkWildcardReferences(model: Model, accept: ValidationAcceptor): void {
216
+ const { shared } = this.services;
217
+ const context = pathResolveContextFromServices(this.services);
218
+ for (const node of AstUtils.streamAst(model)) {
219
+ if (!isWildcardReference(node)) {
220
+ continue;
221
+ }
222
+ const path = parseWildcardPathPattern(node.pathPattern);
223
+ if (!path.trim()) {
224
+ accept('warning', 'Wildcard path pattern is empty.', { node, property: 'pathPattern' });
225
+ continue;
226
+ }
227
+ const matches = resolveWildcardReferenceMatches(
228
+ node,
229
+ shared.workspace.LangiumDocuments,
230
+ context
231
+ );
232
+ if (matches.length === 0) {
233
+ accept(
234
+ 'warning',
235
+ `No ideas match wildcard reference [${wildcardReferenceLabel(path, node.ideaPattern)}].`,
236
+ { node }
237
+ );
238
+ }
239
+ }
240
+ }
207
241
  }
208
242
 
209
243
  type ImportBindingSource = ReturnType<typeof importBindings>[number];