@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.
- package/README.md +26 -2
- package/out/file-path-rewrite.d.ts +8 -1
- package/out/file-path-rewrite.js +63 -12
- package/out/file-path-rewrite.js.map +1 -1
- package/out/index.d.ts +3 -0
- package/out/index.js +3 -0
- package/out/index.js.map +1 -1
- package/out/reqlan-code-action-provider.d.ts +13 -0
- package/out/reqlan-code-action-provider.js +59 -1
- package/out/reqlan-code-action-provider.js.map +1 -1
- package/out/reqlan-comment-rename.d.ts +28 -0
- package/out/reqlan-comment-rename.js +113 -0
- package/out/reqlan-comment-rename.js.map +1 -0
- package/out/reqlan-idea-refactor.d.ts +23 -0
- package/out/reqlan-idea-refactor.js +158 -0
- package/out/reqlan-idea-refactor.js.map +1 -0
- package/out/reqlan-import-bindings.d.ts +9 -1
- package/out/reqlan-import-bindings.js +9 -9
- package/out/reqlan-import-bindings.js.map +1 -1
- package/out/reqlan-module.js +3 -1
- package/out/reqlan-module.js.map +1 -1
- package/out/reqlan-path-references.js.map +1 -1
- package/out/reqlan-path-resolve.d.ts +10 -2
- package/out/reqlan-path-resolve.js +25 -14
- package/out/reqlan-path-resolve.js.map +1 -1
- package/out/reqlan-rename-provider.d.ts +15 -0
- package/out/reqlan-rename-provider.js +84 -0
- package/out/reqlan-rename-provider.js.map +1 -0
- package/out/reqlan-scope.js +2 -0
- package/out/reqlan-scope.js.map +1 -1
- package/out/reqlan-validator.d.ts +6 -0
- package/out/reqlan-validator.js +7 -9
- package/out/reqlan-validator.js.map +1 -1
- package/package.json +1 -1
- package/src/file-path-rewrite.ts +89 -12
- package/src/index.ts +10 -0
- package/src/reqlan-code-action-provider.ts +73 -1
- package/src/reqlan-comment-rename.ts +151 -0
- package/src/reqlan-idea-refactor.ts +222 -0
- package/src/reqlan-import-bindings.ts +9 -10
- package/src/reqlan-module.ts +3 -1
- package/src/reqlan-path-references.ts +2 -0
- package/src/reqlan-path-resolve.ts +30 -15
- package/src/reqlan-rename-provider.ts +124 -0
- package/src/reqlan-scope.ts +2 -0
- package/src/reqlan-validator.ts +6 -11
- 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) {
|
package/src/reqlan-module.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
127
|
-
if (fileSystem.existsSync(
|
|
128
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
191
|
+
: UriUtils.resolvePath(baseRootUri, record.root);
|
|
176
192
|
}
|
|
177
193
|
return mapping;
|
|
178
194
|
}
|
|
179
195
|
|
|
180
|
-
function parseImportRoots(raw: unknown,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
+
}
|
package/src/reqlan-scope.ts
CHANGED
|
@@ -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) {
|
package/src/reqlan-validator.ts
CHANGED
|
@@ -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
|
}
|
package/src/reqlan.langium
CHANGED
|
@@ -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
|
|