@telorun/ide-support 0.13.3 → 0.14.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.
- package/dist/cel-chain.d.ts +36 -0
- package/dist/cel-chain.d.ts.map +1 -0
- package/dist/cel-chain.js +77 -0
- package/dist/completions/detect-context.d.ts +5 -5
- package/dist/completions/detect-context.d.ts.map +1 -1
- package/dist/completions/detect-context.js +72 -5
- package/dist/completions/prop-keys.d.ts +1 -1
- package/dist/completions/prop-keys.d.ts.map +1 -1
- package/dist/completions/prop-keys.js +18 -1
- package/dist/definition/resolve-cel-target.d.ts.map +1 -1
- package/dist/definition/resolve-cel-target.js +1 -60
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/rename/build-rename.d.ts +39 -0
- package/dist/rename/build-rename.d.ts.map +1 -0
- package/dist/rename/build-rename.js +407 -0
- package/dist/rename/find-sites.d.ts +49 -0
- package/dist/rename/find-sites.d.ts.map +1 -0
- package/dist/rename/find-sites.js +202 -0
- package/dist/rename/index.d.ts +3 -0
- package/dist/rename/index.d.ts.map +1 -0
- package/dist/rename/index.js +1 -0
- package/dist/rename/types.d.ts +55 -0
- package/dist/rename/types.d.ts.map +1 -0
- package/dist/rename/types.js +1 -0
- package/package.json +2 -2
- package/src/cel-chain.ts +86 -0
- package/src/completions/detect-context.ts +73 -6
- package/src/completions/prop-keys.ts +23 -2
- package/src/definition/resolve-cel-target.ts +1 -68
- package/src/index.ts +1 -0
- package/src/rename/build-rename.ts +513 -0
- package/src/rename/find-sites.ts +215 -0
- package/src/rename/index.ts +9 -0
- package/src/rename/types.ts +51 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CelParseError,
|
|
3
|
+
buildLineOffsets,
|
|
4
|
+
checkName,
|
|
5
|
+
offsetToPosition,
|
|
6
|
+
parseToAst,
|
|
7
|
+
type AstDocument,
|
|
8
|
+
type AstScalar,
|
|
9
|
+
type LoadedFile,
|
|
10
|
+
type LoadedGraph,
|
|
11
|
+
type LoadedModule,
|
|
12
|
+
type Range,
|
|
13
|
+
} from "@telorun/analyzer";
|
|
14
|
+
|
|
15
|
+
import { chainAt } from "../cel-chain.js";
|
|
16
|
+
import { resolveNodeAtPosition, scalarString } from "../completions/resolve-node.js";
|
|
17
|
+
import { moduleDoc, moduleForFile, moduleFiles } from "../definition/manifest-navigation.js";
|
|
18
|
+
import {
|
|
19
|
+
declarationSites,
|
|
20
|
+
resourceDeclarations,
|
|
21
|
+
resourceSites,
|
|
22
|
+
stepDeclarations,
|
|
23
|
+
stepSites,
|
|
24
|
+
type NameSite,
|
|
25
|
+
} from "./find-sites.js";
|
|
26
|
+
import type {
|
|
27
|
+
RenameEdit,
|
|
28
|
+
RenameFileEdits,
|
|
29
|
+
RenamePreparation,
|
|
30
|
+
RenameResult,
|
|
31
|
+
RenameSymbol,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Rename a name and every reference to it.
|
|
36
|
+
*
|
|
37
|
+
* **This is a refactor, not a fix**, and the distinction is the reason it lives
|
|
38
|
+
* here rather than behind a `DiagnosticFix`. A fix is a whole-value replacement
|
|
39
|
+
* for ONE node, verified by the diagnostic that produced it; a rename is only
|
|
40
|
+
* correct when every reference moves with it, which means the operation's unit
|
|
41
|
+
* is the reference graph, not the node. Renaming `metadata.name` alone leaves
|
|
42
|
+
* every `!ref`, `resources.<name>` and `steps.<name>.result` pointing at a name
|
|
43
|
+
* that no longer exists — so a rename offered as a quick fix would break the
|
|
44
|
+
* file it claimed to repair.
|
|
45
|
+
*
|
|
46
|
+
* **Three renameable surfaces, and every other one is an explicit refusal.**
|
|
47
|
+
* What they have in common is that their reference set is *enumerable from this
|
|
48
|
+
* workspace*: a resource instance, a step, and a `variables:` / `secrets:` /
|
|
49
|
+
* `ports:` key are all module-local. A kind name, a module name and an import
|
|
50
|
+
* alias are not supported yet — their references reach schema annotations
|
|
51
|
+
* (`x-telo-ref`, `extends`, `exports.kinds`) as alias-qualified halves of a
|
|
52
|
+
* larger value, which is a materially bigger surface than a bare identifier and
|
|
53
|
+
* wants its own pass.
|
|
54
|
+
*
|
|
55
|
+
* **A name in `exports.resources` is refused outright**, and that is the load-
|
|
56
|
+
* bearing refusal. Such a name is the library's public ABI: a consumer writes
|
|
57
|
+
* `!ref Alias.name` and reads `resources.Alias.name`, in files this workspace
|
|
58
|
+
* may not contain and, for a published consumer, cannot. Renaming it is a
|
|
59
|
+
* breaking change to be *versioned*, not an edit to be applied — and a rename
|
|
60
|
+
* box that silently ships one would be the worst available framing.
|
|
61
|
+
*
|
|
62
|
+
* **Ambiguity is refused rather than guessed.** A name declared twice in reach
|
|
63
|
+
* (a `with:`-scoped resource shadowing a module-level one, two steps in one
|
|
64
|
+
* resource sharing a spelling) has references that resolve to different
|
|
65
|
+
* declarations, and no edit set is right for both.
|
|
66
|
+
*/
|
|
67
|
+
export function prepareRename(
|
|
68
|
+
text: string,
|
|
69
|
+
line: number,
|
|
70
|
+
character: number,
|
|
71
|
+
graph: LoadedGraph,
|
|
72
|
+
currentFilePath: string,
|
|
73
|
+
docs?: AstDocument[],
|
|
74
|
+
): RenamePreparation {
|
|
75
|
+
const astDocs = docs ?? parseToAst(text);
|
|
76
|
+
const resolved = resolveNodeAtPosition(text, astDocs, line, character);
|
|
77
|
+
if (!resolved) return { ok: false, reason: "Nothing renameable here." };
|
|
78
|
+
|
|
79
|
+
const mod = moduleForFile(graph, currentFilePath);
|
|
80
|
+
if (!mod) {
|
|
81
|
+
return { ok: false, reason: "This file is not part of a loaded Telo module." };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lineOffsets = buildLineOffsets(text);
|
|
85
|
+
const toRange = (span: [number, number]): Range => ({
|
|
86
|
+
start: offsetToPosition(span[0], lineOffsets),
|
|
87
|
+
end: offsetToPosition(span[1], lineOffsets),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const symbol = symbolAt(resolved, astDocs, toRange);
|
|
91
|
+
if (!symbol) return { ok: false, reason: "Nothing renameable here." };
|
|
92
|
+
if ("reason" in symbol) return { ok: false, reason: symbol.reason };
|
|
93
|
+
|
|
94
|
+
const refusal = refuse(symbol, mod, currentFilePath, text, astDocs);
|
|
95
|
+
return refusal ? { ok: false, reason: refusal } : { ok: true, symbol };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Prepare, validate the new name, then collect every edit. */
|
|
99
|
+
export function buildRename(
|
|
100
|
+
text: string,
|
|
101
|
+
line: number,
|
|
102
|
+
character: number,
|
|
103
|
+
newName: string,
|
|
104
|
+
graph: LoadedGraph,
|
|
105
|
+
currentFilePath: string,
|
|
106
|
+
docs?: AstDocument[],
|
|
107
|
+
): RenameResult {
|
|
108
|
+
const prepared = prepareRename(text, line, character, graph, currentFilePath, docs);
|
|
109
|
+
if (!prepared.ok) return prepared;
|
|
110
|
+
const { symbol } = prepared;
|
|
111
|
+
|
|
112
|
+
if (newName === symbol.name) return { ok: true, symbol, files: [] };
|
|
113
|
+
|
|
114
|
+
// Every renameable surface is value-level, so the new name is checked against
|
|
115
|
+
// that half of the convention — the same rule `telo check` enforces. Renaming
|
|
116
|
+
// *into* a name the analyzer would reject is a mistake worth catching in the
|
|
117
|
+
// rename box rather than as a squiggle afterwards.
|
|
118
|
+
const violation = checkName(newName, "value", surfaceLabel(symbol));
|
|
119
|
+
if (violation) return { ok: false, reason: violation.message };
|
|
120
|
+
|
|
121
|
+
const mod = moduleForFile(graph, currentFilePath)!;
|
|
122
|
+
const files = filesForRename(symbol, mod, currentFilePath, text, docs);
|
|
123
|
+
|
|
124
|
+
const out: RenameFileEdits[] = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const edits = editsIn(file, symbol, newName);
|
|
127
|
+
if (edits.length > 0) out.push({ uri: file.uri, edits });
|
|
128
|
+
}
|
|
129
|
+
if (out.length === 0) {
|
|
130
|
+
return { ok: false, reason: `Found nothing to rename for '${symbol.name}'.` };
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, symbol, files: out };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// What is under the cursor
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
type SymbolOrRefusal = RenameSymbol | { reason: string };
|
|
140
|
+
|
|
141
|
+
const DECLARATION_BLOCKS = new Set(["variables", "secrets", "ports"]);
|
|
142
|
+
const MODULE_DOC_KINDS = new Set(["Telo.Application", "Telo.Library"]);
|
|
143
|
+
const KIND_DOC_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
|
|
144
|
+
const SELF_PREFIX = "Self.";
|
|
145
|
+
|
|
146
|
+
/** Dispatch on what the cursor sits IN rather than on the field it is under —
|
|
147
|
+
* the posture `buildDefinition` takes, so the two features agree about what a
|
|
148
|
+
* given position means. */
|
|
149
|
+
function symbolAt(
|
|
150
|
+
resolved: ReturnType<typeof resolveNodeAtPosition> & object,
|
|
151
|
+
astDocs: AstDocument[],
|
|
152
|
+
toRange: (span: [number, number]) => Range,
|
|
153
|
+
): SymbolOrRefusal | undefined {
|
|
154
|
+
if (resolved.cel) return celSymbol(resolved.cel, toRange);
|
|
155
|
+
|
|
156
|
+
const path = resolved.path;
|
|
157
|
+
const node = resolved.node;
|
|
158
|
+
|
|
159
|
+
// A key inside `variables:` / `secrets:` / `ports:` on the module doc.
|
|
160
|
+
if (
|
|
161
|
+
resolved.slot === "key" &&
|
|
162
|
+
node?.kind === "scalar" &&
|
|
163
|
+
path.length === 1 &&
|
|
164
|
+
DECLARATION_BLOCKS.has(path[0]) &&
|
|
165
|
+
MODULE_DOC_KINDS.has(resolved.docKind ?? "")
|
|
166
|
+
) {
|
|
167
|
+
const name = scalarString(node);
|
|
168
|
+
if (!name) return undefined;
|
|
169
|
+
return {
|
|
170
|
+
kind: "declaration",
|
|
171
|
+
name,
|
|
172
|
+
range: toRange(node.range),
|
|
173
|
+
block: path[0] as "variables" | "secrets" | "ports",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (resolved.slot !== "value" || node?.kind !== "scalar") return undefined;
|
|
178
|
+
const scalar = node as AstScalar;
|
|
179
|
+
|
|
180
|
+
// A `!ref` target. `<Alias>.<name>` crosses an import boundary, where the
|
|
181
|
+
// declaration is another module's and the edit set is not this workspace's.
|
|
182
|
+
if (scalar.tag === "!ref") {
|
|
183
|
+
const raw = refText(scalar);
|
|
184
|
+
if (!raw) return undefined;
|
|
185
|
+
if (raw.startsWith(SELF_PREFIX)) {
|
|
186
|
+
const name = raw.slice(SELF_PREFIX.length);
|
|
187
|
+
const start = scalar.range[0] + SELF_PREFIX.length;
|
|
188
|
+
return { kind: "resource", name, range: toRange([start, scalar.range[1]]) };
|
|
189
|
+
}
|
|
190
|
+
if (raw.includes(".")) {
|
|
191
|
+
return {
|
|
192
|
+
reason:
|
|
193
|
+
`'${raw}' names an instance exported by an imported module. Rename it where it is ` +
|
|
194
|
+
`declared — and only if it is not part of that module's exports.`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { kind: "resource", name: raw, range: toRange(scalar.range) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const name = scalarString(scalar);
|
|
201
|
+
if (!name) return undefined;
|
|
202
|
+
|
|
203
|
+
// `metadata.name` — a declaration site. Which surface it is depends on the
|
|
204
|
+
// document's kind, and two of the three are type-level.
|
|
205
|
+
if (path.length === 2 && path[0] === "metadata" && path[1] === "name") {
|
|
206
|
+
const docKind = resolved.docKind ?? "";
|
|
207
|
+
if (MODULE_DOC_KINDS.has(docKind)) {
|
|
208
|
+
return {
|
|
209
|
+
reason:
|
|
210
|
+
"Renaming a module is not supported yet — its name is the canonical kind prefix, " +
|
|
211
|
+
"so every consumer's `kind:` and `extends:` values resolve through it.",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (KIND_DOC_KINDS.has(docKind)) {
|
|
215
|
+
return {
|
|
216
|
+
reason:
|
|
217
|
+
"Renaming a kind is not supported yet — its references are alias-qualified halves " +
|
|
218
|
+
"of `kind:`, `extends:`, `x-telo-ref` and `exports.kinds` values.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (docKind === "Telo.Import") {
|
|
222
|
+
return {
|
|
223
|
+
reason:
|
|
224
|
+
"Renaming an import alias is not supported yet — the alias is the prefix of every " +
|
|
225
|
+
"`kind:`, `extends:` and `x-telo-ref` value that resolves through it.",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { kind: "resource", name, range: toRange(scalar.range) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A step's `name:` — the last path segment, with no enclosing `metadata`.
|
|
232
|
+
if (path.length >= 1 && path[path.length - 1] === "name") {
|
|
233
|
+
return { kind: "step", name, range: toRange(scalar.range) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A bare scalar under `exports.resources`, which is where the ABI refusal is
|
|
237
|
+
// most likely to be attempted from.
|
|
238
|
+
if (path.length === 2 && path[0] === "exports" && path[1] === "resources") {
|
|
239
|
+
return { kind: "resource", name, range: toRange(scalar.range) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A CEL chain root that names a renameable scope, with the cursor on its
|
|
246
|
+
* member. Anything deeper is a field of a resolved value, not a declaration. */
|
|
247
|
+
function celSymbol(
|
|
248
|
+
cel: { segment: { ast(): unknown; source: string }; offset: number },
|
|
249
|
+
toRange: (span: [number, number]) => Range,
|
|
250
|
+
): SymbolOrRefusal | undefined {
|
|
251
|
+
let ast;
|
|
252
|
+
try {
|
|
253
|
+
ast = cel.segment.ast() as Parameters<typeof chainAt>[0];
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (!(error instanceof CelParseError)) throw error;
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
const hit = chainAt(ast, cel.offset);
|
|
259
|
+
if (!hit || hit.index !== 1) return undefined;
|
|
260
|
+
const root = hit.parts[0].name;
|
|
261
|
+
const part = hit.parts[1];
|
|
262
|
+
|
|
263
|
+
if (root === "resources") {
|
|
264
|
+
return { kind: "resource", name: part.name, range: toRange(part.range) };
|
|
265
|
+
}
|
|
266
|
+
if (root === "steps") {
|
|
267
|
+
return { kind: "step", name: part.name, range: toRange(part.range) };
|
|
268
|
+
}
|
|
269
|
+
if (DECLARATION_BLOCKS.has(root)) {
|
|
270
|
+
return {
|
|
271
|
+
kind: "declaration",
|
|
272
|
+
name: part.name,
|
|
273
|
+
range: toRange(part.range),
|
|
274
|
+
block: root as "variables" | "secrets" | "ports",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function refText(scalar: AstScalar): string | undefined {
|
|
281
|
+
const value = scalar.value as { source?: unknown } | string | undefined;
|
|
282
|
+
if (typeof value === "string") return value;
|
|
283
|
+
if (value && typeof value === "object" && typeof value.source === "string") return value.source;
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function surfaceLabel(symbol: RenameSymbol): string {
|
|
288
|
+
if (symbol.kind === "resource") return "resource name";
|
|
289
|
+
if (symbol.kind === "step") return "step name";
|
|
290
|
+
return `${symbol.block === "ports" ? "port" : symbol.block === "secrets" ? "secret" : "variable"} name`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Refusals
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
function refuse(
|
|
298
|
+
symbol: RenameSymbol,
|
|
299
|
+
mod: LoadedModule,
|
|
300
|
+
currentFilePath: string,
|
|
301
|
+
text: string,
|
|
302
|
+
astDocs: AstDocument[],
|
|
303
|
+
): string | undefined {
|
|
304
|
+
const doc = moduleDoc(mod) as
|
|
305
|
+
| { kind?: string; exports?: { resources?: unknown } }
|
|
306
|
+
| undefined;
|
|
307
|
+
|
|
308
|
+
if (symbol.kind === "resource") {
|
|
309
|
+
const exported = Array.isArray(doc?.exports?.resources)
|
|
310
|
+
? (doc!.exports!.resources as unknown[]).filter((e): e is string => typeof e === "string")
|
|
311
|
+
: [];
|
|
312
|
+
// An entry is either `<name>` (local) or `<Alias>.<name>` (a re-export,
|
|
313
|
+
// whose declaration is not this module's anyway).
|
|
314
|
+
if (exported.some((e) => e === symbol.name)) {
|
|
315
|
+
return (
|
|
316
|
+
`'${symbol.name}' is listed in this module's 'exports.resources', so it is part of its ` +
|
|
317
|
+
`public surface — consumers reference it as '!ref <Alias>.${symbol.name}' in files this ` +
|
|
318
|
+
`workspace may not contain. Renaming it is a breaking change; version it instead.`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const declarations = countResourceDeclarations(mod, symbol.name, currentFilePath, text, astDocs);
|
|
323
|
+
if (declarations > 1) {
|
|
324
|
+
return (
|
|
325
|
+
`'${symbol.name}' is declared ${declarations} times in this module — a scoped ('with:') ` +
|
|
326
|
+
`declaration shadows a module-level one, so references resolve to different resources ` +
|
|
327
|
+
`depending on where they sit. Disambiguate them first.`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (declarations === 0) {
|
|
331
|
+
return `Could not find where '${symbol.name}' is declared in this module.`;
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (symbol.kind === "step") {
|
|
337
|
+
// Document-scoped, and the cursor is in this file, so the live docs are the
|
|
338
|
+
// only ones that can hold the declaration.
|
|
339
|
+
const target = astDocs.find((d) => stepDeclarations(d, symbol.name).length > 0);
|
|
340
|
+
if (!target) return `Could not find a step named '${symbol.name}' in this document.`;
|
|
341
|
+
const count = stepDeclarations(target, symbol.name).length;
|
|
342
|
+
if (count > 1) {
|
|
343
|
+
return (
|
|
344
|
+
`'${symbol.name}' names ${count} steps in this document, so 'steps.${symbol.name}.result' ` +
|
|
345
|
+
`is ambiguous. Disambiguate them first.`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A Library's declared config is its contract: an importer passes values
|
|
352
|
+
// keyed by these names, so renaming one breaks every consumer exactly as
|
|
353
|
+
// renaming an exported instance does.
|
|
354
|
+
if (doc?.kind === "Telo.Library") {
|
|
355
|
+
return (
|
|
356
|
+
`'${symbol.block}.${symbol.name}' is part of this library's contract — importers pass ` +
|
|
357
|
+
`values keyed by that name. Renaming it is a breaking change; version it instead.`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Declarations of a resource name across the module, counting nested (scoped)
|
|
364
|
+
* ones. The live buffer stands in for the current file's snapshot. */
|
|
365
|
+
function countResourceDeclarations(
|
|
366
|
+
mod: LoadedModule,
|
|
367
|
+
name: string,
|
|
368
|
+
currentFilePath: string,
|
|
369
|
+
text: string,
|
|
370
|
+
astDocs: AstDocument[],
|
|
371
|
+
): number {
|
|
372
|
+
let count = 0;
|
|
373
|
+
for (const file of moduleFiles(mod)) {
|
|
374
|
+
const docs = file.source === currentFilePath ? astDocs : file.astDocuments;
|
|
375
|
+
for (const doc of docs) count += resourceDeclarations(doc, name).length;
|
|
376
|
+
}
|
|
377
|
+
return count;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Edit collection
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/** A file to rewrite, with the text its offsets are measured against. */
|
|
385
|
+
interface RenameFile {
|
|
386
|
+
uri: string;
|
|
387
|
+
text: string;
|
|
388
|
+
docs: AstDocument[];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Which files a rename may touch.
|
|
393
|
+
*
|
|
394
|
+
* A resource or a config declaration is module-scoped, so every file in the
|
|
395
|
+
* module's scope is in range. A **step is document-scoped**: `steps.<name>.result`
|
|
396
|
+
* is readable only inside the resource whose body declares the step, and a
|
|
397
|
+
* resource is one YAML document — so the edit set is that document alone, which
|
|
398
|
+
* is also what makes two same-named steps in one document the ambiguity to
|
|
399
|
+
* refuse rather than a cross-file hazard.
|
|
400
|
+
*
|
|
401
|
+
* The live buffer always stands in for the current file. The graph is a snapshot
|
|
402
|
+
* taken at the last analysis, so applying edits computed against it would write
|
|
403
|
+
* stale offsets into a file the author has since edited.
|
|
404
|
+
*/
|
|
405
|
+
function filesForRename(
|
|
406
|
+
symbol: RenameSymbol,
|
|
407
|
+
mod: LoadedModule,
|
|
408
|
+
currentFilePath: string,
|
|
409
|
+
text: string,
|
|
410
|
+
docs: AstDocument[] | undefined,
|
|
411
|
+
): RenameFile[] {
|
|
412
|
+
const live = docs ?? parseToAst(text);
|
|
413
|
+
const asRenameFile = (file: LoadedFile): RenameFile =>
|
|
414
|
+
file.source === currentFilePath
|
|
415
|
+
? { uri: file.source, text, docs: live }
|
|
416
|
+
: { uri: file.source, text: file.text, docs: file.astDocuments };
|
|
417
|
+
|
|
418
|
+
const all = moduleFiles(mod).map(asRenameFile);
|
|
419
|
+
if (symbol.kind !== "step") return all;
|
|
420
|
+
|
|
421
|
+
// Narrow to the one document declaring the step.
|
|
422
|
+
for (const file of all) {
|
|
423
|
+
const index = file.docs.findIndex((d) => stepDeclarations(d, symbol.name).length > 0);
|
|
424
|
+
if (index >= 0) return [{ ...file, docs: [file.docs[index]] }];
|
|
425
|
+
}
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function editsIn(file: RenameFile, symbol: RenameSymbol, newName: string): RenameEdit[] {
|
|
430
|
+
const lineOffsets = buildLineOffsets(file.text);
|
|
431
|
+
const spans: Array<[number, number]> = [];
|
|
432
|
+
|
|
433
|
+
for (const doc of file.docs) {
|
|
434
|
+
if (symbol.kind === "resource") {
|
|
435
|
+
for (const span of resourceDeclarations(doc, symbol.name)) spans.push(span);
|
|
436
|
+
for (const site of resourceSites(doc, symbol.name)) spans.push(site.range);
|
|
437
|
+
for (const span of exportEntrySpans(doc, symbol.name)) spans.push(span);
|
|
438
|
+
} else if (symbol.kind === "step") {
|
|
439
|
+
for (const span of stepDeclarations(doc, symbol.name)) spans.push(span);
|
|
440
|
+
for (const site of stepSites(doc, symbol.name)) spans.push(site.range);
|
|
441
|
+
} else {
|
|
442
|
+
for (const span of declarationKeySpans(doc, symbol.block!, symbol.name)) spans.push(span);
|
|
443
|
+
for (const site of declarationSites(doc, symbol.block!, symbol.name)) spans.push(site.range);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Sorted and de-duplicated: a host applies a set of edits, and two edits over
|
|
448
|
+
// one span — which a name reachable by two walks would produce — is an
|
|
449
|
+
// overlapping-edit error in every LSP client.
|
|
450
|
+
return dedupe(spans).map((span) => ({
|
|
451
|
+
range: {
|
|
452
|
+
start: offsetToPosition(span[0], lineOffsets),
|
|
453
|
+
end: offsetToPosition(span[1], lineOffsets),
|
|
454
|
+
},
|
|
455
|
+
newText: newName,
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function dedupe(spans: Array<[number, number]>): Array<[number, number]> {
|
|
460
|
+
const seen = new Set<string>();
|
|
461
|
+
const out: Array<[number, number]> = [];
|
|
462
|
+
for (const span of spans.sort((a, b) => a[0] - b[0] || a[1] - b[1])) {
|
|
463
|
+
const key = `${span[0]}:${span[1]}`;
|
|
464
|
+
if (seen.has(key)) continue;
|
|
465
|
+
seen.add(key);
|
|
466
|
+
out.push(span);
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Spans of `exports.resources` entries naming the resource. Collected even
|
|
472
|
+
* though an exported name is refused, because the refusal is decided from the
|
|
473
|
+
* module doc's JSON while these come from the AST: a name reached here that the
|
|
474
|
+
* refusal did not catch (a re-export spelled `Self.<name>`) still has to move
|
|
475
|
+
* with its declaration rather than being left dangling. */
|
|
476
|
+
function exportEntrySpans(doc: AstDocument, name: string): Array<[number, number]> {
|
|
477
|
+
const out: Array<[number, number]> = [];
|
|
478
|
+
if (doc.root?.kind !== "map") return out;
|
|
479
|
+
for (const pair of doc.root.entries) {
|
|
480
|
+
if (scalarString(pair.key) !== "exports" || pair.value?.kind !== "map") continue;
|
|
481
|
+
for (const inner of pair.value.entries) {
|
|
482
|
+
if (scalarString(inner.key) !== "resources" || inner.value?.kind !== "seq") continue;
|
|
483
|
+
for (const item of inner.value.items) {
|
|
484
|
+
if (item.kind !== "scalar") continue;
|
|
485
|
+
const value = scalarString(item);
|
|
486
|
+
if (value === name) out.push(item.range);
|
|
487
|
+
else if (value === `${SELF_PREFIX}${name}`) {
|
|
488
|
+
out.push([item.range[0] + SELF_PREFIX.length, item.range[1]]);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The `variables:` / `secrets:` / `ports:` key itself. */
|
|
497
|
+
function declarationKeySpans(
|
|
498
|
+
doc: AstDocument,
|
|
499
|
+
block: string,
|
|
500
|
+
name: string,
|
|
501
|
+
): Array<[number, number]> {
|
|
502
|
+
const out: Array<[number, number]> = [];
|
|
503
|
+
if (doc.root?.kind !== "map") return out;
|
|
504
|
+
for (const pair of doc.root.entries) {
|
|
505
|
+
if (scalarString(pair.key) !== block || pair.value?.kind !== "map") continue;
|
|
506
|
+
for (const inner of pair.value.entries) {
|
|
507
|
+
if (inner.key.kind === "scalar" && scalarString(inner.key) === name) {
|
|
508
|
+
out.push(inner.key.range);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|