import * as vscode from "vscode"; import { assert, unwrapUndefinable } from "./util"; export type SnippetTextDocumentEdit = [vscode.Uri, (vscode.TextEdit | vscode.SnippetTextEdit)[]]; export async function applySnippetWorkspaceEdit( edit: vscode.WorkspaceEdit, editEntries: SnippetTextDocumentEdit[], ) { if (editEntries.length === 1) { const [uri, edits] = unwrapUndefinable(editEntries[0]); const editor = await editorFromUri(uri); if (editor) { edit.set(uri, removeLeadingWhitespace(editor, edits)); await vscode.workspace.applyEdit(edit); } return; } for (const [uri, edits] of editEntries) { const editor = await editorFromUri(uri); if (editor) { await editor.edit((builder) => { for (const indel of edits) { assert( !(indel instanceof vscode.SnippetTextEdit), `bad ws edit: snippet received with multiple edits: ${JSON.stringify( edit, )}`, ); builder.replace(indel.range, indel.newText); } }); } } } async function editorFromUri(uri: vscode.Uri): Promise { if (vscode.window.activeTextEditor?.document.uri !== uri) { // `vscode.window.visibleTextEditors` only contains editors whose contents are being displayed await vscode.window.showTextDocument(uri, {}); } return vscode.window.visibleTextEditors.find( (it) => it.document.uri.toString() === uri.toString(), ); } export async function applySnippetTextEdits(editor: vscode.TextEditor, edits: vscode.TextEdit[]) { const edit = new vscode.WorkspaceEdit(); const snippetEdits = toSnippetTextEdits(edits); edit.set(editor.document.uri, removeLeadingWhitespace(editor, snippetEdits)); await vscode.workspace.applyEdit(edit); } function hasSnippet(snip: string): boolean { const m = snip.match(/\$\d+|\{\d+:[^}]*\}/); return m != null; } function toSnippetTextEdits( edits: vscode.TextEdit[], ): (vscode.TextEdit | vscode.SnippetTextEdit)[] { return edits.map((textEdit) => { // Note: text edits without any snippets are returned as-is instead of // being wrapped in a SnippetTextEdit, as otherwise it would be // treated as if it had a tab stop at the end. if (hasSnippet(textEdit.newText)) { return new vscode.SnippetTextEdit( textEdit.range, new vscode.SnippetString(textEdit.newText), ); } else { return textEdit; } }); } /** * Removes the leading whitespace from snippet edits, so as to not double up * on indentation. * * Snippet edits by default adjust any multi-line snippets to match the * indentation of the line to insert at. Unfortunately, we (the server) also * include the required indentation to match what we line insert at, so we end * up doubling up the indentation. Since there isn't any way to tell vscode to * not fixup indentation for us, we instead opt to remove the indentation and * then let vscode add it back in. * * This assumes that the source snippet text edits have the required * indentation, but that's okay as even without this workaround and the problem * to workaround, those snippet edits would already be inserting at the wrong * indentation. */ function removeLeadingWhitespace( editor: vscode.TextEditor, edits: (vscode.TextEdit | vscode.SnippetTextEdit)[], ) { return edits.map((edit) => { if (edit instanceof vscode.SnippetTextEdit) { const snippetEdit: vscode.SnippetTextEdit = edit; const firstLineEnd = snippetEdit.snippet.value.indexOf("\n"); if (firstLineEnd !== -1) { // Is a multi-line snippet, remove the indentation which // would be added back in by vscode. const startLine = editor.document.lineAt(snippetEdit.range.start.line); const leadingWhitespace = getLeadingWhitespace( startLine.text, 0, startLine.firstNonWhitespaceCharacterIndex, ); const [firstLine, rest] = splitAt(snippetEdit.snippet.value, firstLineEnd + 1); const unindentedLines = rest .split("\n") .map((line) => line.replace(leadingWhitespace, "")) .join("\n"); snippetEdit.snippet.value = firstLine + unindentedLines; } return snippetEdit; } else { return edit; } }); } // based on https://github.com/microsoft/vscode/blob/main/src/vs/base/common/strings.ts#L284 function getLeadingWhitespace(str: string, start: number = 0, end: number = str.length): string { for (let i = start; i < end; i++) { const chCode = str.charCodeAt(i); if (chCode !== " ".charCodeAt(0) && chCode !== " ".charCodeAt(0)) { return str.substring(start, i); } } return str.substring(start, end); } function splitAt(str: string, index: number): [string, string] { return [str.substring(0, index), str.substring(index)]; }