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