fix(lsp): preserved workspace resource options

Propagated CreateFile, RenameFile, and DeleteFile options through workspace edit planning and enforced their overwrite, ignore, and recursive semantics during execution.

Fixes #8373
This commit is contained in:
roboomp
2026-08-13 03:04:31 +00:00
parent 06aecdd51f
commit f6e2b6c2e1
3 changed files with 167 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### 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
### Added
+54 -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,
@@ -159,9 +163,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
@@ -207,7 +211,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.
@@ -217,11 +221,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 });
}
}
}
@@ -255,17 +264,53 @@ 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 {
await fs.lstat(newPath);
if (!op.options?.overwrite) {
if (op.options?.ignoreIfExists) continue;
throw new ToolError(`rename target already exists: ${formatPathRelativeToCwd(newPath, cwd)}`);
}
await fs.lstat(oldPath);
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)}`);
}
}
@@ -2422,6 +2422,7 @@ describe("lsp regressions", () => {
kind: "rename",
oldUri,
newUri,
options: { overwrite: true },
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [targetEdit, renameOp],
@@ -2608,6 +2609,113 @@ 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("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
@@ -2638,6 +2746,7 @@ describe("lsp regressions", () => {
const folderDelete: DeleteFile = {
kind: "delete",
uri: folderUri,
options: { recursive: true },
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [childEdit, folderDelete],