From 99123170547ec000179dbf1a41021d770abb5071 Mon Sep 17 00:00:00 2001 From: Tristan Jahnke Date: Tue, 27 Oct 2026 16:20:12 -0500 Subject: [PATCH] fix: cleanup and better info Signed-off-by: Tristan Jahnke --- .codebook.toml | 125 +++++++++---------- devenv.nix | 21 ---- lua/autocmds.lua | 67 ++--------- lua/utils/gostructfields.lua | 219 ++++++++++++++++++++++++++++++++++ nvim-pack-lock.json | 33 ++++- plugins/atlas.lua | 6 +- plugins/autopair.lua | 20 +++- plugins/blink.lua | 132 ++++++++++---------- plugins/conform.lua | 44 ++++++- plugins/godoc.lua | 45 +++++++ plugins/gopher.lua | 161 +++++++++++++------------ plugins/lspconfig.lua | 102 ++++++++++++---- plugins/mason.lua | 1 + plugins/nvim-treesitter.lua | 3 + plugins/snacks.lua | 19 ++- plugins/tiny_code_actions.lua | 6 + plugins/tiny_inline_diags.lua | 15 +++ plugins/treesj.lua | 7 ++ 18 files changed, 712 insertions(+), 314 deletions(-) create mode 100644 lua/utils/gostructfields.lua create mode 100644 plugins/godoc.lua create mode 100644 plugins/tiny_code_actions.lua create mode 100644 plugins/tiny_inline_diags.lua create mode 100644 plugins/treesj.lua diff --git a/.codebook.toml b/.codebook.toml index b71134b..dd7c6b9 100644 --- a/.codebook.toml +++ b/.codebook.toml @@ -1,64 +1,65 @@ words = [ - "autocmd", - "bigfile", - "keymapdoc", - "bufferline", - "bufmap", - "bufnr", - "buftype", - "catppuccin", - "codelenses", - "copyfile", - "devenv", - "direnv", - "errnm", - "etype", - "filecopy", - "fileformat", - "filekey", - "fnamemodify", - "gitsigns", - "glab", - "globalstatus", - "gopls", - "hardlinks", - "inlinedoc", - "isdirectory", - "iswin", - "joinpath", - "justinmk", - "keymaps", - "lazygit", - "lnum", - "lpeg", - "luacheck", - "lualine", - "luarc", - "neotest", - "neovim", - "netrw", - "nvim", - "nvim's", - "oldfiles", - "performantly", - "pyproject", - "pyright", - "quickfile", - "readblob", - "realpath", - "relpath", - "ripgrep", - "scandir", - "startinsert", - "statuscolumn", - "statusline", - "stdpath", - "stylua", - "sysname", - "tabline", - "tmux", - "tst", - "unc", - "winbar", - "xdg", + "autocmd", + "bigfile", + "bufferline", + "bufmap", + "bufnr", + "buftype", + "catppuccin", + "codelenses", + "copyfile", + "devenv", + "direnv", + "errnm", + "etype", + "filecopy", + "fileformat", + "filekey", + "fnamemodify", + "gitsigns", + "glab", + "globalstatus", + "gopls", + "hardlinks", + "inlinedoc", + "isdirectory", + "iswin", + "joinpath", + "justinmk", + "keymapdoc", + "keymaps", + "lazygit", + "lnum", + "lpeg", + "luacheck", + "lualine", + "luarc", + "neotest", + "neovim", + "netrw", + "noremap", + "nvim", + "nvim's", + "oldfiles", + "performantly", + "pyproject", + "pyright", + "quickfile", + "readblob", + "realpath", + "relpath", + "ripgrep", + "scandir", + "startinsert", + "statuscolumn", + "statusline", + "stdpath", + "stylua", + "sysname", + "tabline", + "tmux", + "tst", + "unc", + "winbar", + "xdg", ] diff --git a/devenv.nix b/devenv.nix index 9d22604..7b54167 100644 --- a/devenv.nix +++ b/devenv.nix @@ -7,23 +7,8 @@ # https://devenv.sh/packages/ packages = [ pkgs.git ]; - # https://devenv.sh/languages/ - # languages.rust.enable = true; - - # https://devenv.sh/processes/ - # processes.dev.exec = "${lib.getExe pkgs.watchexec} -n -- ls -la"; - - # https://devenv.sh/services/ - # services.postgres.enable = true; - - # https://devenv.sh/scripts/ - scripts.hello.exec = '' - echo hello from $GREET - ''; - # https://devenv.sh/basics/ enterShell = '' - hello # Run scripts directly git --version # Use packages ''; @@ -38,9 +23,3 @@ echo "Running tests" git --version | grep --color=auto "${pkgs.git.version}" ''; - - # https://devenv.sh/git-hooks/ - # git-hooks.hooks.shellcheck.enable = true; - - # See full reference at https://devenv.sh/reference/options/ -} diff --git a/lua/autocmds.lua b/lua/autocmds.lua index eb1e08c..5bf6cc8 100644 --- a/lua/autocmds.lua +++ b/lua/autocmds.lua @@ -10,68 +10,23 @@ local augroup = vim.api.nvim_create_augroup("UserConfig", { clear = true }) vim.filetype.add({ extension = { tofu = "terraform", + -- Go templates (gopls + treesitter `gotmpl`). + gotmpl = "gotmpl", + tmpl = "gotmpl", }, pattern = { [".*%.tofu%.json"] = "json", + -- Helm chart templates (treesitter `helm`); only inside a chart, so other + -- templates/ dirs stay yaml. + [".*/templates/.*%.ya?ml"] = function(path) + return vim.fs.root(path, "Chart.yaml") and "helm" or nil + end, + [".*/templates/.*%.tpl"] = function(path) + return vim.fs.root(path, "Chart.yaml") and "helm" or nil + end, }, }) --- Format on save (ONLY real file buffers, ONLY when efm is attached) -vim.api.nvim_create_autocmd("BufWritePre", { - group = augroup, - pattern = { - "*.lua", - "*.py", - "*.go", - "*.js", - "*.jsx", - "*.ts", - "*.tsx", - "*.json", - "*.css", - "*.scss", - "*.html", - "*.sh", - "*.bash", - "*.zsh", - "*.c", - "*.cpp", - "*.h", - "*.hpp", - }, - callback = function(args) - -- avoid formatting non-file buffers (helps prevent weird write prompts) - if vim.bo[args.buf].buftype ~= "" then - return - end - if not vim.bo[args.buf].modifiable then - return - end - if vim.api.nvim_buf_get_name(args.buf) == "" then - return - end - - local has_efm = false - for _, c in ipairs(vim.lsp.get_clients({ bufnr = args.buf })) do - if c.name == "efm" then - has_efm = true - break - end - end - if not has_efm then - return - end - - pcall(vim.lsp.buf.format, { - bufnr = args.buf, - timeout_ms = 2000, - filter = function(c) - return c.name == "efm" - end, - }) - end, -}) - -- highlight yanked text vim.api.nvim_create_autocmd("TextYankPost", { group = augroup, diff --git a/lua/utils/gostructfields.lua b/lua/utils/gostructfields.lua new file mode 100644 index 0000000..a83a6a0 --- /dev/null +++ b/lua/utils/gostructfields.lua @@ -0,0 +1,219 @@ +--- Insert-mode overlay listing the fields a Go struct literal can still set. +--- +--- While the cursor is inside `T{...}` (also `&T{...}` and elided `[]T{{...}}`), +--- a float at the window's right edge lists the fields not set yet, with their +--- types and the first sentence of their doc comments. The list comes from +--- gopls' completions at the literal's `{`, so it works on unsaved code, +--- embedded fields, imported and generic types; fields drop off as you set +--- them. Shown only while inlay hints are on (th). +--- +--- Enabled per buffer from LspAttach when gopls attaches (plugins/lspconfig.lua). +local M = {} + +local FIELD = vim.lsp.protocol.CompletionItemKind.Field +local MAX_HEIGHT = 12 +local MAX_DOC = 50 -- characters of doc comment per field +local MAX_TYPE_ALIGN = 24 -- longer types (func signatures) are not padded +local ns = vim.api.nvim_create_namespace("gostructfields") +local timer = assert(vim.uv.new_timer()) +local state = { win = nil, buf = nil, key = nil, seq = 0 } + +local function close() + state.key = nil + state.seq = state.seq + 1 -- drop in-flight responses + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_win_close(state.win, true) + end + state.win = nil +end + +--- First sentence of a field's doc comment, without the leading field name +--- Go docs start with ("Addr optionally specifies ..." -> "optionally ..."). +---@param doc? string|lsp.MarkupContent +---@param name string +---@return string +local function summary(doc, name) + local text = type(doc) == "table" and doc.value or doc or "" + text = vim.split(vim.trim(text), "\n")[1] + text = text:match("^(.-%.)%s") or text + if vim.startswith(text, name .. " ") then + text = text:sub(#name + 2) + end + return vim.fn.strcharlen(text) > MAX_DOC and vim.fn.strcharpart(text, 0, MAX_DOC - 1) .. "…" or text +end + +---@param title string +---@param fields string[][] { name, type, doc } triples +local function show(title, fields) + if #fields == 0 then + return close() + end + -- gopls ranks by relevance; sort for a stable list (exported fields first). + table.sort(fields, function(a, b) + return a[1] < b[1] + end) + + -- Columns: name, type (aligned up to MAX_TYPE_ALIGN), `// doc`. The float + -- takes at most 60% of the window; docs are shortened to fit, then dropped. + local win = vim.api.nvim_get_current_win() + local maxw = math.floor(vim.api.nvim_win_get_width(win) * 0.6) + local namew, typew = 0, 0 + for _, f in ipairs(fields) do + namew = math.max(namew, #f[1]) + typew = math.max(typew, math.min(#f[2], MAX_TYPE_ALIGN)) + end + local shown = #fields > MAX_HEIGHT and MAX_HEIGHT - 1 or #fields + local lines, docs = {}, {} + for i = 1, shown do + local name, typ, doc = unpack(fields[i]) + local line = (" %-" .. namew .. "s %-" .. typew .. "s"):format(name, typ) + local room = maxw - vim.fn.strdisplaywidth(line) - 6 -- " // " and a trailing space + if vim.fn.strcharlen(doc) > room then + doc = room >= 10 and vim.fn.strcharpart(doc, 0, room - 1) .. "…" or "" + end + docs[i] = doc ~= "" and #line + 2 or nil -- byte column of `//` + lines[i] = (doc ~= "" and line .. " // " .. doc or line:gsub("%s+$", "")) .. " " + end + if shown < #fields then + table.insert(lines, (" … %d more "):format(#fields - shown)) + end + + if not (state.buf and vim.api.nvim_buf_is_valid(state.buf)) then + state.buf = vim.api.nvim_create_buf(false, true) + end + vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, lines) + vim.api.nvim_buf_clear_namespace(state.buf, ns, 0, -1) + for i = 1, shown do + local type_start = 1 + namew + 2 + vim.hl.range(state.buf, ns, "@variable.member", { i - 1, 1 }, { i - 1, 1 + #fields[i][1] }) + vim.hl.range(state.buf, ns, "@type", { i - 1, type_start }, { i - 1, type_start + #fields[i][2] }) + if docs[i] then + vim.hl.range(state.buf, ns, "Comment", { i - 1, docs[i] }, { i - 1, #lines[i] }) + end + end + if shown < #fields then + vim.hl.range(state.buf, ns, "Comment", { shown, 0 }, { shown, #lines[#lines] }) + end + + -- Right edge of the window, below the cursor line (above if no room), so it + -- stays clear of the completion menu and signature help at the cursor. + local width = 0 + for _, line in ipairs(lines) do + width = math.max(width, vim.fn.strdisplaywidth(line)) + end + width = math.min(width, maxw) + local height = #lines + local below = vim.fn.winline() + height + 2 <= vim.api.nvim_win_get_height(win) + local config = { + relative = "win", + win = win, + anchor = below and "NE" or "SE", + row = below and vim.fn.winline() or vim.fn.winline() - 1, + col = vim.api.nvim_win_get_width(win), + width = width, + height = height, + style = "minimal", + border = "rounded", + title = " " .. title .. " ", + title_pos = "center", + focusable = false, + zindex = 45, + } + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_win_set_config(state.win, config) + else + config.noautocmd = true + state.win = vim.api.nvim_open_win(state.buf, false, config) + end +end + +---@param buf integer +local function update(buf) + if + buf ~= vim.api.nvim_get_current_buf() + or vim.fn.mode() ~= "i" + or not vim.lsp.inlay_hint.is_enabled({ bufnr = buf }) + then + return close() + end + local client = vim.lsp.get_clients({ bufnr = buf, name = "gopls" })[1] + local ok, parser = pcall(vim.treesitter.get_parser, buf, "go") + if not (client and ok and parser) then + return close() + end + parser:parse() + + -- Innermost `{...}` around the cursor. + local lit = vim.treesitter.get_node({ bufnr = buf, ignore_injections = true }) + while lit and lit:type() ~= "literal_value" do + lit = lit:parent() + end + if not lit then + return close() + end + + local row, col = lit:start() + local key = ("%d:%d:%d"):format(row, col, vim.b[buf].changedtick) + if key == state.key then + return + end + state.key = key + state.seq = state.seq + 1 + local seq = state.seq + + local parent = lit:parent() + local type_node = parent and parent:type() == "composite_literal" and parent:field("type")[1] + local title = type_node and vim.treesitter.get_node_text(type_node, buf) or "fields" + + local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, true)[1] + local params = { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = row, character = vim.str_utfindex(line, client.offset_encoding, col + 1, false) }, + context = { triggerKind = vim.lsp.protocol.CompletionTriggerKind.Invoked }, + } + client:request("textDocument/completion", params, function(err, result) + vim.schedule(function() + if seq ~= state.seq or err or not result or vim.fn.mode() ~= "i" then + return + end + local fields, seen = {}, {} + for _, item in ipairs(result.items or result) do + -- Skip deep completions like `u.Name` (fields of in-scope variables). + if item.kind == FIELD and not item.label:find(".", 1, true) and not seen[item.label] then + seen[item.label] = true + table.insert(fields, { item.label, item.detail or "", summary(item.documentation, item.label) }) + end + end + show(title, fields) + end) + end, buf) +end + +--- Enable the overlay for a Go buffer. +---@param buf integer +function M.attach(buf) + local group = vim.api.nvim_create_augroup("gostructfields_" .. buf, { clear = true }) + vim.api.nvim_create_autocmd({ "InsertEnter", "CursorMovedI", "TextChangedI" }, { + group = group, + buffer = buf, + callback = function() + timer:start( + 80, + 0, + vim.schedule_wrap(function() + update(buf) + end) + ) + end, + }) + vim.api.nvim_create_autocmd({ "InsertLeave", "BufLeave" }, { + group = group, + buffer = buf, + callback = function() + timer:stop() + close() + end, + }) +end + +return M diff --git a/nvim-pack-lock.json b/nvim-pack-lock.json index 2627716..d35e32c 100644 --- a/nvim-pack-lock.json +++ b/nvim-pack-lock.json @@ -6,9 +6,13 @@ "version": "2.0.0 - 3.0.0" }, "atlas.nvim": { - "rev": "fb40953462ff922c1084f1338072588c6e0eb0bf", + "rev": "4cf907ea0077c15a525121d428ced231e28c2b09", "src": "https://github.com/emrearmagan/atlas.nvim" }, + "blink-ripgrep.nvim": { + "rev": "bb0f3061e50270434a4e2664cb79ca1d6c26b9c3", + "src": "https://github.com/mikavilpas/blink-ripgrep.nvim" + }, "blink.cmp": { "rev": "78336bc89ee5365633bcf754d93df01678b5c08f", "src": "https://github.com/saghen/blink.cmp", @@ -28,7 +32,7 @@ "src": "https://github.com/j-hui/fidget.nvim" }, "friendly-snippets": { - "rev": "b4d01b0fdf3c9a549961c2f9ffe8dc09be166219", + "rev": "b2ea5c41a150ba72fb656eedb4ed0a86d138d945", "src": "https://github.com/rafamadriz/friendly-snippets" }, "gitsigns.nvim": { @@ -36,6 +40,11 @@ "src": "https://github.com/lewis6991/gitsigns.nvim", "version": "'v2.1.0'" }, + "godoc.nvim": { + "rev": "54a7c3b14f6391a299b94db7c18d3a9ee1e31bde", + "src": "https://github.com/fredrikaverpil/godoc.nvim", + "version": ">=0.0.0" + }, "gopher.nvim": { "rev": "f397bed1c35f29f4f85d60a210650e677a8c5cb4", "src": "https://github.com/olexsmir/gopher.nvim", @@ -50,7 +59,7 @@ "src": "https://github.com/nvim-lualine/lualine.nvim" }, "mason-lspconfig.nvim": { - "rev": "e17625cdc8c2650787467398e02c5d63a2cde966", + "rev": "a338f44149f5673882c32629697d67b0c0547aec", "src": "https://github.com/mason-org/mason-lspconfig.nvim" }, "mason.nvim": { @@ -111,7 +120,7 @@ "src": "https://github.com/theHamsta/nvim-dap-virtual-text" }, "nvim-lspconfig": { - "rev": "6d27c288ff951e1e94cb5f91c800adeddf0dc0c2", + "rev": "77b7432d4770d23b277d37923e5a08a442d8bb07", "src": "https://github.com/neovim/nvim-lspconfig" }, "nvim-nio": { @@ -120,7 +129,7 @@ "version": "1.0.0 - 2.0.0" }, "nvim-treesitter": { - "rev": "1a4f47b03dfe92d28de7d5654c67327ef008c9e6", + "rev": "a2f694478be07f89c2d03cc17858a8745a400c4b", "src": "https://github.com/nvim-treesitter/nvim-treesitter", "version": "'main'" }, @@ -148,13 +157,25 @@ "snacks.nvim": { "rev": "e6fd58c82f2f3fcddd3fe81703d47d6d48fc7b9f", "src": "https://github.com/folke/snacks.nvim", - "version": "'v2.31.0'" + "version": ">=0.0.0" + }, + "tiny-code-action.nvim": { + "rev": "91a9c32228e9a7761d241023d9ebb9d11d8fb10d", + "src": "https://github.com/rachartier/tiny-code-action.nvim" + }, + "tiny-inline-diagnostic.nvim": { + "rev": "46ae9b3c8f3039e96ff4497d190ad0aee091f30d", + "src": "https://github.com/rachartier/tiny-inline-diagnostic.nvim" }, "todo-comments.nvim": { "rev": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668", "src": "https://github.com/folke/todo-comments.nvim", "version": "'v1.5.0'" }, + "treesj": { + "rev": "9f349ba62c524f065d6f43254d731ddf8319b9bf", + "src": "https://github.com/Wansmer/treesj" + }, "trouble.nvim": { "rev": "bd67efe408d4816e25e8491cc5ad4088e708a69a", "src": "https://github.com/folke/trouble.nvim", diff --git a/plugins/atlas.lua b/plugins/atlas.lua index c484741..5a77fba 100644 --- a/plugins/atlas.lua +++ b/plugins/atlas.lua @@ -8,9 +8,9 @@ return { pulls = { default_merge_method = "squash", default_delete_source_branch = true, - description = { - include_diffstat = true, - }, + -- description = { + -- include_diffstat = true, + -- }, diff = { review_panel = { hidden = false, diff --git a/plugins/autopair.lua b/plugins/autopair.lua index a9bbd97..bc1c0e7 100644 --- a/plugins/autopair.lua +++ b/plugins/autopair.lua @@ -2,6 +2,24 @@ return { src = "https://github.com/windwp/nvim-autopairs", version = "0.10.0", config = function() - require("nvim-autopairs").setup() + local npairs = require("nvim-autopairs") + local Rule = require("nvim-autopairs.rule") + local cond = require("nvim-autopairs.conds") + local utils = require("nvim-autopairs.utils") + npairs.setup() + + -- Go struct tags (`json:"id"`): the default `"` rule never pairs inside a + -- backtick string (it counts ` as a quote) or right before a closing `. + -- Go gets its own rule that only refuses to pair inside a "..." string. + npairs.get_rules('"')[1].not_filetypes = { "vim", "go" } + npairs.add_rule(Rule('"', '"', "go") + :with_pair(function(opts) + if utils.is_in_quotes(opts.text, opts.col - 1, '"') then + return false + end + end) + :with_pair(cond.not_after_regex("[%w%%'%[\"%.%$]")) + :with_move(cond.move_right()) + :use_undo(true)) end, } diff --git a/plugins/blink.lua b/plugins/blink.lua index a09fa80..ddd3926 100644 --- a/plugins/blink.lua +++ b/plugins/blink.lua @@ -3,75 +3,79 @@ -- =================================Keymaps End================================= return { - src = "https://github.com/saghen/blink.cmp", - version = vim.version.range("1.*"), - config = function() - require("blink.cmp").setup({ - keymap = { - preset = "default", - -- Jump through snippet tabstops (LuaSnip). Falls back to normal - -- Tab behaviour when not inside a snippet. - [""] = { "snippet_forward", "fallback" }, - [""] = { "snippet_backward", "fallback" }, - }, - appearance = { - nerd_font_variant = "mono", - }, - sources = { - default = { "lsp", "path", "snippets", "buffer" }, - providers = { - -- Rank LSP results above everything else, then snippets, then - -- path, then plain buffer words. This is what makes the menu feel - -- "smart" without any AI source. - lsp = { score_offset = 10 }, - snippets = { score_offset = 8 }, - path = { score_offset = 5 }, - buffer = { - -- Buffer words are the weakest signal, so only offer them once a - -- few characters are typed (keeps short LSP matches at the top). - min_keyword_length = 4, - score_offset = 0, - opts = { - -- Pull words from every loaded normal-file buffer, not just the - -- current one — handy when jumping between related files. - get_bufnrs = function() - return vim.tbl_filter(function(bufnr) - return vim.bo[bufnr].buftype == "" - end, vim.api.nvim_list_bufs()) - end, + { + src = "https://github.com/saghen/blink.cmp", + version = vim.version.range("1.*"), + config = function() + require("blink.cmp").setup({ + keymap = { + preset = "default", + -- Jump through snippet tabstops (LuaSnip). Falls back to normal + -- Tab behaviour when not inside a snippet. + [""] = { "snippet_forward", "fallback" }, + [""] = { "snippet_backward", "fallback" }, + }, + appearance = { + nerd_font_variant = "mono", + }, + sources = { + default = { "lsp", "snippets", "buffer", "ripgrep", "path" }, + providers = { + lsp = { score_offset = 10 }, + snippets = { score_offset = 8 }, + ripgrep = { + score_offset = 7, + module = "blink-ripgrep", + name = "Ripgrep", + opts = {}, + }, + path = { score_offset = 5 }, + buffer = { + min_keyword_length = 4, + score_offset = 0, + opts = { + get_bufnrs = function() + return vim.tbl_filter(function(bufnr) + return vim.bo[bufnr].buftype == "" + end, vim.api.nvim_list_bufs()) + end, + }, }, }, }, - }, - completion = { - documentation = { - auto_show = true, - auto_show_delay_ms = 200, - update_delay_ms = 50, - window = { + completion = { + documentation = { + auto_show = true, + auto_show_delay_ms = 200, + update_delay_ms = 50, + window = { + border = "rounded", + }, + }, + ghost_text = { enabled = true, show_without_selection = true }, + menu = { border = "rounded", + auto_show = true, + max_height = 10, + scrolloff = 2, + scrollbar = true, + draw = { + treesitter = { "lsp" }, + columns = { { "label", "label_description", gap = 1 }, { "kind_icon", "kind" } }, + }, }, }, - ghost_text = { enabled = true, show_without_selection = true }, - menu = { - border = "rounded", - auto_show = true, - max_height = 10, - scrolloff = 2, - scrollbar = true, - draw = { - treesitter = { "lsp" }, - columns = { { "label", "label_description", gap = 1 }, { "kind_icon", "kind" } }, - }, + signature = { + enabled = true, + trigger = { show_on_insert = true }, + window = { border = "rounded" }, }, - }, - signature = { - enabled = true, - trigger = { show_on_insert = true }, - window = { border = "rounded" }, - }, - snippets = { preset = "luasnip" }, - fuzzy = { implementation = "prefer_rust" }, - }) - end, + snippets = { preset = "luasnip" }, + fuzzy = { implementation = "prefer_rust" }, + }) + end, + }, + { + src = "https://github.com/mikavilpas/blink-ripgrep.nvim", + }, } diff --git a/plugins/conform.lua b/plugins/conform.lua index 9ef60d8..e2ac1d9 100644 --- a/plugins/conform.lua +++ b/plugins/conform.lua @@ -1,3 +1,25 @@ +--- Formatting on save. Go: goimports (adds/removes imports), then either +--- gofumpt, or `golangci-lint fmt` when the repo's golangci config defines +--- formatters, so saved files match what CI lint expects. + +local GOLANGCI_CONFIGS = { ".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json" } + +--- Nearest golangci-lint config above `dirname` that has a (v2) top-level +--- `formatters` section, or nil. +---@param dirname string +---@return string? +local function golangci_fmt_config(dirname) + local cfg = vim.fs.find(GOLANGCI_CONFIGS, { path = dirname, upward = true })[1] + if not cfg then + return nil + end + for line in io.lines(cfg) do + if line:match("^formatters:") or line:match("^%[formatters[%].]") or line:match('^%s*"formatters"%s*:') then + return cfg + end + end +end + return { src = "https://github.com/stevearc/conform.nvim", version = "v9.1.0", @@ -7,7 +29,7 @@ return { ["_"] = { "trim_whitespace" }, bash = { "shfmt" }, elixir = { "mix" }, - go = { "goimports", "gofumpt" }, + go = { "goimports", "gofumpt", "golangci-lint" }, json = { "jq" }, lua = { "stylua" }, markdown = { "markdownlint" }, @@ -17,6 +39,22 @@ return { toml = { "taplo" }, yaml = { "yamlfmt" }, }, + formatters = { + gofumpt = { + condition = function(_, ctx) + return golangci_fmt_config(ctx.dirname) == nil + end, + }, + ["golangci-lint"] = { + condition = function(_, ctx) + return golangci_fmt_config(ctx.dirname) ~= nil + end, + -- `fmt --stdin` reads the config from its working directory. + cwd = function(_, ctx) + return vim.fs.dirname(golangci_fmt_config(ctx.dirname)) + end, + }, + }, format_on_save = function(bufnr) -- Don't format .norg files (Neorg handles its own formatting) local bufname = vim.api.nvim_buf_get_name(bufnr) @@ -25,7 +63,9 @@ return { end return { lsp_format = "fallback", - timeout_ms = 500, + -- goimports may scan the module cache for missing imports, and Go + -- runs two formatters in a row; 500ms could skip formatting. + timeout_ms = vim.bo[bufnr].filetype == "go" and 2000 or 500, } end, notify_on_error = true, diff --git a/plugins/godoc.lua b/plugins/godoc.lua new file mode 100644 index 0000000..5773de2 --- /dev/null +++ b/plugins/godoc.lua @@ -0,0 +1,45 @@ +return { + src = "https://github.com/fredrikaverpil/godoc.nvim", + version = vim.version.range("*"), + config = function() + require("godoc").setup({ + adapters = { + { + name = "go", + opts = { + command = "GoDoc", + get_syntax_info = function() + return { + filetype = "godoc", + language = "godoc", + } + end, + }, + }, + }, + window = { + type = "split", + }, + picker = { + type = "snacks", + snacks = {}, + }, + }) + end, + + vim.api.nvim_set_keymap("n", "Gd", "GoDoc", { desc = "Search GoDoc" }), + + vim.api.nvim_create_autocmd("PackChanged", { + callback = function(ev) + if ev.data.spec.name == "godoc.nvim" and (ev.data.kind == "install" or ev.data.kind == "update") then + vim + .system({ + "go", + "install", + "github.com/lotusirous/gostdsym/stdsym@latest", + }) + :wait() + end + end, + }), +} diff --git a/plugins/gopher.lua b/plugins/gopher.lua index 3ec05aa..06b48b5 100644 --- a/plugins/gopher.lua +++ b/plugins/gopher.lua @@ -12,19 +12,25 @@ --- s JSON -> struct --- tests t test for func · T tests for file · E tests for exported --- o switch between foo.go / foo_test.go ---- docs d full `go doc` for symbol under cursor in a split (q closes) --- go commands m go mod tidy · g go get (prompt) · G go generate (file) +--- b go build ./... · v go vet ./... (errors -> quickfix) +--- x go run . (terminal split, package of the current file) return { src = "https://github.com/olexsmir/gopher.nvim", version = vim.version.range("0.*"), config = function() require("gopher").setup({}) - --- Prompt for arguments, then run `cmd `; keeps a visual range. - ---@param cmd string Ex command name - ---@param prompt string + --- Mapping that runs `[range]cmd [input]`, keeping a visual range. + --- `prompt` asks for the input first. `save` writes the buffer before + --- running: gomodifytags (GoTag*) reads the file from disk, so unsaved + --- edits make it fail ("struct name does not exist") or splice its + --- output over the wrong lines. + ---@param cmd string Ex command, optionally with fixed arguments + ---@param opts? { prompt?: string, save?: boolean } ---@return function - local function prompt_cmd(cmd, prompt) + local function go_cmd(cmd, opts) + opts = opts or {} return function() local range = "" if vim.fn.mode():match("[vV\22]") then @@ -32,70 +38,62 @@ return { range = math.min(s, e) .. "," .. math.max(s, e) vim.api.nvim_feedkeys(vim.keycode(""), "nx", false) end - vim.ui.input({ prompt = prompt }, function(input) + local function run(input) + if opts.save then + vim.cmd("silent update") + -- Format-on-save may have changed the buffer; refresh the tree + -- gopher uses to find the struct under the cursor. + vim.treesitter.get_parser():parse() + end + vim.cmd(range .. cmd .. (input and " " .. input or "")) + end + if not opts.prompt then + return run() + end + vim.ui.input({ prompt = opts.prompt }, function(input) if input ~= nil then - vim.cmd(range .. cmd .. " " .. input) + run(input) end end) end end - --- Full `go doc -all` for the symbol under the cursor, in a reusable split. - --- Unlike K (hover), this includes every method/field doc of a type, or the - --- whole package when the cursor is on a package name. gopls resolves the - --- symbol (aliases, dependencies, local packages) via the pkg.go.dev link - --- it puts in hover text; `go doc` then runs from the file's directory so - --- module dependencies resolve. + --- Run `go ` from the buffer's module root and load the errors into + --- the quickfix list (opened when there are any). ---@param buf integer - local function godoc_split(buf) - local params = vim.lsp.util.make_position_params(0, "utf-16") - vim.lsp.buf_request_all(buf, "textDocument/hover", params, function(results) - local target - for _, res in pairs(results) do - local value = vim.tbl_get(res, "result", "contents", "value") or "" - local path, sym = value:match("https://pkg%.go%.dev/([^#%)]+)#?([%w_%.]*)") - if path then - -- Drop a module version (github.com/x/y@v1.2.3/sub -> github.com/x/y/sub). - target = { path:gsub("@[^/]+", ""), sym ~= "" and sym or nil } - break - end - end - if not target then - vim.notify("No Go documentation for symbol under cursor", vim.log.levels.WARN) - return - end - - local cmd = vim.list_extend({ "go", "doc", "-all" }, target) - local dir = vim.fs.dirname(vim.api.nvim_buf_get_name(buf)) - vim.system(cmd, { cwd = dir, text = true }, function(out) - vim.schedule(function() - if out.code ~= 0 then - vim.notify(vim.trim(out.stderr), vim.log.levels.WARN) - return + ---@param args string[] + local function go_quickfix(buf, args) + local root = vim.fs.root(buf, "go.mod") or vim.fs.dirname(vim.api.nvim_buf_get_name(buf)) + local title = "go " .. table.concat(args, " ") + vim.notify(title .. " …") + vim.system(vim.list_extend({ "go" }, args), { cwd = root, text = true }, function(out) + vim.schedule(function() + -- go prints paths relative to `root` (vet type errors prefixed with + -- "vet: "); make them absolute so quickfix resolves them from any cwd. + local lines = vim.tbl_map(function(line) + local file, rest = line:gsub("^vet: ", ""):match("^([^%s:]+%.go)(:.*)$") + if not file then + return line end - -- Reuse the godoc window in this tab, otherwise open a vertical split. - local win = vim.iter(vim.api.nvim_tabpage_list_wins(0)):find(function(w) - return vim.bo[vim.api.nvim_win_get_buf(w)].filetype == "godoc" - end) - local doc - if win then - doc = vim.api.nvim_win_get_buf(win) - vim.api.nvim_set_current_win(win) - else - doc = vim.api.nvim_create_buf(false, true) - vim.bo[doc].bufhidden = "wipe" - -- Own filetype so gopls/conform don't attach; Go syntax colors. - vim.bo[doc].filetype = "godoc" - pcall(vim.treesitter.start, doc, "go") - vim.keymap.set("n", "q", "close", { buffer = doc, desc = "Close godoc" }) - vim.cmd("vertical sbuffer " .. doc) + if not vim.startswith(file, "/") then + file = vim.fs.normalize(vim.fs.joinpath(root, file)) end - pcall(vim.api.nvim_buf_set_name, doc, "godoc://" .. table.concat(target, " ")) - vim.bo[doc].modifiable = true - vim.api.nvim_buf_set_lines(doc, 0, -1, false, vim.split(vim.trim(out.stdout), "\n")) - vim.bo[doc].modifiable = false - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - end) + return file .. rest + end, vim.split(out.stdout .. out.stderr, "\n", { trimempty = true })) + -- Same errorformat as Neovim's `:compiler go`. + local efm = [[%-G# %.%#,%A%f:%l:%c: %m,%A%f:%l: %m,%C%*\s%m,%-G%.%#]] + vim.fn.setqflist({}, " ", { title = title, lines = lines, efm = efm }) + local items = vim.tbl_filter(function(item) + return item.valid == 1 + end, vim.fn.getqflist()) + if #items > 0 then + vim.cmd("botright copen") + elseif out.code ~= 0 then + vim.notify(title .. " failed:\n" .. table.concat(lines, "\n"), vim.log.levels.ERROR) + else + vim.cmd("cclose") + vim.notify(title .. ": ok") + end end) end) end @@ -109,15 +107,25 @@ return { end local nx = { "n", "x" } - -- Struct tags (`:` instead of so a visual range is passed along). - map(nx, "Gj", ":GoTagAdd json", "Add json tags") - map(nx, "Gy", ":GoTagAdd yaml", "Add yaml tags") - map(nx, "Ga", prompt_cmd("GoTagAdd", "Add tags (e.g. json=omitempty db): "), "Add tags…") - map(nx, "Gr", prompt_cmd("GoTagRm", "Remove tags (empty = json): "), "Remove tags…") - map("n", "GR", "GoTagClear", "Clear all tags") + -- Struct tags (save first; j/y/a/r pass a visual range along). + map(nx, "Gj", go_cmd("GoTagAdd json", { save = true }), "Add json tags") + map(nx, "Gy", go_cmd("GoTagAdd yaml", { save = true }), "Add yaml tags") + map( + nx, + "Ga", + go_cmd("GoTagAdd", { prompt = "Add tags (e.g. json=omitempty db): ", save = true }), + "Add tags…" + ) + map( + nx, + "Gr", + go_cmd("GoTagRm", { prompt = "Remove tags (empty = json): ", save = true }), + "Remove tags…" + ) + map("n", "GR", go_cmd("GoTagClear", { save = true }), "Clear all tags") -- Code generation - map("n", "Gi", prompt_cmd("GoImpl", "Implement (e.g. io.Reader): "), "Implement interface…") + map("n", "Gi", go_cmd("GoImpl", { prompt = "Implement (e.g. io.Reader): " }), "Implement interface…") map("n", "Ge", "GoIfErr", "Insert if err != nil") map("n", "Gn", "GoNew", "Generate constructor") map("n", "Gc", "GoCmt", "Generate doc comment") @@ -133,15 +141,22 @@ return { vim.cmd.edit(vim.fn.fnameescape(alt)) end, "Alternate test/impl file") - -- Docs - map("n", "Gd", function() - godoc_split(ev.buf) - end, "Go doc in split (full)") - -- go commands map("n", "Gm", "GoMod tidy", "go mod tidy") - map("n", "Gg", prompt_cmd("GoGet", "go get: "), "go get…") + map("n", "Gg", go_cmd("GoGet", { prompt = "go get: " }), "go get…") map("n", "GG", "GoGenerate %", "go generate (file)") + map("n", "Gb", function() + go_quickfix(ev.buf, { "build", "./..." }) + end, "go build ./... (quickfix)") + map("n", "Gv", function() + go_quickfix(ev.buf, { "vet", "./..." }) + end, "go vet ./... (quickfix)") + map("n", "Gx", function() + local dir = vim.fs.dirname(vim.api.nvim_buf_get_name(ev.buf)) + vim.cmd("botright 15new") + vim.fn.jobstart({ "go", "run", "." }, { term = true, cwd = dir }) + vim.cmd.startinsert() + end, "go run . (terminal)") end, }) end, diff --git a/plugins/lspconfig.lua b/plugins/lspconfig.lua index e342e3f..dcfdcda 100644 --- a/plugins/lspconfig.lua +++ b/plugins/lspconfig.lua @@ -62,20 +62,21 @@ return { usePlaceholders = true, -- Richer, type-aware highlighting on top of treesitter. semanticTokens = true, - -- Extra analyzers beyond gopls' defaults. Most staticcheck checks - -- come from golangci-lint (golangci_lint_ls) using the repo config. - analyses = { - nilness = true, - unusedparams = true, - unusedvariable = true, - unusedwrite = true, - useany = true, - }, + -- gopls' default analyzers (nilness, unused*, modernize, ...) are + -- enough; staticcheck-style checks come from golangci-lint + -- (golangci_lint_ls) using the repo config. + -- Report known-vulnerable dependencies as go.mod diagnostics. + vulncheck = "Imports", + -- Template files (gotmpl filetype, see lua/autocmds.lua). + templateExtensions = { "gotmpl", "tmpl" }, -- Inlay hints are enabled on attach; toggle with th. hints = { + assignVariableTypes = true, compositeLiteralFields = true, + compositeLiteralTypes = true, constantValues = true, functionTypeParameters = true, + ignoredError = true, parameterNames = true, rangeVariableTypes = true, }, @@ -102,17 +103,58 @@ return { vim.lsp.codelens.enable(true, { bufnr = bufnr }) end + -- Go: float listing the unset fields of the struct literal under the + -- cursor in insert mode (follows the th inlay-hint toggle). + if client and client.name == "gopls" then + require("utils.gostructfields").attach(bufnr) + end + -- Navigation - vim.keymap.set("n", "gd", "lua vim.lsp.buf.definition()", { desc = "Go to definition" }) - vim.keymap.set("n", "gD", "lua vim.lsp.buf.declaration()", { desc = "Go to declaration" }) - vim.keymap.set("n", "gi", "lua vim.lsp.buf.implementation()", { desc = "Go to implementation" }) - vim.keymap.set("n", "go", "lua vim.lsp.buf.type_definition()", { desc = "Go to type definition" }) - vim.keymap.set("n", "gr", "lua vim.lsp.buf.references()", { desc = "List references" }) - vim.keymap.set("n", "gs", "lua vim.lsp.buf.signature_help()", { desc = "Signature help" }) + vim.keymap.set( + "n", + "gd", + "lua vim.lsp.buf.definition()", + { buffer = bufnr, desc = "Go to definition" } + ) + vim.keymap.set( + "n", + "gD", + "lua vim.lsp.buf.declaration()", + { buffer = bufnr, desc = "Go to declaration" } + ) + vim.keymap.set( + "n", + "gi", + "lua vim.lsp.buf.implementation()", + { buffer = bufnr, desc = "Go to implementation" } + ) + vim.keymap.set( + "n", + "go", + "lua vim.lsp.buf.type_definition()", + { buffer = bufnr, desc = "Go to type definition" } + ) + vim.keymap.set("n", "gr", "lua vim.lsp.buf.references()", { buffer = bufnr, desc = "List references" }) + vim.keymap.set( + "n", + "gs", + "lua vim.lsp.buf.signature_help()", + { buffer = bufnr, desc = "Signature help" } + ) -- Actions - vim.keymap.set("n", "cr", "lua vim.lsp.buf.rename()", { desc = "Rename symbol" }) - vim.keymap.set({ "n", "x" }, "ca", "lua vim.lsp.buf.code_action()", { desc = "Code action" }) + vim.keymap.set( + "n", + "cr", + "lua vim.lsp.buf.rename()", + { buffer = bufnr, desc = "Rename symbol" } + ) + vim.keymap.set( + { "n", "x" }, + "ca", + "lua vim.lsp.buf.code_action()", + { buffer = bufnr, desc = "Code action" } + ) -- Hover: LSP hover, falls back to diagnostic float if no hover info vim.keymap.set("n", "K", function() @@ -123,30 +165,40 @@ return { elseif has_diag then vim.diagnostic.open_float({ border = "rounded" }) end - end, { desc = "Hover / diagnostic" }) + end, { buffer = bufnr, desc = "Hover / diagnostic" }) -- Diagnostics - vim.keymap.set("n", "gl", "lua vim.diagnostic.open_float()", { desc = "Show diagnostics" }) + vim.keymap.set( + "n", + "gl", + "lua vim.diagnostic.open_float()", + { buffer = bufnr, desc = "Show diagnostics" } + ) vim.keymap.set("n", "[d", function() vim.diagnostic.jump({ count = -1 }) - end, { desc = "Previous diagnostic" }) + end, { buffer = bufnr, desc = "Previous diagnostic" }) vim.keymap.set("n", "]d", function() vim.diagnostic.jump({ count = 1 }) - end, { desc = "Next diagnostic" }) + end, { buffer = bufnr, desc = "Next diagnostic" }) -- Document symbols (outline view) vim.keymap.set("n", "ls", function() Snacks.picker.lsp_symbols() - end, { desc = "Document symbols" }) + end, { buffer = bufnr, desc = "Document symbols" }) -- Workspace symbols (project-wide symbol search) vim.keymap.set("n", "lw", function() Snacks.picker.lsp_workspace_symbols() - end, { desc = "Workspace symbols" }) + end, { buffer = bufnr, desc = "Workspace symbols" }) -- Code lens is refreshed automatically (see the codeLens autocmd -- above); cl runs the lens action under the cursor. - vim.keymap.set("n", "cl", "lua vim.lsp.codelens.run()", { desc = "Run code lens" }) + vim.keymap.set( + "n", + "cl", + "lua vim.lsp.codelens.run()", + { buffer = bufnr, desc = "Run code lens" } + ) -- Debug LSP info vim.keymap.set("n", "li", function() @@ -173,7 +225,7 @@ return { end vim.notify("LSP Clients:\n" .. table.concat(info, "\n"), vim.log.levels.INFO) - end, { desc = "LSP info" }) + end, { buffer = bufnr, desc = "LSP info" }) end, }) end, diff --git a/plugins/mason.lua b/plugins/mason.lua index b40369b..15fbdeb 100644 --- a/plugins/mason.lua +++ b/plugins/mason.lua @@ -16,6 +16,7 @@ return { package_uninstalled = "✗", }, }, + max_concurrent_installers = 4, }) require("mason-lspconfig").setup({ diff --git a/plugins/nvim-treesitter.lua b/plugins/nvim-treesitter.lua index 1aea4f5..43a46d2 100644 --- a/plugins/nvim-treesitter.lua +++ b/plugins/nvim-treesitter.lua @@ -12,8 +12,10 @@ return { "go", "gomod", "gosum", + "gotmpl", "gowork", "hcl", + "helm", "html", "javascript", "jsdoc", @@ -27,6 +29,7 @@ return { "python", "query", "regex", + "sql", "terraform", "toml", "tsx", diff --git a/plugins/snacks.lua b/plugins/snacks.lua index 976dbe5..a360dec 100644 --- a/plugins/snacks.lua +++ b/plugins/snacks.lua @@ -1,6 +1,6 @@ return { src = "https://github.com/folke/snacks.nvim", - version = "v2.31.0", + version = vim.version.range("*"), config = function() require("snacks").setup({ ---@module 'snacks' Snacks @@ -92,6 +92,15 @@ return { enabled = true, }, statuscolumn = { enabled = true }, + terminal = { + enabled = true, + win = { + position = "float", + width = 0.85, + height = 0.85, + border = "rounded", + }, + }, quickfile = { enabled = true }, words = { enabled = true }, }) @@ -185,5 +194,13 @@ return { end, }) end, { desc = "Select scratch buffer." }) + + Snacks.keymap.set("n", "st", function() + Snacks.terminal.toggle() + end, { desc = "Snack Terminal" }) + + Snacks.keymap.set("n", "td", function() + Snacks.toggle.dim() + end, { desc = "Snack dim" }) end, } diff --git a/plugins/tiny_code_actions.lua b/plugins/tiny_code_actions.lua new file mode 100644 index 0000000..d380a5d --- /dev/null +++ b/plugins/tiny_code_actions.lua @@ -0,0 +1,6 @@ +return { + src = "https://github.com/rachartier/tiny-code-action.nvim", + vim.keymap.set({ "n", "x" }, "Ca", function() + require("tiny-code-action").code_action() + end, { noremap = true, silent = true }), +} diff --git a/plugins/tiny_inline_diags.lua b/plugins/tiny_inline_diags.lua new file mode 100644 index 0000000..f81add6 --- /dev/null +++ b/plugins/tiny_inline_diags.lua @@ -0,0 +1,15 @@ +return { + src = "https://github.com/rachartier/tiny-inline-diagnostic.nvim", + -- version = vim.version.range("*") + config = function() + require("tiny-inline-diagnostic").setup({ + options = { + multilines = { + enabled = true, + always_show = true, + }, + show_all_diags_on_cursorline = true, + }, + }) + end, +} diff --git a/plugins/treesj.lua b/plugins/treesj.lua new file mode 100644 index 0000000..0c7dbfd --- /dev/null +++ b/plugins/treesj.lua @@ -0,0 +1,7 @@ +return { + src = "https://github.com/Wansmer/treesj", + config = function() + require("treesj").setup({}) + end, + vim.api.nvim_set_keymap("n", "ts", "TSJToggle", { desc = "Toggle split/join on cursor line." }), +}