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:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user