The following describes how I have set up my nvim config to reliably work with language support in a large .NET 4 Framework codebase. It uses - Lazy.nvim as a package manager to install plugins. - csharp-ls as a langauge server Besides the main lspconfig this also uses csharpls-extended-lsp.nvim for decomp-goto-defintion. Note that csharp_ls currently only works with SDK style project files. There is also currently a bug that prevents csharp_ls from working if multiple target-frameworks are defined in a project file. Further down is also a workaround for that. Anyway here's the lazy-config for lsp-config. If you don't understand any of the following, I suggest you look at https://github.com/nvim-lua/kickstart.nvim which has extensive explanations for all of this. Reading through the help pages of lspconfig doesn't hurt either. ```lua require('lazy').setup({ -- part of the lazy.setup { -- LSP Configuration & Plugins 'neovim/nvim-lspconfig', dependencies = { -- Automatically install LSPs and related tools to stdpath for neovim 'williamboman/mason.nvim', 'williamboman/mason-lspconfig.nvim', 'WhoIsSethDaniel/mason-tool-installer.nvim', { 'j-hui/fidget.nvim', opts = {} }, { 'folke/neodev.nvim', opts = {} }, }, config = function() vim.api.nvim_create_autocmd('LspAttach', { group = vim.api.nvim_create_augroup('kickstart-lsp-attach', { clear = true }), callback = function(event) local map = function(keys, func, desc) vim.keymap.set('n', keys, func, { buffer = event.buf, desc = 'LSP: ' .. desc }) end local builtin = require 'telescope.builtin' local lsp_buf = vim.lsp.buf -- map these however you want -- these are how kickstart.nvim sets them up map('gd', builtin.lsp_definitions, '[G]oto [D]efinition') map('gr', builtin.lsp_references, '[G]oto [R]eferences') map('gi', builtin.lsp_implementations, '[G]oto [I]mplementation') map('D', builtin.lsp_type_definitions, 'Type [D]efinition') map('ds', builtin.lsp_document_symbols, '[D]ocument [S]ymbols') map('ws', builtin.lsp_dynamic_workspace_symbols, '[W]orkspace [S]ymbols') map('rn', lsp_buf.rename, '[R]e[n]ame') map('ca', lsp_buf.code_action, '[C]ode [A]ction') map('K', lsp_buf.hover, 'Hover Documentation') map('gD', lsp_buf.declaration, '[G]oto [D]eclaration') end, }) local capabilities = vim.lsp.protocol.make_client_capabilities() capabilities = vim.tbl_deep_extend('force', capabilities, require('cmp_nvim_lsp').default_capabilities()) -- the following allows me to reliably force csharp_ls to load a specific solution -- This is accomplished by speficiying a "SOLUTION" enviroment variable before starting nvim -- reading it as a path, and then passing the absolute path to csharp_ls as a startup argument -- Usage would something like this -- CMD: -- > set SOLUTION=./Soultions/Main.sln -- > nvim . -- PWSH: -- > $env:SOLUTION = "./Solutions/Main.sln" -- > nvim . -- -- -- and then in nvim :LspStart in a csharp file to start the language server -- -- it'll then prompt for the root_dir (see below) -- -- and then it loads local cmd = { 'csharp-ls' } local forceSolution = vim.env.SOLUTION if forceSolution then forceSolution = abs_norm_path(forceSolution) vim.notify('Solution: ' .. forceSolution) -- NOTE:that this will start the csharp-ls thats installed globally under your system, not the one that Mason installs -- You could replace this with the absolute path to csharp_ls that mason installs but this works too -- if you install it yourself with 'dotnet tool install --global csharp-ls' -- Note that you still need to have it installed with Mason so that lspconfig knows its availible cmd = { 'csharp-ls', '-s', forceSolution } end local lspconfig = require 'lspconfig' local servers = { csharp_ls = { -- autostart is disabled since I don't always want to load a huge solution when I just wanna look at a file -- Manually start with :LspStart autostart = false, cmd = cmd, --[[ another important thing to know is the concept of the root directory in lspconfig and how it affects its behavior By default lspconfig tries to guess the root directory of a repo when opening a source file if there already is a language server running with that root_dir and a matching language then it attaches the file to the existing language server instead of starting a new server for that file For csharp_ls the default config is to walk up the file tree until you find a solution (*.sln) and if it can't find a solution, then walk up the file until it find a project (*.csproj) and if it can't find that if falls back to the current working directory However sometimes solutions include projects and their source code that are not within the parent directory of the solution So lsp-config would guess a different root_dir and think the source file belongs to a different repo, which it has to start a second language server for To make sure that doesn't happen, my root_dir callback prompts the user for the root dir, and once set, always returns that one root_dir it presets the root_dir it finds by default This setup prevents loading up two different c# langauge servers within one nvim instance which to me isn't an issue. If I need to open another separate repo, I can start a second nvim instance altogether Note that the Solution file can't be prompted dynamically in this way, since it has to be hard-coded into the cmd that starts the language sever during the time that lspconfig is configured which is when nvim starts up --]] root_dir = function(startpath) if vim.g.root_dir ~= nil then return vim.g.root_dir end local util = lspconfig.util local resultPath = util.root_pattern("*.sln")(startpath) or util.root_pattern('*.csproj')(startpath) -- default csharp_ls root_dir func if resultPath and resultPath == vim.g.root_dir then return resultPath end if resultPath == nil then resultPath = vim.fn.getcwd() end resultPath = vim.fn.input { prompt = "Project Root > ", default = resultPath, cancelreturn = resultPath } vim.g.root_dir = resultPath return resultPath; end, }, } -- the rest if from the kickstart.nvim lsp setup require('mason').setup() -- You can add other tools here that you want Mason to install -- for you, so that they are available from within Neovim. local ensure_installed = vim.tbl_keys(servers or {}) vim.list_extend(ensure_installed, { 'stylua', -- Used to format lua code }) require('mason-tool-installer').setup { ensure_installed = {} } require('mason-lspconfig').setup { handlers = { function(server_name) local server = servers[server_name] or {} -- This handles overriding only values explicitly passed -- by the server configuration above. Useful when disabling -- certain features of an LSP (for example, turning off formatting for tsserver) server.capabilities = vim.tbl_deep_extend('force', {}, capabilities, server.capabilities or {}) require('lspconfig')[server_name].setup(server) end, }, } end, }, -- this enabled decomiple goto-definition on nuget packages and corelib types -- note that this won't work under .NET framework for stdlib types. -- It just finds reference assemblies for those that don't contain the actual code -- it should still work for nuget packages and manuall assembly references under .NET framework -- as long as the assemlby refernces in the project file have their paths specified -- note that you will need to set a separate keybind for the decomp-goto-defintion command 'Decodetalkers/csharpls-extended-lsp.nvim', -- ..........rest of plugins here ``` ### Manually attaching buffers to an existing language server If you already started your langauge server, and the file you have currently open wasn't attached to the server, (for example if it isn't under the root_dir), but you know it belongs to the same project, then you can force it to attach with the following command :lua vim.lsp.buf_attach_clien(0, 1) The first arg is the id of the buffer, with 0 always being the current buffer The second arg is the id of the language server, if you have started csharp_ls before any other language server then it should 1. But you can manually check which id it has with :LspInfo ### Working with multiple Target-Frameworks and csharp-ls I ran into that issue just recently as I have started migrating the code base I work on to .NET 8. My workaround for that was to dynamically set a single target framework accross projects ```csproj $(dynamic_tf) // rest of csproj ``` which is controlled via an external enviroment variable and a central `Directory.build.props` at the root of the repo. Which is imported into all projects automatically thanks to its naming scheme And it contains this: ```props net48 net8.0-windows // rest Directory.Build.props if any `