Merge PR #8393: fix(lsp): preserve workspace resource options (@roboomp)

This commit is contained in:
can1357
2026-08-13 05:46:42 +02:00
3 changed files with 205 additions and 9 deletions
+3
View File
@@ -69,6 +69,9 @@
### Fixed
- Fixed rust-analyzer code actions writing snippet placeholders such as `$0` into source by no longer advertising unsupported snippet text edits and rejecting any unexpected snippet edit before applying it ([#8376](https://github.com/can1357/oh-my-pi/issues/8376)).
### Fixed
- Fixed LSP `WorkspaceEdit` resource operations discarding create, rename, and delete options ([#8373](https://github.com/can1357/oh-my-pi/issues/8373)).
## [17.2.15] - 2026-08-12
+60 -9
View File
@@ -1,13 +1,17 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEexist, isEnoent } from "@oh-my-pi/pi-utils";
import { formatPathRelativeToCwd } from "../tools/path-utils";
import { ToolError } from "../tools/tool-errors";
import type {
CreateFile,
CreateFileOptions,
DeleteFile,
DeleteFileOptions,
Position,
Range,
RenameFile,
RenameFileOptions,
TextDocumentEdit,
TextEdit,
WorkspaceEdit,
@@ -168,9 +172,9 @@ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promi
type WorkspaceEditOp =
| { kind: "text"; uri: string; edits: TextEdit[] }
| { kind: "create"; uri: string }
| { kind: "rename"; oldUri: string; newUri: string }
| { kind: "delete"; uri: string };
| { kind: "create"; uri: string; options?: CreateFileOptions }
| { kind: "rename"; oldUri: string; newUri: string; options?: RenameFileOptions }
| { kind: "delete"; uri: string; options?: DeleteFileOptions };
/**
* Flatten documentChanges into an ordered op list. Text edits are accumulated
@@ -216,7 +220,7 @@ function planDocumentChanges(documentChanges: NonNullable<WorkspaceEdit["documen
if (change.kind === "create") {
const createOp = change as CreateFile;
flushUri(createOp.uri);
ops.push({ kind: "create", uri: createOp.uri });
ops.push({ kind: "create", uri: createOp.uri, options: createOp.options });
} else if (change.kind === "rename") {
const renameOp = change as RenameFile;
// Per LSP §3.16.2 documentChanges are applied in declared order.
@@ -226,11 +230,16 @@ function planDocumentChanges(documentChanges: NonNullable<WorkspaceEdit["documen
// `options.overwrite` and `options.ignoreIfExists`).
flushSubtree(renameOp.oldUri);
flushSubtree(renameOp.newUri);
ops.push({ kind: "rename", oldUri: renameOp.oldUri, newUri: renameOp.newUri });
ops.push({
kind: "rename",
oldUri: renameOp.oldUri,
newUri: renameOp.newUri,
options: renameOp.options,
});
} else if (change.kind === "delete") {
const deleteOp = change as DeleteFile;
flushSubtree(deleteOp.uri);
ops.push({ kind: "delete", uri: deleteOp.uri });
ops.push({ kind: "delete", uri: deleteOp.uri, options: deleteOp.options });
}
}
}
@@ -264,17 +273,59 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom
applied.push(`Applied ${op.edits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if (op.kind === "create") {
const filePath = uriToFile(op.uri);
await Bun.write(filePath, "");
await fs.mkdir(path.dirname(filePath), { recursive: true });
try {
if (op.options?.overwrite) {
await Bun.write(filePath, "");
} else {
const handle = await fs.open(filePath, "wx");
await handle.close();
}
} catch (error) {
if (!(op.options?.ignoreIfExists && !op.options.overwrite && isEexist(error))) {
throw error;
}
continue;
}
applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if (op.kind === "rename") {
const oldPath = uriToFile(op.oldUri);
const newPath = uriToFile(op.newUri);
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.rename(oldPath, newPath);
if (oldPath !== newPath) {
try {
const targetStat = await fs.lstat(newPath);
if (!op.options?.overwrite) {
if (op.options?.ignoreIfExists) continue;
throw new ToolError(`rename target already exists: ${formatPathRelativeToCwd(newPath, cwd)}`);
}
// Only remove the destination when it is a distinct file. On a
// case-insensitive filesystem a case-only rename resolves both
// paths to the same inode; removing newPath would delete the
// source, so let fs.rename change the case in place instead.
const sourceStat = await fs.lstat(oldPath);
if (sourceStat.dev !== targetStat.dev || sourceStat.ino !== targetStat.ino) {
await fs.rm(newPath, { recursive: true });
}
} catch (error) {
if (!isEnoent(error)) throw error;
}
await fs.rename(oldPath, newPath);
}
applied.push(`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`);
} else {
const filePath = uriToFile(op.uri);
await fs.rm(filePath, { recursive: true });
try {
const stat = await fs.lstat(filePath);
if (stat.isDirectory() && !stat.isSymbolicLink() && !op.options?.recursive) {
await fs.rmdir(filePath);
} else {
await fs.rm(filePath, { recursive: op.options?.recursive ?? false });
}
} catch (error) {
if (!(op.options?.ignoreIfNotExists && isEnoent(error))) throw error;
continue;
}
applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
}
}
@@ -2585,6 +2585,7 @@ describe("lsp regressions", () => {
kind: "rename",
oldUri,
newUri,
options: { overwrite: true },
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [targetEdit, renameOp],
@@ -2783,6 +2784,146 @@ describe("lsp regressions", () => {
}
});
it("honors CreateFile overwrite and ignoreIfExists options", async () => {
const tempDir = TempDir.createSync("@omp-lsp-create-options-");
try {
const filePath = path.join(tempDir.path(), "existing.ts");
const uri = fileToUri(filePath);
await Bun.write(filePath, "ORIGINAL");
const ignored = await applyWorkspaceEdit(
{
documentChanges: [
{ kind: "create", uri, options: { overwrite: false, ignoreIfExists: true } } satisfies CreateFile,
],
},
tempDir.path(),
);
expect(fs.readFileSync(filePath, "utf8")).toBe("ORIGINAL");
expect(ignored).toEqual([]);
await applyWorkspaceEdit(
{
documentChanges: [
{ kind: "create", uri, options: { overwrite: true, ignoreIfExists: true } } satisfies CreateFile,
],
},
tempDir.path(),
);
expect(fs.readFileSync(filePath, "utf8")).toBe("");
} finally {
tempDir.removeSync();
}
});
it("honors RenameFile overwrite and ignoreIfExists options", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rename-options-");
try {
const oldPath = path.join(tempDir.path(), "old.ts");
const newPath = path.join(tempDir.path(), "new.ts");
const oldUri = fileToUri(oldPath);
const newUri = fileToUri(newPath);
await Bun.write(oldPath, "SOURCE");
await Bun.write(newPath, "TARGET");
const ignored = await applyWorkspaceEdit(
{
documentChanges: [
{
kind: "rename",
oldUri,
newUri,
options: { overwrite: false, ignoreIfExists: true },
} satisfies RenameFile,
],
},
tempDir.path(),
);
expect(fs.readFileSync(oldPath, "utf8")).toBe("SOURCE");
expect(fs.readFileSync(newPath, "utf8")).toBe("TARGET");
expect(ignored).toEqual([]);
await applyWorkspaceEdit(
{
documentChanges: [
{
kind: "rename",
oldUri,
newUri,
options: { overwrite: true, ignoreIfExists: true },
} satisfies RenameFile,
],
},
tempDir.path(),
);
expect(fs.existsSync(oldPath)).toBe(false);
expect(fs.readFileSync(newPath, "utf8")).toBe("SOURCE");
} finally {
tempDir.removeSync();
}
});
it("honors DeleteFile recursive and ignoreIfNotExists options", async () => {
const tempDir = TempDir.createSync("@omp-lsp-delete-options-");
try {
const directory = path.join(tempDir.path(), "directory");
const uri = fileToUri(directory);
fs.mkdirSync(directory);
await Bun.write(path.join(directory, "child.ts"), "KEEP");
const nonRecursive: DeleteFile = { kind: "delete", uri, options: { recursive: false } };
await expect(applyWorkspaceEdit({ documentChanges: [nonRecursive] }, tempDir.path())).rejects.toThrow();
expect(fs.readFileSync(path.join(directory, "child.ts"), "utf8")).toBe("KEEP");
const recursive: DeleteFile = { kind: "delete", uri, options: { recursive: true } };
await applyWorkspaceEdit({ documentChanges: [recursive] }, tempDir.path());
expect(fs.existsSync(directory)).toBe(false);
const ignored = await applyWorkspaceEdit(
{
documentChanges: [{ kind: "delete", uri, options: { ignoreIfNotExists: true } } satisfies DeleteFile],
},
tempDir.path(),
);
expect(ignored).toEqual([]);
} finally {
tempDir.removeSync();
}
});
it("does not delete the source when a rename target resolves to the same file", async () => {
// A case-only rename on a case-insensitive filesystem yields distinct path
// strings that resolve to one inode. Reproduce that deterministically on a
// case-sensitive filesystem with a symlinked parent directory: `dir/f.ts`
// and `dirlink/f.ts` are the same file. The overwrite branch must skip
// removing the destination, or it deletes the source before the rename.
const tempDir = TempDir.createSync("@omp-lsp-same-file-rename-");
try {
const realDir = path.join(tempDir.path(), "dir");
fs.mkdirSync(realDir);
const filePath = path.join(realDir, "f.ts");
await Bun.write(filePath, "SOURCE");
const linkDir = path.join(tempDir.path(), "dirlink");
fs.symlinkSync(realDir, linkDir);
const aliasPath = path.join(linkDir, "f.ts");
const renameOp: RenameFile = {
kind: "rename",
oldUri: fileToUri(filePath),
newUri: fileToUri(aliasPath),
options: { overwrite: true },
};
expect(renameOp.oldUri).not.toBe(renameOp.newUri);
await applyWorkspaceEdit({ documentChanges: [renameOp] }, tempDir.path());
expect(fs.existsSync(filePath)).toBe(true);
expect(fs.readFileSync(filePath, "utf8")).toBe("SOURCE");
} finally {
tempDir.removeSync();
}
});
it("flushes pending descendant text edits before a folder delete", async () => {
// Mirror of the folder-rename subtree-flush test for the `delete` arm:
// edits queued against a child URI must land at the original path
@@ -2813,6 +2954,7 @@ describe("lsp regressions", () => {
const folderDelete: DeleteFile = {
kind: "delete",
uri: folderUri,
options: { recursive: true },
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [childEdit, folderDelete],