No description
Find a file
2025-06-27 15:08:43 +00:00
README.md Update README.md 2025-06-27 15:08:43 +00:00

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 Ubuntu as the base image
FROM ubuntu:latest

# Set environment variables to avoid interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive

# Install required packages
RUN apt update && apt install -y \
    sudo \
    curl \
    git \
    ripgrep \
    unzip \
    wget \
    dotnet-sdk-8.0 \
    && rm -rf /var/lib/apt/lists/*

# Create a new user '<yourname>' and add to sudo group (ramboe in my case)
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

RUN mkdir Downloads && mkdir Documents

# Default command to keep the container running
CMD ["/bin/bash"]

Create the Container from the Dockerfile

Within the same directory execute the following commands

  1. docker build -t ubuntu-ramboe .

  2. docker run -it --name my_ubuntu_container ubuntu-ramboe

Congratulations, you now have your ubuntu docker environment set up and are ready go to the actually interesting part.

Install NeoVim and nodejs

While in fedora you would simply do sudo dnf install nodejs neovim, Debian based systems require some extra hoops that we are going to address now.

[Ubuntu] Install homebrew for neovim installation

The apt package has of old version 0.9.x, other distros like fedora or arch have the latest version in their repositories (we want 0.10.x)

Therefore we use homebrew to install neovim since it provides us with the latest version

This is how you install homebrew and then neovim through the terminal

  1. /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

  2. follow instructions on the screen

  3. apt install build-essential

  4. install neovim with homebrew: brew install neovim

[Ubuntu] install node (if necessary)

In order for mason to work properly, node must be installed on the system. Otherwise mason can't install packages.

While in fedora you would do something like sudo dnf install nodejs the installation in Debian based system looks like this:

curl -fsSL https://deb.nodesource.com/setup_23.x \
     -o nodesource_setup.sh && \
sudo -E bash nodesource_setup.sh && \
sudo apt-get install -y nodejs 

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#

Install the necessary plugins via Mason.

add the following lines to plugins/init.lua

-- ~/.config/nvim/lua/plugins/init.lua
-- ...

{
    "williamboman/mason.nvim",
    opts = {
      ensure_installed = {
        "lua-language-server",
        "csharp-language-server",
      "omnisharp",
        "xmlformatter",
        "stylua",
        "bicep-lsp",
        "html-lsp",
        "css-lsp",
        "csharpier",
        "prettier",
        "json-lsp"
      },
    },
  },

  {
    "nvim-treesitter/nvim-treesitter",
    opts = {
      -- ensure_installed = "all"
      ensure_installed = {
        "vim",
        "lua",
        "vimdoc",
        "html",
        "css",
        "c_sharp",
        "bicep"
      },
    },
  },

Then, inside neovim execute :MasonInstallAll

Initiate 'omnisharp', the C# language server

add the following lines to lspconfig.lua

-- ~/.config/nvim/lua/configs/lspconfig.lua
-- ...

-- omnisharp languageserver
local pid = vim.fn.getpid()

lspconfig.omnisharp.setup({
  cmd = { "omnisharp", "--languageserver", "--hostPID", tostring(pid) },
  on_attach = nvlsp.on_attach,
  on_init = nvlsp.on_init,
  capabilities = nvlsp.capabilities,
})

Omnisharp is not the only language server for C#, there is also the csharp-language-server - both have different capabilities and caveats, you might need to try out what's best for you

Setup NeoVim for Debugging and Unit Testing in C#

Configure lua files for debugging

back inside plugins/init.lua add the following lines:

-- ~/.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 init.lua

-- ~/.config/nvim/lua/init.lua
-- ...

require("neotest").setup({
  adapters = {
    require("neotest-dotnet")
  }
})

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

Put the debugger (dap) in place

we need to make sure the debugger is installed as well

the old way (obsolete)

mkdir -p ~/Documents/debuggers && wget -O ~/Downloads/netcoredbg-linux-amd64.tar.gz https://github.com/Samsung/netcoredbg/releases/download/3.1.2-1054/netcoredbg-linux-amd64.tar.gz && tar -xzf ~/Downloads/netcoredbg-linux-amd64.tar.gz -C ~/Documents/debuggers

This will put an executable dotnet debugger into the ~/Documents/debuggers directory.

If you are not happy with that path, simply put it somewhere else, but make sure to update the file path in nvim-dap.lua.

Also be aware that this will download explicitely version 3.1.2-1054 of the netcoredbg-linux-amd64 debugger. By the time you read this, this version might be outdated already.

paste these lines into nvim-dap.lua, this will configure the debugger and add the keybindings that we need while we debug our code

-- ~/.config/nvim/lua/configs/nvim-dap.lua

local dap = require("dap")

dap.adapters.coreclr = {
  type = "executable",
  command = "/home/ramboe/Documents/debuggers/netcoredbg/netcoredbg",
  args = { "--interpreter=vscode" },
}

dap.adapters.netcoredbg = {
  type = "executable",
  command = "/home/ramboe/Documents/debuggers/netcoredbg/netcoredbg",
  args = { "--interpreter=vscode" },
}

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/net8.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' })

the better way

I refactored the old way's code and added DLL auto-detection, you can find the whole update here.

Set up the debugging UI

paste these lines into nvim-dap-ui.lua, This will configure the UI while debugging.

-- ~/.config/nvim/lua/configs/nvim-dap-ui.lua

local dap, dapui = require("dap"), require("dapui")

dapui.setup({
  icons = { expanded = "▾", collapsed = "▸", current_frame = "▸" },
  mappings = {
    -- Use a table to apply multiple mappings
    expand = { "<CR>", "<2-LeftMouse>" },
    open = "o",
    remove = "d",
    edit = "e",
    repl = "r",
    toggle = "t",
  },
  -- Use this to override mappings for specific elements
  element_mappings = {
    -- Example:
    -- stacks = {
    --   open = "<CR>",
    --   expand = "o",
    -- }
  },
  -- Expand lines larger than the window
  expand_lines = vim.fn.has("nvim-0.7") == 1,
  -- Layouts define sections of the screen to place windows.
  -- The position can be "left", "right", "top" or "bottom".
  -- The size specifies the height/width depending on position. It can be an Int
  -- or a Float. Integer specifies height/width directly (i.e. 20 lines/columns) while
  -- Float value specifies percentage (i.e. 0.3 - 30% of available lines/columns)
  -- Elements are the elements shown in the layout (in order).
  -- Layouts are opened in order so that earlier layouts take priority in window sizing.
  layouts = {
    {
      elements = {
        -- Elements can be strings or table with id and size keys.
        { id = "scopes", size = 0.25 },
        "breakpoints",
        "stacks",
        "watches",
      },
      size = 40, -- 40 columns
      position = "left",
    },
    {
      elements = {
        "repl",
        "console",
      },
      size = 0.25, -- 25% of total lines
      position = "bottom",
    },
  },
  controls = {
    -- Requires Neovim nightly (or 0.8 when released)
    enabled = true,
    -- Display controls in this element
    element = "repl",
    icons = {
      pause = "",
      play = "",
      step_into = "",
      step_over = "",
      step_out = "",
      step_back = "",
      run_last = "↻",
      terminate = "□",
    },
  },
  floating = {
    max_height = nil,  -- These can be integers or a float between 0 and 1.
    max_width = nil,   -- Floats will be treated as percentage of your screen.
    border = "single", -- Border style. Can be "single", "double" or "rounded"
    mappings = {
      close = { "q", "<Esc>" },
    },
  },
  windows = { indent = 1 },
  render = {
    max_type_length = nil, -- Can be integer or nil.
    max_value_lines = 100, -- Can be integer or nil.
  },
})

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

vim.api.nvim_set_hl(0, "blue", { fg = "#3d59a1" })
vim.api.nvim_set_hl(0, "green", { fg = "#9ece6a" })
vim.api.nvim_set_hl(0, "yellow", { fg = "#FFFF00" })
vim.api.nvim_set_hl(0, "orange", { fg = "#f09000" })

vim.fn.sign_define('DapBreakpoint',
  {

    text = '', -- nerdfonts icon here
    -- text = '🔴', -- nerdfonts icon here
    texthl = 'DapBreakpointSymbol',
    linehl = 'DapBreakpoint',
    numhl = 'DapBreakpoint'
  })

vim.fn.sign_define('DapStopped',
  {
    text = '', -- nerdfonts icon here
    texthl = 'yellow',
    linehl = 'DapBreakpoint',
    numhl = 'DapBreakpoint'
  })
vim.fn.sign_define('DapBreakpointRejected',
  {
    text = '', -- nerdfonts icon here
    texthl = 'DapStoppedSymbol',
    linehl = 'DapBreakpoint',
    numhl = 'DapBreakpoint'
  })

Test the whole Setup

Test 1) debug a console application

dotnet new console -n 'MyConsole'

set a breakpoint with F9, then start debugging with F5

Test 2) debug a unit test

dotnet new mstest -n 'MyTest'

set a breakpoint with F9, then start debugging with dt

Caveats

This setup covers 95% of what you are ever going to do with C# in neovim. However, there are two things that I could not come up with a solution yet.

Razor LSP Support - .cshtml and .razor files and anything that comes along with it are not being supported (natively) at this very moment. tris203/rzls aims to solve this problem but this is still under construction and I did not manage to get it to work yet. Would be nice if there was something 'official' that works as reliable and natural as the typescript-language-server for example.

Azure function Debugging - although KaiWalter/azure-functions addresses this use case, I did not manage to get it to work yet. I have to say that I did not find this to be a big problem although working with azure functions every day. They should be designed in a unit-testable way anyways so you should not even have to run the function app itself in order to test if your code works

Further Reading

https://git.ramboe.io/configuration/nvchad#1-prerequisites

aaronbos.dev

https://aaronbos.dev/posts/debugging-csharp-neovim-nvim-dap

https://aaronbos.dev/posts/dotnet-roslyn-editorconfig-neovim