diff --git a/README-img/2024-08-08-at-00-09-36.png b/README-img/2024-08-08-at-00-09-36.png deleted file mode 100644 index 4df76f1..0000000 Binary files a/README-img/2024-08-08-at-00-09-36.png and /dev/null differ diff --git a/README-img/search.png b/README-img/search.png new file mode 100644 index 0000000..6f14d01 Binary files /dev/null and b/README-img/search.png differ diff --git a/README-img/sln.png b/README-img/sln.png new file mode 100644 index 0000000..19f592a Binary files /dev/null and b/README-img/sln.png differ diff --git a/README-img/target.png b/README-img/target.png new file mode 100644 index 0000000..4b3e104 Binary files /dev/null and b/README-img/target.png differ diff --git a/README-img/version.png b/README-img/version.png new file mode 100644 index 0000000..dfc84c5 Binary files /dev/null and b/README-img/version.png differ diff --git a/README.md b/README.md index 0c4ca2d..ffefc48 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,38 @@ # NuGet Plugin for Neovim -![2024-08-08-at-00-09-36.png](README-img/2024-08-08-at-00-09-36.png) +![](README-img/search.png) This Neovim plugin allows you to manage NuGet packages within your .NET projects using Telescope for an interactive interface. It provides two main commands: `NuGetInstall` and `NuGetRemove`. -- `NuGetInstall`: Searches for a package with the provided search term, displays the results in Telescope with package details, and allows you to select a package version to install. -- `NuGetRemove`: Removes a package from the installed packages in the .NET project. +- `NuGetInstall`: Opens a 3-stage picker to select a target `.sln` or `.csproj`, search for a NuGet package to install, pick the desired version, and install it. + - If a solution is picked, the second picker will only show packages installed in at least one project. Installed packages will be applied to all projects that already had that package, thus upgrading and/or consolidating installs. + - If a project is picked, the second picker will use `dotnet packages search` to search for the package, and only install it on the selected project. +- `NuGetRemove`: Select a `.csproj` and remove packages from the installed packages in the .NET project. +- `NuGetClearCache`: Clears the internal cache used by the plugin. Not to be confused with NuGets own cache. ## Requirements - Neovim 0.5.0 or later - [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) -- [.NET SDK](https://dotnet.microsoft.com/en-us/download) +- [fidget.nvim](https://github.com/j-hui/fidget.nvim) (optional) +- [.NET SDK](https://dotnet.microsoft.com/en-us/download) 8.0.2xx or later + - This package uses [`dotnet package search`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-package-search) to search NuGet repositories, which was made available starting with the .NET 8.0.2 SDK. +- [fd](https://github.com/sharkdp/fd) ## Installation ### Using [lazy.nvim](https://github.com/folke/lazy.nvim) -If telescope.nvim and plenary.nvim are not already installed: - ```lua -- init.lua: { - "d7omdev/nuget.nvim", - dependencies = { - "nvim-lua/plenary.nvim", - "nvim-telescope/telescope.nvim", - }, - config = function() - require("nuget").setup() - end, -} - --- or plugins/nuget.lua -return { - "d7omdev/nuget.nvim", - dependencies = { - "nvim-lua/plenary.nvim", - "nvim-telescope/telescope.nvim", - }, - config = function() - require("nuget").setup() - end, -} -``` - -If telescope.nvim and plenary.nvim are already installed: - -```lua -{ - "d7omdev/nuget.nvim", - config = function() - require("nuget").setup() - end, + "d7omdev/nuget.nvim", + dependencies = { + "nvim-lua/plenary.nvim", + "nvim-telescope/telescope.nvim", + -- optional, show dotnet command output through fidget instead of vim.notify + "j-hui/fidget.nvim", + } } ``` @@ -62,6 +42,7 @@ If telescope.nvim and plenary.nvim are already installed: - `:NuGetInstall` - Search and install a NuGet package. - `:NuGetRemove` - Remove an installed NuGet package. +- `:NuGetClearCache` - Clear the internal cache. ### Keymaps @@ -69,6 +50,7 @@ Default keymaps are provided but can be overridden in the setup function. - `ni` - Install a NuGet package. - `nr` - Remove a NuGet package. +- `nc` - Clear nuget.nvim cache. # Configuration @@ -96,12 +78,64 @@ require("nuget").setup({ -- action = {"mode", "mapping"} install = { "n", "pi" }, remove = { "n", "pr" }, + clear_cache = { "n", "pc" }, } }) ``` This will override the default keymaps with the ones you provide. +### Project File Parsing + +By default, the plugin will parse `.sln` and `.csproj` files with regular expressions. This is fast but error-prone. You can change the strategy to use `dotnet list`, which is more robust but slower. + +```lua +require("nuget").setup({ + dotnet = { + -- can be "parse" or "dotnet" + method = "dotnet" + } +}) +``` + +### Picker Options + +The install picker can be called with some custom options to configure how packages are managed. + +```lua +require("nuget").setup({}) +vim.keymap.set("n", "na", function() + require("nuget.install")({ dotnet = { prerelease = true } }) +end, { desc = "Install a NuGet prerelease package" }) +``` + +It takes the following options: + +```lua +{ + dotnet = { + -- a list of string urls for any additional NuGet sources + -- these will be passed to dotnet commands as `--source` arguments + sources = {}, + -- enables `--prerelease` on dotnet commands + prerelease = true, + -- change the main binary used to call the dotnet api + dotnet_bin = "dotnet" + } +} +``` + +## Gallery + +### Solution Search +![](README-img/sln.png) + +### Version Select +![](README-img/version.png) + +### Target Search +![](README-img/target.png) + ## Contribution If you want to contribute to this project, feel free to submit a pull request. If you find any bugs or have suggestions for improvements, please open an issue. All contributions are welcome! diff --git a/lua/nuget/dotnet.lua b/lua/nuget/dotnet.lua new file mode 100644 index 0000000..f441461 --- /dev/null +++ b/lua/nuget/dotnet.lua @@ -0,0 +1,562 @@ +local notify = require("nuget.notify") +local utils = require("nuget.utils") +local M = {} + +---@class dotnet_module_opts +---@field method "parse" | "dotnet" | nil which method to use to retrieve the packages. Parse the files or use `dotnet list` + +---@type dotnet_module_opts +local _opts = { method = "parse" } + +---Set up module options +---@param opts dotnet_module_opts +function M.setup(opts) + _opts = opts +end + +---@class dotnet_opts +---@field dotnet_bin string binary to use for dotnet commands +---@field sources string[]? additional NuGet.config sources + +---@class (exact) dotnet_package +---@field mixed_versions boolean whether the projects contain multiple different versions +---@field projects { path: string, version: string } projects that contain this package +---@field version string the lowest version used by any of the projects + +---@alias dotnet_packages { [string]: dotnet_package } + +---@class (exact) dotnet_version +---@field latest string latest version +---@field versions string[] all versions, sorted +---@field description string? +---@field project_url string? + +---@alias dotnet_versions { [string]: dotnet_version } + +---@alias dotnet_map { [string]: { sln: string? }} + +--- Parses a .sln file and returns a list of absolute .csproj paths +--- @param sln_path string Absolute path to the .sln file +--- @return string[] List of absolute .csproj paths +parse_sln = function(sln_path) + local sln_dir = vim.fn.fnamemodify(sln_path, ":h") + local lines = vim.fn.readfile(sln_path) + local projects = {} + + for _, line in ipairs(lines) do + local rel_path = line:match('"([^"]+%.csproj)"') + if rel_path then + rel_path = rel_path:gsub("\\", "/") + table.insert(projects, sln_dir .. "/" .. rel_path) + end + end + + return projects +end + +-- Retrieve all the packages used by the given target +---@param target string .sln or .csproj file to retrieve packages from. .sln means get packages from all related csprojs. +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages): nil called once with the retrieved packages +M.get_installed_packages = function(target, opts, callback) + local ext = vim.fn.fnamemodify(target, ":e") + if ext == "sln" then + M.get_installed_packages_sln(target, opts, callback) + else + M.get_installed_packages_csproj(target, opts, callback) + end +end + +-- Retrieve all the packages used by the given target solution +---@param target string .sln file to retrieve packages from (via all child csprojs) +---@param opts dotnet_opts +---@param callback fun(packages: { [string]: dotnet_package } ): nil called once with the retrieved packages +M.get_installed_packages_sln = function(target, opts, callback) + if _opts.method == "dotnet" then + M.get_installed_packages_dotnet(target, opts, callback) + else + M.get_installed_packages_parse_sln(target, opts, callback) + end +end + +-- Retrieve all the packages used by the given target solution by parsing the files +---@param target string .sln file to retrieve packages from (via all child csprojs) +---@param opts dotnet_opts +---@param callback fun(packages: { [string]: dotnet_package } ): nil called once with the retrieved packages +M.get_installed_packages_parse_sln = function(target, opts, callback) + local sln_dir = vim.fn.fnamemodify(target, ":h") + local lines = vim.fn.readfile(target) + local map = {} + for _, abs_path in ipairs(parse_sln(target)) do + M.get_installed_packages_parse_csproj(abs_path, opts, function(proj_map) + for id, entry in pairs(proj_map) do + if not map[id] then + map[id] = { projects = {}, mixed_versions = false } + end + table.insert(map[id].projects, entry.projects[1]) + end + end) + end + for _, entry in pairs(map) do + local first = entry.projects[1] and entry.projects[1].version + for _, proj in ipairs(entry.projects) do + if proj.version ~= first then + entry.mixed_versions = true + break + end + end + local versions = vim.tbl_map(function(p) return p.version end, entry.projects) + table.sort(versions, utils.version_lt) + entry.version = versions[1] + end + callback(map) +end + +-- Retrieve all the packages used by the given target csproj by parsing the file +---@param target string .csproj file to retrieve packages from +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages ): nil called once with the retrieved packages +M.get_installed_packages_csproj = function(target, opts, callback) + if _opts.method == "dotnet" then + M.get_installed_packages_dotnet(target, opts, callback) + else + M.get_installed_packages_parse_csproj(target, opts, callback) + end +end + +-- Retrieve all the packages used by the given target csproj by parsing the file +---@param target string .csproj file to retrieve packages from +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages ): nil called once with the retrieved packages +M.get_installed_packages_parse_csproj = function(target, opts, callback) + local lines = vim.fn.readfile(target) + local content = table.concat(lines, "\n") + local map = {} + for id, version in content:gmatch(']+Include="' .. + id .. '"[^>]*>%s*([^<]+)') + map[id] = { projects = { { path = target, version = version } }, mixed_versions = false, version = version } + end + end + callback(map) +end + +-- Retrieve all the packages used by the given target csprojs by parsing the files +---@param targets string[] .csproj file to retrieve packages from +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages ): nil called once with the retrieved packages +M.get_installed_packages_csprojs = function(targets, opts, callback) + if _opts.method == "dotnet" then + M.get_installed_packages_dotnet_multi(targets, opts, callback) + else + M.get_installed_packages_parse_csprojs(targets, opts, callback) + end +end + +-- Retrieve all the packages used by the given target csprojs by parsing the files +---@param targets string[] .csproj file to retrieve packages from +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages ): nil called once with the retrieved packages +M.get_installed_packages_parse_csprojs = function(targets, opts, callback) + local map = {} + for _, target in ipairs(targets) do + M.get_installed_packages_parse_csproj(target, opts, function(proj_map) + for id, entry in pairs(proj_map) do + if not map[id] then + map[id] = { projects = {}, mixed_versions = false } + end + table.insert(map[id].projects, entry.projects[1]) + end + end) + end + for _, entry in pairs(map) do + local first = entry.projects[1] and entry.projects[1].version + for _, proj in ipairs(entry.projects) do + if proj.version ~= first then + entry.mixed_versions = true + break + end + end + local versions = vim.tbl_map(function(p) return p.version end, entry.projects) + table.sort(versions, utils.version_lt) + entry.version = versions[1] + end + callback(map) +end + + +-- Retrieve all the packages used by the given target using `dotnet list` +---@param targets string[] .sln or .csproj files to retrieve packages from. .sln means get packages from all related csprojs. +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages): nil called once with the retrieved packages +M.get_installed_packages_dotnet_multi = function(targets, opts, callback) + if #targets == 0 then + callback({}) + return + end + + local map = {} + local pending = #targets + + local function merge(packages) + for id, entry in pairs(packages) do + if not map[id] then + map[id] = { projects = {}, mixed_versions = false } + end + vim.list_extend(map[id].projects, entry.projects) + end + + pending = pending - 1 + if pending == 0 then + for _, entry in pairs(map) do + local first = entry.projects[1] and entry.projects[1].version + for _, proj in ipairs(entry.projects) do + if proj.version ~= first then + entry.mixed_versions = true + break + end + end + local versions = vim.tbl_map(function(p) return p.version end, entry.projects) + table.sort(versions, utils.version_lt) + entry.version = versions[1] + end + callback(map) + end + end + + for _, target in ipairs(targets) do + M.get_installed_packages_dotnet(target, opts, merge) + end +end + +-- Retrieve all the packages used by the given target using `dotnet list` +---@param target string .sln or .csproj file to retrieve packages from. .sln means get packages from all related csprojs. +---@param opts dotnet_opts +---@param callback fun(packages: dotnet_packages): nil called once with the retrieved packages +M.get_installed_packages_dotnet = function(target, opts, callback) + local cmd = { opts.dotnet_bin or "dotnet", "list", target, "package", "--format", "json" } + vim.system(cmd, function(result) + local ok, decoded = pcall(vim.json.decode, result.stdout or "") + if not ok or not decoded then + callback({}) + return + end + local map = {} + for _, proj in ipairs(decoded.projects or {}) do + local proj_path = vim.fn.fnamemodify(proj.path or proj.name or "?", ":.") + for _, fw in ipairs(proj.frameworks or {}) do + for _, pkg in ipairs(fw.topLevelPackages or {}) do + local id = pkg.id + if id then + if not map[id] then + map[id] = { projects = {}, mixed_versions = false } + end + table.insert(map[id].projects, { + path = proj_path, + version = pkg.resolvedVersion or pkg.requestedVersion, + }) + end + end + end + end + for _, entry in pairs(map) do + local first = entry.projects[1] and entry.projects[1].version + for _, proj in ipairs(entry.projects) do + if proj.version ~= first then + entry.mixed_versions = true + break + end + end + local versions = vim.tbl_map(function(p) return p.version end, entry.projects) + table.sort(versions, utils.version_lt) + entry.version = versions[1] + end + callback(map) + end) +end + + +local function build_search_command(query, opts) + local cmd = { opts.dotnet_bin or "dotnet", "package", "search", query } + for _, source in ipairs(opts.sources or {}) do + table.insert(cmd, "--source") + table.insert(cmd, source) + end + if opts.prerelease then table.insert(cmd, "--prerelease") end + if opts.exact_match then + table.insert(cmd, "--exact-match") + else + table.insert(cmd, "--take") + table.insert(cmd, "10") + end + table.insert(cmd, "--format") + table.insert(cmd, "json") + table.insert(cmd, "--verbosity") + table.insert(cmd, opts.verbosity or "detailed") + return cmd +end + + +---@type dotnet_versions +local version_cache = {} + +local function cache_key(id, opts) + return id:lower() .. (opts.prerelease and ":pre" or "") +end + +---Fetch the latest versions of the given package, cached in the module +---@param id string package to fetch +---@param opts dotnet_opts +---@param callback fun(ok: boolean, versions: dotnet_versions?): nil +M.get_latest_versions = function(id, opts, callback) + local key = cache_key(id, opts) + local progress = notify.make_progress("get latest " .. key) + + if version_cache[key] then + progress.finish("found in cache") + callback(true, version_cache[key]) + return + end + + local cmd = build_search_command(id, vim.tbl_extend("force", opts, { + exact_match = true, + verbosity = "detailed", + })) + + progress.report("dotnet search" .. key) + vim.system(cmd, {}, function(result) + if result.code ~= 0 then + progress.finish("failed with exit code " .. tostring(result.code)) + callback(false, nil) + return + end + local ok, decoded = pcall(vim.json.decode, result.stdout or "") + if not ok or not decoded then + progress.finish("failed to decode result") + callback(false, nil) + return + end + + ---@type string[] + local versions = {} + ---@type string | nil + local description = nil + ---@type string | nil + local project_url = nil + for _, source in ipairs(decoded.searchResult or {}) do + for _, pkg in ipairs(source.packages or {}) do + if pkg.id and pkg.id:lower() == id:lower() then + table.insert(versions, pkg.version) + end + if pkg.description then + description = pkg.description + end + if pkg.projectUrl then project_url = pkg.projectUrl end + end + end + + if #versions == 0 then + progress.finish("Found 0 versions") + callback(false, nil) + return + end + + utils.sort_versions(versions, true) + ---@type dotnet_version + local entry = { + latest = versions[1], + versions = versions, + description = description, + project_url = project_url + } + version_cache[key] = entry + progress.finish("Found " .. tostring(#versions) .. " versions") + callback(true, entry) + end) +end + +M.purge_version_cache = function(id, opts) + if id then + version_cache[cache_key(id, opts or {})] = nil + else + version_cache = {} + end +end + +---@class dotnet_search_result +---@field id string +---@field version string +---@field downloads number +---@field owners string? +---@field description string? +---@field project_url string? + +---Parse output from `dotnet packages search` +---@param json_str string stdout +---@return dotnet_search_result[] +local function parse_package_search_results(json_str) + local ok, decoded = pcall(vim.json.decode, json_str) + if not ok or not decoded then return {} end + local packages = {} + for _, source in ipairs(decoded.searchResult or {}) do + for _, pkg in ipairs(source.packages or {}) do + if type(pkg) == "table" and pkg.id then + table.insert(packages, { + id = pkg.id, + version = pkg.latestVersion or "unknown", + downloads = pkg.totalDownloads or 0, + owners = pkg.owners or "", + description = pkg.description or "", + project_url = pkg.projectUrl or "", + }) + end + end + end + return packages +end + +---Search packages with `dotnet package search` +---@param query string search string +---@param opts dotnet_opts +---@param callback fun(ok: boolean, packages: dotnet_search_result?) +M.search_packages = function(query, opts, callback) + vim.system(build_search_command(query, opts), {}, function(result) + if result.code ~= 0 then + callback(false, nil) + return + end + local packages = parse_package_search_results(result.stdout) + callback(true, packages) + end) +end + + +---Install a package to the given csproj +---@param target string path to .csproj +---@param id string package to install +---@param version string version to install +---@param opts dotnet_opts +---@param callback fun(ok: boolean, stdout: string, stderr: string) +M.install_package = function(target, id, version, opts, callback) + local label = id .. " " .. version + local progress = notify.make_progress("Installing " .. label) + + local cmd = { opts.dotnet_bin or "dotnet", "add", target, "package", id, "--version", version } + for _, source in ipairs(opts.sources or {}) do + vim.list_extend(cmd, { "--source", source }) + end + + vim.system(cmd, { + stdout = function(_, data) + if not data then return end + for _, line in ipairs(vim.split(data, "\n", { plain = true })) do + if line ~= "" then + vim.schedule(function() progress.report(line) end) + end + end + end, + }, function(result) + vim.schedule(function() + if result.code == 0 then + progress.finish("Installed " .. label) + else + progress.cancel("Failed " .. label) + end + callback(result.code == 0, result.stdout, result.stderr) + end) + end) +end + +---Remove a package from the given csproj +---@param target string path to .csproj +---@param id string package to remove +---@param opts dotnet_opts +---@param callback fun(ok: boolean, stdout: string, stderr: string) +M.remove_package = function(target, id, opts, callback) + local progress = notify.make_progress("Removing " .. id) + + local cmd = { opts.dotnet_bin or "dotnet", "remove", target, "package", id } + + vim.system(cmd, { + stdout = function(_, data) + if not data then return end + for _, line in ipairs(vim.split(data, "\n", { plain = true })) do + if line ~= "" then + vim.schedule(function() progress.report(line) end) + end + end + end, + }, function(result) + vim.schedule(function() + if result.code == 0 then + progress.finish("Removed " .. id) + else + progress.cancel("Failed to remove " .. id) + end + callback(result.code == 0, result.stdout, result.stderr) + end) + end) +end + + +---finds all csprojs, then returns a list of all of them, and their parent sln, if any +---@param opts dotnet_opts +---@return dotnet_map +M.build_project_map = function(opts) + local cwd = vim.fn.getcwd() + local fd = "fd" + if 1 ~= vim.fn.executable "fd" then + fd = "fdfind" + end + local lines = vim.fn.systemlist(fd .. " --type f --color never -e csproj -e sln --exclude .git -L", cwd) + + local sln_files = {} + local csproj_files = {} + for _, line in ipairs(lines) do + if line ~= "" then + if line:match("%.sln$") then + table.insert(sln_files, line) + else + table.insert(csproj_files, line) + end + end + end + local map = {} + for _, csproj in ipairs(csproj_files) do + map[csproj] = { sln = nil } + end + for _, sln in ipairs(sln_files) do + local sln_dir = vim.fn.fnamemodify(sln, ":h") + local csproj_paths + + if _opts.method == "dotnet" then + vim.notify('foo') + local sln_lines = vim.fn.systemlist(opts.dotnet_bin .. + " sln " .. vim.fn.shellescape(cwd .. "/" .. sln) .. " list") + csproj_paths = {} + for _, line in ipairs(sln_lines) do + local rel = line:gsub("\\", "/"):match("([^\r]+%.csproj)") + if rel then + local joined = sln_dir ~= "." and (sln_dir .. "/" .. rel) or rel + table.insert(csproj_paths, vim.fn.fnamemodify(joined, ":.")) + end + end + else + csproj_paths = vim.tbl_map(function(p) + return vim.fn.fnamemodify(p, ":.") + end, parse_sln(sln)) + end + for _, norm in ipairs(csproj_paths) do + if map[norm] and map[norm].sln == nil then + map[norm].sln = sln + end + end + end + return map +end + +return M diff --git a/lua/nuget/finders/async_finder.lua b/lua/nuget/finders/async_finder.lua new file mode 100644 index 0000000..0fbfac4 --- /dev/null +++ b/lua/nuget/finders/async_finder.lua @@ -0,0 +1,63 @@ +-- based off https://gist.github.com/chuwy/e8476156d6dd4815611228dc96196554 +local make_entry = require("telescope.make_entry") + +return function(opts) + local entry_maker = opts.entry_maker or make_entry.gen_from_string(opts) + local debounce_ms = opts.debounce_ms or 500 + local timer = nil + local cancelled = false + local generation = 0 -- increments on each new search + + local function debounce(thunk) + if timer ~= nil then timer:stop() end + timer = vim.uv.new_timer() + timer:start(debounce_ms, 0, function() + thunk() + timer:stop() + end) + end + + local callable = function(_, prompt, process_result, process_complete) + if not prompt or prompt == "" then + if opts.initial_results then + for i, item in ipairs(opts.initial_results) do + local entry = entry_maker(item) + if entry then + entry.index = i + process_result(entry) + end + end + end + process_complete() + return + end + generation = generation + 1 + local my_generation = generation + debounce(function() + opts.async_fn(prompt, function(i, item) + if cancelled or generation ~= my_generation then return end + local entry = entry_maker(item) + if entry then + entry.index = i + vim.schedule(function() + if generation ~= my_generation then return end + process_result(entry) + end) + end + end, function() + if cancelled or generation ~= my_generation then return end + vim.schedule(process_complete) + end) + end) + end + + return setmetatable({ + close = function() + cancelled = true + if timer ~= nil then + timer:stop() + timer = nil + end + end, + }, { __call = callable }) +end diff --git a/lua/nuget/init.lua b/lua/nuget/init.lua index a83ebf2..6d4ba21 100644 --- a/lua/nuget/init.lua +++ b/lua/nuget/init.lua @@ -2,56 +2,77 @@ local nuget = {} -- Load functionalities local remove = require("nuget.remove") -local search = require("nuget.search") +local install = require("nuget.install") +local dotnet = require("nuget.dotnet") -- Default keymaps local default_keys = { - install = { "n", "ni" }, - remove = { "n", "nr" }, + install = { "n", "ni" }, + remove = { "n", "nr" }, + clear_cache = { "n", "nc" }, } -- Set the commands to ensure they are always available vim.api.nvim_create_user_command("NuGetInstall", function() - search.search_packages() + install() end, {}) vim.api.nvim_create_user_command("NuGetRemove", function() - remove.remove_package() + remove() +end, {}) + +vim.api.nvim_create_user_command("NuGetClearCache", function() + dotnet.purge_version_cache() + vim.notify("nuget.nvim cache cleared!") end, {}) -- Function to setup keymaps function nuget.setup(opts) - opts = opts or {} - - -- If no keys are provided, use default keymaps - if opts.keys == nil then - opts.keys = default_keys - end - - -- Disable keymaps if an empty keys table is provided - if next(opts.keys) == nil then - vim.api.nvim_del_keymap("n", default_keys.install[2]) - vim.api.nvim_del_keymap("n", default_keys.remove[2]) - else - -- Set provided keymaps or default keymaps - if opts.keys.install then - vim.api.nvim_set_keymap( - opts.keys.install[1], - opts.keys.install[2], - "NuGetInstall", - { noremap = true, silent = true, desc = "Install a NuGet package" } - ) - end - - if opts.keys.remove then - vim.api.nvim_set_keymap( - opts.keys.remove[1], - opts.keys.remove[2], - "NuGetRemove", - { noremap = true, silent = true, desc = "Remove a NuGet package" } - ) - end - end + opts = opts or {} + + -- If no keys are provided, use default keymaps + if opts.keys == nil then + opts.keys = default_keys + end + + if opts.dotnet then + dotnet.setup(opts.dotnet) + end + + -- Disable keymaps if an empty keys table is provided + if next(opts.keys) == nil then + vim.api.nvim_del_keymap("n", default_keys.install[2]) + vim.api.nvim_del_keymap("n", default_keys.remove[2]) + vim.api.nvim_del_keymap("n", default_keys.clear_cache[2]) + else + -- Set provided keymaps or default keymaps + if opts.keys.install then + vim.api.nvim_set_keymap( + opts.keys.install[1], + opts.keys.install[2], + "NuGetInstall", + { noremap = true, silent = true, desc = "Install a NuGet package" } + ) + end + + if opts.keys.remove then + vim.api.nvim_set_keymap( + opts.keys.remove[1], + opts.keys.remove[2], + "NuGetRemove", + { noremap = true, silent = true, desc = "Remove a NuGet package" } + ) + end + + if opts.keys.clear_cache then + vim.api.nvim_set_keymap( + opts.keys.clear_cache[1], + opts.keys.clear_cache[2], + "NuGetClearCache", + { noremap = true, silent = true, desc = "Clear nuget.nvim cache" } + ) + end + end end return nuget diff --git a/lua/nuget/install.lua b/lua/nuget/install.lua index bd8b14f..105e6a8 100644 --- a/lua/nuget/install.lua +++ b/lua/nuget/install.lua @@ -1,58 +1,19 @@ -local install = {} -local utils = require("nuget.utils") - -function install.install_package(package) - local project_file = utils.find_project() - - if not project_file then - print("You are not in a .NET project. No .csproj file found.") - return - end - - local cmd = string.format("dotnet add %s package %s", project_file, package) - - -- Run the command and capture its output - local output = vim.fn.system(cmd) - - -- Split the output into lines - local lines = {} - for line in output:gmatch("[^\r\n]+") do - table.insert(lines, line) - end - - -- Create a floating window - local bufnr = vim.api.nvim_create_buf(false, true) - local win_width = math.min(80, vim.api.nvim_get_option("columns")) - local win_height = math.min(30, vim.api.nvim_get_option("lines")) - local win_opts = { - relative = "editor", - width = win_width, - height = win_height, - row = (vim.api.nvim_get_option("lines") - win_height) / 2, - col = (vim.api.nvim_get_option("columns") - win_width) / 2, - style = "minimal", - } - vim.api.nvim_open_win(bufnr, true, win_opts) - - -- Set the buffer's contents to the output lines - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - - -- Close the floating window when the command finishes - vim.fn.jobstart(cmd, { - on_exit = function(j, return_val, event) - if return_val == 0 then - print("Installed Package " .. package) - else - print("Failed to install package " .. package) - end - vim.api.nvim_win_close(0, true) - end, - }) - - -- Check if the package is already installed - if output:match("already installed") then - print("Package " .. package .. " is already installed") - end +local pickers = require("nuget.pickers") + +return function(opts) + opts = vim.tbl_deep_extend("force", { + dotnet = { + sources = {}, + prerelease = false, + dotnet_bin = "dotnet", + } + }, opts or {}) + + pickers.projects(opts, function(result) + if result.filetype == "sln" then + pickers.sln.upgrades(result.path, result.installed, opts) + else + pickers.csproj.upgrades(result.path, result.installed, opts) + end + end) end - -return install diff --git a/lua/nuget/notify.lua b/lua/nuget/notify.lua new file mode 100644 index 0000000..7bd7e26 --- /dev/null +++ b/lua/nuget/notify.lua @@ -0,0 +1,114 @@ +local M = {} + +local fidget_ok, fidget = pcall(require, "fidget") +local progress_ok, fidget_progress = pcall(require, "fidget.progress") + +--- Create a fidget progress handle, falling back to vim.notify if unavailable. +--- Returns a handle with :report(msg) and :finish(msg) methods. +M.make_progress = function(title) + if progress_ok then + local handle = fidget_progress.handle.create({ + title = title, + lsp_client = { name = "nuget" }, + percentage = nil, + }) + return { + report = function(msg) handle:report({ message = msg }) end, + finish = function(msg) + handle:report({ message = msg or "Done" }) + handle:finish() + end, + cancel = function(msg) + handle:report({ message = msg or "Cancelled" }) + handle:finish() + end, + } + else + -- Minimal fallback + return { + report = function(msg) vim.schedule(function() vim.notify("[nuget] " .. msg, vim.log.levels.INFO) end) end, + finish = function(msg) vim.schedule(function() vim.notify("[nuget] " .. (msg or "Done"), vim.log.levels.INFO) end) end, + cancel = function(msg) + vim.schedule(function() + vim.notify("[nuget] " .. (msg or "Cancelled"), + vim.log.levels.WARN) + end) + end, + } + end +end + +--- Show failure output in a floating scratch buffer the user can read and close. +M.show_error_float = function(title, output, on_close) + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].filetype = "text" + + local lines = vim.split(output or "(no output)", "\n", { plain = true }) + table.insert(lines, "") + table.insert(lines, "[press q or to close]") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + + local width = math.min(60, vim.o.columns - 4) + local height = math.min(#lines + 2, vim.o.lines - 6) + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + title = " " .. title .. " ", + title_pos = "center", + }) + vim.wo[win].wrap = true + vim.wo[win].linebreak = true + -- Close keymaps + for _, key in ipairs({ "q", "" }) do + vim.keymap.set("n", key, function() + vim.api.nvim_win_close(win, true) + end, { buffer = buf, nowait = true, silent = true }) + end + + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(win), + once = true, + callback = function() + if on_close then on_close() end + end, + }) +end + +M.show_info_float = function(title, message) + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].filetype = "text" + local lines = vim.split(message, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + local width = math.min(60, vim.o.columns - 4) + local height = #lines + local win = vim.api.nvim_open_win(buf, false, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + title = " " .. title .. " ", + title_pos = "center", + }) + vim.wo[win].wrap = true + return function() + vim.schedule(function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end) + end +end + +return M diff --git a/lua/nuget/pickers/csproj.lua b/lua/nuget/pickers/csproj.lua new file mode 100644 index 0000000..ed357fe --- /dev/null +++ b/lua/nuget/pickers/csproj.lua @@ -0,0 +1,90 @@ +local nuget_pickers = require("nuget.pickers.nuget") +local notify = require("nuget.notify") +local conf = require("telescope.config").values +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") +local pickers = require("telescope.pickers") +local finders = require("telescope.finders") +local dotnet = require("nuget.dotnet") +local entry_display = require("telescope.pickers.entry_display") + +-- contains pickers for managing nugets on an individual csproj +local M = {} + +---@param csproj_path string The path of the .csproj +M.upgrades = function(csproj_path, installed, opts) + nuget_pickers.search({ csproj_path }, installed, opts) +end + + +---@param csproj_path string The path of the .csproj +---@param installed dotnet_packages +---@param opts { dotnet: dotnet_opts } +M.remove = function(csproj_path, installed, opts) + local entries = {} + for id, info in pairs(installed) do + table.insert(entries, { + id = id, + version = info.version, + }) + end + table.sort(entries, function(a, b) + return a.id < b.id + end) + + local displayer = entry_display.create({ + separator = " ", + items = { { width = 15 }, { remaining = true } }, + }) + + local function make_finder() + return finders.new_table({ + results = entries, + entry_maker = function(e) + return { + value = e, + ordinal = e.id, + display = function(et) + return displayer({ + { et.value.version, "TelescopeResultsComment" }, + { et.value.id, "TelescopeResultsIdentifier" }, + }) + end, + } + end, + }) + end + + local picker = pickers.new(opts, { + prompt_title = "NuGet Packages | " .. vim.fn.fnamemodify(csproj_path, ":t"), + finder = make_finder(), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local sel = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if sel then + dotnet.remove_package(csproj_path, sel.value.id, opts.dotnet, function(ok, stdout, stderr) + if not ok then + notify.show_error_float("Failed: " .. sel.value.id .. " " .. sel.value, + (stdout or "") .. "\n" .. (stderr or "")) + M.remove(csproj_path, installed, opts) + return + end + + dotnet.get_installed_packages(csproj_path, opts.dotnet, function(new_installed) + vim.schedule(function() + M.remove(csproj_path, new_installed, opts) + end) + end) + end) + end + end) + return true + end, + }) + + picker:find() +end + +return M diff --git a/lua/nuget/pickers/init.lua b/lua/nuget/pickers/init.lua new file mode 100644 index 0000000..4242116 --- /dev/null +++ b/lua/nuget/pickers/init.lua @@ -0,0 +1,8 @@ +local M = {} + +M.projects = require("nuget.pickers.projects") +M.nuget = require("nuget.pickers.nuget") +M.sln = require("nuget.pickers.sln") +M.csproj = require("nuget.pickers.csproj") + +return M diff --git a/lua/nuget/pickers/nuget.lua b/lua/nuget/pickers/nuget.lua new file mode 100644 index 0000000..0083223 --- /dev/null +++ b/lua/nuget/pickers/nuget.lua @@ -0,0 +1,370 @@ +local entry_display = require("telescope.pickers.entry_display") +local make_entry = require("telescope.make_entry") +local previewers = require("telescope.previewers") +local pickers = require("telescope.pickers") +local conf = require("telescope.config").values +local async_finder = require("nuget.finders.async_finder") +local utils = require("nuget.utils") +local dotnet = require("nuget.dotnet") +local notify = require("nuget.notify") +local finders = require("telescope.finders") +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") + +local M = {} + +local ns_id = vim.api.nvim_create_namespace("NuGetHighlights") + +M.package_previewer = previewers.new_buffer_previewer({ + title = "Package Details", + get_buffer_by_name = function(_, entry) + return entry.value.id + end, + define_preview = function(self, entry, _) + local pkg = entry.value + + local function val(v) + if type(v) == "table" then + return table.concat(v, ", ") + elseif v == nil or v == "" then + return "N/A" + else + return tostring(v):gsub("\r\n", "\n"):gsub("\r", "\n") + end + end + + local lines = {} + local highlights = {} + + table.insert(lines, pkg.id .. " " .. val(pkg.version)) + table.insert(highlights, { line = 0, hl = "Title", col_start = 0, col_end = #pkg.id }) + table.insert(highlights, { line = 0, hl = "TelescopeResultsNormal", col_start = #pkg.id + 1, col_end = -1 }) + + if pkg.owners and pkg.owners ~= "" then + table.insert(highlights, { line = #lines, hl = "TelescopeResultsComment" }) + table.insert(lines, "by " .. val(pkg.owners)) + end + + table.insert(lines, "") + + local fields = { + { label = "Project URL", value = pkg.project_url }, + { label = "Total Downloads", value = pkg.downloads and utils.humanize(pkg.downloads) or nil }, + } + for _, field in ipairs(fields) do + local v = val(field.value) + if v ~= "N/A" then + local line = field.label .. ": " .. v + table.insert(highlights, { line = #lines, hl = "Title", col_start = 0, col_end = #field.label }) + table.insert(lines, line) + end + end + + table.insert(lines, "") + + if pkg.description and pkg.description ~= "" then + for _, vline in ipairs(vim.split(val(pkg.description), "\n", { plain = true })) do + table.insert(lines, vline) + end + end + + + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(self.state.bufnr) then return end + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) + -- enable soft wrap instead of manual wrapping + vim.wo[self.state.winid].wrap = true + vim.wo[self.state.winid].linebreak = true + for _, h in ipairs(highlights) do + vim.api.nvim_buf_add_highlight(self.state.bufnr, ns_id, h.hl, h.line, h.col_start or 0, h.col_end or -1) + end + end) + end, +}) + +---Search for a nuget and install it on the given csprojs +---@param targets string[] List of csprojs to install selected package to +---@param installed dotnet_packages +---@param opts { dotnet: dotnet_opts } +M.search = function(targets, installed, opts) + opts = vim.tbl_deep_extend("force", { + dotnet = {} + }, opts or {}) + + local displayer = entry_display.create({ + separator = " ", + items = { { width = 8 }, { width = 15 }, { remaining = true } }, + }) + + local existing_entries = {} + local keyed_existing_entries = {} + for id, info in pairs(installed) do + local entry = { + id = id, + version = info.version, + outdated = false, -- populated as fetches complete + } + table.insert(existing_entries, entry) + keyed_existing_entries[entry.id] = entry + end + + local function make_finder() + return async_finder({ + initial_results = existing_entries, + async_fn = function(prompt, on_result, on_complete) + dotnet.search_packages(prompt, opts.dotnet, function(ok, result) + if not ok then + on_complete() + return + end + for i, pkg in ipairs(result) do + on_result(i, pkg) + end + on_complete() + end) + end, + entry_maker = function(entry) + entry = keyed_existing_entries[entry.id] or entry + local flag = entry.outdated and "outdated" or "" + return make_entry.set_default_entry_mt({ + value = entry, + ordinal = entry.id .. flag, + display = function(et) + return displayer({ + { flag, "DiagnosticWarn" }, + { et.value.version, "TelescopeResultsComment" }, + { et.value.id, "TelescopeResultsIdentifier" }, + }) + end, + }, opts) + end + }) + end + + + local picker = pickers.new(opts, { + prompt_title = "NuGet Search", + finder = make_finder(), + sorter = conf.generic_sorter(opts), + previewer = M.package_previewer, + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local sel = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if sel then + M.install(targets, sel.value.id, vim.tbl_extend("force", opts, { + on_complete = function(ok, _) + if ok then + dotnet.get_installed_packages_csprojs(targets, opts.dotnet, + function(updated_installed) + vim.schedule(function() M.search(targets, updated_installed, opts) end) + end) + else + M.search(targets, installed, opts) + end + end + })) + end + end) + return true + end, + }) + picker:find() + + -- kick off all fetches immediately after picker opens + for _, entry in ipairs(existing_entries) do + dotnet.get_latest_versions(entry.id, opts.dotnet, function(ok, cached) + if not ok then + return + end + entry.description = cached.description + entry.project_url = cached.project_url + if cached.latest ~= entry.version then + vim.schedule(function() + entry.outdated = true + picker:refresh(make_finder(), { reset_prompt = false }) + end) + end + end) + end +end + +---Create a picker to select the version of a given nuget and install it on the given csprojs +---@param targets string[] List of csprojs to install selected package to +---@param package string package to install +---@param opts { dotnet: dotnet_opts, on_complete: fun(ok: boolean, new_version: string?) } +M.install = function(targets, package, opts) + local progress = notify.make_progress("NuGet search " .. package) + + -- generate a list of siblings that share the same sln (if any) with any of + -- the targets, including targets themselves + progress.report("Building project map") + local project_map = dotnet.build_project_map(opts.dotnet) + + local slns = {} + local csprojs_for_counts = {} + for _, target in ipairs(targets) do + local entry = project_map[target] + if entry.sln then + slns[entry.sln] = true + end + csprojs_for_counts[target] = true + end + for csproj, info in pairs(project_map) do + if info.sln and slns[info.sln] then + csprojs_for_counts[csproj] = true + end + end + + -- build version -> projects map from siblings + local version_projects = {} + local pending = vim.tbl_count(csprojs_for_counts) + local function on_siblings_done() + progress.report("Querying NuGet") + dotnet.get_latest_versions(package, opts.dotnet, function(ok, result) + if not ok then + progress.finish("Found 0 versions") + vim.schedule(function() + notify.show_error_float("NuGet", "Couldn't find any versions for NuGet " .. package, function() + if opts.on_complete then opts.on_complete(false, nil) end + end) + end) + return + end + vim.schedule(function() + progress.finish("Found " .. tostring(#result.versions) .. " versions") + + local displayer = entry_display.create({ + separator = " ", + items = { { width = 30 }, { remaining = true } }, + }) + + local entries = {} + for _, v in ipairs(result.versions) do + local projs = version_projects[v] or {} + local other_cnt = 0 + local is_cur = false + for _, proj in ipairs(projs) do + if vim.tbl_contains(targets, proj) then + is_cur = true + else + other_cnt = other_cnt + 1 + end + end + local is_latest = v == result.versions[1] + local prefix + if is_cur then + prefix = "3_" + elseif is_latest then + prefix = "2_" + elseif other_cnt > 0 then + prefix = "1_" + else + prefix = "0_" + end + local ordinal = prefix .. utils.version_ordinal(v) + table.insert(entries, { + value = v, + ordinal = ordinal, + is_cur = is_cur, + other_cnt = other_cnt, + is_latest = is_latest, + display = function(et) + local badge, badge_hl + if et.is_cur then + badge, badge_hl = "current", "DiagnosticOk" + elseif et.is_latest then + badge, badge_hl = "latest", "DiagnosticInfo" + elseif et.other_cnt > 0 then + badge, badge_hl = "(" .. tostring(et.other_cnt) .. ")", "DiagnosticHint" + else + badge, badge_hl = "", "TelescopeResultsComment" + end + return displayer({ + { et.value, et.is_cur and "DiagnosticOk" or "TelescopeResultsNormal" }, + { badge, badge_hl }, + }) + end, + }) + end + + -- for the initial (unsorted) view + table.sort(entries, function(a, b) return a.ordinal > b.ordinal end) + + pickers.new({}, { + initial_mode = "normal", + prompt_title = "Select Version | " .. package, + finder = finders.new_table({ + results = entries, + entry_maker = function(e) return e end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr, _) + local selected = false + vim.api.nvim_create_autocmd("BufUnload", { + buffer = prompt_bufnr, + once = true, + callback = function() + vim.schedule(function() + if not selected then + if opts.on_complete then opts.on_complete(false, nil) end + end + end) + end, + }) + + + actions.select_default:replace(function() + local sel = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if not sel then + return + end + local close = notify.show_info_float("NuGet", "Installing " .. + package .. "\nVersion " .. sel.value .. "...") + selected = true + local pending_installs = #targets + local all_ok = true + for _, target in ipairs(targets) do + dotnet.install_package(target, package, sel.value, opts, + function(install_ok, stdout, stderr) + if not install_ok then + all_ok = false + notify.show_error_float("Failed: " .. package .. " " .. sel.value, + (stdout or "") .. "\n" .. (stderr or "")) + end + pending_installs = pending_installs - 1 + if pending_installs == 0 then + close() + if opts.on_complete then opts.on_complete(all_ok, sel.value) end + end + end) + end + end) + return true + end, + }):find() + end) + end) + end + + if pending == 0 then + on_siblings_done() + return + end + + for csproj, _ in pairs(csprojs_for_counts) do + dotnet.get_installed_packages_csproj(csproj, opts.dotnet, + function(map) + local info = map[package] + if info and info.version then + version_projects[info.version] = version_projects[info.version] or {} + table.insert(version_projects[info.version], csproj) + end + pending = pending - 1 + if pending == 0 then on_siblings_done() end + end) + end +end + +return M diff --git a/lua/nuget/pickers/projects.lua b/lua/nuget/pickers/projects.lua new file mode 100644 index 0000000..7a5ba3c --- /dev/null +++ b/lua/nuget/pickers/projects.lua @@ -0,0 +1,80 @@ +local entry_display = require('telescope.pickers.entry_display') +local telescope_utils = require('telescope.utils') +local builtin = require('telescope.builtin') +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") +local notify = require("nuget.notify") +local dotnet = require("nuget.dotnet") + +---@class (exact) projects_opts +---@field find_command string override fd command +---@field filter "csproj" | "sln" | nil filter the types of files that show up in the picker. `nil` = sln and csproj +---@field dotnet dotnet_opts additional settings to pass to `nuget.dotnet` commands + +--- Open a project/solution file picker. +---@param opts projects_opts +---@param callback fun(args: { path: string, filetype: string, installed: dotnet_packages, opts: projects_opts}): nil # Called on the main loop after the user picks a file and installed packages have been fetched. +return function(opts, callback) + opts = opts or {} + + local get_command = function() + if opts.filter == "sln" then + return { "fd", "--type", "f", "--color", "never", "-e", "sln", "--exclude", ".git", "-L" } + elseif opts.filter == "csproj" then + return { "fd", "--type", "f", "--color", "never", "-e", "csproj", "--exclude", ".git", "-L" } + end + return { "fd", "--type", "f", "--color", "never", "-e", "csproj", "-e", "sln", "--exclude", ".git", "-L" } + end + opts.find_command = opts.find_command or get_command() + + local displayer = entry_display.create({ + separator = "", + items = { + { width = nil }, + { width = nil }, + } + }) + + local proj_opts = vim.tbl_extend("force", opts, { + prompt_title = "Select Project / Solution", + entry_maker = function(line) + local fn = telescope_utils.path_tail(line) + local path = string.sub(line, 1, -(#fn + 1)) + local filetype = fn:match("^.+%.(%w+)$") + local entry = { + ordinal = fn, + __fn = fn, + __path = path, + path = line, + filetype = filetype, + hl = filetype == "sln" and "TelescopeResultsConstant" or "TelescopeResultsNormal", + } + entry.display = function(et) + return displayer({ + { et.__path, "TelescopeResultsComment" }, + { et.__fn, et.hl }, + }) + end + return entry + end, + + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local sel = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if not sel then return end + + local progress = notify.make_progress("Loading installed packages…") + dotnet.get_installed_packages(sel.path, opts.dotnet, function(installed) + vim.schedule(function() + progress.finish(vim.tbl_count(installed) .. " packages indexed") + callback({ path = sel.path, filetype = sel.filetype, installed = installed, opts = opts }) + end) + end) + end) + return true + end, + }) + + return builtin.find_files(proj_opts) +end diff --git a/lua/nuget/pickers/sln.lua b/lua/nuget/pickers/sln.lua new file mode 100644 index 0000000..495f4d3 --- /dev/null +++ b/lua/nuget/pickers/sln.lua @@ -0,0 +1,110 @@ +local entry_display = require("telescope.pickers.entry_display") +local conf = require("telescope.config").values +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") +local pickers = require("telescope.pickers") +local finders = require("telescope.finders") +local dotnet = require("nuget.dotnet") +local nuget_pickers = require("nuget.pickers.nuget") + +local M = {} + +---Launch picker to upgrade all packages in a given .sln +---@param sln_path string path to .sln file +---@param installed dotnet_packages already installed packages +---@param opts { dotnet: dotnet_opts } +M.upgrades = function(sln_path, installed, opts) + local entries = {} + for id, info in pairs(installed) do + table.insert(entries, { + id = id, + version = info.version, + projects = info.projects, + outdated = false, -- populated as fetches complete + mixed = info.mixed_versions, + }) + end + table.sort(entries, function(a, b) + return a.id < b.id + end) + + local displayer = entry_display.create({ + separator = " ", + items = { { width = 5 }, { width = 8 }, { width = 15 }, { remaining = true } }, + }) + + local function make_finder() + return finders.new_table({ + results = entries, + entry_maker = function(e) + local flag, flag_hl = "", "DiagnosticWarn" + if e.mixed then + flag, flag_hl = "mixed", "DiagnosticError" + elseif e.outdated then + flag = "outdated" + end + return { + value = e, + ordinal = e.id .. flag, + display = function(et) + return displayer({ + { "(" .. #et.value.projects .. ")", "DiagnosticHint" }, + { flag, flag_hl }, + { et.value.version, "TelescopeResultsComment" }, + { et.value.id, "TelescopeResultsIdentifier" }, + }) + end, + } + end, + }) + end + + local picker = pickers.new(opts, { + prompt_title = "Solution Packages | " .. vim.fn.fnamemodify(sln_path, ":t"), + finder = make_finder(), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local sel = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if sel then + nuget_pickers.install(vim.tbl_map(function(p) return p.path end, sel.value.projects), sel.value.id, + vim.tbl_extend("force", opts, { + on_complete = function(ok, _) + if not ok then + M.upgrades(sln_path, installed, opts) + return + end + -- regenerate the project map to account for changes in the installed packages + dotnet.get_installed_packages(sln_path, opts.dotnet, function(new_installed) + vim.schedule(function() + M.upgrades(sln_path, new_installed, opts) + end) + end) + end + })) + end + end) + return true + end, + }) + + picker:find() + + -- kick off all fetches immediately after picker opens + for _, entry in ipairs(entries) do + dotnet.get_latest_versions(entry.id, opts.dotnet, function(ok, cached) + if not ok then + return + end + if cached.latest ~= entry.version then + vim.schedule(function() + entry.outdated = true + picker:refresh(make_finder(), { reset_prompt = false }) + end) + end + end) + end +end + +return M diff --git a/lua/nuget/remove.lua b/lua/nuget/remove.lua index c87e918..cece72e 100644 --- a/lua/nuget/remove.lua +++ b/lua/nuget/remove.lua @@ -1,97 +1,17 @@ -local M = {} -local pickers = require("telescope.pickers") -local finders = require("telescope.finders") -local conf = require("telescope.config").values -local actions = require("telescope.actions") -local action_state = require("telescope.actions.state") -local utils = require("nuget.utils") - --- Helper function to get installed packages -local function get_installed_packages() - local project = utils.find_project() - - if not project then - print("You are not in a .NET project. No .csproj file found.") - return - end - - local cmd = string.format("dotnet list %s package", project) - local output = vim.fn.system(cmd) - local packages = {} - for line in output:gmatch("[^\r\n]+") do - local package = line:match("^%s*> ([^ ]+)") - if package then - table.insert(packages, package) - end - end - return packages -end - --- Remove a NuGet package -local function remove_package(package) - local project = utils.find_project() - local cmd = string.format("dotnet remove %s package %s", project, package) - local output = vim.fn.system(cmd) - if output:match("error") then - print(string.format("Failed to remove package %s: %s", package, output)) - else - local restore_cmd = string.format("dotnet restore %s", project) - local restore_output = vim.fn.system(restore_cmd) - if restore_output:match("error") then - print(string.format("Failed to restore project %s: %s", project, restore_output)) - else - print(string.format("Removed package %s", package)) - end - end -end - --- List and remove NuGet packages -function M.remove_package() - local packages = get_installed_packages() - - if packages == nil then - return - end - - if #packages == 0 then - print("No packages found in the project") - return - end - - local function enter(prompt_bufnr) - local selected = action_state.get_selected_entry() - if selected then - actions.close(prompt_bufnr) - remove_package(selected.value) - else - print("No package selected") - end - end - - pickers - .new({}, { - prompt_title = "Remove NuGet Package", - finder = finders.new_table({ - results = packages, - entry_maker = function(pkg) - return { - value = pkg, - display = pkg, - ordinal = pkg, - } - end, - }), - sorter = conf.generic_sorter({}), - attach_mappings = function(_, map) - map("i", "", enter) - return true - end, - layout_config = { - preview_width = 0.7, - width = 0.5, - }, - }) - :find() +local pickers = require("nuget.pickers") + +return function(opts) + opts = vim.tbl_deep_extend("force", { + dotnet = { + sources = {}, + prerelease = false, + dotnet_bin = "dotnet", + } + }, opts or {}) + + opts.filter = "csproj" + + pickers.projects(opts, function(result) + pickers.csproj.remove(result.path, result.installed, opts) + end) end - -return M diff --git a/lua/nuget/search.lua b/lua/nuget/search.lua deleted file mode 100644 index 0109d3d..0000000 --- a/lua/nuget/search.lua +++ /dev/null @@ -1,235 +0,0 @@ -local search = {} -local pickers = require("telescope.pickers") -local finders = require("telescope.finders") -local conf = require("telescope.config").values -local actions = require("telescope.actions") -local action_state = require("telescope.actions.state") -local previewers = require("telescope.previewers") -local utils = require("nuget.utils") -local install = require("nuget.install") - -local ns_id = vim.api.nvim_create_namespace("NuGetHighlights") - --- Cache to store the package and version data -local package_cache = {} - --- Package details previewer -local package_previewer = previewers.new_buffer_previewer({ - title = "Package Details", - get_buffer_by_name = function(_, entry) - return entry.value - end, - define_preview = function(self, entry, status) - local function safe_concat(value) - if type(value) == "table" then - return table.concat(value, ", ") - elseif value == nil then - return "N/A" - else - return tostring(value):gsub("\n", " "):gsub("\r", "") - end - end - - local pkg = entry.value - local lines = { - "Package: ", - " " .. safe_concat(pkg.id), - " ", - "Version: ", - " " .. safe_concat(pkg.version), - " ", - "Description: ", - " " .. safe_concat(pkg.description), - " ", - "Authors: ", - " " .. safe_concat(pkg.authors), - " ", - "Project URL: ", - " " .. safe_concat(pkg.projectUrl), - " ", - "License: ", - " " .. safe_concat(pkg.licenseUrl), - " ", - "Total Downloads: ", - " " .. safe_concat(pkg.totalDownloads), - "", - } - - vim.schedule(function() - if vim.api.nvim_buf_is_valid(self.state.bufnr) then - -- Manually wrap text - local max_width = vim.api.nvim_win_get_width(0) - local wrapped_lines = {} - for _, line in ipairs(lines) do - local words = {} - for word in line:gmatch("%S+") do - table.insert(words, word) - end - if #words > 0 then - local current_line = table.remove(words, 1) - for _, word in ipairs(words) do - if #word > max_width then - table.insert(wrapped_lines, current_line) - table.insert(wrapped_lines, word) - current_line = "" - elseif #current_line + #word + 1 > max_width then - table.insert(wrapped_lines, current_line) - current_line = word - else - current_line = current_line .. " " .. word - end - end - if #current_line > 0 then - table.insert(wrapped_lines, current_line) - end - end - end - - vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, wrapped_lines) - -- Add highlights - for i, line in ipairs(wrapped_lines) do - if line:match("^.*:$") then - -- Make the title bolder - vim.api.nvim_buf_add_highlight(self.state.bufnr, ns_id, "Title", i - 1, 0, -1) - elseif i == 2 then - -- Make the package name yellow - vim.api.nvim_buf_add_highlight(self.state.bufnr, ns_id, "WarningMsg", i - 1, 0, -1) - end - end - end - end) - end, -}) - --- Search for NuGet packages -function search.search_packages() - local project = utils.find_project() - - if not project then - print("You are not in a .NET project. No .csproj file found.") - return - end - - local query = vim.fn.input("Enter search term: ") - local function enter(prompt_bufnr) - local selected = action_state.get_selected_entry() - if selected then - actions.close(prompt_bufnr) - search.package_versions(selected.value) - else - print("No package selected") - end - end - - -- Check if the data is already cached - if package_cache[query] then - local packages = package_cache[query] - pickers - .new({}, { - prompt_title = "Search NuGet Packages", - finder = finders.new_table({ - results = packages, - entry_maker = function(pkg) - return { - value = pkg, - display = pkg, - ordinal = pkg, - } - end, - }), - layout_config = { - preview_width = 0.60, - }, - sorter = conf.generic_sorter({}), - previewer = package_previewer, - attach_mappings = function(_, map) - map("i", "", enter) - return true - end, - }) - :find() - else - local url = string.format("https://api-v2v3search-0.nuget.org/query?q=%s&take=200&includeDelisted=false", query) - local response = utils.http_get(url) - local packages = {} - for _, pkg in ipairs(response.data) do - table.insert(packages, { - id = pkg.id, - version = pkg.version, - description = pkg.description, - authors = pkg.authors, - projectUrl = pkg.projectUrl, - licenseUrl = pkg.licenseUrl, - totalDownloads = pkg.totalDownloads, - }) - end - -- Cache the data - package_cache[query] = packages - pickers - .new({}, { - prompt_title = "Search NuGet Packages", - finder = finders.new_table({ - results = packages, - entry_maker = function(pkg) - return { - value = pkg, - display = pkg.id, - ordinal = pkg.id, - } - end, - }), - layout_config = { - preview_width = 0.60, - }, - sorter = conf.generic_sorter({}), - previewer = package_previewer, - attach_mappings = function(_, map) - map("i", "", enter) - return true - end, - }) - :find() - end -end - --- Show package versions -function search.package_versions(package) - local function enter(prompt_bufnr) - local selected = action_state.get_selected_entry() - if selected then - actions.close(prompt_bufnr) - install.install_package(package.id) - else - print("No version selected") - end - end - local url = string.format("https://api.nuget.org/v3-flatcontainer/%s/index.json", package.id) - local response = utils.http_get(url) - local versions = response.versions - table.sort(versions, function(a, b) - return a > b - end) - pickers - .new({}, { - prompt_title = string.format("Versions for %s", package.id), - finder = finders.new_table({ - results = versions, - entry_maker = function(version) - return { - value = version, - display = version, - ordinal = version, - } - end, - }), - layout_config = { width = 0.40 }, - sorter = conf.generic_sorter({}), - attach_mappings = function(_, map) - map("i", "", enter) - return true - end, - }) - :find() -end - -return search diff --git a/lua/nuget/utils.lua b/lua/nuget/utils.lua index 8d6ed16..2d66342 100644 --- a/lua/nuget/utils.lua +++ b/lua/nuget/utils.lua @@ -1,24 +1,55 @@ local utils = {} --- Helper function to make HTTP requests -function utils.http_get(url) - local curl = require("plenary.curl") - local response = curl.get(url) - return vim.fn.json_decode(response.body) +function utils.humanize(n) + if n >= 1000000000 then + return string.format("%.1fb", n / 1000000000) + elseif n >= 1000000 then + return string.format("%.1fm", n / 1000000) + elseif n >= 1000 then + return string.format("%.1fk", n / 1000) + else + return tostring(n) + end end --- Find the nearest .csproj file -function utils.find_project() - local file = vim.fn.expand("%:p") - local dir = vim.fn.fnamemodify(file, ":h") - while dir ~= "/" do - local files = vim.fn.glob(dir .. "/*.csproj", false, true) - if #files > 0 then - return files[1] - end - dir = vim.fn.fnamemodify(dir, ":h") - end - return nil +local function parse_version(v) + local base, suffix = v:match("^([%d%.]+)-?(.*)$") + base = base or v + local parts = {} + for n in base:gmatch("%d+") do + table.insert(parts, tonumber(n)) + end + parts.suffix = suffix + return parts +end + +function utils.version_lt(a, b) + local pa, pb = parse_version(a), parse_version(b) + for i = 1, math.max(#pa, #pb) do + local x, y = pa[i] or 0, pb[i] or 0 + if x ~= y then return x < y end + end + + local sa, sb = pa.suffix or "", pb.suffix or "" + if sa == sb then return false end + if sa == "" then return false end -- release > anything + if sb == "" then return true end -- anything < release + return sa < sb +end + +function utils.sort_versions(versions, newest_first) + table.sort(versions, function(a, b) + if newest_first then return utils.version_lt(b, a) end + return utils.version_lt(a, b) + end) +end + +function utils.version_ordinal(v) + local parts = {} + for n in v:gmatch("%d+") do + table.insert(parts, string.format("%08d", tonumber(n))) + end + return table.concat(parts, ".") end return utils