diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e86a84..872ab9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,6 @@ jobs: matrix: neovim_branch: - - "v0.10.4" - "v0.11.0" - "nightly" diff --git a/Makefile b/Makefile index 2c29af6..cb3918c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -export NVIM_RUNNER_VERSION := v0.10.4 -export NVIM_TEST_VERSION ?= v0.10.4 +export NVIM_RUNNER_VERSION := v0.11.0 +export NVIM_TEST_VERSION ?= v0.11.0 nvim-test: git clone https://github.com/lewis6991/nvim-test diff --git a/README.md b/README.md index 7f1571c..eed3ad0 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ This is an actively maintained & upgraded [fork](https://github.com/jmederosalvarado/roslyn.nvim) that interacts with the improved & open-source C# [Roslyn](https://github.com/dotnet/roslyn) language server, meant to replace the old and discontinued OmniSharp. This language server is currently used in the [Visual Studio Code C# Extension](https://github.com/dotnet/vscode-csharp), which is shipped with the standard C# Dev Kit. -This standalone plugin was necessary because Roslyn uses a [non-standard](https://github.com/dotnet/roslyn/issues/72871) method of initializing communication with the client and requires additional custom integrations, unlike typical LSP setups in Neovim. - ## IMPORTANT This plugin does not provide Razor support. @@ -12,7 +10,7 @@ Check out https://github.com/tris203/rzls.nvim if you are using Razor. ## ⚡️ Requirements -- Neovim >= 0.10.0 +- Neovim >= 0.11.0 - Roslyn language server downloaded locally - .NET SDK installed and `dotnet` command available @@ -42,6 +40,7 @@ require("mason").setup({ ``` You can then install it with `:MasonInstall roslyn` or through the popup menu by running `:Mason`. It is not available through [mason-lspconfig.nvim](https://github.com/williamboman/mason-lspconfig.nvim) and the `:LspInstall` interface +When installing the server through mason, the cmd is automatically added to the LSP config, so no need to add it manually **NOTE** @@ -58,16 +57,15 @@ There's currently an open [pull request](https://github.com/mason-org/mason-regi 3. Check if it's working by running `dotnet Microsoft.CodeAnalysis.LanguageServer.dll --version` in the `` directory. 4. Configure it like this: ```lua -require("roslyn").setup({ - config = { - cmd = { - "dotnet", - "/Microsoft.CodeAnalysis.LanguageServer.dll", - "--logLevel=Information", - "--extensionLogDirectory=" .. vim.fs.dirname(vim.lsp.get_log_path()), - "--stdio", - }, +vim.lsp.config("roslyn", { + cmd = { + "dotnet", + "/Microsoft.CodeAnalysis.LanguageServer.dll", + "--logLevel=Information", + "--extensionLogDirectory=" .. vim.fs.dirname(vim.lsp.get_log_path()), + "--stdio", }, + -- Add other options here }) ``` @@ -81,15 +79,14 @@ require("roslyn").setup({ ### [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua -{ +return { "seblyng/roslyn.nvim", ft = "cs", ---@module 'roslyn.config' ---@type RoslynNvimConfig opts = { -- your configuration comes here; leave empty for default settings - -- NOTE: You must configure `cmd` in `config.cmd` unless you have installed via mason - } + }, } ``` @@ -98,15 +95,7 @@ require("roslyn").setup({ The plugin comes with the following defaults: ```lua -{ - ---@type vim.lsp.ClientConfig - config = { - -- Here you can pass in any options that that you would like to pass to `vim.lsp.start`. - -- Use `:h vim.lsp.ClientConfig` to see all possible options. - -- The only options that are overwritten and won't have any effect by setting here: - -- - `name` - -- - `root_dir` - }, +opts = { -- "auto" | "roslyn" | "off" -- -- - "auto": Does nothing for filewatching, leaving everything as default @@ -147,10 +136,29 @@ The plugin comes with the following defaults: -- This will always attach to the target in `vim.g.roslyn_nvim_selected_solution`. -- NOTE: You can use `:Roslyn target` to change the target lock_target = false, -}) +} ``` -To configure language server specific settings sent to the server, you can modify the `config.settings` map. +To configure language server specific settings sent to the server, you can use the `vim.lsp.config` interface with `roslyn` as the name of the server. + +## Example + +```lua +vim.lsp.config("roslyn", { + on_attach = function() + print("This will run when the server attaches!") + end, + settings = { + ["csharp|inlay_hints"] = { + csharp_enable_inlay_hints_for_implicit_object_creation = true, + csharp_enable_inlay_hints_for_implicit_variable_types = true, + }, + ["csharp|code_lens"] = { + dotnet_enable_references_code_lens = true, + }, + }, +}) +``` Some tips and tricks that may be useful, but not in the scope of this plugin, are documented in the [wiki](https://github.com/seblyng/roslyn.nvim/wiki). @@ -284,34 +292,6 @@ This setting controls how the language server should format code. Sort using directives on format alphabetically. Expected values: `true`, `false` -Example: - -```lua -opts = { - config = { - 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_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, - }, - }, - }, -} -``` - ## 📚 Commands - `:Roslyn restart` restarts the server diff --git a/lsp/roslyn.lua b/lsp/roslyn.lua new file mode 100644 index 0000000..759e16f --- /dev/null +++ b/lsp/roslyn.lua @@ -0,0 +1,86 @@ +local utils = require("roslyn.sln.utils") + +---@return string[]? +local function default_cmd() + local sysname = vim.uv.os_uname().sysname:lower() + local iswin = not not (sysname:find("windows") or sysname:find("mingw")) + + local mason_path = vim.fs.joinpath(vim.fn.stdpath("data"), "mason", "bin", "roslyn") + local mason_cmd = iswin and string.format("%s.cmd", mason_path) or mason_path + + if vim.uv.fs_stat(mason_cmd) == nil then + return nil + end + + return { + mason_cmd, + "--logLevel=Information", + "--extensionLogDirectory=" .. vim.fs.dirname(vim.lsp.get_log_path()), + "--stdio", + } +end + +---@type vim.lsp.Config +return { + name = "roslyn", + filetypes = { "cs" }, + cmd = default_cmd(), + cmd_env = { + Configuration = vim.env.Configuration or "Debug", + }, + capabilities = { + textDocument = { + -- HACK: Doesn't show any diagnostics if we do not set this to true + diagnostic = { + dynamicRegistration = true, + }, + }, + }, + root_dir = function(bufnr, on_dir) + local config = require("roslyn.config") + local solutions = config.get().broad_search and utils.find_solutions_broad(bufnr) or utils.find_solutions(bufnr) + local root_dir = utils.root_dir(bufnr, solutions) + if root_dir then + on_dir(root_dir) + end + end, + on_init = { + function(client) + local on_init = require("roslyn.lsp.on_init") + + local config = require("roslyn.config").get() + local selected_solution = vim.g.roslyn_nvim_selected_solution + if config.lock_target and selected_solution then + return on_init.sln(client, selected_solution) + end + + local bufnr = vim.api.nvim_get_current_buf() + local files = utils.find_files_with_extensions(client.config.root_dir, { ".sln", ".slnx", ".slnf" }) + + local solution = utils.predict_target(bufnr, files) + if solution then + return on_init.sln(client, solution) + end + + local csproj = utils.find_files_with_extensions(client.config.root_dir, { ".csproj" }) + if #csproj > 0 then + return on_init.projects(client, csproj) + end + + if selected_solution then + return on_init.sln(client, selected_solution) + end + end, + }, + on_exit = { + function() + vim.g.roslyn_nvim_selected_solution = nil + vim.schedule(function() + require("roslyn.roslyn_emitter"):emit("stopped") + vim.notify("Roslyn server stopped", vim.log.levels.INFO, { title = "roslyn.nvim" }) + end) + end, + }, + commands = require("roslyn.lsp.commands"), + handlers = require("roslyn.lsp.handlers"), +} diff --git a/lua/roslyn/commands.lua b/lua/roslyn/commands.lua index 9bc802b..db208d5 100644 --- a/lua/roslyn/commands.lua +++ b/lua/roslyn/commands.lua @@ -5,33 +5,27 @@ local M = {} local cmd_name = "Roslyn" -local function start_lsp(bufnr, sln_file) - local roslyn_lsp = require("roslyn.lsp") - if not sln_file then - return - end - - local clients = vim.lsp.get_clients({ name = "roslyn" }) - if #clients > 0 then - vim.notify("\n" .. cmd_name .. " server already running", vim.log.levels.WARNING, { title = "roslyn.nvim" }) - return - end - - local sln_dir = vim.fs.dirname(sln_file) - roslyn_lsp.start(bufnr, assert(sln_dir), roslyn_lsp.on_init_sln(sln_file)) -end - -local function select_sln_and_start_lsp() - local bufnr = vim.api.nvim_get_current_buf() - local root = vim.b.roslyn_root or require("roslyn.sln.utils").root(bufnr) - local targets = vim.iter({ root.solutions, root.solution_filters }):flatten():totable() - if #targets == 1 then - start_lsp(bufnr, targets[1]) - return +---@param bufnr integer +---@param config vim.lsp.Config +local start_lsp = function(bufnr, config) + if type(config.root_dir) == "function" then + config.root_dir(bufnr, function(root_dir) + config.root_dir = root_dir + vim.schedule(function() + vim.lsp.start(config, { + bufnr = bufnr, + reuse_client = config.reuse_client, + _root_markers = config.root_markers, + }) + end) + end) + else + vim.lsp.start(config, { + bufnr = bufnr, + reuse_client = config.reuse_client, + _root_markers = config.root_markers, + }) end - vim.ui.select(targets or {}, { prompt = "Select target solution: " }, function(file) - start_lsp(bufnr, file) - end) end ---@class RoslynSubcommandTable @@ -53,10 +47,9 @@ local subcommand_tbl = { local remove_listener = nil local function restart_lsp() + local config = vim.lsp.config["roslyn"] for _, buffer in ipairs(attached_buffers) do - if vim.api.nvim_buf_is_valid(buffer) then - vim.api.nvim_exec_autocmds("FileType", { group = "Roslyn", buffer = buffer }) - end + start_lsp(buffer, config) end if remove_listener then remove_listener() @@ -66,7 +59,7 @@ local subcommand_tbl = { remove_listener = roslyn_emitter:on("stopped", restart_lsp) local force_stop = vim.loop.os_uname().sysname == "Windows_NT" - client.stop(force_stop) + client:stop(force_stop) end, }, stop = { @@ -76,33 +69,60 @@ local subcommand_tbl = { return end - -- TODO: Change this to `client:request` when minimal version is `0.11` - ---@diagnostic disable-next-line: missing-parameter, param-type-mismatch - client.stop(true) + client:stop(true) end, }, target = { impl = function() local bufnr = vim.api.nvim_get_current_buf() - local root = vim.b.roslyn_root or require("roslyn.sln.utils").root(bufnr) - - local roslyn_lsp = require("roslyn.lsp") - - local targets = vim.iter({ root.solutions, root.solution_filters }):flatten():totable() + local utils = require("roslyn.sln.utils") + local broad_search = require("roslyn.config").get().broad_search + local targets = broad_search and utils.find_solutions_broad(bufnr) or utils.find_solutions(bufnr) vim.ui.select(targets or {}, { prompt = "Select target solution: " }, function(file) if not file then return end vim.lsp.stop_client(vim.lsp.get_clients({ name = "roslyn" }), true) - local sln_dir = vim.fs.dirname(file) - roslyn_lsp.start(bufnr, assert(sln_dir), roslyn_lsp.on_init_sln(file)) + vim.lsp.start({ + root_dir = vim.fs.dirname(file), + on_init = function(client) + require("roslyn.lsp.on_init").sln(client, file) + end, + cmd = vim.lsp.config.roslyn.cmd, + unpack(vim.lsp.config.roslyn), + }) end) end, }, start = { impl = function() - select_sln_and_start_lsp() + local bufnr = vim.api.nvim_get_current_buf() + local utils = require("roslyn.sln.utils") + local broad_search = require("roslyn.config").get().broad_search + local solutions = broad_search and utils.find_solutions_broad(bufnr) or utils.find_solutions(bufnr) + + -- If we have more than one solution, immediately ask to pick one + if #solutions > 1 then + vim.ui.select(solutions or {}, { prompt = "Select target solution: " }, function(file) + if not file then + return + end + + vim.lsp.start({ + root_dir = vim.fs.dirname(file), + on_init = function(client) + require("roslyn.lsp.on_init").sln(client, file) + end, + cmd = vim.lsp.config.roslyn.cmd, + unpack(vim.lsp.config.roslyn), + }) + end) + return + end + + -- Fallback to try to start the server normally + start_lsp(bufnr, vim.lsp.config["roslyn"]) end, }, } diff --git a/lua/roslyn/config.lua b/lua/roslyn/config.lua index 32c94d0..8c5bb0a 100644 --- a/lua/roslyn/config.lua +++ b/lua/roslyn/config.lua @@ -2,7 +2,6 @@ local M = {} ---@class InternalRoslynNvimConfig ---@field filewatching "auto" | "off" | "roslyn" ----@field config vim.lsp.ClientConfig ---@field choose_sln? fun(solutions: string[]): string? ---@field ignore_sln? fun(solution: string): boolean ---@field choose_target? fun(targets: string[]): string? @@ -12,7 +11,6 @@ local M = {} ---@class RoslynNvimConfig ---@field filewatching? boolean | "auto" | "off" | "roslyn" ----@field config? vim.lsp.ClientConfig ---@field choose_sln? fun(solutions: string[]): string? ---@field ignore_sln? fun(solution: string): boolean ---@field choose_target? fun(targets: string[]): string? @@ -20,43 +18,9 @@ local M = {} ---@field broad_search? boolean ---@field lock_target? boolean -local sysname = vim.uv.os_uname().sysname:lower() -local iswin = not not (sysname:find("windows") or sysname:find("mingw")) - ----@return lsp.ClientCapabilities -local function default_capabilities() - local cmp_ok, cmp = pcall(require, "cmp_nvim_lsp") - local blink_ok, blink = pcall(require, "blink.cmp") - local default = vim.lsp.protocol.make_client_capabilities() - return cmp_ok and vim.tbl_deep_extend("force", default, cmp.default_capabilities()) - or blink_ok and vim.tbl_deep_extend("force", default, blink.get_lsp_capabilities()) - or default -end - ----@return string[]? -local function get_mason_exe() - local data = vim.fn.stdpath("data") --[[@as string]] - - local mason_path = vim.fs.joinpath(data, "mason", "bin", "roslyn") - local mason_installation = iswin and string.format("%s.cmd", mason_path) or mason_path - - if vim.uv.fs_stat(mason_installation) == nil then - return nil - end - - return { mason_installation } -end - ---@type InternalRoslynNvimConfig local roslyn_config = { filewatching = "auto", - ---@diagnostic disable-next-line: missing-fields - config = { - capabilities = default_capabilities(), - cmd_env = { - Configuration = vim.env.Configuration or "Debug", - }, - }, choose_sln = nil, ignore_sln = nil, choose_target = nil, @@ -69,103 +33,44 @@ function M.get() return roslyn_config end ----@param user_config RoslynNvimConfig -local function deprecate_args(user_config) +-- TODO(seb): Remove this in a couple of months after release +local function handle_deprecated_options() ---@diagnostic disable-next-line: undefined-field - if user_config.args then - vim.notify( - "The `args` option is deprecated. Use `config.cmd` instead", - vim.log.levels.WARN, - { title = "roslyn.nvim" } - ) - end -end + local legacy_config = roslyn_config.config ----@param user_config RoslynNvimConfig ----@return string[]? -local function deprecate_exe(user_config) - ---@diagnostic disable-next-line: undefined-field - if user_config.exe then + if legacy_config then vim.notify( - "The `exe` option is deprecated. Use `config.cmd` instead", + "The `config` option is deprecated. Use `vim.lsp.config` instead", vim.log.levels.WARN, { title = "roslyn.nvim" } ) + vim.lsp.config("roslyn", legacy_config) end end ----@param user_config RoslynNvimConfig -local function resolve_user_cmd(user_config) - local mason_exe = get_mason_exe() - - ---@diagnostic disable-next-line: undefined-field - local args = user_config.args and vim.deepcopy(user_config.args) - or { - "--logLevel=Information", - "--extensionLogDirectory=" .. vim.fs.dirname(vim.lsp.get_log_path()), - "--stdio", - } - - -- If we have mason then use that - if mason_exe then - return vim.list_extend(mason_exe, args) - end - - ---@diagnostic disable-next-line: undefined-field - local exe = user_config.exe and vim.deepcopy(user_config.exe) or nil - if exe then - exe = type(exe) == "string" and { exe } or exe - return vim.list_extend(exe, args) - end - - local legacy_path = vim.fs.joinpath(vim.fn.stdpath("data"), "roslyn", "Microsoft.CodeAnalysis.LanguageServer.dll") - if vim.uv.fs_stat(legacy_path) then - vim.notify( - "The default cmd location of roslyn is deprecated.\nEither download through mason, or specify through `config.cmd` as specified in the README", - vim.log.levels.WARN, - { title = "roslyn.nvim" } - ) - end - - return vim.list_extend({ "dotnet", legacy_path }, args) -end - ---@param user_config? RoslynNvimConfig ---@return InternalRoslynNvimConfig function M.setup(user_config) - user_config = user_config or {} - user_config.config = user_config.config or {} + roslyn_config = vim.tbl_deep_extend("force", roslyn_config, user_config or {}) - deprecate_args(user_config) - deprecate_exe(user_config) + handle_deprecated_options() - if not user_config.config.cmd then - user_config.config.cmd = user_config.config.cmd or resolve_user_cmd(user_config) - end - - roslyn_config = vim.tbl_deep_extend("force", roslyn_config, user_config) - - -- HACK: Enable filewatching to later just not watch any files - -- This is to not make the server watch files and make everything super slow in certain situations + -- HACK: Enable or disable filewatching based on config options + -- `off` enables filewatching but just ignores all files to watch at a later stage + -- `roslyn` disables filewatching to force the server to take care of this if roslyn_config.filewatching == "off" or roslyn_config.filewatching == "roslyn" then - roslyn_config.config.capabilities = vim.tbl_deep_extend("force", roslyn_config.config.capabilities, { - workspace = { - didChangeWatchedFiles = { - dynamicRegistration = roslyn_config.filewatching == "off", + vim.lsp.config("roslyn", { + -- HACK: Set filewatching capabilities here based on filewatching option to the plugin + capabilities = { + workspace = { + didChangeWatchedFiles = { + dynamicRegistration = roslyn_config.filewatching == "off", + }, }, }, }) end - -- HACK: Doesn't show any diagnostics if we do not set this to true - roslyn_config.config.capabilities = vim.tbl_deep_extend("force", roslyn_config.config.capabilities, { - textDocument = { - diagnostic = { - dynamicRegistration = true, - }, - }, - }) - return roslyn_config end diff --git a/lua/roslyn/init.lua b/lua/roslyn/init.lua index a96c2da..f0af87c 100644 --- a/lua/roslyn/init.lua +++ b/lua/roslyn/init.lua @@ -1,78 +1,65 @@ -local utils = require("roslyn.sln.utils") - ----@param buf number ----@return boolean -local function valid_buffer(buf) - local bufname = vim.api.nvim_buf_get_name(buf) - return vim.bo[buf].buftype ~= "nofile" - and ( - bufname:match("^/") - or bufname:match("^[a-zA-Z]:") - or bufname:match("^zipfile://") - or bufname:match("^tarfile:") - or bufname:match("^roslyn%-source%-generated://") - ) -end - local M = {} ---@param config? RoslynNvimConfig function M.setup(config) - local roslyn_config = require("roslyn.config").setup(config) - local roslyn_lsp = require("roslyn.lsp") + if vim.fn.has("nvim-0.11") == 0 then + return vim.notify("roslyn.nvim requires at least nvim 0.11", vim.log.levels.WARN, { title = "roslyn.nvim" }) + end - vim.treesitter.language.register("c_sharp", "csharp") + local group = vim.api.nvim_create_augroup("roslyn.nvim", { clear = true }) - require("roslyn.commands").create_roslyn_commands() + require("roslyn.config").setup(config) - vim.api.nvim_create_autocmd({ "FileType" }, { - group = vim.api.nvim_create_augroup("Roslyn", { clear = true }), - pattern = { "cs", "roslyn-source-generated://*" }, - callback = function(opt) - if not valid_buffer(opt.buf) then - return - end + vim.lsp.enable("roslyn") - -- Lock the target and always start with the currently selected solution - local selected_solution = vim.g.roslyn_nvim_selected_solution - if roslyn_config.lock_target and selected_solution then - local sln_dir = vim.fs.dirname(selected_solution) - return roslyn_lsp.start(opt.buf, sln_dir, roslyn_lsp.on_init_sln(selected_solution)) - end + vim.api.nvim_create_autocmd("FileType", { + group = group, + pattern = "cs", + callback = function() + require("roslyn.commands").create_roslyn_commands() + end, + }) - vim.schedule(function() - local root = utils.root(opt.buf) - vim.b.roslyn_root = root + vim.treesitter.language.register("c_sharp", "csharp") - local multiple, solution = utils.predict_target(root) + vim.api.nvim_create_autocmd({ "BufReadCmd" }, { + group = group, + pattern = "roslyn-source-generated://*", + callback = function(args) + vim.bo[args.buf].modifiable = true + vim.bo[args.buf].swapfile = false - if multiple then - vim.notify( - "Multiple potential target files found. Use `:Roslyn target` to select a target.", - vim.log.levels.INFO, - { title = "roslyn.nvim" } - ) + -- This triggers FileType event which should fire up the lsp client if not already running + vim.bo[args.buf].filetype = "cs" + local client = vim.lsp.get_clients({ name = "roslyn" })[1] + assert(client, "Must have a `roslyn` client to load roslyn source generated file") - -- If the user has `lock_target = true` then wait for them - -- to choose a target explicitly before starting the LSP. - if roslyn_config.lock_target then - return - end + local content + local function handler(err, result) + assert(not err, vim.inspect(err)) + content = result.text + if content == nil then + content = "" end + local normalized = string.gsub(content, "\r\n", "\n") + local source_lines = vim.split(normalized, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(args.buf, 0, -1, false, source_lines) + vim.b[args.buf].resultId = result.resultId + vim.bo[args.buf].modifiable = false + end - if solution then - return roslyn_lsp.start(opt.buf, vim.fs.dirname(solution), roslyn_lsp.on_init_sln(solution)) - elseif root.projects then - local dir = root.projects.directory - return roslyn_lsp.start(opt.buf, dir, roslyn_lsp.on_init_project(root.projects.files)) - end + local params = { + textDocument = { + uri = args.match, + }, + resultId = nil, + } - -- Fallback to the selected solution if we don't find anything. - -- This makes it work kind of like vscode for the decoded files - if selected_solution then - local sln_dir = vim.fs.dirname(selected_solution) - return roslyn_lsp.start(opt.buf, sln_dir, roslyn_lsp.on_init_sln(selected_solution)) - end + client:request("sourceGeneratedDocument/_roslyn_getText", params, handler, args.buf) + -- Need to block. Otherwise logic could run that sets the cursor to a position + -- that's still missing. + vim.wait(1000, function() + return content ~= nil end) end, }) diff --git a/lua/roslyn/lsp.lua b/lua/roslyn/lsp.lua deleted file mode 100644 index 11b57ea..0000000 --- a/lua/roslyn/lsp.lua +++ /dev/null @@ -1,177 +0,0 @@ -local roslyn_emitter = require("roslyn.roslyn_emitter") -local M = {} - ----@param bufnr integer ----@param root_dir string ----@param on_init fun(client: vim.lsp.Client) -function M.start(bufnr, root_dir, on_init) - local roslyn_config = require("roslyn.config").get() - - local config = vim.deepcopy(roslyn_config.config) - - config.name = "roslyn" - config.root_dir = root_dir - config.handlers = vim.tbl_deep_extend("force", { - ["client/registerCapability"] = function(err, res, ctx) - if roslyn_config.filewatching == "off" then - for _, reg in ipairs(res.registrations) do - if reg.method == "workspace/didChangeWatchedFiles" then - reg.registerOptions.watchers = {} - end - end - end - return vim.lsp.handlers["client/registerCapability"](err, res, ctx) - end, - ["workspace/projectInitializationComplete"] = function(_, _, ctx) - vim.notify("Roslyn project initialization complete", vim.log.levels.INFO, { title = "roslyn.nvim" }) - - ---NOTE: This is used by rzls.nvim for init - vim.api.nvim_exec_autocmds("User", { pattern = "RoslynInitialized", modeline = false }) - _G.roslyn_initialized = true - - local buffers = vim.lsp.get_buffers_by_client_id(ctx.client_id) - for _, buf in ipairs(buffers) do - vim.lsp.util._refresh("textDocument/diagnostic", { bufnr = buf }) - end - end, - ["workspace/_roslyn_projectHasUnresolvedDependencies"] = function() - vim.notify("Detected missing dependencies. Run dotnet restore command.", vim.log.levels.ERROR, { - title = "roslyn.nvim", - }) - return vim.NIL - end, - ["workspace/refreshSourceGeneratedDocument"] = function(_, _, ctx) - local client = assert(vim.lsp.get_client_by_id(ctx.client_id)) - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - local uri = vim.api.nvim_buf_get_name(buf) - if vim.api.nvim_buf_get_name(buf):match("^roslyn%-source%-generated://") then - local function handler(err, result) - assert(not err, vim.inspect(err)) - if vim.b[buf].resultId == result.resultId then - return - end - local content = result.text - if content == nil then - content = "" - end - local normalized = string.gsub(content, "\r\n", "\n") - local source_lines = vim.split(normalized, "\n", { plain = true }) - vim.bo[buf].modifiable = true - vim.api.nvim_buf_set_lines(buf, 0, -1, false, source_lines) - vim.b[buf].resultId = result.resultId - vim.bo[buf].modifiable = false - end - - local params = { - textDocument = { - uri = uri, - }, - resultId = vim.b[buf].resultId, - } - - -- TODO: Change this to `client:request` when minimal version is `0.11` - ---@diagnostic disable-next-line: param-type-mismatch - client.request("sourceGeneratedDocument/_roslyn_getText", params, handler, buf) - end - end - end, - }, config.handlers or {}) - config.on_init = function(client, initialize_result) - if roslyn_config.config.on_init then - roslyn_config.config.on_init(client, initialize_result) - end - on_init(client) - - local lsp_commands = require("roslyn.lsp_commands") - lsp_commands.fix_all_code_action(client) - lsp_commands.nested_code_action(client) - lsp_commands.completion_complex_edit() - end - - config.on_exit = function(code, signal, client_id) - vim.g.roslyn_nvim_selected_solution = nil - vim.schedule(function() - roslyn_emitter:emit("stopped") - vim.notify("Roslyn server stopped", vim.log.levels.INFO, { title = "roslyn.nvim" }) - end) - if roslyn_config.config.on_exit then - roslyn_config.config.on_exit(code, signal, client_id) - end - end - - vim.api.nvim_create_autocmd({ "BufReadCmd" }, { - pattern = "roslyn-source-generated://*", - callback = function() - local uri = vim.fn.expand("") - local buf = vim.api.nvim_get_current_buf() - vim.bo[buf].modifiable = true - vim.bo[buf].swapfile = false - vim.bo[buf].buftype = "nowrite" - -- This triggers FileType event which should fire up the lsp client if not already running - vim.bo[buf].filetype = "cs" - local client = vim.lsp.get_clients({ name = "roslyn" })[1] - assert(client, "Must have a `roslyn` client to load roslyn source generated file") - - local content - local function handler(err, result) - assert(not err, vim.inspect(err)) - content = result.text - if content == nil then - content = "" - end - local normalized = string.gsub(content, "\r\n", "\n") - local source_lines = vim.split(normalized, "\n", { plain = true }) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, source_lines) - vim.b[buf].resultId = result.resultId - vim.bo[buf].modifiable = false - end - - local params = { - textDocument = { - uri = uri, - }, - resultId = nil, - } - - -- TODO: Change this to `client:request` when minimal version is `0.11` - ---@diagnostic disable-next-line: param-type-mismatch - client.request("sourceGeneratedDocument/_roslyn_getText", params, handler, buf) - -- Need to block. Otherwise logic could run that sets the cursor to a position - -- that's still missing. - vim.wait(1000, function() - return content ~= nil - end) - end, - }) - - vim.lsp.start(config, { bufnr = bufnr }) -end - ----@param solution string -function M.on_init_sln(solution) - return function(client) - vim.g.roslyn_nvim_selected_solution = solution - vim.notify("Initializing Roslyn client for " .. solution, vim.log.levels.INFO, { title = "roslyn.nvim" }) - -- TODO: Change this to `client:request` when minimal version is `0.11` - ---@diagnostic disable-next-line: param-type-mismatch - client.notify("solution/open", { - solution = vim.uri_from_fname(solution), - }) - end -end - ----@param files string[] -function M.on_init_project(files) - return function(client) - vim.notify("Initializing Roslyn client for projects", vim.log.levels.INFO, { title = "roslyn.nvim" }) - -- TODO: Change this to `client:request` when minimal version is `0.11` - ---@diagnostic disable-next-line: param-type-mismatch - client.notify("project/open", { - projects = vim.tbl_map(function(file) - return vim.uri_from_fname(file) - end, files), - }) - end -end - -return M diff --git a/lua/roslyn/lsp_commands.lua b/lua/roslyn/lsp/commands.lua similarity index 74% rename from lua/roslyn/lsp_commands.lua rename to lua/roslyn/lsp/commands.lua index ee17095..c053fa4 100644 --- a/lua/roslyn/lsp_commands.lua +++ b/lua/roslyn/lsp/commands.lua @@ -1,5 +1,3 @@ -local M = {} - ---@class RoslynCodeAction ---@field title string ---@field code_action table @@ -30,8 +28,7 @@ end local function handle_fix_all_code_action(client, data) local flavors = data.arguments[1].FixAllFlavors vim.ui.select(flavors, { prompt = "Pick a fix all scope:" }, function(flavor) - -- TODO: Change this to `client:request` when minimal version is `0.11` - client.request("codeAction/resolveFixAll", { + client:request("codeAction/resolveFixAll", { title = data.title, data = data.arguments[1], scope = flavor, @@ -46,16 +43,44 @@ local function handle_fix_all_code_action(client, data) end) end ----@param client vim.lsp.Client -function M.fix_all_code_action(client) - vim.lsp.commands["roslyn.client.fixAllCodeAction"] = function(data) - handle_fix_all_code_action(client, data) +local function best_cursor_pos(lines, start_row, start_col) + local target_i, col + + for i = #lines, 1, -1 do + local line = lines[i] + for j = #line, 1, -1 do + if not line:sub(j, j):match("[%s(){}]") then + target_i = i + 1 + col = j + break + end + end + if target_i then + break + end end + + -- Fallback position if somehow not found + if not target_i then + target_i = #lines + col = #lines[target_i] or 0 + end + + local row = start_row + target_i - 1 + if target_i == 1 then + col = start_col + col + end + + return { row, col } end ----@param client vim.lsp.Client -function M.nested_code_action(client) - vim.lsp.commands["roslyn.client.nestedCodeAction"] = function(data) +return { + ["roslyn.client.fixAllCodeAction"] = function(data, ctx) + local client = assert(vim.lsp.get_client_by_id(ctx.client_id)) + handle_fix_all_code_action(client, data) + end, + ["roslyn.client.nestedCodeAction"] = function(data, ctx) + local client = assert(vim.lsp.get_client_by_id(ctx.client_id)) local args = data.arguments[1] local code_actions = get_code_actions(args.NestedCodeActions) local titles = vim.iter(code_actions) @@ -72,9 +97,7 @@ function M.nested_code_action(client) if action.code_action.data.FixAllFlavors then handle_fix_all_code_action(client, action.code_action.command) else - -- TODO: Change this to `client:request` when minimal version is `0.11` - ---@diagnostic disable-next-line: param-type-mismatch - client.request("codeAction/resolve", { + client:request("codeAction/resolve", { title = action.code_action.title, data = action.code_action.data, ---@diagnostic disable-next-line: param-type-mismatch @@ -88,42 +111,8 @@ function M.nested_code_action(client) end) end end) - end -end - -function M.completion_complex_edit() - local function best_cursor_pos(lines, start_row, start_col) - local target_i, col - - for i = #lines, 1, -1 do - local line = lines[i] - for j = #line, 1, -1 do - if not line:sub(j, j):match("[%s(){}]") then - target_i = i + 1 - col = j - break - end - end - if target_i then - break - end - end - - -- Fallback position if somehow not found - if not target_i then - target_i = #lines - col = #lines[target_i] or 0 - end - - local row = start_row + target_i - 1 - if target_i == 1 then - col = start_col + col - end - - return { row, col } - end - - vim.lsp.commands["roslyn.client.completionComplexEdit"] = function(data, _) + end, + ["roslyn.client.completionComplexEdit"] = function(data) local arguments = data.arguments local uri = arguments[1].uri local edit = arguments[2] @@ -158,7 +147,5 @@ function M.completion_complex_edit() end vim.api.nvim_win_set_cursor(0, best_cursor_pos(lines, start_row, start_col)) - end -end - -return M + end, +} diff --git a/lua/roslyn/lsp/handlers.lua b/lua/roslyn/lsp/handlers.lua new file mode 100644 index 0000000..61c45ec --- /dev/null +++ b/lua/roslyn/lsp/handlers.lua @@ -0,0 +1,63 @@ +return { + ["client/registerCapability"] = function(err, res, ctx) + if require("roslyn.config").get().filewatching == "off" then + for _, reg in ipairs(res.registrations) do + if reg.method == "workspace/didChangeWatchedFiles" then + reg.registerOptions.watchers = {} + end + end + end + return vim.lsp.handlers["client/registerCapability"](err, res, ctx) + end, + ["workspace/projectInitializationComplete"] = function(_, _, ctx) + vim.notify("Roslyn project initialization complete", vim.log.levels.INFO, { title = "roslyn.nvim" }) + + ---NOTE: This is used by rzls.nvim for init + vim.api.nvim_exec_autocmds("User", { pattern = "RoslynInitialized", modeline = false }) + _G.roslyn_initialized = true + + local buffers = vim.lsp.get_buffers_by_client_id(ctx.client_id) + for _, buf in ipairs(buffers) do + vim.lsp.util._refresh("textDocument/diagnostic", { bufnr = buf }) + end + end, + ["workspace/_roslyn_projectHasUnresolvedDependencies"] = function() + vim.notify("Detected missing dependencies. Run dotnet restore command.", vim.log.levels.ERROR, { + title = "roslyn.nvim", + }) + return vim.NIL + end, + ["workspace/refreshSourceGeneratedDocument"] = function(_, _, ctx) + local client = assert(vim.lsp.get_client_by_id(ctx.client_id)) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local uri = vim.api.nvim_buf_get_name(buf) + if vim.api.nvim_buf_get_name(buf):match("^roslyn%-source%-generated://") then + local function handler(err, result) + assert(not err, vim.inspect(err)) + if vim.b[buf].resultId == result.resultId then + return + end + local content = result.text + if content == nil then + content = "" + end + local normalized = string.gsub(content, "\r\n", "\n") + local source_lines = vim.split(normalized, "\n", { plain = true }) + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, source_lines) + vim.b[buf].resultId = result.resultId + vim.bo[buf].modifiable = false + end + + local params = { + textDocument = { + uri = uri, + }, + resultId = vim.b[buf].resultId, + } + + client:request("sourceGeneratedDocument/_roslyn_getText", params, handler, buf) + end + end + end, +} diff --git a/lua/roslyn/lsp/on_init.lua b/lua/roslyn/lsp/on_init.lua new file mode 100644 index 0000000..b67248e --- /dev/null +++ b/lua/roslyn/lsp/on_init.lua @@ -0,0 +1,20 @@ +local M = {} + +function M.sln(client, solution) + vim.g.roslyn_nvim_selected_solution = solution + vim.notify("Initializing Roslyn for: " .. solution, vim.log.levels.INFO, { title = "roslyn.nvim" }) + client:notify("solution/open", { + solution = vim.uri_from_fname(solution), + }) +end + +function M.projects(client, projects) + vim.notify("Initializing Roslyn for: projects", vim.log.levels.INFO, { title = "roslyn.nvim" }) + client:notify("project/open", { + projects = vim.tbl_map(function(file) + return vim.uri_from_fname(file) + end, projects), + }) +end + +return M diff --git a/lua/roslyn/sln/utils.lua b/lua/roslyn/sln/utils.lua index 30d9a75..c7f59a8 100644 --- a/lua/roslyn/sln/utils.lua +++ b/lua/roslyn/sln/utils.lua @@ -1,3 +1,5 @@ +local sln_api = require("roslyn.sln.api") + local M = {} --- Searches for files with a specific extension within a directory. @@ -7,7 +9,7 @@ local M = {} --- @param extensions string[] The file extensions to look for (e.g., ".sln"). --- --- @return string[] List of file paths that match the specified extension. -local function find_files_with_extensions(dir, extensions) +function M.find_files_with_extensions(dir, extensions) local matches = {} for entry, type in vim.fs.dir(dir) do @@ -23,17 +25,53 @@ local function find_files_with_extensions(dir, extensions) return matches end ---- @param dir string -local function ignore_dir(dir) - return dir:match("[Bb]in$") or dir:match("[Oo]bj$") +---@param targets string[] +---@param csproj string +---@return string[] +local function filter_targets(targets, csproj) + local config = require("roslyn.config").get() + return vim.iter(targets) + :filter(function(target) + if config.ignore_target and config.ignore_target(target) then + return false + end + + return not csproj or sln_api.exists_in_target(target, csproj) + end) + :totable() +end + +---@param buffer number +local function resolve_broad_search_root(buffer) + local sln_root = vim.fs.root(buffer, function(fname, _) + return fname:match("%.sln$") ~= nil or fname:match("%.slnx$") ~= nil + end) + + local git_root = vim.fs.root(buffer, ".git") + if sln_root and git_root then + return git_root and sln_root:find(git_root, 1, true) and git_root or sln_root + else + return sln_root or git_root + end +end + +function M.find_solutions(bufnr) + return vim.fs.find(function(name) + return name:match("%.sln$") or name:match("%.slnx$") or name:match("%.slnf$") + end, { upward = true, path = vim.api.nvim_buf_get_name(bufnr), limit = math.huge }) end ---- @param path string ---- @return string[] slns, string[] slnfs -local function find_solutions(path) - local dirs = { path } +-- Dirs we are not looking for solutions inside +local ignored_dirs = { + "obj", + "bin", + ".git", +} + +function M.find_solutions_broad(bufnr) + local root = resolve_broad_search_root(bufnr) + local dirs = { root } local slns = {} --- @type string[] - local slnfs = {} --- @type string[] while #dirs > 0 do local dir = table.remove(dirs, 1) @@ -42,143 +80,65 @@ local function find_solutions(path) local name = vim.fs.joinpath(dir, other) if fs_obj_type == "file" then - if name:match("%.sln$") or name:match("%.slnx$") then + if name:match("%.sln$") or name:match("%.slnx$") or name:match("%.slnf$") then slns[#slns + 1] = vim.fs.normalize(name) - elseif name:match("%.slnf$") then - slnfs[#slnfs + 1] = vim.fs.normalize(name) end - elseif fs_obj_type == "directory" and not ignore_dir(name) then + elseif fs_obj_type == "directory" and not vim.list_contains(ignored_dirs, vim.fs.basename(name)) then dirs[#dirs + 1] = name end end end - return slns, slnfs + return slns end ---- @class FindTargetsResult ---- @field csproj_dir string? ---- @field sln_dir string? ---- @field slnf_dir string? - ---- Searches for the directory of a project and/or solution to use for the buffer. ----@param buffer integer ----@return FindTargetsResult -local function find_targets(buffer) - -- We should always find csproj/slnf files "on the way" to the solution file, - -- so walk once towards the solution, and capture them as we go by. - local csproj_dir = nil - local slnf_dir = nil - - local sln_dir = vim.fs.root(buffer, function(name, path) - if not csproj_dir and name:match("%.csproj$") then - csproj_dir = path - end - - if not slnf_dir and name:match("%.slnf$") then - slnf_dir = path - end - - return name:match("%.sln$") ~= nil or name:match("%.slnx$") - end) - - return { csproj_dir = csproj_dir, sln_dir = sln_dir, slnf_dir = slnf_dir } -end - ----@class RoslynNvimDirectoryWithFiles ----@field directory string ----@field files string[] - ----@class RoslynNvimRootDir ----@field projects? RoslynNvimDirectoryWithFiles ----@field solutions string[] ----@field solution_filters string[] - ----@param buffer integer ----@return RoslynNvimRootDir -function M.root(buffer) - local targets = find_targets(buffer) - if not targets.csproj_dir then - return { - solution_filters = {}, - solutions = {}, - projects = nil, - } +---@param bufnr number +---@param solutions string[] +function M.root_dir(bufnr, solutions) + if #solutions == 1 then + return vim.fs.dirname(solutions[1]) end - local projects = { - files = find_files_with_extensions(targets.csproj_dir, { ".csproj" }), - directory = targets.csproj_dir, - } - - local sln = targets.sln_dir - local slnf = targets.slnf_dir - - if not require("roslyn.config").get().broad_search then - return { - solutions = sln and find_files_with_extensions(sln, { ".sln", ".slnx" }) or {}, - solution_filters = slnf and find_files_with_extensions(slnf, { ".slnf" }) or {}, - projects = projects, - } - end - - local git_root = vim.fs.root(buffer, ".git") - if not sln and not git_root then - return { - solutions = {}, - solution_filters = {}, - projects = projects, - } - end + local csproj = vim.fs.find(function(name) + return name:match("%.csproj$") ~= nil + end, { upward = true, path = vim.api.nvim_buf_get_name(bufnr) })[1] - local search_root - if sln and git_root then - search_root = git_root and sln:find(git_root, 1, true) and git_root or sln + local filtered_targets = filter_targets(solutions, csproj) + if #filtered_targets > 1 then + local config = require("roslyn.config").get() + local chosen = config.choose_target and config.choose_target(filtered_targets) + if chosen then + return vim.fs.dirname(chosen) + else + return vim.notify( + "Multiple potential target files found. Use `:Roslyn target` to select a target.", + vim.log.levels.INFO, + { title = "roslyn.nvim" } + ) + end else - search_root = sln or git_root --[[@as string]] + local selected_solution = vim.g.roslyn_nvim_selected_solution + return vim.fs.dirname(filtered_targets[1]) + or selected_solution and vim.fs.dirname(selected_solution) + or csproj and vim.fs.dirname(csproj) end - - local solutions, solution_filters = find_solutions(search_root) - - return { - solutions = solutions, - solution_filters = solution_filters, - projects = projects, - } end ----Tries to predict which target to use if we found some ----returning the potentially predicted target ----@param root RoslynNvimRootDir ----@return boolean multiple, string? predicted_target -function M.predict_target(root) +---@param bufnr number +---@param targets string[] +---@return string? +function M.predict_target(bufnr, targets) local config = require("roslyn.config").get() - local sln_api = require("roslyn.sln.api") - local filtered_targets = vim.iter({ root.solutions, root.solution_filters }) - :flatten() - :filter(function(target) - if config.ignore_target and config.ignore_target(target) then - return false - end - - return not root.projects - or vim.iter(root.projects.files):any(function(csproj_file) - return sln_api.exists_in_target(target, csproj_file) - end) - end) - :totable() + local csproj = vim.fs.find(function(name) + return name:match("%.csproj$") ~= nil + end, { upward = true, path = vim.api.nvim_buf_get_name(bufnr) })[1] + local filtered_targets = filter_targets(targets, csproj) if #filtered_targets > 1 then - local chosen = config.choose_target and config.choose_target(filtered_targets) - - if chosen then - return false, chosen - end - - return true, nil + return config.choose_target and config.choose_target(filtered_targets) or nil else - return false, filtered_targets[1] + return filtered_targets[1] end end diff --git a/test/helpers.lua b/test/helpers.lua index ff6096f..f931e71 100644 --- a/test/helpers.lua +++ b/test/helpers.lua @@ -232,22 +232,42 @@ function M.create_slnx_file(path, projects) return M.create_file(path, sln_string) end -function M.get_root(file_path) +function M.get_root_dir(file_path, solutions) command("edit " .. vim.fs.joinpath(M.scratch, file_path)) + return helpers.exec_lua(function(path, solutions0) + package.path = path + local bufnr = vim.api.nvim_get_current_buf() + return require("roslyn.sln.utils").root_dir(bufnr, solutions0) + end, package.path, solutions) +end + +function M.find_solutions(file_path) + command("edit " .. vim.fs.joinpath(M.scratch, file_path)) + return helpers.exec_lua(function(path) + package.path = path + local bufnr = vim.api.nvim_get_current_buf() + return require("roslyn.sln.utils").find_solutions(bufnr) + end, package.path) +end + +function M.find_solutions_broad(file_path) + command("edit " .. vim.fs.joinpath(M.scratch, file_path)) return helpers.exec_lua(function(path) package.path = path local bufnr = vim.api.nvim_get_current_buf() - return require("roslyn.sln.utils").root(bufnr) + return require("roslyn.sln.utils").find_solutions_broad(bufnr) end, package.path) end ---@return string? -function M.predict_target(root) - return helpers.exec_lua(function(path, root0) +function M.predict_target(file_path, targets) + command("edit " .. vim.fs.joinpath(M.scratch, file_path)) + return helpers.exec_lua(function(path, targets0) package.path = path - return require("roslyn.sln.utils").predict_target(root0) - end, package.path, root) + local bufnr = vim.api.nvim_get_current_buf() + return require("roslyn.sln.utils").predict_target(bufnr, targets0) + end, package.path, targets) end function M.api_projects(target) diff --git a/test/predict_spec.lua b/test/predict_spec.lua index 2a4ea41..6ad4c59 100644 --- a/test/predict_spec.lua +++ b/test/predict_spec.lua @@ -4,7 +4,6 @@ local system = helpers.fn.system local create_file = helpers.create_file local create_sln_file = helpers.create_sln_file local predict_target = helpers.predict_target -local get_root = helpers.get_root local scratch = helpers.scratch local setup = helpers.setup @@ -27,8 +26,11 @@ describe("predicts", function() { name = "Baz", path = [[Foo\Bar\Baz.csproj]] }, }) - local root = get_root("Program.cs") - local _, target = predict_target(root) + local targets = { + vim.fs.joinpath(scratch, "Foo.sln"), + } + + local target = predict_target("Program.cs", targets) assert.are_same(vim.fs.joinpath(scratch, "Foo.sln"), target) end) @@ -40,8 +42,11 @@ describe("predicts", function() { name = "Baz", path = [[Foo\Bar\Baz.csproj]] }, }) - local root = get_root("Program.cs") - local _, target = predict_target(root) + local targets = { + vim.fs.joinpath(scratch, "Foo.sln"), + } + + local target = predict_target("Program.cs", targets) assert.is_nil(target) end) @@ -59,8 +64,12 @@ describe("predicts", function() { name = "Baz", path = [[Foo\Bar\Baz.csproj]] }, }) - local root = get_root("Program.cs") - local _, target = predict_target(root) + local targets = { + vim.fs.joinpath(scratch, "Foo.sln"), + vim.fs.joinpath(scratch, "FooBar.sln"), + } + + local target = predict_target("Program.cs", targets) assert.are_same(vim.fs.joinpath(scratch, "FooBar.sln"), target) end) @@ -78,8 +87,12 @@ describe("predicts", function() { name = "Baz", path = [[Foo\Bar\Baz.csproj]] }, }) - local root = get_root("Program.cs") - local _, target = predict_target(root) + local targets = { + vim.fs.joinpath(scratch, "Foo.sln"), + vim.fs.joinpath(scratch, "FooBar.sln"), + } + + local target = predict_target("Program.cs", targets) assert.is_nil(target) end) @@ -99,8 +112,12 @@ describe("predicts", function() { name = "Baz", path = [[Foo\Bar\Baz.csproj]] }, }) - local root = get_root("Program.cs") - local _, target = predict_target(root) + local targets = { + vim.fs.joinpath(scratch, "Foo.sln"), + vim.fs.joinpath(scratch, "FooBar.sln"), + } + + local target = predict_target("Program.cs", targets) assert.are_same(vim.fs.joinpath(scratch, "FooBar.sln"), target) end) @@ -120,8 +137,12 @@ describe("predicts", function() { name = "Baz", path = [[Foo\Bar\Baz.csproj]] }, }) - local root = get_root("Program.cs") - local _, target = predict_target(root) + local targets = { + vim.fs.joinpath(scratch, "Foo.sln"), + vim.fs.joinpath(scratch, "FooBar.sln"), + } + + local target = predict_target("Program.cs", targets) assert.are_same(vim.fs.joinpath(scratch, "Foo.sln"), target) end) end) diff --git a/test/root_spec.lua b/test/root_spec.lua index 4603278..26451d0 100644 --- a/test/root_spec.lua +++ b/test/root_spec.lua @@ -2,13 +2,14 @@ local helpers = require("test.helpers") local clear = helpers.clear local system = helpers.fn.system local create_file = helpers.create_file -local get_root = helpers.get_root -local setup = helpers.setup +local get_root_dir = helpers.get_root_dir +local find_solutions = helpers.find_solutions +local find_solutions_broad = helpers.find_solutions_broad local scratch = helpers.scratch helpers.env() -describe("root tests", function() +describe("root_dir tests", function() after_each(function() system({ "rm", "-rf", scratch }) end) @@ -17,98 +18,58 @@ describe("root tests", function() system({ "mkdir", "-p", vim.fs.joinpath(scratch, ".git") }) end) - it("requires a project file", function() - create_file("Program.cs") - create_file("Foo.sln") - - local root = get_root("Program.cs") - - assert.is_nil(root.projects) - assert.are_same({}, root.solution_filters) - assert.are_same({}, root.solutions) - end) - - it("finds a project file", function() + it("finds a root_dir of project file", function() create_file("Program.cs") create_file("Foo.csproj") - local root = get_root("Program.cs") + local solutions = find_solutions("Program.cs") + local root_dir = get_root_dir("Program.cs", solutions) - assert.are_same({ vim.fs.joinpath(scratch, "Foo.csproj") }, root.projects.files) - assert.are_same({}, root.solution_filters) - assert.are_same({}, root.solutions) + assert.are_same(scratch, root_dir) end) - it("finds a sln file", function() + it("finds root_dir of sln file", function() create_file("src/Foo/Program.cs") create_file("src/Foo/Foo.csproj") create_file("src/Bar.sln") - local root = get_root("src/Foo/Program.cs") + local solutions = find_solutions("src/Foo/Program.cs") + local root_dir = get_root_dir("src/Foo/Program.cs", solutions) - assert.are_same({ vim.fs.joinpath(scratch, "src/Foo/Foo.csproj") }, root.projects.files) - assert.are_same({ vim.fs.joinpath(scratch, "src/Bar.sln") }, root.solutions) - - assert.are_same({}, root.solution_filters) + assert.are_same(vim.fs.joinpath(scratch, "src"), root_dir) end) - it("requires a project file with broad search", function() - setup({ broad_search = true }) - - create_file("Program.cs") - create_file("Foo.sln") - - local root = get_root("Program.cs") - - assert.are_same({}, root.solution_filters) - assert.are_same({}, root.solutions) - assert.is_nil(root.projects) - end) - - it("finds a sln file with broad search and one solution in git root", function() - setup({ broad_search = true }) - + it("fallback to csproj, multiple solutions, cs file not related to solution", function() create_file("src/Foo/Program.cs") create_file("src/Foo/Foo.csproj") create_file("src/Bar/Bar.sln") create_file("src/Baz.sln") - local root = get_root("src/Foo/Program.cs") + local solutions = find_solutions_broad("src/Foo/Program.cs") + local root_dir = get_root_dir("src/Foo/Program.cs", solutions) - assert.are_same({ vim.fs.joinpath(scratch, "src/Foo/Foo.csproj") }, root.projects.files) - assert.are_same({ - vim.fs.joinpath(scratch, "src/Baz.sln"), - vim.fs.joinpath(scratch, "src/Bar/Bar.sln"), - }, root.solutions) - - assert.are_same({}, root.solution_filters) + assert.are_same(vim.fs.joinpath(scratch, "src", "Foo"), root_dir) end) - it("finds a sln file with broad search and no solution in git root", function() - setup({ broad_search = true }) - + it("finds root of sln file with broad search and no solution in git root", function() create_file("src/Foo/Program.cs") create_file("src/Foo/Foo.csproj") create_file("src/Bar/Bar.sln") - local root = get_root("src/Foo/Program.cs") + local solutions = find_solutions_broad("src/Foo/Program.cs") + local root_dir = get_root_dir("src/Foo/Program.cs", solutions) - assert.are_same({ vim.fs.joinpath(scratch, "src/Foo/Foo.csproj") }, root.projects.files) - assert.are_same({ vim.fs.joinpath(scratch, "src/Bar/Bar.sln") }, root.solutions) - - assert.are_same({}, root.solution_filters) + assert.are_same(vim.fs.joinpath(scratch, "src", "Bar"), root_dir) end) it("finds a slnf file with broad search and no solution in git root", function() - setup({ broad_search = true }) - create_file("src/Foo/Program.cs") create_file("src/Foo/Foo.csproj") create_file("src/Bar/Bar.slnf") - local root = get_root("src/Foo/Program.cs") + local solutions = find_solutions_broad("src/Foo/Program.cs") + local root_dir = get_root_dir("src/Foo/Program.cs", solutions) - assert.are_same({ vim.fs.joinpath(scratch, "src/Foo/Foo.csproj") }, root.projects.files) - assert.are_same({ vim.fs.joinpath(scratch, "src/Bar/Bar.slnf") }, root.solution_filters) + assert.are_same(vim.fs.joinpath(scratch, "src", "Bar"), root_dir) end) end) diff --git a/test/solution_finder_spec.lua b/test/solution_finder_spec.lua new file mode 100644 index 0000000..d7f2a18 --- /dev/null +++ b/test/solution_finder_spec.lua @@ -0,0 +1,58 @@ +local helpers = require("test.helpers") +local clear = helpers.clear +local system = helpers.fn.system +local create_file = helpers.create_file +local find_solutions = helpers.find_solutions +local find_solutions_broad = helpers.find_solutions_broad +local scratch = helpers.scratch + +helpers.env() + +describe("find_solution tests", function() + after_each(function() + system({ "rm", "-rf", scratch }) + end) + before_each(function() + clear() + system({ "mkdir", "-p", vim.fs.joinpath(scratch, ".git") }) + end) + + it("finds solutions", function() + create_file("src/Foo/Program.cs") + create_file("src/Foo.sln") + + local solutions = find_solutions("src/Foo/Program.cs") + assert.are_same({ vim.fs.joinpath(scratch, "src", "Foo.sln") }, solutions) + end) + + it("ignores broad solutions with regular", function() + create_file("src/Foo/Program.cs") + create_file("src/Bar/Foo.sln") + + local solutions = find_solutions("src/Foo/Program.cs") + assert.are_same({}, solutions) + end) + + it("finds solutions broad", function() + create_file("src/Foo/Program.cs") + create_file("src/Bar/Foo.sln") + create_file("src/Baz/Foo.sln") + + local solutions = find_solutions_broad("src/Foo/Program.cs") + assert.are_same({ + vim.fs.joinpath(scratch, "src", "Bar", "Foo.sln"), + vim.fs.joinpath(scratch, "src", "Baz", "Foo.sln"), + }, solutions) + end) + + it("ignores bin, obj and .git directories", function() + create_file("src/Foo/Program.cs") + create_file("src/bin/Foo.sln") + create_file("src/obj/Foo.sln") + create_file("src/.git/Foo.sln") + + local solutions = find_solutions_broad("src/Foo/Program.cs") + + assert.are_same({}, solutions) + end) +end)