diff options
Diffstat (limited to 'lua')
38 files changed, 2422 insertions, 53 deletions
diff --git a/lua/mason-core/functional/init.lua b/lua/mason-core/functional/init.lua index 20293bd8..5e0ac680 100644 --- a/lua/mason-core/functional/init.lua +++ b/lua/mason-core/functional/init.lua @@ -10,6 +10,8 @@ local function lazy_require(module) }) end +_.lazy_require = lazy_require + ---@module "mason-core.functional.data" local data = lazy_require "mason-core.functional.data" _.table_pack = data.table_pack diff --git a/lua/mason-core/installer/context.lua b/lua/mason-core/installer/context.lua index 8a1e3678..d637707a 100644 --- a/lua/mason-core/installer/context.lua +++ b/lua/mason-core/installer/context.lua @@ -118,6 +118,12 @@ function ContextualFs:mkdir(dir_path) end ---@async +---@param dir_path string +function ContextualFs:mkdirp(dir_path) + return fs.async.mkdirp(path.concat { self.cwd:get(), dir_path }) +end + +---@async ---@param file_path string ---@param mode integer function ContextualFs:chmod(file_path, mode) @@ -179,7 +185,7 @@ function InstallContext.new(handle, opts) local cwd_manager = CwdManager.new(path.install_prefix()) return setmetatable({ cwd = cwd_manager, - spawn = ContextualSpawn.new(cwd_manager, handle, handle.package.spec.schema ~= "registry+v1"), + spawn = ContextualSpawn.new(cwd_manager, handle, not handle.package:is_registry_spec()), handle = handle, package = handle.package, -- for convenience fs = ContextualFs.new(cwd_manager), diff --git a/lua/mason-core/installer/init.lua b/lua/mason-core/installer/init.lua index 9f150269..ee0e6397 100644 --- a/lua/mason-core/installer/init.lua +++ b/lua/mason-core/installer/init.lua @@ -64,13 +64,19 @@ function M.prepare_installer(context) try(Result.pcall(fs.async.mkdirp, package_build_prefix)) context.cwd:set(package_build_prefix) - return context.package.spec.install + if context.package:is_registry_spec() then + local registry_installer = require "mason-core.installer.registry" + return try(registry_installer.compile(context.handle.package.spec, context.opts)) + else + return context.package.spec.install + end end) end ----@async +---@generic T ---@param context InstallContext ----@param fn async fun(context: InstallContext) +---@param fn fun(context: InstallContext): T +---@return T function M.exec_in_context(context, fn) local thread = coroutine.create(function(...) -- We wrap the function to allow it to be a spy instance (in which case it's not actually a function, but a diff --git a/lua/mason-core/installer/managers/cargo.lua b/lua/mason-core/installer/managers/cargo.lua new file mode 100644 index 00000000..72355c9c --- /dev/null +++ b/lua/mason-core/installer/managers/cargo.lua @@ -0,0 +1,47 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" +local path = require "mason-core.path" + +local M = {} + +---@async +---@param crate string +---@param version string +---@param opts? { features?: string, locked?: boolean, git?: { url: string, rev?: boolean } } +function M.install(crate, version, opts) + opts = opts or {} + log.fmt_debug("cargo: install %s %s %s", crate, version, opts) + local ctx = installer.context() + + return ctx.spawn.cargo { + "install", + "--root", + ".", + opts.git and { + "--git", + opts.git.url, + opts.git.rev and "--rev" or "--tag", + version, + } or { "--version", version }, + opts.features and { "--features", opts.features } or vim.NIL, + opts.locked and "--locked" or vim.NIL, + crate, + } +end + +---@param bin string +function M.bin_path(bin) + return Result.pcall(platform.when, { + unix = function() + return path.concat { "bin", bin } + end, + win = function() + return path.concat { "bin", ("%s.exe"):format(bin) } + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/composer.lua b/lua/mason-core/installer/managers/composer.lua new file mode 100644 index 00000000..faa01bc4 --- /dev/null +++ b/lua/mason-core/installer/managers/composer.lua @@ -0,0 +1,42 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local path = require "mason-core.path" +local platform = require "mason-core.platform" + +local M = {} + +---@async +---@param package string +---@param version string +---@nodiscard +function M.install(package, version) + log.fmt_debug("composer: install %s %s", package, version) + local ctx = installer.context() + return Result.try(function(try) + try(ctx.spawn.composer { + "init", + "--no-interaction", + "--stability=stable", + }) + try(ctx.spawn.composer { + "require", + ("%s:%s"):format(package, version), + }) + end) +end + +---@param executable string +function M.bin_path(executable) + return Result.pcall(platform.when, { + unix = function() + return path.concat { "vendor", "bin", executable } + end, + win = function() + return path.concat { "vendor", "bin", ("%s.bat"):format(executable) } + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/gem.lua b/lua/mason-core/installer/managers/gem.lua new file mode 100644 index 00000000..0eac275e --- /dev/null +++ b/lua/mason-core/installer/managers/gem.lua @@ -0,0 +1,65 @@ +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" +local path = require "mason-core.path" +local Result = require "mason-core.result" + +local M = {} + +---@async +---@param pkg string +---@param version string +---@param opts? { extra_packages?: string[] } +---@nodiscard +function M.install(pkg, version, opts) + opts = opts or {} + log.fmt_debug("gem: install %s %s %s", pkg, version, opts) + local ctx = installer.context() + + return ctx.spawn.gem { + "install", + "--no-user-install", + "--no-format-executable", + "--install-dir=.", + "--bindir=bin", + "--no-document", + ("%s:%s"):format(pkg, version), + opts.extra_packages or vim.NIL, + env = { + GEM_HOME = ctx.cwd:get(), + }, + } +end + +---@async +---@param bin string +---@nodiscard +function M.create_bin_wrapper(bin) + local ctx = installer.context() + + local bin_path = platform.when { + unix = function() + return path.concat { "bin", bin } + end, + win = function() + return path.concat { "bin", ("%s.bat"):format(bin) } + end, + } + + if not ctx.fs:file_exists(bin_path) then + return Result.failure(("Cannot link Gem executable %q because it doesn't exist."):format(bin)) + end + + return Result.pcall(ctx.write_shell_exec_wrapper, ctx, bin, path.concat { ctx.package:get_install_path(), bin_path }, { + GEM_PATH = platform.when { + unix = function() + return ("%s:$GEM_PATH"):format(ctx.package:get_install_path()) + end, + win = function() + return ("%s;%%GEM_PATH%%"):format(ctx.package:get_install_path()) + end, + }, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/golang.lua b/lua/mason-core/installer/managers/golang.lua new file mode 100644 index 00000000..fdc000b3 --- /dev/null +++ b/lua/mason-core/installer/managers/golang.lua @@ -0,0 +1,52 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" + +local M = {} + +---@async +---@param pkg string +---@param version string +---@param opts? { extra_packages?: string[] } +function M.install(pkg, version, opts) + return Result.try(function(try) + opts = opts or {} + log.fmt_debug("golang: install %s %s %s", pkg, version, opts) + local ctx = installer.context() + local env = { + GOBIN = ctx.cwd:get(), + } + try(ctx.spawn.go { + "install", + "-v", + ("%s@%s"):format(pkg, version), + env = env, + }) + if opts.extra_packages then + for _, pkg in ipairs(opts.extra_packages) do + try(ctx.spawn.go { + "install", + "-v", + ("%s@latest"):format(pkg), + env = env, + }) + end + end + end) +end + +---@param bin string +function M.bin_path(bin) + return Result.pcall(platform.when, { + unix = function() + return bin + end, + win = function() + return ("%s.exe"):format(bin) + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/luarocks.lua b/lua/mason-core/installer/managers/luarocks.lua new file mode 100644 index 00000000..7f636e2b --- /dev/null +++ b/lua/mason-core/installer/managers/luarocks.lua @@ -0,0 +1,40 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" +local path = require "mason-core.path" + +local M = {} + +---@async +---@param pkg string +---@param version string +---@param opts { server?: string, dev?: boolean } +function M.install(pkg, version, opts) + opts = opts or {} + log.fmt_debug("luarocks: install %s %s %s", pkg, version, opts) + local ctx = installer.context() + ctx:promote_cwd() -- luarocks encodes absolute paths during installation + return ctx.spawn.luarocks { + "install", + { "--tree", ctx.cwd:get() }, + opts.dev and "--dev" or vim.NIL, + opts.server and ("--server=%s"):format(opts.server) or vim.NIL, + { pkg, version }, + } +end + +---@param exec string +function M.bin_path(exec) + return Result.pcall(platform.when, { + unix = function() + return path.concat { "bin", exec } + end, + win = function() + return path.concat { "bin", ("%s.bat"):format(exec) } + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/npm.lua b/lua/mason-core/installer/managers/npm.lua new file mode 100644 index 00000000..5eec7627 --- /dev/null +++ b/lua/mason-core/installer/managers/npm.lua @@ -0,0 +1,64 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" +local path = require "mason-core.path" + +local M = {} + +---@async +function M.init() + log.debug "npm: init" + local ctx = installer.context() + return Result.try(function(try) + try(ctx.spawn.npm { + "init", + "--yes", + "--scope=mason", + }) + + -- Use global-style. The reasons for this are: + -- a) To avoid polluting the executables (aka bin-links) that npm creates. + -- b) The installation is, after all, more similar to a "global" installation. We don't really gain + -- any of the benefits of not using global style (e.g., deduping the dependency tree). + -- + -- We write to .npmrc manually instead of going through npm because managing a local .npmrc file + -- is a bit unreliable across npm versions (especially <7), so we take extra measures to avoid + -- inadvertently polluting global npm config. + try(Result.pcall(function() + ctx.fs:append_file(".npmrc", "global-style=true") + end)) + + ctx.stdio_sink.stdout "Initialized npm root\n" + end) +end + +---@async +---@param pkg string +---@param version string +---@param opts? { extra_packages?: string[] } +function M.install(pkg, version, opts) + opts = opts or {} + log.fmt_debug("npm: install %s %s %s", pkg, version, opts) + local ctx = installer.context() + return ctx.spawn.npm { + "install", + ("%s@%s"):format(pkg, version), + opts.extra_packages or vim.NIL, + } +end + +---@param exec string +function M.bin_path(exec) + return Result.pcall(platform.when, { + unix = function() + return path.concat { "node_modules", ".bin", exec } + end, + win = function() + return path.concat { "node_modules", ".bin", ("%s.cmd"):format(exec) } + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/nuget.lua b/lua/mason-core/installer/managers/nuget.lua new file mode 100644 index 00000000..f547d81b --- /dev/null +++ b/lua/mason-core/installer/managers/nuget.lua @@ -0,0 +1,37 @@ +local installer = require "mason-core.installer" +local platform = require "mason-core.platform" +local Result = require "mason-core.result" +local log = require "mason-core.log" + +local M = {} + +---@async +---@param package string +---@param version string +---@nodiscard +function M.install(package, version) + log.fmt_debug("nuget: install %s %s", package, version) + local ctx = installer.context() + return ctx.spawn.dotnet { + "tool", + "update", + "--tool-path", + ".", + { "--version", version }, + package, + } +end + +---@param bin string +function M.bin_path(bin) + return Result.pcall(platform.when, { + unix = function() + return bin + end, + win = function() + return ("%s.exe"):format(bin) + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/opam.lua b/lua/mason-core/installer/managers/opam.lua new file mode 100644 index 00000000..2a07c4f8 --- /dev/null +++ b/lua/mason-core/installer/managers/opam.lua @@ -0,0 +1,38 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" +local path = require "mason-core.path" + +local M = {} + +---@async +---@param package string +---@param version string +---@nodiscard +function M.install(package, version) + log.fmt_debug("opam: install %s %s", package, version) + local ctx = installer.context() + return ctx.spawn.opam { + "install", + "--destdir=.", + "--yes", + "--verbose", + ("%s.%s"):format(package, version), + } +end + +---@param bin string +function M.bin_path(bin) + return Result.pcall(platform.when, { + unix = function() + return path.concat { "bin", bin } + end, + win = function() + return path.concat { "bin", ("%s.exe"):format(bin) } + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/pypi.lua b/lua/mason-core/installer/managers/pypi.lua new file mode 100644 index 00000000..0800b155 --- /dev/null +++ b/lua/mason-core/installer/managers/pypi.lua @@ -0,0 +1,106 @@ +local Optional = require "mason-core.optional" +local _ = require "mason-core.functional" +local a = require "mason-core.async" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local path = require "mason-core.path" +local platform = require "mason-core.platform" +local Result = require "mason-core.result" + +local M = {} + +local VENV_DIR = "venv" + +---@async +---@param py_executables string[] +local function create_venv(py_executables) + local ctx = installer.context() + return Optional.of_nilable(_.find_first(function(executable) + return ctx.spawn[executable]({ "-m", "venv", VENV_DIR }):is_success() + end, py_executables)):ok_or "Failed to create python3 virtual environment." +end + +---@async +---@param args SpawnArgs +local function venv_python(args) + local ctx = installer.context() + local python_path = path.concat { + ctx.cwd:get(), + VENV_DIR, + platform.is.win and "Scripts" or "bin", + platform.is.win and "python.exe" or "python", + } + return ctx.spawn[python_path](args) +end + +---@async +---@param pkgs string[] +---@param extra_args? string[] +local function pip_install(pkgs, extra_args) + return venv_python { + "-m", + "pip", + "--disable-pip-version-check", + "install", + "-U", + extra_args or vim.NIL, + pkgs, + } +end + +---@async +---@param opts { upgrade_pip: boolean, install_extra_args?: string[] } +function M.init(opts) + return Result.try(function(try) + log.fmt_debug("pypi: init", opts) + local ctx = installer.context() + + if vim.in_fast_event() then + a.scheduler() + end + + local executables = platform.is.win + and _.list_not_nil( + vim.g.python3_host_prog and vim.fn.expand(vim.g.python3_host_prog), + "python", + "python3" + ) + or _.list_not_nil(vim.g.python3_host_prog and vim.fn.expand(vim.g.python3_host_prog), "python3", "python") + + -- pip3 will hardcode the full path to venv executables, so we need to promote cwd to make sure pip uses the final destination path. + ctx:promote_cwd() + + try(create_venv(executables)) + + if opts.upgrade_pip then + try(pip_install({ "pip" }, opts.install_extra_args)) + end + end) +end + +---@async +---@param pkg string +---@param version string +---@param opts? { extra?: string, extra_packages?: string[], install_extra_args?: string[] } +function M.install(pkg, version, opts) + opts = opts or {} + log.fmt_debug("pypi: install %s %s", pkg, version, opts) + return pip_install({ + opts.extra and ("%s[%s]==%s"):format(pkg, opts.extra, version) or ("%s==%s"):format(pkg, version), + opts.extra_packages or vim.NIL, + }, opts.install_extra_args) +end + +---@param exec string +function M.bin_path(exec) + return Result.pcall(platform.when, { + unix = function() + return path.concat { "venv", "bin", exec } + end, + win = function() + return path.concat { "venv", "Scripts", ("%s.exe"):format(exec) } + end, + }) +end + +return M diff --git a/lua/mason-core/installer/managers/std.lua b/lua/mason-core/installer/managers/std.lua new file mode 100644 index 00000000..4ae3fc7b --- /dev/null +++ b/lua/mason-core/installer/managers/std.lua @@ -0,0 +1,241 @@ +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local fetch = require "mason-core.fetch" +local path = require "mason-core.path" +local platform = require "mason-core.platform" +local powershell = require "mason-core.managers.powershell" +local Result = require "mason-core.result" +local log = require "mason-core.log" + +local M = {} + +---@async +---@param rel_path string +---@nodiscard +local function unpack_7z(rel_path) + log.fmt_debug("std: unpack_7z %s", rel_path) + local ctx = installer.context() + return ctx.spawn["7z"] { "x", "-y", "-r", rel_path } +end + +---@async +---@param rel_path string +---@nodiscard +local function unpack_peazip(rel_path) + log.fmt_debug("std: unpack_peazip %s", rel_path) + local ctx = installer.context() + return ctx.spawn.peazip { "-ext2here", path.concat { ctx.cwd:get(), rel_path } } -- peazip requires absolute paths +end + +---@async +---@param rel_path string +---@nodiscard +local function wzunzip(rel_path) + log.fmt_debug("std: wzunzip %s", rel_path) + local ctx = installer.context() + return ctx.spawn.wzunzip { rel_path } +end + +---@async +---@param rel_path string +---@nodiscard +local function unpack_winrar(rel_path) + log.fmt_debug("std: unpack_winrar %s", rel_path) + local ctx = installer.context() + return ctx.spawn.winrar { "e", rel_path } +end + +---@async +---@param rel_path string +---@nodiscard +local function gunzip_unix(rel_path) + log.fmt_debug("std: gunzip_unix %s", rel_path) + local ctx = installer.context() + return ctx.spawn.gzip { "-d", rel_path } +end + +---@async +---@param rel_path string +---@nodiscard +local function unpack_arc(rel_path) + log.fmt_debug("std: unpack_arc %s", rel_path) + local ctx = installer.context() + return ctx.spawn.arc { "unarchive", rel_path } +end + +---@param rel_path string +---@return Result +local function win_decompress(rel_path) + local ctx = installer.context() + return gunzip_unix(rel_path) + :or_else(function() + return unpack_7z(rel_path) + end) + :or_else(function() + return unpack_peazip(rel_path) + end) + :or_else(function() + return wzunzip(rel_path) + end) + :or_else(function() + return unpack_winrar(rel_path) + end) + :on_success(function() + pcall(function() + ctx.fs:unlink(rel_path) + end) + end) +end + +---@async +---@param url string +---@param out_file string +---@nodiscard +function M.download_file(url, out_file) + log.fmt_debug("std: downloading file %s", url, out_file) + local ctx = installer.context() + ctx.stdio_sink.stdout(("Downloading file %q...\n"):format(url)) + return fetch(url, { + out_file = path.concat { ctx.cwd:get(), out_file }, + }):map_err(function(err) + return ("%s\nFailed to download file %q."):format(err, url) + end) +end + +---@async +---@param rel_path string +---@nodiscard +local function untar(rel_path) + log.fmt_debug("std: untar %s", rel_path) + local ctx = installer.context() + return ctx.spawn.tar({ "--no-same-owner", "-xvf", rel_path }):on_success(function() + pcall(function() + ctx.fs:unlink(rel_path) + end) + end) +end + +---@async +---@param rel_path string +---@nodiscard +local function unzip(rel_path) + log.fmt_debug("std: unzip %s", rel_path) + local ctx = installer.context() + return platform.when { + unix = function() + return ctx.spawn.unzip({ "-d", ".", rel_path }):on_success(function() + pcall(function() + ctx.fs:unlink(rel_path) + end) + end) + end, + win = function() + return Result.pcall(function() + -- Expand-Archive seems to be hard-coded to only allow .zip extensions. Bit weird but ok. + if not _.matches("%.zip$", rel_path) then + local zip_file = ("%s.zip"):format(rel_path) + ctx.fs:rename(rel_path, zip_file) + return zip_file + end + return rel_path + end):and_then(function(zip_file) + return powershell + .command( + ("Microsoft.PowerShell.Archive\\Expand-Archive -Path %q -DestinationPath ."):format(zip_file), + {}, + ctx.spawn + ) + :on_success(function() + pcall(function() + ctx.fs:unlink(zip_file) + end) + end) + end) + end, + } +end + +---@async +---@param rel_path string +---@nodiscard +local function gunzip(rel_path) + log.fmt_debug("std: gunzip %s", rel_path) + return platform.when { + unix = function() + return gunzip_unix(rel_path) + end, + win = function() + return win_decompress(rel_path) + end, + } +end + +---@async +---@param rel_path string +---@return Result +---@nodiscard +local function untar_compressed(rel_path) + log.fmt_debug("std: untar_compressed %s", rel_path) + return platform.when { + unix = function() + return untar(rel_path) + end, + win = function() + return win_decompress(rel_path) + :and_then(function() + return untar(_.gsub("%.tar%..*$", ".tar", rel_path)) + end) + :or_else(function() + -- arc both decompresses and unpacks tar in one go + return unpack_arc(rel_path) + end) + end, + } +end + +-- Order is important. +local unpack_by_filename = _.cond { + { _.matches "%.tar$", untar }, + { _.matches "%.tar%.gz$", untar }, + { _.matches "%.tar%.bz2$", untar }, + { _.matches "%.tar%.xz$", untar_compressed }, + { _.matches "%.tar%.zst$", untar_compressed }, + { _.matches "%.zip$", unzip }, + { _.matches "%.vsix$", unzip }, + { _.matches "%.gz$", gunzip }, + { _.T, _.compose(Result.success, _.format "%q doesn't need unpacking.") }, +} + +---@async +---@param rel_path string The relative path to the file to unpack. +---@nodiscard +function M.unpack(rel_path) + log.fmt_debug("std: unpack %s", rel_path) + return unpack_by_filename(rel_path) +end + +---@async +---@param git_url string +---@param opts? { rev?: string, recursive?: boolean } +---@nodiscard +function M.clone(git_url, opts) + opts = opts or {} + log.fmt_debug("std: clone %s %s", git_url, opts) + local ctx = installer.context() + return Result.try(function(try) + try(ctx.spawn.git { + "clone", + "--depth", + "1", + opts.recursive and "--recursive" or vim.NIL, + git_url, + ".", + }) + if opts.rev then + try(ctx.spawn.git { "fetch", "--depth", "1", "origin", opts.rev }) + try(ctx.spawn.git { "checkout", "FETCH_HEAD" }) + end + end) +end + +return M diff --git a/lua/mason-core/installer/registry/init.lua b/lua/mason-core/installer/registry/init.lua new file mode 100644 index 00000000..7c27f1ef --- /dev/null +++ b/lua/mason-core/installer/registry/init.lua @@ -0,0 +1,196 @@ +local a = require "mason-core.async" +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local Purl = require "mason-core.purl" +local Optional = require "mason-core.optional" +local link = require "mason-core.installer.registry.link" +local log = require "mason-core.log" +local semver = require "mason-core.semver" + +local M = {} + +M.SCHEMA_CAP = _.set_of { + "registry+v1", +} + +---@type table<string, InstallerProvider> +local PROVIDERS = {} + +---@param id string +---@param provider InstallerProvider +function M.register_provider(id, provider) + PROVIDERS[id] = provider +end + +M.register_provider("cargo", _.lazy_require "mason-core.installer.registry.providers.cargo") +M.register_provider("composer", _.lazy_require "mason-core.installer.registry.providers.composer") +M.register_provider("gem", _.lazy_require "mason-core.installer.registry.providers.gem") +M.register_provider("generic", _.lazy_require "mason-core.installer.registry.providers.generic") +M.register_provider("github", _.lazy_require "mason-core.installer.registry.providers.github") +M.register_provider("golang", _.lazy_require "mason-core.installer.registry.providers.golang") +M.register_provider("luarocks", _.lazy_require "mason-core.installer.registry.providers.luarocks") +M.register_provider("npm", _.lazy_require "mason-core.installer.registry.providers.npm") +M.register_provider("nuget", _.lazy_require "mason-core.installer.registry.providers.nuget") +M.register_provider("opam", _.lazy_require "mason-core.installer.registry.providers.opam") +M.register_provider("pypi", _.lazy_require "mason-core.installer.registry.providers.pypi") + +---@param purl Purl +local function get_provider(purl) + return Optional.of_nilable(PROVIDERS[purl.type]):ok_or(("Unknown purl type: %s"):format(purl.type)) +end + +---@class InstallerProvider +---@field parse fun(source: RegistryPackageSource, purl: Purl, opts: PackageInstallOpts): Result +---@field install async fun(ctx: InstallContext, source: ParsedPackageSource): Result + +---@class ParsedPackageSource + +---Upserts {dst} with contents of {src}. List table values will be merged, with contents of {src} prepended. +---@param dst table +---@param src table +local function upsert(dst, src) + for k, v in pairs(src) do + if type(v) == "table" then + if vim.tbl_islist(v) then + dst[k] = _.concat(v, dst[k] or {}) + else + dst[k] = upsert(dst[k] or {}, src[k]) + end + else + dst[k] = v + end + end + return dst +end + +---@param source RegistryPackageSource +---@param version string +local function coalesce_source(source, version) + if source.version_overrides then + for i = #source.version_overrides, 1, -1 do + local version_override = source.version_overrides[i] + local version_type, constraint = unpack(_.split(":", version_override.constraint)) + if version_type == "semver" then + local version_match = Result.try(function(try) + local requested_version = try(semver.parse(version)) + if _.starts_with("<=", constraint) then + local rule_version = try(semver.parse(_.strip_prefix("<=", constraint))) + return requested_version <= rule_version + elseif _.starts_with(">=", constraint) then + local rule_version = try(semver.parse(_.strip_prefix(">=", constraint))) + return requested_version >= rule_version + else + local rule_version = try(semver.parse(constraint)) + return requested_version == rule_version + end + end):get_or_else(false) + + if version_match then + if version_override.id then + -- Because this entry provides its own purl id, it overrides the entire source definition. + return version_override + else + -- Upsert the default source with the contents of the version override. + return upsert(vim.deepcopy(source), _.dissoc("constraint", version_override)) + end + end + end + end + end + return source +end + +---@param spec RegistryPackageSpec +---@param opts PackageInstallOpts +function M.parse(spec, opts) + log.debug("Parsing spec", spec.name, opts) + return Result.try(function(try) + if not M.SCHEMA_CAP[spec.schema] then + return Result.failure( + ("Current version of mason.nvim is not capable of parsing package schema version %q."):format( + spec.schema + ) + ) + end + + local source = opts.version and coalesce_source(spec.source, opts.version) or spec.source + + ---@type Purl + local purl = try(Purl.parse(source.id)) + log.trace("Parsed purl.", source.id, purl) + if opts.version then + purl.version = opts.version + end + + ---@type InstallerProvider + local provider = try(get_provider(purl)) + log.trace("Found provider for purl.", source.id) + local parsed_source = try(provider.parse(source, purl, opts)) + log.trace("Parsed source for purl.", source.id, parsed_source) + return { + provider = provider, + source = parsed_source, + purl = purl, + } + end) +end + +---@async +---@param spec RegistryPackageSpec +---@param opts PackageInstallOpts +function M.compile(spec, opts) + log.debug("Compiling installer.", spec.name, opts) + return Result.try(function(try) + if vim.in_fast_event() then + -- Parsers run synchronously and may access API functions, so we schedule before-hand. + a.scheduler() + end + + local map_parse_err = _.cond { + { + _.equals "PLATFORM_UNSUPPORTED", + function() + if opts.target then + return ("Platform %q is unsupported."):format(opts.target) + else + return "The current platform is unsupported." + end + end, + }, + { _.T, _.identity }, + } + + ---@type { purl: Purl, provider: InstallerProvider, source: ParsedPackageSource } + local parsed = try(M.parse(spec, opts):map_err(map_parse_err)) + + ---@async + ---@param ctx InstallContext + return function(ctx) + return Result.try(function(try) + -- Run installer + try(parsed.provider.install(ctx, parsed.source)) + + -- Expand & register links + if spec.bin then + try(link.bin(ctx, spec, parsed.purl, parsed.source)) + end + if spec.share then + try(link.share(ctx, spec, parsed.purl, parsed.source)) + end + if spec.opt then + try(link.opt(ctx, spec, parsed.purl, parsed.source)) + end + + ctx.receipt:with_primary_source { + type = ctx.package.spec.schema, + id = Purl.compile(parsed.purl), + source = parsed.source, + } + end):on_failure(function(err) + error(err, 0) + end) + end + end) +end + +return M diff --git a/lua/mason-core/installer/registry/link.lua b/lua/mason-core/installer/registry/link.lua new file mode 100644 index 00000000..d66809e0 --- /dev/null +++ b/lua/mason-core/installer/registry/link.lua @@ -0,0 +1,300 @@ +local expr = require "mason-core.installer.registry.expr" +local log = require "mason-core.log" +local _ = require "mason-core.functional" +local path = require "mason-core.path" +local platform = require "mason-core.platform" +local fs = require "mason-core.fs" +local Result = require "mason-core.result" +local Optional = require "mason-core.optional" +local a = require "mason-core.async" + +local M = {} + +local filter_empty_values = _.compose( + _.from_pairs, + _.filter(function(pair) + return pair[2] ~= "" + end), + _.to_pairs +) + +local bin_delegates = { + ["luarocks"] = function(target) + return require("mason-core.installer.managers.luarocks").bin_path(target) + end, + ["composer"] = function(target) + return require("mason-core.installer.managers.composer").bin_path(target) + end, + ["opam"] = function(target) + return require("mason-core.installer.managers.opam").bin_path(target) + end, + ["python"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + if not ctx.fs:file_exists(target) then + return Result.failure(("Cannot write python wrapper for path %q as it doesn't exist."):format(target)) + end + return Result.pcall(function() + local python = platform.is.win and "python" or "python3" + return ctx:write_shell_exec_wrapper( + bin, + ("%s %q"):format(python, path.concat { ctx.package:get_install_path(), target }) + ) + end) + end, + ["php"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_php_exec_wrapper(bin, target) + end) + end, + ["pyvenv"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_pyvenv_exec_wrapper(bin, target) + end) + end, + ["dotnet"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + if not ctx.fs:file_exists(target) then + return Result.failure(("Cannot write dotnet wrapper for path %q as it doesn't exist."):format(target)) + end + return Result.pcall(function() + return ctx:write_shell_exec_wrapper( + bin, + ("dotnet %q"):format(path.concat { + ctx.package:get_install_path(), + target, + }) + ) + end) + end, + ["node"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_node_exec_wrapper(bin, target) + end) + end, + ["exec"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_exec_wrapper(bin, target) + end) + end, + ["java-jar"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + if not ctx.fs:file_exists(target) then + return Result.failure(("Cannot write Java JAR wrapper for path %q as it doesn't exist."):format(target)) + end + return Result.pcall(function() + return ctx:write_shell_exec_wrapper( + bin, + ("java -jar %q"):format(path.concat { + ctx.package:get_install_path(), + target, + }) + ) + end) + end, + ["nuget"] = function(target) + return require("mason-core.installer.managers.nuget").bin_path(target) + end, + ["npm"] = function(target) + return require("mason-core.installer.managers.npm").bin_path(target) + end, + ["gem"] = function(target) + return require("mason-core.installer.managers.gem").create_bin_wrapper(target) + end, + ["cargo"] = function(target) + return require("mason-core.installer.managers.cargo").bin_path(target) + end, + ["pypi"] = function(target) + return require("mason-core.installer.managers.pypi").bin_path(target) + end, + ["golang"] = function(target) + return require("mason-core.installer.managers.golang").bin_path(target) + end, +} + +---@async +---@param ctx InstallContext +---@param target string +local function chmod_exec(ctx, target) + local bit = require "bit" + -- see chmod(2) + local USR_EXEC = 0x40 + local GRP_EXEC = 0x8 + local ALL_EXEC = 0x1 + local EXEC = bit.bor(USR_EXEC, GRP_EXEC, ALL_EXEC) + local fstat = ctx.fs:fstat(target) + if bit.band(fstat.mode, EXEC) ~= EXEC then + local plus_exec = bit.bor(fstat.mode, EXEC) + log.fmt_debug("Setting exec flags on file %s %o -> %o", target, fstat.mode, plus_exec) + ctx.fs:chmod(target, plus_exec) -- chmod +x + end +end + +---Expands bin specification from spec and registers bins to be linked. +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +local function expand_bin(ctx, spec, purl, source) + log.debug("Registering bin links", ctx.package, spec.bin) + return Result.try(function(try) + local expr_ctx = { version = purl.version, source = source } + + local bin_table = spec.bin + if not bin_table then + log.fmt_debug("%s spec provides no bin.", ctx.package) + return + end + + local interpolated_bins = filter_empty_values(try(expr.tbl_interpolate(bin_table, expr_ctx))) + + local expanded_bin_table = {} + for bin, target in pairs(interpolated_bins) do + -- Expand "npm:typescript-language-server"-like expressions + local delegated_bin = _.match("^(.+):(.+)$", target) + if #delegated_bin > 0 then + local bin_type, executable = unpack(delegated_bin) + log.fmt_trace("Transforming managed executable=%s via %s", executable, bin_type) + local delegate = + try(Optional.of_nilable(bin_delegates[bin_type]):ok_or(("Unknown bin type: %s"):format(bin_type))) + target = try(delegate(executable, bin)) + end + + log.fmt_debug("Expanded bin link %s -> %s", bin, target) + if not ctx.fs:file_exists(target) then + return Result.failure(("Tried to link bin %q to non-existent target %q."):format(bin, target)) + end + + if platform.is.unix then + chmod_exec(ctx, target) + end + + expanded_bin_table[bin] = target + end + return expanded_bin_table + end) +end + +local is_dir_path = _.matches "/$" + +---Expands symlink path specifications from spec and returns symlink file table. +---@async +---@param ctx InstallContext +---@param purl Purl +---@param source ParsedPackageSource +---@param file_spec_table table<string, string> +local function expand_file_spec(ctx, purl, source, file_spec_table) + log.debug("Registering symlinks", ctx.package, file_spec_table) + return Result.try(function(try) + local expr_ctx = { version = purl.version, source = source } + + ---@type table<string, string> + local interpolated_paths = filter_empty_values(try(expr.tbl_interpolate(file_spec_table, expr_ctx))) + + ---@type table<string, string> + local expanded_links = {} + + for dest, source_path in pairs(interpolated_paths) do + local cwd = ctx.cwd:get() + + if is_dir_path(dest) then + -- linking dir -> dir + if not is_dir_path(source_path) then + return Result.failure(("Cannot link file %q to dir %q."):format(source_path, dest)) + end + + if vim.in_fast_event() then + a.scheduler() + end + + local glob = path.concat { cwd, source_path } .. "**/*" + log.fmt_trace("Symlink glob for %s: %s", ctx.package, glob) + + ---@type string[] + local files = _.filter_map(function(abs_path) + -- fs.sync because async causes stack overflow on many files (TODO fix that) + if not fs.sync.file_exists(abs_path) then + -- only link actual files (e.g. exclude directory entries from glob) + return Optional.empty() + end + -- turn into relative paths + return Optional.of(abs_path:sub(#cwd + 2)) -- + 2 to remove leading path separator (/) + end, vim.fn.glob(glob, false, true)) + + log.fmt_trace("Expanded glob %s: %s", glob, files) + + for __, file in ipairs(files) do + -- File destination should be relative to the source directory. For example, should the source_path + -- be "gh_2.22.1_macOS_amd64/share/man/" and dest be "man/", it should link source files to the + -- following destinations: + -- + -- gh_2.22.1_macOS_amd64/share/man/ man/ + -- ------------------------------------------------------------------------- + -- gh_2.22.1_macOS_amd64/share/man/man1/gh.1 man/man1/gh.1 + -- gh_2.22.1_macOS_amd64/share/man/man1/gh-run.1 man/man1/gh-run.1 + -- gh_2.22.1_macOS_amd64/share/man/man1/gh-ssh-key.1 man/man1/gh-run.1 + -- + local file_dest = path.concat { + _.trim_end_matches("/", dest), + file:sub(#source_path + 1), + } + expanded_links[file_dest] = file + end + else + -- linking file -> file + if is_dir_path(source_path) then + return Result.failure(("Cannot link dir %q to file %q."):format(source_path, dest)) + end + expanded_links[dest] = source_path + end + end + + return expanded_links + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +M.bin = function(ctx, spec, purl, source) + return expand_bin(ctx, spec, purl, source):on_success(function(links) + ctx.links.bin = links + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +M.share = function(ctx, spec, purl, source) + return expand_file_spec(ctx, purl, source, spec.share):on_success(function(links) + ctx.links.share = links + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +M.opt = function(ctx, spec, purl, source) + return expand_file_spec(ctx, purl, source, spec.opt):on_success(function(links) + ctx.links.opt = links + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/cargo.lua b/lua/mason-core/installer/registry/providers/cargo.lua new file mode 100644 index 00000000..4c609be6 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/cargo.lua @@ -0,0 +1,64 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local util = require "mason-core.installer.registry.util" + +local M = {} + +---@class CargoSource : RegistryPackageSource +---@field supported_platforms? string[] + +---@param source CargoSource +---@param purl Purl +function M.parse(source, purl) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + + local repository_url = _.path({ "qualifiers", "repository_url" }, purl) + + local git + if repository_url then + git = { + url = repository_url, + rev = _.path({ "qualifiers", "rev" }, purl) == "true", + } + end + + ---@type string? + local features = _.path({ "qualifiers", "features" }, purl) + local locked = _.path({ "qualifiers", "locked" }, purl) + + ---@class ParsedCargoSource : ParsedPackageSource + local parsed_source = { + crate = purl.name, + version = purl.version, + features = features, + locked = locked ~= "false", + git = git, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedCargoSource +function M.install(ctx, source) + local cargo = require "mason-core.installer.managers.cargo" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.crates.get_all_versions(source.crate) + end)) + + try(cargo.install(source.crate, source.version, { + git = source.git, + features = source.features, + locked = source.locked, + })) + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/composer.lua b/lua/mason-core/installer/registry/providers/composer.lua new file mode 100644 index 00000000..59c0ae61 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/composer.lua @@ -0,0 +1,34 @@ +local Result = require "mason-core.result" +local util = require "mason-core.installer.registry.util" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedComposerSource : ParsedPackageSource + local parsed_source = { + package = ("%s/%s"):format(purl.namespace, purl.name), + version = purl.version, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedComposerSource +function M.install(ctx, source) + local composer = require "mason-core.installer.managers.composer" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.packagist.get_all_versions(source.package) + end)) + + try(composer.install(source.package, source.version)) + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/gem.lua b/lua/mason-core/installer/registry/providers/gem.lua new file mode 100644 index 00000000..ba829b9a --- /dev/null +++ b/lua/mason-core/installer/registry/providers/gem.lua @@ -0,0 +1,47 @@ +local _ = require "mason-core.functional" +local Result = require "mason-core.result" +local util = require "mason-core.installer.registry.util" + +local M = {} + +---@class GemSource : RegistryPackageSource +---@field supported_platforms? string[] +---@field extra_packages? string[] + +---@param source GemSource +---@param purl Purl +function M.parse(source, purl) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + + ---@class ParsedGemSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + extra_packages = source.extra_packages, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source GemSource +function M.install(ctx, source) + local gem = require "mason-core.installer.managers.gem" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.rubygems.get_all_versions(source.package) + end)) + + try(gem.install(source.package, source.version, { + extra_packages = source.extra_packages, + })) + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/generic.lua b/lua/mason-core/installer/registry/providers/generic.lua new file mode 100644 index 00000000..5c493ae6 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/generic.lua @@ -0,0 +1,47 @@ +local _ = require "mason-core.functional" +local Result = require "mason-core.result" +local expr = require "mason-core.installer.registry.expr" +local util = require "mason-core.installer.registry.util" + +local M = {} + +---@class GenericDownload +---@field target (Platform | Platform[])? +---@field files table<string, string> + +---@class GenericSource : RegistryPackageSource +---@field download GenericDownload | GenericDownload[] + +---@param source GenericSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + return Result.try(function(try) + local download = try(util.coalesce_by_target(source.download, opts):ok_or "PLATFORM_UNSUPPORTED") + + local expr_ctx = { version = purl.version } + ---@type { files: table<string, string> } + local interpolated_download = try(expr.tbl_interpolate(download, expr_ctx)) + + ---@class ParsedGenericSource : ParsedPackageSource + local parsed_source = { + download = interpolated_download, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGenericSource +function M.install(ctx, source) + local std = require "mason-core.installer.managers.std" + return Result.try(function(try) + for out_file, url in pairs(source.download.files) do + try(std.download_file(url, out_file)) + try(std.unpack(out_file)) + end + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/github.lua b/lua/mason-core/installer/registry/providers/github.lua new file mode 100644 index 00000000..2dda6fe6 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/github.lua @@ -0,0 +1,179 @@ +local a = require "mason-core.async" +local async_uv = require "mason-core.async.uv" +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local platform = require "mason-core.platform" +local path = require "mason-core.path" +local settings = require "mason.settings" +local expr = require "mason-core.installer.registry.expr" +local util = require "mason-core.installer.registry.util" + +local build = { + ---@param source GitHubBuildSource + ---@param purl Purl + ---@param opts PackageInstallOpts + parse = function(source, purl, opts) + return Result.try(function(try) + ---@type { run: string } + local build_instruction = try(util.coalesce_by_target(source.build, opts):ok_or "PLATFORM_UNSUPPORTED") + + ---@class ParsedGitHubBuildSource : ParsedPackageSource + local parsed_source = { + build = build_instruction, + repo = ("https://github.com/%s/%s.git"):format(purl.namespace, purl.name), + rev = purl.version, + } + return parsed_source + end) + end, + + ---@async + ---@param ctx InstallContext + ---@param source ParsedGitHubBuildSource + install = function(ctx, source) + local std = require "mason-core.installer.managers.std" + return Result.try(function(try) + try(std.clone(source.repo, { rev = source.rev })) + try(platform.when { + unix = function() + return ctx.spawn.bash { + on_spawn = a.scope(function(_, stdio) + local stdin = stdio[1] + async_uv.write(stdin, "set -euxo pipefail;\n") + async_uv.write(stdin, source.build.run) + async_uv.shutdown(stdin) + async_uv.close(stdin) + end), + } + end, + win = function() + local powershell = require "mason-core.managers.powershell" + return powershell.command(source.build.run, {}, ctx.spawn) + end, + }) + end) + end, +} + +local release = { + ---@param source GitHubReleaseSource + ---@param purl Purl + ---@param opts PackageInstallOpts + parse = function(source, purl, opts) + return Result.try(function(try) + local asset = try(util.coalesce_by_target(source.asset, opts):ok_or "PLATFORM_UNSUPPORTED") + + local expr_ctx = { version = purl.version } + + ---@type { out_file: string, download_url: string }[] + local downloads = {} + + for __, file in ipairs(type(asset.file) == "string" and { asset.file } or asset.file) do + local asset_file_components = _.split(":", file) + local source_file = try(expr.interpolate(_.head(asset_file_components), expr_ctx)) + local out_file = try(expr.interpolate(_.last(asset_file_components), expr_ctx)) + + if _.matches("/$", out_file) then + -- out_file is a dir expression (e.g. "libexec/") + out_file = out_file .. source_file + end + + table.insert(downloads, { + out_file = out_file, + download_url = settings.current.github.download_url_template:format( + ("%s/%s"):format(purl.namespace, purl.name), + purl.version, + source_file + ), + }) + end + + local interpolated_asset = try(expr.tbl_interpolate(asset, expr_ctx)) + + ---@class ParsedGitHubReleaseSource : ParsedPackageSource + local parsed_source = { + repo = ("%s/%s"):format(purl.namespace, purl.name), + asset = interpolated_asset, + downloads = downloads, + } + return parsed_source + end) + end, + + ---@async + ---@param ctx InstallContext + ---@param source ParsedGitHubReleaseSource + install = function(ctx, source) + local std = require "mason-core.installer.managers.std" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.github.get_all_release_versions(source.repo) + end)) + + for __, download in ipairs(source.downloads) do + if vim.in_fast_event() then + a.scheduler() + end + local out_dir = vim.fn.fnamemodify(download.out_file, ":h") + local out_file = vim.fn.fnamemodify(download.out_file, ":t") + if out_dir ~= "." then + try(Result.pcall(function() + ctx.fs:mkdirp(out_dir) + end)) + end + try(ctx:chdir(out_dir, function() + return Result.try(function(try) + try(std.download_file(download.download_url, out_file)) + try(std.unpack(out_file)) + end) + end)) + end + end) + end, +} + +local M = {} + +---@class GitHubReleaseAsset +---@field target? Platform | Platform[] +---@field file string | string[] + +---@class GitHubReleaseSource : RegistryPackageSource +---@field asset GitHubReleaseAsset | GitHubReleaseAsset[] + +---@class GitHubBuildInstruction +---@field target? Platform | Platform[] +---@field run string + +---@class GitHubBuildSource : RegistryPackageSource +---@field build GitHubBuildInstruction | GitHubBuildInstruction[] + +---@param source GitHubReleaseSource | GitHubBuildSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + if source.asset then + return release.parse(source --[[@as GitHubReleaseSource]], purl, opts) + elseif source.build then + return build.parse(source --[[@as GitHubBuildSource]], purl, opts) + else + return Result.failure "Unknown source type." + end +end + +---@async +---@param ctx InstallContext +---@param source ParsedGitHubReleaseSource | ParsedGitHubBuildSource +function M.install(ctx, source) + if source.asset then + return release.install(ctx, source) + elseif source.build then + return build.install(ctx, source) + else + return Result.failure "Unknown source type." + end +end + +return M diff --git a/lua/mason-core/installer/registry/providers/golang.lua b/lua/mason-core/installer/registry/providers/golang.lua new file mode 100644 index 00000000..34d8d160 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/golang.lua @@ -0,0 +1,50 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local util = require "mason-core.installer.registry.util" + +local M = {} + +---@param purl Purl +local function get_package_name(purl) + if purl.subpath then + return ("%s/%s/%s"):format(purl.namespace, purl.name, purl.subpath) + else + return ("%s/%s"):format(purl.namespace, purl.name) + end +end + +---@class GolangSource : RegistryPackageSource +---@field extra_packages? string[] + +---@param source GolangSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedGolangSource : ParsedPackageSource + local parsed_source = { + package = get_package_name(purl), + version = purl.version, + extra_packages = source.extra_packages, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGolangSource +function M.install(ctx, source) + local golang = require "mason-core.installer.managers.golang" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.golang.get_all_versions(source.package) + end)) + + try(golang.install(source.package, source.version, { + extra_packages = source.extra_packages, + })) + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/luarocks.lua b/lua/mason-core/installer/registry/providers/luarocks.lua new file mode 100644 index 00000000..78b0fc7f --- /dev/null +++ b/lua/mason-core/installer/registry/providers/luarocks.lua @@ -0,0 +1,45 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" + +local M = {} + +---@param purl Purl +local function parse_package_name(purl) + if purl.namespace then + return ("%s/%s"):format(purl.namespace, purl.name) + else + return purl.name + end +end + +local parse_server = _.path { "qualifiers", "repository_url" } +local parse_dev = _.compose(_.equals "true", _.path { "qualifiers", "dev" }) + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedLuaRocksSource : ParsedPackageSource + local parsed_source = { + package = parse_package_name(purl), + version = purl.version, + ---@type string? + server = parse_server(purl), + ---@type boolean? + dev = parse_dev(purl), + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedLuaRocksSource +function M.install(ctx, source) + local luarocks = require "mason-core.installer.managers.luarocks" + return luarocks.install(source.package, source.version, { + server = source.server, + dev = source.dev, + }) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/npm.lua b/lua/mason-core/installer/registry/providers/npm.lua new file mode 100644 index 00000000..4b14c084 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/npm.lua @@ -0,0 +1,51 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local util = require "mason-core.installer.registry.util" + +---@param purl Purl +local function purl_to_npm(purl) + if purl.namespace then + return ("%s/%s"):format(purl.namespace, purl.name) + else + return purl.name + end +end + +local M = {} + +---@class NpmSource : RegistryPackageSource +---@field extra_packages? string[] + +---@param source NpmSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedNpmSource : ParsedPackageSource + local parsed_source = { + package = purl_to_npm(purl), + version = purl.version, + extra_packages = source.extra_packages, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedNpmSource +function M.install(ctx, source) + local npm = require "mason-core.installer.managers.npm" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.npm.get_all_versions(source.package) + end)) + + try(npm.init()) + try(npm.install(source.package, source.version, { + extra_packages = source.extra_packages, + })) + end) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/nuget.lua b/lua/mason-core/installer/registry/providers/nuget.lua new file mode 100644 index 00000000..55bc689d --- /dev/null +++ b/lua/mason-core/installer/registry/providers/nuget.lua @@ -0,0 +1,25 @@ +local Result = require "mason-core.result" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedNugetSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedNugetSource +function M.install(ctx, source) + local nuget = require "mason-core.installer.managers.nuget" + return nuget.install(source.package, source.version) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/opam.lua b/lua/mason-core/installer/registry/providers/opam.lua new file mode 100644 index 00000000..78608e85 --- /dev/null +++ b/lua/mason-core/installer/registry/providers/opam.lua @@ -0,0 +1,25 @@ +local Result = require "mason-core.result" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedOpamSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedOpamSource +function M.install(ctx, source) + local opam = require "mason-core.installer.managers.opam" + return opam.install(source.package, source.version) +end + +return M diff --git a/lua/mason-core/installer/registry/providers/pypi.lua b/lua/mason-core/installer/registry/providers/pypi.lua new file mode 100644 index 00000000..8281d07e --- /dev/null +++ b/lua/mason-core/installer/registry/providers/pypi.lua @@ -0,0 +1,59 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local settings = require "mason.settings" +local util = require "mason-core.installer.registry.util" + +local M = {} + +---@class PypiSource : RegistryPackageSource +---@field extra_packages? string[] +---@field supported_platforms? string[] + +---@param source PypiSource +---@param purl Purl +function M.parse(source, purl) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + + ---@class ParsedPypiSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + extra = _.path({ "qualifiers", "extra" }, purl), + extra_packages = source.extra_packages, + pip = { + upgrade = settings.current.pip.upgrade_pip, + extra_args = settings.current.pip.install_args, + }, + } + + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedPypiSource +function M.install(ctx, source) + local pypi = require "mason-core.installer.managers.pypi" + local providers = require "mason-core.providers" + + return Result.try(function(try) + try(util.ensure_valid_version(function() + return providers.pypi.get_all_versions(source.package) + end)) + + try(pypi.init { + upgrade_pip = source.pip.upgrade, + install_extra_args = source.pip.extra_args, + }) + try(pypi.install(source.package, source.version, { + extra = source.extra, + extra_packages = source.extra_packages, + })) + end) +end + +return M diff --git a/lua/mason-core/installer/registry/util.lua b/lua/mason-core/installer/registry/util.lua new file mode 100644 index 00000000..e49b5b82 --- /dev/null +++ b/lua/mason-core/installer/registry/util.lua @@ -0,0 +1,83 @@ +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local Optional = require "mason-core.optional" +local platform = require "mason-core.platform" +local Result = require "mason-core.result" +local log = require "mason-core.log" + +local M = {} + +---@generic T : { target: Platform | Platform[] } +---@param candidates T[] | T +---@param opts PackageInstallOpts +---@return Optional # Optional<T> +function M.coalesce_by_target(candidates, opts) + if not vim.tbl_islist(candidates) then + return Optional.of(candidates) + end + return Optional.of_nilable(_.find_first(function(asset) + if opts.target then + -- Matching against a provided target rather than the current platform is an escape hatch primarily meant + -- for automated testing purposes. + if type(asset.target) == "table" then + return _.any(_.equals(opts.target), asset.target) + else + return asset.target == opts.target + end + else + if type(asset.target) == "table" then + return _.any(function(target) + return platform.is[target] + end, asset.target) + else + return platform.is[asset.target] + end + end + end, candidates)) +end + +---Checks whether a custom version of a package installation corresponds to a valid version. +---@async +---@param versions_thunk async fun(): Result Result<string> +function M.ensure_valid_version(versions_thunk) + local ctx = installer.context() + local version = ctx.opts.version + + if version and not ctx.opts.force then + ctx.stdio_sink.stdout "Fetching available versions…\n" + local all_versions = versions_thunk() + if all_versions:is_failure() then + log.warn("Failed to fetch versions for package %s", ctx.package) + -- Gracefully fail (i.e. optimistically continue package installation) + return Result.success() + end + all_versions = all_versions:get_or_else {} + + if not _.any(_.equals(version), all_versions) then + ctx.stdio_sink.stderr(("Tried to install invalid version %q. Available versions:\n"):format(version)) + ctx.stdio_sink.stderr(_.compose(_.join "\n", _.map(_.join ", "), _.split_every(15))(all_versions)) + ctx.stdio_sink.stderr "\n\n" + ctx.stdio_sink.stderr( + ("Run with --force flag to bypass version validation:\n :MasonInstall --force %s@%s\n\n"):format( + ctx.package.name, + version + ) + ) + return Result.failure(("Version %q is not available."):format(version)) + end + end + + return Result.success() +end + +---@param platforms string[] +function M.ensure_valid_platform(platforms) + if not _.any(function(target) + return platform.is[target] + end, platforms) then + return Result.failure "PLATFORM_UNSUPPORTED" + end + return Result.success() +end + +return M diff --git a/lua/mason-core/package/init.lua b/lua/mason-core/package/init.lua index 88e89c41..16250ead 100644 --- a/lua/mason-core/package/init.lua +++ b/lua/mason-core/package/init.lua @@ -6,10 +6,16 @@ local log = require "mason-core.log" local EventEmitter = require "mason-core.EventEmitter" local fs = require "mason-core.fs" local path = require "mason-core.path" +local Result = require "mason-core.result" +local Purl = require "mason-core.purl" + +local is_not_nil = _.complement(_.is_nil) +local is_registry_schema_id = _.matches "^registry%+v[1-9]+$" +local is_registry_spec = _.prop_satisfies(_.all_pass { is_not_nil, is_registry_schema_id }, "schema") ---@class Package : EventEmitter ---@field name string ----@field spec PackageSpec +---@field spec RegistryPackageSpec | PackageSpec ---@field private handle InstallHandle The currently associated handle. local Package = setmetatable({}, { __index = EventEmitter }) @@ -50,16 +56,52 @@ local PackageMt = { __index = Package } ---@field languages PackageLanguage[] ---@field install async fun(ctx: InstallContext) ----@param spec PackageSpec +---@class RegistryPackageSourceVersionOverride : RegistryPackageSource +---@field constraint string + +---@class RegistryPackageSource +---@field id string PURL-compliant identifier. +---@field version_overrides? RegistryPackageSourceVersionOverride[] + +---@class RegistryPackageSpec +---@field schema '"registry+v1"' +---@field name string +---@field description string +---@field homepage string +---@field licenses string[] +---@field languages string[] +---@field categories string[] +---@field source RegistryPackageSource +---@field bin table<string, string>? +---@field share table<string, string>? +---@field opt table<string, string>? + +---@param spec PackageSpec | RegistryPackageSpec function Package.new(spec) - vim.validate { - name = { spec.name, "s" }, - desc = { spec.desc, "s" }, - homepage = { spec.homepage, "s" }, - categories = { spec.categories, "t" }, - languages = { spec.languages, "t" }, - install = { spec.install, "f" }, - } + if is_registry_spec(spec) then + vim.validate { + name = { spec.name, "s" }, + description = { spec.description, "s" }, + homepage = { spec.homepage, "s" }, + licenses = { spec.licenses, "t" }, + categories = { spec.categories, "t" }, + languages = { spec.languages, "t" }, + source = { spec.source, "t" }, + bin = { spec.bin, { "t", "nil" } }, + share = { spec.share, { "t", "nil" } }, + } + -- XXX: this is for compatibilty with the PackageSpec structure + spec.desc = spec.description + else + vim.validate { + name = { spec.name, "s" }, + desc = { spec.desc, "s" }, + homepage = { spec.homepage, "s" }, + categories = { spec.categories, "t" }, + languages = { spec.languages, "t" }, + install = { spec.install, "f" }, + } + end return EventEmitter.init(setmetatable({ name = spec.name, -- for convenient access @@ -190,20 +232,65 @@ end ---@param callback fun(success: boolean, version_or_err: string) function Package:get_installed_version(callback) - a.run(function() - local receipt = self:get_receipt():or_else_throw "Unable to get receipt." - local version_checks = require "mason-core.package.version-check" - return version_checks.get_installed_version(receipt, self:get_install_path()):get_or_throw() - end, callback) + self:get_receipt() + :if_present( + ---@param receipt InstallReceipt + function(receipt) + if is_registry_schema_id(receipt.primary_source.type) then + local resolve = _.curryN(callback, 2) + Purl.parse(receipt.primary_source.id) + :map(_.prop "version") + :on_success(resolve(true)) + :on_failure(resolve(false)) + else + a.run(function() + local version_checks = require "mason-core.package.version-check" + return version_checks.get_installed_version(receipt, self:get_install_path()):get_or_throw() + end, callback) + end + end + ) + :if_not_present(function() + callback(false, "Unable to get receipt.") + end) end ---@param callback fun(success: boolean, result_or_err: NewPackageVersion) function Package:check_new_version(callback) - a.run(function() - local receipt = self:get_receipt():or_else_throw "Unable to get receipt." - local version_checks = require "mason-core.package.version-check" - return version_checks.get_new_version(receipt, self:get_install_path()):get_or_throw() - end, callback) + if self:is_registry_spec() then + self:get_installed_version(function(success, installed_version) + if not success then + return callback(false, installed_version) + end + local resolve = _.curryN(callback, 2) + Result.try(function(try) + -- This is a bit goofy, but it's done to verify that a new version is supported by the + -- current platform (parse fails if it's not). We don't want to surface new versions that + -- are unsupported. + try(require("mason-core.installer.registry").parse(self.spec, {})) + + ---@type Purl + local purl = try(Purl.parse(self.spec.source.id)) + if purl.version and installed_version ~= purl.version then + return { + name = purl.name, + current_version = installed_version, + latest_version = purl.version, + } + else + return Result.failure "Package is not outdated." + end + end) + :on_success(resolve(true)) + :on_failure(resolve(false)) + end) + else + a.run(function() + local receipt = self:get_receipt():or_else_throw "Unable to get receipt." + local version_checks = require "mason-core.package.version-check" + return version_checks.get_new_version(receipt, self:get_install_path()):get_or_throw() + end, callback) + end end function Package:get_lsp_settings_schema() @@ -214,6 +301,11 @@ function Package:get_lsp_settings_schema() return Optional.of(schema) end +---@return boolean +function Package:is_registry_spec() + return is_registry_spec(self.spec) +end + function PackageMt.__tostring(self) return ("Package(name=%s)"):format(self.name) end diff --git a/lua/mason-core/path.lua b/lua/mason-core/path.lua index b63b6c4a..1e0038be 100644 --- a/lua/mason-core/path.lua +++ b/lua/mason-core/path.lua @@ -58,4 +58,8 @@ function M.package_build_prefix(name) return M.concat { M.install_prefix ".packages", name } end +function M.registry_prefix() + return M.install_prefix "registries" +end + return M diff --git a/lua/mason-core/platform.lua b/lua/mason-core/platform.lua index 0a7a3e33..cd958abc 100644 --- a/lua/mason-core/platform.lua +++ b/lua/mason-core/platform.lua @@ -5,11 +5,22 @@ local M = {} local uname = vim.loop.os_uname() ---@alias Platform ----| '"win"' ----| '"unix"' ----| '"linux"' ----| '"mac"' ----| '"darwin"' +---| '"darwin_arm64"' +---| '"darwin_x64"' +---| '"linux_arm"' +---| '"linux_arm64"' +---| '"linux_arm64_gnu"' +---| '"linux_arm64_openbsd"' +---| '"linux_arm_gnu"' +---| '"linux_x64"' +---| '"linux_x64_gnu"' +---| '"linux_x64_openbsd"' +---| '"linux_x86"' +---| '"linux_x86_gnu"' +---| '"win_arm"' +---| '"win_arm64"' +---| '"win_x64"' +---| '"win_x86"' local arch_aliases = { ["x86_64"] = "x64", diff --git a/lua/mason-registry/init.lua b/lua/mason-registry/init.lua index 01f18c90..a87b3b07 100644 --- a/lua/mason-registry/init.lua +++ b/lua/mason-registry/init.lua @@ -11,7 +11,7 @@ local sources = require "mason-registry.sources" ---@field get_all_package_names fun(self: RegistrySource): string[] ---@field get_display_name fun(self: RegistrySource): string ---@field is_installed fun(self: RegistrySource): boolean ----@field install async fun(self: RegistrySource): Result +---@field get_installer fun(self: RegistrySource): Optional # Optional<async fun (): Result> ---@class MasonRegistry : EventEmitter ---@diagnostic disable-next-line: assign-type-mismatch @@ -118,4 +118,40 @@ function M.get_all_packages() return get_packages(M.get_all_package_names()) end +---@param cb fun(success: boolean, err: any?) +function M.update(cb) + local a = require "mason-core.async" + local Result = require "mason-core.result" + + a.run(function() + return Result.try(function(try) + local updated_sources = {} + for source in sources.iter { include_uninstalled = true } do + source:get_installer():if_present(function(installer) + try(installer():map_err(function(err) + return ("%s failed to install: %s"):format(source, err) + end)) + table.insert(updated_sources, source) + end) + end + return updated_sources + end) + end, function(success, sources_or_err) + if not success then + cb(success, sources_or_err) + return + end + sources_or_err + :on_success(function(updated_sources) + if #updated_sources > 0 then + M:emit("update", updated_sources) + end + cb(true, updated_sources) + end) + :on_failure(function(err) + cb(false, err) + end) + end) +end + return M diff --git a/lua/mason-registry/sources/github.lua b/lua/mason-registry/sources/github.lua new file mode 100644 index 00000000..0cddff21 --- /dev/null +++ b/lua/mason-registry/sources/github.lua @@ -0,0 +1,195 @@ +local log = require "mason-core.log" +local fs = require "mason-core.fs" +local providers = require "mason-core.providers" +local _ = require "mason-core.functional" +local path = require "mason-core.path" +local Result = require "mason-core.result" +local Optional = require "mason-core.optional" +local fetch = require "mason-core.fetch" +local settings = require "mason.settings" +local platform = require "mason-core.platform" +local spawn = require "mason-core.spawn" +local Pkg = require "mason-core.package" +local registry_installer = require "mason-core.installer.registry" + +-- Parse sha256sum text output to a table<filename: string, sha256sum: string> structure +local parse_checksums = _.compose(_.from_pairs, _.map(_.compose(_.reverse, _.split " ")), _.split "\n", _.trim) + +---@class GitHubRegistrySourceSpec +---@field id string +---@field repo string +---@field namespace string +---@field name string +---@field version string? + +---@class GitHubRegistrySource : RegistrySource +---@field spec GitHubRegistrySourceSpec +---@field repo string +---@field root_dir string +---@field private data_file string +---@field private info_file string +---@field buffer table<string, Package>? +local GitHubRegistrySource = {} +GitHubRegistrySource.__index = GitHubRegistrySource + +---@param spec GitHubRegistrySourceSpec +function GitHubRegistrySource.new(spec) + local root_dir = path.concat { path.registry_prefix(), "github", spec.namespace, spec.name } + return setmetatable({ + spec = spec, + root_dir = root_dir, + data_file = path.concat { root_dir, "registry.json" }, + info_file = path.concat { root_dir, "info.json" }, + }, GitHubRegistrySource) +end + +function GitHubRegistrySource:is_installed() + return fs.sync.file_exists(self.data_file) +end + +function GitHubRegistrySource:reload() + if not self:is_installed() then + return + end + local data = vim.json.decode(fs.sync.read_file(self.data_file)) + self.buffer = _.compose( + _.index_by(_.prop "name"), + _.filter_map( + ---@param spec RegistryPackageSpec + function(spec) + -- registry+v1 specifications doesn't include a schema property, so infer it + spec.schema = spec.schema or "registry+v1" + + if not registry_installer.SCHEMA_CAP[spec.schema] then + log.fmt_debug("Excluding package=%s with unsupported schema_version=%s", spec.name, spec.schema) + return Optional.empty() + end + + -- hydrate Pkg.Lang index + _.each(function(lang) + local _ = Pkg.Lang[lang] + end, spec.languages) + + local pkg = self.buffer and self.buffer[spec.name] + if pkg then + -- Apply spec to the existing Package instance. This is important as to not have lingering package + -- instances. + pkg.spec = spec + return Optional.of(pkg) + end + return Optional.of(Pkg.new(spec)) + end + ) + )(data) + return self.buffer +end + +function GitHubRegistrySource:get_buffer() + return self.buffer or self:reload() or {} +end + +---@param pkg string +---@return Package? +function GitHubRegistrySource:get_package(pkg) + return self:get_buffer()[pkg] +end + +function GitHubRegistrySource:get_all_package_names() + return _.keys(self:get_buffer()) +end + +function GitHubRegistrySource:get_installer() + return Optional.of(_.partial(self.install, self)) +end + +---@async +function GitHubRegistrySource:install() + return Result.try(function(try) + if not fs.async.dir_exists(self.root_dir) then + log.debug("Creating registry directory", self) + try(Result.pcall(fs.async.mkdirp, self.root_dir)) + end + + local version = self.spec.version + if version == nil or version == "latest" then + log.trace("Resolving latest version for registry", self) + ---@type GitHubRelease + local release = try(providers.github.get_latest_release(self.spec.repo)) + version = release.tag_name + log.trace("Resolved latest registry version", self, version) + end + + try(fetch(settings.current.github.download_url_template:format(self.spec.repo, version, "registry.json.zip"), { + out_file = path.concat { self.root_dir, "registry.json.zip" }, + }):map_err(_.always "Failed to download registry.json.zip.")) + + local checksums = try( + fetch(settings.current.github.download_url_template:format(self.spec.repo, version, "checksums.txt")):map_err( + _.always "Failed to download checksums.txt." + ) + ) + local parsed_checksums = parse_checksums(checksums) + + platform.when { + unix = function() + try(spawn.unzip({ "-o", "registry.json.zip", cwd = self.root_dir }):map_err(function(err) + return ("Failed to unpack registry contents: %s"):format(err.stderr) + end)) + end, + win = function() + local powershell = require "mason-core.managers.powershell" + powershell + .command( + ("Microsoft.PowerShell.Archive\\Expand-Archive -Force -Path %q -DestinationPath ."):format "registry.json.zip", + { + cwd = self.root_dir, + } + ) + :map_err(function(err) + return ("Failed to unpack registry contents: %s"):format(err.stderr) + end) + end, + } + pcall(fs.async.unlink, path.concat { self.root_dir, "registry.json.zip" }) + + try(Result.pcall( + fs.async.write_file, + self.info_file, + vim.json.encode { + checksums = parsed_checksums, + version = version, + download_timestamp = os.time(), + } + )) + end) + :on_success(function() + self:reload() + end) + :on_failure(function(err) + log.fmt_error("Failed to install registry %s. %s", self, err) + end) +end + +---@return { checksums: table<string, string>, version: string, download_timestamp: integer } +function GitHubRegistrySource:get_info() + return vim.json.decode(fs.sync.read_file(self.info_file)) +end + +function GitHubRegistrySource:get_display_name() + if self:is_installed() then + local info = self:get_info() + return ("github.com/%s version: %s"):format(self.spec.repo, info.version) + else + return ("github.com/%s [uninstalled]"):format(self.spec.repo) + end +end + +function GitHubRegistrySource:__tostring() + if self.spec.version then + return ("GitHubRegistrySource(repo=%s, version=%s)"):format(self.spec.repo, self.spec.version) + else + return ("GitHubRegistrySource(repo=%s)"):format(self.spec.repo) + end +end + +return GitHubRegistrySource diff --git a/lua/mason-registry/sources/init.lua b/lua/mason-registry/sources/init.lua index e4abe062..5a332326 100644 --- a/lua/mason-registry/sources/init.lua +++ b/lua/mason-registry/sources/init.lua @@ -4,7 +4,23 @@ local M = {} ---@return fun(): RegistrySource # Thunk to instantiate provider. local function parse(registry_id) local type, id = registry_id:match "^(.+):(.+)$" - if type == "lua" then + if type == "github" then + local namespace, name = id:match "^(.+)/(.+)$" + if not namespace or not name then + error(("Failed to parse repository from GitHub registry: %q."):format(registry_id), 0) + end + local name, version = unpack(vim.split(name, "@")) + return function() + local GitHubRegistrySource = require "mason-registry.sources.github" + return GitHubRegistrySource.new { + id = registry_id, + repo = ("%s/%s"):format(namespace, name), + namespace = namespace, + name = name, + version = version or "latest", + } + end + elseif type == "lua" then return function() local LuaRegistrySource = require "mason-registry.sources.lua" return LuaRegistrySource.new { diff --git a/lua/mason-registry/sources/lua.lua b/lua/mason-registry/sources/lua.lua index 7af4feee..ac41c03c 100644 --- a/lua/mason-registry/sources/lua.lua +++ b/lua/mason-registry/sources/lua.lua @@ -34,9 +34,9 @@ function LuaRegistrySource:is_installed() return ok end -function LuaRegistrySource:install() - local Result = require "mason-core.result" - return Result.success() +function LuaRegistrySource:get_installer() + local Optional = require "mason-core.optional" + return Optional.empty() end function LuaRegistrySource:get_display_name() diff --git a/lua/mason/api/command.lua b/lua/mason/api/command.lua index 5d09c67d..fadb9ac4 100644 --- a/lua/mason/api/command.lua +++ b/lua/mason/api/command.lua @@ -180,6 +180,24 @@ vim.api.nvim_create_user_command("MasonUninstallAll", MasonUninstallAll, { desc = "Uninstall all packages.", }) +local function MasonUpdate() + local notify = require "mason-core.notify" + local registry = require "mason-registry" + notify "Updating registries…" + registry.update(vim.schedule_wrap(function(success, updated_registries) + if success then + local count = #updated_registries + notify(("Successfully updated %d %s."):format(count, count == 1 and "registry" or "registries")) + else + notify(("Failed to update registries: %s"):format(updated_registries), vim.log.levels.ERROR) + end + end)) +end + +vim.api.nvim_create_user_command("MasonUpdate", MasonUpdate, { + desc = "Update Mason registries.", +}) + local function MasonLog() local log = require "mason-core.log" vim.cmd(([[tabnew %s]]):format(log.outfile)) @@ -210,5 +228,6 @@ return { MasonInstall = MasonInstall, MasonUninstall = MasonUninstall, MasonUninstallAll = MasonUninstallAll, + MasonUpdate = MasonUpdate, MasonLog = MasonLog, } diff --git a/lua/mason/settings.lua b/lua/mason/settings.lua index 9862c3cd..fe93eedc 100644 --- a/lua/mason/settings.lua +++ b/lua/mason/settings.lua @@ -14,12 +14,6 @@ local DEFAULT_SETTINGS = { ---@type '"prepend"' | '"append"' | '"skip"' PATH = "prepend", - -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in - -- multiple registries, the registry listed first will be used. - registries = { - "lua:mason-registry.index", - }, - -- Controls to which degree logs are written to the log file. It's useful to set this to vim.log.levels.DEBUG when -- debugging issues with package installations. log_level = vim.log.levels.INFO, @@ -28,6 +22,13 @@ local DEFAULT_SETTINGS = { -- packages that are requested to be installed will be put in a queue. max_concurrent_installers = 4, + -- [Advanced setting] + -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in + -- multiple registries, the registry listed first will be used. + registries = { + "lua:mason-registry.index", + }, + -- The provider implementations to use for resolving supplementary package metadata (e.g., all available versions). -- Accepts multiple entries, where later entries will be used as fallback should prior providers fail. -- Builtin providers are: diff --git a/lua/mason/ui/init.lua b/lua/mason/ui/init.lua index a8a60946..bbe534ad 100644 --- a/lua/mason/ui/init.lua +++ b/lua/mason/ui/init.lua @@ -1,5 +1,10 @@ local M = {} +function M.close() + local api = require "mason.ui.instance" + api.close() +end + function M.open() local api = require "mason.ui.instance" api.window.open() diff --git a/lua/mason/ui/instance.lua b/lua/mason/ui/instance.lua index 52717654..d1031fcb 100644 --- a/lua/mason/ui/instance.lua +++ b/lua/mason/ui/instance.lua @@ -81,6 +81,8 @@ local INITIAL_STATE = { total = 0, percentage_complete = 0, }, + ---@type Package[] + all = {}, ---@type table<string, boolean> visible = {}, ---@type string|nil @@ -115,7 +117,6 @@ local function remove(list, item) end local window = display.new_view_only_win("mason.nvim", "mason") -local packages = _.sort_by(_.prop "name", registry.get_all_packages()) window.view( ---@param state InstallerUiState @@ -174,7 +175,7 @@ local function mutate_package_visibility(mutate_fn) _.prop_satisfies(_.any(_.equals(state.view.language_filter)), "languages"), _.T ) - for __, pkg in ipairs(packages) do + for __, pkg in ipairs(state.packages.all) do state.packages.visible[pkg.name] = _.all_pass({ view_predicate[state.view.current], language_predicate }, pkg.spec) end @@ -579,7 +580,26 @@ local effects = { ["UPDATE_ALL_PACKAGES"] = update_all_packages, } -for _, pkg in ipairs(packages) do +local registered_packages = {} + +---@param pkg Package +local function setup_package(pkg) + if registered_packages[pkg] then + return + end + + mutate_state(function(state) + for _, group in ipairs { state.packages.installed, state.packages.uninstalled, state.packages.failed } do + for i, existing_pkg in ipairs(group) do + if existing_pkg.name == pkg.name and pkg ~= existing_pkg then + -- New package instance (i.e. from a new, updated, registry source). + -- Release the old package instance. + table.remove(group, i) + end + end + end + end) + -- hydrate initial state mutate_state(function(state) state.packages.states[pkg.name] = create_initial_package_state() @@ -626,8 +646,37 @@ for _, pkg in ipairs(packages) do end) mutate_package_grouping(pkg, "uninstalled") end) + + registered_packages[pkg] = true end +local function update_registry_info() + local registries = {} + for source in require("mason-registry.sources").iter { include_uninstalled = true } do + table.insert(registries, source:get_display_name()) + end + mutate_state(function(state) + state.info.registries = registries + end) +end + +---@param packages Package[] +local function setup_packages(packages) + _.each(setup_package, _.sort_by(_.prop "name", packages)) + mutate_state(function(state) + state.packages.all = packages + end) +end + +setup_packages(registry.get_all_packages()) + +registry:on("update", function() + setup_packages(registry.get_all_packages()) + update_registry_info() +end) + +update_registry_info() + window.init { effects = effects, border = settings.current.ui.border, @@ -645,16 +694,6 @@ if settings.current.ui.check_outdated_packages_on_open then ) end -do - local registries = {} - for source in require("mason-registry.sources").iter { include_uninstalled = true } do - table.insert(registries, source:get_display_name()) - end - mutate_state(function(state) - state.info.registries = registries - end) -end - return { window = window, set_view = function(view) |
