@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.
- package/README.md +18 -0
- package/out/generated/ast.d.ts +28 -1
- package/out/generated/ast.js +27 -6
- package/out/generated/ast.js.map +1 -1
- package/out/generated/grammar.js +1 -1
- package/out/generated/grammar.js.map +1 -1
- package/out/index.d.ts +1 -0
- package/out/index.js +1 -0
- package/out/index.js.map +1 -1
- package/out/reqlan-code-action-provider.d.ts +15 -12
- package/out/reqlan-code-action-provider.js +55 -20
- package/out/reqlan-code-action-provider.js.map +1 -1
- package/out/reqlan-comment-resolver.js +5 -0
- package/out/reqlan-comment-resolver.js.map +1 -1
- package/out/reqlan-completion-provider.d.ts +10 -0
- package/out/reqlan-completion-provider.js +63 -29
- package/out/reqlan-completion-provider.js.map +1 -1
- package/out/reqlan-definition-provider.d.ts +7 -0
- package/out/reqlan-definition-provider.js +86 -19
- package/out/reqlan-definition-provider.js.map +1 -1
- package/out/reqlan-document-link-provider.d.ts +2 -1
- package/out/reqlan-document-link-provider.js +4 -0
- package/out/reqlan-document-link-provider.js.map +1 -1
- package/out/reqlan-file-link-resolver.d.ts +17 -4
- package/out/reqlan-file-link-resolver.js +89 -14
- package/out/reqlan-file-link-resolver.js.map +1 -1
- package/out/reqlan-import-edits.d.ts +3 -1
- package/out/reqlan-import-edits.js.map +1 -1
- package/out/reqlan-inbound-reference-inlay-label.d.ts +11 -0
- package/out/reqlan-inbound-reference-inlay-label.js +60 -35
- package/out/reqlan-inbound-reference-inlay-label.js.map +1 -1
- package/out/reqlan-inlay-hint-provider.d.ts +6 -0
- package/out/reqlan-inlay-hint-provider.js +34 -6
- package/out/reqlan-inlay-hint-provider.js.map +1 -1
- package/out/reqlan-markdown-links.js +4 -0
- package/out/reqlan-markdown-links.js.map +1 -1
- package/out/reqlan-path-resolve.d.ts +8 -0
- package/out/reqlan-path-resolve.js +18 -4
- package/out/reqlan-path-resolve.js.map +1 -1
- package/out/reqlan-reference-search-site.d.ts +11 -0
- package/out/reqlan-reference-search-site.js +11 -0
- package/out/reqlan-reference-search-site.js.map +1 -1
- package/out/reqlan-scope.d.ts +11 -0
- package/out/reqlan-scope.js +28 -1
- package/out/reqlan-scope.js.map +1 -1
- package/out/reqlan-semantic-token-provider.js +6 -1
- package/out/reqlan-semantic-token-provider.js.map +1 -1
- package/out/reqlan-token-builder.d.ts +3 -2
- package/out/reqlan-token-builder.js +48 -1
- package/out/reqlan-token-builder.js.map +1 -1
- package/out/reqlan-validator.d.ts +1 -0
- package/out/reqlan-validator.js +21 -1
- package/out/reqlan-validator.js.map +1 -1
- package/out/reqlan-wildcard-resolve.d.ts +45 -0
- package/out/reqlan-wildcard-resolve.js +203 -0
- package/out/reqlan-wildcard-resolve.js.map +1 -0
- package/out/reqlan-workspace-manager.d.ts +7 -0
- package/out/reqlan-workspace-manager.js +7 -0
- package/out/reqlan-workspace-manager.js.map +1 -1
- package/package.json +1 -1
- package/src/generated/ast.ts +38 -7
- package/src/generated/grammar.ts +1 -1
- package/src/index.ts +1 -0
- package/src/reqlan-code-action-provider.ts +67 -32
- package/src/reqlan-comment-resolver.ts +5 -0
- package/src/reqlan-completion-provider.ts +81 -27
- package/src/reqlan-definition-provider.ts +91 -18
- package/src/reqlan-document-link-provider.ts +9 -1
- package/src/reqlan-file-link-resolver.ts +112 -17
- package/src/reqlan-import-edits.ts +3 -1
- package/src/reqlan-inbound-reference-inlay-label.ts +93 -38
- package/src/reqlan-inlay-hint-provider.ts +36 -6
- package/src/reqlan-markdown-links.ts +4 -0
- package/src/reqlan-path-resolve.ts +28 -5
- package/src/reqlan-reference-search-site.ts +25 -0
- package/src/reqlan-scope.ts +34 -1
- package/src/reqlan-semantic-token-provider.ts +6 -0
- package/src/reqlan-token-builder.ts +52 -3
- package/src/reqlan-validator.ts +34 -0
- package/src/reqlan-wildcard-resolve.ts +281 -0
- package/src/reqlan-workspace-manager.ts +9 -0
- 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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
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: {
|
|
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]`.
|
package/src/reqlan-scope.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
-
|
|
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',
|
package/src/reqlan-validator.ts
CHANGED
|
@@ -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];
|