Neovim plugin for day-to-day .NET work: create C# types, manage solutions and project references, NuGet, Entity Framework, build/run/watch, and debug.
Requires Neovim 0.10+.
- Create C# types (class, interface, record, enum, controller, exception) with file-scoped namespace inferred from the
.csproj - LuaSnip snippets (
cls,iface,rec,enm,ctrl,exc) - Add/remove project references and solution entries (
.slnand.slnx) - NuGet: add, list (with outdated), remove, update
- Entity Framework: migrate, update, remove, list, SQL script
- Build into the quickfix list; run / watch / stop
- Debug launch via nvim-dap + netcoredbg
- Per-repo config (
.csnew.lua) so layered apps do not need a global setup
| Tool | Required for |
|---|---|
| Neovim ≥ 0.10 | everything (vim.system) |
.NET SDK (dotnet on PATH) |
everything except :CsNew / snippets |
dotnet-ef (dotnet tool install --global dotnet-ef) |
:EfMigrate, :EfUpdate, … |
Microsoft.EntityFrameworkCore.Design on the startup project |
EF design-time (DbContext creation) |
| LuaSnip | snippets (cls, iface, …) |
| nvim-dap + netcoredbg | :CsDebugRun |
| dressing.nvim or telescope-ui-select | nicer pickers (vim.ui.select) |
| nvim-tree.lua | optional: create a type on the node under the cursor |
| roslyn / OmniSharp LSP | post-change :LspRestart actually reloads C# |
Lua API stays require("csnew") (setup, create, dap). The GitHub repo is dotnetkit.nvim.
Core commands only. No snippets, no debug.
{
"Bottoniel/dotnetkit.nvim",
ft = "cs",
cmd = {
"CsNew",
"CsBuild",
"CsRun",
"CsWatch",
"CsStop",
"CsAddRef",
"CsRemoveRef",
"CsSlnAdd",
"CsSlnRemove",
"CsAddPkg",
"CsListPkg",
"CsRemovePkg",
"CsUpdatePkg",
"CsNewProject",
"EfMigrate",
"EfUpdate",
"EfRemove",
"EfList",
"EfScript",
},
opts = {},
}opts = {} calls require("csnew").setup({}). Without setup(), snippets are not registered.
Snippets + a keymap. Debug is a separate spec (see below).
{
"Bottoniel/dotnetkit.nvim",
ft = "cs",
dependencies = { "L3MON4D3/LuaSnip" },
cmd = {
"CsNew",
"CsBuild",
"CsRun",
"CsWatch",
"CsStop",
"CsAddRef",
"CsRemoveRef",
"CsSlnAdd",
"CsSlnRemove",
"CsAddPkg",
"CsListPkg",
"CsRemovePkg",
"CsUpdatePkg",
"CsNewProject",
"CsDebugRun",
"EfMigrate",
"EfUpdate",
"EfRemove",
"EfList",
"EfScript",
},
keys = {
{
"<leader>cn",
function()
require("csnew").create()
end,
desc = "New C# type",
},
},
opts = {},
}These are not part of this plugin. Copy them into your Neovim config (lazy spec files) if you want the same workflow: Telescope pickers, format on save, Mason tools, and debug UI.
| What | Where (your config) | Add |
|---|---|---|
| Pickers in Telescope | spec of telescope.nvim | telescope-ui-select |
Format on save for .cs |
spec of conform.nvim | cs = { "csharpier" } |
| Install formatter + debugger | spec of mason-tool-installer | csharpier, netcoredbg |
| Debug UI / keymaps | new spec, e.g. lua/plugins/dap.lua |
nvim-dap + nvim-dap-ui |
:CsNew on the tree node |
spec of nvim-tree | on_attach (see nvim-tree) |
Edit the lazy spec where you already configure Telescope (not this plugin). Three additions:
{
"nvim-telescope/telescope.nvim",
dependencies = {
-- ...your other deps
"nvim-telescope/telescope-ui-select.nvim",
},
config = function()
local telescope = require("telescope")
telescope.setup({
extensions = {
["ui-select"] = require("telescope.themes").get_dropdown({}),
},
-- ...your defaults
})
telescope.load_extension("ui-select")
end,
}After this, every vim.ui.select from the plugin (:CsAddRef, :EfMigrate, …) opens in Telescope.
This is not a C# project file. It goes in your conform.nvim spec (e.g. lua/plugins/formatting.lua or whatever file already has stevearc/conform.nvim).
If you already use conform, add one line to formatters_by_ft and keep format_on_save:
conform.setup({
formatters_by_ft = {
-- ...your other filetypes
cs = { "csharpier" },
},
format_on_save = {
lsp_fallback = true,
async = false,
timeout_ms = 3000,
},
})If you do not have conform yet:
{
"stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
require("conform").setup({
formatters_by_ft = { cs = { "csharpier" } },
format_on_save = { lsp_fallback = true, async = false, timeout_ms = 3000 },
})
end,
}Install the binary with Mason (csharpier below) or :MasonInstall csharpier.
Edit the spec where you call mason-tool-installer (often next to mason.nvim, e.g. lua/plugins/lsp/mason.lua):
require("mason-tool-installer").setup({
ensure_installed = {
-- ...your other tools
"csharpier",
"netcoredbg",
},
})Or install once by hand: :MasonInstall csharpier and :MasonInstall netcoredbg.
dotnetkit does not install nvim-dap. Add a new lazy spec (e.g. lua/plugins/dap.lua):
{
"mfussenegger/nvim-dap",
dependencies = {
"rcarriga/nvim-dap-ui",
"nvim-neotest/nvim-nio",
},
keys = {
{ "<leader>db", function() require("dap").toggle_breakpoint() end, desc = "Debug: toggle breakpoint" },
{ "<leader>dB", function() require("dap").set_breakpoint(vim.fn.input("Breakpoint condition: ")) end, desc = "Debug: conditional breakpoint" },
{ "<leader>dd", function() require("dap").clear_breakpoints() end, desc = "Debug: clear all breakpoints" },
{ "<leader>dl", function() require("dap").list_breakpoints() vim.cmd("copen") end, desc = "Debug: list breakpoints" },
{ "<leader>dc", function() require("dap").continue() end, desc = "Debug: continue" },
{ "<leader>do", function() require("dap").step_over() end, desc = "Debug: step over" },
{ "<leader>di", function() require("dap").step_into() end, desc = "Debug: step into" },
{ "<leader>dO", function() require("dap").step_out() end, desc = "Debug: step out" },
{ "<leader>dt", function() require("dap").terminate() end, desc = "Debug: terminate" },
{ "<leader>du", function() require("dapui").toggle() end, desc = "Debug: toggle UI" },
{ "<leader>dr", function() require("csnew.dap").debug_run() end, desc = "Debug: run current project" },
},
config = function()
local dap, dapui = require("dap"), require("dapui")
dapui.setup()
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
require("csnew.dap").setup()
end,
}Needs netcoredbg (Mason, above). Then :CsBuild → breakpoint → <leader>dr.
| Command | CLI / action |
|---|---|
:CsNew [kind] [name] |
Create a C# type in the current directory |
:CsNewProject [template] [name] [outdir] |
dotnet new (console, classlib, webapi, web, xunit, nunit, mstest); offers sln add |
:CsAddRef [target] [ref] |
dotnet add reference |
:CsRemoveRef [target] [ref] |
dotnet remove reference |
:CsSlnAdd [project] |
dotnet sln add |
:CsSlnRemove [project] |
dotnet sln remove |
:CsAddPkg [name] [version] |
dotnet add package |
:CsListPkg [project] |
dotnet list package --outdated (scratch buffer) |
:CsRemovePkg [name] |
dotnet remove package |
:CsUpdatePkg [name] [version] |
update one outdated package (or named) |
:CsBuild [target] |
dotnet build → quickfix (:cnext) |
:CsRun [project] |
dotnet run + launchSettings.json profile picker |
:CsWatch [project] |
dotnet watch run |
:CsStop |
stop the Run/Watch job |
:CsDebugRun [project] |
launch bin/Debug/*/<project>.dll with nvim-dap |
:EfMigrate [name] |
dotnet ef migrations add |
:EfUpdate [target] |
dotnet ef database update (empty = latest, 0 = revert all) |
:EfRemove |
dotnet ef migrations remove |
:EfList |
dotnet ef migrations list (scratch buffer) |
:EfScript |
dotnet ef migrations script --idempotent → Migrations.sql |
Arguments are optional. Missing pieces are asked with vim.ui.select / vim.ui.input.
After sln / reference / package changes the plugin runs dotnet clean + dotnet restore on the solution and :LspRestart (configurable).
Requires LuaSnip and setup() (opts = {} is enough).
In a .cs buffer, type the trigger and expand with your LuaSnip / nvim-cmp mapping:
| Trigger | Type | Naming |
|---|---|---|
cls |
class | as typed |
iface |
interface | I prefix if missing |
rec |
record | as typed |
enm |
enum | as typed |
ctrl |
controller | Controller suffix if missing |
exc |
exception | Exception suffix if missing |
Namespace is RootNamespace (or the .csproj name) plus folders relative to the project.
The plugin works without nvim-tree. :CsNew and <leader>cn use the current buffer directory (or cwd if there is no file).
To create the type on the tree node under the cursor, add this to nvim-tree on_attach:
local function on_attach(bufnr)
local api = require("nvim-tree.api")
api.config.mappings.default_on_attach(bufnr)
vim.keymap.set("n", "<leader>cn", function()
local node = api.tree.get_node_under_cursor()
if not node then
return
end
local dir = node.absolute_path
if node.type ~= "directory" then
dir = vim.fn.fnamemodify(dir, ":h")
end
require("csnew").create({ dir = dir })
end, { buffer = bufnr, noremap = true, silent = true, desc = "New C# type" })
endMark dotnetkit.nvim as a dependency of nvim-tree so the module is available when the tree loads.
You do not call setup() in a random file. With lazy.nvim it goes in the plugin spec as opts. Lazy runs require("csnew").setup(opts) for you.
Most people only need this (already in the recommended spec):
{
"Bottoniel/dotnetkit.nvim",
-- ...
opts = {},
}That enables snippets and default after_change. To override defaults, put the table inside opts:
{
"Bottoniel/dotnetkit.nvim",
-- ...
opts = {
dotnet = {
-- after sln / reference / package changes (runs on the .sln)
after_change = { "clean", "restore", "lsp_restart" },
},
ef = {
-- optional globals; better set per repo in .csnew.lua (below)
script_idempotent = true, -- false for SQLite
},
},
}Do not put ef.project / ef.startup_project here if you work on more than one solution — those paths belong in .csnew.lua at each repo root.
All ef.* keys are optional. With nothing set:
- one
.csprojin the repo → used as both--projectand--startup-project - several → two pickers (migrations project, then startup). The choice is remembered for the session (
Use last: X -> Y)
Empty tables count as lists, and lists replace the default instead of merging. after_change = {} therefore disables every post-change step — and by the same rule an empty dotnet = {} clears that whole section rather than merging with the defaults.
Put this file at the git root (same folder as .git / the .sln). Same schema as setup(). It overrides the global setup (deep merge: defaults < setup() < .csnew.lua).
The first time Neovim sees the file it will ask you to trust it (same prompt as exrc). Deny and pickers still work; the choice is remembered for the session while the file is unchanged. The file is evaluated in an empty environment: it may only return a table literal, it cannot call Neovim APIs.
-- .csnew.lua
return {
ef = {
project = "src/Control.Infrastructure",
startup_project = "src/Control.Web",
},
}If the file is missing, pickers still work. If it is broken, the plugin notifies and falls back to pickers.
New type — open a .cs (or a folder in nvim-tree) → :CsNew or <leader>cn → pick kind + name.
New project — :CsNewProject webapi MyApi → confirm sln add.
Build — :CsBuild (solution if unique, else picker) → errors in quickfix → :cnext.
Run an API — :CsRun → pick project / launch profile → split terminal. :CsStop to kill it. :CsWatch for dotnet watch.
EF — :EfMigrate AddUsers → :EfUpdate. Target empty = latest, 0 = revert all, or a migration name from :EfList.
Debug
:CsBuild(needsbin/Debug/<tfm>/<Project>.dll)<leader>dbon a line<leader>dror:CsDebugRun- dap-ui opens; step with
<leader>do/<leader>di <leader>dtto stop;<leader>ddclears all breakpoints;<leader>dllists them in the quickfix
NuGet — :CsAddPkg Newtonsoft.Json, :CsListPkg, :CsUpdatePkg, :CsRemovePkg.
| Symptom | Fix |
|---|---|
Unable to create a 'DbContext'… DbContextOptions |
Layered app: set ef.startup_project (or .csnew.lua) to the project that has Program.cs + AddDbContext. The Infrastructure project is not a host. |
Generating idempotent scripts… not supported for SQLite |
ef.script_idempotent = false |
dotnet-ef not available |
dotnet tool install --global dotnet-ef |
netcoredbg not found |
:MasonInstall netcoredbg (or put netcoredbg on PATH) |
no Debug dll found |
:CsBuild first |
Connection error to localhost:5432 / SQL Server |
Not the plugin — Postgres/SQL is down or the connection string is wrong |
| Snippets do nothing | LuaSnip in dependencies, and opts = {} so setup() runs |
| Pickers look ugly | dressing.nvim or telescope-ui-select |
MIT