From f5106d9cbae3d9e668821567300b64bddf6d0496 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 25 Aug 2026 14:47:06 -0400 Subject: [PATCH] feat(config): add pre-save LSP code actions and UI toggle helpers --- lua/config/lsp_code_actions.lua | 104 ++++++++++++++++++++++++++++++++ lua/config/picker.lua | 21 +++++++ lua/config/toggles.lua | 59 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 lua/config/lsp_code_actions.lua create mode 100644 lua/config/picker.lua create mode 100644 lua/config/toggles.lua diff --git a/lua/config/lsp_code_actions.lua b/lua/config/lsp_code_actions.lua new file mode 100644 index 0000000..7b3a955 --- /dev/null +++ b/lua/config/lsp_code_actions.lua @@ -0,0 +1,104 @@ +--- Pre-save LSP code action pass: apply every action an attached server +--- advertises that is safe to run unattended (import organizing, fix-alls, +--- quickfixes), before conform formats the buffer. +--- +--- Driven from conform's `format_on_save` hook (plugins/conform.lua) rather +--- than its own BufWritePre autocmd: that hook is the one place where the order +--- relative to formatting is guaranteed, and it is already gated by the +--- `uf` / `uF` autoformat toggles. +--- @module config.lsp_code_actions + +-- Action kinds we apply unattended. Matches exact kinds and any subkinds via +-- "." prefix (LSP CodeActionKind is hierarchical). +-- +-- Deliberately NOT the bare "source" umbrella: it covers non-idempotent +-- document rewrites (e.g. marksman's "Create Table of Contents"). +-- Deliberately NOT "quickfix" either: those are diagnostic-scoped choices that +-- need a human. ruff, for one, offers "Disable rule for this line" as a +-- quickfix, so a save would silently paste `# noqa: I001` into the source. +local SAFE_KINDS = { + "source.organizeImports", + "source.fixAll", +} + +local function is_safe_kind(kind) + if not kind or kind == "" then return false end + for _, safe in ipairs(SAFE_KINDS) do + if kind == safe or vim.startswith(kind, safe .. ".") then return true end + end + return false +end + +-- Pull the kinds a given client advertises support for, filtered to safe ones. +-- If the server returns no kinds list, fall back to the top-level SAFE_KINDS so +-- it gets the chance to satisfy a broad "only" filter. +local function safe_kinds_for(client) + local provider = (client.server_capabilities or {}).codeActionProvider + if not provider then return {} end + local advertised = type(provider) == "table" and provider.codeActionKinds or nil + if not advertised or #advertised == 0 then return vim.deepcopy(SAFE_KINDS) end + local kinds = {} + for _, k in ipairs(advertised) do + if is_safe_kind(k) then table.insert(kinds, k) end + end + return kinds +end + +-- Collect LSP-shaped diagnostics so quickfix actions have something to bind to. +local function lsp_diagnostics(bufnr) + local out = {} + for _, d in ipairs(vim.diagnostic.get(bufnr)) do + if d.user_data and d.user_data.lsp then table.insert(out, d.user_data.lsp) end + end + return out +end + +-- Servers are allowed to return an action with neither `edit` nor `command`, +-- carrying only `data` to be exchanged for the real edit via codeAction/resolve +-- (vtsls does exactly this for source.organizeImports). Without this step the +-- whole pass silently does nothing on those servers. +local function resolve(client, bufnr, action) + if action.edit or type(action.command) == "table" then return action end + if not vim.tbl_get(client.server_capabilities or {}, "codeActionProvider", "resolveProvider") then return action end + local resp = client:request_sync("codeAction/resolve", action, 1000, bufnr) + if resp and not resp.err and resp.result then return resp.result end + return action +end + +-- Servers may ignore context.only (LSP allows it), so re-filter the returned +-- actions by kind before applying. Kind-less actions are skipped: we asked for +-- specific kinds, so anything untagged is not provably safe. +local function apply_result(client, bufnr, result) + if not result then return end + for _, action in ipairs(result) do + if is_safe_kind(action.kind) then + action = resolve(client, bufnr, action) + if action.edit then vim.lsp.util.apply_workspace_edit(action.edit, client.offset_encoding or "utf-8") end + if type(action.command) == "table" then client:exec_cmd(action.command, { bufnr = bufnr }) end + end + end +end + +local M = {} + +--- Apply every safe code action advertised for `bufnr`, synchronously. +--- No-op for scratch/special buffers and for buffers no server serves. +--- @param bufnr integer +function M.apply(bufnr) + if vim.bo[bufnr].buftype ~= "" then return end + local clients = vim.lsp.get_clients { bufnr = bufnr, method = "textDocument/codeAction" } + if #clients == 0 then return end + local diagnostics = lsp_diagnostics(bufnr) + for _, client in ipairs(clients) do + local kinds = safe_kinds_for(client) + if #kinds > 0 then + local enc = client.offset_encoding or "utf-8" + local params = vim.lsp.util.make_range_params(0, enc) + params.context = { only = kinds, diagnostics = diagnostics, triggerKind = 2 } + local resp = client:request_sync("textDocument/codeAction", params, 1000, bufnr) + if resp and not resp.err then apply_result(client, bufnr, resp.result) end + end + end +end + +return M diff --git a/lua/config/picker.lua b/lua/config/picker.lua new file mode 100644 index 0000000..0c59042 --- /dev/null +++ b/lua/config/picker.lua @@ -0,0 +1,21 @@ +--- Shared snacks.picker helpers. +--- @module config.picker + +local M = {} + +--- Open a snacks picker with trouble.nvim's send-to-list action bound to , +--- so a picker result set can be promoted to a persistent Trouble list. +--- @param source string snacks.picker source name (e.g. "diagnostics") +--- @param opts? snacks.picker.Config extra picker options +function M.with_trouble(source, opts) + opts = opts or {} + opts.actions = require("trouble.sources.snacks").actions + opts.win = opts.win or {} + opts.win.input = opts.win.input or {} + opts.win.input.keys = vim.tbl_extend("force", opts.win.input.keys or {}, { + [""] = { "trouble_open", mode = { "n", "i" } }, + }) + require("snacks").picker[source](opts) +end + +return M diff --git a/lua/config/toggles.lua b/lua/config/toggles.lua new file mode 100644 index 0000000..adba678 --- /dev/null +++ b/lua/config/toggles.lua @@ -0,0 +1,59 @@ +--- UI toggles for the `u` group, registered through +--- `Snacks.toggle():map()` instead of plain astrocore mappings. +--- +--- That is what buys the snacks default which-key integration: each toggle +--- adds its own which-key entry with the enabled/disabled state icon, the +--- green/yellow state colour, and an "Enable X" / "Disable X" label that +--- flips with the current value. Plain `vim.keymap.set` mappings only ever +--- show a static description. +--- +--- Called from polish.lua, i.e. after astrocore has installed its own +--- `u` defaults, so these win on the keys they share. Toggles +--- AstroNvim provides and snacks does not (`ub` background, +--- `uA` autochdir, ...) are left alone. +--- @module config.toggles + +local M = {} + +function M.setup() + local ok, Snacks = pcall(require, "snacks") + if not ok then return end + local toggle = Snacks.toggle + + toggle.diagnostics():map "ud" + toggle.line_number():map "un" + toggle.option("relativenumber", { name = "Relative Numbers" }):map "ur" + toggle.option("wrap", { name = "Wrap" }):map "uw" + toggle.option("spell", { name = "Spelling" }):map "us" + toggle.option("conceallevel", { off = 0, on = 2, name = "Conceal" }):map "uc" + toggle.inlay_hints():map "ui" + toggle.treesitter():map "ut" + toggle.indent():map "uI" + toggle.dim():map "ux" + toggle.zen():map "uz" + toggle.zoom():map "uZ" + toggle.animate():map "ua" + toggle.scroll():map "uS" + toggle.profiler():map "up" + toggle.profiler_highlights():map "uh" + + -- conform owns format-on-save (astrocommunity conform pack) and gates it on + -- vim.b.autoformat / vim.g.autoformat; the pre-save LSP code action pass + -- rides the same hook, so this one flag governs the whole on-save pipeline. + -- Mirrors the pack's own uF, with which-key state on top. + toggle({ + name = "Format on Save", + get = function() return vim.F.if_nil(vim.b.autoformat, vim.g.autoformat, true) end, + set = function(state) vim.g.autoformat, vim.b.autoformat = state, nil end, + }):map "uf" + + -- virtual_lines is native since 0.11; showing both at once is unreadable, + -- so the two diagnostic renderers trade places + toggle({ + name = "Diagnostic Virtual Lines", + get = function() return ((vim.diagnostic.config() or {}).virtual_lines and true) or false end, + set = function(state) vim.diagnostic.config { virtual_lines = state, virtual_text = not state } end, + }):map "ul" +end + +return M