diff options
| author | William Boman <william@redwill.se> | 2023-03-12 08:21:15 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-03-12 08:21:15 +0100 |
| commit | a01d02ad7f680aec98a1e2ec35b04cedd307cfa8 (patch) | |
| tree | 1a09e274a1f2a4da85b911abcbb182a211035501 | |
| parent | feat(golangci-lint): support linux_arm64 (#1089) (diff) | |
| download | mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.tar mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.tar.gz mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.tar.bz2 mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.tar.lz mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.tar.xz mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.tar.zst mason-a01d02ad7f680aec98a1e2ec35b04cedd307cfa8.zip | |
feat: add github registry source capabilities (#1091)
76 files changed, 4852 insertions, 77 deletions
@@ -1,2 +1,3 @@ +.luarc.json /dependencies /tests/fixtures/mason @@ -163,12 +163,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, @@ -177,6 +171,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/doc/mason.txt b/doc/mason.txt index 8bb8b762..1c2ba506 100644 --- a/doc/mason.txt +++ b/doc/mason.txt @@ -239,12 +239,6 @@ Example: ---@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, @@ -253,6 +247,13 @@ Example: -- 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/doc/reference.md b/doc/reference.md index e51ba57e..ba121423 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -26,6 +26,7 @@ RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as de - [Architecture diagram](#architecture-diagram) - [Registry events](#registry-events) - [`PackageSpec`](#packagespec) +- [`RegistryPackageSpec`](#registrypackagespec) - [`Package`](#package) - [`Package.Parse({package_identifier})`](#packageparsepackage_identifier) - [`Package.Lang`](#packagelang) @@ -65,7 +66,9 @@ RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as de ## Architecture diagram - +<!-- https://excalidraw.com/#json=vbTmp7nM8H5odJDiaw7Ue,TghucvHHAw8bl7sgX1VuvA --> + + ## Registry events @@ -107,10 +110,26 @@ registry:on( | name | `string` | | desc | `string` | | homepage | `string` | -| categories | [`PackageCategory[]`](#package-cat) | -| languages | [`PackageLanguage[]`](#package-lang) | +| categories | [`PackageCategory[]`](#packagecat) | +| languages | [`PackageLanguage[]`](#packagelang) | | install | `async fun(ctx: InstallContext)` | +## `RegistryPackageSpec` + +| Key | Value | +| ----------- | ------------------------------------ | +| schema | `"registry+v1"` | +| name | `string` | +| description | `string` | +| homepage | `string` | +| licenses | `string` | +| categories | [`PackageCategory[]`](#packagecat) | +| languages | [`PackageLanguage[]`](#packagelang) | +| source | `table` | +| bin | `table<string, string>?` | +| share | `table<string, string>?` | +| opt | `table<string, string>?` | + ## `Package` Module: [`"mason-core.package"`](../lua/mason-core/package/init.lua) @@ -175,7 +194,7 @@ All the available categories a package can be tagged with. ### `Package.spec` -**Type**: [`PackageSpec`](#packagespec) +**Type**: [`PackageSpec`](#packagespec) or [`RegistryPackageSpec`](#registrypackagespec) ### `Package:install({opts})` 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) diff --git a/tests/helpers/lua/dummy2_package.lua b/tests/helpers/lua/dummy-registry/dummy2_package.lua index 424e47d7..424e47d7 100644 --- a/tests/helpers/lua/dummy2_package.lua +++ b/tests/helpers/lua/dummy-registry/dummy2_package.lua diff --git a/tests/helpers/lua/dummy_package.lua b/tests/helpers/lua/dummy-registry/dummy_package.lua index b38d1cd8..b38d1cd8 100644 --- a/tests/helpers/lua/dummy_package.lua +++ b/tests/helpers/lua/dummy-registry/dummy_package.lua diff --git a/tests/helpers/lua/dummy-registry/index.lua b/tests/helpers/lua/dummy-registry/index.lua new file mode 100644 index 00000000..85fe000f --- /dev/null +++ b/tests/helpers/lua/dummy-registry/index.lua @@ -0,0 +1,5 @@ +return { + ["dummy"] = "dummy-registry.dummy_package", + ["dummy2"] = "dummy-registry.dummy2_package", + ["registry"] = "dummy-registry.registry_package", +} diff --git a/tests/helpers/lua/dummy-registry/registry_package.lua b/tests/helpers/lua/dummy-registry/registry_package.lua new file mode 100644 index 00000000..e72284a8 --- /dev/null +++ b/tests/helpers/lua/dummy-registry/registry_package.lua @@ -0,0 +1,14 @@ +local Pkg = require "mason-core.package" + +return Pkg.new { + schema = "registry+v1", + name = "registry", + description = [[This is a dummy package.]], + homepage = "https://example.com", + licenses = { "MIT" }, + languages = { "DummyLang" }, + categories = { "LSP" }, + source = { + id = "pkg:dummy/registry@1.0.0", + }, +} diff --git a/tests/helpers/lua/test_helpers.lua b/tests/helpers/lua/test_helpers.lua index f11765d5..8a69ead8 100644 --- a/tests/helpers/lua/test_helpers.lua +++ b/tests/helpers/lua/test_helpers.lua @@ -2,7 +2,9 @@ local util = require "luassert.util" local spy = require "luassert.spy" +local path = require "mason-core.path" local a = require "mason-core.async" +local Result = require "mason-core.result" local InstallHandle = require "mason-core.installer.handle" local InstallContext = require "mason-core.installer.context" local registry = require "mason-registry" @@ -32,6 +34,21 @@ mockx = { end, } +---@param opts? PackageInstallOpts +function create_dummy_context(opts) + local ctx = InstallContextGenerator(InstallHandleGenerator "registry", opts) + ctx.cwd:set(path.package_build_prefix "registry") + ctx.spawn = setmetatable({}, { + __index = function(s, cmd) + s[cmd] = spy.new(function() + return Result.success { stdout = nil, stderr = nil } + end) + return s[cmd] + end, + }) + return ctx +end + -- selene: allow(unused_variable) ---@param package_name string function InstallHandleGenerator(package_name) diff --git a/tests/mason-core/installer/installer_spec.lua b/tests/mason-core/installer/installer_spec.lua index 66cdb89b..aa459cce 100644 --- a/tests/mason-core/installer/installer_spec.lua +++ b/tests/mason-core/installer/installer_spec.lua @@ -63,7 +63,7 @@ describe("installer", function() local handler = InstallHandleGenerator "dummy" ---@param ctx InstallContext handler.package.spec.install = function(ctx) - ctx.receipt:with_primary_source { type = "source", metadata = {} } + ctx.receipt:with_primary_source { type = "source", source = {} } ctx.fs:write_file("target", "") ctx.fs:write_file("file.jar", "") @@ -86,7 +86,7 @@ describe("installer", function() ---@type InstallReceipt local receipt = vim.json.decode(arg) assert.equals("dummy", receipt.name) - assert.same({ type = "source", metadata = {} }, receipt.primary_source) + assert.same({ type = "source", source = {} }, receipt.primary_source) assert.same({}, receipt.secondary_sources) assert.same("1.1", receipt.schema_version) assert.same({ diff --git a/tests/mason-core/installer/managers/cargo_spec.lua b/tests/mason-core/installer/managers/cargo_spec.lua new file mode 100644 index 00000000..63768f4d --- /dev/null +++ b/tests/mason-core/installer/managers/cargo_spec.lua @@ -0,0 +1,112 @@ +local installer = require "mason-core.installer" +local cargo = require "mason-core.installer.managers.cargo" + +describe("cargo manager", function() + it("should install", function() + local handle = InstallHandleGenerator "dummy" + local ctx = InstallContextGenerator(handle) + installer.exec_in_context(ctx, function() + cargo.install("my-crate", "1.0.0") + end) + + assert.spy(ctx.spawn.cargo).was_called(1) + assert.spy(ctx.spawn.cargo).was_called_with { + "install", + "--root", + ".", + { "--version", "1.0.0" }, + vim.NIL, -- features + vim.NIL, -- locked + "my-crate", + } + end) + + it("should install locked", function() + local handle = InstallHandleGenerator "dummy" + local ctx = InstallContextGenerator(handle) + installer.exec_in_context(ctx, function() + cargo.install("my-crate", "1.0.0", { + locked = true, + }) + end) + + assert.spy(ctx.spawn.cargo).was_called(1) + assert.spy(ctx.spawn.cargo).was_called_with { + "install", + "--root", + ".", + { "--version", "1.0.0" }, + vim.NIL, -- features + "--locked", -- locked + "my-crate", + } + end) + + it("should install provided features", function() + local handle = InstallHandleGenerator "dummy" + local ctx = InstallContextGenerator(handle) + installer.exec_in_context(ctx, function() + cargo.install("my-crate", "1.0.0", { + features = "lsp,cli", + }) + end) + + assert.spy(ctx.spawn.cargo).was_called(1) + assert.spy(ctx.spawn.cargo).was_called_with { + "install", + "--root", + ".", + { "--version", "1.0.0" }, + { "--features", "lsp,cli" }, -- features + vim.NIL, -- locked + "my-crate", + } + end) + + it("should install git tag source", function() + local handle = InstallHandleGenerator "dummy" + local ctx = InstallContextGenerator(handle) + installer.exec_in_context(ctx, function() + cargo.install("my-crate", "1.0.0", { + git = { + url = "https://github.com/neovim/neovim", + }, + }) + end) + + assert.spy(ctx.spawn.cargo).was_called(1) + assert.spy(ctx.spawn.cargo).was_called_with { + "install", + "--root", + ".", + { "--git", "https://github.com/neovim/neovim", "--tag", "1.0.0" }, + vim.NIL, -- features + vim.NIL, -- locked + "my-crate", + } + end) + + it("should install git rev source", function() + local handle = InstallHandleGenerator "dummy" + local ctx = InstallContextGenerator(handle) + installer.exec_in_context(ctx, function() + cargo.install("my-crate", "16dfc89abd413c391e5b63ae5d132c22843ce9a7", { + git = { + url = "https://github.com/neovim/neovim", + rev = true, + }, + }) + end) + + assert.spy(ctx.spawn.cargo).was_called(1) + assert.spy(ctx.spawn.cargo).was_called_with { + "install", + "--root", + ".", + { "--git", "https://github.com/neovim/neovim", "--rev", "16dfc89abd413c391e5b63ae5d132c22843ce9a7" }, + vim.NIL, -- features + vim.NIL, -- locked + "my-crate", + } + end) +end) diff --git a/tests/mason-core/installer/managers/composer_spec.lua b/tests/mason-core/installer/managers/composer_spec.lua new file mode 100644 index 00000000..a4e1f82f --- /dev/null +++ b/tests/mason-core/installer/managers/composer_spec.lua @@ -0,0 +1,22 @@ +local installer = require "mason-core.installer" +local composer = require "mason-core.installer.managers.composer" + +describe("composer manager", function() + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + composer.install("my-package", "1.0.0") + end) + + assert.spy(ctx.spawn.composer).was_called(2) + assert.spy(ctx.spawn.composer).was_called_with { + "init", + "--no-interaction", + "--stability=stable", + } + assert.spy(ctx.spawn.composer).was_called_with { + "require", + "my-package:1.0.0", + } + end) +end) diff --git a/tests/mason-core/installer/managers/gem_spec.lua b/tests/mason-core/installer/managers/gem_spec.lua new file mode 100644 index 00000000..580c6432 --- /dev/null +++ b/tests/mason-core/installer/managers/gem_spec.lua @@ -0,0 +1,50 @@ +local installer = require "mason-core.installer" +local gem = require "mason-core.installer.managers.gem" + +describe("gem manager", function() + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + gem.install("my-gem", "1.0.0") + end) + + assert.spy(ctx.spawn.gem).was_called(1) + assert.spy(ctx.spawn.gem).was_called_with { + "install", + "--no-user-install", + "--no-format-executable", + "--install-dir=.", + "--bindir=bin", + "--no-document", + "my-gem:1.0.0", + vim.NIL, -- extra_packages + env = { + GEM_HOME = ctx.cwd:get(), + }, + } + end) + + it("should install extra packages", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + gem.install("my-gem", "1.0.0", { + extra_packages = { "extra-gem" }, + }) + end) + + assert.spy(ctx.spawn.gem).was_called(1) + assert.spy(ctx.spawn.gem).was_called_with { + "install", + "--no-user-install", + "--no-format-executable", + "--install-dir=.", + "--bindir=bin", + "--no-document", + "my-gem:1.0.0", + { "extra-gem" }, + env = { + GEM_HOME = ctx.cwd:get(), + }, + } + end) +end) diff --git a/tests/mason-core/installer/managers/golang_spec.lua b/tests/mason-core/installer/managers/golang_spec.lua new file mode 100644 index 00000000..cdad6e25 --- /dev/null +++ b/tests/mason-core/installer/managers/golang_spec.lua @@ -0,0 +1,56 @@ +local installer = require "mason-core.installer" +local golang = require "mason-core.installer.managers.golang" + +describe("golang manager", function() + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + golang.install("my-golang", "1.0.0") + end) + + assert.spy(ctx.spawn.go).was_called(1) + assert.spy(ctx.spawn.go).was_called_with { + "install", + "-v", + "my-golang@1.0.0", + env = { + GOBIN = ctx.cwd:get(), + }, + } + end) + + it("should install extra packages", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + golang.install("my-golang", "1.0.0", { + extra_packages = { "extra", "package" }, + }) + end) + + assert.spy(ctx.spawn.go).was_called(3) + assert.spy(ctx.spawn.go).was_called_with { + "install", + "-v", + "my-golang@1.0.0", + env = { + GOBIN = ctx.cwd:get(), + }, + } + assert.spy(ctx.spawn.go).was_called_with { + "install", + "-v", + "extra@latest", + env = { + GOBIN = ctx.cwd:get(), + }, + } + assert.spy(ctx.spawn.go).was_called_with { + "install", + "-v", + "package@latest", + env = { + GOBIN = ctx.cwd:get(), + }, + } + end) +end) diff --git a/tests/mason-core/installer/managers/luarocks_spec.lua b/tests/mason-core/installer/managers/luarocks_spec.lua new file mode 100644 index 00000000..69ac3946 --- /dev/null +++ b/tests/mason-core/installer/managers/luarocks_spec.lua @@ -0,0 +1,63 @@ +local installer = require "mason-core.installer" +local stub = require "luassert.stub" +local luarocks = require "mason-core.installer.managers.luarocks" + +describe("luarocks manager", function() + it("should install", function() + local ctx = create_dummy_context() + stub(ctx, "promote_cwd") + installer.exec_in_context(ctx, function() + luarocks.install("my-rock", "1.0.0") + end) + + assert.spy(ctx.promote_cwd).was_called(1) + assert.spy(ctx.spawn.luarocks).was_called(1) + assert.spy(ctx.spawn.luarocks).was_called_with { + "install", + { "--tree", ctx.cwd:get() }, + vim.NIL, -- dev + vim.NIL, -- server + { "my-rock", "1.0.0" }, + } + end) + + it("should install dev mode", function() + local ctx = create_dummy_context() + stub(ctx, "promote_cwd") + installer.exec_in_context(ctx, function() + luarocks.install("my-rock", "1.0.0", { + dev = true, + }) + end) + + assert.spy(ctx.promote_cwd).was_called(1) + assert.spy(ctx.spawn.luarocks).was_called(1) + assert.spy(ctx.spawn.luarocks).was_called_with { + "install", + { "--tree", ctx.cwd:get() }, + "--dev", + vim.NIL, -- server + { "my-rock", "1.0.0" }, + } + end) + + it("should install using provided server", function() + local ctx = create_dummy_context() + stub(ctx, "promote_cwd") + installer.exec_in_context(ctx, function() + luarocks.install("my-rock", "1.0.0", { + server = "https://luarocks.org/dev", + }) + end) + + assert.spy(ctx.promote_cwd).was_called(1) + assert.spy(ctx.spawn.luarocks).was_called(1) + assert.spy(ctx.spawn.luarocks).was_called_with { + "install", + { "--tree", ctx.cwd:get() }, + vim.NIL, -- dev + "--server=https://luarocks.org/dev", + { "my-rock", "1.0.0" }, + } + end) +end) diff --git a/tests/mason-core/installer/managers/npm_spec.lua b/tests/mason-core/installer/managers/npm_spec.lua new file mode 100644 index 00000000..655acb64 --- /dev/null +++ b/tests/mason-core/installer/managers/npm_spec.lua @@ -0,0 +1,53 @@ +local installer = require "mason-core.installer" +local stub = require "luassert.stub" +local match = require "luassert.match" +local npm = require "mason-core.installer.managers.npm" + +describe("npm manager", function() + it("should init package.json", function() + local ctx = create_dummy_context() + stub(ctx.fs, "append_file") + installer.exec_in_context(ctx, function() + npm.init() + end) + + assert.spy(ctx.spawn.npm).was_called(1) + assert.spy(ctx.spawn.npm).was_called_with { + "init", + "--yes", + "--scope=mason", + } + assert.spy(ctx.fs.append_file).was_called(1) + assert.spy(ctx.fs.append_file).was_called_with(match.is_ref(ctx.fs), ".npmrc", "global-style=true") + end) + + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + npm.install("my-package", "1.0.0") + end) + + assert.spy(ctx.spawn.npm).was_called(1) + assert.spy(ctx.spawn.npm).was_called_with { + "install", + "my-package@1.0.0", + vim.NIL, -- extra_packages + } + end) + + it("should install extra packages", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + npm.install("my-package", "1.0.0", { + extra_packages = { "extra-package" }, + }) + end) + + assert.spy(ctx.spawn.npm).was_called(1) + assert.spy(ctx.spawn.npm).was_called_with { + "install", + "my-package@1.0.0", + { "extra-package" }, + } + end) +end) diff --git a/tests/mason-core/installer/managers/nuget_spec.lua b/tests/mason-core/installer/managers/nuget_spec.lua new file mode 100644 index 00000000..1bdecf37 --- /dev/null +++ b/tests/mason-core/installer/managers/nuget_spec.lua @@ -0,0 +1,21 @@ +local installer = require "mason-core.installer" +local nuget = require "mason-core.installer.managers.nuget" + +describe("nuget manager", function() + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + nuget.install("nuget-package", "1.0.0") + end) + + assert.spy(ctx.spawn.dotnet).was_called(1) + assert.spy(ctx.spawn.dotnet).was_called_with { + "tool", + "update", + "--tool-path", + ".", + { "--version", "1.0.0" }, + "nuget-package", + } + end) +end) diff --git a/tests/mason-core/installer/managers/opam_spec.lua b/tests/mason-core/installer/managers/opam_spec.lua new file mode 100644 index 00000000..c1fe59f6 --- /dev/null +++ b/tests/mason-core/installer/managers/opam_spec.lua @@ -0,0 +1,20 @@ +local installer = require "mason-core.installer" +local opam = require "mason-core.installer.managers.opam" + +describe("opam manager", function() + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + opam.install("opam-package", "1.0.0") + end) + + assert.spy(ctx.spawn.opam).was_called(1) + assert.spy(ctx.spawn.opam).was_called_with { + "install", + "--destdir=.", + "--yes", + "--verbose", + "opam-package.1.0.0", + } + end) +end) diff --git a/tests/mason-core/installer/managers/pypi_spec.lua b/tests/mason-core/installer/managers/pypi_spec.lua new file mode 100644 index 00000000..f0a6cdc6 --- /dev/null +++ b/tests/mason-core/installer/managers/pypi_spec.lua @@ -0,0 +1,126 @@ +local installer = require "mason-core.installer" +local stub = require "luassert.stub" +local path = require "mason-core.path" +local pypi = require "mason-core.installer.managers.pypi" + +---@param ctx InstallContext +local function venv_py(ctx) + return path.concat { + ctx.cwd:get(), + "venv", + "bin", + "python", + } +end + +describe("pypi manager", function() + it("should init venv without upgrading pip", function() + local ctx = create_dummy_context() + stub(ctx, "promote_cwd") + installer.exec_in_context(ctx, function() + pypi.init { upgrade_pip = false } + end) + + assert.spy(ctx.promote_cwd).was_called(1) + assert.spy(ctx.spawn.python3).was_called(1) + assert.spy(ctx.spawn.python3).was_called_with { + "-m", + "venv", + "venv", + } + end) + + it("should init venv and upgrade pip", function() + local ctx = create_dummy_context() + stub(ctx, "promote_cwd") + installer.exec_in_context(ctx, function() + pypi.init { upgrade_pip = true, install_extra_args = { "--proxy", "http://localhost" } } + end) + + assert.spy(ctx.promote_cwd).was_called(1) + assert.spy(ctx.spawn.python3).was_called(1) + assert.spy(ctx.spawn.python3).was_called_with { + "-m", + "venv", + "venv", + } + assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1) + assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with { + "-m", + "pip", + "--disable-pip-version-check", + "install", + "-U", + { "--proxy", "http://localhost" }, + { "pip" }, + } + end) + + it("should install", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + pypi.install("pypi-package", "1.0.0") + end) + + assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1) + assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with { + "-m", + "pip", + "--disable-pip-version-check", + "install", + "-U", + vim.NIL, -- install_extra_args + { + "pypi-package==1.0.0", + vim.NIL, -- extra_packages + }, + } + end) + + it("should install extra specifier", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + pypi.install("pypi-package", "1.0.0", { + extra = "lsp", + }) + end) + + assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1) + assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with { + "-m", + "pip", + "--disable-pip-version-check", + "install", + "-U", + vim.NIL, -- install_extra_args + { + "pypi-package[lsp]==1.0.0", + vim.NIL, -- extra_packages + }, + } + end) + + it("should install extra packages", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + pypi.install("pypi-package", "1.0.0", { + extra_packages = { "extra-package" }, + install_extra_args = { "--proxy", "http://localhost:9000" }, + }) + end) + + assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1) + assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with { + "-m", + "pip", + "--disable-pip-version-check", + "install", + "-U", + { "--proxy", "http://localhost:9000" }, + { + "pypi-package==1.0.0", + { "extra-package" }, + }, + } + end) +end) diff --git a/tests/mason-core/installer/managers/std_spec.lua b/tests/mason-core/installer/managers/std_spec.lua new file mode 100644 index 00000000..feff9abe --- /dev/null +++ b/tests/mason-core/installer/managers/std_spec.lua @@ -0,0 +1,150 @@ +local installer = require "mason-core.installer" +local stub = require "luassert.stub" +local match = require "luassert.match" +local std = require "mason-core.installer.managers.std" + +describe("std unpack [Unix]", function() + it("should unpack .gz", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + std.unpack "file.gz" + end) + + assert.spy(ctx.spawn.gzip).was_called(1) + assert.spy(ctx.spawn.gzip).was_called_with { "-d", "file.gz" } + end) + + it("should unpack .tar", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.tar" + end) + + assert.spy(ctx.spawn.tar).was_called(1) + assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar") + end) + + it("should unpack .tar.bz2", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.tar.bz2" + end) + + assert.spy(ctx.spawn.tar).was_called(1) + assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.bz2" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.bz2") + end) + + it("should unpack .tar.gz", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.tar.gz" + end) + + assert.spy(ctx.spawn.tar).was_called(1) + assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.gz" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.gz") + end) + + it("should unpack .tar.xz", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.tar.xz" + end) + + assert.spy(ctx.spawn.tar).was_called(1) + assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.xz" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.xz") + end) + + it("should unpack .tar.zst", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.tar.zst" + end) + + assert.spy(ctx.spawn.tar).was_called(1) + assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.zst" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.zst") + end) + + it("should unpack .vsix", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.vsix" + end) + + assert.spy(ctx.spawn.unzip).was_called(1) + assert.spy(ctx.spawn.unzip).was_called_with { "-d", ".", "file.vsix" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.vsix") + end) + + it("should unpack .zip", function() + local ctx = create_dummy_context() + stub(ctx.fs, "unlink") + installer.exec_in_context(ctx, function() + std.unpack "file.zip" + end) + + assert.spy(ctx.spawn.unzip).was_called(1) + assert.spy(ctx.spawn.unzip).was_called_with { "-d", ".", "file.zip" } + assert.spy(ctx.fs.unlink).was_called(1) + assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.zip") + end) +end) + +describe("std clone", function() + it("should clone", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + std.clone "https://github.com/williamboman/mason.nvim" + end) + + assert.spy(ctx.spawn.git).was_called(1) + assert.spy(ctx.spawn.git).was_called_with { + "clone", + "--depth", + "1", + vim.NIL, -- recursive + "https://github.com/williamboman/mason.nvim", + ".", + } + end) + + it("should clone and checkout rev", function() + local ctx = create_dummy_context() + installer.exec_in_context(ctx, function() + std.clone("https://github.com/williamboman/mason.nvim", { + rev = "e1fd03b1856cb5ad8425f49e18353dc524b02f91", + recursive = true, + }) + end) + + assert.spy(ctx.spawn.git).was_called(3) + assert.spy(ctx.spawn.git).was_called_with { + "clone", + "--depth", + "1", + "--recursive", + "https://github.com/williamboman/mason.nvim", + ".", + } + assert + .spy(ctx.spawn.git) + .was_called_with { "fetch", "--depth", "1", "origin", "e1fd03b1856cb5ad8425f49e18353dc524b02f91" } + assert.spy(ctx.spawn.git).was_called_with { "checkout", "FETCH_HEAD" } + end) +end) diff --git a/tests/mason-core/installer/registry/installer_spec.lua b/tests/mason-core/installer/registry/installer_spec.lua new file mode 100644 index 00000000..e6ca6f91 --- /dev/null +++ b/tests/mason-core/installer/registry/installer_spec.lua @@ -0,0 +1,203 @@ +local match = require "luassert.match" +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local installer = require "mason-core.installer.registry" +local util = require "mason-core.installer.registry.util" + +---@type InstallerProvider +local dummy_provider = { + ---@param source RegistryPackageSource + ---@param purl Purl + ---@param opts PackageInstallOpts + parse = function(source, purl, opts) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + return { + package = purl.name, + extra_info = source.extra_info, + should_fail = source.should_fail, + } + end) + end, + install = function(ctx, source) + if source.should_fail then + return Result.failure "This is a failure." + else + return Result.success() + end + end, +} + +describe("registry installer :: parsing", function() + it("should parse valid package specs", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.parse({ + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + extra_info = "here", + }, + }, {}) + local parsed = result:get_or_nil() + + assert.is_true(result:is_success()) + assert.is_true(match.is_ref(dummy_provider)(parsed.provider)) + assert.same({ + name = "package-name", + scheme = "pkg", + type = "dummy", + version = "v1.2.3", + }, parsed.purl) + assert.same({ + package = "package-name", + extra_info = "here", + }, parsed.source) + end) + + it("should reject incompatible schema versions", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.parse({ + schema = "registry+v1337", + source = { + id = "pkg:dummy/package-name@v1.2.3", + }, + }, {}) + assert.same( + Result.failure [[Current version of mason.nvim is not capable of parsing package schema version "registry+v1337".]], + result + ) + end) + + it("should use requested version", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.parse({ + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + }, + }, { version = "v2.0.0" }) + + assert.is_true(result:is_success()) + local parsed = result:get_or_nil() + + assert.same({ + name = "package-name", + scheme = "pkg", + type = "dummy", + version = "v2.0.0", + }, parsed.purl) + end) + + it("should handle PLATFORM_UNSUPPORTED", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.compile({ + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + supported_platforms = { "VIC64" }, + }, + }, { version = "v2.0.0" }) + + assert.same(Result.failure "The current platform is unsupported.", result) + end) + + it("should error upon parsing failures", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.compile({ + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + supported_platforms = { "VIC64" }, + }, + }, { version = "v2.0.0" }) + + assert.same(Result.failure "The current platform is unsupported.", result) + end) +end) + +describe("registry installer :: compiling", function() + it("should run compiled installer function successfully", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.compile({ + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + }, + }, {}) + + assert.is_true(result:is_success()) + local installer_fn = result:get_or_nil() + + local ctx = create_dummy_context() + local installer_result = require("mason-core.installer").exec_in_context(ctx, installer_fn) + assert.same(Result.success(), installer_result) + end) + + it("should raise errors upon installer failures", function() + installer.register_provider("dummy", dummy_provider) + + local result = installer.compile({ + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + should_fail = true, + }, + }, {}) + + assert.is_true(result:is_success()) + local installer_fn = result:get_or_nil() + + local ctx = create_dummy_context() + local err = assert.has_error(function() + require("mason-core.installer").exec_in_context(ctx, installer_fn) + end) + assert.equals("This is a failure.", err) + end) + + it("should register links", function() + installer.register_provider("dummy", dummy_provider) + local link = require "mason-core.installer.registry.link" + stub(link, "bin", mockx.returns(Result.success())) + stub(link, "share", mockx.returns(Result.success())) + stub(link, "opt", mockx.returns(Result.success())) + + local spec = { + schema = "registry+v1", + source = { + id = "pkg:dummy/package-name@v1.2.3", + }, + bin = { ["exec"] = "exec" }, + opt = { ["opt/"] = "opt/" }, + share = { ["share/"] = "share/" }, + } + + local result = installer.compile(spec, {}) + + assert.is_true(result:is_success()) + local installer_fn = result:get_or_nil() + + local ctx = create_dummy_context() + local installer_result = require("mason-core.installer").exec_in_context(ctx, installer_fn) + assert.is_true(installer_result:is_success()) + + for _, spy in ipairs { link.bin, link.share, link.opt } do + assert.spy(spy).was_called(1) + assert.spy(spy).was_called_with(match.is_ref(ctx), spec, { + scheme = "pkg", + type = "dummy", + name = "package-name", + version = "v1.2.3", + }, { + package = "package-name", + }) + end + end) +end) diff --git a/tests/mason-core/installer/registry/link_spec.lua b/tests/mason-core/installer/registry/link_spec.lua new file mode 100644 index 00000000..eef66349 --- /dev/null +++ b/tests/mason-core/installer/registry/link_spec.lua @@ -0,0 +1,231 @@ +local stub = require "luassert.stub" +local match = require "luassert.match" +local Result = require "mason-core.result" +local Purl = require "mason-core.purl" +local link = require "mason-core.installer.registry.link" +local fs = require "mason-core.fs" +local path = require "mason-core.path" + +describe("registry linker", function() + it("should expand bin table", function() + local ctx = create_dummy_context() + stub(ctx.fs, "file_exists") + stub(ctx.fs, "chmod") + stub(ctx.fs, "fstat") + + ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns(true) + ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns { + mode = 493, -- 0755 + } + + local result = link.bin( + ctx, + { + bin = { + ["exec"] = "exec.sh", + }, + }, + Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), + { + metadata = "value", + } + ) + + assert.same( + Result.success { + ["exec"] = "exec.sh", + }, + result + ) + assert.same({ + ["exec"] = "exec.sh", + }, ctx.links.bin) + + assert.spy(ctx.fs.chmod).was_not_called() + end) + + it("should chmod executable if necessary", function() + local ctx = create_dummy_context() + stub(ctx.fs, "file_exists") + stub(ctx.fs, "chmod") + stub(ctx.fs, "fstat") + + ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns(true) + ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns { + mode = 420, -- 0644 + } + + local result = link.bin( + ctx, + { + bin = { + ["exec"] = "exec.sh", + }, + }, + Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), + { + metadata = "value", + } + ) + + assert.is_true(result:is_success()) + assert.spy(ctx.fs.chmod).was_called(1) + assert.spy(ctx.fs.chmod).was_called_with(match.is_ref(ctx.fs), "exec.sh", 493) + end) + + it("should interpolate bin table", function() + local ctx = create_dummy_context() + stub(ctx.fs, "file_exists") + stub(ctx.fs, "chmod") + stub(ctx.fs, "fstat") + + ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns(true) + ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns { + mode = 493, -- 0755 + } + + local result = link.bin( + ctx, + { + bin = { + ["exec"] = "{{version}}-{{source.script}}", + }, + }, + Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), + { + script = "exec.sh", + } + ) + + assert.same( + Result.success { + ["exec"] = "v1.0.0-exec.sh", + }, + result + ) + end) + + it("should delegate bin paths", function() + local ctx = create_dummy_context() + stub(ctx.fs, "file_exists") + stub(ctx.fs, "chmod") + stub(ctx.fs, "fstat") + + local matrix = { + ["cargo:executable"] = "bin/executable", + ["composer:executable"] = "vendor/bin/executable", + ["golang:executable"] = "executable", + ["luarocks:executable"] = "bin/executable", + ["npm:executable"] = "node_modules/.bin/executable", + ["nuget:executable"] = "executable", + ["opam:executable"] = "bin/executable", + ["pypi:executable"] = "venv/bin/executable", + } + + for bin, path in pairs(matrix) do + ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), path).returns(true) + ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), path).returns { + mode = 493, -- 0755 + } + + local result = link.bin(ctx, { + bin = { + ["executable"] = bin, + }, + }, Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), {}) + + assert.same( + Result.success { + ["executable"] = path, + }, + result + ) + end + end) + + it("should register share links", function() + local ctx = create_dummy_context() + stub(ctx.fs, "file_exists") + stub(fs.sync, "file_exists") + stub(vim.fn, "glob") + + vim.fn.glob.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0/dir/" } .. "**/*", false, true).returns { + path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" }, + path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" }, + path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" }, + } + fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" }).returns(true) + fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" }).returns(true) + fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" }).returns(true) + + ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns(true) + + local result = link.share( + ctx, + { + share = { + ["file"] = "{{version}}-{{source.file}}", + ["dir/"] = "{{version}}/dir/", + ["empty/"] = "{{source.empty}}", + }, + }, + Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), + { + file = "file", + } + ) + + assert.same( + Result.success { + ["file"] = "v1.0.0-file", + ["dir/file1"] = "v1.0.0/dir/file1", + ["dir/file2"] = "v1.0.0/dir/file2", + ["dir/file3"] = "v1.0.0/dir/file3", + }, + result + ) + end) + + it("should register opt links", function() + local ctx = create_dummy_context() + stub(ctx.fs, "file_exists") + stub(fs.sync, "file_exists") + stub(vim.fn, "glob") + + vim.fn.glob.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0/dir/" } .. "**/*", false, true).returns { + path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" }, + path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" }, + path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" }, + } + fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" }).returns(true) + fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" }).returns(true) + fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" }).returns(true) + + ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns(true) + + local result = link.opt( + ctx, + { + opt = { + ["file"] = "{{version}}-{{source.file}}", + ["dir/"] = "{{version}}/dir/", + ["empty/"] = "{{source.empty}}", + }, + }, + Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), + { + file = "file", + } + ) + + assert.same( + Result.success { + ["file"] = "v1.0.0-file", + ["dir/file1"] = "v1.0.0/dir/file1", + ["dir/file2"] = "v1.0.0/dir/file2", + ["dir/file3"] = "v1.0.0/dir/file3", + }, + result + ) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/cargo_spec.lua b/tests/mason-core/installer/registry/providers/cargo_spec.lua new file mode 100644 index 00000000..f2ae679c --- /dev/null +++ b/tests/mason-core/installer/registry/providers/cargo_spec.lua @@ -0,0 +1,143 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local cargo = require "mason-core.installer.registry.providers.cargo" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:cargo/crate-name@1.4.3"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("cargo provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + crate = "crate-name", + version = "1.4.3", + features = nil, + locked = true, + git = nil, + }, + cargo.parse({}, purl()) + ) + end) + + it("should respect repository_url qualifier", function() + assert.same( + Result.success { + crate = "crate-name", + version = "1.4.3", + features = nil, + locked = true, + git = { url = "https://github.com/crate-org/crate-name", rev = false }, + }, + cargo.parse({}, purl { qualifiers = { repository_url = "https://github.com/crate-org/crate-name" } }) + ) + end) + + it("should respect repository_url qualifier with rev=true qualifier", function() + assert.same( + Result.success { + crate = "crate-name", + version = "1.4.3", + features = nil, + locked = true, + git = { url = "https://github.com/crate-org/crate-name", rev = true }, + }, + cargo.parse( + {}, + purl { qualifiers = { repository_url = "https://github.com/crate-org/crate-name", rev = "true" } } + ) + ) + end) + + it("should respect features qualifier", function() + assert.same( + Result.success { + crate = "crate-name", + version = "1.4.3", + features = "lsp,cli", + locked = true, + git = nil, + }, + cargo.parse({}, purl { qualifiers = { features = "lsp,cli" } }) + ) + end) + + it("should respect locked qualifier", function() + assert.same( + Result.success { + crate = "crate-name", + version = "1.4.3", + features = nil, + locked = false, + git = nil, + }, + cargo.parse({}, purl { qualifiers = { locked = "false" } }) + ) + end) + + it("should check supported platforms", function() + assert.same( + Result.failure "PLATFORM_UNSUPPORTED", + cargo.parse({ + supported_platforms = { "VIC64" }, + }, purl { qualifiers = { locked = "false" } }) + ) + end) +end) + +describe("cargo provider :: installing", function() + it("should install cargo packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.cargo" + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return cargo.install(ctx, { + crate = "crate-name", + version = "1.2.0", + features = nil, + locked = true, + git = nil, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("crate-name", "1.2.0", { + git = nil, + features = nil, + locked = true, + }) + end) + + it("should ensure valid version", function() + local ctx = create_dummy_context { + version = "1.10.0", + } + local manager = require "mason-core.installer.managers.cargo" + local providers = require "mason-core.providers" + stub(providers.crates, "get_all_versions", mockx.returns(Result.success { "1.0.0" })) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return cargo.install(ctx, { + crate = "crate-name", + version = "1.10.0", + features = nil, + locked = true, + git = nil, + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.10.0" is not available.]], result) + assert.spy(manager.install).was_called(0) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/composer_spec.lua b/tests/mason-core/installer/registry/providers/composer_spec.lua new file mode 100644 index 00000000..4849e3f7 --- /dev/null +++ b/tests/mason-core/installer/registry/providers/composer_spec.lua @@ -0,0 +1,66 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local composer = require "mason-core.installer.registry.providers.composer" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:composer/vendor/package@2.0.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("composer provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "vendor/package", + version = "2.0.0", + }, + composer.parse({}, purl()) + ) + end) +end) + +describe("composer provider :: installing", function() + it("should install composer packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.composer" + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return composer.install(ctx, { + package = "vendor/package", + version = "1.2.0", + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("vendor/package", "1.2.0") + end) + + it("should ensure valid version", function() + local ctx = create_dummy_context { + version = "1.10.0", + } + local manager = require "mason-core.installer.managers.composer" + local providers = require "mason-core.providers" + stub(providers.packagist, "get_all_versions", mockx.returns(Result.success { "1.0.0" })) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return composer.install(ctx, { + package = "vendor/package", + version = "1.10.0", + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.10.0" is not available.]], result) + assert.spy(manager.install).was_called(0) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/gem_spec.lua b/tests/mason-core/installer/registry/providers/gem_spec.lua new file mode 100644 index 00000000..9696ec6e --- /dev/null +++ b/tests/mason-core/installer/registry/providers/gem_spec.lua @@ -0,0 +1,72 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local gem = require "mason-core.installer.registry.providers.gem" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:gem/package@1.2.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("gem provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "package", + version = "1.2.0", + extra_packages = { "extra" }, + }, + gem.parse({ extra_packages = { "extra" } }, purl()) + ) + end) + + it("should check supported platforms", function() + assert.same(Result.failure "PLATFORM_UNSUPPORTED", gem.parse({ supported_platforms = { "VIC64" } }, purl())) + end) +end) + +describe("gem provider :: installing", function() + it("should install gem packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.gem" + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return gem.install(ctx, { + package = "package", + version = "5.2.0", + extra_packages = { "extra" }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("package", "5.2.0", { extra_packages = { "extra" } }) + end) + + it("should ensure valid version", function() + local ctx = create_dummy_context { + version = "1.10.0", + } + local manager = require "mason-core.installer.managers.gem" + local providers = require "mason-core.providers" + stub(providers.rubygems, "get_all_versions", mockx.returns(Result.success { "1.0.0" })) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return gem.install(ctx, { + package = "package", + version = "1.10.0", + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.10.0" is not available.]], result) + assert.spy(manager.install).was_called(0) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/generic_spec.lua b/tests/mason-core/installer/registry/providers/generic_spec.lua new file mode 100644 index 00000000..899709c5 --- /dev/null +++ b/tests/mason-core/installer/registry/providers/generic_spec.lua @@ -0,0 +1,100 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local generic = require "mason-core.installer.registry.providers.generic" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:generic/namespace/name@v1.2.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("generic provider :: parsing", function() + it("should parse single download target", function() + assert.same( + Result.success { + download = { + files = { + ["name.tar.gz"] = [[https://getpackage.org/downloads/1.2.0/name.tar.gz]], + }, + }, + }, + generic.parse({ + download = { + files = { + ["name.tar.gz"] = [[https://getpackage.org/downloads/{{version | strip_prefix "v"}}/name.tar.gz]], + }, + }, + }, purl()) + ) + end) + + it("should coalesce download target", function() + assert.same( + Result.success { + download = { + target = "linux_arm64", + files = { + ["name.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/1.2.0/name.tar.gz]], + }, + }, + }, + generic.parse({ + download = { + { + target = "linux_arm64", + files = { + ["name.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/{{version | strip_prefix "v"}}/name.tar.gz]], + }, + }, + { + target = "win_arm64", + files = { + ["name.tar.gz"] = [[https://getpackage.org/downloads/win-aarch64/{{version | strip_prefix "v"}}/name.tar.gz]], + }, + }, + }, + }, purl(), { target = "linux_arm64" }) + ) + end) + + it("should check supported platforms", function() + assert.same(Result.failure "PLATFORM_UNSUPPORTED", generic.parse({ supported_platforms = { "VIC64" } }, purl())) + end) +end) + +describe("generic provider :: installing", function() + it("should install generic packages", function() + local ctx = create_dummy_context() + local std = require "mason-core.installer.managers.std" + stub(std, "download_file", mockx.returns(Result.success())) + stub(std, "unpack", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return generic.install(ctx, { + download = { + files = { + ["name.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/1.2.0/name.tar.gz]], + ["archive.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/1.2.0/archive.tar.gz]], + }, + }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(std.download_file).was_called(2) + assert + .spy(std.download_file) + .was_called_with("https://getpackage.org/downloads/linux-aarch64/1.2.0/name.tar.gz", "name.tar.gz") + assert + .spy(std.download_file) + .was_called_with("https://getpackage.org/downloads/linux-aarch64/1.2.0/archive.tar.gz", "archive.tar.gz") + assert.spy(std.unpack).was_called(2) + assert.spy(std.unpack).was_called_with "name.tar.gz" + assert.spy(std.unpack).was_called_with "archive.tar.gz" + end) +end) diff --git a/tests/mason-core/installer/registry/providers/github_spec.lua b/tests/mason-core/installer/registry/providers/github_spec.lua new file mode 100644 index 00000000..0c43e3cf --- /dev/null +++ b/tests/mason-core/installer/registry/providers/github_spec.lua @@ -0,0 +1,355 @@ +local stub = require "luassert.stub" +local mock = require "luassert.mock" +local spy = require "luassert.spy" +local match = require "luassert.match" +local Result = require "mason-core.result" +local github = require "mason-core.installer.registry.providers.github" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" +local registry_installer = require "mason-core.installer.registry" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:github/namespace/name@2023-03-09"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("github provider :: parsing", function() + it("should parse release asset source", function() + assert.same( + Result.success { + repo = "namespace/name", + asset = { + file = "file-2023-03-09.jar", + }, + downloads = { + { + out_file = "file-2023-03-09.jar", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-2023-03-09.jar", + }, + }, + }, + github.parse({ + asset = { + file = "file-{{version}}.jar", + }, + }, purl()) + ) + end) + + it("should parse release asset source with multiple targets", function() + assert.same( + Result.success { + repo = "namespace/name", + asset = { + target = "linux_x64", + file = "file-linux-amd64-2023-03-09.tar.gz", + }, + downloads = { + { + out_file = "file-linux-amd64-2023-03-09.tar.gz", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz", + }, + }, + }, + github.parse({ + asset = { + { + target = "win_arm", + file = "file-win-arm-{{version}}.zip", + }, + { + target = "linux_x64", + file = "file-linux-amd64-{{version}}.tar.gz", + }, + }, + }, purl(), { target = "linux_x64" }) + ) + end) + + it("should parse release asset source with output to different directory", function() + assert.same( + Result.success { + repo = "namespace/name", + asset = { + file = "file-linux-amd64-2023-03-09.tar.gz:out-dir/", + }, + downloads = { + { + out_file = "out-dir/file-linux-amd64-2023-03-09.tar.gz", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz", + }, + }, + }, + github.parse({ + asset = { + file = "file-linux-amd64-{{version}}.tar.gz:out-dir/", + }, + }, purl(), { target = "linux_x64" }) + ) + end) + + it("should parse build source", function() + assert.same( + Result.success { + build = { run = [[npm install && npm run compile]] }, + repo = "https://github.com/namespace/name.git", + rev = "2023-03-09", + }, + github.parse({ + build = { + run = [[npm install && npm run compile]], + }, + }, purl()) + ) + end) + + it("should parse build source with multiple targets", function() + assert.same( + Result.success { + build = { target = "win_x64", run = [[npm install]] }, + repo = "https://github.com/namespace/name.git", + rev = "2023-03-09", + }, + github.parse({ + build = { + { + target = "linux_arm64", + run = [[npm install && npm run compile]], + }, + { + target = "win_x64", + run = [[npm install]], + }, + }, + }, purl(), { target = "win_x64" }) + ) + end) + + it("should upsert version overrides", function() + local result = registry_installer.parse({ + schema = "registry+v1", + source = { + id = "pkg:github/owner/repo@1.2.3", + asset = { + { + target = "darwin_x64", + file = "asset.tar.gz", + }, + }, + version_overrides = { + { + constraint = "semver:<=1.0.0", + asset = { + { + target = "darwin_x64", + file = "old-asset.tar.gz", + }, + }, + }, + }, + }, + }, { version = "1.0.0", target = "darwin_x64" }) + local parsed = result:get_or_nil() + + assert.is_true(result:is_success()) + assert.same({ + asset = { + target = "darwin_x64", + file = "old-asset.tar.gz", + }, + downloads = { + { + download_url = "https://github.com/owner/repo/releases/download/1.0.0/old-asset.tar.gz", + out_file = "old-asset.tar.gz", + }, + }, + repo = "owner/repo", + }, parsed.source) + end) + + it("should override source if version override provides its own purl id", function() + local result = registry_installer.parse({ + schema = "registry+v1", + source = { + id = "pkg:github/owner/repo@1.2.3", + asset = { + file = "asset.tar.gz", + }, + version_overrides = { + { + constraint = "semver:<=1.0.0", + id = "pkg:npm/old-package", + }, + }, + }, + }, { version = "1.0.0", target = "darwin_x64" }) + + assert.is_true(result:is_success()) + local parsed = result:get_or_throw() + assert.same({ + type = "npm", + scheme = "pkg", + name = "old-package", + version = "1.0.0", + }, parsed.purl) + end) +end) + +describe("github provider :: installing", function() + it("should install github release assets", function() + local ctx = create_dummy_context() + local std = require "mason-core.installer.managers.std" + stub(std, "download_file", mockx.returns(Result.success())) + stub(std, "unpack", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return github.install(ctx, { + repo = "namespace/name", + asset = { + file = "file-linux-amd64-2023-03-09.tar.gz", + }, + downloads = { + { + out_file = "file-linux-amd64-2023-03-09.tar.gz", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz", + }, + { + out_file = "another-file-linux-amd64-2023-03-09.tar.gz", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/another-file-linux-amd64-2023-03-09.tar.gz", + }, + }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(std.download_file).was_called(2) + assert.spy(std.download_file).was_called_with( + "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz", + "file-linux-amd64-2023-03-09.tar.gz" + ) + assert.spy(std.download_file).was_called_with( + "https://github.com/namespace/name/releases/download/2023-03-09/another-file-linux-amd64-2023-03-09.tar.gz", + "another-file-linux-amd64-2023-03-09.tar.gz" + ) + assert.spy(std.unpack).was_called(2) + assert.spy(std.unpack).was_called_with "file-linux-amd64-2023-03-09.tar.gz" + assert.spy(std.unpack).was_called_with "another-file-linux-amd64-2023-03-09.tar.gz" + end) + + it("should install github release assets into specified output directory", function() + local ctx = create_dummy_context() + local std = require "mason-core.installer.managers.std" + local download_file_cwd, unpack_cwd + stub(std, "download_file", function() + download_file_cwd = ctx.cwd:get() + return Result.success() + end) + stub(std, "unpack", function() + unpack_cwd = ctx.cwd:get() + return Result.success() + end) + stub(ctx.fs, "mkdirp") + spy.on(ctx, "chdir") + + local result = installer.exec_in_context(ctx, function() + return github.install(ctx, { + repo = "namespace/name", + asset = { + file = "file.zip", + }, + downloads = { + { + out_file = "out/dir/file.zip", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file.zip", + }, + }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(ctx.fs.mkdirp).was_called(1) + assert.spy(ctx.fs.mkdirp).was_called_with(match.is_ref(ctx.fs), "out/dir") + assert.spy(ctx.chdir).was_called(1) + assert.spy(ctx.chdir).was_called_with(match.is_ref(ctx), "out/dir", match.is_function()) + assert.spy(std.download_file).was_called(1) + assert.is_true(match.matches "out/dir$"(download_file_cwd)) + assert + .spy(std.download_file) + .was_called_with("https://github.com/namespace/name/releases/download/2023-03-09/file.zip", "file.zip") + assert.spy(std.unpack).was_called(1) + assert.is_true(match.matches "out/dir$"(unpack_cwd)) + assert.spy(std.unpack).was_called_with "file.zip" + end) + + it("should install ensure valid version when installing release asset", function() + local ctx = create_dummy_context { + version = "1.42.0", + } + local std = require "mason-core.installer.managers.std" + local providers = require "mason-core.providers" + stub(std, "download_file") + stub(providers.github, "get_all_release_versions", mockx.returns(Result.success { "2023-03-09" })) + + local result = installer.exec_in_context(ctx, function() + return github.install(ctx, { + repo = "namespace/name", + asset = { + file = "file.zip", + }, + downloads = { + { + out_file = "out/dir/file.zip", + download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file.zip", + }, + }, + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.42.0" is not available.]], result) + assert.spy(std.download_file).was_called(0) + end) + + it("should install github build sources", function() + local ctx = create_dummy_context() + local std = require "mason-core.installer.managers.std" + local uv = require "mason-core.async.uv" + stub(uv, "write") + stub(uv, "shutdown") + stub(uv, "close") + local stdin = mock.new() + stub(std, "clone", mockx.returns(Result.success())) + stub( + ctx.spawn, + "bash", ---@param args SpawnArgs + function(args) + args.on_spawn(mock.new(), { stdin }) + return Result.success() + end + ) + + local result = installer.exec_in_context(ctx, function() + return github.install(ctx, { + repo = "namespace/name", + rev = "2023-03-09", + build = { + run = [[npm install && npm run compile]], + }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(std.clone).was_called(1) + assert.spy(std.clone).was_called_with("namespace/name", { rev = "2023-03-09" }) + assert.spy(ctx.spawn.bash).was_called(1) + assert.spy(uv.write).was_called(2) + assert.spy(uv.write).was_called_with(stdin, "set -euxo pipefail;\n") + assert.spy(uv.write).was_called_with(stdin, "npm install && npm run compile") + assert.spy(uv.shutdown).was_called_with(stdin) + assert.spy(uv.close).was_called_with(stdin) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/golang_spec.lua b/tests/mason-core/installer/registry/providers/golang_spec.lua new file mode 100644 index 00000000..d7b9f448 --- /dev/null +++ b/tests/mason-core/installer/registry/providers/golang_spec.lua @@ -0,0 +1,68 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local golang = require "mason-core.installer.registry.providers.golang" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:golang/namespace/package@v1.5.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("golang provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "namespace/package", + version = "v1.5.0", + extra_packages = { "extra" }, + }, + golang.parse({ extra_packages = { "extra" } }, purl()) + ) + end) +end) + +describe("golang provider :: installing", function() + it("should install golang packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.golang" + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return golang.install(ctx, { + package = "namespace/package", + version = "v1.5.0", + extra_packages = { "extra" }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("namespace/package", "v1.5.0", { extra_packages = { "extra" } }) + end) + + it("should ensure valid version", function() + local ctx = create_dummy_context { + version = "1.10.0", + } + local manager = require "mason-core.installer.managers.golang" + local providers = require "mason-core.providers" + stub(providers.golang, "get_all_versions", mockx.returns(Result.success { "1.0.0" })) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return golang.install(ctx, { + package = "package", + version = "1.10.0", + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.10.0" is not available.]], result) + assert.spy(manager.install).was_called(0) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/luarocks_spec.lua b/tests/mason-core/installer/registry/providers/luarocks_spec.lua new file mode 100644 index 00000000..992726eb --- /dev/null +++ b/tests/mason-core/installer/registry/providers/luarocks_spec.lua @@ -0,0 +1,78 @@ +local stub = require "luassert.stub" +local match = require "luassert.match" +local Result = require "mason-core.result" +local luarocks = require "mason-core.installer.registry.providers.luarocks" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:luarocks/namespace/name@1.0.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("luarocks provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "namespace/name", + version = "1.0.0", + server = nil, + dev = false, + }, + luarocks.parse({}, purl()) + ) + end) + + it("should parse package dev flag", function() + assert.same( + Result.success { + package = "namespace/name", + version = "1.0.0", + server = nil, + dev = true, + }, + luarocks.parse({}, purl { qualifiers = { dev = "true" } }) + ) + end) + + it("should parse package server flag", function() + assert.same( + Result.success { + package = "namespace/name", + version = "1.0.0", + server = "https://luarocks.org/dev", + dev = false, + }, + luarocks.parse({}, purl { qualifiers = { repository_url = "https://luarocks.org/dev" } }) + ) + end) +end) + +describe("luarocks provider :: installing", function() + it("should install luarocks packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.luarocks" + local ret_val = Result.success() + stub(manager, "install", mockx.returns(ret_val)) + + local result = installer.exec_in_context(ctx, function() + return luarocks.install(ctx, { + package = "namespace/name", + version = "1.0.0", + server = "https://luarocks.org/dev", + dev = false, + }) + end) + + assert.is_true(match.is_ref(ret_val)(result)) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("namespace/name", "1.0.0", { + dev = false, + server = "https://luarocks.org/dev", + }) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/npm_spec.lua b/tests/mason-core/installer/registry/providers/npm_spec.lua new file mode 100644 index 00000000..cd20a07d --- /dev/null +++ b/tests/mason-core/installer/registry/providers/npm_spec.lua @@ -0,0 +1,72 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local npm = require "mason-core.installer.registry.providers.npm" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:npm/%40namespace/package@v1.5.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("npm provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "@namespace/package", + version = "v1.5.0", + extra_packages = { "extra" }, + }, + npm.parse({ extra_packages = { "extra" } }, purl()) + ) + end) +end) + +describe("npm provider :: installing", function() + it("should install npm packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.npm" + stub(manager, "init", mockx.returns(Result.success())) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return npm.install(ctx, { + package = "@namespace/package", + version = "v1.5.0", + extra_packages = { "extra" }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.init).was_called(1) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("@namespace/package", "v1.5.0", { extra_packages = { "extra" } }) + end) + + it("should ensure valid version", function() + local ctx = create_dummy_context { + version = "1.10.0", + } + local manager = require "mason-core.installer.managers.npm" + local providers = require "mason-core.providers" + stub(providers.npm, "get_all_versions", mockx.returns(Result.success { "1.0.0" })) + stub(manager, "init", mockx.returns(Result.success())) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return npm.install(ctx, { + package = "package", + version = "1.10.0", + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.10.0" is not available.]], result) + assert.spy(manager.init).was_called(0) + assert.spy(manager.install).was_called(0) + end) +end) diff --git a/tests/mason-core/installer/registry/providers/nuget_spec.lua b/tests/mason-core/installer/registry/providers/nuget_spec.lua new file mode 100644 index 00000000..eac96251 --- /dev/null +++ b/tests/mason-core/installer/registry/providers/nuget_spec.lua @@ -0,0 +1,45 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local nuget = require "mason-core.installer.registry.providers.nuget" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:nuget/package@2.2.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("nuget provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "package", + version = "2.2.0", + }, + nuget.parse({}, purl()) + ) + end) +end) + +describe("nuget provider :: installing", function() + it("should install nuget packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.nuget" + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return nuget.install(ctx, { + package = "package", + version = "1.5.0", + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("package", "1.5.0") + end) +end) diff --git a/tests/mason-core/installer/registry/providers/opam_spec.lua b/tests/mason-core/installer/registry/providers/opam_spec.lua new file mode 100644 index 00000000..53b5d767 --- /dev/null +++ b/tests/mason-core/installer/registry/providers/opam_spec.lua @@ -0,0 +1,45 @@ +local stub = require "luassert.stub" +local Result = require "mason-core.result" +local opam = require "mason-core.installer.registry.providers.opam" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:opam/package@2.2.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("opam provider :: parsing", function() + it("should parse package", function() + assert.same( + Result.success { + package = "package", + version = "2.2.0", + }, + opam.parse({}, purl()) + ) + end) +end) + +describe("opam provider :: installing", function() + it("should install opam packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.opam" + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return opam.install(ctx, { + package = "package", + version = "1.5.0", + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("package", "1.5.0") + end) +end) diff --git a/tests/mason-core/installer/registry/providers/pypi_spec.lua b/tests/mason-core/installer/registry/providers/pypi_spec.lua new file mode 100644 index 00000000..222b473e --- /dev/null +++ b/tests/mason-core/installer/registry/providers/pypi_spec.lua @@ -0,0 +1,110 @@ +local stub = require "luassert.stub" +local settings = require "mason.settings" +local Result = require "mason-core.result" +local pypi = require "mason-core.installer.registry.providers.pypi" +local Purl = require "mason-core.purl" +local installer = require "mason-core.installer" + +---@param overrides Purl +local function purl(overrides) + local purl = Purl.parse("pkg:pypi/package@5.5.0"):get_or_throw() + if not overrides then + return purl + end + return vim.tbl_deep_extend("force", purl, overrides) +end + +describe("pypi provider :: parsing", function() + it("should parse package", function() + settings.set { + pip = { + install_args = { "--proxy", "http://localghost" }, + upgrade_pip = true, + }, + } + + assert.same( + Result.success { + package = "package", + version = "5.5.0", + extra_packages = { "extra" }, + pip = { + upgrade = true, + extra_args = { "--proxy", "http://localghost" }, + }, + }, + pypi.parse({ extra_packages = { "extra" } }, purl()) + ) + settings.set(settings._DEFAULT_SETTINGS) + end) + + it("should check supported platforms", function() + assert.same(Result.failure "PLATFORM_UNSUPPORTED", pypi.parse({ supported_platforms = { "VIC64" } }, purl())) + end) +end) + +describe("pypi provider :: installing", function() + it("should install pypi packages", function() + local ctx = create_dummy_context() + local manager = require "mason-core.installer.managers.pypi" + stub(manager, "init", mockx.returns(Result.success())) + stub(manager, "install", mockx.returns(Result.success())) + settings.set { + pip = { + install_args = { "--proxy", "http://localghost" }, + upgrade_pip = true, + }, + } + + local result = installer.exec_in_context(ctx, function() + return pypi.install(ctx, { + package = "package", + extra = "lsp", + version = "1.5.0", + extra_packages = { "extra" }, + pip = { + upgrade = true, + extra_args = { "--proxy", "http://localghost" }, + }, + }) + end) + + assert.is_true(result:is_success()) + assert.spy(manager.init).was_called(1) + assert.spy(manager.init).was_called_with { + upgrade_pip = true, + install_extra_args = { "--proxy", "http://localghost" }, + } + assert.spy(manager.install).was_called(1) + assert.spy(manager.install).was_called_with("package", "1.5.0", { extra = "lsp", extra_packages = { "extra" } }) + settings.set(settings._DEFAULT_SETTINGS) + end) + + it("should ensure valid version", function() + local ctx = create_dummy_context { + version = "1.10.0", + } + local manager = require "mason-core.installer.managers.pypi" + local providers = require "mason-core.providers" + stub(providers.pypi, "get_all_versions", mockx.returns(Result.success { "1.0.0" })) + stub(manager, "init", mockx.returns(Result.success())) + stub(manager, "install", mockx.returns(Result.success())) + + local result = installer.exec_in_context(ctx, function() + return pypi.install(ctx, { + package = "package", + version = "1.5.0", + extra_packages = {}, + pip = { + upgrade = true, + extra_args = { "--proxy", "http://localghost" }, + }, + }) + end) + + assert.is_true(result:is_failure()) + assert.same(Result.failure [[Version "1.10.0" is not available.]], result) + assert.spy(manager.init).was_called(0) + assert.spy(manager.install).was_called(0) + end) +end) diff --git a/tests/mason-core/installer/registry/util_spec.lua b/tests/mason-core/installer/registry/util_spec.lua new file mode 100644 index 00000000..d6cdd6b5 --- /dev/null +++ b/tests/mason-core/installer/registry/util_spec.lua @@ -0,0 +1,81 @@ +local match = require "luassert.match" +local Result = require "mason-core.result" +local util = require "mason-core.installer.registry.util" +local platform = require "mason-core.platform" +local installer = require "mason-core.installer" + +describe("registry installer util", function() + it("should coalesce single target", function() + local source = { value = "here" } + local coalesced = util.coalesce_by_target(source, {}):get() + assert.is_true(match.is_ref(source)(coalesced)) + end) + + it("should coalesce multiple targets", function() + local source = { target = "VIC64", value = "here" } + local coalesced = util.coalesce_by_target({ + { + target = "linux_arm64", + value = "here", + }, + source, + }, { target = "VIC64" }):get() + + assert.is_true(match.is_ref(source)(coalesced)) + end) + + it("should accept valid platform", function() + platform.is.VIC64 = true + local result = util.ensure_valid_platform { + "VIC64", + "linux_arm64", + } + assert.is_true(result:is_success()) + platform.is.VIC64 = nil + end) + + it("should reject invalid platform", function() + local result = util.ensure_valid_platform { "VIC64" } + assert.same(Result.failure "PLATFORM_UNSUPPORTED", result) + end) + + it("should accept valid version", function() + local ctx = create_dummy_context { version = "1.0.0" } + local result = installer.exec_in_context(ctx, function() + return util.ensure_valid_version(function() + return Result.success { "1.0.0", "2.0.0", "3.0.0" } + end) + end) + assert.is_true(result:is_success()) + end) + + it("should reject invalid version", function() + local ctx = create_dummy_context { version = "13.3.7" } + local result = installer.exec_in_context(ctx, function() + return util.ensure_valid_version(function() + return Result.success { "1.0.0", "2.0.0", "3.0.0" } + end) + end) + assert.same(Result.failure [[Version "13.3.7" is not available.]], result) + end) + + it("should gracefully accept version if unable to resolve available versions", function() + local ctx = create_dummy_context { version = "13.3.7" } + local result = installer.exec_in_context(ctx, function() + return util.ensure_valid_version(function() + return Result.failure() + end) + end) + assert.is_true(result:is_success()) + end) + + it("should accept version if in force mode", function() + local ctx = create_dummy_context { version = "13.3.7", force = true } + local result = installer.exec_in_context(ctx, function() + return util.ensure_valid_version(function() + return Result.success { "1.0.0" } + end) + end) + assert.is_true(result:is_success()) + end) +end) diff --git a/tests/mason-core/managers/powershell_spec.lua b/tests/mason-core/managers/powershell_spec.lua index 46e096bf..b8facacb 100644 --- a/tests/mason-core/managers/powershell_spec.lua +++ b/tests/mason-core/managers/powershell_spec.lua @@ -31,7 +31,9 @@ describe("powershell manager", function() stub(vim.fn, "executable") vim.fn.executable.on_call_with("pwsh").returns(0) - powershell().command "echo 'Is this bash?'" + local powershell = powershell() + a.scheduler() + powershell.command "echo 'Is this bash?'" assert.spy(spawn.pwsh).was_called(0) assert.spy(spawn.powershell).was_called(1) diff --git a/tests/mason-core/package/package_spec.lua b/tests/mason-core/package/package_spec.lua index 1eee3ed5..6f8eb8d4 100644 --- a/tests/mason-core/package/package_spec.lua +++ b/tests/mason-core/package/package_spec.lua @@ -86,6 +86,7 @@ describe("package", function() local handle = dummy:new_handle() assert.spy(handle_handler).was_called(1) assert.spy(handle_handler).was_called_with(match.ref(handle)) + handle:close() end) it("should not create new handle if one already exists", function() @@ -100,6 +101,7 @@ describe("package", function() end) assert.equals("Cannot create new handle because existing handle is not closed.", err) assert.spy(handle_handler).was_called(0) + dummy.handle = nil end) it( @@ -186,7 +188,6 @@ describe("package", function() assert.wait_for(function() assert.is_true(handle:is_closed()) - assert.is_true(dummy:is_installed()) end) end) ) diff --git a/tests/minimal_init.vim b/tests/minimal_init.vim index f099416c..0d9fb211 100644 --- a/tests/minimal_init.vim +++ b/tests/minimal_init.vim @@ -16,12 +16,11 @@ lua require("luassertx") lua require("test_helpers") lua <<EOF -local index = require "mason-registry.index" -index["dummy"] = "dummy_package" -index["dummy2"] = "dummy2_package" - require("mason").setup { install_root_dir = vim.env.INSTALL_ROOT_DIR, + registries = { + "lua:dummy-registry.index" + } } EOF @@ -14,6 +14,10 @@ globals: assert.wait_for: args: - type: function + create_dummy_context: + args: + - type: table + required: false InstallHandleGenerator: args: - type: string |
