aboutsummaryrefslogtreecommitdiffstats
path: root/lua/mason-core/installer/context
diff options
context:
space:
mode:
Diffstat (limited to 'lua/mason-core/installer/context')
-rw-r--r--lua/mason-core/installer/context/cwd.lua48
-rw-r--r--lua/mason-core/installer/context/fs.lua108
-rw-r--r--lua/mason-core/installer/context/init.lua261
-rw-r--r--lua/mason-core/installer/context/spawn.lua46
4 files changed, 463 insertions, 0 deletions
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<string, string>, share: table<string, string>, opt: table<string, string> }
+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<string, string>?
+---@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