Transform Your Neovim into an IDE: A Step-by-Step Guide (Neovim v0.12)
Updates:
- 2026-04-06. Use the native plugin manager introduced in Neovim v0.12. You may find the related PR here.
- 2025-09-06. Remove
nvim-lspconfigdependency in favor of native LSP settings. - 2025-05-14. Neovim has been upgraded to v0.11, so I simplified the LSP configurations. :)
- 2025-03-22. Replace the
nvim-cmpplugin withblink.cmp. - 2024-04-04. Use lazy.nvim rather than packer.nvim. You may find the related migration commit here
Further reading
- How to set up Neovim for a new programming language and get more control over code formatting, please refer to the next post
Version info
I use a MacBook Air M3 edition with macOS 26.4. This is the detailed information of nvim on my laptop.
NVIM v0.12.0
Build type: Release
LuaJIT 2.1.1741730670
Vim versions: 8.1, 8.2, 9.0, 9.1, 9.2
system vimrc file: "$VIM/sysinit.vim"
fall-back for $VIM: "
/nix/store/qzlmii7hrm51ic46rdm1imvbaqa0m9my-neovim-unwrapped-0.12.0/share/nvim
"
Run :checkhealth for more info
Why Neovim
After using Vim for one year, I find myself having trouble in configuring ~/.vimrc. The syntax of Vimscript is not to my liking, leading me to switch to Neovim(nvim). Rather than migrating my old ~/.vimrc, I decided to start from scratch and take this opportunity to re-evaluate my previous Vim configuration. I aim to replace my plugins with the latest best practices. It’s been some time since I last edited my ~/.vimrc
In my opinion, it’s essential to understand the meaning behind each option and statement in the configuration file. That’s the approach I took in this post. My goal is to make the configuration files self-contained and easily understandable. To achieve this, I aim to provide clear explanations for each setting and include comments to enhance readability.
Please note that I may have missed some explanations. However, as a reminder, you can always access the help docs in the nvim by typing :h <name> to get more information.
This post assumes that you have a basic understanding of Vim
The basics
Lua
In my nvim configuration, I will use the Lua programming language as much as possible. Thus, it’s recommended that the reader familiarize themselves with Lua. Take a look at Learn Lua in Y minutes
Configuration files paths
On Linux/macOS, the configuration directory for nvim is located at ~/.config/nvim. It’s also called the runtimepath, the nvim will read ~/.config/nvim/init.lua when it starts up. Theoretically, we can put everything inside this single file. It’s a bad practice, though. To keep things organized, I prefer to break it down into smaller, more manageable parts.
If you follow this post to configure your nvim, your ~/.config/nvim should look like this⬇️
.
├── init.lua
├── lsp
│ └── ty.lua
├── lua
│ ├── keymaps.lua
│ ├── lsp.lua
│ └── options.lua
├── nvim-pack-lock.json
└── plugin
├── blink.cmp.lua
├── mason-nvim.lua
└── monokai.nvim.lua
The explanations
init.luais the entry point. We will “import” other*.luafiles ininit.luakeymaps.luafor key mappingslsp.luafor the LSP supportoptions.luafor some global options
nvim-pack-lock.jsonfile is the lock file automatically generated by the native plugin manager. You shouldn’t edit this file by yourself.lspfolder. This folder contains detailed lsp settings for each lsp. In this example, I only set Python’s lsp called ty.luafolder. When we userequireto import a module in Lua, it searches this folder. The argument of this function call is quite simple: replace the separator of the imported file’s path/with., and remove the.luasuffix.pluginfolder. When we userequireto import a module in Lua, it searches this folder. The argument of this function call is quite simple: replace the separator of the imported file’s path/with., and remove the.luasuffix.
Options
We mainly use vim.g, vim.opt, and vim.cmd. I made a cheatsheet below:
In Vim |
In nvim |
Note |
|---|---|---|
let g:foo = bar |
vim.g.foo = bar |
|
set foo = bar |
vim.opt.foo = bar |
set foo = vim.opt.foo = true |
some_vimscript |
vim.cmd(some_vimscript) |
key mappings
The syntax of key binding in nvim:
vim.keymap.set(<mode>, <key>, <action>, <opts>)
For a detailed explanation, please refer to :h vim.keymap.set
Configure nvim from scratch
For those who don’t want to wait, I’ve already uploaded the configurations from this post to this repo. You can simply clone the folder and create a symlink to ~/.config/nvim to start using it.
Now we can configure nvim step by step :)
Install Neovim
I am a Mac user, so I use Homebrew to install nvim1
$ brew install neovim
After completing the installation, if the ~/.config/nvim/ directory doesn’t exist, you should create the folders and the files.
$ mkdir ~/.config/nvim
$ mkdir ~/.config/nvim/lsp
$ mkdir ~/.config/nvim/lua
$ mkdir ~/.config/nvim/plugin
$ touch ~/.config/nvim/init.lua
Please note that after making any modifications to the *.lua files, you need to restart the nvim to see the changes take effect. I will assume that you restart your nvim after each section.
Options configuration
The features:
- Use the system’s clipboard
- Use the mouse in
nvim - Tab and whitespace
- UI configuration
- Smart search
Create ~/.config/nvim/lua/options.lua file and edit:
-- Hint: use `:h <option>` to figure out the meaning if needed
vim.opt.clipboard = 'unnamedplus' -- use system clipboard
vim.opt.completeopt = {'menu', 'menuone', 'noselect'}
vim.opt.mouse = 'a' -- allow the mouse to be used in nvim
-- Tab
vim.opt.tabstop = 4 -- number of visual spaces per TAB
vim.opt.softtabstop = 4 -- number of spaces in tab when editing
vim.opt.shiftwidth = 4 -- insert 4 spaces on a tab
vim.opt.expandtab = true -- tabs are spaces, mainly because of Python
-- UI config
vim.opt.number = true -- show absolute number
vim.opt.relativenumber = true -- add numbers to each line on the left side
vim.opt.cursorline = true -- highlight cursor line underneath the cursor horizontally
vim.opt.splitbelow = true -- open new vertical split bottom
vim.opt.splitright = true -- open new horizontal splits right
-- vim.opt.termguicolors = true -- enable 24-bit RGB color in the TUI
vim.opt.showmode = false -- we are experienced, wo don't need the "-- INSERT --" mode hint
-- Searching
vim.opt.incsearch = true -- search as characters are entered
vim.opt.hlsearch = false -- do not highlight matches
vim.opt.ignorecase = true -- ignore case in searches by default
vim.opt.smartcase = true -- but make it case sensitive if an uppercase is entered
... means that I omit other lines (to save the length of the post)
Then edit the init.lua file, use require to import the options.lua file.
... -- rest of the configuration
require('options')
Key mappings configuration
The features:
- Use
<C-h/j/k/l>to move the cursor among windows. - Use
Ctrl+ arrow keys to resize windows. - In visual mode, we can use
TaborShift-Tabto change the indentation repeatedly.
Create ~/.config/nvim/lua/keymaps.lua and edit:
-- define common options
local opts = {
noremap = true, -- non-recursive
silent = true, -- do not show message
}
-----------------
-- Normal mode --
-----------------
-- Hint: see `:h vim.map.set()`
-- Better window navigation
vim.keymap.set('n', '<C-h>', '<C-w>h', opts)
vim.keymap.set('n', '<C-j>', '<C-w>j', opts)
vim.keymap.set('n', '<C-k>', '<C-w>k', opts)
vim.keymap.set('n', '<C-l>', '<C-w>l', opts)
-- Resize with arrows
-- delta: 2 lines
vim.keymap.set('n', '<C-Up>', ':resize -2<CR>', opts)
vim.keymap.set('n', '<C-Down>', ':resize +2<CR>', opts)
vim.keymap.set('n', '<C-Left>', ':vertical resize -2<CR>', opts)
vim.keymap.set('n', '<C-Right>', ':vertical resize +2<CR>', opts)
-----------------
-- Visual mode --
-----------------
-- Hint: start visual mode with the same area as the previous area and the same mode
vim.keymap.set('v', '<', '<gv', opts)
vim.keymap.set('v', '>', '>gv', opts)
Edit init.lua and import keymaps.lua
... -- rest of the configuration
require('keymaps')
Use the native plugin manager
Since the release of Neovim v0.12, we can now use the native plugin manager without introducing any third-party packager manager. To install a plugin, all you need to do is using the vim.pack.add API. I drew the demonstration code from the help docs.
vim.pack.add({
-- Install "plugin1" and use default branch (usually `main` or `master`)
'https://github.com/user/plugin1',
-- Same as above, but using a table (allows setting other options)
{ src = 'https://github.com/user/plugin1' },
-- Specify plugin's name (here the plugin will be called "plugin2"
-- instead of "generic-name")
{ src = 'https://github.com/user/generic-name', name = 'plugin2' },
-- Specify version to follow during install and update
{
src = 'https://github.com/user/plugin3',
-- Version constraint, see |vim.version.range()|
version = vim.version.range('1.0'),
},
{
src = 'https://github.com/user/plugin4',
-- Git branch, tag, or commit hash
version = 'main',
},
})
When Neovim starts, it will load any files under ~/.config/nvim/plugin/. Note that the vim.pack.add is designed to be safe to call in multiple places without side effects, which enables a modular configuration setup.
If we put all plugins into a single vim.pack.add API call, we lose the modularity and extensibility. Therefore, I will create a separate file under ~/.config/nvim/plugin/ for each plugin.
Colorscheme
My favorite theme - monokai.nvim. Create a new file in ~/.config/nvim/plugin/monokai.nvim.lua with the following content.
vim.pack.add({
{ src = "https://github.com/tanvirtin/monokai.nvim", name = "monokai" },
})
vim.cmd("colorscheme " .. "monokai")
Here we use vim.pack.add to add the monokai.nvim plugin and use vim.cmd to set the colorscheme. After saving the changes and restarting Neovim, it will prompt for confirmation before installing the plugin. Press y to proceed, and it will begin downloading the plugin.
Auto-completion
blink.cmp is still in beta version, meaning that it may have breaking changes and many bugs.
In the previous edition of this post, I used nvim-cmp as the completion plugin. However, I found its configurations too complicated. This time, I am using blink.cmp plugin for a simple setup and better performance.
Let’s create a file ~/.config/nvim/plugin/blink.cmp.lua with the following content.
vim.pack.add({
{ src = "https://github.com/saghen/blink.cmp", version = "v1", name = "blink.cmp" },
})
require("blink.cmp").setup({
-- 'default' (recommended) for mappings similar to built-in completions (C-y to accept)
-- 'super-tab' for mappings similar to vscode (tab to accept)
-- 'enter' for enter to accept
-- 'none' for no mappings
--
-- All presets have the following mappings:
-- C-space: Open menu or open docs if already open
-- C-n/C-p or Up/Down: Select next/previous item
-- C-e: Hide menu
-- C-k: Toggle signature help (if signature.enabled = true)
--
-- See :h blink-cmp-config-keymap for defining your own keymap
keymap = {
-- Each keymap may be a list of commands and/or functions
preset = "enter",
-- Select completions
["<Up>"] = { "select_prev", "fallback" },
["<Down>"] = { "select_next", "fallback" },
["<Tab>"] = { "select_next", "fallback" },
["<S-Tab>"] = { "select_prev", "fallback" },
-- Scroll documentation
["<C-b>"] = { "scroll_documentation_up", "fallback" },
["<C-f>"] = { "scroll_documentation_down", "fallback" },
-- Show/hide signature
["<C-k>"] = { "show_signature", "hide_signature", "fallback" },
},
appearance = {
-- 'mono' (default) for 'Nerd Font Mono' or 'normal' for 'Nerd Font'
-- Adjusts spacing to ensure icons are aligned
nerd_font_variant = "mono",
},
-- Default list of enabled providers defined so that you can extend it
-- elsewhere in your config, without redefining it, due to `opts_extend`
sources = {
default = { "lsp", "path", "snippets", "buffer" },
},
-- (Default) Rust fuzzy matcher for typo resistance and significantly better performance
-- You may use a lua implementation instead by using `implementation = "lua"` or fallback to the lua implementation,
-- when the Rust fuzzy matcher is not available, by using `implementation = "prefer_rust"`
--
-- See the fuzzy documentation for more information
fuzzy = { implementation = "prefer_rust_with_warning" },
completion = {
-- The keyword should only matchh against the text before
keyword = { range = "prefix" },
menu = {
-- Use treesitter to highlight the label text for the given list of sources
draw = {
treesitter = { "lsp" },
},
},
-- Show completions after tying a trigger character, defined by the source
trigger = { show_on_trigger_character = true },
documentation = {
-- Show documentation automatically
auto_show = true,
},
},
-- Signature help when tying
signature = { enabled = true },
})
In Neovim, the convention API for setting up a plugin is .setup.
Here we are calling the .setup to set up blink.cmp. The defailed options are passed as a Lua table. The key configurations here are:
keymappreset = "enter"- Press<CR>to confirm completion.select_prev, select_next- cycle through the item. I set it to ⬆️/⬇️ or Tab/Shift-Tab.scroll_documentation_up, scroll_documentation_down- Scroll the documentation up and down. I set it toCtrl-b, Ctrl-f.
trigger = { show_on_trigger_character = true }- Show completions after typing a trigger character.documentation = { auto_show = true }- Show the documentation whenever an item is selected
🎙️ You can use basic completion now ~
LSP
After configuring blink.cmp, Neovim can give basic completion suggestions. However, to turn nvim into an IDE, it is necessary to rely on LSP2 such that we can perform go to definition or other code actions.
The mason.nvim is not required if you choose to install LSP with your system’s package manager. However, I found that using mason.nvim is more convenient.
Since the release of Neovim v0.11, the vim.lsp.config and vim.lsp.enable are available for configuring LSP without downloading nvim-lspconfig. However, we may need a third-party plugin called mason.nvim. This plugin is an LSP registry, that is, you can download any LSP you want easily.
Let’s create a file ~/.config/nvim/plugin/mason-nvim.lua with the following content.
vim.pack.add({
{ src = "https://github.com/mason-org/mason.nvim", name = "mason" },
})
require("mason").setup({
ui = {
icons = {
package_installed = "✓",
package_pending = "➜",
package_uninstalled = "✗",
},
},
})
There exist two ways to set up a an LSP
- We could use the aforementioned
vim.lsp.config.<lsp> = { ... }to configure a specific LSP. - Creating an
lspfolder inruntimepath(~/.config/nvimif you are using Linux/macOS) and creating a file for each LSP under thelspfolder. I will pick method two, as it is more modular.
Now, let’s see how to add an LSP for yourself with the following 3 steps:
- Use
mason.nvimto download an LSP. After opening Neovim, just type:Masonand find the LSP you want. Pressiwhen your cursor is hovering over the LSP. - Use
vim.lsp.config.<lsp> = { ... }to configure the specific LSP. You need to provide the following information.cmd- How to start the LSP Server?filetypes- What type of files you want your LSP to attachroot_markers- Where is the root directory of the attached file? Note that all the files with the same root directory will use the same LSP.settings(optional) - These are specific settings for your LSP choice. Read the official manual.
- Use
vim.lsp.enable({ ... })to enable your LSP.
You can always refer to nvim-lspconfig if you are not sure how to configure a specific LSP. See this link
Let’s take Python’s LSP as an example. It has many LSP choices. As for me, I pick the ty (the same developer team behind the amazing uv). After installing ty, create ty.lua under the lsp directory with the following content.
-- src: https://github.com/neovim/nvim-lspconfig/blob/master/lsp/ty.lua
---@type vim.lsp.Config
return {
cmd = { 'ty', 'server' },
filetypes = { 'python' },
root_markers = { 'ty.toml', 'pyproject.toml', '.git' },
}
Now let’s create ~/.config/nvim/lua/lsp.lua to add customized key mappings and enable LSP:
... -- rest of the configuration
-- Remove Global Default Key mapping
vim.keymap.del("n", "grn")
vim.keymap.del("n", "gra")
vim.keymap.del("n", "grr")
vim.keymap.del("n", "gri")
vim.keymap.del("n", "gO")
-- Create keymapping
-- LspAttach: After an LSP Client performs "initialize" and attaches to a buffer.
vim.api.nvim_create_autocmd("LspAttach", {
callback = function (args)
local keymap = vim.keymap
local lsp = vim.lsp
local bufopts = { noremap = true, silent = true }
keymap.set("n", "gr", lsp.buf.references, bufopts)
keymap.set("n", "gd", lsp.buf.definition, bufopts)
keymap.set("n", "<space>rn", lsp.buf.rename, bufopts)
keymap.set("n", "K", lsp.buf.hover, bufopts)
keymap.set("n", "<space>f", function()
vim.lsp.buf.format({ async = true })
end, bufopts)
end
})
vim.lsp.enable({ "ty" })
The configurations here first use vim.keymap.del to remove default key mappings, then add custom key mappings with vim.keymap.set. For example, pressing gd in normal mode will jump to the definition of the symbol where the current cursor points. If you want to add more custom key mappings, add more lines here.
Finally, remember to add the following line in init.lua
... -- rest of the configuration
require('lsp')
Once you’ve restarted nvim, you can edit a .py file with the power of LSP. If you are unsure whether the configuration works as expected, you can type :checkhealth vim.lsp. It should contain a similar output to this.
vim.lsp: Active Clients ~
- ty (id: 1)
- Version: 0.0.26 (940305127 2026-03-26)
- Root directory: ...
- Command: { "ty", "server" }
- Settings: {}
- Attached buffers: 1
Wrap-up
With this configuration, we successfully turned nvim into a lightweight IDE, which supports code highlighting, code completion, syntax checking, and other functionalities. It is completely open source and free 🤗.
I realized that even after trying different code editors and IDEs, I always found myself searching for Vim support. So I chose to turn nvim into an IDE and host the configuration files on my martinlwx/dotfiles. In this way, I can easily clone my configuration files to any new machine and have a consistent programming experience across machines.
Refining your tools takes time and effort. To understand what each option does, I had to dig through a range of documentation and resources. Despite that, it’s clearly worth it. Once you understand your tools, you can extend and customize them much more effectively.
This article focuses on a simple setup, but there’s still plenty of room for further refinement and personalization—along with many excellent third-party plugins that haven’t been covered here. The rest is left for you to explore.