#!/bin/bash echo echo "=========================================" echo " Starting Neovim Setup" echo "=========================================" # 1. Detect distro and install system dependencies echo "[1/4] Detecting distro and installing system dependencies..." if [ ! -f /etc/os-release ]; then echo "ERROR: Cannot detect your Linux distribution (/etc/os-release not found)." echo "This script supports Fedora, Ubuntu, Debian, and Arch Linux." exit 1 fi . /etc/os-release DISTRO_ID="$ID" DISTRO_ID_LIKE="${ID_LIKE:-}" # Normalize into one of: fedora, debian, arch. Checking ID_LIKE too so # common derivatives (Pop!_OS, Linux Mint, Manjaro, EndeavourOS, etc.) # land in the right family even though only the four distros above # were specifically asked for. case "$DISTRO_ID $DISTRO_ID_LIKE" in *fedora*|*rhel*) DISTRO_FAMILY="fedora" ;; *arch*) DISTRO_FAMILY="arch" ;; *debian*|*ubuntu*) DISTRO_FAMILY="debian" ;; *) echo "ERROR: Unsupported or undetected distro (ID=$DISTRO_ID, ID_LIKE=$DISTRO_ID_LIKE)." echo "This script supports Fedora, Ubuntu, Debian, and Arch Linux." exit 1 ;; esac echo "Detected: $DISTRO_ID (family: $DISTRO_FAMILY)" 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 ;; debian) export DEBIAN_FRONTEND=noninteractive sudo apt-get update # Package name differences from Fedora: g++ (not gcc-c++), # golang-go (not golang), clang-tools (not clang-tools-extra -- # Debian/Ubuntu split clang-format/clang-tidy differently, this # is the closest equivalent bundle). 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 # fd-find installs its binary as `fdfind` on Debian/Ubuntu (a # name clash with an unrelated existing package called `fd`), # unlike Fedora/Arch where it's just `fd`. Symlink it so tools # that look for a plain `fd` on PATH (e.g. Telescope's # find_files) work the same way here as everywhere else. if command -v fdfind >/dev/null 2>&1 && ! command -v fd >/dev/null 2>&1; then mkdir -p "$HOME/.local/bin" ln -sf "$(command -v fdfind)" "$HOME/.local/bin/fd" echo "Symlinked fdfind -> ~/.local/bin/fd (make sure ~/.local/bin is on your PATH)." fi # .NET SDK: Ubuntu ships dotnet-sdk-8.0 directly in its own # repos. Debian does not (long-standing packaging/licensing # reasons) and needs Microsoft's own apt feed added first. # Best-effort either way -- a failure here doesn't stop the # rest of the script, since this only matters for C#/omnisharp. if [ "$DISTRO_ID" = "ubuntu" ] || echo "$DISTRO_ID_LIKE" | grep -qi ubuntu; then sudo apt-get install -y dotnet-sdk-8.0 || \ echo "WARNING: dotnet-sdk-8.0 install failed -- see https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install" else ( set -e DEBIAN_MAJOR="${VERSION_ID%%.*}" wget -q "https://packages.microsoft.com/config/debian/${DEBIAN_MAJOR}/packages-microsoft-prod.deb" -O /tmp/packages-microsoft-prod.deb sudo dpkg -i /tmp/packages-microsoft-prod.deb rm -f /tmp/packages-microsoft-prod.deb sudo apt-get update sudo apt-get install -y dotnet-sdk-8.0 ) || echo "WARNING: dotnet-sdk-8.0 install failed -- see https://learn.microsoft.com/en-us/dotnet/core/install/linux-debian for manual steps" fi ;; arch) # Arch's own wiki explicitly warns against `pacman -Sy # ` (sync without upgrading first) -- it risks # partial-upgrade dependency issues. -Syu first is the # recommended safe order; this does mean the script also # upgrades your existing packages, not just installs new ones, # which is expected/intentional here, not a side effect to # work around. sudo pacman -Syu --noconfirm # Package name differences from Fedora: gcc includes g++ # already (no separate gcc-c++ package), python-pip (not # python3-pip -- Arch's default python already is python3), # fd (not fd-find), go (not golang), clang bundles clang-format/ # clang-tidy directly (no separate clang-tools-extra). 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 ;; esac # This config relies on fairly recent Neovim APIs (vim.lsp.config/ # enable, virtual_lines diagnostics, vim.o.winborder -- all 0.11+). # Distro-packaged Neovim can lag well behind that, especially on # Debian stable and older Ubuntu LTS releases. Warn (don't fail) if # what actually got installed is too old. NVIM_VER_LINE=$(nvim --version 2>/dev/null | head -n1) NVIM_MAJOR=$(echo "$NVIM_VER_LINE" | sed -n 's/.*v\([0-9]*\)\.\([0-9]*\).*/\1/p') NVIM_MINOR=$(echo "$NVIM_VER_LINE" | sed -n 's/.*v\([0-9]*\)\.\([0-9]*\).*/\2/p') if [ -n "$NVIM_MAJOR" ] && [ -n "$NVIM_MINOR" ] && [ "$NVIM_MAJOR" -eq 0 ] && [ "$NVIM_MINOR" -lt 11 ]; then echo "WARNING: Installed Neovim is v${NVIM_MAJOR}.${NVIM_MINOR}, but this config needs 0.11+." echo " Debian stable and older Ubuntu LTS releases often ship an older" echo " version. See https://github.com/neovim/neovim/blob/master/INSTALL.md" echo " for official AppImage/PPA/prebuilt options if so." fi # Install global NPM packages for JS/TS, HTML, and CSS LSPs echo "Installing NPM-based LSPs..." sudo npm install -g typescript typescript-language-server vscode-langservers-extracted # 2. Install Nerd Fonts (JetBrainsMono and Cascadia Code) echo "[2/4] Installing JetBrainsMono and Cascadia Code Nerd Fonts..." FONT_DIR="$HOME/.local/share/fonts" mkdir -p "$FONT_DIR" # JetBrainsMono if [ ! -f "$FONT_DIR/JetBrainsMonoNerdFont-Regular.ttf" ]; then wget -q --show-progress https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip -O /tmp/JetBrainsMono.zip unzip -q -o /tmp/JetBrainsMono.zip -d "$FONT_DIR" rm /tmp/JetBrainsMono.zip echo "JetBrainsMono Nerd Font downloaded." else echo "JetBrainsMono Nerd Font already installed." fi # Cascadia Code (CaskaydiaCove) if [ ! -f "$FONT_DIR/CaskaydiaCoveNerdFont-Regular.ttf" ]; then wget -q --show-progress https://github.com/ryanoasis/nerd-fonts/releases/latest/download/CascadiaCode.zip -O /tmp/CascadiaCode.zip unzip -q -o /tmp/CascadiaCode.zip -d "$FONT_DIR" rm /tmp/CascadiaCode.zip echo "Cascadia Code Nerd Font downloaded." else echo "Cascadia Code Nerd Font already installed." fi # Update font cache fc-cache -fv echo "Fonts installed and cache updated successfully!" # 3. Delete existing Neovim config echo "[3/4] Deleting existing Neovim configurations..." if [ -d "$HOME/.config/nvim" ]; then rm -fr "$HOME/.config/nvim" fi if [ -d "$HOME/.local/share/nvim" ]; then rm -fr "$HOME/.local/share/nvim" fi # 4. Create Neovim Configuration Structure echo "[4/4] Writing new Neovim configuration..." NVIM_DIR="$HOME/.config/nvim" mkdir -p "$NVIM_DIR/lua/plugins" mkdir -p "$NVIM_DIR/lua/dap/configurations" # --- init.lua --- cat << 'EOF' > "$NVIM_DIR/init.lua" vim.g.mapleader = " " vim.g.maplocalleader = " " -- Basic Options vim.opt.number = true -- Tab width: 4, using real tab characters (not expanded to spaces) vim.opt.tabstop = 4 vim.opt.shiftwidth = 4 vim.opt.softtabstop = 4 vim.opt.expandtab = false vim.opt.relativenumber = true vim.opt.mouse = "a" vim.opt.clipboard = "unnamedplus" vim.opt.breakindent = true vim.opt.undofile = true vim.opt.ignorecase = true vim.opt.smartcase = true vim.opt.signcolumn = "yes" vim.opt.updatetime = 250 vim.opt.timeoutlen = 1000 vim.opt.splitright = true vim.opt.splitbelow = true vim.opt.termguicolors = true vim.opt.hlsearch = true vim.opt.laststatus = 3 -- global statusline (for lualine) -- Styled window borders between splits (nvim-tree, terminal, etc.) vim.opt.fillchars = { eob = " ", vert = "│", horiz = "─", horizup = "┴", horizdown = "┬", vertleft = "┤", vertright = "├", verthoriz = "┼", } vim.api.nvim_create_autocmd("ColorScheme", { callback = function() vim.api.nvim_set_hl(0, "WinSeparator", { fg = "#3b4261" }) end, }) -- Rounded borders on ALL floating windows (hover, signature help, LspInfo, -- Mason, diagnostics float, etc.) that don't set their own border. -- Requires Neovim 0.11+; older versions just skip this and fall back to -- whatever each plugin sets individually. if vim.fn.has("nvim-0.11") == 1 then vim.o.winborder = "rounded" end -- SSH Clipboard Support (OSC 52) vim.g.clipboard = { name = 'OSC 52', copy = { ['+'] = require('vim.ui.clipboard.osc52').copy('+'), ['*'] = require('vim.ui.clipboard.osc52').copy('*'), }, paste = { -- We now return BOTH the text and the register type ('v', 'V', or '^V') -- This restores native p and P line-wise behavior ['+'] = function() return { vim.fn.getreg('"', 1, true), vim.fn.getregtype('"') } end, ['*'] = function() return { vim.fn.getreg('"', 1, true), vim.fn.getregtype('"') } end, }, } -- GENERAL KEYMAPS -- -- Clear search highlight on pressing Esc in normal mode vim.keymap.set('n', '', 'nohlsearch') -- Forced Inline Paste (The behavior you liked) -- This forces the pasted text to be treated as characters at your exact cursor position vim.keymap.set('n', 'p', function() local reg_content = vim.fn.getreg('"', 1, true) vim.api.nvim_put(reg_content, 'c', true, true) end, { desc = "Force Paste Inline" }) -- Visual mode paste: Prevent replacing the unnamed register when pasting over selected text vim.keymap.set("x", "p", '"_dP') vim.keymap.set("x", "P", '"_dP') -- Move lines up and down in visual mode vim.keymap.set("v", "J", ":m '>+1gv=gv", { desc = "Move line down" }) vim.keymap.set("v", "K", ":m '<-2gv=gv", { desc = "Move line up" }) -- Easy Save vim.keymap.set({'n', 'v', 'i'}, '', 'w', { desc = "Save File" }) -- Window Navigation (Ctrl + h/j/k/l) vim.keymap.set('n', '', 'h', { desc = "Go to left window" }) vim.keymap.set('n', '', 'j', { desc = "Go to lower window" }) vim.keymap.set('n', '', 'k', { desc = "Go to upper window" }) vim.keymap.set('n', '', 'l', { desc = "Go to right window" }) -- Window Splits (Namespaced under s) vim.keymap.set('n', 'sv', 'v', { desc = "Split Vertically" }) vim.keymap.set('n', 'sh', 's', { desc = "Split Horizontally" }) vim.keymap.set('n', 'se', '=', { desc = "Make Splits Equal" }) vim.keymap.set('n', 'sx', 'close', { desc = "Close Current Split" }) -- --------------------------------------------------------------------- -- Neovim 0.12 compatibility shim for nvim-treesitter's legacy (master -- branch) query predicates/directives. -- -- In Neovim 0.12 a capture in `match[id]` can now be a LIST of TSNode -- instead of a single TSNode (for captures that quantify to more than -- one node). nvim-treesitter's `master` branch is frozen for backward -- compatibility and its query_predicates.lua still assumes a single -- node everywhere, so it calls node:range() on what's now a table and -- throws "attempt to call method 'range' (a nil value)". This is a -- known, currently unresolved upstream issue (nvim-treesitter/nvim- -- treesitter#8618 and #8636, both closed "not planned" since master -- won't receive further fixes). It's cosmetic -- highlighting still -- works -- but it spams :messages, most often on markdown files -- (fenced code blocks use the conceal_lines directive). -- -- This wraps query.add_predicate/add_directive so any capture that has -- "spilled" into a list of nodes is transparently unwrapped to its -- first node before the underlying handler runs, restoring the -- single-node behavior those legacy handlers expect. Must run before -- nvim-treesitter is loaded (its query_predicates.lua registers its -- handlers at require-time), so this sits above the lazy.nvim -- bootstrap/setup call below. -- --------------------------------------------------------------------- do local query = vim.treesitter.query local function normalize_match(match) local out = {} for id, val in pairs(match) do if type(val) == "table" and val[1] ~= nil and type(val[1]) == "userdata" then out[id] = val[1] else out[id] = val end end return out end local orig_add_predicate = query.add_predicate query.add_predicate = function(name, handler, opts) return orig_add_predicate(name, function(match, ...) return handler(normalize_match(match), ...) end, opts) end local orig_add_directive = query.add_directive query.add_directive = function(name, handler, opts) return orig_add_directive(name, function(match, ...) return handler(normalize_match(match), ...) end, opts) end end -- Bootstrap lazy.nvim local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" if not vim.loop.fs_stat(lazypath) then vim.fn.system({ "git", "clone", "--filter=blob:none", "https://github.com/folke/lazy.nvim.git", "--branch=stable", lazypath, }) end vim.opt.rtp:prepend(lazypath) require("lazy").setup("plugins") EOF # --- plugins.lua --- cat << 'EOF' > "$NVIM_DIR/lua/plugins/init.lua" return { -- REALLY Dark Theme (Pitch Black TokyoNight) { "folke/tokyonight.nvim", priority = 1000, config = function() require("tokyonight").setup({ style = "night", transparent = false, on_colors = function(colors) colors.bg = "#000000" -- Pure black background colors.bg_dark = "#000000" colors.bg_float = "#050505" colors.bg_highlight = "#1a1a1a" colors.bg_popup = "#050505" colors.bg_sidebar = "#000000" colors.bg_statusline = "#000000" end, on_highlights = function(hl, c) hl.DiagnosticVirtualTextError = { bg = "#351010", fg = c.error } hl.DiagnosticVirtualTextWarn = { bg = "#35280b", fg = c.warning } hl.DiagnosticVirtualTextInfo = { bg = "#0b2035", fg = c.info } hl.DiagnosticVirtualTextHint = { bg = "#0b3528", fg = c.hint } hl.DiagnosticVirtualLinesError = { bg = "#351010", fg = c.error } hl.DiagnosticVirtualLinesWarn = { bg = "#35280b", fg = c.warning } hl.DiagnosticVirtualLinesInfo = { bg = "#0b2035", fg = c.info } hl.DiagnosticVirtualLinesHint = { bg = "#0b3528", fg = c.hint } end, }) vim.cmd[[colorscheme tokyonight]] 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" }, { "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, }, }) 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 = { theme = "tokyonight", component_separators = { left = "│", right = "│" }, section_separators = { left = "", right = "" }, globalstatus = true, }, sections = { lualine_a = { "mode" }, lualine_b = { "branch", "diagnostics" }, lualine_c = { { "filename", path = 1 } }, lualine_x = { "encoding", "filetype" }, lualine_y = { "progress" }, lualine_z = { "location" }, }, }) 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, }, -- Nicer notifications (replaces the default vim.notify popups) { "rcarriga/nvim-notify", config = function() require("notify").setup({ background_colour = "#000000", timeout = 3000, render = "compact", }) 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 above-line virtual lines 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() -- 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" }) -- Safe Rename Function (Fixes double execution/prompt issues) local function safe_rename() 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" }) vim.keymap.set('n', 'la', vim.lsp.buf.code_action, { buffer = bufnr, silent = true, desc = "Code Actions" }) vim.keymap.set('n', 'lf', function() vim.lsp.buf.format { async = true } end, { buffer = bufnr, silent = true, desc = "Format File" }) vim.keymap.set('n', 'li', 'LspInfo', { buffer = bufnr, silent = true, desc = "LSP Info" }) end vim.api.nvim_create_autocmd("LspAttach", { group = vim.api.nvim_create_augroup("UserLspAttach", { clear = true }), callback = function(args) on_attach(args.buf) end, }) -- 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, { capabilities = capabilities, }) 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, }, -- ========================================================= -- 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, }, } EOF # --- dap/configurations/cmake.lua --- # Project-specific nvim-dap launch configs for CMake-built binaries. cat << 'EOF' > "$NVIM_DIR/lua/dap/configurations/cmake.lua" -- dap.configurations.cmake -- -- nvim-dap debug configurations for this project's CMake-built -- binaries: tests/test_dynamic_library and demo/example_host. -- -- Load from your Neovim config, e.g.: -- -- dofile(vim.fn.getcwd() .. '/dap.configurations.cmake') -- -- (or place it under a Lua module path as dap/configurations/cmake.lua -- and `require('dap.configurations.cmake')` instead). -- -- Assumes: -- * nvim-dap is installed. -- * a `codelldb` adapter is available on PATH (e.g. installed via -- mason.nvim) -- this file only registers dap.adapters.codelldb if -- nothing has registered one already, so it won't clobber your own -- adapter config. -- * the project has been built with scripts/build.sh, so the binaries -- referenced below exist under build/. -- -- Debugging needs actual debug symbols: build with -- ./scripts/build.sh --debug -- Release builds will run under the debugger but won't have useful -- source-line info. local dap = require('dap') -- --------------------------------------------------------------------- -- Locate the project root by walking up from the current working -- directory to find CMakeLists.txt. Falls back to cwd if not found, -- which covers the common case of launching nvim from the project -- root directly. -- --------------------------------------------------------------------- local function project_root() local found = vim.fs.find('CMakeLists.txt', { upward = true, path = vim.fn.getcwd(), })[1] if found then return vim.fs.dirname(found) end return vim.fn.getcwd() end local function build_dir() return project_root() .. '/build' end -- --------------------------------------------------------------------- -- codelldb adapter (skipped if one is already registered elsewhere, -- e.g. by mason-nvim-dap) -- -- NOTE: this must be a plain table, not a function. nvim-dap only -- performs its automatic ${port} -> real free port substitution (and -- spawns the executable itself, waiting for it to be ready) for -- table-style server adapters. A function-style adapter is expected -- to resolve the port itself; leaving the literal string "${port}" -- in a function adapter's return value causes nvim-dap to try to -- connect to a host literally named "${port}", which fails with -- ECONNREFUSED. -- --------------------------------------------------------------------- if not dap.adapters.codelldb then dap.adapters.codelldb = { type = 'server', port = '${port}', executable = { command = vim.fn.exepath('codelldb'), args = { '--port', '${port}' }, }, } end -- --------------------------------------------------------------------- -- Platform-appropriate shared library naming, matching -- dynlib::DynamicLibrary::MakeLibraryFileName(). -- --------------------------------------------------------------------- local function shared_lib_prefix() return vim.loop.os_uname().sysname == 'Windows_NT' and '' or 'lib' end local function shared_lib_ext() local sysname = vim.loop.os_uname().sysname if sysname == 'Windows_NT' then return '.dll' elseif sysname == 'Darwin' then return '.dylib' end return '.so' end local function fixture_lib_path() return build_dir() .. '/tests/' .. shared_lib_prefix() .. 'dynlib_test_fixture' .. shared_lib_ext() end local function demo_plugin_path() return build_dir() .. '/demo/' .. shared_lib_prefix() .. 'example_plugin' .. shared_lib_ext() end -- --------------------------------------------------------------------- -- Debug configurations -- --------------------------------------------------------------------- local configurations = { { name = 'Debug test_dynamic_library (tests)', type = 'codelldb', request = 'launch', program = function() return build_dir() .. '/tests/test_dynamic_library' end, args = function() return { fixture_lib_path() } end, cwd = function() return build_dir() .. '/tests' end, stopOnEntry = false, }, { name = 'Debug example_host (demo)', type = 'codelldb', request = 'launch', program = function() return build_dir() .. '/demo/example_host' end, args = function() return { demo_plugin_path() } end, cwd = function() return build_dir() .. '/demo' end, stopOnEntry = false, }, { name = 'Debug example_host (prompt for plugin path)', type = 'codelldb', request = 'launch', program = function() return build_dir() .. '/demo/example_host' end, args = function() local path = vim.fn.input('Plugin path: ', demo_plugin_path(), 'file') return { path } end, cwd = function() return build_dir() .. '/demo' end, stopOnEntry = false, }, } dap.configurations.cpp = vim.list_extend(dap.configurations.cpp or {}, configurations) dap.configurations.c = vim.list_extend(dap.configurations.c or {}, configurations) return configurations EOF # --- ~/.clang-format --- # Personal fallback clang-format style. clang-format walks UP from the # file being formatted looking for a .clang-format file; $HOME sits # above every project you'll open, so this becomes your default style # for any project that doesn't ship its own .clang-format (a project's # own file, being closer to the file, always takes precedence over # this one). BreakBeforeBraces: Attach keeps braces on the same line # as the declaration (K&R), instead of Allman-style braces on their # own line. # # NOTE: written unconditionally (not "if missing") every run, same as # the rest of this config -- an earlier version only wrote this if the # file didn't already exist, which meant a stray pre-existing # ~/.clang-format (from some other tool, or leftover from before this # script managed it) would silently block the fix forever. cat << 'EOF' > "$HOME/.clang-format" BasedOnStyle: LLVM BreakBeforeBraces: Attach IndentWidth: 4 ColumnLimit: 100 UseTab: Always TabWidth: 4 EOF echo "Wrote ~/.clang-format (Attach braces, real tabs)." echo "=========================================" echo " Setup Complete!" 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 "========================================="