diff --git a/home-manager/home.nix b/home-manager/home.nix index 172550e..014c2fc 100644 --- a/home-manager/home.nix +++ b/home-manager/home.nix @@ -242,10 +242,11 @@ ]; withRuby = true; - extraLuaConfig = builtins.readFile ./nvim/build-utils.lua + "\n" + builtins.readFile ./nvim/dotnet.lua + "\n" + builtins.readFile ./nvim/init.lua + "\n" + builtins.readFile ./nvim/python.lua; + extraLuaConfig = builtins.readFile ./nvim/build-utils.lua + "\n" + (builtins.replaceStrings ["_CURL_"] ["${nixpkgs.curl}/bin/curl"] (builtins.readFile ./nvim/dotnet.lua)) + "\n" + builtins.readFile ./nvim/init.lua + "\n" + builtins.readFile ./nvim/python.lua; }; home.packages = [ + nixpkgs.jq nixpkgs.difftastic nixpkgs.syncthing nixpkgs.nodePackages_latest.dockerfile-language-server-nodejs diff --git a/home-manager/nvim/dotnet.lua b/home-manager/nvim/dotnet.lua index eb117b2..0824b09 100644 --- a/home-manager/nvim/dotnet.lua +++ b/home-manager/nvim/dotnet.lua @@ -158,3 +158,564 @@ vim.api.nvim_create_autocmd("FileType", { FindAndRegisterSolution(false) end, }) + +-- For what I'm sure are reasons, Lua appears to have nothing in its standard library +---@generic K +---@generic V1 +---@generic V2 +---@param tbl table +---@param f fun(V1): V2 +---@return table +local function map(tbl, f) + local t = {} + for k, v in pairs(tbl) do + t[k] = f(v) + end + return t +end + +---@generic K +---@generic V +---@param tbl table +---@param f fun(V1): nil +local function iter(tbl, f) + for _, v in pairs(tbl) do + f(v) + end +end + +---@generic K +---@generic V +---@param tbl table +---@param predicate fun(V): boolean +---@return boolean, V +local function find(tbl, predicate) + for _, v in pairs(tbl) do + if predicate(v) then + return true, v + end + end + return false, nil +end + +---@class (exact) NuGetVersion +---@field major number +---@field minor number +---@field patch number +---@field suffix? string +local NuGetVersion = {} + +---@param v NuGetVersion +---@nodiscard +---@return string +local function nuGetVersionToString(v) + local s = tostring(v.major) .. "." .. tostring(v.minor) .. "." .. tostring(v.patch) + if v.suffix then + return s .. v.suffix + else + return s + end +end + +---@param v string +---@nodiscard +---@return NuGetVersion +local function parse_version(v) + local major, minor, patch, pre = v:match("(%d+)%.(%d+)%.(%d+)(.*)$") + -- TODO: why does this type-check if you remove the field names? + return { + major = tonumber(major) or 0, + minor = tonumber(minor) or 0, + patch = tonumber(patch) or 0, + suffix = pre or nil, + } +end + +---@param a NuGetVersion +---@param b NuGetVersion +---@nodiscard +---@return boolean +local function compare_versions(a, b) + if a.major ~= b.major then + return a.major < b.major + elseif a.minor ~= b.minor then + return a.minor < b.minor + elseif a.patch ~= b.patch then + return a.patch < b.patch + elseif a.suffix and not b.suffix then + return false + elseif not a.suffix and b.suffix then + return true + else + return a.suffix < b.suffix + end +end + +---@param url string +---@nodiscard +local function curl_sync(url) + local command = string.format("_CURL_ --silent --compressed --fail '%s'", url) + local response = vim.fn.system(command) + if vim.v.shell_error ~= 0 then + print("Failed to fetch " .. url) + return nil + end + local success, decoded = pcall(vim.fn.json_decode, response) + if not success then + print("Failed to decode JSON from curl at " .. url) + return nil + end + return decoded +end + +---@param url string +---@param callback fun(table): nil +---@return nil +local function curl(url, callback) + local stdout = vim.uv.new_pipe(false) + local stdout_text = "" + handle, _ = vim.uv.spawn( + "_CURL_", + { args = { "--silent", "--compressed", "--fail", url }, stdio = { nil, stdout, nil } }, + vim.schedule_wrap(function(code, _) + stdout:read_stop() + stdout:close() + if handle and not handle:is_closing() then + handle:close() + end + if code ~= 0 then + print("Failed to fetch " .. url) + end + local success, decoded = pcall(vim.fn.json_decode, stdout_text) + if not success then + print("Failed to decode JSON from curl at " .. url .. "\n" .. stdout_text) + end + callback(decoded) + end) + ) + vim.uv.read_start(stdout, function(err, data) + assert(not err, err) + if data then + stdout_text = stdout_text .. data + end + end) +end + +local _nugetIndex +local _packageBaseAddress + +---@param callback fun(): nil +local function populate_nuget_api(callback) + if _nugetIndex ~= nil then + callback() + end + local url = string.format("https://api.nuget.org/v3/index.json") + local function handle(decoded) + local default_nuget_reg = "https://api.nuget.org/v3/registration5-semver1/" + local default_base_address = "https://api.nuget.org/v3-flatcontainer/" + + if not decoded then + print("Failed to fetch NuGet index; falling back to default") + _nugetIndex = default_nuget_reg + _packageBaseAddress = default_base_address + else + local resources = decoded["resources"] + if resources == nil then + print("Failed to parse: " .. decoded .. tostring(decoded)) + for k, v in pairs(decoded) do + print(k .. ": " .. tostring(v)) + end + callback() + end + + local resourceSuccess, regUrl = find(resources, function(o) + return o["@type"] == "RegistrationsBaseUrl/3.6.0" + end) + if not resourceSuccess then + print("Failed to find endpoint in NuGet index; falling back to default") + _nugetIndex = default_nuget_reg + else + _nugetIndex = regUrl["@id"] + end + + local baseAddrSuccess, baseAddrUrl = find(resources, function(o) + return o["@type"] == "PackageBaseAddress/3.0.0" + end) + if not baseAddrSuccess then + print("Failed to find endpoint in NuGet index; falling back to default") + _packageBaseAddress = default_base_address + else + _packageBaseAddress = baseAddrUrl["@id"] + end + end + callback() + end + curl(url, handle) +end + +---@return nil +local function populate_nuget_api_sync() + if _nugetIndex ~= nil then + return + end + local url = string.format("https://api.nuget.org/v3/index.json") + local decoded = curl_sync(url) + local default_nuget_reg = "https://api.nuget.org/v3/registration5-semver1/" + local default_base_address = "https://api.nuget.org/v3-flatcontainer/" + + if not decoded then + print("Failed to fetch NuGet index; falling back to default") + _nugetIndex = default_nuget_reg + _packageBaseAddress = default_base_address + else + local resources = decoded["resources"] + + local resourceSuccess, regUrl = find(resources, function(o) + return o["@type"] == "RegistrationsBaseUrl/3.6.0" + end) + if not resourceSuccess then + print("Failed to find endpoint in NuGet index; falling back to default") + _nugetIndex = default_nuget_reg + else + _nugetIndex = regUrl["@id"] + end + + local baseAddrSuccess, baseAddrUrl = find(resources, function(o) + return o["@type"] == "PackageBaseAddress/3.0.0" + end) + if not baseAddrSuccess then + print("Failed to find endpoint in NuGet index; falling back to default") + _packageBaseAddress = default_base_address + else + _packageBaseAddress = baseAddrUrl["@id"] + end + end +end + +---@return nil +---@param callback fun(nugetIndex: string): nil +local function get_nuget_index(callback) + populate_nuget_api(function() + callback(_nugetIndex) + end) +end + +---@return nil +---@param callback fun(packageBaseIndex: string): nil +local function get_package_base_addr(callback) + populate_nuget_api(function() + callback(_packageBaseAddress) + end) +end + +---@return string +local function get_package_base_addr_sync() + populate_nuget_api_sync() + return _packageBaseAddress +end + +local _package_versions_cache = {} + +---@param package_name string +---@return NuGetVersion[] +local function get_package_versions_sync(package_name) + if _package_versions_cache[package_name] ~= nil then + return _package_versions_cache[package_name] + end + local base = get_package_base_addr_sync() + + local url = base .. string.format("%s/index.json", package_name:lower()) + local decoded = curl_sync(url) + if not decoded then + print("Failed to fetch package versions") + return {} + end + + local versions = map(decoded.versions, parse_version) + table.sort(versions, function(a, b) + return compare_versions(b, a) + end) + _package_versions_cache[package_name] = versions + return versions +end + +---@param package_name string +---@param callback fun(v: NuGetVersion[]): nil +---@return nil +local function get_package_versions(package_name, callback) + if _package_versions_cache[package_name] ~= nil then + callback(_package_versions_cache[package_name]) + end + + local function handle(base) + local url = base .. string.format("%s/index.json", package_name:lower()) + local function handle2(decoded) + if not decoded then + print("Failed to fetch package versions") + callback({}) + end + + local versions = map(decoded.versions, parse_version) + table.sort(versions, function(a, b) + return compare_versions(b, a) + end) + _package_versions_cache[package_name] = versions + callback(versions) + end + curl(url, handle2) + end + get_package_base_addr(handle) +end + +---@param version NuGetVersion +---@return nil +local function update_package_version(version) + local line = vim.api.nvim_get_current_line() + local new_line = line:gsub('Version="[^"]+"', string.format('Version="%s"', nuGetVersionToString(version))) + vim.api.nvim_set_current_line(new_line) +end + +-- A map from package to { packageWeDependOn: version }. +--- @type table> +local _package_dependency_cache = {} +---@param package_name string +---@param version NuGetVersion +---@param callback fun(result: table): nil +---@return nil +local function get_package_dependencies(package_name, version, callback) + local key = package_name .. "@" .. nuGetVersionToString(version) + local cache_hit = _package_dependency_cache[key] + if cache_hit ~= nil then + callback(cache_hit) + return + end + + local function handle1(index) + local url = index .. string.format("%s/%s.json", package_name:lower(), nuGetVersionToString(version):lower()) + + local function handle(response) + if not response then + print( + "Failed to get dependencies of " + .. package_name + .. " at version " + .. version + .. " : unsuccessful response to " + .. url + ) + return + end + + local entry_url = response["catalogEntry"] + local function handle2(catalog_entry) + if not catalog_entry then + print( + "Failed to get dependencies of " + .. package_name + .. " at version " + .. version + .. " : unsuccessful response to " + .. entry_url + ) + return + end + + local result = {} + if catalog_entry["dependencyGroups"] then + iter(catalog_entry["dependencyGroups"], function(grp) + if grp["dependencies"] then + for _, dep in pairs(grp["dependencies"]) do + result[dep["id"]] = dep["range"] + end + end + end) + end + + _package_dependency_cache[key] = result + + callback(result) + end + curl(entry_url, handle2) + end + + curl(url, handle) + end + + get_nuget_index(handle1) +end + +---@return table +---@nodiscard +local function get_all_package_references() + local packages = {} + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + + for _, line in ipairs(lines) do + local package_name = line:match('PackageReference Include="([^"]+)"') + or line:match('PackageReference Update="([^"]+)"') + local version = line:match('Version="([^"]+)"') + + if package_name and version then + if not packages[package_name] then + packages[package_name] = {} + end + table.insert(packages[package_name], parse_version(version)) + end + end + + return packages +end + +function ClearNuGetDependencyCache() + for k, _ in pairs(_package_dependency_cache) do + _package_dependency_cache[k] = nil + end +end +vim.api.nvim_create_user_command("ClearNuGetDependencyCache", ClearNuGetDependencyCache, {}) + +function PrintNuGetDependencyCache() + for k, v in pairs(_package_dependency_cache) do + print(k .. ":") + for dep, ver in pairs(v) do + print(" " .. dep .. ": " .. ver) + end + end +end +vim.api.nvim_create_user_command("PrintNuGetDependencyCache", PrintNuGetDependencyCache, {}) + +local function prefetch_dependencies() + local packages = get_all_package_references() + + local function process_package(package_name, versions, callback) + local count = #versions + for _, version in ipairs(versions) do + vim.schedule(function() + get_package_dependencies(package_name, version, function(_) + count = count - 1 + if count == 0 then + callback() + end + end) + end) + end + end + + local total_packages = 0 + for _ in pairs(packages) do + total_packages = total_packages + 1 + end + + local processed_packages = 0 + for package_name, versions in pairs(packages) do + process_package(package_name, versions, function() + local function handle(package_versions) + if package_versions then + process_package(package_name, package_versions, function() + processed_packages = processed_packages + 1 + if processed_packages == total_packages then + print("All dependencies prefetched") + end + end) + else + processed_packages = processed_packages + 1 + if processed_packages == total_packages then + print("All dependencies prefetched") + end + end + end + get_package_versions(package_name, handle) + end) + end +end + +vim.api.nvim_create_autocmd("FileType", { + pattern = { "fsharp_project", "csharp_project" }, + callback = function() + function UpdateNuGetVersion() + local line = vim.api.nvim_get_current_line() + local package_name = line:match('PackageReference Include="([^"]+)"') + or line:match('PackageReference Update="([^"]+)"') + local current_version = nuGetVersionToString(line:match('Version="([^"]+)"')) + + if not package_name then + print("No package reference found on the current line") + return + end + + local package_versions = get_package_versions_sync(package_name) + + if #package_versions == 0 then + print("No versions found for the package") + return + end + + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local previewers = require("telescope.previewers") + + pickers + .new({}, { + prompt_title = string.format("Select version for %s", package_name), + finder = finders.new_table({ + results = package_versions, + entry_maker = function(entry) + local val = nuGetVersionToString(entry) + local display_value = val + if current_version and entry == current_version then + display_value = "[CURRENT] " .. val + end + return { + value = entry, + display = display_value, + ordinal = entry, + } + end, + }), + previewer = previewers.new_buffer_previewer({ + define_preview = function(self, entry, _) + local bufnr = self.state.bufnr + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "Loading..." }) + get_package_dependencies(package_name, entry.value, function(package_dependencies) + if not package_dependencies then + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "No dependencies found" }) + return + end + + local display = {} + table.insert( + display, + "Dependencies for " + .. package_name + .. " at version " + .. nuGetVersionToString(entry.value) + .. ":" + ) + for dep, range in pairs(package_dependencies) do + table.insert(display, dep .. ": " .. range) + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display) + end) + end, + }), + attach_mappings = function(_, mapping) + mapping("i", "", function(prompt_bufnr) + local selection = require("telescope.actions.state").get_selected_entry() + require("telescope.actions").close(prompt_bufnr) + update_package_version(selection.value) + end) + return true + end, + }) + :find() + end + local whichkey = require("which-key") + whichkey.register({ + n = { + name = "NuGet", + u = { UpdateNuGetVersion, "Upgrade NuGet versions" }, + }, + }, { prefix = vim.api.nvim_get_var("maplocalleader"), buffer = vim.api.nvim_get_current_buf() }) + + vim.schedule(prefetch_dependencies) + end, +}) diff --git a/home-manager/nvim/init.lua b/home-manager/nvim/init.lua index 71b70d8..d399a7b 100644 --- a/home-manager/nvim/init.lua +++ b/home-manager/nvim/init.lua @@ -288,9 +288,9 @@ whichkey.register({ }, }, { prefix = vim.api.nvim_get_var("mapleader") }) -vim.api.nvim_create_autocmd({"BufRead","BufNewFile"}, { - pattern = {"Directory.Build.props", "*.fsproj", "*.csproj"}, - callback = function() - vim.bo.filetype = "xml" - end, +vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, { + pattern = { "Directory.Build.props", "*.fsproj", "*.csproj" }, + callback = function() + vim.bo.filetype = "xml" + end, }) diff --git a/home-manager/nvim/ionide-vim.lua b/home-manager/nvim/ionide-vim.lua index 539db58..da37ab4 100644 --- a/home-manager/nvim/ionide-vim.lua +++ b/home-manager/nvim/ionide-vim.lua @@ -59,7 +59,7 @@ local function BuildFSharpProjects(projects) end if not projects then - projects = vim.fn['fsharp#getLoadedProjects']() + projects = vim.fn["fsharp#getLoadedProjects"]() end if projects then local total_projects = 0 @@ -90,7 +90,7 @@ vim.api.nvim_create_user_command("BuildFSharpProject", function(opts) .new({}, { prompt_title = "Projects", finder = finders.new_table({ - results = vim.fn['fsharp#getLoadedProjects'](), + results = vim.fn["fsharp#getLoadedProjects"](), }), sorter = conf.generic_sorter({}), attach_mappings = function(prompt_buf, _) @@ -156,7 +156,7 @@ vim.api.nvim_create_user_command("RunFSharpProject", function(opts) .new({}, { prompt_title = "Projects", finder = finders.new_table({ - results = vim.fn['fsharp#getLoadedProjects'](), + results = vim.fn["fsharp#getLoadedProjects"](), }), sorter = conf.generic_sorter({}), attach_mappings = function(prompt_buf, _) @@ -185,7 +185,7 @@ vim.api.nvim_create_user_command("PublishFSharpProject", function(opts) .new({}, { prompt_title = "Projects", finder = finders.new_table({ - results = vim.fn['fsharp#getLoadedProjects'](), + results = vim.fn["fsharp#getLoadedProjects"](), }), sorter = conf.generic_sorter({}), attach_mappings = function(prompt_buf, _) diff --git a/home-manager/nvim/nvim-dap-python.lua b/home-manager/nvim/nvim-dap-python.lua index 059ab3e..79f524a 100644 --- a/home-manager/nvim/nvim-dap-python.lua +++ b/home-manager/nvim/nvim-dap-python.lua @@ -3,13 +3,13 @@ require("dap-python").setup("%PYTHONENV%/bin/python") do local whichkey = require("which-key") whichkey.register({ - ['pd'] = { - "Debugger-related commands", - t = { - "Tests", - f = { require("dap-python").test_class, "Run Python tests in the current file" }, - c = { require("dap-python").test_method, "Run the Python test under the cursor" }, - }, + ["pd"] = { + "Debugger-related commands", + t = { + "Tests", + f = { require("dap-python").test_class, "Run Python tests in the current file" }, + c = { require("dap-python").test_method, "Run the Python test under the cursor" }, + }, }, }, { prefix = vim.api.nvim_get_var("maplocalleader") }) end diff --git a/home-manager/nvim/python.lua b/home-manager/nvim/python.lua index 5476f60..a7be732 100644 --- a/home-manager/nvim/python.lua +++ b/home-manager/nvim/python.lua @@ -56,11 +56,11 @@ end do local whichkey = require("which-key") whichkey.register({ - ['pt'] = { - "Run Python tests", - f = { RunPythonTestsInFile, "Run Python tests in the current file" }, - a = { RunAllPythonTests, "Run all Python tests" }, - c = { RunPythonTestAtCursor, "Run the Python test under the cursor" }, + ["pt"] = { + "Run Python tests", + f = { RunPythonTestsInFile, "Run Python tests in the current file" }, + a = { RunAllPythonTests, "Run all Python tests" }, + c = { RunPythonTestAtCursor, "Run the Python test under the cursor" }, }, }, { prefix = vim.api.nvim_get_var("maplocalleader") }) end diff --git a/home-manager/nvim/venv-selector.lua b/home-manager/nvim/venv-selector.lua index 37ed4cc..3205606 100644 --- a/home-manager/nvim/venv-selector.lua +++ b/home-manager/nvim/venv-selector.lua @@ -85,16 +85,16 @@ end do local whichkey = require("which-key") whichkey.register({ - ['pv'] = { - name = "Python virtual environment-related commands", - c = { CreateVenv, "Create virtual environment" }, - l = { SelectVenv, "Load virtual environment" }, - o = { - function() - vim.cmd("VenvSelect") - end, - "Choose (override) new virtual environment", - }, + ["pv"] = { + name = "Python virtual environment-related commands", + c = { CreateVenv, "Create virtual environment" }, + l = { SelectVenv, "Load virtual environment" }, + o = { + function() + vim.cmd("VenvSelect") + end, + "Choose (override) new virtual environment", + }, }, }, { prefix = vim.api.nvim_get_var("maplocalleader") }) end