diff --git a/lib/adapters/apply-edit-adapter.ts b/lib/adapters/apply-edit-adapter.ts index 5990410f..7ae33c03 100644 --- a/lib/adapters/apply-edit-adapter.ts +++ b/lib/adapters/apply-edit-adapter.ts @@ -1,7 +1,19 @@ import type * as atomIde from "atom-ide-base" import Convert from "../convert" -import { LanguageClientConnection, ApplyWorkspaceEditParams, ApplyWorkspaceEditResponse } from "../languageclient" +import { + LanguageClientConnection, + ApplyWorkspaceEditParams, + ApplyWorkspaceEditResponse, + WorkspaceEdit, + TextDocumentEdit, + CreateFile, + RenameFile, + DeleteFile, + DocumentUri, +} from "../languageclient" import { TextBuffer, TextEditor } from "atom" +import { promises as fsp, Stats } from "fs" +import * as rimraf from "rimraf" /** Public: Adapts workspace/applyEdit commands to editors. */ export default class ApplyEditAdapter { @@ -17,7 +29,7 @@ export default class ApplyEditAdapter { // Sort edits in reverse order to prevent edit conflicts. edits.sort((edit1, edit2) => -edit1.oldRange.compare(edit2.oldRange)) edits.reduce((previous: atomIde.TextEdit | null, current) => { - ApplyEditAdapter.validateEdit(buffer, current, previous) + validateEdit(buffer, current, previous) buffer.setTextInRange(current.oldRange, current.newText) return current }, null) @@ -30,36 +42,35 @@ export default class ApplyEditAdapter { } public static async onApplyEdit(params: ApplyWorkspaceEditParams): Promise { - let changes = params.edit.changes || {} - - if (params.edit.documentChanges) { - changes = {} - params.edit.documentChanges.forEach((change) => { - if (change && "textDocument" in change && change.textDocument) { - changes[change.textDocument.uri] = change.edits - } - }) - } + return ApplyEditAdapter.apply(params.edit) + } - const uris = Object.keys(changes) + public static async apply(workspaceEdit: WorkspaceEdit): Promise { + normalize(workspaceEdit) // Keep checkpoints from all successful buffer edits const checkpoints: Array<{ buffer: TextBuffer; checkpoint: number }> = [] - const promises = uris.map(async (uri) => { - const path = Convert.uriToPath(uri) - const editor = (await atom.workspace.open(path, { - searchAllPanes: true, - // Open new editors in the background. - activatePane: false, - activateItem: false, - })) as TextEditor - const buffer = editor.getBuffer() - // Get an existing editor for the file, or open a new one if it doesn't exist. - const edits = Convert.convertLsTextEdits(changes[uri]) - const checkpoint = ApplyEditAdapter.applyEdits(buffer, edits) - checkpoints.push({ buffer, checkpoint }) - }) + const promises = (workspaceEdit.documentChanges || []).map( + async (edit): Promise => { + if (!TextDocumentEdit.is(edit)) { + return ApplyEditAdapter.handleResourceOperation(edit).catch((err) => { + throw Error(`Error during ${edit.kind} resource operation: ${err.message}`) + }) + } + const path = Convert.uriToPath(edit.textDocument.uri) + const editor = (await atom.workspace.open(path, { + searchAllPanes: true, + // Open new editors in the background. + activatePane: false, + activateItem: false, + })) as TextEditor + const buffer = editor.getBuffer() + const edits = Convert.convertLsTextEdits(edit.edits) + const checkpoint = ApplyEditAdapter.applyEdits(buffer, edits) + checkpoints.push({ buffer, checkpoint }) + } + ) // Apply all edits or fail and revert everything const applied = await Promise.all(promises) @@ -78,17 +89,100 @@ export default class ApplyEditAdapter { return { applied } } - /** Private: Do some basic sanity checking on the edit ranges. */ - private static validateEdit(buffer: TextBuffer, edit: atomIde.TextEdit, prevEdit: atomIde.TextEdit | null): void { - const path = buffer.getPath() || "" - if (prevEdit && edit.oldRange.end.compare(prevEdit.oldRange.start) > 0) { - throw Error(`Found overlapping edit ranges in ${path}`) + private static async handleResourceOperation(edit: CreateFile | RenameFile | DeleteFile): Promise { + if (DeleteFile.is(edit)) { + const path = Convert.uriToPath(edit.uri) + const stats: boolean | Stats = await fsp.lstat(path).catch(() => false) + const ignoreIfNotExists = edit.options?.ignoreIfNotExists + + if (!stats) { + if (ignoreIfNotExists !== false) { + return + } + throw Error(`Target doesn't exist.`) + } + + if (stats.isDirectory()) { + if (edit.options?.recursive) { + return new Promise((resolve, reject) => { + rimraf(path, { glob: false }, (err) => { + if (err) { + reject(err) + } + resolve() + }) + }) + } + return fsp.rmdir(path, { recursive: edit.options?.recursive }) + } + + return fsp.unlink(path) } - const startRow = edit.oldRange.start.row - const startCol = edit.oldRange.start.column - const lineLength = buffer.lineLengthForRow(startRow) - if (lineLength == null || startCol > lineLength) { - throw Error(`Out of range edit on ${path}:${startRow + 1}:${startCol + 1}`) + if (RenameFile.is(edit)) { + const oldPath = Convert.uriToPath(edit.oldUri) + const newPath = Convert.uriToPath(edit.newUri) + const exists = await fsp + .access(newPath) + .then(() => true) + .catch(() => false) + const ignoreIfExists = edit.options?.ignoreIfExists + const overwrite = edit.options?.overwrite + + if (exists && ignoreIfExists && !overwrite) { + return + } + + if (exists && !ignoreIfExists && !overwrite) { + throw Error(`Target exists.`) + } + + return fsp.rename(oldPath, newPath) } + if (CreateFile.is(edit)) { + const path = Convert.uriToPath(edit.uri) + const exists = await fsp + .access(path) + .then(() => true) + .catch(() => false) + const ignoreIfExists = edit.options?.ignoreIfExists + const overwrite = edit.options?.overwrite + + if (exists && ignoreIfExists && !overwrite) { + return + } + + return fsp.writeFile(path, "") + } + } +} + +function normalize(workspaceEdit: WorkspaceEdit): void { + const documentChanges = workspaceEdit.documentChanges || [] + + if (!("documentChanges" in workspaceEdit) && "changes" in workspaceEdit) { + Object.keys(workspaceEdit.changes || []).forEach((uri: DocumentUri) => { + documentChanges.push({ + textDocument: { + version: null, + uri, + }, + edits: workspaceEdit.changes![uri], + }) + }) + } + + workspaceEdit.documentChanges = documentChanges +} + +function validateEdit(buffer: TextBuffer, edit: atomIde.TextEdit, prevEdit: atomIde.TextEdit | null): void { + const path = buffer.getPath() || "" + if (prevEdit && edit.oldRange.end.compare(prevEdit.oldRange.start) > 0) { + throw Error(`Found overlapping edit ranges in ${path}`) + } + const startRow = edit.oldRange.start.row + const startCol = edit.oldRange.start.column + const lineLength = buffer.lineLengthForRow(startRow) + if (lineLength == null || startCol > lineLength) { + throw Error(`Out of range edit on ${path}:${startRow + 1}:${startCol + 1}`) } } diff --git a/lib/auto-languageclient.ts b/lib/auto-languageclient.ts index 27c431b1..13618420 100644 --- a/lib/auto-languageclient.ts +++ b/lib/auto-languageclient.ts @@ -122,6 +122,7 @@ export default class AutoLanguageClient { documentChanges: true, normalizesLineEndings: false, changeAnnotationSupport: undefined, + resourceOperations: ["create", "rename", "delete"], }, workspaceFolders: false, didChangeConfiguration: { diff --git a/package.json b/package.json index aba82cc3..f93a3a13 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "prettier": "prettier-config-atomic", "atomTestRunner": "./test/runner", "dependencies": { + "@types/rimraf": "^3.0.0", "atom-ide-base": "^2.4.0", + "rimraf": "^3.0.2", "vscode-jsonrpc": "6.0.0", "vscode-languageserver-protocol": "3.16.0", "vscode-languageserver-types": "3.16.0", diff --git a/test/adapters/apply-edit-adapter.test.ts b/test/adapters/apply-edit-adapter.test.ts index 429e3f53..f681fb1d 100644 --- a/test/adapters/apply-edit-adapter.test.ts +++ b/test/adapters/apply-edit-adapter.test.ts @@ -1,5 +1,7 @@ import { expect } from "chai" import * as path from "path" +import * as os from "os" +import * as fs from "fs" import * as sinon from "sinon" import ApplyEditAdapter from "../../lib/adapters/apply-edit-adapter" import Convert from "../../lib/convert" @@ -9,6 +11,7 @@ const TEST_PATH1 = normalizeDriveLetterName(path.join(__dirname, "test.txt")) const TEST_PATH2 = normalizeDriveLetterName(path.join(__dirname, "test2.txt")) const TEST_PATH3 = normalizeDriveLetterName(path.join(__dirname, "test3.txt")) const TEST_PATH4 = normalizeDriveLetterName(path.join(__dirname, "test4.txt")) +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "atom-languageclient-tests")) function normalizeDriveLetterName(filePath: string): string { if (process.platform === "win32") { @@ -186,5 +189,237 @@ describe("ApplyEditAdapter", () => { expect(errorCalls.length).to.equal(1) expect(errorCalls[0].args[1].detail).to.equal(`Out of range edit on ${TEST_PATH4}:1:2`) }) + + it("handles rename resource operations", async () => { + const directory = fs.mkdtempSync(tempDir) + const oldUri = path.join(directory, "test.txt") + const newUri = path.join(directory, "test-renamed.txt") + fs.writeFileSync(oldUri, "abcd") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "rename", + oldUri, + newUri, + }, + ], + }, + }) + + expect(result.applied).to.equal(true) + expect(fs.existsSync(newUri)).to.equal(true) + expect(fs.readFileSync(newUri).toString()).to.equal("abcd") + expect(fs.existsSync(oldUri)).to.equal(false) + }) + + it("handles rename operation with ignoreIfExists option", async () => { + const directory = fs.mkdtempSync(tempDir) + const oldUri = path.join(directory, "test.txt") + const newUri = path.join(directory, "test-renamed.txt") + fs.writeFileSync(oldUri, "abcd") + fs.writeFileSync(newUri, "efgh") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "rename", + oldUri, + newUri, + options: { + ignoreIfExists: true, + }, + }, + ], + }, + }) + + expect(result.applied).to.equal(true) + expect(fs.existsSync(oldUri)).to.equal(true) + expect(fs.readFileSync(newUri).toString()).to.equal("efgh") + }) + + it("handles rename operation with overwrite option", async () => { + const directory = fs.mkdtempSync(tempDir) + const oldUri = path.join(directory, "test.txt") + const newUri = path.join(directory, "test-renamed.txt") + fs.writeFileSync(oldUri, "abcd") + fs.writeFileSync(newUri, "efgh") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "rename", + oldUri, + newUri, + options: { + overwrite: true, + ignoreIfExists: true, // Overwrite wins over ignoreIfExists + }, + }, + ], + }, + }) + + expect(result.applied).to.equal(true) + expect(fs.existsSync(oldUri)).to.equal(false) + expect(fs.readFileSync(newUri).toString()).to.equal("abcd") + }) + + it("throws an error on rename operation if target exists", async () => { + const directory = fs.mkdtempSync(tempDir) + const oldUri = path.join(directory, "test.txt") + const newUri = path.join(directory, "test-renamed.txt") + fs.writeFileSync(oldUri, "abcd") + fs.writeFileSync(newUri, "efgh") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "rename", + oldUri, + newUri, + }, + ], + }, + }) + + expect(result.applied).to.equal(false) + expect(fs.existsSync(oldUri)).to.equal(true) + expect(fs.readFileSync(oldUri).toString()).to.equal("abcd") + expect(fs.existsSync(newUri)).to.equal(true) + expect(fs.readFileSync(newUri).toString()).to.equal("efgh") + + expect( + (atom as any).notifications.addError.calledWith("workspace/applyEdits failed", { + description: "Failed to apply edits.", + detail: "Error during rename resource operation: Target exists.", + }) + ).to.equal(true) + }) + + it("handles delete resource operations on files", async () => { + const directory = fs.mkdtempSync(tempDir) + const uri = path.join(directory, "test.txt") + fs.writeFileSync(uri, "abcd") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "delete", + uri, + }, + ], + }, + }) + + expect(result.applied).to.equal(true) + expect(fs.existsSync(uri)).to.equal(false) + }) + + it("handles delete resource operations on directories", async () => { + const directory = fs.mkdtempSync(tempDir) + const file1 = path.join(directory, "1.txt") + const file2 = path.join(directory, "2.txt") + fs.writeFileSync(file1, "1") + fs.writeFileSync(file2, "2") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "delete", + uri: directory, + options: { + recursive: true, + }, + }, + ], + }, + }) + + expect(result.applied).to.equal(true) + expect(fs.existsSync(directory)).to.equal(false) + expect(fs.existsSync(file1)).to.equal(false) + expect(fs.existsSync(file2)).to.equal(false) + }) + + it("throws an error when deleting a non-empty directory without recursive option", async () => { + const directory = fs.mkdtempSync(tempDir) + const file1 = path.join(directory, "1.txt") + const file2 = path.join(directory, "2.txt") + fs.writeFileSync(file1, "1") + fs.writeFileSync(file2, "2") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "delete", + uri: directory, + options: { + recursive: false, + }, + }, + ], + }, + }) + + expect(result.applied).to.equal(false) + expect(fs.existsSync(directory)).to.equal(true) + expect(fs.existsSync(file1)).to.equal(true) + expect(fs.existsSync(file2)).to.equal(true) + const errorCalls = (atom as any).notifications.addError.getCalls() + expect(errorCalls.length).to.equal(1) + expect(errorCalls[0].args[1].detail).to.match(/Error during delete resource operation: (.*)/) + }) + + it("throws an error on delete operation if target doesnt exist", async () => { + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "delete", + uri: path.join(tempDir, "unexisting.txt"), + options: { + ignoreIfNotExists: false, + }, + }, + ], + }, + }) + // + expect(result.applied).to.equal(false) + expect( + (atom as any).notifications.addError.calledWith("workspace/applyEdits failed", { + description: "Failed to apply edits.", + detail: "Error during delete resource operation: Target doesn't exist.", + }) + ).to.equal(true) + }) + + it("handles create resource operations", async () => { + const directory = fs.mkdtempSync(tempDir) + const uri = path.join(directory, "test.txt") + + const result = await ApplyEditAdapter.onApplyEdit({ + edit: { + documentChanges: [ + { + kind: "create", + uri, + }, + ], + }, + }) + + expect(result.applied).to.equal(true) + expect(fs.existsSync(uri)).to.equal(true) + }) }) })