diff --git a/loader/src/resolver/server/findReferencedNodeServer.ts b/loader/src/resolver/server/findReferencedNodeServer.ts index ecfc88f6..7b777f3c 100644 --- a/loader/src/resolver/server/findReferencedNodeServer.ts +++ b/loader/src/resolver/server/findReferencedNodeServer.ts @@ -10,6 +10,7 @@ import * as _Nodes from "@flyde/nodes/dist/all"; import { join } from "path"; import { existsSync, readFileSync } from "fs"; import { resolveImportablePaths } from "./resolveImportablePaths"; +import { recoverMovedFileSource } from "./recoverMovedFileSource"; import { deserializeFlowByPath } from "../../serdes"; import { ReferencedNodeFinder } from "./../ReferencedNodeFinder"; import { resolveCodeNodeDependencies } from "./serverUtils"; @@ -135,7 +136,12 @@ export function createServerReferencedNodeFinder( return nodeWrapper; } case "file": { - const fullFilePath = join(fullFlowPath, "..", instance.source.data); + const directPath = join(fullFlowPath, "..", instance.source.data); + // Issue #112: stored relative paths break after moves. Explorer renames + // are rewritten via onWillRenameFiles; this recovers external/fs moves. + const fullFilePath = existsSync(directPath) + ? directPath + : recoverMovedFileSource(fullFlowPath, instance.source.data) ?? directPath; // Check if the file is a .flyde file (visual flow) if (fullFilePath.endsWith('.flyde')) { diff --git a/loader/src/resolver/server/recoverMovedFileSource.spec.ts b/loader/src/resolver/server/recoverMovedFileSource.spec.ts new file mode 100644 index 00000000..526bd941 --- /dev/null +++ b/loader/src/resolver/server/recoverMovedFileSource.spec.ts @@ -0,0 +1,77 @@ +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { assert } from "chai"; +import { recoverMovedFileSource } from "./recoverMovedFileSource"; + +describe("recoverMovedFileSource", () => { + const base = join(__dirname, "../../../fixture/recover-moved-file-source-tmp"); + + const B_FLOW = [ + "node:", + " id: AddOne", + " inputs:", + " n:", + " mode: required", + " type: number", + " outputs:", + " r:", + " type: number", + " instances: []", + " connections: []", + ].join("\n"); + + beforeEach(() => { + rmSync(base, { recursive: true, force: true }); + mkdirSync(join(base, "proj/flows"), { recursive: true }); + mkdirSync(join(base, "proj/parts"), { recursive: true }); + mkdirSync(join(base, "proj/newparts"), { recursive: true }); + writeFileSync(join(base, "proj/package.json"), "{}"); + writeFileSync(join(base, "proj/flows/main.flyde"), "node:\n id: Main\n"); + writeFileSync(join(base, "proj/parts/b.flyde"), B_FLOW); + writeFileSync(join(base, "proj/newparts/b.flyde"), B_FLOW); + }); + + afterEach(() => { + rmSync(base, { recursive: true, force: true }); + }); + + const flowPath = () => join(base, "proj/flows/main.flyde"); + + it("returns the stored path when it still resolves", () => { + const flowDir = join(base, "proj/flows"); + const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde"); + assert.equal(result, join(flowDir, "../parts/b.flyde")); + }); + + it("recovers after the imported file was moved to another folder", () => { + rmSync(join(base, "proj/parts"), { recursive: true, force: true }); + const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde"); + assert.isNotNull(result); + assert.equal(result, join(base, "proj/newparts/b.flyde")); + }); + + it("still returns a candidate when several share the basename", () => { + rmSync(join(base, "proj/parts"), { recursive: true, force: true }); + mkdirSync(join(base, "proj/parts/deep"), { recursive: true }); + writeFileSync(join(base, "proj/parts/deep/b.flyde"), B_FLOW); + const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde"); + assert.isNotNull(result); + assert.match(String(result), /b\.flyde$/); + }); + + it("returns null when no plausible candidate exists", () => { + rmSync(join(base, "proj/parts"), { recursive: true, force: true }); + rmSync(join(base, "proj/newparts"), { recursive: true, force: true }); + const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde"); + assert.isNull(result); + }); + + it("never descends into node_modules or .git", () => { + rmSync(join(base, "proj/parts"), { recursive: true, force: true }); + rmSync(join(base, "proj/newparts"), { recursive: true, force: true }); + mkdirSync(join(base, "proj/node_modules/somepkg"), { recursive: true }); + writeFileSync(join(base, "proj/node_modules/somepkg/b.flyde"), B_FLOW); + const result = recoverMovedFileSource(flowPath(), "../parts/b.flyde"); + assert.isNull(result); + }); +}); diff --git a/loader/src/resolver/server/recoverMovedFileSource.ts b/loader/src/resolver/server/recoverMovedFileSource.ts new file mode 100644 index 00000000..d28ed8e2 --- /dev/null +++ b/loader/src/resolver/server/recoverMovedFileSource.ts @@ -0,0 +1,124 @@ +import { existsSync, readdirSync } from "fs"; +import { basename, dirname, resolve } from "path"; + +/** + * Fallback for file-sourced imports whose stored relative path no longer + * resolves (e.g. the imported file was moved outside VS Code). Primary fix for + * Explorer renames lives in `getReferenceEditsForRename` + onWillRenameFiles. + * + * Strategy: + * 1. If the stored path still exists, return it. + * 2. Otherwise walk the nearest project root for matching basenames. + * 3. Prefer the candidate whose path suffix best matches the stored path. + */ + +const SKIP_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "build", + "out", + ".flyde", + "coverage", +]); + +const MAX_DEPTH = 8; +const MAX_HITS = 50; + +function findProjectRoot(startDir: string): string { + let dir = resolve(startDir); + for (let i = 0; i < MAX_DEPTH; i++) { + if (existsSync(resolve(dir, "package.json")) || existsSync(resolve(dir, ".git"))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return resolve(startDir); +} + +function walk( + dir: string, + targetBase: string, + hits: string[], + depth: number +): void { + if (hits.length >= MAX_HITS || depth > MAX_DEPTH) { + return; + } + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (hits.length >= MAX_HITS) { + return; + } + if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) { + continue; + } + const full = resolve(dir, entry.name); + if (entry.isDirectory()) { + walk(full, targetBase, hits, depth + 1); + } else if (entry.isFile() && entry.name === targetBase) { + hits.push(full); + } + } +} + +function suffixScore(candidatePath: string, storedParts: string[]): number { + const candidateParts = candidatePath.replace(/\\/g, "/").split("/"); + let score = 0; + for (let i = 1; i <= storedParts.length; i++) { + const storedSeg = storedParts[storedParts.length - i]; + const candSeg = candidateParts[candidateParts.length - i]; + if ( + storedSeg === undefined || + candSeg === undefined || + storedSeg !== candSeg + ) { + break; + } + score += i; + } + return score; +} + +/** + * Returns an absolute path for a file-sourced import, or null if nothing + * plausible is found after a move outside the rewrite path. + */ +export function recoverMovedFileSource( + fullFlowPath: string, + storedPath: string +): string | null { + const direct = resolve(dirname(fullFlowPath), storedPath); + if (existsSync(direct)) { + return direct; + } + + const normalizedStored = storedPath.replace(/\\/g, "/"); + const root = findProjectRoot(dirname(fullFlowPath)); + const hits: string[] = []; + walk(root, basename(normalizedStored), hits, 0); + if (hits.length === 0) { + return null; + } + + const storedParts = normalizedStored.split("/").filter(Boolean); + let best: string | null = null; + let bestScore = -1; + for (const hit of hits) { + const score = suffixScore(hit, storedParts); + if (score > bestScore) { + bestScore = score; + best = hit; + } + } + return best; +} diff --git a/loader/src/serdes/index.ts b/loader/src/serdes/index.ts index 5e1098cb..7dfa6c8e 100644 --- a/loader/src/serdes/index.ts +++ b/loader/src/serdes/index.ts @@ -1,3 +1,4 @@ export * from "./serialize"; export * from "./deserialize"; export * from "./migrations"; +export * from "./rename-references"; diff --git a/loader/src/serdes/rename-references.spec.ts b/loader/src/serdes/rename-references.spec.ts new file mode 100644 index 00000000..78580b02 --- /dev/null +++ b/loader/src/serdes/rename-references.spec.ts @@ -0,0 +1,315 @@ +import { strict as assert } from "assert"; +import * as path from "path"; +import { parse, stringify } from "yaml"; +import { FileRename, getReferenceEditsForRename } from "./rename-references"; + +const flowYaml = (data: string, type = "file") => + stringify({ + node: { instances: [{ type: "visual", source: { type, data } }] }, + }); + +function applyEdits( + contents: string, + file: string, + renames: FileRename[], + paths: typeof path.posix = path.posix +): string { + const edits = getReferenceEditsForRename(contents, file, renames, paths); + return edits + .sort((a, b) => b.start - a.start) + .reduce( + (text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), + contents + ); +} + +const readSource = (text: string) => parse(text).node.instances[0].source.data; +const rename = (oldPath: string, newPath: string): FileRename[] => [ + { oldPath, newPath }, +]; + +describe("getReferenceEditsForRename", () => { + it("rewrites when the imported producer file moves", () => { + assert.equal( + readSource( + applyEdits( + flowYaml("./Producer.flyde"), + "/p/Consumer.flyde", + rename("/p/Producer.flyde", "/p/shared/Producer.flyde") + ) + ), + "./shared/Producer.flyde" + ); + }); + + it("rebases relative imports when only the consumer moves", () => { + assert.equal( + readSource( + applyEdits( + flowYaml("./Producer.flyde"), + "/p/Consumer.flyde", + rename("/p/Consumer.flyde", "/p/sub/Consumer.flyde") + ) + ), + "../Producer.flyde" + ); + }); + + it("maps descendants when a folder is renamed", () => { + assert.equal( + readSource( + applyEdits( + flowYaml("./components/Producer.flyde"), + "/p/Consumer.flyde", + rename("/p/components", "/p/shared") + ) + ), + "./shared/Producer.flyde" + ); + }); + + it("rebases consumers living inside a moved folder", () => { + assert.equal( + readSource( + applyEdits( + flowYaml("../Producer.flyde"), + "/p/flows/Consumer.flyde", + rename("/p/flows", "/p/deep/flows") + ) + ), + "../../Producer.flyde" + ); + }); + + it("leaves bytes unchanged when producer and consumer move together", () => { + const original = flowYaml("./Producer.flyde"); + assert.equal( + applyEdits(original, "/p/flows/Consumer.flyde", rename("/p/flows", "/p/shared")), + original + ); + }); + + it("handles independent producer and consumer moves in one batch", () => { + assert.equal( + readSource( + applyEdits(flowYaml("./Producer.flyde"), "/p/Consumer.flyde", [ + ...rename("/p/Consumer.flyde", "/p/flows/Consumer.flyde"), + ...rename("/p/Producer.flyde", "/p/shared/Producer.flyde"), + ]) + ), + "../shared/Producer.flyde" + ); + }); + + it("does not treat directory-name prefixes as descendants", () => { + const original = flowYaml("./components-old/Producer.flyde"); + assert.equal( + applyEdits(original, "/p/Consumer.flyde", rename("/p/components", "/p/shared")), + original + ); + }); + + it("applies simultaneous renames without chaining targets", () => { + assert.equal( + readSource( + applyEdits(flowYaml("./A.flyde"), "/p/Consumer.flyde", [ + ...rename("/p/A.flyde", "/p/B.flyde"), + ...rename("/p/B.flyde", "/p/C.flyde"), + ]) + ), + "./B.flyde" + ); + }); + + it("prefers the most specific mapping when paths overlap", () => { + assert.equal( + readSource( + applyEdits(flowYaml("./components/Producer.flyde"), "/p/Consumer.flyde", [ + ...rename("/p/components", "/p/shared"), + ...rename("/p/components/Producer.flyde", "/p/Special.flyde"), + ]) + ), + "./Special.flyde" + ); + }); + + it("leaves package and custom sources untouched", () => { + for (const type of ["package", "custom"]) { + const original = flowYaml("./Producer.flyde", type); + assert.equal( + applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Producer.flyde", "/p/New.flyde") + ), + original + ); + } + }); + + it("updates nested inline visual file sources", () => { + const original = stringify({ + node: { + instances: [ + { + source: { + type: "inline", + data: { + instances: [ + { + type: "code", + source: { type: "file", data: "./Producer.flyde.ts" }, + }, + ], + }, + }, + }, + ], + }, + }); + const result = parse( + applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Producer.flyde.ts", "/p/New.flyde.ts") + ) + ); + assert.equal( + result.node.instances[0].source.data.instances[0].source.data, + "./New.flyde.ts" + ); + }); + + it("preserves comments, CRLF, formatting, and unrelated values", () => { + const original = + "# keep\r\nnode:\r\n instances:\r\n - source: {type: file, data: './Producer.flyde'} # note\r\nunknown: 001\r\n"; + const result = applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Producer.flyde", "/p/New.flyde") + ); + assert.equal(result, original.replace("'./Producer.flyde'", '"./New.flyde"')); + }); + + it("rewrites a scalar alias locally without mutating its anchor", () => { + const original = + "other: &path ./Producer.flyde\nnode:\n instances:\n - source: {type: file, data: *path}\n"; + const result = applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Producer.flyde", "/p/New.flyde") + ); + assert.equal(parse(result).other, "./Producer.flyde"); + assert.equal(readSource(result), "./New.flyde"); + }); + + it("keeps absolute references when only the consumer moves", () => { + const original = flowYaml("/shared/Producer.flyde"); + assert.equal( + applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Consumer.flyde", "/p/sub/Consumer.flyde") + ), + original + ); + }); + + it("updates absolute references when the producer moves", () => { + assert.equal( + readSource( + applyEdits( + flowYaml("/shared/Producer.flyde"), + "/p/Consumer.flyde", + rename("/shared", "/new") + ) + ), + "/new/Producer.flyde" + ); + }); + + it("handles Windows paths and emits portable separators", () => { + assert.equal( + readSource( + applyEdits( + flowYaml(".\\Producer.flyde"), + "C:\\p\\Consumer.flyde", + rename("C:\\p\\Consumer.flyde", "C:\\p\\sub\\Consumer.flyde"), + path.win32 + ) + ), + "../Producer.flyde" + ); + }); + + it("uses an absolute path on Windows cross-drive moves", () => { + assert.equal( + readSource( + applyEdits( + flowYaml("./Producer.flyde"), + "C:\\p\\Consumer.flyde", + rename("C:\\p\\Producer.flyde", "D:\\shared\\Producer.flyde"), + path.win32 + ) + ), + "D:/shared/Producer.flyde" + ); + }); + + it("rejects invalid YAML instead of returning partial edits", () => { + assert.throws( + () => + getReferenceEditsForRename( + "node: [", + "/p/A.flyde", + rename("/p/A.flyde", "/p/B.flyde") + ), + /Invalid flow YAML/ + ); + }); + + it("preserves block-scalar comments and the following property", () => { + for (const style of ["|-", ">-"]) { + for (const eol of ["\n", "\r\n"]) { + const original = + `node:\n instances:\n - source:\n type: file\n data: ${style} # keep\n ./Producer.flyde\nunknown: 001\n`.replace( + /\n/g, + eol + ); + const result = applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Producer.flyde", "/p/New.flyde") + ); + assert.equal(readSource(result), "./New.flyde"); + assert.ok(result.includes(`# keep${eol}unknown: 001${eol}`)); + } + } + }); + + it("refuses to rewrite anchors that could affect unrelated data", () => { + const original = + "node:\n instances:\n - source: {type: file, data: &path ./Producer.flyde}\nother: *path\n"; + assert.throws( + () => + applyEdits( + original, + "/p/Consumer.flyde", + rename("/p/Producer.flyde", "/p/New.flyde") + ), + /anchored file reference/ + ); + }); + + it("no-ops for unrelated renames and empty batches", () => { + const original = flowYaml("./Producer.flyde"); + assert.equal( + applyEdits(original, "/p/Consumer.flyde", rename("/other", "/new")), + original + ); + assert.deepEqual( + getReferenceEditsForRename(original, "/p/Consumer.flyde", []), + [] + ); + }); +}); diff --git a/loader/src/serdes/rename-references.ts b/loader/src/serdes/rename-references.ts new file mode 100644 index 00000000..df2c3307 --- /dev/null +++ b/loader/src/serdes/rename-references.ts @@ -0,0 +1,166 @@ +import * as path from "path"; +import { isAlias, isMap, isScalar, isSeq, parseDocument } from "yaml"; + +export interface FileRename { + oldPath: string; + newPath: string; +} + +export interface ReferenceTextEdit { + start: number; + end: number; + text: string; +} + +type PathApi = typeof path.posix; + +function normalizeMappings( + renames: readonly FileRename[], + paths: PathApi +): Array<{ oldPath: string; newPath: string }> { + return renames + .map(({ oldPath, newPath }) => ({ + oldPath: paths.resolve(oldPath), + newPath: paths.resolve(newPath), + })) + // Longest (most specific) old path wins for overlapping folder/file renames. + .sort((a, b) => b.oldPath.length - a.oldPath.length); +} + +function applyRenameMap( + filePath: string, + mappings: Array<{ oldPath: string; newPath: string }>, + paths: PathApi +): string { + for (const rename of mappings) { + const suffix = paths.relative(rename.oldPath, filePath); + const isDescendant = + suffix !== ".." && + !suffix.startsWith(`..${paths.sep}`) && + !paths.isAbsolute(suffix); + if (isDescendant) { + return paths.resolve(rename.newPath, suffix); + } + } + return filePath; +} + +function toPortableRelative(fromDir: string, target: string, paths: PathApi): string { + let next = paths.relative(fromDir, target); + if ( + !paths.isAbsolute(next) && + next !== ".." && + !next.startsWith(`..${paths.sep}`) + ) { + next = `.${paths.sep}${next}`; + } + return next.split(paths.sep).join("/"); +} + +/** + * Compute scalar-only text edits that keep `source: { type: file }` paths + * valid after Explorer renames. Operates on the pre-rename document bytes so + * `onWillRenameFiles` WorkspaceEdits apply at the old URI. + */ +export function getReferenceEditsForRename( + contents: string, + flowPath: string, + renames: readonly FileRename[], + paths: PathApi = path +): ReferenceTextEdit[] { + if (renames.length === 0) { + return []; + } + + const document = parseDocument(contents); + if (document.errors.length) { + throw new Error("Invalid flow YAML"); + } + + const mappings = normalizeMappings(renames, paths); + const oldDirectory = paths.dirname(paths.resolve(flowPath)); + const newDirectory = paths.dirname( + applyRenameMap(paths.resolve(flowPath), mappings, paths) + ); + const edits: ReferenceTextEdit[] = []; + + const rewriteFileSource = (source: unknown) => { + if (!isMap(source)) { + return; + } + + if (source.get("type") === "inline") { + visitVisual(source.get("data", true)); + return; + } + + if (source.get("type") !== "file") { + return; + } + + const node = source.get("data", true); + if (!isScalar(node) && !isAlias(node)) { + return; + } + + const scalar = isAlias(node) ? node.resolve(document) : node; + if (!isScalar(scalar) || typeof scalar.value !== "string" || !node.range) { + return; + } + + const original = scalar.value; + const target = applyRenameMap( + paths.resolve(oldDirectory, original), + mappings, + paths + ); + + // Relative path still resolves after both ends move together. + if (paths.resolve(newDirectory, original) === target) { + return; + } + + // Rewriting a shared anchor would mutate unrelated YAML nodes. + if (isScalar(node) && node.anchor) { + throw new Error("Cannot safely rewrite an anchored file reference"); + } + + const next = paths.isAbsolute(original) + ? target.split(paths.sep).join("/") + : toPortableRelative(newDirectory, target, paths); + + let text = JSON.stringify(next); + if ( + isScalar(node) && + (node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED") + ) { + if (node.comment) { + text += ` #${node.comment}`; + } + const token = contents.slice(node.range[0], node.range[1]); + if (token.endsWith("\n")) { + text += token.endsWith("\r\n") ? "\r\n" : "\n"; + } + } + + edits.push({ start: node.range[0], end: node.range[1], text }); + }; + + const visitVisual = (node: unknown) => { + if (!isMap(node)) { + return; + } + const instances = node.get("instances", true); + if (!isSeq(instances)) { + return; + } + for (const instance of instances.items) { + if (isMap(instance)) { + rewriteFileSource(instance.get("source", true)); + } + } + }; + + visitVisual(document.get("node", true)); + return edits; +} diff --git a/vscode/src/flydeEditor.ts b/vscode/src/flydeEditor.ts index c0ab2496..81bc81b6 100644 --- a/vscode/src/flydeEditor.ts +++ b/vscode/src/flydeEditor.ts @@ -12,6 +12,7 @@ import { resolveEditorNode, } from "@flyde/loader"; import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server"; +import { registerRenameReferences } from "./renameReferences"; import { FlydeFlow, @@ -105,7 +106,9 @@ export class FlydeEditorEditorProvider provider ); - return providerRegistration; + const renameRegistration = registerRenameReferences(params.mainOutputChannel); + + return vscode.Disposable.from(providerRegistration, renameRegistration); } private static readonly viewType = "flydeEditor"; diff --git a/vscode/src/renameReferences.ts b/vscode/src/renameReferences.ts new file mode 100644 index 00000000..f0093f1a --- /dev/null +++ b/vscode/src/renameReferences.ts @@ -0,0 +1,73 @@ +import * as vscode from "vscode"; +import { getReferenceEditsForRename } from "@flyde/loader/dist/server"; + +export function registerRenameReferences( + output: vscode.OutputChannel +): vscode.Disposable { + return vscode.workspace.onWillRenameFiles((event) => { + // Text edits apply at the old URIs before VS Code renames the files. + event.waitUntil(prepareRenameReferences(event.files, output)); + }); +} + +export async function prepareRenameReferences( + files: vscode.FileWillRenameEvent["files"], + output: vscode.OutputChannel +): Promise { + const edit = new vscode.WorkspaceEdit(); + const flows = await vscode.workspace.findFiles( + "**/*.flyde", + "**/{node_modules,.git}/**" + ); + const versions = new Map(); + + for (const uri of flows) { + const renames = files + .filter( + ({ oldUri, newUri }) => + oldUri.scheme === uri.scheme && + newUri.scheme === uri.scheme && + oldUri.authority === uri.authority && + newUri.authority === uri.authority + ) + .map(({ oldUri, newUri }) => ({ + oldPath: oldUri.fsPath, + newPath: newUri.fsPath, + })); + if (renames.length === 0) { + continue; + } + + try { + const document = await vscode.workspace.openTextDocument(uri); + const changes = getReferenceEditsForRename( + document.getText(), + uri.fsPath, + renames + ); + if (changes.length === 0) { + continue; + } + versions.set(document, document.version); + for (const change of changes) { + edit.replace( + uri, + new vscode.Range( + document.positionAt(change.start), + document.positionAt(change.end) + ), + change.text + ); + } + } catch { + output.appendLine(`Could not update moved references in ${uri.fsPath}`); + } + } + + for (const [document, version] of versions) { + if (document.version !== version) { + throw new Error("A flow changed while preparing rename edits"); + } + } + return edit; +} diff --git a/vscode/src/test/suite/renameReferences.test.ts b/vscode/src/test/suite/renameReferences.test.ts new file mode 100644 index 00000000..e453614a --- /dev/null +++ b/vscode/src/test/suite/renameReferences.test.ts @@ -0,0 +1,116 @@ +import { strict as assert } from "assert"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import { + prepareRenameReferences, + registerRenameReferences, +} from "../../renameReferences"; + +suite("File rename references", () => { + const uri = vscode.Uri.file("/p/Consumer.flyde"); + const contents = + "node:\n instances:\n - source: {type: file, data: ./Producer.flyde}\n"; + const renames = [ + { + oldUri: vscode.Uri.file("/p/Producer.flyde"), + newUri: vscode.Uri.file("/p/New.flyde"), + }, + ]; + const output = { appendLine: sinon.spy() } as unknown as vscode.OutputChannel; + const document = (text = contents) => + ({ + uri, + version: 1, + isDirty: true, + getText: () => text, + positionAt: (offset: number) => { + const lines = text.slice(0, offset).split("\n"); + return new vscode.Position( + lines.length - 1, + lines[lines.length - 1]!.length + ); + }, + } as vscode.TextDocument); + + teardown(() => sinon.restore()); + + test("uses unsaved document contents and pre-rename URIs", async () => { + sinon.stub(vscode.workspace, "findFiles").resolves([uri]); + const open = sinon + .stub(vscode.workspace, "openTextDocument") + .resolves(document()); + const edit = await prepareRenameReferences(renames, output); + assert.equal(open.callCount, 1); + assert.equal(edit.entries()[0]![0].toString(), uri.toString()); + assert.equal(edit.get(uri)[0]!.newText, '"./New.flyde"'); + assert.equal(edit.get(uri)[0]!.range.start.line, 2); + assert.equal(edit.get(uri)[0]!.range.start.character, 33); + }); + + test("rebases a moved consumer before its URI changes", async () => { + sinon.stub(vscode.workspace, "findFiles").resolves([uri]); + sinon.stub(vscode.workspace, "openTextDocument").resolves(document()); + const edit = await prepareRenameReferences( + [{ oldUri: uri, newUri: vscode.Uri.file("/p/sub/Consumer.flyde") }], + output + ); + assert.equal(edit.get(uri)[0]!.newText, '"../Producer.flyde"'); + }); + + test("skips malformed documents without rewriting them", async () => { + sinon.stub(vscode.workspace, "findFiles").resolves([uri]); + sinon + .stub(vscode.workspace, "openTextDocument") + .resolves(document("node: [")); + assert.equal((await prepareRenameReferences(renames, output)).size, 0); + }); + + test("does not apply local renames to a different filesystem authority", async () => { + sinon + .stub(vscode.workspace, "findFiles") + .resolves([vscode.Uri.parse("vscode-remote://other/p/Consumer.flyde")]); + const open = sinon.stub(vscode.workspace, "openTextDocument"); + assert.equal((await prepareRenameReferences(renames, output)).size, 0); + assert.equal(open.callCount, 0); + }); + + test("rejects an edit if a document changes while scanning another file", async () => { + const doc = document(); + sinon + .stub(vscode.workspace, "findFiles") + .resolves([uri, vscode.Uri.file("/p/Other.flyde")]); + const open = sinon.stub(vscode.workspace, "openTextDocument"); + open.onFirstCall().resolves(doc); + open.onSecondCall().callsFake(async () => { + Object.defineProperty(doc, "version", { value: 2 }); + return document(); + }); + await assert.rejects( + prepareRenameReferences(renames, output), + /flow changed/ + ); + }); + + test("registers waitUntil synchronously and exposes disposal", async () => { + sinon.stub(vscode.workspace, "findFiles").resolves([]); + let listener: ((event: vscode.FileWillRenameEvent) => unknown) | undefined; + const disposable = { dispose: sinon.spy() }; + sinon.stub(vscode.workspace, "onWillRenameFiles").callsFake((handler) => { + listener = handler; + return disposable; + }); + const registered = registerRenameReferences(output); + let pending: Thenable | undefined; + listener!({ + files: renames, + token: {} as vscode.CancellationToken, + waitUntil: (promise: Thenable) => { + pending = promise; + }, + } as vscode.FileWillRenameEvent); + assert.ok(pending); + await pending; + registered.dispose(); + assert.equal(disposable.dispose.callCount, 1); + }); +});