aboutsummaryrefslogtreecommitdiffstats
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
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)
-rw-r--r--.gitignore1
-rw-r--r--README.md13
-rw-r--r--doc/mason.txt13
-rw-r--r--doc/reference.md27
-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
-rw-r--r--tests/helpers/lua/dummy-registry/dummy2_package.lua (renamed from tests/helpers/lua/dummy2_package.lua)0
-rw-r--r--tests/helpers/lua/dummy-registry/dummy_package.lua (renamed from tests/helpers/lua/dummy_package.lua)0
-rw-r--r--tests/helpers/lua/dummy-registry/index.lua5
-rw-r--r--tests/helpers/lua/dummy-registry/registry_package.lua14
-rw-r--r--tests/helpers/lua/test_helpers.lua17
-rw-r--r--tests/mason-core/installer/installer_spec.lua4
-rw-r--r--tests/mason-core/installer/managers/cargo_spec.lua112
-rw-r--r--tests/mason-core/installer/managers/composer_spec.lua22
-rw-r--r--tests/mason-core/installer/managers/gem_spec.lua50
-rw-r--r--tests/mason-core/installer/managers/golang_spec.lua56
-rw-r--r--tests/mason-core/installer/managers/luarocks_spec.lua63
-rw-r--r--tests/mason-core/installer/managers/npm_spec.lua53
-rw-r--r--tests/mason-core/installer/managers/nuget_spec.lua21
-rw-r--r--tests/mason-core/installer/managers/opam_spec.lua20
-rw-r--r--tests/mason-core/installer/managers/pypi_spec.lua126
-rw-r--r--tests/mason-core/installer/managers/std_spec.lua150
-rw-r--r--tests/mason-core/installer/registry/installer_spec.lua203
-rw-r--r--tests/mason-core/installer/registry/link_spec.lua231
-rw-r--r--tests/mason-core/installer/registry/providers/cargo_spec.lua143
-rw-r--r--tests/mason-core/installer/registry/providers/composer_spec.lua66
-rw-r--r--tests/mason-core/installer/registry/providers/gem_spec.lua72
-rw-r--r--tests/mason-core/installer/registry/providers/generic_spec.lua100
-rw-r--r--tests/mason-core/installer/registry/providers/github_spec.lua355
-rw-r--r--tests/mason-core/installer/registry/providers/golang_spec.lua68
-rw-r--r--tests/mason-core/installer/registry/providers/luarocks_spec.lua78
-rw-r--r--tests/mason-core/installer/registry/providers/npm_spec.lua72
-rw-r--r--tests/mason-core/installer/registry/providers/nuget_spec.lua45
-rw-r--r--tests/mason-core/installer/registry/providers/opam_spec.lua45
-rw-r--r--tests/mason-core/installer/registry/providers/pypi_spec.lua110
-rw-r--r--tests/mason-core/installer/registry/util_spec.lua81
-rw-r--r--tests/mason-core/managers/powershell_spec.lua4
-rw-r--r--tests/mason-core/package/package_spec.lua3
-rw-r--r--tests/minimal_init.vim7
-rw-r--r--vim.yml4
76 files changed, 4852 insertions, 77 deletions
diff --git a/.gitignore b/.gitignore
index 68422d01..0b40c597 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
+.luarc.json
/dependencies
/tests/fixtures/mason
diff --git a/README.md b/README.md
index ed0d5679..5dca2047 100644
--- a/README.md
+++ b/README.md
@@ -163,12 +163,6 @@ local DEFAULT_SETTINGS = {
---@type '"prepend"' | '"append"' | '"skip"'
PATH = "prepend",
- -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in
- -- multiple registries, the registry listed first will be used.
- registries = {
- "lua:mason-registry.index",
- },
-
-- Controls to which degree logs are written to the log file. It's useful to set this to vim.log.levels.DEBUG when
-- debugging issues with package installations.
log_level = vim.log.levels.INFO,
@@ -177,6 +171,13 @@ local DEFAULT_SETTINGS = {
-- packages that are requested to be installed will be put in a queue.
max_concurrent_installers = 4,
+ -- [Advanced setting]
+ -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in
+ -- multiple registries, the registry listed first will be used.
+ registries = {
+ "lua:mason-registry.index",
+ },
+
-- The provider implementations to use for resolving supplementary package metadata (e.g., all available versions).
-- Accepts multiple entries, where later entries will be used as fallback should prior providers fail.
-- Builtin providers are:
diff --git a/doc/mason.txt b/doc/mason.txt
index 8bb8b762..1c2ba506 100644
--- a/doc/mason.txt
+++ b/doc/mason.txt
@@ -239,12 +239,6 @@ Example:
---@type '"prepend"' | '"append"' | '"skip"'
PATH = "prepend",
- -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in
- -- multiple registries, the registry listed first will be used.
- registries = {
- "lua:mason-registry.index",
- },
-
-- Controls to which degree logs are written to the log file. It's useful to set this to vim.log.levels.DEBUG when
-- debugging issues with package installations.
log_level = vim.log.levels.INFO,
@@ -253,6 +247,13 @@ Example:
-- packages that are requested to be installed will be put in a queue.
max_concurrent_installers = 4,
+ -- [Advanced setting]
+ -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in
+ -- multiple registries, the registry listed first will be used.
+ registries = {
+ "lua:mason-registry.index",
+ },
+
-- The provider implementations to use for resolving supplementary package metadata (e.g., all available versions).
-- Accepts multiple entries, where later entries will be used as fallback should prior providers fail.
-- Builtin providers are:
diff --git a/doc/reference.md b/doc/reference.md
index e51ba57e..ba121423 100644
--- a/doc/reference.md
+++ b/doc/reference.md
@@ -26,6 +26,7 @@ RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as de
- [Architecture diagram](#architecture-diagram)
- [Registry events](#registry-events)
- [`PackageSpec`](#packagespec)
+- [`RegistryPackageSpec`](#registrypackagespec)
- [`Package`](#package)
- [`Package.Parse({package_identifier})`](#packageparsepackage_identifier)
- [`Package.Lang`](#packagelang)
@@ -65,7 +66,9 @@ RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as de
## Architecture diagram
-![architecture](https://user-images.githubusercontent.com/6705160/179120955-2f093b80-4a4e-4201-8c7a-26adfa508cdf.png)
+<!-- https://excalidraw.com/#json=vbTmp7nM8H5odJDiaw7Ue,TghucvHHAw8bl7sgX1VuvA -->
+
+![architecture](https://user-images.githubusercontent.com/6705160/224515490-de6381f4-d0c0-40e6-82a0-89f95d08e865.png)
## Registry events
@@ -107,10 +110,26 @@ registry:on(
| name | `string` |
| desc | `string` |
| homepage | `string` |
-| categories | [`PackageCategory[]`](#package-cat) |
-| languages | [`PackageLanguage[]`](#package-lang) |
+| categories | [`PackageCategory[]`](#packagecat) |
+| languages | [`PackageLanguage[]`](#packagelang) |
| install | `async fun(ctx: InstallContext)` |
+## `RegistryPackageSpec`
+
+| Key | Value |
+| ----------- | ------------------------------------ |
+| schema | `"registry+v1"` |
+| name | `string` |
+| description | `string` |
+| homepage | `string` |
+| licenses | `string` |
+| categories | [`PackageCategory[]`](#packagecat) |
+| languages | [`PackageLanguage[]`](#packagelang) |
+| source | `table` |
+| bin | `table<string, string>?` |
+| share | `table<string, string>?` |
+| opt | `table<string, string>?` |
+
## `Package`
Module: [`"mason-core.package"`](../lua/mason-core/package/init.lua)
@@ -175,7 +194,7 @@ All the available categories a package can be tagged with.
### `Package.spec`
-**Type**: [`PackageSpec`](#packagespec)
+**Type**: [`PackageSpec`](#packagespec) or [`RegistryPackageSpec`](#registrypackagespec)
### `Package:install({opts})`
diff --git a/lua/mason-core/functional/init.lua b/lua/mason-core/functional/init.lua
index 20293bd8..5e0ac680 100644
--- a/lua/mason-core/functional/init.lua
+++ b/lua/mason-core/functional/init.lua
@@ -10,6 +10,8 @@ local function lazy_require(module)
})
end
+_.lazy_require = lazy_require
+
---@module "mason-core.functional.data"
local data = lazy_require "mason-core.functional.data"
_.table_pack = data.table_pack
diff --git a/lua/mason-core/installer/context.lua b/lua/mason-core/installer/context.lua
index 8a1e3678..d637707a 100644
--- a/lua/mason-core/installer/context.lua
+++ b/lua/mason-core/installer/context.lua
@@ -118,6 +118,12 @@ function ContextualFs:mkdir(dir_path)
end
---@async
+---@param dir_path string
+function ContextualFs:mkdirp(dir_path)
+ return fs.async.mkdirp(path.concat { self.cwd:get(), dir_path })
+end
+
+---@async
---@param file_path string
---@param mode integer
function ContextualFs:chmod(file_path, mode)
@@ -179,7 +185,7 @@ function InstallContext.new(handle, opts)
local cwd_manager = CwdManager.new(path.install_prefix())
return setmetatable({
cwd = cwd_manager,
- spawn = ContextualSpawn.new(cwd_manager, handle, handle.package.spec.schema ~= "registry+v1"),
+ spawn = ContextualSpawn.new(cwd_manager, handle, not handle.package:is_registry_spec()),
handle = handle,
package = handle.package, -- for convenience
fs = ContextualFs.new(cwd_manager),
diff --git a/lua/mason-core/installer/init.lua b/lua/mason-core/installer/init.lua
index 9f150269..ee0e6397 100644
--- a/lua/mason-core/installer/init.lua
+++ b/lua/mason-core/installer/init.lua
@@ -64,13 +64,19 @@ function M.prepare_installer(context)
try(Result.pcall(fs.async.mkdirp, package_build_prefix))
context.cwd:set(package_build_prefix)
- return context.package.spec.install
+ if context.package:is_registry_spec() then
+ local registry_installer = require "mason-core.installer.registry"
+ return try(registry_installer.compile(context.handle.package.spec, context.opts))
+ else
+ return context.package.spec.install
+ end
end)
end
----@async
+---@generic T
---@param context InstallContext
----@param fn async fun(context: InstallContext)
+---@param fn fun(context: InstallContext): T
+---@return T
function M.exec_in_context(context, fn)
local thread = coroutine.create(function(...)
-- We wrap the function to allow it to be a spy instance (in which case it's not actually a function, but a
diff --git a/lua/mason-core/installer/managers/cargo.lua b/lua/mason-core/installer/managers/cargo.lua
new file mode 100644
index 00000000..72355c9c
--- /dev/null
+++ b/lua/mason-core/installer/managers/cargo.lua
@@ -0,0 +1,47 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+local path = require "mason-core.path"
+
+local M = {}
+
+---@async
+---@param crate string
+---@param version string
+---@param opts? { features?: string, locked?: boolean, git?: { url: string, rev?: boolean } }
+function M.install(crate, version, opts)
+ opts = opts or {}
+ log.fmt_debug("cargo: install %s %s %s", crate, version, opts)
+ local ctx = installer.context()
+
+ return ctx.spawn.cargo {
+ "install",
+ "--root",
+ ".",
+ opts.git and {
+ "--git",
+ opts.git.url,
+ opts.git.rev and "--rev" or "--tag",
+ version,
+ } or { "--version", version },
+ opts.features and { "--features", opts.features } or vim.NIL,
+ opts.locked and "--locked" or vim.NIL,
+ crate,
+ }
+end
+
+---@param bin string
+function M.bin_path(bin)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return path.concat { "bin", bin }
+ end,
+ win = function()
+ return path.concat { "bin", ("%s.exe"):format(bin) }
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/composer.lua b/lua/mason-core/installer/managers/composer.lua
new file mode 100644
index 00000000..faa01bc4
--- /dev/null
+++ b/lua/mason-core/installer/managers/composer.lua
@@ -0,0 +1,42 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+---@async
+---@param package string
+---@param version string
+---@nodiscard
+function M.install(package, version)
+ log.fmt_debug("composer: install %s %s", package, version)
+ local ctx = installer.context()
+ return Result.try(function(try)
+ try(ctx.spawn.composer {
+ "init",
+ "--no-interaction",
+ "--stability=stable",
+ })
+ try(ctx.spawn.composer {
+ "require",
+ ("%s:%s"):format(package, version),
+ })
+ end)
+end
+
+---@param executable string
+function M.bin_path(executable)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return path.concat { "vendor", "bin", executable }
+ end,
+ win = function()
+ return path.concat { "vendor", "bin", ("%s.bat"):format(executable) }
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/gem.lua b/lua/mason-core/installer/managers/gem.lua
new file mode 100644
index 00000000..0eac275e
--- /dev/null
+++ b/lua/mason-core/installer/managers/gem.lua
@@ -0,0 +1,65 @@
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+
+local M = {}
+
+---@async
+---@param pkg string
+---@param version string
+---@param opts? { extra_packages?: string[] }
+---@nodiscard
+function M.install(pkg, version, opts)
+ opts = opts or {}
+ log.fmt_debug("gem: install %s %s %s", pkg, version, opts)
+ local ctx = installer.context()
+
+ return ctx.spawn.gem {
+ "install",
+ "--no-user-install",
+ "--no-format-executable",
+ "--install-dir=.",
+ "--bindir=bin",
+ "--no-document",
+ ("%s:%s"):format(pkg, version),
+ opts.extra_packages or vim.NIL,
+ env = {
+ GEM_HOME = ctx.cwd:get(),
+ },
+ }
+end
+
+---@async
+---@param bin string
+---@nodiscard
+function M.create_bin_wrapper(bin)
+ local ctx = installer.context()
+
+ local bin_path = platform.when {
+ unix = function()
+ return path.concat { "bin", bin }
+ end,
+ win = function()
+ return path.concat { "bin", ("%s.bat"):format(bin) }
+ end,
+ }
+
+ if not ctx.fs:file_exists(bin_path) then
+ return Result.failure(("Cannot link Gem executable %q because it doesn't exist."):format(bin))
+ end
+
+ return Result.pcall(ctx.write_shell_exec_wrapper, ctx, bin, path.concat { ctx.package:get_install_path(), bin_path }, {
+ GEM_PATH = platform.when {
+ unix = function()
+ return ("%s:$GEM_PATH"):format(ctx.package:get_install_path())
+ end,
+ win = function()
+ return ("%s;%%GEM_PATH%%"):format(ctx.package:get_install_path())
+ end,
+ },
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/golang.lua b/lua/mason-core/installer/managers/golang.lua
new file mode 100644
index 00000000..fdc000b3
--- /dev/null
+++ b/lua/mason-core/installer/managers/golang.lua
@@ -0,0 +1,52 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+---@async
+---@param pkg string
+---@param version string
+---@param opts? { extra_packages?: string[] }
+function M.install(pkg, version, opts)
+ return Result.try(function(try)
+ opts = opts or {}
+ log.fmt_debug("golang: install %s %s %s", pkg, version, opts)
+ local ctx = installer.context()
+ local env = {
+ GOBIN = ctx.cwd:get(),
+ }
+ try(ctx.spawn.go {
+ "install",
+ "-v",
+ ("%s@%s"):format(pkg, version),
+ env = env,
+ })
+ if opts.extra_packages then
+ for _, pkg in ipairs(opts.extra_packages) do
+ try(ctx.spawn.go {
+ "install",
+ "-v",
+ ("%s@latest"):format(pkg),
+ env = env,
+ })
+ end
+ end
+ end)
+end
+
+---@param bin string
+function M.bin_path(bin)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return bin
+ end,
+ win = function()
+ return ("%s.exe"):format(bin)
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/luarocks.lua b/lua/mason-core/installer/managers/luarocks.lua
new file mode 100644
index 00000000..7f636e2b
--- /dev/null
+++ b/lua/mason-core/installer/managers/luarocks.lua
@@ -0,0 +1,40 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+local path = require "mason-core.path"
+
+local M = {}
+
+---@async
+---@param pkg string
+---@param version string
+---@param opts { server?: string, dev?: boolean }
+function M.install(pkg, version, opts)
+ opts = opts or {}
+ log.fmt_debug("luarocks: install %s %s %s", pkg, version, opts)
+ local ctx = installer.context()
+ ctx:promote_cwd() -- luarocks encodes absolute paths during installation
+ return ctx.spawn.luarocks {
+ "install",
+ { "--tree", ctx.cwd:get() },
+ opts.dev and "--dev" or vim.NIL,
+ opts.server and ("--server=%s"):format(opts.server) or vim.NIL,
+ { pkg, version },
+ }
+end
+
+---@param exec string
+function M.bin_path(exec)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return path.concat { "bin", exec }
+ end,
+ win = function()
+ return path.concat { "bin", ("%s.bat"):format(exec) }
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/npm.lua b/lua/mason-core/installer/managers/npm.lua
new file mode 100644
index 00000000..5eec7627
--- /dev/null
+++ b/lua/mason-core/installer/managers/npm.lua
@@ -0,0 +1,64 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+local path = require "mason-core.path"
+
+local M = {}
+
+---@async
+function M.init()
+ log.debug "npm: init"
+ local ctx = installer.context()
+ return Result.try(function(try)
+ try(ctx.spawn.npm {
+ "init",
+ "--yes",
+ "--scope=mason",
+ })
+
+ -- Use global-style. The reasons for this are:
+ -- a) To avoid polluting the executables (aka bin-links) that npm creates.
+ -- b) The installation is, after all, more similar to a "global" installation. We don't really gain
+ -- any of the benefits of not using global style (e.g., deduping the dependency tree).
+ --
+ -- We write to .npmrc manually instead of going through npm because managing a local .npmrc file
+ -- is a bit unreliable across npm versions (especially <7), so we take extra measures to avoid
+ -- inadvertently polluting global npm config.
+ try(Result.pcall(function()
+ ctx.fs:append_file(".npmrc", "global-style=true")
+ end))
+
+ ctx.stdio_sink.stdout "Initialized npm root\n"
+ end)
+end
+
+---@async
+---@param pkg string
+---@param version string
+---@param opts? { extra_packages?: string[] }
+function M.install(pkg, version, opts)
+ opts = opts or {}
+ log.fmt_debug("npm: install %s %s %s", pkg, version, opts)
+ local ctx = installer.context()
+ return ctx.spawn.npm {
+ "install",
+ ("%s@%s"):format(pkg, version),
+ opts.extra_packages or vim.NIL,
+ }
+end
+
+---@param exec string
+function M.bin_path(exec)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return path.concat { "node_modules", ".bin", exec }
+ end,
+ win = function()
+ return path.concat { "node_modules", ".bin", ("%s.cmd"):format(exec) }
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/nuget.lua b/lua/mason-core/installer/managers/nuget.lua
new file mode 100644
index 00000000..f547d81b
--- /dev/null
+++ b/lua/mason-core/installer/managers/nuget.lua
@@ -0,0 +1,37 @@
+local installer = require "mason-core.installer"
+local platform = require "mason-core.platform"
+local Result = require "mason-core.result"
+local log = require "mason-core.log"
+
+local M = {}
+
+---@async
+---@param package string
+---@param version string
+---@nodiscard
+function M.install(package, version)
+ log.fmt_debug("nuget: install %s %s", package, version)
+ local ctx = installer.context()
+ return ctx.spawn.dotnet {
+ "tool",
+ "update",
+ "--tool-path",
+ ".",
+ { "--version", version },
+ package,
+ }
+end
+
+---@param bin string
+function M.bin_path(bin)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return bin
+ end,
+ win = function()
+ return ("%s.exe"):format(bin)
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/opam.lua b/lua/mason-core/installer/managers/opam.lua
new file mode 100644
index 00000000..2a07c4f8
--- /dev/null
+++ b/lua/mason-core/installer/managers/opam.lua
@@ -0,0 +1,38 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+local path = require "mason-core.path"
+
+local M = {}
+
+---@async
+---@param package string
+---@param version string
+---@nodiscard
+function M.install(package, version)
+ log.fmt_debug("opam: install %s %s", package, version)
+ local ctx = installer.context()
+ return ctx.spawn.opam {
+ "install",
+ "--destdir=.",
+ "--yes",
+ "--verbose",
+ ("%s.%s"):format(package, version),
+ }
+end
+
+---@param bin string
+function M.bin_path(bin)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return path.concat { "bin", bin }
+ end,
+ win = function()
+ return path.concat { "bin", ("%s.exe"):format(bin) }
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/pypi.lua b/lua/mason-core/installer/managers/pypi.lua
new file mode 100644
index 00000000..0800b155
--- /dev/null
+++ b/lua/mason-core/installer/managers/pypi.lua
@@ -0,0 +1,106 @@
+local Optional = require "mason-core.optional"
+local _ = require "mason-core.functional"
+local a = require "mason-core.async"
+local installer = require "mason-core.installer"
+local log = require "mason-core.log"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local Result = require "mason-core.result"
+
+local M = {}
+
+local VENV_DIR = "venv"
+
+---@async
+---@param py_executables string[]
+local function create_venv(py_executables)
+ local ctx = installer.context()
+ return Optional.of_nilable(_.find_first(function(executable)
+ return ctx.spawn[executable]({ "-m", "venv", VENV_DIR }):is_success()
+ end, py_executables)):ok_or "Failed to create python3 virtual environment."
+end
+
+---@async
+---@param args SpawnArgs
+local function venv_python(args)
+ local ctx = installer.context()
+ local python_path = path.concat {
+ ctx.cwd:get(),
+ VENV_DIR,
+ platform.is.win and "Scripts" or "bin",
+ platform.is.win and "python.exe" or "python",
+ }
+ return ctx.spawn[python_path](args)
+end
+
+---@async
+---@param pkgs string[]
+---@param extra_args? string[]
+local function pip_install(pkgs, extra_args)
+ return venv_python {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ extra_args or vim.NIL,
+ pkgs,
+ }
+end
+
+---@async
+---@param opts { upgrade_pip: boolean, install_extra_args?: string[] }
+function M.init(opts)
+ return Result.try(function(try)
+ log.fmt_debug("pypi: init", opts)
+ local ctx = installer.context()
+
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+
+ local executables = platform.is.win
+ and _.list_not_nil(
+ vim.g.python3_host_prog and vim.fn.expand(vim.g.python3_host_prog),
+ "python",
+ "python3"
+ )
+ or _.list_not_nil(vim.g.python3_host_prog and vim.fn.expand(vim.g.python3_host_prog), "python3", "python")
+
+ -- pip3 will hardcode the full path to venv executables, so we need to promote cwd to make sure pip uses the final destination path.
+ ctx:promote_cwd()
+
+ try(create_venv(executables))
+
+ if opts.upgrade_pip then
+ try(pip_install({ "pip" }, opts.install_extra_args))
+ end
+ end)
+end
+
+---@async
+---@param pkg string
+---@param version string
+---@param opts? { extra?: string, extra_packages?: string[], install_extra_args?: string[] }
+function M.install(pkg, version, opts)
+ opts = opts or {}
+ log.fmt_debug("pypi: install %s %s", pkg, version, opts)
+ return pip_install({
+ opts.extra and ("%s[%s]==%s"):format(pkg, opts.extra, version) or ("%s==%s"):format(pkg, version),
+ opts.extra_packages or vim.NIL,
+ }, opts.install_extra_args)
+end
+
+---@param exec string
+function M.bin_path(exec)
+ return Result.pcall(platform.when, {
+ unix = function()
+ return path.concat { "venv", "bin", exec }
+ end,
+ win = function()
+ return path.concat { "venv", "Scripts", ("%s.exe"):format(exec) }
+ end,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/managers/std.lua b/lua/mason-core/installer/managers/std.lua
new file mode 100644
index 00000000..4ae3fc7b
--- /dev/null
+++ b/lua/mason-core/installer/managers/std.lua
@@ -0,0 +1,241 @@
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local fetch = require "mason-core.fetch"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local powershell = require "mason-core.managers.powershell"
+local Result = require "mason-core.result"
+local log = require "mason-core.log"
+
+local M = {}
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function unpack_7z(rel_path)
+ log.fmt_debug("std: unpack_7z %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn["7z"] { "x", "-y", "-r", rel_path }
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function unpack_peazip(rel_path)
+ log.fmt_debug("std: unpack_peazip %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn.peazip { "-ext2here", path.concat { ctx.cwd:get(), rel_path } } -- peazip requires absolute paths
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function wzunzip(rel_path)
+ log.fmt_debug("std: wzunzip %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn.wzunzip { rel_path }
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function unpack_winrar(rel_path)
+ log.fmt_debug("std: unpack_winrar %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn.winrar { "e", rel_path }
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function gunzip_unix(rel_path)
+ log.fmt_debug("std: gunzip_unix %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn.gzip { "-d", rel_path }
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function unpack_arc(rel_path)
+ log.fmt_debug("std: unpack_arc %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn.arc { "unarchive", rel_path }
+end
+
+---@param rel_path string
+---@return Result
+local function win_decompress(rel_path)
+ local ctx = installer.context()
+ return gunzip_unix(rel_path)
+ :or_else(function()
+ return unpack_7z(rel_path)
+ end)
+ :or_else(function()
+ return unpack_peazip(rel_path)
+ end)
+ :or_else(function()
+ return wzunzip(rel_path)
+ end)
+ :or_else(function()
+ return unpack_winrar(rel_path)
+ end)
+ :on_success(function()
+ pcall(function()
+ ctx.fs:unlink(rel_path)
+ end)
+ end)
+end
+
+---@async
+---@param url string
+---@param out_file string
+---@nodiscard
+function M.download_file(url, out_file)
+ log.fmt_debug("std: downloading file %s", url, out_file)
+ local ctx = installer.context()
+ ctx.stdio_sink.stdout(("Downloading file %q...\n"):format(url))
+ return fetch(url, {
+ out_file = path.concat { ctx.cwd:get(), out_file },
+ }):map_err(function(err)
+ return ("%s\nFailed to download file %q."):format(err, url)
+ end)
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function untar(rel_path)
+ log.fmt_debug("std: untar %s", rel_path)
+ local ctx = installer.context()
+ return ctx.spawn.tar({ "--no-same-owner", "-xvf", rel_path }):on_success(function()
+ pcall(function()
+ ctx.fs:unlink(rel_path)
+ end)
+ end)
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function unzip(rel_path)
+ log.fmt_debug("std: unzip %s", rel_path)
+ local ctx = installer.context()
+ return platform.when {
+ unix = function()
+ return ctx.spawn.unzip({ "-d", ".", rel_path }):on_success(function()
+ pcall(function()
+ ctx.fs:unlink(rel_path)
+ end)
+ end)
+ end,
+ win = function()
+ return Result.pcall(function()
+ -- Expand-Archive seems to be hard-coded to only allow .zip extensions. Bit weird but ok.
+ if not _.matches("%.zip$", rel_path) then
+ local zip_file = ("%s.zip"):format(rel_path)
+ ctx.fs:rename(rel_path, zip_file)
+ return zip_file
+ end
+ return rel_path
+ end):and_then(function(zip_file)
+ return powershell
+ .command(
+ ("Microsoft.PowerShell.Archive\\Expand-Archive -Path %q -DestinationPath ."):format(zip_file),
+ {},
+ ctx.spawn
+ )
+ :on_success(function()
+ pcall(function()
+ ctx.fs:unlink(zip_file)
+ end)
+ end)
+ end)
+ end,
+ }
+end
+
+---@async
+---@param rel_path string
+---@nodiscard
+local function gunzip(rel_path)
+ log.fmt_debug("std: gunzip %s", rel_path)
+ return platform.when {
+ unix = function()
+ return gunzip_unix(rel_path)
+ end,
+ win = function()
+ return win_decompress(rel_path)
+ end,
+ }
+end
+
+---@async
+---@param rel_path string
+---@return Result
+---@nodiscard
+local function untar_compressed(rel_path)
+ log.fmt_debug("std: untar_compressed %s", rel_path)
+ return platform.when {
+ unix = function()
+ return untar(rel_path)
+ end,
+ win = function()
+ return win_decompress(rel_path)
+ :and_then(function()
+ return untar(_.gsub("%.tar%..*$", ".tar", rel_path))
+ end)
+ :or_else(function()
+ -- arc both decompresses and unpacks tar in one go
+ return unpack_arc(rel_path)
+ end)
+ end,
+ }
+end
+
+-- Order is important.
+local unpack_by_filename = _.cond {
+ { _.matches "%.tar$", untar },
+ { _.matches "%.tar%.gz$", untar },
+ { _.matches "%.tar%.bz2$", untar },
+ { _.matches "%.tar%.xz$", untar_compressed },
+ { _.matches "%.tar%.zst$", untar_compressed },
+ { _.matches "%.zip$", unzip },
+ { _.matches "%.vsix$", unzip },
+ { _.matches "%.gz$", gunzip },
+ { _.T, _.compose(Result.success, _.format "%q doesn't need unpacking.") },
+}
+
+---@async
+---@param rel_path string The relative path to the file to unpack.
+---@nodiscard
+function M.unpack(rel_path)
+ log.fmt_debug("std: unpack %s", rel_path)
+ return unpack_by_filename(rel_path)
+end
+
+---@async
+---@param git_url string
+---@param opts? { rev?: string, recursive?: boolean }
+---@nodiscard
+function M.clone(git_url, opts)
+ opts = opts or {}
+ log.fmt_debug("std: clone %s %s", git_url, opts)
+ local ctx = installer.context()
+ return Result.try(function(try)
+ try(ctx.spawn.git {
+ "clone",
+ "--depth",
+ "1",
+ opts.recursive and "--recursive" or vim.NIL,
+ git_url,
+ ".",
+ })
+ if opts.rev then
+ try(ctx.spawn.git { "fetch", "--depth", "1", "origin", opts.rev })
+ try(ctx.spawn.git { "checkout", "FETCH_HEAD" })
+ end
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/init.lua b/lua/mason-core/installer/registry/init.lua
new file mode 100644
index 00000000..7c27f1ef
--- /dev/null
+++ b/lua/mason-core/installer/registry/init.lua
@@ -0,0 +1,196 @@
+local a = require "mason-core.async"
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local Purl = require "mason-core.purl"
+local Optional = require "mason-core.optional"
+local link = require "mason-core.installer.registry.link"
+local log = require "mason-core.log"
+local semver = require "mason-core.semver"
+
+local M = {}
+
+M.SCHEMA_CAP = _.set_of {
+ "registry+v1",
+}
+
+---@type table<string, InstallerProvider>
+local PROVIDERS = {}
+
+---@param id string
+---@param provider InstallerProvider
+function M.register_provider(id, provider)
+ PROVIDERS[id] = provider
+end
+
+M.register_provider("cargo", _.lazy_require "mason-core.installer.registry.providers.cargo")
+M.register_provider("composer", _.lazy_require "mason-core.installer.registry.providers.composer")
+M.register_provider("gem", _.lazy_require "mason-core.installer.registry.providers.gem")
+M.register_provider("generic", _.lazy_require "mason-core.installer.registry.providers.generic")
+M.register_provider("github", _.lazy_require "mason-core.installer.registry.providers.github")
+M.register_provider("golang", _.lazy_require "mason-core.installer.registry.providers.golang")
+M.register_provider("luarocks", _.lazy_require "mason-core.installer.registry.providers.luarocks")
+M.register_provider("npm", _.lazy_require "mason-core.installer.registry.providers.npm")
+M.register_provider("nuget", _.lazy_require "mason-core.installer.registry.providers.nuget")
+M.register_provider("opam", _.lazy_require "mason-core.installer.registry.providers.opam")
+M.register_provider("pypi", _.lazy_require "mason-core.installer.registry.providers.pypi")
+
+---@param purl Purl
+local function get_provider(purl)
+ return Optional.of_nilable(PROVIDERS[purl.type]):ok_or(("Unknown purl type: %s"):format(purl.type))
+end
+
+---@class InstallerProvider
+---@field parse fun(source: RegistryPackageSource, purl: Purl, opts: PackageInstallOpts): Result
+---@field install async fun(ctx: InstallContext, source: ParsedPackageSource): Result
+
+---@class ParsedPackageSource
+
+---Upserts {dst} with contents of {src}. List table values will be merged, with contents of {src} prepended.
+---@param dst table
+---@param src table
+local function upsert(dst, src)
+ for k, v in pairs(src) do
+ if type(v) == "table" then
+ if vim.tbl_islist(v) then
+ dst[k] = _.concat(v, dst[k] or {})
+ else
+ dst[k] = upsert(dst[k] or {}, src[k])
+ end
+ else
+ dst[k] = v
+ end
+ end
+ return dst
+end
+
+---@param source RegistryPackageSource
+---@param version string
+local function coalesce_source(source, version)
+ if source.version_overrides then
+ for i = #source.version_overrides, 1, -1 do
+ local version_override = source.version_overrides[i]
+ local version_type, constraint = unpack(_.split(":", version_override.constraint))
+ if version_type == "semver" then
+ local version_match = Result.try(function(try)
+ local requested_version = try(semver.parse(version))
+ if _.starts_with("<=", constraint) then
+ local rule_version = try(semver.parse(_.strip_prefix("<=", constraint)))
+ return requested_version <= rule_version
+ elseif _.starts_with(">=", constraint) then
+ local rule_version = try(semver.parse(_.strip_prefix(">=", constraint)))
+ return requested_version >= rule_version
+ else
+ local rule_version = try(semver.parse(constraint))
+ return requested_version == rule_version
+ end
+ end):get_or_else(false)
+
+ if version_match then
+ if version_override.id then
+ -- Because this entry provides its own purl id, it overrides the entire source definition.
+ return version_override
+ else
+ -- Upsert the default source with the contents of the version override.
+ return upsert(vim.deepcopy(source), _.dissoc("constraint", version_override))
+ end
+ end
+ end
+ end
+ end
+ return source
+end
+
+---@param spec RegistryPackageSpec
+---@param opts PackageInstallOpts
+function M.parse(spec, opts)
+ log.debug("Parsing spec", spec.name, opts)
+ return Result.try(function(try)
+ if not M.SCHEMA_CAP[spec.schema] then
+ return Result.failure(
+ ("Current version of mason.nvim is not capable of parsing package schema version %q."):format(
+ spec.schema
+ )
+ )
+ end
+
+ local source = opts.version and coalesce_source(spec.source, opts.version) or spec.source
+
+ ---@type Purl
+ local purl = try(Purl.parse(source.id))
+ log.trace("Parsed purl.", source.id, purl)
+ if opts.version then
+ purl.version = opts.version
+ end
+
+ ---@type InstallerProvider
+ local provider = try(get_provider(purl))
+ log.trace("Found provider for purl.", source.id)
+ local parsed_source = try(provider.parse(source, purl, opts))
+ log.trace("Parsed source for purl.", source.id, parsed_source)
+ return {
+ provider = provider,
+ source = parsed_source,
+ purl = purl,
+ }
+ end)
+end
+
+---@async
+---@param spec RegistryPackageSpec
+---@param opts PackageInstallOpts
+function M.compile(spec, opts)
+ log.debug("Compiling installer.", spec.name, opts)
+ return Result.try(function(try)
+ if vim.in_fast_event() then
+ -- Parsers run synchronously and may access API functions, so we schedule before-hand.
+ a.scheduler()
+ end
+
+ local map_parse_err = _.cond {
+ {
+ _.equals "PLATFORM_UNSUPPORTED",
+ function()
+ if opts.target then
+ return ("Platform %q is unsupported."):format(opts.target)
+ else
+ return "The current platform is unsupported."
+ end
+ end,
+ },
+ { _.T, _.identity },
+ }
+
+ ---@type { purl: Purl, provider: InstallerProvider, source: ParsedPackageSource }
+ local parsed = try(M.parse(spec, opts):map_err(map_parse_err))
+
+ ---@async
+ ---@param ctx InstallContext
+ return function(ctx)
+ return Result.try(function(try)
+ -- Run installer
+ try(parsed.provider.install(ctx, parsed.source))
+
+ -- Expand & register links
+ if spec.bin then
+ try(link.bin(ctx, spec, parsed.purl, parsed.source))
+ end
+ if spec.share then
+ try(link.share(ctx, spec, parsed.purl, parsed.source))
+ end
+ if spec.opt then
+ try(link.opt(ctx, spec, parsed.purl, parsed.source))
+ end
+
+ ctx.receipt:with_primary_source {
+ type = ctx.package.spec.schema,
+ id = Purl.compile(parsed.purl),
+ source = parsed.source,
+ }
+ end):on_failure(function(err)
+ error(err, 0)
+ end)
+ end
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/link.lua b/lua/mason-core/installer/registry/link.lua
new file mode 100644
index 00000000..d66809e0
--- /dev/null
+++ b/lua/mason-core/installer/registry/link.lua
@@ -0,0 +1,300 @@
+local expr = require "mason-core.installer.registry.expr"
+local log = require "mason-core.log"
+local _ = require "mason-core.functional"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local fs = require "mason-core.fs"
+local Result = require "mason-core.result"
+local Optional = require "mason-core.optional"
+local a = require "mason-core.async"
+
+local M = {}
+
+local filter_empty_values = _.compose(
+ _.from_pairs,
+ _.filter(function(pair)
+ return pair[2] ~= ""
+ end),
+ _.to_pairs
+)
+
+local bin_delegates = {
+ ["luarocks"] = function(target)
+ return require("mason-core.installer.managers.luarocks").bin_path(target)
+ end,
+ ["composer"] = function(target)
+ return require("mason-core.installer.managers.composer").bin_path(target)
+ end,
+ ["opam"] = function(target)
+ return require("mason-core.installer.managers.opam").bin_path(target)
+ end,
+ ["python"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ if not ctx.fs:file_exists(target) then
+ return Result.failure(("Cannot write python wrapper for path %q as it doesn't exist."):format(target))
+ end
+ return Result.pcall(function()
+ local python = platform.is.win and "python" or "python3"
+ return ctx:write_shell_exec_wrapper(
+ bin,
+ ("%s %q"):format(python, path.concat { ctx.package:get_install_path(), target })
+ )
+ end)
+ end,
+ ["php"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ return Result.pcall(function()
+ return ctx:write_php_exec_wrapper(bin, target)
+ end)
+ end,
+ ["pyvenv"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ return Result.pcall(function()
+ return ctx:write_pyvenv_exec_wrapper(bin, target)
+ end)
+ end,
+ ["dotnet"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ if not ctx.fs:file_exists(target) then
+ return Result.failure(("Cannot write dotnet wrapper for path %q as it doesn't exist."):format(target))
+ end
+ return Result.pcall(function()
+ return ctx:write_shell_exec_wrapper(
+ bin,
+ ("dotnet %q"):format(path.concat {
+ ctx.package:get_install_path(),
+ target,
+ })
+ )
+ end)
+ end,
+ ["node"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ return Result.pcall(function()
+ return ctx:write_node_exec_wrapper(bin, target)
+ end)
+ end,
+ ["exec"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ return Result.pcall(function()
+ return ctx:write_exec_wrapper(bin, target)
+ end)
+ end,
+ ["java-jar"] = function(target, bin)
+ local installer = require "mason-core.installer"
+ local ctx = installer.context()
+ if not ctx.fs:file_exists(target) then
+ return Result.failure(("Cannot write Java JAR wrapper for path %q as it doesn't exist."):format(target))
+ end
+ return Result.pcall(function()
+ return ctx:write_shell_exec_wrapper(
+ bin,
+ ("java -jar %q"):format(path.concat {
+ ctx.package:get_install_path(),
+ target,
+ })
+ )
+ end)
+ end,
+ ["nuget"] = function(target)
+ return require("mason-core.installer.managers.nuget").bin_path(target)
+ end,
+ ["npm"] = function(target)
+ return require("mason-core.installer.managers.npm").bin_path(target)
+ end,
+ ["gem"] = function(target)
+ return require("mason-core.installer.managers.gem").create_bin_wrapper(target)
+ end,
+ ["cargo"] = function(target)
+ return require("mason-core.installer.managers.cargo").bin_path(target)
+ end,
+ ["pypi"] = function(target)
+ return require("mason-core.installer.managers.pypi").bin_path(target)
+ end,
+ ["golang"] = function(target)
+ return require("mason-core.installer.managers.golang").bin_path(target)
+ end,
+}
+
+---@async
+---@param ctx InstallContext
+---@param target string
+local function chmod_exec(ctx, target)
+ local bit = require "bit"
+ -- see chmod(2)
+ local USR_EXEC = 0x40
+ local GRP_EXEC = 0x8
+ local ALL_EXEC = 0x1
+ local EXEC = bit.bor(USR_EXEC, GRP_EXEC, ALL_EXEC)
+ local fstat = ctx.fs:fstat(target)
+ if bit.band(fstat.mode, EXEC) ~= EXEC then
+ local plus_exec = bit.bor(fstat.mode, EXEC)
+ log.fmt_debug("Setting exec flags on file %s %o -> %o", target, fstat.mode, plus_exec)
+ ctx.fs:chmod(target, plus_exec) -- chmod +x
+ end
+end
+
+---Expands bin specification from spec and registers bins to be linked.
+---@async
+---@param ctx InstallContext
+---@param spec RegistryPackageSpec
+---@param purl Purl
+---@param source ParsedPackageSource
+local function expand_bin(ctx, spec, purl, source)
+ log.debug("Registering bin links", ctx.package, spec.bin)
+ return Result.try(function(try)
+ local expr_ctx = { version = purl.version, source = source }
+
+ local bin_table = spec.bin
+ if not bin_table then
+ log.fmt_debug("%s spec provides no bin.", ctx.package)
+ return
+ end
+
+ local interpolated_bins = filter_empty_values(try(expr.tbl_interpolate(bin_table, expr_ctx)))
+
+ local expanded_bin_table = {}
+ for bin, target in pairs(interpolated_bins) do
+ -- Expand "npm:typescript-language-server"-like expressions
+ local delegated_bin = _.match("^(.+):(.+)$", target)
+ if #delegated_bin > 0 then
+ local bin_type, executable = unpack(delegated_bin)
+ log.fmt_trace("Transforming managed executable=%s via %s", executable, bin_type)
+ local delegate =
+ try(Optional.of_nilable(bin_delegates[bin_type]):ok_or(("Unknown bin type: %s"):format(bin_type)))
+ target = try(delegate(executable, bin))
+ end
+
+ log.fmt_debug("Expanded bin link %s -> %s", bin, target)
+ if not ctx.fs:file_exists(target) then
+ return Result.failure(("Tried to link bin %q to non-existent target %q."):format(bin, target))
+ end
+
+ if platform.is.unix then
+ chmod_exec(ctx, target)
+ end
+
+ expanded_bin_table[bin] = target
+ end
+ return expanded_bin_table
+ end)
+end
+
+local is_dir_path = _.matches "/$"
+
+---Expands symlink path specifications from spec and returns symlink file table.
+---@async
+---@param ctx InstallContext
+---@param purl Purl
+---@param source ParsedPackageSource
+---@param file_spec_table table<string, string>
+local function expand_file_spec(ctx, purl, source, file_spec_table)
+ log.debug("Registering symlinks", ctx.package, file_spec_table)
+ return Result.try(function(try)
+ local expr_ctx = { version = purl.version, source = source }
+
+ ---@type table<string, string>
+ local interpolated_paths = filter_empty_values(try(expr.tbl_interpolate(file_spec_table, expr_ctx)))
+
+ ---@type table<string, string>
+ local expanded_links = {}
+
+ for dest, source_path in pairs(interpolated_paths) do
+ local cwd = ctx.cwd:get()
+
+ if is_dir_path(dest) then
+ -- linking dir -> dir
+ if not is_dir_path(source_path) then
+ return Result.failure(("Cannot link file %q to dir %q."):format(source_path, dest))
+ end
+
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+
+ local glob = path.concat { cwd, source_path } .. "**/*"
+ log.fmt_trace("Symlink glob for %s: %s", ctx.package, glob)
+
+ ---@type string[]
+ local files = _.filter_map(function(abs_path)
+ -- fs.sync because async causes stack overflow on many files (TODO fix that)
+ if not fs.sync.file_exists(abs_path) then
+ -- only link actual files (e.g. exclude directory entries from glob)
+ return Optional.empty()
+ end
+ -- turn into relative paths
+ return Optional.of(abs_path:sub(#cwd + 2)) -- + 2 to remove leading path separator (/)
+ end, vim.fn.glob(glob, false, true))
+
+ log.fmt_trace("Expanded glob %s: %s", glob, files)
+
+ for __, file in ipairs(files) do
+ -- File destination should be relative to the source directory. For example, should the source_path
+ -- be "gh_2.22.1_macOS_amd64/share/man/" and dest be "man/", it should link source files to the
+ -- following destinations:
+ --
+ -- gh_2.22.1_macOS_amd64/share/man/ man/
+ -- -------------------------------------------------------------------------
+ -- gh_2.22.1_macOS_amd64/share/man/man1/gh.1 man/man1/gh.1
+ -- gh_2.22.1_macOS_amd64/share/man/man1/gh-run.1 man/man1/gh-run.1
+ -- gh_2.22.1_macOS_amd64/share/man/man1/gh-ssh-key.1 man/man1/gh-run.1
+ --
+ local file_dest = path.concat {
+ _.trim_end_matches("/", dest),
+ file:sub(#source_path + 1),
+ }
+ expanded_links[file_dest] = file
+ end
+ else
+ -- linking file -> file
+ if is_dir_path(source_path) then
+ return Result.failure(("Cannot link dir %q to file %q."):format(source_path, dest))
+ end
+ expanded_links[dest] = source_path
+ end
+ end
+
+ return expanded_links
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param spec RegistryPackageSpec
+---@param purl Purl
+---@param source ParsedPackageSource
+M.bin = function(ctx, spec, purl, source)
+ return expand_bin(ctx, spec, purl, source):on_success(function(links)
+ ctx.links.bin = links
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param spec RegistryPackageSpec
+---@param purl Purl
+---@param source ParsedPackageSource
+M.share = function(ctx, spec, purl, source)
+ return expand_file_spec(ctx, purl, source, spec.share):on_success(function(links)
+ ctx.links.share = links
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param spec RegistryPackageSpec
+---@param purl Purl
+---@param source ParsedPackageSource
+M.opt = function(ctx, spec, purl, source)
+ return expand_file_spec(ctx, purl, source, spec.opt):on_success(function(links)
+ ctx.links.opt = links
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/cargo.lua b/lua/mason-core/installer/registry/providers/cargo.lua
new file mode 100644
index 00000000..4c609be6
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/cargo.lua
@@ -0,0 +1,64 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local util = require "mason-core.installer.registry.util"
+
+local M = {}
+
+---@class CargoSource : RegistryPackageSource
+---@field supported_platforms? string[]
+
+---@param source CargoSource
+---@param purl Purl
+function M.parse(source, purl)
+ return Result.try(function(try)
+ if source.supported_platforms then
+ try(util.ensure_valid_platform(source.supported_platforms))
+ end
+
+ local repository_url = _.path({ "qualifiers", "repository_url" }, purl)
+
+ local git
+ if repository_url then
+ git = {
+ url = repository_url,
+ rev = _.path({ "qualifiers", "rev" }, purl) == "true",
+ }
+ end
+
+ ---@type string?
+ local features = _.path({ "qualifiers", "features" }, purl)
+ local locked = _.path({ "qualifiers", "locked" }, purl)
+
+ ---@class ParsedCargoSource : ParsedPackageSource
+ local parsed_source = {
+ crate = purl.name,
+ version = purl.version,
+ features = features,
+ locked = locked ~= "false",
+ git = git,
+ }
+ return parsed_source
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedCargoSource
+function M.install(ctx, source)
+ local cargo = require "mason-core.installer.managers.cargo"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.crates.get_all_versions(source.crate)
+ end))
+
+ try(cargo.install(source.crate, source.version, {
+ git = source.git,
+ features = source.features,
+ locked = source.locked,
+ }))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/composer.lua b/lua/mason-core/installer/registry/providers/composer.lua
new file mode 100644
index 00000000..59c0ae61
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/composer.lua
@@ -0,0 +1,34 @@
+local Result = require "mason-core.result"
+local util = require "mason-core.installer.registry.util"
+
+local M = {}
+
+---@param source RegistryPackageSource
+---@param purl Purl
+function M.parse(source, purl)
+ ---@class ParsedComposerSource : ParsedPackageSource
+ local parsed_source = {
+ package = ("%s/%s"):format(purl.namespace, purl.name),
+ version = purl.version,
+ }
+
+ return Result.success(parsed_source)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedComposerSource
+function M.install(ctx, source)
+ local composer = require "mason-core.installer.managers.composer"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.packagist.get_all_versions(source.package)
+ end))
+
+ try(composer.install(source.package, source.version))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/gem.lua b/lua/mason-core/installer/registry/providers/gem.lua
new file mode 100644
index 00000000..ba829b9a
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/gem.lua
@@ -0,0 +1,47 @@
+local _ = require "mason-core.functional"
+local Result = require "mason-core.result"
+local util = require "mason-core.installer.registry.util"
+
+local M = {}
+
+---@class GemSource : RegistryPackageSource
+---@field supported_platforms? string[]
+---@field extra_packages? string[]
+
+---@param source GemSource
+---@param purl Purl
+function M.parse(source, purl)
+ return Result.try(function(try)
+ if source.supported_platforms then
+ try(util.ensure_valid_platform(source.supported_platforms))
+ end
+
+ ---@class ParsedGemSource : ParsedPackageSource
+ local parsed_source = {
+ package = purl.name,
+ version = purl.version,
+ extra_packages = source.extra_packages,
+ }
+ return parsed_source
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source GemSource
+function M.install(ctx, source)
+ local gem = require "mason-core.installer.managers.gem"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.rubygems.get_all_versions(source.package)
+ end))
+
+ try(gem.install(source.package, source.version, {
+ extra_packages = source.extra_packages,
+ }))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/generic.lua b/lua/mason-core/installer/registry/providers/generic.lua
new file mode 100644
index 00000000..5c493ae6
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/generic.lua
@@ -0,0 +1,47 @@
+local _ = require "mason-core.functional"
+local Result = require "mason-core.result"
+local expr = require "mason-core.installer.registry.expr"
+local util = require "mason-core.installer.registry.util"
+
+local M = {}
+
+---@class GenericDownload
+---@field target (Platform | Platform[])?
+---@field files table<string, string>
+
+---@class GenericSource : RegistryPackageSource
+---@field download GenericDownload | GenericDownload[]
+
+---@param source GenericSource
+---@param purl Purl
+---@param opts PackageInstallOpts
+function M.parse(source, purl, opts)
+ return Result.try(function(try)
+ local download = try(util.coalesce_by_target(source.download, opts):ok_or "PLATFORM_UNSUPPORTED")
+
+ local expr_ctx = { version = purl.version }
+ ---@type { files: table<string, string> }
+ local interpolated_download = try(expr.tbl_interpolate(download, expr_ctx))
+
+ ---@class ParsedGenericSource : ParsedPackageSource
+ local parsed_source = {
+ download = interpolated_download,
+ }
+ return parsed_source
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedGenericSource
+function M.install(ctx, source)
+ local std = require "mason-core.installer.managers.std"
+ return Result.try(function(try)
+ for out_file, url in pairs(source.download.files) do
+ try(std.download_file(url, out_file))
+ try(std.unpack(out_file))
+ end
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/github.lua b/lua/mason-core/installer/registry/providers/github.lua
new file mode 100644
index 00000000..2dda6fe6
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/github.lua
@@ -0,0 +1,179 @@
+local a = require "mason-core.async"
+local async_uv = require "mason-core.async.uv"
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+local path = require "mason-core.path"
+local settings = require "mason.settings"
+local expr = require "mason-core.installer.registry.expr"
+local util = require "mason-core.installer.registry.util"
+
+local build = {
+ ---@param source GitHubBuildSource
+ ---@param purl Purl
+ ---@param opts PackageInstallOpts
+ parse = function(source, purl, opts)
+ return Result.try(function(try)
+ ---@type { run: string }
+ local build_instruction = try(util.coalesce_by_target(source.build, opts):ok_or "PLATFORM_UNSUPPORTED")
+
+ ---@class ParsedGitHubBuildSource : ParsedPackageSource
+ local parsed_source = {
+ build = build_instruction,
+ repo = ("https://github.com/%s/%s.git"):format(purl.namespace, purl.name),
+ rev = purl.version,
+ }
+ return parsed_source
+ end)
+ end,
+
+ ---@async
+ ---@param ctx InstallContext
+ ---@param source ParsedGitHubBuildSource
+ install = function(ctx, source)
+ local std = require "mason-core.installer.managers.std"
+ return Result.try(function(try)
+ try(std.clone(source.repo, { rev = source.rev }))
+ try(platform.when {
+ unix = function()
+ return ctx.spawn.bash {
+ on_spawn = a.scope(function(_, stdio)
+ local stdin = stdio[1]
+ async_uv.write(stdin, "set -euxo pipefail;\n")
+ async_uv.write(stdin, source.build.run)
+ async_uv.shutdown(stdin)
+ async_uv.close(stdin)
+ end),
+ }
+ end,
+ win = function()
+ local powershell = require "mason-core.managers.powershell"
+ return powershell.command(source.build.run, {}, ctx.spawn)
+ end,
+ })
+ end)
+ end,
+}
+
+local release = {
+ ---@param source GitHubReleaseSource
+ ---@param purl Purl
+ ---@param opts PackageInstallOpts
+ parse = function(source, purl, opts)
+ return Result.try(function(try)
+ local asset = try(util.coalesce_by_target(source.asset, opts):ok_or "PLATFORM_UNSUPPORTED")
+
+ local expr_ctx = { version = purl.version }
+
+ ---@type { out_file: string, download_url: string }[]
+ local downloads = {}
+
+ for __, file in ipairs(type(asset.file) == "string" and { asset.file } or asset.file) do
+ local asset_file_components = _.split(":", file)
+ local source_file = try(expr.interpolate(_.head(asset_file_components), expr_ctx))
+ local out_file = try(expr.interpolate(_.last(asset_file_components), expr_ctx))
+
+ if _.matches("/$", out_file) then
+ -- out_file is a dir expression (e.g. "libexec/")
+ out_file = out_file .. source_file
+ end
+
+ table.insert(downloads, {
+ out_file = out_file,
+ download_url = settings.current.github.download_url_template:format(
+ ("%s/%s"):format(purl.namespace, purl.name),
+ purl.version,
+ source_file
+ ),
+ })
+ end
+
+ local interpolated_asset = try(expr.tbl_interpolate(asset, expr_ctx))
+
+ ---@class ParsedGitHubReleaseSource : ParsedPackageSource
+ local parsed_source = {
+ repo = ("%s/%s"):format(purl.namespace, purl.name),
+ asset = interpolated_asset,
+ downloads = downloads,
+ }
+ return parsed_source
+ end)
+ end,
+
+ ---@async
+ ---@param ctx InstallContext
+ ---@param source ParsedGitHubReleaseSource
+ install = function(ctx, source)
+ local std = require "mason-core.installer.managers.std"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.github.get_all_release_versions(source.repo)
+ end))
+
+ for __, download in ipairs(source.downloads) do
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ local out_dir = vim.fn.fnamemodify(download.out_file, ":h")
+ local out_file = vim.fn.fnamemodify(download.out_file, ":t")
+ if out_dir ~= "." then
+ try(Result.pcall(function()
+ ctx.fs:mkdirp(out_dir)
+ end))
+ end
+ try(ctx:chdir(out_dir, function()
+ return Result.try(function(try)
+ try(std.download_file(download.download_url, out_file))
+ try(std.unpack(out_file))
+ end)
+ end))
+ end
+ end)
+ end,
+}
+
+local M = {}
+
+---@class GitHubReleaseAsset
+---@field target? Platform | Platform[]
+---@field file string | string[]
+
+---@class GitHubReleaseSource : RegistryPackageSource
+---@field asset GitHubReleaseAsset | GitHubReleaseAsset[]
+
+---@class GitHubBuildInstruction
+---@field target? Platform | Platform[]
+---@field run string
+
+---@class GitHubBuildSource : RegistryPackageSource
+---@field build GitHubBuildInstruction | GitHubBuildInstruction[]
+
+---@param source GitHubReleaseSource | GitHubBuildSource
+---@param purl Purl
+---@param opts PackageInstallOpts
+function M.parse(source, purl, opts)
+ if source.asset then
+ return release.parse(source --[[@as GitHubReleaseSource]], purl, opts)
+ elseif source.build then
+ return build.parse(source --[[@as GitHubBuildSource]], purl, opts)
+ else
+ return Result.failure "Unknown source type."
+ end
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedGitHubReleaseSource | ParsedGitHubBuildSource
+function M.install(ctx, source)
+ if source.asset then
+ return release.install(ctx, source)
+ elseif source.build then
+ return build.install(ctx, source)
+ else
+ return Result.failure "Unknown source type."
+ end
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/golang.lua b/lua/mason-core/installer/registry/providers/golang.lua
new file mode 100644
index 00000000..34d8d160
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/golang.lua
@@ -0,0 +1,50 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local util = require "mason-core.installer.registry.util"
+
+local M = {}
+
+---@param purl Purl
+local function get_package_name(purl)
+ if purl.subpath then
+ return ("%s/%s/%s"):format(purl.namespace, purl.name, purl.subpath)
+ else
+ return ("%s/%s"):format(purl.namespace, purl.name)
+ end
+end
+
+---@class GolangSource : RegistryPackageSource
+---@field extra_packages? string[]
+
+---@param source GolangSource
+---@param purl Purl
+function M.parse(source, purl)
+ ---@class ParsedGolangSource : ParsedPackageSource
+ local parsed_source = {
+ package = get_package_name(purl),
+ version = purl.version,
+ extra_packages = source.extra_packages,
+ }
+
+ return Result.success(parsed_source)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedGolangSource
+function M.install(ctx, source)
+ local golang = require "mason-core.installer.managers.golang"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.golang.get_all_versions(source.package)
+ end))
+
+ try(golang.install(source.package, source.version, {
+ extra_packages = source.extra_packages,
+ }))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/luarocks.lua b/lua/mason-core/installer/registry/providers/luarocks.lua
new file mode 100644
index 00000000..78b0fc7f
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/luarocks.lua
@@ -0,0 +1,45 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+
+local M = {}
+
+---@param purl Purl
+local function parse_package_name(purl)
+ if purl.namespace then
+ return ("%s/%s"):format(purl.namespace, purl.name)
+ else
+ return purl.name
+ end
+end
+
+local parse_server = _.path { "qualifiers", "repository_url" }
+local parse_dev = _.compose(_.equals "true", _.path { "qualifiers", "dev" })
+
+---@param source RegistryPackageSource
+---@param purl Purl
+function M.parse(source, purl)
+ ---@class ParsedLuaRocksSource : ParsedPackageSource
+ local parsed_source = {
+ package = parse_package_name(purl),
+ version = purl.version,
+ ---@type string?
+ server = parse_server(purl),
+ ---@type boolean?
+ dev = parse_dev(purl),
+ }
+
+ return Result.success(parsed_source)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedLuaRocksSource
+function M.install(ctx, source)
+ local luarocks = require "mason-core.installer.managers.luarocks"
+ return luarocks.install(source.package, source.version, {
+ server = source.server,
+ dev = source.dev,
+ })
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/npm.lua b/lua/mason-core/installer/registry/providers/npm.lua
new file mode 100644
index 00000000..4b14c084
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/npm.lua
@@ -0,0 +1,51 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local util = require "mason-core.installer.registry.util"
+
+---@param purl Purl
+local function purl_to_npm(purl)
+ if purl.namespace then
+ return ("%s/%s"):format(purl.namespace, purl.name)
+ else
+ return purl.name
+ end
+end
+
+local M = {}
+
+---@class NpmSource : RegistryPackageSource
+---@field extra_packages? string[]
+
+---@param source NpmSource
+---@param purl Purl
+function M.parse(source, purl)
+ ---@class ParsedNpmSource : ParsedPackageSource
+ local parsed_source = {
+ package = purl_to_npm(purl),
+ version = purl.version,
+ extra_packages = source.extra_packages,
+ }
+
+ return Result.success(parsed_source)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedNpmSource
+function M.install(ctx, source)
+ local npm = require "mason-core.installer.managers.npm"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.npm.get_all_versions(source.package)
+ end))
+
+ try(npm.init())
+ try(npm.install(source.package, source.version, {
+ extra_packages = source.extra_packages,
+ }))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/nuget.lua b/lua/mason-core/installer/registry/providers/nuget.lua
new file mode 100644
index 00000000..55bc689d
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/nuget.lua
@@ -0,0 +1,25 @@
+local Result = require "mason-core.result"
+
+local M = {}
+
+---@param source RegistryPackageSource
+---@param purl Purl
+function M.parse(source, purl)
+ ---@class ParsedNugetSource : ParsedPackageSource
+ local parsed_source = {
+ package = purl.name,
+ version = purl.version,
+ }
+
+ return Result.success(parsed_source)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedNugetSource
+function M.install(ctx, source)
+ local nuget = require "mason-core.installer.managers.nuget"
+ return nuget.install(source.package, source.version)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/opam.lua b/lua/mason-core/installer/registry/providers/opam.lua
new file mode 100644
index 00000000..78608e85
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/opam.lua
@@ -0,0 +1,25 @@
+local Result = require "mason-core.result"
+
+local M = {}
+
+---@param source RegistryPackageSource
+---@param purl Purl
+function M.parse(source, purl)
+ ---@class ParsedOpamSource : ParsedPackageSource
+ local parsed_source = {
+ package = purl.name,
+ version = purl.version,
+ }
+
+ return Result.success(parsed_source)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedOpamSource
+function M.install(ctx, source)
+ local opam = require "mason-core.installer.managers.opam"
+ return opam.install(source.package, source.version)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/providers/pypi.lua b/lua/mason-core/installer/registry/providers/pypi.lua
new file mode 100644
index 00000000..8281d07e
--- /dev/null
+++ b/lua/mason-core/installer/registry/providers/pypi.lua
@@ -0,0 +1,59 @@
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local settings = require "mason.settings"
+local util = require "mason-core.installer.registry.util"
+
+local M = {}
+
+---@class PypiSource : RegistryPackageSource
+---@field extra_packages? string[]
+---@field supported_platforms? string[]
+
+---@param source PypiSource
+---@param purl Purl
+function M.parse(source, purl)
+ return Result.try(function(try)
+ if source.supported_platforms then
+ try(util.ensure_valid_platform(source.supported_platforms))
+ end
+
+ ---@class ParsedPypiSource : ParsedPackageSource
+ local parsed_source = {
+ package = purl.name,
+ version = purl.version,
+ extra = _.path({ "qualifiers", "extra" }, purl),
+ extra_packages = source.extra_packages,
+ pip = {
+ upgrade = settings.current.pip.upgrade_pip,
+ extra_args = settings.current.pip.install_args,
+ },
+ }
+
+ return parsed_source
+ end)
+end
+
+---@async
+---@param ctx InstallContext
+---@param source ParsedPypiSource
+function M.install(ctx, source)
+ local pypi = require "mason-core.installer.managers.pypi"
+ local providers = require "mason-core.providers"
+
+ return Result.try(function(try)
+ try(util.ensure_valid_version(function()
+ return providers.pypi.get_all_versions(source.package)
+ end))
+
+ try(pypi.init {
+ upgrade_pip = source.pip.upgrade,
+ install_extra_args = source.pip.extra_args,
+ })
+ try(pypi.install(source.package, source.version, {
+ extra = source.extra,
+ extra_packages = source.extra_packages,
+ }))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/installer/registry/util.lua b/lua/mason-core/installer/registry/util.lua
new file mode 100644
index 00000000..e49b5b82
--- /dev/null
+++ b/lua/mason-core/installer/registry/util.lua
@@ -0,0 +1,83 @@
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local Optional = require "mason-core.optional"
+local platform = require "mason-core.platform"
+local Result = require "mason-core.result"
+local log = require "mason-core.log"
+
+local M = {}
+
+---@generic T : { target: Platform | Platform[] }
+---@param candidates T[] | T
+---@param opts PackageInstallOpts
+---@return Optional # Optional<T>
+function M.coalesce_by_target(candidates, opts)
+ if not vim.tbl_islist(candidates) then
+ return Optional.of(candidates)
+ end
+ return Optional.of_nilable(_.find_first(function(asset)
+ if opts.target then
+ -- Matching against a provided target rather than the current platform is an escape hatch primarily meant
+ -- for automated testing purposes.
+ if type(asset.target) == "table" then
+ return _.any(_.equals(opts.target), asset.target)
+ else
+ return asset.target == opts.target
+ end
+ else
+ if type(asset.target) == "table" then
+ return _.any(function(target)
+ return platform.is[target]
+ end, asset.target)
+ else
+ return platform.is[asset.target]
+ end
+ end
+ end, candidates))
+end
+
+---Checks whether a custom version of a package installation corresponds to a valid version.
+---@async
+---@param versions_thunk async fun(): Result Result<string>
+function M.ensure_valid_version(versions_thunk)
+ local ctx = installer.context()
+ local version = ctx.opts.version
+
+ if version and not ctx.opts.force then
+ ctx.stdio_sink.stdout "Fetching available versions…\n"
+ local all_versions = versions_thunk()
+ if all_versions:is_failure() then
+ log.warn("Failed to fetch versions for package %s", ctx.package)
+ -- Gracefully fail (i.e. optimistically continue package installation)
+ return Result.success()
+ end
+ all_versions = all_versions:get_or_else {}
+
+ if not _.any(_.equals(version), all_versions) then
+ ctx.stdio_sink.stderr(("Tried to install invalid version %q. Available versions:\n"):format(version))
+ ctx.stdio_sink.stderr(_.compose(_.join "\n", _.map(_.join ", "), _.split_every(15))(all_versions))
+ ctx.stdio_sink.stderr "\n\n"
+ ctx.stdio_sink.stderr(
+ ("Run with --force flag to bypass version validation:\n :MasonInstall --force %s@%s\n\n"):format(
+ ctx.package.name,
+ version
+ )
+ )
+ return Result.failure(("Version %q is not available."):format(version))
+ end
+ end
+
+ return Result.success()
+end
+
+---@param platforms string[]
+function M.ensure_valid_platform(platforms)
+ if not _.any(function(target)
+ return platform.is[target]
+ end, platforms) then
+ return Result.failure "PLATFORM_UNSUPPORTED"
+ end
+ return Result.success()
+end
+
+return M
diff --git a/lua/mason-core/package/init.lua b/lua/mason-core/package/init.lua
index 88e89c41..16250ead 100644
--- a/lua/mason-core/package/init.lua
+++ b/lua/mason-core/package/init.lua
@@ -6,10 +6,16 @@ local log = require "mason-core.log"
local EventEmitter = require "mason-core.EventEmitter"
local fs = require "mason-core.fs"
local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local Purl = require "mason-core.purl"
+
+local is_not_nil = _.complement(_.is_nil)
+local is_registry_schema_id = _.matches "^registry%+v[1-9]+$"
+local is_registry_spec = _.prop_satisfies(_.all_pass { is_not_nil, is_registry_schema_id }, "schema")
---@class Package : EventEmitter
---@field name string
----@field spec PackageSpec
+---@field spec RegistryPackageSpec | PackageSpec
---@field private handle InstallHandle The currently associated handle.
local Package = setmetatable({}, { __index = EventEmitter })
@@ -50,16 +56,52 @@ local PackageMt = { __index = Package }
---@field languages PackageLanguage[]
---@field install async fun(ctx: InstallContext)
----@param spec PackageSpec
+---@class RegistryPackageSourceVersionOverride : RegistryPackageSource
+---@field constraint string
+
+---@class RegistryPackageSource
+---@field id string PURL-compliant identifier.
+---@field version_overrides? RegistryPackageSourceVersionOverride[]
+
+---@class RegistryPackageSpec
+---@field schema '"registry+v1"'
+---@field name string
+---@field description string
+---@field homepage string
+---@field licenses string[]
+---@field languages string[]
+---@field categories string[]
+---@field source RegistryPackageSource
+---@field bin table<string, string>?
+---@field share table<string, string>?
+---@field opt table<string, string>?
+
+---@param spec PackageSpec | RegistryPackageSpec
function Package.new(spec)
- vim.validate {
- name = { spec.name, "s" },
- desc = { spec.desc, "s" },
- homepage = { spec.homepage, "s" },
- categories = { spec.categories, "t" },
- languages = { spec.languages, "t" },
- install = { spec.install, "f" },
- }
+ if is_registry_spec(spec) then
+ vim.validate {
+ name = { spec.name, "s" },
+ description = { spec.description, "s" },
+ homepage = { spec.homepage, "s" },
+ licenses = { spec.licenses, "t" },
+ categories = { spec.categories, "t" },
+ languages = { spec.languages, "t" },
+ source = { spec.source, "t" },
+ bin = { spec.bin, { "t", "nil" } },
+ share = { spec.share, { "t", "nil" } },
+ }
+ -- XXX: this is for compatibilty with the PackageSpec structure
+ spec.desc = spec.description
+ else
+ vim.validate {
+ name = { spec.name, "s" },
+ desc = { spec.desc, "s" },
+ homepage = { spec.homepage, "s" },
+ categories = { spec.categories, "t" },
+ languages = { spec.languages, "t" },
+ install = { spec.install, "f" },
+ }
+ end
return EventEmitter.init(setmetatable({
name = spec.name, -- for convenient access
@@ -190,20 +232,65 @@ end
---@param callback fun(success: boolean, version_or_err: string)
function Package:get_installed_version(callback)
- a.run(function()
- local receipt = self:get_receipt():or_else_throw "Unable to get receipt."
- local version_checks = require "mason-core.package.version-check"
- return version_checks.get_installed_version(receipt, self:get_install_path()):get_or_throw()
- end, callback)
+ self:get_receipt()
+ :if_present(
+ ---@param receipt InstallReceipt
+ function(receipt)
+ if is_registry_schema_id(receipt.primary_source.type) then
+ local resolve = _.curryN(callback, 2)
+ Purl.parse(receipt.primary_source.id)
+ :map(_.prop "version")
+ :on_success(resolve(true))
+ :on_failure(resolve(false))
+ else
+ a.run(function()
+ local version_checks = require "mason-core.package.version-check"
+ return version_checks.get_installed_version(receipt, self:get_install_path()):get_or_throw()
+ end, callback)
+ end
+ end
+ )
+ :if_not_present(function()
+ callback(false, "Unable to get receipt.")
+ end)
end
---@param callback fun(success: boolean, result_or_err: NewPackageVersion)
function Package:check_new_version(callback)
- a.run(function()
- local receipt = self:get_receipt():or_else_throw "Unable to get receipt."
- local version_checks = require "mason-core.package.version-check"
- return version_checks.get_new_version(receipt, self:get_install_path()):get_or_throw()
- end, callback)
+ if self:is_registry_spec() then
+ self:get_installed_version(function(success, installed_version)
+ if not success then
+ return callback(false, installed_version)
+ end
+ local resolve = _.curryN(callback, 2)
+ Result.try(function(try)
+ -- This is a bit goofy, but it's done to verify that a new version is supported by the
+ -- current platform (parse fails if it's not). We don't want to surface new versions that
+ -- are unsupported.
+ try(require("mason-core.installer.registry").parse(self.spec, {}))
+
+ ---@type Purl
+ local purl = try(Purl.parse(self.spec.source.id))
+ if purl.version and installed_version ~= purl.version then
+ return {
+ name = purl.name,
+ current_version = installed_version,
+ latest_version = purl.version,
+ }
+ else
+ return Result.failure "Package is not outdated."
+ end
+ end)
+ :on_success(resolve(true))
+ :on_failure(resolve(false))
+ end)
+ else
+ a.run(function()
+ local receipt = self:get_receipt():or_else_throw "Unable to get receipt."
+ local version_checks = require "mason-core.package.version-check"
+ return version_checks.get_new_version(receipt, self:get_install_path()):get_or_throw()
+ end, callback)
+ end
end
function Package:get_lsp_settings_schema()
@@ -214,6 +301,11 @@ function Package:get_lsp_settings_schema()
return Optional.of(schema)
end
+---@return boolean
+function Package:is_registry_spec()
+ return is_registry_spec(self.spec)
+end
+
function PackageMt.__tostring(self)
return ("Package(name=%s)"):format(self.name)
end
diff --git a/lua/mason-core/path.lua b/lua/mason-core/path.lua
index b63b6c4a..1e0038be 100644
--- a/lua/mason-core/path.lua
+++ b/lua/mason-core/path.lua
@@ -58,4 +58,8 @@ function M.package_build_prefix(name)
return M.concat { M.install_prefix ".packages", name }
end
+function M.registry_prefix()
+ return M.install_prefix "registries"
+end
+
return M
diff --git a/lua/mason-core/platform.lua b/lua/mason-core/platform.lua
index 0a7a3e33..cd958abc 100644
--- a/lua/mason-core/platform.lua
+++ b/lua/mason-core/platform.lua
@@ -5,11 +5,22 @@ local M = {}
local uname = vim.loop.os_uname()
---@alias Platform
----| '"win"'
----| '"unix"'
----| '"linux"'
----| '"mac"'
----| '"darwin"'
+---| '"darwin_arm64"'
+---| '"darwin_x64"'
+---| '"linux_arm"'
+---| '"linux_arm64"'
+---| '"linux_arm64_gnu"'
+---| '"linux_arm64_openbsd"'
+---| '"linux_arm_gnu"'
+---| '"linux_x64"'
+---| '"linux_x64_gnu"'
+---| '"linux_x64_openbsd"'
+---| '"linux_x86"'
+---| '"linux_x86_gnu"'
+---| '"win_arm"'
+---| '"win_arm64"'
+---| '"win_x64"'
+---| '"win_x86"'
local arch_aliases = {
["x86_64"] = "x64",
diff --git a/lua/mason-registry/init.lua b/lua/mason-registry/init.lua
index 01f18c90..a87b3b07 100644
--- a/lua/mason-registry/init.lua
+++ b/lua/mason-registry/init.lua
@@ -11,7 +11,7 @@ local sources = require "mason-registry.sources"
---@field get_all_package_names fun(self: RegistrySource): string[]
---@field get_display_name fun(self: RegistrySource): string
---@field is_installed fun(self: RegistrySource): boolean
----@field install async fun(self: RegistrySource): Result
+---@field get_installer fun(self: RegistrySource): Optional # Optional<async fun (): Result>
---@class MasonRegistry : EventEmitter
---@diagnostic disable-next-line: assign-type-mismatch
@@ -118,4 +118,40 @@ function M.get_all_packages()
return get_packages(M.get_all_package_names())
end
+---@param cb fun(success: boolean, err: any?)
+function M.update(cb)
+ local a = require "mason-core.async"
+ local Result = require "mason-core.result"
+
+ a.run(function()
+ return Result.try(function(try)
+ local updated_sources = {}
+ for source in sources.iter { include_uninstalled = true } do
+ source:get_installer():if_present(function(installer)
+ try(installer():map_err(function(err)
+ return ("%s failed to install: %s"):format(source, err)
+ end))
+ table.insert(updated_sources, source)
+ end)
+ end
+ return updated_sources
+ end)
+ end, function(success, sources_or_err)
+ if not success then
+ cb(success, sources_or_err)
+ return
+ end
+ sources_or_err
+ :on_success(function(updated_sources)
+ if #updated_sources > 0 then
+ M:emit("update", updated_sources)
+ end
+ cb(true, updated_sources)
+ end)
+ :on_failure(function(err)
+ cb(false, err)
+ end)
+ end)
+end
+
return M
diff --git a/lua/mason-registry/sources/github.lua b/lua/mason-registry/sources/github.lua
new file mode 100644
index 00000000..0cddff21
--- /dev/null
+++ b/lua/mason-registry/sources/github.lua
@@ -0,0 +1,195 @@
+local log = require "mason-core.log"
+local fs = require "mason-core.fs"
+local providers = require "mason-core.providers"
+local _ = require "mason-core.functional"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local Optional = require "mason-core.optional"
+local fetch = require "mason-core.fetch"
+local settings = require "mason.settings"
+local platform = require "mason-core.platform"
+local spawn = require "mason-core.spawn"
+local Pkg = require "mason-core.package"
+local registry_installer = require "mason-core.installer.registry"
+
+-- Parse sha256sum text output to a table<filename: string, sha256sum: string> structure
+local parse_checksums = _.compose(_.from_pairs, _.map(_.compose(_.reverse, _.split " ")), _.split "\n", _.trim)
+
+---@class GitHubRegistrySourceSpec
+---@field id string
+---@field repo string
+---@field namespace string
+---@field name string
+---@field version string?
+
+---@class GitHubRegistrySource : RegistrySource
+---@field spec GitHubRegistrySourceSpec
+---@field repo string
+---@field root_dir string
+---@field private data_file string
+---@field private info_file string
+---@field buffer table<string, Package>?
+local GitHubRegistrySource = {}
+GitHubRegistrySource.__index = GitHubRegistrySource
+
+---@param spec GitHubRegistrySourceSpec
+function GitHubRegistrySource.new(spec)
+ local root_dir = path.concat { path.registry_prefix(), "github", spec.namespace, spec.name }
+ return setmetatable({
+ spec = spec,
+ root_dir = root_dir,
+ data_file = path.concat { root_dir, "registry.json" },
+ info_file = path.concat { root_dir, "info.json" },
+ }, GitHubRegistrySource)
+end
+
+function GitHubRegistrySource:is_installed()
+ return fs.sync.file_exists(self.data_file)
+end
+
+function GitHubRegistrySource:reload()
+ if not self:is_installed() then
+ return
+ end
+ local data = vim.json.decode(fs.sync.read_file(self.data_file))
+ self.buffer = _.compose(
+ _.index_by(_.prop "name"),
+ _.filter_map(
+ ---@param spec RegistryPackageSpec
+ function(spec)
+ -- registry+v1 specifications doesn't include a schema property, so infer it
+ spec.schema = spec.schema or "registry+v1"
+
+ if not registry_installer.SCHEMA_CAP[spec.schema] then
+ log.fmt_debug("Excluding package=%s with unsupported schema_version=%s", spec.name, spec.schema)
+ return Optional.empty()
+ end
+
+ -- hydrate Pkg.Lang index
+ _.each(function(lang)
+ local _ = Pkg.Lang[lang]
+ end, spec.languages)
+
+ local pkg = self.buffer and self.buffer[spec.name]
+ if pkg then
+ -- Apply spec to the existing Package instance. This is important as to not have lingering package
+ -- instances.
+ pkg.spec = spec
+ return Optional.of(pkg)
+ end
+ return Optional.of(Pkg.new(spec))
+ end
+ )
+ )(data)
+ return self.buffer
+end
+
+function GitHubRegistrySource:get_buffer()
+ return self.buffer or self:reload() or {}
+end
+
+---@param pkg string
+---@return Package?
+function GitHubRegistrySource:get_package(pkg)
+ return self:get_buffer()[pkg]
+end
+
+function GitHubRegistrySource:get_all_package_names()
+ return _.keys(self:get_buffer())
+end
+
+function GitHubRegistrySource:get_installer()
+ return Optional.of(_.partial(self.install, self))
+end
+
+---@async
+function GitHubRegistrySource:install()
+ return Result.try(function(try)
+ if not fs.async.dir_exists(self.root_dir) then
+ log.debug("Creating registry directory", self)
+ try(Result.pcall(fs.async.mkdirp, self.root_dir))
+ end
+
+ local version = self.spec.version
+ if version == nil or version == "latest" then
+ log.trace("Resolving latest version for registry", self)
+ ---@type GitHubRelease
+ local release = try(providers.github.get_latest_release(self.spec.repo))
+ version = release.tag_name
+ log.trace("Resolved latest registry version", self, version)
+ end
+
+ try(fetch(settings.current.github.download_url_template:format(self.spec.repo, version, "registry.json.zip"), {
+ out_file = path.concat { self.root_dir, "registry.json.zip" },
+ }):map_err(_.always "Failed to download registry.json.zip."))
+
+ local checksums = try(
+ fetch(settings.current.github.download_url_template:format(self.spec.repo, version, "checksums.txt")):map_err(
+ _.always "Failed to download checksums.txt."
+ )
+ )
+ local parsed_checksums = parse_checksums(checksums)
+
+ platform.when {
+ unix = function()
+ try(spawn.unzip({ "-o", "registry.json.zip", cwd = self.root_dir }):map_err(function(err)
+ return ("Failed to unpack registry contents: %s"):format(err.stderr)
+ end))
+ end,
+ win = function()
+ local powershell = require "mason-core.managers.powershell"
+ powershell
+ .command(
+ ("Microsoft.PowerShell.Archive\\Expand-Archive -Force -Path %q -DestinationPath ."):format "registry.json.zip",
+ {
+ cwd = self.root_dir,
+ }
+ )
+ :map_err(function(err)
+ return ("Failed to unpack registry contents: %s"):format(err.stderr)
+ end)
+ end,
+ }
+ pcall(fs.async.unlink, path.concat { self.root_dir, "registry.json.zip" })
+
+ try(Result.pcall(
+ fs.async.write_file,
+ self.info_file,
+ vim.json.encode {
+ checksums = parsed_checksums,
+ version = version,
+ download_timestamp = os.time(),
+ }
+ ))
+ end)
+ :on_success(function()
+ self:reload()
+ end)
+ :on_failure(function(err)
+ log.fmt_error("Failed to install registry %s. %s", self, err)
+ end)
+end
+
+---@return { checksums: table<string, string>, version: string, download_timestamp: integer }
+function GitHubRegistrySource:get_info()
+ return vim.json.decode(fs.sync.read_file(self.info_file))
+end
+
+function GitHubRegistrySource:get_display_name()
+ if self:is_installed() then
+ local info = self:get_info()
+ return ("github.com/%s version: %s"):format(self.spec.repo, info.version)
+ else
+ return ("github.com/%s [uninstalled]"):format(self.spec.repo)
+ end
+end
+
+function GitHubRegistrySource:__tostring()
+ if self.spec.version then
+ return ("GitHubRegistrySource(repo=%s, version=%s)"):format(self.spec.repo, self.spec.version)
+ else
+ return ("GitHubRegistrySource(repo=%s)"):format(self.spec.repo)
+ end
+end
+
+return GitHubRegistrySource
diff --git a/lua/mason-registry/sources/init.lua b/lua/mason-registry/sources/init.lua
index e4abe062..5a332326 100644
--- a/lua/mason-registry/sources/init.lua
+++ b/lua/mason-registry/sources/init.lua
@@ -4,7 +4,23 @@ local M = {}
---@return fun(): RegistrySource # Thunk to instantiate provider.
local function parse(registry_id)
local type, id = registry_id:match "^(.+):(.+)$"
- if type == "lua" then
+ if type == "github" then
+ local namespace, name = id:match "^(.+)/(.+)$"
+ if not namespace or not name then
+ error(("Failed to parse repository from GitHub registry: %q."):format(registry_id), 0)
+ end
+ local name, version = unpack(vim.split(name, "@"))
+ return function()
+ local GitHubRegistrySource = require "mason-registry.sources.github"
+ return GitHubRegistrySource.new {
+ id = registry_id,
+ repo = ("%s/%s"):format(namespace, name),
+ namespace = namespace,
+ name = name,
+ version = version or "latest",
+ }
+ end
+ elseif type == "lua" then
return function()
local LuaRegistrySource = require "mason-registry.sources.lua"
return LuaRegistrySource.new {
diff --git a/lua/mason-registry/sources/lua.lua b/lua/mason-registry/sources/lua.lua
index 7af4feee..ac41c03c 100644
--- a/lua/mason-registry/sources/lua.lua
+++ b/lua/mason-registry/sources/lua.lua
@@ -34,9 +34,9 @@ function LuaRegistrySource:is_installed()
return ok
end
-function LuaRegistrySource:install()
- local Result = require "mason-core.result"
- return Result.success()
+function LuaRegistrySource:get_installer()
+ local Optional = require "mason-core.optional"
+ return Optional.empty()
end
function LuaRegistrySource:get_display_name()
diff --git a/lua/mason/api/command.lua b/lua/mason/api/command.lua
index 5d09c67d..fadb9ac4 100644
--- a/lua/mason/api/command.lua
+++ b/lua/mason/api/command.lua
@@ -180,6 +180,24 @@ vim.api.nvim_create_user_command("MasonUninstallAll", MasonUninstallAll, {
desc = "Uninstall all packages.",
})
+local function MasonUpdate()
+ local notify = require "mason-core.notify"
+ local registry = require "mason-registry"
+ notify "Updating registries…"
+ registry.update(vim.schedule_wrap(function(success, updated_registries)
+ if success then
+ local count = #updated_registries
+ notify(("Successfully updated %d %s."):format(count, count == 1 and "registry" or "registries"))
+ else
+ notify(("Failed to update registries: %s"):format(updated_registries), vim.log.levels.ERROR)
+ end
+ end))
+end
+
+vim.api.nvim_create_user_command("MasonUpdate", MasonUpdate, {
+ desc = "Update Mason registries.",
+})
+
local function MasonLog()
local log = require "mason-core.log"
vim.cmd(([[tabnew %s]]):format(log.outfile))
@@ -210,5 +228,6 @@ return {
MasonInstall = MasonInstall,
MasonUninstall = MasonUninstall,
MasonUninstallAll = MasonUninstallAll,
+ MasonUpdate = MasonUpdate,
MasonLog = MasonLog,
}
diff --git a/lua/mason/settings.lua b/lua/mason/settings.lua
index 9862c3cd..fe93eedc 100644
--- a/lua/mason/settings.lua
+++ b/lua/mason/settings.lua
@@ -14,12 +14,6 @@ local DEFAULT_SETTINGS = {
---@type '"prepend"' | '"append"' | '"skip"'
PATH = "prepend",
- -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in
- -- multiple registries, the registry listed first will be used.
- registries = {
- "lua:mason-registry.index",
- },
-
-- Controls to which degree logs are written to the log file. It's useful to set this to vim.log.levels.DEBUG when
-- debugging issues with package installations.
log_level = vim.log.levels.INFO,
@@ -28,6 +22,13 @@ local DEFAULT_SETTINGS = {
-- packages that are requested to be installed will be put in a queue.
max_concurrent_installers = 4,
+ -- [Advanced setting]
+ -- The registries to source packages from. Accepts multiple entries. Should a package with the same name exist in
+ -- multiple registries, the registry listed first will be used.
+ registries = {
+ "lua:mason-registry.index",
+ },
+
-- The provider implementations to use for resolving supplementary package metadata (e.g., all available versions).
-- Accepts multiple entries, where later entries will be used as fallback should prior providers fail.
-- Builtin providers are:
diff --git a/lua/mason/ui/init.lua b/lua/mason/ui/init.lua
index a8a60946..bbe534ad 100644
--- a/lua/mason/ui/init.lua
+++ b/lua/mason/ui/init.lua
@@ -1,5 +1,10 @@
local M = {}
+function M.close()
+ local api = require "mason.ui.instance"
+ api.close()
+end
+
function M.open()
local api = require "mason.ui.instance"
api.window.open()
diff --git a/lua/mason/ui/instance.lua b/lua/mason/ui/instance.lua
index 52717654..d1031fcb 100644
--- a/lua/mason/ui/instance.lua
+++ b/lua/mason/ui/instance.lua
@@ -81,6 +81,8 @@ local INITIAL_STATE = {
total = 0,
percentage_complete = 0,
},
+ ---@type Package[]
+ all = {},
---@type table<string, boolean>
visible = {},
---@type string|nil
@@ -115,7 +117,6 @@ local function remove(list, item)
end
local window = display.new_view_only_win("mason.nvim", "mason")
-local packages = _.sort_by(_.prop "name", registry.get_all_packages())
window.view(
---@param state InstallerUiState
@@ -174,7 +175,7 @@ local function mutate_package_visibility(mutate_fn)
_.prop_satisfies(_.any(_.equals(state.view.language_filter)), "languages"),
_.T
)
- for __, pkg in ipairs(packages) do
+ for __, pkg in ipairs(state.packages.all) do
state.packages.visible[pkg.name] =
_.all_pass({ view_predicate[state.view.current], language_predicate }, pkg.spec)
end
@@ -579,7 +580,26 @@ local effects = {
["UPDATE_ALL_PACKAGES"] = update_all_packages,
}
-for _, pkg in ipairs(packages) do
+local registered_packages = {}
+
+---@param pkg Package
+local function setup_package(pkg)
+ if registered_packages[pkg] then
+ return
+ end
+
+ mutate_state(function(state)
+ for _, group in ipairs { state.packages.installed, state.packages.uninstalled, state.packages.failed } do
+ for i, existing_pkg in ipairs(group) do
+ if existing_pkg.name == pkg.name and pkg ~= existing_pkg then
+ -- New package instance (i.e. from a new, updated, registry source).
+ -- Release the old package instance.
+ table.remove(group, i)
+ end
+ end
+ end
+ end)
+
-- hydrate initial state
mutate_state(function(state)
state.packages.states[pkg.name] = create_initial_package_state()
@@ -626,8 +646,37 @@ for _, pkg in ipairs(packages) do
end)
mutate_package_grouping(pkg, "uninstalled")
end)
+
+ registered_packages[pkg] = true
end
+local function update_registry_info()
+ local registries = {}
+ for source in require("mason-registry.sources").iter { include_uninstalled = true } do
+ table.insert(registries, source:get_display_name())
+ end
+ mutate_state(function(state)
+ state.info.registries = registries
+ end)
+end
+
+---@param packages Package[]
+local function setup_packages(packages)
+ _.each(setup_package, _.sort_by(_.prop "name", packages))
+ mutate_state(function(state)
+ state.packages.all = packages
+ end)
+end
+
+setup_packages(registry.get_all_packages())
+
+registry:on("update", function()
+ setup_packages(registry.get_all_packages())
+ update_registry_info()
+end)
+
+update_registry_info()
+
window.init {
effects = effects,
border = settings.current.ui.border,
@@ -645,16 +694,6 @@ if settings.current.ui.check_outdated_packages_on_open then
)
end
-do
- local registries = {}
- for source in require("mason-registry.sources").iter { include_uninstalled = true } do
- table.insert(registries, source:get_display_name())
- end
- mutate_state(function(state)
- state.info.registries = registries
- end)
-end
-
return {
window = window,
set_view = function(view)
diff --git a/tests/helpers/lua/dummy2_package.lua b/tests/helpers/lua/dummy-registry/dummy2_package.lua
index 424e47d7..424e47d7 100644
--- a/tests/helpers/lua/dummy2_package.lua
+++ b/tests/helpers/lua/dummy-registry/dummy2_package.lua
diff --git a/tests/helpers/lua/dummy_package.lua b/tests/helpers/lua/dummy-registry/dummy_package.lua
index b38d1cd8..b38d1cd8 100644
--- a/tests/helpers/lua/dummy_package.lua
+++ b/tests/helpers/lua/dummy-registry/dummy_package.lua
diff --git a/tests/helpers/lua/dummy-registry/index.lua b/tests/helpers/lua/dummy-registry/index.lua
new file mode 100644
index 00000000..85fe000f
--- /dev/null
+++ b/tests/helpers/lua/dummy-registry/index.lua
@@ -0,0 +1,5 @@
+return {
+ ["dummy"] = "dummy-registry.dummy_package",
+ ["dummy2"] = "dummy-registry.dummy2_package",
+ ["registry"] = "dummy-registry.registry_package",
+}
diff --git a/tests/helpers/lua/dummy-registry/registry_package.lua b/tests/helpers/lua/dummy-registry/registry_package.lua
new file mode 100644
index 00000000..e72284a8
--- /dev/null
+++ b/tests/helpers/lua/dummy-registry/registry_package.lua
@@ -0,0 +1,14 @@
+local Pkg = require "mason-core.package"
+
+return Pkg.new {
+ schema = "registry+v1",
+ name = "registry",
+ description = [[This is a dummy package.]],
+ homepage = "https://example.com",
+ licenses = { "MIT" },
+ languages = { "DummyLang" },
+ categories = { "LSP" },
+ source = {
+ id = "pkg:dummy/registry@1.0.0",
+ },
+}
diff --git a/tests/helpers/lua/test_helpers.lua b/tests/helpers/lua/test_helpers.lua
index f11765d5..8a69ead8 100644
--- a/tests/helpers/lua/test_helpers.lua
+++ b/tests/helpers/lua/test_helpers.lua
@@ -2,7 +2,9 @@
local util = require "luassert.util"
local spy = require "luassert.spy"
+local path = require "mason-core.path"
local a = require "mason-core.async"
+local Result = require "mason-core.result"
local InstallHandle = require "mason-core.installer.handle"
local InstallContext = require "mason-core.installer.context"
local registry = require "mason-registry"
@@ -32,6 +34,21 @@ mockx = {
end,
}
+---@param opts? PackageInstallOpts
+function create_dummy_context(opts)
+ local ctx = InstallContextGenerator(InstallHandleGenerator "registry", opts)
+ ctx.cwd:set(path.package_build_prefix "registry")
+ ctx.spawn = setmetatable({}, {
+ __index = function(s, cmd)
+ s[cmd] = spy.new(function()
+ return Result.success { stdout = nil, stderr = nil }
+ end)
+ return s[cmd]
+ end,
+ })
+ return ctx
+end
+
-- selene: allow(unused_variable)
---@param package_name string
function InstallHandleGenerator(package_name)
diff --git a/tests/mason-core/installer/installer_spec.lua b/tests/mason-core/installer/installer_spec.lua
index 66cdb89b..aa459cce 100644
--- a/tests/mason-core/installer/installer_spec.lua
+++ b/tests/mason-core/installer/installer_spec.lua
@@ -63,7 +63,7 @@ describe("installer", function()
local handler = InstallHandleGenerator "dummy"
---@param ctx InstallContext
handler.package.spec.install = function(ctx)
- ctx.receipt:with_primary_source { type = "source", metadata = {} }
+ ctx.receipt:with_primary_source { type = "source", source = {} }
ctx.fs:write_file("target", "")
ctx.fs:write_file("file.jar", "")
@@ -86,7 +86,7 @@ describe("installer", function()
---@type InstallReceipt
local receipt = vim.json.decode(arg)
assert.equals("dummy", receipt.name)
- assert.same({ type = "source", metadata = {} }, receipt.primary_source)
+ assert.same({ type = "source", source = {} }, receipt.primary_source)
assert.same({}, receipt.secondary_sources)
assert.same("1.1", receipt.schema_version)
assert.same({
diff --git a/tests/mason-core/installer/managers/cargo_spec.lua b/tests/mason-core/installer/managers/cargo_spec.lua
new file mode 100644
index 00000000..63768f4d
--- /dev/null
+++ b/tests/mason-core/installer/managers/cargo_spec.lua
@@ -0,0 +1,112 @@
+local installer = require "mason-core.installer"
+local cargo = require "mason-core.installer.managers.cargo"
+
+describe("cargo manager", function()
+ it("should install", function()
+ local handle = InstallHandleGenerator "dummy"
+ local ctx = InstallContextGenerator(handle)
+ installer.exec_in_context(ctx, function()
+ cargo.install("my-crate", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.cargo).was_called(1)
+ assert.spy(ctx.spawn.cargo).was_called_with {
+ "install",
+ "--root",
+ ".",
+ { "--version", "1.0.0" },
+ vim.NIL, -- features
+ vim.NIL, -- locked
+ "my-crate",
+ }
+ end)
+
+ it("should install locked", function()
+ local handle = InstallHandleGenerator "dummy"
+ local ctx = InstallContextGenerator(handle)
+ installer.exec_in_context(ctx, function()
+ cargo.install("my-crate", "1.0.0", {
+ locked = true,
+ })
+ end)
+
+ assert.spy(ctx.spawn.cargo).was_called(1)
+ assert.spy(ctx.spawn.cargo).was_called_with {
+ "install",
+ "--root",
+ ".",
+ { "--version", "1.0.0" },
+ vim.NIL, -- features
+ "--locked", -- locked
+ "my-crate",
+ }
+ end)
+
+ it("should install provided features", function()
+ local handle = InstallHandleGenerator "dummy"
+ local ctx = InstallContextGenerator(handle)
+ installer.exec_in_context(ctx, function()
+ cargo.install("my-crate", "1.0.0", {
+ features = "lsp,cli",
+ })
+ end)
+
+ assert.spy(ctx.spawn.cargo).was_called(1)
+ assert.spy(ctx.spawn.cargo).was_called_with {
+ "install",
+ "--root",
+ ".",
+ { "--version", "1.0.0" },
+ { "--features", "lsp,cli" }, -- features
+ vim.NIL, -- locked
+ "my-crate",
+ }
+ end)
+
+ it("should install git tag source", function()
+ local handle = InstallHandleGenerator "dummy"
+ local ctx = InstallContextGenerator(handle)
+ installer.exec_in_context(ctx, function()
+ cargo.install("my-crate", "1.0.0", {
+ git = {
+ url = "https://github.com/neovim/neovim",
+ },
+ })
+ end)
+
+ assert.spy(ctx.spawn.cargo).was_called(1)
+ assert.spy(ctx.spawn.cargo).was_called_with {
+ "install",
+ "--root",
+ ".",
+ { "--git", "https://github.com/neovim/neovim", "--tag", "1.0.0" },
+ vim.NIL, -- features
+ vim.NIL, -- locked
+ "my-crate",
+ }
+ end)
+
+ it("should install git rev source", function()
+ local handle = InstallHandleGenerator "dummy"
+ local ctx = InstallContextGenerator(handle)
+ installer.exec_in_context(ctx, function()
+ cargo.install("my-crate", "16dfc89abd413c391e5b63ae5d132c22843ce9a7", {
+ git = {
+ url = "https://github.com/neovim/neovim",
+ rev = true,
+ },
+ })
+ end)
+
+ assert.spy(ctx.spawn.cargo).was_called(1)
+ assert.spy(ctx.spawn.cargo).was_called_with {
+ "install",
+ "--root",
+ ".",
+ { "--git", "https://github.com/neovim/neovim", "--rev", "16dfc89abd413c391e5b63ae5d132c22843ce9a7" },
+ vim.NIL, -- features
+ vim.NIL, -- locked
+ "my-crate",
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/composer_spec.lua b/tests/mason-core/installer/managers/composer_spec.lua
new file mode 100644
index 00000000..a4e1f82f
--- /dev/null
+++ b/tests/mason-core/installer/managers/composer_spec.lua
@@ -0,0 +1,22 @@
+local installer = require "mason-core.installer"
+local composer = require "mason-core.installer.managers.composer"
+
+describe("composer manager", function()
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ composer.install("my-package", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.composer).was_called(2)
+ assert.spy(ctx.spawn.composer).was_called_with {
+ "init",
+ "--no-interaction",
+ "--stability=stable",
+ }
+ assert.spy(ctx.spawn.composer).was_called_with {
+ "require",
+ "my-package:1.0.0",
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/gem_spec.lua b/tests/mason-core/installer/managers/gem_spec.lua
new file mode 100644
index 00000000..580c6432
--- /dev/null
+++ b/tests/mason-core/installer/managers/gem_spec.lua
@@ -0,0 +1,50 @@
+local installer = require "mason-core.installer"
+local gem = require "mason-core.installer.managers.gem"
+
+describe("gem manager", function()
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ gem.install("my-gem", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.gem).was_called(1)
+ assert.spy(ctx.spawn.gem).was_called_with {
+ "install",
+ "--no-user-install",
+ "--no-format-executable",
+ "--install-dir=.",
+ "--bindir=bin",
+ "--no-document",
+ "my-gem:1.0.0",
+ vim.NIL, -- extra_packages
+ env = {
+ GEM_HOME = ctx.cwd:get(),
+ },
+ }
+ end)
+
+ it("should install extra packages", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ gem.install("my-gem", "1.0.0", {
+ extra_packages = { "extra-gem" },
+ })
+ end)
+
+ assert.spy(ctx.spawn.gem).was_called(1)
+ assert.spy(ctx.spawn.gem).was_called_with {
+ "install",
+ "--no-user-install",
+ "--no-format-executable",
+ "--install-dir=.",
+ "--bindir=bin",
+ "--no-document",
+ "my-gem:1.0.0",
+ { "extra-gem" },
+ env = {
+ GEM_HOME = ctx.cwd:get(),
+ },
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/golang_spec.lua b/tests/mason-core/installer/managers/golang_spec.lua
new file mode 100644
index 00000000..cdad6e25
--- /dev/null
+++ b/tests/mason-core/installer/managers/golang_spec.lua
@@ -0,0 +1,56 @@
+local installer = require "mason-core.installer"
+local golang = require "mason-core.installer.managers.golang"
+
+describe("golang manager", function()
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ golang.install("my-golang", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.go).was_called(1)
+ assert.spy(ctx.spawn.go).was_called_with {
+ "install",
+ "-v",
+ "my-golang@1.0.0",
+ env = {
+ GOBIN = ctx.cwd:get(),
+ },
+ }
+ end)
+
+ it("should install extra packages", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ golang.install("my-golang", "1.0.0", {
+ extra_packages = { "extra", "package" },
+ })
+ end)
+
+ assert.spy(ctx.spawn.go).was_called(3)
+ assert.spy(ctx.spawn.go).was_called_with {
+ "install",
+ "-v",
+ "my-golang@1.0.0",
+ env = {
+ GOBIN = ctx.cwd:get(),
+ },
+ }
+ assert.spy(ctx.spawn.go).was_called_with {
+ "install",
+ "-v",
+ "extra@latest",
+ env = {
+ GOBIN = ctx.cwd:get(),
+ },
+ }
+ assert.spy(ctx.spawn.go).was_called_with {
+ "install",
+ "-v",
+ "package@latest",
+ env = {
+ GOBIN = ctx.cwd:get(),
+ },
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/luarocks_spec.lua b/tests/mason-core/installer/managers/luarocks_spec.lua
new file mode 100644
index 00000000..69ac3946
--- /dev/null
+++ b/tests/mason-core/installer/managers/luarocks_spec.lua
@@ -0,0 +1,63 @@
+local installer = require "mason-core.installer"
+local stub = require "luassert.stub"
+local luarocks = require "mason-core.installer.managers.luarocks"
+
+describe("luarocks manager", function()
+ it("should install", function()
+ local ctx = create_dummy_context()
+ stub(ctx, "promote_cwd")
+ installer.exec_in_context(ctx, function()
+ luarocks.install("my-rock", "1.0.0")
+ end)
+
+ assert.spy(ctx.promote_cwd).was_called(1)
+ assert.spy(ctx.spawn.luarocks).was_called(1)
+ assert.spy(ctx.spawn.luarocks).was_called_with {
+ "install",
+ { "--tree", ctx.cwd:get() },
+ vim.NIL, -- dev
+ vim.NIL, -- server
+ { "my-rock", "1.0.0" },
+ }
+ end)
+
+ it("should install dev mode", function()
+ local ctx = create_dummy_context()
+ stub(ctx, "promote_cwd")
+ installer.exec_in_context(ctx, function()
+ luarocks.install("my-rock", "1.0.0", {
+ dev = true,
+ })
+ end)
+
+ assert.spy(ctx.promote_cwd).was_called(1)
+ assert.spy(ctx.spawn.luarocks).was_called(1)
+ assert.spy(ctx.spawn.luarocks).was_called_with {
+ "install",
+ { "--tree", ctx.cwd:get() },
+ "--dev",
+ vim.NIL, -- server
+ { "my-rock", "1.0.0" },
+ }
+ end)
+
+ it("should install using provided server", function()
+ local ctx = create_dummy_context()
+ stub(ctx, "promote_cwd")
+ installer.exec_in_context(ctx, function()
+ luarocks.install("my-rock", "1.0.0", {
+ server = "https://luarocks.org/dev",
+ })
+ end)
+
+ assert.spy(ctx.promote_cwd).was_called(1)
+ assert.spy(ctx.spawn.luarocks).was_called(1)
+ assert.spy(ctx.spawn.luarocks).was_called_with {
+ "install",
+ { "--tree", ctx.cwd:get() },
+ vim.NIL, -- dev
+ "--server=https://luarocks.org/dev",
+ { "my-rock", "1.0.0" },
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/npm_spec.lua b/tests/mason-core/installer/managers/npm_spec.lua
new file mode 100644
index 00000000..655acb64
--- /dev/null
+++ b/tests/mason-core/installer/managers/npm_spec.lua
@@ -0,0 +1,53 @@
+local installer = require "mason-core.installer"
+local stub = require "luassert.stub"
+local match = require "luassert.match"
+local npm = require "mason-core.installer.managers.npm"
+
+describe("npm manager", function()
+ it("should init package.json", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "append_file")
+ installer.exec_in_context(ctx, function()
+ npm.init()
+ end)
+
+ assert.spy(ctx.spawn.npm).was_called(1)
+ assert.spy(ctx.spawn.npm).was_called_with {
+ "init",
+ "--yes",
+ "--scope=mason",
+ }
+ assert.spy(ctx.fs.append_file).was_called(1)
+ assert.spy(ctx.fs.append_file).was_called_with(match.is_ref(ctx.fs), ".npmrc", "global-style=true")
+ end)
+
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ npm.install("my-package", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.npm).was_called(1)
+ assert.spy(ctx.spawn.npm).was_called_with {
+ "install",
+ "my-package@1.0.0",
+ vim.NIL, -- extra_packages
+ }
+ end)
+
+ it("should install extra packages", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ npm.install("my-package", "1.0.0", {
+ extra_packages = { "extra-package" },
+ })
+ end)
+
+ assert.spy(ctx.spawn.npm).was_called(1)
+ assert.spy(ctx.spawn.npm).was_called_with {
+ "install",
+ "my-package@1.0.0",
+ { "extra-package" },
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/nuget_spec.lua b/tests/mason-core/installer/managers/nuget_spec.lua
new file mode 100644
index 00000000..1bdecf37
--- /dev/null
+++ b/tests/mason-core/installer/managers/nuget_spec.lua
@@ -0,0 +1,21 @@
+local installer = require "mason-core.installer"
+local nuget = require "mason-core.installer.managers.nuget"
+
+describe("nuget manager", function()
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ nuget.install("nuget-package", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.dotnet).was_called(1)
+ assert.spy(ctx.spawn.dotnet).was_called_with {
+ "tool",
+ "update",
+ "--tool-path",
+ ".",
+ { "--version", "1.0.0" },
+ "nuget-package",
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/opam_spec.lua b/tests/mason-core/installer/managers/opam_spec.lua
new file mode 100644
index 00000000..c1fe59f6
--- /dev/null
+++ b/tests/mason-core/installer/managers/opam_spec.lua
@@ -0,0 +1,20 @@
+local installer = require "mason-core.installer"
+local opam = require "mason-core.installer.managers.opam"
+
+describe("opam manager", function()
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ opam.install("opam-package", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn.opam).was_called(1)
+ assert.spy(ctx.spawn.opam).was_called_with {
+ "install",
+ "--destdir=.",
+ "--yes",
+ "--verbose",
+ "opam-package.1.0.0",
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/pypi_spec.lua b/tests/mason-core/installer/managers/pypi_spec.lua
new file mode 100644
index 00000000..f0a6cdc6
--- /dev/null
+++ b/tests/mason-core/installer/managers/pypi_spec.lua
@@ -0,0 +1,126 @@
+local installer = require "mason-core.installer"
+local stub = require "luassert.stub"
+local path = require "mason-core.path"
+local pypi = require "mason-core.installer.managers.pypi"
+
+---@param ctx InstallContext
+local function venv_py(ctx)
+ return path.concat {
+ ctx.cwd:get(),
+ "venv",
+ "bin",
+ "python",
+ }
+end
+
+describe("pypi manager", function()
+ it("should init venv without upgrading pip", function()
+ local ctx = create_dummy_context()
+ stub(ctx, "promote_cwd")
+ installer.exec_in_context(ctx, function()
+ pypi.init { upgrade_pip = false }
+ end)
+
+ assert.spy(ctx.promote_cwd).was_called(1)
+ assert.spy(ctx.spawn.python3).was_called(1)
+ assert.spy(ctx.spawn.python3).was_called_with {
+ "-m",
+ "venv",
+ "venv",
+ }
+ end)
+
+ it("should init venv and upgrade pip", function()
+ local ctx = create_dummy_context()
+ stub(ctx, "promote_cwd")
+ installer.exec_in_context(ctx, function()
+ pypi.init { upgrade_pip = true, install_extra_args = { "--proxy", "http://localhost" } }
+ end)
+
+ assert.spy(ctx.promote_cwd).was_called(1)
+ assert.spy(ctx.spawn.python3).was_called(1)
+ assert.spy(ctx.spawn.python3).was_called_with {
+ "-m",
+ "venv",
+ "venv",
+ }
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1)
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ { "--proxy", "http://localhost" },
+ { "pip" },
+ }
+ end)
+
+ it("should install", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ pypi.install("pypi-package", "1.0.0")
+ end)
+
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1)
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ vim.NIL, -- install_extra_args
+ {
+ "pypi-package==1.0.0",
+ vim.NIL, -- extra_packages
+ },
+ }
+ end)
+
+ it("should install extra specifier", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ pypi.install("pypi-package", "1.0.0", {
+ extra = "lsp",
+ })
+ end)
+
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1)
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ vim.NIL, -- install_extra_args
+ {
+ "pypi-package[lsp]==1.0.0",
+ vim.NIL, -- extra_packages
+ },
+ }
+ end)
+
+ it("should install extra packages", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ pypi.install("pypi-package", "1.0.0", {
+ extra_packages = { "extra-package" },
+ install_extra_args = { "--proxy", "http://localhost:9000" },
+ })
+ end)
+
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called(1)
+ assert.spy(ctx.spawn[venv_py(ctx)]).was_called_with {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ { "--proxy", "http://localhost:9000" },
+ {
+ "pypi-package==1.0.0",
+ { "extra-package" },
+ },
+ }
+ end)
+end)
diff --git a/tests/mason-core/installer/managers/std_spec.lua b/tests/mason-core/installer/managers/std_spec.lua
new file mode 100644
index 00000000..feff9abe
--- /dev/null
+++ b/tests/mason-core/installer/managers/std_spec.lua
@@ -0,0 +1,150 @@
+local installer = require "mason-core.installer"
+local stub = require "luassert.stub"
+local match = require "luassert.match"
+local std = require "mason-core.installer.managers.std"
+
+describe("std unpack [Unix]", function()
+ it("should unpack .gz", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.gz"
+ end)
+
+ assert.spy(ctx.spawn.gzip).was_called(1)
+ assert.spy(ctx.spawn.gzip).was_called_with { "-d", "file.gz" }
+ end)
+
+ it("should unpack .tar", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.tar"
+ end)
+
+ assert.spy(ctx.spawn.tar).was_called(1)
+ assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar")
+ end)
+
+ it("should unpack .tar.bz2", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.tar.bz2"
+ end)
+
+ assert.spy(ctx.spawn.tar).was_called(1)
+ assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.bz2" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.bz2")
+ end)
+
+ it("should unpack .tar.gz", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.tar.gz"
+ end)
+
+ assert.spy(ctx.spawn.tar).was_called(1)
+ assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.gz" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.gz")
+ end)
+
+ it("should unpack .tar.xz", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.tar.xz"
+ end)
+
+ assert.spy(ctx.spawn.tar).was_called(1)
+ assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.xz" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.xz")
+ end)
+
+ it("should unpack .tar.zst", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.tar.zst"
+ end)
+
+ assert.spy(ctx.spawn.tar).was_called(1)
+ assert.spy(ctx.spawn.tar).was_called_with { "--no-same-owner", "-xvf", "file.tar.zst" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.tar.zst")
+ end)
+
+ it("should unpack .vsix", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.vsix"
+ end)
+
+ assert.spy(ctx.spawn.unzip).was_called(1)
+ assert.spy(ctx.spawn.unzip).was_called_with { "-d", ".", "file.vsix" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.vsix")
+ end)
+
+ it("should unpack .zip", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "unlink")
+ installer.exec_in_context(ctx, function()
+ std.unpack "file.zip"
+ end)
+
+ assert.spy(ctx.spawn.unzip).was_called(1)
+ assert.spy(ctx.spawn.unzip).was_called_with { "-d", ".", "file.zip" }
+ assert.spy(ctx.fs.unlink).was_called(1)
+ assert.spy(ctx.fs.unlink).was_called_with(match.is_ref(ctx.fs), "file.zip")
+ end)
+end)
+
+describe("std clone", function()
+ it("should clone", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ std.clone "https://github.com/williamboman/mason.nvim"
+ end)
+
+ assert.spy(ctx.spawn.git).was_called(1)
+ assert.spy(ctx.spawn.git).was_called_with {
+ "clone",
+ "--depth",
+ "1",
+ vim.NIL, -- recursive
+ "https://github.com/williamboman/mason.nvim",
+ ".",
+ }
+ end)
+
+ it("should clone and checkout rev", function()
+ local ctx = create_dummy_context()
+ installer.exec_in_context(ctx, function()
+ std.clone("https://github.com/williamboman/mason.nvim", {
+ rev = "e1fd03b1856cb5ad8425f49e18353dc524b02f91",
+ recursive = true,
+ })
+ end)
+
+ assert.spy(ctx.spawn.git).was_called(3)
+ assert.spy(ctx.spawn.git).was_called_with {
+ "clone",
+ "--depth",
+ "1",
+ "--recursive",
+ "https://github.com/williamboman/mason.nvim",
+ ".",
+ }
+ assert
+ .spy(ctx.spawn.git)
+ .was_called_with { "fetch", "--depth", "1", "origin", "e1fd03b1856cb5ad8425f49e18353dc524b02f91" }
+ assert.spy(ctx.spawn.git).was_called_with { "checkout", "FETCH_HEAD" }
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/installer_spec.lua b/tests/mason-core/installer/registry/installer_spec.lua
new file mode 100644
index 00000000..e6ca6f91
--- /dev/null
+++ b/tests/mason-core/installer/registry/installer_spec.lua
@@ -0,0 +1,203 @@
+local match = require "luassert.match"
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local installer = require "mason-core.installer.registry"
+local util = require "mason-core.installer.registry.util"
+
+---@type InstallerProvider
+local dummy_provider = {
+ ---@param source RegistryPackageSource
+ ---@param purl Purl
+ ---@param opts PackageInstallOpts
+ parse = function(source, purl, opts)
+ return Result.try(function(try)
+ if source.supported_platforms then
+ try(util.ensure_valid_platform(source.supported_platforms))
+ end
+ return {
+ package = purl.name,
+ extra_info = source.extra_info,
+ should_fail = source.should_fail,
+ }
+ end)
+ end,
+ install = function(ctx, source)
+ if source.should_fail then
+ return Result.failure "This is a failure."
+ else
+ return Result.success()
+ end
+ end,
+}
+
+describe("registry installer :: parsing", function()
+ it("should parse valid package specs", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.parse({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ extra_info = "here",
+ },
+ }, {})
+ local parsed = result:get_or_nil()
+
+ assert.is_true(result:is_success())
+ assert.is_true(match.is_ref(dummy_provider)(parsed.provider))
+ assert.same({
+ name = "package-name",
+ scheme = "pkg",
+ type = "dummy",
+ version = "v1.2.3",
+ }, parsed.purl)
+ assert.same({
+ package = "package-name",
+ extra_info = "here",
+ }, parsed.source)
+ end)
+
+ it("should reject incompatible schema versions", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.parse({
+ schema = "registry+v1337",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ },
+ }, {})
+ assert.same(
+ Result.failure [[Current version of mason.nvim is not capable of parsing package schema version "registry+v1337".]],
+ result
+ )
+ end)
+
+ it("should use requested version", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.parse({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ },
+ }, { version = "v2.0.0" })
+
+ assert.is_true(result:is_success())
+ local parsed = result:get_or_nil()
+
+ assert.same({
+ name = "package-name",
+ scheme = "pkg",
+ type = "dummy",
+ version = "v2.0.0",
+ }, parsed.purl)
+ end)
+
+ it("should handle PLATFORM_UNSUPPORTED", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.compile({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ supported_platforms = { "VIC64" },
+ },
+ }, { version = "v2.0.0" })
+
+ assert.same(Result.failure "The current platform is unsupported.", result)
+ end)
+
+ it("should error upon parsing failures", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.compile({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ supported_platforms = { "VIC64" },
+ },
+ }, { version = "v2.0.0" })
+
+ assert.same(Result.failure "The current platform is unsupported.", result)
+ end)
+end)
+
+describe("registry installer :: compiling", function()
+ it("should run compiled installer function successfully", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.compile({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ },
+ }, {})
+
+ assert.is_true(result:is_success())
+ local installer_fn = result:get_or_nil()
+
+ local ctx = create_dummy_context()
+ local installer_result = require("mason-core.installer").exec_in_context(ctx, installer_fn)
+ assert.same(Result.success(), installer_result)
+ end)
+
+ it("should raise errors upon installer failures", function()
+ installer.register_provider("dummy", dummy_provider)
+
+ local result = installer.compile({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ should_fail = true,
+ },
+ }, {})
+
+ assert.is_true(result:is_success())
+ local installer_fn = result:get_or_nil()
+
+ local ctx = create_dummy_context()
+ local err = assert.has_error(function()
+ require("mason-core.installer").exec_in_context(ctx, installer_fn)
+ end)
+ assert.equals("This is a failure.", err)
+ end)
+
+ it("should register links", function()
+ installer.register_provider("dummy", dummy_provider)
+ local link = require "mason-core.installer.registry.link"
+ stub(link, "bin", mockx.returns(Result.success()))
+ stub(link, "share", mockx.returns(Result.success()))
+ stub(link, "opt", mockx.returns(Result.success()))
+
+ local spec = {
+ schema = "registry+v1",
+ source = {
+ id = "pkg:dummy/package-name@v1.2.3",
+ },
+ bin = { ["exec"] = "exec" },
+ opt = { ["opt/"] = "opt/" },
+ share = { ["share/"] = "share/" },
+ }
+
+ local result = installer.compile(spec, {})
+
+ assert.is_true(result:is_success())
+ local installer_fn = result:get_or_nil()
+
+ local ctx = create_dummy_context()
+ local installer_result = require("mason-core.installer").exec_in_context(ctx, installer_fn)
+ assert.is_true(installer_result:is_success())
+
+ for _, spy in ipairs { link.bin, link.share, link.opt } do
+ assert.spy(spy).was_called(1)
+ assert.spy(spy).was_called_with(match.is_ref(ctx), spec, {
+ scheme = "pkg",
+ type = "dummy",
+ name = "package-name",
+ version = "v1.2.3",
+ }, {
+ package = "package-name",
+ })
+ end
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/link_spec.lua b/tests/mason-core/installer/registry/link_spec.lua
new file mode 100644
index 00000000..eef66349
--- /dev/null
+++ b/tests/mason-core/installer/registry/link_spec.lua
@@ -0,0 +1,231 @@
+local stub = require "luassert.stub"
+local match = require "luassert.match"
+local Result = require "mason-core.result"
+local Purl = require "mason-core.purl"
+local link = require "mason-core.installer.registry.link"
+local fs = require "mason-core.fs"
+local path = require "mason-core.path"
+
+describe("registry linker", function()
+ it("should expand bin table", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "file_exists")
+ stub(ctx.fs, "chmod")
+ stub(ctx.fs, "fstat")
+
+ ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns(true)
+ ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns {
+ mode = 493, -- 0755
+ }
+
+ local result = link.bin(
+ ctx,
+ {
+ bin = {
+ ["exec"] = "exec.sh",
+ },
+ },
+ Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(),
+ {
+ metadata = "value",
+ }
+ )
+
+ assert.same(
+ Result.success {
+ ["exec"] = "exec.sh",
+ },
+ result
+ )
+ assert.same({
+ ["exec"] = "exec.sh",
+ }, ctx.links.bin)
+
+ assert.spy(ctx.fs.chmod).was_not_called()
+ end)
+
+ it("should chmod executable if necessary", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "file_exists")
+ stub(ctx.fs, "chmod")
+ stub(ctx.fs, "fstat")
+
+ ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns(true)
+ ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), "exec.sh").returns {
+ mode = 420, -- 0644
+ }
+
+ local result = link.bin(
+ ctx,
+ {
+ bin = {
+ ["exec"] = "exec.sh",
+ },
+ },
+ Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(),
+ {
+ metadata = "value",
+ }
+ )
+
+ assert.is_true(result:is_success())
+ assert.spy(ctx.fs.chmod).was_called(1)
+ assert.spy(ctx.fs.chmod).was_called_with(match.is_ref(ctx.fs), "exec.sh", 493)
+ end)
+
+ it("should interpolate bin table", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "file_exists")
+ stub(ctx.fs, "chmod")
+ stub(ctx.fs, "fstat")
+
+ ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns(true)
+ ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns {
+ mode = 493, -- 0755
+ }
+
+ local result = link.bin(
+ ctx,
+ {
+ bin = {
+ ["exec"] = "{{version}}-{{source.script}}",
+ },
+ },
+ Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(),
+ {
+ script = "exec.sh",
+ }
+ )
+
+ assert.same(
+ Result.success {
+ ["exec"] = "v1.0.0-exec.sh",
+ },
+ result
+ )
+ end)
+
+ it("should delegate bin paths", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "file_exists")
+ stub(ctx.fs, "chmod")
+ stub(ctx.fs, "fstat")
+
+ local matrix = {
+ ["cargo:executable"] = "bin/executable",
+ ["composer:executable"] = "vendor/bin/executable",
+ ["golang:executable"] = "executable",
+ ["luarocks:executable"] = "bin/executable",
+ ["npm:executable"] = "node_modules/.bin/executable",
+ ["nuget:executable"] = "executable",
+ ["opam:executable"] = "bin/executable",
+ ["pypi:executable"] = "venv/bin/executable",
+ }
+
+ for bin, path in pairs(matrix) do
+ ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), path).returns(true)
+ ctx.fs.fstat.on_call_with(match.is_ref(ctx.fs), path).returns {
+ mode = 493, -- 0755
+ }
+
+ local result = link.bin(ctx, {
+ bin = {
+ ["executable"] = bin,
+ },
+ }, Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(), {})
+
+ assert.same(
+ Result.success {
+ ["executable"] = path,
+ },
+ result
+ )
+ end
+ end)
+
+ it("should register share links", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "file_exists")
+ stub(fs.sync, "file_exists")
+ stub(vim.fn, "glob")
+
+ vim.fn.glob.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0/dir/" } .. "**/*", false, true).returns {
+ path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" },
+ path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" },
+ path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" },
+ }
+ fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" }).returns(true)
+ fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" }).returns(true)
+ fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" }).returns(true)
+
+ ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns(true)
+
+ local result = link.share(
+ ctx,
+ {
+ share = {
+ ["file"] = "{{version}}-{{source.file}}",
+ ["dir/"] = "{{version}}/dir/",
+ ["empty/"] = "{{source.empty}}",
+ },
+ },
+ Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(),
+ {
+ file = "file",
+ }
+ )
+
+ assert.same(
+ Result.success {
+ ["file"] = "v1.0.0-file",
+ ["dir/file1"] = "v1.0.0/dir/file1",
+ ["dir/file2"] = "v1.0.0/dir/file2",
+ ["dir/file3"] = "v1.0.0/dir/file3",
+ },
+ result
+ )
+ end)
+
+ it("should register opt links", function()
+ local ctx = create_dummy_context()
+ stub(ctx.fs, "file_exists")
+ stub(fs.sync, "file_exists")
+ stub(vim.fn, "glob")
+
+ vim.fn.glob.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0/dir/" } .. "**/*", false, true).returns {
+ path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" },
+ path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" },
+ path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" },
+ }
+ fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file1" }).returns(true)
+ fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file2" }).returns(true)
+ fs.sync.file_exists.on_call_with(path.concat { ctx.cwd:get(), "v1.0.0", "dir", "file3" }).returns(true)
+
+ ctx.fs.file_exists.on_call_with(match.is_ref(ctx.fs), "v1.0.0-exec.sh").returns(true)
+
+ local result = link.opt(
+ ctx,
+ {
+ opt = {
+ ["file"] = "{{version}}-{{source.file}}",
+ ["dir/"] = "{{version}}/dir/",
+ ["empty/"] = "{{source.empty}}",
+ },
+ },
+ Purl.parse("pkg:dummy/package@v1.0.0"):get_or_throw(),
+ {
+ file = "file",
+ }
+ )
+
+ assert.same(
+ Result.success {
+ ["file"] = "v1.0.0-file",
+ ["dir/file1"] = "v1.0.0/dir/file1",
+ ["dir/file2"] = "v1.0.0/dir/file2",
+ ["dir/file3"] = "v1.0.0/dir/file3",
+ },
+ result
+ )
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/cargo_spec.lua b/tests/mason-core/installer/registry/providers/cargo_spec.lua
new file mode 100644
index 00000000..f2ae679c
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/cargo_spec.lua
@@ -0,0 +1,143 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local cargo = require "mason-core.installer.registry.providers.cargo"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:cargo/crate-name@1.4.3"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("cargo provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ crate = "crate-name",
+ version = "1.4.3",
+ features = nil,
+ locked = true,
+ git = nil,
+ },
+ cargo.parse({}, purl())
+ )
+ end)
+
+ it("should respect repository_url qualifier", function()
+ assert.same(
+ Result.success {
+ crate = "crate-name",
+ version = "1.4.3",
+ features = nil,
+ locked = true,
+ git = { url = "https://github.com/crate-org/crate-name", rev = false },
+ },
+ cargo.parse({}, purl { qualifiers = { repository_url = "https://github.com/crate-org/crate-name" } })
+ )
+ end)
+
+ it("should respect repository_url qualifier with rev=true qualifier", function()
+ assert.same(
+ Result.success {
+ crate = "crate-name",
+ version = "1.4.3",
+ features = nil,
+ locked = true,
+ git = { url = "https://github.com/crate-org/crate-name", rev = true },
+ },
+ cargo.parse(
+ {},
+ purl { qualifiers = { repository_url = "https://github.com/crate-org/crate-name", rev = "true" } }
+ )
+ )
+ end)
+
+ it("should respect features qualifier", function()
+ assert.same(
+ Result.success {
+ crate = "crate-name",
+ version = "1.4.3",
+ features = "lsp,cli",
+ locked = true,
+ git = nil,
+ },
+ cargo.parse({}, purl { qualifiers = { features = "lsp,cli" } })
+ )
+ end)
+
+ it("should respect locked qualifier", function()
+ assert.same(
+ Result.success {
+ crate = "crate-name",
+ version = "1.4.3",
+ features = nil,
+ locked = false,
+ git = nil,
+ },
+ cargo.parse({}, purl { qualifiers = { locked = "false" } })
+ )
+ end)
+
+ it("should check supported platforms", function()
+ assert.same(
+ Result.failure "PLATFORM_UNSUPPORTED",
+ cargo.parse({
+ supported_platforms = { "VIC64" },
+ }, purl { qualifiers = { locked = "false" } })
+ )
+ end)
+end)
+
+describe("cargo provider :: installing", function()
+ it("should install cargo packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.cargo"
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return cargo.install(ctx, {
+ crate = "crate-name",
+ version = "1.2.0",
+ features = nil,
+ locked = true,
+ git = nil,
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("crate-name", "1.2.0", {
+ git = nil,
+ features = nil,
+ locked = true,
+ })
+ end)
+
+ it("should ensure valid version", function()
+ local ctx = create_dummy_context {
+ version = "1.10.0",
+ }
+ local manager = require "mason-core.installer.managers.cargo"
+ local providers = require "mason-core.providers"
+ stub(providers.crates, "get_all_versions", mockx.returns(Result.success { "1.0.0" }))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return cargo.install(ctx, {
+ crate = "crate-name",
+ version = "1.10.0",
+ features = nil,
+ locked = true,
+ git = nil,
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.10.0" is not available.]], result)
+ assert.spy(manager.install).was_called(0)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/composer_spec.lua b/tests/mason-core/installer/registry/providers/composer_spec.lua
new file mode 100644
index 00000000..4849e3f7
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/composer_spec.lua
@@ -0,0 +1,66 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local composer = require "mason-core.installer.registry.providers.composer"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:composer/vendor/package@2.0.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("composer provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "vendor/package",
+ version = "2.0.0",
+ },
+ composer.parse({}, purl())
+ )
+ end)
+end)
+
+describe("composer provider :: installing", function()
+ it("should install composer packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.composer"
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return composer.install(ctx, {
+ package = "vendor/package",
+ version = "1.2.0",
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("vendor/package", "1.2.0")
+ end)
+
+ it("should ensure valid version", function()
+ local ctx = create_dummy_context {
+ version = "1.10.0",
+ }
+ local manager = require "mason-core.installer.managers.composer"
+ local providers = require "mason-core.providers"
+ stub(providers.packagist, "get_all_versions", mockx.returns(Result.success { "1.0.0" }))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return composer.install(ctx, {
+ package = "vendor/package",
+ version = "1.10.0",
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.10.0" is not available.]], result)
+ assert.spy(manager.install).was_called(0)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/gem_spec.lua b/tests/mason-core/installer/registry/providers/gem_spec.lua
new file mode 100644
index 00000000..9696ec6e
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/gem_spec.lua
@@ -0,0 +1,72 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local gem = require "mason-core.installer.registry.providers.gem"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:gem/package@1.2.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("gem provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "package",
+ version = "1.2.0",
+ extra_packages = { "extra" },
+ },
+ gem.parse({ extra_packages = { "extra" } }, purl())
+ )
+ end)
+
+ it("should check supported platforms", function()
+ assert.same(Result.failure "PLATFORM_UNSUPPORTED", gem.parse({ supported_platforms = { "VIC64" } }, purl()))
+ end)
+end)
+
+describe("gem provider :: installing", function()
+ it("should install gem packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.gem"
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return gem.install(ctx, {
+ package = "package",
+ version = "5.2.0",
+ extra_packages = { "extra" },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("package", "5.2.0", { extra_packages = { "extra" } })
+ end)
+
+ it("should ensure valid version", function()
+ local ctx = create_dummy_context {
+ version = "1.10.0",
+ }
+ local manager = require "mason-core.installer.managers.gem"
+ local providers = require "mason-core.providers"
+ stub(providers.rubygems, "get_all_versions", mockx.returns(Result.success { "1.0.0" }))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return gem.install(ctx, {
+ package = "package",
+ version = "1.10.0",
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.10.0" is not available.]], result)
+ assert.spy(manager.install).was_called(0)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/generic_spec.lua b/tests/mason-core/installer/registry/providers/generic_spec.lua
new file mode 100644
index 00000000..899709c5
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/generic_spec.lua
@@ -0,0 +1,100 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local generic = require "mason-core.installer.registry.providers.generic"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:generic/namespace/name@v1.2.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("generic provider :: parsing", function()
+ it("should parse single download target", function()
+ assert.same(
+ Result.success {
+ download = {
+ files = {
+ ["name.tar.gz"] = [[https://getpackage.org/downloads/1.2.0/name.tar.gz]],
+ },
+ },
+ },
+ generic.parse({
+ download = {
+ files = {
+ ["name.tar.gz"] = [[https://getpackage.org/downloads/{{version | strip_prefix "v"}}/name.tar.gz]],
+ },
+ },
+ }, purl())
+ )
+ end)
+
+ it("should coalesce download target", function()
+ assert.same(
+ Result.success {
+ download = {
+ target = "linux_arm64",
+ files = {
+ ["name.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/1.2.0/name.tar.gz]],
+ },
+ },
+ },
+ generic.parse({
+ download = {
+ {
+ target = "linux_arm64",
+ files = {
+ ["name.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/{{version | strip_prefix "v"}}/name.tar.gz]],
+ },
+ },
+ {
+ target = "win_arm64",
+ files = {
+ ["name.tar.gz"] = [[https://getpackage.org/downloads/win-aarch64/{{version | strip_prefix "v"}}/name.tar.gz]],
+ },
+ },
+ },
+ }, purl(), { target = "linux_arm64" })
+ )
+ end)
+
+ it("should check supported platforms", function()
+ assert.same(Result.failure "PLATFORM_UNSUPPORTED", generic.parse({ supported_platforms = { "VIC64" } }, purl()))
+ end)
+end)
+
+describe("generic provider :: installing", function()
+ it("should install generic packages", function()
+ local ctx = create_dummy_context()
+ local std = require "mason-core.installer.managers.std"
+ stub(std, "download_file", mockx.returns(Result.success()))
+ stub(std, "unpack", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return generic.install(ctx, {
+ download = {
+ files = {
+ ["name.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/1.2.0/name.tar.gz]],
+ ["archive.tar.gz"] = [[https://getpackage.org/downloads/linux-aarch64/1.2.0/archive.tar.gz]],
+ },
+ },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(std.download_file).was_called(2)
+ assert
+ .spy(std.download_file)
+ .was_called_with("https://getpackage.org/downloads/linux-aarch64/1.2.0/name.tar.gz", "name.tar.gz")
+ assert
+ .spy(std.download_file)
+ .was_called_with("https://getpackage.org/downloads/linux-aarch64/1.2.0/archive.tar.gz", "archive.tar.gz")
+ assert.spy(std.unpack).was_called(2)
+ assert.spy(std.unpack).was_called_with "name.tar.gz"
+ assert.spy(std.unpack).was_called_with "archive.tar.gz"
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/github_spec.lua b/tests/mason-core/installer/registry/providers/github_spec.lua
new file mode 100644
index 00000000..0c43e3cf
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/github_spec.lua
@@ -0,0 +1,355 @@
+local stub = require "luassert.stub"
+local mock = require "luassert.mock"
+local spy = require "luassert.spy"
+local match = require "luassert.match"
+local Result = require "mason-core.result"
+local github = require "mason-core.installer.registry.providers.github"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+local registry_installer = require "mason-core.installer.registry"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:github/namespace/name@2023-03-09"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("github provider :: parsing", function()
+ it("should parse release asset source", function()
+ assert.same(
+ Result.success {
+ repo = "namespace/name",
+ asset = {
+ file = "file-2023-03-09.jar",
+ },
+ downloads = {
+ {
+ out_file = "file-2023-03-09.jar",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-2023-03-09.jar",
+ },
+ },
+ },
+ github.parse({
+ asset = {
+ file = "file-{{version}}.jar",
+ },
+ }, purl())
+ )
+ end)
+
+ it("should parse release asset source with multiple targets", function()
+ assert.same(
+ Result.success {
+ repo = "namespace/name",
+ asset = {
+ target = "linux_x64",
+ file = "file-linux-amd64-2023-03-09.tar.gz",
+ },
+ downloads = {
+ {
+ out_file = "file-linux-amd64-2023-03-09.tar.gz",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz",
+ },
+ },
+ },
+ github.parse({
+ asset = {
+ {
+ target = "win_arm",
+ file = "file-win-arm-{{version}}.zip",
+ },
+ {
+ target = "linux_x64",
+ file = "file-linux-amd64-{{version}}.tar.gz",
+ },
+ },
+ }, purl(), { target = "linux_x64" })
+ )
+ end)
+
+ it("should parse release asset source with output to different directory", function()
+ assert.same(
+ Result.success {
+ repo = "namespace/name",
+ asset = {
+ file = "file-linux-amd64-2023-03-09.tar.gz:out-dir/",
+ },
+ downloads = {
+ {
+ out_file = "out-dir/file-linux-amd64-2023-03-09.tar.gz",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz",
+ },
+ },
+ },
+ github.parse({
+ asset = {
+ file = "file-linux-amd64-{{version}}.tar.gz:out-dir/",
+ },
+ }, purl(), { target = "linux_x64" })
+ )
+ end)
+
+ it("should parse build source", function()
+ assert.same(
+ Result.success {
+ build = { run = [[npm install && npm run compile]] },
+ repo = "https://github.com/namespace/name.git",
+ rev = "2023-03-09",
+ },
+ github.parse({
+ build = {
+ run = [[npm install && npm run compile]],
+ },
+ }, purl())
+ )
+ end)
+
+ it("should parse build source with multiple targets", function()
+ assert.same(
+ Result.success {
+ build = { target = "win_x64", run = [[npm install]] },
+ repo = "https://github.com/namespace/name.git",
+ rev = "2023-03-09",
+ },
+ github.parse({
+ build = {
+ {
+ target = "linux_arm64",
+ run = [[npm install && npm run compile]],
+ },
+ {
+ target = "win_x64",
+ run = [[npm install]],
+ },
+ },
+ }, purl(), { target = "win_x64" })
+ )
+ end)
+
+ it("should upsert version overrides", function()
+ local result = registry_installer.parse({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:github/owner/repo@1.2.3",
+ asset = {
+ {
+ target = "darwin_x64",
+ file = "asset.tar.gz",
+ },
+ },
+ version_overrides = {
+ {
+ constraint = "semver:<=1.0.0",
+ asset = {
+ {
+ target = "darwin_x64",
+ file = "old-asset.tar.gz",
+ },
+ },
+ },
+ },
+ },
+ }, { version = "1.0.0", target = "darwin_x64" })
+ local parsed = result:get_or_nil()
+
+ assert.is_true(result:is_success())
+ assert.same({
+ asset = {
+ target = "darwin_x64",
+ file = "old-asset.tar.gz",
+ },
+ downloads = {
+ {
+ download_url = "https://github.com/owner/repo/releases/download/1.0.0/old-asset.tar.gz",
+ out_file = "old-asset.tar.gz",
+ },
+ },
+ repo = "owner/repo",
+ }, parsed.source)
+ end)
+
+ it("should override source if version override provides its own purl id", function()
+ local result = registry_installer.parse({
+ schema = "registry+v1",
+ source = {
+ id = "pkg:github/owner/repo@1.2.3",
+ asset = {
+ file = "asset.tar.gz",
+ },
+ version_overrides = {
+ {
+ constraint = "semver:<=1.0.0",
+ id = "pkg:npm/old-package",
+ },
+ },
+ },
+ }, { version = "1.0.0", target = "darwin_x64" })
+
+ assert.is_true(result:is_success())
+ local parsed = result:get_or_throw()
+ assert.same({
+ type = "npm",
+ scheme = "pkg",
+ name = "old-package",
+ version = "1.0.0",
+ }, parsed.purl)
+ end)
+end)
+
+describe("github provider :: installing", function()
+ it("should install github release assets", function()
+ local ctx = create_dummy_context()
+ local std = require "mason-core.installer.managers.std"
+ stub(std, "download_file", mockx.returns(Result.success()))
+ stub(std, "unpack", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return github.install(ctx, {
+ repo = "namespace/name",
+ asset = {
+ file = "file-linux-amd64-2023-03-09.tar.gz",
+ },
+ downloads = {
+ {
+ out_file = "file-linux-amd64-2023-03-09.tar.gz",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz",
+ },
+ {
+ out_file = "another-file-linux-amd64-2023-03-09.tar.gz",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/another-file-linux-amd64-2023-03-09.tar.gz",
+ },
+ },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(std.download_file).was_called(2)
+ assert.spy(std.download_file).was_called_with(
+ "https://github.com/namespace/name/releases/download/2023-03-09/file-linux-amd64-2023-03-09.tar.gz",
+ "file-linux-amd64-2023-03-09.tar.gz"
+ )
+ assert.spy(std.download_file).was_called_with(
+ "https://github.com/namespace/name/releases/download/2023-03-09/another-file-linux-amd64-2023-03-09.tar.gz",
+ "another-file-linux-amd64-2023-03-09.tar.gz"
+ )
+ assert.spy(std.unpack).was_called(2)
+ assert.spy(std.unpack).was_called_with "file-linux-amd64-2023-03-09.tar.gz"
+ assert.spy(std.unpack).was_called_with "another-file-linux-amd64-2023-03-09.tar.gz"
+ end)
+
+ it("should install github release assets into specified output directory", function()
+ local ctx = create_dummy_context()
+ local std = require "mason-core.installer.managers.std"
+ local download_file_cwd, unpack_cwd
+ stub(std, "download_file", function()
+ download_file_cwd = ctx.cwd:get()
+ return Result.success()
+ end)
+ stub(std, "unpack", function()
+ unpack_cwd = ctx.cwd:get()
+ return Result.success()
+ end)
+ stub(ctx.fs, "mkdirp")
+ spy.on(ctx, "chdir")
+
+ local result = installer.exec_in_context(ctx, function()
+ return github.install(ctx, {
+ repo = "namespace/name",
+ asset = {
+ file = "file.zip",
+ },
+ downloads = {
+ {
+ out_file = "out/dir/file.zip",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file.zip",
+ },
+ },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(ctx.fs.mkdirp).was_called(1)
+ assert.spy(ctx.fs.mkdirp).was_called_with(match.is_ref(ctx.fs), "out/dir")
+ assert.spy(ctx.chdir).was_called(1)
+ assert.spy(ctx.chdir).was_called_with(match.is_ref(ctx), "out/dir", match.is_function())
+ assert.spy(std.download_file).was_called(1)
+ assert.is_true(match.matches "out/dir$"(download_file_cwd))
+ assert
+ .spy(std.download_file)
+ .was_called_with("https://github.com/namespace/name/releases/download/2023-03-09/file.zip", "file.zip")
+ assert.spy(std.unpack).was_called(1)
+ assert.is_true(match.matches "out/dir$"(unpack_cwd))
+ assert.spy(std.unpack).was_called_with "file.zip"
+ end)
+
+ it("should install ensure valid version when installing release asset", function()
+ local ctx = create_dummy_context {
+ version = "1.42.0",
+ }
+ local std = require "mason-core.installer.managers.std"
+ local providers = require "mason-core.providers"
+ stub(std, "download_file")
+ stub(providers.github, "get_all_release_versions", mockx.returns(Result.success { "2023-03-09" }))
+
+ local result = installer.exec_in_context(ctx, function()
+ return github.install(ctx, {
+ repo = "namespace/name",
+ asset = {
+ file = "file.zip",
+ },
+ downloads = {
+ {
+ out_file = "out/dir/file.zip",
+ download_url = "https://github.com/namespace/name/releases/download/2023-03-09/file.zip",
+ },
+ },
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.42.0" is not available.]], result)
+ assert.spy(std.download_file).was_called(0)
+ end)
+
+ it("should install github build sources", function()
+ local ctx = create_dummy_context()
+ local std = require "mason-core.installer.managers.std"
+ local uv = require "mason-core.async.uv"
+ stub(uv, "write")
+ stub(uv, "shutdown")
+ stub(uv, "close")
+ local stdin = mock.new()
+ stub(std, "clone", mockx.returns(Result.success()))
+ stub(
+ ctx.spawn,
+ "bash", ---@param args SpawnArgs
+ function(args)
+ args.on_spawn(mock.new(), { stdin })
+ return Result.success()
+ end
+ )
+
+ local result = installer.exec_in_context(ctx, function()
+ return github.install(ctx, {
+ repo = "namespace/name",
+ rev = "2023-03-09",
+ build = {
+ run = [[npm install && npm run compile]],
+ },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(std.clone).was_called(1)
+ assert.spy(std.clone).was_called_with("namespace/name", { rev = "2023-03-09" })
+ assert.spy(ctx.spawn.bash).was_called(1)
+ assert.spy(uv.write).was_called(2)
+ assert.spy(uv.write).was_called_with(stdin, "set -euxo pipefail;\n")
+ assert.spy(uv.write).was_called_with(stdin, "npm install && npm run compile")
+ assert.spy(uv.shutdown).was_called_with(stdin)
+ assert.spy(uv.close).was_called_with(stdin)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/golang_spec.lua b/tests/mason-core/installer/registry/providers/golang_spec.lua
new file mode 100644
index 00000000..d7b9f448
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/golang_spec.lua
@@ -0,0 +1,68 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local golang = require "mason-core.installer.registry.providers.golang"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:golang/namespace/package@v1.5.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("golang provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "namespace/package",
+ version = "v1.5.0",
+ extra_packages = { "extra" },
+ },
+ golang.parse({ extra_packages = { "extra" } }, purl())
+ )
+ end)
+end)
+
+describe("golang provider :: installing", function()
+ it("should install golang packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.golang"
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return golang.install(ctx, {
+ package = "namespace/package",
+ version = "v1.5.0",
+ extra_packages = { "extra" },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("namespace/package", "v1.5.0", { extra_packages = { "extra" } })
+ end)
+
+ it("should ensure valid version", function()
+ local ctx = create_dummy_context {
+ version = "1.10.0",
+ }
+ local manager = require "mason-core.installer.managers.golang"
+ local providers = require "mason-core.providers"
+ stub(providers.golang, "get_all_versions", mockx.returns(Result.success { "1.0.0" }))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return golang.install(ctx, {
+ package = "package",
+ version = "1.10.0",
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.10.0" is not available.]], result)
+ assert.spy(manager.install).was_called(0)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/luarocks_spec.lua b/tests/mason-core/installer/registry/providers/luarocks_spec.lua
new file mode 100644
index 00000000..992726eb
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/luarocks_spec.lua
@@ -0,0 +1,78 @@
+local stub = require "luassert.stub"
+local match = require "luassert.match"
+local Result = require "mason-core.result"
+local luarocks = require "mason-core.installer.registry.providers.luarocks"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:luarocks/namespace/name@1.0.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("luarocks provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "namespace/name",
+ version = "1.0.0",
+ server = nil,
+ dev = false,
+ },
+ luarocks.parse({}, purl())
+ )
+ end)
+
+ it("should parse package dev flag", function()
+ assert.same(
+ Result.success {
+ package = "namespace/name",
+ version = "1.0.0",
+ server = nil,
+ dev = true,
+ },
+ luarocks.parse({}, purl { qualifiers = { dev = "true" } })
+ )
+ end)
+
+ it("should parse package server flag", function()
+ assert.same(
+ Result.success {
+ package = "namespace/name",
+ version = "1.0.0",
+ server = "https://luarocks.org/dev",
+ dev = false,
+ },
+ luarocks.parse({}, purl { qualifiers = { repository_url = "https://luarocks.org/dev" } })
+ )
+ end)
+end)
+
+describe("luarocks provider :: installing", function()
+ it("should install luarocks packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.luarocks"
+ local ret_val = Result.success()
+ stub(manager, "install", mockx.returns(ret_val))
+
+ local result = installer.exec_in_context(ctx, function()
+ return luarocks.install(ctx, {
+ package = "namespace/name",
+ version = "1.0.0",
+ server = "https://luarocks.org/dev",
+ dev = false,
+ })
+ end)
+
+ assert.is_true(match.is_ref(ret_val)(result))
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("namespace/name", "1.0.0", {
+ dev = false,
+ server = "https://luarocks.org/dev",
+ })
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/npm_spec.lua b/tests/mason-core/installer/registry/providers/npm_spec.lua
new file mode 100644
index 00000000..cd20a07d
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/npm_spec.lua
@@ -0,0 +1,72 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local npm = require "mason-core.installer.registry.providers.npm"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:npm/%40namespace/package@v1.5.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("npm provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "@namespace/package",
+ version = "v1.5.0",
+ extra_packages = { "extra" },
+ },
+ npm.parse({ extra_packages = { "extra" } }, purl())
+ )
+ end)
+end)
+
+describe("npm provider :: installing", function()
+ it("should install npm packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.npm"
+ stub(manager, "init", mockx.returns(Result.success()))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return npm.install(ctx, {
+ package = "@namespace/package",
+ version = "v1.5.0",
+ extra_packages = { "extra" },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.init).was_called(1)
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("@namespace/package", "v1.5.0", { extra_packages = { "extra" } })
+ end)
+
+ it("should ensure valid version", function()
+ local ctx = create_dummy_context {
+ version = "1.10.0",
+ }
+ local manager = require "mason-core.installer.managers.npm"
+ local providers = require "mason-core.providers"
+ stub(providers.npm, "get_all_versions", mockx.returns(Result.success { "1.0.0" }))
+ stub(manager, "init", mockx.returns(Result.success()))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return npm.install(ctx, {
+ package = "package",
+ version = "1.10.0",
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.10.0" is not available.]], result)
+ assert.spy(manager.init).was_called(0)
+ assert.spy(manager.install).was_called(0)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/nuget_spec.lua b/tests/mason-core/installer/registry/providers/nuget_spec.lua
new file mode 100644
index 00000000..eac96251
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/nuget_spec.lua
@@ -0,0 +1,45 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local nuget = require "mason-core.installer.registry.providers.nuget"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:nuget/package@2.2.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("nuget provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "package",
+ version = "2.2.0",
+ },
+ nuget.parse({}, purl())
+ )
+ end)
+end)
+
+describe("nuget provider :: installing", function()
+ it("should install nuget packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.nuget"
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return nuget.install(ctx, {
+ package = "package",
+ version = "1.5.0",
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("package", "1.5.0")
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/opam_spec.lua b/tests/mason-core/installer/registry/providers/opam_spec.lua
new file mode 100644
index 00000000..53b5d767
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/opam_spec.lua
@@ -0,0 +1,45 @@
+local stub = require "luassert.stub"
+local Result = require "mason-core.result"
+local opam = require "mason-core.installer.registry.providers.opam"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:opam/package@2.2.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("opam provider :: parsing", function()
+ it("should parse package", function()
+ assert.same(
+ Result.success {
+ package = "package",
+ version = "2.2.0",
+ },
+ opam.parse({}, purl())
+ )
+ end)
+end)
+
+describe("opam provider :: installing", function()
+ it("should install opam packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.opam"
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return opam.install(ctx, {
+ package = "package",
+ version = "1.5.0",
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("package", "1.5.0")
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/providers/pypi_spec.lua b/tests/mason-core/installer/registry/providers/pypi_spec.lua
new file mode 100644
index 00000000..222b473e
--- /dev/null
+++ b/tests/mason-core/installer/registry/providers/pypi_spec.lua
@@ -0,0 +1,110 @@
+local stub = require "luassert.stub"
+local settings = require "mason.settings"
+local Result = require "mason-core.result"
+local pypi = require "mason-core.installer.registry.providers.pypi"
+local Purl = require "mason-core.purl"
+local installer = require "mason-core.installer"
+
+---@param overrides Purl
+local function purl(overrides)
+ local purl = Purl.parse("pkg:pypi/package@5.5.0"):get_or_throw()
+ if not overrides then
+ return purl
+ end
+ return vim.tbl_deep_extend("force", purl, overrides)
+end
+
+describe("pypi provider :: parsing", function()
+ it("should parse package", function()
+ settings.set {
+ pip = {
+ install_args = { "--proxy", "http://localghost" },
+ upgrade_pip = true,
+ },
+ }
+
+ assert.same(
+ Result.success {
+ package = "package",
+ version = "5.5.0",
+ extra_packages = { "extra" },
+ pip = {
+ upgrade = true,
+ extra_args = { "--proxy", "http://localghost" },
+ },
+ },
+ pypi.parse({ extra_packages = { "extra" } }, purl())
+ )
+ settings.set(settings._DEFAULT_SETTINGS)
+ end)
+
+ it("should check supported platforms", function()
+ assert.same(Result.failure "PLATFORM_UNSUPPORTED", pypi.parse({ supported_platforms = { "VIC64" } }, purl()))
+ end)
+end)
+
+describe("pypi provider :: installing", function()
+ it("should install pypi packages", function()
+ local ctx = create_dummy_context()
+ local manager = require "mason-core.installer.managers.pypi"
+ stub(manager, "init", mockx.returns(Result.success()))
+ stub(manager, "install", mockx.returns(Result.success()))
+ settings.set {
+ pip = {
+ install_args = { "--proxy", "http://localghost" },
+ upgrade_pip = true,
+ },
+ }
+
+ local result = installer.exec_in_context(ctx, function()
+ return pypi.install(ctx, {
+ package = "package",
+ extra = "lsp",
+ version = "1.5.0",
+ extra_packages = { "extra" },
+ pip = {
+ upgrade = true,
+ extra_args = { "--proxy", "http://localghost" },
+ },
+ })
+ end)
+
+ assert.is_true(result:is_success())
+ assert.spy(manager.init).was_called(1)
+ assert.spy(manager.init).was_called_with {
+ upgrade_pip = true,
+ install_extra_args = { "--proxy", "http://localghost" },
+ }
+ assert.spy(manager.install).was_called(1)
+ assert.spy(manager.install).was_called_with("package", "1.5.0", { extra = "lsp", extra_packages = { "extra" } })
+ settings.set(settings._DEFAULT_SETTINGS)
+ end)
+
+ it("should ensure valid version", function()
+ local ctx = create_dummy_context {
+ version = "1.10.0",
+ }
+ local manager = require "mason-core.installer.managers.pypi"
+ local providers = require "mason-core.providers"
+ stub(providers.pypi, "get_all_versions", mockx.returns(Result.success { "1.0.0" }))
+ stub(manager, "init", mockx.returns(Result.success()))
+ stub(manager, "install", mockx.returns(Result.success()))
+
+ local result = installer.exec_in_context(ctx, function()
+ return pypi.install(ctx, {
+ package = "package",
+ version = "1.5.0",
+ extra_packages = {},
+ pip = {
+ upgrade = true,
+ extra_args = { "--proxy", "http://localghost" },
+ },
+ })
+ end)
+
+ assert.is_true(result:is_failure())
+ assert.same(Result.failure [[Version "1.10.0" is not available.]], result)
+ assert.spy(manager.init).was_called(0)
+ assert.spy(manager.install).was_called(0)
+ end)
+end)
diff --git a/tests/mason-core/installer/registry/util_spec.lua b/tests/mason-core/installer/registry/util_spec.lua
new file mode 100644
index 00000000..d6cdd6b5
--- /dev/null
+++ b/tests/mason-core/installer/registry/util_spec.lua
@@ -0,0 +1,81 @@
+local match = require "luassert.match"
+local Result = require "mason-core.result"
+local util = require "mason-core.installer.registry.util"
+local platform = require "mason-core.platform"
+local installer = require "mason-core.installer"
+
+describe("registry installer util", function()
+ it("should coalesce single target", function()
+ local source = { value = "here" }
+ local coalesced = util.coalesce_by_target(source, {}):get()
+ assert.is_true(match.is_ref(source)(coalesced))
+ end)
+
+ it("should coalesce multiple targets", function()
+ local source = { target = "VIC64", value = "here" }
+ local coalesced = util.coalesce_by_target({
+ {
+ target = "linux_arm64",
+ value = "here",
+ },
+ source,
+ }, { target = "VIC64" }):get()
+
+ assert.is_true(match.is_ref(source)(coalesced))
+ end)
+
+ it("should accept valid platform", function()
+ platform.is.VIC64 = true
+ local result = util.ensure_valid_platform {
+ "VIC64",
+ "linux_arm64",
+ }
+ assert.is_true(result:is_success())
+ platform.is.VIC64 = nil
+ end)
+
+ it("should reject invalid platform", function()
+ local result = util.ensure_valid_platform { "VIC64" }
+ assert.same(Result.failure "PLATFORM_UNSUPPORTED", result)
+ end)
+
+ it("should accept valid version", function()
+ local ctx = create_dummy_context { version = "1.0.0" }
+ local result = installer.exec_in_context(ctx, function()
+ return util.ensure_valid_version(function()
+ return Result.success { "1.0.0", "2.0.0", "3.0.0" }
+ end)
+ end)
+ assert.is_true(result:is_success())
+ end)
+
+ it("should reject invalid version", function()
+ local ctx = create_dummy_context { version = "13.3.7" }
+ local result = installer.exec_in_context(ctx, function()
+ return util.ensure_valid_version(function()
+ return Result.success { "1.0.0", "2.0.0", "3.0.0" }
+ end)
+ end)
+ assert.same(Result.failure [[Version "13.3.7" is not available.]], result)
+ end)
+
+ it("should gracefully accept version if unable to resolve available versions", function()
+ local ctx = create_dummy_context { version = "13.3.7" }
+ local result = installer.exec_in_context(ctx, function()
+ return util.ensure_valid_version(function()
+ return Result.failure()
+ end)
+ end)
+ assert.is_true(result:is_success())
+ end)
+
+ it("should accept version if in force mode", function()
+ local ctx = create_dummy_context { version = "13.3.7", force = true }
+ local result = installer.exec_in_context(ctx, function()
+ return util.ensure_valid_version(function()
+ return Result.success { "1.0.0" }
+ end)
+ end)
+ assert.is_true(result:is_success())
+ end)
+end)
diff --git a/tests/mason-core/managers/powershell_spec.lua b/tests/mason-core/managers/powershell_spec.lua
index 46e096bf..b8facacb 100644
--- a/tests/mason-core/managers/powershell_spec.lua
+++ b/tests/mason-core/managers/powershell_spec.lua
@@ -31,7 +31,9 @@ describe("powershell manager", function()
stub(vim.fn, "executable")
vim.fn.executable.on_call_with("pwsh").returns(0)
- powershell().command "echo 'Is this bash?'"
+ local powershell = powershell()
+ a.scheduler()
+ powershell.command "echo 'Is this bash?'"
assert.spy(spawn.pwsh).was_called(0)
assert.spy(spawn.powershell).was_called(1)
diff --git a/tests/mason-core/package/package_spec.lua b/tests/mason-core/package/package_spec.lua
index 1eee3ed5..6f8eb8d4 100644
--- a/tests/mason-core/package/package_spec.lua
+++ b/tests/mason-core/package/package_spec.lua
@@ -86,6 +86,7 @@ describe("package", function()
local handle = dummy:new_handle()
assert.spy(handle_handler).was_called(1)
assert.spy(handle_handler).was_called_with(match.ref(handle))
+ handle:close()
end)
it("should not create new handle if one already exists", function()
@@ -100,6 +101,7 @@ describe("package", function()
end)
assert.equals("Cannot create new handle because existing handle is not closed.", err)
assert.spy(handle_handler).was_called(0)
+ dummy.handle = nil
end)
it(
@@ -186,7 +188,6 @@ describe("package", function()
assert.wait_for(function()
assert.is_true(handle:is_closed())
- assert.is_true(dummy:is_installed())
end)
end)
)
diff --git a/tests/minimal_init.vim b/tests/minimal_init.vim
index f099416c..0d9fb211 100644
--- a/tests/minimal_init.vim
+++ b/tests/minimal_init.vim
@@ -16,12 +16,11 @@ lua require("luassertx")
lua require("test_helpers")
lua <<EOF
-local index = require "mason-registry.index"
-index["dummy"] = "dummy_package"
-index["dummy2"] = "dummy2_package"
-
require("mason").setup {
install_root_dir = vim.env.INSTALL_ROOT_DIR,
+ registries = {
+ "lua:dummy-registry.index"
+ }
}
EOF
diff --git a/vim.yml b/vim.yml
index 55380edf..1b348cf0 100644
--- a/vim.yml
+++ b/vim.yml
@@ -14,6 +14,10 @@ globals:
assert.wait_for:
args:
- type: function
+ create_dummy_context:
+ args:
+ - type: table
+ required: false
InstallHandleGenerator:
args:
- type: string