neovim config - native lsp support instead of coc

7 minute read

Migrating language server, linting and formatting away from coc

Context

I am a big vim (neovim) fan and do basically all my programming and writing in it, the exception being work things that need MS Office. Luckily, most of those things are possible with the browser-based version of office, the exception being macros and obscure things like Powerpoint footer changes. Anyways, that is a completely different story.

Naked, vanilla vim is nice and present in basically every environment. So, it’s good to be able to use it, especially for remote configuration/documentation/troubleshooting work. The real power is unleashed with more advanced capabilities and extensions, which extend the barebone editor with modern creature comforts, such as linting, automatic fixing, code completion, auto formatting, syntax highlighting, test execution, debugging and intelligent navigation.

My code completion and intelligent navigation setup started with YouCompleteMe in 2017. I needed it mainly for C, and it did a good job.

A bit later, Microsoft’s Language Server Protocol gained much more traction, with more and more really usable language servers being really usable and taking off, mainly n the context of visual studio code. Most of the Language servers where installed and integrated into vscode through dedicated plugins.

In comes the neovim plugin coc (conquer of completion). This plugin acts as a language server client, meaning that it can use any language server that implements the lsp standard, and it can integrate slightly adapted vscode pluins as coc plugins. This meant that the managed installation of language servers and the integration thereof became really simple and straightforward, with a very active ecosystem and community around it. I migrated to coc in 2019, adding support and better integration for many more languages into my setup.

coc with a set of plugins and a handful of “manually” integrated language servers as well as with a set of linters and formatters, integrated with diagnostic-languageserver, was my default setup for 5 years. I changed the supported languages all the time, based on what I needed, but besides that and rewriting the config to lua, my setup stayed like this for 5 years.

In the meantime, neovim gained native language server client support, removing the need for a dedicated plugin for lsp support. However, coc still provided the easy integration and awesome out of the box functionality.

The awesome and very capable community embraced the feature, though. There is mason, an nvim native package/lsp manager, and many integration layer plugins between neovim and mason, making the integration of language servers slimmer (no extra layer), extremely flexible and manageable, with concise and powerful configuration.

Powered by all of that, projects like lazyvim started, providing much more out of the box functionality and ease of use than coc had. Lazyvim is basically a full-featured one size fits all preconfiguration of nvim, with very sane defaults and a smart plugin and configuration system that makes it feature rich without bloat. I know a few people who recently went down the rabbithole of vim, and they started with lazyvim right away. I respect it a lot, even though I want to have a bit more control over my setup, so not for me. The ecosystem grew and grew and is now more active and powerful than coc’s ecosystem.

I was very interested in the principle of the powerful and manageable modularization, but still, the normal out of the box experience was a lot worse than with coc, as it was basically not a box, but a collection of elements you had to build your box with yourself. The capabilities of coc are split into separate and independent components for completion (with lsp as source amongst others), dedicated components for formatters, linters, dedicated configuration of all components and system integration.

Interesting, but it’s a lot. Especially because my current setup just worked and did mostly what I wanted it to do, and I had no real functioning reason to migrate besides the itch. So, I put a migration on the back of my to do list, to do some other time.

Some other time is now

That is until for the first time ever I had to do things in C#. There is a coc plugin for C#, coc-omnisharp, which in the README greets you with the message:

⛔ This project is lacking proper maintanence. I would recommend csharp-ls at this moment.

This leaves manual integration of the csharp-language-server. Awesome, I did it right away.

But there was a problem.

With the coc integration, I was unable to find a way to replace the default sync handler for system libraries only available as binaries, used e.g. for navigation, such as go_to_definition, with a buffer showing the decompiled sourcecode. This is explicitly (and easily) possible with the flexible configuration of native language servers in nvim, as documented.

So, last weekend, I did it.

I migrated my configuration.

Structure

I use lazy as plugin manager, which makes it really simple to split up plugin configuration. I decided to split my language server config into a dedicated file for the abstract definition of the behaviour of lsps, e.g. with key mappings, sources of the completion system and so on, a dedicated file for the specific definition of supported languages, and a dedicated file for the separate configuration of linters and fixers, while also revamping the test execution and debugging setup.

/home/gierdo/.dotfiles/.config/nvim/lua/plugins

❯ tree
.
├── debugging.lua
├── filetree.lua
├── fuzzyness.lua
├── git.lua
├── init.lua
├── linters.lua
├── lsp-behaviour.lua
├── lsp-languages.lua
├── neoai.lua
├── testing.lua
├── theme.lua
└── treesitter.lua

Copy-pasting the few hundred lines of lua here probably makes this post even less readable, but I’ll point out some things here that are not just obvious configuration.

If you want to see the full configuration, check out my dotfiles.

The original motivation: C# decompiler integration

      local cs_config = {
        capabilities = lsp_capabilities,
        handlers = {
          ["textDocument/definition"] = require("csharpls_extended").handler,
          ["textDocument/typeDefinition"] = require("csharpls_extended").handler,
        },
      }
      lspconfig.csharp_ls.setup(cs_config)

Fuzzy and fancy selection for multiple navigation target hits

          vim.keymap.set(
            "n",
            "gd",
            require('telescope.builtin').lsp_definitions,
            { buffer = event.buf, desc = "Go to definition." }
          )
          vim.keymap.set(
            "n",
            "gi",
            require('telescope.builtin').lsp_implementations,
            { buffer = event.buf, desc = "Go to implementation." }
          )
          vim.keymap.set(
            "n",
            "go",
            require('telescope.builtin').lsp_type_definitions,
            { buffer = event.buf, desc = "Go to type definition." }
          )
          vim.keymap.set(
            "n",
            "gr",
            require('telescope.builtin').lsp_references,
            { buffer = event.buf, desc = "Show references." }
          )

Extended json- and yaml schema support

      lspconfig.jsonls.setup({
        capabilities = lsp_capabilities,
        settings = {
          json = {
            schemas = require("schemastore").json.schemas(),
            validate = { enable = true },
            schemaDownload = { enable = false },
          },
        },
      })

      lspconfig.yamlls.setup({
        capabilities = lsp_capabilities,
        settings = {
          yaml = {
            schemaStore = {
              enable = false,
              url = "",
            },
            schemas = require("schemastore").yaml.schemas({
              extra = {
                {
                  name = "Cloudformation",
                  description = "Cloudformation Template",
                  fileMatch = { "*.template.y*ml", "*-template.y*ml" },
                  url = "https://raw.githubusercontent.com/awslabs/goformation/master/schema/cloudformation.schema.json",
                },
              },
            }),
            customTags = {
              -- Cloudformation tags
              "!And scalar",
              "!If scalar",
              "!Not",
              "!Equals scalar",
              "!Or scalar",
              "!FindInMap scalar",
              "!Base64",
              "!Cidr",
              "!Ref",
              "!Sub",
              "!GetAtt sequence",
              "!GetAZs",
              "!ImportValue sequence",
              "!Select sequence",
              "!Split sequence",
              "!Join sequence",
            },
          },
        },
      })

imho smart test execution and debugging keymaps

      local wk = require("which-key")

      wk.add({
        { "<leader>t", group = "   Test" },
        { "<leader>tr", neotest.run.run, desc = "Run nearest test." },
        {
          "<leader>tf",
          function()
            neotest.run.run(vim.fn.expand("%"))
          end,
          desc = "Run current file.",
        },
        {
          "<leader>td",
          function()
            neotest.run.run({ strategy = "dap" })
          end,
          desc = "Debug nearest test.",
        },
        {
          "<leader>ts",
          neotest.summary.toggle,
          desc = "Show/hide summary.",
        },
      })

      wk.add({
        { "<leader>d", group = "   Debug" },
        {
          "<leader>dd",
          telescope.extensions.dap.commands,
          desc = "Show debug commands.",
        },
        {
          "<leader>dt",
          dap.toggle_breakpoint,
          desc = "Toggle line breakpoint on the current line.",
        },
        {
          "<leader>dbs",
          dap.set_breakpoint,
          desc = "Set breakpoint.",
        },
        {
          "<leader>dbl",
          telescope.extensions.dap.list_breakpoints,
          desc = "List breakpoints.",
        },
        {
          "<leader>dv",
          telescope.extensions.dap.variables,
          desc = "Show variables.",
        },
        {
          "<leader>dbc",
          dap.clear_breakpoints,
          desc = "Clear breakpoints.",
        },
        {
          "<leader>dbe",
          dap.set_exception_breakpoints,
          desc = "Set Exception breakpoints.",
        },
        {
          "<leader>dc",
          dap.continue,
          desc = "When debugging, continue. Otherwise start debugging.",
        },
        {
          "<leader>dfd",
          dap.down,
          desc = "Move down a frame in the current call stack.",
        },
        {
          "<leader>dfu",
          dap.up,
          desc = "Move up a frame in the current call stack.",
        },
        { "<leader>dp", dap.pause, desc = "Pause debuggee." },
        {
          "<leader>dr",
          dap.run_to_cursor,
          desc = "Continues execution to the current cursor.",
        },
        {
          "<leader>dre",
          dap.restart,
          desc = "Restart debugging with the same configuration.",
        },
      })

tl;dr

  • language servers and such + vim are great
  • there are many ways to integrate vim with language servers
  • coc is not that slim and flexible with good out of the box experience
  • native nvim lsp support is slim and flexible with worse out of the box experience
  • with existing plugins and thanks to the community, benefiting from the advantages of native nvim lsp support is possible in a sane way

Leave a comment