@reqlan/language 1.9.7 → 1.10.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 (62) hide show
  1. package/README.md +34 -0
  2. package/out/file-path-rewrite.d.ts +1 -1
  3. package/out/generated/ast.d.ts +2 -2
  4. package/out/generated/ast.js +1 -1
  5. package/out/generated/ast.js.map +1 -1
  6. package/out/generated/grammar.js +1 -1
  7. package/out/generated/grammar.js.map +1 -1
  8. package/out/index.d.ts +1 -0
  9. package/out/index.js +1 -0
  10. package/out/index.js.map +1 -1
  11. package/out/reqlan-comment-resolver.d.ts +1 -1
  12. package/out/reqlan-completion-provider.d.ts +2 -2
  13. package/out/reqlan-definition-provider.js +1 -1
  14. package/out/reqlan-definition-provider.js.map +1 -1
  15. package/out/reqlan-document-link-provider.d.ts +2 -0
  16. package/out/reqlan-document-link-provider.js +3 -0
  17. package/out/reqlan-document-link-provider.js.map +1 -1
  18. package/out/reqlan-embedded-file-references.d.ts +3 -0
  19. package/out/reqlan-embedded-file-references.js +47 -5
  20. package/out/reqlan-embedded-file-references.js.map +1 -1
  21. package/out/reqlan-file-link-resolver.d.ts +8 -0
  22. package/out/reqlan-file-link-resolver.js +46 -11
  23. package/out/reqlan-file-link-resolver.js.map +1 -1
  24. package/out/reqlan-file-references.d.ts +1 -0
  25. package/out/reqlan-file-references.js +24 -16
  26. package/out/reqlan-file-references.js.map +1 -1
  27. package/out/reqlan-ignore-error.d.ts +2 -1
  28. package/out/reqlan-ignore-error.js +4 -17
  29. package/out/reqlan-ignore-error.js.map +1 -1
  30. package/out/reqlan-imports.d.ts +3 -1
  31. package/out/reqlan-imports.js +2 -5
  32. package/out/reqlan-imports.js.map +1 -1
  33. package/out/reqlan-module.js +1 -0
  34. package/out/reqlan-module.js.map +1 -1
  35. package/out/reqlan-reference-at-position.d.ts +3 -0
  36. package/out/reqlan-reference-at-position.js +8 -2
  37. package/out/reqlan-reference-at-position.js.map +1 -1
  38. package/out/reqlan-scope.d.ts +14 -0
  39. package/out/reqlan-scope.js +58 -15
  40. package/out/reqlan-scope.js.map +1 -1
  41. package/out/reqlan-validator.d.ts +7 -1
  42. package/out/reqlan-validator.js +21 -4
  43. package/out/reqlan-validator.js.map +1 -1
  44. package/package.json +3 -2
  45. package/src/file-path-rewrite.ts +1 -1
  46. package/src/generated/ast.ts +2 -2
  47. package/src/generated/grammar.ts +1 -1
  48. package/src/index.ts +5 -0
  49. package/src/reqlan-comment-resolver.ts +1 -1
  50. package/src/reqlan-completion-provider.ts +2 -2
  51. package/src/reqlan-definition-provider.ts +1 -1
  52. package/src/reqlan-document-link-provider.ts +5 -0
  53. package/src/reqlan-embedded-file-references.ts +54 -5
  54. package/src/reqlan-file-link-resolver.ts +56 -11
  55. package/src/reqlan-file-references.ts +26 -16
  56. package/src/reqlan-ignore-error.ts +4 -18
  57. package/src/reqlan-imports.ts +4 -5
  58. package/src/reqlan-module.ts +1 -0
  59. package/src/reqlan-reference-at-position.ts +16 -2
  60. package/src/reqlan-scope.ts +73 -16
  61. package/src/reqlan-validator.ts +25 -4
  62. package/src/reqlan.langium +4 -1
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Resolves file references, idea references, and import paths to target URIs and editor ranges.
3
3
  * Document links use these so same-file and cross-file refs share the same underline/click affordance.
4
+ * Missing file refs stay in this result so the validator can underline them without creating a link.
5
+ * rq:["../../../reqlan rq/extension/language-support/language-server-errors.rq".file_reference_errors]
6
+ * rq:["../../../reqlan rq/extension/syntax/features-syntax.rq".file_references]
7
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_folder_targets]
4
8
  */
5
9
  import type { AstNode, CstNode, FileSystemProvider, LangiumDocument, LangiumDocuments, Reference, URI } from 'langium';
6
10
  import { AstUtils, CstUtils, GrammarUtils } from 'langium';
@@ -50,6 +54,8 @@ export type ReferenceResolution = 'file' | 'folder' | 'missing' | 'wildcard';
50
54
 
51
55
  export type FileLinkTargetIssue = 'empty' | 'parse-error';
52
56
 
57
+ export const FILE_REFERENCE_MISSING = 'file-reference-missing';
58
+
53
59
  export interface ResolvedFileLink {
54
60
  sourceRange: Range;
55
61
  targetUri: string;
@@ -57,6 +63,8 @@ export interface ResolvedFileLink {
57
63
  resolution?: ReferenceResolution;
58
64
  folderFiles?: string[];
59
65
  targetIssue?: FileLinkTargetIssue;
66
+ /** Authored path (no test/line suffix) for missing-file diagnostics. */
67
+ authoredPath?: string;
60
68
  /** Present when resolution is `wildcard`. */
61
69
  wildcardArgs?: WildcardReferenceArgs;
62
70
  wildcardMatches?: WildcardMatch[];
@@ -126,14 +134,18 @@ export function resolveImportPathLink(
126
134
  }
127
135
  const imported = findImportedDocument(path, document, documents, context);
128
136
  const target = imported?.parseResult.value.$cstNode;
129
- if (!imported || !target) {
137
+ if (imported && target) {
138
+ return {
139
+ sourceRange,
140
+ targetUri: imported.textDocument.uri,
141
+ targetRange: target.range
142
+ };
143
+ }
144
+ const fileSystem = context?.fileSystem;
145
+ if (!fileSystem) {
130
146
  return undefined;
131
147
  }
132
- return {
133
- sourceRange,
134
- targetUri: imported.textDocument.uri,
135
- targetRange: target.range
136
- };
148
+ return resolveImportedFileLink(document, documents, fileSystem, path, sourceRange, context);
137
149
  }
138
150
 
139
151
  export function resolveQualifiedReferencePathLink(
@@ -238,21 +250,28 @@ function resolvePathStringLink(
238
250
  targetUri: URI,
239
251
  sourceRange: Range
240
252
  ): ResolvedFileLink | undefined {
253
+ if (!parsed.filePath.trim()) {
254
+ return missingFileLink(sourceRange, targetUri, parsed.filePath);
255
+ }
256
+ if (isRemoteFileReferencePath(parsed.filePath)) {
257
+ return undefined;
258
+ }
241
259
  const resolution = classifyReferenceUri(targetUri, documents, fileSystem);
242
260
  if (resolution === 'missing') {
243
- return undefined;
261
+ return missingFileLink(sourceRange, targetUri, parsed.filePath);
244
262
  }
245
263
  if (resolution === 'folder') {
246
264
  return {
247
265
  sourceRange,
248
266
  targetUri: targetUri.toString(),
249
267
  resolution,
250
- folderFiles: listFolderFileNames(fileSystem, targetUri)
268
+ folderFiles: listFolderFileNames(fileSystem, targetUri),
269
+ authoredPath: parsed.filePath
251
270
  };
252
271
  }
253
272
  const text = readTargetText(targetUri, documents, fileSystem);
254
273
  if (text === undefined) {
255
- return undefined;
274
+ return missingFileLink(sourceRange, targetUri, parsed.filePath);
256
275
  }
257
276
  const targetIssue = detectFileLinkTargetIssue(text, findTargetDocument(targetUri, documents));
258
277
  let targetRange: Range | undefined;
@@ -271,10 +290,24 @@ function resolvePathStringLink(
271
290
  targetUri: targetUri.toString(),
272
291
  targetRange,
273
292
  resolution: 'file',
274
- targetIssue
293
+ targetIssue,
294
+ authoredPath: parsed.filePath
295
+ };
296
+ }
297
+
298
+ function missingFileLink(sourceRange: Range, targetUri: URI, authoredPath: string): ResolvedFileLink {
299
+ return {
300
+ sourceRange,
301
+ targetUri: targetUri.toString(),
302
+ resolution: 'missing',
303
+ authoredPath
275
304
  };
276
305
  }
277
306
 
307
+ function isRemoteFileReferencePath(path: string): boolean {
308
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(path);
309
+ }
310
+
278
311
  export function detectFileLinkTargetIssue(
279
312
  text: string,
280
313
  targetDocument: LangiumDocument | undefined
@@ -297,6 +330,14 @@ export function fileLinkTargetIssueMessage(issue: FileLinkTargetIssue): string {
297
330
  }
298
331
  }
299
332
 
333
+ export function fileLinkMissingMessage(path: string): string {
334
+ const trimmed = path.trim();
335
+ if (!trimmed) {
336
+ return 'Could not resolve file reference.';
337
+ }
338
+ return `Could not resolve file reference '${trimmed}'.`;
339
+ }
340
+
300
341
  function findTargetDocument(targetUri: URI, documents: LangiumDocuments): LangiumDocument | undefined {
301
342
  const direct = documents.getDocument(targetUri);
302
343
  if (direct) {
@@ -347,7 +388,11 @@ export function resolveImportedFileLink(
347
388
  context?: PathResolveContext
348
389
  ): ResolvedFileLink | undefined {
349
390
  const targetUri = resolveExistingImportUri(filePath, document, documents, fileSystem, context);
350
- return resolvePathStringLink(document, documents, fileSystem, { filePath }, targetUri, sourceRange);
391
+ const link = resolvePathStringLink(document, documents, fileSystem, { filePath }, targetUri, sourceRange);
392
+ if (!link || link.resolution === 'missing') {
393
+ return undefined;
394
+ }
395
+ return link;
351
396
  }
352
397
 
353
398
  export function filePathRangeInStringNode(pathNode: CstNode, parsed: ParsedFileReference): Range {
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Parses file path strings from bracket references, including L# line suffixes and :test name suffixes.
3
+ * rq:["../../../reqlan rq/language/syntax.rq".reference_file]
3
4
  */
4
5
 
5
6
  export interface ParsedFileReference {
@@ -30,28 +31,37 @@ export function parseFileReferenceString(file: string): ParsedFileReference {
30
31
  }
31
32
 
32
33
  function parseTestNameSuffix(file: string): { filePath: string; testName?: string } {
33
- let colonIndex = -1;
34
- for (let index = file.length - 1; index >= 0; index--) {
34
+ for (let index = 0; index < file.length; index++) {
35
35
  if (file[index] !== ':') {
36
36
  continue;
37
37
  }
38
- if (index > 0 && file[index - 1] === '/' && index > 1 && file[index - 2] === '/') {
38
+ if (isUriSchemeColon(file, index) || isWindowsDriveColon(file, index)) {
39
39
  continue;
40
40
  }
41
- colonIndex = index;
42
- break;
43
- }
44
- if (colonIndex < 0) {
45
- return { filePath: file };
46
- }
47
- const testName = file.slice(colonIndex + 1);
48
- if (!testName) {
49
- return { filePath: file };
41
+ const testName = file.slice(index + 1);
42
+ if (!testName) {
43
+ return { filePath: file };
44
+ }
45
+ return {
46
+ filePath: file.slice(0, index),
47
+ testName
48
+ };
50
49
  }
51
- return {
52
- filePath: file.slice(0, colonIndex),
53
- testName
54
- };
50
+ return { filePath: file };
51
+ }
52
+
53
+ /** `https://` / `file://` — the colon that starts `://`. */
54
+ function isUriSchemeColon(file: string, index: number): boolean {
55
+ return file[index + 1] === '/' && file[index + 2] === '/';
56
+ }
57
+
58
+ /** Drive letter in `C:\…` or `file://C:/…`. */
59
+ function isWindowsDriveColon(file: string, index: number): boolean {
60
+ const previous = file[index - 1];
61
+ const next = file[index + 1];
62
+ return previous !== undefined
63
+ && /[A-Za-z]/.test(previous)
64
+ && (next === '\\' || next === '/');
55
65
  }
56
66
 
57
67
  const NON_RQ_FILE_EXTENSION = /\.[^./\\]+$/;
@@ -1,28 +1,14 @@
1
1
  /**
2
- * Suppresses diagnostics on lines immediately following `//rq-ignore-error`.
2
+ * Langium glue: drop diagnostics on lines that `//rq-ignore-error` suppresses.
3
+ * Line detection is the core Rust scanner (`find_rq_ignore_error_target_lines`).
3
4
  * rq:["../../../reqlan rq/language/syntax.rq".comment_reference_ignore]
4
5
  */
5
6
  import { DocumentState, type LangiumDocument, type LangiumSharedCoreServices } from 'langium';
6
7
  import type { Diagnostic } from 'vscode-languageserver';
7
- import { findLineCommentStart } from './reqlan-comment-resolver.js';
8
-
9
- const RQ_IGNORE_ERROR = /\/\/\s*rq-ignore-error\b/;
8
+ import { findRqIgnoreErrorTargetLines as nativeFindRqIgnoreErrorTargetLines } from '@reqlan/analytical/core';
10
9
 
11
10
  export function findRqIgnoreErrorTargetLines(text: string): Set<number> {
12
- const lines = text.split(/\r?\n/);
13
- const targetLines = new Set<number>();
14
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
15
- const line = lines[lineIndex]!;
16
- const commentStart = findLineCommentStart(line);
17
- if (commentStart < 0) {
18
- continue;
19
- }
20
- const commentText = line.slice(commentStart);
21
- if (RQ_IGNORE_ERROR.test(commentText) && lineIndex + 1 < lines.length) {
22
- targetLines.add(lineIndex + 1);
23
- }
24
- }
25
- return targetLines;
11
+ return new Set(nativeFindRqIgnoreErrorTargetLines(text));
26
12
  }
27
13
 
28
14
  export function filterRqIgnoredDiagnostics(text: string, diagnostics: Diagnostic[]): Diagnostic[] {
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Resolves imported requirement documents from relative and import-root-aliased paths.
3
3
  * Interacts with scope linking and go-to-definition for import paths.
4
+ * A path that exists as a folder is a valid import target (same as a file).
4
5
  * rq:["../../../reqlan rq/ontology.rq".import_statement]
5
6
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".implicit_file_extension]
7
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_folder_targets]
6
8
  */
7
9
  import type { FileSystemProvider, LangiumDocument, LangiumDocuments, URI } from 'langium';
8
10
  import {
@@ -16,7 +18,7 @@ export const IMPLICIT_IMPORT_EXTENSION = '.rq';
16
18
 
17
19
  /**
18
20
  * Import paths written without an extension mean `.rq`; the literal path stays a fallback so
19
- * extensionless files on disk keep resolving.
21
+ * extensionless files and folders on disk keep resolving.
20
22
  */
21
23
  export function importPathCandidates(path: string): string[] {
22
24
  const implicit = importPathWithImplicitExtension(path);
@@ -99,10 +101,7 @@ function isImportTarget(
99
101
  if (documents.getDocument(uri)) {
100
102
  return true;
101
103
  }
102
- if (!fileSystem?.existsSync(uri)) {
103
- return false;
104
- }
105
- return !fileSystem.statSync(uri).isDirectory;
104
+ return fileSystem?.existsSync(uri) === true;
106
105
  }
107
106
 
108
107
  function withFileSystem(
@@ -110,6 +110,7 @@ export function createReqlanServices(context: DefaultSharedModuleContext): {
110
110
  );
111
111
  shared.ServiceRegistry.register(Reqlan);
112
112
  registerValidationChecks(Reqlan);
113
+ // rq:["../../../reqlan rq/language/syntax.rq".comment_reference_ignore]
113
114
  registerRqIgnoreErrorFiltering(shared);
114
115
  if (!context.connection) {
115
116
  // We don't run inside a language server
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Locates file path references at a cursor position and resolves them to files, folders, or missing paths.
3
+ * Missing targets are omitted here so go-to-definition can still try import-path resolution
4
+ * (for example `from "./target"` → `./target.rq`). Diagnostics still underline those refs.
5
+ * rq:["../../../reqlan rq/extension/language-support/language-server-errors.rq".file_reference_errors]
3
6
  */
4
7
  import type { CstNode, FileSystemProvider, LangiumDocument, LangiumDocuments } from 'langium';
5
8
  import { CstUtils, GrammarUtils } from 'langium';
@@ -55,15 +58,26 @@ export function findFileReferenceAtPosition(
55
58
  }
56
59
  const embeddedReference = findEmbeddedFileReferenceAt(document, position);
57
60
  if (embeddedReference) {
58
- return resolveEmbeddedFileReferenceLink(embeddedReference, document, documents, fileSystem, pathContext);
61
+ return navigableFileLink(
62
+ resolveEmbeddedFileReferenceLink(embeddedReference, document, documents, fileSystem, pathContext)
63
+ );
59
64
  }
60
65
  const fileReference = findFileReferenceNodeAtPosition(document, position);
61
66
  if (fileReference) {
62
- return resolveFileReferenceLink(fileReference.node, documents, fileSystem, pathContext);
67
+ return navigableFileLink(
68
+ resolveFileReferenceLink(fileReference.node, documents, fileSystem, pathContext)
69
+ );
63
70
  }
64
71
  return undefined;
65
72
  }
66
73
 
74
+ function navigableFileLink(link: ResolvedFileLink | undefined): ResolvedFileLink | undefined {
75
+ if (!link || link.resolution === 'missing') {
76
+ return undefined;
77
+ }
78
+ return link;
79
+ }
80
+
67
81
  function resolvePathReference(
68
82
  document: LangiumDocument,
69
83
  path: string,
@@ -1,18 +1,20 @@
1
1
  /**
2
2
  * Publishes idea and ideaset declarations for file-local and cross-file reference resolution.
3
+ * Local declarations are collected first; imports are consulted only when a name is not local.
4
+ * rq:["../../../reqlan rq/language/syntax.rq".same_file_reference]
5
+ * rq:["../../../reqlan rq/language/syntax.rq".reference_resolution_order]
3
6
  */
4
7
  import type { AstNodeDescription, LangiumDocument, ReferenceInfo } from 'langium';
5
8
  import { AstUtils, DefaultScopeComputation, DefaultScopeProvider, StreamScope, stream, type Scope } from 'langium';
6
9
  import {
7
10
  isFromImport,
8
11
  isFromImportSpecifier,
9
- isIdea,
12
+ isIdeaDeclaration,
10
13
  isIdeaSet,
11
14
  isInvalidFromImport,
12
15
  isLocalReference,
13
16
  isModel,
14
17
  isNamespaceImport,
15
- isOneLinerIdea,
16
18
  isQualifiedImport,
17
19
  isQualifiedReference,
18
20
  type FromImportSpecifier,
@@ -34,7 +36,7 @@ export class ReqlanScopeComputation extends DefaultScopeComputation {
34
36
  const model = document.parseResult.value;
35
37
  if (isModel(model)) {
36
38
  for (const element of model.elements) {
37
- if (isIdea(element) || isOneLinerIdea(element) || isIdeaSet(element)) {
39
+ if (isIdeaDeclaration(element)) {
38
40
  this.addExportedSymbol(element, exports, document);
39
41
  }
40
42
  }
@@ -146,20 +148,49 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
146
148
  }
147
149
 
148
150
  private buildFileIdeasScope(document: LangiumDocument): Scope {
151
+ const locals = this.localDeclarationDescriptions(document);
152
+ return new StreamScope(
153
+ stream(locals),
154
+ new LazyScope(() => this.importedIdeaScope(document)),
155
+ { concatOuterScope: false }
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Every named top-level declaration in the file, regardless of source order.
161
+ * Bare `[name]` and ideaset members resolve against this set first.
162
+ */
163
+ private localDeclarationDescriptions(document: LangiumDocument): AstNodeDescription[] {
149
164
  const model = document.parseResult.value as Model;
150
165
  if (!isModel(model)) {
151
- return new StreamScope(stream([]));
166
+ return [];
152
167
  }
153
- const descriptions = model.elements
154
- .filter(element => isIdea(element) || isOneLinerIdea(element))
155
- .map(idea => this.descriptions.createDescription(idea, idea.name, document));
168
+ return model.elements
169
+ .filter(isIdeaDeclaration)
170
+ .map(element => this.descriptions.createDescription(element, element.name, document));
171
+ }
172
+
173
+ /**
174
+ * Imported bindings only. Built lazily so a fully-local file never walks import `.ref`s
175
+ * while resolving same-file names.
176
+ */
177
+ private importedIdeaScope(document: LangiumDocument): Scope {
178
+ return new StreamScope(stream(this.importedIdeaDescriptions(document)));
179
+ }
180
+
181
+ private importedIdeaDescriptions(document: LangiumDocument): AstNodeDescription[] {
182
+ const model = document.parseResult.value as Model;
183
+ if (!isModel(model)) {
184
+ return [];
185
+ }
186
+ const descriptions: AstNodeDescription[] = [];
156
187
  // Imported ideas enter scope under their binding name only (alias when present).
157
188
  // See import_tokenisation: aliased base names are not reserved locally.
158
189
  for (const importDecl of model.imports) {
159
190
  if (isFromImport(importDecl)) {
160
191
  for (const specifier of importDecl.specifiers) {
161
192
  const target = specifier.idea.ref;
162
- if (!target || (!isIdea(target) && !isOneLinerIdea(target))) {
193
+ if (!target || !isIdeaDeclaration(target)) {
163
194
  continue;
164
195
  }
165
196
  const name = specifierBindingName(specifier) ?? target.name;
@@ -169,21 +200,21 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
169
200
  }
170
201
  if (isQualifiedImport(importDecl)) {
171
202
  const target = importDecl.idea.ref;
172
- if (!target || (!isIdea(target) && !isOneLinerIdea(target))) {
203
+ if (!target || !isIdeaDeclaration(target)) {
173
204
  continue;
174
205
  }
175
206
  const name = importDecl.alias ?? target.name;
176
207
  descriptions.push(this.descriptions.createDescription(target, name, AstUtils.getDocument(target)));
177
208
  }
178
209
  }
179
- return new StreamScope(stream(descriptions));
210
+ return descriptions;
180
211
  }
181
212
 
182
213
  private scopeForIdeasetMembers(ideaset: IdeaSet, document: LangiumDocument): Scope {
183
214
  const descriptions = ideaset.members
184
215
  .map(member => member.ref)
185
216
  .filter((target): target is NonNullable<typeof target> =>
186
- target !== undefined && (isIdea(target) || isOneLinerIdea(target))
217
+ target !== undefined && isIdeaDeclaration(target)
187
218
  )
188
219
  .map(target => this.descriptions.createDescription(target, target.name, AstUtils.getDocument(target)));
189
220
  if (descriptions.length > 0) {
@@ -209,7 +240,7 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
209
240
  return new StreamScope(stream([]));
210
241
  }
211
242
  const locals = model.elements
212
- .filter(element => isIdea(element) || isOneLinerIdea(element) || isIdeaSet(element))
243
+ .filter(isIdeaDeclaration)
213
244
  .map(element => this.descriptions.createDescription(element, element.name, document));
214
245
  const namedImports = model.imports.flatMap(importDecl => {
215
246
  if (isFromImport(importDecl)) {
@@ -247,7 +278,7 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
247
278
  return undefined;
248
279
  }
249
280
  const target = importDecl.idea.ref;
250
- if (!target || (!isIdea(target) && !isOneLinerIdea(target))) {
281
+ if (!target || !isIdeaDeclaration(target)) {
251
282
  return undefined;
252
283
  }
253
284
  const description = this.descriptions.createDescription(target, target.name, imported);
@@ -261,7 +292,7 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
261
292
  const descriptions = importDecl.specifiers
262
293
  .map(specifier => specifier.idea.ref)
263
294
  .filter((target): target is NonNullable<typeof target> =>
264
- target !== undefined && (isIdea(target) || isOneLinerIdea(target))
295
+ target !== undefined && isIdeaDeclaration(target)
265
296
  )
266
297
  .map(target => this.descriptions.createDescription(target, target.name, imported));
267
298
  return new StreamScope(stream(descriptions));
@@ -272,7 +303,7 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
272
303
  return undefined;
273
304
  }
274
305
  const target = importDecl.idea.ref;
275
- if (!target || (!isIdea(target) && !isOneLinerIdea(target))) {
306
+ if (!target || !isIdeaDeclaration(target)) {
276
307
  return undefined;
277
308
  }
278
309
  const description = this.descriptions.createDescription(target, target.name, imported);
@@ -315,7 +346,7 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
315
346
  return undefined;
316
347
  }
317
348
  const descriptions = model.elements
318
- .filter(element => isIdea(element) || isOneLinerIdea(element))
349
+ .filter(isIdeaDeclaration)
319
350
  .map(idea => this.descriptions.createDescription(idea, idea.name, imported));
320
351
  const scope = new StreamScope(stream(descriptions));
321
352
  this.importPathCache.set(key, { version: imported.textDocument.version, scope });
@@ -349,3 +380,29 @@ function importAlias(entry: Import): string | undefined {
349
380
  }
350
381
  return entry.alias;
351
382
  }
383
+
384
+ /**
385
+ * Defers outer-scope construction until a local lookup misses.
386
+ * StreamScope `getElement` does not call the outer scope when a local name matches.
387
+ */
388
+ class LazyScope implements Scope {
389
+ private resolved: Scope | undefined;
390
+
391
+ constructor(private readonly load: () => Scope) {}
392
+
393
+ getElement(name: string): AstNodeDescription | undefined {
394
+ return this.inner().getElement(name);
395
+ }
396
+
397
+ getElements(name: string): ReturnType<Scope['getElements']> {
398
+ return this.inner().getElements(name);
399
+ }
400
+
401
+ getAllElements(): ReturnType<Scope['getAllElements']> {
402
+ return this.inner().getAllElements();
403
+ }
404
+
405
+ private inner(): Scope {
406
+ return this.resolved ??= this.load();
407
+ }
408
+ }
@@ -3,10 +3,9 @@ import { AstUtils } from 'langium';
3
3
  import type { reqlanAstType } from './generated/ast.js';
4
4
  import {
5
5
  isFromImport,
6
- isIdea,
6
+ isIdeaDeclaration,
7
7
  isInvalidFromImport,
8
8
  isModel,
9
- isOneLinerIdea,
10
9
  isWildcardReference,
11
10
  type AnonymousBlock,
12
11
  type Model
@@ -14,6 +13,8 @@ import {
14
13
  import { collectCommentReferenceIssues } from './reqlan-comment-diagnostics.js';
15
14
  import {
16
15
  collectFileLinks,
16
+ FILE_REFERENCE_MISSING,
17
+ fileLinkMissingMessage,
17
18
  fileLinkTargetIssueMessage
18
19
  } from './reqlan-file-link-resolver.js';
19
20
  import {
@@ -34,6 +35,7 @@ import {
34
35
  /**
35
36
  * Registers validation hooks for the requirement graph AST.
36
37
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_does_not_exist_error]
38
+ * rq:["../../../reqlan rq/extension/language-support/language-server-errors.rq".file_reference_errors]
37
39
  * rq:["../../../reqlan rq/language/imports.rq".import_error_recovery]
38
40
  * rq:["../../../reqlan rq/language/imports.rq".import_tokenisation]
39
41
  * rq:["../../../reqlan rq/language/syntax.rq".no_name_idea_safe_warning]
@@ -53,10 +55,13 @@ export function registerValidationChecks(services: ReqlanServices) {
53
55
  /**
54
56
  * Custom validations for Reqlan documents.
55
57
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_does_not_exist_error]
58
+ * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_folder_targets]
59
+ * rq:["../../../reqlan rq/extension/language-support/language-server-errors.rq".file_reference_errors]
56
60
  * rq:["../../../reqlan rq/language/imports.rq".import_error_recovery]
57
61
  * rq:["../../../reqlan rq/language/imports.rq".import_tokenisation]
58
62
  * rq:["../../../reqlan rq/language/syntax.rq".no_name_idea_safe_warning]
59
63
  * rq:["../../../reqlan rq/language/syntax.rq".comment_reference_resolution_error]
64
+ * rq:["../../../reqlan rq/core_analysis/check.rq".check_wildcard_sparse]
60
65
  * rq:["../../../reqlan rq/extension/features-non-rq-code-comment/functional-code-comment-references.rq".comment_reference_resolution_error_state]
61
66
  */
62
67
  export class ReqlanValidator {
@@ -139,6 +144,8 @@ export class ReqlanValidator {
139
144
  /**
140
145
  * Duplicate check uses import *bindings* only (alias when present, otherwise the idea name).
141
146
  * An aliased import does not reserve the imported base name for local ideas.
147
+ * Ideasets share the same name space as ideas in the file.
148
+ * rq:["../../../reqlan rq/language/syntax.rq".idea_name]
142
149
  */
143
150
  checkDuplicateIdeaNames(model: Model, accept: ValidationAcceptor): void {
144
151
  const seen = new Map<string, AstNode>();
@@ -148,7 +155,7 @@ export class ReqlanValidator {
148
155
  }
149
156
  }
150
157
  for (const element of model.elements) {
151
- if (!isIdea(element) && !isOneLinerIdea(element)) {
158
+ if (!isIdeaDeclaration(element)) {
152
159
  continue;
153
160
  }
154
161
  const name = element.name;
@@ -164,7 +171,7 @@ export class ReqlanValidator {
164
171
  }
165
172
 
166
173
  /**
167
- * Error underline when an `import` / `from` path does not resolve to a file.
174
+ * Error underline when an `import` / `from` path does not resolve to a file or folder.
168
175
  * Missing imported ideas are reported by the linker as unresolved references.
169
176
  */
170
177
  checkImportTargets(model: Model, accept: ValidationAcceptor): void {
@@ -208,6 +215,14 @@ export class ReqlanValidator {
208
215
  shared.workspace.FileSystemProvider,
209
216
  pathResolveContextFromServices(this.services)
210
217
  )) {
218
+ if (link.resolution === 'missing') {
219
+ accept('error', fileLinkMissingMessage(link.authoredPath ?? ''), {
220
+ node: model,
221
+ range: link.sourceRange,
222
+ code: FILE_REFERENCE_MISSING
223
+ });
224
+ continue;
225
+ }
211
226
  if (!link.targetIssue) {
212
227
  continue;
213
228
  }
@@ -258,6 +273,12 @@ export class ReqlanValidator {
258
273
  `No ideas match wildcard reference [${wildcardReferenceLabel(path, node.ideaPattern)}].`,
259
274
  { node }
260
275
  );
276
+ } else if (matches.length === 1) {
277
+ accept(
278
+ 'warning',
279
+ `Wildcard reference [${wildcardReferenceLabel(path, node.ideaPattern)}] matches only 1 idea.`,
280
+ { node }
281
+ );
261
282
  }
262
283
  }
263
284
  }
@@ -38,8 +38,11 @@ OneLinerIdea:
38
38
  OneLinerBody:
39
39
  content+=(MarkdownLink | BracketReference | WikiLink | OneLinerText)+;
40
40
 
41
+ // Bare [name] refs resolve to any of these. Collect all declarations before linking
42
+ // so a reference may target a name declared later in the same file.
43
+ // rq:["../../../reqlan rq/language/syntax.rq".reference_resolution_order]
41
44
  IdeaDeclaration:
42
- Idea | OneLinerIdea;
45
+ Idea | OneLinerIdea | IdeaSet;
43
46
 
44
47
  Idea:
45
48
  name=IdeaName '{'