diff --git a/lua/plugins/astrocore.lua b/lua/plugins/astrocore.lua index d9fb51f..cdb4cb6 100644 --- a/lua/plugins/astrocore.lua +++ b/lua/plugins/astrocore.lua @@ -6,18 +6,6 @@ local ignore_patterns = require "config.ignore_patterns" local file_filter = ignore_patterns.create_picker_filter() --- Open a snacks picker with trouble.nvim send-to-quickfix action bound to . -local function 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 - -- e: two-stage find-by-extension. Stage 1 scans the repo with fd -- (respects .gitignore) and shows every file extension ranked by count; -- stage 2 opens fff prefilled with a `*. ` glob constraint so the @@ -289,7 +277,10 @@ return { function() require("snacks").terminal.toggle("lazydocker", { win = { style = "float" } }) end, desc = "Toggle lazydocker (float)", }, - [""] = { "Scooter", desc = "Find & replace (scooter)" }, + -- grug-far.nvim: buffer-based find & replace (plugins/grug-far.lua). + -- `open` reads the visual selection itself, so the same callback + -- serves both modes. + [""] = { function() require("grug-far").open() end, desc = "Find & replace (grug-far)" }, -- which-key prefix group labels [""] = { desc = "Git + GitHub" }, [""] = { desc = "Find" }, @@ -364,12 +355,6 @@ return { find_files_by_extension, desc = "Find files by extension", }, - [""] = { - function() - require("snacks").picker.lsp_symbols { layout = { preset = "vscode", preview = "main" } } - end, - desc = "Find LSP symbols", - }, ["o"] = { function() require("snacks").picker.recent() end, desc = "Find recent files", @@ -429,106 +414,19 @@ return { end, desc = "Paste from system clipboard", }, + -- LSP-scoped assist binds (a/s/S) are capability-gated in + -- plugins/astrolsp.lua; diagnostics exist without a server, so this + -- one stays global ["d"] = { - function() with_trouble("diagnostics") end, + function() require("config.picker").with_trouble "diagnostics" end, desc = "Search diagnostics ( sends to Trouble)", }, - ["a"] = { - function() vim.lsp.buf.code_action() end, - desc = "Show code actions", - }, - ["s"] = { - function() with_trouble("lsp_symbols") end, - desc = "Search LSP symbols ( sends to Trouble)", - }, - ["S"] = { - function() with_trouble("lsp_workspace_symbols") end, - desc = "Search LSP workspace symbols ( sends to Trouble)", - }, [""] = { "SupermavenToggle", desc = "Toggle Supermaven" }, + -- u toggles are mapped through Snacks.toggle in + -- config.toggles (called from polish.lua) so which-key renders their + -- enabled/disabled state ["u"] = { desc = "UI Toggles" }, - ["ud"] = { - function() require("snacks").toggle.diagnostics():toggle() end, - desc = "Toggle Diagnostics", - }, - ["un"] = { - function() require("snacks").toggle.line_number():toggle() end, - desc = "Toggle Line Numbers", - }, - ["ur"] = { - function() require("snacks").toggle.option("relativenumber"):toggle() end, - desc = "Toggle Relative Numbers", - }, - ["uw"] = { - function() require("snacks").toggle.option("wrap"):toggle() end, - desc = "Toggle Line Wrap", - }, - ["us"] = { - function() require("snacks").toggle.option("spell"):toggle() end, - desc = "Toggle Spelling", - }, - ["ui"] = { - function() require("snacks").toggle.inlay_hints():toggle() end, - desc = "Toggle Inlay Hints", - }, - ["ut"] = { - function() require("snacks").toggle.treesitter():toggle() end, - desc = "Toggle Treesitter", - }, - ["uc"] = { - function() require("snacks").toggle.option("conceallevel", { off = 0, on = 2 }):toggle() end, - desc = "Toggle Conceal", - }, - ["uI"] = { - function() require("snacks").toggle.indent():toggle() end, - desc = "Toggle Indent Guides", - }, - ["uz"] = { - function() require("snacks").toggle.zen():toggle() end, - desc = "Toggle Zen Mode", - }, - ["uZ"] = { - function() require("snacks").toggle.zoom():toggle() end, - desc = "Toggle Zoom", - }, - ["ua"] = { - function() require("snacks").toggle.animate():toggle() end, - desc = "Toggle Animations", - }, - ["uS"] = { - function() require("snacks").toggle.scroll():toggle() end, - desc = "Toggle Smooth Scroll", - }, - ["up"] = { - function() require("snacks").toggle.profiler():toggle() end, - desc = "Toggle Profiler", - }, - ["uh"] = { - function() require("snacks").toggle.profiler_highlights():toggle() end, - desc = "Toggle Profiler Highlights", - }, - ["ux"] = { - function() require("snacks").toggle.dim():toggle() end, - desc = "Toggle Dim Inactive", - }, - ["uf"] = { - function() - vim.g.disable_autoformat = not vim.g.disable_autoformat - local status = vim.g.disable_autoformat and "disabled" or "enabled" - vim.notify("Format on save " .. status, vim.log.levels.INFO) - end, - desc = "Toggle Format on Save", - }, - -- v6: virtual_lines is native in vim.diagnostic.config, lsp_lines plugin no longer needed - ["ul"] = { - function() - local cfg = vim.diagnostic.config() or {} - local enabled = not cfg.virtual_lines - vim.diagnostic.config { virtual_lines = enabled, virtual_text = not enabled } - end, - desc = "Toggle Virtual Lines", - }, }, i = { [""] = { "wa", desc = "Save buffer, and return to normal mode" }, @@ -568,6 +466,13 @@ return { function() require("fff").live_grep_under_cursor() end, desc = "Grep selection", }, + -- charwise (v) selection prefills the search input, linewise (V) + -- limits search & replace to the selected range (visualSelectionUsage + -- = "auto-detect" in plugins/grug-far.lua) + [""] = { + function() require("grug-far").open() end, + desc = "Find & replace selection (grug-far)", + }, ["D"] = { function() vim.cmd 'normal! "vy' diff --git a/lua/plugins/astrolsp.lua b/lua/plugins/astrolsp.lua index b8b121f..23c9467 100644 --- a/lua/plugins/astrolsp.lua +++ b/lua/plugins/astrolsp.lua @@ -1,104 +1,70 @@ -if true then return {} end -- WARN: REMOVE THIS LINE TO ACTIVATE THIS FILE +--- AstroLSP: the configuration that should only exist while a language server +--- is attached (`:h astrolsp`). Every mapping carries a `cond` — a server +--- method name — so it is created buffer-locally on attach and is simply +--- absent (including in which-key) wherever no server advertises it, instead +--- of being a global bind that errors or silently does nothing. +--- +--- Deliberately configured elsewhere: +--- * per-server settings: basedpyright interpreter resolution lives in +--- plugins/python.lua, the language packs in community.lua own the rest +--- * formatting: conform owns it end to end. The astrocommunity conform +--- pack sets `formatting.disabled = true` here, and the pre-save code +--- action pass hangs off conform's `format_on_save` hook so its order +--- relative to formatting is deterministic (plugins/conform.lua) +--- * diagnostics jumps/pickers (`J`, `K`, `d`) and everything else that +--- works without a server: plugins/astrocore.lua +--- @module plugins.astrolsp --- AstroLSP allows you to customize the features in AstroNvim's LSP configuration engine --- Configuration documentation can be found with `:h astrolsp` --- NOTE: We highly recommend setting up the Lua Language Server (`:LspInstall lua_ls`) --- as this provides autocomplete and documentation while editing +--- @param source string snacks.picker source +--- @return function +local function trouble_picker(source) + return function() require("config.picker").with_trouble(source) end +end ---@type LazySpec return { "AstroNvim/astrolsp", ---@type AstroLSPOpts opts = { - -- Configuration table of features provided by AstroLSP - features = { - codelens = true, -- enable/disable codelens refresh on start - inlay_hints = false, -- enable/disable inlay hints on start - semantic_tokens = true, -- enable/disable semantic token highlighting - }, - -- customize lsp formatting options - formatting = { - -- control auto formatting on save - format_on_save = { - enabled = true, -- enable or disable format on save globally - allow_filetypes = { -- enable format on save for specified filetypes only - -- "go", - }, - ignore_filetypes = { -- disable format on save for specified filetypes - -- "python", - }, - }, - disabled = { -- disable formatting capabilities for the listed language servers - -- disable lua_ls formatting capability if you want to use StyLua to format your lua code - -- "lua_ls", - }, - timeout_ms = 1000, -- default format timeout - -- filter = function(client) -- fully override the default formatting function - -- return true - -- end - }, - -- enable servers that you already have installed without mason - servers = { - -- "pyright" - }, - -- customize language server configuration passed to `vim.lsp.config` - -- client specific configuration can also go in `lsp/` in your configuration root (see `:h lsp-config`) - config = { - -- ["*"] = { capabilities = {} }, -- modify default LSP client settings such as capabilities - }, - -- customize how language servers are attached - handlers = { - -- a function with the key `*` modifies the default handler, functions takes the server name as the parameter - -- ["*"] = function(server) vim.lsp.enable(server) end - - -- the key is the server that is being setup with `vim.lsp.config` - -- rust_analyzer = false, -- setting a handler to false will disable the set up of that language server - }, - -- Configure buffer local auto commands to add when attaching a language server - autocmds = { - -- first key is the `augroup` to add the auto commands to (:h augroup) - lsp_codelens_refresh = { - -- Optional condition to create/delete auto command group - -- can either be a string of a client capability or a function of `fun(client, bufnr): boolean` - -- condition will be resolved for each client on each execution and if it ever fails for all clients, - -- the auto commands will be deleted for that buffer - cond = "textDocument/codeLens", - -- cond = function(client, bufnr) return client.name == "lua_ls" end, - -- list of auto commands to set - { - -- events to trigger - event = { "InsertLeave", "BufEnter" }, - -- the rest of the autocmd options (:h nvim_create_autocmd) - desc = "Refresh codelens (buffer)", - callback = function(args) - if require("astrolsp").config.features.codelens then vim.lsp.codelens.enable(true, { bufnr = args.buf }) end - end, - }, - }, - }, - -- mappings to be set up on attaching of a language server mappings = { n = { - -- a `cond` key can provided as the string of a server capability to be required to attach, or a function with `client` and `bufnr` parameters from the `on_attach` that returns a boolean - gD = { - function() vim.lsp.buf.declaration() end, - desc = "Declaration of current symbol", - cond = "textDocument/declaration", + -- snacks.words auto-highlights the references of the symbol under the + -- cursor (`words` in plugins/user.lua); these cycle through them, + -- complementing the core astrolsp `gd` / `gr` binds + ["]]"] = { + function() require("snacks").words.jump(vim.v.count1, true) end, + desc = "Next reference", + cond = "textDocument/documentHighlight", }, - ["uY"] = { - function() require("astrolsp.toggles").buffer_semantic_tokens() end, - desc = "Toggle LSP semantic highlight (buffer)", - cond = function(client) - return client:supports_method "textDocument/semanticTokens/full" and vim.lsp.semantic_tokens ~= nil - end, + ["[["] = { + function() require("snacks").words.jump(-vim.v.count1, true) end, + desc = "Previous reference", + cond = "textDocument/documentHighlight", + }, + + -- : assist. Code actions and symbol search, each with the + -- trouble.nvim hand-off on where a picker is involved. + ["a"] = { + function() vim.lsp.buf.code_action() end, + desc = "Show code actions", + cond = "textDocument/codeAction", + }, + ["s"] = { + trouble_picker "lsp_symbols", + desc = "Search LSP symbols ( sends to Trouble)", + cond = "textDocument/documentSymbol", + }, + ["S"] = { + trouble_picker "lsp_workspace_symbols", + desc = "Search LSP workspace symbols ( sends to Trouble)", + cond = "workspace/symbol", + }, + [""] = { + function() require("snacks").picker.lsp_symbols { layout = { preset = "vscode", preview = "main" } } end, + desc = "Find LSP symbols", + cond = "textDocument/documentSymbol", }, }, }, - -- A custom `on_attach` function to be run after the default `on_attach` function - -- takes two parameters `client` and `bufnr` (`:h lsp-attach`) - on_attach = function(client, bufnr) - -- this would disable semanticTokensProvider for all clients - -- client.server_capabilities.semanticTokensProvider = nil - end, }, } diff --git a/lua/plugins/conform.lua b/lua/plugins/conform.lua index 9ad2f43..67a66a3 100644 --- a/lua/plugins/conform.lua +++ b/lua/plugins/conform.lua @@ -1,81 +1,8 @@ --- Run all safe LSP code actions advertised by attached servers before save, --- then let conform format. The astrocommunity conform-nvim pack wires up --- format_on_save; this just adds the pre-save code-action pass. - --- Action kinds we consider safe to apply on save. 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"). -local SAFE_KINDS = { - "source.organizeImports", - "source.fixAll", - "quickfix", -} - -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 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 - 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 function apply_safe_actions(bufnr) - 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 +-- Python/JS formatting rules, plus the on-save pipeline: every safe LSP code +-- action (import organizing, fix-alls, quickfixes) runs first, then conform +-- formats. Both hang off conform's `format_on_save` hook, which is the only +-- place the relative order is guaranteed; the action pass itself lives in +-- config.lsp_code_actions. ---@type LazySpec return { @@ -89,17 +16,6 @@ return { }, { "stevearc/conform.nvim", - init = function() - vim.api.nvim_create_autocmd("BufWritePre", { - group = vim.api.nvim_create_augroup("user_lsp_safe_actions", { clear = true }), - desc = "Apply all safe LSP code actions (source.*, quickfix) before save", - callback = function(args) - if vim.bo[args.buf].buftype ~= "" then return end - if vim.g.disable_autoformat or vim.b[args.buf].disable_autoformat then return end - apply_safe_actions(args.buf) - end, - }) - end, opts = function(_, opts) -- Python: ruff sorts imports (I001), then reformats. ruff always emits -- 4-space indentation, so mixed tab/space indentation is normalized on @@ -107,14 +23,23 @@ return { if not opts.formatters_by_ft then opts.formatters_by_ft = {} end opts.formatters_by_ft.python = { "ruff_organize_imports", "ruff_format" } - -- Skip format-on-save for JavaScript buffers (.js/.mjs/.cjs); manual - -- :Format / lf still works. Wraps the astrocommunity pack's - -- format_on_save so its autoformat toggle handling is preserved. + -- The pack's function returns nil when autoformat is toggled off + -- (uf buffer / uF global), so one toggle governs the + -- whole pipeline: no code actions and no formatting. local pack_format_on_save = opts.format_on_save opts.format_on_save = function(bufnr) + -- NOT `f(bufnr) or f`: that resolves to the function itself when the + -- call returns nil, and conform then gets a function as format opts + local format = pack_format_on_save + if type(format) == "function" then format = format(bufnr) end + if not format then return end + + require("config.lsp_code_actions").apply(bufnr) + + -- Skip formatting JavaScript buffers (.js/.mjs/.cjs); the code actions + -- above still run, and manual :Format / lf still works. if vim.bo[bufnr].filetype == "javascript" then return end - if type(pack_format_on_save) == "function" then return pack_format_on_save(bufnr) end - return pack_format_on_save + return format end end, }, diff --git a/lua/plugins/external-tui.lua b/lua/plugins/external-tui.lua deleted file mode 100644 index 26bb913..0000000 --- a/lua/plugins/external-tui.lua +++ /dev/null @@ -1,30 +0,0 @@ ---- External TUIs without a dedicated snacks.nvim integration (nvim-external-tui). ---- Tools that DO have a native snacks path (lazygit -> Snacks.lazygit, ---- lazydocker -> Snacks.terminal) are wired directly in astrocore.lua. ----@type LazySpec -return { - "gfontenot/nvim-external-tui", - dependencies = { "folke/snacks.nvim" }, - event = "VeryLazy", - config = function() - local external_tui = require "external-tui" - - external_tui.setup { - -- the `scooter` win style (snacks opts.styles in user.lua) renders the - -- terminal as a borderless right-hand 30% split - terminal_provider = { - snacks = { win = { style = "scooter" } }, - }, - } - - -- :Scooter — find & replace TUI; picking a result closes the terminal - -- and opens the file at that line in this nvim instance. Visual range - -- (:'<,'>Scooter) and args (:Scooter term) prefill the search text. - external_tui.add { - user_cmd = "Scooter", - cmd = "scooter", - text_flag = "--search-text", - editor_flag = "--editor-command", -- scooter >= 0.8.4 - } - end, -} diff --git a/lua/plugins/grug-far.lua b/lua/plugins/grug-far.lua new file mode 100644 index 0000000..a57da25 --- /dev/null +++ b/lua/plugins/grug-far.lua @@ -0,0 +1,22 @@ +--- grug-far.nvim: buffer-based find & replace, backing the binds in +--- plugins/astrocore.lua. Replaces the scooter TUI (nvim-external-tui, gone). +--- Buffer-local actions hang off (`,`): `,r` replace, +--- `,s` sync all lines, `,l` sync line, `,q` quickfix, `,c` close, `g?` help. +--- @module plugins.grug-far + +return { + "MagicDuck/grug-far.nvim", + cmd = { "GrugFar", "GrugFarWithin" }, + opts = { + -- ripgrep only: no ast-grep binary on this machine, so keep the + -- astgrep engines out of the Swap Engine (`,e`) cycle + enabledEngines = { "ripgrep" }, + + -- right-hand split, matching where scooter used to open + windowCreationCommand = "botright vsplit", + + -- linewise selection (V) limits search/replace to that range, + -- charwise selection (v) prefills the search input instead + visualSelectionUsage = "auto-detect", + }, +} diff --git a/lua/plugins/python.lua b/lua/plugins/python.lua new file mode 100644 index 0000000..ac05654 --- /dev/null +++ b/lua/plugins/python.lua @@ -0,0 +1,88 @@ +--- Python: interpreter resolution for basedpyright. +--- +--- The astrocommunity basedpyright subpack pins `python.pythonPath` to +--- `exepath "python"`, i.e. the system interpreter. Builtins and the stdlib +--- (`enumerate`, `collections.Counter`, ...) come from basedpyright's bundled +--- typeshed either way, but third-party imports only resolve — and only +--- complete — when the server points at the environment that has them, which +--- for a uv/poetry/venv project is the project's own virtualenv. +--- +--- Resolution order: activated environment ($VIRTUAL_ENV / $CONDA_PREFIX) -> +--- a virtualenv inside the LSP root -> system python3. +--- `lv` (venv-selector, from the base pack) still overrides at runtime. +--- @module plugins.python + +local VENV_DIRS = { ".venv", "venv", "env" } +local ROOT_MARKERS = { "pyproject.toml", "uv.lock", "setup.py", "setup.cfg", "requirements.txt", ".git" } + +--- @param path string? candidate interpreter +--- @return string? path when it is an executable file +local function executable(path) + if not path or path == "" then return nil end + local stat = vim.uv.fs_stat(path) + if stat and stat.type == "file" and vim.fn.executable(path) == 1 then return path end + return nil +end + +--- @param root string? project root +--- @return string interpreter absolute path or bare name +local function python_path(root) + local activated = vim.env.VIRTUAL_ENV or vim.env.CONDA_PREFIX + local found = activated and executable(activated .. "/bin/python") + if found then return found end + + if root then + for _, dir in ipairs(VENV_DIRS) do + found = executable(root .. "/" .. dir .. "/bin/python") + if found then return found end + end + end + + return executable(vim.fn.exepath "python3") or "python3" +end + +local warned = {} ---@type table roots already warned about + +--- A locked project with no virtualenv checked out resolves against the system +--- interpreter, where its dependencies are missing. That reads as "the LSP is +--- broken" rather than "nothing is installed", so say it out loud once. +--- @param root string? +--- @param interpreter string +local function warn_unsynced_project(root, interpreter) + if not root or warned[root] or interpreter:find(root, 1, true) then return end + local lock = vim.uv.fs_stat(root .. "/uv.lock") or vim.uv.fs_stat(root .. "/poetry.lock") + if not lock then return end + warned[root] = true + vim.schedule( + function() + vim.notify( + ("basedpyright: no virtualenv in %s, using %s.\nRun `uv sync` for third-party completions.") + :format(vim.fn.fnamemodify(root, ":~"), interpreter), + vim.log.levels.WARN + ) + end + ) +end + +---@type LazySpec +return { + "AstroNvim/astrolsp", + optional = true, + ---@type AstroLSPOpts + opts = { + config = { + basedpyright = { + before_init = function(_, config) + local root = config.root_dir or vim.fs.root(0, ROOT_MARKERS) + local interpreter = python_path(root) + warn_unsynced_project(root, interpreter) + config.settings = vim.tbl_deep_extend( + "force", + config.settings or {}, + { python = { pythonPath = interpreter } } + ) + end, + }, + }, + }, +} diff --git a/lua/plugins/user.lua b/lua/plugins/user.lua index f6272fc..58c8b79 100644 --- a/lua/plugins/user.lua +++ b/lua/plugins/user.lua @@ -63,18 +63,8 @@ return { gh = {}, -- auto-highlight LSP references of the symbol under the cursor; - -- ]] / [[ jump binds live in polish.lua (Snacks.keymap, LSP-gated) + -- ]] / [[ jump binds live in plugins/astrolsp.lua (capability-gated) words = { enabled = true }, - - styles = { - -- right-hand 30% split used by :Scooter (external-tui snacks terminal) - scooter = { - position = "right", - width = 0.3, - height = 0, - border = "none", - }, - }, }, },