@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
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Resolve qualified path + idea-pattern wildcard references.
3
+ * rq:["../../../reqlan rq/language/imports.rq".wildcard_references]
4
+ */
5
+ import type { LangiumDocument, LangiumDocuments } from 'langium';
6
+ import { AstUtils, CstUtils, GrammarUtils, URI, UriUtils } from 'langium';
7
+ import type { Range } from 'vscode-languageserver';
8
+ import {
9
+ isIdea,
10
+ isModel,
11
+ isOneLinerIdea,
12
+ isWildcardReference,
13
+ type IdeaDeclaration,
14
+ type WildcardReference
15
+ } from './generated/ast.js';
16
+ import {
17
+ matchImportRootMapping,
18
+ resolveImportRootUri,
19
+ resolveRqConfig,
20
+ type PathResolveContext
21
+ } from './reqlan-path-resolve.js';
22
+ import { unquoteReqlanString } from './reqlan-quoted-strings.js';
23
+
24
+ export const REQLAN_OPEN_WILDCARD_COMMAND = 'reqlan.openWildcardReference';
25
+ export const REQLAN_WILDCARD_REFERENCE_AT_REQUEST = 'reqlan/wildcardReferenceAt';
26
+
27
+ export interface WildcardMatch {
28
+ fileUri: string;
29
+ ideaName: string;
30
+ range?: Range;
31
+ }
32
+
33
+ /** Lightweight catalog entry for index-time expansion without full Langium docs. */
34
+ export interface WildcardIdeaCandidate {
35
+ fileUri: string;
36
+ /** Absolute or workspace path used for path-glob matching. */
37
+ filePath: string;
38
+ ideaName: string;
39
+ range?: Range;
40
+ }
41
+
42
+ export interface WildcardReferenceArgs {
43
+ pathPattern: string;
44
+ ideaPattern: string;
45
+ fromUri: string;
46
+ }
47
+
48
+ export function pathHasGlobMeta(path: string): boolean {
49
+ return /[*?]/.test(path) || path.includes('**');
50
+ }
51
+
52
+ export function ideaHasGlobMeta(name: string): boolean {
53
+ return /[*?]/.test(name);
54
+ }
55
+
56
+ /** Convert a shell-style glob to an anchored RegExp. */
57
+ export function globToRegExp(glob: string, mode: 'path' | 'name' = 'name'): RegExp {
58
+ let pattern = '';
59
+ for (let i = 0; i < glob.length; i++) {
60
+ const ch = glob[i]!;
61
+ if (ch === '*' && glob[i + 1] === '*') {
62
+ // ** optionally followed by / matches across path segments
63
+ if (glob[i + 2] === '/') {
64
+ pattern += '(?:.*/)?';
65
+ i += 2;
66
+ } else {
67
+ pattern += '.*';
68
+ i += 1;
69
+ }
70
+ continue;
71
+ }
72
+ if (ch === '*') {
73
+ pattern += mode === 'path' ? '[^/]*' : '.*';
74
+ continue;
75
+ }
76
+ if (ch === '?') {
77
+ pattern += mode === 'path' ? '[^/]' : '.';
78
+ continue;
79
+ }
80
+ if ('\\^$+{}[]()|/.'.includes(ch)) {
81
+ pattern += `\\${ch}`;
82
+ continue;
83
+ }
84
+ pattern += ch;
85
+ }
86
+ return new RegExp(`^${pattern}$`);
87
+ }
88
+
89
+ export function wildcardReferenceCommandTarget(args: WildcardReferenceArgs): string {
90
+ return `command:${REQLAN_OPEN_WILDCARD_COMMAND}?${encodeURIComponent(JSON.stringify([args]))}`;
91
+ }
92
+
93
+ export function wildcardReferenceLabel(pathPattern: string, ideaPattern: string): string {
94
+ return `${pathPattern}.${ideaPattern}`;
95
+ }
96
+
97
+ export function parseWildcardPathPattern(pathPatternQuoted: string): string {
98
+ return unquoteReqlanString(pathPatternQuoted);
99
+ }
100
+
101
+ /**
102
+ * Resolve the path glob against the document directory / import-root alias,
103
+ * returning a regex that matches absolute file paths (posix-style).
104
+ */
105
+ export function pathPatternToAbsoluteRegex(
106
+ pathPatternQuoted: string,
107
+ document: LangiumDocument,
108
+ context?: PathResolveContext
109
+ ): RegExp {
110
+ const raw = parseWildcardPathPattern(pathPatternQuoted);
111
+ const config = resolveRqConfig(document, context);
112
+ const matched = matchImportRootMapping(raw, config.importRoots);
113
+ let absoluteGlob: string;
114
+ if (matched) {
115
+ const importRoot = resolveImportRootUri(document, context, matched.mapping);
116
+ const rootPath = importRoot
117
+ ? uriToPath(importRoot)
118
+ : uriToPath(UriUtils.dirname(document.uri));
119
+ absoluteGlob = joinGlobPath(rootPath, matched.remainder);
120
+ } else if (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)) {
121
+ absoluteGlob = normalizePathSeparators(raw);
122
+ } else {
123
+ absoluteGlob = joinGlobPath(uriToPath(UriUtils.dirname(document.uri)), raw);
124
+ }
125
+ return globToRegExp(normalizePathSeparators(absoluteGlob), 'path');
126
+ }
127
+
128
+ export function collectIdeaCandidatesFromDocuments(
129
+ documents: Iterable<LangiumDocument>
130
+ ): WildcardIdeaCandidate[] {
131
+ const candidates: WildcardIdeaCandidate[] = [];
132
+ for (const document of documents) {
133
+ const model = document.parseResult.value;
134
+ if (!isModel(model)) {
135
+ continue;
136
+ }
137
+ const fileUri = document.uri.toString();
138
+ const filePath = uriToPath(document.uri);
139
+ for (const idea of collectIdeas(model)) {
140
+ const nameNode = GrammarUtils.findNodeForProperty(idea.$cstNode, 'name');
141
+ candidates.push({
142
+ fileUri,
143
+ filePath,
144
+ ideaName: idea.name,
145
+ range: nameNode?.range
146
+ });
147
+ }
148
+ }
149
+ return candidates;
150
+ }
151
+
152
+ export function matchWildcardAgainstCatalog(
153
+ pathPatternQuoted: string,
154
+ ideaPattern: string,
155
+ document: LangiumDocument,
156
+ candidates: readonly WildcardIdeaCandidate[],
157
+ context?: PathResolveContext
158
+ ): WildcardMatch[] {
159
+ const pathRegex = pathPatternToAbsoluteRegex(pathPatternQuoted, document, context);
160
+ const ideaRegex = globToRegExp(ideaPattern, 'name');
161
+ const matches: WildcardMatch[] = [];
162
+ for (const candidate of candidates) {
163
+ const path = normalizePathSeparators(candidate.filePath);
164
+ if (!pathRegex.test(path)) {
165
+ continue;
166
+ }
167
+ if (!ideaRegex.test(candidate.ideaName)) {
168
+ continue;
169
+ }
170
+ matches.push({
171
+ fileUri: candidate.fileUri,
172
+ ideaName: candidate.ideaName,
173
+ range: candidate.range
174
+ });
175
+ }
176
+ return matches.sort((left, right) => {
177
+ const byFile = left.fileUri.localeCompare(right.fileUri);
178
+ return byFile !== 0 ? byFile : left.ideaName.localeCompare(right.ideaName);
179
+ });
180
+ }
181
+
182
+ export function resolveWildcardReferenceMatches(
183
+ reference: WildcardReference,
184
+ documents: LangiumDocuments | Iterable<LangiumDocument>,
185
+ context?: PathResolveContext
186
+ ): WildcardMatch[] {
187
+ const document = AstUtils.getDocument(reference);
188
+ const iterable = isLangiumDocuments(documents) ? documents.all : documents;
189
+ const candidates = collectIdeaCandidatesFromDocuments(iterable);
190
+ return matchWildcardAgainstCatalog(
191
+ reference.pathPattern,
192
+ reference.ideaPattern,
193
+ document,
194
+ candidates,
195
+ context
196
+ );
197
+ }
198
+
199
+ export function wildcardArgsFromReference(reference: WildcardReference): WildcardReferenceArgs {
200
+ const document = AstUtils.getDocument(reference);
201
+ return {
202
+ pathPattern: parseWildcardPathPattern(reference.pathPattern),
203
+ ideaPattern: reference.ideaPattern,
204
+ fromUri: document.uri.toString()
205
+ };
206
+ }
207
+
208
+ export function findWildcardReferenceAtPosition(
209
+ document: LangiumDocument,
210
+ offset: number
211
+ ): WildcardReference | undefined {
212
+ const root = document.parseResult.value.$cstNode;
213
+ if (!root) {
214
+ return undefined;
215
+ }
216
+ let current = CstUtils.findLeafNodeAtOffset(root, offset)?.astNode;
217
+ while (current) {
218
+ if (isWildcardReference(current)) {
219
+ return current;
220
+ }
221
+ if ('target' in current && isWildcardReference((current as { target?: unknown }).target)) {
222
+ return (current as { target: WildcardReference }).target;
223
+ }
224
+ current = current.$container;
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ function collectIdeas(model: { elements?: unknown[] }): IdeaDeclaration[] {
230
+ const ideas: IdeaDeclaration[] = [];
231
+ for (const node of AstUtils.streamAst(model as Parameters<typeof AstUtils.streamAst>[0])) {
232
+ if (isIdea(node) || isOneLinerIdea(node)) {
233
+ ideas.push(node);
234
+ }
235
+ }
236
+ return ideas;
237
+ }
238
+
239
+ function isLangiumDocuments(value: unknown): value is LangiumDocuments {
240
+ return typeof value === 'object' && value !== null && 'all' in value;
241
+ }
242
+
243
+ function uriToPath(uri: URI): string {
244
+ if (uri.scheme === 'file') {
245
+ return normalizePathSeparators(uri.fsPath ?? uri.path);
246
+ }
247
+ return normalizePathSeparators(uri.path);
248
+ }
249
+
250
+ function normalizePathSeparators(path: string): string {
251
+ return path.replace(/\\/g, '/');
252
+ }
253
+
254
+ function joinGlobPath(base: string, relative: string): string {
255
+ const left = normalizePathSeparators(base).replace(/\/+$/, '');
256
+ const right = normalizePathSeparators(relative).replace(/^\/+/, '');
257
+ return normalizeGlobPath(`${left}/${right}`);
258
+ }
259
+
260
+ /** Collapse `.` / `..` segments without expanding glob metacharacters. */
261
+ function normalizeGlobPath(path: string): string {
262
+ const absolute = path.startsWith('/');
263
+ const parts = path.split('/');
264
+ const out: string[] = [];
265
+ for (const part of parts) {
266
+ if (!part || part === '.') {
267
+ continue;
268
+ }
269
+ if (part === '..') {
270
+ if (out.length > 0 && out[out.length - 1] !== '..' && !/[*?]/.test(out[out.length - 1]!)) {
271
+ out.pop();
272
+ } else if (!absolute) {
273
+ out.push(part);
274
+ }
275
+ continue;
276
+ }
277
+ out.push(part);
278
+ }
279
+ const joined = out.join('/');
280
+ return absolute ? `/${joined}` : joined;
281
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type Stream,
6
6
  type URI
7
7
  } from 'langium';
8
+ import type { RqConfig } from './reqlan-path-resolve.js';
8
9
 
9
10
  /**
10
11
  * Loads workspace documents independently so one catastrophic parse failure
@@ -13,6 +14,13 @@ import {
13
14
  * rq:["../../../reqlan rq/language/parser_lexer.rq".parse_budget_timeout]
14
15
  */
15
16
  export class ReqlanWorkspaceManager extends DefaultWorkspaceManager {
17
+ /**
18
+ * Per-directory cache for resolved `.reqlan/config.json` results.
19
+ * Keyed by directory URI string. `undefined` values mean the directory tree has no `.reqlan/` base.
20
+ * Cleared on workspace reinitialisation so config changes take effect on the next reload.
21
+ */
22
+ readonly rqConfigCache: Map<string, RqConfig | undefined> = new Map();
23
+
16
24
  constructor(services: LangiumSharedCoreServices) {
17
25
  super(services);
18
26
  }
@@ -21,6 +29,7 @@ export class ReqlanWorkspaceManager extends DefaultWorkspaceManager {
21
29
  uris: Stream<URI>,
22
30
  collector: (document: LangiumDocument) => void
23
31
  ): Promise<void> {
32
+ this.rqConfigCache.clear();
24
33
  await Promise.all(
25
34
  uris.map(async uri => {
26
35
  try {
@@ -80,11 +80,11 @@ CodeSnippet:
80
80
  // ---------------------------------------------------------------------------
81
81
 
82
82
  OneLinerText returns string:
83
- WORD | ID | NUMBER | INLINE_CODE | OTHER | PlainPunctuation | '(' | ')' | '|' | 'from' | 'import' | 'as' | '`';
83
+ WORD | ID | NUMBER | INLINE_CODE | OTHER | WILDCARD_NAME | PlainPunctuation | '(' | ')' | '|' | 'from' | 'import' | 'as' | '`';
84
84
 
85
85
  // PlainText must not include INLINE_CODE — that terminal is its own RichTextPart/InlineTextPart alt.
86
86
  PlainText returns string:
87
- WORD | ID | NUMBER | OTHER | PlainPunctuation | '[' | ']' | '|' | 'from' | 'import' | 'as' | '`';
87
+ WORD | ID | NUMBER | OTHER | WILDCARD_NAME | PlainPunctuation | '[' | ']' | '|' | 'from' | 'import' | 'as' | '`';
88
88
 
89
89
  PlainPunctuation returns string:
90
90
  '.' | ',' | ':' | ';' | '!' | '?' | '-';
@@ -113,7 +113,7 @@ NonBangInlineTextPart:
113
113
 
114
114
  // No '!' (negated flag) and no leading ':' (attribute separator).
115
115
  NonBangPlainText returns string:
116
- WORD | ID | NUMBER | OTHER | NonBangPunctuation | '[' | ']' | '|' | 'from' | 'import' | 'as' | '`';
116
+ WORD | ID | NUMBER | OTHER | WILDCARD_NAME | NonBangPunctuation | '[' | ']' | '|' | 'from' | 'import' | 'as' | '`';
117
117
 
118
118
  NonBangPunctuation returns string:
119
119
  '.' | ',' | ';' | '?' | '-';
@@ -152,7 +152,7 @@ ListItemBody:
152
152
  content+=(MarkdownLink | BracketReference | WikiLink | ListItemText)+;
153
153
 
154
154
  ListItemText returns string:
155
- WORD | ID | NUMBER | INLINE_CODE | OTHER | ListItemPunctuation | '(' | '|' | 'from' | 'import' | 'as' | '`';
155
+ WORD | ID | NUMBER | INLINE_CODE | OTHER | WILDCARD_NAME | ListItemPunctuation | '(' | '|' | 'from' | 'import' | 'as' | '`';
156
156
 
157
157
  ListItemPunctuation returns string:
158
158
  '.' | ':' | ';' | '!' | '?' | '-';
@@ -180,12 +180,13 @@ BracketReference:
180
180
  '[' target=ReferenceTarget ']';
181
181
 
182
182
  // Order and shape chosen so alternatives do not share a common LL prefix:
183
+ // - WildcardReference is STRING.WILDCARD_NAME (idea pattern must contain * or ?)
183
184
  // - QualifiedReference covers STRING.ID / ID.ID (incl. idea.attribute surface form)
184
185
  // - FileSymbolReference only for STRING.ID.ID.ID+ (3+ symbol segments)
185
186
  // - FileReference is bare STRING
186
187
  // - LocalReference is a bare local name
187
188
  ReferenceTarget:
188
- QualifiedReference | FileSymbolReference | FileReference | LocalReference;
189
+ WildcardReference | QualifiedReference | FileSymbolReference | FileReference | LocalReference;
189
190
 
190
191
  FileReference:
191
192
  file=STRING;
@@ -193,6 +194,10 @@ FileReference:
193
194
  FileSymbolReference:
194
195
  file=STRING '.' symbols+=ID '.' symbols+=ID '.' symbols+=ID ('.' symbols+=ID)*;
195
196
 
197
+ // Path may include globs (* ** ?); ideaPattern must include * or ? so exact qualified refs stay QualifiedReference.
198
+ WildcardReference:
199
+ pathPattern=STRING '.' ideaPattern=WILDCARD_NAME;
200
+
196
201
  QualifiedReference:
197
202
  (path=[Import:STRING] | qualifier=[Import:ID]) '.'
198
203
  (ideaset=[IdeaSet:ReferenceName] '.')? idea=[IdeaDeclaration:ReferenceName];
@@ -234,10 +239,10 @@ InvalidFromImport:
234
239
  'from' head=InvalidImportHeadToken tokens+=InvalidImportToken*;
235
240
 
236
241
  InvalidImportHeadToken returns string:
237
- ID | WORD | NUMBER | OTHER | PlainPunctuation | '(' | ')' | '|' | 'as' | 'import' | 'from' | '`' | '[' | ']';
242
+ ID | WORD | NUMBER | OTHER | WILDCARD_NAME | PlainPunctuation | '(' | ')' | '|' | 'as' | 'import' | 'from' | '`' | '[' | ']';
238
243
 
239
244
  InvalidImportToken returns string:
240
- ID | STRING | WORD | NUMBER | OTHER | PlainPunctuation | '(' | ')' | '|' | 'as' | 'import' | 'from' | '`' | '[' | ']';
245
+ ID | STRING | WORD | NUMBER | OTHER | WILDCARD_NAME | PlainPunctuation | '(' | ')' | '|' | 'as' | 'import' | 'from' | '`' | '[' | ']';
241
246
 
242
247
  // ---------------------------------------------------------------------------
243
248
  // comments, string_literals
@@ -250,6 +255,8 @@ hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;
250
255
 
251
256
  terminal MARKDOWN_LINK: /\[(?!\[)[^\]]+\]\([^)]+\)/;
252
257
  terminal ID: /[_a-zA-Z][\w-]*/;
258
+ // Must contain * or ? so it does not steal ordinary ID tokens.
259
+ terminal WILDCARD_NAME: /\*[\w*?-]*|\?[\w*?-]*|[_a-zA-Z][\w-]*[*?][\w*?-]*/;
253
260
  terminal STRING: /"(\\.|[^"\\\r\n])*"|'(\\.|[^'\\\r\n])*'/;
254
261
  terminal NUMBER: /\d+/;
255
262
  terminal WORD: /[A-Za-z_][\w']*/;