aboutsummaryrefslogtreecommitdiffstats
path: root/lua/mason-core/managers
diff options
context:
space:
mode:
authorWilliam Boman <william@redwill.se>2022-07-08 18:34:38 +0200
committerGitHub <noreply@github.com>2022-07-08 18:34:38 +0200
commit976aa4fbee8a070f362cab6f6ec84e9251a90cf9 (patch)
tree5e8d9c9c59444a25c7801b8f39763c4ba6e1f76d /lua/mason-core/managers
parentfeat: add gotests, gomodifytags, impl (#28) (diff)
downloadmason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.gz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.bz2
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.lz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.xz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.zst
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.zip
refactor: add mason-schemas and mason-core modules (#29)
* refactor: add mason-schemas and move generated filetype map to mason-lspconfig * refactor: add mason-core module
Diffstat (limited to 'lua/mason-core/managers')
-rw-r--r--lua/mason-core/managers/cargo/client.lua14
-rw-r--r--lua/mason-core/managers/cargo/init.lua140
-rw-r--r--lua/mason-core/managers/composer/init.lua135
-rw-r--r--lua/mason-core/managers/dotnet/init.lua64
-rw-r--r--lua/mason-core/managers/gem/init.lua159
-rw-r--r--lua/mason-core/managers/git/init.lua76
-rw-r--r--lua/mason-core/managers/github/client.lua117
-rw-r--r--lua/mason-core/managers/github/init.lua171
-rw-r--r--lua/mason-core/managers/go/init.lua171
-rw-r--r--lua/mason-core/managers/luarocks/init.lua144
-rw-r--r--lua/mason-core/managers/npm/init.lua143
-rw-r--r--lua/mason-core/managers/opam/init.lua69
-rw-r--r--lua/mason-core/managers/pip3/init.lua175
-rw-r--r--lua/mason-core/managers/powershell/init.lua46
-rw-r--r--lua/mason-core/managers/std/init.lua188
15 files changed, 1812 insertions, 0 deletions
diff --git a/lua/mason-core/managers/cargo/client.lua b/lua/mason-core/managers/cargo/client.lua
new file mode 100644
index 00000000..3df7550b
--- /dev/null
+++ b/lua/mason-core/managers/cargo/client.lua
@@ -0,0 +1,14 @@
+local fetch = require "mason-core.fetch"
+
+local M = {}
+
+---@alias CrateResponse {crate: {id: string, max_stable_version: string, max_version: string, newest_version: string}}
+
+---@async
+---@param crate string
+---@return Result @of Crate
+function M.fetch_crate(crate)
+ return fetch(("https://crates.io/api/v1/crates/%s"):format(crate)):map_catching(vim.json.decode)
+end
+
+return M
diff --git a/lua/mason-core/managers/cargo/init.lua b/lua/mason-core/managers/cargo/init.lua
new file mode 100644
index 00000000..5b87667c
--- /dev/null
+++ b/lua/mason-core/managers/cargo/init.lua
@@ -0,0 +1,140 @@
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local spawn = require "mason-core.spawn"
+local a = require "mason-core.async"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local client = require "mason-core.managers.cargo.client"
+local _ = require "mason-core.functional"
+
+local get_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity))
+
+---@param crate string
+local function with_receipt(crate)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.cargo(crate))
+ end
+end
+
+local M = {}
+
+---@async
+---@param crate string The crate to install.
+---@param opts {git: boolean | string, features: string|nil, bin: string[] | nil } | nil
+function M.crate(crate, opts)
+ return function()
+ M.install(crate, opts).with_receipt()
+ end
+end
+
+---@async
+---@param crate string The crate to install.
+---@param opts {git: boolean | string, features: string|nil, bin: string[] | nil } | nil
+function M.install(crate, opts)
+ local ctx = installer.context()
+ opts = opts or {}
+ ctx.requested_version:if_present(function()
+ assert(not opts.git, "Providing a version when installing a git crate is not allowed.")
+ end)
+
+ local final_crate = crate
+
+ if opts.git then
+ final_crate = { "--git" }
+ if type(opts.git) == "string" then
+ table.insert(final_crate, opts.git)
+ end
+ table.insert(final_crate, crate)
+ end
+
+ ctx.spawn.cargo {
+ "install",
+ "--root",
+ ".",
+ "--locked",
+ ctx.requested_version
+ :map(function(version)
+ return { "--version", version }
+ end)
+ :or_else(vim.NIL),
+ opts.features and { "--features", opts.features } or vim.NIL,
+ final_crate,
+ }
+
+ if opts.bin then
+ _.each(function(bin)
+ ctx:link_bin(bin, get_bin_path(bin))
+ end, opts.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(crate),
+ }
+end
+
+---@param output string @The `cargo install --list` output.
+---@return table<string, string> @Key is the crate name, value is its version.
+function M.parse_installed_crates(output)
+ local installed_crates = {}
+ for _, line in ipairs(vim.split(output, "\n")) do
+ local name, version = line:match "^(.+)%s+v([.%S]+)[%s:]"
+ if name and version then
+ installed_crates[name] = version
+ end
+ end
+ return installed_crates
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ return M.get_installed_primary_package_version(receipt, install_dir):map_catching(function(installed_version)
+ ---@type CrateResponse
+ local crate_response = client.fetch_crate(receipt.primary_source.package):get_or_throw()
+ if installed_version ~= crate_response.crate.max_stable_version then
+ return {
+ name = receipt.primary_source.package,
+ current_version = installed_version,
+ latest_version = crate_response.crate.max_stable_version,
+ }
+ else
+ error "Primary package is not outdated."
+ end
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ return spawn
+ .cargo({
+ "install",
+ "--list",
+ "--root",
+ ".",
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local installed_crates = M.parse_installed_crates(result.stdout)
+ if vim.in_fast_event() then
+ a.scheduler() -- needed because vim.fn.* call
+ end
+ local pkg = vim.fn.fnamemodify(receipt.primary_source.package, ":t")
+ return Optional.of_nilable(installed_crates[pkg]):or_else_throw "Failed to find cargo package version."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/composer/init.lua b/lua/mason-core/managers/composer/init.lua
new file mode 100644
index 00000000..96ab5f14
--- /dev/null
+++ b/lua/mason-core/managers/composer/init.lua
@@ -0,0 +1,135 @@
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "vendor", "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.bat", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+
+ ctx.receipt:with_primary_source(ctx.receipt.composer(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.composer(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The composer packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.require(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The composer packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.require(packages)
+ local ctx = installer.context()
+ local pkgs = _.list_copy(packages)
+
+ if not ctx.fs:file_exists "composer.json" then
+ ctx.spawn.composer { "init", "--no-interaction", "--stability=stable" }
+ end
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s:%s"):format(pkgs[1], version)
+ end)
+
+ ctx.spawn.composer { "require", pkgs }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@async
+function M.install()
+ local ctx = installer.context()
+ ctx.spawn.composer {
+ "install",
+ "--no-interaction",
+ "--no-dev",
+ "--optimize-autoloader",
+ "--classmap-authoritative",
+ }
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "composer" then
+ return Result.failure "Receipt does not have a primary source of type composer"
+ end
+ return spawn
+ .composer({
+ "outdated",
+ "--no-interaction",
+ "--format=json",
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local outdated_packages = vim.json.decode(result.stdout)
+ local outdated_package = _.find_first(function(pkg)
+ return pkg.name == receipt.primary_source.package
+ end, outdated_packages.installed)
+ return Optional.of_nilable(outdated_package)
+ :map(function(pkg)
+ if pkg.version ~= pkg.latest then
+ return {
+ name = pkg.name,
+ current_version = pkg.version,
+ latest_version = pkg.latest,
+ }
+ end
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "composer" then
+ return Result.failure "Receipt does not have a primary source of type composer"
+ end
+ return spawn
+ .composer({
+ "info",
+ "--format=json",
+ receipt.primary_source.package,
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local info = vim.json.decode(result.stdout)
+ return info.versions[1]
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "vendor", "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/dotnet/init.lua b/lua/mason-core/managers/dotnet/init.lua
new file mode 100644
index 00000000..f89d61ca
--- /dev/null
+++ b/lua/mason-core/managers/dotnet/init.lua
@@ -0,0 +1,64 @@
+local process = require "mason-core.process"
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity)
+
+---@param package string
+local function with_receipt(package)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.dotnet(package))
+ end
+end
+
+---@async
+---@param pkg string
+---@param opt { bin: string[] | nil } | nil
+function M.package(pkg, opt)
+ return function()
+ return M.install(pkg, opt).with_receipt()
+ end
+end
+
+---@async
+---@param pkg string
+---@param opt { bin: string[] | nil } | nil
+function M.install(pkg, opt)
+ local ctx = installer.context()
+ ctx.spawn.dotnet {
+ "tool",
+ "update",
+ "--tool-path",
+ ".",
+ ctx.requested_version
+ :map(function(version)
+ return { "--version", version }
+ end)
+ :or_else(vim.NIL),
+ pkg,
+ }
+
+ if opt and opt.bin then
+ if opt.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, opt.bin)
+ end
+ end
+
+ return {
+ with_receipt = with_receipt(pkg),
+ }
+end
+
+function M.env(root_dir)
+ return {
+ PATH = process.extend_path { root_dir },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/gem/init.lua b/lua/mason-core/managers/gem/init.lua
new file mode 100644
index 00000000..11019985
--- /dev/null
+++ b/lua/mason-core/managers/gem/init.lua
@@ -0,0 +1,159 @@
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.cmd", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.gem(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.gem(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The Gem packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The Gem packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = _.list_copy(packages or {})
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s:%s"):format(pkgs[1], version)
+ end)
+
+ ctx.spawn.gem {
+ "install",
+ "--no-user-install",
+ "--install-dir=.",
+ "--bindir=bin",
+ "--no-document",
+ pkgs,
+ }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@alias GemOutdatedPackage {name:string, current_version: string, latest_version: string}
+
+---Parses a string input like "package (0.1.0 < 0.2.0)" into its components
+---@param outdated_gem string
+---@return GemOutdatedPackage
+function M.parse_outdated_gem(outdated_gem)
+ local package_name, version_expression = outdated_gem:match "^(.+) %((.+)%)"
+ if not package_name or not version_expression then
+ -- unparseable
+ return nil
+ end
+ local current_version, latest_version = unpack(vim.split(version_expression, "<"))
+
+ ---@type GemOutdatedPackage
+ local outdated_package = {
+ name = vim.trim(package_name),
+ current_version = vim.trim(current_version),
+ latest_version = vim.trim(latest_version),
+ }
+ return outdated_package
+end
+
+---Parses the stdout of the `gem list` command into a table<package_name, version>
+---@param output string
+function M.parse_gem_list_output(output)
+ ---@type table<string, string>
+ local gem_versions = {}
+ for _, line in ipairs(vim.split(output, "\n")) do
+ local gem_package, version = line:match "^(%S+) %((%S+)%)$"
+ if gem_package and version then
+ gem_versions[gem_package] = version
+ end
+ end
+ return gem_versions
+end
+
+local function not_empty(s)
+ return s ~= nil and s ~= ""
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "gem" then
+ return Result.failure "Receipt does not have a primary source of type gem"
+ end
+ return spawn.gem({ "outdated", cwd = install_dir, env = M.env(install_dir) }):map_catching(function(result)
+ ---@type string[]
+ local lines = vim.split(result.stdout, "\n")
+ local outdated_gems = vim.tbl_map(M.parse_outdated_gem, vim.tbl_filter(not_empty, lines))
+
+ local outdated_gem = _.find_first(function(gem)
+ return gem.name == receipt.primary_source.package and gem.current_version ~= gem.latest_version
+ end, outdated_gems)
+
+ return Optional.of_nilable(outdated_gem)
+ :map(function(gem)
+ return {
+ name = receipt.primary_source.package,
+ current_version = assert(gem.current_version),
+ latest_version = assert(gem.latest_version),
+ }
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ return spawn
+ .gem({
+ "list",
+ cwd = install_dir,
+ env = M.env(install_dir),
+ })
+ :map_catching(function(result)
+ local gems = M.parse_gem_list_output(result.stdout)
+ return Optional.of_nilable(gems[receipt.primary_source.package])
+ :or_else_throw "Failed to find gem package version."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ GEM_HOME = install_dir,
+ GEM_PATH = install_dir,
+ PATH = process.extend_path { path.concat { install_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/git/init.lua b/lua/mason-core/managers/git/init.lua
new file mode 100644
index 00000000..432d18f4
--- /dev/null
+++ b/lua/mason-core/managers/git/init.lua
@@ -0,0 +1,76 @@
+local spawn = require "mason-core.spawn"
+local Result = require "mason-core.result"
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+
+local M = {}
+
+---@param repo string
+local function with_receipt(repo)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.git_remote(repo))
+ end
+end
+
+---@async
+---@param opts {[1]: string, recursive: boolean, version: Optional|nil} @The first item in the table is the repository to clone.
+function M.clone(opts)
+ local ctx = installer.context()
+ local repo = assert(opts[1], "No git URL provided.")
+ ctx.spawn.git {
+ "clone",
+ "--depth",
+ "1",
+ opts.recursive and "--recursive" or vim.NIL,
+ repo,
+ ".",
+ }
+ _.coalesce(opts.version, ctx.requested_version):if_present(function(version)
+ ctx.spawn.git { "fetch", "--depth", "1", "origin", version }
+ ctx.spawn.git { "checkout", "FETCH_HEAD" }
+ end)
+
+ return {
+ with_receipt = with_receipt(repo),
+ }
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_git_clone(receipt, install_dir)
+ if receipt.primary_source.type ~= "git" then
+ return Result.failure "Receipt does not have a primary source of type git"
+ end
+ return spawn.git({ "fetch", "origin", "HEAD", cwd = install_dir }):map_catching(function()
+ local result = spawn.git({ "rev-parse", "FETCH_HEAD", "HEAD", cwd = install_dir }):get_or_throw()
+ local remote_head, local_head = unpack(vim.split(result.stdout, "\n"))
+ if remote_head == local_head then
+ error("Git clone is up to date.", 2)
+ end
+ return {
+ name = receipt.primary_source.remote,
+ current_version = assert(local_head),
+ latest_version = assert(remote_head),
+ }
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_revision(receipt, install_dir)
+ return spawn
+ .git({
+ "rev-parse",
+ "--short",
+ "HEAD",
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ return assert(vim.trim(result.stdout))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/managers/github/client.lua b/lua/mason-core/managers/github/client.lua
new file mode 100644
index 00000000..1bcede7a
--- /dev/null
+++ b/lua/mason-core/managers/github/client.lua
@@ -0,0 +1,117 @@
+local _ = require "mason-core.functional"
+local log = require "mason-core.log"
+local fetch = require "mason-core.fetch"
+local spawn = require "mason-core.spawn"
+
+local M = {}
+
+---@alias GitHubReleaseAsset {url: string, id: integer, name: string, browser_download_url: string, created_at: string, updated_at: string, size: integer, download_count: integer}
+---@alias GitHubRelease {tag_name: string, prerelease: boolean, draft: boolean, assets:GitHubReleaseAsset[]}
+---@alias GitHubTag {name: string}
+
+---@param path string
+---@return Result @JSON decoded response.
+local function api_call(path)
+ return spawn
+ .gh({ "api", path })
+ :map(_.prop "stdout")
+ :recover_catching(function()
+ return fetch(("https://api.github.com/%s"):format(path)):get_or_throw()
+ end)
+ :map_catching(vim.json.decode)
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@return Result @of GitHubRelease[]
+function M.fetch_releases(repo)
+ log.fmt_trace("Fetching GitHub releases for repo=%s", repo)
+ local path = ("repos/%s/releases"):format(repo)
+ return api_call(path):map_err(function()
+ return ("Failed to fetch releases for GitHub repository %s."):format(repo)
+ end)
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@param tag_name string @The tag_name of the release to fetch.
+function M.fetch_release(repo, tag_name)
+ log.fmt_trace("Fetching GitHub release for repo=%s, tag_name=%s", repo, tag_name)
+ local path = ("repos/%s/releases/tags/%s"):format(repo, tag_name)
+ return api_call(path):map_err(function()
+ return ("Failed to fetch release %q for GitHub repository %s."):format(tag_name, repo)
+ end)
+end
+
+---@param opts {include_prerelease: boolean, tag_name_pattern: string}
+function M.release_predicate(opts)
+ local is_not_draft = _.prop_eq("draft", false)
+ local is_not_prerelease = _.prop_eq("prerelease", false)
+ local tag_name_matches = _.prop_satisfies(_.matches(opts.tag_name_pattern), "tag_name")
+
+ return _.all_pass {
+ _.if_else(_.always(opts.include_prerelease), _.T, is_not_prerelease),
+ _.if_else(_.always(opts.tag_name_pattern), tag_name_matches, _.T),
+ is_not_draft,
+ }
+end
+
+---@alias FetchLatestGithubReleaseOpts {tag_name_pattern:string|nil, include_prerelease: boolean}
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@param opts FetchLatestGithubReleaseOpts|nil
+---@return Result @of GitHubRelease
+function M.fetch_latest_release(repo, opts)
+ opts = opts or {
+ tag_name_pattern = nil,
+ include_prerelease = false,
+ }
+ return M.fetch_releases(repo):map_catching(
+ ---@param releases GitHubRelease[]
+ function(releases)
+ local is_stable_release = M.release_predicate(opts)
+ ---@type GitHubRelease|nil
+ local latest_release = _.find_first(is_stable_release, releases)
+
+ if not latest_release then
+ log.fmt_info("Failed to find latest release. repo=%s, opts=%s", repo, opts)
+ error "Failed to find latest release."
+ end
+
+ log.fmt_debug("Resolved latest version repo=%s, tag_name=%s", repo, latest_release.tag_name)
+ return latest_release
+ end
+ )
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@return Result @of GitHubTag[]
+function M.fetch_tags(repo)
+ local path = ("repos/%s/tags"):format(repo)
+ return api_call(path):map_err(function()
+ return ("Failed to fetch tags for GitHub repository %s."):format(repo)
+ end)
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@return Result @Result<string> - The latest tag name.
+function M.fetch_latest_tag(repo)
+ -- https://github.com/williamboman/vercel-github-api-latest-tag-proxy
+ return fetch(("https://latest-github-tag.redwill.se/api/latest-tag?repo=%s"):format(repo))
+ :map_catching(vim.json.decode)
+ :map(_.prop "tag")
+end
+
+---@alias GitHubRateLimit {limit: integer, remaining: integer, reset: integer, used: integer}
+---@alias GitHubRateLimitResponse {resources: { core: GitHubRateLimit }}
+
+---@async
+--@return Result @of GitHubRateLimitResponse
+function M.fetch_rate_limit()
+ return api_call "rate_limit"
+end
+
+return M
diff --git a/lua/mason-core/managers/github/init.lua b/lua/mason-core/managers/github/init.lua
new file mode 100644
index 00000000..55f3600f
--- /dev/null
+++ b/lua/mason-core/managers/github/init.lua
@@ -0,0 +1,171 @@
+local installer = require "mason-core.installer"
+local std = require "mason-core.managers.std"
+local client = require "mason-core.managers.github.client"
+local platform = require "mason-core.platform"
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local settings = require "mason.settings"
+
+local M = {}
+
+---@param repo string
+---@param asset_file string
+---@param release string
+local function with_release_file_receipt(repo, asset_file, release)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source {
+ type = "github_release_file",
+ repo = repo,
+ file = asset_file,
+ release = release,
+ }
+ end
+end
+
+---@param repo string
+---@param tag string
+local function with_tag_receipt(repo, tag)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source {
+ type = "github_tag",
+ repo = repo,
+ tag = tag,
+ }
+ end
+end
+
+---@async
+---@param opts {repo: string, version: Optional|nil, asset_file: string|fun(release: string):string}
+function M.release_file(opts)
+ local ctx = installer.context()
+ local release = _.coalesce(opts.version, ctx.requested_version):or_else_get(function()
+ return client
+ .fetch_latest_release(opts.repo)
+ :map(_.prop "tag_name")
+ :get_or_throw "Failed to fetch latest release from GitHub API. Refer to :h mason-errors-github-api for more information."
+ end)
+ ---@type string
+ local asset_file
+ if type(opts.asset_file) == "function" then
+ asset_file = opts.asset_file(release)
+ else
+ asset_file = opts.asset_file
+ end
+ if not asset_file then
+ error(
+ (
+ "Could not find which release file to download.\nMost likely the current operating system, architecture (%s), or libc (%s) is not supported."
+ ):format(platform.arch, platform.get_libc()),
+ 0
+ )
+ end
+ local download_url = settings.current.github.download_url_template:format(opts.repo, release, asset_file)
+ return {
+ release = release,
+ download_url = download_url,
+ asset_file = asset_file,
+ with_receipt = with_release_file_receipt(opts.repo, download_url, release),
+ }
+end
+
+---@async
+---@param opts {repo: string, version: Optional|nil}
+function M.tag(opts)
+ local ctx = installer.context()
+ local tag = _.coalesce(opts.version, ctx.requested_version):or_else_get(function()
+ return client.fetch_latest_tag(opts.repo):get_or_throw "Failed to fetch latest tag from GitHub API."
+ end)
+
+ return {
+ tag = tag,
+ with_receipt = with_tag_receipt(opts.repo, tag),
+ }
+end
+
+---@param filename string
+---@param processor async fun(opts: table)
+local function release_file_processor(filename, processor)
+ ---@async
+ ---@param opts {repo: string, asset_file: string|fun(release: string):string}
+ return function(opts)
+ local release_file_source = M.release_file(opts)
+ std.download_file(release_file_source.download_url, filename)
+ processor(opts)
+ return release_file_source
+ end
+end
+
+M.unzip_release_file = release_file_processor("archive.zip", function()
+ std.unzip("archive.zip", ".")
+end)
+
+M.untarxz_release_file = release_file_processor("archive.tar.xz", function(opts)
+ std.untarxz("archive.tar.xz", { strip_components = opts.strip_components })
+end)
+
+M.untargz_release_file = release_file_processor("archive.tar.gz", function(opts)
+ std.untar("archive.tar.gz", { strip_components = opts.strip_components })
+end)
+
+---@async
+---@param opts {repo: string, out_file:string, asset_file: string|fun(release: string):string}
+function M.download_release_file(opts)
+ local release_file_source = M.release_file(opts)
+ std.download_file(release_file_source.download_url, assert(opts.out_file, "out_file is required"))
+ return release_file_source
+end
+
+---@async
+---@param opts {repo: string, out_file:string, asset_file: string|fun(release: string):string}
+function M.gunzip_release_file(opts)
+ local release_file_source = M.release_file(opts)
+ local gzipped_file = ("%s.gz"):format(assert(opts.out_file, "out_file must be specified"))
+ std.download_file(release_file_source.download_url, gzipped_file)
+ std.gunzip(gzipped_file)
+ return release_file_source
+end
+
+---@async
+---@param receipt InstallReceipt
+function M.check_outdated_primary_package_release(receipt)
+ local source = receipt.primary_source
+ if source.type ~= "github_release" and source.type ~= "github_release_file" then
+ return Result.failure "Receipt does not have a primary source of type (github_release|github_release_file)."
+ end
+ return client.fetch_latest_release(source.repo, { tag_name_pattern = source.tag_name_pattern }):map_catching(
+ ---@param latest_release GitHubRelease
+ function(latest_release)
+ if source.release ~= latest_release.tag_name then
+ return {
+ name = source.repo,
+ current_version = source.release,
+ latest_version = latest_release.tag_name,
+ }
+ end
+ error "Primary package is not outdated."
+ end
+ )
+end
+
+---@async
+---@param receipt InstallReceipt
+function M.check_outdated_primary_package_tag(receipt)
+ local source = receipt.primary_source
+ if source.type ~= "github_tag" then
+ return Result.failure "Receipt does not have a primary source of type github_tag."
+ end
+ return client.fetch_latest_tag(source.repo):map_catching(function(latest_tag)
+ if source.tag ~= latest_tag then
+ return {
+ name = source.repo,
+ current_version = source.tag,
+ latest_version = latest_tag,
+ }
+ end
+ error "Primary package is not outdated."
+ end)
+end
+
+return M
diff --git a/lua/mason-core/managers/go/init.lua b/lua/mason-core/managers/go/init.lua
new file mode 100644
index 00000000..dbdfdc45
--- /dev/null
+++ b/lua/mason-core/managers/go/init.lua
@@ -0,0 +1,171 @@
+local installer = require "mason-core.installer"
+local process = require "mason-core.process"
+local platform = require "mason-core.platform"
+local spawn = require "mason-core.spawn"
+local a = require "mason-core.async"
+local Optional = require "mason-core.optional"
+local _ = require "mason-core.functional"
+
+local M = {}
+
+local create_bin_path = _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity)
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.go(packages[1]))
+ -- Install secondary packages
+ for i = 2, #packages do
+ local pkg = packages[i]
+ ctx.receipt:with_secondary_source(ctx.receipt.go(pkg))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The go packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The go packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local env = {
+ GOBIN = ctx.cwd:get(),
+ }
+ -- Install the head package
+ do
+ local head_package = packages[1]
+ local version = ctx.requested_version:or_else "latest"
+ ctx.spawn.go {
+ "install",
+ "-v",
+ ("%s@%s"):format(head_package, version),
+ env = env,
+ }
+ end
+
+ -- Install secondary packages
+ for i = 2, #packages do
+ ctx.spawn.go { "install", "-v", ("%s@latest"):format(packages[i]), env = env }
+ end
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@param output string @The output from `go version -m` command.
+function M.parse_mod_version_output(output)
+ ---@type {path: string[], mod: string[], dep: string[], build: string[]}
+ local result = {}
+ local lines = vim.split(output, "\n")
+ for _, line in ipairs { unpack(lines, 2) } do
+ local type, id, value = unpack(vim.split(line, "%s+", { trimempty = true }))
+ if type and id then
+ result[type] = result[type] or {}
+ result[type][id] = value or ""
+ end
+ end
+ return result
+end
+
+local trim_wildcard_suffix = _.gsub("/%.%.%.$", "")
+
+---@param pkg string
+function M.parse_package_mod(pkg)
+ if _.starts_with("github.com", pkg) then
+ local components = _.split("/", pkg)
+ return trim_wildcard_suffix(_.join("/", {
+ components[1], -- github.com
+ components[2], -- owner
+ components[3], -- repo
+ }))
+ elseif _.starts_with("golang.org", pkg) then
+ local components = _.split("/", pkg)
+ return trim_wildcard_suffix(_.join("/", {
+ components[1], -- golang.org
+ components[2], -- x
+ components[3], -- owner
+ components[4], -- repo
+ }))
+ else
+ return trim_wildcard_suffix(pkg)
+ end
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ local normalized_pkg_name = trim_wildcard_suffix(receipt.primary_source.package)
+ -- trims e.g. golang.org/x/tools/gopls to gopls
+ local executable = vim.fn.fnamemodify(normalized_pkg_name, ":t")
+ return spawn
+ .go({
+ "version",
+ "-m",
+ platform.is_win and ("%s.exe"):format(executable) or executable,
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local parsed_output = M.parse_mod_version_output(result.stdout)
+ return Optional.of_nilable(parsed_output.mod[M.parse_package_mod(receipt.primary_source.package)])
+ :or_else_throw "Failed to parse mod version"
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ local normalized_pkg_name = M.parse_package_mod(receipt.primary_source.package)
+ return spawn
+ .go({
+ "list",
+ "-json",
+ "-m",
+ ("%s@latest"):format(normalized_pkg_name),
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ ---@type {Path: string, Version: string}
+ local output = vim.json.decode(result.stdout)
+ return Optional.of_nilable(output.Version)
+ :map(function(latest_version)
+ local installed_version =
+ M.get_installed_primary_package_version(receipt, install_dir):get_or_throw()
+ if installed_version ~= latest_version then
+ return {
+ name = normalized_pkg_name,
+ current_version = assert(installed_version),
+ latest_version = assert(latest_version),
+ }
+ end
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { install_dir },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/luarocks/init.lua b/lua/mason-core/managers/luarocks/init.lua
new file mode 100644
index 00000000..7959261c
--- /dev/null
+++ b/lua/mason-core/managers/luarocks/init.lua
@@ -0,0 +1,144 @@
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.bat", _.identity))
+
+---@param package string
+local function with_receipt(package)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.luarocks(package))
+ end
+end
+
+---@param package string @The luarock package to install.
+---@param opts { dev: boolean, bin : string[] | nil } | nil
+function M.package(package, opts)
+ return function()
+ return M.install(package, opts).with_receipt()
+ end
+end
+
+---@async
+---@param pkg string @The luarock package to install.
+---@param opts { dev: boolean, bin : string[] | nil } | nil
+function M.install(pkg, opts)
+ opts = opts or {}
+ local ctx = installer.context()
+ ctx:promote_cwd()
+ ctx.spawn.luarocks {
+ "install",
+ "--tree",
+ ctx.cwd:get(),
+ opts.dev and "--dev" or vim.NIL,
+ pkg,
+ ctx.requested_version:or_else(vim.NIL),
+ }
+ if pkg.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, pkg.bin)
+ end
+ return {
+ with_receipt = with_receipt(pkg),
+ }
+end
+
+---@alias InstalledLuarock {package: string, version: string, arch: string, nrepo: string, namespace: string}
+
+---@type fun(output: string): InstalledLuarock[]
+M.parse_installed_rocks = _.compose(
+ _.map(_.compose(
+ -- https://github.com/luarocks/luarocks/blob/fbd3566a312e647cde57b5d774533731e1aa844d/src/luarocks/search.lua#L317
+ _.zip_table { "package", "version", "arch", "nrepo", "namespace" },
+ _.split "\t"
+ )),
+ _.split "\n"
+)
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "luarocks" then
+ return Result.failure "Receipt does not have a primary source of type luarocks"
+ end
+ local primary_package = receipt.primary_source.package
+ return spawn
+ .luarocks({
+ "list",
+ "--tree",
+ install_dir,
+ "--porcelain",
+ })
+ :map_catching(function(result)
+ local luarocks = M.parse_installed_rocks(result.stdout)
+ return Optional.of_nilable(_.find_first(_.prop_eq("package", primary_package), luarocks))
+ :map(_.prop "version")
+ :or_else_throw()
+ end)
+end
+
+---@alias OutdatedLuarock {name: string, installed: string, available: string, repo: string}
+
+---@type fun(output: string): OutdatedLuarock[]
+M.parse_outdated_rocks = _.compose(
+ _.map(_.compose(
+ -- https://github.com/luarocks/luarocks/blob/fbd3566a312e647cde57b5d774533731e1aa844d/src/luarocks/cmd/list.lua#L59
+ _.zip_table { "name", "installed", "available", "repo" },
+ _.split "\t"
+ )),
+ _.split "\n"
+)
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "luarocks" then
+ return Result.failure "Receipt does not have a primary source of type luarocks"
+ end
+ local primary_package = receipt.primary_source.package
+ return spawn
+ .luarocks({
+ "list",
+ "--outdated",
+ "--tree",
+ install_dir,
+ "--porcelain",
+ })
+ :map_catching(function(result)
+ local outdated_rocks = M.parse_outdated_rocks(result.stdout)
+ return Optional.of_nilable(_.find_first(_.prop_eq("name", primary_package), outdated_rocks))
+ :map(
+ ---@param outdated_rock OutdatedLuarock
+ function(outdated_rock)
+ return {
+ name = outdated_rock.name,
+ current_version = assert(outdated_rock.installed),
+ latest_version = assert(outdated_rock.available),
+ }
+ end
+ )
+ :or_else_throw()
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/npm/init.lua b/lua/mason-core/managers/npm/init.lua
new file mode 100644
index 00000000..828afd12
--- /dev/null
+++ b/lua/mason-core/managers/npm/init.lua
@@ -0,0 +1,143 @@
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local Result = require "mason-core.result"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+
+local list_copy = _.list_copy
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "node_modules", ".bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.cmd", _.identity))
+
+---@async
+---@param ctx InstallContext
+local function ensure_npm_root(ctx)
+ if not (ctx.fs:dir_exists "node_modules" or ctx.fs:file_exists "package.json") then
+ -- Create a package.json to set a boundary for where npm installs packages.
+ ctx.spawn.npm { "init", "--yes", "--scope=mason" }
+ end
+end
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.npm(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.npm(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The npm packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The npm packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = list_copy(packages)
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s@%s"):format(pkgs[1], version)
+ end)
+
+ -- 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.
+ ctx.fs:append_file(".npmrc", "global-style=true")
+
+ ensure_npm_root(ctx)
+ ctx.spawn.npm { "install", pkgs }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@async
+---@param exec_args string[] @The arguments to pass to npm exec.
+function M.exec(exec_args)
+ local ctx = installer.context()
+ ctx.spawn.npm { "exec", "--yes", "--", exec_args }
+end
+
+---@async
+---@param script string @The npm script to run.
+function M.run(script)
+ local ctx = installer.context()
+ ctx.spawn.npm { "run", script }
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "npm" then
+ return Result.failure "Receipt does not have a primary source of type npm"
+ end
+ return spawn.npm({ "ls", "--json", cwd = install_dir }):map_catching(function(result)
+ local npm_packages = vim.json.decode(result.stdout)
+ return npm_packages.dependencies[receipt.primary_source.package].version
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "npm" then
+ return Result.failure "Receipt does not have a primary source of type npm"
+ end
+ local primary_package = receipt.primary_source.package
+ local npm_outdated = spawn.npm { "outdated", "--json", primary_package, cwd = install_dir }
+ if npm_outdated:is_success() then
+ return Result.failure "Primary package is not outdated."
+ end
+ return npm_outdated:recover_catching(function(result)
+ assert(result.exit_code == 1, "Expected npm outdated to return exit code 1.")
+ local data = vim.json.decode(result.stdout)
+
+ return Optional.of_nilable(data[primary_package])
+ :map(function(outdated_package)
+ if outdated_package.current ~= outdated_package.latest then
+ return {
+ name = primary_package,
+ current_version = assert(outdated_package.current),
+ latest_version = assert(outdated_package.latest),
+ }
+ end
+ end)
+ :or_else_throw()
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "node_modules", ".bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/opam/init.lua b/lua/mason-core/managers/opam/init.lua
new file mode 100644
index 00000000..8b42e4e9
--- /dev/null
+++ b/lua/mason-core/managers/opam/init.lua
@@ -0,0 +1,69 @@
+local path = require "mason-core.path"
+local process = require "mason-core.process"
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local list_copy = _.list_copy
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.opam(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.opam(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The opam packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The opam packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = list_copy(packages)
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s.%s"):format(pkgs[1], version)
+ end)
+
+ ctx.spawn.opam {
+ "install",
+ "--destdir=.",
+ "--yes",
+ "--verbose",
+ pkgs,
+ }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+function M.env(root_dir)
+ return {
+ PATH = process.extend_path { path.concat { root_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/pip3/init.lua b/lua/mason-core/managers/pip3/init.lua
new file mode 100644
index 00000000..9502e89e
--- /dev/null
+++ b/lua/mason-core/managers/pip3/init.lua
@@ -0,0 +1,175 @@
+local _ = require "mason-core.functional"
+local settings = require "mason.settings"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+
+local VENV_DIR = "venv"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { VENV_DIR, platform.is_win and "Scripts" or "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.pip3(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.pip3(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The pip packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The pip packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = _.list_copy(packages)
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s==%s"):format(pkgs[1], version)
+ end)
+
+ local executables = platform.is_win and _.list_not_nil(vim.g.python3_host_prog, "python", "python3")
+ or _.list_not_nil(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()
+
+ -- Find first executable that manages to create venv
+ local executable = _.find_first(function(executable)
+ return pcall(ctx.spawn[executable], { "-m", "venv", VENV_DIR })
+ end, executables)
+
+ Optional.of_nilable(executable)
+ :if_present(function()
+ ctx.spawn.python {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ settings.current.pip.install_args,
+ pkgs,
+ with_paths = { M.venv_path(ctx.cwd:get()) },
+ }
+ end)
+ :or_else_throw "Unable to create python3 venv environment."
+
+ if packages.bin then
+ _.each(function(bin)
+ ctx:link_bin(bin, create_bin_path(bin))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@param pkg string
+---@return string
+function M.normalize_package(pkg)
+ -- https://stackoverflow.com/a/60307740
+ local s = pkg:gsub("%[.*%]", "")
+ return s
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "pip3" then
+ return Result.failure "Receipt does not have a primary source of type pip3"
+ end
+ local normalized_package = M.normalize_package(receipt.primary_source.package)
+ return spawn
+ .python({
+ "-m",
+ "pip",
+ "list",
+ "--outdated",
+ "--format=json",
+ cwd = install_dir,
+ with_paths = { M.venv_path(install_dir) },
+ })
+ :map_catching(function(result)
+ ---@alias PipOutdatedPackage {name: string, version: string, latest_version: string}
+ ---@type PipOutdatedPackage[]
+ local packages = vim.json.decode(result.stdout)
+
+ local outdated_primary_package = _.find_first(function(outdated_package)
+ return outdated_package.name == normalized_package
+ and outdated_package.version ~= outdated_package.latest_version
+ end, packages)
+
+ return Optional.of_nilable(outdated_primary_package)
+ :map(function(pkg)
+ return {
+ name = normalized_package,
+ current_version = assert(pkg.version),
+ latest_version = assert(pkg.latest_version),
+ }
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "pip3" then
+ return Result.failure "Receipt does not have a primary source of type pip3"
+ end
+ return spawn
+ .python({
+ "-m",
+ "pip",
+ "list",
+ "--format=json",
+ cwd = install_dir,
+ with_paths = { M.venv_path(install_dir) },
+ })
+ :map_catching(function(result)
+ local pip_packages = vim.json.decode(result.stdout)
+ local normalized_pip_package = M.normalize_package(receipt.primary_source.package)
+ local pip_package = _.find_first(function(pkg)
+ return pkg.name == normalized_pip_package
+ end, pip_packages)
+ return Optional.of_nilable(pip_package)
+ :map(function(pkg)
+ return pkg.version
+ end)
+ :or_else_throw "Unable to find pip package."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { M.venv_path(install_dir) },
+ }
+end
+
+---@param install_dir string
+function M.venv_path(install_dir)
+ return path.concat { install_dir, VENV_DIR, platform.is_win and "Scripts" or "bin" }
+end
+
+return M
diff --git a/lua/mason-core/managers/powershell/init.lua b/lua/mason-core/managers/powershell/init.lua
new file mode 100644
index 00000000..209e0fe1
--- /dev/null
+++ b/lua/mason-core/managers/powershell/init.lua
@@ -0,0 +1,46 @@
+local spawn = require "mason-core.spawn"
+local process = require "mason-core.process"
+
+local M = {}
+
+local PWSHOPT = {
+ progress_preference = [[ $ProgressPreference = 'SilentlyContinue'; ]], -- https://stackoverflow.com/a/63301751
+ security_protocol = [[ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ]],
+}
+
+---@param script string
+---@param opts JobSpawnOpts | nil
+---@param custom_spawn JobSpawn | nil
+function M.script(script, opts, custom_spawn)
+ opts = opts or {}
+ ---@type JobSpawn
+ local spawner = custom_spawn or spawn
+ return spawner.powershell(vim.tbl_extend("keep", {
+ "-NoProfile",
+ on_spawn = function(_, stdio)
+ local stdin = stdio[1]
+ stdin:write(PWSHOPT.progress_preference)
+ stdin:write(PWSHOPT.security_protocol)
+ stdin:write(script)
+ stdin:close()
+ end,
+ env_raw = process.graft_env(opts.env or {}, { "PSMODULEPATH" }),
+ }, opts))
+end
+
+---@param command string
+---@param opts JobSpawnOpts | nil
+---@param custom_spawn JobSpawn | nil
+function M.command(command, opts, custom_spawn)
+ opts = opts or {}
+ ---@type JobSpawn
+ local spawner = custom_spawn or spawn
+ return spawner.powershell(vim.tbl_extend("keep", {
+ "-NoProfile",
+ "-Command",
+ PWSHOPT.progress_preference .. PWSHOPT.security_protocol .. command,
+ env_raw = process.graft_env(opts.env or {}, { "PSMODULEPATH" }),
+ }, opts))
+end
+
+return M
diff --git a/lua/mason-core/managers/std/init.lua b/lua/mason-core/managers/std/init.lua
new file mode 100644
index 00000000..e021a261
--- /dev/null
+++ b/lua/mason-core/managers/std/init.lua
@@ -0,0 +1,188 @@
+local a = require "mason-core.async"
+local installer = require "mason-core.installer"
+local fetch = require "mason-core.fetch"
+local platform = require "mason-core.platform"
+local powershell = require "mason-core.managers.powershell"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+
+local M = {}
+
+local function with_system_executable_receipt(executable)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.system(executable))
+ end
+end
+
+---@async
+---@param executable string
+---@param opts {help_url:string|nil}
+function M.system_executable(executable, opts)
+ return function()
+ M.ensure_executable(executable, opts).with_receipt()
+ end
+end
+
+---@async
+---@param executable string
+---@param opts {help_url:string|nil}
+function M.ensure_executable(executable, opts)
+ local ctx = installer.context()
+ opts = opts or {}
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ if vim.fn.executable(executable) ~= 1 then
+ ctx.stdio_sink.stderr(("%s was not found in path.\n"):format(executable))
+ if opts.help_url then
+ ctx.stdio_sink.stderr(("See %s for installation instructions.\n"):format(opts.help_url))
+ end
+ error("Installation failed: system executable was not found.", 0)
+ end
+
+ return {
+ with_receipt = with_system_executable_receipt(executable),
+ }
+end
+
+---@async
+---@param url string
+---@param out_file string
+function M.download_file(url, out_file)
+ local ctx = installer.context()
+ ctx.stdio_sink.stdout(("Downloading file %q...\n"):format(url))
+ fetch(url, {
+ out_file = path.concat { ctx.cwd:get(), out_file },
+ })
+ :map_err(function(err)
+ return ("Failed to download file %q.\n%s"):format(url, err)
+ end)
+ :get_or_throw()
+end
+
+---@async
+---@param file string
+---@param dest string
+function M.unzip(file, dest)
+ local ctx = installer.context()
+ platform.when {
+ unix = function()
+ ctx.spawn.unzip { "-d", dest, file }
+ end,
+ win = function()
+ powershell.command(
+ ("Microsoft.PowerShell.Archive\\Expand-Archive -Path %q -DestinationPath %q"):format(file, dest),
+ {},
+ ctx.spawn
+ )
+ end,
+ }
+ pcall(function()
+ -- make sure the .zip archive doesn't linger
+ ctx.fs:unlink(file)
+ end)
+end
+
+---@param file string
+local function win_extract(file)
+ local ctx = installer.context()
+ Result.run_catching(function()
+ ctx.spawn["7z"] { "x", "-y", "-r", file }
+ end)
+ :recover_catching(function()
+ ctx.spawn.peazip { "-ext2here", path.concat { ctx.cwd:get(), file } } -- peazip requires absolute paths
+ end)
+ :recover_catching(function()
+ ctx.spawn.wzunzip { file }
+ end)
+ :recover_catching(function()
+ ctx.spawn.winrar { "e", file }
+ end)
+ :get_or_throw(("Unable to unpack %s."):format(file))
+end
+
+---@async
+---@param file string
+---@param opts {strip_components:integer}|nil
+function M.untar(file, opts)
+ opts = opts or {}
+ local ctx = installer.context()
+ ctx.spawn.tar {
+ opts.strip_components and { "--strip-components", opts.strip_components } or vim.NIL,
+ "--no-same-owner",
+ "-xvf",
+ file,
+ }
+ pcall(function()
+ ctx.fs:unlink(file)
+ end)
+end
+
+---@async
+---@param file string
+---@param opts {strip_components:integer}|nil
+function M.untarxz(file, opts)
+ opts = opts or {}
+ local ctx = installer.context()
+ platform.when {
+ unix = function()
+ M.untar(file, opts)
+ end,
+ win = function()
+ Result.run_catching(function()
+ win_extract(file) -- unpack .tar.xz to .tar
+ local uncompressed_tar = file:gsub(".xz$", "")
+ M.untar(uncompressed_tar, opts)
+ end):recover(function()
+ ctx.spawn.arc {
+ "unarchive",
+ opts.strip_components and { "--strip-components", opts.strip_components } or vim.NIL,
+ file,
+ }
+ pcall(function()
+ ctx.fs:unlink(file)
+ end)
+ end)
+ end,
+ }
+end
+
+---@async
+---@param file string
+function M.gunzip(file)
+ platform.when {
+ unix = function()
+ local ctx = installer.context()
+ ctx.spawn.gzip { "-d", file }
+ end,
+ win = function()
+ win_extract(file)
+ end,
+ }
+end
+
+---@async
+---@param flags string @The chmod flag to apply.
+---@param files string[] @A list of relative paths to apply the chmod on.
+function M.chmod(flags, files)
+ if platform.is_unix then
+ local ctx = installer.context()
+ ctx.spawn.chmod { flags, files }
+ end
+end
+
+---@async
+---Wrapper around vim.ui.select.
+---@param items table
+---@params opts
+function M.select(items, opts)
+ assert(not platform.is_headless, "Tried to prompt for user input while in headless mode.")
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ local async_select = a.promisify(vim.ui.select)
+ return async_select(items, opts)
+end
+
+return M