[] Dark Mode

Coding C# in Neovim

(posted in blog)

Inspired to try Neovim again by an article I read recently, this is an update to replace my older Coding C# in Vim article, which is woefully outdated.

In this post I’ll detail how I set up a new C# coding environment using Neovim‘s native LSP support. This guide assumes you’re starting completely fresh, as I did just prior to writing this. My goal was to see if I like the Neovim setup as much as or better than the Vim setup I’ve been using for years. My conclusion on that is at the end of the article.

Step 1: Obtain Neovim

Should be easy enough. Install it using your distribution’s package manager or whatever other method you’re comfortable with. On my Arch machine I did it like this:

pacman -S neovim

I don’t believe anything I’m using requires the bleeding-edge nightly version. I’m not using it, anyway, and everything I wanted seems to be working.

Step 2: Obtain Omnisharp

You’ll want the latest version of omnisharp-roslyn, which is what Neovim will launch in the background and use as the LSP server whenever you open a .cs file. You might be able to find it with your package manager (it’s in the AUR, for example), or you can just download the latest omnisharp-linux-x64.tar.gz release and decompress that anywhere you want.

Step 3: Install Neovim Plugin Manager

Neovim has a variety of package managers available. My goal is to use plugins written and configured in Lua whenever possible, because I really don’t like (or understand) vimscript and Lua looks and feels a lot nicer to me. I chose packer.nvim and simply followed the instructions on their github:

$> git clone --depth 1 https://github.com/wbthomason/packer.nvim\
   ~/.local/share/nvim/site/pack/packer/start/packer.nvim

Step 4: Install Plugins

I created the file ~/.config/nvim/lua/plugins.lua with the following content:

1
2
3
4
5
6
7
8
9
10
return require('packer').startup(function()
use 'wbthomason/packer.nvim' -- so packer can update itself
use { -- nice interface for LSP functions (among other things)
'nvim-telescope/telescope.nvim',
requires = { {'nvim-lua/plenary.nvim'} }
}
use 'neovim/nvim-lspconfig' -- native LSP support
use 'hrsh7th/nvim-cmp' -- autocompletion framework
use 'hrsh7th/cmp-nvim-lsp' -- LSP autocompletion provider
end)
  • telescope.nvim - Not strictly required, but provides a nice floating-window interface to several of the LSP functions, and is also a nice pure-Lua substitute for both the fzf and grepper plugins I use in Vim.
  • nvim-lspconfig - This is what automatically launches LSP servers (such as Omnisharp) in the background so that Neovim can talk to them.
  • nvim-cmp - This seems to be the most widely recommended completion engine for Neovim.
  • cmp-nvim-lsp - Enables LSP completions.

You may also want to check out hrsh7th‘s other repositories for additional completion sources (I also use buffer and path).

Then at the top of your ~/.config/nvim/init.vim (which you should create if it doesn’t exist already—perhaps by making a copy of your ~/.vimrc file, although I created mine from scratch because my vim config is quite bloated and I wanted to selectively enable only the things I actually still need or want):

lua require('plugins')

After you save that file, then close and re-launch Neovim, you can run this ex command to install all the plugins we just added:

:PackerUpdate

Step 5: Configure Plugins

These configuration blocks go into the same plugins.lua file you created in the previous step, below the use statements and above end).

Autocompletions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- autocomplete config
local cmp = require 'cmp'
cmp.setup {
mapping = {
['<Tab>'] = cmp.mapping.select_next_item(),
['<S-Tab>'] = cmp.mapping.select_prev_item(),
['<CR>'] = cmp.mapping.confirm({
behavior = cmp.ConfirmBehavior.Replace,
select = true,
})
},
sources = {
{ name = 'nvim_lsp' },
}
}

This is enabling the autocomplete plugin, adding a couple keybindings so you can tab and shift-tab between items in the menu, and telling it to use LSP as a completions source. I haven’t played with the keybindings too much, so that is still a prime target for tweaking.

If you also installed other completion sources you would also add those here to the sources section.

Omnisharp LSP

1
2
3
4
5
6
7
8
-- omnisharp lsp config
require'lspconfig'.omnisharp.setup {
capabilities = require('cmp_nvim_lsp').update_capabilities(vim.lsp.protocol.make_client_capabilities()),
on_attach = function(_, bufnr)
vim.api.nvim_buf_set_option(bufnr, 'omnifunc', 'v:lua.vim.lsp.omnifunc')
end,
cmd = { "/path/to/omnisharp-roslyn/bin/omnisharp/run", "--languageserver" , "--hostPID", tostring(pid) },
}

This is instructing nvim-lspconfig that we want to enable the omnisharp LSP provider. It’s also setting up completions with the capabilities and on_attach lines, but I’m not entirely sure what those are actually doing. All I know is that completions don’t work without them.

You’ll want to replace the path on line 7 with the correct path to omnisharp-roslyn that you obtained earlier.

Step 6: Configure Keybindings

These are the keybindings I use, as an example, but obviously you should tweak these to your own personal preference. In your ~/.config/nvim/init.vim file. In general, the functions you’ll probably be interested in mapping are the :Telescope lsp_* ones, the :lua vim.lsp.buf.* ones, and the :lua vim.lsp.diagnostic.* ones:

1
2
3
4
5
6
7
8
9
nnoremap('<leader>fu', 'Telescope lsp_references')
nnoremap('<leader>gd', 'Telescope lsp_definitions')
nnoremap('<leader>rn', 'lua vim.lsp.buf.rename()')
nnoremap('<leader>dn', 'lua vim.lsp.diagnostic.goto_next()')
nnoremap('<leader>dN', 'lua vim.lsp.diagnostic.goto_prev()')
nnoremap('<leader>dd', 'Telescope lsp_document_diagnostics')
nnoremap('<leader>dD', 'Telescope lsp_workspace_diagnostics')
nnoremap('<leader>xx', 'Telescope lsp_code_actions')
nnoremap('<leader>xd', '%Telescope lsp_range_code_actions')

Explanation by line number:

  1. <leader>fu for find-usings, will open a Telescope window with all the places in your code that the current symbol under your cursor is used in the rest of the project.
  2. <leader>gd for goto-definition, will jump to the definition of the current symbol under your cursor (or provide a Telescope list if there are more than one).
  3. <leader>rn for rename, will prompt you for a new name to give the current symbol under your cursor, and refactor all references to it in the current project
  4. <leader>dn will jump to the next warning or error in the current buffer.
  5. <leader>dN will jump to the previous warning or error in the current buffer.
  6. <leader>dd will open a Telescope window listing all errors or warnings in the current buffer.
  7. <leader>dD will open a Telecsope window listing all errors or warnings across the current project.
  8. <leader>xx will open a Telescope window with all of the possible code actions on the error or warning item under the cursor.
  9. <leader>xX will open a Telescope window with all of the possible code actions on all errors or warnings in the current buffer.

I edited the <leader>xX mapping above to a more useful action (document-wide code actions instead of project-wide code actions).

I edited the mappings above to reflect the new keybindings I’ve arrived at after using it a bit more.

Conclusion

If everything is set up correctly, when you open a .cs file with Neovim, it should launch omnisharp-roslyn in the background and start giving you feedback (errors, warnings, autocomplete-as-you-type, and so on). When you’re in the file you can run :LspInfo for a dialog with details about the current LSP provider(s). It should say 1 client(s) attached to this buffer and Client: omnisharp in there.

Overall I’m pretty happy with this setup so far. The completions feel snappier than in Vim, and I really like Telescope in general. Some other plugins that I’ve also got going right now to complete the setup:

  • lualine.nvim for a slightly fancier status line (replacing vim-airline from my Vim setup)
  • gitsigns.nvim for git indicators (replacing gitgutter from my Vim setup)
  • I don’t need a replacement for fzf and vim-grepper because Telescope already covers those
  • vim-filebeagle is the one hold-out that I copied from my Vim setup—it’s just too useful, simple, and ingrained in my muscle memory to live without or care about finding an alternative

I’ve also installed a few additional supported LSP providers and configured them similar to how Omnisharp was configured above. For languages that don’t have LSP providers (such as shellcheck for POSIX shell scripts) I’ve installed efm-langserver, which I’m hoping should let me avoid something like ALE or language-specific plugins, and just stick with Neovim’s built-in LSP support across the board. That way the general behavior and keybindings I configured above will be consistent regardless of what language I’m working in.

So far the only thing I’m missing during C# development is the OmniSharpFixUsings command that omnisharp-vim provided. As far as I can tell, that functionality is not (yet) exposed through the LSP interface of omnisharp-roslyn. It’s not too terrible a loss, however, because my <leader>xX mapping will pull up all possible code actions in the current document, so I can repeat that and add all the missing using statements one at a time. It’s not as quick as running a single command and having all the missing usings added for you, but it’s an acceptable alternative, especially since the OmniSharpFixUsings feature had some annoying idiosyncrasies around when there were multiple different choices for a using anyway.

I hope this helps some fellow Vim-loving Linux C# developers out there. Happy (Neo)vimming!

Bonus! .editorconfig Support

I’ve figured out how to get code analyzers and .editorconfig support working now. Create a file named omnisharp.json at your project root if you don’t have one already, and make sure it has the following options enabled:

1
2
3
4
5
6
7
8
{
"RoslynExtensionsOptions": {
"enableAnalyzersSupport": true
},
"FormattingOptions": {
"enableEditorConfigSupport": true
}
}

Now create your .editorconfig at the project root. You can pull in the example config from the omnisharp-roslyn repo. After restarting everything, you should start seeing code analysis and formatting suggestions along side your syntax errors and warnings.

Then if you also install the editorconfig.nvim plugin, it will make it so you don’t need filetype plugins or other custom Neovim configuration to change your indent type or size for .cs files (it will read those from your .editorconfig and do that for you).

Bonus v2.0! dotnet-script Support

If you haven’t installed dotnet-script yet, you’re missing out. It’s a dotnet CLI plugin that allows you to use C# as a scripting language. You install it like so:

dotnet tool install -g dotnet-script

Now you can create C# scripts with a .csx extension like this one:

1
2
#!/usr/bin/env dotnet-script
Console.WriteLine("Hello world.");

And execute it like this:

dotnet script main.csx

Or (provided you have the #! on line 1) even like this:

chmod 755 main.csx
./main.csx

It even supports loading external nuget packages (an exercise I’ll leave up to the reader). You’ll note, however, that typing :LspInfo while editing that file shows that the omnisharp LSP client is not attaching. To fix this we need to do two things.

1. Map the .csx extension to the cs file type

You can do this in your init.vim:

1
au BufRead,BufNewFile *.csx set filetype=cs

Or if you prefer to do it in one of your Lua config files, like plugins.lua:

1
vim.api.nvim_command('au BufRead,BufNewFile *.csx set filetype=cs')

2. Configure the omnisharp LSP root_dir

By default, the omnisharp LSP provider traverses up the file path from your current C# file looking for either a .sln or .csproj file in order to tell omnisharp where the root directory of the current project is. When you’re editing a .csx script, there is no .sln or .csproj file, so omnisharp doesn’t know where to look.

We can solve this by overriding the default root_dir configuration to simply return the current directory if the file being edited ends with .csx (or use the default behavior if not). In your plugins.lua file, add this inside the curly braces after require'lspconfig'.omnisharp.setup:

1
2
3
4
5
6
root_dir = function(file, _)
if file:sub(-#".csx") == ".csx" then
return util.path.dirname(file)
end
return util.root_pattern("*.sln")(file) or util.root_pattern("*.csproj")(file)
end,

You’ll also need to import util at the top of your config like this:

1
local util = require('lspconfig').util

I fixed a problem with the above function where it wasn’t searching for the .sln file on its own first, so multiple lsp clients could end up attaching if you were working with multiple projects in the same solution.

If you did these two steps correctly, the next time you open a .csx file, the LSP provider should (eventually) attach itself and you’ll get diagnostics and autocompletions in your C# script!

Short Permalink for Attribution: rdsm.ca/4fpea

Comments