@reqlan/language 1.5.0 → 1.6.0

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 (47) hide show
  1. package/README.md +26 -2
  2. package/out/file-path-rewrite.d.ts +8 -1
  3. package/out/file-path-rewrite.js +63 -12
  4. package/out/file-path-rewrite.js.map +1 -1
  5. package/out/index.d.ts +3 -0
  6. package/out/index.js +3 -0
  7. package/out/index.js.map +1 -1
  8. package/out/reqlan-code-action-provider.d.ts +13 -0
  9. package/out/reqlan-code-action-provider.js +59 -1
  10. package/out/reqlan-code-action-provider.js.map +1 -1
  11. package/out/reqlan-comment-rename.d.ts +28 -0
  12. package/out/reqlan-comment-rename.js +113 -0
  13. package/out/reqlan-comment-rename.js.map +1 -0
  14. package/out/reqlan-idea-refactor.d.ts +23 -0
  15. package/out/reqlan-idea-refactor.js +158 -0
  16. package/out/reqlan-idea-refactor.js.map +1 -0
  17. package/out/reqlan-import-bindings.d.ts +9 -1
  18. package/out/reqlan-import-bindings.js +9 -9
  19. package/out/reqlan-import-bindings.js.map +1 -1
  20. package/out/reqlan-module.js +3 -1
  21. package/out/reqlan-module.js.map +1 -1
  22. package/out/reqlan-path-references.js.map +1 -1
  23. package/out/reqlan-path-resolve.d.ts +10 -2
  24. package/out/reqlan-path-resolve.js +25 -14
  25. package/out/reqlan-path-resolve.js.map +1 -1
  26. package/out/reqlan-rename-provider.d.ts +15 -0
  27. package/out/reqlan-rename-provider.js +84 -0
  28. package/out/reqlan-rename-provider.js.map +1 -0
  29. package/out/reqlan-scope.js +2 -0
  30. package/out/reqlan-scope.js.map +1 -1
  31. package/out/reqlan-validator.d.ts +6 -0
  32. package/out/reqlan-validator.js +7 -9
  33. package/out/reqlan-validator.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/file-path-rewrite.ts +89 -12
  36. package/src/index.ts +10 -0
  37. package/src/reqlan-code-action-provider.ts +73 -1
  38. package/src/reqlan-comment-rename.ts +151 -0
  39. package/src/reqlan-idea-refactor.ts +222 -0
  40. package/src/reqlan-import-bindings.ts +9 -10
  41. package/src/reqlan-module.ts +3 -1
  42. package/src/reqlan-path-references.ts +2 -0
  43. package/src/reqlan-path-resolve.ts +30 -15
  44. package/src/reqlan-rename-provider.ts +124 -0
  45. package/src/reqlan-scope.ts +2 -0
  46. package/src/reqlan-validator.ts +6 -11
  47. package/src/reqlan.langium +5 -1
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Plan workspace edits for moving or deleting idea declarations and their references.
3
+ * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_symbol_move]
4
+ * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_symbol_delete]
5
+ * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_changes]
6
+ */
7
+ import type { AstNode, LangiumDocument, ReferenceDescription } from 'langium';
8
+ import { AstUtils } from 'langium';
9
+ import type { Range, TextEdit } from 'vscode-languageserver';
10
+ import {
11
+ isIdea,
12
+ isModel,
13
+ isOneLinerIdea,
14
+ type Idea,
15
+ type Model,
16
+ type OneLinerIdea
17
+ } from './generated/ast.js';
18
+ import {
19
+ buildFromImportEdit,
20
+ findImportInsertPosition,
21
+ relativeRqImportPath
22
+ } from './reqlan-import-edits.js';
23
+
24
+ export type RefactorIdeaDeclaration = Idea | OneLinerIdea;
25
+
26
+ export interface DocumentTextEdits {
27
+ uri: string;
28
+ edits: TextEdit[];
29
+ }
30
+
31
+ export function isRefactorIdeaDeclaration(node: AstNode): node is RefactorIdeaDeclaration {
32
+ return isIdea(node) || isOneLinerIdea(node);
33
+ }
34
+
35
+ export function ideaDeclarationText(document: LangiumDocument, idea: RefactorIdeaDeclaration): string | undefined {
36
+ const range = idea.$cstNode?.range;
37
+ if (!range) {
38
+ return undefined;
39
+ }
40
+ return document.textDocument.getText(range);
41
+ }
42
+
43
+ export function planIdeaDeleteEdits(
44
+ idea: RefactorIdeaDeclaration,
45
+ references: readonly ReferenceDescription[],
46
+ documentsText?: Map<string, string>
47
+ ): DocumentTextEdits[] {
48
+ const byUri = new Map<string, TextEdit[]>();
49
+ const declarationDoc = AstUtils.getDocument(idea);
50
+ const declarationUri = declarationDoc.uri.toString();
51
+ const declarationRange = expandDeclarationRange(idea);
52
+ if (declarationRange) {
53
+ pushEdit(byUri, declarationUri, { range: declarationRange, newText: '' });
54
+ }
55
+
56
+ for (const reference of references) {
57
+ const uri = reference.sourceUri.toString();
58
+ if (uri === declarationUri && rangesOverlap(reference.segment.range, declarationRange)) {
59
+ continue;
60
+ }
61
+ const clearRange = expandReferenceClearRange(
62
+ documentsText?.get(uri),
63
+ reference.segment.range
64
+ );
65
+ pushEdit(byUri, uri, { range: clearRange, newText: '' });
66
+ }
67
+
68
+ if (documentsText) {
69
+ for (const [uri, text] of documentsText) {
70
+ for (const edit of planCommentReferenceRemovals(text, idea.name)) {
71
+ pushEdit(byUri, uri, edit);
72
+ }
73
+ }
74
+ }
75
+
76
+ return toDocumentEdits(byUri);
77
+ }
78
+
79
+ export function planIdeaMoveEdits(input: {
80
+ idea: RefactorIdeaDeclaration;
81
+ sourceDocument: LangiumDocument;
82
+ destinationDocument: LangiumDocument;
83
+ references: readonly ReferenceDescription[];
84
+ }): DocumentTextEdits[] {
85
+ const ideaText = ideaDeclarationText(input.sourceDocument, input.idea);
86
+ if (!ideaText) {
87
+ return [];
88
+ }
89
+
90
+ const byUri = new Map<string, TextEdit[]>();
91
+ const sourceUri = input.sourceDocument.uri.toString();
92
+ const destUri = input.destinationDocument.uri.toString();
93
+ const declarationRange = expandDeclarationRange(input.idea);
94
+ if (declarationRange) {
95
+ pushEdit(byUri, sourceUri, { range: declarationRange, newText: '' });
96
+ }
97
+
98
+ const destModel = input.destinationDocument.parseResult.value;
99
+ if (isModel(destModel)) {
100
+ const insert = findIdeaInsertPosition(destModel);
101
+ pushEdit(byUri, destUri, {
102
+ range: { start: insert, end: insert },
103
+ newText: `${ideaText}\n`
104
+ });
105
+ }
106
+
107
+ if (sourceKeepsReferences(input.references, sourceUri, declarationRange)) {
108
+ const importPath = relativeRqImportPath(input.sourceDocument.uri, input.destinationDocument.uri);
109
+ const importEdit = buildFromImportEdit(input.sourceDocument, importPath, input.idea.name);
110
+ if (importEdit) {
111
+ pushEdit(byUri, sourceUri, importEdit);
112
+ }
113
+ }
114
+
115
+ return toDocumentEdits(byUri);
116
+ }
117
+
118
+ function sourceKeepsReferences(
119
+ references: readonly ReferenceDescription[],
120
+ sourceUri: string,
121
+ declarationRange: Range | undefined
122
+ ): boolean {
123
+ return references.some(reference =>
124
+ reference.sourceUri.toString() === sourceUri
125
+ && !rangesOverlap(reference.segment.range, declarationRange)
126
+ );
127
+ }
128
+
129
+ function findIdeaInsertPosition(model: Model): { line: number; character: number } {
130
+ const lastElement = model.elements[model.elements.length - 1];
131
+ if (lastElement?.$cstNode) {
132
+ const end = lastElement.$cstNode.range.end;
133
+ return { line: end.line + 1, character: 0 };
134
+ }
135
+ return findImportInsertPosition(model).position;
136
+ }
137
+
138
+ function expandDeclarationRange(idea: RefactorIdeaDeclaration): Range | undefined {
139
+ const range = idea.$cstNode?.range;
140
+ if (!range) {
141
+ return undefined;
142
+ }
143
+ return {
144
+ start: { line: range.start.line, character: 0 },
145
+ end: { line: range.end.line + 1, character: 0 }
146
+ };
147
+ }
148
+
149
+ function expandReferenceClearRange(text: string | undefined, range: Range): Range {
150
+ if (!text) {
151
+ return range;
152
+ }
153
+ const line = text.split(/\r?\n/)[range.start.line] ?? '';
154
+ const before = line.slice(0, range.start.character);
155
+ const after = line.slice(range.end.character);
156
+ const wikiOpen = before.endsWith('[[') ? 2 : before.endsWith('[') ? 1 : 0;
157
+ const wikiClose = after.startsWith(']]') ? 2 : after.startsWith(']') ? 1 : 0;
158
+ if (wikiOpen > 0 && wikiClose > 0) {
159
+ return {
160
+ start: { line: range.start.line, character: range.start.character - wikiOpen },
161
+ end: { line: range.end.line, character: range.end.character + wikiClose }
162
+ };
163
+ }
164
+ return range;
165
+ }
166
+
167
+ function planCommentReferenceRemovals(text: string, ideaName: string): TextEdit[] {
168
+ const edits: TextEdit[] = [];
169
+ const pattern = /rq:\s*\[[^\]]*\]/g;
170
+ for (const match of text.matchAll(pattern)) {
171
+ const body = match[0]!;
172
+ if (!new RegExp(`(\\.|\\[)${escapeRegExp(ideaName)}(\\]|$)`).test(body)) {
173
+ continue;
174
+ }
175
+ const start = offsetToPosition(text, match.index ?? 0);
176
+ const end = offsetToPosition(text, (match.index ?? 0) + body.length);
177
+ edits.push({ range: { start, end }, newText: '' });
178
+ }
179
+ return edits;
180
+ }
181
+
182
+ function pushEdit(map: Map<string, TextEdit[]>, uri: string, edit: TextEdit): void {
183
+ const list = map.get(uri) ?? [];
184
+ list.push(edit);
185
+ map.set(uri, list);
186
+ }
187
+
188
+ function toDocumentEdits(byUri: Map<string, TextEdit[]>): DocumentTextEdits[] {
189
+ return [...byUri.entries()].map(([uri, edits]) => ({
190
+ uri,
191
+ edits: [...edits].sort((left, right) => {
192
+ if (left.range.start.line !== right.range.start.line) {
193
+ return right.range.start.line - left.range.start.line;
194
+ }
195
+ return right.range.start.character - left.range.start.character;
196
+ })
197
+ }));
198
+ }
199
+
200
+ function rangesOverlap(left: Range, right: Range | undefined): boolean {
201
+ if (!right) {
202
+ return false;
203
+ }
204
+ const leftStart = left.start.line * 1e9 + left.start.character;
205
+ const leftEnd = left.end.line * 1e9 + left.end.character;
206
+ const rightStart = right.start.line * 1e9 + right.start.character;
207
+ const rightEnd = right.end.line * 1e9 + right.end.character;
208
+ return leftStart < rightEnd && rightStart < leftEnd;
209
+ }
210
+
211
+ function offsetToPosition(text: string, offset: number): { line: number; character: number } {
212
+ const before = text.slice(0, offset);
213
+ const lines = before.split(/\r?\n/);
214
+ return {
215
+ line: lines.length - 1,
216
+ character: lines[lines.length - 1]!.length
217
+ };
218
+ }
219
+
220
+ function escapeRegExp(value: string): string {
221
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
222
+ }
@@ -18,6 +18,11 @@ export interface ImportBinding {
18
18
 
19
19
  export type PathBearingImport = Exclude<Import, InvalidFromImport>;
20
20
 
21
+ /**
22
+ * Local binding name for a from-import specifier.
23
+ * When `as alias` is present, only the alias is bound; the imported idea's base name
24
+ * remains free for a local idea (see import_tokenisation).
25
+ */
21
26
  export function specifierBindingName(specifier: FromImportSpecifier): string | undefined {
22
27
  return specifier.alias ?? specifier.idea.$refText;
23
28
  }
@@ -35,6 +40,10 @@ export function importPathOf(importDecl: Import): string | undefined {
35
40
  return isPathBearingImport(importDecl) ? importDecl.path : undefined;
36
41
  }
37
42
 
43
+ /**
44
+ * Names bound into the importing file's local namespace.
45
+ * Aliased imports bind only the alias — never the imported idea's base name.
46
+ */
38
47
  export function importBindings(importDecl: Import): ImportBinding[] {
39
48
  if (isFromImport(importDecl)) {
40
49
  // Mistaken `from "path" as alias` (no idea specifiers) must not bind a name.
@@ -73,16 +82,6 @@ export function importBindings(importDecl: Import): ImportBinding[] {
73
82
  return [];
74
83
  }
75
84
 
76
- export function importedIdeaNames(importDecl: Import): string[] {
77
- if (isFromImport(importDecl)) {
78
- return importDecl.specifiers.map(specifier => specifier.idea.$refText);
79
- }
80
- if (isQualifiedImport(importDecl)) {
81
- return [importDecl.idea.$refText];
82
- }
83
- return [];
84
- }
85
-
86
85
  export function findNamespaceImportByAlias(imports: Import[], alias: string): Import | undefined {
87
86
  for (const importDecl of imports) {
88
87
  if (!isNamespaceImport(importDecl) || importDecl.alias !== alias) {
@@ -13,6 +13,7 @@ import { ReqlanCodeActionProvider } from './reqlan-code-action-provider.js';
13
13
  import { sharedAttributeCatalog } from './reqlan-attribute-catalog.js';
14
14
  import { ReqlanCodeLensProvider } from './reqlan-code-lens-provider.js';
15
15
  import { ReqlanInlayHintProvider } from './reqlan-inlay-hint-provider.js';
16
+ import { ReqlanRenameProvider } from './reqlan-rename-provider.js';
16
17
  import { ReqlanSemanticTokenProvider } from './reqlan-semantic-token-provider.js';
17
18
  import { ReqlanTokenBuilder } from './reqlan-token-builder.js';
18
19
  import { registerRqIgnoreErrorFiltering } from './reqlan-ignore-error.js';
@@ -60,7 +61,8 @@ export const ReqlanModule: Module<ReqlanServices, PartialLangiumServices & Reqla
60
61
  InlayHintProvider: services => new ReqlanInlayHintProvider(services),
61
62
  CodeLensProvider: services => new ReqlanCodeLensProvider(services),
62
63
  CompletionProvider: services => new ReqlanCompletionProvider(services, sharedAttributeCatalog),
63
- CodeActionProvider: services => new ReqlanCodeActionProvider(services)
64
+ CodeActionProvider: services => new ReqlanCodeActionProvider(services),
65
+ RenameProvider: services => new ReqlanRenameProvider(services)
64
66
  }
65
67
  };
66
68
 
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Collects relative file path strings in .rq imports and embedded references, and rq: paths in comments.
3
+ * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_file_moves]
4
+ * rq:["../../../reqlan rq/extension/features-mutation-hooks.rq".move_file]
3
5
  */
4
6
  import type { Range } from 'vscode-languageserver';
5
7
  import { findCommentReferencesInText } from './reqlan-comment-resolver.js';
@@ -8,7 +8,10 @@ import type { FileSystemProvider, LangiumDocument, URI } from 'langium';
8
8
  import { URI as UriCtor, UriUtils } from 'langium';
9
9
 
10
10
  export const DEFAULT_IMPORT_ROOT_ALIAS = '@';
11
- export const RQCONFIG_FILENAME = '.rqconfig.json';
11
+ /** Base marker / application-memory directory name (must match analytical APPLICATION_MEMORY_DIR). */
12
+ export const REQLAN_DIR = '.reqlan';
13
+ /** Config filename under `<base>/.reqlan/`. */
14
+ export const CONFIG_FILENAME = 'config.json';
12
15
 
13
16
  export interface ImportRootMapping {
14
17
  alias: string;
@@ -53,7 +56,7 @@ export interface PathResolveContext {
53
56
  workspaceFolderUri?: URI;
54
57
  fileSystem?: FileSystemProvider;
55
58
  /**
56
- * Preloaded config. `undefined` loads nearest `.rqconfig.json` when fileSystem is set.
59
+ * Preloaded config. `undefined` loads the owning base’s `.reqlan/config.json` when fileSystem is set.
57
60
  * `null` skips loading and uses defaults only.
58
61
  */
59
62
  config?: RqConfig | null;
@@ -117,15 +120,24 @@ export function findWorkspaceFolderUri(
117
120
  return best;
118
121
  }
119
122
 
123
+ /**
124
+ * Walk ancestors from startDir. The first directory that owns `.reqlan/` is the applying base.
125
+ * Load that base’s `.reqlan/config.json` when present; otherwise return undefined (defaults).
126
+ * Does not inherit a parent base’s config.
127
+ */
120
128
  export function loadApplyingRqConfig(
121
129
  startDirUri: URI,
122
130
  fileSystem: FileSystemProvider
123
131
  ): RqConfig | undefined {
124
132
  let dir = startDirUri;
125
133
  for (;;) {
126
- const configUri = UriUtils.joinPath(dir, RQCONFIG_FILENAME);
127
- if (fileSystem.existsSync(configUri) && !fileSystem.statSync(configUri).isDirectory) {
128
- return parseRqConfig(configUri, fileSystem);
134
+ const reqlanDir = UriUtils.joinPath(dir, REQLAN_DIR);
135
+ if (fileSystem.existsSync(reqlanDir) && fileSystem.statSync(reqlanDir).isDirectory) {
136
+ const configUri = UriUtils.joinPath(reqlanDir, CONFIG_FILENAME);
137
+ if (fileSystem.existsSync(configUri) && !fileSystem.statSync(configUri).isDirectory) {
138
+ return parseRqConfig(configUri, dir, fileSystem);
139
+ }
140
+ return undefined;
129
141
  }
130
142
  const parent = UriUtils.dirname(dir);
131
143
  if (UriUtils.equals(parent, dir)) {
@@ -135,7 +147,11 @@ export function loadApplyingRqConfig(
135
147
  }
136
148
  }
137
149
 
138
- function parseRqConfig(configUri: URI, fileSystem: FileSystemProvider): RqConfig | undefined {
150
+ function parseRqConfig(
151
+ configUri: URI,
152
+ baseRootUri: URI,
153
+ fileSystem: FileSystemProvider
154
+ ): RqConfig | undefined {
139
155
  let raw: unknown;
140
156
  try {
141
157
  raw = JSON.parse(fileSystem.readFileSync(configUri));
@@ -146,11 +162,11 @@ function parseRqConfig(configUri: URI, fileSystem: FileSystemProvider): RqConfig
146
162
  return undefined;
147
163
  }
148
164
  const record = raw as Record<string, unknown>;
149
- const importRoots = parseImportRoots(record.importRoots, configUri);
165
+ const importRoots = parseImportRoots(record.importRoots, baseRootUri);
150
166
  if (record.importRoots !== undefined && importRoots === undefined) {
151
167
  return undefined;
152
168
  }
153
- const parsedExport = parseExportConfig(record.export, UriUtils.dirname(configUri));
169
+ const parsedExport = parseExportConfig(record.export, baseRootUri);
154
170
  const config: RqConfig = {
155
171
  importRoots: importRoots ?? defaultRqConfig().importRoots
156
172
  };
@@ -160,7 +176,7 @@ function parseRqConfig(configUri: URI, fileSystem: FileSystemProvider): RqConfig
160
176
  return config;
161
177
  }
162
178
 
163
- function parseImportRootEntry(entry: unknown, configDir: URI): ImportRootMapping | undefined {
179
+ function parseImportRootEntry(entry: unknown, baseRootUri: URI): ImportRootMapping | undefined {
164
180
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
165
181
  return undefined;
166
182
  }
@@ -172,22 +188,21 @@ function parseImportRootEntry(entry: unknown, configDir: URI): ImportRootMapping
172
188
  if (typeof record.root === 'string' && record.root.length > 0) {
173
189
  mapping.rootUri = isAbsoluteUriOrPath(record.root)
174
190
  ? toDirectoryUri(record.root)
175
- : UriUtils.resolvePath(configDir, record.root);
191
+ : UriUtils.resolvePath(baseRootUri, record.root);
176
192
  }
177
193
  return mapping;
178
194
  }
179
195
 
180
- function parseImportRoots(raw: unknown, configUri: URI): ImportRootMapping[] | undefined {
196
+ function parseImportRoots(raw: unknown, baseRootUri: URI): ImportRootMapping[] | undefined {
181
197
  if (raw === undefined) {
182
198
  return undefined;
183
199
  }
184
200
  if (!Array.isArray(raw)) {
185
201
  return undefined;
186
202
  }
187
- const configDir = UriUtils.dirname(configUri);
188
203
  const importRoots: ImportRootMapping[] = [];
189
204
  for (const entry of raw) {
190
- const mapping = parseImportRootEntry(entry, configDir);
205
+ const mapping = parseImportRootEntry(entry, baseRootUri);
191
206
  if (mapping) {
192
207
  importRoots.push(mapping);
193
208
  }
@@ -195,7 +210,7 @@ function parseImportRoots(raw: unknown, configUri: URI): ImportRootMapping[] | u
195
210
  return importRoots.length > 0 ? importRoots : defaultRqConfig().importRoots;
196
211
  }
197
212
 
198
- function parseExportConfig(raw: unknown, configDir: URI): RqExportConfig | undefined {
213
+ function parseExportConfig(raw: unknown, baseRootUri: URI): RqExportConfig | undefined {
199
214
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
200
215
  return undefined;
201
216
  }
@@ -205,7 +220,7 @@ function parseExportConfig(raw: unknown, configDir: URI): RqExportConfig | undef
205
220
  if (typeof record.outputFolder === 'string' && record.outputFolder.trim().length > 0) {
206
221
  config.outputFolder = isAbsoluteUriOrPath(record.outputFolder)
207
222
  ? toDirectoryUri(record.outputFolder).fsPath
208
- : UriUtils.resolvePath(configDir, record.outputFolder).fsPath;
223
+ : UriUtils.resolvePath(baseRootUri, record.outputFolder).fsPath;
209
224
  }
210
225
  if (typeof record.templateId === 'string' && record.templateId.trim().length > 0) {
211
226
  config.templateId = record.templateId.trim();
@@ -0,0 +1,124 @@
1
+ /**
2
+ * LSP rename for reqlan symbols, including `rq:[...]` comment idea tokens in .rq docs.
3
+ * rq:["../../../reqlan rq/extension/refactor_support.rq".refactor_symbol_rename]
4
+ * rq:["../../../reqlan rq/extension/syntax/features-syntax.rq".refactor_support]
5
+ */
6
+ import type { AstNode, LangiumDocument } from 'langium';
7
+ import { AstUtils, CstUtils } from 'langium';
8
+ import { DefaultRenameProvider } from 'langium/lsp';
9
+ import type { Position, RenameParams, WorkspaceEdit } from 'vscode-languageserver';
10
+ import { TextEdit } from 'vscode-languageserver';
11
+ import { isIdea, isIdeaSet, isOneLinerIdea } from './generated/ast.js';
12
+ import { findCommentIdeaRenameMatches } from './reqlan-comment-rename.js';
13
+ import type { ReqlanServices } from './reqlan-module.js';
14
+
15
+ export class ReqlanRenameProvider extends DefaultRenameProvider {
16
+ private readonly documents: ReqlanServices['shared']['workspace']['LangiumDocuments'];
17
+
18
+ constructor(services: ReqlanServices) {
19
+ super(services);
20
+ this.documents = services.shared.workspace.LangiumDocuments;
21
+ }
22
+
23
+ override async rename(
24
+ document: LangiumDocument,
25
+ params: RenameParams
26
+ ): Promise<WorkspaceEdit | undefined> {
27
+ const base = await super.rename(document, params);
28
+ const target = this.resolveRenameTarget(document, params.position);
29
+ const oldName = targetName(target, node => this.nameProvider.getName(node));
30
+ if (!oldName || oldName === params.newName) {
31
+ return base;
32
+ }
33
+
34
+ const declarationDoc = target ? AstUtils.getDocument(target) : document;
35
+ const declarationPath = declarationDoc.uri.path;
36
+ const changes = { ...(base?.changes ?? {}) };
37
+
38
+ for (const doc of this.documents.all) {
39
+ const uri = doc.uri.toString();
40
+ const text = doc.textDocument.getText();
41
+ const matches = findCommentIdeaRenameMatches(text, oldName, { includePathless: true });
42
+ const edits = matches
43
+ .filter(match => commentMatchApplies(
44
+ match.path,
45
+ declarationPath,
46
+ doc.uri.toString() === declarationDoc.uri.toString()
47
+ ))
48
+ .map(match => ({
49
+ range: match.range,
50
+ newText: params.newName
51
+ }))
52
+ .filter(edit => !(changes[uri] ?? []).some(existing => rangesEqual(existing.range, edit.range)));
53
+
54
+ if (edits.length === 0) {
55
+ continue;
56
+ }
57
+ changes[uri] = [
58
+ ...(changes[uri] ?? []),
59
+ ...edits.map(edit => TextEdit.replace(edit.range, edit.newText))
60
+ ];
61
+ }
62
+
63
+ return Object.keys(changes).length > 0 ? { changes } : base;
64
+ }
65
+
66
+ private resolveRenameTarget(document: LangiumDocument, position: Position): AstNode | undefined {
67
+ const rootNode = document.parseResult.value.$cstNode;
68
+ if (!rootNode) {
69
+ return undefined;
70
+ }
71
+ const offset = document.textDocument.offsetAt(position);
72
+ const leafNode = CstUtils.findDeclarationNodeAtOffset(
73
+ rootNode,
74
+ offset,
75
+ this.grammarConfig.nameRegexp
76
+ );
77
+ if (!leafNode) {
78
+ return undefined;
79
+ }
80
+ const declarations = this.references.findDeclarations(leafNode);
81
+ return declarations[0] ?? leafNode.astNode;
82
+ }
83
+ }
84
+
85
+ function targetName(
86
+ target: AstNode | undefined,
87
+ getName: (node: AstNode) => string | undefined
88
+ ): string | undefined {
89
+ if (!target) {
90
+ return undefined;
91
+ }
92
+ if (isIdea(target) || isOneLinerIdea(target) || isIdeaSet(target)) {
93
+ return target.name;
94
+ }
95
+ return getName(target);
96
+ }
97
+
98
+ function commentMatchApplies(
99
+ path: string | undefined,
100
+ declarationPath: string,
101
+ isDeclarationDocument: boolean
102
+ ): boolean {
103
+ if (path === undefined) {
104
+ return isDeclarationDocument;
105
+ }
106
+ const normalizedDecl = declarationPath.replace(/\\/g, '/');
107
+ const normalizedPath = path.replace(/\\/g, '/');
108
+ const declBase = normalizedDecl.slice(normalizedDecl.lastIndexOf('/') + 1);
109
+ const pathBase = normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1);
110
+ return normalizedDecl.endsWith(normalizedPath)
111
+ || normalizedDecl.endsWith(`${normalizedPath.replace(/\.rq$/i, '')}.rq`)
112
+ || declBase === pathBase
113
+ || declBase.replace(/\.rq$/i, '') === pathBase.replace(/\.rq$/i, '');
114
+ }
115
+
116
+ function rangesEqual(
117
+ left: { start: { line: number; character: number }; end: { line: number; character: number } },
118
+ right: { start: { line: number; character: number }; end: { line: number; character: number } }
119
+ ): boolean {
120
+ return left.start.line === right.start.line
121
+ && left.start.character === right.start.character
122
+ && left.end.line === right.end.line
123
+ && left.end.character === right.end.character;
124
+ }
@@ -127,6 +127,8 @@ export class ReqlanScopeProvider extends DefaultScopeProvider {
127
127
  const descriptions = model.elements
128
128
  .filter(element => isIdea(element) || isOneLinerIdea(element))
129
129
  .map(idea => this.descriptions.createDescription(idea, idea.name, document));
130
+ // Imported ideas enter scope under their binding name only (alias when present).
131
+ // See import_tokenisation: aliased base names are not reserved locally.
130
132
  for (const importDecl of model.imports) {
131
133
  if (isFromImport(importDecl)) {
132
134
  for (const specifier of importDecl.specifiers) {
@@ -15,7 +15,6 @@ import {
15
15
  import {
16
16
  importBindings,
17
17
  importPathOf,
18
- importedIdeaNames,
19
18
  isWellFormedFromImport
20
19
  } from './reqlan-import-bindings.js';
21
20
  import { isResolvableImportPath } from './reqlan-imports.js';
@@ -27,6 +26,7 @@ import { unquoteReqlanString } from './reqlan-quoted-strings.js';
27
26
  * Registers validation hooks for the requirement graph AST.
28
27
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_does_not_exist_error]
29
28
  * rq:["../../../reqlan rq/language/imports.rq".import_error_recovery]
29
+ * rq:["../../../reqlan rq/language/imports.rq".import_tokenisation]
30
30
  */
31
31
  export function registerValidationChecks(services: ReqlanServices) {
32
32
  const registry = services.validation.ValidationRegistry;
@@ -41,6 +41,7 @@ export function registerValidationChecks(services: ReqlanServices) {
41
41
  * Custom validations for Reqlan documents.
42
42
  * rq:["../../../reqlan rq/extension/language-support/features-imports.rq".import_does_not_exist_error]
43
43
  * rq:["../../../reqlan rq/language/imports.rq".import_error_recovery]
44
+ * rq:["../../../reqlan rq/language/imports.rq".import_tokenisation]
44
45
  */
45
46
  export class ReqlanValidator {
46
47
 
@@ -101,6 +102,10 @@ export class ReqlanValidator {
101
102
  }
102
103
  }
103
104
 
105
+ /**
106
+ * Duplicate check uses import *bindings* only (alias when present, otherwise the idea name).
107
+ * An aliased import does not reserve the imported base name for local ideas.
108
+ */
104
109
  checkDuplicateIdeaNames(model: Model, accept: ValidationAcceptor): void {
105
110
  const seen = new Map<string, AstNode>();
106
111
  for (const importDecl of model.imports) {
@@ -120,16 +125,6 @@ export class ReqlanValidator {
120
125
  });
121
126
  continue;
122
127
  }
123
- const importedNameConflict = model.imports.some(importDecl =>
124
- importedIdeaNames(importDecl).includes(name)
125
- );
126
- if (importedNameConflict) {
127
- accept('error', `'${name}' is already defined in this file.`, {
128
- node: element,
129
- property: 'name'
130
- });
131
- continue;
132
- }
133
128
  seen.set(name, element);
134
129
  }
135
130
  }
@@ -199,10 +199,12 @@ LocalReference:
199
199
  idea=[IdeaDeclaration:ReferenceName];
200
200
 
201
201
  // ---------------------------------------------------------------------------
202
- // import_from, import_namespace, import_qualified — see imports.rq
202
+ // import_from, import_namespace, import_qualified, import_tokenisation — see imports.rq
203
203
  // InvalidFromImport recovers unquoted/malformed `from` lines so later ideas still parse.
204
204
  // FromImport allows a mistaken `from "path" as alias` shape for a local diagnostic
205
205
  // (valid form still requires `import` + idea specifiers).
206
+ // When a specifier/qualified import has `as alias`, only `alias` binds locally; the
207
+ // imported idea's base name stays free for a local idea declaration.
206
208
  // ---------------------------------------------------------------------------
207
209
 
208
210
  Import:
@@ -214,12 +216,14 @@ FromImport:
214
216
  | 'as' alias=ID
215
217
  )?;
216
218
 
219
+ // Binding name is `alias` when present, otherwise the imported idea name.
217
220
  FromImportSpecifier:
218
221
  idea=[IdeaDeclaration:ID] ('as' alias=ID)?;
219
222
 
220
223
  NamespaceImport:
221
224
  'import' path=STRING ('as' alias=ID)?;
222
225
 
226
+ // Binding name is `alias` when present, otherwise the imported idea name.
223
227
  QualifiedImport:
224
228
  'import' path=STRING '.' ideaset=ID '.' idea=[IdeaDeclaration:ID] ('as' alias=ID)?;
225
229