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