Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<filetype>.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`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<leader>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

Expand Down
1 change: 1 addition & 0 deletions lazy-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
199 changes: 199 additions & 0 deletions lua/simonkdev/dotnet.lua
Original file line number Diff line number Diff line change
@@ -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 <leader>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("<OutputType>%s*(%a+)%s*</OutputType>") 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<string, string>?
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
8 changes: 8 additions & 0 deletions lua/simonkdev/lazy/csharp.lua
Original file line number Diff line number Diff line change
@@ -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 = {},
}
7 changes: 7 additions & 0 deletions lua/simonkdev/lazy/debugger/mason-nvim-dap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
4 changes: 2 additions & 2 deletions lua/simonkdev/lazy/formatting.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
14 changes: 12 additions & 2 deletions lua/simonkdev/lazy/lsp/lspconfig.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 <leader>ih; these settings make the servers send them.
Expand Down
9 changes: 8 additions & 1 deletion lua/simonkdev/lazy/lsp/mason.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions tests/smoke.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading