lua | ||
init.lua | ||
LICENSE | ||
README.md |
Neovim + C# in 2025: The Actually Improved Setup | roslyn.nvim + rzsl.nvim (from scratch)
Prerequisites (for VS Code / Rider / Visual Studio users)
Before you start, you should have:
1) Installed software (Arch Linux)
- Neovim ≥ 0.11.0
- .NET SDK (9.0+) →
pacman -S dotnet-sdk
- Git, GCC, ripgrep, fzf, unzip, curl, wget, npm →
pacman -S git gcc ripgrep fzf unzip curl wget npm
- NVChad (we install it in the tutorial)
- Optional: Docker (if you want to run the demo container)
2) Neovim basics (VS Code mapping)
- Modal editing: Normal (navigate/commands), Insert (typing), Visual (select).
- Enter insert:
i
• Back to normal:Esc
- Enter insert:
- Save/Quit:
:w
(save),:q
(quit),:wq
(save & quit) - Files & panes:
:e <file>
(open), splits/tabs (like VS Code editor groups/tabs) - Help:
:help
(built-in docs),:checkhealth
(diagnostics) - Leader key: usually
<leader>
=Space
in NVChad (used for custom mappings), NvChad default mappings
3) Plugin & tooling concepts you’ll see
- Treesitter: modern syntax highlighting/AST.
- Mason: installs external tools (LSPs, debuggers, formatters).
:Mason
,:MasonInstall <tool>
- Lazy (NVChad’s plugin manager):
:Lazy
to sync/update. - LSP (Language Server Protocol): Roslyn powers IntelliSense, go-to-def, rename, code actions, hints.
- DAP (Debug Adapter Protocol):
nvim-dap
for debugging (similar to VS Code’s debugger).
4) .NET CLI familiarity
- Project basics:
.sln
,.csproj
- Commands you’ll use:
dotnet new console -n MyConsole
dotnet new blazor -n MyBlazor
dotnet build
dotnet test
- Knowing where build outputs go (e.g.
bin/Debug/net9.0/
)
5) Razor/Blazor context (high level)
- Razor files (
.razor
,.cshtml
) and “code-behind” partial classes. - Expect rzls (Razor LSP) to enable navigation/diagnostics in Razor files.
6) Debugging expectations
- We’ll use netcoredbg via
nvim-dap
. - You should be comfortable selecting a .dll to launch when prompted.
7) (Nice to have)
- Basic Lua reading (you’ll copy small config blocks).
- Comfort with environment paths (e.g. Mason tools live under
:echo $MASON
).
Create a Docker Image for Testing
For the purpose of demonstration or to try out how everything works before you break your system, we can run all the upcoming commands inside a docker container. In this demo that container is based on the latest ubuntu image.
Create the Dockerfile
Create a Dockerfile
in your desired directory that looks like this:
# Use Arch Linux as the base image
FROM archlinux:latest
# Update system and install required packages
RUN pacman -Syu --noconfirm && \
pacman -S --noconfirm \
sudo \
curl \
git \
fzf \
ripgrep \
unzip \
wget \
npm \
dotnet-sdk \
gcc \
neovim \
&& pacman -Scc --noconfirm
# Create a new user 'ramboe' with sudo privileges
RUN useradd -m -s /bin/bash ramboe && \
echo "ramboe ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Switch to the new user
USER ramboe
WORKDIR /home/ramboe
# Create common folders
RUN mkdir Downloads Documents
# Default command
CMD ["/bin/bash"]
Create the Container from the Dockerfile
Within the same directory execute the following commands
docker build -t csharp-nvim-demo .
docker run -it --name cnd-container csharp-nvim-demo
docker exec -it cnd-container bash
Congratulations, you now have your arch docker environment set up and are ready go to the actually interesting part.
Install NvChad for neovim and C# dependencies
For the sake of convenience we use nvchad in this tutorial since it already comes with a lot of comfort pre-installed and uses common package managers like Mason and LazyVim
git clone https://github.com/NvChad/starter ~/.config/nvim && nvim
Configure NeoVim for C#
Set up treesitter for syntax highlighting
ADD the following lines to ~/.config/nvim/lua/plugins/init.lua
{
"nvim-treesitter/nvim-treesitter",
opts = {
ensure_installed = {
"hyprlang",
"vim",
"lua",
"vimdoc",
"html",
"css",
-- !
"c_sharp",
"razor"
},
},
},
Install the necessary executables via Mason.
ADD the following lines to ~/.config/nvim/lua/plugins/init.lua
{
"williamboman/mason.nvim",
opts = {
registries = {
"github:mason-org/mason-registry",
"github:Crashdummyy/mason-registry",
},
ensure_installed = {
"lua-language-server",
"xmlformatter",
"csharpier",
"prettier",
"stylua",
"bicep-lsp",
"html-lsp",
"css-lsp",
"eslint-lsp",
"typescript-language-server",
"json-lsp",
"rust-analyzer",
-- !
"roslyn",
"rzls",
-- "csharp-language-server",
-- "omnisharp",
},
},
},
Then, inside neovim execute :MasonInstallAll
Since the roslyn
executable comes from foreign registries it has to be installed explicitely with :MasonInstall roslyn
LSP for dotnet: seblyng/roslyn.nvim
ADD the following lines to ~/.config/nvim/lua/plugins/init.lua
{
"seblyng/roslyn.nvim",
---@module 'roslyn.config'
---@type RoslynNvimConfig
ft = { "cs", "razor" },
opts = {
-- your configuration comes here; leave empty for default settings
},
},
ADD the following lines to ~/.config/nvim/lua/configs/lspconfig.lua
vim.lsp.config("roslyn", {})
Test: Syntax Highlighting and roslyn.nvim LSP
Create Test Projects
cd ~/Documents && \
dotnet new console -n MyConsole && \
cd MyConsole && \
dotnet build
Then provoke syntax error to see if the LSP screams at you, navigate around with :lua vim.lsp.buf.definition()
LSP for razor (Blazor): tris203/rzls.nvim
Since the rzls
executable comes from foreign registries it has to be installed explicitely with :MasonInstall rzls
.
In the end your seblyng/roslyn.nvim
section inside ~/.config/nvim/lua/plugins/init.lua
should look like this:
{
"seblyng/roslyn.nvim",
---@module 'roslyn.config'
---@type RoslynNvimConfig
ft = { "cs", "razor" },
opts = {
-- your configuration comes here; leave empty for default settings
},
-- ADD THIS:
dependencies = {
{
-- By loading as a dependencies, we ensure that we are available to set
-- the handlers for Roslyn.
"tris203/rzls.nvim",
config = true,
},
},
lazy = false,
config = function()
-- Use one of the methods in the Integration section to compose the command.
local mason_registry = require("mason-registry")
local rzls_path = vim.fn.expand("$MASON/packages/rzls/libexec")
local cmd = {
"roslyn",
"--stdio",
"--logLevel=Information",
"--extensionLogDirectory=" .. vim.fs.dirname(vim.lsp.get_log_path()),
"--razorSourceGenerator=" .. vim.fs.joinpath(rzls_path, "Microsoft.CodeAnalysis.Razor.Compiler.dll"),
"--razorDesignTimePath=" .. vim.fs.joinpath(rzls_path, "Targets", "Microsoft.NET.Sdk.Razor.DesignTime.targets"),
"--extension",
vim.fs.joinpath(rzls_path, "RazorExtension", "Microsoft.VisualStudioCode.RazorExtension.dll"),
}
vim.lsp.config("roslyn", {
cmd = cmd,
handlers = require("rzls.roslyn_handlers"),
settings = {
["csharp|inlay_hints"] = {
csharp_enable_inlay_hints_for_implicit_object_creation = true,
csharp_enable_inlay_hints_for_implicit_variable_types = true,
csharp_enable_inlay_hints_for_lambda_parameter_types = true,
csharp_enable_inlay_hints_for_types = true,
dotnet_enable_inlay_hints_for_indexer_parameters = true,
dotnet_enable_inlay_hints_for_literal_parameters = true,
dotnet_enable_inlay_hints_for_object_creation_parameters = true,
dotnet_enable_inlay_hints_for_other_parameters = true,
dotnet_enable_inlay_hints_for_parameters = true,
dotnet_suppress_inlay_hints_for_parameters_that_differ_only_by_suffix = true,
dotnet_suppress_inlay_hints_for_parameters_that_match_argument_name = true,
dotnet_suppress_inlay_hints_for_parameters_that_match_method_intent = true,
},
["csharp|code_lens"] = {
dotnet_enable_references_code_lens = true,
},
},
})
vim.lsp.enable("roslyn")
end,
init = function()
-- We add the Razor file types before the plugin loads.
vim.filetype.add({
extension = {
razor = "razor",
cshtml = "razor",
},
})
end,
},
Test: rzls.nvim LSP
Create Test Projects
cd ~/Documents && \
dotnet new blazor -n MyBlazor
cd MyBlazor && \
dotnet build
Then provoke syntax error to see if the LSP screams at you, navigate around with :lua vim.lsp.buf.definition()
Setup NeoVim for Debugging and Unit Testing in C#
Put the debugger (dap) in place
:MasonInstall netcoredbg
create nvim-dap.lua
and nvim-dap-ui.lua
touch ~/.config/nvim/lua/configs/nvim-dap.lua && touch ~/.config/nvim/lua/configs/nvim-dap-ui.lua
PASTE these lines into ~/.config/nvim/lua/configs/nvim-dap.lua
This will configure the debugger and add the keybindings that we need while we debug our code
local dap = require("dap")
local mason_path = vim.fn.stdpath("data") .. "/mason/packages/netcoredbg/netcoredbg"
local netcoredbg_adapter = {
type = "executable",
command = mason_path,
args = { "--interpreter=vscode" },
}
dap.adapters.netcoredbg = netcoredbg_adapter -- needed for normal debugging
dap.adapters.coreclr = netcoredbg_adapter -- needed for unit test debugging
dap.configurations.cs = {
{
type = "coreclr",
name = "launch - netcoredbg",
request = "launch",
program = function()
-- return vim.fn.input("Path to dll: ", vim.fn.getcwd() .. "/src/", "file")
return vim.fn.input("Path to dll: ", vim.fn.getcwd() .. "/bin/Debug/net9.0/", "file")
end,
-- justMyCode = false,
-- stopAtEntry = false,
-- -- program = function()
-- -- -- todo: request input from ui
-- -- return "/path/to/your.dll"
-- -- end,
-- env = {
-- ASPNETCORE_ENVIRONMENT = function()
-- -- todo: request input from ui
-- return "Development"
-- end,
-- ASPNETCORE_URLS = function()
-- -- todo: request input from ui
-- return "http://localhost:5050"
-- end,
-- },
-- cwd = function()
-- -- todo: request input from ui
-- return vim.fn.getcwd()
-- end,
},
}
local map = vim.keymap.set
local opts = { noremap = true, silent = true }
map("n", "<F5>", "<Cmd>lua require'dap'.continue()<CR>", opts)
map("n", "<F6>", "<Cmd>lua require('neotest').run.run({strategy = 'dap'})<CR>", opts)
map("n", "<F9>", "<Cmd>lua require'dap'.toggle_breakpoint()<CR>", opts)
map("n", "<F10>", "<Cmd>lua require'dap'.step_over()<CR>", opts)
map("n", "<F11>", "<Cmd>lua require'dap'.step_into()<CR>", opts)
map("n", "<F8>", "<Cmd>lua require'dap'.step_out()<CR>", opts)
-- map("n", "<F12>", "<Cmd>lua require'dap'.step_out()<CR>", opts)
map("n", "<leader>dr", "<Cmd>lua require'dap'.repl.open()<CR>", opts)
map("n", "<leader>dl", "<Cmd>lua require'dap'.run_last()<CR>", opts)
map("n", "<leader>dt", "<Cmd>lua require('neotest').run.run({strategy = 'dap'})<CR>",
{ noremap = true, silent = true, desc = 'debug nearest test' })
PASTE these lines into ~/.config/nvim/lua/configs/nvim-dap-ui.lua
This will configure the UI while debugging.
local dapui = require("dapui")
local dap = require("dap")
--- open ui immediately when debugging starts
dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end
dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end
dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end
-- default configuration
dapui.setup()
Configure lua files for debugging
ADD the following lines to ~/.config/nvim/lua/plugins/init.lua
{
-- Debug Framework
"mfussenegger/nvim-dap",
dependencies = {
"rcarriga/nvim-dap-ui",
},
config = function()
require "configs.nvim-dap"
end,
event = "VeryLazy",
},
{ "nvim-neotest/nvim-nio" },
{
-- UI for debugging
"rcarriga/nvim-dap-ui",
dependencies = {
"mfussenegger/nvim-dap",
},
config = function()
require "configs.nvim-dap-ui"
end,
},
{
"nvim-neotest/neotest",
requires = {
{
"Issafalcon/neotest-dotnet",
}
},
dependencies = {
"nvim-neotest/nvim-nio",
"nvim-lua/plenary.nvim",
"antoinemadec/FixCursorHold.nvim",
"nvim-treesitter/nvim-treesitter"
}
},
{
"Issafalcon/neotest-dotnet",
lazy = false,
dependencies = {
"nvim-neotest/neotest"
}
},
ADD the following lines to ~/.config/nvim/init.lua
require("neotest").setup({
adapters = {
require("neotest-dotnet")
}
})
Test: Debugging and Unit Testing
Debug the Program.cs
cd ~/Documents/MyConsole && \
nvim
Set breakpoint with F9
, start debugger with F5
Create a Test Project
cd ~/Documents && \
dotnet new nunit -n MyTest && \
cd MyTest && \
dotnet build
debug a unit test with <leader>dt
[OBSOLETE] Temporay workaround until nvim-neotest/neotest
is fixed
Test recognition has been fixed with the following commit:
2cf3544fb5
- you therefore can skip the "Temporary workaround" section from now
SET the commit property for a neotest version that recognizes untit tests in ~/.config/nvim/lua/plugins/init.lua
{
"nvim-neotest/neotest",
commit = "52fca6717ef972113ddd6ca223e30ad0abb2800c", -- THIS ONE
-- ...
},
:Lazy
> U
Improving the Debugging experience
https://github.com/ramboe/ramboe-dotnet-utils/blob/main/lua/dap-dll-autopicker/README.md
De-Briefing
latest dotnet version
- for arch see https://wiki.archlinux.org/title/.NET
- find latest version number here https://dotnet.microsoft.com/en-us/download/dotnet/9.0