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