diff --git a/.gitignore b/.gitignore index a6b1ac8..95d9c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ Thumbs.db # Profiling output profile.json + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml.bak similarity index 100% rename from .pre-commit-config.yaml rename to .pre-commit-config.yaml.bak diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..549fa2c --- /dev/null +++ b/devenv.lock @@ -0,0 +1,65 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1789928656, + "narHash": "sha256-D7RNclo2qbp+r3wR1ac5zR+rD6eTe5LUbdVtO/jJvuA=", + "owner": "cachix", + "repo": "devenv", + "rev": "c5ee672098ceb23c694fd89fdb1ff7478ee386fa", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1789506231, + "narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1789370336, + "narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c7def046b9a883d46974757852106483d741586f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..9d22604 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,46 @@ +{ pkgs, lib, config, inputs, ... }: + +{ + # https://devenv.sh/basics/ + env.GREET = "devenv"; + + # 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 + ''; + + # https://devenv.sh/tasks/ + # tasks = { + # "myproj:setup".exec = "mytool build"; + # "devenv:enterShell".after = [ "myproj:setup" ]; + # }; + + # https://devenv.sh/tests/ + enterTest = '' + 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/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..4da5c66 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +# If you're using non-OSS software, you can set allowUnfree to true. +# allowUnfree: true + +# If you're not willing to allow unsupported packages: +# allowUnsupportedSystem: false + +# If you're willing to use a package that's vulnerable +# permittedInsecurePackages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/lua/keymaps.lua b/lua/keymaps.lua index eab5d66..64f806d 100644 --- a/lua/keymaps.lua +++ b/lua/keymaps.lua @@ -97,6 +97,9 @@ end, "Copy full file path") map("n", "td", function() vim.diagnostic.enable(not vim.diagnostic.is_enabled()) end, "Toggle diagnostics") +map("n", "th", function() + vim.lsp.inlay_hint.enable(not vim.lsp.inlay_hint.is_enabled({ bufnr = 0 }), { bufnr = 0 }) +end, "Toggle inlay hints") -- Vim Pack ------------------------------------------------------------------- map("n", "vu", ":lua vim.pack.update()", "Update plugins (no force)") diff --git a/nvim-pack-lock.json b/nvim-pack-lock.json index 182268a..2627716 100644 --- a/nvim-pack-lock.json +++ b/nvim-pack-lock.json @@ -5,6 +5,10 @@ "src": "https://github.com/L3MON4D3/LuaSnip", "version": "2.0.0 - 3.0.0" }, + "atlas.nvim": { + "rev": "fb40953462ff922c1084f1338072588c6e0eb0bf", + "src": "https://github.com/emrearmagan/atlas.nvim" + }, "blink.cmp": { "rev": "78336bc89ee5365633bcf754d93df01678b5c08f", "src": "https://github.com/saghen/blink.cmp", @@ -32,6 +36,11 @@ "src": "https://github.com/lewis6991/gitsigns.nvim", "version": "'v2.1.0'" }, + "gopher.nvim": { + "rev": "f397bed1c35f29f4f85d60a210650e677a8c5cb4", + "src": "https://github.com/olexsmir/gopher.nvim", + "version": "0.0.0 - 1.0.0" + }, "image.nvim": { "rev": "365e2ace0f619164ae6fd2f6f1d2807775dd6849", "src": "https://github.com/3rd/image.nvim" @@ -41,7 +50,7 @@ "src": "https://github.com/nvim-lualine/lualine.nvim" }, "mason-lspconfig.nvim": { - "rev": "49b16d699f9ca83184330c6eb886737a27f23bdd", + "rev": "e17625cdc8c2650787467398e02c5d63a2cde966", "src": "https://github.com/mason-org/mason-lspconfig.nvim" }, "mason.nvim": { @@ -57,6 +66,16 @@ "rev": "23e7e9f883d01289ebd90e98025acc860ea26366", "src": "https://github.com/danymat/neogen" }, + "neotest": { + "rev": "27bf921498043f7ecd821d6db68d05de244bbd02", + "src": "https://github.com/nvim-neotest/neotest", + "version": "5.0.0 - 6.0.0" + }, + "neotest-golang": { + "rev": "05cd7bf89a146cf1fa2848a9fe277a1e68a3a51d", + "src": "https://github.com/fredrikaverpil/neotest-golang", + "version": "2.0.0 - 3.0.0" + }, "nvim": { "rev": "edefef779ab08ce1a4a404713e3012b0d202bd35", "src": "https://github.com/catppuccin/nvim" @@ -66,21 +85,60 @@ "src": "https://github.com/windwp/nvim-autopairs", "version": "'0.10.0'" }, + "nvim-coverage": { + "rev": "a939e425e363319d952a6c35fb3f38b34041ded2", + "src": "https://github.com/andythigpen/nvim-coverage" + }, + "nvim-dap": { + "rev": "cfa2d58f4537aca6ca83e2de1a0d9f1491121264", + "src": "https://github.com/mfussenegger/nvim-dap" + }, + "nvim-dap-go": { + "rev": "b4421153ead5d726603b02743ea40cf26a51ed5f", + "src": "https://github.com/leoluz/nvim-dap-go" + }, + "nvim-dap-ui": { + "rev": "cc9dd33aade7f20bae414d0cba163bc60d4d4b43", + "src": "https://github.com/rcarriga/nvim-dap-ui" + }, + "nvim-dap-view": { + "rev": "210b2c0e4acc08aad23fbd5a7989dd0169f716d0", + "src": "https://github.com/igorlfs/nvim-dap-view", + "version": "1.0.0 - 2.0.0" + }, + "nvim-dap-virtual-text": { + "rev": "fbdb48c2ed45f4a8293d0d483f7730d24467ccb6", + "src": "https://github.com/theHamsta/nvim-dap-virtual-text" + }, "nvim-lspconfig": { - "rev": "ffd261c09c3dabd0bf1a438f47a8ae3b22f3c3ff", + "rev": "6d27c288ff951e1e94cb5f91c800adeddf0dc0c2", "src": "https://github.com/neovim/nvim-lspconfig" }, + "nvim-nio": { + "rev": "edcc181a875301dd21840189aa2f2f9ad69fc172", + "src": "https://github.com/nvim-neotest/nvim-nio", + "version": "1.0.0 - 2.0.0" + }, "nvim-treesitter": { - "rev": "f603a2f4da48728f80257fb5fbb90145fd1dc173", + "rev": "1a4f47b03dfe92d28de7d5654c67327ef008c9e6", "src": "https://github.com/nvim-treesitter/nvim-treesitter", "version": "'main'" }, + "nvim-treesitter-textobjects": { + "rev": "5c7b0263797dfd1bd6202f2b219f3b53a80b2187", + "src": "https://github.com/nvim-treesitter/nvim-treesitter-textobjects", + "version": "'main'" + }, + "plenary.nvim": { + "rev": "74b06c6c75e4eeb3108ec01852001636d85a932b", + "src": "https://github.com/nvim-lua/plenary.nvim" + }, "pr-description.nvim": { - "rev": "d0af0074d6eec7af2b3724bc92c66163d82b29f3", + "rev": "b2737be4c1e167d3b4a2959ae40cec85fcb13865", "src": "https://github.com/remoterabbit/pr-description.nvim" }, "render-markdown.nvim": { - "rev": "640a3ec6d538bad17c328be373c7cad0293d9589", + "rev": "8e8795368391036643b622875a6da6a51bcd33f4", "src": "https://github.com/MeanderingProgrammer/render-markdown.nvim" }, "showkeys": { diff --git a/plugins/atlas.lua b/plugins/atlas.lua new file mode 100644 index 0000000..21824a9 --- /dev/null +++ b/plugins/atlas.lua @@ -0,0 +1,18 @@ +return { + src = "https://github.com/emrearmagan/atlas.nvim", + config = function() + require("atlas").setup({ + ui = { + picker = "snacks", + }, + providers = { + github = {}, + }, + keymaps = { + ui = { + submit = "", + }, + }, + }) + end, +} diff --git a/plugins/dap.lua b/plugins/dap.lua new file mode 100644 index 0000000..b7b8004 --- /dev/null +++ b/plugins/dap.lua @@ -0,0 +1,85 @@ +--- Debugging: nvim-dap (client) + nvim-dap-go (delve adapter/configs) + +--- nvim-dap-view (UI: scopes, watches, breakpoints, threads, REPL, console). +--- +--- Go: requires `dlv` on PATH (mason package `delve`, or `go install +--- github.com/go-delve/delve/cmd/dlv@latest`). Set a breakpoint with +--- Db, start with Dc and pick a configuration (Debug / Debug +--- Package / Debug test / Attach ...), or use Dt to debug the test +--- under the cursor. Ds enters step mode, where single keys +--- (n/i/o/c) step until . The view opens/closes with the session. +--- +--- Project-specific launch configs can go in `.vscode/launch.json`; nvim-dap +--- reads it automatically. +return { + { + src = "https://github.com/mfussenegger/nvim-dap", + }, + { + src = "https://github.com/leoluz/nvim-dap-go", + }, + { + src = "https://github.com/igorlfs/nvim-dap-view", + version = vim.version.range("1.*"), + config = function() + local dap = require("dap") + + require("dap-view").setup({ + auto_toggle = true, + -- Clickable play/step/stop buttons in the view's winbar. + winbar = { controls = { enabled = true } }, + -- Show variable values inline in the source while stopped. + -- Long values (structs like *testing.T) are truncated; see the full + -- value in the Scopes pane or with Dw. + virtual_text = { + enabled = true, + format = function(variable) + local value = variable.value:gsub("%s+", " ") + return " " .. (#value > 50 and value:sub(1, 50) .. "…" or value) + end, + }, + }) + require("dap-go").setup() + + vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" }) + vim.fn.sign_define("DapBreakpointCondition", { text = "◆", texthl = "DiagnosticWarn" }) + vim.fn.sign_define("DapLogPoint", { text = "◉", texthl = "DiagnosticInfo" }) + vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "Visual" }) + + local function map(lhs, rhs, desc) + vim.keymap.set("n", lhs, rhs, { desc = desc }) + end + + map("Dc", dap.continue, "Start/continue") + map("Dn", dap.step_over, "Step over (next)") + map("Di", dap.step_into, "Step into") + map("Do", dap.step_out, "Step out") + -- Step mode: keeps the D menu open so stepping is one key per + -- step (n/i/o/c/b/...) until . + map("Ds", function() + require("which-key").show({ keys = "D", loop = true }) + end, "Step mode (repeat keys, exits)") + map("Db", dap.toggle_breakpoint, "Toggle breakpoint") + map("DB", function() + dap.set_breakpoint(vim.fn.input("Condition: ")) + end, "Conditional breakpoint") + map("Dl", function() + dap.set_breakpoint(nil, nil, vim.fn.input("Log message: ")) + end, "Log point") + map("DC", dap.run_to_cursor, "Run to cursor") + map("Dr", dap.restart, "Restart session") + map("DL", dap.run_last, "Run last configuration") + map("Dq", dap.terminate, "Terminate session") + map("Dx", dap.clear_breakpoints, "Clear all breakpoints") + map("Du", function() + require("dap-view").toggle() + end, "Toggle debug view") + map("Dw", "DapViewWatch", "Watch expression under cursor") + map("Dt", function() + require("dap-go").debug_test() + end, "Debug nearest Go test") + map("DT", function() + require("dap-go").debug_last_test() + end, "Debug last Go test") + end, + }, +} diff --git a/plugins/gopher.lua b/plugins/gopher.lua new file mode 100644 index 0000000..3ec05aa --- /dev/null +++ b/plugins/gopher.lua @@ -0,0 +1,148 @@ +--- gopher.nvim — Go code generation on top of the Go tool binaries +--- (gomodifytags, impl, gotests, iferr, json2go). It is NOT an LSP plugin: +--- definitions/hover/rename/diagnostics come from gopls (plugins/lspconfig.lua), +--- formatting from conform, debugging from plugins/dap.lua. +--- +--- Missing binaries: run `:GoInstallDeps`, then `:checkhealth gopher`. +--- +--- All keymaps are buffer-local to Go files, under G: +--- struct tags j json · y yaml · a add (prompt) · r remove (prompt) · R clear +--- (j/y/a/r also work on a visual selection of fields) +--- generate i impl interface · e if err · n constructor · c doc comment +--- 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) +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 + ---@return function + local function prompt_cmd(cmd, prompt) + return function() + local range = "" + if vim.fn.mode():match("[vV\22]") then + local s, e = vim.fn.line("v"), vim.fn.line(".") + 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) + if input ~= nil then + vim.cmd(range .. cmd .. " " .. 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. + ---@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 + 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) + 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) + end) + end) + end + + vim.api.nvim_create_autocmd("FileType", { + group = vim.api.nvim_create_augroup("gopher_keymaps", { clear = true }), + pattern = "go", + callback = function(ev) + local function map(mode, lhs, rhs, desc) + vim.keymap.set(mode, lhs, rhs, { buffer = ev.buf, silent = true, desc = desc }) + 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") + + -- Code generation + map("n", "Gi", prompt_cmd("GoImpl", "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") + map("n", "Gs", "GoJson", "JSON to struct") + + -- Tests + map("n", "Gt", "GoTestAdd", "Generate test for function") + map("n", "GT", "GoTestsAll", "Generate tests for file") + map("n", "GE", "GoTestsExp", "Generate tests for exported") + map("n", "Go", function() + local file = vim.api.nvim_buf_get_name(ev.buf) + local alt = file:match("_test%.go$") and file:gsub("_test%.go$", ".go") or file:gsub("%.go$", "_test.go") + 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", "GoGenerate %", "go generate (file)") + end, + }) + end, +} diff --git a/plugins/lspconfig.lua b/plugins/lspconfig.lua index 7ffdcec..e342e3f 100644 --- a/plugins/lspconfig.lua +++ b/plugins/lspconfig.lua @@ -51,9 +51,35 @@ return { -- gopls disables the `test` codelens by default. Enabling it puts a -- runnable "run test"/"run benchmark" lens above each Test/Benchmark -- function in *_test.go files; trigger it with cl (run lens). + -- The other lenses (generate, tidy, vendor, upgrade_dependency, + -- run_govulncheck, regenerate_cgo) are on by default. codelenses = { test = true, }, + -- Match conform's gofumpt so LSP-fallback formatting agrees. + gofumpt = true, + -- Fill in function-argument / struct-field placeholders on completion. + 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, + }, + -- Inlay hints are enabled on attach; toggle with th. + hints = { + compositeLiteralFields = true, + constantValues = true, + functionTypeParameters = true, + parameterNames = true, + rangeVariableTypes = true, + }, + directoryFilters = { "-.git", "-.direnv", "-.devenv", "-**/node_modules" }, }, }, }) @@ -85,8 +111,8 @@ return { vim.keymap.set("n", "gs", "lua vim.lsp.buf.signature_help()", { desc = "Signature help" }) -- Actions - vim.keymap.set("n", "", "lua vim.lsp.buf.rename()", { desc = "Rename symbol" }) - vim.keymap.set("n", "", "lua vim.lsp.buf.code_action()", { desc = "Code action" }) + 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" }) -- Hover: LSP hover, falls back to diagnostic float if no hover info vim.keymap.set("n", "K", function() diff --git a/plugins/luasnip.lua b/plugins/luasnip.lua index d24b968..74c2034 100644 --- a/plugins/luasnip.lua +++ b/plugins/luasnip.lua @@ -43,6 +43,13 @@ return { require("luasnip.loaders").edit_snippet_files() end, { desc = "Edit snippets" }) + -- Cycle choice nodes (e.g. `err` vs `fmt.Errorf(...)` in Go's iferr). + vim.keymap.set({ "i", "s" }, "", function() + if ls.choice_active() then + ls.change_choice(1) + end + end, { desc = "Next snippet choice" }) + if not pcall(require, "luasnip-jsregexp") and not pcall(require, "jsregexp") then local info = vim.iter(vim.pack.get()):find(function(p) return p.spec and p.spec.name == "LuaSnip" diff --git a/plugins/mason.lua b/plugins/mason.lua index 56d3e25..b40369b 100644 --- a/plugins/mason.lua +++ b/plugins/mason.lua @@ -31,6 +31,7 @@ return { -- Or in Neovim run `:checkhealth mason-lspconfig` / `:Mason`. ensure_installed = { "codebook", + "golangci_lint_ls", "gopls", "lua_ls", "pyright", @@ -39,6 +40,29 @@ return { "tofu_ls", }, }) + + -- Non-LSP tools (formatters, linters, debuggers, codegen). These are + -- mason package names, installed in the background if missing. + local tools = { + "delve", -- Go debugger (nvim-dap-go, plugins/dap.lua) + "gofumpt", -- Go formatter (conform) + "goimports", -- Go import fixer (conform) + "golangci-lint", -- Go linter (driven by golangci_lint_ls) + "gomodifytags", -- Go struct tags (gopher.nvim) + "gotests", -- Go test generation (gopher.nvim) + "gotestsum", -- Go test runner (neotest-golang) + "iferr", -- Go `if err != nil` generation (gopher.nvim) + "impl", -- Go interface stubs (gopher.nvim) + } + local registry = require("mason-registry") + registry.refresh(function() + for _, name in ipairs(tools) do + local ok, pkg = pcall(registry.get_package, name) + if ok and not pkg:is_installed() and not pkg:is_installing() then + pkg:install() + end + end + end) end, }, } diff --git a/plugins/neotest.lua b/plugins/neotest.lua new file mode 100644 index 0000000..e803c6a --- /dev/null +++ b/plugins/neotest.lua @@ -0,0 +1,118 @@ +--- Testing: neotest (runner UI) + neotest-golang (Go adapter) + nvim-coverage +--- (line coverage signs). Language-agnostic keymaps under T; add more +--- adapters to `adapters` for other languages. +--- +--- Go: tests run through gotestsum when installed (falls back to `go test`) +--- and every run writes a coverage profile. Coverage files live in Neovim's +--- cache dir (one per project), not in the repo. Coverage maps files relative +--- to the module root, so run Neovim from the module root. +--- +--- Debugging a test (Td) uses nvim-dap-go (plugins/dap.lua). +return { + { + src = "https://github.com/nvim-lua/plenary.nvim", + }, + { + src = "https://github.com/nvim-neotest/nvim-nio", + version = vim.version.range("1.*"), + }, + { + src = "https://github.com/fredrikaverpil/neotest-golang", + version = vim.version.range("2.*"), + }, + { + src = "https://github.com/andythigpen/nvim-coverage", + }, + { + src = "https://github.com/nvim-neotest/neotest", + version = vim.version.range("5.*"), + config = function() + local neotest = require("neotest") + local coverage = require("coverage") + + --- Per-project coverage profile path, e.g. + --- ~/.cache/nvim/coverage/%home%me%proj.out + ---@return string + local function coverage_file() + local dir = vim.fn.stdpath("cache") .. "/coverage" + vim.fn.mkdir(dir, "p") + return dir .. "/" .. vim.fn.getcwd():gsub("[/\\:]", "%%") .. ".out" + end + + neotest.setup({ + adapters = { + require("neotest-golang")({ + runner = vim.fn.executable("gotestsum") == 1 and "gotestsum" or "go", + go_test_args = function() + return { "-v", "-race", "-count=1", "-coverprofile=" .. coverage_file() } + end, + }), + }, + }) + + coverage.setup({ + -- Re-read the profile whenever a test run rewrites it. + auto_reload = true, + lang = { go = { coverage_file = coverage_file } }, + }) + + local function map(lhs, rhs, desc) + vim.keymap.set("n", lhs, rhs, { desc = desc }) + end + + map("Tt", function() + neotest.run.run() + end, "Run nearest test") + map("Tf", function() + neotest.run.run(vim.fn.expand("%")) + end, "Run file") + map("Ta", function() + neotest.run.run(vim.uv.cwd()) + end, "Run all (project)") + map("Tl", function() + neotest.run.run_last() + end, "Run last") + map("Td", function() + neotest.run.run({ strategy = "dap" }) + end, "Debug nearest test") + map("Tx", function() + neotest.run.stop() + end, "Stop") + map("Ts", function() + neotest.summary.toggle() + end, "Toggle summary tree") + map("To", function() + neotest.output.open({ enter = true, auto_close = true }) + end, "Show test output") + map("TO", function() + neotest.output_panel.toggle() + end, "Toggle output panel") + map("Tw", function() + neotest.watch.toggle(vim.fn.expand("%")) + end, "Toggle watch (file)") + map("]T", function() + neotest.jump.next({ status = "failed" }) + end, "Next failed test") + map("[T", function() + neotest.jump.prev({ status = "failed" }) + end, "Previous failed test") + + -- Coverage from the last test run. First press loads + shows it; after + -- that it toggles, and auto_reload keeps it current as tests re-run. + local coverage_loaded = false + map("Tc", function() + if coverage_loaded then + coverage.toggle() + elseif vim.uv.fs_stat(coverage_file()) then + coverage.load(true) + coverage_loaded = true + else + vim.notify("No coverage yet; run some tests first (Tt/Tf/Ta)", vim.log.levels.INFO) + end + end, "Toggle coverage") + map("TC", function() + coverage.summary() + end, "Coverage summary") + end, + }, +} diff --git a/plugins/nvim-treesitter-textobjects.lua b/plugins/nvim-treesitter-textobjects.lua new file mode 100644 index 0000000..3cb6580 --- /dev/null +++ b/plugins/nvim-treesitter-textobjects.lua @@ -0,0 +1,44 @@ +--- Treesitter text objects + motions (any language with textobject queries). +--- select (visual/operator): af/if function · ac/ic class (Go: struct / +--- interface) · aa/ia parameter/argument e.g. daf, vic, cia +--- move: ]f/[f next/prev function start · ]F/[F next/prev function end +return { + src = "https://github.com/nvim-treesitter/nvim-treesitter-textobjects", + version = "main", + config = function() + require("nvim-treesitter-textobjects").setup({ + select = { lookahead = true }, + move = { set_jumps = true }, + }) + + local select = require("nvim-treesitter-textobjects.select") + local move = require("nvim-treesitter-textobjects.move") + + for lhs, spec in pairs({ + af = { "@function.outer", "Around function" }, + ["if"] = { "@function.inner", "Inside function" }, + ac = { "@class.outer", "Around class/struct" }, + ic = { "@class.inner", "Inside class/struct" }, + aa = { "@parameter.outer", "Around parameter" }, + ia = { "@parameter.inner", "Inside parameter" }, + }) do + vim.keymap.set({ "x", "o" }, lhs, function() + select.select_textobject(spec[1], "textobjects") + end, { desc = spec[2] }) + end + + local nxo = { "n", "x", "o" } + vim.keymap.set(nxo, "]f", function() + move.goto_next_start("@function.outer", "textobjects") + end, { desc = "Next function start" }) + vim.keymap.set(nxo, "[f", function() + move.goto_previous_start("@function.outer", "textobjects") + end, { desc = "Previous function start" }) + vim.keymap.set(nxo, "]F", function() + move.goto_next_end("@function.outer", "textobjects") + end, { desc = "Next function end" }) + vim.keymap.set(nxo, "[F", function() + move.goto_previous_end("@function.outer", "textobjects") + end, { desc = "Previous function end" }) + end, +} diff --git a/plugins/which-key.lua b/plugins/which-key.lua index a1b0d5c..c80719d 100644 --- a/plugins/which-key.lua +++ b/plugins/which-key.lua @@ -12,12 +12,16 @@ return { -- their `desc` automatically. Register only the prefixes here. wk.add({ { "b", group = "buffer" }, + { "c", group = "code" }, { "d", group = "delete (no yank)" }, + { "D", group = "debug" }, { "f", group = "find" }, { "g", group = "git" }, + { "G", group = "go" }, + { "l", group = "lsp" }, { "p", group = "paste/path" }, { "s", group = "split/scratch" }, - { "t", group = "toggle" }, + { "t", group = "test/toggle" }, { "x", group = "trouble/diagnostics" }, }) end, diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..44bc191 --- /dev/null +++ b/prek.toml @@ -0,0 +1,43 @@ +# Configuration file for `prek`, a git hook framework written in Rust. +# See https://prek.j178.dev for more information. +#:schema https://www.schemastore.org/prek.json + +[[repos]] +repo = "builtin" +hooks = [ + { id = "trailing-whitespace" }, + { id = "end-of-file-fixer" }, + { id = "check-added-large-files" }, + { id = "trailing-whitespace" }, + { id = "end-of-file-fixer" }, + { id = "check-yaml" }, + { id = "check-toml" }, + { id = "check-json" }, + { id = "check-merge-conflict" }, + { id = "check-added-large-files" }, + { id = "mixed-line-ending" }, +] + +[[repos]] +repo = "https://github.com/gitleaks/gitleaks" +rev = "v8.24.2" +hooks = [{ id = "gitleaks" }] + +[[repos]] +repo = "https://github.com/JohnnyMorganz/StyLua" +rev = "v2.5.2" +hooks = [ + { + id = "stylua-github", + args = ["--config-path=stylua.toml"] + } +] + +[[repos]] +repo = "https://github.com/compilerla/conventional-pre-commit" +rev = "v4.4.0" +hooks = [ + { id = "conventional-pre-commit", + stages = ["commit-msg"] + } + ] diff --git a/snippets/go.lua b/snippets/go.lua index 11822f3..6aa830a 100644 --- a/snippets/go.lua +++ b/snippets/go.lua @@ -1,22 +1,180 @@ --- Native LuaSnip snippets for Go. --- See snippets/all.lua for the format. +--- +--- `iferr`/`ife` read the enclosing function's result types with treesitter and +--- fill in matching zero values, e.g. inside `func f() (*T, int, error)` they +--- expand to `return nil, 0, err`. The error slot is a choice node: cycle +--- between `err` and `fmt.Errorf("...: %w", err)` with (plugins/luasnip.lua). local ls = require("luasnip") local s = ls.snippet +local sn = ls.snippet_node +local t = ls.text_node local i = ls.insert_node -local fmt = require("luasnip.extras.fmt").fmt +local c = ls.choice_node +local d = ls.dynamic_node +--- fmt with each 2-space indent in the template turned into a real indent unit, +--- so expansions use tabs in Go buffers (noexpandtab) instead of literal spaces. +local function fmt(str, nodes) + return require("luasnip.extras.fmt").fmt(str, nodes, { indent_string = " " }) +end +local rep = require("luasnip.extras").rep + +local FUNC_NODES = { function_declaration = true, method_declaration = true, func_literal = true } + +local NUMERIC = {} +for _, ty in ipairs({ + "int", + "int8", + "int16", + "int32", + "int64", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uintptr", + "float32", + "float64", + "complex64", + "complex128", + "byte", + "rune", +}) do + NUMERIC[ty] = true +end + +local NIL_KINDS = { + pointer_type = true, + slice_type = true, + map_type = true, + channel_type = true, + function_type = true, + interface_type = true, +} + +--- Names of generic type parameters visible in `fn`, from its own type +--- parameter list and (for methods) the receiver, e.g. `func (s *Stack[T])`. +---@param fn TSNode +---@return table +local function type_params(fn) + local names = {} + local function collect(node, in_params) + for child in node:iter_children() do + local kind = child:type() + if in_params and (kind == "identifier" or kind == "type_identifier") then + names[vim.treesitter.get_node_text(child, 0)] = true + end + collect(child, in_params or kind == "type_parameter_declaration" or kind == "type_arguments") + end + end + for _, field in ipairs({ "type_parameters", "receiver" }) do + local node = fn:field(field)[1] + if node then + collect(node, false) + end + end + return names +end + +--- Result type nodes of `fn`, one per returned value (`(a, b int)` -> int, int). +---@param fn TSNode +---@return TSNode[] +local function result_types(fn) + local result = fn:field("result")[1] + if not result then + return {} + elseif result:type() ~= "parameter_list" then + return { result } + end + local types = {} + for decl in result:iter_children() do + if decl:type() == "parameter_declaration" then + local ty = decl:field("type")[1] + for _ = 1, math.max(#decl:field("name"), 1) do + table.insert(types, ty) + end + end + end + return types +end + +--- Zero-value snippet node for one result type. +---@param ty TSNode +---@param idx integer jump index +---@param generics table +local function zero_value(ty, idx, generics) + local text = vim.treesitter.get_node_text(ty, 0) + if text == "error" then + return c(idx, { + t("err"), + sn(nil, { t('fmt.Errorf("'), i(1, "context"), t(': %w", err)') }), + }) + end + local value + if NUMERIC[text] then + value = "0" + elseif text == "string" then + value = '""' + elseif text == "bool" then + value = "false" + elseif NIL_KINDS[ty:type()] or text == "any" then + value = "nil" + elseif generics[text] then + value = "*new(" .. text .. ")" + else + -- Named/qualified type: assume a struct. Tab to it to fix up interfaces. + value = text .. "{}" + end + return i(idx, value) +end + +--- Dynamic node producing `return ` for the enclosing function. +---@param jump integer jump index of the dynamic node +local function go_return(jump) + return d(jump, function() + local fn + if pcall(function() + vim.treesitter.get_parser(0, "go"):parse() + end) then + local ok, node = pcall(vim.treesitter.get_node, { bufnr = 0, ignore_injections = true }) + fn = ok and node or nil + while fn and not FUNC_NODES[fn:type()] do + fn = fn:parent() + end + end + if not fn then + return sn(nil, { t("return "), i(1, "err") }) + end + + local types = result_types(fn) + if #types == 0 then + return sn(nil, { t("return") }) + end + local generics = type_params(fn) + local nodes = { t("return ") } + for idx, ty in ipairs(types) do + if idx > 1 then + table.insert(nodes, t(", ")) + end + table.insert(nodes, zero_value(ty, idx, generics)) + end + return sn(nil, nodes) + end) +end return { - -- if err != nil { return ... } + -- if err != nil { return , err } s( "iferr", fmt( [[ if err != nil {{ - return {} + {} }} ]], - { i(1, "err") } + { go_return(1) } ) ), @@ -27,16 +185,32 @@ return { [[ {}, err := {} if err != nil {{ - return {} + {} }} ]], - { i(1, "val"), i(2, "f()"), i(3, "err") } + { i(1, "val"), i(2, "f()"), go_return(3) } + ) + ), + + -- Error check inside a test. + s( + "terr", + fmt( + [[ + if err != nil {{ + t.Fatalf("{}: %v", err) + }} + ]], + { i(1, "unexpected error") } ) ), -- Wrap an error with context. s("errf", fmt([[fmt.Errorf("{}: %w", {})]], { i(1, "context"), i(2, "err") })), + -- Sentinel error. + s("errv", fmt([[var Err{} = errors.New("{}")]], { i(1, "NotFound"), i(2, "not found") })), + -- func main. s( "main", @@ -76,6 +250,21 @@ return { ) ), + -- Constructor. + s( + "new", + fmt( + [[ + func New{}({}) *{} {{ + return &{}{{ + {} + }} + }} + ]], + { i(1, "Type"), i(2), rep(1), rep(1), i(0) } + ) + ), + -- Struct type. s( "struct", @@ -102,6 +291,123 @@ return { ) ), + -- context.Context parameter. + s("ctx", t("ctx context.Context")), + + -- Context with timeout. + s( + "ctxt", + fmt( + [[ + ctx, cancel := context.WithTimeout({}, {}) + defer cancel() + ]], + { i(1, "ctx"), i(2, "5*time.Second") } + ) + ), + + -- Lock + deferred unlock. + s( + "lock", + fmt( + [[ + {}.Lock() + defer {}.Unlock() + ]], + { i(1, "mu"), rep(1) } + ) + ), + + -- Select on a channel with context cancellation. + s( + "sel", + fmt( + [[ + select {{ + case <-ctx.Done(): + return ctx.Err() + case {} := <-{}: + {} + }} + ]], + { i(1, "v"), i(2, "ch"), i(0) } + ) + ), + + -- Type switch. + s( + "tsw", + fmt( + [[ + switch v := {}.(type) {{ + case {}: + {} + default: + {} + }} + ]], + { i(1, "x"), i(2, "string"), i(3), i(0) } + ) + ), + + -- Goroutine. + s( + "go", + fmt( + [[ + go func() {{ + {} + }}() + ]], + { i(0) } + ) + ), + + -- WaitGroup fan-out. + s( + "wg", + fmt( + [[ + var wg sync.WaitGroup + for _, {} := range {} {{ + wg.Add(1) + go func() {{ + defer wg.Done() + {} + }}() + }} + wg.Wait() + ]], + { i(1, "item"), i(2, "items"), i(0) } + ) + ), + + -- HTTP handler func. + s( + "hf", + fmt( + [[ + func {}(w http.ResponseWriter, r *http.Request) {{ + {} + }} + ]], + { i(1, "handler"), i(0) } + ) + ), + + -- Plain test. + s( + "test", + fmt( + [[ + func Test{}(t *testing.T) {{ + {} + }} + ]], + { i(1, "Name"), i(0) } + ) + ), + -- Table-driven test. s( "tt", @@ -125,16 +431,34 @@ return { ) ), - -- Goroutine. + -- Benchmark (Go 1.24+ b.Loop). s( - "go", + "bench", fmt( [[ - go func() {{ - {} - }}() + func Benchmark{}(b *testing.B) {{ + for b.Loop() {{ + {} + }} + }} ]], - { i(0) } + { i(1, "Name"), i(0) } + ) + ), + + -- Fuzz test. + s( + "fuzz", + fmt( + [[ + func Fuzz{}(f *testing.F) {{ + f.Add({}) + f.Fuzz(func(t *testing.T, {}) {{ + {} + }}) + }} + ]], + { i(1, "Name"), i(2, '"seed"'), i(3, "in string"), i(0) } ) ), }