← Home

How to get human rights in Neovim without plugins

13 December, 2024

Introduction

This is a rewrite of an article I wrote last year based on my PoC config project: NativeVim.

Plugins have become a natural part of the Neovim community, which has grown explosively starting with v0.5.0 released in 2021.

etc

It is pretty common to find more than 10 plugins installed in most minimalistic neovim config.

If you have one of these questions, you've come to the right place.

In this article, we will look at how to achieve the same functionality in native neovim without famous plugins, and find out what exactly each plugin does and why it is needed.

Actually you don't need most of them

Most plugins are just 1) abstracted & automated version of common setup, 2) reimplementation of builtin functionalities providing advanced features, 3) implementation of missing features which are now builtin. So they can be easily replaced with manual setup / builtin features.

LSP plugins & setups

Let's start with most basic ones. Plugins known for providing LSP integrations.

nvim-lspconfig

mason.nvim

keymaps/options

Many options are now builtin especially from upcomming v0.11 release.

Completions

While completion is part of LSP features, it can do other other stuffs without LSP. That's why I'm treating Completions in separate section from LSP.

Some surprising facts:

I'll compare with nvim-cmp ecosystem because it is most mature and famous completion plugin at this moment. But points below also applies to other common completion plugins like [coq_nvim], [mini.completion], [blink.cmp] and [care.nvim].

cmp-nvim-lsp (LSP completions)

Neovim automatically sets 'omnifunc' option to vim.lsp.omnifunc() when Language Server is attached.

Below is how internal code making this possible:

vim.api.nvim_create_autocmd("LspAttach", {
    callback = function (ev)
        vim.bo[ev.buf].omnifunc = "v:lua.vim.lsp.omnifunc"
    end,
})

This auto-command is one of defaults in Neovim since v0.10 so you don't need to set this manually in your config.

Enabling LSP completion

However, we can't say that this alone properly utilizes the autocompletion provided by LSP. Since 'omnifunc' does not provide features such as snippet or auto-import, support for these must be set separately. Fortunately, starting from v0.11, Neovim added this feature as a built-in API.

vim.api.nvim_create_autocmd("LspAttach", {
    callback = function (ev)
        vim.lsp.completion.enable(true, ev.data.client_id, ev.buf, { autotrigger = false })
    end,
})

After vim.lsp.completion.enable() is executed, Neovim performs the additional operations provided by the LSP in buffer where user auto-completes with LSP. (e.g. expanding a snippet or modifying part of the code like auto-import)

cmp-path (path completions)

Vim has a built-in filename completion (:h i_CTRL-X_CTRL-F)

This is based on where nvim is executed, not current filepath.

cmp-buffer (buffer text completions)

Vim has a builtin buffer word completion.

advanced features from builtin completion

Why would I use nvim-cmp then?

While builtin completions are powerful enough, it is quite reasonable to use external completion plugins. External completion plugins not depending on builtin completions usually has better performance and provide way more customizabilty of how it looks and behaves.

Snippets

Lets have the snippet feature without plugins like Luasnip or [nvim-snippet].

Neovim has built-in snippet API in vim.snippet since v0.10, so most LSP-provided snippets work (since v0.11, if you enable the LSP completion.) But it still don't provide native API to make user-defined snippets that expands on specific keywords or trigger keymaps.

Instead, there is a similar function called Abbreviation (:h Abbreviations.) To briefly explain, if you register abbreviation in insert mode with :iab ms Microsoft, ms<space> or ms<cr> will be replaced with Microsoft<space> and Microsoft<cr>, respectively. This abbreviation can be triggered with the keymap <c-]> in addition to special characters such as <space>, <cr> or ()[]{}<>'",..

By using abbreviation and vim.snippet, we can implement the following snippet API.

---@param trigger string trigger string for snippet
---@param body string snippet text that will be expanded
---@param opts? vim.keymap.set.Opts
---
---Refer to <https://microsoft.github.io/language-server-protocol/specification/#snippet_syntax>
---for the specification of valid body.
function vim.snippet.add(trigger, body, opts)
    vim.keymap.set("ia", trigger, function()
        -- If abbrev is expanded with keys like "(", ")", "<cr>", "<space>",
        -- don't expand the snippet. Only accept "<c-]>" as trigger key.
        local c = vim.fn.nr2char(vim.fn.getchar(0))
        if c ~= "" then
            vim.api.nvim_feedkeys(trigger .. c, "i", true)
            return
        end
        vim.snippet.expand(body)
    end, opts)
end

With this, you can add custom snippets with vim.snippet.add() and use <c-]> keymap to expand the snippets.

Example of some lua function definition snippets:

vim.snippet.add(
    "fn",
    "function ${1:name}($2)\n\t${3:-- content}\nend",
    { buffer = 0 }
)
vim.snippet.add(
    "lfn",
    "local function ${1:name}($2)\n\t${3:-- content}\nend",
    { buffer = 0 }
)

put this in after/ftplugin/lua.lua

So when you type fn<c-]> in lua file, it will be expanded to this: (|...| is a cursor)

function |name|()
    -- content
end

tree-sitter plugins & setups

nvim-treesitter

Long ago, it was near imposible to use tree-sitter parsers in Neovim without nvim-treesitter because most tree-sitter APIs lived in nvim-treesitter. But nowadays, those APIs are included in Neovim core, so you can use tree-sitter parsers without nvim-treesitter.

You can attach tree-sitter parser to current buffer with vim.treesitter.start().

vim.api.nvim_create_autocmd("FileType", {
    callback = function(ev)
        pcall(vim.treesitter.start, ev.buf)
    end
})

We use pcall here because vim.treesitter.start may fail if no parser is registered for the filetype of the current buffer.

Install tree-sitter parser manually

:TSInstall command in nvim-treesitter clones Parser's git repository and built the parser from it. Some tree-sitter parsers include query statements (highlight.scm, fold.scm, etc.) related to the parser in the repository, but some parts may not match Neovim's spec, so the nvim-treesitter plugin includes query statements for all parsers.

This is based on the master branch of nvim-treesitter. New main branch will not include those query files.

Fortunately, as of this writing, most of the tree-sitter parsers are available on luarocks.org, so you can easily install the tree-sitter parser package for Neovim using [luarocks]. These luarocks packages (aka. rocks) also include various queries (mostly taken from the nvim-treesitter repository) required to use each parser.

luarocks \
    --lua-version=5.1 \
    --tree=$HOME/.local/share/nvim/rocks \
    install tree-sitter-rust

replace --tree option to $HOME/AppData/Local/nvim-data/rocks if you are on Windows

With this script, you can install tree-sitter-rust parser and its query files in $HOME/.local/share/nvim/rocks/lib/luarocks/rock-5.1/tree-sitter-rust/0.0.27-1 where 0.0.27 is the installed version. You can add this path to 'packpath' and Neovim will recognize and register the parser with bundled queries.

# create target packpath directory (`treesitter` is arbitrary)
mkdir -p $HOME/.local/share/nvim/site/pack/treesitter/start

# symlink installed rock to packpath
ln -sf $HOME/.local/share/nvim/rocks/lib/luarocks/rocks-5.1/tree-sitter-rust/0.0.27-1 $HOME/.local/share/nvim/site/pack/treesitter/start/tree-sitter-rust

You can use :checkhealth vim.treesitter to see if installed parser is registered without problem.

Some tree-sitter parsers requires other packages to work properly. For example, tree-sitter-javascript and tree-sitter-typescript depend on tree-sitter-ecma. You don't need to care about this in installation step as luarocks will handle dependencies automatically but you should manually put those in packpath

Extra IDE features

Folding

Neovim has builtin folding method using tree-sitter. You can use tree-sitter provided folding with following options.

vim.o.foldenable = true
vim.o.foldlevel = 99
vim.o.foldlevelstart = 99
vim.o.foldmethod = "expr"
vim.o.foldexpr = "v:lua.vim.treesitter.foldexpr()"

For related keymaps, please refer to :h folds.

Commenting

Neovim includes commenting support since v0.10. (:h commenting)

File Tree

Yes, you read it correct. Neovim does have builtin file tree view. Well it is not a side bar style file tree, but you can see folders in tree view.

Netrw, which is one of first-party plugins providing ability to open a folder in vim actually has multiple display styles.

Try open a directory with neovim and press i three times. Or put this somewhere in your config and open a directory.

vim.g.netrw_list_style = 3

You can press SHIFT_ENTER to toggle each folders.

Check :h netrw for more info.

You might already have some plugins

It is extremely rare but some system-wide softwares even ship with vim plugins.

fzf (fuzzy finding plugin)

One example for this is fzf. Which contains vim plugin in its main repository.

homebrew

vim.opt.runtimepath:append("/opt/homebrew/opt/fzf")

AUR

If you installed fzf from AUR, system package managers will automatically setup vim plugin as system-wide config.

manual install from source

-- replace `~/.fzf` to path where you cloned the fzf
vim.opt.runtimepath:append("~/.fzf")

Don't expect Vim to be like VSCode

If you learn a new language, don't expect it to behave like the languages you've used before - theprimeagen

Installing plugins

Ok, maybe not using any plugins at all is too much.

Then how to avoid verbose configuratino

1. Use plugins with less setup steps

There are few plugins that don't require manual setup. Just install with your favorate plugin manager. No need to run require("my-plugin").setup() call in your config.

You don't have to care about lazy-loading because plugins can do that better.

For example, [rest.nvim] ships with lazy-loading and no setup calls. Installing this plugin is as simple as adding single line in your plugin manager config:

require("lazy").setup({
    spec = {
        "rest-nvim/rest.nvim", -- add this line
    },
})

2. Use plugins with rockspec support

Plugin managers like lazy.nvim and rocks.nvim supports rockspec. If the plugin

Conclusion

So far we've looked at how to set up Neovim in its purest form, without relying on any external plugins.

Should I use plugins?

YES.