diff options
Diffstat (limited to 'lua')
28 files changed, 848 insertions, 519 deletions
diff --git a/lua/mason-core/functional/init.lua b/lua/mason-core/functional/init.lua index d377d2db..7aa58940 100644 --- a/lua/mason-core/functional/init.lua +++ b/lua/mason-core/functional/init.lua @@ -3,9 +3,7 @@ local _ = {} local function lazy_require(module) return setmetatable({}, { __index = function(m, k) - return function(...) - return require(module)[k](...) - end + return require(module)[k] end, }) end diff --git a/lua/mason-core/installer/handle.lua b/lua/mason-core/installer/InstallHandle.lua index 62da5bae..f5a42c53 100644 --- a/lua/mason-core/installer/handle.lua +++ b/lua/mason-core/installer/InstallHandle.lua @@ -43,10 +43,11 @@ function InstallHandleSpawnHandle:__tostring() end ---@class InstallHandle : EventEmitter ----@field package Package +---@field package AbstractPackage ---@field state InstallHandleState ---@field stdio { buffers: { stdout: string[], stderr: string[] }, sink: StdioSink } ---@field is_terminated boolean +---@field location InstallLocation ---@field private spawn_handles InstallHandleSpawnHandle[] local InstallHandle = {} InstallHandle.__index = InstallHandle @@ -70,14 +71,17 @@ local function new_sink(handle) } end ----@param pkg Package -function InstallHandle:new(pkg) +---@param pkg AbstractPackage +---@param location InstallLocation +function InstallHandle:new(pkg, location) + ---@type InstallHandle local instance = EventEmitter.new(self) instance.state = "IDLE" instance.package = pkg instance.spawn_handles = {} instance.stdio = new_sink(instance) instance.is_terminated = false + instance.location = location return instance end diff --git a/lua/mason-core/installer/location.lua b/lua/mason-core/installer/InstallLocation.lua index 9cdf097f..00b517b9 100644 --- a/lua/mason-core/installer/location.lua +++ b/lua/mason-core/installer/InstallLocation.lua @@ -59,9 +59,9 @@ function InstallLocation:opt(path) return Path.concat { self.dir, "opt", path } end ----@param path string? -function InstallLocation:package(path) - return Path.concat { self.dir, "packages", path } +---@param pkg string? +function InstallLocation:package(pkg) + return Path.concat { self.dir, "packages", pkg } end ---@param path string? @@ -79,6 +79,11 @@ function InstallLocation:registry(path) return Path.concat { self.dir, "registries", path } end +---@param pkg string +function InstallLocation:receipt(pkg) + return Path.concat { self:package(pkg), "mason-receipt.json" } +end + ---@param opts { PATH: '"append"' | '"prepend"' | '"skip"' } function InstallLocation:set_env(opts) vim.env.MASON = self.dir diff --git a/lua/mason-core/installer/runner.lua b/lua/mason-core/installer/InstallRunner.lua index 64aa605d..fa2b3fcf 100644 --- a/lua/mason-core/installer/runner.lua +++ b/lua/mason-core/installer/InstallRunner.lua @@ -2,41 +2,45 @@ 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 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 registry = require "mason-registry" +local OneShotChannel = control.OneShotChannel + local InstallContext = require "mason-core.installer.context" ---@class InstallRunner ----@field location InstallLocation ---@field handle InstallHandle ----@field semaphore Semaphore ----@field permit Permit? +---@field global_semaphore Semaphore +---@field global_permit Permit? +---@field package_permit Permit? local InstallRunner = {} InstallRunner.__index = InstallRunner ----@param location InstallLocation ---@param handle InstallHandle ---@param semaphore Semaphore -function InstallRunner:new(location, handle, semaphore) +function InstallRunner:new(handle, semaphore) ---@type InstallRunner local instance = {} setmetatable(instance, self) instance.location = location - instance.semaphore = semaphore + instance.global_semaphore = semaphore instance.handle = handle return instance end +---@alias InstallRunnerCallback fun(success: true, receipt: InstallReceipt) | fun(success: false, handle: InstallHandle, error: any) + ---@param opts PackageInstallOpts ----@param callback? fun(success: boolean, result: any) +---@param callback? InstallRunnerCallback function InstallRunner:execute(opts, callback) local handle = self.handle log.fmt_info("Executing installer for %s %s", handle.package, opts) - local context = InstallContext:new(handle, self.location, opts) + local context = InstallContext:new(handle, opts) local tailed_output = {} @@ -79,25 +83,27 @@ function InstallRunner:execute(opts, callback) 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) + if callback then + callback(true, result.receipt) + end + handle.package:emit("install:success", result.receipt) + registry:emit("package:install:success", handle.package, result.receipt) 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) + if callback then + callback(false, result) + end + handle.package:emit("install:failed", result) + registry:emit("package:install:failed", handle.package, result) end end) local cancel_execution = a.run(function() return Result.try(function(try) - try(self:acquire_permit()) - try(self.location:initialize()) + try(self.handle.location:initialize()) + try(self:acquire_permit()):receive() try(self:acquire_lock(opts.force)) context.receipt:with_start_time(vim.loop.gettimeofday()) @@ -107,7 +113,7 @@ function InstallRunner:execute(opts, callback) -- 2. run installer ---@type async fun(ctx: InstallContext): Result - local installer = try(compiler.compile(handle.package.spec, opts)) + local installer = try(compiler.compile_installer(handle.package.spec, opts)) try(context:execute(installer)) -- 3. promote temporary installation dir @@ -116,28 +122,23 @@ function InstallRunner:execute(opts, callback) end)) -- 4. link package & write receipt - return linker - .link(context) - :and_then(function() - return context:build_receipt(context) - end) - :and_then( + try(linker.link(context):on_failure(function() + -- unlink any links that were made before failure + context:build_receipt():on_success( ---@param receipt InstallReceipt function(receipt) - return receipt:write(context.cwd:get()) + linker.unlink(handle.package, receipt, self.handle.location):on_failure(function(err) + log.error("Failed to unlink failed installation.", err) + end) 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, self.location):on_failure(function(err) - log.error("Failed to unlink failed installation.", err) - end) - end - ) - end) + end)) + ---@type InstallReceipt + local receipt = try(context:build_receipt()) + try(Result.pcall(fs.sync.write_file, handle.location:receipt(handle.package.name), receipt:to_json())) + return { + receipt = receipt, + } end):get_or_throw() end, finalize) @@ -157,7 +158,7 @@ end ---@async ---@private function InstallRunner:release_lock() - pcall(fs.async.unlink, self.location:lockfile(self.handle.package.name)) + pcall(fs.async.unlink, self.handle.location:lockfile(self.handle.package.name)) end ---@async @@ -166,7 +167,7 @@ end function InstallRunner:acquire_lock(force) local pkg = self.handle.package log.debug("Attempting to lock package", pkg) - local lockfile = self.location:lockfile(pkg.name) + local lockfile = self.handle.location:lockfile(pkg.name) if force ~= true and fs.async.file_exists(lockfile) then log.error("Lockfile already exists.", pkg) return Result.failure( @@ -181,33 +182,45 @@ function InstallRunner:acquire_lock(force) return Result.success(lockfile) end ----@async ---@private function InstallRunner:acquire_permit() + local channel = OneShotChannel:new() + log.fmt_debug("Acquiring permit for %s", self.handle.package) local handle = self.handle - if handle:is_active() or handle:is_closed() then - log.fmt_debug("Received active or closed handle %s", handle) + if handle:is_active() or handle:is_closing() then + log.fmt_debug("Received active or closing 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() + a.run(function() + self.global_permit = self.global_semaphore:acquire() + self.package_permit = handle.package:acquire_permit() + end, function(success, err) + if not success or handle:is_closing() then + if not success then + log.error("Acquiring permits failed", err) + end + self:release_permit() + else + log.fmt_debug("Activating handle %s", handle) + handle:active() + channel:send() + end + end) + + return Result.success(channel) end ---@private function InstallRunner:release_permit() - if self.permit then - self.permit:forget() - self.permit = nil + if self.global_permit then + self.global_permit:forget() + self.global_permit = nil + end + if self.package_permit then + self.package_permit:forget() + self.package_permit = nil end end diff --git a/lua/mason-core/installer/UninstallRunner.lua b/lua/mason-core/installer/UninstallRunner.lua new file mode 100644 index 00000000..661bfefa --- /dev/null +++ b/lua/mason-core/installer/UninstallRunner.lua @@ -0,0 +1,119 @@ +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 compiler = require "mason-core.installer.compiler" +local control = require "mason-core.async.control" +local fs = require "mason-core.fs" +local log = require "mason-core.log" +local registry = require "mason-registry" + +local OneShotChannel = control.OneShotChannel + +---@class UninstallRunner +---@field handle InstallHandle +---@field global_semaphore Semaphore +---@field package_permit Permit? +---@field global_permit Permit? +local UninstallRunner = {} +UninstallRunner.__index = UninstallRunner + +---@param handle InstallHandle +---@param global_semaphore Semaphore +---@return UninstallRunner +function UninstallRunner:new(handle, global_semaphore) + local instance = {} + setmetatable(instance, self) + instance.handle = handle + instance.global_semaphore = global_semaphore + return instance +end + +---@param opts PackageUninstallOpts +---@param callback? InstallRunnerCallback +function UninstallRunner:execute(opts, callback) + local pkg = self.handle.package + local location = self.handle.location + log.fmt_info("Executing uninstaller for %s %s", pkg, opts) + a.run(function() + Result.try(function(try) + if not opts.bypass_permit then + try(self:acquire_permit()):receive() + end + ---@type InstallReceipt? + local receipt = pkg:get_receipt(location):or_else(nil) + if receipt == nil then + log.fmt_warn("Receipt not found when uninstalling %s", pkg) + end + try(pkg:unlink(location)) + fs.sync.rmrf(location:package(pkg.name)) + return receipt + end):get_or_throw() + end, function(success, result) + if not self.handle:is_closing() then + self.handle:close() + end + self:release_permit() + + if success then + local receipt = result + log.fmt_info("Uninstallation succeeded for %s", pkg) + if callback then + callback(true, receipt) + end + pkg:emit("uninstall:success", receipt) + registry:emit("package:uninstall:success", pkg, receipt) + else + log.fmt_error("Uninstallation failed for %s error=%s", pkg, result) + if callback then + callback(false, result) + end + pkg:emit("uninstall:failed", result) + registry:emit("package:uninstall:failed", pkg, result) + end + end) +end + +---@private +function UninstallRunner:acquire_permit() + local channel = OneShotChannel:new() + log.fmt_debug("Acquiring permit for %s", self.handle.package) + local handle = self.handle + if handle:is_active() or handle:is_closing() then + log.fmt_debug("Received active or closing handle %s", handle) + return Result.failure "Invalid handle state." + end + + handle:queued() + a.run(function() + self.global_permit = self.global_semaphore:acquire() + self.package_permit = handle.package:acquire_permit() + end, function(success, err) + if not success or handle:is_closing() then + if not success then + log.error("Acquiring permits failed", err) + end + self:release_permit() + else + log.fmt_debug("Activating handle %s", handle) + handle:active() + channel:send() + end + end) + + return Result.success(channel) +end + +---@private +function UninstallRunner:release_permit() + if self.global_permit then + self.global_permit:forget() + self.global_permit = nil + end + if self.package_permit then + self.package_permit:forget() + self.package_permit = nil + end +end + +return UninstallRunner diff --git a/lua/mason-core/installer/compiler/compilers/github/init.lua b/lua/mason-core/installer/compiler/compilers/github/init.lua index d8646975..5a8dfce5 100644 --- a/lua/mason-core/installer/compiler/compilers/github/init.lua +++ b/lua/mason-core/installer/compiler/compilers/github/init.lua @@ -20,7 +20,7 @@ end ---@async ---@param ctx InstallContext ---@param source ParsedGitHubReleaseSource | ParsedGitHubBuildSource -function M.install(ctx, source, purl) +function M.install(ctx, source) if source.asset then source = source--[[@as ParsedGitHubReleaseSource]] return require("mason-core.installer.compiler.compilers.github.release").install(ctx, source) diff --git a/lua/mason-core/installer/compiler/init.lua b/lua/mason-core/installer/compiler/init.lua index e1df6784..4eed986b 100644 --- a/lua/mason-core/installer/compiler/init.lua +++ b/lua/mason-core/installer/compiler/init.lua @@ -71,9 +71,9 @@ local function upsert(dst, src) end ---@param source RegistryPackageSource ----@param version string +---@param version string? local function coalesce_source(source, version) - if source.version_overrides then + if version and 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)) @@ -94,18 +94,12 @@ local function coalesce_source(source, version) 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 + return _.dissoc("constraint", version_override) end end end end - return source + return _.dissoc("version_overrides", source) end ---@param spec RegistryPackageSpec @@ -121,7 +115,7 @@ function M.parse(spec, opts) ) end - local source = opts.version and coalesce_source(spec.source, opts.version) or spec.source + local source = coalesce_source(spec.source, opts.version) ---@type Purl local purl = try(Purl.parse(source.id)) @@ -149,7 +143,7 @@ end ---@async ---@param spec RegistryPackageSpec ---@param opts PackageInstallOpts -function M.compile(spec, opts) +function M.compile_installer(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. @@ -210,9 +204,10 @@ function M.compile(spec, opts) ctx.receipt:with_source { type = ctx.package.spec.schema, id = Purl.compile(parsed.purl), + -- Exclude the "install" field from "mason" sources because this is a Lua function. + raw = parsed.purl.type == "mason" and _.dissoc("install", parsed.raw_source) or parsed.raw_source, } - end):on_failure(function(err) - error(err, 0) + ctx.receipt:with_install_options(opts) end) end end) diff --git a/lua/mason-core/installer/compiler/link.lua b/lua/mason-core/installer/compiler/link.lua index 9719eaa9..d60fce47 100644 --- a/lua/mason-core/installer/compiler/link.lua +++ b/lua/mason-core/installer/compiler/link.lua @@ -38,7 +38,7 @@ local bin_delegates = { 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 }) + ("%s %q"):format(python, path.concat { ctx:get_install_path(), target }) ) end) end, @@ -66,7 +66,7 @@ local bin_delegates = { return ctx:write_shell_exec_wrapper( bin, ("dotnet %q"):format(path.concat { - ctx.package:get_install_path(), + ctx:get_install_path(), target, }) ) @@ -103,7 +103,7 @@ local bin_delegates = { return ctx:write_shell_exec_wrapper( bin, ("java -jar %q"):format(path.concat { - ctx.package:get_install_path(), + ctx:get_install_path(), target, }) ) diff --git a/lua/mason-core/installer/context/cwd.lua b/lua/mason-core/installer/context/InstallContextCwd.lua index 2b74bf55..b365cbd9 100644 --- a/lua/mason-core/installer/context/cwd.lua +++ b/lua/mason-core/installer/context/InstallContextCwd.lua @@ -3,20 +3,16 @@ 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 handle InstallHandle ---@field private cwd string? local InstallContextCwd = {} InstallContextCwd.__index = InstallContextCwd ---@param handle InstallHandle ----@param location InstallLocation -function InstallContextCwd:new(handle, location) - assert(location, "location not provided") +function InstallContextCwd:new(handle) ---@type InstallContextCwd local instance = {} setmetatable(instance, self) - instance.location = location instance.handle = handle instance.cwd = nil return instance @@ -24,7 +20,7 @@ end function InstallContextCwd:initialize() return Result.try(function(try) - local staging_dir = self.location:staging(self.handle.package.name) + local staging_dir = self.handle.location:staging(self.handle.package.name) if fs.sync.dir_exists(staging_dir) then try(Result.pcall(fs.sync.rmrf, staging_dir)) end @@ -42,8 +38,8 @@ end 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) + path.is_subdirectory(self.handle.location:get_dir(), new_abs_cwd), + ("%q is not a subdirectory of %q"):format(new_abs_cwd, self.handle.location) ) self.cwd = new_abs_cwd return self diff --git a/lua/mason-core/installer/context/fs.lua b/lua/mason-core/installer/context/InstallContextFs.lua index 93379017..93379017 100644 --- a/lua/mason-core/installer/context/fs.lua +++ b/lua/mason-core/installer/context/InstallContextFs.lua diff --git a/lua/mason-core/installer/context/spawn.lua b/lua/mason-core/installer/context/InstallContextSpawn.lua index f2ce8df2..f2ce8df2 100644 --- a/lua/mason-core/installer/context/spawn.lua +++ b/lua/mason-core/installer/context/InstallContextSpawn.lua diff --git a/lua/mason-core/installer/context/init.lua b/lua/mason-core/installer/context/init.lua index 425bf39c..097ea696 100644 --- a/lua/mason-core/installer/context/init.lua +++ b/lua/mason-core/installer/context/init.lua @@ -1,8 +1,9 @@ -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 InstallContextCwd = require "mason-core.installer.context.InstallContextCwd" +local InstallContextFs = require "mason-core.installer.context.InstallContextFs" +local InstallContextSpawn = require "mason-core.installer.context.InstallContextSpawn" 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" @@ -15,7 +16,7 @@ local receipt = require "mason-core.receipt" ---@field location InstallLocation ---@field spawn InstallContextSpawn ---@field handle InstallHandle ----@field package Package +---@field package AbstractPackage ---@field cwd InstallContextCwd ---@field opts PackageInstallOpts ---@field stdio_sink StdioSink @@ -24,17 +25,16 @@ local InstallContext = {} InstallContext.__index = InstallContext ---@param handle InstallHandle ----@param location InstallLocation ---@param opts PackageInstallOpts -function InstallContext:new(handle, location, opts) - local cwd = InstallContextCwd:new(handle, location) +function InstallContext:new(handle, opts) + local cwd = InstallContextCwd:new(handle) local spawn = InstallContextSpawn:new(handle, cwd, false) local fs = InstallContextFs:new(cwd) return setmetatable({ cwd = cwd, spawn = spawn, handle = handle, - location = location, + location = handle.location, -- for convenience package = handle.package, -- for convenience fs = fs, receipt = receipt.InstallReceiptBuilder:new(), @@ -51,23 +51,35 @@ end ---@async function InstallContext:promote_cwd() local cwd = self.cwd:get() - local install_path = self.package:get_install_path() + local install_path = self:get_install_path() if install_path == cwd then - log.fmt_debug("cwd %s is already promoted (at %s)", cwd, install_path) + log.fmt_debug("cwd %s is already promoted", cwd) return end log.fmt_debug("Promoting cwd %s to %s", cwd, install_path) + -- 1. Uninstall any existing installation - self.handle.package:uninstall() + if self.handle.package:is_installed() then + a.wait(function(resolve, reject) + self.handle.package:uninstall({ bypass_permit = true }, function(success, result) + if not success then + reject(result) + else + resolve() + end + end) + end) + end + -- 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 + -- 3. Update cwd self.cwd:set(install_path) + -- 4. Move the cwd to the final installation directory + fs.async.rename(cwd, 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). @@ -94,7 +106,7 @@ function InstallContext:write_node_exec_wrapper(new_executable_rel_path, script_ return self:write_shell_exec_wrapper( new_executable_rel_path, ("node %q"):format(path.concat { - self.package:get_install_path(), + self:get_install_path(), script_rel_path, }) ) @@ -109,7 +121,7 @@ function InstallContext:write_ruby_exec_wrapper(new_executable_rel_path, script_ return self:write_shell_exec_wrapper( new_executable_rel_path, ("ruby %q"):format(path.concat { - self.package:get_install_path(), + self:get_install_path(), script_rel_path, }) ) @@ -124,7 +136,7 @@ function InstallContext:write_php_exec_wrapper(new_executable_rel_path, script_r return self:write_shell_exec_wrapper( new_executable_rel_path, ("php %q"):format(path.concat { - self.package:get_install_path(), + self:get_install_path(), script_rel_path, }) ) @@ -149,7 +161,7 @@ function InstallContext:write_pyvenv_exec_wrapper(new_executable_rel_path, modul new_executable_rel_path, ("%q -m %s"):format( path.concat { - pypi.venv_path(self.package:get_install_path()), + pypi.venv_path(self:get_install_path()), "python", }, module @@ -169,7 +181,7 @@ function InstallContext:write_exec_wrapper(new_executable_rel_path, target_execu return self:write_shell_exec_wrapper( new_executable_rel_path, ("%q"):format(path.concat { - self.package:get_install_path(), + self:get_install_path(), target_executable_rel_path, }) ) @@ -264,4 +276,8 @@ function InstallContext:build_receipt() end) end +function InstallContext:get_install_path() + return self.location:package(self.package.name) +end + return InstallContext diff --git a/lua/mason-core/installer/linker.lua b/lua/mason-core/installer/linker.lua index a5c54273..a26d2592 100644 --- a/lua/mason-core/installer/linker.lua +++ b/lua/mason-core/installer/linker.lua @@ -57,7 +57,7 @@ local function unlink(receipt, link_context, location) end) end ----@param pkg Package +---@param pkg AbstractPackage ---@param receipt InstallReceipt ---@param location InstallLocation ---@nodiscard @@ -82,31 +82,27 @@ local function link(context, link_context, link_fn) name = ("%s.cmd"):format(name) end local new_abs_path = link_context.prefix(name, context.location) - local target_abs_path = path.concat { context.package:get_install_path(), rel_path } + local target_abs_path = path.concat { context:get_install_path(), rel_path } local target_rel_path = path.relative(new_abs_path, target_abs_path) - do - -- 1. Ensure destination directory exists - a.scheduler() - local dir = vim.fn.fnamemodify(new_abs_path, ":h") - if not fs.async.dir_exists(dir) then - try(Result.pcall(fs.async.mkdirp, dir)) - end + -- 1. Ensure destination directory exists + a.scheduler() + local dir = vim.fn.fnamemodify(new_abs_path, ":h") + if not fs.async.dir_exists(dir) then + try(Result.pcall(fs.async.mkdirp, dir)) end - do - -- 2. Ensure source file exists and target doesn't yet exist OR if --force unlink target if it already - -- exists. - if context.opts.force then - if fs.async.file_exists(new_abs_path) then - try(Result.pcall(fs.async.unlink, new_abs_path)) - end - elseif fs.async.file_exists(new_abs_path) then - return Result.failure(("%q is already linked."):format(new_abs_path, name)) - end - if not fs.async.file_exists(target_abs_path) then - return Result.failure(("Link target %q does not exist."):format(target_abs_path)) + -- 2. Ensure source file exists and target doesn't yet exist OR if --force unlink target if it already + -- exists. + if context.opts.force then + if fs.async.file_exists(new_abs_path) then + try(Result.pcall(fs.async.unlink, new_abs_path)) end + elseif fs.async.file_exists(new_abs_path) then + return Result.failure(("%q is already linked."):format(new_abs_path, name)) + end + if not fs.async.file_exists(target_abs_path) then + return Result.failure(("Link target %q does not exist."):format(target_abs_path)) end -- 3. Execute link. diff --git a/lua/mason-core/installer/managers/gem.lua b/lua/mason-core/installer/managers/gem.lua index cb502de5..e8723d7e 100644 --- a/lua/mason-core/installer/managers/gem.lua +++ b/lua/mason-core/installer/managers/gem.lua @@ -54,14 +54,14 @@ function M.create_bin_wrapper(bin) ctx.write_shell_exec_wrapper, ctx, bin, - path.concat { ctx.package:get_install_path(), bin_path }, + path.concat { ctx:get_install_path(), bin_path }, { GEM_PATH = platform.when { unix = function() - return ("%s:$GEM_PATH"):format(ctx.package:get_install_path()) + return ("%s:$GEM_PATH"):format(ctx:get_install_path()) end, win = function() - return ("%s;%%GEM_PATH%%"):format(ctx.package:get_install_path()) + return ("%s;%%GEM_PATH%%"):format(ctx:get_install_path()) end, }, } diff --git a/lua/mason-core/installer/managers/npm.lua b/lua/mason-core/installer/managers/npm.lua index 10a3e9fd..df8ece35 100644 --- a/lua/mason-core/installer/managers/npm.lua +++ b/lua/mason-core/installer/managers/npm.lua @@ -70,6 +70,14 @@ function M.install(pkg, version, opts) } end +---@async +---@param pkg string +function M.uninstall(pkg) + local ctx = installer.context() + ctx.stdio_sink.stdout(("Uninstalling npm package %s…\n"):format(pkg)) + return ctx.spawn.npm { "uninstall", pkg } +end + ---@param exec string function M.bin_path(exec) return Result.pcall(platform.when, { diff --git a/lua/mason-core/installer/managers/pypi.lua b/lua/mason-core/installer/managers/pypi.lua index c569e0fd..85fadc9f 100644 --- a/lua/mason-core/installer/managers/pypi.lua +++ b/lua/mason-core/installer/managers/pypi.lua @@ -214,6 +214,19 @@ function M.install(pkg, version, opts) }, opts.install_extra_args) end +---@async +---@param pkg string +function M.uninstall(pkg) + log.fmt_debug("pypi: uninstall %s", pkg) + return venv_python { + "-m", + "pip", + "uninstall", + "-y", + pkg, + } +end + ---@param executable string function M.bin_path(executable) local ctx = installer.context() diff --git a/lua/mason-core/package.lua b/lua/mason-core/package.lua deleted file mode 100644 index a8a7ac79..00000000 --- a/lua/mason-core/package.lua +++ /dev/null @@ -1,274 +0,0 @@ -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 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 ----@field spec RegistryPackageSpec ----@field private handle InstallHandle The currently associated handle. -local Package = {} -Package.__index = Package -setmetatable(Package, { __index = EventEmitter }) - ----@param package_identifier string ----@return string, string? -Package.Parse = function(package_identifier) - local name, version = unpack(vim.split(package_identifier, "@")) - return name, version -end - ----@alias PackageLanguage string - ----@type table<PackageLanguage, PackageLanguage> -Package.Lang = setmetatable({}, { - __index = function(s, lang) - s[lang] = lang - return s[lang] - end, -}) - ----@enum PackageCategory -Package.Cat = { - Compiler = "Compiler", - Runtime = "Runtime", - DAP = "DAP", - LSP = "LSP", - Linter = "Linter", - Formatter = "Formatter", -} - ----@alias PackageLicense string - ----@type table<PackageLicense, PackageLicense> -Package.License = setmetatable({}, { - __index = function(s, license) - s[license] = license - return s[license] - end, -}) - ----@class RegistryPackageSourceVersionOverride : RegistryPackageSource ----@field constraint string - ----@class RegistryPackageSource ----@field id string PURL-compliant identifier. ----@field version_overrides? RegistryPackageSourceVersionOverride[] - ----@class RegistryPackageSchemas ----@field lsp string? - ----@class RegistryPackageDeprecation ----@field since string ----@field message string - ----@alias RegistryPackageSpecSchema ---- | '"registry+v1"' - ----@class RegistryPackageSpec ----@field schema RegistryPackageSpecSchema ----@field name string ----@field description string ----@field homepage string ----@field licenses string[] ----@field languages string[] ----@field categories string[] ----@field source RegistryPackageSource ----@field deprecation RegistryPackageDeprecation? ----@field schemas RegistryPackageSchemas? ----@field bin table<string, string>? ----@field share table<string, string>? ----@field opt table<string, string>? - ----@param spec RegistryPackageSpec -local function validate_spec(spec) - if platform.cached_features["nvim-0.11"] ~= 1 then - return - end - vim.validate("schema", spec.schema, _.equals "registry+v1", "registry+v1") - vim.validate("name", spec.name, "string") - vim.validate("description", spec.description, "string") - vim.validate("homepage", spec.homepage, "string") - vim.validate("licenses", spec.licenses, "table") - vim.validate("categories", spec.categories, "table") - vim.validate("languages", spec.languages, "table") - vim.validate("source", spec.source, "table") - vim.validate("bin", spec.bin, { "table", "nil" }) - vim.validate("share", spec.share, { "table", "nil" }) -end - ----@param spec RegistryPackageSpec -function Package:new(spec) - validate_spec(spec) - local instance = EventEmitter.new(self) --[[@as Package]] - instance.name = spec.name -- for convenient access - instance.spec = spec - return instance -end - -function Package:new_handle() - self:get_handle():if_present(function(handle) - assert(handle:is_closed(), "Cannot create new handle because existing handle is not closed.") - end) - log.fmt_trace("Creating new handle for %s", self) - local InstallationHandle = require "mason-core.installer.handle" - local handle = InstallationHandle:new(self) - self.handle = handle - - -- Ideally we'd decouple this and leverage Mason's event system, but to allow loading as little as possible during - -- setup (i.e. not load modules related to Mason's event system) of the mason.nvim plugin we explicitly call into - -- terminator here. - require("mason-core.terminator").register(handle) - - self:emit("handle", handle) - registry:emit("package:handle", self, handle) - - return handle -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, callback) - opts = opts or {} - assert(not self:is_installing(), "Package is already installing.") - local handle = self:new_handle() - local runner = InstallRunner:new(InstallLocation.global(), handle, semaphore) - runner:execute(opts, callback) - return handle -end - ----@return boolean -function Package:uninstall() - 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 - ----@private ----@param receipt InstallReceipt -function Package:unlink(receipt) - log.fmt_trace("Unlinking %s", self) - local install_path = self:get_install_path() - - -- 1. Unlink - local linker = require "mason-core.installer.linker" - linker.unlink(self, receipt, InstallLocation.global()):get_or_throw() - - -- 2. Remove installation artifacts - fs.sync.rmrf(install_path) -end - -function Package:is_installed() - return registry.is_installed(self.name) -end - -function Package:get_handle() - return Optional.of_nilable(self.handle) -end - -function Package:get_install_path() - return InstallLocation.global():package(self.name) -end - ----@return Optional # Optional<InstallReceipt> -function Package:get_receipt() - local receipt_path = path.concat { self:get_install_path(), "mason-receipt.json" } - if fs.sync.file_exists(receipt_path) then - local receipt = require "mason-core.receipt" - return Optional.of(receipt.InstallReceipt.from_json(vim.json.decode(fs.sync.read_file(receipt_path)))) - end - return Optional.empty() -end - ----@return string? -function Package:get_installed_version() - return self:get_receipt() - :and_then( - ---@param receipt InstallReceipt - function(receipt) - local source = receipt:get_source() - if source.id then - return Purl.parse(source.id):map(_.prop "version"):ok() - else - return Optional.empty() - end - end - ) - :or_else(nil) -end - ----@return string -function Package:get_latest_version() - return Purl.parse(self.spec.source.id) - :map(_.prop "version") - :get_or_throw(("Unable to retrieve version from malformed purl: %s."):format(self.spec.source.id)) -end - ----@param opts? PackageInstallOpts -function Package:is_installable(opts) - return require("mason-core.installer.compiler").parse(self.spec, opts or {}):is_success() -end - ----@return Result # Result<string[]> -function Package:get_all_versions() - local compiler = require "mason-core.installer.compiler" - return Result.try(function(try) - ---@type Purl - local purl = try(Purl.parse(self.spec.source.id)) - ---@type InstallerCompiler - local compiler = try(compiler.get_compiler(purl)) - return compiler.get_versions(purl, self.spec.source) - end) -end - -function Package:get_lsp_settings_schema() - local schema_file = InstallLocation.global() - :share(path.concat { "mason-schemas", "lsp", ("%s.json"):format(self.name) }) - if fs.sync.file_exists(schema_file) then - return Result.pcall(vim.json.decode, fs.sync.read_file(schema_file), { - luanil = { object = true, array = true }, - }):ok() - end - return Optional.empty() -end -function Package:get_aliases() - return require("mason-registry").get_package_aliases(self.name) -end - -function Package:__tostring() - return ("Package(name=%s)"):format(self.name) -end - -return Package diff --git a/lua/mason-core/package/AbstractPackage.lua b/lua/mason-core/package/AbstractPackage.lua new file mode 100644 index 00000000..b490fc87 --- /dev/null +++ b/lua/mason-core/package/AbstractPackage.lua @@ -0,0 +1,203 @@ +local EventEmitter = require "mason-core.EventEmitter" +local InstallLocation = require "mason-core.installer.InstallLocation" +local Optional = require "mason-core.optional" +local Purl = require "mason-core.purl" +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 settings = require "mason.settings" +local Semaphore = require("mason-core.async.control").Semaphore + +---@alias PackageInstallOpts { version?: string, debug?: boolean, target?: string, force?: boolean, strict?: boolean, location?: InstallLocation } +---@alias PackageUninstallOpts { bypass_permit?: boolean, location?: InstallLocation } + +---@class AbstractPackage : EventEmitter +---@field name string +---@field spec RegistryPackageSpec +---@field private install_handle InstallHandle? The currently associated installation handle. +---@field private uninstall_handle InstallHandle? The currently associated uninstallation handle. +local AbstractPackage = {} +AbstractPackage.__index = AbstractPackage +setmetatable(AbstractPackage, { __index = EventEmitter }) + +AbstractPackage.SEMAPHORE = Semaphore:new(settings.current.max_concurrent_installers) +---@type PackageInstallOpts +AbstractPackage.DEFAULT_INSTALL_OPTS = { + debug = false, + force = false, + strict = false, + target = nil, + version = nil, +} + +---@param spec RegistryPackageSpec +function AbstractPackage:new(spec) + local instance = EventEmitter.new(self) + instance.name = spec.name -- for convenient access + instance.spec = spec + return instance +end + +---@return boolean +function AbstractPackage:is_installing() + return self:get_install_handle() + :map( + ---@param handle InstallHandle + function(handle) + return not handle:is_closed() + end + ) + :or_else(false) +end + +---@return boolean +function AbstractPackage:is_uninstalling() + return self:get_uninstall_handle() + :map( + ---@param handle InstallHandle + function(handle) + return not handle:is_closed() + end + ) + :or_else(false) +end + +function AbstractPackage:get_install_handle() + return Optional.of_nilable(self.install_handle) +end + +function AbstractPackage:get_uninstall_handle() + return Optional.of_nilable(self.uninstall_handle) +end + +---@param location InstallLocation +function AbstractPackage:new_handle(location) + assert(location, "Cannot create new handle without a location.") + local InstallHandle = require "mason-core.installer.InstallHandle" + local handle = InstallHandle:new(self, location) + -- Ideally we'd decouple this and leverage Mason's event system, but to allow loading as little as possible during + -- setup (i.e. not load modules related to Mason's event system) of the mason.nvim plugin we explicitly call into + -- terminator here. + require("mason-core.terminator").register(handle) + return handle +end + +---@param location? InstallLocation +function AbstractPackage:new_install_handle(location) + location = location or InstallLocation.global() + log.fmt_trace("Creating new installation handle for %s", self) + self:get_install_handle():if_present(function(handle) + assert(handle:is_closed(), "Cannot create new install handle because existing handle is not closed.") + end) + self.install_handle = self:new_handle(location) + self:emit("install:handle", self.install_handle) + return self.install_handle +end + +---@param location? InstallLocation +function AbstractPackage:new_uninstall_handle(location) + location = location or InstallLocation.global() + log.fmt_trace("Creating new uninstallation handle for %s", self) + self:get_uninstall_handle():if_present(function(handle) + assert(handle:is_closed(), "Cannot create new uninstall handle because existing handle is not closed.") + end) + self.uninstall_handle = self:new_handle(location) + self:emit("uninstall:handle", self.uninstall_handle) + return self.uninstall_handle +end + +---@param opts? PackageInstallOpts +function AbstractPackage:is_installable(opts) + return require("mason-core.installer.compiler").parse(self.spec, opts or {}):is_success() +end + +---@param location? InstallLocation +---@return Optional # Optional<InstallReceipt> +function AbstractPackage:get_receipt(location) + location = location or InstallLocation.global() + local receipt_path = location:receipt(self.name) + if fs.sync.file_exists(receipt_path) then + local receipt = require "mason-core.receipt" + return Optional.of(receipt.InstallReceipt.from_json(vim.json.decode(fs.sync.read_file(receipt_path)))) + end + return Optional.empty() +end + +---@param location? InstallLocation +---@return boolean +function AbstractPackage:is_installed(location) + error "Unimplemented." +end + +---@return Result # Result<string[]> +function AbstractPackage:get_all_versions() + local compiler = require "mason-core.installer.compiler" + return Result.try(function(try) + ---@type Purl + local purl = try(Purl.parse(self.spec.source.id)) + ---@type InstallerCompiler + local compiler = try(compiler.get_compiler(purl)) + return compiler.get_versions(purl, self.spec.source) + end) +end + +---@return string +function AbstractPackage:get_latest_version() + return Purl.parse(self.spec.source.id) + :map(_.prop "version") + :get_or_throw(("Unable to retrieve version from malformed purl: %s."):format(self.spec.source.id)) +end + +---@param location? InstallLocation +---@return string? +function AbstractPackage:get_installed_version(location) + return self:get_receipt(location) + :and_then( + ---@param receipt InstallReceipt + function(receipt) + local source = receipt:get_source() + if source.id then + return Purl.parse(source.id):map(_.prop "version"):ok() + else + return Optional.empty() + end + end + ) + :or_else(nil) +end + +---@param opts? PackageInstallOpts +---@param callback? InstallRunnerCallback +---@return InstallHandle +function AbstractPackage:install(opts, callback) + error "Unimplemented." +end + +---@param opts? PackageUninstallOpts +---@param callback? InstallRunnerCallback +---@return InstallHandle +function AbstractPackage:uninstall(opts, callback) + error "Unimplemented." +end + +---@private +---@param location? InstallLocation +function AbstractPackage:unlink(location) + location = location or InstallLocation.global() + log.fmt_trace("Unlinking", self, location) + local linker = require "mason-core.installer.linker" + return self:get_receipt(location):ok_or("Unable to find receipt."):and_then(function(receipt) + return linker.unlink(self, receipt, location) + end) +end + +---@async +---@private +---@return Permit +function AbstractPackage:acquire_permit() + error "Unimplemented." +end + +return AbstractPackage diff --git a/lua/mason-core/package/init.lua b/lua/mason-core/package/init.lua new file mode 100644 index 00000000..09b0ebbf --- /dev/null +++ b/lua/mason-core/package/init.lua @@ -0,0 +1,182 @@ +local AbstractPackage = require "mason-core.package.AbstractPackage" +local InstallLocation = require "mason-core.installer.InstallLocation" +local InstallRunner = require "mason-core.installer.InstallRunner" +local Optional = require "mason-core.optional" +local Result = require "mason-core.result" +local UninstallRunner = require "mason-core.installer.UninstallRunner" +local _ = require "mason-core.functional" +local fs = require "mason-core.fs" +local path = require "mason-core.path" +local registry = require "mason-registry" +local platform = require "mason-core.platform" +local Semaphore = require("mason-core.async.control").Semaphore + +---@class Package : AbstractPackage +---@field spec RegistryPackageSpec +---@field local_semaphore Semaphore +local Package = {} +Package.__index = Package +setmetatable(Package, { __index = AbstractPackage }) + +---@param package_identifier string +---@return string, string? +Package.Parse = function(package_identifier) + local name, version = unpack(vim.split(package_identifier, "@")) + return name, version +end + +---@alias PackageLanguage string + +---@type table<PackageLanguage, PackageLanguage> +Package.Lang = setmetatable({}, { + __index = function(s, lang) + s[lang] = lang + return s[lang] + end, +}) + +---@enum PackageCategory +Package.Cat = { + Compiler = "Compiler", + Runtime = "Runtime", + DAP = "DAP", + LSP = "LSP", + Linter = "Linter", + Formatter = "Formatter", +} + +---@alias PackageLicense string + +---@type table<PackageLicense, PackageLicense> +Package.License = setmetatable({}, { + __index = function(s, license) + s[license] = license + return s[license] + end, +}) + +---@class RegistryPackageSourceVersionOverride : RegistryPackageSource +---@field constraint string + +---@class RegistryPackageSource +---@field id string PURL-compliant identifier. +---@field version_overrides? RegistryPackageSourceVersionOverride[] + +---@class RegistryPackageSchemas +---@field lsp string? + +---@class RegistryPackageDeprecation +---@field since string +---@field message string + +---@alias RegistryPackageSpecSchema +--- | '"registry+v1"' + +---@class RegistryPackageSpec +---@field schema RegistryPackageSpecSchema +---@field name string +---@field description string +---@field homepage string +---@field licenses string[] +---@field languages string[] +---@field categories string[] +---@field deprecation RegistryPackageDeprecation? +---@field source RegistryPackageSource +---@field schemas RegistryPackageSchemas? +---@field bin table<string, string>? +---@field share table<string, string>? +---@field opt table<string, string>? + +---@param spec RegistryPackageSpec +local function validate_spec(spec) + if platform.cached_features["nvim-0.11"] ~= 1 then + return + end + vim.validate("schema", spec.schema, _.equals "registry+v1", "registry+v1") + vim.validate("name", spec.name, "string") + vim.validate("description", spec.description, "string") + vim.validate("homepage", spec.homepage, "string") + vim.validate("licenses", spec.licenses, "table") + vim.validate("categories", spec.categories, "table") + vim.validate("languages", spec.languages, "table") + vim.validate("source", spec.source, "table") + vim.validate("bin", spec.bin, { "table", "nil" }) + vim.validate("share", spec.share, { "table", "nil" }) +end + +---@param spec RegistryPackageSpec +function Package:new(spec) + validate_spec(spec) + ---@type Package + local instance = AbstractPackage.new(self, spec) + instance.local_semaphore = Semaphore:new(1) + return instance +end + +---@param opts? PackageInstallOpts +---@param callback? InstallRunnerCallback +---@return InstallHandle +function Package:install(opts, callback) + opts = opts or {} + assert(not self:is_installing(), "Package is already installing.") + assert(not self:is_uninstalling(), "Package is uninstalling.") + opts = vim.tbl_extend("force", self.DEFAULT_INSTALL_OPTS, opts or {}) + + local handle = self:new_install_handle(opts.location) + registry:emit("package:install:handle", handle) + local runner = InstallRunner:new(handle, AbstractPackage.SEMAPHORE) + + runner:execute(opts, callback) + + return handle +end + +---@param opts? PackageUninstallOpts +---@param callback? fun(success: boolean, error: any) +function Package:uninstall(opts, callback) + opts = opts or {} + assert(self:is_installed(opts.location), "Package is not installed.") + assert(not self:is_uninstalling(), "Package is already uninstalling.") + local handle = self:new_uninstall_handle(opts.location) + registry:emit("package:uninstall:handle", handle) + local runner = UninstallRunner:new(handle, AbstractPackage.SEMAPHORE) + runner:execute(opts, callback) + return handle +end + +---@param location? InstallLocation +function Package:is_installed(location) + location = location or InstallLocation.global() + local ok, stat = pcall(vim.loop.fs_stat, location:package(self.name)) + if not ok or not stat then + return false + end + return stat.type == "directory" +end + +function Package:get_lsp_settings_schema() + local schema_file = InstallLocation.global() + :share(path.concat { "mason-schemas", "lsp", ("%s.json"):format(self.name) }) + if fs.sync.file_exists(schema_file) then + return Result.pcall(vim.json.decode, fs.sync.read_file(schema_file), { + luanil = { object = true, array = true }, + }):ok() + end + return Optional.empty() +end + +function Package:get_aliases() + return require("mason-registry").get_package_aliases(self.name) +end + +---@async +---@private +function Package:acquire_permit() + return self.local_semaphore:acquire() +end + +function Package:__tostring() + return ("Package(name=%s)"):format(self.name) +end + +return Package diff --git a/lua/mason-core/receipt.lua b/lua/mason-core/receipt.lua index 63403503..847b8011 100644 --- a/lua/mason-core/receipt.lua +++ b/lua/mason-core/receipt.lua @@ -1,15 +1,11 @@ -local Result = require "mason-core.result" -local fs = require "mason-core.fs" -local path = require "mason-core.path" - local M = {} ---@alias InstallReceiptSchemaVersion ---| '"1.0"' ---| '"1.1"' ----| '"1.2"' +---| '"2.0"' ----@alias InstallReceiptSource {type: RegistryPackageSpecSchema, id: string} +---@alias InstallReceiptSource {type: RegistryPackageSpecSchema, id: string, raw: RegistryPackageSource} ---@class InstallReceiptLinks ---@field bin? table<string, string> @@ -22,6 +18,7 @@ local M = {} ---@field public metrics {start_time:integer, completion_time:integer} ---@field public source InstallReceiptSource ---@field public links InstallReceiptLinks +---@field public install_options PackageInstallOpts local InstallReceipt = {} InstallReceipt.__index = InstallReceipt @@ -33,6 +30,10 @@ function InstallReceipt.from_json(json) return InstallReceipt:new(json) end +function InstallReceipt:__tostring() + return ("InstallReceipt(name=%s, purl=%s)"):format(self.name, self:get_source().id or "N/A") +end + function InstallReceipt:get_name() return self.name end @@ -49,22 +50,30 @@ end ---@return InstallReceiptSource function InstallReceipt:get_source() - if self:is_schema_min "1.2" then + if self:is_schema_min "2.0" then return self.source end return self.primary_source --[[@as InstallReceiptSource]] end +function InstallReceipt:get_raw_source() + if self:is_schema_min "2.0" then + return self.source.raw + else + return nil + end +end + +function InstallReceipt:get_install_options() + return self.install_options +end + function InstallReceipt:get_links() return self.links end ----@async ----@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) +function InstallReceipt:to_json() + return vim.json.encode(self) end ---@class InstallReceiptBuilder @@ -96,6 +105,12 @@ function InstallReceiptBuilder:with_source(source) return self end +---@param install_options PackageInstallOpts +function InstallReceiptBuilder:with_install_options(install_options) + self.install_options = install_options + return self +end + ---@param typ '"bin"' | '"share"' | '"opt"' ---@param name string ---@param rel_path string @@ -132,13 +147,15 @@ function InstallReceiptBuilder:build() assert(self.start_time, "start_time is required") assert(self.completion_time, "completion_time is required") assert(self.source, "source is required") + assert(self.install_options, "install_options is required") return InstallReceipt:new { name = self.name, - schema_version = "1.2", + schema_version = "2.0", metrics = { start_time = self.start_time, completion_time = self.completion_time, }, + install_options = self.install_options, source = self.source, links = self.links, } diff --git a/lua/mason-registry/init.lua b/lua/mason-registry/init.lua index 9842748b..746e487b 100644 --- a/lua/mason-registry/init.lua +++ b/lua/mason-registry/init.lua @@ -1,5 +1,5 @@ local EventEmitter = require "mason-core.EventEmitter" -local InstallLocation = require "mason-core.installer.location" +local InstallLocation = require "mason-core.installer.InstallLocation" local Optional = require "mason-core.optional" local _ = require "mason-core.functional" local fs = require "mason-core.fs" @@ -123,7 +123,7 @@ function M.get_all_packages() return get_packages(M.get_all_package_names()) end ----@return (RegistryPackageSpec | PackageSpec)[] +---@return RegistryPackageSpec[] function M.get_all_package_specs() local specs = {} for source in sources.iter() do diff --git a/lua/mason-registry/sources/github.lua b/lua/mason-registry/sources/github.lua index d0a782fb..b314d690 100644 --- a/lua/mason-registry/sources/github.lua +++ b/lua/mason-registry/sources/github.lua @@ -1,4 +1,4 @@ -local InstallLocation = require "mason-core.installer.location" +local InstallLocation = require "mason-core.installer.InstallLocation" local Optional = require "mason-core.optional" local Result = require "mason-core.result" local _ = require "mason-core.functional" diff --git a/lua/mason-registry/sources/util.lua b/lua/mason-registry/sources/util.lua index 8be07010..ed399156 100644 --- a/lua/mason-registry/sources/util.lua +++ b/lua/mason-registry/sources/util.lua @@ -1,5 +1,5 @@ local Optional = require "mason-core.optional" -local Pkg = require "mason-core.package" +local Package = require "mason-core.package" local _ = require "mason-core.functional" local compiler = require "mason-core.installer.compiler" local log = require "mason-core.log" @@ -23,19 +23,19 @@ end M.hydrate_package = _.curryN(function(buffer, spec) -- hydrate Pkg.Lang/License index _.each(function(lang) - local _ = Pkg.Lang[lang] + local _ = Package.Lang[lang] end, spec.languages) _.each(function(lang) - local _ = Pkg.License[lang] + local _ = Package.License[lang] end, spec.licenses) local pkg = buffer[spec.name] if pkg then - -- Apply spec to the existing Package instance. This is important as to not have lingering package instances. + -- Apply spec to the existing Package instances. This is important as to not have lingering package instances. pkg.spec = spec return pkg end - return Pkg:new(spec) + return Package:new(spec) end, 2) return M diff --git a/lua/mason-test/helpers.lua b/lua/mason-test/helpers.lua index 2348e9df..88354046 100644 --- a/lua/mason-test/helpers.lua +++ b/lua/mason-test/helpers.lua @@ -1,7 +1,8 @@ local InstallContext = require "mason-core.installer.context" -local InstallHandle = require "mason-core.installer.handle" -local InstallLocation = require "mason-core.installer.location" +local InstallHandle = require "mason-core.installer.InstallHandle" +local InstallLocation = require "mason-core.installer.InstallLocation" local Result = require "mason-core.result" +local a = require "mason-core.async" local registry = require "mason-registry" local spy = require "luassert.spy" @@ -10,9 +11,8 @@ 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.global() - local context = InstallContext:new(handle, location, opts and opts.install_opts or {}) + local handle = InstallHandle:new(pkg, InstallLocation.global()) + local context = InstallContext:new(handle, opts and opts.install_opts or {}) context.spawn = setmetatable({}, { __index = function(s, cmd) s[cmd] = spy.new(function() @@ -25,4 +25,39 @@ function M.create_context(opts) return context end +---@param pkg AbstractPackage +---@param opts? PackageInstallOpts +function M.sync_install(pkg, opts) + return a.run_blocking(function() + return a.wait(function(resolve, reject) + pkg:install(opts, function(success, result) + (success and resolve or reject)(result) + end) + end) + end) +end + +---@param pkg AbstractPackage +---@param opts? PackageUninstallOpts +function M.sync_uninstall(pkg, opts) + return a.run_blocking(function() + return a.wait(function(resolve, reject) + pkg:uninstall(opts, function(success, result) + (success and resolve or reject)(result) + end) + end) + end) +end + +---@param runner InstallRunner +---@param opts PackageInstallOpts +function M.sync_runner_execute(runner, opts) + local callback = spy.new() + runner:execute(opts, callback) + assert.wait(function() + assert.spy(callback).was_called() + end) + return callback +end + return M diff --git a/lua/mason/api/command.lua b/lua/mason/api/command.lua index 3a3c997b..ea466351 100644 --- a/lua/mason/api/command.lua +++ b/lua/mason/api/command.lua @@ -10,18 +10,18 @@ vim.api.nvim_create_user_command("Mason", Mason, { nargs = 0, }) --- This is needed because neovim doesn't do any validation of command args when using custom completion (I think?) -local filter_valid_packages = _.filter(function(pkg_specifier) +local get_valid_packages = _.filter_map(function(pkg_specifier) + local Optional = require "mason-core.optional" local notify = require "mason-core.notify" local Package = require "mason-core.package" local registry = require "mason-registry" - local package_name = Package.Parse(pkg_specifier) - local ok = pcall(registry.get_package, package_name) - if ok then - return true + local package_name, version = Package.Parse(pkg_specifier) + local ok, pkg = pcall(registry.get_package, package_name) + if ok and pkg then + return Optional.of { pkg = pkg, version = version } else notify(("%q is not a valid package."):format(pkg_specifier), vim.log.levels.ERROR) - return false + return Optional.empty() end end) @@ -56,9 +56,7 @@ local function join_handles(handles) handles )) local failed_packages = _.filter_map(function(handle) - -- TODO: The outcome of a package installation is currently not captured in the handle, but is instead - -- internalized in the Package instance itself. Change this to assert on the handle state when it's - -- available. + -- TODO: Use new install callback to determine success. if not handle.package:is_installed() then return Optional.of(handle.package.name) else @@ -79,21 +77,18 @@ local function join_handles(handles) end ---@param package_specifiers string[] ----@param opts? PackageInstallOpts +---@param opts? table<string, string | boolean> local function MasonInstall(package_specifiers, opts) opts = opts or {} - local Package = require "mason-core.package" local registry = require "mason-registry" local Optional = require "mason-core.optional" - local install_packages = _.filter_map(function(pkg_specifier) - local package_name, version = Package.Parse(pkg_specifier) - local pkg = registry.get_package(package_name) - if pkg:is_installing() then + local install_packages = _.filter_map(function(target) + if target.pkg:is_installing() then return Optional.empty() else - return Optional.of(pkg:install { - version = version, + return Optional.of(target.pkg:install { + version = target.version, debug = opts.debug, force = opts.force, strict = opts.strict, @@ -104,7 +99,7 @@ local function MasonInstall(package_specifiers, opts) if platform.is_headless then registry.refresh() - local valid_packages = filter_valid_packages(package_specifiers) + local valid_packages = get_valid_packages(package_specifiers) if #valid_packages ~= #package_specifiers then -- When executing in headless mode we don't allow any of the provided packages to be invalid. -- This is to avoid things like scripts silently not erroring even if they've provided one or more invalid packages. @@ -117,7 +112,7 @@ local function MasonInstall(package_specifiers, opts) -- Important: We start installation of packages _after_ opening the UI. This gives the UI components a chance to -- register the necessary event handlers in time, avoiding desynced state. registry.refresh(function() - local valid_packages = filter_valid_packages(package_specifiers) + local valid_packages = get_valid_packages(package_specifiers) install_packages(valid_packages) vim.schedule(function() ui.set_sticky_cursor "installing-section" @@ -165,7 +160,7 @@ end, { elseif _.matches("^.+@", arg_lead) then local pkg_name, version = unpack(_.match("^(.+)@(.*)", arg_lead)) local ok, pkg = pcall(registry.get_package, pkg_name) - if not ok then + if not ok or not pkg then return {} end local a = require "mason-core.async" @@ -197,12 +192,10 @@ end, { ---@param package_names string[] local function MasonUninstall(package_names) - local registry = require "mason-registry" - local valid_packages = filter_valid_packages(package_names) + local valid_packages = get_valid_packages(package_names) if #valid_packages > 0 then - _.each(function(package_name) - local pkg = registry.get_package(package_name) - pkg:uninstall() + _.each(function(target) + target.pkg:uninstall() end, valid_packages) require("mason.ui").open() end diff --git a/lua/mason/init.lua b/lua/mason/init.lua index 0be007a4..ff26cc8d 100644 --- a/lua/mason/init.lua +++ b/lua/mason/init.lua @@ -1,4 +1,4 @@ -local InstallLocation = require "mason-core.installer.location" +local InstallLocation = require "mason-core.installer.InstallLocation" local settings = require "mason.settings" local M = {} diff --git a/lua/mason/ui/components/main/package_list.lua b/lua/mason/ui/components/main/package_list.lua index 08870306..2d892a82 100644 --- a/lua/mason/ui/components/main/package_list.lua +++ b/lua/mason/ui/components/main/package_list.lua @@ -86,7 +86,6 @@ local function ExpandedPackageInfo(state, pkg, is_installed) return ExecutablesTable(pkg_state.linked_executables) end) )), - -- ExecutablesTable(is_installed and pkg_state.linked_executables or package.spec.executables), Ui.When(pkg_state.lsp_settings_schema ~= nil, function() local has_expanded = pkg_state.expanded_json_schemas["lsp"] return Ui.Node { diff --git a/lua/mason/ui/instance.lua b/lua/mason/ui/instance.lua index ae246887..8a82bc64 100644 --- a/lua/mason/ui/instance.lua +++ b/lua/mason/ui/instance.lua @@ -1,8 +1,10 @@ +-- !!! +-- in dire need of rework, proceed with caution +-- !!! local Package = require "mason-core.package" local Ui = require "mason-core.ui" local _ = require "mason-core.functional" local a = require "mason-core.async" -local control = require "mason-core.async.control" local display = require "mason-core.ui.display" local notify = require "mason-core.notify" local registry = require "mason-registry" @@ -14,8 +16,6 @@ local LanguageFilter = require "mason.ui.components.language-filter" local Main = require "mason.ui.components.main" local Tabs = require "mason.ui.components.tabs" -local Semaphore = control.Semaphore - require "mason.ui.colors" ---@param state InstallerUiState @@ -81,8 +81,6 @@ local INITIAL_STATE = { outdated_packages = {}, new_versions_check = { is_checking = false, - current = 0, - total = 0, percentage_complete = 0, }, ---@type Package[] @@ -288,8 +286,6 @@ local function setup_handle(handle) handle_spawnhandle_change() mutate_state(function(state) state.packages.states[handle.package.name] = create_initial_package_state() - state.packages.outdated_packages = - _.filter(_.complement(_.equals(handle.package)), state.packages.outdated_packages) end) end @@ -372,11 +368,21 @@ end local function terminate_package_handle(event) ---@type Package local pkg = event.payload - vim.schedule_wrap(notify)(("Cancelling installation of %q."):format(pkg.name)) - pkg:get_handle():if_present( + pkg:get_install_handle():if_present( + ---@param handle InstallHandle + function(handle) + if not handle:is_closed() then + vim.schedule_wrap(notify)(("Cancelling installation of %q."):format(pkg.name)) + handle:terminate() + end + end + ) + + pkg:get_uninstall_handle():if_present( ---@param handle InstallHandle function(handle) if not handle:is_closed() then + vim.schedule_wrap(notify)(("Cancelling uninstallation of %q."):format(pkg.name)) handle:terminate() end end @@ -387,7 +393,7 @@ local function terminate_all_package_handles(event) ---@type Package[] local pkgs = _.list_copy(event.payload) -- we copy because list is mutated while iterating it for _, pkg in ipairs(pkgs) do - pkg:get_handle():if_present( + pkg:get_install_handle():if_present( ---@param handle InstallHandle function(handle) if not handle:is_closed() then @@ -399,16 +405,22 @@ local function terminate_all_package_handles(event) end local function install_package(event) - ---@type Package + ---@type AbstractPackage local pkg = event.payload - pkg:install() + if not pkg:is_installing() then + pkg:install() + end + mutate_state(function(state) + state.packages.outdated_packages = _.filter(_.complement(_.equals(pkg)), state.packages.outdated_packages) + end) end local function uninstall_package(event) - ---@type Package + ---@type AbstractPackage local pkg = event.payload - pkg:uninstall() - vim.schedule_wrap(notify)(("%q was successfully uninstalled."):format(pkg.name)) + if not pkg:is_uninstalling() then + pkg:uninstall() + end end local function toggle_expand_package(event) @@ -449,39 +461,22 @@ local function check_new_package_version(pkg) end ---@async -local function check_new_visible_package_versions() +local function check_new_package_versions() local state = get_state() if state.packages.new_versions_check.is_checking then return end - local installed_visible_packages = _.compose( - _.filter( - ---@param package Package - function(package) - return package - :get_handle() - :map(function(handle) - return handle:is_closed() - end) - :or_else(true) - end - ), - _.filter(function(package) - return state.packages.visible[package.name] - end) - )(state.packages.installed) mutate_state(function(state) state.packages.outdated_packages = {} state.packages.new_versions_check.is_checking = true - state.packages.new_versions_check.current = 0 - state.packages.new_versions_check.total = #installed_visible_packages state.packages.new_versions_check.percentage_complete = 0 end) do local success, err = a.wait(registry.update) mutate_state(function(state) + state.packages.new_versions_check.percentage_complete = 1 if not success then state.info.registry_update_error = tostring(_.gsub("\n", " ", err)) else @@ -490,25 +485,25 @@ local function check_new_visible_package_versions() end) end - local sem = Semaphore:new(5) - a.wait_all(_.map(function(pkg) - return function() - local permit = sem:acquire() - local has_new_version = check_new_package_version(pkg) - mutate_state(function(state) - state.packages.new_versions_check.current = state.packages.new_versions_check.current + 1 - state.packages.new_versions_check.percentage_complete = state.packages.new_versions_check.current - / state.packages.new_versions_check.total - if has_new_version then - table.insert(state.packages.outdated_packages, pkg) - end - end) - permit:forget() + local outdated_packages = {} + + mutate_state(function(state) + for _, pkg in ipairs(state.packages.installed) do + local current_version = pkg:get_installed_version() + local latest_version = pkg:get_latest_version() + if current_version ~= latest_version then + state.packages.states[pkg.name].version = current_version + state.packages.states[pkg.name].new_version = latest_version + table.insert(outdated_packages, pkg) + else + state.packages.states[pkg.name].new_version = nil + end end - end, installed_visible_packages)) + end) - a.sleep(800) + a.sleep(1000) mutate_state(function(state) + state.packages.outdated_packages = outdated_packages state.packages.new_versions_check.is_checking = false state.packages.new_versions_check.current = 0 state.packages.new_versions_check.total = 0 @@ -567,7 +562,7 @@ end local function update_all_packages() local state = get_state() _.each(function(pkg) - pkg:install(pkg) + pkg:install() end, state.packages.outdated_packages) mutate_state(function(state) state.packages.outdated_packages = {} @@ -584,7 +579,7 @@ end local effects = { ["CHECK_NEW_PACKAGE_VERSION"] = a.scope(_.compose(_.partial(pcall, check_new_package_version), _.prop "payload")), - ["CHECK_NEW_VISIBLE_PACKAGE_VERSIONS"] = a.scope(check_new_visible_package_versions), + ["CHECK_NEW_VISIBLE_PACKAGE_VERSIONS"] = a.scope(check_new_package_versions), ["CLEAR_LANGUAGE_FILTER"] = clear_filter, ["CLEAR_SEARCH_MODE"] = clear_search_mode, ["CLOSE_WINDOW"] = window.close, @@ -630,10 +625,13 @@ local function setup_package(pkg) table.insert(state.packages[pkg:is_installed() and "installed" or "uninstalled"], pkg) end) - pkg:get_handle():if_present(setup_handle) - pkg:on("handle", setup_handle) + pkg:get_install_handle():if_present(setup_handle) + pkg:on("install:handle", setup_handle) pkg:on("install:success", function() + vim.schedule(function() + notify(("%s was successfully installed."):format(pkg.name)) + end) mutate_state(function(state) state.packages.states[pkg.name] = create_initial_package_state() if state.packages.expanded == pkg.name then @@ -641,7 +639,6 @@ local function setup_package(pkg) end end) mutate_package_grouping(pkg, "installed") - vim.schedule_wrap(notify)(("%q was successfully installed."):format(pkg.name)) end) pkg:on( @@ -655,6 +652,9 @@ local function setup_package(pkg) end) mutate_package_grouping(pkg, pkg:is_installed() and "installed" or "uninstalled") else + vim.schedule(function() + notify(("%s failed to install."):format(pkg.name), vim.log.levels.ERROR) + end) mutate_package_grouping(pkg, "failed") mutate_state(function(state) state.packages.states[pkg.name].has_failed = true @@ -664,8 +664,17 @@ local function setup_package(pkg) ) pkg:on("uninstall:success", function() + if pkg:is_installing() then + -- We don't care about uninstallations that occur during installation because it's expected behaviour and + -- not constructive to surface to users. + return + end + vim.schedule(function() + notify(("%s was successfully uninstalled."):format(pkg.name)) + end) mutate_state(function(state) state.packages.states[pkg.name] = create_initial_package_state() + state.packages.outdated_packages = _.filter(_.complement(_.equals(pkg)), state.packages.outdated_packages) end) mutate_package_grouping(pkg, "uninstalled") end) @@ -688,7 +697,9 @@ end ---@param packages Package[] local function setup_packages(packages) - _.each(setup_package, _.sort_by(_.prop "name", packages)) + for _, pkg in ipairs(_.sort_by(_.prop "name", packages)) do + setup_package(pkg) + end mutate_state(function(state) state.packages.all = packages end) @@ -714,7 +725,7 @@ window.init { if settings.current.ui.check_outdated_packages_on_open then vim.defer_fn( a.scope(function() - check_new_visible_package_versions() + check_new_package_versions() end), 100 ) |
