From 047ec18da56ad8f331e5c6bc7417dc5a9a6e71cc Mon Sep 17 00:00:00 2001 From: William Boman Date: Wed, 11 Oct 2023 16:31:50 +0200 Subject: refactor!: refactor installer internals and add new Package class methods (#1523) This contains the following changes: 1) `Package:install()` now accepts a second, optional, callback argument which is called when installation finishes (successfully or not). 2) Adds a `Package:is_installing()` method. This contains the following breaking changes: 1) `Package:install()` will now error when called while an installation is already ongoing. Use the new `Package:is_installing()` method to check whether an installation is already running. This also refactors large portions of the tests by removing test globals, removing async_test, and adding the `mason-test` Lua module instead. Test helpers via globals are problematic to work with due to not being detected through tools like the Lua language server without additional configuration. This has been replaced with a Lua module `mason-test`. `async_test` has also been removed in favour of explicitly making use of the `mason-core.async` API. These changes stands for a significant portion of the diff. --- .../installer/compiler/compilers/cargo.lua | 85 +++++ .../installer/compiler/compilers/composer.lua | 33 ++ .../installer/compiler/compilers/gem.lua | 46 +++ .../installer/compiler/compilers/generic/build.lua | 40 +++ .../compiler/compilers/generic/download.lua | 52 +++ .../installer/compiler/compilers/generic/init.lua | 42 +++ .../installer/compiler/compilers/github/build.lua | 51 +++ .../installer/compiler/compilers/github/init.lua | 49 +++ .../compiler/compilers/github/release.lua | 57 +++ .../installer/compiler/compilers/golang.lua | 50 +++ .../installer/compiler/compilers/luarocks.lua | 51 +++ .../installer/compiler/compilers/mason.lua | 43 +++ .../installer/compiler/compilers/npm.lua | 52 +++ .../installer/compiler/compilers/nuget.lua | 31 ++ .../installer/compiler/compilers/opam.lua | 31 ++ .../installer/compiler/compilers/openvsx.lua | 64 ++++ .../installer/compiler/compilers/pypi.lua | 66 ++++ lua/mason-core/installer/compiler/expr.lua | 110 ++++++ lua/mason-core/installer/compiler/init.lua | 221 ++++++++++++ lua/mason-core/installer/compiler/link.lua | 293 +++++++++++++++ lua/mason-core/installer/compiler/schemas.lua | 75 ++++ lua/mason-core/installer/compiler/util.lua | 83 +++++ lua/mason-core/installer/context.lua | 399 --------------------- lua/mason-core/installer/context/cwd.lua | 48 +++ lua/mason-core/installer/context/fs.lua | 108 ++++++ lua/mason-core/installer/context/init.lua | 261 ++++++++++++++ lua/mason-core/installer/context/spawn.lua | 46 +++ lua/mason-core/installer/handle.lua | 4 + lua/mason-core/installer/init.lua | 255 +------------ lua/mason-core/installer/location.lua | 63 ++++ lua/mason-core/installer/registry/expr.lua | 110 ------ lua/mason-core/installer/registry/init.lua | 219 ----------- lua/mason-core/installer/registry/link.lua | 293 --------------- .../installer/registry/providers/cargo.lua | 85 ----- .../installer/registry/providers/composer.lua | 33 -- .../installer/registry/providers/gem.lua | 46 --- .../installer/registry/providers/generic/build.lua | 40 --- .../registry/providers/generic/download.lua | 52 --- .../installer/registry/providers/generic/init.lua | 42 --- .../installer/registry/providers/github/build.lua | 51 --- .../installer/registry/providers/github/init.lua | 49 --- .../registry/providers/github/release.lua | 54 --- .../installer/registry/providers/golang.lua | 50 --- .../installer/registry/providers/luarocks.lua | 51 --- .../installer/registry/providers/mason.lua | 43 --- .../installer/registry/providers/npm.lua | 52 --- .../installer/registry/providers/nuget.lua | 31 -- .../installer/registry/providers/opam.lua | 31 -- .../installer/registry/providers/openvsx.lua | 63 ---- .../installer/registry/providers/pypi.lua | 66 ---- lua/mason-core/installer/registry/schemas.lua | 75 ---- lua/mason-core/installer/registry/util.lua | 83 ----- lua/mason-core/installer/runner.lua | 218 +++++++++++ lua/mason-core/package/init.lua | 114 +++--- lua/mason-core/receipt.lua | 14 +- lua/mason-registry/sources/util.lua | 4 +- lua/mason-test/helpers.lua | 33 ++ 57 files changed, 2464 insertions(+), 2347 deletions(-) create mode 100644 lua/mason-core/installer/compiler/compilers/cargo.lua create mode 100644 lua/mason-core/installer/compiler/compilers/composer.lua create mode 100644 lua/mason-core/installer/compiler/compilers/gem.lua create mode 100644 lua/mason-core/installer/compiler/compilers/generic/build.lua create mode 100644 lua/mason-core/installer/compiler/compilers/generic/download.lua create mode 100644 lua/mason-core/installer/compiler/compilers/generic/init.lua create mode 100644 lua/mason-core/installer/compiler/compilers/github/build.lua create mode 100644 lua/mason-core/installer/compiler/compilers/github/init.lua create mode 100644 lua/mason-core/installer/compiler/compilers/github/release.lua create mode 100644 lua/mason-core/installer/compiler/compilers/golang.lua create mode 100644 lua/mason-core/installer/compiler/compilers/luarocks.lua create mode 100644 lua/mason-core/installer/compiler/compilers/mason.lua create mode 100644 lua/mason-core/installer/compiler/compilers/npm.lua create mode 100644 lua/mason-core/installer/compiler/compilers/nuget.lua create mode 100644 lua/mason-core/installer/compiler/compilers/opam.lua create mode 100644 lua/mason-core/installer/compiler/compilers/openvsx.lua create mode 100644 lua/mason-core/installer/compiler/compilers/pypi.lua create mode 100644 lua/mason-core/installer/compiler/expr.lua create mode 100644 lua/mason-core/installer/compiler/init.lua create mode 100644 lua/mason-core/installer/compiler/link.lua create mode 100644 lua/mason-core/installer/compiler/schemas.lua create mode 100644 lua/mason-core/installer/compiler/util.lua delete mode 100644 lua/mason-core/installer/context.lua create mode 100644 lua/mason-core/installer/context/cwd.lua create mode 100644 lua/mason-core/installer/context/fs.lua create mode 100644 lua/mason-core/installer/context/init.lua create mode 100644 lua/mason-core/installer/context/spawn.lua create mode 100644 lua/mason-core/installer/location.lua delete mode 100644 lua/mason-core/installer/registry/expr.lua delete mode 100644 lua/mason-core/installer/registry/init.lua delete mode 100644 lua/mason-core/installer/registry/link.lua delete mode 100644 lua/mason-core/installer/registry/providers/cargo.lua delete mode 100644 lua/mason-core/installer/registry/providers/composer.lua delete mode 100644 lua/mason-core/installer/registry/providers/gem.lua delete mode 100644 lua/mason-core/installer/registry/providers/generic/build.lua delete mode 100644 lua/mason-core/installer/registry/providers/generic/download.lua delete mode 100644 lua/mason-core/installer/registry/providers/generic/init.lua delete mode 100644 lua/mason-core/installer/registry/providers/github/build.lua delete mode 100644 lua/mason-core/installer/registry/providers/github/init.lua delete mode 100644 lua/mason-core/installer/registry/providers/github/release.lua delete mode 100644 lua/mason-core/installer/registry/providers/golang.lua delete mode 100644 lua/mason-core/installer/registry/providers/luarocks.lua delete mode 100644 lua/mason-core/installer/registry/providers/mason.lua delete mode 100644 lua/mason-core/installer/registry/providers/npm.lua delete mode 100644 lua/mason-core/installer/registry/providers/nuget.lua delete mode 100644 lua/mason-core/installer/registry/providers/opam.lua delete mode 100644 lua/mason-core/installer/registry/providers/openvsx.lua delete mode 100644 lua/mason-core/installer/registry/providers/pypi.lua delete mode 100644 lua/mason-core/installer/registry/schemas.lua delete mode 100644 lua/mason-core/installer/registry/util.lua create mode 100644 lua/mason-core/installer/runner.lua create mode 100644 lua/mason-test/helpers.lua (limited to 'lua') diff --git a/lua/mason-core/installer/compiler/compilers/cargo.lua b/lua/mason-core/installer/compiler/compilers/cargo.lua new file mode 100644 index 00000000..e0f281c5 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/cargo.lua @@ -0,0 +1,85 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local providers = require "mason-core.providers" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class CargoSource : RegistryPackageSource +---@field supported_platforms? string[] + +---@param source CargoSource +---@param purl Purl +function M.parse(source, purl) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + + local repository_url = _.path({ "qualifiers", "repository_url" }, purl) + + local git + if repository_url then + git = { + url = repository_url, + rev = _.path({ "qualifiers", "rev" }, purl) == "true", + } + end + + ---@type string? + local features = _.path({ "qualifiers", "features" }, purl) + local locked = _.path({ "qualifiers", "locked" }, purl) + + ---@class ParsedCargoSource : ParsedPackageSource + local parsed_source = { + crate = purl.name, + version = purl.version, + features = features, + locked = locked ~= "false", + git = git, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedCargoSource +function M.install(ctx, source) + local cargo = require "mason-core.installer.managers.cargo" + + return cargo.install(source.crate, source.version, { + git = source.git, + features = source.features, + locked = source.locked, + }) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + ---@type string? + local repository_url = _.path({ "qualifiers", "repository_url" }, purl) + local rev = _.path({ "qualifiers", "rev" }, purl) + if repository_url then + if rev == "true" then + -- When ?rev=true we're targeting a commit SHA. It's not feasible to retrieve all commit SHAs for a + -- repository so we fail instead. + return Result.failure "Unable to retrieve commit SHAs." + end + + ---@type Result? + local git_tags = _.cond { + { + _.matches "github.com/(.+)", + _.compose(providers.github.get_all_tags, _.head, _.match "github.com/(.+)"), + }, + }(repository_url) + if git_tags then + return git_tags + end + end + return providers.crates.get_all_versions(purl.name) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/composer.lua b/lua/mason-core/installer/compiler/compilers/composer.lua new file mode 100644 index 00000000..259512a2 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/composer.lua @@ -0,0 +1,33 @@ +local Result = require "mason-core.result" +local providers = require "mason-core.providers" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedComposerSource : ParsedPackageSource + local parsed_source = { + package = ("%s/%s"):format(purl.namespace, purl.name), + version = purl.version, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedComposerSource +function M.install(ctx, source) + local composer = require "mason-core.installer.managers.composer" + return composer.install(source.package, source.version) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return providers.packagist.get_all_versions(("%s/%s"):format(purl.namespace, purl.name)) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/gem.lua b/lua/mason-core/installer/compiler/compilers/gem.lua new file mode 100644 index 00000000..7a343eec --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/gem.lua @@ -0,0 +1,46 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local providers = require "mason-core.providers" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class GemSource : RegistryPackageSource +---@field supported_platforms? string[] +---@field extra_packages? string[] + +---@param source GemSource +---@param purl Purl +function M.parse(source, purl) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + + ---@class ParsedGemSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + extra_packages = source.extra_packages, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGemSource +function M.install(ctx, source) + local gem = require "mason-core.installer.managers.gem" + return gem.install(source.package, source.version, { + extra_packages = source.extra_packages, + }) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return providers.rubygems.get_all_versions(purl.name) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/generic/build.lua b/lua/mason-core/installer/compiler/compilers/generic/build.lua new file mode 100644 index 00000000..df97a118 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/generic/build.lua @@ -0,0 +1,40 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local common = require "mason-core.installer.managers.common" +local expr = require "mason-core.installer.compiler.expr" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class GenericBuildSource : RegistryPackageSource +---@field build BuildInstruction | BuildInstruction[] + +---@param source GenericBuildSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + return Result.try(function(try) + ---@type BuildInstruction + local build_instruction = try(util.coalesce_by_target(source.build, opts)) + + if build_instruction.env then + local expr_ctx = { version = purl.version, target = build_instruction.target } + build_instruction.env = try(expr.tbl_interpolate(build_instruction.env, expr_ctx)) + end + + ---@class ParsedGenericBuildSource : ParsedPackageSource + local parsed_source = { + build = build_instruction, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGenericBuildSource +function M.install(ctx, source) + return common.run_build_instruction(source.build) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/generic/download.lua b/lua/mason-core/installer/compiler/compilers/generic/download.lua new file mode 100644 index 00000000..37e54d96 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/generic/download.lua @@ -0,0 +1,52 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local common = require "mason-core.installer.managers.common" +local expr = require "mason-core.installer.compiler.expr" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class GenericDownload +---@field target (Platform | Platform[])? +---@field files table + +---@class GenericDownloadSource : RegistryPackageSource +---@field download GenericDownload | GenericDownload[] + +---@param source GenericDownloadSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + return Result.try(function(try) + local download = try(util.coalesce_by_target(source.download, opts)) + + local expr_ctx = { version = purl.version } + ---@type { files: table } + local interpolated_download = try(expr.tbl_interpolate(download, expr_ctx)) + + ---@type DownloadItem[] + local downloads = _.map(function(pair) + ---@type DownloadItem + return { + out_file = pair[1], + download_url = pair[2], + } + end, _.to_pairs(interpolated_download.files)) + + ---@class ParsedGenericDownloadSource : ParsedPackageSource + local parsed_source = { + download = interpolated_download, + downloads = downloads, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGenericDownloadSource +function M.install(ctx, source) + return common.download_files(ctx, source.downloads) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/generic/init.lua b/lua/mason-core/installer/compiler/compilers/generic/init.lua new file mode 100644 index 00000000..8206883f --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/generic/init.lua @@ -0,0 +1,42 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" + +local M = {} + +---@param source GenericDownloadSource | GenericBuildSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + if source.download then + source = source --[[@as GenericDownloadSource]] + return require("mason-core.installer.compiler.compilers.generic.download").parse(source, purl, opts) + elseif source.build then + source = source --[[@as GenericBuildSource]] + return require("mason-core.installer.compiler.compilers.generic.build").parse(source, purl, opts) + else + return Result.failure "Unknown source type." + end +end + +---@async +---@param ctx InstallContext +---@param source ParsedGenericDownloadSource | ParsedGenericBuildSource +function M.install(ctx, source) + if source.download then + source = source --[[@as ParsedGenericDownloadSource]] + return require("mason-core.installer.compiler.compilers.generic.download").install(ctx, source) + elseif source.build then + source = source --[[@as ParsedGenericBuildSource]] + return require("mason-core.installer.compiler.compilers.generic.build").install(ctx, source) + else + return Result.failure "Unknown source type." + end +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return Result.failure "Unimplemented." +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/github/build.lua b/lua/mason-core/installer/compiler/compilers/github/build.lua new file mode 100644 index 00000000..22f3e3cc --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/github/build.lua @@ -0,0 +1,51 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local common = require "mason-core.installer.managers.common" +local expr = require "mason-core.installer.compiler.expr" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class GitHubBuildSource : RegistryPackageSource +---@field build BuildInstruction | BuildInstruction[] + +---@param source GitHubBuildSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + return Result.try(function(try) + ---@type BuildInstruction + local build_instruction = try(util.coalesce_by_target(source.build, opts)) + + local expr_ctx = { version = purl.version } + + -- TODO: In a few releases of the core registry, r-languageserver reads $MASON_VERSION directly. Remove this + -- some time in the future. + local default_env = { + MASON_VERSION = purl.version, + } + build_instruction.env = + vim.tbl_extend("force", default_env, try(expr.tbl_interpolate(build_instruction.env or {}, expr_ctx))) + + ---@class ParsedGitHubBuildSource : ParsedPackageSource + local parsed_source = { + build = build_instruction, + repo = ("https://github.com/%s/%s.git"):format(purl.namespace, purl.name), + rev = purl.version, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGitHubBuildSource +function M.install(ctx, source) + local std = require "mason-core.installer.managers.std" + return Result.try(function(try) + try(std.clone(source.repo, { rev = source.rev })) + try(common.run_build_instruction(source.build)) + end) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/github/init.lua b/lua/mason-core/installer/compiler/compilers/github/init.lua new file mode 100644 index 00000000..d8646975 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/github/init.lua @@ -0,0 +1,49 @@ +local Result = require "mason-core.result" + +local M = {} + +---@param source GitHubReleaseSource | GitHubBuildSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + if source.asset then + source = source --[[@as GitHubReleaseSource]] + return require("mason-core.installer.compiler.compilers.github.release").parse(source, purl, opts) + elseif source.build then + source = source --[[@as GitHubBuildSource]] + return require("mason-core.installer.compiler.compilers.github.build").parse(source, purl, opts) + else + return Result.failure "Unknown source type." + end +end + +---@async +---@param ctx InstallContext +---@param source ParsedGitHubReleaseSource | ParsedGitHubBuildSource +function M.install(ctx, source, purl) + if source.asset then + source = source--[[@as ParsedGitHubReleaseSource]] + return require("mason-core.installer.compiler.compilers.github.release").install(ctx, source) + elseif source.build then + source = source--[[@as ParsedGitHubBuildSource]] + return require("mason-core.installer.compiler.compilers.github.build").install(ctx, source) + else + return Result.failure "Unknown source type." + end +end + +---@async +---@param purl Purl +---@param source GitHubReleaseSource | GitHubBuildSource +function M.get_versions(purl, source) + if source.asset then + return require("mason-core.installer.compiler.compilers.github.release").get_versions(purl) + elseif source.build then + -- We can't yet reliably determine the true source (release, tag, commit, etc.) for "build" sources. + return Result.failure "Unimplemented." + else + return Result.failure "Unknown source type." + end +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/github/release.lua b/lua/mason-core/installer/compiler/compilers/github/release.lua new file mode 100644 index 00000000..39f7d862 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/github/release.lua @@ -0,0 +1,57 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local common = require "mason-core.installer.managers.common" +local expr = require "mason-core.installer.compiler.expr" +local providers = require "mason-core.providers" +local settings = require "mason.settings" +local util = require "mason-core.installer.compiler.util" + +---@class GitHubReleaseSourceAsset : FileDownloadSpec +---@field target? Platform | Platform[] + +---@class GitHubReleaseSource : RegistryPackageSource +---@field asset GitHubReleaseSourceAsset | GitHubReleaseSourceAsset[] + +local M = {} + +---@param source GitHubReleaseSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + return Result.try(function(try) + local expr_ctx = { version = purl.version } + ---@type GitHubReleaseSourceAsset + local asset = try(util.coalesce_by_target(try(expr.tbl_interpolate(source.asset, expr_ctx)), opts)) + + local downloads = common.parse_downloads(asset, function(file) + return settings.current.github.download_url_template:format( + ("%s/%s"):format(purl.namespace, purl.name), + purl.version, + file + ) + end) + + ---@class ParsedGitHubReleaseSource : ParsedPackageSource + local parsed_source = { + repo = ("%s/%s"):format(purl.namespace, purl.name), + asset = common.normalize_files(asset), + downloads = downloads, + } + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGitHubReleaseSource +function M.install(ctx, source) + return common.download_files(ctx, source.downloads) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return providers.github.get_all_release_versions(("%s/%s"):format(purl.namespace, purl.name)) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/golang.lua b/lua/mason-core/installer/compiler/compilers/golang.lua new file mode 100644 index 00000000..01807088 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/golang.lua @@ -0,0 +1,50 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local providers = require "mason-core.providers" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@param purl Purl +local function get_package_name(purl) + if purl.subpath then + return ("%s/%s/%s"):format(purl.namespace, purl.name, purl.subpath) + else + return ("%s/%s"):format(purl.namespace, purl.name) + end +end + +---@class GolangSource : RegistryPackageSource +---@field extra_packages? string[] + +---@param source GolangSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedGolangSource : ParsedPackageSource + local parsed_source = { + package = get_package_name(purl), + version = purl.version, + extra_packages = source.extra_packages, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedGolangSource +function M.install(ctx, source) + local golang = require "mason-core.installer.managers.golang" + + return golang.install(source.package, source.version, { + extra_packages = source.extra_packages, + }) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return providers.golang.get_all_versions(("%s/%s"):format(purl.namespace, purl.name)) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/luarocks.lua b/lua/mason-core/installer/compiler/compilers/luarocks.lua new file mode 100644 index 00000000..356857c0 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/luarocks.lua @@ -0,0 +1,51 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" + +local M = {} + +---@param purl Purl +local function parse_package_name(purl) + if purl.namespace then + return ("%s/%s"):format(purl.namespace, purl.name) + else + return purl.name + end +end + +local parse_server = _.path { "qualifiers", "repository_url" } +local parse_dev = _.compose(_.equals "true", _.path { "qualifiers", "dev" }) + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedLuaRocksSource : ParsedPackageSource + local parsed_source = { + package = parse_package_name(purl), + version = purl.version, + ---@type string? + server = parse_server(purl), + ---@type boolean? + dev = parse_dev(purl), + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedLuaRocksSource +function M.install(ctx, source) + local luarocks = require "mason-core.installer.managers.luarocks" + return luarocks.install(source.package, source.version, { + server = source.server, + dev = source.dev, + }) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return Result.failure "Unimplemented." +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/mason.lua b/lua/mason-core/installer/compiler/compilers/mason.lua new file mode 100644 index 00000000..3490ebaa --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/mason.lua @@ -0,0 +1,43 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + if type(source.install) ~= "function" and type((getmetatable(source.install) or {}).__call) ~= "function" then + return Result.failure "source.install is not a function." + end + + ---@class ParsedMasonSource : ParsedPackageSource + local parsed_source = { + purl = purl, + ---@type async fun(ctx: InstallContext, purl: Purl) + install = source.install, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedMasonSource +function M.install(ctx, source) + ctx.spawn.strict_mode = true + return Result.pcall(source.install, ctx, source.purl) + :on_success(function() + ctx.spawn.strict_mode = false + end) + :on_failure(function() + ctx.spawn.strict_mode = false + end) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return Result.failure "Unimplemented." +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/npm.lua b/lua/mason-core/installer/compiler/compilers/npm.lua new file mode 100644 index 00000000..e8489fe8 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/npm.lua @@ -0,0 +1,52 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local providers = require "mason-core.providers" + +---@param purl Purl +local function purl_to_npm(purl) + if purl.namespace then + return ("%s/%s"):format(purl.namespace, purl.name) + else + return purl.name + end +end + +local M = {} + +---@class NpmSource : RegistryPackageSource +---@field extra_packages? string[] + +---@param source NpmSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedNpmSource : ParsedPackageSource + local parsed_source = { + package = purl_to_npm(purl), + version = purl.version, + extra_packages = source.extra_packages, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedNpmSource +function M.install(ctx, source) + local npm = require "mason-core.installer.managers.npm" + + return Result.try(function(try) + try(npm.init()) + try(npm.install(source.package, source.version, { + extra_packages = source.extra_packages, + })) + end) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return providers.npm.get_all_versions(purl_to_npm(purl)) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/nuget.lua b/lua/mason-core/installer/compiler/compilers/nuget.lua new file mode 100644 index 00000000..370c7b95 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/nuget.lua @@ -0,0 +1,31 @@ +local Result = require "mason-core.result" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedNugetSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedNugetSource +function M.install(ctx, source) + local nuget = require "mason-core.installer.managers.nuget" + return nuget.install(source.package, source.version) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return Result.failure "Unimplemented." +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/opam.lua b/lua/mason-core/installer/compiler/compilers/opam.lua new file mode 100644 index 00000000..276686ae --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/opam.lua @@ -0,0 +1,31 @@ +local Result = require "mason-core.result" + +local M = {} + +---@param source RegistryPackageSource +---@param purl Purl +function M.parse(source, purl) + ---@class ParsedOpamSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version, + } + + return Result.success(parsed_source) +end + +---@async +---@param ctx InstallContext +---@param source ParsedOpamSource +function M.install(ctx, source) + local opam = require "mason-core.installer.managers.opam" + return opam.install(source.package, source.version) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return Result.failure "Unimplemented." +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/openvsx.lua b/lua/mason-core/installer/compiler/compilers/openvsx.lua new file mode 100644 index 00000000..bf31e2f9 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/openvsx.lua @@ -0,0 +1,64 @@ +local Result = require "mason-core.result" +local common = require "mason-core.installer.managers.common" +local expr = require "mason-core.installer.compiler.expr" +local providers = require "mason-core.providers" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class OpenVSXSourceDownload : FileDownloadSpec +---@field target? Platform | Platform[] +---@field target_platform? string + +---@class OpenVSXSource : RegistryPackageSource +---@field download OpenVSXSourceDownload | OpenVSXSourceDownload[] + +---@param source OpenVSXSource +---@param purl Purl +---@param opts PackageInstallOpts +function M.parse(source, purl, opts) + return Result.try(function(try) + local expr_ctx = { version = purl.version } + ---@type OpenVSXSourceDownload + local download = try(util.coalesce_by_target(try(expr.tbl_interpolate(source.download, expr_ctx)), opts)) + + local downloads = common.parse_downloads(download, function(file) + if download.target_platform then + return ("https://open-vsx.org/api/%s/%s/%s/%s/file/%s"):format( + purl.namespace, + purl.name, + download.target_platform, + purl.version, + file + ) + else + return ("https://open-vsx.org/api/%s/%s/%s/file/%s"):format( + purl.namespace, + purl.name, + purl.version, + file + ) + end + end) + + ---@class ParsedOpenVSXSource : ParsedPackageSource + local parsed_source = { + download = common.normalize_files(download), + downloads = downloads, + } + return parsed_source + end) +end + +---@param ctx InstallContext +---@param source ParsedOpenVSXSource +function M.install(ctx, source) + return common.download_files(ctx, source.downloads) +end + +---@param purl Purl +function M.get_versions(purl) + return providers.openvsx.get_all_versions(purl.namespace, purl.name) +end + +return M diff --git a/lua/mason-core/installer/compiler/compilers/pypi.lua b/lua/mason-core/installer/compiler/compilers/pypi.lua new file mode 100644 index 00000000..c44fcfe1 --- /dev/null +++ b/lua/mason-core/installer/compiler/compilers/pypi.lua @@ -0,0 +1,66 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local providers = require "mason-core.providers" +local settings = require "mason.settings" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@class PypiSource : RegistryPackageSource +---@field extra_packages? string[] +---@field supported_platforms? string[] + +---@param source PypiSource +---@param purl Purl +function M.parse(source, purl) + return Result.try(function(try) + if source.supported_platforms then + try(util.ensure_valid_platform(source.supported_platforms)) + end + + ---@class ParsedPypiSource : ParsedPackageSource + local parsed_source = { + package = purl.name, + version = purl.version --[[ @as string ]], + extra = _.path({ "qualifiers", "extra" }, purl), + extra_packages = source.extra_packages, + pip = { + upgrade = settings.current.pip.upgrade_pip, + extra_args = settings.current.pip.install_args, + }, + } + + return parsed_source + end) +end + +---@async +---@param ctx InstallContext +---@param source ParsedPypiSource +function M.install(ctx, source) + local pypi = require "mason-core.installer.managers.pypi" + + return Result.try(function(try) + try(pypi.init { + package = { + name = source.package, + version = source.version, + }, + upgrade_pip = source.pip.upgrade, + install_extra_args = source.pip.extra_args, + }) + try(pypi.install(source.package, source.version, { + extra = source.extra, + extra_packages = source.extra_packages, + install_extra_args = source.pip.extra_args, + })) + end) +end + +---@async +---@param purl Purl +function M.get_versions(purl) + return providers.pypi.get_all_versions(purl.name) +end + +return M diff --git a/lua/mason-core/installer/compiler/expr.lua b/lua/mason-core/installer/compiler/expr.lua new file mode 100644 index 00000000..a07fc00d --- /dev/null +++ b/lua/mason-core/installer/compiler/expr.lua @@ -0,0 +1,110 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local platform = require "mason-core.platform" + +local M = {} + +local parse_expr = _.compose( + _.apply_spec { + value_expr = _.head, + filters = _.drop(1), + }, + _.filter(_.complement(_.equals "")), + _.map(_.trim), + _.split "|" +) + +---@param predicate (fun(value: string): boolean) | boolean +---@param value string +local take_if = _.curryN(function(predicate, value) + if type(predicate) == "boolean" then + predicate = _.always(predicate) + end + return predicate(value) and value or nil +end, 2) + +---@param predicate (fun(value: string): boolean) | boolean +---@param value string +local take_if_not = _.curryN(function(predicate, value) + if type(predicate) == "boolean" then + predicate = _.always(predicate) + end + return (not predicate(value)) and value or nil +end, 2) + +local FILTERS = { + equals = _.equals, + not_equals = _.not_equals, + strip_prefix = _.strip_prefix, + strip_suffix = _.strip_suffix, + take_if = take_if, + take_if_not = take_if_not, + to_lower = _.to_lower, + to_upper = _.to_upper, + is_platform = function(target) + return platform.is[target] + end, +} + +---@generic T : table +---@param tbl T +---@return T +local function shallow_clone(tbl) + local res = {} + for k, v in pairs(tbl) do + res[k] = v + end + return res +end + +---@param expr string +---@param ctx table +local function eval(expr, ctx) + return setfenv(assert(loadstring("return " .. expr), ("Failed to parse expression: %q"):format(expr)), ctx)() +end + +---@param str string +---@param ctx table +function M.interpolate(str, ctx) + ctx = shallow_clone(ctx) + setmetatable(ctx, { __index = FILTERS }) + return Result.pcall(function() + return _.gsub("{{([^}]+)}}", function(expr) + local components = parse_expr(expr) + + local value = eval(components.value_expr, ctx) + + local filters = _.map(function(filter_expr) + local filter = eval(filter_expr, ctx) + assert(type(filter) == "function", ("Invalid filter expression: %q"):format(filter_expr)) + return filter + end, components.filters) + + local reduced_value = _.reduce(_.apply_to, value, filters) + + return reduced_value ~= nil and tostring(reduced_value) or "" + end, str) + end) +end + +---@generic T : table +---@param tbl T +---@param ctx table +---@return Result # Result +function M.tbl_interpolate(tbl, ctx) + return Result.try(function(try) + local interpolated = {} + for k, v in pairs(tbl) do + if type(v) == "string" then + interpolated[k] = try(M.interpolate(v, ctx)) + elseif type(v) == "table" then + interpolated[k] = try(M.tbl_interpolate(v, ctx)) + else + interpolated[k] = v + end + end + return interpolated + end) +end + +return M diff --git a/lua/mason-core/installer/compiler/init.lua b/lua/mason-core/installer/compiler/init.lua new file mode 100644 index 00000000..e1df6784 --- /dev/null +++ b/lua/mason-core/installer/compiler/init.lua @@ -0,0 +1,221 @@ +local Optional = require "mason-core.optional" +local Purl = require "mason-core.purl" +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local a = require "mason-core.async" +local link = require "mason-core.installer.compiler.link" +local log = require "mason-core.log" +local schemas = require "mason-core.installer.compiler.schemas" +local util = require "mason-core.installer.compiler.util" + +local M = {} + +---@type table +M.SCHEMA_CAP = _.set_of { + "registry+v1", +} + +---@type table +local COMPILERS = {} + +---@param id string +---@param compiler InstallerCompiler +function M.register_compiler(id, compiler) + COMPILERS[id] = compiler +end + +M.register_compiler("cargo", _.lazy_require "mason-core.installer.compiler.compilers.cargo") +M.register_compiler("composer", _.lazy_require "mason-core.installer.compiler.compilers.composer") +M.register_compiler("gem", _.lazy_require "mason-core.installer.compiler.compilers.gem") +M.register_compiler("generic", _.lazy_require "mason-core.installer.compiler.compilers.generic") +M.register_compiler("github", _.lazy_require "mason-core.installer.compiler.compilers.github") +M.register_compiler("golang", _.lazy_require "mason-core.installer.compiler.compilers.golang") +M.register_compiler("luarocks", _.lazy_require "mason-core.installer.compiler.compilers.luarocks") +M.register_compiler("mason", _.lazy_require "mason-core.installer.compiler.compilers.mason") +M.register_compiler("npm", _.lazy_require "mason-core.installer.compiler.compilers.npm") +M.register_compiler("nuget", _.lazy_require "mason-core.installer.compiler.compilers.nuget") +M.register_compiler("opam", _.lazy_require "mason-core.installer.compiler.compilers.opam") +M.register_compiler("openvsx", _.lazy_require "mason-core.installer.compiler.compilers.openvsx") +M.register_compiler("pypi", _.lazy_require "mason-core.installer.compiler.compilers.pypi") + +---@param purl Purl +---@return Result # Result +function M.get_compiler(purl) + return Optional.of_nilable(COMPILERS[purl.type]) + :ok_or(("Current version of mason.nvim is not capable of parsing package type %q."):format(purl.type)) +end + +---@class InstallerCompiler +---@field parse fun(source: RegistryPackageSource, purl: Purl, opts: PackageInstallOpts): Result +---@field install async fun(ctx: InstallContext, source: ParsedPackageSource, purl: Purl): Result +---@field get_versions async fun(purl: Purl, source: RegistryPackageSource): Result # Result + +---@class ParsedPackageSource + +---Upserts {dst} with contents of {src}. List table values will be merged, with contents of {src} prepended. +---@param dst table +---@param src table +local function upsert(dst, src) + for k, v in pairs(src) do + if type(v) == "table" then + if _.is_list(v) then + dst[k] = _.concat(v, dst[k] or {}) + else + dst[k] = upsert(dst[k] or {}, src[k]) + end + else + dst[k] = v + end + end + return dst +end + +---@param source RegistryPackageSource +---@param version string +local function coalesce_source(source, version) + if source.version_overrides then + for i = #source.version_overrides, 1, -1 do + local version_override = source.version_overrides[i] + local version_type, constraint = unpack(_.split(":", version_override.constraint)) + if version_type == "semver" then + local semver = require "mason-core.semver" + local version_match = Result.try(function(try) + local requested_version = try(semver.parse(version)) + if _.starts_with("<=", constraint) then + local rule_version = try(semver.parse(_.strip_prefix("<=", constraint))) + return requested_version <= rule_version + elseif _.starts_with(">=", constraint) then + local rule_version = try(semver.parse(_.strip_prefix(">=", constraint))) + return requested_version >= rule_version + else + local rule_version = try(semver.parse(constraint)) + return requested_version == rule_version + end + end):get_or_else(false) + + if version_match then + if version_override.id then + -- Because this entry provides its own purl id, it overrides the entire source definition. + return version_override + else + -- Upsert the default source with the contents of the version override. + return upsert(vim.deepcopy(source), _.dissoc("constraint", version_override)) + end + end + end + end + end + return source +end + +---@param spec RegistryPackageSpec +---@param opts PackageInstallOpts +function M.parse(spec, opts) + log.trace("Parsing spec", spec.name, opts) + return Result.try(function(try) + if not M.SCHEMA_CAP[spec.schema] then + return Result.failure( + ("Current version of mason.nvim is not capable of parsing package schema version %q."):format( + spec.schema + ) + ) + end + + local source = opts.version and coalesce_source(spec.source, opts.version) or spec.source + + ---@type Purl + local purl = try(Purl.parse(source.id)) + log.trace("Parsed purl.", source.id, purl) + if opts.version then + purl.version = opts.version + end + + ---@type InstallerCompiler + local compiler = try(M.get_compiler(purl)) + log.trace("Found compiler for purl.", source.id) + local parsed_source = try(compiler.parse(source, purl, opts)) + log.trace("Parsed source for purl.", source.id, parsed_source) + return { + compiler = compiler, + source = vim.tbl_extend("keep", parsed_source, source), + raw_source = source, + purl = purl, + } + end):on_failure(function(err) + log.debug("Failed to parse spec spec", spec.name, err) + end) +end + +---@async +---@param spec RegistryPackageSpec +---@param opts PackageInstallOpts +function M.compile(spec, opts) + log.debug("Compiling installer.", spec.name, opts) + return Result.try(function(try) + -- Parsers run synchronously and may access API functions, so we schedule before-hand. + a.scheduler() + + local map_parse_err = _.cond { + { + _.equals "PLATFORM_UNSUPPORTED", + function() + if opts.target then + return ("Platform %q is unsupported."):format(opts.target) + else + return "The current platform is unsupported." + end + end, + }, + { _.T, _.identity }, + } + + ---@type { purl: Purl, compiler: InstallerCompiler, source: ParsedPackageSource, raw_source: RegistryPackageSource } + local parsed = try(M.parse(spec, opts):map_err(map_parse_err)) + + ---@async + ---@param ctx InstallContext + return function(ctx) + return Result.try(function(try) + if ctx.opts.version then + try(util.ensure_valid_version(function() + return parsed.compiler.get_versions(parsed.purl, parsed.raw_source) + end)) + end + + -- Run installer + a.scheduler() + try(parsed.compiler.install(ctx, parsed.source, parsed.purl)) + + if spec.schemas then + local result = schemas.download(ctx, spec, parsed.purl, parsed.source):on_failure(function(err) + log.error("Failed to download schemas", ctx.package, err) + end) + if opts.strict then + -- schema download sources are not considered stable nor a critical feature, so we only fail in strict mode + try(result) + end + end + + -- Expand & register links + if spec.bin then + try(link.bin(ctx, spec, parsed.purl, parsed.source)) + end + if spec.share then + try(link.share(ctx, spec, parsed.purl, parsed.source)) + end + if spec.opt then + try(link.opt(ctx, spec, parsed.purl, parsed.source)) + end + + ctx.receipt:with_source { + type = ctx.package.spec.schema, + id = Purl.compile(parsed.purl), + } + end):on_failure(function(err) + error(err, 0) + end) + end + end) +end + +return M diff --git a/lua/mason-core/installer/compiler/link.lua b/lua/mason-core/installer/compiler/link.lua new file mode 100644 index 00000000..5d136322 --- /dev/null +++ b/lua/mason-core/installer/compiler/link.lua @@ -0,0 +1,293 @@ +local Optional = require "mason-core.optional" +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local a = require "mason-core.async" +local expr = require "mason-core.installer.compiler.expr" +local fs = require "mason-core.fs" +local log = require "mason-core.log" +local path = require "mason-core.path" +local platform = require "mason-core.platform" + +local M = {} + +local filter_empty_values = _.compose( + _.from_pairs, + _.filter(function(pair) + return pair[2] ~= "" + end), + _.to_pairs +) + +local bin_delegates = { + ["luarocks"] = function(target) + return require("mason-core.installer.managers.luarocks").bin_path(target) + end, + ["composer"] = function(target) + return require("mason-core.installer.managers.composer").bin_path(target) + end, + ["opam"] = function(target) + return require("mason-core.installer.managers.opam").bin_path(target) + end, + ["python"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + if not ctx.fs:file_exists(target) then + return Result.failure(("Cannot write python wrapper for path %q as it doesn't exist."):format(target)) + end + return Result.pcall(function() + local python = platform.is.win and "python" or "python3" + return ctx:write_shell_exec_wrapper( + bin, + ("%s %q"):format(python, path.concat { ctx.package:get_install_path(), target }) + ) + end) + end, + ["php"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_php_exec_wrapper(bin, target) + end) + end, + ["pyvenv"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_pyvenv_exec_wrapper(bin, target) + end) + end, + ["dotnet"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + if not ctx.fs:file_exists(target) then + return Result.failure(("Cannot write dotnet wrapper for path %q as it doesn't exist."):format(target)) + end + return Result.pcall(function() + return ctx:write_shell_exec_wrapper( + bin, + ("dotnet %q"):format(path.concat { + ctx.package:get_install_path(), + target, + }) + ) + end) + end, + ["node"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_node_exec_wrapper(bin, target) + end) + end, + ["ruby"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_ruby_exec_wrapper(bin, target) + end) + end, + ["exec"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + return Result.pcall(function() + return ctx:write_exec_wrapper(bin, target) + end) + end, + ["java-jar"] = function(target, bin) + local installer = require "mason-core.installer" + local ctx = installer.context() + if not ctx.fs:file_exists(target) then + return Result.failure(("Cannot write Java JAR wrapper for path %q as it doesn't exist."):format(target)) + end + return Result.pcall(function() + return ctx:write_shell_exec_wrapper( + bin, + ("java -jar %q"):format(path.concat { + ctx.package:get_install_path(), + target, + }) + ) + end) + end, + ["nuget"] = function(target) + return require("mason-core.installer.managers.nuget").bin_path(target) + end, + ["npm"] = function(target) + return require("mason-core.installer.managers.npm").bin_path(target) + end, + ["gem"] = function(target) + return require("mason-core.installer.managers.gem").create_bin_wrapper(target) + end, + ["cargo"] = function(target) + return require("mason-core.installer.managers.cargo").bin_path(target) + end, + ["pypi"] = function(target) + return require("mason-core.installer.managers.pypi").bin_path(target) + end, + ["golang"] = function(target) + return require("mason-core.installer.managers.golang").bin_path(target) + end, +} + +---Expands bin specification from spec and registers bins to be linked. +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +local function expand_bin(ctx, spec, purl, source) + log.debug("Registering bin links", ctx.package, spec.bin) + return Result.try(function(try) + local expr_ctx = { + version = purl.version, + source = source, + } + + local bin_table = spec.bin + if not bin_table then + log.fmt_debug("%s spec provides no bin.", ctx.package) + return + end + + local interpolated_bins = filter_empty_values(try(expr.tbl_interpolate(bin_table, expr_ctx))) + + local expanded_bin_table = {} + for bin, target in pairs(interpolated_bins) do + -- Expand "npm:typescript-language-server"-like expressions + local delegated_bin = _.match("^(.+):(.+)$", target) + if #delegated_bin > 0 then + local bin_type, executable = unpack(delegated_bin) + log.fmt_trace("Transforming managed executable=%s via %s", executable, bin_type) + local delegate = + try(Optional.of_nilable(bin_delegates[bin_type]):ok_or(("Unknown bin type: %s"):format(bin_type))) + target = try(delegate(executable, bin)) + end + + log.fmt_debug("Expanded bin link %s -> %s", bin, target) + if not ctx.fs:file_exists(target) then + return Result.failure(("Tried to link bin %q to non-existent target %q."):format(bin, target)) + end + + if platform.is.unix then + ctx.fs:chmod_exec(target) + end + + expanded_bin_table[bin] = target + end + return expanded_bin_table + end) +end + +local is_dir_path = _.matches "/$" + +---Expands symlink path specifications from spec and returns symlink file table. +---@async +---@param ctx InstallContext +---@param purl Purl +---@param source ParsedPackageSource +---@param file_spec_table table +local function expand_file_spec(ctx, purl, source, file_spec_table) + log.debug("Registering symlinks", ctx.package, file_spec_table) + return Result.try(function(try) + local expr_ctx = { version = purl.version, source = source } + + ---@type table + local interpolated_paths = filter_empty_values(try(expr.tbl_interpolate(file_spec_table, expr_ctx))) + + ---@type table + local expanded_links = {} + + for dest, source_path in pairs(interpolated_paths) do + local cwd = ctx.cwd:get() + + if is_dir_path(dest) then + -- linking dir -> dir + if not is_dir_path(source_path) then + return Result.failure(("Cannot link file %q to dir %q."):format(source_path, dest)) + end + + a.scheduler() + + local glob = path.concat { cwd, source_path } .. "**/*" + log.fmt_trace("Symlink glob for %s: %s", ctx.package, glob) + + ---@type string[] + local files = _.filter_map(function(abs_path) + -- fs.sync because async causes stack overflow on many files (TODO fix that) + if not fs.sync.file_exists(abs_path) then + -- only link actual files (e.g. exclude directory entries from glob) + return Optional.empty() + end + -- turn into relative paths + return Optional.of(abs_path:sub(#cwd + 2)) -- + 2 to remove leading path separator (/) + end, vim.fn.glob(glob, false, true)) + + log.fmt_trace("Expanded glob %s: %s", glob, files) + + for __, file in ipairs(files) do + -- File destination should be relative to the source directory. For example, should the source_path + -- be "gh_2.22.1_macOS_amd64/share/man/" and dest be "man/", it should link source files to the + -- following destinations: + -- + -- gh_2.22.1_macOS_amd64/share/man/ man/ + -- ------------------------------------------------------------------------- + -- gh_2.22.1_macOS_amd64/share/man/man1/gh.1 man/man1/gh.1 + -- gh_2.22.1_macOS_amd64/share/man/man1/gh-run.1 man/man1/gh-run.1 + -- gh_2.22.1_macOS_amd64/share/man/man1/gh-ssh-key.1 man/man1/gh-run.1 + -- + local file_dest = path.concat { + _.trim_end_matches("/", dest), + file:sub(#source_path + 1), + } + expanded_links[file_dest] = file + end + else + -- linking file -> file + if is_dir_path(source_path) then + return Result.failure(("Cannot link dir %q to file %q."):format(source_path, dest)) + end + expanded_links[dest] = source_path + end + end + + return expanded_links + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +---@nodiscard +M.bin = function(ctx, spec, purl, source) + return expand_bin(ctx, spec, purl, source):on_success(function(links) + ctx.links.bin = vim.tbl_extend("force", ctx.links.bin, links) + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +---@nodiscard +M.share = function(ctx, spec, purl, source) + return expand_file_spec(ctx, purl, source, spec.share):on_success(function(links) + ctx.links.share = vim.tbl_extend("force", ctx.links.share, links) + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +---@nodiscard +M.opt = function(ctx, spec, purl, source) + return expand_file_spec(ctx, purl, source, spec.opt):on_success(function(links) + ctx.links.opt = vim.tbl_extend("force", ctx.links.opt, links) + end) +end + +return M diff --git a/lua/mason-core/installer/compiler/schemas.lua b/lua/mason-core/installer/compiler/schemas.lua new file mode 100644 index 00000000..5e578dbd --- /dev/null +++ b/lua/mason-core/installer/compiler/schemas.lua @@ -0,0 +1,75 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local a = require "mason-core.async" +local expr = require "mason-core.installer.compiler.expr" +local fetch = require "mason-core.fetch" +local log = require "mason-core.log" +local path = require "mason-core.path" +local std = require "mason-core.installer.managers.std" + +local M = {} + +---@async +---@param ctx InstallContext +---@param url string +local function download_lsp_schema(ctx, url) + return Result.try(function(try) + local is_vscode_schema = _.starts_with("vscode:", url) + local out_file = path.concat { "mason-schemas", "lsp.json" } + local share_file = path.concat { "mason-schemas", "lsp", ("%s.json"):format(ctx.package.name) } + + if is_vscode_schema then + local url = unpack(_.match("^vscode:(.+)$", url)) + ctx.stdio_sink.stdout(("Downloading LSP configuration schema from %q…\n"):format(url)) + local json = try(fetch(url)) + + ---@type { contributes?: { configuration?: table } } + local schema = try(Result.pcall(vim.json.decode, json)) + local configuration = schema.contributes and schema.contributes.configuration + + if configuration then + ctx.fs:write_file(out_file, vim.json.encode(configuration) --[[@as string]]) + ctx.links.share[share_file] = out_file + else + return Result.failure "Unable to find LSP entry in VSCode schema." + end + else + ctx.stdio_sink.stdout(("Downloading LSP configuration schema from %q…\n"):format(url)) + try(std.download_file(url, out_file)) + ctx.links.share[share_file] = out_file + end + end) +end + +---@async +---@param ctx InstallContext +---@param spec RegistryPackageSpec +---@param purl Purl +---@param source ParsedPackageSource +---@nodiscard +function M.download(ctx, spec, purl, source) + return Result.try(function(try) + log.debug("schemas: download", ctx.package, spec.schemas) + local schemas = spec.schemas + if not schemas then + return + end + ---@type RegistryPackageSchemas + local interpolated_schemas = try(expr.tbl_interpolate(schemas, { version = purl.version, source = source })) + ctx.fs:mkdir "mason-schemas" + + if interpolated_schemas.lsp then + try(a.wait_first { + function() + return download_lsp_schema(ctx, interpolated_schemas.lsp) + end, + function() + a.sleep(5000) + return Result.failure "Schema download timed out." + end, + }) + end + end) +end + +return M diff --git a/lua/mason-core/installer/compiler/util.lua b/lua/mason-core/installer/compiler/util.lua new file mode 100644 index 00000000..b3735c9c --- /dev/null +++ b/lua/mason-core/installer/compiler/util.lua @@ -0,0 +1,83 @@ +local Optional = require "mason-core.optional" +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local installer = require "mason-core.installer" +local log = require "mason-core.log" +local platform = require "mason-core.platform" + +local M = {} + +---@generic T : { target: Platform | Platform[] } +---@param candidates T[] | T +---@param opts PackageInstallOpts +---@return Result # Result +function M.coalesce_by_target(candidates, opts) + if not _.is_list(candidates) then + return Result.success(candidates) + end + return Optional.of_nilable(_.find_first(function(asset) + if opts.target then + -- Matching against a provided target rather than the current platform is an escape hatch primarily meant + -- for automated testing purposes. + if type(asset.target) == "table" then + return _.any(_.equals(opts.target), asset.target) + else + return asset.target == opts.target + end + else + if type(asset.target) == "table" then + return _.any(function(target) + return platform.is[target] + end, asset.target) + else + return platform.is[asset.target] + end + end + end, candidates)):ok_or "PLATFORM_UNSUPPORTED" +end + +---Checks whether a custom version of a package installation corresponds to a valid version. +---@async +---@param versions_thunk async fun(): Result Result +function M.ensure_valid_version(versions_thunk) + local ctx = installer.context() + local version = ctx.opts.version + + if version and not ctx.opts.force then + ctx.stdio_sink.stdout "Fetching available versions…\n" + local all_versions = versions_thunk() + if all_versions:is_failure() then + log.warn("Failed to fetch versions for package", ctx.package) + -- Gracefully fail (i.e. optimistically continue package installation) + return Result.success() + end + all_versions = all_versions:get_or_else {} + + if not _.any(_.equals(version), all_versions) then + ctx.stdio_sink.stderr(("Tried to install invalid version %q. Available versions:\n"):format(version)) + ctx.stdio_sink.stderr(_.compose(_.join "\n", _.map(_.join ", "), _.split_every(15))(all_versions)) + ctx.stdio_sink.stderr "\n\n" + ctx.stdio_sink.stderr( + ("Run with --force flag to bypass version validation:\n :MasonInstall --force %s@%s\n\n"):format( + ctx.package.name, + version + ) + ) + return Result.failure(("Version %q is not available."):format(version)) + end + end + + return Result.success() +end + +---@param platforms string[] +function M.ensure_valid_platform(platforms) + if not _.any(function(target) + return platform.is[target] + end, platforms) then + return Result.failure "PLATFORM_UNSUPPORTED" + end + return Result.success() +end + +return M diff --git a/lua/mason-core/installer/context.lua b/lua/mason-core/installer/context.lua deleted file mode 100644 index a991cd9f..00000000 --- a/lua/mason-core/installer/context.lua +++ /dev/null @@ -1,399 +0,0 @@ -local Optional = require "mason-core.optional" -local _ = require "mason-core.functional" -local fs = require "mason-core.fs" -local log = require "mason-core.log" -local path = require "mason-core.path" -local platform = require "mason-core.platform" -local receipt = require "mason-core.receipt" -local spawn = require "mason-core.spawn" - ----@class ContextualSpawn ----@field strict_mode boolean Whether spawn failures should raise an exception rather then return a Result. ----@field cwd CwdManager ----@field handle InstallHandle ----@field [string] async fun(opts: SpawnArgs): Result -local ContextualSpawn = {} - ----@param cwd CwdManager ----@param handle InstallHandle ----@param strict_mode boolean -function ContextualSpawn.new(cwd, handle, strict_mode) - return setmetatable({ cwd = cwd, handle = handle, strict_mode = strict_mode }, ContextualSpawn) -end - ----@param cmd string -function ContextualSpawn:__index(cmd) - ---@param args JobSpawnOpts - return function(args) - args.cwd = args.cwd or self.cwd:get() - args.stdio_sink = args.stdio_sink or self.handle.stdio.sink - local on_spawn = args.on_spawn - local captured_handle - args.on_spawn = function(handle, stdio, pid, ...) - captured_handle = handle - self.handle:register_spawn_handle(handle, pid, cmd, spawn._flatten_cmd_args(args)) - if on_spawn then - on_spawn(handle, stdio, pid, ...) - end - end - local function pop_spawn_stack() - if captured_handle then - self.handle:deregister_spawn_handle(captured_handle) - end - end - local result = spawn[cmd](args):on_success(pop_spawn_stack):on_failure(pop_spawn_stack) - if self.strict_mode then - return result:get_or_throw() - else - return result - end - end -end - ----@class ContextualFs ----@field private cwd CwdManager -local ContextualFs = {} -ContextualFs.__index = ContextualFs - ----@param cwd CwdManager -function ContextualFs.new(cwd) - return setmetatable({ cwd = cwd }, ContextualFs) -end - ----@async ----@param rel_path string The relative path from the current working directory to the file to append. ----@param contents string -function ContextualFs:append_file(rel_path, contents) - return fs.async.append_file(path.concat { self.cwd:get(), rel_path }, contents) -end - ----@async ----@param rel_path string The relative path from the current working directory to the file to write. ----@param contents string -function ContextualFs:write_file(rel_path, contents) - return fs.async.write_file(path.concat { self.cwd:get(), rel_path }, contents) -end - ----@async ----@param rel_path string The relative path from the current working directory to the file to read. -function ContextualFs:read_file(rel_path) - return fs.async.read_file(path.concat { self.cwd:get(), rel_path }) -end - ----@async ----@param rel_path string The relative path from the current working directory. -function ContextualFs:file_exists(rel_path) - return fs.async.file_exists(path.concat { self.cwd:get(), rel_path }) -end - ----@async ----@param rel_path string The relative path from the current working directory. -function ContextualFs:dir_exists(rel_path) - return fs.async.dir_exists(path.concat { self.cwd:get(), rel_path }) -end - ----@async ----@param rel_path string The relative path from the current working directory. -function ContextualFs:rmrf(rel_path) - return fs.async.rmrf(path.concat { self.cwd:get(), rel_path }) -end - ----@async ----@param rel_path string The relative path from the current working directory. -function ContextualFs:unlink(rel_path) - return fs.async.unlink(path.concat { self.cwd:get(), rel_path }) -end - ----@async ----@param old_path string ----@param new_path string -function ContextualFs:rename(old_path, new_path) - return fs.async.rename(path.concat { self.cwd:get(), old_path }, path.concat { self.cwd:get(), new_path }) -end - ----@async ----@param dir_path string -function ContextualFs:mkdir(dir_path) - return fs.async.mkdir(path.concat { self.cwd:get(), dir_path }) -end - ----@async ----@param dir_path string -function ContextualFs:mkdirp(dir_path) - return fs.async.mkdirp(path.concat { self.cwd:get(), dir_path }) -end - ----@async ----@param file_path string -function ContextualFs:chmod_exec(file_path) - local bit = require "bit" - -- see chmod(2) - local USR_EXEC = 0x40 - local GRP_EXEC = 0x8 - local ALL_EXEC = 0x1 - local EXEC = bit.bor(USR_EXEC, GRP_EXEC, ALL_EXEC) - local fstat = self:fstat(file_path) - if bit.band(fstat.mode, EXEC) ~= EXEC then - local plus_exec = bit.bor(fstat.mode, EXEC) - log.fmt_debug("Setting exec flags on file %s %o -> %o", file_path, fstat.mode, plus_exec) - self:chmod(file_path, plus_exec) -- chmod +x - end -end - ----@async ----@param file_path string ----@param mode integer -function ContextualFs:chmod(file_path, mode) - return fs.async.chmod(path.concat { self.cwd:get(), file_path }, mode) -end - ----@async ----@param file_path string -function ContextualFs:fstat(file_path) - return fs.async.fstat(path.concat { self.cwd:get(), file_path }) -end - ----@class CwdManager ----@field private install_prefix string Defines the upper boundary for which paths are allowed as cwd. ----@field private cwd string -local CwdManager = {} -CwdManager.__index = CwdManager - -function CwdManager.new(install_prefix) - assert(type(install_prefix) == "string", "install_prefix not provided") - return setmetatable({ - install_prefix = install_prefix, - cwd = nil, - }, CwdManager) -end - -function CwdManager:get() - assert(self.cwd ~= nil, "Tried to access cwd before it was set.") - return self.cwd -end - ----@param new_cwd string -function CwdManager:set(new_cwd) - assert(type(new_cwd) == "string", "new_cwd is not a string") - assert( - path.is_subdirectory(self.install_prefix, new_cwd), - ("%q is not a subdirectory of %q"):format(new_cwd, self.install_prefix) - ) - self.cwd = new_cwd -end - ----@class InstallContext ----@field public receipt InstallReceiptBuilder ----@field public requested_version Optional ----@field public fs ContextualFs ----@field public spawn ContextualSpawn ----@field public handle InstallHandle ----@field public package Package ----@field public cwd CwdManager ----@field public opts PackageInstallOpts ----@field public stdio_sink StdioSink ----@field links { bin: table, share: table, opt: table } -local InstallContext = {} -InstallContext.__index = InstallContext - ----@param handle InstallHandle ----@param opts PackageInstallOpts -function InstallContext.new(handle, opts) - local cwd_manager = CwdManager.new(path.install_prefix()) - return setmetatable({ - cwd = cwd_manager, - spawn = ContextualSpawn.new(cwd_manager, handle, false), - handle = handle, - package = handle.package, -- for convenience - fs = ContextualFs.new(cwd_manager), - receipt = receipt.InstallReceiptBuilder.new(), - requested_version = Optional.of_nilable(opts.version), - stdio_sink = handle.stdio.sink, - links = { - bin = {}, - share = {}, - opt = {}, - }, - opts = opts, - }, InstallContext) -end - ----@async -function InstallContext:promote_cwd() - local cwd = self.cwd:get() - local install_path = self.package:get_install_path() - if install_path == cwd then - log.fmt_debug("cwd %s is already promoted (at %s)", cwd, install_path) - return - end - log.fmt_debug("Promoting cwd %s to %s", cwd, install_path) - -- 1. Unlink any existing installation - self.handle.package:unlink() - -- 2. Prepare for renaming cwd to destination - if platform.is.unix then - -- Some Unix systems will raise an error when renaming a directory to a destination that does not already exist. - fs.async.mkdir(install_path) - end - -- 3. Move the cwd to the final installation directory - fs.async.rename(cwd, install_path) - -- 4. Update cwd - self.cwd:set(install_path) -end - ----@param rel_path string The relative path from the current working directory to change cwd to. Will only restore to the initial cwd after execution of fn (if provided). ----@param fn async (fun(): any)? The function to run in the context of the given path. -function InstallContext:chdir(rel_path, fn) - local old_cwd = self.cwd:get() - self.cwd:set(path.concat { old_cwd, rel_path }) - if fn then - local ok, result = pcall(fn) - self.cwd:set(old_cwd) - if not ok then - error(result, 0) - end - return result - end -end - ----@param new_executable_rel_path string Relative path to the executable file to create. ----@param script_rel_path string Relative path to the Node.js script. -function InstallContext:write_node_exec_wrapper(new_executable_rel_path, script_rel_path) - if not self.fs:file_exists(script_rel_path) then - error(("Cannot write Node exec wrapper for path %q as it doesn't exist."):format(script_rel_path), 0) - end - return self:write_shell_exec_wrapper( - new_executable_rel_path, - ("node %q"):format(path.concat { - self.package:get_install_path(), - script_rel_path, - }) - ) -end - ----@param new_executable_rel_path string Relative path to the executable file to create. ----@param script_rel_path string Relative path to the Node.js script. -function InstallContext:write_ruby_exec_wrapper(new_executable_rel_path, script_rel_path) - if not self.fs:file_exists(script_rel_path) then - error(("Cannot write Ruby exec wrapper for path %q as it doesn't exist."):format(script_rel_path), 0) - end - return self:write_shell_exec_wrapper( - new_executable_rel_path, - ("ruby %q"):format(path.concat { - self.package:get_install_path(), - script_rel_path, - }) - ) -end - ----@param new_executable_rel_path string Relative path to the executable file to create. ----@param script_rel_path string Relative path to the PHP script. -function InstallContext:write_php_exec_wrapper(new_executable_rel_path, script_rel_path) - if not self.fs:file_exists(script_rel_path) then - error(("Cannot write PHP exec wrapper for path %q as it doesn't exist."):format(script_rel_path), 0) - end - return self:write_shell_exec_wrapper( - new_executable_rel_path, - ("php %q"):format(path.concat { - self.package:get_install_path(), - script_rel_path, - }) - ) -end - ----@param new_executable_rel_path string Relative path to the executable file to create. ----@param module string The python module to call. -function InstallContext:write_pyvenv_exec_wrapper(new_executable_rel_path, module) - local pypi = require "mason-core.installer.managers.pypi" - local module_exists, module_err = pcall(function() - local result = - self.spawn.python { "-c", ("import %s"):format(module), with_paths = { pypi.venv_path(self.cwd:get()) } } - if not self.spawn.strict_mode then - result:get_or_throw() - end - end) - if not module_exists then - log.fmt_error("Failed to find module %q for package %q. %s", module, self.package, module_err) - error(("Cannot write Python exec wrapper for module %q as it doesn't exist."):format(module), 0) - end - return self:write_shell_exec_wrapper( - new_executable_rel_path, - ("%q -m %s"):format( - path.concat { - pypi.venv_path(self.package:get_install_path()), - "python", - }, - module - ) - ) -end - ----@param new_executable_rel_path string Relative path to the executable file to create. ----@param target_executable_rel_path string -function InstallContext:write_exec_wrapper(new_executable_rel_path, target_executable_rel_path) - if not self.fs:file_exists(target_executable_rel_path) then - error(("Cannot write exec wrapper for path %q as it doesn't exist."):format(target_executable_rel_path), 0) - end - if platform.is.unix then - self.fs:chmod_exec(target_executable_rel_path) - end - return self:write_shell_exec_wrapper( - new_executable_rel_path, - ("%q"):format(path.concat { - self.package:get_install_path(), - target_executable_rel_path, - }) - ) -end - -local BASH_TEMPLATE = _.dedent [[ -#!/usr/bin/env bash -%s -exec %s "$@" -]] - -local BATCH_TEMPLATE = _.dedent [[ -@ECHO off -%s -%s %%* -]] - ----@param new_executable_rel_path string Relative path to the executable file to create. ----@param command string The shell command to run. ----@param env table? ----@return string # The created executable filename. -function InstallContext:write_shell_exec_wrapper(new_executable_rel_path, command, env) - if self.fs:file_exists(new_executable_rel_path) or self.fs:dir_exists(new_executable_rel_path) then - error(("Cannot write exec wrapper to %q because the file already exists."):format(new_executable_rel_path), 0) - end - return platform.when { - unix = function() - local formatted_envs = _.map(function(pair) - local var, value = pair[1], pair[2] - return ("export %s=%q"):format(var, value) - end, _.to_pairs(env or {})) - - self.fs:write_file(new_executable_rel_path, BASH_TEMPLATE:format(_.join("\n", formatted_envs), command)) - self.fs:chmod_exec(new_executable_rel_path) - return new_executable_rel_path - end, - win = function() - local executable_file = ("%s.cmd"):format(new_executable_rel_path) - local formatted_envs = _.map(function(pair) - local var, value = pair[1], pair[2] - return ("SET %s=%s"):format(var, value) - end, _.to_pairs(env or {})) - - self.fs:write_file(executable_file, BATCH_TEMPLATE:format(_.join("\n", formatted_envs), command)) - return executable_file - end, - } -end - ----@param executable string ----@param rel_path string -function InstallContext:link_bin(executable, rel_path) - self.links.bin[executable] = rel_path - return self -end - -return InstallContext diff --git a/lua/mason-core/installer/context/cwd.lua b/lua/mason-core/installer/context/cwd.lua new file mode 100644 index 00000000..4f645fbb --- /dev/null +++ b/lua/mason-core/installer/context/cwd.lua @@ -0,0 +1,48 @@ +local Result = require "mason-core.result" +local fs = require "mason-core.fs" +local path = require "mason-core.path" + +---@class InstallContextCwd +---@field private location InstallLocation Defines the upper boundary for which paths are allowed as cwd. +---@field private cwd string? +local InstallContextCwd = {} +InstallContextCwd.__index = InstallContextCwd + +---@param location InstallLocation +function InstallContextCwd.new(location) + assert(location, "location not provided") + return setmetatable({ + location = location, + cwd = nil, + }, InstallContextCwd) +end + +---@param handle InstallHandle +function InstallContextCwd:initialize(handle) + return Result.try(function(try) + local staging_dir = self.location:staging(handle.package.name) + if fs.async.dir_exists(staging_dir) then + try(Result.pcall(fs.async.rmrf, staging_dir)) + end + try(Result.pcall(fs.async.mkdirp, staging_dir)) + self:set(staging_dir) + end) +end + +function InstallContextCwd:get() + assert(self.cwd ~= nil, "Tried to access cwd before it was set.") + return self.cwd +end + +---@param new_abs_cwd string +function InstallContextCwd:set(new_abs_cwd) + assert(type(new_abs_cwd) == "string", "new_cwd is not a string") + assert( + path.is_subdirectory(self.location:get_dir(), new_abs_cwd), + ("%q is not a subdirectory of %q"):format(new_abs_cwd, self.location) + ) + self.cwd = new_abs_cwd + return self +end + +return InstallContextCwd diff --git a/lua/mason-core/installer/context/fs.lua b/lua/mason-core/installer/context/fs.lua new file mode 100644 index 00000000..5c51fb56 --- /dev/null +++ b/lua/mason-core/installer/context/fs.lua @@ -0,0 +1,108 @@ +local fs = require "mason-core.fs" +local log = require "mason-core.log" +local path = require "mason-core.path" + +---@class InstallContextFs +---@field private cwd InstallContextCwd +local InstallContextFs = {} +InstallContextFs.__index = InstallContextFs + +---@param cwd InstallContextCwd +function InstallContextFs.new(cwd) + return setmetatable({ cwd = cwd }, InstallContextFs) +end + +---@async +---@param rel_path string The relative path from the current working directory to the file to append. +---@param contents string +function InstallContextFs:append_file(rel_path, contents) + return fs.async.append_file(path.concat { self.cwd:get(), rel_path }, contents) +end + +---@async +---@param rel_path string The relative path from the current working directory to the file to write. +---@param contents string +function InstallContextFs:write_file(rel_path, contents) + return fs.async.write_file(path.concat { self.cwd:get(), rel_path }, contents) +end + +---@async +---@param rel_path string The relative path from the current working directory to the file to read. +function InstallContextFs:read_file(rel_path) + return fs.async.read_file(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string The relative path from the current working directory. +function InstallContextFs:file_exists(rel_path) + return fs.async.file_exists(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string The relative path from the current working directory. +function InstallContextFs:dir_exists(rel_path) + return fs.async.dir_exists(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string The relative path from the current working directory. +function InstallContextFs:rmrf(rel_path) + return fs.async.rmrf(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param rel_path string The relative path from the current working directory. +function InstallContextFs:unlink(rel_path) + return fs.async.unlink(path.concat { self.cwd:get(), rel_path }) +end + +---@async +---@param old_path string +---@param new_path string +function InstallContextFs:rename(old_path, new_path) + return fs.async.rename(path.concat { self.cwd:get(), old_path }, path.concat { self.cwd:get(), new_path }) +end + +---@async +---@param dir_path string +function InstallContextFs:mkdir(dir_path) + return fs.async.mkdir(path.concat { self.cwd:get(), dir_path }) +end + +---@async +---@param dir_path string +function InstallContextFs:mkdirp(dir_path) + return fs.async.mkdirp(path.concat { self.cwd:get(), dir_path }) +end + +---@async +---@param file_path string +function InstallContextFs:chmod_exec(file_path) + local bit = require "bit" + -- see chmod(2) + local USR_EXEC = 0x40 + local GRP_EXEC = 0x8 + local ALL_EXEC = 0x1 + local EXEC = bit.bor(USR_EXEC, GRP_EXEC, ALL_EXEC) + local fstat = self:fstat(file_path) + if bit.band(fstat.mode, EXEC) ~= EXEC then + local plus_exec = bit.bor(fstat.mode, EXEC) + log.fmt_debug("Setting exec flags on file %s %o -> %o", file_path, fstat.mode, plus_exec) + self:chmod(file_path, plus_exec) -- chmod +x + end +end + +---@async +---@param file_path string +---@param mode integer +function InstallContextFs:chmod(file_path, mode) + return fs.async.chmod(path.concat { self.cwd:get(), file_path }, mode) +end + +---@async +---@param file_path string +function InstallContextFs:fstat(file_path) + return fs.async.fstat(path.concat { self.cwd:get(), file_path }) +end + +return InstallContextFs diff --git a/lua/mason-core/installer/context/init.lua b/lua/mason-core/installer/context/init.lua new file mode 100644 index 00000000..0d178c4e --- /dev/null +++ b/lua/mason-core/installer/context/init.lua @@ -0,0 +1,261 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local fs = require "mason-core.fs" +local log = require "mason-core.log" +local path = require "mason-core.path" +local platform = require "mason-core.platform" +local receipt = require "mason-core.receipt" + +---@class InstallContext +---@field receipt InstallReceiptBuilder +---@field fs InstallContextFs +---@field spawn InstallContextSpawn +---@field handle InstallHandle +---@field package Package +---@field cwd InstallContextCwd +---@field opts PackageInstallOpts +---@field stdio_sink StdioSink +---@field links { bin: table, share: table, opt: table } +local InstallContext = {} +InstallContext.__index = InstallContext + +---@param handle InstallHandle +---@param cwd InstallContextCwd +---@param spawn InstallContextSpawn +---@param fs InstallContextFs +---@param opts PackageInstallOpts +function InstallContext.new(handle, cwd, spawn, fs, opts) + return setmetatable({ + cwd = cwd, + spawn = spawn, + handle = handle, + package = handle.package, -- for convenience + fs = fs, + receipt = receipt.InstallReceiptBuilder.new(), + stdio_sink = handle.stdio.sink, + links = { + bin = {}, + share = {}, + opt = {}, + }, + opts = opts, + }, InstallContext) +end + +---@async +function InstallContext:promote_cwd() + local cwd = self.cwd:get() + local install_path = self.package:get_install_path() + if install_path == cwd then + log.fmt_debug("cwd %s is already promoted (at %s)", cwd, install_path) + return + end + log.fmt_debug("Promoting cwd %s to %s", cwd, install_path) + -- 1. Uninstall any existing installation + self.handle.package:uninstall() + -- 2. Prepare for renaming cwd to destination + if platform.is.unix then + -- Some Unix systems will raise an error when renaming a directory to a destination that does not already exist. + fs.async.mkdir(install_path) + end + -- 3. Move the cwd to the final installation directory + fs.async.rename(cwd, install_path) + -- 4. Update cwd + self.cwd:set(install_path) +end + +---@param rel_path string The relative path from the current working directory to change cwd to. Will only restore to the initial cwd after execution of fn (if provided). +---@param fn async (fun(): any)? The function to run in the context of the given path. +function InstallContext:chdir(rel_path, fn) + local old_cwd = self.cwd:get() + self.cwd:set(path.concat { old_cwd, rel_path }) + if fn then + local ok, result = pcall(fn) + self.cwd:set(old_cwd) + if not ok then + error(result, 0) + end + return result + end +end + +---@param new_executable_rel_path string Relative path to the executable file to create. +---@param script_rel_path string Relative path to the Node.js script. +function InstallContext:write_node_exec_wrapper(new_executable_rel_path, script_rel_path) + if not self.fs:file_exists(script_rel_path) then + error(("Cannot write Node exec wrapper for path %q as it doesn't exist."):format(script_rel_path), 0) + end + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("node %q"):format(path.concat { + self.package:get_install_path(), + script_rel_path, + }) + ) +end + +---@param new_executable_rel_path string Relative path to the executable file to create. +---@param script_rel_path string Relative path to the Node.js script. +function InstallContext:write_ruby_exec_wrapper(new_executable_rel_path, script_rel_path) + if not self.fs:file_exists(script_rel_path) then + error(("Cannot write Ruby exec wrapper for path %q as it doesn't exist."):format(script_rel_path), 0) + end + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("ruby %q"):format(path.concat { + self.package:get_install_path(), + script_rel_path, + }) + ) +end + +---@param new_executable_rel_path string Relative path to the executable file to create. +---@param script_rel_path string Relative path to the PHP script. +function InstallContext:write_php_exec_wrapper(new_executable_rel_path, script_rel_path) + if not self.fs:file_exists(script_rel_path) then + error(("Cannot write PHP exec wrapper for path %q as it doesn't exist."):format(script_rel_path), 0) + end + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("php %q"):format(path.concat { + self.package:get_install_path(), + script_rel_path, + }) + ) +end + +---@param new_executable_rel_path string Relative path to the executable file to create. +---@param module string The python module to call. +function InstallContext:write_pyvenv_exec_wrapper(new_executable_rel_path, module) + local pypi = require "mason-core.installer.managers.pypi" + local module_exists, module_err = pcall(function() + local result = + self.spawn.python { "-c", ("import %s"):format(module), with_paths = { pypi.venv_path(self.cwd:get()) } } + if not self.spawn.strict_mode then + result:get_or_throw() + end + end) + if not module_exists then + log.fmt_error("Failed to find module %q for package %q. %s", module, self.package, module_err) + error(("Cannot write Python exec wrapper for module %q as it doesn't exist."):format(module), 0) + end + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("%q -m %s"):format( + path.concat { + pypi.venv_path(self.package:get_install_path()), + "python", + }, + module + ) + ) +end + +---@param new_executable_rel_path string Relative path to the executable file to create. +---@param target_executable_rel_path string +function InstallContext:write_exec_wrapper(new_executable_rel_path, target_executable_rel_path) + if not self.fs:file_exists(target_executable_rel_path) then + error(("Cannot write exec wrapper for path %q as it doesn't exist."):format(target_executable_rel_path), 0) + end + if platform.is.unix then + self.fs:chmod_exec(target_executable_rel_path) + end + return self:write_shell_exec_wrapper( + new_executable_rel_path, + ("%q"):format(path.concat { + self.package:get_install_path(), + target_executable_rel_path, + }) + ) +end + +local BASH_TEMPLATE = _.dedent [[ +#!/usr/bin/env bash +%s +exec %s "$@" +]] + +local BATCH_TEMPLATE = _.dedent [[ +@ECHO off +%s +%s %%* +]] + +---@param new_executable_rel_path string Relative path to the executable file to create. +---@param command string The shell command to run. +---@param env table? +---@return string # The created executable filename. +function InstallContext:write_shell_exec_wrapper(new_executable_rel_path, command, env) + if self.fs:file_exists(new_executable_rel_path) or self.fs:dir_exists(new_executable_rel_path) then + error(("Cannot write exec wrapper to %q because the file already exists."):format(new_executable_rel_path), 0) + end + return platform.when { + unix = function() + local formatted_envs = _.map(function(pair) + local var, value = pair[1], pair[2] + return ("export %s=%q"):format(var, value) + end, _.to_pairs(env or {})) + + self.fs:write_file(new_executable_rel_path, BASH_TEMPLATE:format(_.join("\n", formatted_envs), command)) + self.fs:chmod_exec(new_executable_rel_path) + return new_executable_rel_path + end, + win = function() + local executable_file = ("%s.cmd"):format(new_executable_rel_path) + local formatted_envs = _.map(function(pair) + local var, value = pair[1], pair[2] + return ("SET %s=%s"):format(var, value) + end, _.to_pairs(env or {})) + + self.fs:write_file(executable_file, BATCH_TEMPLATE:format(_.join("\n", formatted_envs), command)) + return executable_file + end, + } +end + +---@param executable string +---@param rel_path string +function InstallContext:link_bin(executable, rel_path) + self.links.bin[executable] = rel_path + return self +end + +InstallContext.CONTEXT_REQUEST = {} + +---@generic T +---@param fn fun(context: InstallContext): T +---@return T +function InstallContext:execute(fn) + local thread = coroutine.create(function(...) + -- We wrap the function to allow it to be a spy instance (in which case it's not actually a function, but a + -- callable metatable - coroutine.create strictly expects functions only) + return fn(...) + end) + local step + local ret_val + step = function(...) + local ok, result = coroutine.resume(thread, ...) + if not ok then + error(result, 0) + elseif result == InstallContext.CONTEXT_REQUEST then + step(self) + elseif coroutine.status(thread) == "suspended" then + -- yield to parent coroutine + step(coroutine.yield(result)) + else + ret_val = result + end + end + step(self) + return ret_val +end + +---@async +function InstallContext:build_receipt() + log.fmt_debug("Building receipt for %s", self.package) + return Result.pcall(function() + return self.receipt:with_name(self.package.name):with_completion_time(vim.loop.gettimeofday()):build() + end) +end + +return InstallContext diff --git a/lua/mason-core/installer/context/spawn.lua b/lua/mason-core/installer/context/spawn.lua new file mode 100644 index 00000000..6528c4b3 --- /dev/null +++ b/lua/mason-core/installer/context/spawn.lua @@ -0,0 +1,46 @@ +local spawn = require "mason-core.spawn" + +---@class InstallContextSpawn +---@field strict_mode boolean Whether spawn failures should raise an exception rather then return a Result. +---@field private cwd InstallContextCwd +---@field private handle InstallHandle +---@field [string] async fun(opts: SpawnArgs): Result +local InstallContextSpawn = {} + +---@param cwd InstallContextCwd +---@param handle InstallHandle +---@param strict_mode boolean +function InstallContextSpawn.new(cwd, handle, strict_mode) + return setmetatable({ cwd = cwd, handle = handle, strict_mode = strict_mode }, InstallContextSpawn) +end + +---@param cmd string +function InstallContextSpawn:__index(cmd) + ---@param args JobSpawnOpts + return function(args) + args.cwd = args.cwd or self.cwd:get() + args.stdio_sink = args.stdio_sink or self.handle.stdio.sink + local on_spawn = args.on_spawn + local captured_handle + args.on_spawn = function(handle, stdio, pid, ...) + captured_handle = handle + self.handle:register_spawn_handle(handle, pid, cmd, spawn._flatten_cmd_args(args)) + if on_spawn then + on_spawn(handle, stdio, pid, ...) + end + end + local function pop_spawn_stack() + if captured_handle then + self.handle:deregister_spawn_handle(captured_handle) + end + end + local result = spawn[cmd](args):on_success(pop_spawn_stack):on_failure(pop_spawn_stack) + if self.strict_mode then + return result:get_or_throw() + else + return result + end + end +end + +return InstallContextSpawn diff --git a/lua/mason-core/installer/handle.lua b/lua/mason-core/installer/handle.lua index f9b03557..96acbdd1 100644 --- a/lua/mason-core/installer/handle.lua +++ b/lua/mason-core/installer/handle.lua @@ -120,6 +120,10 @@ function InstallHandle:is_closed() return self.state == "CLOSED" end +function InstallHandle:is_closing() + return self:is_closed() or self.is_terminated +end + ---@param new_state InstallHandleState function InstallHandle:set_state(new_state) local old_state = self.state diff --git a/lua/mason-core/installer/init.lua b/lua/mason-core/installer/init.lua index 45bba46b..37c74fcb 100644 --- a/lua/mason-core/installer/init.lua +++ b/lua/mason-core/installer/init.lua @@ -1,263 +1,10 @@ local InstallContext = require "mason-core.installer.context" -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local a = require "mason-core.async" -local control = require "mason-core.async.control" -local fs = require "mason-core.fs" -local linker = require "mason-core.installer.linker" -local log = require "mason-core.log" -local path = require "mason-core.path" -local settings = require "mason.settings" - -local Semaphore = control.Semaphore - -local sem = Semaphore.new(settings.current.max_concurrent_installers) local M = {} ----@async -function M.create_prefix_dirs() - return Result.try(function(try) - for _, p in ipairs { - path.install_prefix(), - path.bin_prefix(), - path.share_prefix(), - path.package_prefix(), - path.package_build_prefix(), - } do - if not fs.async.dir_exists(p) then - try(Result.pcall(fs.async.mkdirp, p)) - end - end - end) -end - ----@async ----@param context InstallContext -local function build_receipt(context) - return Result.pcall(function() - log.fmt_debug("Building receipt for %s", context.package) - return context.receipt:with_name(context.package.name):with_completion_time(vim.loop.gettimeofday()):build() - end) -end - -local CONTEXT_REQUEST = {} - ---@return InstallContext function M.context() - return coroutine.yield(CONTEXT_REQUEST) -end - ----@async ----@param ctx InstallContext -local function lock_package(ctx) - log.debug("Attempting to lock package", ctx.package) - local lockfile = path.package_lock(ctx.package.name) - if not ctx.opts.force and fs.async.file_exists(lockfile) then - log.error("Lockfile already exists.", ctx.package) - return Result.failure( - ("Lockfile exists, installation is already running in another process (pid: %s). Run with :MasonInstall --force to bypass."):format( - fs.sync.read_file(lockfile) - ) - ) - end - a.scheduler() - fs.async.write_file(lockfile, vim.fn.getpid()) - log.debug("Wrote lockfile", ctx.package) - return Result.success(lockfile) -end - ----@async ----@param context InstallContext -function M.prepare_installer(context) - local installer = require "mason-core.installer.registry" - return Result.try(function(try) - local package_build_prefix = path.package_build_prefix(context.package.name) - if fs.async.dir_exists(package_build_prefix) then - try(Result.pcall(fs.async.rmrf, package_build_prefix)) - end - try(Result.pcall(fs.async.mkdirp, package_build_prefix)) - context.cwd:set(package_build_prefix) - - return try(installer.compile(context.handle.package.spec, context.opts)) - end) -end - ----@generic T ----@param context InstallContext ----@param fn fun(context: InstallContext): T ----@return T -function M.exec_in_context(context, fn) - local thread = coroutine.create(function(...) - -- We wrap the function to allow it to be a spy instance (in which case it's not actually a function, but a - -- callable metatable - coroutine.create strictly expects functions only) - return fn(...) - end) - local step - local ret_val - step = function(...) - local ok, result = coroutine.resume(thread, ...) - if not ok then - error(result, 0) - elseif result == CONTEXT_REQUEST then - step(context) - elseif coroutine.status(thread) == "suspended" then - -- yield to parent coroutine - step(coroutine.yield(result)) - else - ret_val = result - end - end - context.receipt:with_start_time(vim.loop.gettimeofday()) - step(context) - return ret_val -end - ----@async ----@param context InstallContext ----@param installer async fun(ctx: InstallContext) -local function run_installer(context, installer) - local handle = context.handle - return Result.pcall(function() - return a.wait(function(resolve, reject) - local cancel_thread = a.run(M.exec_in_context, function(success, result) - if success then - resolve(result) - else - reject(result) - end - end, context, installer) - - handle:once("terminate", function() - cancel_thread() - if handle:is_closed() then - reject "Installation was aborted." - else - handle:once("closed", function() - reject "Installation was aborted." - end) - end - end) - end) - end) -end - ----@async ----@param handle InstallHandle ----@param opts PackageInstallOpts -function M.execute(handle, opts) - if handle:is_active() or handle:is_closed() then - log.fmt_debug("Received active or closed handle %s", handle) - return Result.failure "Invalid handle state." - end - - handle:queued() - local permit = sem:acquire() - if handle:is_closed() then - permit:forget() - log.fmt_trace("Installation was aborted %s", handle) - return Result.failure "Installation was aborted." - end - log.fmt_trace("Activating handle %s", handle) - handle:active() - - local pkg = handle.package - local context = InstallContext.new(handle, opts) - local tailed_output = {} - - if opts.debug then - local function append_log(chunk) - tailed_output[#tailed_output + 1] = chunk - end - handle:on("stdout", append_log) - handle:on("stderr", append_log) - end - - log.fmt_info("Executing installer for %s %s", pkg, opts) - - return M.create_prefix_dirs() - :and_then(function() - return lock_package(context) - end) - :and_then(function(lockfile) - local release_lock = _.partial(pcall, fs.async.unlink, lockfile) - return Result.try(function(try) - -- 1. prepare directories and initialize cwd - local installer = try(M.prepare_installer(context)) - - -- 2. execute installer - try(run_installer(context, installer)) - - -- 3. promote temporary installation dir - try(Result.pcall(function() - context:promote_cwd() - end)) - - -- 4. link package - try(linker.link(context)) - - -- 5. build & write receipt - ---@type InstallReceipt - local receipt = try(build_receipt(context)) - try(Result.pcall(function() - receipt:write(context.cwd:get()) - end)) - end) - :on_success(function() - release_lock() - if opts.debug then - context.fs:write_file("mason-debug.log", table.concat(tailed_output, "")) - end - end) - :on_failure(function() - release_lock() - if not opts.debug then - -- clean up installation dir - pcall(function() - fs.async.rmrf(context.cwd:get()) - end) - else - context.fs:write_file("mason-debug.log", table.concat(tailed_output, "")) - context.stdio_sink.stdout( - ("[debug] Installation directory retained at %q.\n"):format(context.cwd:get()) - ) - end - - -- unlink linked executables (in the occasion an error occurs after linking) - build_receipt(context):on_success(function(receipt) - linker.unlink(context.package, receipt):on_failure(function(err) - log.error("Failed to unlink failed installation", err) - end) - end) - end) - end) - :on_success(function() - permit:forget() - handle:close() - log.fmt_info("Installation succeeded for %s", pkg) - end) - :on_failure(function(failure) - permit:forget() - log.fmt_error("Installation failed for %s error=%s", pkg, failure) - context.stdio_sink.stderr(tostring(failure)) - context.stdio_sink.stderr "\n" - - if not handle:is_closed() and not handle.is_terminated then - handle:close() - end - end) -end - ----Runs the provided async functions concurrently and returns their result, once all are resolved. ----This is really just a wrapper around a.wait_all() that makes sure to patch the coroutine context before creating the ----new async execution contexts. ----@async ----@param suspend_fns async fun(ctx: InstallContext)[] -function M.run_concurrently(suspend_fns) - local context = M.context() - return a.wait_all(_.map(function(suspend_fn) - return _.partial(M.exec_in_context, context, suspend_fn) - end, suspend_fns)) + return coroutine.yield(InstallContext.CONTEXT_REQUEST) end return M diff --git a/lua/mason-core/installer/location.lua b/lua/mason-core/installer/location.lua new file mode 100644 index 00000000..2cc038e4 --- /dev/null +++ b/lua/mason-core/installer/location.lua @@ -0,0 +1,63 @@ +local Path = require "mason-core.path" +local Result = require "mason-core.result" +local fs = require "mason-core.fs" + +---@class InstallLocation +---@field private dir string +local InstallLocation = {} +InstallLocation.__index = InstallLocation + +---@param dir string +function InstallLocation.new(dir) + return setmetatable({ + dir = dir, + }, InstallLocation) +end + +function InstallLocation:get_dir() + return self.dir +end + +---@async +function InstallLocation:initialize() + return Result.try(function(try) + for _, p in ipairs { + self.dir, + self:bin(), + self:share(), + self:package(), + self:staging(), + } do + if not fs.async.dir_exists(p) then + try(Result.pcall(fs.async.mkdirp, p)) + end + end + end) +end + +---@param path string? +function InstallLocation:bin(path) + return Path.concat { self.dir, "bin", path } +end + +---@param path string? +function InstallLocation:share(path) + return Path.concat { self.dir, "share", path } +end + +---@param path string? +function InstallLocation:package(path) + return Path.concat { self.dir, "packages", path } +end + +---@param path string? +function InstallLocation:staging(path) + return Path.concat { self.dir, "staging", path } +end + +---@param name string +function InstallLocation:lockfile(name) + return self:staging(("%s.lock"):format(name)) +end + +return InstallLocation diff --git a/lua/mason-core/installer/registry/expr.lua b/lua/mason-core/installer/registry/expr.lua deleted file mode 100644 index a07fc00d..00000000 --- a/lua/mason-core/installer/registry/expr.lua +++ /dev/null @@ -1,110 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local platform = require "mason-core.platform" - -local M = {} - -local parse_expr = _.compose( - _.apply_spec { - value_expr = _.head, - filters = _.drop(1), - }, - _.filter(_.complement(_.equals "")), - _.map(_.trim), - _.split "|" -) - ----@param predicate (fun(value: string): boolean) | boolean ----@param value string -local take_if = _.curryN(function(predicate, value) - if type(predicate) == "boolean" then - predicate = _.always(predicate) - end - return predicate(value) and value or nil -end, 2) - ----@param predicate (fun(value: string): boolean) | boolean ----@param value string -local take_if_not = _.curryN(function(predicate, value) - if type(predicate) == "boolean" then - predicate = _.always(predicate) - end - return (not predicate(value)) and value or nil -end, 2) - -local FILTERS = { - equals = _.equals, - not_equals = _.not_equals, - strip_prefix = _.strip_prefix, - strip_suffix = _.strip_suffix, - take_if = take_if, - take_if_not = take_if_not, - to_lower = _.to_lower, - to_upper = _.to_upper, - is_platform = function(target) - return platform.is[target] - end, -} - ----@generic T : table ----@param tbl T ----@return T -local function shallow_clone(tbl) - local res = {} - for k, v in pairs(tbl) do - res[k] = v - end - return res -end - ----@param expr string ----@param ctx table -local function eval(expr, ctx) - return setfenv(assert(loadstring("return " .. expr), ("Failed to parse expression: %q"):format(expr)), ctx)() -end - ----@param str string ----@param ctx table -function M.interpolate(str, ctx) - ctx = shallow_clone(ctx) - setmetatable(ctx, { __index = FILTERS }) - return Result.pcall(function() - return _.gsub("{{([^}]+)}}", function(expr) - local components = parse_expr(expr) - - local value = eval(components.value_expr, ctx) - - local filters = _.map(function(filter_expr) - local filter = eval(filter_expr, ctx) - assert(type(filter) == "function", ("Invalid filter expression: %q"):format(filter_expr)) - return filter - end, components.filters) - - local reduced_value = _.reduce(_.apply_to, value, filters) - - return reduced_value ~= nil and tostring(reduced_value) or "" - end, str) - end) -end - ----@generic T : table ----@param tbl T ----@param ctx table ----@return Result # Result -function M.tbl_interpolate(tbl, ctx) - return Result.try(function(try) - local interpolated = {} - for k, v in pairs(tbl) do - if type(v) == "string" then - interpolated[k] = try(M.interpolate(v, ctx)) - elseif type(v) == "table" then - interpolated[k] = try(M.tbl_interpolate(v, ctx)) - else - interpolated[k] = v - end - end - return interpolated - end) -end - -return M diff --git a/lua/mason-core/installer/registry/init.lua b/lua/mason-core/installer/registry/init.lua deleted file mode 100644 index 7376db86..00000000 --- a/lua/mason-core/installer/registry/init.lua +++ /dev/null @@ -1,219 +0,0 @@ -local Optional = require "mason-core.optional" -local Purl = require "mason-core.purl" -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local a = require "mason-core.async" -local link = require "mason-core.installer.registry.link" -local log = require "mason-core.log" -local schemas = require "mason-core.installer.registry.schemas" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@type table -M.SCHEMA_CAP = _.set_of { - "registry+v1", -} - ----@type table -local PROVIDERS = {} - ----@param id string ----@param provider InstallerProvider -function M.register_provider(id, provider) - PROVIDERS[id] = provider -end - -M.register_provider("cargo", _.lazy_require "mason-core.installer.registry.providers.cargo") -M.register_provider("composer", _.lazy_require "mason-core.installer.registry.providers.composer") -M.register_provider("gem", _.lazy_require "mason-core.installer.registry.providers.gem") -M.register_provider("generic", _.lazy_require "mason-core.installer.registry.providers.generic") -M.register_provider("github", _.lazy_require "mason-core.installer.registry.providers.github") -M.register_provider("golang", _.lazy_require "mason-core.installer.registry.providers.golang") -M.register_provider("luarocks", _.lazy_require "mason-core.installer.registry.providers.luarocks") -M.register_provider("npm", _.lazy_require "mason-core.installer.registry.providers.npm") -M.register_provider("nuget", _.lazy_require "mason-core.installer.registry.providers.nuget") -M.register_provider("opam", _.lazy_require "mason-core.installer.registry.providers.opam") -M.register_provider("openvsx", _.lazy_require "mason-core.installer.registry.providers.openvsx") -M.register_provider("pypi", _.lazy_require "mason-core.installer.registry.providers.pypi") -M.register_provider("mason", _.lazy_require "mason-core.installer.registry.providers.mason") - ----@param purl Purl -function M.get_provider(purl) - return Optional.of_nilable(PROVIDERS[purl.type]):ok_or(("Unknown purl type: %s"):format(purl.type)) -end - ----@class InstallerProvider ----@field parse fun(source: RegistryPackageSource, purl: Purl, opts: PackageInstallOpts): Result ----@field install async fun(ctx: InstallContext, source: ParsedPackageSource, purl: Purl): Result ----@field get_versions async fun(purl: Purl, source: RegistryPackageSource): Result # Result - ----@class ParsedPackageSource - ----Upserts {dst} with contents of {src}. List table values will be merged, with contents of {src} prepended. ----@param dst table ----@param src table -local function upsert(dst, src) - for k, v in pairs(src) do - if type(v) == "table" then - if _.is_list(v) then - dst[k] = _.concat(v, dst[k] or {}) - else - dst[k] = upsert(dst[k] or {}, src[k]) - end - else - dst[k] = v - end - end - return dst -end - ----@param source RegistryPackageSource ----@param version string -local function coalesce_source(source, version) - if source.version_overrides then - for i = #source.version_overrides, 1, -1 do - local version_override = source.version_overrides[i] - local version_type, constraint = unpack(_.split(":", version_override.constraint)) - if version_type == "semver" then - local semver = require "mason-core.semver" - local version_match = Result.try(function(try) - local requested_version = try(semver.parse(version)) - if _.starts_with("<=", constraint) then - local rule_version = try(semver.parse(_.strip_prefix("<=", constraint))) - return requested_version <= rule_version - elseif _.starts_with(">=", constraint) then - local rule_version = try(semver.parse(_.strip_prefix(">=", constraint))) - return requested_version >= rule_version - else - local rule_version = try(semver.parse(constraint)) - return requested_version == rule_version - end - end):get_or_else(false) - - if version_match then - if version_override.id then - -- Because this entry provides its own purl id, it overrides the entire source definition. - return version_override - else - -- Upsert the default source with the contents of the version override. - return upsert(vim.deepcopy(source), _.dissoc("constraint", version_override)) - end - end - end - end - end - return source -end - ----@param spec RegistryPackageSpec ----@param opts PackageInstallOpts -function M.parse(spec, opts) - log.trace("Parsing spec", spec.name, opts) - return Result.try(function(try) - if not M.SCHEMA_CAP[spec.schema] then - return Result.failure( - ("Current version of mason.nvim is not capable of parsing package schema version %q."):format( - spec.schema - ) - ) - end - - local source = opts.version and coalesce_source(spec.source, opts.version) or spec.source - - ---@type Purl - local purl = try(Purl.parse(source.id)) - log.trace("Parsed purl.", source.id, purl) - if opts.version then - purl.version = opts.version - end - - ---@type InstallerProvider - local provider = try(M.get_provider(purl)) - log.trace("Found provider for purl.", source.id) - local parsed_source = try(provider.parse(source, purl, opts)) - log.trace("Parsed source for purl.", source.id, parsed_source) - return { - provider = provider, - source = vim.tbl_extend("keep", parsed_source, source), - raw_source = source, - purl = purl, - } - end):on_failure(function(err) - log.debug("Failed to parse spec spec", spec.name, err) - end) -end - ----@async ----@param spec RegistryPackageSpec ----@param opts PackageInstallOpts -function M.compile(spec, opts) - log.debug("Compiling installer.", spec.name, opts) - return Result.try(function(try) - -- Parsers run synchronously and may access API functions, so we schedule before-hand. - a.scheduler() - - local map_parse_err = _.cond { - { - _.equals "PLATFORM_UNSUPPORTED", - function() - if opts.target then - return ("Platform %q is unsupported."):format(opts.target) - else - return "The current platform is unsupported." - end - end, - }, - { _.T, _.identity }, - } - - ---@type { purl: Purl, provider: InstallerProvider, source: ParsedPackageSource, raw_source: RegistryPackageSource } - local parsed = try(M.parse(spec, opts):map_err(map_parse_err)) - - ---@async - ---@param ctx InstallContext - return function(ctx) - return Result.try(function(try) - if ctx.opts.version then - try(util.ensure_valid_version(function() - return parsed.provider.get_versions(parsed.purl, parsed.raw_source) - end)) - end - - -- Run installer - a.scheduler() - try(parsed.provider.install(ctx, parsed.source, parsed.purl)) - - if spec.schemas then - local result = schemas.download(ctx, spec, parsed.purl, parsed.source):on_failure(function(err) - log.error("Failed to download schemas", ctx.package, err) - end) - if opts.strict then - -- schema download sources are not considered stable nor a critical feature, so we only fail in strict mode - try(result) - end - end - - -- Expand & register links - if spec.bin then - try(link.bin(ctx, spec, parsed.purl, parsed.source)) - end - if spec.share then - try(link.share(ctx, spec, parsed.purl, parsed.source)) - end - if spec.opt then - try(link.opt(ctx, spec, parsed.purl, parsed.source)) - end - - ctx.receipt:with_source { - type = ctx.package.spec.schema, - id = Purl.compile(parsed.purl), - } - end):on_failure(function(err) - error(err, 0) - end) - end - end) -end - -return M diff --git a/lua/mason-core/installer/registry/link.lua b/lua/mason-core/installer/registry/link.lua deleted file mode 100644 index 85e751b7..00000000 --- a/lua/mason-core/installer/registry/link.lua +++ /dev/null @@ -1,293 +0,0 @@ -local Optional = require "mason-core.optional" -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local a = require "mason-core.async" -local expr = require "mason-core.installer.registry.expr" -local fs = require "mason-core.fs" -local log = require "mason-core.log" -local path = require "mason-core.path" -local platform = require "mason-core.platform" - -local M = {} - -local filter_empty_values = _.compose( - _.from_pairs, - _.filter(function(pair) - return pair[2] ~= "" - end), - _.to_pairs -) - -local bin_delegates = { - ["luarocks"] = function(target) - return require("mason-core.installer.managers.luarocks").bin_path(target) - end, - ["composer"] = function(target) - return require("mason-core.installer.managers.composer").bin_path(target) - end, - ["opam"] = function(target) - return require("mason-core.installer.managers.opam").bin_path(target) - end, - ["python"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - if not ctx.fs:file_exists(target) then - return Result.failure(("Cannot write python wrapper for path %q as it doesn't exist."):format(target)) - end - return Result.pcall(function() - local python = platform.is.win and "python" or "python3" - return ctx:write_shell_exec_wrapper( - bin, - ("%s %q"):format(python, path.concat { ctx.package:get_install_path(), target }) - ) - end) - end, - ["php"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - return Result.pcall(function() - return ctx:write_php_exec_wrapper(bin, target) - end) - end, - ["pyvenv"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - return Result.pcall(function() - return ctx:write_pyvenv_exec_wrapper(bin, target) - end) - end, - ["dotnet"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - if not ctx.fs:file_exists(target) then - return Result.failure(("Cannot write dotnet wrapper for path %q as it doesn't exist."):format(target)) - end - return Result.pcall(function() - return ctx:write_shell_exec_wrapper( - bin, - ("dotnet %q"):format(path.concat { - ctx.package:get_install_path(), - target, - }) - ) - end) - end, - ["node"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - return Result.pcall(function() - return ctx:write_node_exec_wrapper(bin, target) - end) - end, - ["ruby"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - return Result.pcall(function() - return ctx:write_ruby_exec_wrapper(bin, target) - end) - end, - ["exec"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - return Result.pcall(function() - return ctx:write_exec_wrapper(bin, target) - end) - end, - ["java-jar"] = function(target, bin) - local installer = require "mason-core.installer" - local ctx = installer.context() - if not ctx.fs:file_exists(target) then - return Result.failure(("Cannot write Java JAR wrapper for path %q as it doesn't exist."):format(target)) - end - return Result.pcall(function() - return ctx:write_shell_exec_wrapper( - bin, - ("java -jar %q"):format(path.concat { - ctx.package:get_install_path(), - target, - }) - ) - end) - end, - ["nuget"] = function(target) - return require("mason-core.installer.managers.nuget").bin_path(target) - end, - ["npm"] = function(target) - return require("mason-core.installer.managers.npm").bin_path(target) - end, - ["gem"] = function(target) - return require("mason-core.installer.managers.gem").create_bin_wrapper(target) - end, - ["cargo"] = function(target) - return require("mason-core.installer.managers.cargo").bin_path(target) - end, - ["pypi"] = function(target) - return require("mason-core.installer.managers.pypi").bin_path(target) - end, - ["golang"] = function(target) - return require("mason-core.installer.managers.golang").bin_path(target) - end, -} - ----Expands bin specification from spec and registers bins to be linked. ----@async ----@param ctx InstallContext ----@param spec RegistryPackageSpec ----@param purl Purl ----@param source ParsedPackageSource -local function expand_bin(ctx, spec, purl, source) - log.debug("Registering bin links", ctx.package, spec.bin) - return Result.try(function(try) - local expr_ctx = { - version = purl.version, - source = source, - } - - local bin_table = spec.bin - if not bin_table then - log.fmt_debug("%s spec provides no bin.", ctx.package) - return - end - - local interpolated_bins = filter_empty_values(try(expr.tbl_interpolate(bin_table, expr_ctx))) - - local expanded_bin_table = {} - for bin, target in pairs(interpolated_bins) do - -- Expand "npm:typescript-language-server"-like expressions - local delegated_bin = _.match("^(.+):(.+)$", target) - if #delegated_bin > 0 then - local bin_type, executable = unpack(delegated_bin) - log.fmt_trace("Transforming managed executable=%s via %s", executable, bin_type) - local delegate = - try(Optional.of_nilable(bin_delegates[bin_type]):ok_or(("Unknown bin type: %s"):format(bin_type))) - target = try(delegate(executable, bin)) - end - - log.fmt_debug("Expanded bin link %s -> %s", bin, target) - if not ctx.fs:file_exists(target) then - return Result.failure(("Tried to link bin %q to non-existent target %q."):format(bin, target)) - end - - if platform.is.unix then - ctx.fs:chmod_exec(target) - end - - expanded_bin_table[bin] = target - end - return expanded_bin_table - end) -end - -local is_dir_path = _.matches "/$" - ----Expands symlink path specifications from spec and returns symlink file table. ----@async ----@param ctx InstallContext ----@param purl Purl ----@param source ParsedPackageSource ----@param file_spec_table table -local function expand_file_spec(ctx, purl, source, file_spec_table) - log.debug("Registering symlinks", ctx.package, file_spec_table) - return Result.try(function(try) - local expr_ctx = { version = purl.version, source = source } - - ---@type table - local interpolated_paths = filter_empty_values(try(expr.tbl_interpolate(file_spec_table, expr_ctx))) - - ---@type table - local expanded_links = {} - - for dest, source_path in pairs(interpolated_paths) do - local cwd = ctx.cwd:get() - - if is_dir_path(dest) then - -- linking dir -> dir - if not is_dir_path(source_path) then - return Result.failure(("Cannot link file %q to dir %q."):format(source_path, dest)) - end - - a.scheduler() - - local glob = path.concat { cwd, source_path } .. "**/*" - log.fmt_trace("Symlink glob for %s: %s", ctx.package, glob) - - ---@type string[] - local files = _.filter_map(function(abs_path) - -- fs.sync because async causes stack overflow on many files (TODO fix that) - if not fs.sync.file_exists(abs_path) then - -- only link actual files (e.g. exclude directory entries from glob) - return Optional.empty() - end - -- turn into relative paths - return Optional.of(abs_path:sub(#cwd + 2)) -- + 2 to remove leading path separator (/) - end, vim.fn.glob(glob, false, true)) - - log.fmt_trace("Expanded glob %s: %s", glob, files) - - for __, file in ipairs(files) do - -- File destination should be relative to the source directory. For example, should the source_path - -- be "gh_2.22.1_macOS_amd64/share/man/" and dest be "man/", it should link source files to the - -- following destinations: - -- - -- gh_2.22.1_macOS_amd64/share/man/ man/ - -- ------------------------------------------------------------------------- - -- gh_2.22.1_macOS_amd64/share/man/man1/gh.1 man/man1/gh.1 - -- gh_2.22.1_macOS_amd64/share/man/man1/gh-run.1 man/man1/gh-run.1 - -- gh_2.22.1_macOS_amd64/share/man/man1/gh-ssh-key.1 man/man1/gh-run.1 - -- - local file_dest = path.concat { - _.trim_end_matches("/", dest), - file:sub(#source_path + 1), - } - expanded_links[file_dest] = file - end - else - -- linking file -> file - if is_dir_path(source_path) then - return Result.failure(("Cannot link dir %q to file %q."):format(source_path, dest)) - end - expanded_links[dest] = source_path - end - end - - return expanded_links - end) -end - ----@async ----@param ctx InstallContext ----@param spec RegistryPackageSpec ----@param purl Purl ----@param source ParsedPackageSource ----@nodiscard -M.bin = function(ctx, spec, purl, source) - return expand_bin(ctx, spec, purl, source):on_success(function(links) - ctx.links.bin = vim.tbl_extend("force", ctx.links.bin, links) - end) -end - ----@async ----@param ctx InstallContext ----@param spec RegistryPackageSpec ----@param purl Purl ----@param source ParsedPackageSource ----@nodiscard -M.share = function(ctx, spec, purl, source) - return expand_file_spec(ctx, purl, source, spec.share):on_success(function(links) - ctx.links.share = vim.tbl_extend("force", ctx.links.share, links) - end) -end - ----@async ----@param ctx InstallContext ----@param spec RegistryPackageSpec ----@param purl Purl ----@param source ParsedPackageSource ----@nodiscard -M.opt = function(ctx, spec, purl, source) - return expand_file_spec(ctx, purl, source, spec.opt):on_success(function(links) - ctx.links.opt = vim.tbl_extend("force", ctx.links.opt, links) - end) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/cargo.lua b/lua/mason-core/installer/registry/providers/cargo.lua deleted file mode 100644 index f4904b73..00000000 --- a/lua/mason-core/installer/registry/providers/cargo.lua +++ /dev/null @@ -1,85 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local providers = require "mason-core.providers" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class CargoSource : RegistryPackageSource ----@field supported_platforms? string[] - ----@param source CargoSource ----@param purl Purl -function M.parse(source, purl) - return Result.try(function(try) - if source.supported_platforms then - try(util.ensure_valid_platform(source.supported_platforms)) - end - - local repository_url = _.path({ "qualifiers", "repository_url" }, purl) - - local git - if repository_url then - git = { - url = repository_url, - rev = _.path({ "qualifiers", "rev" }, purl) == "true", - } - end - - ---@type string? - local features = _.path({ "qualifiers", "features" }, purl) - local locked = _.path({ "qualifiers", "locked" }, purl) - - ---@class ParsedCargoSource : ParsedPackageSource - local parsed_source = { - crate = purl.name, - version = purl.version, - features = features, - locked = locked ~= "false", - git = git, - } - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedCargoSource -function M.install(ctx, source) - local cargo = require "mason-core.installer.managers.cargo" - - return cargo.install(source.crate, source.version, { - git = source.git, - features = source.features, - locked = source.locked, - }) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - ---@type string? - local repository_url = _.path({ "qualifiers", "repository_url" }, purl) - local rev = _.path({ "qualifiers", "rev" }, purl) - if repository_url then - if rev == "true" then - -- When ?rev=true we're targeting a commit SHA. It's not feasible to retrieve all commit SHAs for a - -- repository so we fail instead. - return Result.failure "Unable to retrieve commit SHAs." - end - - ---@type Result? - local git_tags = _.cond { - { - _.matches "github.com/(.+)", - _.compose(providers.github.get_all_tags, _.head, _.match "github.com/(.+)"), - }, - }(repository_url) - if git_tags then - return git_tags - end - end - return providers.crates.get_all_versions(purl.name) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/composer.lua b/lua/mason-core/installer/registry/providers/composer.lua deleted file mode 100644 index d85dd2ba..00000000 --- a/lua/mason-core/installer/registry/providers/composer.lua +++ /dev/null @@ -1,33 +0,0 @@ -local Result = require "mason-core.result" -local providers = require "mason-core.providers" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@param source RegistryPackageSource ----@param purl Purl -function M.parse(source, purl) - ---@class ParsedComposerSource : ParsedPackageSource - local parsed_source = { - package = ("%s/%s"):format(purl.namespace, purl.name), - version = purl.version, - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedComposerSource -function M.install(ctx, source) - local composer = require "mason-core.installer.managers.composer" - return composer.install(source.package, source.version) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return providers.packagist.get_all_versions(("%s/%s"):format(purl.namespace, purl.name)) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/gem.lua b/lua/mason-core/installer/registry/providers/gem.lua deleted file mode 100644 index 9653f116..00000000 --- a/lua/mason-core/installer/registry/providers/gem.lua +++ /dev/null @@ -1,46 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local providers = require "mason-core.providers" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class GemSource : RegistryPackageSource ----@field supported_platforms? string[] ----@field extra_packages? string[] - ----@param source GemSource ----@param purl Purl -function M.parse(source, purl) - return Result.try(function(try) - if source.supported_platforms then - try(util.ensure_valid_platform(source.supported_platforms)) - end - - ---@class ParsedGemSource : ParsedPackageSource - local parsed_source = { - package = purl.name, - version = purl.version, - extra_packages = source.extra_packages, - } - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedGemSource -function M.install(ctx, source) - local gem = require "mason-core.installer.managers.gem" - return gem.install(source.package, source.version, { - extra_packages = source.extra_packages, - }) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return providers.rubygems.get_all_versions(purl.name) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/generic/build.lua b/lua/mason-core/installer/registry/providers/generic/build.lua deleted file mode 100644 index a0d517d8..00000000 --- a/lua/mason-core/installer/registry/providers/generic/build.lua +++ /dev/null @@ -1,40 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local common = require "mason-core.installer.managers.common" -local expr = require "mason-core.installer.registry.expr" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class GenericBuildSource : RegistryPackageSource ----@field build BuildInstruction | BuildInstruction[] - ----@param source GenericBuildSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - return Result.try(function(try) - ---@type BuildInstruction - local build_instruction = try(util.coalesce_by_target(source.build, opts)) - - if build_instruction.env then - local expr_ctx = { version = purl.version, target = build_instruction.target } - build_instruction.env = try(expr.tbl_interpolate(build_instruction.env, expr_ctx)) - end - - ---@class ParsedGenericBuildSource : ParsedPackageSource - local parsed_source = { - build = build_instruction, - } - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedGenericBuildSource -function M.install(ctx, source) - return common.run_build_instruction(source.build) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/generic/download.lua b/lua/mason-core/installer/registry/providers/generic/download.lua deleted file mode 100644 index 4622a844..00000000 --- a/lua/mason-core/installer/registry/providers/generic/download.lua +++ /dev/null @@ -1,52 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local common = require "mason-core.installer.managers.common" -local expr = require "mason-core.installer.registry.expr" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class GenericDownload ----@field target (Platform | Platform[])? ----@field files table - ----@class GenericDownloadSource : RegistryPackageSource ----@field download GenericDownload | GenericDownload[] - ----@param source GenericDownloadSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - return Result.try(function(try) - local download = try(util.coalesce_by_target(source.download, opts)) - - local expr_ctx = { version = purl.version } - ---@type { files: table } - local interpolated_download = try(expr.tbl_interpolate(download, expr_ctx)) - - ---@type DownloadItem[] - local downloads = _.map(function(pair) - ---@type DownloadItem - return { - out_file = pair[1], - download_url = pair[2], - } - end, _.to_pairs(interpolated_download.files)) - - ---@class ParsedGenericDownloadSource : ParsedPackageSource - local parsed_source = { - download = interpolated_download, - downloads = downloads, - } - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedGenericDownloadSource -function M.install(ctx, source) - return common.download_files(ctx, source.downloads) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/generic/init.lua b/lua/mason-core/installer/registry/providers/generic/init.lua deleted file mode 100644 index 1bf79e94..00000000 --- a/lua/mason-core/installer/registry/providers/generic/init.lua +++ /dev/null @@ -1,42 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" - -local M = {} - ----@param source GenericDownloadSource | GenericBuildSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - if source.download then - source = source --[[@as GenericDownloadSource]] - return require("mason-core.installer.registry.providers.generic.download").parse(source, purl, opts) - elseif source.build then - source = source --[[@as GenericBuildSource]] - return require("mason-core.installer.registry.providers.generic.build").parse(source, purl, opts) - else - return Result.failure "Unknown source type." - end -end - ----@async ----@param ctx InstallContext ----@param source ParsedGenericDownloadSource | ParsedGenericBuildSource -function M.install(ctx, source) - if source.download then - source = source --[[@as ParsedGenericDownloadSource]] - return require("mason-core.installer.registry.providers.generic.download").install(ctx, source) - elseif source.build then - source = source --[[@as ParsedGenericBuildSource]] - return require("mason-core.installer.registry.providers.generic.build").install(ctx, source) - else - return Result.failure "Unknown source type." - end -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return Result.failure "Unimplemented." -end - -return M diff --git a/lua/mason-core/installer/registry/providers/github/build.lua b/lua/mason-core/installer/registry/providers/github/build.lua deleted file mode 100644 index 1c17bb1a..00000000 --- a/lua/mason-core/installer/registry/providers/github/build.lua +++ /dev/null @@ -1,51 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local common = require "mason-core.installer.managers.common" -local expr = require "mason-core.installer.registry.expr" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class GitHubBuildSource : RegistryPackageSource ----@field build BuildInstruction | BuildInstruction[] - ----@param source GitHubBuildSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - return Result.try(function(try) - ---@type BuildInstruction - local build_instruction = try(util.coalesce_by_target(source.build, opts)) - - local expr_ctx = { version = purl.version } - - -- TODO: In a few releases of the core registry, r-languageserver reads $MASON_VERSION directly. Remove this - -- some time in the future. - local default_env = { - MASON_VERSION = purl.version, - } - build_instruction.env = - vim.tbl_extend("force", default_env, try(expr.tbl_interpolate(build_instruction.env or {}, expr_ctx))) - - ---@class ParsedGitHubBuildSource : ParsedPackageSource - local parsed_source = { - build = build_instruction, - repo = ("https://github.com/%s/%s.git"):format(purl.namespace, purl.name), - rev = purl.version, - } - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedGitHubBuildSource -function M.install(ctx, source) - local std = require "mason-core.installer.managers.std" - return Result.try(function(try) - try(std.clone(source.repo, { rev = source.rev })) - try(common.run_build_instruction(source.build)) - end) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/github/init.lua b/lua/mason-core/installer/registry/providers/github/init.lua deleted file mode 100644 index 0d68f3a5..00000000 --- a/lua/mason-core/installer/registry/providers/github/init.lua +++ /dev/null @@ -1,49 +0,0 @@ -local Result = require "mason-core.result" - -local M = {} - ----@param source GitHubReleaseSource | GitHubBuildSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - if source.asset then - source = source --[[@as GitHubReleaseSource]] - return require("mason-core.installer.registry.providers.github.release").parse(source, purl, opts) - elseif source.build then - source = source --[[@as GitHubBuildSource]] - return require("mason-core.installer.registry.providers.github.build").parse(source, purl, opts) - else - return Result.failure "Unknown source type." - end -end - ----@async ----@param ctx InstallContext ----@param source ParsedGitHubReleaseSource | ParsedGitHubBuildSource -function M.install(ctx, source, purl) - if source.asset then - source = source--[[@as ParsedGitHubReleaseSource]] - return require("mason-core.installer.registry.providers.github.release").install(ctx, source) - elseif source.build then - source = source--[[@as ParsedGitHubBuildSource]] - return require("mason-core.installer.registry.providers.github.build").install(ctx, source) - else - return Result.failure "Unknown source type." - end -end - ----@async ----@param purl Purl ----@param source GitHubReleaseSource | GitHubBuildSource -function M.get_versions(purl, source) - if source.asset then - return require("mason-core.installer.registry.providers.github.release").get_versions(purl) - elseif source.build then - -- We can't yet reliably determine the true source (release, tag, commit, etc.) for "build" sources. - return Result.failure "Unimplemented." - else - return Result.failure "Unknown source type." - end -end - -return M diff --git a/lua/mason-core/installer/registry/providers/github/release.lua b/lua/mason-core/installer/registry/providers/github/release.lua deleted file mode 100644 index 8c8a8a8f..00000000 --- a/lua/mason-core/installer/registry/providers/github/release.lua +++ /dev/null @@ -1,54 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local common = require "mason-core.installer.managers.common" -local expr = require "mason-core.installer.registry.expr" -local providers = require "mason-core.providers" -local settings = require "mason.settings" -local util = require "mason-core.installer.registry.util" - ----@class GitHubReleaseSource : RegistryPackageSource ----@field asset FileDownloadSpec | FileDownloadSpec[] - -local M = {} - ----@param source GitHubReleaseSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - return Result.try(function(try) - local expr_ctx = { version = purl.version } - ---@type FileDownloadSpec - local asset = try(util.coalesce_by_target(try(expr.tbl_interpolate(source.asset, expr_ctx)), opts)) - - local downloads = common.parse_downloads(asset, function(file) - return settings.current.github.download_url_template:format( - ("%s/%s"):format(purl.namespace, purl.name), - purl.version, - file - ) - end) - - ---@class ParsedGitHubReleaseSource : ParsedPackageSource - local parsed_source = { - repo = ("%s/%s"):format(purl.namespace, purl.name), - asset = common.normalize_files(asset), - downloads = downloads, - } - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedGitHubReleaseSource -function M.install(ctx, source) - return common.download_files(ctx, source.downloads) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return providers.github.get_all_release_versions(("%s/%s"):format(purl.namespace, purl.name)) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/golang.lua b/lua/mason-core/installer/registry/providers/golang.lua deleted file mode 100644 index 896d9bf9..00000000 --- a/lua/mason-core/installer/registry/providers/golang.lua +++ /dev/null @@ -1,50 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local providers = require "mason-core.providers" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@param purl Purl -local function get_package_name(purl) - if purl.subpath then - return ("%s/%s/%s"):format(purl.namespace, purl.name, purl.subpath) - else - return ("%s/%s"):format(purl.namespace, purl.name) - end -end - ----@class GolangSource : RegistryPackageSource ----@field extra_packages? string[] - ----@param source GolangSource ----@param purl Purl -function M.parse(source, purl) - ---@class ParsedGolangSource : ParsedPackageSource - local parsed_source = { - package = get_package_name(purl), - version = purl.version, - extra_packages = source.extra_packages, - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedGolangSource -function M.install(ctx, source) - local golang = require "mason-core.installer.managers.golang" - - return golang.install(source.package, source.version, { - extra_packages = source.extra_packages, - }) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return providers.golang.get_all_versions(("%s/%s"):format(purl.namespace, purl.name)) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/luarocks.lua b/lua/mason-core/installer/registry/providers/luarocks.lua deleted file mode 100644 index 356857c0..00000000 --- a/lua/mason-core/installer/registry/providers/luarocks.lua +++ /dev/null @@ -1,51 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" - -local M = {} - ----@param purl Purl -local function parse_package_name(purl) - if purl.namespace then - return ("%s/%s"):format(purl.namespace, purl.name) - else - return purl.name - end -end - -local parse_server = _.path { "qualifiers", "repository_url" } -local parse_dev = _.compose(_.equals "true", _.path { "qualifiers", "dev" }) - ----@param source RegistryPackageSource ----@param purl Purl -function M.parse(source, purl) - ---@class ParsedLuaRocksSource : ParsedPackageSource - local parsed_source = { - package = parse_package_name(purl), - version = purl.version, - ---@type string? - server = parse_server(purl), - ---@type boolean? - dev = parse_dev(purl), - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedLuaRocksSource -function M.install(ctx, source) - local luarocks = require "mason-core.installer.managers.luarocks" - return luarocks.install(source.package, source.version, { - server = source.server, - dev = source.dev, - }) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return Result.failure "Unimplemented." -end - -return M diff --git a/lua/mason-core/installer/registry/providers/mason.lua b/lua/mason-core/installer/registry/providers/mason.lua deleted file mode 100644 index 3490ebaa..00000000 --- a/lua/mason-core/installer/registry/providers/mason.lua +++ /dev/null @@ -1,43 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" - -local M = {} - ----@param source RegistryPackageSource ----@param purl Purl -function M.parse(source, purl) - if type(source.install) ~= "function" and type((getmetatable(source.install) or {}).__call) ~= "function" then - return Result.failure "source.install is not a function." - end - - ---@class ParsedMasonSource : ParsedPackageSource - local parsed_source = { - purl = purl, - ---@type async fun(ctx: InstallContext, purl: Purl) - install = source.install, - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedMasonSource -function M.install(ctx, source) - ctx.spawn.strict_mode = true - return Result.pcall(source.install, ctx, source.purl) - :on_success(function() - ctx.spawn.strict_mode = false - end) - :on_failure(function() - ctx.spawn.strict_mode = false - end) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return Result.failure "Unimplemented." -end - -return M diff --git a/lua/mason-core/installer/registry/providers/npm.lua b/lua/mason-core/installer/registry/providers/npm.lua deleted file mode 100644 index e8489fe8..00000000 --- a/lua/mason-core/installer/registry/providers/npm.lua +++ /dev/null @@ -1,52 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local providers = require "mason-core.providers" - ----@param purl Purl -local function purl_to_npm(purl) - if purl.namespace then - return ("%s/%s"):format(purl.namespace, purl.name) - else - return purl.name - end -end - -local M = {} - ----@class NpmSource : RegistryPackageSource ----@field extra_packages? string[] - ----@param source NpmSource ----@param purl Purl -function M.parse(source, purl) - ---@class ParsedNpmSource : ParsedPackageSource - local parsed_source = { - package = purl_to_npm(purl), - version = purl.version, - extra_packages = source.extra_packages, - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedNpmSource -function M.install(ctx, source) - local npm = require "mason-core.installer.managers.npm" - - return Result.try(function(try) - try(npm.init()) - try(npm.install(source.package, source.version, { - extra_packages = source.extra_packages, - })) - end) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return providers.npm.get_all_versions(purl_to_npm(purl)) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/nuget.lua b/lua/mason-core/installer/registry/providers/nuget.lua deleted file mode 100644 index 370c7b95..00000000 --- a/lua/mason-core/installer/registry/providers/nuget.lua +++ /dev/null @@ -1,31 +0,0 @@ -local Result = require "mason-core.result" - -local M = {} - ----@param source RegistryPackageSource ----@param purl Purl -function M.parse(source, purl) - ---@class ParsedNugetSource : ParsedPackageSource - local parsed_source = { - package = purl.name, - version = purl.version, - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedNugetSource -function M.install(ctx, source) - local nuget = require "mason-core.installer.managers.nuget" - return nuget.install(source.package, source.version) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return Result.failure "Unimplemented." -end - -return M diff --git a/lua/mason-core/installer/registry/providers/opam.lua b/lua/mason-core/installer/registry/providers/opam.lua deleted file mode 100644 index 276686ae..00000000 --- a/lua/mason-core/installer/registry/providers/opam.lua +++ /dev/null @@ -1,31 +0,0 @@ -local Result = require "mason-core.result" - -local M = {} - ----@param source RegistryPackageSource ----@param purl Purl -function M.parse(source, purl) - ---@class ParsedOpamSource : ParsedPackageSource - local parsed_source = { - package = purl.name, - version = purl.version, - } - - return Result.success(parsed_source) -end - ----@async ----@param ctx InstallContext ----@param source ParsedOpamSource -function M.install(ctx, source) - local opam = require "mason-core.installer.managers.opam" - return opam.install(source.package, source.version) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return Result.failure "Unimplemented." -end - -return M diff --git a/lua/mason-core/installer/registry/providers/openvsx.lua b/lua/mason-core/installer/registry/providers/openvsx.lua deleted file mode 100644 index df52807a..00000000 --- a/lua/mason-core/installer/registry/providers/openvsx.lua +++ /dev/null @@ -1,63 +0,0 @@ -local Result = require "mason-core.result" -local common = require "mason-core.installer.managers.common" -local expr = require "mason-core.installer.registry.expr" -local providers = require "mason-core.providers" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class OpenVSXSourceDownload : FileDownloadSpec ----@field target_platform? string - ----@class OpenVSXSource : RegistryPackageSource ----@field download FileDownloadSpec | FileDownloadSpec[] - ----@param source OpenVSXSource ----@param purl Purl ----@param opts PackageInstallOpts -function M.parse(source, purl, opts) - return Result.try(function(try) - local expr_ctx = { version = purl.version } - ---@type OpenVSXSourceDownload - local download = try(util.coalesce_by_target(try(expr.tbl_interpolate(source.download, expr_ctx)), opts)) - - local downloads = common.parse_downloads(download, function(file) - if download.target_platform then - return ("https://open-vsx.org/api/%s/%s/%s/%s/file/%s"):format( - purl.namespace, - purl.name, - download.target_platform, - purl.version, - file - ) - else - return ("https://open-vsx.org/api/%s/%s/%s/file/%s"):format( - purl.namespace, - purl.name, - purl.version, - file - ) - end - end) - - ---@class ParsedOpenVSXSource : ParsedPackageSource - local parsed_source = { - download = common.normalize_files(download), - downloads = downloads, - } - return parsed_source - end) -end - ----@param ctx InstallContext ----@param source ParsedOpenVSXSource -function M.install(ctx, source) - return common.download_files(ctx, source.downloads) -end - ----@param purl Purl -function M.get_versions(purl) - return providers.openvsx.get_all_versions(purl.namespace, purl.name) -end - -return M diff --git a/lua/mason-core/installer/registry/providers/pypi.lua b/lua/mason-core/installer/registry/providers/pypi.lua deleted file mode 100644 index 3fe6f89e..00000000 --- a/lua/mason-core/installer/registry/providers/pypi.lua +++ /dev/null @@ -1,66 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local providers = require "mason-core.providers" -local settings = require "mason.settings" -local util = require "mason-core.installer.registry.util" - -local M = {} - ----@class PypiSource : RegistryPackageSource ----@field extra_packages? string[] ----@field supported_platforms? string[] - ----@param source PypiSource ----@param purl Purl -function M.parse(source, purl) - return Result.try(function(try) - if source.supported_platforms then - try(util.ensure_valid_platform(source.supported_platforms)) - end - - ---@class ParsedPypiSource : ParsedPackageSource - local parsed_source = { - package = purl.name, - version = purl.version --[[ @as string ]], - extra = _.path({ "qualifiers", "extra" }, purl), - extra_packages = source.extra_packages, - pip = { - upgrade = settings.current.pip.upgrade_pip, - extra_args = settings.current.pip.install_args, - }, - } - - return parsed_source - end) -end - ----@async ----@param ctx InstallContext ----@param source ParsedPypiSource -function M.install(ctx, source) - local pypi = require "mason-core.installer.managers.pypi" - - return Result.try(function(try) - try(pypi.init { - package = { - name = source.package, - version = source.version, - }, - upgrade_pip = source.pip.upgrade, - install_extra_args = source.pip.extra_args, - }) - try(pypi.install(source.package, source.version, { - extra = source.extra, - extra_packages = source.extra_packages, - install_extra_args = source.pip.extra_args, - })) - end) -end - ----@async ----@param purl Purl -function M.get_versions(purl) - return providers.pypi.get_all_versions(purl.name) -end - -return M diff --git a/lua/mason-core/installer/registry/schemas.lua b/lua/mason-core/installer/registry/schemas.lua deleted file mode 100644 index f9d044af..00000000 --- a/lua/mason-core/installer/registry/schemas.lua +++ /dev/null @@ -1,75 +0,0 @@ -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local a = require "mason-core.async" -local expr = require "mason-core.installer.registry.expr" -local fetch = require "mason-core.fetch" -local log = require "mason-core.log" -local path = require "mason-core.path" -local std = require "mason-core.installer.managers.std" - -local M = {} - ----@async ----@param ctx InstallContext ----@param url string -local function download_lsp_schema(ctx, url) - return Result.try(function(try) - local is_vscode_schema = _.starts_with("vscode:", url) - local out_file = path.concat { "mason-schemas", "lsp.json" } - local share_file = path.concat { "mason-schemas", "lsp", ("%s.json"):format(ctx.package.name) } - - if is_vscode_schema then - local url = unpack(_.match("^vscode:(.+)$", url)) - ctx.stdio_sink.stdout(("Downloading LSP configuration schema from %q…\n"):format(url)) - local json = try(fetch(url)) - - ---@type { contributes?: { configuration?: table } } - local schema = try(Result.pcall(vim.json.decode, json)) - local configuration = schema.contributes and schema.contributes.configuration - - if configuration then - ctx.fs:write_file(out_file, vim.json.encode(configuration) --[[@as string]]) - ctx.links.share[share_file] = out_file - else - return Result.failure "Unable to find LSP entry in VSCode schema." - end - else - ctx.stdio_sink.stdout(("Downloading LSP configuration schema from %q…\n"):format(url)) - try(std.download_file(url, out_file)) - ctx.links.share[share_file] = out_file - end - end) -end - ----@async ----@param ctx InstallContext ----@param spec RegistryPackageSpec ----@param purl Purl ----@param source ParsedPackageSource ----@nodiscard -function M.download(ctx, spec, purl, source) - return Result.try(function(try) - log.debug("schemas: download", ctx.package, spec.schemas) - local schemas = spec.schemas - if not schemas then - return - end - ---@type RegistryPackageSchemas - local interpolated_schemas = try(expr.tbl_interpolate(schemas, { version = purl.version, source = source })) - ctx.fs:mkdir "mason-schemas" - - if interpolated_schemas.lsp then - try(a.wait_first { - function() - return download_lsp_schema(ctx, interpolated_schemas.lsp) - end, - function() - a.sleep(5000) - return Result.failure "Schema download timed out." - end, - }) - end - end) -end - -return M diff --git a/lua/mason-core/installer/registry/util.lua b/lua/mason-core/installer/registry/util.lua deleted file mode 100644 index b3735c9c..00000000 --- a/lua/mason-core/installer/registry/util.lua +++ /dev/null @@ -1,83 +0,0 @@ -local Optional = require "mason-core.optional" -local Result = require "mason-core.result" -local _ = require "mason-core.functional" -local installer = require "mason-core.installer" -local log = require "mason-core.log" -local platform = require "mason-core.platform" - -local M = {} - ----@generic T : { target: Platform | Platform[] } ----@param candidates T[] | T ----@param opts PackageInstallOpts ----@return Result # Result -function M.coalesce_by_target(candidates, opts) - if not _.is_list(candidates) then - return Result.success(candidates) - end - return Optional.of_nilable(_.find_first(function(asset) - if opts.target then - -- Matching against a provided target rather than the current platform is an escape hatch primarily meant - -- for automated testing purposes. - if type(asset.target) == "table" then - return _.any(_.equals(opts.target), asset.target) - else - return asset.target == opts.target - end - else - if type(asset.target) == "table" then - return _.any(function(target) - return platform.is[target] - end, asset.target) - else - return platform.is[asset.target] - end - end - end, candidates)):ok_or "PLATFORM_UNSUPPORTED" -end - ----Checks whether a custom version of a package installation corresponds to a valid version. ----@async ----@param versions_thunk async fun(): Result Result -function M.ensure_valid_version(versions_thunk) - local ctx = installer.context() - local version = ctx.opts.version - - if version and not ctx.opts.force then - ctx.stdio_sink.stdout "Fetching available versions…\n" - local all_versions = versions_thunk() - if all_versions:is_failure() then - log.warn("Failed to fetch versions for package", ctx.package) - -- Gracefully fail (i.e. optimistically continue package installation) - return Result.success() - end - all_versions = all_versions:get_or_else {} - - if not _.any(_.equals(version), all_versions) then - ctx.stdio_sink.stderr(("Tried to install invalid version %q. Available versions:\n"):format(version)) - ctx.stdio_sink.stderr(_.compose(_.join "\n", _.map(_.join ", "), _.split_every(15))(all_versions)) - ctx.stdio_sink.stderr "\n\n" - ctx.stdio_sink.stderr( - ("Run with --force flag to bypass version validation:\n :MasonInstall --force %s@%s\n\n"):format( - ctx.package.name, - version - ) - ) - return Result.failure(("Version %q is not available."):format(version)) - end - end - - return Result.success() -end - ----@param platforms string[] -function M.ensure_valid_platform(platforms) - if not _.any(function(target) - return platform.is[target] - end, platforms) then - return Result.failure "PLATFORM_UNSUPPORTED" - end - return Result.success() -end - -return M diff --git a/lua/mason-core/installer/runner.lua b/lua/mason-core/installer/runner.lua new file mode 100644 index 00000000..175610d5 --- /dev/null +++ b/lua/mason-core/installer/runner.lua @@ -0,0 +1,218 @@ +local Result = require "mason-core.result" +local _ = require "mason-core.functional" +local a = require "mason-core.async" +local compiler = require "mason-core.installer.compiler" +local fs = require "mason-core.fs" +local linker = require "mason-core.installer.linker" +local log = require "mason-core.log" +local registry = require "mason-registry" + +local InstallContext = require "mason-core.installer.context" +local InstallContextCwd = require "mason-core.installer.context.cwd" +local InstallContextFs = require "mason-core.installer.context.fs" +local InstallContextSpawn = require "mason-core.installer.context.spawn" + +---@class InstallRunner +---@field location InstallLocation +---@field handle InstallHandle +---@field semaphore Semaphore +---@field permit Permit? +local InstallRunner = {} +InstallRunner.__index = InstallRunner + +---@param location InstallLocation +---@param handle InstallHandle +---@param semaphore Semaphore +function InstallRunner.new(location, handle, semaphore) + return setmetatable({ + location = location, + semaphore = semaphore, + handle = handle, + }, InstallRunner) +end + +---@param opts PackageInstallOpts +---@param callback? fun(success: boolean, result: any) +function InstallRunner:execute(opts, callback) + local handle = self.handle + log.fmt_info("Executing installer for %s %s", handle.package, opts) + + local context_cwd = InstallContextCwd.new(self.location) + local context_spawn = InstallContextSpawn.new(context_cwd, handle, false) + local context_fs = InstallContextFs.new(context_cwd) + local context = InstallContext.new(handle, context_cwd, context_spawn, context_fs, opts) + + local tailed_output = {} + + if opts.debug then + local function append_log(chunk) + tailed_output[#tailed_output + 1] = chunk + end + handle:on("stdout", append_log) + handle:on("stderr", append_log) + end + + ---@async + local function finalize_logs(success, result) + if not success then + context.stdio_sink.stderr(tostring(result)) + context.stdio_sink.stderr "\n" + end + + if opts.debug then + context.fs:write_file("mason-debug.log", table.concat(tailed_output, "")) + context.stdio_sink.stdout(("[debug] Installation directory retained at %q.\n"):format(context.cwd:get())) + end + end + + ---@async + local finalize = a.scope(function(success, result) + finalize_logs(success, result) + + if not opts.debug and not success then + -- clean up installation dir + pcall(function() + fs.async.rmrf(context.cwd:get()) + end) + end + + if not handle:is_closing() then + handle:close() + end + + self:release_lock() + self:release_permit() + + if callback then + callback(success, result) + end + + if success then + log.fmt_info("Installation succeeded for %s", handle.package) + handle.package:emit("install:success", handle) + registry:emit("package:install:success", handle.package, handle) + else + log.fmt_error("Installation failed for %s error=%s", handle.package, result) + handle.package:emit("install:failed", handle, result) + registry:emit("package:install:failed", handle.package, handle, result) + end + end) + + local cancel_execution = a.run(function() + return Result.try(function(try) + try(self:acquire_permit()) + try(self.location:initialize()) + try(self:acquire_lock(opts.force)) + + context.receipt:with_start_time(vim.loop.gettimeofday()) + + -- 1. initialize working directory + try(context_cwd:initialize(handle)) + + -- 2. run installer + ---@type async fun(ctx: InstallContext): Result + local installer = try(compiler.compile(handle.package.spec, opts)) + try(context:execute(installer)) + + -- 3. promote temporary installation dir + try(Result.pcall(function() + context:promote_cwd() + end)) + + -- 4. link package & write receipt + return linker + .link(context) + :and_then(function() + return context:build_receipt(context) + end) + :and_then( + ---@param receipt InstallReceipt + function(receipt) + return receipt:write(context.cwd:get()) + end + ) + :on_failure(function() + -- unlink any links that were made before failure + context:build_receipt():on_success( + ---@param receipt InstallReceipt + function(receipt) + linker.unlink(handle.package, receipt):on_failure(function(err) + log.error("Failed to unlink failed installation.", err) + end) + end + ) + end) + end):get_or_throw() + end, finalize) + + handle:once("terminate", function() + cancel_execution() + local function on_close() + finalize(false, "Installation was aborted.") + end + if handle:is_closed() then + on_close() + else + handle:once("closed", on_close) + end + end) +end + +---@async +---@private +function InstallRunner:release_lock() + pcall(fs.async.unlink, self.location:lockfile(self.handle.package.name)) +end + +---@async +---@param force boolean? +---@private +function InstallRunner:acquire_lock(force) + local pkg = self.handle.package + log.debug("Attempting to lock package", pkg) + local lockfile = self.location:lockfile(pkg.name) + if force ~= true and fs.async.file_exists(lockfile) then + log.error("Lockfile already exists.", pkg) + return Result.failure( + ("Lockfile exists, installation is already running in another process (pid: %s). Run with :MasonInstall --force to bypass."):format( + fs.async.read_file(lockfile) + ) + ) + end + a.scheduler() + fs.async.write_file(lockfile, vim.fn.getpid()) + log.debug("Wrote lockfile", pkg) + return Result.success(lockfile) +end + +---@async +---@private +function InstallRunner:acquire_permit() + local handle = self.handle + if handle:is_active() or handle:is_closed() then + log.fmt_debug("Received active or closed handle %s", handle) + return Result.failure "Invalid handle state." + end + + handle:queued() + local permit = self.semaphore:acquire() + if handle:is_closed() then + permit:forget() + log.fmt_trace("Installation was aborted %s", handle) + return Result.failure "Installation was aborted." + end + log.fmt_trace("Activating handle %s", handle) + handle:active() + self.permit = permit + return Result.success() +end + +---@private +function InstallRunner:release_permit() + if self.permit then + self.permit:forget() + self.permit = nil + end +end + +return InstallRunner diff --git a/lua/mason-core/package/init.lua b/lua/mason-core/package/init.lua index bc98a72a..b0da8a61 100644 --- a/lua/mason-core/package/init.lua +++ b/lua/mason-core/package/init.lua @@ -1,14 +1,17 @@ local EventEmitter = require "mason-core.EventEmitter" +local InstallLocation = require "mason-core.installer.location" +local InstallRunner = require "mason-core.installer.runner" local Optional = require "mason-core.optional" local Purl = require "mason-core.purl" local Result = require "mason-core.result" local _ = require "mason-core.functional" -local a = require "mason-core.async" local fs = require "mason-core.fs" local log = require "mason-core.log" local path = require "mason-core.path" local platform = require "mason-core.platform" local registry = require "mason-registry" +local settings = require "mason.settings" +local Semaphore = require("mason-core.async.control").Semaphore ---@class Package : EventEmitter ---@field name string @@ -135,81 +138,56 @@ end ---@alias PackageInstallOpts { version?: string, debug?: boolean, target?: string, force?: boolean, strict?: boolean } +-- TODO this needs to be elsewhere +local semaphore = Semaphore.new(settings.current.max_concurrent_installers) + +function Package:is_installing() + return self:get_handle() + :map( + ---@param handle InstallHandle + function(handle) + return not handle:is_closed() + end + ) + :or_else(false) +end + ---@param opts? PackageInstallOpts +---@param callback? fun(success: boolean, result: any) ---@return InstallHandle -function Package:install(opts) +function Package:install(opts, callback) opts = opts or {} - return self:get_handle() - :map(function(handle) - if not handle:is_closed() then - log.fmt_debug("Handle %s already exist for package %s", handle, self) - return handle - end - end) - :or_else_get(function() - local handle = self:new_handle() - a.run( - require("mason-core.installer").execute, - ---@param success boolean - ---@param result Result - function(success, result) - if not success then - -- Installer failed abnormally (i.e. unexpected exception in the installer code itself). - log.error("Unexpected error", result) - handle.stdio.sink.stderr(tostring(result)) - handle.stdio.sink.stderr "\nInstallation failed abnormally. Please report this error." - self:emit("install:failed", handle) - registry:emit("package:install:failed", self, handle) - - -- We terminate _after_ emitting failure events because [termination -> failed] have different - -- meaning than [failed -> terminate] ([termination -> failed] is interpreted as a triggered - -- termination). - if not handle:is_closed() and not handle.is_terminated then - handle:terminate() - end - return - end - result - :on_success(function() - self:emit("install:success", handle) - registry:emit("package:install:success", self, handle) - end) - :on_failure(function() - self:emit("install:failed", handle) - registry:emit("package:install:failed", self, handle) - end) - end, - handle, - opts - ) - return handle - end) + assert(not self:is_installing(), "Package is already installing.") + local handle = self:new_handle() + local runner = InstallRunner.new(InstallLocation.new(settings.current.install_root_dir), handle, semaphore) + runner:execute(opts, callback) + return handle end +---@return boolean function Package:uninstall() - local was_unlinked = self:unlink() - if was_unlinked then - self:emit "uninstall:success" - registry:emit("package:uninstall:success", self) - end - return was_unlinked + return self:get_receipt() + :map(function(receipt) + self:unlink(receipt) + self:emit("uninstall:success", receipt) + registry:emit("package:uninstall:success", self, receipt) + return true + end) + :or_else(false) end -function Package:unlink() +---@private +---@param receipt InstallReceipt +function Package:unlink(receipt) log.fmt_trace("Unlinking %s", self) local install_path = self:get_install_path() + -- 1. Unlink - self:get_receipt():if_present(function(receipt) - local linker = require "mason-core.installer.linker" - linker.unlink(self, receipt):get_or_throw() - end) + local linker = require "mason-core.installer.linker" + linker.unlink(self, receipt):get_or_throw() -- 2. Remove installation artifacts - if fs.sync.dir_exists(install_path) then - fs.sync.rmrf(install_path) - return true - end - return false + fs.sync.rmrf(install_path) end function Package:is_installed() @@ -260,18 +238,18 @@ end ---@param opts? PackageInstallOpts function Package:is_installable(opts) - return require("mason-core.installer.registry").parse(self.spec, opts or {}):is_success() + return require("mason-core.installer.compiler").parse(self.spec, opts or {}):is_success() end ---@return Result # Result function Package:get_all_versions() - local registry_installer = require "mason-core.installer.registry" + local compiler = require "mason-core.installer.compiler" return Result.try(function(try) ---@type Purl local purl = try(Purl.parse(self.spec.source.id)) - ---@type InstallerProvider - local provider = try(registry_installer.get_provider(purl)) - return provider.get_versions(purl, self.spec.source) + ---@type InstallerCompiler + local compiler = try(compiler.get_compiler(purl)) + return compiler.get_versions(purl, self.spec.source) end) end diff --git a/lua/mason-core/receipt.lua b/lua/mason-core/receipt.lua index d9fe9d88..748cab38 100644 --- a/lua/mason-core/receipt.lua +++ b/lua/mason-core/receipt.lua @@ -1,3 +1,7 @@ +local Result = require "mason-core.result" +local fs = require "mason-core.fs" +local path = require "mason-core.path" + local M = {} ---@alias InstallReceiptSchemaVersion @@ -56,11 +60,11 @@ function InstallReceipt:get_links() end ---@async ----@param cwd string -function InstallReceipt:write(cwd) - local path = require "mason-core.path" - local fs = require "mason-core.fs" - fs.async.write_file(path.concat { cwd, "mason-receipt.json" }, vim.json.encode(self)) +---@param dir string +function InstallReceipt:write(dir) + return Result.pcall(function() + fs.async.write_file(path.concat { dir, "mason-receipt.json" }, vim.json.encode(self)) + end) end ---@class InstallReceiptBuilder diff --git a/lua/mason-registry/sources/util.lua b/lua/mason-registry/sources/util.lua index 80d5f16f..04ab7845 100644 --- a/lua/mason-registry/sources/util.lua +++ b/lua/mason-registry/sources/util.lua @@ -1,8 +1,8 @@ local Optional = require "mason-core.optional" local Pkg = require "mason-core.package" local _ = require "mason-core.functional" +local compiler = require "mason-core.installer.compiler" local log = require "mason-core.log" -local registry_installer = require "mason-core.installer.registry" local M = {} @@ -10,7 +10,7 @@ local M = {} function M.map_registry_spec(spec) spec.schema = spec.schema or "registry+v1" - if not registry_installer.SCHEMA_CAP[spec.schema] then + if not compiler.SCHEMA_CAP[spec.schema] then log.fmt_debug("Excluding package=%s with unsupported schema_version=%s", spec.name, spec.schema) return Optional.empty() end diff --git a/lua/mason-test/helpers.lua b/lua/mason-test/helpers.lua new file mode 100644 index 00000000..57b486ea --- /dev/null +++ b/lua/mason-test/helpers.lua @@ -0,0 +1,33 @@ +local InstallContext = require "mason-core.installer.context" +local InstallContextCwd = require "mason-core.installer.context.cwd" +local InstallContextFs = require "mason-core.installer.context.fs" +local InstallContextSpawn = require "mason-core.installer.context.spawn" +local InstallHandle = require "mason-core.installer.handle" +local InstallLocation = require "mason-core.installer.location" +local Result = require "mason-core.result" +local registry = require "mason-registry" +local spy = require "luassert.spy" + +local M = {} + +---@param opts? { install_opts?: PackageInstallOpts, package?: string } +function M.create_context(opts) + local pkg = registry.get_package(opts and opts.package or "dummy") + local handle = InstallHandle.new(pkg) + local location = InstallLocation.new "/tmp/install-dir" + local context_cwd = InstallContextCwd.new(location):set(location.dir) + local context_spawn = InstallContextSpawn.new(context_cwd, handle, false) + local context_fs = InstallContextFs.new(context_cwd) + local context = InstallContext.new(handle, context_cwd, context_spawn, context_fs, opts and opts.install_opts or {}) + context.spawn = setmetatable({}, { + __index = function(s, cmd) + s[cmd] = spy.new(function() + return Result.success { stdout = nil, stderr = nil } + end) + return s[cmd] + end, + }) + return context +end + +return M -- cgit v1.2.3-70-g09d2