Using Neovim with Unity

26 Apr 2023

In the last few years I’ve switched fully to Neovim, and embraced the lua scripting and built-in LSP support. Most development I do these days tends to be TypeScript or Rust, and my editor’s integration with tooling like prettier, rustfmt, rust-analyzer, and tsc is great! Having recently started doing some work in Unity, however, I discovered that I would have to get my hands dirty for a good C# experience.

I started off by searching around for other people’s experience using Neovim with Unity, and found a decent blog post that walked me through the basic steps:

  1. Configure Unity to send a message over a Unix socket to neovim when you want to open a file
  2. Create a script to launch neovim listening to that Unix socket

I didn’t end up using exactly the solution they provide (nvr doesn’t seem to be available as part of my neovim install) but after reading the documentation for nvim’s --remote option I was able to set up this part of the ingration fine. When I opened a file in Unity, it would pop open in my vim session.

The logical next step is some sort of LSP integration. I went for the officially-supported omnisharp-roslyn and installed it on my system. The nvim-lsp documentation recommended the omnisharp_extended plugin for handling go-to-definition, so I added that to my config as well. My config ended up looking like this:

omnisharp = {
    cmd = {
        "mono",
        "/Users/ryanisaacg/bin/omnisharp-osx/omnisharp/OmniSharp.exe",
        "--languageserver",
        "--hostPID",
        tostring(pid),
    },
    handlers = {
        ["textDocument/definition"] = require('omnisharp_extended').handler,
    },

    -- Enables support for reading code style, naming convention and analyzer
    -- settings from .editorconfig.
    enable_editorconfig_support = true,

    -- If true, MSBuild project system will only load projects for files that
    -- were opened in the editor. This setting is useful for big C# codebases
    -- and allows for faster initialization of code navigation features only
    -- for projects that are relevant to code that is being edited. With this
    -- setting enabled OmniSharp may load fewer projects and may thus display
    -- incomplete reference lists for symbols.
    enable_ms_build_load_projects_on_demand = false,

    -- Enables support for roslyn analyzers, code fixes and rulesets.
    enable_roslyn_analyzers = false,

    -- Specifies whether 'using' directives should be grouped and sorted during
    -- document formatting.
    organize_imports_on_format = false,

    -- Enables support for showing unimported types and unimported extension
    -- methods in completion lists. When committed, the appropriate using
    -- directive will be added at the top of the current file. This option can
    -- have a negative impact on initial completion responsiveness,
    -- particularly for the first few completion sessions after opening a
    -- solution.
    enable_import_completion = false,

    -- Specifies whether to include preview versions of the .NET SDK when
    -- determining which version to use for project loading.
    sdk_include_prereleases = true,

    -- Only run analyzers against open files when 'enableRoslynAnalyzers' is
    -- true
    analyze_open_documents_only = false,
}

and it worked pretty well! I could open up my files from Unity, have them pop open in Neovim, and they were properly integrated with the C# LSP. Unfortunately omnisharp apparently won’t search your PATH for OmniSharp.exe, which means that this part of my config isn’t portable to other machines. Other than that, everything seemed just fine.

After using this setup for a few days I ran into a problem. Whenever I created a new C# file, omnisharp would be completely borked: anything it defined was unavailable in other files and all of the imports in the new file would be marked as unresolved. I could fix the problems by restarting my entire editor, but omnisharp’s long boot time makes that painful. At this point I thought was defeated and started to configure VSCode for C#. Fortunately, a new update to Neovim saved the day! In the GitHub issue someone pointed out that the issue was the LSP client not properly handling the didChangeWatchedFiles message from the server. Last week Neovim added experimental support for handling this in its LSP, so once you enable it in the LSP capabilities the new-file-problem goes away!

local capabilities = vim.tbl_deep_extend(
    "force",
    require('cmp_nvim_lsp').default_capabilities(),
    {
        workspace = {
            didChangeWatchedFiles = {
                dynamicRegistration = true,
            },
        },
    }
)

Upgrading Neovim, however, introduced a new problem. It seems like omnisharp-roslyn reports invalid tokens for the LSP semantic-highlighting feature, which trips up nvim. There’s a hacky workaround I’m using (that I found in a blog post) which manually fixes the invalid tokens; hopefully omnisharp fixes the issue upstream soon.

That’s essentially where I’m leaving things for now: there are some hacks underlying my setup, but day-to-day everything works fine. I have a newfound appreciation for web dev tooling and the Rust project; this was definitely the most fiddly thing I’ve done with my editor in quite some time.