From 87a23b7f3020d7a9099ba15c47165a8a32c63ae9 Mon Sep 17 00:00:00 2001 From: AlenikaKoda Date: Wed, 30 Sep 2026 16:29:04 -0300 Subject: [PATCH] Update NVIM for mariadb --- nvim/README.md | 124 +- nvim/config/nvim/init.lua | 2 +- nvim/config/nvim/lua/config/lsp/commands.lua | 116 ++ nvim/config/nvim/lua/config/lsp/keymaps.lua | 301 ++++ nvim/config/nvim/lua/config/lsp/servers.lua | 71 + nvim/config/nvim/lua/plugins/autopairs.lua | 28 + nvim/config/nvim/lua/plugins/bufferline.lua | 136 ++ nvim/config/nvim/lua/plugins/colorizer.lua | 9 + nvim/config/nvim/lua/plugins/colorscheme.lua | 49 + nvim/config/nvim/lua/plugins/completion.lua | 106 ++ nvim/config/nvim/lua/plugins/dap.lua | 135 ++ nvim/config/nvim/lua/plugins/dashboard.lua | 33 + nvim/config/nvim/lua/plugins/database.lua | 116 ++ nvim/config/nvim/lua/plugins/dressing.lua | 10 + nvim/config/nvim/lua/plugins/gitsigns.lua | 9 + nvim/config/nvim/lua/plugins/hex.lua | 45 + .../nvim/lua/plugins/indent-blankline.lua | 22 + nvim/config/nvim/lua/plugins/init.lua | 1479 ----------------- nvim/config/nvim/lua/plugins/lsp.lua | 76 + nvim/config/nvim/lua/plugins/lualine.lua | 130 ++ nvim/config/nvim/lua/plugins/neogen.lua | 28 + nvim/config/nvim/lua/plugins/notify.lua | 22 + nvim/config/nvim/lua/plugins/nvim-tree.lua | 216 +++ nvim/config/nvim/lua/plugins/smear-cursor.lua | 25 + nvim/config/nvim/lua/plugins/telescope.lua | 21 + nvim/config/nvim/lua/plugins/toggleterm.lua | 20 + nvim/config/nvim/lua/plugins/treesitter.lua | 44 + nvim/config/nvim/lua/plugins/which-key.lua | 25 + nvim/{install.sh => setup_neovim.sh} | 28 +- 29 files changed, 1934 insertions(+), 1492 deletions(-) create mode 100644 nvim/config/nvim/lua/config/lsp/commands.lua create mode 100644 nvim/config/nvim/lua/config/lsp/keymaps.lua create mode 100644 nvim/config/nvim/lua/config/lsp/servers.lua create mode 100644 nvim/config/nvim/lua/plugins/autopairs.lua create mode 100644 nvim/config/nvim/lua/plugins/bufferline.lua create mode 100644 nvim/config/nvim/lua/plugins/colorizer.lua create mode 100644 nvim/config/nvim/lua/plugins/colorscheme.lua create mode 100644 nvim/config/nvim/lua/plugins/completion.lua create mode 100644 nvim/config/nvim/lua/plugins/dap.lua create mode 100644 nvim/config/nvim/lua/plugins/dashboard.lua create mode 100644 nvim/config/nvim/lua/plugins/database.lua create mode 100644 nvim/config/nvim/lua/plugins/dressing.lua create mode 100644 nvim/config/nvim/lua/plugins/gitsigns.lua create mode 100644 nvim/config/nvim/lua/plugins/hex.lua create mode 100644 nvim/config/nvim/lua/plugins/indent-blankline.lua delete mode 100644 nvim/config/nvim/lua/plugins/init.lua create mode 100644 nvim/config/nvim/lua/plugins/lsp.lua create mode 100644 nvim/config/nvim/lua/plugins/lualine.lua create mode 100644 nvim/config/nvim/lua/plugins/neogen.lua create mode 100644 nvim/config/nvim/lua/plugins/notify.lua create mode 100644 nvim/config/nvim/lua/plugins/nvim-tree.lua create mode 100644 nvim/config/nvim/lua/plugins/smear-cursor.lua create mode 100644 nvim/config/nvim/lua/plugins/telescope.lua create mode 100644 nvim/config/nvim/lua/plugins/toggleterm.lua create mode 100644 nvim/config/nvim/lua/plugins/treesitter.lua create mode 100644 nvim/config/nvim/lua/plugins/which-key.lua rename nvim/{install.sh => setup_neovim.sh} (91%) diff --git a/nvim/README.md b/nvim/README.md index faa356f..e418ca7 100644 --- a/nvim/README.md +++ b/nvim/README.md @@ -8,6 +8,7 @@ Supports **Fedora, Ubuntu, Debian, and Arch Linux** (and their common derivative - [Installing](#installing) - [What gets installed](#what-gets-installed) +- [Config layout & adding plugins](#config-layout--adding-plugins) - [Terminal vs. desktop (Neovide)](#terminal-vs-desktop-neovide) - [Editor basics](#editor-basics) - [Look & feel](#look--feel) @@ -21,6 +22,7 @@ Supports **Fedora, Ubuntu, Debian, and Arch Linux** (and their common derivative - [Debugging (nvim-dap)](#debugging-nvim-dap) - [Terminal](#terminal) - [Git](#git) +- [Database (MariaDB)](#database-mariadb) - [Hex viewer](#hex-viewer) - [Formatting and linting](#formatting-and-linting) - [Known quirks](#known-quirks) @@ -56,8 +58,8 @@ First launch takes a minute or two: `lazy.nvim` bootstraps itself and installs a Package names vary by distro (see the script for the exact list per package manager), but the same set gets installed everywhere: - **Compilers/tools**: gcc, g++, make, cmake, clang tooling, lldb, gdb, .NET SDK 8.0, Go, nodejs/npm, python3-pip -- **CLI tools**: ripgrep, fd, git, curl, wget, unzip, fish (used only for Neovim's embedded terminal — see [Terminal](#terminal)) -- **Language servers**: clangd, gopls, omnisharp, ts_ls, html, cssls (via Mason), plus `typescript-language-server` and `vscode-langservers-extracted` via npm +- **CLI tools**: ripgrep, fd, git, curl, wget, unzip, fish (used only for Neovim's embedded terminal — see [Terminal](#terminal)), and the MariaDB *client* (`mariadb`; client only, no server — see [Database (MariaDB)](#database-mariadb)) +- **Language servers**: clangd, gopls, omnisharp, ts_ls, html, cssls, sqls (via Mason), plus `typescript-language-server` and `vscode-langservers-extracted` via npm - **Debug adapter**: codelldb (auto-installed by Mason on first debug session) - **Neovide**, optionally — see [Terminal vs. desktop (Neovide)](#terminal-vs-desktop-neovide) @@ -65,11 +67,51 @@ Everything the script *writes* rather than installs comes from `config/`, copied | `config/` | Copied to | |---|---| -| `nvim/init.lua`, `nvim/lua/plugins/init.lua`, `nvim/lua/dap/configurations/cmake.lua` | `~/.config/nvim/...` (same layout) | +| `nvim/` — the whole tree: `init.lua`, `lua/plugins/*.lua`, `lua/config/…`, `lua/dap/…` (see [Config layout](#config-layout--adding-plugins)) | `~/.config/nvim/...` (same layout) | | `clang-format` | `~/.clang-format` | | `clang-tidy` | `~/.clang-tidy` | | `cpp-style-guide.md` | `~/cpp-style-guide.md` | +## Config layout & adding plugins + +``` +config/nvim/ +├── init.lua options, keymaps, Neovide settings, lazy.nvim bootstrap +└── lua/ + ├── plugins/ one file per plugin (or tightly coupled group) + │ ├── lsp.lua nvim-lspconfig + Mason + │ ├── completion.lua nvim-cmp + LuaSnip + │ ├── database.lua vim-dadbod + UI + completion (MariaDB) + │ ├── dap.lua nvim-dap + UI + │ └── … telescope, nvim-tree, bufferline, lualine, and so on + ├── config/lsp/ helper modules that are *not* plugin specs + │ ├── servers.lua the list of language servers, plus per-server overrides + │ ├── keymaps.lua LSP keymaps, applied when a server attaches + │ └── commands.lua :LspRestart & co. for Neovim 0.12+ + └── dap/configurations/ per-project debug targets (cmake.lua) +``` + +`init.lua` calls `require("lazy").setup("plugins")`, which makes lazy.nvim `require` every `.lua` file in `lua/plugins/` and merge what they return. Each file returns a list of plugin specs, the files don't depend on one another, and there is no central list to update. File order doesn't matter either: the colorscheme sets `priority = 1000`, and every plugin that calls into another one declares it as a dependency. + +**Adding a plugin** means dropping a new file into `lua/plugins/`: + +```lua +-- lua/plugins/my-plugin.lua +return { + { + "author/plugin.nvim", + event = "VeryLazy", + opts = {}, + }, +} +``` + +**Adding a language server** means adding one line to `lua/config/lsp/servers.lua` (`name = {},` — or with overrides inside the braces). That one table feeds both Mason's installer and `vim.lsp.enable()`, so there's no second list to keep in sync. + +**Keep non-spec modules out of `lua/plugins/`.** lazy.nvim treats everything in that folder as a plugin spec, so a helper module placed there would be read as a (malformed) spec. Helpers go under `lua/config/` instead. + +**If you copy these files over an existing `~/.config/nvim` by hand** rather than running the script (which wipes the directory first), delete the old `lua/plugins/init.lua` — lazy.nvim would still load it alongside the new files, and every plugin would be declared twice. + ## Terminal vs. desktop (Neovide) The installer asks which Neovim frontend(s) to set up: @@ -172,7 +214,7 @@ Setting an explicit `` mapping here also overrides Neovim's built-in ``- ## LSP & code intelligence -Configured servers: **clangd** (C/C++), **gopls** (Go), **omnisharp** (C#), **ts_ls** (TS/JS), **html**, **cssls**. Diagnostics render as virtual lines directly below the offending line (not inline virtual text), with the message's leading category prefix stripped; diagnostic underlines are off. +Configured servers: **clangd** (C/C++), **gopls** (Go), **omnisharp** (C#), **ts_ls** (TS/JS), **html**, **cssls**, **sqls** (SQL — see [Database (MariaDB)](#database-mariadb)). The list lives in a single table, `lua/config/lsp/servers.lua`. Diagnostics render as virtual lines directly below the offending line (not inline virtual text), with the message's leading category prefix stripped; diagnostic underlines are off. | Key | Action | |---|---| @@ -191,6 +233,18 @@ Configured servers: **clangd** (C/C++), **gopls** (Go), **omnisharp** (C#), **ts | `]t` / `[t` | Jump to next/previous tip (LSP's "Hint" severity) | | `cd` | Generate a Doxygen-style comment block — see [Documentation generation](#documentation-generation) | +**`:LspRestart` and friends.** Neovim 0.12 added a built-in `:lsp` command (`:lsp enable`, `:lsp disable`, `:lsp restart`, `:lsp stop`), and nvim-lspconfig responds by no longer registering its own `:LspStart` / `:LspStop` / `:LspRestart` / `:LspInfo` / `:LspLog` — so on 0.12 they simply don't exist (`E492: Not an editor command`). `lua/config/lsp/commands.lua` restores the old names as thin aliases over the built-ins: + +| Command | Runs | +|---|---| +| `:LspRestart [client…]` | `:lsp restart [client…]` | +| `:LspStart [server…]` | `:lsp enable [server…]` | +| `:LspStop [server…]` | `:lsp disable [server…]` | +| `:LspInfo` (also `li`) | `:checkhealth vim.lsp` | +| `:LspLog` | opens the LSP log in a new tab | + +Arguments are passed straight through and tab-complete (running clients for restart/stop, configured servers for start). On Neovim 0.11 and older there is no `:lsp`, nvim-lspconfig still provides the real commands itself, and the aliases stay out of the way. + **On `la` coming up thin:** clangd's code actions — and most of its clang-tidy checks — need real compiler flags (`-std=`, `-I`, defines) to do full semantic analysis. Without a `compile_commands.json` in the project, clangd falls back to guessed flags and the action list shrinks accordingly, often down to "No code actions available" everywhere. Generate one via CMake (`-DCMAKE_EXPORT_COMPILE_COMMANDS=ON`, then symlink or copy `build/compile_commands.json` to the project root) or `bear` for non-CMake projects. **`lt` and the same compile database:** `lt` runs the standalone `clang-tidy` binary rather than going through clangd's code actions, so it never comes back "No code actions available" — it always runs. But it looks for that same `compile_commands.json` on its own (passed via clang-tidy's `-p` flag, found the same way as above), for the same underlying reason clangd wants one: without it, clang-tidy falls back to guessed flags and can miss checks that need real semantic analysis. It also uses `--fix` rather than `--fix-errors`, so it deliberately leaves the file untouched if clang-tidy hits real compiler errors, instead of forcing fixes onto code that doesn't currently compile. @@ -219,6 +273,7 @@ The enclosing class is found via Treesitter by walking up from the cursor — no - **``**: confirm the selected completion. - **`////`**: close the completion menu, then perform the normal cursor/escape action. - Snippet sessions auto-close once the cursor actually leaves their text (different line, or outside the placeholder's parentheses) — so `Tab` won't unexpectedly jump back into an old, already-finished snippet later on. +- **SQL buffers** use their own source list: dadbod's table names, LSP (sqls), snippets, and buffer words (no path source). See [Database (MariaDB)](#database-mariadb). ## Documentation generation @@ -260,6 +315,67 @@ Neovim's embedded terminal launches **fish** instead of your login shell, purely `gitsigns` shows added/changed/removed lines in the sign column against the current git state. +## Database (MariaDB) + +Two separate tools do two separate jobs here: + +- **Connection and queries: [vim-dadbod](https://github.com/tpope/vim-dadbod) + [vim-dadbod-ui](https://github.com/kristijanhusak/vim-dadbod-ui).** Browse a server's tables in a sidebar, run queries, read the results in a split. dadbod doesn't speak the database protocol itself; it shells out to the `mariadb` command-line client, which the setup script installs. That's **the client only — no MariaDB server is installed or started**, so you need one running somewhere (local or remote). +- **Editing help: [sqls](https://github.com/sqls-server/sqls)**, a language server for SQL (completion, hover, formatting, code actions). There is no MariaDB-specific language server; sqls's MySQL driver speaks MariaDB's wire protocol and SQL flavor, which covers it. Mason installs it on first launch — it builds from source with `go install`, so Go has to be on `PATH` (the setup script installs it). + +The two are independent: dadbod works without sqls, and sqls works without dadbod. + +### Connecting + +| Key | Action | +|---|---| +| `Du` | Toggle the DB UI sidebar | +| `Da` | Add a connection (same as pressing `A` inside the sidebar) | +| `Df` | Find the current buffer in the sidebar — or, for a buffer that wasn't opened from the DB UI (a plain `.sql` file), pick the connection it should run against (`:DBUIFindBuffer`) | +| `Dr` | Rename the current query buffer or saved query (a buffer has to have been written once first) | +| `Dl` | Print the last query that ran and how long it took | + +`Da` asks for a URL and then a name: + +``` +mariadb://user:password@host:3306/dbname +``` + +Open the sidebar, pick a connection, and expand it to see its tables; expanding a table shows **List**, **Columns**, **Indexes**, and **Count** queries. Picking one writes the query into a buffer without running it; to write your own, choose *New query* under the connection. Either way, **saving the buffer runs it** (`:w`, and `` works too), and dadbod-ui's own default `S` runs it without saving — the whole buffer, or only the visual selection if there is one. Results open in a split. Query buffers are temporary (they live in a temp folder and are cleared when Neovim quits) unless you save one with dadbod-ui's default `W`, which files it under the connection's *Saved queries*. + +Details worth knowing: + +- **Include the database name** in the URL: the sidebar lists the tables of that one database. For several databases on one server, add one connection each. +- **`localhost` vs `127.0.0.1`:** the client treats `localhost` as "use the local Unix socket" and `127.0.0.1` as "use TCP". Use whichever your server is actually set up for. +- **Special characters in the password** have to be percent-encoded in the URL (`@` → `%40`, `/` → `%2F`, and so on). +- **Keeping the password out of the URL:** leave it out and put `user=` / `password=` under `[client]` in `~/.my.cnf` instead — the `mariadb` client reads credentials from there. +- **Where connections are stored:** `~/.local/share/db_ui/connections.json`, **in plain text, password included**. That location is deliberately *outside* `~/.config/nvim` and `~/.local/share/nvim`, so re-running `setup_neovim.sh` (which wipes both) doesn't delete your connections. Nothing with credentials is ever written into the config files themselves. +- **Why `mariadb://` and not `mysql://`:** dadbod has a dedicated MariaDB adapter that runs the `mariadb` binary. The older `mysql://` scheme runs `mysql` instead, which MariaDB 11+ only ships as a deprecated alias that prints a "Deprecated program name" warning on every call — noise that ends up in query results and table lists. The trade-off is that dadbod-completion's column-level completion covers only some schemes (`mysql://` is one, `mariadb://` isn't), which is why columns come from sqls (below). Stick to `mysql://` for real MySQL servers. + +### Language server (sqls) + +sqls starts automatically in `sql`/`mysql` buffers and works out of the box for keyword completion and formatting (`lf`). For **table and column completion** it has to connect to the database *itself* — it does not share dadbod's connections — and it reads that from its own config file, `~/.config/sqls/config.yml`: + +```yaml +lowercaseKeywords: false +connections: + - alias: local_mariadb + driver: mysql # MariaDB speaks the MySQL protocol + proto: tcp + host: 127.0.0.1 + port: 3306 + user: me + passwd: secret + dbName: mydb +``` + +The file holds a password in plain text, so `chmod 600 ~/.config/sqls/config.yml`. For a Unix-socket connection, swap the individual fields for a Go-MySQL DSN: `dataSourceName: me:secret@unix(/run/mysqld/mysqld.sock)/mydb` (the socket path varies by distro). After editing the file, `:LspRestart` restarts sqls so it picks the change up. + +This file is deliberately *not* created by the setup script, so a half-filled template with a made-up password can't make sqls fail to connect on every start. + +### Completion and syntax + +In SQL buffers, completion draws on dadbod's table names (from the connection the buffer is attached to), sqls, snippets, and words already in the buffer. Vim's built-in SQL syntax is switched to its MySQL dialect (Vim defaults to Oracle's), the closest built-in match for MariaDB. + ## Hex viewer `h` toggles the current buffer between normal and hex-dump view (backed by `xxd`), rather than showing both simultaneously. That's deliberate: the hex dump is just ordinary buffer text under the hood, so editing it gets normal Neovim undo/redo for free, and nothing writes to disk until an explicit `:w` — same as any other file. A plugin doing a true live simultaneous hex+ASCII view was tried first, but it manages bytes with its own logic outside normal buffer editing, which is exactly why it had no undo at all. diff --git a/nvim/config/nvim/init.lua b/nvim/config/nvim/init.lua index 1061c4f..f5f280d 100644 --- a/nvim/config/nvim/init.lua +++ b/nvim/config/nvim/init.lua @@ -61,7 +61,7 @@ end -- (truthy) when Neovide itself launches this config as its backend -- -- plain terminal Neovim never defines it, so everything in this block -- is a no-op there. Terminal Neovim gets its cursor animation from --- smear-cursor.nvim instead (see plugins/init.lua): a terminal has no +-- smear-cursor.nvim instead (see plugins/smear-cursor.lua): a terminal has no -- equivalent native capability to hook into, which is exactly the gap -- that plugin fills. if vim.g.neovide then diff --git a/nvim/config/nvim/lua/config/lsp/commands.lua b/nvim/config/nvim/lua/config/lsp/commands.lua new file mode 100644 index 0000000..7ea5474 --- /dev/null +++ b/nvim/config/nvim/lua/config/lsp/commands.lua @@ -0,0 +1,116 @@ +-- :LspRestart / :LspStart / :LspStop / :LspInfo / :LspLog for Neovim 0.12+. +-- +-- These five commands used to come from nvim-lspconfig. Neovim 0.12 added a +-- built-in `:lsp` command (`:lsp enable|disable|restart|stop`, see `:help +-- lsp-commands`) that covers the same ground, and nvim-lspconfig +-- deliberately stops registering its own copies whenever `:lsp` exists -- +-- so on 0.12 they simply vanish: `:LspRestart` -> "E492: Not an editor +-- command". `:LspInfo` went with them (its replacement is `:checkhealth +-- vim.lsp`), and so did `:LspLog`. +-- +-- Rather than retraining fingers (or the `li` mapping, or every +-- habit and doc that says `:LspRestart`), this defines the old names as thin +-- aliases over the built-in replacements: +-- +-- :LspRestart [client...] -> :lsp restart [client...] +-- :LspStart [config...] -> :lsp enable [config...] +-- :LspStop [config...] -> :lsp disable [config...] +-- :LspInfo -> :checkhealth vim.lsp +-- :LspLog -> open the LSP log in a new tab +-- +-- Nothing here reimplements restart/enable/disable: each alias just forwards +-- its arguments to the built-in, so behavior is whatever Neovim's own `:lsp` +-- does. The mapping follows nvim-lspconfig's own documented equivalents. +-- +-- On Neovim 0.11 and older there is no `:lsp`, and nvim-lspconfig still +-- provides the real commands itself, so M.setup() does nothing there. A +-- command that already exists is never overwritten. + +local M = {} + +-- Keep only the names that start with what's been typed so far. (A +-- `complete` function's result isn't filtered by Neovim -- that's on us.) +local function starting_with(names, arg_lead) + local matches = {} + for _, name in ipairs(names) do + if vim.startswith(name, arg_lead) then + table.insert(matches, name) + end + end + return matches +end + +-- Names of the clients running right now (for :LspRestart / :LspStop). +local function running_clients() + local seen, names = {}, {} + for _, client in ipairs(vim.lsp.get_clients()) do + if not seen[client.name] then + seen[client.name] = true + table.insert(names, client.name) + end + end + table.sort(names) + return names +end + +-- Every server this config knows how to start (for :LspStart). +local function configured_servers() + local names = {} + local ok, servers = pcall(require, "config.lsp.servers") + if ok then + for name in pairs(servers) do + table.insert(names, name) + end + end + table.sort(names) + return names +end + +-- Define `: [args]` as `:lsp [args]`. +local function alias(name, subcommand, candidates, desc) + if vim.fn.exists(":" .. name) ~= 0 then + return + end + vim.api.nvim_create_user_command(name, function(opts) + local cmd = "lsp " .. subcommand + if opts.args ~= "" then + cmd = cmd .. " " .. opts.args + end + vim.cmd(cmd) + end, { + nargs = "*", + complete = function(arg_lead) + return starting_with(candidates(), arg_lead) + end, + desc = desc, + }) +end + +function M.setup() + -- `exists(":lsp")` is 2 for a full command match; 0 on Neovim < 0.12. + if vim.fn.exists(":lsp") < 2 then + return + end + + alias("LspRestart", "restart", running_clients, "Restart LSP clients (alias for :lsp restart)") + alias("LspStart", "enable", configured_servers, "Enable LSP servers (alias for :lsp enable)") + alias("LspStop", "disable", running_clients, "Disable LSP servers (alias for :lsp disable)") + + if vim.fn.exists(":LspInfo") == 0 then + vim.api.nvim_create_user_command("LspInfo", function() + vim.cmd("checkhealth vim.lsp") + end, { desc = "Show LSP status (alias for :checkhealth vim.lsp)" }) + end + + if vim.fn.exists(":LspLog") == 0 then + vim.api.nvim_create_user_command("LspLog", function() + local ok, path = pcall(vim.lsp.log.get_filename) + if not ok or not path then + path = vim.fn.stdpath("log") .. "/lsp.log" + end + vim.cmd.tabnew(vim.fn.fnameescape(path)) + end, { desc = "Open the LSP log" }) + end +end + +return M diff --git a/nvim/config/nvim/lua/config/lsp/keymaps.lua b/nvim/config/nvim/lua/config/lsp/keymaps.lua new file mode 100644 index 0000000..7d16c85 --- /dev/null +++ b/nvim/config/nvim/lua/config/lsp/keymaps.lua @@ -0,0 +1,301 @@ +-- LSP keymaps and diagnostic navigation, applied per-buffer whenever a +-- language server attaches. Wired up from plugins/lsp.lua via +-- require("config.lsp.keymaps").setup(). + +local M = {} + +-- LSP Keymaps (Organized under l), applied on attach via +-- the LspAttach autocommand rather than a per-server on_attach +-- callback passed through lspconfig[x].setup(). +local function on_attach(bufnr) + -- Core goto commands (Standard Vim behavior, no leader required) + -- + -- #include lines get special handling: clangd resolves + -- textDocument/definition (and /declaration) on an #include + -- directive to the target header regardless of which token on + -- that line the cursor sits on, so both gd and gD below open + -- it directly. If clangd hasn't attached/indexed yet, or the + -- header isn't part of the compile database, we fall back to + -- Vim's built-in "goto file under cursor" (gf), which still + -- resolves a local #include "quoted.h" relative to the + -- current file even with no LSP involved at all. + local function is_include_line() + return vim.api.nvim_get_current_line():match('^%s*#%s*include') ~= nil + end + + -- Shared result handler for definition/declaration: jump + -- straight there for a single match; fill the quickfix list + -- and open it for multiple (e.g. a function declared once but + -- defined across several translation units) instead of + -- silently picking whichever one clangd listed first. + local function on_list(t) + if vim.tbl_isempty(t.items) then + if is_include_line() then + vim.cmd('normal! gf') + end + return + end + vim.fn.setqflist({}, ' ', t) + if #t.items > 1 then + vim.cmd('botright copen') + else + vim.cmd('cfirst') + end + end + + vim.keymap.set('n', 'gd', function() + if is_include_line() then + vim.lsp.buf.definition({ on_list = on_list }) + else + -- Regular symbol: Telescope gives a fuzzy picker with + -- preview when there's more than one candidate (e.g. + -- overloads), and jumps straight there for a single one -- + -- nicer than the bare quickfix list for that common case. + require('telescope.builtin').lsp_definitions({ reuse_win = true }) + end + end, { buffer = bufnr, silent = true, desc = "Go to Definition" }) + + vim.keymap.set('n', 'gD', function() + -- No Telescope equivalent exists for declaration (only + -- definition/references/implementation/type_definition are + -- exposed), so this stays on vim.lsp.buf.declaration -- + -- just with the same on_list handling as gd above. + vim.lsp.buf.declaration({ on_list = on_list }) + end, { buffer = bufnr, silent = true, desc = "Go to Declaration" }) + + vim.keymap.set('n', 'gr', function() + -- vim.lsp.buf.references() on its own just fills the + -- quickfix list silently without opening it, so pressing gr + -- looks like nothing happened. Telescope's picker actually + -- shows the list with a preview, same as gd above. + require('telescope.builtin').lsp_references({ reuse_win = true }) + end, { buffer = bufnr, silent = true, desc = "Find References" }) + + vim.keymap.set('n', 'K', vim.lsp.buf.hover, { buffer = bufnr, silent = true, desc = "Hover Info" }) + + -- clangd doesn't support renaming macros or namespaces at all + -- (clangd/clangd#1890, still open) -- it's supposed to just + -- reply "symbol is not a supported kind", but has a separate + -- crash bug where it can SIGSEGV while handling that same + -- rejection instead of returning the error cleanly. This + -- catches the common case (cursor on the #define/namespace + -- line itself) with treesitter before ever asking clangd, so + -- the crash can't be triggered that way. It can't catch every + -- case -- invoking rename on a macro from where it's *used* + -- rather than defined still reaches clangd, since a macro + -- usage is just plain text to treesitter, indistinguishable + -- from any other identifier. If clangd crashes anyway, restart + -- it with :LspRestart; renaming a macro by hand with + -- :%s/\/new/g is the right tool regardless, since it's + -- pure text substitution with no scope for clangd to resolve. + local function renaming_unsupported_kind() + local ok, node = pcall(vim.treesitter.get_node) + if not ok then + return false + end + local cword = vim.fn.expand("") + while node do + local kind = node:type() + if kind == "preproc_def" or kind == "preproc_function_def" or kind == "namespace_definition" then + local name_node = node:field("name")[1] + if name_node and vim.treesitter.get_node_text(name_node, 0) == cword then + return true + end + end + node = node:parent() + end + return false + end + + -- Safe Rename Function (Fixes double execution/prompt issues) + local function safe_rename() + if renaming_unsupported_kind() then + vim.notify( + "clangd can't rename macros or namespaces (unsupported upstream, and known to crash on the attempt) -- " + .. "use :%s/\\/new/g instead.", + vim.log.levels.WARN + ) + return + end + local current_name = vim.fn.expand("") + vim.ui.input({ prompt = "Rename: ", default = current_name }, function(new_name) + if not new_name or new_name == "" or new_name == current_name then + return + end + vim.lsp.buf.rename(new_name) + end) + end + + -- Leader commands (Grouped under l) + vim.keymap.set('n', 'lr', safe_rename, { buffer = bufnr, silent = true, desc = "Rename Symbol" }) + + -- Neovim's default vim.lsp.buf.code_action() only looks at + -- diagnostics on the exact cursor line. That's a needlessly + -- easy way to come up empty here specifically: virtual_lines + -- (set in plugins/lsp.lua) renders a diagnostic's message as an extra line + -- directly BELOW its own line, right where the next real line + -- of code sits -- so a diagnostic that reads as "right there" + -- is actually one line above the cursor as far as the LSP + -- request is concerned. This widens the request to also catch + -- a diagnostic one line either side, pulling each one's + -- original LSP-format diagnostic back out of user_data.lsp + -- (vim.diagnostic.get() preserves it there for exactly this), + -- and grows the requested range to actually cover them too -- + -- a server is free to ignore a diagnostic in context that + -- falls outside the requested range, so widening only the + -- diagnostics list without the range wouldn't reliably help. + -- + -- code_action()'s `range` wants plain {row, col} tuples using + -- mark-like indexing (1-indexed row, 0-indexed col -- the same + -- shape nvim_win_get_cursor()/nvim_buf_get_mark() use), NOT LSP + -- {line=, character=} position objects: it hands start/end + -- straight to vim.lsp.util.make_given_range_params(), which + -- indexes into them positionally (start_pos[1], start_pos[2]). + -- A {line=, character=} table has no numeric [1]/[2], so that + -- indexing was silently nil and the arithmetic on it crashed + -- with E5108 ("attempt to perform arithmetic on a nil value") + -- every time this ran. min_line/max_line below are 0-indexed + -- (from diagnostic .lnum/.end_lnum), so +1 converts to mark + -- indexing; the end column is the target line's real length so + -- the range also reaches a diagnostic that starts mid-line + -- rather than only ones starting at column 0. + vim.keymap.set('n', 'la', function() + local row = vim.api.nvim_win_get_cursor(0)[1] - 1 + local nearby, min_line, max_line = {}, row, row + for _, d in ipairs(vim.diagnostic.get(bufnr)) do + if math.abs(d.lnum - row) <= 1 and d.user_data and d.user_data.lsp then + table.insert(nearby, d.user_data.lsp) + min_line = math.min(min_line, d.lnum) + max_line = math.max(max_line, d.end_lnum or d.lnum) + end + end + if vim.tbl_isempty(nearby) then + vim.lsp.buf.code_action() + return + end + local last_line = vim.api.nvim_buf_get_lines(bufnr, max_line, max_line + 1, false)[1] or "" + vim.lsp.buf.code_action({ + context = { diagnostics = nearby }, + range = { start = { min_line + 1, 0 }, ["end"] = { max_line + 1, #last_line } }, + }) + end, { buffer = bufnr, silent = true, desc = "Code Actions" }) + -- Visual-mode counterpart: several clangd tweaks (Extract + -- Function/Variable, raw-string conversion, ...) only show up + -- when the request carries an actual selection range instead + -- of the zero-width one normal mode sends. + vim.keymap.set('x', 'la', vim.lsp.buf.code_action, { buffer = bufnr, silent = true, desc = "Code Actions (Selection)" }) + vim.keymap.set('n', 'lf', function() vim.lsp.buf.format { async = true } end, { buffer = bufnr, silent = true, desc = "Format File" }) + -- "Fix all clang-tidy issues in this file" turns out not to be + -- something clangd's code actions can actually do: this used + -- to filter for LSP's `source.fixAll` code action kind, but + -- clangd never returns actions of that kind -- clang-tidy + -- fixes surface from clangd only as individual `quickfix` + -- actions, one per diagnostic, and clangd's own issue tracker + -- confirms there's no batched "fix everything" kind (clangd/ + -- clangd#1446). So this always came back "No code actions + -- available" no matter what the file contained. + -- + -- Running the actual clang-tidy binary with --fix sidesteps + -- clangd's code actions entirely and does what this key was + -- always meant to: apply every fix the tool can make across + -- the whole file in one pass, using the same .clang-tidy and + -- compile_commands.json clangd itself uses. --fix (rather than + -- --fix-errors) deliberately leaves the file untouched if + -- clang-tidy hits real compiler errors, instead of forcing + -- fixes onto code that doesn't compile -- switch it to + -- --fix-errors below if you'd rather it try anyway. + vim.keymap.set('n', 'lt', function() + if vim.fn.executable("clang-tidy") == 0 then + vim.notify("clang-tidy not found on PATH", vim.log.levels.ERROR) + return + end + local filepath = vim.api.nvim_buf_get_name(bufnr) + if filepath == "" then + vim.notify("Buffer has no file on disk to run clang-tidy on", vim.log.levels.WARN) + return + end + -- clang-tidy reads from disk, not the buffer -- make sure + -- what's on disk actually matches what's on screen first. + if vim.bo[bufnr].modified then + vim.cmd("write") + end + + local cmd = { "clang-tidy", "--fix" } + local db = vim.fs.find("compile_commands.json", { + upward = true, + path = vim.fs.dirname(filepath), + })[1] + if db then + vim.list_extend(cmd, { "-p", vim.fs.dirname(db) }) + end + table.insert(cmd, filepath) + + -- No "starting..." notification, and nothing shown at all on + -- a normal (exit 0) run: clang-tidy always writes chatter to + -- stderr -- including a notice about a missing compile + -- database, and its own end-of-run warning count -- even + -- when everything worked fine, so treating that output as + -- warning-worthy just because it's non-empty was the actual + -- source of the noise. Only a genuine failure (non-zero + -- exit) surfaces anything here. + vim.system(cmd, { text = true }, function(result) + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + -- Reload from disk so the buffer picks up whatever + -- clang-tidy just rewrote. :edit! (rather than + -- :checktime) reloads unconditionally instead of + -- prompting, since 'autoread' isn't set above. + local view = vim.fn.winsaveview() + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("edit!") + end) + pcall(vim.fn.winrestview, view) + + if result.code ~= 0 then + local msg = vim.trim(result.stderr or "") + if #msg > 400 then + msg = msg:sub(1, 400) .. "\n... (truncated -- re-run in a terminal for full output)" + end + vim.notify("clang-tidy exited with status " .. result.code .. ":\n" .. msg, vim.log.levels.WARN) + else + vim.notify("clang-tidy --fix finished", vim.log.levels.INFO) + end + end) + end) + end, { buffer = bufnr, silent = true, desc = "Fix All (clang-tidy)" }) + vim.keymap.set('n', 'li', 'LspInfo', { buffer = bufnr, silent = true, desc = "LSP Info" }) + + -- Diagnostic navigation, by severity. ]d/[d for "whatever's + -- next" regardless of severity; ]e/[e, ]w/[w, ]t/[t narrow to + -- errors, warnings, and hints ("tip" is the mnemonic here -- + -- LSP itself calls that severity level "Hint") respectively. + -- No `float` option on the jump itself: virtual_lines (set in + -- plugins/lsp.lua) already shows the message text permanently, so a + -- floating preview on top of it would just be redundant. + local function diag_jump(count, severity) + return function() + vim.diagnostic.jump({ count = count, severity = severity }) + end + end + vim.keymap.set('n', ']d', diag_jump(1), { buffer = bufnr, silent = true, desc = "Next Diagnostic" }) + vim.keymap.set('n', '[d', diag_jump(-1), { buffer = bufnr, silent = true, desc = "Previous Diagnostic" }) + vim.keymap.set('n', ']e', diag_jump(1, vim.diagnostic.severity.ERROR), { buffer = bufnr, silent = true, desc = "Next Error" }) + vim.keymap.set('n', '[e', diag_jump(-1, vim.diagnostic.severity.ERROR), { buffer = bufnr, silent = true, desc = "Previous Error" }) + vim.keymap.set('n', ']w', diag_jump(1, vim.diagnostic.severity.WARN), { buffer = bufnr, silent = true, desc = "Next Warning" }) + vim.keymap.set('n', '[w', diag_jump(-1, vim.diagnostic.severity.WARN), { buffer = bufnr, silent = true, desc = "Previous Warning" }) + vim.keymap.set('n', ']t', diag_jump(1, vim.diagnostic.severity.HINT), { buffer = bufnr, silent = true, desc = "Next Tip" }) + vim.keymap.set('n', '[t', diag_jump(-1, vim.diagnostic.severity.HINT), { buffer = bufnr, silent = true, desc = "Previous Tip" }) +end + +function M.setup() + vim.api.nvim_create_autocmd("LspAttach", { + group = vim.api.nvim_create_augroup("UserLspAttach", { clear = true }), + callback = function(args) + on_attach(args.buf) + end, + }) +end + +return M diff --git a/nvim/config/nvim/lua/config/lsp/servers.lua b/nvim/config/nvim/lua/config/lsp/servers.lua new file mode 100644 index 0000000..edcf181 --- /dev/null +++ b/nvim/config/nvim/lua/config/lsp/servers.lua @@ -0,0 +1,71 @@ +-- Language servers this config sets up. +-- +-- One entry per server, keyed by its nvim-lspconfig name (`:help +-- lspconfig-all` lists them). The value is a table of extra settings layered +-- on top of nvim-lspconfig's own defaults for that server via +-- vim.lsp.config() -- `{}` means "the defaults are fine". +-- +-- This single table drives all of plugins/lsp.lua: the keys are what Mason +-- installs (mason-lspconfig's ensure_installed) AND what gets passed to +-- vim.lsp.enable(), so adding a server is one entry here instead of two +-- lists that have to be kept in sync by hand. + +return { + -- Per-server overrides beyond the shared `capabilities` (see plugins/lsp.lua). + -- clangd's own binary defaults already cover most of this, but + -- being explicit documents intent and avoids depending on + -- whatever a given clangd build's defaults happen to be: + -- --background-index index the whole project up front, + -- not just open files -- clang-tidy + -- checks and code actions that need + -- full semantic info are noticeably + -- thinner without it. + -- --clang-tidy explicit, even though on by default. + -- --header-insertion/--completion-style/--all-scopes-completion/ + -- --cross-file-rename richer completion and project-wide + -- rename instead of the conservative + -- single-file defaults. + -- None of this substitutes for a compile_commands.json, though: + -- without one, clangd falls back to guessed compiler flags, and + -- most clang-tidy checks -- and the code actions/fixes tied to + -- them -- need real -std/-I flags to run at all. Generate one + -- via CMake (`-DCMAKE_EXPORT_COMPILE_COMMANDS=ON`, then symlink + -- or copy build/compile_commands.json to the project root) or + -- `bear` for non-CMake projects if la/lt still + -- come up thin on a real project after this. + clangd = { + cmd = { + "clangd", + "--background-index", + "--clang-tidy", + "--completion-style=detailed", + "--header-insertion=iwyu", + "--all-scopes-completion", + "--cross-file-rename", + }, + }, + + gopls = {}, + omnisharp = {}, + ts_ls = {}, + html = {}, + cssls = {}, + + -- SQL for MariaDB / MySQL: completion, hover, formatting and code actions + -- in .sql buffers. sqls has no MariaDB dialect of its own, but its MySQL + -- driver speaks MariaDB's wire protocol and SQL flavor, so it covers + -- MariaDB fine. Mason installs it by building from source with `go + -- install`, so Go has to be on PATH (the setup script installs it). + -- + -- sqls only knows table/column names if it has a database connection of + -- its OWN, which it reads from ~/.config/sqls/config.yml -- it does not + -- share vim-dadbod's connections. Without that file it still runs, but + -- only as keyword completion + formatting. Setup steps and an example + -- file are in README.md ("Database (MariaDB)"). + -- + -- Connections are deliberately not set through `settings` here: per + -- sqls' docs, whatever the client hands it over workspace/configuration + -- takes priority over config.yml, so defining them here would shadow the + -- file instead of complementing it. + sqls = {}, +} diff --git a/nvim/config/nvim/lua/plugins/autopairs.lua b/nvim/config/nvim/lua/plugins/autopairs.lua new file mode 100644 index 0000000..d58e4a1 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/autopairs.lua @@ -0,0 +1,28 @@ +return { + -- Auto-pair brackets/quotes: (), {}, [], "", '' -- nvim-autopairs' + -- own defaults. <> was tried here too but removed: it collided too + -- much with < and > as comparison operators to be worth keeping. + { + "windwp/nvim-autopairs", + dependencies = { "hrsh7th/nvim-cmp" }, + config = function() + local autopairs = require("nvim-autopairs") + -- map_cr defaults to true, which makes nvim-autopairs install + -- its OWN insert-mode mapping (for expanding bracket pairs + -- onto their own indented line). That's set during plugin + -- loading, which runs after init.lua's own top-level code -- + -- where the comment-continuation mapping lives -- so it + -- was silently overwriting that mapping entirely regardless of + -- what it did internally. Disabled here to free up for + -- that mapping; the trade-off is losing autopairs' own + -- brace-expands-on-Enter behavior, which was never explicitly + -- asked for. + autopairs.setup({ map_cr = false }) + + -- Integrates with nvim-cmp so accepting a completion (e.g. a + -- function call) doesn't end up with doubled-up parens. + local cmp_autopairs = require("nvim-autopairs.completion.cmp") + require("cmp").event:on("confirm_done", cmp_autopairs.on_confirm_done()) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/bufferline.lua b/nvim/config/nvim/lua/plugins/bufferline.lua new file mode 100644 index 0000000..5ec4046 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/bufferline.lua @@ -0,0 +1,136 @@ +return { + -- Bufferline (Tabs) + { + "akinsho/bufferline.nvim", + version = "*", + dependencies = "nvim-tree/nvim-web-devicons", + config = function() + require("bufferline").setup({ + options = { + diagnostics = "nvim_lsp", + show_buffer_close_icons = false, + numbers = "ordinal", -- shows 1, 2, 3... on the left of each tab, + -- matching the position used by the Alt+number jump below. + -- + -- Makes :BufferLineMovePrev/MoveNext refuse to walk an + -- unpinned buffer across the pinned block (wraps to the + -- other end instead) -- this is bufferline's own built-in + -- boundary check (see get_last_pinned_index/M.move in its + -- source), just off by default. + move_wraps_at_ends = true, + } + }) + + -- Navigate buffers. Using bufferline's own cycle commands rather + -- than :bprevious/:bnext -- once buffers can be reordered (see + -- / below), plain :bnext/:bprevious would cycle by + -- vim's internal buffer number instead of the order shown on + -- screen, which stops matching what you actually see. This is + -- bufferline's own documented recommendation for exactly this + -- situation. + vim.keymap.set('n', '', 'BufferLineCyclePrev', { desc = "Prev buffer" }) + vim.keymap.set('n', '', 'BufferLineCycleNext', { desc = "Next buffer" }) + vim.keymap.set('n', 'bd', 'bdelete', { desc = "Close Current Buffer" }) + + -- Alt+1..9: jump straight to the buffer at that ordinal position + -- (the number shown on the left of each tab). Alt+0 jumps to the + -- last one, mirroring bufferline's own "$" convention. + for i = 1, 9 do + vim.keymap.set('n', (''):format(i), function() + require('bufferline').go_to(i, true) + end, { desc = "Go to Buffer " .. i }) + end + vim.keymap.set('n', '', function() + require('bufferline').go_to(-1, true) + end, { desc = "Go to Last Buffer" }) + + -- F1..F9: same jump-by-position, on Fn+Number instead of + -- Alt+Number. Explicitly setting here overrides Neovim's + -- default -opens-help binding outright (any explicit + -- vim.keymap.set for a key always supersedes Neovim's built-in + -- default for it), so no separate step is needed to "disable" + -- that. + -- + -- and are set below, OUTSIDE this loop -- both are + -- shared with nvim-dap (Continue and Step Over respectively) and + -- toggle dynamically based on whether a debug session is active + -- (see the dap.listeners hooks in plugins/dap.lua). + -- At rest, both act as ordinary buffer jumps like everything + -- else in this loop. + for _, i in ipairs({ 1, 2, 3, 4, 6, 7, 8, 9 }) do + vim.keymap.set('n', (''):format(i), function() + require('bufferline').go_to(i, true) + end, { desc = "Go to Buffer " .. i }) + end + vim.keymap.set('n', '', function() + require('bufferline').go_to(5, true) + end, { desc = "Go to Buffer 5" }) + vim.keymap.set('n', '', function() + require('bufferline').go_to(-1, true) + end, { desc = "Go to Last Buffer" }) + + -- Alt+,/. : reorder the current buffer left/right in the + -- bufferline (persists for the session as long as + -- sessionoptions includes "globals", which lazy.nvim's default + -- vimrc already does). move_wraps_at_ends above handles keeping + -- these from crossing into the pinned block -- no custom guard + -- needed here. + vim.keymap.set('n', '', 'BufferLineMovePrev', { desc = "Move Buffer Left" }) + vim.keymap.set('n', '', 'BufferLineMoveNext', { desc = "Move Buffer Right" }) + + -- Pin the current buffer -- pinned buffers stay pinned to the + -- start of the bufferline regardless of sorting/reordering. + -- + -- Pin state is ALSO recorded in a plain buffer-local variable + -- here, set at the same time as the real toggle. bufferline's + -- internal pin/group state isn't reliably exposed as a stable + -- public API across versions -- require('bufferline').group_action + -- (tried previously) doesn't exist on the version actually + -- installed here, hence the earlier E5108 error. Tracking it + -- ourselves means bx below never has to ask bufferline + -- which buffers are pinned at all. + -- + -- :BufferLineTogglePin only updates the RENDER-time grouping; it + -- doesn't physically move the buffer within bufferline's + -- internal position list (state.components), which is the exact + -- list go_to()/number labels are computed from. That gap is what + -- caused Alt+number to occasionally land on/label the wrong + -- buffer right after pinning. move_to() operates on that same + -- internal list directly (it's what :BufferLineMovePrev/Next and + -- the sort commands use internally), so calling it here forces + -- the two back into sync immediately: to the front when pinning, + -- to just after the remaining pinned block when unpinning. + vim.keymap.set('n', 'bp', function() + vim.cmd('BufferLineTogglePin') + local buf = vim.api.nvim_get_current_buf() + local now_pinned = not vim.b[buf].pinned + vim.b[buf].pinned = now_pinned + + if now_pinned then + require('bufferline').move_to(1) + else + local pinned_count = 0 + for _, b in ipairs(vim.fn.getbufinfo({ buflisted = 1 })) do + if b.bufnr ~= buf and vim.b[b.bufnr].pinned then + pinned_count = pinned_count + 1 + end + end + require('bufferline').move_to(pinned_count + 1) + end + end, { desc = "Toggle Pin" }) + + -- Close every unpinned buffer that has NO unsaved changes, using + -- the buffer-local pin flag set above -- deliberately not using + -- :BufferLineGroupClose ungrouped, since its default + -- close_command is "bdelete! %d" (forced), which would silently + -- discard unsaved work in any unpinned buffer. + vim.keymap.set('n', 'bx', function() + for _, buf in ipairs(vim.fn.getbufinfo({ buflisted = 1 })) do + if not vim.b[buf.bufnr].pinned and buf.changed == 0 then + pcall(vim.cmd, 'bdelete ' .. buf.bufnr) + end + end + end, { desc = "Close Unpinned Saved Buffers" }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/colorizer.lua b/nvim/config/nvim/lua/plugins/colorizer.lua new file mode 100644 index 0000000..a5f8f3f --- /dev/null +++ b/nvim/config/nvim/lua/plugins/colorizer.lua @@ -0,0 +1,9 @@ +return { + -- Inline hex/rgb color preview + { + "norcalli/nvim-colorizer.lua", + config = function() + require("colorizer").setup() + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/colorscheme.lua b/nvim/config/nvim/lua/plugins/colorscheme.lua new file mode 100644 index 0000000..f999584 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/colorscheme.lua @@ -0,0 +1,49 @@ +return { + -- REALLY Dark Theme (near-black koda) + -- + -- Terminal Neovim and Neovide (the desktop GUI app) render the same + -- highlight groups, so the theme itself needs nothing + -- frontend-specific -- it's only the cursor *animation* that splits + -- in two (see smear-cursor.lua and init.lua), since a terminal and a GPU-rendered GUI window have + -- different ways of drawing one. + { + "oskarnurm/koda.nvim", + priority = 1000, + config = function() + require("koda").setup({ + -- Terminal only (see the "not Neovide" comment on the setting + -- above): clears highlight-group backgrounds to NONE so + -- whatever the *terminal emulator* draws behind Neovim shows + -- through the gaps -- including a background image, if the + -- terminal supports one (e.g. kitty's own `background_image` + -- setting in kitty.conf, which is a terminal-level feature + -- this config has no reach into). Without that configured on + -- the terminal side, this just shows the terminal's normal + -- background color instead, which is a harmless no-op look. + -- Neovide doesn't use this option at all -- see + -- vim.g.neovide_opacity in init.lua for its equivalent. + transparent = not vim.g.neovide, + colors = { + -- koda's own "dark" variant defaults to #101010 -- nudged a + -- touch further down to match the old pure-black background. + bg = "#090909", + line = "#1a1a1a", + }, + on_highlights = function(hl, c) + -- koda has no dedicated "hint" color; c.cyan is the closest + -- hue match to the dark-teal hint background below. + hl.DiagnosticVirtualTextError = { bg = "#351010", fg = c.danger } + hl.DiagnosticVirtualTextWarn = { bg = "#35280b", fg = c.warning } + hl.DiagnosticVirtualTextInfo = { bg = "#0b2035", fg = c.info } + hl.DiagnosticVirtualTextHint = { bg = "#0b3528", fg = c.cyan } + + hl.DiagnosticVirtualLinesError = { bg = "#351010", fg = c.danger } + hl.DiagnosticVirtualLinesWarn = { bg = "#35280b", fg = c.warning } + hl.DiagnosticVirtualLinesInfo = { bg = "#0b2035", fg = c.info } + hl.DiagnosticVirtualLinesHint = { bg = "#0b3528", fg = c.cyan } + end, + }) + vim.cmd.colorscheme("koda-dark") + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/completion.lua b/nvim/config/nvim/lua/plugins/completion.lua new file mode 100644 index 0000000..ad16612 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/completion.lua @@ -0,0 +1,106 @@ +return { + -- Autocompletion + { + "hrsh7th/nvim-cmp", + dependencies = { + "L3MON4D3/LuaSnip", + "saadparwaiz1/cmp_luasnip", + "hrsh7th/cmp-nvim-lsp", + "hrsh7th/cmp-buffer", + "hrsh7th/cmp-path", + }, + config = function() + local cmp = require("cmp") + local luasnip = require("luasnip") + + -- Without this, LuaSnip never checks whether the cursor has + -- actually left a snippet's text -- so pressing Tab later, even + -- lines away, can jump straight back into an old, unfinished + -- placeholder session (e.g. clangd's function-argument + -- placeholders). region_check_events makes it watch cursor + -- movement and automatically exit a snippet once you've moved + -- outside its region, in both normal and insert mode. + luasnip.setup({ + region_check_events = "CursorMoved,CursorMovedI", + }) + + cmp.setup({ + -- Ghost text: previews the currently-selected completion inline + -- after the cursor, instead of only showing it in the popup menu. + -- Combined with the cmp-path source below, this is what gives you + -- inline "ghost" previews as you type a filesystem path (e.g. + -- typing ./src/ will ghost-preview entries found in that + -- directory) without needing a separate plugin for it. + experimental = { + ghost_text = true, + }, + snippet = { + expand = function(args) + luasnip.lsp_expand(args.body) + end, + }, + window = { + completion = cmp.config.window.bordered(), + documentation = cmp.config.window.bordered(), + }, + mapping = cmp.mapping.preset.insert({ + -- Safely close the menu without reverting text state + [''] = cmp.mapping(function(fallback) + if cmp.visible() then cmp.close() end + fallback() + end, { 'i', 's' }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then cmp.close() end + fallback() + end, { 'i', 's' }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then cmp.close() end + fallback() + end, { 'i', 's' }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then cmp.close() end + fallback() + end, { 'i', 's' }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then cmp.close() end + fallback() + end, { 'i', 's' }), + + [''] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_next_item() + elseif luasnip.expand_or_jumpable() then + -- No completion menu open, but we're sitting in a snippet + -- (e.g. clangd's function-argument placeholders after + -- accepting a call like memcpy(...)) -- jump to the next + -- tabstop/placeholder instead of inserting a literal tab. + luasnip.expand_or_jump() + else + fallback() + end + end, { 'i', 's' }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_prev_item() + elseif luasnip.jumpable(-1) then + luasnip.jump(-1) + else + fallback() + end + end, { 'i', 's' }), + + [''] = cmp.mapping.confirm({ select = false }), + }), + sources = cmp.config.sources({ + { name = "nvim_lsp" }, + { name = "luasnip" }, + { name = "buffer" }, + -- Lists and completes filesystem paths/directory contents as + -- you type a path string (e.g. after typing "./" or "/home/"). + -- This is the source that feeds the ghost-text path preview. + { name = "path" }, + }), + }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/dap.lua b/nvim/config/nvim/lua/plugins/dap.lua new file mode 100644 index 0000000..cfcdf62 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/dap.lua @@ -0,0 +1,135 @@ +return { + -- ========================================================= + -- DAP: In-editor C/C++ debugging (breakpoints, step, watch) + -- Uses codelldb (installed via Mason) as the debug adapter. + -- ========================================================= + { + "mfussenegger/nvim-dap", + dependencies = { + "rcarriga/nvim-dap-ui", + "nvim-neotest/nvim-nio", + "theHamsta/nvim-dap-virtual-text", + "jay-babu/mason-nvim-dap.nvim", + }, + config = function() + local dap = require("dap") + local dapui = require("dapui") + + -- Auto-install codelldb through Mason and wire it into nvim-dap. + -- NOTE: omitting `handlers` entirely (rather than passing {}) is + -- what makes mason-nvim-dap run its DEFAULT handler, which is + -- what actually registers a working dap.adapters.codelldb with + -- correct dynamic ${port} allocation. Passing handlers = {} + -- disables that default handler instead of using it. + require("mason-nvim-dap").setup({ + ensure_installed = { "codelldb" }, + automatic_installation = true, + }) + + require("dapui").setup() + require("nvim-dap-virtual-text").setup({ + commented = true, + }) + + -- Auto open/close the UI when a debug session starts/ends + dap.listeners.after.event_initialized["dapui_config"] = function() + dapui.open() + end + dap.listeners.before.event_terminated["dapui_config"] = function() + dapui.close() + end + dap.listeners.before.event_exited["dapui_config"] = function() + dapui.close() + end + + -- and are each shared between an nvim-dap action + -- (Continue, Step Over) and a bufferline buffer jump (buffer 5, + -- last buffer) -- at rest they're buffer jumps; for the + -- duration of a debug session they're handed over to DAP + -- instead, so nothing fights over the same key. Note this means + -- can no longer START a fresh session on its own (there's + -- no session yet at that point, so it's still doing buffer-jump + -- duty) -- use dc or db to start one, then + -- takes over for continuing from there. Reusing the same + -- listener points as the dapui open/close hooks above. + dap.listeners.after.event_initialized["fkey_toggle"] = function() + vim.keymap.set('n', '', dap.continue, { desc = "Debug: Continue" }) + vim.keymap.set('n', '', dap.step_over, { desc = "Debug: Step Over" }) + end + local function restore_fkey_buffer_jumps() + vim.keymap.set('n', '', function() + require('bufferline').go_to(5, true) + end, { desc = "Go to Buffer 5" }) + vim.keymap.set('n', '', function() + require('bufferline').go_to(-1, true) + end, { desc = "Go to Last Buffer" }) + end + dap.listeners.before.event_terminated["fkey_toggle"] = restore_fkey_buffer_jumps + dap.listeners.before.event_exited["fkey_toggle"] = restore_fkey_buffer_jumps + + -- C / C++ / Rust launch configs, all sharing the codelldb adapter + dap.configurations.cpp = { + { + name = "Launch executable", + type = "codelldb", + request = "launch", + program = function() + return vim.fn.input( + "Path to executable: ", + vim.fn.getcwd() .. "/", + "file" + ) + end, + cwd = "${workspaceFolder}", + stopOnEntry = false, + args = {}, + }, + { + name = "Attach to process", + type = "codelldb", + request = "attach", + pid = require("dap.utils").pick_process, + cwd = "${workspaceFolder}", + }, + } + dap.configurations.c = dap.configurations.cpp + dap.configurations.rust = dap.configurations.cpp + + -- Breakpoint / stepping signs + vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError", linehl = "", numhl = "" }) + vim.fn.sign_define("DapBreakpointCondition", { text = "◆", texthl = "DiagnosticWarn", linehl = "", numhl = "" }) + vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticInfo", linehl = "DapStoppedLine", numhl = "" }) + vim.api.nvim_set_hl(0, "DapStoppedLine", { bg = "#0b3528" }) + + -- Keymaps, grouped under d (shows in which-key) + -- and intentionally NOT bound here -- both are set + -- dynamically by the dap.listeners hooks above (Continue/Step + -- Over only while a session is active; bufferline's buffer-5/ + -- last-buffer jumps otherwise). + vim.keymap.set('n', '', dap.step_into, { desc = "Debug: Step Into" }) + vim.keymap.set('n', '', dap.step_out, { desc = "Debug: Step Out" }) + + vim.keymap.set('n', 'db', dap.toggle_breakpoint, { desc = "Toggle Breakpoint" }) + vim.keymap.set('n', 'dB', function() + dap.set_breakpoint(vim.fn.input("Breakpoint condition: ")) + end, { desc = "Conditional Breakpoint" }) + vim.keymap.set('n', 'dc', dap.continue, { desc = "Continue" }) + vim.keymap.set('n', 'do', dap.step_over, { desc = "Step Over" }) + vim.keymap.set('n', 'di', dap.step_into, { desc = "Step Into" }) + vim.keymap.set('n', 'dO', dap.step_out, { desc = "Step Out" }) + vim.keymap.set('n', 'dr', dap.repl.open, { desc = "Open REPL" }) + vim.keymap.set('n', 'dl', dap.run_last, { desc = "Run Last" }) + vim.keymap.set('n', 'dt', dap.terminate, { desc = "Terminate Session" }) + vim.keymap.set('n', 'du', dapui.toggle, { desc = "Toggle Debug UI" }) + vim.keymap.set('n', 'dh', function() + require("dap.ui.widgets").hover() + end, { desc = "Hover Variable" }) + + -- Load project-specific CMake-built target configs (defines + -- Debug configs for build/tests/... and build/demo/... binaries). + -- This also makes nvim-dap lazy-load the same file automatically + -- when you start debugging from a CMakeLists.txt buffer. + require("dap.configurations.cmake") + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/dashboard.lua b/nvim/config/nvim/lua/plugins/dashboard.lua new file mode 100644 index 0000000..21b031c --- /dev/null +++ b/nvim/config/nvim/lua/plugins/dashboard.lua @@ -0,0 +1,33 @@ +return { + -- Dashboard (NvChad-style start screen) + { + "goolord/alpha-nvim", + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + local alpha = require("alpha") + local dashboard = require("alpha.themes.dashboard") + + dashboard.section.header.val = { + " ", + " ███╗ ██╗██╗ ██╗██╗███╗ ███╗ ", + " ████╗ ██║██║ ██║██║████╗ ████║ ", + " ██╔██╗ ██║██║ ██║██║██╔████╔██║ ", + " ██║╚██╗██║╚██╗ ██╔╝██║██║╚██╔╝██║ ", + " ██║ ╚████║ ╚████╔╝ ██║██║ ╚═╝ ██║ ", + " ╚═╝ ╚═══╝ ╚═══╝ ╚═╝╚═╝ ╚═╝ ", + " ", + } + + dashboard.section.buttons.val = { + dashboard.button("f", " Find File", "Telescope find_files"), + dashboard.button("r", " Recent Files", "Telescope oldfiles"), + dashboard.button("w", " Live Grep", "Telescope live_grep"), + dashboard.button("e", " New File", "ene"), + dashboard.button("c", " Edit Config", "edit ~/.config/nvim/init.lua"), + dashboard.button("q", " Quit", "qa"), + } + + alpha.setup(dashboard.opts) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/database.lua b/nvim/config/nvim/lua/plugins/database.lua new file mode 100644 index 0000000..4bfe779 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/database.lua @@ -0,0 +1,116 @@ +return { + -- Database access, built around MariaDB. vim-dadbod runs queries against a + -- server through the database's own command-line client, vim-dadbod-ui + -- adds a sidebar of connections/tables plus saved query buffers, and + -- vim-dadbod-completion feeds table names into nvim-cmp in SQL buffers. + -- Language-server features for SQL (keyword/column completion, hover, + -- formatting) are a separate thing: that's sqls, set up in + -- lua/config/lsp/servers.lua. README.md ("Database (MariaDB)") covers + -- how the two fit together. + -- + -- Connections are URLs, with one scheme per backend. For MariaDB it's + -- mariadb://user:password@host:3306/dbname + -- which uses dadbod's dedicated MariaDB adapter and runs the `mariadb` + -- client binary. (The older `mysql://` scheme works too, but it shells + -- out to `mysql`, which MariaDB 11+ only ships as a deprecated alias that + -- prints "Deprecated program name" on every call -- noise that ends up in + -- query results and table lists.) Include the database name in the URL: + -- the drawer lists the tables of that one database. + -- + -- Add connections from inside Neovim: `A` in the drawer, or Da. + -- Nothing with credentials belongs in this file. + { + "kristijanhusak/vim-dadbod-ui", + dependencies = { + { "tpope/vim-dadbod", lazy = true }, + { + "kristijanhusak/vim-dadbod-completion", + -- Its completion source calls dadbod's autoload functions, so it has + -- to pull dadbod in along with itself -- otherwise opening a plain + -- .sql file (never touching the DB UI) and typing would hit an + -- undefined `db#...` function. + dependencies = "tpope/vim-dadbod", + ft = { "sql", "mysql", "plsql" }, + lazy = true, + }, + }, + cmd = { + "DBUI", + "DBUIToggle", + "DBUIAddConnection", + "DBUIFindBuffer", + "DBUIRenameBuffer", + "DBUILastQueryInfo", + }, + keys = { + { "Du", "DBUIToggle", desc = "Toggle DB UI" }, + { "Df", "DBUIFindBuffer", desc = "Find DB Buffer" }, + { "Dr", "DBUIRenameBuffer", desc = "Rename DB Buffer" }, + { "Dl", "DBUILastQueryInfo", desc = "Last Query Info" }, + { "Da", "DBUIAddConnection", desc = "Add DB Connection" }, + }, + -- `init` (not `config`) because these globals have to be set before the + -- plugins load, and because the FileType autocmd below has to exist from + -- startup even though the plugins themselves are lazy-loaded. + init = function() + vim.g.db_ui_use_nerd_fonts = 1 + + -- Where connections added through the UI (and saved queries) live. + -- Set explicitly, to somewhere OUTSIDE both ~/.config/nvim and + -- ~/.local/share/nvim: setup_neovim.sh wipes those two on every run, + -- and saved connections shouldn't go with them. The URLs are stored in + -- plain text in connections.json here, password included -- leave the + -- password out of the URL and put it in ~/.my.cnf's [client] section + -- if that bothers you (the mariadb client reads it from there). + local data_home = vim.env.XDG_DATA_HOME + if not data_home or data_home == "" then + data_home = vim.env.HOME .. "/.local/share" + end + vim.g.db_ui_save_location = data_home .. "/db_ui" + + -- Table helpers for mariadb:// connections: the entries listed under + -- each table in the drawer. Spelled out here because dadbod-ui's + -- generic fallback query double-quotes the table name + -- (SELECT * from "{table}" ...), and MariaDB reads double quotes as a + -- string literal rather than an identifier unless ANSI_QUOTES is on. + -- These use backticks instead, plus a few more useful helpers. + vim.g.db_ui_table_helpers = { + mariadb = { + List = "SELECT * FROM `{table}` LIMIT 200;", + Columns = "DESCRIBE `{table}`;", + Indexes = "SHOW INDEX FROM `{table}`;", + Count = "SELECT COUNT(*) FROM `{table}`;", + }, + } + + -- Vim's built-in SQL syntax/indent/ftplugin default to Oracle's + -- dialect; 'mysql' is the closest built-in match for MariaDB. + vim.g.sql_type_default = "mysql" + + -- In SQL buffers, add vim-dadbod-completion to the usual cmp sources. + -- It completes table names from the active dadbod connection, and the + -- plugin registers the "vim-dadbod-completion" source with cmp itself. + -- Its column-level completion only covers some schemes (mysql, + -- postgres, ...) and not mariadb://, so columns, keywords and so on + -- come from sqls through the nvim_lsp source instead. + vim.api.nvim_create_autocmd("FileType", { + group = vim.api.nvim_create_augroup("UserDadbodCompletion", { clear = true }), + pattern = { "sql", "mysql", "plsql" }, + callback = function() + local ok, cmp = pcall(require, "cmp") + if not ok then + return + end + cmp.setup.buffer({ + sources = cmp.config.sources({ + { name = "vim-dadbod-completion" }, + { name = "nvim_lsp" }, + { name = "luasnip" }, + { name = "buffer" }, + }), + }) + end, + }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/dressing.lua b/nvim/config/nvim/lua/plugins/dressing.lua new file mode 100644 index 0000000..757af12 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/dressing.lua @@ -0,0 +1,10 @@ +return { + -- Prettier vim.ui.input / vim.ui.select -- upgrades your existing + -- rename prompt, conditional breakpoint prompt, and DAP path prompts + -- to floating, styled dialogs instead of the command-line input. + { + "stevearc/dressing.nvim", + event = "VeryLazy", + opts = {}, + }, +} diff --git a/nvim/config/nvim/lua/plugins/gitsigns.lua b/nvim/config/nvim/lua/plugins/gitsigns.lua new file mode 100644 index 0000000..39cf5f8 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/gitsigns.lua @@ -0,0 +1,9 @@ +return { + -- Git State + { + "lewis6991/gitsigns.nvim", + config = function() + require("gitsigns").setup() + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/hex.lua b/nvim/config/nvim/lua/plugins/hex.lua new file mode 100644 index 0000000..3a87abe --- /dev/null +++ b/nvim/config/nvim/lua/plugins/hex.lua @@ -0,0 +1,45 @@ +return { + -- Hex file viewer/editor. Toggles between normal and hex-dump view + -- (backed by xxd) rather than showing both simultaneously -- that + -- trade-off is deliberate: since the hex dump is just ordinary, + -- plain buffer text under the hood, editing it gets Neovim's normal + -- undo/redo for free, and nothing writes to disk until an explicit + -- :w, same as any other file. The only plugin found that does a + -- true live simultaneous hex+ASCII view (hexview.nvim, used here + -- previously) manages bytes with its own custom logic instead of + -- going through normal buffer text editing, which is exactly why it + -- has no undo at all -- not a gap that plugin could patch, an + -- architectural trade-off against the same thing being fixed here. + { + "RaafatTurki/hex.nvim", + config = function() + require("hex").setup() + vim.keymap.set('n', 'h', function() + if not vim.bo.binary then + -- Hex editing depends on the buffer actually being loaded + -- as binary. Otherwise Neovim applies normal text encoding/ + -- line-ending handling to the raw bytes first (NUL bytes + -- included, which any real binary is full of) -- that + -- misinterpretation is what produces "CONVERSION ERROR" + -- rather than a clean hex dump, and it happens BEFORE xxd + -- ever runs. Re-reading with ++bin loads the file correctly + -- from scratch instead of operating on an already-misread + -- buffer. + -- + -- That reload is forced (!), which discards whatever's + -- currently in the buffer without asking -- refuse to do it + -- if there are unsaved changes rather than silently losing + -- them. Only relevant on this first switch into hex mode: + -- once vim.bo.binary is already true, no reload happens and + -- this check doesn't apply. + if vim.bo.modified then + vim.notify("Buffer has unsaved changes -- save first (or :e! to discard) before opening hex view", vim.log.levels.WARN) + return + end + vim.cmd('edit! ++bin %') + end + vim.cmd('HexToggle') + end, { desc = "Toggle Hex View" }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/indent-blankline.lua b/nvim/config/nvim/lua/plugins/indent-blankline.lua new file mode 100644 index 0000000..cd89108 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/indent-blankline.lua @@ -0,0 +1,22 @@ +return { + -- Indent guides + { + "lukas-reineke/indent-blankline.nvim", + main = "ibl", + config = function() + require("ibl").setup({ + indent = { char = "│" }, + scope = { + enabled = true, + -- show_start/show_end default to true and underline the + -- first/last line of whatever lexical scope the cursor is + -- currently inside (recalculated as the cursor moves -- + -- that's why it seemed to "follow" the cursor). The colored + -- guide itself is kept; just the underline is turned off. + show_start = false, + show_end = false, + }, + }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/init.lua b/nvim/config/nvim/lua/plugins/init.lua deleted file mode 100644 index 18845ff..0000000 --- a/nvim/config/nvim/lua/plugins/init.lua +++ /dev/null @@ -1,1479 +0,0 @@ -return { - -- REALLY Dark Theme (near-black koda) - -- - -- Terminal Neovim and Neovide (the desktop GUI app) render the same - -- highlight groups, so the theme itself needs nothing - -- frontend-specific -- it's only the cursor *animation* that splits - -- in two below, since a terminal and a GPU-rendered GUI window have - -- different ways of drawing one. - { - "oskarnurm/koda.nvim", - priority = 1000, - config = function() - require("koda").setup({ - -- Terminal only (see the "not Neovide" comment on the setting - -- above): clears highlight-group backgrounds to NONE so - -- whatever the *terminal emulator* draws behind Neovim shows - -- through the gaps -- including a background image, if the - -- terminal supports one (e.g. kitty's own `background_image` - -- setting in kitty.conf, which is a terminal-level feature - -- this config has no reach into). Without that configured on - -- the terminal side, this just shows the terminal's normal - -- background color instead, which is a harmless no-op look. - -- Neovide doesn't use this option at all -- see - -- vim.g.neovide_opacity in init.lua for its equivalent. - transparent = not vim.g.neovide, - colors = { - -- koda's own "dark" variant defaults to #101010 -- nudged a - -- touch further down to match the old pure-black background. - bg = "#090909", - line = "#1a1a1a", - }, - on_highlights = function(hl, c) - -- koda has no dedicated "hint" color; c.cyan is the closest - -- hue match to the dark-teal hint background below. - hl.DiagnosticVirtualTextError = { bg = "#351010", fg = c.danger } - hl.DiagnosticVirtualTextWarn = { bg = "#35280b", fg = c.warning } - hl.DiagnosticVirtualTextInfo = { bg = "#0b2035", fg = c.info } - hl.DiagnosticVirtualTextHint = { bg = "#0b3528", fg = c.cyan } - - hl.DiagnosticVirtualLinesError = { bg = "#351010", fg = c.danger } - hl.DiagnosticVirtualLinesWarn = { bg = "#35280b", fg = c.warning } - hl.DiagnosticVirtualLinesInfo = { bg = "#0b2035", fg = c.info } - hl.DiagnosticVirtualLinesHint = { bg = "#0b3528", fg = c.cyan } - end, - }) - vim.cmd.colorscheme("koda-dark") - end, - }, - - -- Animated "smear" cursor trail, terminal only -- Neovide has its - -- own native, GPU-accelerated cursor VFX (see the `if vim.g.neovide` - -- block in init.lua), and running both at once would just be two - -- animation systems fighting over the same cursor. `cond` here means - -- this plugin doesn't even load under Neovide. Tuning below is - -- upstream's own suggested starting point. - { - "sphamba/smear-cursor.nvim", - cond = not vim.g.neovide, - config = function() - require("smear_cursor").setup({ - cursor_color = "#ffffff", - never_draw_over_target = true, - smear_insert_mode = false, - min_vertical_distance_smear = 2, - min_horizontal_distance_smear = 2, - time_interval = 17, -- ms - stiffness = 0.9, - trailing_stiffness = 0.4, - damping = 0.99, -- stops bouncing - }) - end, - }, - - -- Treesitter (syntax-aware highlighting + indentation) - -- - -- NOTE: pinned to branch = "master". Upstream's default branch is now - -- "main", which is a full incompatible rewrite that deleted the old - -- require("nvim-treesitter.configs").setup({...}) API entirely (this - -- is what caused "module 'nvim-treesitter.configs' not found"). The - -- "master" branch is explicitly kept frozen by upstream for backward - -- compatibility with this config style, so pin to it rather than - -- rewriting everything against the new API. - { - "nvim-treesitter/nvim-treesitter", - branch = "master", - build = ":TSUpdate", - config = function() - require("nvim-treesitter.configs").setup({ - ensure_installed = { - "c", "cpp", "lua", "go", "python", "javascript", "typescript", - "html", "css", "bash", "json", "yaml", "markdown", "cmake", - "doxygen", - }, - highlight = { enable = true }, - indent = { - enable = true, - -- treesitter's indent module is well-known to be unreliable - -- for a handful of languages specifically (inconsistent - -- brace placement, phantom extra indent on nested blocks) - -- while being fine for the rest. This is more pronounced on - -- the "master" branch pinned above, since it's frozen and - -- gets no further upstream fixes at all. Disabling it for - -- these falls back to Neovim's own built-in, per-filetype - -- indent logic (cindent for c/cpp, the bundled javascript/ - -- typescript indent scripts for the rest), which is far - -- more predictable for brace-heavy code. - -- - -- css and json are also curly-brace languages that CAN hit - -- the same class of issue -- add them here too if you - -- notice the same symptom there. - disable = { "c", "cpp", "javascript", "typescript" }, - }, - }) - end, - }, - - -- Doxygen (and other language) doc-comment generation. - -- - -- Not a language server -- clangd already understands Doxygen syntax - -- in existing comments (hover/signature-help render @param/@return), - -- this is the other half: generating the comment skeleton itself. - -- Place the cursor on/above a function and press cd to drop - -- in a Doxygen-style block with @brief/@param/@return stubs already - -- matching that function's actual parameters and return type. - { - "danymat/neogen", - dependencies = "nvim-treesitter/nvim-treesitter", - config = function() - require("neogen").setup({ - enabled = true, - -- c/cpp's default annotation_convention is already - -- "doxygen_cpp", so nothing to override there. - snippet_engine = "luasnip", -- inserted fields become LuaSnip - -- tabstops, so / (wired up in the nvim-cmp config - -- below for cmp/LuaSnip) cycles through @brief, each @param, - -- and @return. - }) - vim.keymap.set("n", "cd", function() - require("neogen").generate() - end, { desc = "Generate Doxygen Comment" }) - end, - }, - - -- Dashboard (NvChad-style start screen) - { - "goolord/alpha-nvim", - dependencies = { "nvim-tree/nvim-web-devicons" }, - config = function() - local alpha = require("alpha") - local dashboard = require("alpha.themes.dashboard") - - dashboard.section.header.val = { - " ", - " ███╗ ██╗██╗ ██╗██╗███╗ ███╗ ", - " ████╗ ██║██║ ██║██║████╗ ████║ ", - " ██╔██╗ ██║██║ ██║██║██╔████╔██║ ", - " ██║╚██╗██║╚██╗ ██╔╝██║██║╚██╔╝██║ ", - " ██║ ╚████║ ╚████╔╝ ██║██║ ╚═╝ ██║ ", - " ╚═╝ ╚═══╝ ╚═══╝ ╚═╝╚═╝ ╚═╝ ", - " ", - } - - dashboard.section.buttons.val = { - dashboard.button("f", " Find File", "Telescope find_files"), - dashboard.button("r", " Recent Files", "Telescope oldfiles"), - dashboard.button("w", " Live Grep", "Telescope live_grep"), - dashboard.button("e", " New File", "ene"), - dashboard.button("c", " Edit Config", "edit ~/.config/nvim/init.lua"), - dashboard.button("q", " Quit", "qa"), - } - - alpha.setup(dashboard.opts) - end, - }, - - -- Which-Key (Registers your namespaces so the menu looks clean) - { - "folke/which-key.nvim", - event = "VeryLazy", - config = function() - local wk = require("which-key") - wk.setup({ - delay = 0, - }) - -- Define group names for the menu - wk.add({ - { "b", group = "Buffers" }, - { "c", group = "Comments" }, - { "f", group = "Find / Grep" }, - { "l", group = "LSP" }, - { "o", group = "OOP" }, - { "s", group = "Splits" }, - { "t", group = "Terminal" }, - { "d", group = "Debug" }, - }) - end, - }, - - -- Telescope (Fuzzy Finding and Grepping) - { - "nvim-telescope/telescope.nvim", - -- NOTE: intentionally NOT pinned to branch = "0.1.x" -- that branch's - -- previewer calls nvim-treesitter's now-removed `ft_to_lang` helper, - -- which crashes the file preview with "attempt to call field - -- 'ft_to_lang' (a nil value)". The current default branch calls the - -- built-in vim.treesitter.language.get_lang() instead, which isn't - -- affected by that removal. - dependencies = { "nvim-lua/plenary.nvim" }, - config = function() - local builtin = require('telescope.builtin') - vim.keymap.set('n', 'ff', builtin.find_files, { desc = "Find Files" }) - vim.keymap.set('n', 'fw', builtin.live_grep, { desc = "Find Word (Live Grep)" }) - vim.keymap.set('n', 'fb', builtin.buffers, { desc = "Find Open Buffers" }) - vim.keymap.set('n', 'fh', builtin.help_tags, { desc = "Find Help Tags" }) - vim.keymap.set('n', 'fr', builtin.oldfiles, { desc = "Find Recent Files" }) - end, - }, - - -- NvimTree (On the right) - { - "nvim-tree/nvim-tree.lua", - dependencies = { "nvim-tree/nvim-web-devicons" }, - config = function() - require("nvim-tree").setup({ - view = { - side = "right", - width = 30, - }, - -- Routes the paste overwrite/rename CHOICE through - -- vim.ui.select tagged with kind = "nvimtree_overwrite_rename" - -- (nvim-tree's own documented hook for this), which the gP - -- batch-paste below intercepts. - select_prompts = true, - - -- Keep nvim-tree's own default mappings and add gP on top of - -- them, via the officially documented on_attach recipe -- - -- rather than the TreeAttachedPost event this used before, - -- whose handler is passed the bufnr directly as a plain - -- number (confirmed from nvim-tree's own docs), not a table - -- with a .buf field -- indexing that number is what threw - -- "attempt to index local 'data' (a number value)". - on_attach = function(bufnr) - local api = require("nvim-tree.api") - - -- The helper that installs the defaults has been named - -- differently across versions (api.config.mappings. - -- default_on_attach vs the older api.map.on_attach.default) - -- -- try both rather than assume one, so this doesn't - -- silently lose every default mapping on a version where - -- the other name is the real one. - if not pcall(api.config.mappings.default_on_attach, bufnr) then - pcall(api.map.on_attach.default, bufnr) - end - - -- gP: paste, resolving every naming collision in this one - -- paste with a single :s///-style pattern/replacement - -- - -- CAVEAT: this assumes a single-file-style overwrite/rename - -- choice per conflict. nvim-tree turns out to have a - -- SEPARATE, differently-shaped dialog specifically for - -- multi-file conflicts in one paste ("N file(s) already - -- exist" / Rename (suffix) / Overwrite all / Skip all), - -- which this doesn't detect or intercept -- so gP is only - -- confirmed reliable for a single conflicting file. For - -- "always rename every file, conflict or not" -- which is - -- what was actually being asked for -- gM below is the - -- better fit: it doesn't touch nvim-tree's paste/conflict - -- system at all, just copies marked files directly. - -- instead of nvim-tree's normal one-at-a-time - -- overwrite/rename prompt per conflicting file. Plain `p` - -- (bound above by default_on_attach) is untouched -- this - -- is a deliberately separate mapping, not a replacement. - -- - -- nvim-tree doesn't expose a way to enumerate the clipboard - -- or pre-scan for collisions before pasting (only - -- print/clear it), and conflicts are resolved one at a time - -- internally as the paste runs -- there's no "here's the - -- whole batch" moment to hook into directly. This gets the - -- same practical result a different way: ask ONCE upfront - -- for the pattern, then answer nvim-tree's own conflict - -- prompts programmatically for the duration of that paste - -- using vim.fn.substitute() (real Vim regex, same engine - -- :s/// itself uses) against the filename nvim-tree - -- pre-fills as its default. - -- - -- vim.ui.select/vim.ui.input are wrapped HERE, at call time - -- inside the keymap function, rather than once when this - -- plugin's config runs. nvim-tree loads eagerly at startup, - -- but dressing.nvim (which ALSO wraps these same two - -- functions, to prettify them) loads lazily on VeryLazy, - -- afterward -- wrapping once at config time would capture - -- the plain pre-dressing versions, which then get silently - -- overwritten (discarding this wrapper entirely) once - -- dressing finishes loading. Wrapping fresh on every call - -- instead always captures whatever's actually active. - vim.keymap.set('n', 'gP', function() - vim.ui.input({ prompt = "Rename pattern/replacement (Vim regex, e.g. foo/bar): " }, function(input) - if not input or input == "" then - api.fs.paste() - return - end - local pattern, replacement = input:match("^(.-)/(.*)$") - if not pattern then - vim.notify("Expected pattern/replacement, e.g. foo/bar", vim.log.levels.WARN) - return - end - - local real_select = vim.ui.select - local real_input = vim.ui.input - - vim.ui.select = function(items, opts, on_choice) - if opts and opts.kind == "nvimtree_overwrite_rename" then - for i, item in ipairs(items) do - if tostring(item):lower():match("rename") then - on_choice(item, i) - return - end - end - end - real_select(items, opts, on_choice) - end - - vim.ui.input = function(input_opts, on_confirm) - if input_opts and input_opts.default then - local ok, new_name = pcall(vim.fn.substitute, input_opts.default, pattern, replacement, "") - on_confirm(ok and new_name ~= "" and new_name or input_opts.default) - return - end - real_input(input_opts, on_confirm) - end - - -- Restored via a short defer rather than immediately - -- after api.fs.paste() returns, since it's not - -- confirmed whether paste() resolves every prompt - -- synchronously before returning to caller or defers - -- some of them -- restoring immediately risked only - -- patching the FIRST conflict in a multi-file paste and - -- silently falling back to normal prompts for the rest. - -- If that's what happens, this assumption is the next - -- thing to revisit. - api.fs.paste() - vim.defer_fn(function() - vim.ui.select = real_select - vim.ui.input = real_input - end, 2000) - end) - end, { desc = "nvim-tree: Paste (Batch Rename Regex)", buffer = bufnr, silent = true }) - - -- Clears the copy/cut clipboard outright. Useful if it's - -- holding a stale entry -- a file that's since been - -- renamed, moved, or deleted -- which pastes as "ENOENT: no - -- such file or directory" until cleared and re-copied fresh. - vim.keymap.set('n', 'gC', api.fs.clear_clipboard, { desc = "nvim-tree: Clear Clipboard", buffer = bufnr, silent = true }) - - -- gM: copy every MARKED file/folder (toggle a mark with m, - -- shown as a star) into the directory under the cursor, - -- applying a single :s///-style pattern/replacement to - -- EVERY name unconditionally -- not just on collision. This - -- is deliberately separate from nvim-tree's own copy/paste - -- clipboard (c/x/p/gP above) entirely: it reads the marked - -- list via api.marks.list() (a real, documented, stable - -- API -- unlike the clipboard, which has no equivalent way - -- to enumerate its contents) and shells out to `cp -r` to - -- do the actual copying itself, the same approach used in - -- nvim-tree's own official custom-copy recipe. Marks are - -- left as-is afterward (not auto-cleared), matching how the - -- regular clipboard also isn't cleared after a paste -- - -- toggle them off individually with m if you're done with - -- them. - vim.keymap.set('n', 'gM', function() - local marks = api.marks.list() - if not marks or #marks == 0 then - vim.notify("No marked files (mark with 'm' first)", vim.log.levels.WARN) - return - end - local cursor_node = api.tree.get_node_under_cursor() - if not cursor_node then - return - end - local dest_dir = cursor_node.type == "directory" and cursor_node.absolute_path - or vim.fn.fnamemodify(cursor_node.absolute_path, ":h") - - vim.ui.input({ prompt = "Rename pattern/replacement (Vim regex, e.g. Foo/Bar): " }, function(input) - if not input or input == "" then - return - end - local pattern, replacement = input:match("^(.-)/(.*)$") - if not pattern then - vim.notify("Expected pattern/replacement, e.g. Foo/Bar", vim.log.levels.WARN) - return - end - for _, node in ipairs(marks) do - if not vim.loop.fs_stat(node.absolute_path) then - -- Marks persist by path in nvim-tree's own state and - -- are NOT cleared when the underlying file/folder is - -- deleted, so a stale mark can point at a path that - -- no longer exists (e.g. you marked it, then deleted - -- it). Clean up the stale mark itself here instead - -- of attempting (and failing) a copy from it. - pcall(api.marks.toggle, node) - vim.notify("Skipped stale mark (no longer exists): " .. node.absolute_path, vim.log.levels.WARN) - else - local base = vim.fn.fnamemodify(node.absolute_path, ":t") - local ok, new_name = pcall(vim.fn.substitute, base, pattern, replacement, "") - if not ok or new_name == "" then - new_name = base - end - local dest_path = dest_dir .. "/" .. new_name - if dest_path == node.absolute_path then - -- The pattern didn't match this particular name - -- (so it came back unchanged) and the destination - -- is the same directory the file's already in -- - -- that's a copy onto itself, which cp correctly - -- refuses. Skip it up front with a clearer reason - -- instead of surfacing cp's raw "same file" error. - vim.notify("Skipped " .. base .. ": pattern didn't match, and destination is the same as source", vim.log.levels.WARN) - else - local result = vim.fn.system({ "cp", "-r", node.absolute_path, dest_path }) - if vim.v.shell_error ~= 0 then - vim.notify("Copy failed for " .. base .. ": " .. result, vim.log.levels.ERROR) - end - end - end - end - api.tree.reload() - end) - end, { desc = "nvim-tree: Copy Marked (Regex Rename)", buffer = bufnr, silent = true }) - end, - }) - vim.keymap.set('n', 'e', ':NvimTreeToggle', { silent = true, desc = "Toggle File Explorer" }) - end, - }, - - -- Bufferline (Tabs) - { - "akinsho/bufferline.nvim", - version = "*", - dependencies = "nvim-tree/nvim-web-devicons", - config = function() - require("bufferline").setup({ - options = { - diagnostics = "nvim_lsp", - show_buffer_close_icons = false, - numbers = "ordinal", -- shows 1, 2, 3... on the left of each tab, - -- matching the position used by the Alt+number jump below. - -- - -- Makes :BufferLineMovePrev/MoveNext refuse to walk an - -- unpinned buffer across the pinned block (wraps to the - -- other end instead) -- this is bufferline's own built-in - -- boundary check (see get_last_pinned_index/M.move in its - -- source), just off by default. - move_wraps_at_ends = true, - } - }) - - -- Navigate buffers. Using bufferline's own cycle commands rather - -- than :bprevious/:bnext -- once buffers can be reordered (see - -- / below), plain :bnext/:bprevious would cycle by - -- vim's internal buffer number instead of the order shown on - -- screen, which stops matching what you actually see. This is - -- bufferline's own documented recommendation for exactly this - -- situation. - vim.keymap.set('n', '', 'BufferLineCyclePrev', { desc = "Prev buffer" }) - vim.keymap.set('n', '', 'BufferLineCycleNext', { desc = "Next buffer" }) - vim.keymap.set('n', 'bd', 'bdelete', { desc = "Close Current Buffer" }) - - -- Alt+1..9: jump straight to the buffer at that ordinal position - -- (the number shown on the left of each tab). Alt+0 jumps to the - -- last one, mirroring bufferline's own "$" convention. - for i = 1, 9 do - vim.keymap.set('n', (''):format(i), function() - require('bufferline').go_to(i, true) - end, { desc = "Go to Buffer " .. i }) - end - vim.keymap.set('n', '', function() - require('bufferline').go_to(-1, true) - end, { desc = "Go to Last Buffer" }) - - -- F1..F9: same jump-by-position, on Fn+Number instead of - -- Alt+Number. Explicitly setting here overrides Neovim's - -- default -opens-help binding outright (any explicit - -- vim.keymap.set for a key always supersedes Neovim's built-in - -- default for it), so no separate step is needed to "disable" - -- that. - -- - -- and are set below, OUTSIDE this loop -- both are - -- shared with nvim-dap (Continue and Step Over respectively) and - -- toggle dynamically based on whether a debug session is active - -- (see the dap.listeners hooks in the DAP plugin further down). - -- At rest, both act as ordinary buffer jumps like everything - -- else in this loop. - for _, i in ipairs({ 1, 2, 3, 4, 6, 7, 8, 9 }) do - vim.keymap.set('n', (''):format(i), function() - require('bufferline').go_to(i, true) - end, { desc = "Go to Buffer " .. i }) - end - vim.keymap.set('n', '', function() - require('bufferline').go_to(5, true) - end, { desc = "Go to Buffer 5" }) - vim.keymap.set('n', '', function() - require('bufferline').go_to(-1, true) - end, { desc = "Go to Last Buffer" }) - - -- Alt+,/. : reorder the current buffer left/right in the - -- bufferline (persists for the session as long as - -- sessionoptions includes "globals", which lazy.nvim's default - -- vimrc already does). move_wraps_at_ends above handles keeping - -- these from crossing into the pinned block -- no custom guard - -- needed here. - vim.keymap.set('n', '', 'BufferLineMovePrev', { desc = "Move Buffer Left" }) - vim.keymap.set('n', '', 'BufferLineMoveNext', { desc = "Move Buffer Right" }) - - -- Pin the current buffer -- pinned buffers stay pinned to the - -- start of the bufferline regardless of sorting/reordering. - -- - -- Pin state is ALSO recorded in a plain buffer-local variable - -- here, set at the same time as the real toggle. bufferline's - -- internal pin/group state isn't reliably exposed as a stable - -- public API across versions -- require('bufferline').group_action - -- (tried previously) doesn't exist on the version actually - -- installed here, hence the earlier E5108 error. Tracking it - -- ourselves means bx below never has to ask bufferline - -- which buffers are pinned at all. - -- - -- :BufferLineTogglePin only updates the RENDER-time grouping; it - -- doesn't physically move the buffer within bufferline's - -- internal position list (state.components), which is the exact - -- list go_to()/number labels are computed from. That gap is what - -- caused Alt+number to occasionally land on/label the wrong - -- buffer right after pinning. move_to() operates on that same - -- internal list directly (it's what :BufferLineMovePrev/Next and - -- the sort commands use internally), so calling it here forces - -- the two back into sync immediately: to the front when pinning, - -- to just after the remaining pinned block when unpinning. - vim.keymap.set('n', 'bp', function() - vim.cmd('BufferLineTogglePin') - local buf = vim.api.nvim_get_current_buf() - local now_pinned = not vim.b[buf].pinned - vim.b[buf].pinned = now_pinned - - if now_pinned then - require('bufferline').move_to(1) - else - local pinned_count = 0 - for _, b in ipairs(vim.fn.getbufinfo({ buflisted = 1 })) do - if b.bufnr ~= buf and vim.b[b.bufnr].pinned then - pinned_count = pinned_count + 1 - end - end - require('bufferline').move_to(pinned_count + 1) - end - end, { desc = "Toggle Pin" }) - - -- Close every unpinned buffer that has NO unsaved changes, using - -- the buffer-local pin flag set above -- deliberately not using - -- :BufferLineGroupClose ungrouped, since its default - -- close_command is "bdelete! %d" (forced), which would silently - -- discard unsaved work in any unpinned buffer. - vim.keymap.set('n', 'bx', function() - for _, buf in ipairs(vim.fn.getbufinfo({ buflisted = 1 })) do - if not vim.b[buf.bufnr].pinned and buf.changed == 0 then - pcall(vim.cmd, 'bdelete ' .. buf.bufnr) - end - end - end, { desc = "Close Unpinned Saved Buffers" }) - end, - }, - - -- Statusline - { - "nvim-lualine/lualine.nvim", - dependencies = { "nvim-tree/nvim-web-devicons" }, - config = function() - require("lualine").setup({ - options = { - -- "auto" derives lualine's colors from the active colorscheme's - -- own highlight groups -- koda doesn't ship a hand-built lualine - -- theme the way some more established colorschemes do, so this - -- is what keeps the statusline actually matching it instead of - -- falling back to lualine's generic default palette. - theme = "auto", - -- Powerline private-use-area glyphs (E0B0-E0B3). This needs - -- the terminal's font actually set to the PATCHED family - -- (e.g. "JetBrainsMono Nerd Font" / "CaskaydiaCove Nerd - -- Font", not plain "JetBrains Mono"/"Cascadia Code") -- if - -- these render as boxes/question marks, that's the fix. - -- The thin variants (E0B1/E0B3) are used between components - -- within a section so they pick up each component's colors - -- instead of a fixed one. - -- Powerline glyphs U+E0B0-U+E0B3, written as raw UTF-8 byte - -- escapes (not literal characters) so they can't get - -- silently stripped by an editor/tool that mishandles - -- Private Use Area codepoints. - component_separators = { left = "\238\130\177", right = "\238\130\179" }, - section_separators = { left = "\238\130\176", right = "\238\130\178" }, - globalstatus = true, - }, - sections = { - lualine_a = { - "mode", - -- Only appears while recording a macro -- `cond` hides - -- the component (and its separator) entirely the rest of - -- the time, so it doesn't sit there empty. - { - function() - return "\226\151\143 REC @" .. vim.fn.reg_recording() - end, - cond = function() - return vim.fn.reg_recording() ~= "" - end, - color = { fg = "#e0af68", gui = "bold" }, - }, - }, - lualine_b = { - -- Icon is baked into lualine's own "branch" component -- - -- nothing authored here, so no glyph-encoding risk. - { "branch" }, - -- Moved in next to branch (was in lualine_x) so all the - -- git state reads as one cluster instead of being split - -- across opposite ends of the line. - { "diff" }, - }, - lualine_c = { - -- separator = "" fuses this to the filename that follows - -- instead of leaving the icon boxed off by its own arrow - -- on both sides. - { "filetype", icon_only = true, padding = { left = 1, right = 0 }, separator = "" }, - { - "filename", - path = 0, - symbols = { modified = " \226\151\143", readonly = " [RO]", unnamed = "[No Name]" }, - }, - }, - lualine_x = { - "searchcount", - "diagnostics", - -- Attached LSP client name(s) -- hidden via `cond` when - -- nothing's attached to the buffer. - { - function() - local names = {} - for _, client in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do - table.insert(names, client.name) - end - return "\239\128\147 " .. table.concat(names, ", ") - end, - cond = function() - return #vim.lsp.get_clients({ bufnr = 0 }) > 0 - end, - }, - -- Word count, only for prose filetypes -- noise anywhere - -- else, so it's gated the same way. - { - function() - return "\194\182 " .. vim.fn.wordcount().words - end, - cond = function() - local ft = vim.bo.filetype - return ft == "markdown" or ft == "text" or ft == "gitcommit" or ft == "tex" - end, - }, - }, - lualine_y = { - "progress", - "location", - -- Encoding/line-ending: only shown when they DIFFER from - -- Neovim's own defaults (utf-8, unix) -- surfaces the - -- unusual case instead of confirming the boring default - -- on every single file. - { - function() - local enc = vim.bo.fileencoding ~= "" and vim.bo.fileencoding or vim.o.encoding - local parts = {} - if enc ~= "utf-8" then - table.insert(parts, enc) - end - if vim.bo.fileformat ~= "unix" then - table.insert(parts, vim.bo.fileformat) - end - return table.concat(parts, " ") - end, - cond = function() - local enc = vim.bo.fileencoding ~= "" and vim.bo.fileencoding or vim.o.encoding - return enc ~= "utf-8" or vim.bo.fileformat ~= "unix" - end, - }, - }, - lualine_z = { - function() - return " " .. os.date("%a %H:%M") - end, - }, - }, - }) - end, - }, - - -- ToggleTerm (Floating & Bottom Terminal) - { - "akinsho/toggleterm.nvim", - version = "*", - config = function() - -- shell = "fish": the embedded terminal launches fish instead of - -- your login shell. fish has grey inline "ghost text" suggestions - -- built in (paths, directory contents, and history) with zero - -- config -- accept with -> or . This only affects terminals - -- opened from Neovim; your system login shell is untouched. - require("toggleterm").setup({ - shell = "fish", - }) - vim.keymap.set('n', 'tf', 'ToggleTerm direction=float', { desc = "Floating Terminal" }) - vim.keymap.set('n', 'th', 'ToggleTerm direction=horizontal', { desc = "Bottom Terminal" }) - vim.keymap.set('n', 'tv', 'ToggleTerm direction=vertical size=40', { desc = "Side Terminal" }) - end, - }, - - -- Git State - { - "lewis6991/gitsigns.nvim", - config = function() - require("gitsigns").setup() - end, - }, - - -- Indent guides - { - "lukas-reineke/indent-blankline.nvim", - main = "ibl", - config = function() - require("ibl").setup({ - indent = { char = "│" }, - scope = { - enabled = true, - -- show_start/show_end default to true and underline the - -- first/last line of whatever lexical scope the cursor is - -- currently inside (recalculated as the cursor moves -- - -- that's why it seemed to "follow" the cursor). The colored - -- guide itself is kept; just the underline is turned off. - show_start = false, - show_end = false, - }, - }) - end, - }, - - -- Inline hex/rgb color preview - { - "norcalli/nvim-colorizer.lua", - config = function() - require("colorizer").setup() - end, - }, - - -- Hex file viewer/editor. Toggles between normal and hex-dump view - -- (backed by xxd) rather than showing both simultaneously -- that - -- trade-off is deliberate: since the hex dump is just ordinary, - -- plain buffer text under the hood, editing it gets Neovim's normal - -- undo/redo for free, and nothing writes to disk until an explicit - -- :w, same as any other file. The only plugin found that does a - -- true live simultaneous hex+ASCII view (hexview.nvim, used here - -- previously) manages bytes with its own custom logic instead of - -- going through normal buffer text editing, which is exactly why it - -- has no undo at all -- not a gap that plugin could patch, an - -- architectural trade-off against the same thing being fixed here. - { - "RaafatTurki/hex.nvim", - config = function() - require("hex").setup() - vim.keymap.set('n', 'h', function() - if not vim.bo.binary then - -- Hex editing depends on the buffer actually being loaded - -- as binary. Otherwise Neovim applies normal text encoding/ - -- line-ending handling to the raw bytes first (NUL bytes - -- included, which any real binary is full of) -- that - -- misinterpretation is what produces "CONVERSION ERROR" - -- rather than a clean hex dump, and it happens BEFORE xxd - -- ever runs. Re-reading with ++bin loads the file correctly - -- from scratch instead of operating on an already-misread - -- buffer. - -- - -- That reload is forced (!), which discards whatever's - -- currently in the buffer without asking -- refuse to do it - -- if there are unsaved changes rather than silently losing - -- them. Only relevant on this first switch into hex mode: - -- once vim.bo.binary is already true, no reload happens and - -- this check doesn't apply. - if vim.bo.modified then - vim.notify("Buffer has unsaved changes -- save first (or :e! to discard) before opening hex view", vim.log.levels.WARN) - return - end - vim.cmd('edit! ++bin %') - end - vim.cmd('HexToggle') - end, { desc = "Toggle Hex View" }) - end, - }, - - -- Nicer notifications (replaces the default vim.notify popups) - { - "rcarriga/nvim-notify", - config = function() - require("notify").setup({ - background_colour = "#000000", - timeout = 3000, - -- Stack upward from the bottom instead of down from the top - -- (nvim-notify anchors to the right by default either way), - -- so notifications land bottom-right instead of top-right. - top_down = false, - -- Switched from "compact": that renderer has a documented - -- history of text overflow/wrapping bugs, and is the prime - -- suspect for the garbled/duplicated text seen in some - -- NvimTree error notifications. Not confirmed as the actual - -- cause, but worth trying alongside the position change. - }) - vim.notify = require("notify") - end, - }, - - -- Prettier vim.ui.input / vim.ui.select -- upgrades your existing - -- rename prompt, conditional breakpoint prompt, and DAP path prompts - -- to floating, styled dialogs instead of the command-line input. - { - "stevearc/dressing.nvim", - event = "VeryLazy", - opts = {}, - }, - - -- LSP and Mason - { - "neovim/nvim-lspconfig", - dependencies = { - "williamboman/mason.nvim", - "williamboman/mason-lspconfig.nvim", - "hrsh7th/cmp-nvim-lsp", - }, - config = function() - -- Enable native virtual lines (rendered directly below the - -- affected line) with custom formatting - vim.diagnostic.config({ - underline = false, -- virtual_lines already shows the message; skip the extra underline - virtual_text = false, - virtual_lines = { - format = function(diagnostic) - local msg = diagnostic.message or "" - msg = msg:gsub("^[a-z_][a-z0-9_%-]*:%s*", "") - return msg - end, - }, - }) - - -- NOTE: modern Neovim (0.11+) dedupes diagnostics internally, and - -- the old vim.lsp.with()-based publishDiagnostics override is - -- deprecated, so it's intentionally not used here. - - require("mason").setup() - require("mason-lspconfig").setup({ - ensure_installed = { "clangd", "gopls", "omnisharp", "ts_ls", "html", "cssls" }, - }) - - local capabilities = require("cmp_nvim_lsp").default_capabilities() - - -- Some clangd tweaks (e.g. several of its clang-tidy quick - -- fixes) are only sent to clients that advertise support for - -- resolving a code action's edit in a second round-trip, rather - -- than requiring the whole edit up front. cmp-nvim-lsp's - -- capabilities don't declare this on their own, so without it - -- clangd just leaves those actions out of the list entirely - -- instead of sending something the client can't apply. - capabilities.textDocument.codeAction = vim.tbl_deep_extend("force", capabilities.textDocument.codeAction or {}, { - dataSupport = true, - resolveSupport = { properties = { "edit" } }, - }) - - -- LSP Keymaps (Organized under l), applied on attach via - -- the LspAttach autocommand rather than a per-server on_attach - -- callback passed through lspconfig[x].setup(). - local function on_attach(bufnr) - -- Core goto commands (Standard Vim behavior, no leader required) - -- - -- #include lines get special handling: clangd resolves - -- textDocument/definition (and /declaration) on an #include - -- directive to the target header regardless of which token on - -- that line the cursor sits on, so both gd and gD below open - -- it directly. If clangd hasn't attached/indexed yet, or the - -- header isn't part of the compile database, we fall back to - -- Vim's built-in "goto file under cursor" (gf), which still - -- resolves a local #include "quoted.h" relative to the - -- current file even with no LSP involved at all. - local function is_include_line() - return vim.api.nvim_get_current_line():match('^%s*#%s*include') ~= nil - end - - -- Shared result handler for definition/declaration: jump - -- straight there for a single match; fill the quickfix list - -- and open it for multiple (e.g. a function declared once but - -- defined across several translation units) instead of - -- silently picking whichever one clangd listed first. - local function on_list(t) - if vim.tbl_isempty(t.items) then - if is_include_line() then - vim.cmd('normal! gf') - end - return - end - vim.fn.setqflist({}, ' ', t) - if #t.items > 1 then - vim.cmd('botright copen') - else - vim.cmd('cfirst') - end - end - - vim.keymap.set('n', 'gd', function() - if is_include_line() then - vim.lsp.buf.definition({ on_list = on_list }) - else - -- Regular symbol: Telescope gives a fuzzy picker with - -- preview when there's more than one candidate (e.g. - -- overloads), and jumps straight there for a single one -- - -- nicer than the bare quickfix list for that common case. - require('telescope.builtin').lsp_definitions({ reuse_win = true }) - end - end, { buffer = bufnr, silent = true, desc = "Go to Definition" }) - - vim.keymap.set('n', 'gD', function() - -- No Telescope equivalent exists for declaration (only - -- definition/references/implementation/type_definition are - -- exposed), so this stays on vim.lsp.buf.declaration -- - -- just with the same on_list handling as gd above. - vim.lsp.buf.declaration({ on_list = on_list }) - end, { buffer = bufnr, silent = true, desc = "Go to Declaration" }) - - vim.keymap.set('n', 'gr', function() - -- vim.lsp.buf.references() on its own just fills the - -- quickfix list silently without opening it, so pressing gr - -- looks like nothing happened. Telescope's picker actually - -- shows the list with a preview, same as gd above. - require('telescope.builtin').lsp_references({ reuse_win = true }) - end, { buffer = bufnr, silent = true, desc = "Find References" }) - - vim.keymap.set('n', 'K', vim.lsp.buf.hover, { buffer = bufnr, silent = true, desc = "Hover Info" }) - - -- clangd doesn't support renaming macros or namespaces at all - -- (clangd/clangd#1890, still open) -- it's supposed to just - -- reply "symbol is not a supported kind", but has a separate - -- crash bug where it can SIGSEGV while handling that same - -- rejection instead of returning the error cleanly. This - -- catches the common case (cursor on the #define/namespace - -- line itself) with treesitter before ever asking clangd, so - -- the crash can't be triggered that way. It can't catch every - -- case -- invoking rename on a macro from where it's *used* - -- rather than defined still reaches clangd, since a macro - -- usage is just plain text to treesitter, indistinguishable - -- from any other identifier. If clangd crashes anyway, restart - -- it with :LspRestart; renaming a macro by hand with - -- :%s/\/new/g is the right tool regardless, since it's - -- pure text substitution with no scope for clangd to resolve. - local function renaming_unsupported_kind() - local ok, node = pcall(vim.treesitter.get_node) - if not ok then - return false - end - local cword = vim.fn.expand("") - while node do - local kind = node:type() - if kind == "preproc_def" or kind == "preproc_function_def" or kind == "namespace_definition" then - local name_node = node:field("name")[1] - if name_node and vim.treesitter.get_node_text(name_node, 0) == cword then - return true - end - end - node = node:parent() - end - return false - end - - -- Safe Rename Function (Fixes double execution/prompt issues) - local function safe_rename() - if renaming_unsupported_kind() then - vim.notify( - "clangd can't rename macros or namespaces (unsupported upstream, and known to crash on the attempt) -- " - .. "use :%s/\\/new/g instead.", - vim.log.levels.WARN - ) - return - end - local current_name = vim.fn.expand("") - vim.ui.input({ prompt = "Rename: ", default = current_name }, function(new_name) - if not new_name or new_name == "" or new_name == current_name then - return - end - vim.lsp.buf.rename(new_name) - end) - end - - -- Leader commands (Grouped under l) - vim.keymap.set('n', 'lr', safe_rename, { buffer = bufnr, silent = true, desc = "Rename Symbol" }) - - -- Neovim's default vim.lsp.buf.code_action() only looks at - -- diagnostics on the exact cursor line. That's a needlessly - -- easy way to come up empty here specifically: virtual_lines - -- (set above) renders a diagnostic's message as an extra line - -- directly BELOW its own line, right where the next real line - -- of code sits -- so a diagnostic that reads as "right there" - -- is actually one line above the cursor as far as the LSP - -- request is concerned. This widens the request to also catch - -- a diagnostic one line either side, pulling each one's - -- original LSP-format diagnostic back out of user_data.lsp - -- (vim.diagnostic.get() preserves it there for exactly this), - -- and grows the requested range to actually cover them too -- - -- a server is free to ignore a diagnostic in context that - -- falls outside the requested range, so widening only the - -- diagnostics list without the range wouldn't reliably help. - -- - -- code_action()'s `range` wants plain {row, col} tuples using - -- mark-like indexing (1-indexed row, 0-indexed col -- the same - -- shape nvim_win_get_cursor()/nvim_buf_get_mark() use), NOT LSP - -- {line=, character=} position objects: it hands start/end - -- straight to vim.lsp.util.make_given_range_params(), which - -- indexes into them positionally (start_pos[1], start_pos[2]). - -- A {line=, character=} table has no numeric [1]/[2], so that - -- indexing was silently nil and the arithmetic on it crashed - -- with E5108 ("attempt to perform arithmetic on a nil value") - -- every time this ran. min_line/max_line below are 0-indexed - -- (from diagnostic .lnum/.end_lnum), so +1 converts to mark - -- indexing; the end column is the target line's real length so - -- the range also reaches a diagnostic that starts mid-line - -- rather than only ones starting at column 0. - vim.keymap.set('n', 'la', function() - local row = vim.api.nvim_win_get_cursor(0)[1] - 1 - local nearby, min_line, max_line = {}, row, row - for _, d in ipairs(vim.diagnostic.get(bufnr)) do - if math.abs(d.lnum - row) <= 1 and d.user_data and d.user_data.lsp then - table.insert(nearby, d.user_data.lsp) - min_line = math.min(min_line, d.lnum) - max_line = math.max(max_line, d.end_lnum or d.lnum) - end - end - if vim.tbl_isempty(nearby) then - vim.lsp.buf.code_action() - return - end - local last_line = vim.api.nvim_buf_get_lines(bufnr, max_line, max_line + 1, false)[1] or "" - vim.lsp.buf.code_action({ - context = { diagnostics = nearby }, - range = { start = { min_line + 1, 0 }, ["end"] = { max_line + 1, #last_line } }, - }) - end, { buffer = bufnr, silent = true, desc = "Code Actions" }) - -- Visual-mode counterpart: several clangd tweaks (Extract - -- Function/Variable, raw-string conversion, ...) only show up - -- when the request carries an actual selection range instead - -- of the zero-width one normal mode sends. - vim.keymap.set('x', 'la', vim.lsp.buf.code_action, { buffer = bufnr, silent = true, desc = "Code Actions (Selection)" }) - vim.keymap.set('n', 'lf', function() vim.lsp.buf.format { async = true } end, { buffer = bufnr, silent = true, desc = "Format File" }) - -- "Fix all clang-tidy issues in this file" turns out not to be - -- something clangd's code actions can actually do: this used - -- to filter for LSP's `source.fixAll` code action kind, but - -- clangd never returns actions of that kind -- clang-tidy - -- fixes surface from clangd only as individual `quickfix` - -- actions, one per diagnostic, and clangd's own issue tracker - -- confirms there's no batched "fix everything" kind (clangd/ - -- clangd#1446). So this always came back "No code actions - -- available" no matter what the file contained. - -- - -- Running the actual clang-tidy binary with --fix sidesteps - -- clangd's code actions entirely and does what this key was - -- always meant to: apply every fix the tool can make across - -- the whole file in one pass, using the same .clang-tidy and - -- compile_commands.json clangd itself uses. --fix (rather than - -- --fix-errors) deliberately leaves the file untouched if - -- clang-tidy hits real compiler errors, instead of forcing - -- fixes onto code that doesn't compile -- switch it to - -- --fix-errors below if you'd rather it try anyway. - vim.keymap.set('n', 'lt', function() - if vim.fn.executable("clang-tidy") == 0 then - vim.notify("clang-tidy not found on PATH", vim.log.levels.ERROR) - return - end - local filepath = vim.api.nvim_buf_get_name(bufnr) - if filepath == "" then - vim.notify("Buffer has no file on disk to run clang-tidy on", vim.log.levels.WARN) - return - end - -- clang-tidy reads from disk, not the buffer -- make sure - -- what's on disk actually matches what's on screen first. - if vim.bo[bufnr].modified then - vim.cmd("write") - end - - local cmd = { "clang-tidy", "--fix" } - local db = vim.fs.find("compile_commands.json", { - upward = true, - path = vim.fs.dirname(filepath), - })[1] - if db then - vim.list_extend(cmd, { "-p", vim.fs.dirname(db) }) - end - table.insert(cmd, filepath) - - -- No "starting..." notification, and nothing shown at all on - -- a normal (exit 0) run: clang-tidy always writes chatter to - -- stderr -- including a notice about a missing compile - -- database, and its own end-of-run warning count -- even - -- when everything worked fine, so treating that output as - -- warning-worthy just because it's non-empty was the actual - -- source of the noise. Only a genuine failure (non-zero - -- exit) surfaces anything here. - vim.system(cmd, { text = true }, function(result) - vim.schedule(function() - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - -- Reload from disk so the buffer picks up whatever - -- clang-tidy just rewrote. :edit! (rather than - -- :checktime) reloads unconditionally instead of - -- prompting, since 'autoread' isn't set above. - local view = vim.fn.winsaveview() - vim.api.nvim_buf_call(bufnr, function() - vim.cmd("edit!") - end) - pcall(vim.fn.winrestview, view) - - if result.code ~= 0 then - local msg = vim.trim(result.stderr or "") - if #msg > 400 then - msg = msg:sub(1, 400) .. "\n... (truncated -- re-run in a terminal for full output)" - end - vim.notify("clang-tidy exited with status " .. result.code .. ":\n" .. msg, vim.log.levels.WARN) - else - vim.notify("clang-tidy --fix finished", vim.log.levels.INFO) - end - end) - end) - end, { buffer = bufnr, silent = true, desc = "Fix All (clang-tidy)" }) - vim.keymap.set('n', 'li', 'LspInfo', { buffer = bufnr, silent = true, desc = "LSP Info" }) - - -- Diagnostic navigation, by severity. ]d/[d for "whatever's - -- next" regardless of severity; ]e/[e, ]w/[w, ]t/[t narrow to - -- errors, warnings, and hints ("tip" is the mnemonic here -- - -- LSP itself calls that severity level "Hint") respectively. - -- No `float` option on the jump itself: virtual_lines (set - -- above) already shows the message text permanently, so a - -- floating preview on top of it would just be redundant. - local function diag_jump(count, severity) - return function() - vim.diagnostic.jump({ count = count, severity = severity }) - end - end - vim.keymap.set('n', ']d', diag_jump(1), { buffer = bufnr, silent = true, desc = "Next Diagnostic" }) - vim.keymap.set('n', '[d', diag_jump(-1), { buffer = bufnr, silent = true, desc = "Previous Diagnostic" }) - vim.keymap.set('n', ']e', diag_jump(1, vim.diagnostic.severity.ERROR), { buffer = bufnr, silent = true, desc = "Next Error" }) - vim.keymap.set('n', '[e', diag_jump(-1, vim.diagnostic.severity.ERROR), { buffer = bufnr, silent = true, desc = "Previous Error" }) - vim.keymap.set('n', ']w', diag_jump(1, vim.diagnostic.severity.WARN), { buffer = bufnr, silent = true, desc = "Next Warning" }) - vim.keymap.set('n', '[w', diag_jump(-1, vim.diagnostic.severity.WARN), { buffer = bufnr, silent = true, desc = "Previous Warning" }) - vim.keymap.set('n', ']t', diag_jump(1, vim.diagnostic.severity.HINT), { buffer = bufnr, silent = true, desc = "Next Tip" }) - vim.keymap.set('n', '[t', diag_jump(-1, vim.diagnostic.severity.HINT), { buffer = bufnr, silent = true, desc = "Previous Tip" }) - end - - vim.api.nvim_create_autocmd("LspAttach", { - group = vim.api.nvim_create_augroup("UserLspAttach", { clear = true }), - callback = function(args) - on_attach(args.buf) - end, - }) - - -- Per-server overrides beyond the shared `capabilities` above. - -- clangd's own binary defaults already cover most of this, but - -- being explicit documents intent and avoids depending on - -- whatever a given clangd build's defaults happen to be: - -- --background-index index the whole project up front, - -- not just open files -- clang-tidy - -- checks and code actions that need - -- full semantic info are noticeably - -- thinner without it. - -- --clang-tidy explicit, even though on by default. - -- --header-insertion/--completion-style/--all-scopes-completion/ - -- --cross-file-rename richer completion and project-wide - -- rename instead of the conservative - -- single-file defaults. - -- None of this substitutes for a compile_commands.json, though: - -- without one, clangd falls back to guessed compiler flags, and - -- most clang-tidy checks -- and the code actions/fixes tied to - -- them -- need real -std/-I flags to run at all. Generate one - -- via CMake (`-DCMAKE_EXPORT_COMPILE_COMMANDS=ON`, then symlink - -- or copy build/compile_commands.json to the project root) or - -- `bear` for non-CMake projects if la/lt still - -- come up thin on a real project after this. - local server_opts = { - clangd = { - cmd = { - "clangd", - "--background-index", - "--clang-tidy", - "--completion-style=detailed", - "--header-insertion=iwyu", - "--all-scopes-completion", - "--cross-file-rename", - }, - }, - } - - -- Setup LSPs via the built-in vim.lsp.config()/vim.lsp.enable() - -- API (replaces the deprecated require('lspconfig')[x].setup() - -- framework). Default per-server settings still come from - -- nvim-lspconfig, which only needs to be on the runtimepath. - local servers = { "clangd", "gopls", "omnisharp", "ts_ls", "html", "cssls" } - for _, lsp in ipairs(servers) do - vim.lsp.config(lsp, vim.tbl_deep_extend("force", { capabilities = capabilities }, server_opts[lsp] or {})) - end - vim.lsp.enable(servers) - end, - }, - - -- Autocompletion - { - "hrsh7th/nvim-cmp", - dependencies = { - "L3MON4D3/LuaSnip", - "saadparwaiz1/cmp_luasnip", - "hrsh7th/cmp-nvim-lsp", - "hrsh7th/cmp-buffer", - "hrsh7th/cmp-path", - }, - config = function() - local cmp = require("cmp") - local luasnip = require("luasnip") - - -- Without this, LuaSnip never checks whether the cursor has - -- actually left a snippet's text -- so pressing Tab later, even - -- lines away, can jump straight back into an old, unfinished - -- placeholder session (e.g. clangd's function-argument - -- placeholders). region_check_events makes it watch cursor - -- movement and automatically exit a snippet once you've moved - -- outside its region, in both normal and insert mode. - luasnip.setup({ - region_check_events = "CursorMoved,CursorMovedI", - }) - - cmp.setup({ - -- Ghost text: previews the currently-selected completion inline - -- after the cursor, instead of only showing it in the popup menu. - -- Combined with the cmp-path source below, this is what gives you - -- inline "ghost" previews as you type a filesystem path (e.g. - -- typing ./src/ will ghost-preview entries found in that - -- directory) without needing a separate plugin for it. - experimental = { - ghost_text = true, - }, - snippet = { - expand = function(args) - luasnip.lsp_expand(args.body) - end, - }, - window = { - completion = cmp.config.window.bordered(), - documentation = cmp.config.window.bordered(), - }, - mapping = cmp.mapping.preset.insert({ - -- Safely close the menu without reverting text state - [''] = cmp.mapping(function(fallback) - if cmp.visible() then cmp.close() end - fallback() - end, { 'i', 's' }), - [''] = cmp.mapping(function(fallback) - if cmp.visible() then cmp.close() end - fallback() - end, { 'i', 's' }), - [''] = cmp.mapping(function(fallback) - if cmp.visible() then cmp.close() end - fallback() - end, { 'i', 's' }), - [''] = cmp.mapping(function(fallback) - if cmp.visible() then cmp.close() end - fallback() - end, { 'i', 's' }), - [''] = cmp.mapping(function(fallback) - if cmp.visible() then cmp.close() end - fallback() - end, { 'i', 's' }), - - [''] = cmp.mapping(function(fallback) - if cmp.visible() then - cmp.select_next_item() - elseif luasnip.expand_or_jumpable() then - -- No completion menu open, but we're sitting in a snippet - -- (e.g. clangd's function-argument placeholders after - -- accepting a call like memcpy(...)) -- jump to the next - -- tabstop/placeholder instead of inserting a literal tab. - luasnip.expand_or_jump() - else - fallback() - end - end, { 'i', 's' }), - [''] = cmp.mapping(function(fallback) - if cmp.visible() then - cmp.select_prev_item() - elseif luasnip.jumpable(-1) then - luasnip.jump(-1) - else - fallback() - end - end, { 'i', 's' }), - - [''] = cmp.mapping.confirm({ select = false }), - }), - sources = cmp.config.sources({ - { name = "nvim_lsp" }, - { name = "luasnip" }, - { name = "buffer" }, - -- Lists and completes filesystem paths/directory contents as - -- you type a path string (e.g. after typing "./" or "/home/"). - -- This is the source that feeds the ghost-text path preview. - { name = "path" }, - }), - }) - end, - }, - - -- Auto-pair brackets/quotes: (), {}, [], "", '' -- nvim-autopairs' - -- own defaults. <> was tried here too but removed: it collided too - -- much with < and > as comparison operators to be worth keeping. - { - "windwp/nvim-autopairs", - dependencies = { "hrsh7th/nvim-cmp" }, - config = function() - local autopairs = require("nvim-autopairs") - -- map_cr defaults to true, which makes nvim-autopairs install - -- its OWN insert-mode mapping (for expanding bracket pairs - -- onto their own indented line). That's set during plugin - -- loading, which runs after init.lua's own top-level code -- - -- where the comment-continuation mapping lives -- so it - -- was silently overwriting that mapping entirely regardless of - -- what it did internally. Disabled here to free up for - -- that mapping; the trade-off is losing autopairs' own - -- brace-expands-on-Enter behavior, which was never explicitly - -- asked for. - autopairs.setup({ map_cr = false }) - - -- Integrates with nvim-cmp so accepting a completion (e.g. a - -- function call) doesn't end up with doubled-up parens. - local cmp_autopairs = require("nvim-autopairs.completion.cmp") - require("cmp").event:on("confirm_done", cmp_autopairs.on_confirm_done()) - end, - }, - - -- ========================================================= - -- DAP: In-editor C/C++ debugging (breakpoints, step, watch) - -- Uses codelldb (installed via Mason) as the debug adapter. - -- ========================================================= - { - "mfussenegger/nvim-dap", - dependencies = { - "rcarriga/nvim-dap-ui", - "nvim-neotest/nvim-nio", - "theHamsta/nvim-dap-virtual-text", - "jay-babu/mason-nvim-dap.nvim", - }, - config = function() - local dap = require("dap") - local dapui = require("dapui") - - -- Auto-install codelldb through Mason and wire it into nvim-dap. - -- NOTE: omitting `handlers` entirely (rather than passing {}) is - -- what makes mason-nvim-dap run its DEFAULT handler, which is - -- what actually registers a working dap.adapters.codelldb with - -- correct dynamic ${port} allocation. Passing handlers = {} - -- disables that default handler instead of using it. - require("mason-nvim-dap").setup({ - ensure_installed = { "codelldb" }, - automatic_installation = true, - }) - - require("dapui").setup() - require("nvim-dap-virtual-text").setup({ - commented = true, - }) - - -- Auto open/close the UI when a debug session starts/ends - dap.listeners.after.event_initialized["dapui_config"] = function() - dapui.open() - end - dap.listeners.before.event_terminated["dapui_config"] = function() - dapui.close() - end - dap.listeners.before.event_exited["dapui_config"] = function() - dapui.close() - end - - -- and are each shared between an nvim-dap action - -- (Continue, Step Over) and a bufferline buffer jump (buffer 5, - -- last buffer) -- at rest they're buffer jumps; for the - -- duration of a debug session they're handed over to DAP - -- instead, so nothing fights over the same key. Note this means - -- can no longer START a fresh session on its own (there's - -- no session yet at that point, so it's still doing buffer-jump - -- duty) -- use dc or db to start one, then - -- takes over for continuing from there. Reusing the same - -- listener points as the dapui open/close hooks above. - dap.listeners.after.event_initialized["fkey_toggle"] = function() - vim.keymap.set('n', '', dap.continue, { desc = "Debug: Continue" }) - vim.keymap.set('n', '', dap.step_over, { desc = "Debug: Step Over" }) - end - local function restore_fkey_buffer_jumps() - vim.keymap.set('n', '', function() - require('bufferline').go_to(5, true) - end, { desc = "Go to Buffer 5" }) - vim.keymap.set('n', '', function() - require('bufferline').go_to(-1, true) - end, { desc = "Go to Last Buffer" }) - end - dap.listeners.before.event_terminated["fkey_toggle"] = restore_fkey_buffer_jumps - dap.listeners.before.event_exited["fkey_toggle"] = restore_fkey_buffer_jumps - - -- C / C++ / Rust launch configs, all sharing the codelldb adapter - dap.configurations.cpp = { - { - name = "Launch executable", - type = "codelldb", - request = "launch", - program = function() - return vim.fn.input( - "Path to executable: ", - vim.fn.getcwd() .. "/", - "file" - ) - end, - cwd = "${workspaceFolder}", - stopOnEntry = false, - args = {}, - }, - { - name = "Attach to process", - type = "codelldb", - request = "attach", - pid = require("dap.utils").pick_process, - cwd = "${workspaceFolder}", - }, - } - dap.configurations.c = dap.configurations.cpp - dap.configurations.rust = dap.configurations.cpp - - -- Breakpoint / stepping signs - vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError", linehl = "", numhl = "" }) - vim.fn.sign_define("DapBreakpointCondition", { text = "◆", texthl = "DiagnosticWarn", linehl = "", numhl = "" }) - vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticInfo", linehl = "DapStoppedLine", numhl = "" }) - vim.api.nvim_set_hl(0, "DapStoppedLine", { bg = "#0b3528" }) - - -- Keymaps, grouped under d (shows in which-key) - -- and intentionally NOT bound here -- both are set - -- dynamically by the dap.listeners hooks above (Continue/Step - -- Over only while a session is active; bufferline's buffer-5/ - -- last-buffer jumps otherwise). - vim.keymap.set('n', '', dap.step_into, { desc = "Debug: Step Into" }) - vim.keymap.set('n', '', dap.step_out, { desc = "Debug: Step Out" }) - - vim.keymap.set('n', 'db', dap.toggle_breakpoint, { desc = "Toggle Breakpoint" }) - vim.keymap.set('n', 'dB', function() - dap.set_breakpoint(vim.fn.input("Breakpoint condition: ")) - end, { desc = "Conditional Breakpoint" }) - vim.keymap.set('n', 'dc', dap.continue, { desc = "Continue" }) - vim.keymap.set('n', 'do', dap.step_over, { desc = "Step Over" }) - vim.keymap.set('n', 'di', dap.step_into, { desc = "Step Into" }) - vim.keymap.set('n', 'dO', dap.step_out, { desc = "Step Out" }) - vim.keymap.set('n', 'dr', dap.repl.open, { desc = "Open REPL" }) - vim.keymap.set('n', 'dl', dap.run_last, { desc = "Run Last" }) - vim.keymap.set('n', 'dt', dap.terminate, { desc = "Terminate Session" }) - vim.keymap.set('n', 'du', dapui.toggle, { desc = "Toggle Debug UI" }) - vim.keymap.set('n', 'dh', function() - require("dap.ui.widgets").hover() - end, { desc = "Hover Variable" }) - - -- Load project-specific CMake-built target configs (defines - -- Debug configs for build/tests/... and build/demo/... binaries). - -- This also makes nvim-dap lazy-load the same file automatically - -- when you start debugging from a CMakeLists.txt buffer. - require("dap.configurations.cmake") - end, - }, -} diff --git a/nvim/config/nvim/lua/plugins/lsp.lua b/nvim/config/nvim/lua/plugins/lsp.lua new file mode 100644 index 0000000..55892ef --- /dev/null +++ b/nvim/config/nvim/lua/plugins/lsp.lua @@ -0,0 +1,76 @@ +return { + -- LSP and Mason + { + "neovim/nvim-lspconfig", + dependencies = { + "williamboman/mason.nvim", + "williamboman/mason-lspconfig.nvim", + "hrsh7th/cmp-nvim-lsp", + }, + config = function() + -- Enable native virtual lines (rendered directly below the + -- affected line) with custom formatting + vim.diagnostic.config({ + underline = false, -- virtual_lines already shows the message; skip the extra underline + virtual_text = false, + virtual_lines = { + format = function(diagnostic) + local msg = diagnostic.message or "" + msg = msg:gsub("^[a-z_][a-z0-9_%-]*:%s*", "") + return msg + end, + }, + }) + + -- NOTE: modern Neovim (0.11+) dedupes diagnostics internally, and + -- the old vim.lsp.with()-based publishDiagnostics override is + -- deprecated, so it's intentionally not used here. + + -- Which servers to set up (what Mason installs, what gets enabled, + -- and any per-server overrides) is defined in one table in + -- lua/config/lsp/servers.lua -- to add a language server, add it + -- there rather than here. + local servers = require("config.lsp.servers") + local server_names = vim.tbl_keys(servers) + table.sort(server_names) + + require("mason").setup() + require("mason-lspconfig").setup({ + ensure_installed = server_names, + }) + + local capabilities = require("cmp_nvim_lsp").default_capabilities() + + -- Some clangd tweaks (e.g. several of its clang-tidy quick + -- fixes) are only sent to clients that advertise support for + -- resolving a code action's edit in a second round-trip, rather + -- than requiring the whole edit up front. cmp-nvim-lsp's + -- capabilities don't declare this on their own, so without it + -- clangd just leaves those actions out of the list entirely + -- instead of sending something the client can't apply. + capabilities.textDocument.codeAction = vim.tbl_deep_extend("force", capabilities.textDocument.codeAction or {}, { + dataSupport = true, + resolveSupport = { properties = { "edit" } }, + }) + + -- Keymaps, applied per-buffer on LspAttach (lua/config/lsp/keymaps.lua). + require("config.lsp.keymaps").setup() + + -- :LspRestart / :LspStart / :LspStop / :LspInfo / :LspLog. nvim-lspconfig + -- stopped providing these on Neovim 0.12 (core's own `:lsp` command + -- replaced them), so lua/config/lsp/commands.lua defines them as + -- aliases over the built-ins. It does nothing on older Neovim, where + -- nvim-lspconfig still registers the real ones. + require("config.lsp.commands").setup() + + -- Setup LSPs via the built-in vim.lsp.config()/vim.lsp.enable() + -- API (replaces the deprecated require('lspconfig')[x].setup() + -- framework). Default per-server settings still come from + -- nvim-lspconfig, which only needs to be on the runtimepath. + for name, opts in pairs(servers) do + vim.lsp.config(name, vim.tbl_deep_extend("force", { capabilities = capabilities }, opts)) + end + vim.lsp.enable(server_names) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/lualine.lua b/nvim/config/nvim/lua/plugins/lualine.lua new file mode 100644 index 0000000..2b6b9da --- /dev/null +++ b/nvim/config/nvim/lua/plugins/lualine.lua @@ -0,0 +1,130 @@ +return { + -- Statusline + { + "nvim-lualine/lualine.nvim", + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + require("lualine").setup({ + options = { + -- "auto" derives lualine's colors from the active colorscheme's + -- own highlight groups -- koda doesn't ship a hand-built lualine + -- theme the way some more established colorschemes do, so this + -- is what keeps the statusline actually matching it instead of + -- falling back to lualine's generic default palette. + theme = "auto", + -- Powerline private-use-area glyphs (E0B0-E0B3). This needs + -- the terminal's font actually set to the PATCHED family + -- (e.g. "JetBrainsMono Nerd Font" / "CaskaydiaCove Nerd + -- Font", not plain "JetBrains Mono"/"Cascadia Code") -- if + -- these render as boxes/question marks, that's the fix. + -- The thin variants (E0B1/E0B3) are used between components + -- within a section so they pick up each component's colors + -- instead of a fixed one. + -- Powerline glyphs U+E0B0-U+E0B3, written as raw UTF-8 byte + -- escapes (not literal characters) so they can't get + -- silently stripped by an editor/tool that mishandles + -- Private Use Area codepoints. + component_separators = { left = "\238\130\177", right = "\238\130\179" }, + section_separators = { left = "\238\130\176", right = "\238\130\178" }, + globalstatus = true, + }, + sections = { + lualine_a = { + "mode", + -- Only appears while recording a macro -- `cond` hides + -- the component (and its separator) entirely the rest of + -- the time, so it doesn't sit there empty. + { + function() + return "\226\151\143 REC @" .. vim.fn.reg_recording() + end, + cond = function() + return vim.fn.reg_recording() ~= "" + end, + color = { fg = "#e0af68", gui = "bold" }, + }, + }, + lualine_b = { + -- Icon is baked into lualine's own "branch" component -- + -- nothing authored here, so no glyph-encoding risk. + { "branch" }, + -- Moved in next to branch (was in lualine_x) so all the + -- git state reads as one cluster instead of being split + -- across opposite ends of the line. + { "diff" }, + }, + lualine_c = { + -- separator = "" fuses this to the filename that follows + -- instead of leaving the icon boxed off by its own arrow + -- on both sides. + { "filetype", icon_only = true, padding = { left = 1, right = 0 }, separator = "" }, + { + "filename", + path = 0, + symbols = { modified = " \226\151\143", readonly = " [RO]", unnamed = "[No Name]" }, + }, + }, + lualine_x = { + "searchcount", + "diagnostics", + -- Attached LSP client name(s) -- hidden via `cond` when + -- nothing's attached to the buffer. + { + function() + local names = {} + for _, client in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do + table.insert(names, client.name) + end + return "\239\128\147 " .. table.concat(names, ", ") + end, + cond = function() + return #vim.lsp.get_clients({ bufnr = 0 }) > 0 + end, + }, + -- Word count, only for prose filetypes -- noise anywhere + -- else, so it's gated the same way. + { + function() + return "\194\182 " .. vim.fn.wordcount().words + end, + cond = function() + local ft = vim.bo.filetype + return ft == "markdown" or ft == "text" or ft == "gitcommit" or ft == "tex" + end, + }, + }, + lualine_y = { + "progress", + "location", + -- Encoding/line-ending: only shown when they DIFFER from + -- Neovim's own defaults (utf-8, unix) -- surfaces the + -- unusual case instead of confirming the boring default + -- on every single file. + { + function() + local enc = vim.bo.fileencoding ~= "" and vim.bo.fileencoding or vim.o.encoding + local parts = {} + if enc ~= "utf-8" then + table.insert(parts, enc) + end + if vim.bo.fileformat ~= "unix" then + table.insert(parts, vim.bo.fileformat) + end + return table.concat(parts, " ") + end, + cond = function() + local enc = vim.bo.fileencoding ~= "" and vim.bo.fileencoding or vim.o.encoding + return enc ~= "utf-8" or vim.bo.fileformat ~= "unix" + end, + }, + }, + lualine_z = { + function() + return " " .. os.date("%a %H:%M") + end, + }, + }, + }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/neogen.lua b/nvim/config/nvim/lua/plugins/neogen.lua new file mode 100644 index 0000000..7ba4124 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/neogen.lua @@ -0,0 +1,28 @@ +return { + -- Doxygen (and other language) doc-comment generation. + -- + -- Not a language server -- clangd already understands Doxygen syntax + -- in existing comments (hover/signature-help render @param/@return), + -- this is the other half: generating the comment skeleton itself. + -- Place the cursor on/above a function and press cd to drop + -- in a Doxygen-style block with @brief/@param/@return stubs already + -- matching that function's actual parameters and return type. + { + "danymat/neogen", + dependencies = "nvim-treesitter/nvim-treesitter", + config = function() + require("neogen").setup({ + enabled = true, + -- c/cpp's default annotation_convention is already + -- "doxygen_cpp", so nothing to override there. + snippet_engine = "luasnip", -- inserted fields become LuaSnip + -- tabstops, so / (wired up in the nvim-cmp config in + -- completion.lua for cmp/LuaSnip) cycles through @brief, each @param, + -- and @return. + }) + vim.keymap.set("n", "cd", function() + require("neogen").generate() + end, { desc = "Generate Doxygen Comment" }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/notify.lua b/nvim/config/nvim/lua/plugins/notify.lua new file mode 100644 index 0000000..0f16b8a --- /dev/null +++ b/nvim/config/nvim/lua/plugins/notify.lua @@ -0,0 +1,22 @@ +return { + -- Nicer notifications (replaces the default vim.notify popups) + { + "rcarriga/nvim-notify", + config = function() + require("notify").setup({ + background_colour = "#000000", + timeout = 3000, + -- Stack upward from the bottom instead of down from the top + -- (nvim-notify anchors to the right by default either way), + -- so notifications land bottom-right instead of top-right. + top_down = false, + -- Switched from "compact": that renderer has a documented + -- history of text overflow/wrapping bugs, and is the prime + -- suspect for the garbled/duplicated text seen in some + -- NvimTree error notifications. Not confirmed as the actual + -- cause, but worth trying alongside the position change. + }) + vim.notify = require("notify") + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/nvim-tree.lua b/nvim/config/nvim/lua/plugins/nvim-tree.lua new file mode 100644 index 0000000..431b262 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/nvim-tree.lua @@ -0,0 +1,216 @@ +return { + -- NvimTree (On the right) + { + "nvim-tree/nvim-tree.lua", + dependencies = { "nvim-tree/nvim-web-devicons" }, + config = function() + require("nvim-tree").setup({ + view = { + side = "right", + width = 30, + }, + -- Routes the paste overwrite/rename CHOICE through + -- vim.ui.select tagged with kind = "nvimtree_overwrite_rename" + -- (nvim-tree's own documented hook for this), which the gP + -- batch-paste below intercepts. + select_prompts = true, + + -- Keep nvim-tree's own default mappings and add gP on top of + -- them, via the officially documented on_attach recipe -- + -- rather than the TreeAttachedPost event this used before, + -- whose handler is passed the bufnr directly as a plain + -- number (confirmed from nvim-tree's own docs), not a table + -- with a .buf field -- indexing that number is what threw + -- "attempt to index local 'data' (a number value)". + on_attach = function(bufnr) + local api = require("nvim-tree.api") + + -- The helper that installs the defaults has been named + -- differently across versions (api.config.mappings. + -- default_on_attach vs the older api.map.on_attach.default) + -- -- try both rather than assume one, so this doesn't + -- silently lose every default mapping on a version where + -- the other name is the real one. + if not pcall(api.config.mappings.default_on_attach, bufnr) then + pcall(api.map.on_attach.default, bufnr) + end + + -- gP: paste, resolving every naming collision in this one + -- paste with a single :s///-style pattern/replacement + -- + -- CAVEAT: this assumes a single-file-style overwrite/rename + -- choice per conflict. nvim-tree turns out to have a + -- SEPARATE, differently-shaped dialog specifically for + -- multi-file conflicts in one paste ("N file(s) already + -- exist" / Rename (suffix) / Overwrite all / Skip all), + -- which this doesn't detect or intercept -- so gP is only + -- confirmed reliable for a single conflicting file. For + -- "always rename every file, conflict or not" -- which is + -- what was actually being asked for -- gM below is the + -- better fit: it doesn't touch nvim-tree's paste/conflict + -- system at all, just copies marked files directly. + -- instead of nvim-tree's normal one-at-a-time + -- overwrite/rename prompt per conflicting file. Plain `p` + -- (bound above by default_on_attach) is untouched -- this + -- is a deliberately separate mapping, not a replacement. + -- + -- nvim-tree doesn't expose a way to enumerate the clipboard + -- or pre-scan for collisions before pasting (only + -- print/clear it), and conflicts are resolved one at a time + -- internally as the paste runs -- there's no "here's the + -- whole batch" moment to hook into directly. This gets the + -- same practical result a different way: ask ONCE upfront + -- for the pattern, then answer nvim-tree's own conflict + -- prompts programmatically for the duration of that paste + -- using vim.fn.substitute() (real Vim regex, same engine + -- :s/// itself uses) against the filename nvim-tree + -- pre-fills as its default. + -- + -- vim.ui.select/vim.ui.input are wrapped HERE, at call time + -- inside the keymap function, rather than once when this + -- plugin's config runs. nvim-tree loads eagerly at startup, + -- but dressing.nvim (which ALSO wraps these same two + -- functions, to prettify them) loads lazily on VeryLazy, + -- afterward -- wrapping once at config time would capture + -- the plain pre-dressing versions, which then get silently + -- overwritten (discarding this wrapper entirely) once + -- dressing finishes loading. Wrapping fresh on every call + -- instead always captures whatever's actually active. + vim.keymap.set('n', 'gP', function() + vim.ui.input({ prompt = "Rename pattern/replacement (Vim regex, e.g. foo/bar): " }, function(input) + if not input or input == "" then + api.fs.paste() + return + end + local pattern, replacement = input:match("^(.-)/(.*)$") + if not pattern then + vim.notify("Expected pattern/replacement, e.g. foo/bar", vim.log.levels.WARN) + return + end + + local real_select = vim.ui.select + local real_input = vim.ui.input + + vim.ui.select = function(items, opts, on_choice) + if opts and opts.kind == "nvimtree_overwrite_rename" then + for i, item in ipairs(items) do + if tostring(item):lower():match("rename") then + on_choice(item, i) + return + end + end + end + real_select(items, opts, on_choice) + end + + vim.ui.input = function(input_opts, on_confirm) + if input_opts and input_opts.default then + local ok, new_name = pcall(vim.fn.substitute, input_opts.default, pattern, replacement, "") + on_confirm(ok and new_name ~= "" and new_name or input_opts.default) + return + end + real_input(input_opts, on_confirm) + end + + -- Restored via a short defer rather than immediately + -- after api.fs.paste() returns, since it's not + -- confirmed whether paste() resolves every prompt + -- synchronously before returning to caller or defers + -- some of them -- restoring immediately risked only + -- patching the FIRST conflict in a multi-file paste and + -- silently falling back to normal prompts for the rest. + -- If that's what happens, this assumption is the next + -- thing to revisit. + api.fs.paste() + vim.defer_fn(function() + vim.ui.select = real_select + vim.ui.input = real_input + end, 2000) + end) + end, { desc = "nvim-tree: Paste (Batch Rename Regex)", buffer = bufnr, silent = true }) + + -- Clears the copy/cut clipboard outright. Useful if it's + -- holding a stale entry -- a file that's since been + -- renamed, moved, or deleted -- which pastes as "ENOENT: no + -- such file or directory" until cleared and re-copied fresh. + vim.keymap.set('n', 'gC', api.fs.clear_clipboard, { desc = "nvim-tree: Clear Clipboard", buffer = bufnr, silent = true }) + + -- gM: copy every MARKED file/folder (toggle a mark with m, + -- shown as a star) into the directory under the cursor, + -- applying a single :s///-style pattern/replacement to + -- EVERY name unconditionally -- not just on collision. This + -- is deliberately separate from nvim-tree's own copy/paste + -- clipboard (c/x/p/gP above) entirely: it reads the marked + -- list via api.marks.list() (a real, documented, stable + -- API -- unlike the clipboard, which has no equivalent way + -- to enumerate its contents) and shells out to `cp -r` to + -- do the actual copying itself, the same approach used in + -- nvim-tree's own official custom-copy recipe. Marks are + -- left as-is afterward (not auto-cleared), matching how the + -- regular clipboard also isn't cleared after a paste -- + -- toggle them off individually with m if you're done with + -- them. + vim.keymap.set('n', 'gM', function() + local marks = api.marks.list() + if not marks or #marks == 0 then + vim.notify("No marked files (mark with 'm' first)", vim.log.levels.WARN) + return + end + local cursor_node = api.tree.get_node_under_cursor() + if not cursor_node then + return + end + local dest_dir = cursor_node.type == "directory" and cursor_node.absolute_path + or vim.fn.fnamemodify(cursor_node.absolute_path, ":h") + + vim.ui.input({ prompt = "Rename pattern/replacement (Vim regex, e.g. Foo/Bar): " }, function(input) + if not input or input == "" then + return + end + local pattern, replacement = input:match("^(.-)/(.*)$") + if not pattern then + vim.notify("Expected pattern/replacement, e.g. Foo/Bar", vim.log.levels.WARN) + return + end + for _, node in ipairs(marks) do + if not vim.loop.fs_stat(node.absolute_path) then + -- Marks persist by path in nvim-tree's own state and + -- are NOT cleared when the underlying file/folder is + -- deleted, so a stale mark can point at a path that + -- no longer exists (e.g. you marked it, then deleted + -- it). Clean up the stale mark itself here instead + -- of attempting (and failing) a copy from it. + pcall(api.marks.toggle, node) + vim.notify("Skipped stale mark (no longer exists): " .. node.absolute_path, vim.log.levels.WARN) + else + local base = vim.fn.fnamemodify(node.absolute_path, ":t") + local ok, new_name = pcall(vim.fn.substitute, base, pattern, replacement, "") + if not ok or new_name == "" then + new_name = base + end + local dest_path = dest_dir .. "/" .. new_name + if dest_path == node.absolute_path then + -- The pattern didn't match this particular name + -- (so it came back unchanged) and the destination + -- is the same directory the file's already in -- + -- that's a copy onto itself, which cp correctly + -- refuses. Skip it up front with a clearer reason + -- instead of surfacing cp's raw "same file" error. + vim.notify("Skipped " .. base .. ": pattern didn't match, and destination is the same as source", vim.log.levels.WARN) + else + local result = vim.fn.system({ "cp", "-r", node.absolute_path, dest_path }) + if vim.v.shell_error ~= 0 then + vim.notify("Copy failed for " .. base .. ": " .. result, vim.log.levels.ERROR) + end + end + end + end + api.tree.reload() + end) + end, { desc = "nvim-tree: Copy Marked (Regex Rename)", buffer = bufnr, silent = true }) + end, + }) + vim.keymap.set('n', 'e', ':NvimTreeToggle', { silent = true, desc = "Toggle File Explorer" }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/smear-cursor.lua b/nvim/config/nvim/lua/plugins/smear-cursor.lua new file mode 100644 index 0000000..6eb5ce7 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/smear-cursor.lua @@ -0,0 +1,25 @@ +return { + -- Animated "smear" cursor trail, terminal only -- Neovide has its + -- own native, GPU-accelerated cursor VFX (see the `if vim.g.neovide` + -- block in init.lua), and running both at once would just be two + -- animation systems fighting over the same cursor. `cond` here means + -- this plugin doesn't even load under Neovide. Tuning below is + -- upstream's own suggested starting point. + { + "sphamba/smear-cursor.nvim", + cond = not vim.g.neovide, + config = function() + require("smear_cursor").setup({ + cursor_color = "#ffffff", + never_draw_over_target = true, + smear_insert_mode = false, + min_vertical_distance_smear = 2, + min_horizontal_distance_smear = 2, + time_interval = 17, -- ms + stiffness = 0.9, + trailing_stiffness = 0.4, + damping = 0.99, -- stops bouncing + }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/telescope.lua b/nvim/config/nvim/lua/plugins/telescope.lua new file mode 100644 index 0000000..8172a74 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/telescope.lua @@ -0,0 +1,21 @@ +return { + -- Telescope (Fuzzy Finding and Grepping) + { + "nvim-telescope/telescope.nvim", + -- NOTE: intentionally NOT pinned to branch = "0.1.x" -- that branch's + -- previewer calls nvim-treesitter's now-removed `ft_to_lang` helper, + -- which crashes the file preview with "attempt to call field + -- 'ft_to_lang' (a nil value)". The current default branch calls the + -- built-in vim.treesitter.language.get_lang() instead, which isn't + -- affected by that removal. + dependencies = { "nvim-lua/plenary.nvim" }, + config = function() + local builtin = require('telescope.builtin') + vim.keymap.set('n', 'ff', builtin.find_files, { desc = "Find Files" }) + vim.keymap.set('n', 'fw', builtin.live_grep, { desc = "Find Word (Live Grep)" }) + vim.keymap.set('n', 'fb', builtin.buffers, { desc = "Find Open Buffers" }) + vim.keymap.set('n', 'fh', builtin.help_tags, { desc = "Find Help Tags" }) + vim.keymap.set('n', 'fr', builtin.oldfiles, { desc = "Find Recent Files" }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/toggleterm.lua b/nvim/config/nvim/lua/plugins/toggleterm.lua new file mode 100644 index 0000000..e8d18ad --- /dev/null +++ b/nvim/config/nvim/lua/plugins/toggleterm.lua @@ -0,0 +1,20 @@ +return { + -- ToggleTerm (Floating & Bottom Terminal) + { + "akinsho/toggleterm.nvim", + version = "*", + config = function() + -- shell = "fish": the embedded terminal launches fish instead of + -- your login shell. fish has grey inline "ghost text" suggestions + -- built in (paths, directory contents, and history) with zero + -- config -- accept with -> or . This only affects terminals + -- opened from Neovim; your system login shell is untouched. + require("toggleterm").setup({ + shell = "fish", + }) + vim.keymap.set('n', 'tf', 'ToggleTerm direction=float', { desc = "Floating Terminal" }) + vim.keymap.set('n', 'th', 'ToggleTerm direction=horizontal', { desc = "Bottom Terminal" }) + vim.keymap.set('n', 'tv', 'ToggleTerm direction=vertical size=40', { desc = "Side Terminal" }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/treesitter.lua b/nvim/config/nvim/lua/plugins/treesitter.lua new file mode 100644 index 0000000..b5c4a66 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/treesitter.lua @@ -0,0 +1,44 @@ +return { + -- Treesitter (syntax-aware highlighting + indentation) + -- + -- NOTE: pinned to branch = "master". Upstream's default branch is now + -- "main", which is a full incompatible rewrite that deleted the old + -- require("nvim-treesitter.configs").setup({...}) API entirely (this + -- is what caused "module 'nvim-treesitter.configs' not found"). The + -- "master" branch is explicitly kept frozen by upstream for backward + -- compatibility with this config style, so pin to it rather than + -- rewriting everything against the new API. + { + "nvim-treesitter/nvim-treesitter", + branch = "master", + build = ":TSUpdate", + config = function() + require("nvim-treesitter.configs").setup({ + ensure_installed = { + "c", "cpp", "lua", "go", "python", "javascript", "typescript", + "html", "css", "bash", "json", "yaml", "markdown", "cmake", + "doxygen", + }, + highlight = { enable = true }, + indent = { + enable = true, + -- treesitter's indent module is well-known to be unreliable + -- for a handful of languages specifically (inconsistent + -- brace placement, phantom extra indent on nested blocks) + -- while being fine for the rest. This is more pronounced on + -- the "master" branch pinned above, since it's frozen and + -- gets no further upstream fixes at all. Disabling it for + -- these falls back to Neovim's own built-in, per-filetype + -- indent logic (cindent for c/cpp, the bundled javascript/ + -- typescript indent scripts for the rest), which is far + -- more predictable for brace-heavy code. + -- + -- css and json are also curly-brace languages that CAN hit + -- the same class of issue -- add them here too if you + -- notice the same symptom there. + disable = { "c", "cpp", "javascript", "typescript" }, + }, + }) + end, + }, +} diff --git a/nvim/config/nvim/lua/plugins/which-key.lua b/nvim/config/nvim/lua/plugins/which-key.lua new file mode 100644 index 0000000..848acf2 --- /dev/null +++ b/nvim/config/nvim/lua/plugins/which-key.lua @@ -0,0 +1,25 @@ +return { + -- Which-Key (Registers your namespaces so the menu looks clean) + { + "folke/which-key.nvim", + event = "VeryLazy", + config = function() + local wk = require("which-key") + wk.setup({ + delay = 0, + }) + -- Define group names for the menu + wk.add({ + { "b", group = "Buffers" }, + { "c", group = "Comments" }, + { "f", group = "Find / Grep" }, + { "l", group = "LSP" }, + { "o", group = "OOP" }, + { "s", group = "Splits" }, + { "t", group = "Terminal" }, + { "d", group = "Debug" }, + { "D", group = "Database" }, + }) + end, + }, +} diff --git a/nvim/install.sh b/nvim/setup_neovim.sh similarity index 91% rename from nvim/install.sh rename to nvim/setup_neovim.sh index 1386f6c..fe15f74 100755 --- a/nvim/install.sh +++ b/nvim/setup_neovim.sh @@ -82,12 +82,17 @@ else echo "Run this script directly (not piped) and choose option 2 or 3 to also install Neovide." fi +# The MariaDB *client* (not the server) is installed too: vim-dadbod runs +# queries by shelling out to the `mariadb` binary. The package name differs +# per distro -- Fedora: mariadb, Debian/Ubuntu: mariadb-client, Arch: +# mariadb-clients. To actually query something you also need a server +# running somewhere; this script neither installs nor starts one. case "$DISTRO_FAMILY" in fedora) sudo dnf install -y neovim git curl wget gcc gcc-c++ make cmake \ nodejs npm python3-pip ripgrep fd-find \ golang clang-tools-extra dotnet-sdk-8.0 unzip fontconfig \ - lldb gdb fish + lldb gdb fish mariadb ;; debian) @@ -100,7 +105,7 @@ case "$DISTRO_FAMILY" in sudo apt-get install -y neovim git curl wget gcc g++ make cmake \ nodejs npm python3-pip ripgrep fd-find \ golang-go clang-tools unzip fontconfig \ - lldb gdb fish + lldb gdb fish mariadb-client # fd-find installs its binary as `fdfind` on Debian/Ubuntu (a # name clash with an unrelated existing package called `fd`), @@ -151,7 +156,7 @@ case "$DISTRO_FAMILY" in sudo pacman -S --noconfirm neovim git curl wget gcc make cmake \ nodejs npm python-pip ripgrep fd \ go clang dotnet-sdk-8.0 unzip fontconfig \ - lldb gdb fish + lldb gdb fish mariadb-clients ;; esac @@ -262,12 +267,16 @@ fi # embedded in this script -- edit those files directly to customize). echo "[4/4] Installing new Neovim configuration..." NVIM_DIR="$HOME/.config/nvim" -mkdir -p "$NVIM_DIR/lua/plugins" -mkdir -p "$NVIM_DIR/lua/dap/configurations" +mkdir -p "$NVIM_DIR" -cp "$CONFIG_DIR/nvim/init.lua" "$NVIM_DIR/init.lua" -cp "$CONFIG_DIR/nvim/lua/plugins/init.lua" "$NVIM_DIR/lua/plugins/init.lua" -cp "$CONFIG_DIR/nvim/lua/dap/configurations/cmake.lua" "$NVIM_DIR/lua/dap/configurations/cmake.lua" +# Copy the whole config/nvim tree (the trailing "/." copies the directory's +# contents, dotfiles included, into the existing target) rather than naming +# files one by one. The config is split into many small modules -- one file +# per plugin in lua/plugins/, helpers in lua/config/ -- and a new module +# dropped anywhere under config/nvim/ gets installed without touching this +# script. The target was wiped just above, so nothing stale can linger from +# an older layout (such as the old monolithic lua/plugins/init.lua). +cp -r "$CONFIG_DIR/nvim/." "$NVIM_DIR/" # --- ~/.clang-format, ~/.clang-tidy, and ~/cpp-style-guide.md --- # Personal fallback style, implementing a specific C/C++ coding style @@ -301,6 +310,9 @@ echo " Run 'nvim'. p and P are restored, use p to force inline paste!" echo " Debugger: db to set a breakpoint, F5 / dc to start/continue," echo " du to toggle the debug UI. First debug session will auto-install codelldb via Mason." echo " Project-specific CMake debug targets loaded from ~/.config/nvim/lua/dap/configurations/cmake.lua" +echo " Database (MariaDB): Du opens the DB UI; press A there to add a connection" +echo " (URL form: mariadb://user:password@127.0.0.1:3306/dbname). The sqls language server is" +echo " installed on first launch; see README.md ('Database (MariaDB)') for its optional config." if [ "$NVIM_FRONTEND" = "desktop" ] || [ "$NVIM_FRONTEND" = "both" ]; then echo " Neovide: run 'neovide' to launch the desktop app (same config, same keymaps)." echo " If the command isn't found yet, open a new shell first (rustup updates PATH"