Skip to content

feat: support resource operations #140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 131 additions & 37 deletions lib/adapters/apply-edit-adapter.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
Expand All @@ -30,36 +42,35 @@ export default class ApplyEditAdapter {
}

public static async onApplyEdit(params: ApplyWorkspaceEditParams): Promise<ApplyWorkspaceEditResponse> {
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<ApplyWorkspaceEditResponse> {
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<void> => {
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)
Expand All @@ -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<void> {
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}`)
}
}
1 change: 1 addition & 0 deletions lib/auto-languageclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default class AutoLanguageClient {
documentChanges: true,
normalizesLineEndings: false,
changeAnnotationSupport: undefined,
resourceOperations: ["create", "rename", "delete"],
},
workspaceFolders: false,
didChangeConfiguration: {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading