A guide to building a in-process LSP in neovim

neovim
Author

Zizhou Teng

Published

November 27, 2025

Why in-process LSP?

I have spent quite some time turning most parts of the functionality of obsidian.nvim into an in-process LSP, and many folks have asked me how it compares to an actual LSP process running outside of neovim, or just wanted to get a general idea of what it means, since there aren’t not many good guides out there.

Below are my usual answers, and I intend to use this post with a concrete example project to give you an idea of how it works and why I think it is my favorite “black magic” feature of neovim:

  • You get neovim’s default LSP keymaps for free:
    • You get meaningful keymaps that most users are already using, and by default they are perfect keymaps to overload (expanded in Definition and References)
  • You get to leverage the vast ecosystem of plugins that enhance the LSP experience for free:
    • For example, inc-rename.nvim is just a better rename handler, and completion plugins like blink.cmp and nvim-cmp are essentially completion handlers, you don’t build integrations for all of these plugins with their APIs, you become the source of knowledge for them, thus being plugin agnostic
  • You let neovim do the heavy lifting logic for you:
    • You pass data to neovim’s well maintained hanlders to do things like renaming symbols in your workspace (trust me it will get messy if you do it yourself).
  • You get to “cheat” a bit than LSPs running as a system process:
    • They have to constantly keep negotiating with neovim about the document’s state, while you can just go vim.api.nvim_buf_get_lines

Goal of this guide

I have been enjoying using blink-cmp-words, which provides completion for words and synonyms when I write essays. However, I sometimes get frustrated for not being able to hover over a word and get what I had just seen in the documentation window when doing completion. So building it into an LSP is the perfect solution.

For the purpose of this guide, we will ignore all the details of actually querying the dictionary, and use various hacks and placeholders to get the idea across. I believe the eventual best solution would be using the same WordNet db approach that blink-cmp-words uses.

Infrastructure

---@type table<vim.lsp.protocol.Method, fun(params: table, callback:fun(err: lsp.ResponseError?, result: any))>
local handlers = {}
local ms = vim.lsp.protocol.Methods

---@param buf integer
---@return integer? client_id
local function start_lsp(buf)
   ---@type vim.lsp.ClientConfig
   local client_cfg = {
      name = "dict-lsp",
      cmd = function()
         return {
            request = function(method, params, callback)
               if handlers[method] then
                  handlers[method](params, callback)
               end
            end,
            notify = function() end,
            is_closing = function() end,
            terminate = function() end,
         }
      end,
   }

   return vim.lsp.start(client_cfg, { bufnr = buf, silent = false })
end

vim.api.nvim_create_autocmd("FileType", {
   pattern = { "markdown", "neorg", "org", "txt" },
   callback = function(ev)
      start_lsp(ev.buf)
   end,
})

Note that in theory after nvim-0.11 you could use vim.lsp.config which will be a bit more convenient, I just have not tried it and the improvements don’t concern the topic today.

The most important API to know is :h vim.lsp.start, it takes vim.lsp.ClientConfig and the two fields that concern us at the moment are:

  • name: arbitrary name for the LSP client. Should be unique per language server.
  • cmd: command string[] or function, and cmd in the case of in-process LSP will just be a function that returns a table of handlers, and we will only look into the request handler in this guide, as it handles most of the functionalities we think of when we think of LSP servers.

Initializing the client and server

First time seeing the signature name vim.lsp.ClientConfig for those not familiar with neovim LSP might be somewhat confusing: weren’t we writing a server here? The answer is neovim spawns one LSP client per running server.

And cmd is the recipe for the client to spawn the right LSP server, in this case, we don’t need to to run a new process, we just registered a function (that returns a table of method handlers) in memory.

After spawning, the example above still will not work since our client and server have not negotiated a contract based on each other’s capabilities, usually this is a two way process, but since we are not an editor-agnostic server, we don’t really need to think about neovim’s client capabilities.

We only need neovim to know our server’s capabilities, which is done through responding to the initialize client request. Here’s a minimal example containing all the capabilities we will implement in this guide:

local chars = {}
for i = 32, 126 do
   table.insert(chars, string.char(i))
end

---@type lsp.InitializeResult
local initializeResult = {
   capabilities = {
      hoverProvider = true,
      definitionProvider = true,
      referencesProvider = true,
      completionProvider = {
         triggerCharacters = chars,
      },
   },
   serverInfo = {
      name = "dict-lsp",
      version = "0.0.1",
   },
}

handlers[ms.initialize] = function(_, callback)
   callback(nil, initializeResult)
end

With this handler setup, you should be able to open a markdown buffer, run checkhealth vim.lsp, and see our dict-lsp.

vim.lsp: Active Clients ~
- dict-lsp (id: 3)
  - Version: 0.0.1
  - Root directory: nil
  - Command: <function @/home/n451/Plugins/dict-lsp.nvim//lua/dict-lsp/init.lua:93>
  - Settings: {}
  - Attached buffers: 13

Hover

Let’s try to implement what sets me out to write this guide, the hover.

---@param _ lsp.HoverParams
---@param callback fun(err?: lsp.ResponseError, result: lsp.Hover)
handlers[ms.textDocument_hover] = function(_, callback)
   local word = vim.fn.expand("<cword>")
   local url_format = "https://api.dictionaryapi.dev/api/v2/entries/en/%s"

   vim.system(
      { "curl", url_format:format(word) },
      vim.schedule_wrap(function(out)
         local contents
         if out.code ~= 0 then
            contents = "word fetch failed"
         else
            local ok, decoded = pcall(vim.json.decode, out.stdout)
            if ok and decoded and decoded[1] then
               contents = decoded[1].meanings[1].definitions[1].definition
            else
               contents = decoded.message -- this api gives a nice message if no result
            end
         end
         callback(nil, { contents = contents })
      end)
   )
end

Isn’t it amazing that now you get hover windows on words?

Two points worth noting:

  1. In a “real” LSP server, we will need to get the current file and current cursor position from the params, and then it will need to compare its internal document state and compute the current word, but we get to “cheat” by just calling vim.fn.expand"<cword>", but I would suggest you don’t perform any actions that change the buffer and this is general principle for me when writing these in-process servers: we should think the document as mostly a read-only and immuntatble data, we only compute the data that is neccessary for neovim to performe actions.
  2. I basically copied the implementation to fetch the meaning from the returned json string, from none-ls.nvim’s dictionary source, there’s a even more powerful one to show you more detailed information about the word in hover.nvim’s dictionary provider, the later is pretty much a drag-in replacement.

Definition and References

LSP-related concepts forms a good mental map to restructure and conceptualize scattered pieces of actions, for example in obsidian.nvim, I had a revelation that everything from a tag to all kinds of links the plugin supports can just be thought of as symbols in LSP word that can be performed actions upon, and the two most important actions are goto definition and find references. (in obsidian jargon, it is linking and backlinking), quite like the idea that you can do all sorts of motions on different textobjects in vim.

So applying a similar logic, we can think goto definition in dictionary land could mean just finding the meaning of the word, and find references means looking up synonyms.

My initial idea for a complete implementation will look up the definitions and references, and then write a formatted page of markdown describing the meaning for each of the word in a tmp file, then return a list of lsp.Location pointing to them.

But since we did not implement WordNet queries, and the free dictionary API used above doesn’t provide a mechanism to look up synonyms, I came up with a rather fun and creative hack that kind of bypasses neovim’s handler pattern:

handlers[ms.textDocument_definition] = function()
   local url = "https://dictionary.cambridge.org/dictionary/english/%s"
   local word = vim.fn.expand("<cword>")
   vim.ui.open(string.format(url, word))
end

handlers[ms.textDocument_references] = function()
   local url = "https://dictionary.cambridge.org/thesaurus/%s"
   local word = vim.fn.expand("<cword>")
   vim.ui.open(string.format(url, word))
end

But I do think this is even better than actually querying, at least to me browser would make more sense to display a definition of a word.

Some readers might ask if we did not use the params sent from client, and did not return anything to the client, instead, we only did an arbitrary side effect that is completely unrelated to LSP, what difference does it have compared to just binding the function to the keys like the following?

vim.keymap.set("n", "grr", function()
   local url = "https://dictionary.cambridge.org/thesaurus/%s"
   local word = vim.fn.expand("<cword>")
   vim.ui.open(string.format(url, word))
end)

That leads us to the natural of “LSP keymaps” in neovim, in many ways they are special, many of them correspond to a vim.lsp.buf function, which will properly handle all the servers attached to one buffer and display results properly, for example multiple hover results will be shown together in the hover window, try making out little dict-lsp to also load in lua, and run some hovers if you have not seen multiple servers in action.

This nice feature makes “LSP keymaps” infinitely overloadable, you just add a server with the capability and corresponding handler, whereas the vim.keymap.set example will only ever run that one function in markdown.

Completion

Completion is, in my opinion, the ultimate prize to win in the journey of in-process LSP, once I land lsp completion in obsidian.nvim, I will be able to deleted thousands of lines of lines of completion engine specific code that are messy to maintain. It also serves as a pretty satisfying concluding case for our little guide.

The following is basically an adaptation of none-ls’s spell source. Funny story is I have seen more than one person posting on r/neovim, saying they get unwanted words in their completion list, because none-ls.nvim list this source as a default example, and many forgot they left it there.

The reason this source is not very useful is that it uses vim’s native spellsuggest function to complete the word. I actually happened to find this paragraph describing the difference between a spell file and a dictionary in the help file today :h spell-file-format:

Note that we avoid the word “dictionary” here. That is because the goal of spell checking differs from writing a dictionary (as in the book). For spelling we need a list of words that are OK, thus should not be highlighted. Person and company names will not appear in a dictionary, but do appear in a word list. And some old words are rarely used while they are common misspellings. These do appear in a dictionary but not in a word list.

As with all the cases above, we will need a capable local dictionary db or a paid dictionary API to really resolve this issue, but right now I think spell file approch is good enough for us, and I added fuzzy matching and filtering some unwanted cases to tidy up things a bit.

---Adapted from none-ls
---gets word to complete for use in completion sources
---@param params lsp.CompletionParams
---@return string word_to_complete
local get_word_to_complete = function(params)
   local col = params.position.character + 1
   local line = vim.api.nvim_get_current_line()
   local line_to_cursor = line:sub(1, col)
   local regex = vim.regex("\\k*$")

   return line:sub(regex:match_str(line_to_cursor) + 1, col)
end

---@param params lsp.CompletionParams
---@param callback fun(err?: lsp.ResponseError, result: lsp.CompletionItem[])
handlers[ms.textDocument_completion] = function(params, callback)
   local word = get_word_to_complete(params)
   local get_candidates = function(entries)
      local items = {}
      for k, v in ipairs(entries) do
         items[k] = { label = v, kind = vim.lsp.protocol.CompletionItemKind["Text"] }
      end

      return vim.items
   end

   local candidates = get_candidates(vim.fn.spellsuggest(word)) -- a real implementation queries a dictionary here

   candidates = vim.tbl_filter(function(candidate)
      return candidate.label:find("[ ']") == nil
   end, candidates)

   callback(nil, {
      items = candidates,
      isIncomplete = #candidates > 0,
   })
end

Conclusion

There you go, have fun exploring. I actually have a lot more ideas that I want to implement, but I will leave it here so that I can post it before I procrastinate over perfectionism. Maybe will write a part 2 if I have enough good ideas.