diff --git a/languages/astro.toml b/languages/astro.toml index fd38d9e99..151e45a57 100644 --- a/languages/astro.toml +++ b/languages/astro.toml @@ -41,3 +41,5 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } diff --git a/languages/javascript.toml b/languages/javascript.toml index e92549a8a..5f0737452 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -75,6 +75,8 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } [debugger] adapter = "js-debug" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 7727b36eb..79d4a3b3b 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -74,6 +74,8 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } [debugger] adapter = "js-debug" diff --git a/languages/svelte.toml b/languages/svelte.toml index a345fe397..72e7c1e1f 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -43,3 +43,5 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } diff --git a/languages/typescript.toml b/languages/typescript.toml index 88d5d7bc1..581b2e303 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -89,6 +89,8 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } [debugger] adapter = "js-debug" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index fee9c7330..b83a56462 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -74,6 +74,8 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } [debugger] adapter = "js-debug" diff --git a/languages/vue.toml b/languages/vue.toml index 7a58e6bcd..173293ae9 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -68,3 +68,5 @@ nodePath = "" problems = { shortenToSingleLine = false } workingDirectory = { mode = "auto" } codeAction = { disableRuleComment = { enable = true, location = "separateLine" }, showDocumentation = { enable = true } } +# vscode-eslint reads it for every source.fixAll request (Actions on Save, Code Cleanup). +codeActionOnSave = { mode = "all", rules = [] } diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index 4c639174e..e54db1e37 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -146,6 +146,63 @@ describe("silent code actions", () => { }); }); +describe("fix-all from every server", () => { + const fixAll = (server: number, title: string): CodeAction => ({ + ...resolved, + title, + kind: "source.fixAll", + server, + edit: null, + data: "{}", + }); + const editor = () => { + const view = new EditorView({ + state: EditorState.create({ + doc: "const a = 'x';", + extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })], + }), + }); + views.push(view); + return view; + }; + + it("applies each server's source.fixAll in turn, asking the next one again after an edit", async () => { + const view = editor(); + const ts = fixAll(1, "Fix all fixable JS/TS issues"); + const eslint = fixAll(2, "Fix all fixable ESLint issues"); + const eslintAfter = { ...eslint, data: '{"version":2}' }; + lspCodeActions + .mockResolvedValueOnce({ status: "ok", data: [ts, eslint] }) + .mockResolvedValueOnce({ status: "ok", data: [{ ...ts, data: '{"again":true}' }, eslintAfter] }); + lspApplyCodeAction.mockResolvedValue({ status: "ok", data: null }); + expect(await applyKindSilently(view, "source.fixAll")).toBe(true); + expect(lspCodeActions).toHaveBeenCalledTimes(2); + expect(lspApplyCodeAction.mock.calls.map((c) => (c as unknown[])[1])).toEqual([ts, eslintAfter]); + }); + + it("goes on with the next server when one server's action fails", async () => { + const view = editor(); + const ts = fixAll(1, "Fix all fixable JS/TS issues"); + const eslint = fixAll(2, "Fix all fixable ESLint issues"); + lspCodeActions.mockResolvedValue({ status: "ok", data: [ts, eslint] }); + lspApplyCodeAction + .mockResolvedValueOnce({ status: "error", error: { kind: "internal", message: "boom" } }) + .mockResolvedValueOnce({ status: "ok", data: null }); + expect(await applyKindSilently(view, "source.fixAll")).toBe(true); + expect(lspApplyCodeAction.mock.calls.map((c) => ((c as unknown[])[1] as CodeAction).server)).toEqual([1, 2]); + }); + + it("applies a single organize-imports action", async () => { + const view = editor(); + const a = { ...fixAll(1, "Organize Imports"), kind: "source.organizeImports" }; + const b = { ...fixAll(2, "Organize Imports"), kind: "source.organizeImports" }; + lspCodeActions.mockResolvedValueOnce({ status: "ok", data: [a, b] }); + lspApplyCodeAction.mockResolvedValue({ status: "ok", data: null }); + expect(await applyKindSilently(view, "source.organizeImports")).toBe(true); + expect(lspApplyCodeAction).toHaveBeenCalledTimes(1); + }); +}); + describe("intention preview", () => { it("does not resolve lazy plugin actions while previewing", async () => { const view = new EditorView({ diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 6cdeebb65..6b894eaf4 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -299,27 +299,61 @@ export function changedLines(before: Text, after: Text): Array<{ from: number; t * Applies the first enabled whole-file action of `kind` without UI (Actions on * Save). Waits until the backend edit reached this editor; false when none. */ +/** + * Applies the server's `kind` action to the whole document without UI (Actions on Save, + * Code Cleanup). Fix-all runs once per server that offers it, one after another, each + * asked again for the document as the previous one left it (VS Code's codeActionsOnSave): + * vtsls and ESLint both answer `source.fixAll` for a TypeScript file. + */ export async function applyKindSilently(view: EditorView, kind: string): Promise { const doc = docOf(view); if (!doc || doc.readOnly) return false; + const matching = (actions: CodeAction[], server?: CodeAction["server"]) => + actions.filter( + (a) => + a.kind?.startsWith(kind) && + !a.disabledReason && + !isLazyPluginAction(a) && + (server === undefined || a.server === server), + ); + const pick = (actions: CodeAction[]) => actions.find((a) => a.isPreferred) ?? actions[0]; let actions: CodeAction[]; try { - actions = (await fetchActions(view, [kind], true)).filter( - (a) => a.kind?.startsWith(kind) && !a.disabledReason && !isLazyPluginAction(a), - ); - } catch { + actions = matching(await fetchActions(view, [kind], true)); + } catch (e) { + console.warn(`[lsp] ${kind} on ${doc.path} failed`, e); return false; } - const action = actions.find((a) => a.isPreferred) ?? actions[0]; - if (!action) return false; - const before = view.state.doc; - try { - await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action)); - } catch { - return false; + const first = pick(actions); + if (!first) return false; + const servers = kind.startsWith("source.fixAll") + ? [first.server, ...new Set(actions.map((a) => a.server).filter((s) => s !== first.server))] + : [first.server]; + let applied = false; + for (const [i, server] of servers.entries()) { + let action: CodeAction | undefined = first; + if (i > 0) { + // The previous server's edit changed the document: this one's answer must be fresh. + try { + action = pick(matching(await fetchActions(view, [kind], true), server)); + } catch (e) { + console.warn(`[lsp] ${kind} on ${doc.path} failed`, e); + continue; + } + if (!action) continue; + } + const before = view.state.doc; + const chosen = action; + try { + await lspRequest(() => commands.lspApplyCodeAction(doc.doc, chosen)); + } catch (e) { + console.warn(`[lsp] applying ${chosen.kind} on ${doc.path} failed`, e); + continue; + } + applied = true; + for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20)); } - for (let i = 0; i < 25 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20)); - return true; + return applied; } /**