diff options
| author | William Boman <william@redwill.se> | 2022-07-08 18:34:38 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-07-08 18:34:38 +0200 |
| commit | 976aa4fbee8a070f362cab6f6ec84e9251a90cf9 (patch) | |
| tree | 5e8d9c9c59444a25c7801b8f39763c4ba6e1f76d /lua/mason-core/managers | |
| parent | feat: add gotests, gomodifytags, impl (#28) (diff) | |
| download | mason-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.lua | 14 | ||||
| -rw-r--r-- | lua/mason-core/managers/cargo/init.lua | 140 | ||||
| -rw-r--r-- | lua/mason-core/managers/composer/init.lua | 135 | ||||
| -rw-r--r-- | lua/mason-core/managers/dotnet/init.lua | 64 | ||||
| -rw-r--r-- | lua/mason-core/managers/gem/init.lua | 159 | ||||
| -rw-r--r-- | lua/mason-core/managers/git/init.lua | 76 | ||||
| -rw-r--r-- | lua/mason-core/managers/github/client.lua | 117 | ||||
| -rw-r--r-- | lua/mason-core/managers/github/init.lua | 171 | ||||
| -rw-r--r-- | lua/mason-core/managers/go/init.lua | 171 | ||||
| -rw-r--r-- | lua/mason-core/managers/luarocks/init.lua | 144 | ||||
| -rw-r--r-- | lua/mason-core/managers/npm/init.lua | 143 | ||||
| -rw-r--r-- | lua/mason-core/managers/opam/init.lua | 69 | ||||
| -rw-r--r-- | lua/mason-core/managers/pip3/init.lua | 175 | ||||
| -rw-r--r-- | lua/mason-core/managers/powershell/init.lua | 46 | ||||
| -rw-r--r-- | lua/mason-core/managers/std/init.lua | 188 |
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 |
