· #neovim #tooling #workflow · ~1400 words · 7 min read
Abstract: Most "dev environment" guides hand you a pile of plugins and say "good luck." This one doesn't. This is a minimal, CLI-first Neovim config built for AI security research — native completion, native LSP, six plugins total, one init.lua. No package manager. No magic. You understand every line or you don't ship it.
GUI editors hide the machine from you. They abstract away the filesystem, the shell, the process model. That abstraction is comfortable, but comfort is the enemy of understanding. When you're reverse-engineering prompt injection chains or tracing an LLM's token flow, you need to be close to the metal — or at least close to the terminal.
CLI is less magic for more learning. Every keybind is explicit. Every config line is readable. Every tool composes with every other tool through pipes and stdout. You don't click a button that says "Run"; you type a command that you can reproduce, script, and audit.
Neovim specifically because it ships batteries that most editors bolt on after the fact: native LSP, native completion, Lua as a first-class config language, and vim.pack.add for dependency-free plugin loading. The editor gets out of your way and lets you think about the actual problem.
Don't use your distro's package manager for Neovim. The version in apt is almost always stale, and the features we're using here (native autocomplete, vim.pack.add, built-in LSP improvements) require nightly or recent stable builds. Bob is a Neovim version manager — think nvm for Node, but for Neovim. Install it, point it at stable or nightly, and switch between them in one command.
apt update && apt upgrade -y
apt install -y curl unzip
curl -fsSL https://raw.githubusercontent.com/MordechaiHadad/bob/master/scripts/install.sh | bash
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
bob --version || echo "bob not found — check PATH or re-login"
Configure Bob to keep things clean:
mkdir -p ~/.config/bob
cat > ~/.config/bob/config.json << 'EOF'
{
"enable_nightly_info": true,
"enable_release_build": false,
"downloads_location": "$HOME/.local/share/bob",
"installation_location": "$HOME/.local/share/bob/nvim-bin",
"version_sync_file_location": "$HOME/.config/nvim/nvim.version",
"rollback_limit": 5,
"github_mirror": "https://github.com",
"add_neovim_binary_to_path": true,
"ignore_running_instances": false
}
EOF
Install and activate:
bob install stable
bob use stable
bob install nightly
bob use nightly
touch ~/.config/nvim/nvim.version
mkdir -p ~/.local/share/bash-completion/completions
bob complete bash > ~/.local/share/bash-completion/completions/bob
source ~/.bashrc
bob list
The version_sync_file_location setting lets you pin your Neovim version per-project. The rollback_limit keeps 5 old versions around in case nightly breaks something. bob list shows you what's installed and what's active.
One file. No plugin manager. No lazy-loading framework. Everything lives in ~/.config/nvim/init.lua. If you can't read it top to bottom in five minutes, it's too complex.
mkdir -p ~/.config/nvim/{lua,lsp}
touch ~/.config/nvim/init.lua
The config opens with three aliases and the leader key:
local map = vim.keymap.set
local au = vim.api.nvim_create_autocmd
local o = vim.opt
vim.g.mapleader = " "
map("n", "<space>", "<Nop>")
Space as leader. The <Nop> mapping prevents space from moving the cursor in normal mode so it's free for leader combos.
vim.cmd.filetype("plugin indent on")
o.undofile = true
o.undodir = '~/.cache/nvim/undodir'
o.termguicolors = true
o.laststatus = 3
o.guicursor = "i:block"
o.signcolumn = "yes:1"
o.ignorecase = true
o.swapfile = false
o.autoindent = true
o.expandtab = true
o.tabstop = 2
o.softtabstop = 2
o.shiftwidth = 2
o.shiftround = true
o.number = true
o.relativenumber = true
o.wrap = false
o.scrolloff = 1000
o.inccommand = "nosplit"
o.hlsearch = true
The highlights: undofile gives you persistent undo across sessions — you can close Neovim, reopen a file a week later, and still undo. laststatus = 3 gives you a single global statusline instead of one per split. scrolloff = 1000 effectively keeps your cursor centered vertically at all times. swapfile = false because swap files solve a problem that persistent undo already handles, and they create noise in your directory.
o.autocomplete = true
o.complete = "o,.,w,b,u"
o.completeopt = "fuzzy,menuone,noselect,popup"
No nvim-cmp. No completion plugin at all. Neovim's native autocomplete with the fuzzy option handles it. The complete sources pull from the current buffer, other open buffers, and undo history. When LSP attaches, it overrides this with language-aware completions. Zero dependencies for something most people install three plugins to get.
vim.pack.add({
"https://github.com/neovim/nvim-lspconfig",
"https://github.com/nvim-treesitter/nvim-treesitter",
"https://github.com/folke/which-key.nvim",
"https://github.com/folke/snacks.nvim",
"https://github.com/nvim-tree/nvim-web-devicons",
"https://github.com/nvim-lua/plenary.nvim",
})
Six plugins. That's it. nvim-lspconfig for LSP server configs. Treesitter for syntax highlighting that actually understands your code. which-key so you don't have to memorize every keybind on day one. Snacks.nvim for file picking, grep, and a file explorer. devicons for file type icons. plenary because Snacks depends on it.
No plugin manager — vim.pack.add is native to Neovim. It clones the repos into your pack directory and loads them. First run takes a few seconds. After that, instant.
vim.lsp.enable({
"lua_ls",
-- "clangd",
})
Enable the language servers you need. Uncomment clangd for C/C++ work, add pyright for Python, ts_ls for TypeScript. The servers themselves need to be installed on your system — lspconfig just tells Neovim how to talk to them.
Diagnostics start quiet:
local d_b = {
virtual_text = false,
virtual_lines = false,
}
vim.diagnostic.config(d_b)
No inline noise by default. Toggle them on with <leader>d:
local d_a = {
virtual_text = {
severity = { max = vim.diagnostic.severity.WARN },
},
virtual_lines = {
severity = { min = vim.diagnostic.severity.ERROR },
},
}
vim.keymap.set("n", "<leader>d", function()
if d_toggle then
vim.diagnostic.config(d_a)
else
vim.diagnostic.config(d_b)
end
d_toggle = not d_toggle
end, { desc = "Toggle diagnostic virtual_lines" })
When toggled on, warnings show as inline virtual text and errors get full virtual lines underneath the offending code. This split keeps the signal-to-noise ratio high — you see errors immediately, warnings in your peripheral vision.
map("n", "<C-d>", "<C-d>zz")
map("n", "<C-u>", "<C-u>zz")
map("n", "<C-s>", ":w<C-M>")
C-d and C-u scroll half a page and then center. Without the zz, your eyes have to hunt for where the cursor landed. With it, the cursor stays planted in the middle of the screen.
local snacks = require("snacks")
map("n", "<C-e>", function() snacks.picker.smart() end)
map("n", "<C-f>", function() snacks.picker.buffers() end)
map("n", "<C-g>", function() snacks.picker.grep() end)
C-e for smart file finding, C-f for open buffers, C-g for live grep across the project. Three keys cover 90% of navigation.
The shell command trick is worth calling out:
map("n", "<space>c", function()
vim.ui.input({}, function(c)
if c and c ~= "" then
vim.cmd("noswapfile vnew")
vim.bo.buftype = "nofile"
vim.bo.bufhidden = "wipe"
vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.fn.systemlist(c))
end
end)
end)
<space>c prompts you for a shell command, runs it, and dumps the output into a scratch buffer in a vertical split. Run ip a, git log --oneline -20, curl -s — whatever you need — without leaving the editor. The buffer wipes itself when you close it.
Wrapped j and k handle visual line movement:
map("n", "j", function()
return tonumber(vim.api.nvim_get_vvar("count")) > 0 and "j" or "gj"
end, { expr = true, silent = true })
map("n", "k", function()
return tonumber(vim.api.nvim_get_vvar("count")) > 0 and "k" or "gk"
end, { expr = true, silent = true })
If you press j by itself, it moves by visual line (useful with wrapped text). If you press 5j, it moves by actual line (useful for jumping). Best of both worlds.
au("BufEnter", {
callback = function()
if vim.bo.buftype == 'prompt' then
vim.opt.autocomplete = false
return
end
vim.opt.autocomplete = true
end,
})
Disables autocomplete in prompt buffers (like the <space>c input). You don't want the completion menu popping up when you're typing a shell command.
au("LspAttach", {
callback = function(ev)
vim.cmd('setlocal complete=o')
vim.lsp.completion.enable(true, ev.data.client_id, ev.buf)
end,
})
When an LSP server attaches to a buffer, this switches the completion source from buffer words to the language server. complete=o tells Neovim to use omnifunc (LSP) as the completion source instead of the default buffer scan.
au("LspAttach", {
callback = function(args)
au("BufWritePre", {
buffer = args.buf,
callback = function()
vim.lsp.buf.format { async = false, id = args.data.client_id }
end,
})
end
})
Format on save. Synchronous so the file is formatted before it hits disk. Uses whichever formatter the attached LSP server provides. If you're running lua_ls, you get StyLua formatting. pyright won't format (use ruff for that), but ts_ls will. One autocommand handles all of them.
The full config is ~190 lines of Lua. No abstraction layers. No lazy-loading. No plugin manager managing your plugin manager. You open Neovim, you have LSP, completion, diagnostics, file finding, grep, and a file explorer. Everything else is the terminal you're already sitting in.
This setup is optimized for the kind of work we do here: reading papers, writing code, grepping through logs, running tools, and switching between contexts fast. The CLI-first philosophy means every part of it composes with the rest of your toolkit. Pipe output into Neovim. Pipe Neovim into your shell. The editor is a node in the graph, not a walled garden.
The raw config is in guide.md if you want to copy-paste and go.