seblj / roslyn.nvim

Roslyn LSP plugin for neovim
MIT License
108 stars 10 forks source link

Workspace diagnostics #32

Closed SchnozzleCat closed 4 days ago

SchnozzleCat commented 1 month ago

Is there a way to get workspace diagnostics to work? Not having to repeatedly run dotnet build to find errors while refactoring would be nice.

SchnozzleCat commented 1 month ago

Looking into this a bit more, it seems the roslyn server already supports this, but setting the following:

["csharp|background_analysis"] = {
  dotnet_compiler_diagnostics_scope = "fullSolution"
}

Doesn't seem to change anything.

seblj commented 1 month ago

Hmm I actually have no idea... I have spent an hour or two now trying to look through the source code for neovim, roslyn and c# dev-kit, but I can't see anything that indicates that this shouldn't work... Let me know if you manage to find something

SchnozzleCat commented 1 month ago

Hmm I actually have no idea... I have spent an hour or two now trying to look through the source code for neovim, roslyn and c# dev-kit, but I can't see anything that indicates that this shouldn't work... Let me know if you manage to find something

Yeah it's strange. I even compiled the language server from source and set the default for that option to use FullSolution, and it still doesn't work.

I have no idea how the event handlers for the LSP client work, maybe it's missing one for Workspace Diagnostics or something?

seblj commented 1 month ago

Maybe🤔 but I couldn't find anything that would suggest this. It is responding to a textDocument/diagnostic, and I assumed that the server returns all diagnostics if the option is on. However, it clearly isn't... I have tried a couple of times to compile it myself to debug, but I havent't managed to do it. How did you do it? I think if we follow everything from when the server gets the request, we could figure out why. However, this is super difficult by just looking at the source code, and without actually debugging it

SchnozzleCat commented 1 month ago

Maybe🤔 but I couldn't find anything that would suggest this. It is responding to a textDocument/diagnostic, and I assumed that the server returns all diagnostics if the option is on. However, it clearly isn't... I have tried a couple of times to compile it myself to debug, but I havent't managed to do it. How did you do it? I think if we follow everything from when the server gets the request, we could figure out why. However, this is super difficult by just looking at the source code, and without actually debugging it

I pulled the roslyn main branch, installed dotnet9, and ran dotnet build ./src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Microsoft.CodeAnalysis.LanguageServer.csproj --configuration RELEASE.

I assume running it in DEBUG should let you attach a debugger like netcoredbg to it, but I haven't tested it yet.

seblj commented 1 month ago

Okay thanks! Will look at this more at a later time. Feel free the comment here about your findings if you keep debugging this!

SchnozzleCat commented 1 month ago

No specific findings, but if you compile with DEBUG you can attach, and if you run the server with the --debug flag, it will wait for a managed debugger to attach before continuing, which should make debugging easier.

I'll look into it a bit myself, but I don't really know how these language servers work, so unsure if I'll find anything useful in the time I have.

seblj commented 1 month ago

I am getting a bunch of these errors trying to compile

image
SchnozzleCat commented 1 month ago

That's odd. It looks like it's trying to pull in dependencies from nuget instead of locally, maybe that's why its failing since the code does not match? Not a nuget expert though...

Actually, I don't think that's it, it looks like C# core stuff. Maybe a version mismatch? Check what's used in global.json on the commit you are on and make sure that's what you are using as well.

seblj commented 1 month ago

Yeah I have no idea. I was trying lots of different things for over an hour now, but no luck... I came across an issue on the repo which looked similar to my issue. However, it was closed as completed and didn't find any workaround or anything

franroa commented 3 weeks ago

In the meanwhile I am using a parserr to get the errors in a quick fix list

GustavEikaas commented 2 weeks ago

I got it working! (kind of)

Confirmed using dll: Microsoft.CodeAnalysis.LanguageServer.dll version: 4.12.0-1.24359.11+7e282662d51afa4923cdd0696ce26f4081faf706

return {
  "seblj/roslyn.nvim",
  commit = "5e36cac9371d014c52c4c1068a438bdb7d1c7987",
  config = function()
    require("roslyn").setup({
      config = {
        settings = {
          ["csharp|background_analysis"] = {
            dotnet_compiler_diagnostics_scope = "fullSolution"
          },
          ["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_indexer_parameters = true,
            dotnet_enable_inlay_hints_for_literal_parameters = true,
            dotnet_enable_inlay_hints_for_object_creation_parameters = true,
            dotnet_enable_inlay_hints_for_other_parameters = true,
            dotnet_enable_inlay_hints_for_parameters = true,
            dotnet_suppress_inlay_hints_for_parameters_that_differ_only_by_suffix = true,
            dotnet_suppress_inlay_hints_for_parameters_that_match_argument_name = true,
            dotnet_suppress_inlay_hints_for_parameters_that_match_method_intent = true,
          },
          ["csharp|code_lens"] = {
            dotnet_enable_references_code_lens = true,
          },
        }
      },
      exe = {
        "dotnet",
        vim.fs.joinpath(vim.fn.stdpath("data"), "roslyn", "Microsoft.CodeAnalysis.LanguageServer.dll"),
      },
      filewatching = true,
    })

    vim.keymap.set("n", "<leader>p", function()
      local clients = vim.lsp.get_clients()
      for _, value in ipairs(clients) do
        if value.name == "roslyn" then
          vim.notify("roslyn client found")
          value.rpc.request("workspace/diagnostic", { previousResultIds = {} }, function(err, result)
            if err ~= nil then
              print(vim.inspect(err))
            end
            if result ~= nil then
              print(vim.inspect(result))
            end
          end)
        end
      end
    end, { noremap = true, silent = true })
  end
}
  1. Use the config above
  2. introduce a build error
  3. Open any file in the solution
  4. <leader>p
  5. :messages
  6. voila

Im not sure if vim has some sort of inbuilt lsp support for workspace diagnostics but this should prove that the server itself behaves correctly

GustavEikaas commented 2 weeks ago

@seblj Im working on another dotnet-plugin where I could make good use of the workspace/symbol the only downside is that I would require the language server to be running independently of any buffer. If you are interested it would be cool to make a PR that exposes a way to start the lsp server when entering vim if it finds a solution/csproject file.

The benefits of this would be:

  1. Project initialization would start when entering vim instead of when entering a file(percieved perf boost)
  2. Reduced lag when entering a .cs file because the server is already running
  3. Enabling the development of things like workspace diagnostic windows/lualine indicators.

This would of course be exposed as opt-in functionality

GustavEikaas commented 2 weeks ago

@SchnozzleCat @franroa Here is a complete qf list keybinding for compiler errors

Cant be called multiple times. Might be necessary to send some textDocument/didOpen, textDocument/didChange before asking for diagnostics the second time

return {
  "seblj/roslyn.nvim",
  commit = "5e36cac9371d014c52c4c1068a438bdb7d1c7987",
  config = function()
    require("roslyn").setup({
      config = {
        settings = {
          ["csharp|background_analysis"] = {
            dotnet_compiler_diagnostics_scope = "fullSolution"
          },
          ["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_indexer_parameters = true,
            dotnet_enable_inlay_hints_for_literal_parameters = true,
            dotnet_enable_inlay_hints_for_object_creation_parameters = true,
            dotnet_enable_inlay_hints_for_other_parameters = true,
            dotnet_enable_inlay_hints_for_parameters = true,
            dotnet_suppress_inlay_hints_for_parameters_that_differ_only_by_suffix = true,
            dotnet_suppress_inlay_hints_for_parameters_that_match_argument_name = true,
            dotnet_suppress_inlay_hints_for_parameters_that_match_method_intent = true,
          },
          ["csharp|code_lens"] = {
            dotnet_enable_references_code_lens = true,
          },
        }
      },
      exe = {
        "dotnet",
        vim.fs.joinpath(vim.fn.stdpath("data"), "roslyn", "Microsoft.CodeAnalysis.LanguageServer.dll"),
      },
      filewatching = true,
    })

    vim.keymap.set("n", "<leader>p", function()
      local clients = vim.lsp.get_clients()
      for _, value in ipairs(clients) do
        if value.name == "roslyn" then
          vim.notify("roslyn client found")
          value.rpc.request("workspace/diagnostic", { previousResultIds = {} }, function(err, result)
            if err ~= nil then
              print(vim.inspect(err))
            end
            if result ~= nil then
              local diags = {}
              local seen = {}
              for _, diag in ipairs(result.items) do
                local filepath = diag.uri:gsub("file:///", "")
                if #diag.items > 0 then
                  for _, diag_line in ipairs(diag.items) do
                    if diag_line.severity == 1 then
                      local hash = diag_line.message .. diag_line.range.start.line .. diag_line.range.start.character
                      if seen[hash] == nil then
                        local s = {
                          text = diag_line.message,
                          lnum = diag_line.range.start.line,
                          col = diag_line.range.start.character,
                          filename = filepath
                        }
                        table.insert(diags, s)
                        seen[hash] = true
                      end
                    end
                  end
                end
              end
              vim.fn.setqflist(diags)
              vim.cmd("copen")
            end
          end)
        end
      end
    end, { noremap = true, silent = true })
  end
}

image

seblj commented 2 weeks ago

@seblj Im working on another dotnet-plugin where I could make good use of the workspace/symbol the only downside is that I would require the language server to be running independently of any buffer. If you are interested it would be cool to make a PR that exposes a way to start the lsp server when entering vim if it finds a solution/csproject file.

The benefits of this would be:

  1. Project initialization would start when entering vim instead of when entering a file(percieved perf boost)
  2. Reduced lag when entering a .cs file because the server is already running
  3. Enabling the development of things like workspace diagnostic windows/lualine indicators.

This would of course be exposed as opt-in functionality

Yes I would accept a PR for this! I have actually been thinking about this myself, but haven't decided if I want to use it myself, so I haven't looked into implementing it

I have also been wondering about changing a bit about how it starts, and what it should prefer from a sln or csproj.

As of now, it always finds the sln file and uses that over any csproj, even in subdirectories. As of now it is impossible to load a single project, even if mye cwd is inside that project.

I have been thinking if I should change this to stop searching at cwd or prefer to attach to a project in the cwd rather than a solution in the parent dir.

What do you think?

GustavEikaas commented 1 week ago

My personal experience from projects is one of these scenarios

  1. There is a sln file and multiple csprojects in subdirectories below it
  2. There is a single csproject file

In all the projects I have ever worked on in C# I would always prefer roslyn to pick the broadest scope for my project. So solution is always preferable. The only reason I see for picking a csproject directly instead of a sln file is performance in massive projects.

As of now it is impossible to load a single project, even if mye cwd is inside that project. If there isnt a solution file then a csproject file is attached right?

TLDR; I think your current approach appeals to the broadest audience

seblj commented 1 week ago

If there isnt a solution file then a csproject file is attached right?

Yes

In all the projects I have ever worked on in C# I would always prefer roslyn to pick the broadest scope for my project. So solution is always preferable. The only reason I see for picking a csproject directly instead of a sln file is performance in massive projects.

Yeah I agree, but I was thinking that if neovim is opened inside one of the projects, then it could use the csproj file, but of course if neovim is opened in the root where the sln file lives, then it would of course use that.

It seems like this could be more consistent with how other editor that has a concept of workspaces do it. Because if I open a specific project in vscode, then it only uses that. But if I open the entire solution, then it used that

GustavEikaas commented 1 week ago

Oh now I understand, thats the way my plugin works, never looks up always looks down. Traversing upwards is nice if it doesnt find anything further down I guess.

Yeah that makes sense to prefer the .csproject if its in cwd or further down and no sln file is present in cwd or inbetween

seblj commented 1 week ago

Yes, I might change to this behaviour sometime in the future since I have wished for this a couple of times now. Maybe I will implement something (or ectend) CSTarget to be able to switch to csproj at runtime as well.

Need to think about this a little bit though, and especially the CSTarget thing

daver32 commented 1 week ago

Maybe this will be useful - here's one that populates vim's diagnostics, so it works with things like Trouble.nvim and Telescope. All the functions except the last client.request are pasted from nvim runtime (vim/lsp/diagnostic.lua) because they're not public.

code ```lua ---@param lines string[]? ---@param lnum integer ---@param col integer ---@param offset_encoding string ---@return integer local function line_byte_from_position(lines, lnum, col, offset_encoding) if not lines or offset_encoding == "utf-8" then return col end local line = lines[lnum + 1] local ok, result = pcall(vim.str_byteindex, line, col, offset_encoding == "utf-16") if ok then return result --- @type integer end return col end ---@param severity lsp.DiagnosticSeverity local function severity_lsp_to_vim(severity) if type(severity) == "string" then severity = vim.lsp.protocol.DiagnosticSeverity[severity] --- @type integer end return severity end --- @param diagnostic lsp.Diagnostic --- @param client_id integer --- @return table? local function tags_lsp_to_vim(diagnostic, client_id) local tags ---@type table? for _, tag in ipairs(diagnostic.tags or {}) do if tag == vim.lsp.protocol.DiagnosticTag.Unnecessary then tags = tags or {} tags.unnecessary = true elseif tag == vim.lsp.protocol.DiagnosticTag.Deprecated then tags = tags or {} tags.deprecated = true else vim.lsp.log.info(string.format("Unknown DiagnosticTag %d from LSP client %d", tag, client_id)) end end return tags end ---@param bufnr integer ---@return string[]? local function get_buf_lines(bufnr) if vim.api.nvim_buf_is_loaded(bufnr) then return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) end local filename = vim.api.nvim_buf_get_name(bufnr) local f = io.open(filename) if not f then return end local content = f:read("*a") if not content then -- Some LSP servers report diagnostics at a directory level, in which case -- io.read() returns nil f:close() return end local lines = vim.split(content, "\n") f:close() return lines end ---@param diagnostics lsp.Diagnostic[] ---@param bufnr integer ---@param client_id integer ---@return vim.Diagnostic[] local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) local buf_lines = get_buf_lines(bufnr) local client = vim.lsp.get_client_by_id(client_id) local offset_encoding = client and client.offset_encoding or "utf-16" --- @param diagnostic lsp.Diagnostic --- @return vim.Diagnostic return vim.tbl_map(function(diagnostic) local start = diagnostic.range.start local _end = diagnostic.range["end"] local message = diagnostic.message if type(message) ~= "string" then vim.notify_once( string.format("Unsupported Markup message from LSP client %d", client_id), vim.lsp.log_levels.ERROR ) message = diagnostic.message.value end --- @type vim.Diagnostic return { lnum = start.line, col = line_byte_from_position(buf_lines, start.line, start.character, offset_encoding), end_lnum = _end.line, end_col = line_byte_from_position(buf_lines, _end.line, _end.character, offset_encoding), severity = severity_lsp_to_vim(diagnostic.severity), message = message, source = diagnostic.source, code = diagnostic.code, _tags = tags_lsp_to_vim(diagnostic, client_id), user_data = { lsp = diagnostic, }, } end, diagnostics) end client.request("workspace/diagnostic", { previousResultIds = {} }, function(err, result, context, config) local ns = vim.lsp.diagnostic.get_namespace(client.id) local function find_buf_or_make_unlisted(file_name) for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(buf) == file_name then return buf end end local buf = vim.api.nvim_create_buf(false, false) vim.api.nvim_buf_set_name(buf, file_name) return buf end for _, per_file_diags in ipairs(result.items) do local filename = string.gsub(per_file_diags.uri, "file://", "") local buf = find_buf_or_make_unlisted(filename) vim.diagnostic.set(ns, buf, diagnostic_lsp_to_vim(per_file_diags.items, buf, context.client_id)) end end) ```
GustavEikaas commented 1 week ago

Looked into being able to preload the server and made a rough implementation but Im uncertain why its not behaving correctly

  1. I start the server
  2. I connect the lsp client
  3. I send "Initialize" request
  4. I send "solution/open"
  5. I query diagnostics with a known build error
  6. I get 0 results

Doing the same thing with Roslyn starting normally from any buffer in the solution returns the build error.

Ill keep investigating maybe i need to do some diagnostic refresh request or something

seblj commented 4 days ago

Okay, so I see now that a handler for workspace/diagnostic is just not implement in neovim core. I tried to search for an issue there or something to see if I could find some discussions about it or something, but I couldn't find it.

I am unfortunately not going to implement the handler in this plugin, as I believe efforts to support the handler should instead go into neovim core. I don't think I will look into sending a PR over there myself.

So with that said, I don't think there is anything to do for me right now for this, but feel free to paste snippets here if there are some improvements to the snippets already here or something, so that other users could take advantage of them😄