aboutsummaryrefslogtreecommitdiffstats
path: root/lua/mason-core/installer
diff options
context:
space:
mode:
authorWilliam Boman <william@redwill.se>2022-07-08 18:34:38 +0200
committerGitHub <noreply@github.com>2022-07-08 18:34:38 +0200
commit976aa4fbee8a070f362cab6f6ec84e9251a90cf9 (patch)
tree5e8d9c9c59444a25c7801b8f39763c4ba6e1f76d /lua/mason-core/installer
parentfeat: add gotests, gomodifytags, impl (#28) (diff)
downloadmason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.gz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.bz2
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.lz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.xz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.zst
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.zip
refactor: add mason-schemas and mason-core modules (#29)
* refactor: add mason-schemas and move generated filetype map to mason-lspconfig * refactor: add mason-core module
Diffstat (limited to 'lua/mason-core/installer')
-rw-r--r--lua/mason-core/installer/context.lua278
-rw-r--r--lua/mason-core/installer/handle.lua214
-rw-r--r--lua/mason-core/installer/init.lua176
-rw-r--r--lua/mason-core/installer/linker.lua84
4 files changed, 752 insertions, 0 deletions
diff --git a/lua/mason-core/installer/context.lua b/lua/mason-core/installer/context.lua
new file mode 100644
index 00000000..9dfbb7f1
--- /dev/null
+++ b/lua/mason-core/installer/context.lua
@@ -0,0 +1,278 @@
+local spawn = require "mason-core.spawn"
+local log = require "mason-core.log"
+local fs = require "mason-core.fs"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local receipt = require "mason-core.receipt"
+local Optional = require "mason-core.optional"
+local _ = require "mason-core.functional"
+
+---@class ContextualSpawn
+---@field cwd CwdManager
+---@field handle InstallHandle
+local ContextualSpawn = {}
+
+---@param cwd CwdManager
+---@param handle InstallHandle
+function ContextualSpawn.new(cwd, handle)
+ return setmetatable({ cwd = cwd, handle = handle }, ContextualSpawn)
+end
+
+function ContextualSpawn.__index(self, cmd)
+ 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:push_spawninfo(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:pop_spawninfo(captured_handle)
+ end
+ end
+ -- We get_or_throw() here for convenience reasons.
+ -- Almost every time spawn is called via context we want the command to succeed.
+ return spawn[cmd](args):on_success(pop_spawn_stack):on_failure(pop_spawn_stack):get_or_throw()
+ end
+end
+
+---@class ContextualFs
+---@field private cwd CwdManager
+local ContextualFs = {}
+ContextualFs.__index = ContextualFs
+
+---@param cwd CwdManager
+function ContextualFs.new(cwd)
+ return setmetatable({ cwd = cwd }, ContextualFs)
+end
+
+---@async
+---@param rel_path string @The relative path from the current working directory to the file to append.
+---@param contents string
+function ContextualFs:append_file(rel_path, contents)
+ return fs.async.append_file(path.concat { self.cwd:get(), rel_path }, contents)
+end
+
+---@async
+---@param rel_path string @The relative path from the current working directory to the file to write.
+---@param contents string
+function ContextualFs:write_file(rel_path, contents)
+ return fs.async.write_file(path.concat { self.cwd:get(), rel_path }, contents)
+end
+
+---@async
+---@param rel_path string @The relative path from the current working directory.
+function ContextualFs:file_exists(rel_path)
+ return fs.async.file_exists(path.concat { self.cwd:get(), rel_path })
+end
+
+---@async
+---@param rel_path string @The relative path from the current working directory.
+function ContextualFs:dir_exists(rel_path)
+ return fs.async.dir_exists(path.concat { self.cwd:get(), rel_path })
+end
+
+---@async
+---@param rel_path string @The relative path from the current working directory.
+function ContextualFs:rmrf(rel_path)
+ return fs.async.rmrf(path.concat { self.cwd:get(), rel_path })
+end
+
+---@async
+---@param rel_path string @The relative path from the current working directory.
+function ContextualFs:unlink(rel_path)
+ return fs.async.unlink(path.concat { self.cwd:get(), rel_path })
+end
+
+---@async
+---@param old_path string
+---@param new_path string
+function ContextualFs:rename(old_path, new_path)
+ return fs.async.rename(path.concat { self.cwd:get(), old_path }, path.concat { self.cwd:get(), new_path })
+end
+
+---@async
+---@param dirpath string
+function ContextualFs:mkdir(dirpath)
+ return fs.async.mkdir(path.concat { self.cwd:get(), dirpath })
+end
+
+---@class CwdManager
+---@field private install_prefix string @Defines the upper boundary for which paths are allowed as cwd.
+---@field private cwd string
+local CwdManager = {}
+CwdManager.__index = CwdManager
+
+function CwdManager.new(install_prefix)
+ assert(type(install_prefix) == "string")
+ return setmetatable({
+ install_prefix = install_prefix,
+ cwd = nil,
+ }, CwdManager)
+end
+
+function CwdManager:get()
+ assert(self.cwd ~= nil, "Tried to access cwd before it was set.")
+ return self.cwd
+end
+
+---@param new_cwd string
+function CwdManager:set(new_cwd)
+ assert(type(new_cwd) == "string")
+ assert(
+ path.is_subdirectory(self.install_prefix, new_cwd),
+ ("%q is not a subdirectory of %q"):format(new_cwd, self.install_prefix)
+ )
+ self.cwd = new_cwd
+end
+
+---@class InstallContext
+---@field public receipt InstallReceiptBuilder
+---@field public requested_version Optional
+---@field public fs ContextualFs
+---@field public spawn JobSpawn
+---@field public handle InstallHandle
+---@field public package Package
+---@field public cwd CwdManager
+---@field public stdio_sink StdioSink
+local InstallContext = {}
+InstallContext.__index = InstallContext
+
+---@class InstallContextOpts
+---@field requested_version string|nil
+
+---@param handle InstallHandle
+---@param opts InstallContextOpts
+function InstallContext.new(handle, opts)
+ local cwd_manager = CwdManager.new(path.install_prefix())
+ return setmetatable({
+ cwd = cwd_manager,
+ spawn = ContextualSpawn.new(cwd_manager, handle),
+ handle = handle,
+ package = handle.package, -- for convenience
+ fs = ContextualFs.new(cwd_manager),
+ receipt = receipt.InstallReceiptBuilder.new(),
+ requested_version = Optional.of_nilable(opts.requested_version),
+ stdio_sink = handle.stdio.sink,
+ }, InstallContext)
+end
+
+---@async
+function InstallContext:promote_cwd()
+ local cwd = self.cwd:get()
+ local install_path = self.package:get_install_path()
+ if install_path == cwd then
+ log.fmt_debug("cwd %s is already promoted (at %s)", cwd, install_path)
+ return
+ end
+ log.fmt_debug("Promoting cwd %s to %s", cwd, install_path)
+ -- 1. Unlink any existing installation
+ self.handle.package:unlink()
+ -- 2. Prepare for renaming cwd to destination
+ if platform.is_unix then
+ -- Some Unix systems will raise an error when renaming a directory to a destination that does not already exist.
+ fs.async.mkdir(install_path)
+ end
+ -- 3. Move the cwd to the final installation directory
+ fs.async.rename(cwd, install_path)
+ -- 4. Update cwd
+ self.cwd:set(install_path)
+end
+
+---@param rel_path string @The relative path from the current working directory to change cwd to. Will only restore to the initial cwd after execution of fn (if provided).
+---@param fn async fun() @(optional) 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)
+ 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 target_executable_rel_path string
+function InstallContext:write_exec_wrapper(new_executable_rel_path, target_executable_rel_path)
+ return self:write_shell_exec_wrapper(
+ new_executable_rel_path,
+ ("%q"):format(path.concat {
+ self.package:get_install_path(),
+ target_executable_rel_path,
+ })
+ )
+end
+
+---@param new_executable_rel_path string @Relative path to the executable file to create.
+---@param command string @The shell command to run.
+---@return string @The created executable filename.
+function InstallContext:write_shell_exec_wrapper(new_executable_rel_path, command)
+ return platform.when {
+ unix = function()
+ local std = require "mason-core.managers.std"
+ self.fs:write_file(
+ new_executable_rel_path,
+ _.dedent(([[
+ #!/bin/bash
+ exec %s "$@"
+ ]]):format(command))
+ )
+ std.chmod("+x", { new_executable_rel_path })
+ return new_executable_rel_path
+ end,
+ win = function()
+ local executable_file = ("%s.cmd"):format(new_executable_rel_path)
+ self.fs:write_file(
+ executable_file,
+ _.dedent(([[
+ @ECHO off
+ %s %%*
+ ]]):format(command))
+ )
+ return executable_file
+ end,
+ }
+end
+
+function InstallContext:link_bin(executable, rel_path)
+ self.receipt:with_link("bin", executable, rel_path)
+end
+
+---@param patches string[]
+function InstallContext:apply_patches(patches)
+ for _, patch in ipairs(patches) do
+ self.spawn.patch {
+ "-g",
+ "0",
+ "-f",
+ on_spawn = function(_, stdio)
+ local stdin = stdio[1]
+ stdin:write(patch)
+ stdin:close()
+ end,
+ }
+ end
+end
+
+return InstallContext
diff --git a/lua/mason-core/installer/handle.lua b/lua/mason-core/installer/handle.lua
new file mode 100644
index 00000000..459e3704
--- /dev/null
+++ b/lua/mason-core/installer/handle.lua
@@ -0,0 +1,214 @@
+local a = require "mason-core.async"
+local spawn = require "mason-core.spawn"
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local EventEmitter = require "mason-core.EventEmitter"
+local log = require "mason-core.log"
+local Optional = require "mason-core.optional"
+local platform = require "mason-core.platform"
+
+local uv = vim.loop
+
+---@alias InstallHandleState
+--- | '"IDLE"'
+--- | '"QUEUED"'
+--- | '"ACTIVE"'
+--- | '"CLOSED"'
+
+---@class InstallHandleSpawnInfo
+---@field handle luv_handle
+---@field pid integer
+---@field cmd string
+---@field args string[]
+local InstallHandleSpawnInfo = {}
+InstallHandleSpawnInfo.__index = InstallHandleSpawnInfo
+
+---@param fields InstallHandleSpawnInfo
+function InstallHandleSpawnInfo.new(fields)
+ return setmetatable(fields, InstallHandleSpawnInfo)
+end
+
+function InstallHandleSpawnInfo:__tostring()
+ return ("%s %s"):format(self.cmd, table.concat(self.args, " "))
+end
+
+---@class InstallHandle : EventEmitter
+---@field package Package
+---@field state InstallHandleState
+---@field stdio { buffers: { stdout: string[], stderr: string[] }, sink: StdioSink }
+---@field is_terminated boolean
+---@field private spawninfo_stack InstallHandleSpawnInfo[]
+local InstallHandle = setmetatable({}, { __index = EventEmitter })
+local InstallHandleMt = { __index = InstallHandle }
+
+---@param handle InstallHandle
+local function new_sink(handle)
+ local stdout, stderr = {}, {}
+ return {
+ buffers = { stdout = stdout, stderr = stderr },
+ sink = {
+ stdout = function(chunk)
+ stdout[#stdout + 1] = chunk
+ handle:emit("stdout", chunk)
+ end,
+ stderr = function(chunk)
+ stderr[#stderr + 1] = chunk
+ handle:emit("stderr", chunk)
+ end,
+ },
+ }
+end
+
+---@param pkg Package
+function InstallHandle.new(pkg)
+ local self = EventEmitter.init(setmetatable({}, InstallHandleMt))
+ self.state = "IDLE"
+ self.package = pkg
+ self.spawninfo_stack = {}
+ self.stdio = new_sink(self)
+ self.is_terminated = false
+ return self
+end
+
+---@param luv_handle luv_handle
+---@param pid integer
+---@param cmd string
+---@param args string[]
+function InstallHandle:push_spawninfo(luv_handle, pid, cmd, args)
+ local spawninfo = InstallHandleSpawnInfo.new {
+ handle = luv_handle,
+ pid = pid,
+ cmd = cmd,
+ args = args,
+ }
+ log.fmt_trace("Pushing spawninfo stack for %s: %s (pid: %s)", self, spawninfo, pid)
+ self.spawninfo_stack[#self.spawninfo_stack + 1] = spawninfo
+ self:emit "spawninfo:change"
+end
+
+---@param luv_handle luv_handle
+function InstallHandle:pop_spawninfo(luv_handle)
+ for i = #self.spawninfo_stack, 1, -1 do
+ if self.spawninfo_stack[i].handle == luv_handle then
+ log.fmt_trace("Popping spawninfo stack for %s: %s", self, self.spawninfo_stack[i])
+ table.remove(self.spawninfo_stack, i)
+ self:emit "spawninfo:change"
+ return true
+ end
+ end
+ return false
+end
+
+---@return Optional @Optional<InstallHandleSpawnInfo>
+function InstallHandle:peek_spawninfo_stack()
+ return Optional.of_nilable(self.spawninfo_stack[#self.spawninfo_stack])
+end
+
+function InstallHandle:is_idle()
+ return self.state == "IDLE"
+end
+
+function InstallHandle:is_queued()
+ return self.state == "QUEUED"
+end
+
+function InstallHandle:is_active()
+ return self.state == "ACTIVE"
+end
+
+function InstallHandle:is_closed()
+ return self.state == "CLOSED"
+end
+
+---@param new_state InstallHandleState
+function InstallHandle:set_state(new_state)
+ local old_state = self.state
+ self.state = new_state
+ log.fmt_trace("Changing %s state from %s to %s", self, old_state, new_state)
+ self:emit("state:change", new_state, old_state)
+end
+
+---@param signal integer
+function InstallHandle:kill(signal)
+ assert(not self:is_closed(), "Cannot kill closed handle.")
+ log.fmt_trace("Sending signal %s to luv handles in %s", signal, self)
+ for _, spawninfo in pairs(self.spawninfo_stack) do
+ process.kill(spawninfo.handle, signal)
+ end
+ self:emit("kill", signal)
+end
+
+---@param pid integer
+local win_taskkill = a.scope(function(pid)
+ spawn.taskkill {
+ "/f",
+ "/t",
+ "/pid",
+ pid,
+ }
+end)
+
+function InstallHandle:terminate()
+ assert(not self:is_closed(), "Cannot terminate closed handle.")
+ if self.is_terminated then
+ log.fmt_trace("Handle is already terminated %s", self)
+ return
+ end
+ log.fmt_trace("Terminating %s", self)
+ -- https://github.com/libuv/libuv/issues/1133
+ if platform.is.win then
+ for _, spawninfo in ipairs(self.spawninfo_stack) do
+ win_taskkill(spawninfo.pid)
+ end
+ else
+ self:kill(15) -- SIGTERM
+ end
+ self.is_terminated = true
+ self:emit "terminate"
+ local check = uv.new_check()
+ check:start(function()
+ for _, spawninfo in ipairs(self.spawninfo_stack) do
+ local luv_handle = spawninfo.handle
+ local ok, is_closing = pcall(luv_handle.is_closing, luv_handle)
+ if ok and not is_closing then
+ return
+ end
+ end
+ check:stop()
+ if not self:is_closed() then
+ self:close()
+ end
+ end)
+end
+
+function InstallHandle:queued()
+ assert(self:is_idle(), "Can only queue idle handles.")
+ self:set_state "QUEUED"
+end
+
+function InstallHandle:active()
+ assert(self:is_idle() or self:is_queued(), "Can only activate idle or queued handles.")
+ self:set_state "ACTIVE"
+end
+
+function InstallHandle:close()
+ log.fmt_trace("Closing %s", self)
+ assert(not self:is_closed(), "Handle is already closed.")
+ for _, spawninfo in ipairs(self.spawninfo_stack) do
+ local luv_handle = spawninfo.handle
+ local ok, is_closing = pcall(luv_handle.is_closing, luv_handle)
+ if ok then
+ assert(is_closing, "There are open libuv handles.")
+ end
+ end
+ self.spawninfo_stack = {}
+ self:set_state "CLOSED"
+ self:emit "closed"
+ self:clear_event_handlers()
+end
+
+function InstallHandleMt:__tostring()
+ return ("InstallHandle(package=%s, state=%s)"):format(self.package, self.state)
+end
+
+return InstallHandle
diff --git a/lua/mason-core/installer/init.lua b/lua/mason-core/installer/init.lua
new file mode 100644
index 00000000..ecf4f2f0
--- /dev/null
+++ b/lua/mason-core/installer/init.lua
@@ -0,0 +1,176 @@
+local log = require "mason-core.log"
+local _ = require "mason-core.functional"
+local path = require "mason-core.path"
+local fs = require "mason-core.fs"
+local a = require "mason-core.async"
+local Result = require "mason-core.result"
+local InstallContext = require "mason-core.installer.context"
+local settings = require "mason.settings"
+local linker = require "mason-core.installer.linker"
+local control = require "mason-core.async.control"
+
+local Semaphore = control.Semaphore
+
+local sem = Semaphore.new(settings.current.max_concurrent_installers)
+
+local M = {}
+
+---@async
+local function create_prefix_dirs()
+ for _, p in ipairs { path.install_prefix(), path.bin_prefix(), path.package_prefix(), path.package_build_prefix() } do
+ if not fs.async.dir_exists(p) then
+ fs.async.mkdirp(p)
+ end
+ end
+end
+
+---@async
+---@param context InstallContext
+local function write_receipt(context)
+ log.fmt_debug("Writing receipt for %s", context.package)
+ context.receipt
+ :with_name(context.package.name)
+ :with_schema_version("1.0")
+ :with_completion_time(vim.loop.gettimeofday())
+ local receipt_path = path.concat { context.cwd:get(), "mason-receipt.json" }
+ local install_receipt = context.receipt:build()
+ fs.async.write_file(receipt_path, vim.json.encode(install_receipt))
+end
+
+local CONTEXT_REQUEST = {}
+
+---@return InstallContext
+function M.context()
+ return coroutine.yield(CONTEXT_REQUEST)
+end
+
+---@async
+---@param context InstallContext
+function M.prepare_installer(context)
+ create_prefix_dirs()
+ local package_build_prefix = path.package_build_prefix(context.package.name)
+ if fs.async.dir_exists(package_build_prefix) then
+ fs.async.rmrf(package_build_prefix)
+ end
+ fs.async.mkdirp(package_build_prefix)
+ context.cwd:set(package_build_prefix)
+end
+
+---@async
+---@param context InstallContext
+---@param installer async fun(context: InstallContext)
+function M.run_installer(context, installer)
+ local thread = coroutine.create(function(...)
+ -- We wrap the installer with a function to allow it to be a spy instance (in which case it's not a function, but a metatable - coroutine.create expects functions only)
+ return installer(...)
+ end)
+ local step
+ local ret_val
+ step = function(...)
+ local ok, result = coroutine.resume(thread, ...)
+ if not ok then
+ error(result, 0)
+ elseif result == CONTEXT_REQUEST then
+ step(context)
+ elseif coroutine.status(thread) == "suspended" then
+ -- yield to parent coroutine
+ step(coroutine.yield(result))
+ else
+ ret_val = result
+ end
+ end
+ context.receipt:with_start_time(vim.loop.gettimeofday())
+ M.prepare_installer(context)
+ step(context)
+ return ret_val
+end
+
+---@async
+---@param handle InstallHandle
+---@param opts InstallContextOpts
+function M.execute(handle, opts)
+ if handle:is_active() or handle:is_closed() then
+ log.fmt_debug("Received active or closed handle %s", handle)
+ return Result.failure "Invalid handle state."
+ end
+
+ handle:queued()
+ local permit = sem:acquire()
+ if handle:is_closed() then
+ permit:forget()
+ log.fmt_trace("Installation was aborted %s", handle)
+ return Result.failure "Installation was aborted."
+ end
+ log.fmt_trace("Activating handle %s", handle)
+ handle:active()
+
+ local pkg = handle.package
+ local context = InstallContext.new(handle, opts)
+
+ log.fmt_info("Executing installer for %s", pkg)
+ return Result.run_catching(function()
+ -- 1. run installer
+ a.wait(function(resolve, reject)
+ local cancel_thread = a.run(M.run_installer, function(success, result)
+ if success then
+ resolve(result)
+ else
+ reject(result)
+ end
+ end, context, pkg.spec.install)
+
+ handle:once("terminate", function()
+ handle:once("closed", function()
+ reject "Installation was aborted."
+ end)
+ cancel_thread()
+ end)
+ end)
+
+ -- 2. promote temporary installation dir
+ context:promote_cwd()
+
+ -- 3. link package
+ linker.link(context)
+
+ -- 4. write receipt
+ write_receipt(context)
+ end)
+ :on_success(function()
+ permit:forget()
+ handle:close()
+ log.fmt_info("Installation succeeded for %s", pkg)
+ end)
+ :on_failure(function(failure)
+ permit:forget()
+ log.fmt_error("Installation failed for %s error=%s", pkg, failure)
+ context.stdio_sink.stderr(tostring(failure))
+ context.stdio_sink.stderr "\n"
+
+ -- clean up installation dir
+ pcall(function()
+ fs.async.rmrf(context.cwd:get())
+ end)
+
+ -- unlink linked executables (in the rare occassion an error occurs after linking)
+ linker.unlink(context.package, context.receipt.links)
+
+ if not handle:is_closed() and not handle.is_terminated then
+ handle:close()
+ end
+ end)
+end
+
+---Runs the provided async functions concurrently and returns their result, once all are resolved.
+---This is really just a wrapper around a.wait_all() that makes sure to patch the coroutine context before creating the
+---new async execution contexts.
+---@async
+---@param suspend_fns async fun(ctx: InstallContext)[]
+function M.run_concurrently(suspend_fns)
+ local context = M.context()
+ return a.wait_all(_.map(function(suspend_fn)
+ return _.partial(M.run_installer, context, suspend_fn)
+ end, suspend_fns))
+end
+
+return M
diff --git a/lua/mason-core/installer/linker.lua b/lua/mason-core/installer/linker.lua
new file mode 100644
index 00000000..0419f392
--- /dev/null
+++ b/lua/mason-core/installer/linker.lua
@@ -0,0 +1,84 @@
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local _ = require "mason-core.functional"
+local log = require "mason-core.log"
+local fs = require "mason-core.fs"
+
+local M = {}
+
+---@param pkg Package
+---@param links InstallReceiptLinks
+local function unlink_bin(pkg, links)
+ for executable in pairs(links.bin) do
+ local bin_path = path.bin_prefix(executable)
+ fs.sync.unlink(bin_path)
+ end
+end
+
+---@param pkg Package
+---@param links InstallReceiptLinks
+function M.unlink(pkg, links)
+ log.fmt_debug("Unlinking %s", pkg)
+ unlink_bin(pkg, links)
+end
+
+---@param to string
+local function relative_path_from_bin(to)
+ local _, match_end = to:find(path.install_prefix(), 1, true)
+ assert(match_end, "Failed to produce relative path.")
+ local relative_path = to:sub(match_end + 1)
+ return ".." .. relative_path
+end
+
+---@async
+---@param context InstallContext
+local function link_bin(context)
+ local links = context.receipt.links.bin
+ local pkg = context.package
+ for name, rel_path in pairs(links) do
+ local target_abs_path = path.concat { pkg:get_install_path(), rel_path }
+ local target_rel_path = relative_path_from_bin(target_abs_path)
+ local bin_path = path.bin_prefix(name)
+
+ assert(not fs.async.file_exists(bin_path), ("bin/%s is already linked."):format(name))
+ assert(fs.async.file_exists(target_abs_path), ("Link target %q does not exist."):format(target_abs_path))
+
+ log.fmt_debug("Linking bin %s to %s", name, target_rel_path)
+
+ platform.when {
+ unix = function()
+ fs.async.symlink(target_rel_path, bin_path)
+ end,
+ win = function()
+ -- We don't "symlink" on Windows because:
+ -- 1) .LNK is not commonly found in PATHEXT
+ -- 2) some executables can only run from their true installation location
+ -- 3) many utilities only consider .COM, .EXE, .CMD, .BAT files as candidates by default when resolving executables (e.g. neovim's |exepath()| and |executable()|)
+ fs.async.write_file(
+ ("%s.cmd"):format(bin_path),
+ _.dedent(([[
+ @ECHO off
+ GOTO start
+ :find_dp0
+ SET dp0=%%~dp0
+ EXIT /b
+ :start
+ SETLOCAL
+ CALL :find_dp0
+
+ endLocal & goto #_undefined_# 2>NUL || title %%COMSPEC%% & "%%dp0%%\%s" %%*
+ ]]):format(target_rel_path))
+ )
+ end,
+ }
+ end
+end
+
+---@async
+---@param context InstallContext
+function M.link(context)
+ log.fmt_debug("Linking %s", context.package)
+ link_bin(context)
+end
+
+return M