From 61ad1dd228c0411c8891817a560e9c10d837aa92 Mon Sep 17 00:00:00 2001 From: SimonKimDev Date: Sat, 17 Oct 2026 18:32:14 -0700 Subject: [PATCH] Switch C# to Roslyn and add build-then-debug - roslyn.nvim runs Roslyn, the server VS Code's C# extension uses, instead of csharp-ls. It's installed through Mason from Microsoft's nuget.org package, pinned because Mason's registry default is older than roslyn.nvim supports. Inlay hint settings keep ih working. - mason-lspconfig no longer installs csharp_ls and excludes roslyn_ls and csharp_ls from automatic_enable, so only one C# client attaches (an existing csharp-ls install would otherwise still start). - simonkdev/dotnet.lua replaces mason-nvim-dap's "pick any DLL under bin/Debug" config with "Build and debug a project": it lists the solution's runnable projects (last pick first), builds the chosen one for its newest .NET target (netcoredbg can't debug .NET Framework), sends build errors to quickfix, and launches the TargetPath MSBuild reports, with the project directory as cwd like `dotnet run`. It runs as the coreclr adapter's enrich_config, so neotest's test debugging is unaffected. - Smoke test checks the roslyn client is enabled and roslyn_ls and csharp_ls are excluded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 90315f97-7c82-4bc1-9bf1-2f8460782554 --- .github/copilot-instructions.md | 2 +- README.md | 1 + lazy-lock.json | 1 + lua/simonkdev/dotnet.lua | 199 ++++++++++++++++++ lua/simonkdev/lazy/csharp.lua | 8 + .../lazy/debugger/mason-nvim-dap.lua | 7 + lua/simonkdev/lazy/formatting.lua | 4 +- lua/simonkdev/lazy/lsp/lspconfig.lua | 14 +- lua/simonkdev/lazy/lsp/mason.lua | 9 +- tests/smoke.lua | 11 +- 10 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 lua/simonkdev/dotnet.lua create mode 100644 lua/simonkdev/lazy/csharp.lua diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 76268e2..88d31a1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,7 +6,7 @@ Personal Neovim config (Lua, lazy.nvim) for Neovim 0.12+, used on Windows with p - `init.lua` loads `lua/simonkdev/`: `options.lua`, `keymaps.lua`, `autocmds.lua`, then `lazy_init.lua`, which bootstraps lazy.nvim. - Plugin specs live in `lua/simonkdev/lazy/`, with `lsp/` and `debugger/` subfolders. Each file returns a lazy.nvim spec. -- Language servers are installed and enabled by mason-lspconfig (`lazy/lsp/mason.lua`) and configured with `vim.lsp.config` (`lazy/lsp/lspconfig.lua`). The exception is Rust: rustaceanvim (`lazy/rust.lua`) runs the toolchain's rust-analyzer, so never add `rust_analyzer` to mason-lspconfig. +- Language servers are installed and enabled by mason-lspconfig (`lazy/lsp/mason.lua`) and configured with `vim.lsp.config` (`lazy/lsp/lspconfig.lua`). The exception is Rust: rustaceanvim (`lazy/rust.lua`) runs the toolchain's rust-analyzer, so never add `rust_analyzer` to mason-lspconfig. C# is similar: roslyn.nvim (`lazy/csharp.lua`) runs Roslyn as the `roslyn` client, so `roslyn_ls` and `csharp_ls` stay excluded from mason-lspconfig. C# build-then-debug lives in `lua/simonkdev/dotnet.lua`. - Per-filetype settings go in `after/ftplugin/.lua`. - `lazy-lock.json` pins every plugin's commit. `tests/smoke.lua` is the headless startup test. - Treesitter uses nvim-treesitter's `main` branch: it only installs parsers (`lazy/treesitter.lua`), and Neovim's built-in treesitter does highlighting, started per buffer from a FileType autocmd. There is no `nvim-treesitter.configs`. diff --git a/README.md b/README.md index 96b9e7b..6ec9635 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Other than small retouch the setup is virtually identitcal to Josean's setup, ch - Neovim 0.12 or later. - A C compiler on `PATH` (e.g. gcc from MinGW) to build treesitter parsers and telescope-fzf-native. nvim-treesitter also needs the `tree-sitter` CLI, which Mason installs; on a fresh machine parsers install once Mason has it. - For Rust, rust-analyzer from your toolchain: `rustup component add rust-analyzer`. rustaceanvim starts it (and Mason's codelldb for debugging); don't install rust-analyzer through Mason. +- For C#, the .NET SDK. Mason installs Roslyn (the language server VS Code's C# extension uses) and netcoredbg. `dc` in a C# file offers *Build and debug a project*: pick a runnable project from the solution, and it builds it (build errors go to quickfix) and starts the debugger on its output. ## Updates diff --git a/lazy-lock.json b/lazy-lock.json index 4789b09..5932098 100644 --- a/lazy-lock.json +++ b/lazy-lock.json @@ -46,6 +46,7 @@ "nvim-ts-context-commentstring": { "branch": "main", "commit": "6141a40173c6efa98242dc951ed4b6f892c97027" }, "nvim-web-devicons": { "branch": "master", "commit": "ec309a0eaff6ea967e58c275996155ab5c16938d" }, "plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" }, + "roslyn.nvim": { "branch": "main", "commit": "bf1bd4e70bc7068d1b4aebf4ce03cc19a86615be" }, "rustaceanvim": { "branch": "main", "commit": "9cb125de42c0256ed1bc1f9ded8d8f0381317a17" }, "substitute.nvim": { "branch": "main", "commit": "0d2077398552f069abccbd964a26dd7cd26882cd" }, "telescope-fzf-native.nvim": { "branch": "main", "commit": "b25b749b9db64d375d782094e2b9dce53ad53a40" }, diff --git a/lua/simonkdev/dotnet.lua b/lua/simonkdev/dotnet.lua new file mode 100644 index 0000000..4f6232f --- /dev/null +++ b/lua/simonkdev/dotnet.lua @@ -0,0 +1,199 @@ +-- Build-then-debug for .NET with netcoredbg (mason-nvim-dap's "coreclr" adapter, lazy/debugger/mason-nvim-dap.lua). +-- The "Build and debug a project" configuration lists the solution's runnable projects, builds the one you pick, +-- and launches the DLL MSBuild reports for it, so there's no hunting through bin\Debug. +local M = {} + +local last_project ---@type string? + +-- Same as :compiler dotnet, so build errors land in the quickfix list like b. +local errorformat = "%E%f(%l\\,%c): %trror %m,%W%f(%l\\,%c): %tarning %m,%-G%.%#" + +---Runs a command without blocking the UI. Call from inside a coroutine. +---@return vim.SystemCompleted +local function run(cmd) + local co = coroutine.running() + vim.system(cmd, { text = true }, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() +end + +---@return string? +local function pick(items, prompt, format_item) + local co = coroutine.running() + vim.ui.select(items, { prompt = prompt, format_item = format_item }, function(choice) + vim.schedule(function() + coroutine.resume(co, choice) + end) + end) + return coroutine.yield() +end + +---The solution or project roslyn.nvim is using, else the nearest solution or project above the buffer. +---@return string? +local function target() + local selected = vim.g.roslyn_nvim_selected_solution + if selected and vim.uv.fs_stat(selected) then + return selected + end + return vim.fs.find(function(name) + return name:match("%.slnx?$") ~= nil or name:match("%.csproj$") ~= nil + end, { upward = true, path = vim.fs.dirname(vim.api.nvim_buf_get_name(0)) })[1] +end + +---@return string[] +local function projects(sln_or_proj) + if sln_or_proj:match("%.csproj$") then + return { sln_or_proj } + end + local result = run({ "dotnet", "sln", sln_or_proj, "list" }) + local found = {} + for line in result.stdout:gmatch("[^\r\n]+") do + line = vim.trim(line) + if line:match("%.csproj$") then + table.insert(found, vim.fs.normalize(vim.fs.joinpath(vim.fs.dirname(sln_or_proj), line))) + end + end + return found +end + +-- A quick read of the project file. OutputType set in Directory.Build.props is missed, hence the fallback below. +local function runnable(project) + local ok, lines = pcall(vim.fn.readfile, project) + local text = ok and table.concat(lines, "\n") or "" + local output_type = (text:match("%s*(%a+)%s*") or ""):lower() + return output_type == "exe" + or output_type == "winexe" + or text:match('Sdk="Microsoft%.NET%.Sdk%.Web"') ~= nil + or text:match('Sdk="Microsoft%.NET%.Sdk%.Worker"') ~= nil +end + +---@return table? +local function properties(project, framework) + local cmd = { "dotnet", "msbuild", project, "-nologo", "-p:Configuration=Debug" } + vim.list_extend(cmd, { "-getProperty:TargetPath", "-getProperty:TargetFrameworks" }) + if framework then + table.insert(cmd, "-p:TargetFramework=" .. framework) + end + local result = run(cmd) + local ok, decoded = pcall(vim.json.decode, result.stdout) + if result.code ~= 0 or not ok then + vim.notify("dotnet msbuild failed:\n" .. result.stdout .. result.stderr, vim.log.levels.ERROR) + return nil + end + return decoded.Properties +end + +---netcoredbg only debugs .NET (Core), so skip .NET Framework targets like net472 and take the newest one. +---@return string? +local function pick_framework(frameworks) + local best, best_version + for framework in frameworks:gmatch("[^;%s]+") do + local major, minor = framework:match("^net(%d+)%.(%d+)") + if not major then + major, minor = framework:match("^netcoreapp(%d+)%.(%d+)") + end + local version = major and tonumber(major) * 100 + tonumber(minor) + if version and (not best_version or version > best_version) then + best, best_version = framework, version + end + end + return best +end + +---@return {program: string, cwd: string}? +local function pick_and_build() + local sln_or_proj = target() + if not sln_or_proj then + vim.notify("No .sln, .slnx or .csproj found above this file", vim.log.levels.ERROR) + return nil + end + + local all = projects(sln_or_proj) + local candidates = vim.tbl_filter(runnable, all) + if #candidates == 0 then + candidates = all + end + if last_project and vim.list_contains(candidates, last_project) then + candidates = vim.list_extend( + { last_project }, + vim.tbl_filter(function(p) + return p ~= last_project + end, candidates) + ) + end + + local project = #candidates == 1 and candidates[1] + or pick(candidates, "Debug which project?", function(p) + return vim.fn.fnamemodify(p, ":t:r") + end) + if not project then + return nil + end + last_project = project + local name = vim.fn.fnamemodify(project, ":t:r") + + local props = properties(project) + if not props then + return nil + end + local framework + if props.TargetPath == "" then + framework = pick_framework(props.TargetFrameworks) + if not framework then + vim.notify(name .. " has no .NET (Core) target framework for netcoredbg", vim.log.levels.ERROR) + return nil + end + props = properties(project, framework) + if not props then + return nil + end + end + + vim.notify(("Building %s%s..."):format(name, framework and (" (" .. framework .. ")") or "")) + local build = { "dotnet", "build", project, "-nologo", "-c", "Debug", "-consoleloggerparameters:NoSummary" } + if framework then + vim.list_extend(build, { "-f", framework }) + end + local result = run(build) + if result.code ~= 0 then + vim.fn.setqflist({}, " ", { + title = "dotnet build " .. name, + lines = vim.split(result.stdout .. result.stderr, "\r?\n"), + efm = errorformat, + }) + vim.cmd.copen() + vim.notify("Build failed: " .. name, vim.log.levels.ERROR) + return nil + end + + -- `dotnet run` starts in the project directory, so appsettings.json and relative paths resolve the same way. + return { program = props.TargetPath, cwd = vim.fs.dirname(project) } +end + +M.configuration = { + type = "coreclr", + name = "Build and debug a project", + request = "launch", + build_and_pick = true, -- handled by enrich_config below +} + +---Adapter hook: nvim-dap calls it with the resolved configuration before launching. +---Other coreclr configurations (e.g. neotest's test debugging) pass through untouched. +function M.enrich_config(config, on_config) + if not config.build_and_pick then + return on_config(config) + end + coroutine.wrap(function() + local launch = pick_and_build() + if launch then + local resolved = vim.tbl_extend("force", config, launch) + resolved.build_and_pick = nil + on_config(resolved) + end + end)() +end + +return M diff --git a/lua/simonkdev/lazy/csharp.lua b/lua/simonkdev/lazy/csharp.lua new file mode 100644 index 0000000..dd9a33d --- /dev/null +++ b/lua/simonkdev/lazy/csharp.lua @@ -0,0 +1,8 @@ +-- C#: roslyn.nvim runs Roslyn, the language server VS Code's C# extension uses, from Mason (lsp/mason.lua). +-- It picks the solution (.sln/.slnx) above the file; `:Roslyn target` switches between solutions. +-- Server settings live in lsp/lspconfig.lua; debugging is in simonkdev/dotnet.lua. +return { + "seblyng/roslyn.nvim", + lazy = false, -- only registers the "roslyn" LSP config; the server starts on the first C# buffer + opts = {}, +} diff --git a/lua/simonkdev/lazy/debugger/mason-nvim-dap.lua b/lua/simonkdev/lazy/debugger/mason-nvim-dap.lua index c01674b..1b9b387 100644 --- a/lua/simonkdev/lazy/debugger/mason-nvim-dap.lua +++ b/lua/simonkdev/lazy/debugger/mason-nvim-dap.lua @@ -10,6 +10,13 @@ return { "codelldb", -- Rust, through rustaceanvim }, handlers = { + -- Replace mason-nvim-dap's "pick any DLL" C# configuration with build-then-debug (simonkdev/dotnet.lua). + coreclr = function(config) + local dotnet = require("simonkdev.dotnet") + config.adapters = vim.tbl_extend("force", config.adapters, { enrich_config = dotnet.enrich_config }) + config.configurations = { dotnet.configuration } + require("mason-nvim-dap").default_setup(config) + end, -- rustaceanvim registers codelldb and Rust's debug configurations itself (lazy/rust.lua) codelldb = function() end, }, diff --git a/lua/simonkdev/lazy/formatting.lua b/lua/simonkdev/lazy/formatting.lua index 2520933..fc41876 100644 --- a/lua/simonkdev/lazy/formatting.lua +++ b/lua/simonkdev/lazy/formatting.lua @@ -19,14 +19,14 @@ return { }, opts = { - -- Filetypes without a formatter here use their LSP's formatter, e.g. csharp_ls for C#. + -- Filetypes without a formatter here use their LSP's formatter, e.g. Roslyn for C#. -- Set as a default so filetype entries can override it; options passed to format() would win. default_format_opts = { lsp_format = "fallback", }, formatters_by_ft = { - -- csharp_ls doesn't strip trailing whitespace, so trim after it formats. + -- Roslyn formats C# but can leave trailing whitespace, so trim after it. cs = { "trim_whitespace", "trim_newlines", lsp_format = "first" }, -- Every filetype not listed here. _ = { "trim_whitespace", "trim_newlines", lsp_format = "first" }, diff --git a/lua/simonkdev/lazy/lsp/lspconfig.lua b/lua/simonkdev/lazy/lsp/lspconfig.lua index f48dfd0..df5c6d5 100644 --- a/lua/simonkdev/lazy/lsp/lspconfig.lua +++ b/lua/simonkdev/lazy/lsp/lspconfig.lua @@ -70,8 +70,18 @@ return { }, }) - vim.lsp.config("csharp_ls", { - capabilities = { offsetEncoding = { "utf-16" } }, + -- C# (started by roslyn.nvim, lazy/csharp.lua) + vim.lsp.config("roslyn", { + 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_parameters = true, + dotnet_suppress_inlay_hints_for_parameters_that_match_argument_name = true, + }, + }, }) -- Inlay hints stay hidden until toggled with ih; these settings make the servers send them. diff --git a/lua/simonkdev/lazy/lsp/mason.lua b/lua/simonkdev/lazy/lsp/mason.lua index 0c2dd91..6824ccd 100644 --- a/lua/simonkdev/lazy/lsp/mason.lua +++ b/lua/simonkdev/lazy/lsp/mason.lua @@ -25,13 +25,17 @@ return { "html", "lua_ls", "cssls", - "csharp_ls", "gopls", "yamlls", "jsonls", "ts_ls", "tailwindcss", }, + -- roslyn.nvim runs C#'s server as "roslyn" (lazy/csharp.lua). Enabling lspconfig's roslyn_ls for + -- the same Mason package, or a leftover csharp-ls install, would attach a second C# client. + automatic_enable = { + exclude = { "roslyn_ls", "csharp_ls" }, + }, }) mason_tool_installer.setup({ -- debug adapters (delve, netcoredbg, ...) are installed by mason-nvim-dap; listing them here @@ -42,6 +46,9 @@ return { "eslint_d", "goimports", "tree-sitter-cli", -- nvim-treesitter compiles parsers with it (treesitter.lua) + -- Microsoft's package from nuget.org, pinned because Mason's registry lags behind and its default is + -- older than roslyn.nvim supports. The plugin update workflow doesn't bump this; do it by hand. + { "roslyn-language-server", version = "5.13.0-1.26514.10" }, }, }) end, diff --git a/tests/smoke.lua b/tests/smoke.lua index b9ff6b9..520cdce 100644 --- a/tests/smoke.lua +++ b/tests/smoke.lua @@ -163,11 +163,18 @@ local function run() -- A second mason-lspconfig setup() call replaces this list, so check it survived startup. local servers = require("mason-lspconfig.settings").current.ensure_installed - for _, name in ipairs({ "gopls", "ts_ls", "lua_ls", "csharp_ls" }) do + for _, name in ipairs({ "gopls", "ts_ls", "lua_ls" }) do check(vim.tbl_contains(servers, name), name .. " is missing from mason-lspconfig ensure_installed") end -- rustaceanvim runs rust-analyzer; a second client from lspconfig would duplicate every result. check(not vim.lsp.is_enabled("rust_analyzer"), "rust_analyzer is enabled alongside rustaceanvim") + -- Same for C#: roslyn.nvim's "roslyn" client only. lspconfig's roslyn_ls would start for the same + -- Mason package once it's installed, so check it's excluded rather than whether it's enabled yet. + check(vim.lsp.is_enabled("roslyn"), "roslyn.nvim didn't enable the roslyn client") + local exclude = vim.tbl_get(require("mason-lspconfig.settings").current, "automatic_enable", "exclude") or {} + for _, name in ipairs({ "roslyn_ls", "csharp_ls" }) do + check(vim.list_contains(exclude, name), name .. " isn't excluded from mason-lspconfig automatic_enable") + end for _, name in ipairs(servers) do local config = vim.lsp.config[name] check(config and config.cmd ~= nil, "no LSP config named " .. name) @@ -196,7 +203,7 @@ local function run() vim.api.nvim_buf_delete(buf, { force = true }) end - -- Files without a formatter (and C#, after csharp_ls) are trimmed on save; patches are left alone. + -- Files without a formatter (and C#, after Roslyn) are trimmed on save; patches are left alone. check(vim.tbl_contains(conform.formatters_by_ft.cs, "trim_whitespace"), "cs buffers aren't trimmed") local input = { " a ", "", "" } for ft, want in pairs({ text = { " a" }, diff = input }) do