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 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 location InstallLocation ---@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 location InstallLocation ---@param opts PackageInstallOpts function InstallContext.new(handle, location, opts) local cwd = InstallContextCwd.new(handle, location) local spawn = InstallContextSpawn.new(handle, cwd, false) local fs = InstallContextFs.new(cwd) return setmetatable({ cwd = cwd, spawn = spawn, handle = handle, location = location, 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