aboutsummaryrefslogtreecommitdiffstats
path: root/lua/mason-core
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
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')
-rw-r--r--lua/mason-core/EventEmitter.lua64
-rw-r--r--lua/mason-core/async/control.lua75
-rw-r--r--lua/mason-core/async/init.lua245
-rw-r--r--lua/mason-core/async/uv.lua49
-rw-r--r--lua/mason-core/clients/eclipse.lua15
-rw-r--r--lua/mason-core/fetch.lua124
-rw-r--r--lua/mason-core/fs.lua152
-rw-r--r--lua/mason-core/functional/data.lua30
-rw-r--r--lua/mason-core/functional/function.lua89
-rw-r--r--lua/mason-core/functional/init.lua112
-rw-r--r--lua/mason-core/functional/list.lua175
-rw-r--r--lua/mason-core/functional/logic.lua63
-rw-r--r--lua/mason-core/functional/number.lua34
-rw-r--r--lua/mason-core/functional/relation.lua17
-rw-r--r--lua/mason-core/functional/string.lua74
-rw-r--r--lua/mason-core/functional/table.lua45
-rw-r--r--lua/mason-core/functional/type.lua14
-rw-r--r--lua/mason-core/health/init.lua298
-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
-rw-r--r--lua/mason-core/log.lua174
-rw-r--r--lua/mason-core/managers/cargo/client.lua14
-rw-r--r--lua/mason-core/managers/cargo/init.lua140
-rw-r--r--lua/mason-core/managers/composer/init.lua135
-rw-r--r--lua/mason-core/managers/dotnet/init.lua64
-rw-r--r--lua/mason-core/managers/gem/init.lua159
-rw-r--r--lua/mason-core/managers/git/init.lua76
-rw-r--r--lua/mason-core/managers/github/client.lua117
-rw-r--r--lua/mason-core/managers/github/init.lua171
-rw-r--r--lua/mason-core/managers/go/init.lua171
-rw-r--r--lua/mason-core/managers/luarocks/init.lua144
-rw-r--r--lua/mason-core/managers/npm/init.lua143
-rw-r--r--lua/mason-core/managers/opam/init.lua69
-rw-r--r--lua/mason-core/managers/pip3/init.lua175
-rw-r--r--lua/mason-core/managers/powershell/init.lua46
-rw-r--r--lua/mason-core/managers/std/init.lua188
-rw-r--r--lua/mason-core/notify.lua13
-rw-r--r--lua/mason-core/optional.lua100
-rw-r--r--lua/mason-core/package/init.lua205
-rw-r--r--lua/mason-core/package/version-check.lua91
-rw-r--r--lua/mason-core/path.lua51
-rw-r--r--lua/mason-core/platform.lua159
-rw-r--r--lua/mason-core/process.lua213
-rw-r--r--lua/mason-core/receipt.lua180
-rw-r--r--lua/mason-core/result.lua152
-rw-r--r--lua/mason-core/spawn.lua112
-rw-r--r--lua/mason-core/ui/display.lua507
-rw-r--r--lua/mason-core/ui/init.lua203
-rw-r--r--lua/mason-core/ui/state.lua24
51 files changed, 6423 insertions, 0 deletions
diff --git a/lua/mason-core/EventEmitter.lua b/lua/mason-core/EventEmitter.lua
new file mode 100644
index 00000000..672c1778
--- /dev/null
+++ b/lua/mason-core/EventEmitter.lua
@@ -0,0 +1,64 @@
+---@class EventEmitter
+---@field private __event_handlers table<any, table<fun(), fun()>>
+---@field private __event_handlers_once table<any, table<fun(), fun()>>
+local EventEmitter = {}
+EventEmitter.__index = EventEmitter
+
+---@generic T
+---@param obj T
+---@return T
+function EventEmitter.init(obj)
+ obj.__event_handlers = {}
+ obj.__event_handlers_once = {}
+ return obj
+end
+
+---@param event any
+function EventEmitter:emit(event, ...)
+ if self.__event_handlers[event] then
+ for handler in pairs(self.__event_handlers[event]) do
+ pcall(handler, ...)
+ end
+ end
+ if self.__event_handlers_once[event] then
+ for handler in pairs(self.__event_handlers_once[event]) do
+ pcall(handler, ...)
+ self.__event_handlers_once[handler] = nil
+ end
+ end
+end
+
+---@param event any
+---@param handler fun(payload: any)
+function EventEmitter:on(event, handler)
+ if not self.__event_handlers[event] then
+ self.__event_handlers[event] = {}
+ end
+ self.__event_handlers[event][handler] = handler
+end
+
+---@param event any
+---@parma handler fun(payload: any)
+function EventEmitter:once(event, handler)
+ if not self.__event_handlers_once[event] then
+ self.__event_handlers_once[event] = {}
+ end
+ self.__event_handlers_once[event][handler] = handler
+end
+
+---@param event any
+---@param handler fun(payload: any)
+function EventEmitter:off(event, handler)
+ if vim.tbl_get(self.__event_handlers, { event, handler }) then
+ self.__event_handlers[event][handler] = nil
+ return true
+ end
+ return false
+end
+
+function EventEmitter:clear_event_handlers()
+ self.__event_handlers = {}
+ self.__event_handlers_once = {}
+end
+
+return EventEmitter
diff --git a/lua/mason-core/async/control.lua b/lua/mason-core/async/control.lua
new file mode 100644
index 00000000..3252c070
--- /dev/null
+++ b/lua/mason-core/async/control.lua
@@ -0,0 +1,75 @@
+local a = require "mason-core.async"
+
+---@class Condvar
+local Condvar = {}
+Condvar.__index = Condvar
+
+function Condvar.new()
+ return setmetatable({ handles = {}, queue = {}, is_notifying = false }, Condvar)
+end
+
+---@async
+function Condvar:wait()
+ a.wait(function(resolve)
+ if self.is_notifying then
+ self.queue[resolve] = true
+ else
+ self.handles[resolve] = true
+ end
+ end)
+end
+
+function Condvar:notify_all()
+ self.is_notifying = true
+ for handle in pairs(self.handles) do
+ handle()
+ end
+ self.handles = self.queue
+ self.queue = {}
+ self.is_notifying = false
+end
+
+local Permit = {}
+Permit.__index = Permit
+
+function Permit.new(semaphore)
+ return setmetatable({ semaphore = semaphore }, Permit)
+end
+
+function Permit:forget()
+ local semaphore = self.semaphore
+ semaphore.permits = semaphore.permits + 1
+
+ if semaphore.permits > 0 and #semaphore.handles > 0 then
+ semaphore.permits = semaphore.permits - 1
+ local release = table.remove(semaphore.handles, 1)
+ release(Permit.new(semaphore))
+ end
+end
+
+---@class Semaphore
+local Semaphore = {}
+Semaphore.__index = Semaphore
+
+---@param permits integer
+function Semaphore.new(permits)
+ return setmetatable({ permits = permits, handles = {} }, Semaphore)
+end
+
+---@async
+function Semaphore:acquire()
+ if self.permits > 0 then
+ self.permits = self.permits - 1
+ else
+ return a.wait(function(resolve)
+ table.insert(self.handles, resolve)
+ end)
+ end
+
+ return Permit.new(self)
+end
+
+return {
+ Condvar = Condvar,
+ Semaphore = Semaphore,
+}
diff --git a/lua/mason-core/async/init.lua b/lua/mason-core/async/init.lua
new file mode 100644
index 00000000..c79c6e42
--- /dev/null
+++ b/lua/mason-core/async/init.lua
@@ -0,0 +1,245 @@
+local _ = require "mason-core.functional"
+local co = coroutine
+
+local exports = {}
+
+local Promise = {}
+Promise.__index = Promise
+
+function Promise.new(resolver)
+ return setmetatable({ resolver = resolver, has_resolved = false }, Promise)
+end
+
+---@param success boolean
+---@param cb fun(success: boolean, value: table)
+function Promise:_wrap_resolver_cb(success, cb)
+ return function(...)
+ if self.has_resolved then
+ return
+ end
+ self.has_resolved = true
+ cb(success, { ... })
+ end
+end
+
+function Promise:__call(callback)
+ self.resolver(self:_wrap_resolver_cb(true, callback), self:_wrap_resolver_cb(false, callback))
+end
+
+local function await(resolver)
+ local ok, value = co.yield(Promise.new(resolver))
+ if not ok then
+ error(value[1], 2)
+ end
+ return unpack(value)
+end
+
+local function table_pack(...)
+ return { n = select("#", ...), ... }
+end
+
+---@param async_fn fun(...)
+---@param should_reject_err boolean|nil @Whether the provided async_fn takes a callback with the signature `fun(err, result)`
+local function promisify(async_fn, should_reject_err)
+ return function(...)
+ local args = table_pack(...)
+ return await(function(resolve, reject)
+ if should_reject_err then
+ args[args.n + 1] = function(err, result)
+ if err then
+ reject(err)
+ else
+ resolve(result)
+ end
+ end
+ else
+ args[args.n + 1] = resolve
+ end
+ local ok, err = pcall(async_fn, unpack(args, 1, args.n + 1))
+ if not ok then
+ reject(err)
+ end
+ end)
+ end
+end
+
+local function new_execution_context(suspend_fn, callback, ...)
+ local thread = co.create(suspend_fn)
+ local cancelled = false
+ local step
+ step = function(...)
+ if cancelled then
+ return
+ end
+ local ok, promise_or_result = co.resume(thread, ...)
+ if ok then
+ if co.status(thread) == "suspended" then
+ if getmetatable(promise_or_result) == Promise then
+ promise_or_result(step)
+ else
+ -- yield to parent coroutine
+ step(coroutine.yield(promise_or_result))
+ end
+ else
+ callback(true, promise_or_result)
+ thread = nil
+ end
+ else
+ callback(false, promise_or_result)
+ thread = nil
+ end
+ end
+
+ step(...)
+ return function()
+ cancelled = true
+ thread = nil
+ end
+end
+
+exports.run = function(suspend_fn, callback, ...)
+ return new_execution_context(suspend_fn, callback, ...)
+end
+
+---@generic T
+---@param suspend_fn T
+---@return T
+exports.scope = function(suspend_fn)
+ return function(...)
+ return new_execution_context(suspend_fn, function(success, err)
+ if not success then
+ error(err, 0)
+ end
+ end, ...)
+ end
+end
+
+exports.run_blocking = function(suspend_fn, ...)
+ local resolved, ok, result
+ local cancel_coroutine = new_execution_context(suspend_fn, function(a, b)
+ resolved = true
+ ok = a
+ result = b
+ end, ...)
+
+ if vim.wait(60000, function()
+ return resolved == true
+ end, 50) then
+ if not ok then
+ error(result, 2)
+ end
+ return result
+ else
+ cancel_coroutine()
+ error("async function failed to resolve in time.", 2)
+ end
+end
+
+exports.wait = await
+exports.promisify = promisify
+
+exports.sleep = function(ms)
+ await(function(resolve)
+ vim.defer_fn(resolve, ms)
+ end)
+end
+
+exports.scheduler = function()
+ await(vim.schedule)
+end
+
+---Creates a oneshot channel that can only send once.
+local function oneshot_channel()
+ local has_sent = false
+ local sent_value
+ local saved_callback
+
+ return {
+ is_closed = function()
+ return has_sent
+ end,
+ send = function(...)
+ assert(not has_sent, "Oneshot channel can only send once.")
+ has_sent = true
+ sent_value = { ... }
+ if saved_callback then
+ saved_callback(unpack(sent_value))
+ end
+ end,
+ receive = function()
+ return await(function(resolve)
+ if has_sent then
+ resolve(unpack(sent_value))
+ else
+ saved_callback = resolve
+ end
+ end)
+ end,
+ }
+end
+
+---@async
+---@param suspend_fns async fun()[]
+---@param mode '"first"' | '"all"'
+local function wait(suspend_fns, mode)
+ local channel = oneshot_channel()
+
+ do
+ local results = {}
+ local thread_cancellations = {}
+ local count = #suspend_fns
+ local completed = 0
+
+ local function cancel()
+ for _, cancel_thread in ipairs(thread_cancellations) do
+ cancel_thread()
+ end
+ end
+
+ for i, suspend_fn in ipairs(suspend_fns) do
+ thread_cancellations[i] = exports.run(suspend_fn, function(success, result)
+ completed = completed + 1
+ if not success then
+ if not channel.is_closed() then
+ cancel()
+ channel.send(false, result)
+ results = nil
+ thread_cancellations = {}
+ end
+ else
+ results[i] = result
+ if mode == "first" or completed >= count then
+ cancel()
+ channel.send(true, mode == "first" and { result } or results)
+ results = nil
+ thread_cancellations = {}
+ end
+ end
+ end)
+ end
+ end
+
+ local ok, results = channel.receive()
+ if not ok then
+ error(results, 2)
+ end
+ return unpack(results)
+end
+
+---@async
+---@param suspend_fns async fun()[]
+function exports.wait_all(suspend_fns)
+ return wait(suspend_fns, "all")
+end
+
+---@async
+---@param suspend_fns async fun()[]
+function exports.wait_first(suspend_fns)
+ return wait(suspend_fns, "first")
+end
+
+function exports.blocking(suspend_fn)
+ return _.partial(exports.run_blocking, suspend_fn)
+end
+
+return exports
diff --git a/lua/mason-core/async/uv.lua b/lua/mason-core/async/uv.lua
new file mode 100644
index 00000000..f3d25b04
--- /dev/null
+++ b/lua/mason-core/async/uv.lua
@@ -0,0 +1,49 @@
+local a = require "mason-core.async"
+
+---@type table<UvMethod, async fun(...)>
+local M = setmetatable({}, {
+ __index = function(cache, method)
+ cache[method] = a.promisify(vim.loop[method], true)
+ return cache[method]
+ end,
+})
+
+return M
+
+---@alias UvMethod
+---| '"fs_close"'
+---| '"fs_open"'
+---| '"fs_read"'
+---| '"fs_unlink"'
+---| '"fs_write"'
+---| '"fs_mkdir"'
+---| '"fs_mkdtemp"'
+---| '"fs_mkstemp"'
+---| '"fs_rmdir"'
+---| '"fs_scandir"'
+---| '"fs_stat"'
+---| '"fs_fstat"'
+---| '"fs_lstat"'
+---| '"fs_rename"'
+---| '"fs_fsync"'
+---| '"fs_fdatasync"'
+---| '"fs_ftruncate"'
+---| '"fs_sendfile"'
+---| '"fs_access"'
+---| '"fs_chmod"'
+---| '"fs_fchmod"'
+---| '"fs_utime"'
+---| '"fs_futime"'
+---| '"fs_lutime"'
+---| '"fs_link"'
+---| '"fs_symlink"'
+---| '"fs_readlink"'
+---| '"fs_realpath"'
+---| '"fs_chown"'
+---| '"fs_fchown"'
+---| '"fs_lchown"'
+---| '"fs_copyfile"'
+---| '"fs_opendir"'
+---| '"fs_readdir"'
+---| '"fs_closedir"'
+---| '"fs_statfs"'
diff --git a/lua/mason-core/clients/eclipse.lua b/lua/mason-core/clients/eclipse.lua
new file mode 100644
index 00000000..bca31648
--- /dev/null
+++ b/lua/mason-core/clients/eclipse.lua
@@ -0,0 +1,15 @@
+local fetch = require "mason-core.fetch"
+local M = {}
+
+---@param version string The version string as found in the latest.txt endpoint.
+---@return string The parsed version number.
+function M._parse_jdtls_version_string(version)
+ return vim.trim(version):gsub("^jdt%-language%-server%-", ""):gsub("%.tar%.gz$", "")
+end
+
+---@async
+function M.fetch_latest_jdtls_version()
+ return fetch("https://download.eclipse.org/jdtls/snapshots/latest.txt"):map(M._parse_jdtls_version_string)
+end
+
+return M
diff --git a/lua/mason-core/fetch.lua b/lua/mason-core/fetch.lua
new file mode 100644
index 00000000..168d0b99
--- /dev/null
+++ b/lua/mason-core/fetch.lua
@@ -0,0 +1,124 @@
+local log = require "mason-core.log"
+local platform = require "mason-core.platform"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local powershell = require "mason-core.managers.powershell"
+local _ = require "mason-core.functional"
+
+local USER_AGENT = "mason.nvim (+https://github.com/williamboman/mason.nvim)"
+
+---@alias FetchMethod
+---| '"GET"'
+---| '"POST"'
+---| '"PUT"'
+---| '"PATCH"'
+---| '"DELETE"'
+
+---@alias FetchOpts {out_file: string, method: FetchMethod, headers: table<string, string>, data: string}
+
+---@async
+---@param url string @The url to fetch.
+---@param opts FetchOpts | nil
+local function fetch(url, opts)
+ opts = opts or {}
+ if not opts.headers then
+ opts.headers = {}
+ end
+ if not opts.method then
+ opts.method = "GET"
+ end
+ opts.headers["User-Agent"] = USER_AGENT
+ log.fmt_debug("Fetching URL %s", url)
+
+ local platform_specific = Result.failure()
+
+ if platform.is_win then
+ local header_entries = _.join(
+ ", ",
+ _.map(function(pair)
+ return ("%q = %q"):format(pair[1], pair[2])
+ end, _.to_pairs(opts.headers))
+ )
+ local headers = ("@{%s}"):format(header_entries)
+ if opts.out_file then
+ platform_specific = powershell.command(
+ ([[iwr %s -UseBasicParsing -Method %q -Uri %q %s -OutFile %q;]]):format(
+ headers,
+ opts.method,
+ url,
+ opts.data and ("-Body %s"):format(opts.data) or "",
+ opts.out_file
+ )
+ )
+ else
+ platform_specific = powershell.command(
+ ([[Write-Output (iwr %s -Method %q -UseBasicParsing %s -Uri %q).Content;]]):format(
+ headers,
+ opts.method,
+ opts.data and ("-Body %s"):format(opts.data) or "",
+ url
+ )
+ )
+ end
+ end
+
+ return platform_specific
+ :recover_catching(function()
+ local headers =
+ _.sort_by(_.identity, _.map(_.compose(_.format "--header='%s'", _.join ": "), _.to_pairs(opts.headers)))
+ return spawn
+ .wget({
+ headers,
+ "-nv",
+ "-O",
+ opts.out_file or "-",
+ ("--method=%s"):format(opts.method),
+ opts.data and {
+ ("--body-data=%s"):format(opts.data) or vim.NIL,
+ } or vim.NIL,
+ url,
+ })
+ :get_or_throw()
+ end)
+ :recover_catching(function()
+ local headers = _.sort_by(
+ _.nth(2),
+ _.map(
+ _.compose(function(header)
+ return { "-H", header }
+ end, _.join ": "),
+ _.to_pairs(opts.headers)
+ )
+ )
+ return spawn
+ .curl({
+ headers,
+ "-fsSL",
+ {
+ "-X",
+ opts.method,
+ },
+ opts.data and { "-d", "@-" } or vim.NIL,
+ opts.out_file and { "-o", opts.out_file } or vim.NIL,
+ url,
+ on_spawn = function(_, stdio)
+ local stdin = stdio[1]
+ if opts.data then
+ log.trace("Writing stdin to curl", opts.data)
+ stdin:write(opts.data)
+ end
+ stdin:close()
+ end,
+ })
+ :get_or_throw()
+ end)
+ :map(function(result)
+ if opts.out_file then
+ return result
+ else
+ return result.stdout
+ end
+ end)
+end
+
+return fetch
diff --git a/lua/mason-core/fs.lua b/lua/mason-core/fs.lua
new file mode 100644
index 00000000..a69b5de5
--- /dev/null
+++ b/lua/mason-core/fs.lua
@@ -0,0 +1,152 @@
+local log = require "mason-core.log"
+local a = require "mason-core.async"
+local Path = require "mason-core.path"
+local settings = require "mason.settings"
+
+local function make_module(uv)
+ local M = {}
+
+ ---@param path string
+ function M.fstat(path)
+ log.trace("fs: fstat", path)
+ local fd = uv.fs_open(path, "r", 438)
+ local fstat = uv.fs_fstat(fd)
+ uv.fs_close(fd)
+ return fstat
+ end
+
+ ---@param path string
+ function M.file_exists(path)
+ log.trace("fs: file_exists", path)
+ local ok, fstat = pcall(M.fstat, path)
+ if not ok then
+ return false
+ end
+ return fstat.type == "file"
+ end
+
+ ---@param path string
+ function M.dir_exists(path)
+ log.trace("fs: dir_exists", path)
+ local ok, fstat = pcall(M.fstat, path)
+ if not ok then
+ return false
+ end
+ return fstat.type == "directory"
+ end
+
+ ---@param path string
+ function M.rmrf(path)
+ assert(
+ Path.is_subdirectory(settings.current.install_root_dir, path),
+ (
+ "Refusing to rmrf %q which is outside of the allowed boundary %q. Please report this error at https://github.com/williamboman/mason.nvim/issues/new"
+ ):format(path, settings.current.install_root_dir)
+ )
+ log.debug("fs: rmrf", path)
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ if vim.fn.delete(path, "rf") ~= 0 then
+ log.debug "fs: rmrf failed"
+ error(("rmrf: Could not remove directory %q."):format(path))
+ end
+ end
+
+ ---@param path string
+ function M.unlink(path)
+ log.debug("fs: unlink", path)
+ uv.fs_unlink(path)
+ end
+
+ ---@param path string
+ function M.mkdir(path)
+ log.debug("fs: mkdir", path)
+ uv.fs_mkdir(path, 493) -- 493(10) == 755(8)
+ end
+
+ ---@param path string
+ function M.mkdirp(path)
+ log.debug("fs: mkdirp", path)
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ if vim.fn.mkdir(path, "p") ~= 1 then
+ log.debug "fs: mkdirp failed"
+ error(("mkdirp: Could not create directory %q."):format(path))
+ end
+ end
+
+ ---@param path string
+ ---@param new_path string
+ function M.rename(path, new_path)
+ log.debug("fs: rename", path, new_path)
+ uv.fs_rename(path, new_path)
+ end
+
+ ---@param path string
+ ---@param contents string
+ ---@param flags string|nil @Defaults to "w".
+ function M.write_file(path, contents, flags)
+ log.debug("fs: write_file", path)
+ local fd = uv.fs_open(path, flags or "w", 438)
+ uv.fs_write(fd, contents, -1)
+ uv.fs_close(fd)
+ end
+
+ ---@param path string
+ ---@param contents string
+ function M.append_file(path, contents)
+ M.write_file(path, contents, "a")
+ end
+
+ ---@param path string
+ function M.read_file(path)
+ log.trace("fs: read_file", path)
+ local fd = uv.fs_open(path, "r", 438)
+ local fstat = uv.fs_fstat(fd)
+ local contents = uv.fs_read(fd, fstat.size, 0)
+ uv.fs_close(fd)
+ return contents
+ end
+
+ ---@alias ReaddirEntry {name: string, type: string}
+
+ ---@param path string @The full path to the directory to read.
+ ---@return ReaddirEntry[]
+ function M.readdir(path)
+ log.trace("fs: fs_opendir", path)
+ local dir = assert(vim.loop.fs_opendir(path, nil, 25))
+ local all_entries = {}
+ local exhausted = false
+
+ repeat
+ local entries = uv.fs_readdir(dir)
+ log.trace("fs: fs_readdir", path, entries)
+ if entries and #entries > 0 then
+ for i = 1, #entries do
+ all_entries[#all_entries + 1] = entries[i]
+ end
+ else
+ log.trace("fs: fs_readdir exhausted scan", path)
+ exhausted = true
+ end
+ until exhausted
+
+ uv.fs_closedir(dir)
+
+ return all_entries
+ end
+
+ function M.symlink(path, new_path)
+ log.trace("fs: symlink", path, new_path)
+ uv.fs_symlink(path, new_path)
+ end
+
+ return M
+end
+
+return {
+ async = make_module(require "mason-core.async.uv"),
+ sync = make_module(vim.loop),
+}
diff --git a/lua/mason-core/functional/data.lua b/lua/mason-core/functional/data.lua
new file mode 100644
index 00000000..da6f1efd
--- /dev/null
+++ b/lua/mason-core/functional/data.lua
@@ -0,0 +1,30 @@
+local _ = {}
+
+_.table_pack = function(...)
+ return { n = select("#", ...), ... }
+end
+
+---@generic T : string
+---@param values T[]
+---@return table<T, T>
+_.enum = function(values)
+ local result = {}
+ for i = 1, #values do
+ local v = values[i]
+ result[v] = v
+ end
+ return result
+end
+
+---@generic T
+---@param list T[]
+---@return table<T, boolean>
+_.set_of = function(list)
+ local set = {}
+ for i = 1, #list do
+ set[list[i]] = true
+ end
+ return set
+end
+
+return _
diff --git a/lua/mason-core/functional/function.lua b/lua/mason-core/functional/function.lua
new file mode 100644
index 00000000..e85081ce
--- /dev/null
+++ b/lua/mason-core/functional/function.lua
@@ -0,0 +1,89 @@
+local data = require "mason-core.functional.data"
+
+local _ = {}
+
+---@generic T : fun(...)
+---@param fn T
+---@param arity integer
+---@return T
+_.curryN = function(fn, arity)
+ return function(...)
+ local args = data.table_pack(...)
+ if args.n >= arity then
+ return fn(unpack(args, 1, arity))
+ else
+ return _.curryN(_.partial(fn, unpack(args, 1, args.n)), arity - args.n)
+ end
+ end
+end
+
+_.compose = function(...)
+ local functions = data.table_pack(...)
+ assert(functions.n > 0, "compose requires at least one function")
+ return function(...)
+ local result = data.table_pack(...)
+ for i = functions.n, 1, -1 do
+ result = data.table_pack(functions[i](unpack(result, 1, result.n)))
+ end
+ return unpack(result, 1, result.n)
+ end
+end
+
+---@generic T
+---@param fn fun(...): T
+---@return fun(...): T
+_.partial = function(fn, ...)
+ local bound_args = data.table_pack(...)
+ return function(...)
+ local args = data.table_pack(...)
+ local merged_args = {}
+ for i = 1, bound_args.n do
+ merged_args[i] = bound_args[i]
+ end
+ for i = 1, args.n do
+ merged_args[bound_args.n + i] = args[i]
+ end
+ return fn(unpack(merged_args, 1, bound_args.n + args.n))
+ end
+end
+
+_.identity = function(a)
+ return a
+end
+
+_.always = function(a)
+ return function()
+ return a
+ end
+end
+
+_.T = _.always(true)
+_.F = _.always(false)
+
+---@generic T : fun(...)
+---@param fn T
+---@param cache_key_generator (fun(...): string | nil)|nil
+---@return T
+_.memoize = function(fn, cache_key_generator)
+ cache_key_generator = cache_key_generator or _.identity
+ local cache = {}
+ return function(...)
+ local key = cache_key_generator(...)
+ if not cache[key] then
+ cache[key] = data.table_pack(fn(...))
+ end
+ return unpack(cache[key], 1, cache[key].n)
+ end
+end
+
+---@generic T
+---@param fn fun(): T
+---@return fun(): T
+_.lazy = function(fn)
+ local memoized = _.memoize(fn, _.always "lazyval")
+ return function()
+ return memoized()
+ end
+end
+
+return _
diff --git a/lua/mason-core/functional/init.lua b/lua/mason-core/functional/init.lua
new file mode 100644
index 00000000..a7b0a369
--- /dev/null
+++ b/lua/mason-core/functional/init.lua
@@ -0,0 +1,112 @@
+local _ = {}
+
+-- data
+local data = require "mason-core.functional.data"
+_.table_pack = data.table_pack
+_.enum = data.enum
+_.set_of = data.set_of
+
+-- function
+local fun = require "mason-core.functional.function"
+_.curryN = fun.curryN
+_.compose = fun.compose
+_.partial = fun.partial
+_.identity = fun.identity
+_.always = fun.always
+_.T = fun.T
+_.F = fun.F
+_.memoize = fun.memoize
+_.lazy = fun.lazy
+
+-- list
+local list = require "mason-core.functional.list"
+_.reverse = list.reverse
+_.list_not_nil = list.list_not_nil
+_.list_copy = list.list_copy
+_.find_first = list.find_first
+_.any = list.any
+_.filter = list.filter
+_.map = list.map
+_.filter_map = list.filter_map
+_.each = list.each
+_.concat = list.concat
+_.append = list.append
+_.prepend = list.prepend
+_.zip_table = list.zip_table
+_.nth = list.nth
+_.head = list.head
+_.length = list.length
+_.flatten = list.flatten
+_.sort_by = list.sort_by
+_.join = list.join
+
+-- relation
+local relation = require "mason-core.functional.relation"
+_.equals = relation.equals
+_.prop_eq = relation.prop_eq
+_.prop_satisfies = relation.prop_satisfies
+
+-- logic
+local logic = require "mason-core.functional.logic"
+_.all_pass = logic.all_pass
+_.any_pass = logic.any_pass
+_.if_else = logic.if_else
+_.is_not = logic.is_not
+_.complement = logic.complement
+_.cond = logic.cond
+
+-- number
+local number = require "mason-core.functional.number"
+_.negate = number.negate
+_.gt = number.gt
+_.gte = number.gte
+_.lt = number.lt
+_.lte = number.lte
+_.inc = number.inc
+_.dec = number.dec
+
+-- string
+local string = require "mason-core.functional.string"
+_.matches = string.matches
+_.format = string.format
+_.split = string.split
+_.gsub = string.gsub
+_.trim = string.trim
+_.dedent = string.dedent
+_.starts_with = string.starts_with
+
+-- table
+local tbl = require "mason-core.functional.table"
+_.prop = tbl.prop
+_.pick = tbl.pick
+_.keys = tbl.keys
+_.size = tbl.size
+_.to_pairs = tbl.to_pairs
+_.invert = tbl.invert
+
+-- type
+local typ = require "mason-core.functional.type"
+_.is_nil = typ.is_nil
+_.is = typ.is
+
+-- TODO do something else with these
+
+_.coalesce = function(...)
+ local args = _.table_pack(...)
+ for i = 1, args.n do
+ local variable = args[i]
+ if variable ~= nil then
+ return variable
+ end
+ end
+end
+
+_.when = function(condition, value)
+ return condition and value or nil
+end
+
+_.lazy_when = function(condition, value)
+ return condition and value() or nil
+end
+
+return _
diff --git a/lua/mason-core/functional/list.lua b/lua/mason-core/functional/list.lua
new file mode 100644
index 00000000..14db386e
--- /dev/null
+++ b/lua/mason-core/functional/list.lua
@@ -0,0 +1,175 @@
+local fun = require "mason-core.functional.function"
+local data = require "mason-core.functional.data"
+
+local _ = {}
+
+---@generic T
+---@param list T[]
+---@return T[]
+_.reverse = function(list)
+ local result = {}
+ for i = #list, 1, -1 do
+ result[#result + 1] = list[i]
+ end
+ return result
+end
+
+_.list_not_nil = function(...)
+ local result = {}
+ local args = data.table_pack(...)
+ for i = 1, args.n do
+ if args[i] ~= nil then
+ result[#result + 1] = args[i]
+ end
+ end
+ return result
+end
+
+---@generic T
+---@param predicate fun(item: T): boolean
+---@param list T[]
+---@return T | nil
+_.find_first = fun.curryN(function(predicate, list)
+ local result
+ for i = 1, #list do
+ local entry = list[i]
+ if predicate(entry) then
+ return entry
+ end
+ end
+ return result
+end, 2)
+
+---@generic T
+---@param predicate fun(item: T): boolean
+---@param list T[]
+---@return boolean
+_.any = fun.curryN(function(predicate, list)
+ for i = 1, #list do
+ if predicate(list[i]) then
+ return true
+ end
+ end
+ return false
+end, 2)
+
+---@generic T
+---@type fun(filter_fn: (fun(item: T): boolean), items: T[]): T[]
+_.filter = fun.curryN(vim.tbl_filter, 2)
+
+---@generic T, U
+---@type fun(map_fn: (fun(item: T): U), items: T[]): U[]
+_.map = fun.curryN(vim.tbl_map, 2)
+
+_.flatten = fun.curryN(vim.tbl_flatten, 1)
+
+---@generic T
+---@param map_fn fun(item: T): Optional
+---@param list T[]
+---@return any[]
+_.filter_map = fun.curryN(function(map_fn, list)
+ local ret = {}
+ for i = 1, #list do
+ map_fn(list[i]):if_present(function(value)
+ ret[#ret + 1] = value
+ end)
+ end
+ return ret
+end, 2)
+
+---@generic T
+---@param fn fun(item: T, index: integer)
+---@param list T[]
+_.each = fun.curryN(function(fn, list)
+ for k, v in pairs(list) do
+ fn(v, k)
+ end
+end, 2)
+
+---@generic T
+---@param list T[]
+---@return T[] @A shallow copy of the list.
+_.list_copy = _.map(fun.identity)
+
+_.concat = fun.curryN(function(a, b)
+ if type(a) == "table" then
+ assert(type(b) == "table", "concat: expected table")
+ return vim.list_extend(_.list_copy(a), b)
+ elseif type(a) == "string" then
+ assert(type(b) == "string", "concat: expected string")
+ return a .. b
+ end
+end, 2)
+
+---@generic T
+---@param value T
+---@param list T[]
+---@return T[]
+_.append = fun.curryN(function(value, list)
+ local list_copy = _.list_copy(list)
+ list_copy[#list_copy + 1] = value
+ return list_copy
+end, 2)
+
+---@generic T
+---@param value T
+---@param list T[]
+---@return T[]
+_.prepend = fun.curryN(function(value, list)
+ local list_copy = _.list_copy(list)
+ table.insert(list_copy, 1, value)
+ return list_copy
+end, 2)
+
+---@generic T
+---@generic U
+---@param keys T[]
+---@param values U[]
+---@return table<T, U>
+_.zip_table = fun.curryN(function(keys, values)
+ local res = {}
+ for i, key in ipairs(keys) do
+ res[key] = values[i]
+ end
+ return res
+end, 2)
+
+---@generic T
+---@param offset number
+---@param value T[]|string
+---@return T|string|nil
+_.nth = fun.curryN(function(offset, value)
+ local index = offset < 0 and (#value + (offset + 1)) or offset
+ if type(value) == "string" then
+ return string.sub(value, index, index)
+ else
+ return value[index]
+ end
+end, 2)
+
+_.head = _.nth(1)
+
+---@param value string|any[]
+_.length = function(value)
+ return #value
+end
+
+---@generic T
+---@param comp fun(item: T): any
+---@param list T[]
+---@return T[]
+_.sort_by = fun.curryN(function(comp, list)
+ local copied_list = _.list_copy(list)
+ table.sort(copied_list, function(a, b)
+ return comp(a) < comp(b)
+ end)
+ return copied_list
+end, 2)
+
+---@param sep string
+---@param list any[]
+_.join = fun.curryN(function(sep, list)
+ return table.concat(list, sep)
+end, 2)
+
+return _
diff --git a/lua/mason-core/functional/logic.lua b/lua/mason-core/functional/logic.lua
new file mode 100644
index 00000000..0e0044d5
--- /dev/null
+++ b/lua/mason-core/functional/logic.lua
@@ -0,0 +1,63 @@
+local fun = require "mason-core.functional.function"
+
+local _ = {}
+
+---@generic T
+---@param predicates (fun(item: T): boolean)[]
+---@return fun(item: T): boolean
+_.all_pass = fun.curryN(function(predicates, item)
+ for i = 1, #predicates do
+ if not predicates[i](item) then
+ return false
+ end
+ end
+ return true
+end, 2)
+
+---@generic T
+---@param predicates (fun(item: T): boolean)[]
+---@return fun(item: T): boolean
+_.any_pass = fun.curryN(function(predicates, item)
+ for i = 1, #predicates do
+ if predicates[i](item) then
+ return true
+ end
+ end
+ return false
+end, 2)
+
+---@generic T
+---@param predicate fun(item: T): boolean
+---@param on_true fun(item: T): any
+---@param on_false fun(item: T): any
+---@param value T
+_.if_else = fun.curryN(function(predicate, on_true, on_false, value)
+ if predicate(value) then
+ return on_true(value)
+ else
+ return on_false(value)
+ end
+end, 4)
+
+---@param value boolean
+_.is_not = function(value)
+ return not value
+end
+
+---@generic T
+---@param predicate fun(value: T): boolean
+---@param value T
+_.complement = fun.curryN(function(predicate, value)
+ return not predicate(value)
+end, 2)
+
+_.cond = fun.curryN(function(predicate_transformer_pairs, value)
+ for _, pair in ipairs(predicate_transformer_pairs) do
+ local predicate, transformer = pair[1], pair[2]
+ if predicate(value) then
+ return transformer(value)
+ end
+ end
+end, 2)
+
+return _
diff --git a/lua/mason-core/functional/number.lua b/lua/mason-core/functional/number.lua
new file mode 100644
index 00000000..11e8f88a
--- /dev/null
+++ b/lua/mason-core/functional/number.lua
@@ -0,0 +1,34 @@
+local fun = require "mason-core.functional.function"
+
+local _ = {}
+
+---@param number number
+_.negate = function(number)
+ return -number
+end
+
+_.gt = fun.curryN(function(number, value)
+ return value > number
+end, 2)
+
+_.gte = fun.curryN(function(number, value)
+ return value >= number
+end, 2)
+
+_.lt = fun.curryN(function(number, value)
+ return value < number
+end, 2)
+
+_.lte = fun.curryN(function(number, value)
+ return value <= number
+end, 2)
+
+_.inc = fun.curryN(function(increment, value)
+ return value + increment
+end, 2)
+
+_.dec = fun.curryN(function(decrement, value)
+ return value - decrement
+end, 2)
+
+return _
diff --git a/lua/mason-core/functional/relation.lua b/lua/mason-core/functional/relation.lua
new file mode 100644
index 00000000..94913a13
--- /dev/null
+++ b/lua/mason-core/functional/relation.lua
@@ -0,0 +1,17 @@
+local fun = require "mason-core.functional.function"
+
+local _ = {}
+
+_.equals = fun.curryN(function(expected, value)
+ return value == expected
+end, 2)
+
+_.prop_eq = fun.curryN(function(property, value, tbl)
+ return tbl[property] == value
+end, 3)
+
+_.prop_satisfies = fun.curryN(function(predicate, property, tbl)
+ return predicate(tbl[property])
+end, 3)
+
+return _
diff --git a/lua/mason-core/functional/string.lua b/lua/mason-core/functional/string.lua
new file mode 100644
index 00000000..7726c8e1
--- /dev/null
+++ b/lua/mason-core/functional/string.lua
@@ -0,0 +1,74 @@
+local fun = require "mason-core.functional.function"
+
+local _ = {}
+
+---@param pattern string
+---@param str string
+_.matches = fun.curryN(function(pattern, str)
+ return str:match(pattern) ~= nil
+end, 2)
+
+---@param template string
+---@param str string
+_.format = fun.curryN(function(template, str)
+ return template:format(str)
+end, 2)
+
+---@param sep string
+---@param str string
+_.split = fun.curryN(function(sep, str)
+ return vim.split(str, sep)
+end, 2)
+
+---@param pattern string
+---@param repl string|function|table
+---@param str string
+_.gsub = fun.curryN(function(pattern, repl, str)
+ return string.gsub(str, pattern, repl)
+end, 3)
+
+_.trim = fun.curryN(function(str)
+ return vim.trim(str)
+end, 1)
+
+---https://github.com/nvim-lua/nvim-package-specification/blob/93475e47545b579fd20b6c5ce13c4163e7956046/lua/packspec/schema.lua#L8-L37
+---@param str string
+---@return string
+_.dedent = fun.curryN(function(str)
+ local lines = {}
+ local indent = nil
+
+ for line in str:gmatch "[^\n]*\n?" do
+ if indent == nil then
+ if not line:match "^%s*$" then
+ -- save pattern for indentation from the first non-empty line
+ indent, line = line:match "^(%s*)(.*)$"
+ indent = "^" .. indent .. "(.*)$"
+ table.insert(lines, line)
+ end
+ else
+ if line:match "^%s*$" then
+ -- replace empty lines with a single newline character.
+ -- empty lines are handled separately to allow the
+ -- closing "]]" to be one indentation level lower.
+ table.insert(lines, "\n")
+ else
+ -- strip indentation on non-empty lines
+ line = assert(line:match(indent), "inconsistent indentation")
+ table.insert(lines, line)
+ end
+ end
+ end
+
+ lines = table.concat(lines)
+ -- trim trailing whitespace
+ return lines:match "^(.-)%s*$"
+end, 1)
+
+---@param prefix string
+---@str string
+_.starts_with = fun.curryN(function(prefix, str)
+ return vim.startswith(str, prefix)
+end, 2)
+
+return _
diff --git a/lua/mason-core/functional/table.lua b/lua/mason-core/functional/table.lua
new file mode 100644
index 00000000..65d05cc8
--- /dev/null
+++ b/lua/mason-core/functional/table.lua
@@ -0,0 +1,45 @@
+local fun = require "mason-core.functional.function"
+
+local _ = {}
+
+---@param index any
+---@param tbl table
+_.prop = fun.curryN(function(index, tbl)
+ return tbl[index]
+end, 2)
+
+---@param keys any[]
+---@param tbl table
+_.pick = fun.curryN(function(keys, tbl)
+ local ret = {}
+ for _, key in ipairs(keys) do
+ ret[key] = tbl[key]
+ end
+ return ret
+end, 2)
+
+_.keys = fun.curryN(vim.tbl_keys, 1)
+_.size = fun.curryN(vim.tbl_count, 1)
+
+---@param tbl table<any, any>
+---@return any[][]
+_.to_pairs = fun.curryN(function(tbl)
+ local result = {}
+ for k, v in pairs(tbl) do
+ result[#result + 1] = { k, v }
+ end
+ return result
+end, 1)
+
+---@generic K, V
+---@param tbl table<K, V>
+---@return table<V, K>
+_.invert = fun.curryN(function(tbl)
+ local result = {}
+ for k, v in pairs(tbl) do
+ result[v] = k
+ end
+ return result
+end, 1)
+
+return _
diff --git a/lua/mason-core/functional/type.lua b/lua/mason-core/functional/type.lua
new file mode 100644
index 00000000..e3bf5fe7
--- /dev/null
+++ b/lua/mason-core/functional/type.lua
@@ -0,0 +1,14 @@
+local fun = require "mason-core.functional.function"
+local rel = require "mason-core.functional.relation"
+
+local _ = {}
+
+_.is_nil = rel.equals(nil)
+
+---@param typ type
+---@param value any
+_.is = fun.curryN(function(typ, value)
+ return type(value) == typ
+end, 2)
+
+return _
diff --git a/lua/mason-core/health/init.lua b/lua/mason-core/health/init.lua
new file mode 100644
index 00000000..39ceac81
--- /dev/null
+++ b/lua/mason-core/health/init.lua
@@ -0,0 +1,298 @@
+local health = vim.health or require "health"
+local a = require "mason-core.async"
+local platform = require "mason-core.platform"
+local github_client = require "mason-core.managers.github.client"
+local _ = require "mason-core.functional"
+local spawn = require "mason-core.spawn"
+
+local M = {}
+
+---@alias HealthCheckResult
+---| '"success"'
+---| '"version-mismatch"'
+---| '"parse-error"'
+---| '"not-available"'
+
+---@class HealthCheck
+---@field public result HealthCheckResult
+---@field public version string|nil
+---@field public relaxed boolean|nil
+---@field public reason string|nil
+---@field public name string
+local HealthCheck = {}
+HealthCheck.__index = HealthCheck
+
+function HealthCheck.new(props)
+ local self = setmetatable(props, HealthCheck)
+ return self
+end
+
+function HealthCheck:get_version()
+ if self.result == "success" and not self.version or self.version == "" then
+ -- Some checks (bourne shell for instance) don't produce any output, so we default to just "Ok"
+ return "Ok"
+ end
+ return self.version
+end
+
+function HealthCheck:get_health_report_level()
+ return ({
+ ["success"] = "report_ok",
+ ["parse-error"] = "report_warn",
+ ["version-mismatch"] = self.relaxed and "report_warn" or "report_error",
+ ["not-available"] = self.relaxed and "report_warn" or "report_error",
+ })[self.result]
+end
+
+function HealthCheck:__tostring()
+ if self.result == "success" then
+ return ("**%s**: `%s`"):format(self.name, self:get_version())
+ elseif self.result == "version-mismatch" then
+ return ("**%s**: unsupported version `%s`. %s"):format(self.name, self:get_version(), self.reason)
+ elseif self.result == "parse-error" then
+ return ("**%s**: failed to parse version"):format(self.name)
+ elseif self.result == "not-available" then
+ return ("**%s**: not available"):format(self.name)
+ end
+end
+
+---@param callback fun(result: HealthCheck)
+local function mk_healthcheck(callback)
+ ---@param opts {cmd:string, args:string[], name: string, use_stderr:boolean}
+ return function(opts)
+ local parse_version =
+ _.compose(_.head, _.split "\n", _.if_else(_.always(opts.use_stderr), _.prop "stderr", _.prop "stdout"))
+
+ ---@async
+ return function()
+ local healthcheck_result = spawn
+ [opts.cmd]({
+ opts.args,
+ on_spawn = function(_, stdio)
+ local stdin = stdio[1]
+ stdin:close() -- some processes (`sh` for example) will endlessly read from stdin, so we close it immediately
+ end,
+ })
+ :map(parse_version)
+ :map(function(version)
+ if opts.version_check then
+ local ok, version_check = pcall(opts.version_check, version)
+ if ok and version_check then
+ return HealthCheck.new {
+ result = "version-mismatch",
+ reason = version_check,
+ version = version,
+ name = opts.name,
+ relaxed = opts.relaxed,
+ }
+ elseif not ok then
+ return HealthCheck.new {
+ result = "parse-error",
+ version = "N/A",
+ name = opts.name,
+ relaxed = opts.relaxed,
+ }
+ end
+ end
+
+ return HealthCheck.new {
+ result = "success",
+ version = version,
+ name = opts.name,
+ relaxed = opts.relaxed,
+ }
+ end)
+ :get_or_else(HealthCheck.new {
+ result = "not-available",
+ version = nil,
+ name = opts.name,
+ relaxed = opts.relaxed,
+ })
+
+ callback(healthcheck_result)
+ end
+ end
+end
+
+function M.check()
+ health.report_start "mason.nvim report"
+ if vim.fn.has "nvim-0.7.0" == 1 then
+ health.report_ok "neovim version >= 0.7.0"
+ else
+ health.report_error "neovim version < 0.7.0"
+ end
+
+ local completed = 0
+
+ local check = mk_healthcheck(vim.schedule_wrap(
+ ---@param healthcheck HealthCheck
+ function(healthcheck)
+ completed = completed + 1
+ health[healthcheck:get_health_report_level()](tostring(healthcheck))
+ end
+ ))
+
+ local checks = {
+ check {
+ cmd = "go",
+ args = { "version" },
+ name = "Go",
+ relaxed = true,
+ version_check = function(version)
+ -- Parses output such as "go version go1.17.3 darwin/arm64" into major, minor, patch components
+ local _, _, major, minor = version:find "go(%d+)%.(%d+)"
+ -- Due to https://go.dev/doc/go-get-install-deprecation
+ if not (tonumber(major) >= 1 and tonumber(minor) >= 17) then
+ return "Go version must be >= 1.17."
+ end
+ end,
+ },
+ check {
+ cmd = "cargo",
+ args = { "--version" },
+ name = "cargo",
+ relaxed = true,
+ version_check = function(version)
+ local _, _, major, minor = version:find "(%d+)%.(%d+)%.(%d+)"
+ if (tonumber(major) <= 1) and (tonumber(minor) < 60) then
+ return "Some cargo installations require Rust >= 1.60.0."
+ end
+ end,
+ },
+ check {
+ cmd = "luarocks",
+ args = { "--version" },
+ name = "luarocks",
+ relaxed = true,
+ version_check = function(version)
+ local _, _, major = version:find "(%d+)%.(%d)%.(%d)"
+ if not (tonumber(major) >= 3) then
+ -- Because of usage of "--dev" flag
+ return "Luarocks version must be >= 3.0.0."
+ end
+ end,
+ },
+ check { cmd = "ruby", args = { "--version" }, name = "Ruby", relaxed = true },
+ check { cmd = "gem", args = { "--version" }, name = "RubyGem", relaxed = true },
+ check { cmd = "composer", args = { "--version" }, name = "Composer", relaxed = true },
+ check { cmd = "php", args = { "--version" }, name = "PHP", relaxed = true },
+ check {
+ cmd = "npm",
+ args = { "--version" },
+ name = "npm",
+ version_check = function(version)
+ -- Parses output such as "8.1.2" into major, minor, patch components
+ local _, _, major = version:find "(%d+)%.(%d+)%.(%d+)"
+ -- Based off of general observations of feature parity
+ if tonumber(major) < 6 then
+ return "npm version must be >= 6"
+ end
+ end,
+ },
+ check {
+ cmd = "node",
+ args = { "--version" },
+ name = "node",
+ version_check = function(version)
+ -- Parses output such as "v16.3.1" into major, minor, patch
+ local _, _, major = version:find "v(%d+)%.(%d+)%.(%d+)"
+ if tonumber(major) < 14 then
+ return "Node version must be >= 14"
+ end
+ end,
+ },
+ check { cmd = "python3", args = { "--version" }, name = "python3", relaxed = true },
+ check { cmd = "python3", args = { "-m", "pip", "--version" }, name = "pip3", relaxed = true },
+ check { cmd = "javac", args = { "-version" }, name = "javac", relaxed = true },
+ check { cmd = "java", args = { "-version" }, name = "java", use_stderr = true, relaxed = true },
+ check { cmd = "julia", args = { "--version" }, name = "julia", relaxed = true },
+ check { cmd = "wget", args = { "--version" }, name = "wget" },
+ -- wget is used interchangeably with curl, but with higher priority, so we mark curl as relaxed
+ check { cmd = "curl", args = { "--version" }, name = "curl", relaxed = true },
+ check {
+ cmd = "gzip",
+ args = { "--version" },
+ name = "gzip",
+ use_stderr = platform.is_mac, -- Apple gzip prints version string to stderr
+ },
+ check { cmd = "tar", args = { "--version" }, name = "tar" },
+ -- when(platform.is_win, check { cmd = "powershell.exe", args = { "-Version" }, name = "PowerShell" }), -- TODO fix me
+ -- when(platform.is_win, check { cmd = "cmd.exe", args = { "-Version" }, name = "cmd" }) -- TODO fix me
+ }
+
+ if platform.is.unix then
+ table.insert(checks, check { cmd = "bash", args = { "--version" }, name = "bash" })
+ table.insert(checks, check { cmd = "sh", name = "sh" })
+ end
+
+ if platform.is.win then
+ table.insert(
+ checks,
+ check { cmd = "python", use_stderr = true, args = { "--version" }, name = "python", relaxed = true }
+ )
+ table.insert(
+ checks,
+ check { cmd = "python", args = { "-m", "pip", "--version" }, name = "pip", relaxed = true }
+ )
+ end
+
+ if vim.g.python3_host_prog then
+ table.insert(
+ checks,
+ check { cmd = vim.g.python3_host_prog, args = { "--version" }, name = "python3_host_prog", relaxed = true }
+ )
+ end
+
+ if vim.env.JAVA_HOME then
+ table.insert(
+ checks,
+ check {
+ cmd = ("%s/bin/java"):format(vim.env.JAVA_HOME),
+ args = { "-version" },
+ name = "JAVA_HOME",
+ use_stderr = true,
+ relaxed = true,
+ }
+ )
+ end
+
+ a.run_blocking(function()
+ for _, c in ipairs(checks) do
+ c()
+ end
+
+ github_client
+ .fetch_rate_limit()
+ :map(
+ ---@param rate_limit GitHubRateLimitResponse
+ function(rate_limit)
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ local remaining = rate_limit.resources.core.remaining
+ local used = rate_limit.resources.core.used
+ local limit = rate_limit.resources.core.limit
+ local reset = rate_limit.resources.core.reset
+ local diagnostics = ("Used: %d. Remaining: %d. Limit: %d. Reset: %s."):format(
+ used,
+ remaining,
+ limit,
+ vim.fn.strftime("%c", reset)
+ )
+ if remaining <= 0 then
+ health.report_error(("GitHub API rate limit exceeded. %s"):format(diagnostics))
+ else
+ health.report_ok(("GitHub API rate limit. %s"):format(diagnostics))
+ end
+ end
+ )
+ :on_failure(function()
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ health.report_warn "Failed to check GitHub API rate limit status."
+ end)
+ end)
+end
+
+return M
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
diff --git a/lua/mason-core/log.lua b/lua/mason-core/log.lua
new file mode 100644
index 00000000..a2593a56
--- /dev/null
+++ b/lua/mason-core/log.lua
@@ -0,0 +1,174 @@
+local _ = require "mason-core.functional"
+local path = require "mason-core.path"
+local settings = require "mason.settings"
+local platform = require "mason-core.platform"
+
+local config = {
+ -- Name of the plugin. Prepended to log messages
+ name = "mason",
+
+ -- Should print the output to neovim while running
+ -- values: 'sync','async',false
+ use_console = platform.is_headless,
+
+ -- Should highlighting be used in console (using echohl)
+ highlights = true,
+
+ -- Should write to a file
+ use_file = true,
+
+ -- Level configuration
+ modes = {
+ { name = "trace", hl = "Comment", level = vim.log.levels.TRACE },
+ { name = "debug", hl = "Comment", level = vim.log.levels.DEBUG },
+ { name = "info", hl = "None", level = vim.log.levels.INFO },
+ { name = "warn", hl = "WarningMsg", level = vim.log.levels.WARN },
+ { name = "error", hl = "ErrorMsg", level = vim.log.levels.ERROR },
+ },
+
+ -- Can limit the number of decimals displayed for floats
+ float_precision = 0.01,
+}
+
+local log = {
+ outfile = path.concat {
+ vim.fn.stdpath "cache", -- TODO use "log" when stable
+ ("%s.log"):format(config.name),
+ },
+}
+
+local unpack = unpack or table.unpack
+
+do
+ local round = function(x, increment)
+ increment = increment or 1
+ x = x / increment
+ return (x > 0 and math.floor(x + 0.5) or math.ceil(x - 0.5)) * increment
+ end
+
+ local tbl_has_tostring = function(tbl)
+ local mt = getmetatable(tbl)
+ return mt and mt.__tostring ~= nil
+ end
+
+ local make_string = function(...)
+ local t = {}
+ for i = 1, select("#", ...) do
+ local x = select(i, ...)
+
+ if type(x) == "number" and config.float_precision then
+ x = tostring(round(x, config.float_precision))
+ elseif type(x) == "table" and not tbl_has_tostring(x) then
+ x = vim.inspect(x)
+ else
+ x = tostring(x)
+ end
+
+ t[#t + 1] = x
+ end
+ return table.concat(t, " ")
+ end
+
+ local log_at_level = function(level_config, message_maker, ...)
+ -- Return early if we're below the current_log_level
+ if level_config.level < settings.current.log_level then
+ return
+ end
+ local nameupper = level_config.name:upper()
+
+ local msg = message_maker(...)
+ local info = debug.getinfo(config.info_level or 2, "Sl")
+ local lineinfo = info.short_src .. ":" .. info.currentline
+
+ -- Output to console
+ if config.use_console then
+ local log_to_console = function()
+ local console_string = string.format("[%-6s%s] %s: %s", nameupper, os.date "%H:%M:%S", lineinfo, msg)
+
+ if config.highlights and level_config.hl then
+ vim.cmd(string.format("echohl %s", level_config.hl))
+ end
+
+ local split_console = vim.split(console_string, "\n")
+ for _, v in ipairs(split_console) do
+ local formatted_msg = string.format("[%s] %s", config.name, vim.fn.escape(v, [["\]]))
+
+ local ok = pcall(vim.cmd, string.format([[echom "%s"]], formatted_msg))
+ if not ok then
+ vim.api.nvim_out_write(msg .. "\n")
+ end
+ end
+
+ if config.highlights and level_config.hl then
+ vim.cmd "echohl NONE"
+ end
+ end
+ if config.use_console == "sync" and not vim.in_fast_event() then
+ log_to_console()
+ else
+ vim.schedule(log_to_console)
+ end
+ end
+
+ -- Output to log file
+ if config.use_file then
+ local fp = assert(io.open(log.outfile, "a"))
+ local str = string.format("[%-6s%s] %s: %s\n", nameupper, os.date(), lineinfo, msg)
+ fp:write(str)
+ fp:close()
+ end
+ end
+
+ for __, x in ipairs(config.modes) do
+ -- log.info("these", "are", "separated")
+ log[x.name] = function(...)
+ return log_at_level(x, make_string, ...)
+ end
+
+ -- log.fmt_info("These are %s strings", "formatted")
+ log[("fmt_%s"):format(x.name)] = function(...)
+ return log_at_level(x, function(...)
+ local passed = { ... }
+ local fmt = table.remove(passed, 1)
+ local inspected = {}
+ for _, v in ipairs(passed) do
+ if type(v) == "table" and tbl_has_tostring(v) then
+ table.insert(inspected, v)
+ else
+ table.insert(inspected, vim.inspect(v))
+ end
+ end
+ return string.format(fmt, unpack(inspected))
+ end, ...)
+ end
+
+ -- log.lazy_info(expensive_to_calculate)
+ log[("lazy_%s"):format(x.name)] = function(f)
+ return log_at_level(x, function()
+ local passed = _.table_pack(f())
+ local fmt = table.remove(passed, 1)
+ local inspected = {}
+ for _, v in ipairs(passed) do
+ if type(v) == "table" and tbl_has_tostring(v) then
+ table.insert(inspected, v)
+ else
+ table.insert(inspected, vim.inspect(v))
+ end
+ end
+ return string.format(fmt, unpack(inspected))
+ end)
+ end
+
+ -- log.file_info("do not print")
+ log[("file_%s"):format(x.name)] = function(vals, override)
+ local original_console = config.use_console
+ config.use_console = false
+ config.info_level = override.info_level
+ log_at_level(x, make_string, unpack(vals))
+ config.use_console = original_console
+ config.info_level = nil
+ end
+ end
+end
+
+return log
diff --git a/lua/mason-core/managers/cargo/client.lua b/lua/mason-core/managers/cargo/client.lua
new file mode 100644
index 00000000..3df7550b
--- /dev/null
+++ b/lua/mason-core/managers/cargo/client.lua
@@ -0,0 +1,14 @@
+local fetch = require "mason-core.fetch"
+
+local M = {}
+
+---@alias CrateResponse {crate: {id: string, max_stable_version: string, max_version: string, newest_version: string}}
+
+---@async
+---@param crate string
+---@return Result @of Crate
+function M.fetch_crate(crate)
+ return fetch(("https://crates.io/api/v1/crates/%s"):format(crate)):map_catching(vim.json.decode)
+end
+
+return M
diff --git a/lua/mason-core/managers/cargo/init.lua b/lua/mason-core/managers/cargo/init.lua
new file mode 100644
index 00000000..5b87667c
--- /dev/null
+++ b/lua/mason-core/managers/cargo/init.lua
@@ -0,0 +1,140 @@
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local spawn = require "mason-core.spawn"
+local a = require "mason-core.async"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local client = require "mason-core.managers.cargo.client"
+local _ = require "mason-core.functional"
+
+local get_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity))
+
+---@param crate string
+local function with_receipt(crate)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.cargo(crate))
+ end
+end
+
+local M = {}
+
+---@async
+---@param crate string The crate to install.
+---@param opts {git: boolean | string, features: string|nil, bin: string[] | nil } | nil
+function M.crate(crate, opts)
+ return function()
+ M.install(crate, opts).with_receipt()
+ end
+end
+
+---@async
+---@param crate string The crate to install.
+---@param opts {git: boolean | string, features: string|nil, bin: string[] | nil } | nil
+function M.install(crate, opts)
+ local ctx = installer.context()
+ opts = opts or {}
+ ctx.requested_version:if_present(function()
+ assert(not opts.git, "Providing a version when installing a git crate is not allowed.")
+ end)
+
+ local final_crate = crate
+
+ if opts.git then
+ final_crate = { "--git" }
+ if type(opts.git) == "string" then
+ table.insert(final_crate, opts.git)
+ end
+ table.insert(final_crate, crate)
+ end
+
+ ctx.spawn.cargo {
+ "install",
+ "--root",
+ ".",
+ "--locked",
+ ctx.requested_version
+ :map(function(version)
+ return { "--version", version }
+ end)
+ :or_else(vim.NIL),
+ opts.features and { "--features", opts.features } or vim.NIL,
+ final_crate,
+ }
+
+ if opts.bin then
+ _.each(function(bin)
+ ctx:link_bin(bin, get_bin_path(bin))
+ end, opts.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(crate),
+ }
+end
+
+---@param output string @The `cargo install --list` output.
+---@return table<string, string> @Key is the crate name, value is its version.
+function M.parse_installed_crates(output)
+ local installed_crates = {}
+ for _, line in ipairs(vim.split(output, "\n")) do
+ local name, version = line:match "^(.+)%s+v([.%S]+)[%s:]"
+ if name and version then
+ installed_crates[name] = version
+ end
+ end
+ return installed_crates
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ return M.get_installed_primary_package_version(receipt, install_dir):map_catching(function(installed_version)
+ ---@type CrateResponse
+ local crate_response = client.fetch_crate(receipt.primary_source.package):get_or_throw()
+ if installed_version ~= crate_response.crate.max_stable_version then
+ return {
+ name = receipt.primary_source.package,
+ current_version = installed_version,
+ latest_version = crate_response.crate.max_stable_version,
+ }
+ else
+ error "Primary package is not outdated."
+ end
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ return spawn
+ .cargo({
+ "install",
+ "--list",
+ "--root",
+ ".",
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local installed_crates = M.parse_installed_crates(result.stdout)
+ if vim.in_fast_event() then
+ a.scheduler() -- needed because vim.fn.* call
+ end
+ local pkg = vim.fn.fnamemodify(receipt.primary_source.package, ":t")
+ return Optional.of_nilable(installed_crates[pkg]):or_else_throw "Failed to find cargo package version."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/composer/init.lua b/lua/mason-core/managers/composer/init.lua
new file mode 100644
index 00000000..96ab5f14
--- /dev/null
+++ b/lua/mason-core/managers/composer/init.lua
@@ -0,0 +1,135 @@
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "vendor", "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.bat", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+
+ ctx.receipt:with_primary_source(ctx.receipt.composer(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.composer(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The composer packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.require(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The composer packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.require(packages)
+ local ctx = installer.context()
+ local pkgs = _.list_copy(packages)
+
+ if not ctx.fs:file_exists "composer.json" then
+ ctx.spawn.composer { "init", "--no-interaction", "--stability=stable" }
+ end
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s:%s"):format(pkgs[1], version)
+ end)
+
+ ctx.spawn.composer { "require", pkgs }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@async
+function M.install()
+ local ctx = installer.context()
+ ctx.spawn.composer {
+ "install",
+ "--no-interaction",
+ "--no-dev",
+ "--optimize-autoloader",
+ "--classmap-authoritative",
+ }
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "composer" then
+ return Result.failure "Receipt does not have a primary source of type composer"
+ end
+ return spawn
+ .composer({
+ "outdated",
+ "--no-interaction",
+ "--format=json",
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local outdated_packages = vim.json.decode(result.stdout)
+ local outdated_package = _.find_first(function(pkg)
+ return pkg.name == receipt.primary_source.package
+ end, outdated_packages.installed)
+ return Optional.of_nilable(outdated_package)
+ :map(function(pkg)
+ if pkg.version ~= pkg.latest then
+ return {
+ name = pkg.name,
+ current_version = pkg.version,
+ latest_version = pkg.latest,
+ }
+ end
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "composer" then
+ return Result.failure "Receipt does not have a primary source of type composer"
+ end
+ return spawn
+ .composer({
+ "info",
+ "--format=json",
+ receipt.primary_source.package,
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local info = vim.json.decode(result.stdout)
+ return info.versions[1]
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "vendor", "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/dotnet/init.lua b/lua/mason-core/managers/dotnet/init.lua
new file mode 100644
index 00000000..f89d61ca
--- /dev/null
+++ b/lua/mason-core/managers/dotnet/init.lua
@@ -0,0 +1,64 @@
+local process = require "mason-core.process"
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity)
+
+---@param package string
+local function with_receipt(package)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.dotnet(package))
+ end
+end
+
+---@async
+---@param pkg string
+---@param opt { bin: string[] | nil } | nil
+function M.package(pkg, opt)
+ return function()
+ return M.install(pkg, opt).with_receipt()
+ end
+end
+
+---@async
+---@param pkg string
+---@param opt { bin: string[] | nil } | nil
+function M.install(pkg, opt)
+ local ctx = installer.context()
+ ctx.spawn.dotnet {
+ "tool",
+ "update",
+ "--tool-path",
+ ".",
+ ctx.requested_version
+ :map(function(version)
+ return { "--version", version }
+ end)
+ :or_else(vim.NIL),
+ pkg,
+ }
+
+ if opt and opt.bin then
+ if opt.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, opt.bin)
+ end
+ end
+
+ return {
+ with_receipt = with_receipt(pkg),
+ }
+end
+
+function M.env(root_dir)
+ return {
+ PATH = process.extend_path { root_dir },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/gem/init.lua b/lua/mason-core/managers/gem/init.lua
new file mode 100644
index 00000000..11019985
--- /dev/null
+++ b/lua/mason-core/managers/gem/init.lua
@@ -0,0 +1,159 @@
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.cmd", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.gem(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.gem(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The Gem packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The Gem packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = _.list_copy(packages or {})
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s:%s"):format(pkgs[1], version)
+ end)
+
+ ctx.spawn.gem {
+ "install",
+ "--no-user-install",
+ "--install-dir=.",
+ "--bindir=bin",
+ "--no-document",
+ pkgs,
+ }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@alias GemOutdatedPackage {name:string, current_version: string, latest_version: string}
+
+---Parses a string input like "package (0.1.0 < 0.2.0)" into its components
+---@param outdated_gem string
+---@return GemOutdatedPackage
+function M.parse_outdated_gem(outdated_gem)
+ local package_name, version_expression = outdated_gem:match "^(.+) %((.+)%)"
+ if not package_name or not version_expression then
+ -- unparseable
+ return nil
+ end
+ local current_version, latest_version = unpack(vim.split(version_expression, "<"))
+
+ ---@type GemOutdatedPackage
+ local outdated_package = {
+ name = vim.trim(package_name),
+ current_version = vim.trim(current_version),
+ latest_version = vim.trim(latest_version),
+ }
+ return outdated_package
+end
+
+---Parses the stdout of the `gem list` command into a table<package_name, version>
+---@param output string
+function M.parse_gem_list_output(output)
+ ---@type table<string, string>
+ local gem_versions = {}
+ for _, line in ipairs(vim.split(output, "\n")) do
+ local gem_package, version = line:match "^(%S+) %((%S+)%)$"
+ if gem_package and version then
+ gem_versions[gem_package] = version
+ end
+ end
+ return gem_versions
+end
+
+local function not_empty(s)
+ return s ~= nil and s ~= ""
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "gem" then
+ return Result.failure "Receipt does not have a primary source of type gem"
+ end
+ return spawn.gem({ "outdated", cwd = install_dir, env = M.env(install_dir) }):map_catching(function(result)
+ ---@type string[]
+ local lines = vim.split(result.stdout, "\n")
+ local outdated_gems = vim.tbl_map(M.parse_outdated_gem, vim.tbl_filter(not_empty, lines))
+
+ local outdated_gem = _.find_first(function(gem)
+ return gem.name == receipt.primary_source.package and gem.current_version ~= gem.latest_version
+ end, outdated_gems)
+
+ return Optional.of_nilable(outdated_gem)
+ :map(function(gem)
+ return {
+ name = receipt.primary_source.package,
+ current_version = assert(gem.current_version),
+ latest_version = assert(gem.latest_version),
+ }
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ return spawn
+ .gem({
+ "list",
+ cwd = install_dir,
+ env = M.env(install_dir),
+ })
+ :map_catching(function(result)
+ local gems = M.parse_gem_list_output(result.stdout)
+ return Optional.of_nilable(gems[receipt.primary_source.package])
+ :or_else_throw "Failed to find gem package version."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ GEM_HOME = install_dir,
+ GEM_PATH = install_dir,
+ PATH = process.extend_path { path.concat { install_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/git/init.lua b/lua/mason-core/managers/git/init.lua
new file mode 100644
index 00000000..432d18f4
--- /dev/null
+++ b/lua/mason-core/managers/git/init.lua
@@ -0,0 +1,76 @@
+local spawn = require "mason-core.spawn"
+local Result = require "mason-core.result"
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+
+local M = {}
+
+---@param repo string
+local function with_receipt(repo)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.git_remote(repo))
+ end
+end
+
+---@async
+---@param opts {[1]: string, recursive: boolean, version: Optional|nil} @The first item in the table is the repository to clone.
+function M.clone(opts)
+ local ctx = installer.context()
+ local repo = assert(opts[1], "No git URL provided.")
+ ctx.spawn.git {
+ "clone",
+ "--depth",
+ "1",
+ opts.recursive and "--recursive" or vim.NIL,
+ repo,
+ ".",
+ }
+ _.coalesce(opts.version, ctx.requested_version):if_present(function(version)
+ ctx.spawn.git { "fetch", "--depth", "1", "origin", version }
+ ctx.spawn.git { "checkout", "FETCH_HEAD" }
+ end)
+
+ return {
+ with_receipt = with_receipt(repo),
+ }
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_git_clone(receipt, install_dir)
+ if receipt.primary_source.type ~= "git" then
+ return Result.failure "Receipt does not have a primary source of type git"
+ end
+ return spawn.git({ "fetch", "origin", "HEAD", cwd = install_dir }):map_catching(function()
+ local result = spawn.git({ "rev-parse", "FETCH_HEAD", "HEAD", cwd = install_dir }):get_or_throw()
+ local remote_head, local_head = unpack(vim.split(result.stdout, "\n"))
+ if remote_head == local_head then
+ error("Git clone is up to date.", 2)
+ end
+ return {
+ name = receipt.primary_source.remote,
+ current_version = assert(local_head),
+ latest_version = assert(remote_head),
+ }
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_revision(receipt, install_dir)
+ return spawn
+ .git({
+ "rev-parse",
+ "--short",
+ "HEAD",
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ return assert(vim.trim(result.stdout))
+ end)
+end
+
+return M
diff --git a/lua/mason-core/managers/github/client.lua b/lua/mason-core/managers/github/client.lua
new file mode 100644
index 00000000..1bcede7a
--- /dev/null
+++ b/lua/mason-core/managers/github/client.lua
@@ -0,0 +1,117 @@
+local _ = require "mason-core.functional"
+local log = require "mason-core.log"
+local fetch = require "mason-core.fetch"
+local spawn = require "mason-core.spawn"
+
+local M = {}
+
+---@alias GitHubReleaseAsset {url: string, id: integer, name: string, browser_download_url: string, created_at: string, updated_at: string, size: integer, download_count: integer}
+---@alias GitHubRelease {tag_name: string, prerelease: boolean, draft: boolean, assets:GitHubReleaseAsset[]}
+---@alias GitHubTag {name: string}
+
+---@param path string
+---@return Result @JSON decoded response.
+local function api_call(path)
+ return spawn
+ .gh({ "api", path })
+ :map(_.prop "stdout")
+ :recover_catching(function()
+ return fetch(("https://api.github.com/%s"):format(path)):get_or_throw()
+ end)
+ :map_catching(vim.json.decode)
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@return Result @of GitHubRelease[]
+function M.fetch_releases(repo)
+ log.fmt_trace("Fetching GitHub releases for repo=%s", repo)
+ local path = ("repos/%s/releases"):format(repo)
+ return api_call(path):map_err(function()
+ return ("Failed to fetch releases for GitHub repository %s."):format(repo)
+ end)
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@param tag_name string @The tag_name of the release to fetch.
+function M.fetch_release(repo, tag_name)
+ log.fmt_trace("Fetching GitHub release for repo=%s, tag_name=%s", repo, tag_name)
+ local path = ("repos/%s/releases/tags/%s"):format(repo, tag_name)
+ return api_call(path):map_err(function()
+ return ("Failed to fetch release %q for GitHub repository %s."):format(tag_name, repo)
+ end)
+end
+
+---@param opts {include_prerelease: boolean, tag_name_pattern: string}
+function M.release_predicate(opts)
+ local is_not_draft = _.prop_eq("draft", false)
+ local is_not_prerelease = _.prop_eq("prerelease", false)
+ local tag_name_matches = _.prop_satisfies(_.matches(opts.tag_name_pattern), "tag_name")
+
+ return _.all_pass {
+ _.if_else(_.always(opts.include_prerelease), _.T, is_not_prerelease),
+ _.if_else(_.always(opts.tag_name_pattern), tag_name_matches, _.T),
+ is_not_draft,
+ }
+end
+
+---@alias FetchLatestGithubReleaseOpts {tag_name_pattern:string|nil, include_prerelease: boolean}
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@param opts FetchLatestGithubReleaseOpts|nil
+---@return Result @of GitHubRelease
+function M.fetch_latest_release(repo, opts)
+ opts = opts or {
+ tag_name_pattern = nil,
+ include_prerelease = false,
+ }
+ return M.fetch_releases(repo):map_catching(
+ ---@param releases GitHubRelease[]
+ function(releases)
+ local is_stable_release = M.release_predicate(opts)
+ ---@type GitHubRelease|nil
+ local latest_release = _.find_first(is_stable_release, releases)
+
+ if not latest_release then
+ log.fmt_info("Failed to find latest release. repo=%s, opts=%s", repo, opts)
+ error "Failed to find latest release."
+ end
+
+ log.fmt_debug("Resolved latest version repo=%s, tag_name=%s", repo, latest_release.tag_name)
+ return latest_release
+ end
+ )
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@return Result @of GitHubTag[]
+function M.fetch_tags(repo)
+ local path = ("repos/%s/tags"):format(repo)
+ return api_call(path):map_err(function()
+ return ("Failed to fetch tags for GitHub repository %s."):format(repo)
+ end)
+end
+
+---@async
+---@param repo string @The GitHub repo ("username/repo").
+---@return Result @Result<string> - The latest tag name.
+function M.fetch_latest_tag(repo)
+ -- https://github.com/williamboman/vercel-github-api-latest-tag-proxy
+ return fetch(("https://latest-github-tag.redwill.se/api/latest-tag?repo=%s"):format(repo))
+ :map_catching(vim.json.decode)
+ :map(_.prop "tag")
+end
+
+---@alias GitHubRateLimit {limit: integer, remaining: integer, reset: integer, used: integer}
+---@alias GitHubRateLimitResponse {resources: { core: GitHubRateLimit }}
+
+---@async
+--@return Result @of GitHubRateLimitResponse
+function M.fetch_rate_limit()
+ return api_call "rate_limit"
+end
+
+return M
diff --git a/lua/mason-core/managers/github/init.lua b/lua/mason-core/managers/github/init.lua
new file mode 100644
index 00000000..55f3600f
--- /dev/null
+++ b/lua/mason-core/managers/github/init.lua
@@ -0,0 +1,171 @@
+local installer = require "mason-core.installer"
+local std = require "mason-core.managers.std"
+local client = require "mason-core.managers.github.client"
+local platform = require "mason-core.platform"
+local Result = require "mason-core.result"
+local _ = require "mason-core.functional"
+local settings = require "mason.settings"
+
+local M = {}
+
+---@param repo string
+---@param asset_file string
+---@param release string
+local function with_release_file_receipt(repo, asset_file, release)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source {
+ type = "github_release_file",
+ repo = repo,
+ file = asset_file,
+ release = release,
+ }
+ end
+end
+
+---@param repo string
+---@param tag string
+local function with_tag_receipt(repo, tag)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source {
+ type = "github_tag",
+ repo = repo,
+ tag = tag,
+ }
+ end
+end
+
+---@async
+---@param opts {repo: string, version: Optional|nil, asset_file: string|fun(release: string):string}
+function M.release_file(opts)
+ local ctx = installer.context()
+ local release = _.coalesce(opts.version, ctx.requested_version):or_else_get(function()
+ return client
+ .fetch_latest_release(opts.repo)
+ :map(_.prop "tag_name")
+ :get_or_throw "Failed to fetch latest release from GitHub API. Refer to :h mason-errors-github-api for more information."
+ end)
+ ---@type string
+ local asset_file
+ if type(opts.asset_file) == "function" then
+ asset_file = opts.asset_file(release)
+ else
+ asset_file = opts.asset_file
+ end
+ if not asset_file then
+ error(
+ (
+ "Could not find which release file to download.\nMost likely the current operating system, architecture (%s), or libc (%s) is not supported."
+ ):format(platform.arch, platform.get_libc()),
+ 0
+ )
+ end
+ local download_url = settings.current.github.download_url_template:format(opts.repo, release, asset_file)
+ return {
+ release = release,
+ download_url = download_url,
+ asset_file = asset_file,
+ with_receipt = with_release_file_receipt(opts.repo, download_url, release),
+ }
+end
+
+---@async
+---@param opts {repo: string, version: Optional|nil}
+function M.tag(opts)
+ local ctx = installer.context()
+ local tag = _.coalesce(opts.version, ctx.requested_version):or_else_get(function()
+ return client.fetch_latest_tag(opts.repo):get_or_throw "Failed to fetch latest tag from GitHub API."
+ end)
+
+ return {
+ tag = tag,
+ with_receipt = with_tag_receipt(opts.repo, tag),
+ }
+end
+
+---@param filename string
+---@param processor async fun(opts: table)
+local function release_file_processor(filename, processor)
+ ---@async
+ ---@param opts {repo: string, asset_file: string|fun(release: string):string}
+ return function(opts)
+ local release_file_source = M.release_file(opts)
+ std.download_file(release_file_source.download_url, filename)
+ processor(opts)
+ return release_file_source
+ end
+end
+
+M.unzip_release_file = release_file_processor("archive.zip", function()
+ std.unzip("archive.zip", ".")
+end)
+
+M.untarxz_release_file = release_file_processor("archive.tar.xz", function(opts)
+ std.untarxz("archive.tar.xz", { strip_components = opts.strip_components })
+end)
+
+M.untargz_release_file = release_file_processor("archive.tar.gz", function(opts)
+ std.untar("archive.tar.gz", { strip_components = opts.strip_components })
+end)
+
+---@async
+---@param opts {repo: string, out_file:string, asset_file: string|fun(release: string):string}
+function M.download_release_file(opts)
+ local release_file_source = M.release_file(opts)
+ std.download_file(release_file_source.download_url, assert(opts.out_file, "out_file is required"))
+ return release_file_source
+end
+
+---@async
+---@param opts {repo: string, out_file:string, asset_file: string|fun(release: string):string}
+function M.gunzip_release_file(opts)
+ local release_file_source = M.release_file(opts)
+ local gzipped_file = ("%s.gz"):format(assert(opts.out_file, "out_file must be specified"))
+ std.download_file(release_file_source.download_url, gzipped_file)
+ std.gunzip(gzipped_file)
+ return release_file_source
+end
+
+---@async
+---@param receipt InstallReceipt
+function M.check_outdated_primary_package_release(receipt)
+ local source = receipt.primary_source
+ if source.type ~= "github_release" and source.type ~= "github_release_file" then
+ return Result.failure "Receipt does not have a primary source of type (github_release|github_release_file)."
+ end
+ return client.fetch_latest_release(source.repo, { tag_name_pattern = source.tag_name_pattern }):map_catching(
+ ---@param latest_release GitHubRelease
+ function(latest_release)
+ if source.release ~= latest_release.tag_name then
+ return {
+ name = source.repo,
+ current_version = source.release,
+ latest_version = latest_release.tag_name,
+ }
+ end
+ error "Primary package is not outdated."
+ end
+ )
+end
+
+---@async
+---@param receipt InstallReceipt
+function M.check_outdated_primary_package_tag(receipt)
+ local source = receipt.primary_source
+ if source.type ~= "github_tag" then
+ return Result.failure "Receipt does not have a primary source of type github_tag."
+ end
+ return client.fetch_latest_tag(source.repo):map_catching(function(latest_tag)
+ if source.tag ~= latest_tag then
+ return {
+ name = source.repo,
+ current_version = source.tag,
+ latest_version = latest_tag,
+ }
+ end
+ error "Primary package is not outdated."
+ end)
+end
+
+return M
diff --git a/lua/mason-core/managers/go/init.lua b/lua/mason-core/managers/go/init.lua
new file mode 100644
index 00000000..dbdfdc45
--- /dev/null
+++ b/lua/mason-core/managers/go/init.lua
@@ -0,0 +1,171 @@
+local installer = require "mason-core.installer"
+local process = require "mason-core.process"
+local platform = require "mason-core.platform"
+local spawn = require "mason-core.spawn"
+local a = require "mason-core.async"
+local Optional = require "mason-core.optional"
+local _ = require "mason-core.functional"
+
+local M = {}
+
+local create_bin_path = _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity)
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.go(packages[1]))
+ -- Install secondary packages
+ for i = 2, #packages do
+ local pkg = packages[i]
+ ctx.receipt:with_secondary_source(ctx.receipt.go(pkg))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The go packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The go packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local env = {
+ GOBIN = ctx.cwd:get(),
+ }
+ -- Install the head package
+ do
+ local head_package = packages[1]
+ local version = ctx.requested_version:or_else "latest"
+ ctx.spawn.go {
+ "install",
+ "-v",
+ ("%s@%s"):format(head_package, version),
+ env = env,
+ }
+ end
+
+ -- Install secondary packages
+ for i = 2, #packages do
+ ctx.spawn.go { "install", "-v", ("%s@latest"):format(packages[i]), env = env }
+ end
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@param output string @The output from `go version -m` command.
+function M.parse_mod_version_output(output)
+ ---@type {path: string[], mod: string[], dep: string[], build: string[]}
+ local result = {}
+ local lines = vim.split(output, "\n")
+ for _, line in ipairs { unpack(lines, 2) } do
+ local type, id, value = unpack(vim.split(line, "%s+", { trimempty = true }))
+ if type and id then
+ result[type] = result[type] or {}
+ result[type][id] = value or ""
+ end
+ end
+ return result
+end
+
+local trim_wildcard_suffix = _.gsub("/%.%.%.$", "")
+
+---@param pkg string
+function M.parse_package_mod(pkg)
+ if _.starts_with("github.com", pkg) then
+ local components = _.split("/", pkg)
+ return trim_wildcard_suffix(_.join("/", {
+ components[1], -- github.com
+ components[2], -- owner
+ components[3], -- repo
+ }))
+ elseif _.starts_with("golang.org", pkg) then
+ local components = _.split("/", pkg)
+ return trim_wildcard_suffix(_.join("/", {
+ components[1], -- golang.org
+ components[2], -- x
+ components[3], -- owner
+ components[4], -- repo
+ }))
+ else
+ return trim_wildcard_suffix(pkg)
+ end
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ local normalized_pkg_name = trim_wildcard_suffix(receipt.primary_source.package)
+ -- trims e.g. golang.org/x/tools/gopls to gopls
+ local executable = vim.fn.fnamemodify(normalized_pkg_name, ":t")
+ return spawn
+ .go({
+ "version",
+ "-m",
+ platform.is_win and ("%s.exe"):format(executable) or executable,
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ local parsed_output = M.parse_mod_version_output(result.stdout)
+ return Optional.of_nilable(parsed_output.mod[M.parse_package_mod(receipt.primary_source.package)])
+ :or_else_throw "Failed to parse mod version"
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ local normalized_pkg_name = M.parse_package_mod(receipt.primary_source.package)
+ return spawn
+ .go({
+ "list",
+ "-json",
+ "-m",
+ ("%s@latest"):format(normalized_pkg_name),
+ cwd = install_dir,
+ })
+ :map_catching(function(result)
+ ---@type {Path: string, Version: string}
+ local output = vim.json.decode(result.stdout)
+ return Optional.of_nilable(output.Version)
+ :map(function(latest_version)
+ local installed_version =
+ M.get_installed_primary_package_version(receipt, install_dir):get_or_throw()
+ if installed_version ~= latest_version then
+ return {
+ name = normalized_pkg_name,
+ current_version = assert(installed_version),
+ latest_version = assert(latest_version),
+ }
+ end
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { install_dir },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/luarocks/init.lua b/lua/mason-core/managers/luarocks/init.lua
new file mode 100644
index 00000000..7959261c
--- /dev/null
+++ b/lua/mason-core/managers/luarocks/init.lua
@@ -0,0 +1,144 @@
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.bat", _.identity))
+
+---@param package string
+local function with_receipt(package)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.luarocks(package))
+ end
+end
+
+---@param package string @The luarock package to install.
+---@param opts { dev: boolean, bin : string[] | nil } | nil
+function M.package(package, opts)
+ return function()
+ return M.install(package, opts).with_receipt()
+ end
+end
+
+---@async
+---@param pkg string @The luarock package to install.
+---@param opts { dev: boolean, bin : string[] | nil } | nil
+function M.install(pkg, opts)
+ opts = opts or {}
+ local ctx = installer.context()
+ ctx:promote_cwd()
+ ctx.spawn.luarocks {
+ "install",
+ "--tree",
+ ctx.cwd:get(),
+ opts.dev and "--dev" or vim.NIL,
+ pkg,
+ ctx.requested_version:or_else(vim.NIL),
+ }
+ if pkg.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, pkg.bin)
+ end
+ return {
+ with_receipt = with_receipt(pkg),
+ }
+end
+
+---@alias InstalledLuarock {package: string, version: string, arch: string, nrepo: string, namespace: string}
+
+---@type fun(output: string): InstalledLuarock[]
+M.parse_installed_rocks = _.compose(
+ _.map(_.compose(
+ -- https://github.com/luarocks/luarocks/blob/fbd3566a312e647cde57b5d774533731e1aa844d/src/luarocks/search.lua#L317
+ _.zip_table { "package", "version", "arch", "nrepo", "namespace" },
+ _.split "\t"
+ )),
+ _.split "\n"
+)
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "luarocks" then
+ return Result.failure "Receipt does not have a primary source of type luarocks"
+ end
+ local primary_package = receipt.primary_source.package
+ return spawn
+ .luarocks({
+ "list",
+ "--tree",
+ install_dir,
+ "--porcelain",
+ })
+ :map_catching(function(result)
+ local luarocks = M.parse_installed_rocks(result.stdout)
+ return Optional.of_nilable(_.find_first(_.prop_eq("package", primary_package), luarocks))
+ :map(_.prop "version")
+ :or_else_throw()
+ end)
+end
+
+---@alias OutdatedLuarock {name: string, installed: string, available: string, repo: string}
+
+---@type fun(output: string): OutdatedLuarock[]
+M.parse_outdated_rocks = _.compose(
+ _.map(_.compose(
+ -- https://github.com/luarocks/luarocks/blob/fbd3566a312e647cde57b5d774533731e1aa844d/src/luarocks/cmd/list.lua#L59
+ _.zip_table { "name", "installed", "available", "repo" },
+ _.split "\t"
+ )),
+ _.split "\n"
+)
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "luarocks" then
+ return Result.failure "Receipt does not have a primary source of type luarocks"
+ end
+ local primary_package = receipt.primary_source.package
+ return spawn
+ .luarocks({
+ "list",
+ "--outdated",
+ "--tree",
+ install_dir,
+ "--porcelain",
+ })
+ :map_catching(function(result)
+ local outdated_rocks = M.parse_outdated_rocks(result.stdout)
+ return Optional.of_nilable(_.find_first(_.prop_eq("name", primary_package), outdated_rocks))
+ :map(
+ ---@param outdated_rock OutdatedLuarock
+ function(outdated_rock)
+ return {
+ name = outdated_rock.name,
+ current_version = assert(outdated_rock.installed),
+ latest_version = assert(outdated_rock.available),
+ }
+ end
+ )
+ :or_else_throw()
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/npm/init.lua b/lua/mason-core/managers/npm/init.lua
new file mode 100644
index 00000000..828afd12
--- /dev/null
+++ b/lua/mason-core/managers/npm/init.lua
@@ -0,0 +1,143 @@
+local spawn = require "mason-core.spawn"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local Result = require "mason-core.result"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+
+local list_copy = _.list_copy
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "node_modules", ".bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.cmd", _.identity))
+
+---@async
+---@param ctx InstallContext
+local function ensure_npm_root(ctx)
+ if not (ctx.fs:dir_exists "node_modules" or ctx.fs:file_exists "package.json") then
+ -- Create a package.json to set a boundary for where npm installs packages.
+ ctx.spawn.npm { "init", "--yes", "--scope=mason" }
+ end
+end
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.npm(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.npm(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The npm packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The npm packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = list_copy(packages)
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s@%s"):format(pkgs[1], version)
+ end)
+
+ -- Use global-style. The reasons for this are:
+ -- a) To avoid polluting the executables (aka bin-links) that npm creates.
+ -- b) The installation is, after all, more similar to a "global" installation. We don't really gain
+ -- any of the benefits of not using global style (e.g., deduping the dependency tree).
+ --
+ -- We write to .npmrc manually instead of going through npm because managing a local .npmrc file
+ -- is a bit unreliable across npm versions (especially <7), so we take extra measures to avoid
+ -- inadvertently polluting global npm config.
+ ctx.fs:append_file(".npmrc", "global-style=true")
+
+ ensure_npm_root(ctx)
+ ctx.spawn.npm { "install", pkgs }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@async
+---@param exec_args string[] @The arguments to pass to npm exec.
+function M.exec(exec_args)
+ local ctx = installer.context()
+ ctx.spawn.npm { "exec", "--yes", "--", exec_args }
+end
+
+---@async
+---@param script string @The npm script to run.
+function M.run(script)
+ local ctx = installer.context()
+ ctx.spawn.npm { "run", script }
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "npm" then
+ return Result.failure "Receipt does not have a primary source of type npm"
+ end
+ return spawn.npm({ "ls", "--json", cwd = install_dir }):map_catching(function(result)
+ local npm_packages = vim.json.decode(result.stdout)
+ return npm_packages.dependencies[receipt.primary_source.package].version
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "npm" then
+ return Result.failure "Receipt does not have a primary source of type npm"
+ end
+ local primary_package = receipt.primary_source.package
+ local npm_outdated = spawn.npm { "outdated", "--json", primary_package, cwd = install_dir }
+ if npm_outdated:is_success() then
+ return Result.failure "Primary package is not outdated."
+ end
+ return npm_outdated:recover_catching(function(result)
+ assert(result.exit_code == 1, "Expected npm outdated to return exit code 1.")
+ local data = vim.json.decode(result.stdout)
+
+ return Optional.of_nilable(data[primary_package])
+ :map(function(outdated_package)
+ if outdated_package.current ~= outdated_package.latest then
+ return {
+ name = primary_package,
+ current_version = assert(outdated_package.current),
+ latest_version = assert(outdated_package.latest),
+ }
+ end
+ end)
+ :or_else_throw()
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { path.concat { install_dir, "node_modules", ".bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/opam/init.lua b/lua/mason-core/managers/opam/init.lua
new file mode 100644
index 00000000..8b42e4e9
--- /dev/null
+++ b/lua/mason-core/managers/opam/init.lua
@@ -0,0 +1,69 @@
+local path = require "mason-core.path"
+local process = require "mason-core.process"
+local installer = require "mason-core.installer"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+
+local M = {}
+
+local list_copy = _.list_copy
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.opam(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.opam(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The opam packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The opam packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = list_copy(packages)
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s.%s"):format(pkgs[1], version)
+ end)
+
+ ctx.spawn.opam {
+ "install",
+ "--destdir=.",
+ "--yes",
+ "--verbose",
+ pkgs,
+ }
+
+ if packages.bin then
+ _.each(function(executable)
+ ctx:link_bin(executable, create_bin_path(executable))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+function M.env(root_dir)
+ return {
+ PATH = process.extend_path { path.concat { root_dir, "bin" } },
+ }
+end
+
+return M
diff --git a/lua/mason-core/managers/pip3/init.lua b/lua/mason-core/managers/pip3/init.lua
new file mode 100644
index 00000000..9502e89e
--- /dev/null
+++ b/lua/mason-core/managers/pip3/init.lua
@@ -0,0 +1,175 @@
+local _ = require "mason-core.functional"
+local settings = require "mason.settings"
+local process = require "mason-core.process"
+local path = require "mason-core.path"
+local platform = require "mason-core.platform"
+local Optional = require "mason-core.optional"
+local installer = require "mason-core.installer"
+local Result = require "mason-core.result"
+local spawn = require "mason-core.spawn"
+
+local VENV_DIR = "venv"
+
+local M = {}
+
+local create_bin_path = _.compose(path.concat, function(executable)
+ return _.append(executable, { VENV_DIR, platform.is_win and "Scripts" or "bin" })
+end, _.if_else(_.always(platform.is.win), _.format "%s.exe", _.identity))
+
+---@param packages string[]
+local function with_receipt(packages)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.pip3(packages[1]))
+ for i = 2, #packages do
+ ctx.receipt:with_secondary_source(ctx.receipt.pip3(packages[i]))
+ end
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The pip packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.packages(packages)
+ return function()
+ return M.install(packages).with_receipt()
+ end
+end
+
+---@async
+---@param packages { [number]: string, bin: string[] | nil } @The pip packages to install. The first item in this list will be the recipient of the requested version, if set.
+function M.install(packages)
+ local ctx = installer.context()
+ local pkgs = _.list_copy(packages)
+
+ ctx.requested_version:if_present(function(version)
+ pkgs[1] = ("%s==%s"):format(pkgs[1], version)
+ end)
+
+ local executables = platform.is_win and _.list_not_nil(vim.g.python3_host_prog, "python", "python3")
+ or _.list_not_nil(vim.g.python3_host_prog, "python3", "python")
+
+ -- pip3 will hardcode the full path to venv executables, so we need to promote cwd to make sure pip uses the final destination path.
+ ctx:promote_cwd()
+
+ -- Find first executable that manages to create venv
+ local executable = _.find_first(function(executable)
+ return pcall(ctx.spawn[executable], { "-m", "venv", VENV_DIR })
+ end, executables)
+
+ Optional.of_nilable(executable)
+ :if_present(function()
+ ctx.spawn.python {
+ "-m",
+ "pip",
+ "--disable-pip-version-check",
+ "install",
+ "-U",
+ settings.current.pip.install_args,
+ pkgs,
+ with_paths = { M.venv_path(ctx.cwd:get()) },
+ }
+ end)
+ :or_else_throw "Unable to create python3 venv environment."
+
+ if packages.bin then
+ _.each(function(bin)
+ ctx:link_bin(bin, create_bin_path(bin))
+ end, packages.bin)
+ end
+
+ return {
+ with_receipt = with_receipt(packages),
+ }
+end
+
+---@param pkg string
+---@return string
+function M.normalize_package(pkg)
+ -- https://stackoverflow.com/a/60307740
+ local s = pkg:gsub("%[.*%]", "")
+ return s
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.check_outdated_primary_package(receipt, install_dir)
+ if receipt.primary_source.type ~= "pip3" then
+ return Result.failure "Receipt does not have a primary source of type pip3"
+ end
+ local normalized_package = M.normalize_package(receipt.primary_source.package)
+ return spawn
+ .python({
+ "-m",
+ "pip",
+ "list",
+ "--outdated",
+ "--format=json",
+ cwd = install_dir,
+ with_paths = { M.venv_path(install_dir) },
+ })
+ :map_catching(function(result)
+ ---@alias PipOutdatedPackage {name: string, version: string, latest_version: string}
+ ---@type PipOutdatedPackage[]
+ local packages = vim.json.decode(result.stdout)
+
+ local outdated_primary_package = _.find_first(function(outdated_package)
+ return outdated_package.name == normalized_package
+ and outdated_package.version ~= outdated_package.latest_version
+ end, packages)
+
+ return Optional.of_nilable(outdated_primary_package)
+ :map(function(pkg)
+ return {
+ name = normalized_package,
+ current_version = assert(pkg.version),
+ latest_version = assert(pkg.latest_version),
+ }
+ end)
+ :or_else_throw "Primary package is not outdated."
+ end)
+end
+
+---@async
+---@param receipt InstallReceipt
+---@param install_dir string
+function M.get_installed_primary_package_version(receipt, install_dir)
+ if receipt.primary_source.type ~= "pip3" then
+ return Result.failure "Receipt does not have a primary source of type pip3"
+ end
+ return spawn
+ .python({
+ "-m",
+ "pip",
+ "list",
+ "--format=json",
+ cwd = install_dir,
+ with_paths = { M.venv_path(install_dir) },
+ })
+ :map_catching(function(result)
+ local pip_packages = vim.json.decode(result.stdout)
+ local normalized_pip_package = M.normalize_package(receipt.primary_source.package)
+ local pip_package = _.find_first(function(pkg)
+ return pkg.name == normalized_pip_package
+ end, pip_packages)
+ return Optional.of_nilable(pip_package)
+ :map(function(pkg)
+ return pkg.version
+ end)
+ :or_else_throw "Unable to find pip package."
+ end)
+end
+
+---@param install_dir string
+function M.env(install_dir)
+ return {
+ PATH = process.extend_path { M.venv_path(install_dir) },
+ }
+end
+
+---@param install_dir string
+function M.venv_path(install_dir)
+ return path.concat { install_dir, VENV_DIR, platform.is_win and "Scripts" or "bin" }
+end
+
+return M
diff --git a/lua/mason-core/managers/powershell/init.lua b/lua/mason-core/managers/powershell/init.lua
new file mode 100644
index 00000000..209e0fe1
--- /dev/null
+++ b/lua/mason-core/managers/powershell/init.lua
@@ -0,0 +1,46 @@
+local spawn = require "mason-core.spawn"
+local process = require "mason-core.process"
+
+local M = {}
+
+local PWSHOPT = {
+ progress_preference = [[ $ProgressPreference = 'SilentlyContinue'; ]], -- https://stackoverflow.com/a/63301751
+ security_protocol = [[ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ]],
+}
+
+---@param script string
+---@param opts JobSpawnOpts | nil
+---@param custom_spawn JobSpawn | nil
+function M.script(script, opts, custom_spawn)
+ opts = opts or {}
+ ---@type JobSpawn
+ local spawner = custom_spawn or spawn
+ return spawner.powershell(vim.tbl_extend("keep", {
+ "-NoProfile",
+ on_spawn = function(_, stdio)
+ local stdin = stdio[1]
+ stdin:write(PWSHOPT.progress_preference)
+ stdin:write(PWSHOPT.security_protocol)
+ stdin:write(script)
+ stdin:close()
+ end,
+ env_raw = process.graft_env(opts.env or {}, { "PSMODULEPATH" }),
+ }, opts))
+end
+
+---@param command string
+---@param opts JobSpawnOpts | nil
+---@param custom_spawn JobSpawn | nil
+function M.command(command, opts, custom_spawn)
+ opts = opts or {}
+ ---@type JobSpawn
+ local spawner = custom_spawn or spawn
+ return spawner.powershell(vim.tbl_extend("keep", {
+ "-NoProfile",
+ "-Command",
+ PWSHOPT.progress_preference .. PWSHOPT.security_protocol .. command,
+ env_raw = process.graft_env(opts.env or {}, { "PSMODULEPATH" }),
+ }, opts))
+end
+
+return M
diff --git a/lua/mason-core/managers/std/init.lua b/lua/mason-core/managers/std/init.lua
new file mode 100644
index 00000000..e021a261
--- /dev/null
+++ b/lua/mason-core/managers/std/init.lua
@@ -0,0 +1,188 @@
+local a = require "mason-core.async"
+local installer = require "mason-core.installer"
+local fetch = require "mason-core.fetch"
+local platform = require "mason-core.platform"
+local powershell = require "mason-core.managers.powershell"
+local path = require "mason-core.path"
+local Result = require "mason-core.result"
+
+local M = {}
+
+local function with_system_executable_receipt(executable)
+ return function()
+ local ctx = installer.context()
+ ctx.receipt:with_primary_source(ctx.receipt.system(executable))
+ end
+end
+
+---@async
+---@param executable string
+---@param opts {help_url:string|nil}
+function M.system_executable(executable, opts)
+ return function()
+ M.ensure_executable(executable, opts).with_receipt()
+ end
+end
+
+---@async
+---@param executable string
+---@param opts {help_url:string|nil}
+function M.ensure_executable(executable, opts)
+ local ctx = installer.context()
+ opts = opts or {}
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ if vim.fn.executable(executable) ~= 1 then
+ ctx.stdio_sink.stderr(("%s was not found in path.\n"):format(executable))
+ if opts.help_url then
+ ctx.stdio_sink.stderr(("See %s for installation instructions.\n"):format(opts.help_url))
+ end
+ error("Installation failed: system executable was not found.", 0)
+ end
+
+ return {
+ with_receipt = with_system_executable_receipt(executable),
+ }
+end
+
+---@async
+---@param url string
+---@param out_file string
+function M.download_file(url, out_file)
+ local ctx = installer.context()
+ ctx.stdio_sink.stdout(("Downloading file %q...\n"):format(url))
+ fetch(url, {
+ out_file = path.concat { ctx.cwd:get(), out_file },
+ })
+ :map_err(function(err)
+ return ("Failed to download file %q.\n%s"):format(url, err)
+ end)
+ :get_or_throw()
+end
+
+---@async
+---@param file string
+---@param dest string
+function M.unzip(file, dest)
+ local ctx = installer.context()
+ platform.when {
+ unix = function()
+ ctx.spawn.unzip { "-d", dest, file }
+ end,
+ win = function()
+ powershell.command(
+ ("Microsoft.PowerShell.Archive\\Expand-Archive -Path %q -DestinationPath %q"):format(file, dest),
+ {},
+ ctx.spawn
+ )
+ end,
+ }
+ pcall(function()
+ -- make sure the .zip archive doesn't linger
+ ctx.fs:unlink(file)
+ end)
+end
+
+---@param file string
+local function win_extract(file)
+ local ctx = installer.context()
+ Result.run_catching(function()
+ ctx.spawn["7z"] { "x", "-y", "-r", file }
+ end)
+ :recover_catching(function()
+ ctx.spawn.peazip { "-ext2here", path.concat { ctx.cwd:get(), file } } -- peazip requires absolute paths
+ end)
+ :recover_catching(function()
+ ctx.spawn.wzunzip { file }
+ end)
+ :recover_catching(function()
+ ctx.spawn.winrar { "e", file }
+ end)
+ :get_or_throw(("Unable to unpack %s."):format(file))
+end
+
+---@async
+---@param file string
+---@param opts {strip_components:integer}|nil
+function M.untar(file, opts)
+ opts = opts or {}
+ local ctx = installer.context()
+ ctx.spawn.tar {
+ opts.strip_components and { "--strip-components", opts.strip_components } or vim.NIL,
+ "--no-same-owner",
+ "-xvf",
+ file,
+ }
+ pcall(function()
+ ctx.fs:unlink(file)
+ end)
+end
+
+---@async
+---@param file string
+---@param opts {strip_components:integer}|nil
+function M.untarxz(file, opts)
+ opts = opts or {}
+ local ctx = installer.context()
+ platform.when {
+ unix = function()
+ M.untar(file, opts)
+ end,
+ win = function()
+ Result.run_catching(function()
+ win_extract(file) -- unpack .tar.xz to .tar
+ local uncompressed_tar = file:gsub(".xz$", "")
+ M.untar(uncompressed_tar, opts)
+ end):recover(function()
+ ctx.spawn.arc {
+ "unarchive",
+ opts.strip_components and { "--strip-components", opts.strip_components } or vim.NIL,
+ file,
+ }
+ pcall(function()
+ ctx.fs:unlink(file)
+ end)
+ end)
+ end,
+ }
+end
+
+---@async
+---@param file string
+function M.gunzip(file)
+ platform.when {
+ unix = function()
+ local ctx = installer.context()
+ ctx.spawn.gzip { "-d", file }
+ end,
+ win = function()
+ win_extract(file)
+ end,
+ }
+end
+
+---@async
+---@param flags string @The chmod flag to apply.
+---@param files string[] @A list of relative paths to apply the chmod on.
+function M.chmod(flags, files)
+ if platform.is_unix then
+ local ctx = installer.context()
+ ctx.spawn.chmod { flags, files }
+ end
+end
+
+---@async
+---Wrapper around vim.ui.select.
+---@param items table
+---@params opts
+function M.select(items, opts)
+ assert(not platform.is_headless, "Tried to prompt for user input while in headless mode.")
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ local async_select = a.promisify(vim.ui.select)
+ return async_select(items, opts)
+end
+
+return M
diff --git a/lua/mason-core/notify.lua b/lua/mason-core/notify.lua
new file mode 100644
index 00000000..b41c7e64
--- /dev/null
+++ b/lua/mason-core/notify.lua
@@ -0,0 +1,13 @@
+local TITLE = "mason.nvim"
+
+return function(msg, level)
+ local has_notify_plugin = pcall(require, "notify")
+ level = level or vim.log.levels.INFO
+ if has_notify_plugin then
+ vim.notify(msg, level, {
+ title = TITLE,
+ })
+ else
+ vim.notify(("[%s] %s"):format(TITLE, msg), level)
+ end
+end
diff --git a/lua/mason-core/optional.lua b/lua/mason-core/optional.lua
new file mode 100644
index 00000000..10af8ccb
--- /dev/null
+++ b/lua/mason-core/optional.lua
@@ -0,0 +1,100 @@
+---@class Optional<T>
+---@field private _value unknown
+local Optional = {}
+Optional.__index = Optional
+
+---@param value any
+function Optional.new(value)
+ return setmetatable({ _value = value }, Optional)
+end
+
+local EMPTY = Optional.new(nil)
+
+---@param value any
+function Optional.of_nilable(value)
+ if value == nil then
+ return EMPTY
+ else
+ return Optional.new(value)
+ end
+end
+
+function Optional.empty()
+ return EMPTY
+end
+
+---@param value any
+function Optional.of(value)
+ return Optional.new(value)
+end
+
+---@param mapper_fn fun(value: any): any
+function Optional:map(mapper_fn)
+ if self:is_present() then
+ return Optional.of_nilable(mapper_fn(self._value))
+ else
+ return EMPTY
+ end
+end
+
+function Optional:get()
+ if not self:is_present() then
+ error("No value present.", 2)
+ end
+ return self._value
+end
+
+---@param value any
+function Optional:or_else(value)
+ if self:is_present() then
+ return self._value
+ else
+ return value
+ end
+end
+
+---@param supplier fun(): any
+function Optional:or_else_get(supplier)
+ if self:is_present() then
+ return self._value
+ else
+ return supplier()
+ end
+end
+
+---@param supplier fun(): Optional
+---@return Optional
+function Optional:or_(supplier)
+ if self:is_present() then
+ return self
+ else
+ return supplier()
+ end
+end
+
+---@param exception any @(optional) The exception to throw if the result is a failure.
+function Optional:or_else_throw(exception)
+ if self:is_present() then
+ return self._value
+ else
+ if exception then
+ error(exception, 2)
+ else
+ error("No value present.", 2)
+ end
+ end
+end
+
+---@param fn fun(value: any)
+function Optional:if_present(fn)
+ if self:is_present() then
+ fn(self._value)
+ end
+ return self
+end
+
+function Optional:is_present()
+ return self._value ~= nil
+end
+
+return Optional
diff --git a/lua/mason-core/package/init.lua b/lua/mason-core/package/init.lua
new file mode 100644
index 00000000..631e423d
--- /dev/null
+++ b/lua/mason-core/package/init.lua
@@ -0,0 +1,205 @@
+local registry = require "mason-registry"
+local a = require "mason-core.async"
+local _ = require "mason-core.functional"
+local installer = require "mason-core.installer"
+local InstallationHandle = require "mason-core.installer.handle"
+local Optional = require "mason-core.optional"
+local log = require "mason-core.log"
+local EventEmitter = require "mason-core.EventEmitter"
+local receipt = require "mason-core.receipt"
+local fs = require "mason-core.fs"
+local path = require "mason-core.path"
+local linker = require "mason-core.installer.linker"
+
+local version_checks = require "mason-core.package.version-check"
+
+---@class Package : EventEmitter
+---@field name string
+---@field spec PackageSpec
+---@field private handle InstallHandle @The currently associated handle.
+local Package = setmetatable({}, { __index = EventEmitter })
+
+---@param package_identifier string
+---@return string, string | nil
+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,
+})
+
+---@class PackageCategory
+Package.Cat = {
+ Compiler = "Compiler",
+ Runtime = "Runtime",
+ DAP = "DAP",
+ LSP = "LSP",
+ Linter = "Linter",
+ Formatter = "Formatter",
+}
+
+local PackageMt = { __index = Package }
+
+---@class PackageSpec
+---@field name string
+---@field desc string
+---@field homepage string
+---@field categories PackageCategory[]
+---@field languages PackageLanguage[]
+---@field install async fun(ctx: InstallContext)
+
+---@param spec PackageSpec
+function Package.new(spec)
+ vim.validate {
+ name = { spec.name, "s" },
+ desc = { spec.desc, "s" },
+ homepage = { spec.homepage, "s" },
+ categories = { spec.categories, "t" },
+ languages = { spec.languages, "t" },
+ install = { spec.install, "f" },
+ }
+
+ return EventEmitter.init(setmetatable({
+ name = spec.name, -- for convenient access
+ spec = spec,
+ }, PackageMt))
+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 handle = InstallationHandle.new(self)
+ self.handle = handle
+ self:emit("handle", handle)
+ return handle
+end
+
+---@param opts { version: string|nil } | nil
+---@return InstallHandle
+function Package:install(opts)
+ opts = opts or {}
+ return self
+ :get_handle()
+ :map(function(handle)
+ if not handle:is_closed() then
+ log.fmt_debug("Handle %s already exist for package %s", handle, self)
+ return handle
+ end
+ end)
+ :or_else_get(function()
+ local handle = self:new_handle()
+ -- This function is not expected to be run in async scope, so we create
+ -- a new scope here and handle the result callback-style.
+ a.run(
+ installer.execute,
+ ---@param success boolean
+ ---@param result Result
+ function(success, result)
+ if not success then
+ log.error("Unexpected error", result)
+ self:emit("install:failed", handle)
+ return
+ end
+ result
+ :on_success(function()
+ self:emit("install:success", handle)
+ registry:emit("package:install:success", self, handle)
+ end)
+ :on_failure(function()
+ self:emit("install:failed", handle)
+ registry:emit("package:install:failed", self, handle)
+ end)
+ end,
+ handle,
+ {
+ requested_version = opts.version,
+ }
+ )
+ return handle
+ end)
+end
+
+function Package:uninstall()
+ local was_unlinked = self:unlink()
+ if was_unlinked then
+ self:emit "uninstall:success"
+ end
+ return was_unlinked
+end
+
+function Package:unlink()
+ log.fmt_info("Unlinking %s", self)
+ local install_path = self:get_install_path()
+ -- 1. Unlink
+ self:get_receipt():map(_.prop "links"):if_present(function(links)
+ linker.unlink(self, links)
+ end)
+
+ -- 2. Remove installation artifacts
+ if fs.sync.dir_exists(install_path) then
+ fs.sync.rmrf(install_path)
+ return true
+ end
+ return false
+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 path.package_prefix(self.name)
+end
+
+---@return Optional @Optional<@see 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
+ return Optional.of(receipt.InstallReceipt.from_json(vim.json.decode(fs.sync.read_file(receipt_path))))
+ end
+ return Optional.empty()
+end
+
+---@param callback fun(success: boolean, version_or_err: string)
+function Package:get_installed_version(callback)
+ a.run(function()
+ local receipt = self:get_receipt():or_else_throw "Unable to get receipt."
+ return version_checks.get_installed_version(receipt, self:get_install_path()):get_or_throw()
+ end, callback)
+end
+
+---@param callback fun(success: boolean, result_or_err: NewPackageVersion)
+function Package:check_new_version(callback)
+ a.run(function()
+ local receipt = self:get_receipt():or_else_throw "Unable to get receipt."
+ return version_checks.get_new_version(receipt, self:get_install_path()):get_or_throw()
+ end, callback)
+end
+
+function Package:get_lsp_settings_schema()
+ local ok, schema = pcall(require, ("mason-schemas.lsp.%s"):format(self.name))
+ if not ok then
+ return Optional.empty()
+ end
+ return Optional.of(schema)
+end
+
+function PackageMt.__tostring(self)
+ return ("Package(name=%s)"):format(self.name)
+end
+
+return Package
diff --git a/lua/mason-core/package/version-check.lua b/lua/mason-core/package/version-check.lua
new file mode 100644
index 00000000..b999c280
--- /dev/null
+++ b/lua/mason-core/package/version-check.lua
@@ -0,0 +1,91 @@
+local Result = require "mason-core.result"
+local cargo = require "mason-core.managers.cargo"
+local composer = require "mason-core.managers.composer"
+local eclipse = require "mason-core.clients.eclipse"
+local gem = require "mason-core.managers.gem"
+local git = require "mason-core.managers.git"
+local github = require "mason-core.managers.github"
+local go = require "mason-core.managers.go"
+local luarocks = require "mason-core.managers.luarocks"
+local npm = require "mason-core.managers.npm"
+local pip3 = require "mason-core.managers.pip3"
+
+---@param field_name string
+local function version_in_receipt(field_name)
+ ---@param receipt InstallReceipt
+ ---@return Result
+ return function(receipt)
+ return Result.success(receipt.primary_source[field_name])
+ end
+end
+
+---@type table<InstallReceiptSourceType, async fun(receipt: InstallReceipt, install_dir: string): Result>
+local get_installed_version_by_type = {
+ ["npm"] = npm.get_installed_primary_package_version,
+ ["pip3"] = pip3.get_installed_primary_package_version,
+ ["gem"] = gem.get_installed_primary_package_version,
+ ["cargo"] = cargo.get_installed_primary_package_version,
+ ["composer"] = composer.get_installed_primary_package_version,
+ ["git"] = git.get_installed_revision,
+ ["go"] = go.get_installed_primary_package_version,
+ ["luarocks"] = luarocks.get_installed_primary_package_version,
+ ["github_release_file"] = version_in_receipt "release",
+ ["github_release"] = version_in_receipt "release",
+ ["github_tag"] = version_in_receipt "tag",
+ ["jdtls"] = version_in_receipt "version",
+}
+
+---@async
+---@param receipt InstallReceipt
+local function jdtls_check(receipt)
+ return eclipse.fetch_latest_jdtls_version():map_catching(function(latest_version)
+ if receipt.primary_source.version ~= latest_version then
+ return {
+ name = "jdtls",
+ current_version = receipt.primary_source.version,
+ latest_version = latest_version,
+ }
+ end
+ error "Primary package is not outdated."
+ end)
+end
+
+---@class NewPackageVersion
+---@field name string
+---@field current_version string
+---@field latest_version string
+
+local get_new_version_by_type = {
+ ["npm"] = npm.check_outdated_primary_package,
+ ["pip3"] = pip3.check_outdated_primary_package,
+ ["git"] = git.check_outdated_git_clone,
+ ["cargo"] = cargo.check_outdated_primary_package,
+ ["composer"] = composer.check_outdated_primary_package,
+ ["gem"] = gem.check_outdated_primary_package,
+ ["go"] = go.check_outdated_primary_package,
+ ["luarocks"] = luarocks.check_outdated_primary_package,
+ ["jdtls"] = jdtls_check,
+ ["github_release_file"] = github.check_outdated_primary_package_release,
+ ["github_release"] = github.check_outdated_primary_package_release,
+ ["github_tag"] = github.check_outdated_primary_package_tag,
+}
+
+---@param provider_mapping table<string, async fun(receipt: InstallReceipt, install_dir: string)>: Result
+local function version_check(provider_mapping)
+ ---@param receipt InstallReceipt
+ ---@param install_dir string
+ return function(receipt, install_dir)
+ local check = provider_mapping[receipt.primary_source.type]
+ if not check then
+ return Result.failure(
+ ("Packages installed via %s does not yet support version check."):format(receipt.primary_source.type)
+ )
+ end
+ return check(receipt, install_dir)
+ end
+end
+
+return {
+ get_installed_version = version_check(get_installed_version_by_type),
+ get_new_version = version_check(get_new_version_by_type),
+}
diff --git a/lua/mason-core/path.lua b/lua/mason-core/path.lua
new file mode 100644
index 00000000..2060c186
--- /dev/null
+++ b/lua/mason-core/path.lua
@@ -0,0 +1,51 @@
+local sep = (function()
+ ---@diagnostic disable-next-line: undefined-global
+ if jit then
+ ---@diagnostic disable-next-line: undefined-global
+ local os = string.lower(jit.os)
+ if os == "linux" or os == "osx" or os == "bsd" then
+ return "/"
+ else
+ return "\\"
+ end
+ else
+ return package.config:sub(1, 1)
+ end
+end)()
+
+local M = {}
+
+---@param path_components string[]
+---@return string
+function M.concat(path_components)
+ return table.concat(path_components, sep)
+end
+
+---@path root_path string
+---@path path string
+function M.is_subdirectory(root_path, path)
+ return root_path == path or path:sub(1, #root_path + 1) == root_path .. sep
+end
+
+---@param dir string|nil
+function M.install_prefix(dir)
+ local settings = require "mason.settings"
+ return M.concat { settings.current.install_root_dir, dir }
+end
+
+---@param executable string|nil
+function M.bin_prefix(executable)
+ return M.concat { M.install_prefix "bin", executable }
+end
+
+---@param name string|nil
+function M.package_prefix(name)
+ return M.concat { M.install_prefix "packages", name }
+end
+
+---@param name string|nil
+function M.package_build_prefix(name)
+ return M.concat { M.install_prefix ".packages", name }
+end
+
+return M
diff --git a/lua/mason-core/platform.lua b/lua/mason-core/platform.lua
new file mode 100644
index 00000000..64e6ba52
--- /dev/null
+++ b/lua/mason-core/platform.lua
@@ -0,0 +1,159 @@
+local fun = require "mason-core.functional.function"
+
+local M = {}
+
+local uname = vim.loop.os_uname()
+
+---@alias Platform
+---| '"win"'
+---| '"unix"'
+---| '"linux"'
+---| '"mac"'
+
+local arch_aliases = {
+ ["x86_64"] = "x64",
+ ["i386"] = "x86",
+ ["i686"] = "x86", -- x86 compat
+ ["aarch64"] = "arm64",
+ ["aarch64_be"] = "arm64",
+ ["armv8b"] = "arm64", -- arm64 compat
+ ["armv8l"] = "arm64", -- arm64 compat
+}
+
+M.arch = arch_aliases[uname.machine] or uname.machine
+
+M.is_win = vim.fn.has "win32" == 1
+M.is_unix = vim.fn.has "unix" == 1
+M.is_mac = vim.fn.has "mac" == 1
+M.is_linux = not M.is_mac and M.is_unix
+
+-- PATH separator
+M.path_sep = M.is_win and ";" or ":"
+
+M.is_headless = #vim.api.nvim_list_uis() == 0
+
+---@generic T
+---@param platform_table table<Platform, T>
+---@return T
+local function get_by_platform(platform_table)
+ if M.is_mac then
+ return platform_table.mac or platform_table.unix
+ elseif M.is_linux then
+ return platform_table.linux or platform_table.unix
+ elseif M.is_unix then
+ return platform_table.unix
+ elseif M.is_win then
+ return platform_table.win
+ else
+ return nil
+ end
+end
+
+function M.when(cases)
+ local case = get_by_platform(cases)
+ if case then
+ return case()
+ else
+ error "Current platform is not supported."
+ end
+end
+
+---@type async fun(): table
+M.os_distribution = fun.lazy(function()
+ local Result = require "mason-core.result"
+
+ ---Parses the provided contents of an /etc/\*-release file and identifies the Linux distribution.
+ ---@param contents string @The contents of a /etc/\*-release file.
+ ---@return table<string, any>
+ local function parse_linux_dist(contents)
+ local lines = vim.split(contents, "\n")
+
+ local entries = {}
+
+ for i = 1, #lines do
+ local line = lines[i]
+ local index = line:find "="
+ if index then
+ local key = line:sub(1, index - 1)
+ local value = line:sub(index + 1)
+ entries[key] = value
+ end
+ end
+
+ if entries.ID == "ubuntu" then
+ -- Parses the Ubuntu OS VERSION_ID into their version components, e.g. "18.04" -> {major=18, minor=04}
+ local version_id = entries.VERSION_ID:gsub([["]], "")
+ local version_parts = vim.split(version_id, "%.")
+ local major = tonumber(version_parts[1])
+ local minor = tonumber(version_parts[2])
+
+ return {
+ id = "ubuntu",
+ version_id = version_id,
+ version = { major = major, minor = minor },
+ }
+ else
+ return {
+ id = "linux-generic",
+ }
+ end
+ end
+
+ return M.when {
+ linux = function()
+ local spawn = require "mason-core.spawn"
+ return spawn
+ .bash({ "-c", "cat /etc/*-release" })
+ :map_catching(function(result)
+ return parse_linux_dist(result.stdout)
+ end)
+ :recover(function()
+ return { id = "linux-generic" }
+ end)
+ :get_or_throw()
+ end,
+ mac = function()
+ return Result.success { id = "macOS" }
+ end,
+ win = function()
+ return Result.success { id = "windows" }
+ end,
+ }
+end)
+
+---@type async fun() Result @of String
+M.get_homebrew_prefix = fun.lazy(function()
+ assert(M.is_mac, "Can only locate Homebrew installation on Mac systems.")
+ local spawn = require "mason-core.spawn"
+ return spawn
+ .brew({ "--prefix" })
+ :map_catching(function(result)
+ return vim.trim(result.stdout)
+ end)
+ :map_err(function()
+ return "Failed to locate Homebrew installation."
+ end)
+end)
+
+-- @return string @The libc found on the system, musl or glibc (glibc if ldd is not found)
+M.get_libc = fun.lazy(function()
+ local _, _, libc_exit_code = os.execute "ldd --version 2>&1 | grep -q musl"
+ if libc_exit_code == 0 then
+ return "musl"
+ else
+ return "glibc"
+ end
+end)
+
+---@type table<string, boolean>
+M.is = setmetatable({}, {
+ __index = function(_, key)
+ local platform, arch = unpack(vim.split(key, "_", { plain = true }))
+ if arch and M.arch ~= arch then
+ return false
+ end
+ return M["is_" .. platform] == true
+ end,
+})
+
+return M
diff --git a/lua/mason-core/process.lua b/lua/mason-core/process.lua
new file mode 100644
index 00000000..fd4eb94f
--- /dev/null
+++ b/lua/mason-core/process.lua
@@ -0,0 +1,213 @@
+local log = require "mason-core.log"
+local _ = require "mason-core.functional"
+local platform = require "mason-core.platform"
+local uv = vim.loop
+
+---@alias luv_pipe any
+---@alias luv_handle any
+
+---@class StdioSink
+---@field stdout fun(chunk: string)
+---@field stderr fun(chunk: string)
+
+local M = {}
+
+---@param pipe luv_pipe
+---@param sink fun(chunk: string)
+local function connect_sink(pipe, sink)
+ ---@param err string | nil
+ ---@param data string | nil
+ return function(err, data)
+ if err then
+ log.error("Unexpected error when reading pipe.", err)
+ end
+ if data ~= nil then
+ sink(data)
+ else
+ pipe:read_stop()
+ pipe:close()
+ end
+ end
+end
+
+-- We gather the root env immediately, primarily because of E5560.
+-- Also, there's no particular reason we need to refresh the environment (yet).
+local initial_environ = vim.fn.environ()
+
+---@param new_paths string[] @A list of paths to prepend the existing PATH with.
+function M.extend_path(new_paths)
+ local new_path_str = table.concat(new_paths, platform.path_sep)
+ return ("%s%s%s"):format(new_path_str, platform.path_sep, initial_environ.PATH or "")
+end
+
+---Merges the provided env param with the user's full environent. Provided env has precedence.
+---@param env table<string, string>
+---@param excluded_var_names string[]|nil
+function M.graft_env(env, excluded_var_names)
+ local excluded_var_names_set = excluded_var_names and _.set_of(excluded_var_names) or {}
+ local merged_env = {}
+ for key, val in pairs(initial_environ) do
+ if not excluded_var_names_set[key] and env[key] == nil then
+ merged_env[#merged_env + 1] = key .. "=" .. val
+ end
+ end
+ for key, val in pairs(env) do
+ if not excluded_var_names_set[key] then
+ merged_env[#merged_env + 1] = key .. "=" .. val
+ end
+ end
+ return merged_env
+end
+
+---@param env_list string[]
+local function sanitize_env_list(env_list)
+ local sanitized_list = {}
+ for __, env in ipairs(env_list) do
+ local safe_envs = {
+ "GO111MODULE",
+ "GOBIN",
+ "GOPATH",
+ "PATH",
+ "GEM_HOME",
+ "GEM_PATH",
+ }
+ local is_safe_env = _.any(function(safe_env)
+ return env:find(safe_env .. "=") == 1
+ end, safe_envs)
+ if is_safe_env then
+ sanitized_list[#sanitized_list + 1] = env
+ else
+ local idx = env:find "="
+ sanitized_list[#sanitized_list + 1] = env:sub(1, idx) .. "<redacted>"
+ end
+ end
+ return sanitized_list
+end
+
+---@alias JobSpawnCallback fun(success: boolean, exit_code: integer, signal: integer)
+
+---@class JobSpawnOpts
+---@field env string[] @List of "key=value" string.
+---@field args string[]
+---@field cwd string
+---@field stdio_sink StdioSink
+
+---@param cmd string @The command/executable.
+---@param opts JobSpawnOpts
+---@param callback JobSpawnCallback
+---@return luv_handle,luv_pipe[],integer @Returns the job handle and the stdio array on success, otherwise returns nil.
+function M.spawn(cmd, opts, callback)
+ local stdin = uv.new_pipe(false)
+ local stdout = uv.new_pipe(false)
+ local stderr = uv.new_pipe(false)
+
+ local stdio = { stdin, stdout, stderr }
+
+ local spawn_opts = {
+ env = opts.env,
+ stdio = stdio,
+ args = opts.args,
+ cwd = opts.cwd,
+ detached = false,
+ hide = true,
+ }
+
+ log.lazy_debug(function()
+ local sanitized_env = opts.env and sanitize_env_list(opts.env) or nil
+ return "Spawning cmd=%s, spawn_opts=%s",
+ cmd,
+ {
+ args = opts.args,
+ cwd = opts.cwd,
+ env = sanitized_env,
+ }
+ end)
+
+ local handle, pid_or_err
+ handle, pid_or_err = uv.spawn(cmd, spawn_opts, function(exit_code, signal)
+ local successful = exit_code == 0 and signal == 0
+ handle:close()
+ if not stdin:is_closing() then
+ stdin:close()
+ end
+
+ -- ensure all pipes are closed, for I am a qualified plumber
+ local check = uv.new_check()
+ check:start(function()
+ for i = 1, #stdio do
+ local pipe = stdio[i]
+ if not pipe:is_closing() then
+ return
+ end
+ end
+ check:stop()
+ callback(successful, exit_code, signal)
+ end)
+
+ log.fmt_debug("Job pid=%s exited with exit_code=%s, signal=%s", pid_or_err, exit_code, signal)
+ end)
+
+ if handle == nil then
+ log.fmt_error("Failed to spawn process. cmd=%s, err=%s", cmd, pid_or_err)
+ if type(pid_or_err) == "string" and pid_or_err:find "ENOENT" == 1 then
+ opts.stdio_sink.stderr(("Could not find executable %q in path.\n"):format(cmd))
+ else
+ opts.stdio_sink.stderr(("Failed to spawn process cmd=%s err=%s\n"):format(cmd, pid_or_err))
+ end
+ callback(false)
+ return nil, nil
+ end
+
+ log.debug("Spawned with pid", pid_or_err)
+
+ stdout:read_start(connect_sink(stdout, opts.stdio_sink.stdout))
+ stderr:read_start(connect_sink(stderr, opts.stdio_sink.stderr))
+
+ return handle, stdio, pid_or_err
+end
+
+function M.empty_sink()
+ local function noop() end
+ return {
+ stdout = noop,
+ stderr = noop,
+ }
+end
+
+function M.simple_sink()
+ return {
+ stdout = vim.schedule_wrap(vim.api.nvim_out_write),
+ stderr = vim.schedule_wrap(vim.api.nvim_err_write),
+ }
+end
+
+function M.in_memory_sink()
+ local stdout, stderr = {}, {}
+ return {
+ buffers = { stdout = stdout, stderr = stderr },
+ sink = {
+ stdout = function(chunk)
+ stdout[#stdout + 1] = chunk
+ end,
+ stderr = function(chunk)
+ stderr[#stderr + 1] = chunk
+ end,
+ },
+ }
+end
+
+---@param luv_handle luv_handle
+---@param signal integer
+function M.kill(luv_handle, signal)
+ assert(type(signal) == "number", "signal is not a number")
+ assert(signal > 0 and signal < 32, "signal must be between 1-31")
+ log.fmt_trace("Sending signal %s to handle %s", signal, luv_handle)
+ local ok, is_active = pcall(uv.is_active, luv_handle)
+ if not ok or not is_active then
+ log.fmt_trace("Tried to send signal %s to inactive uv handle.", signal)
+ return
+ end
+ uv.process_kill(luv_handle, signal)
+end
+
+return M
diff --git a/lua/mason-core/receipt.lua b/lua/mason-core/receipt.lua
new file mode 100644
index 00000000..76cd7449
--- /dev/null
+++ b/lua/mason-core/receipt.lua
@@ -0,0 +1,180 @@
+local M = {}
+
+---@alias InstallReceiptSchemaVersion
+---| '"1.0"'
+
+---@alias InstallReceiptSourceType
+---| '"npm"'
+---| '"pip3"'
+---| '"gem"'
+---| '"go"'
+---| '"cargo"'
+---| '"opam"'
+---| '"dotnet"'
+---| '"r_package"'
+---| '"unmanaged"'
+---| '"system"'
+---| '"jdtls"'
+---| '"git"'
+---| '"github_tag"'
+---| '"github_release"'
+---| '"github_release_file"'
+
+---@alias InstallReceiptSource {type: InstallReceiptSourceType}
+
+---@class InstallReceiptLinks
+---@field bin table<string, string>
+
+---@class InstallReceiptBuilder
+---@field private secondary_sources InstallReceiptSource[]
+---@field private links InstallReceiptLinks
+---@field private epoch_time number
+local InstallReceiptBuilder = {}
+InstallReceiptBuilder.__index = InstallReceiptBuilder
+
+function InstallReceiptBuilder.new()
+ return setmetatable({
+ secondary_sources = {},
+ links = {
+ bin = vim.empty_dict(),
+ },
+ }, InstallReceiptBuilder)
+end
+
+---@param name string
+function InstallReceiptBuilder:with_name(name)
+ self.name = name
+ return self
+end
+
+---@param version InstallReceiptSchemaVersion
+function InstallReceiptBuilder:with_schema_version(version)
+ self.schema_version = version
+ return self
+end
+
+---@param source InstallReceiptSource
+function InstallReceiptBuilder:with_primary_source(source)
+ self.primary_source = source
+ return self
+end
+
+---@param source InstallReceiptSource
+function InstallReceiptBuilder:with_secondary_source(source)
+ table.insert(self.secondary_sources, source)
+ return self
+end
+
+---@param typ '"bin"'
+---@param name string
+---@param rel_path string
+function InstallReceiptBuilder:with_link(typ, name, rel_path)
+ assert(not self.links[typ][name], ("%s/%s has already been linked."):format(typ, name))
+ self.links[typ][name] = rel_path
+ return self
+end
+
+---@param seconds integer
+---@param microseconds integer
+local function to_ms(seconds, microseconds)
+ return (seconds * 1000) + math.floor(microseconds / 1000)
+end
+
+---vim.loop.gettimeofday()
+---@param seconds integer
+---@param microseconds integer
+function InstallReceiptBuilder:with_completion_time(seconds, microseconds)
+ self.completion_time = to_ms(seconds, microseconds)
+ return self
+end
+
+---vim.loop.gettimeofday()
+---@param seconds integer
+---@param microseconds integer
+function InstallReceiptBuilder:with_start_time(seconds, microseconds)
+ self.start_time = to_ms(seconds, microseconds)
+ return self
+end
+
+function InstallReceiptBuilder:build()
+ assert(self.name, "name is required")
+ assert(self.schema_version, "schema_version is required")
+ assert(self.start_time, "start_time is required")
+ assert(self.completion_time, "completion_time is required")
+ assert(self.primary_source, "primary_source is required")
+ return {
+ name = self.name,
+ schema_version = self.schema_version,
+ metrics = {
+ start_time = self.start_time,
+ completion_time = self.completion_time,
+ },
+ primary_source = self.primary_source,
+ secondary_sources = self.secondary_sources,
+ links = self.links,
+ }
+end
+
+---@param type InstallReceiptSourceType
+local function package_source(type)
+ ---@param pkg string
+ return function(pkg)
+ return { type = type, package = pkg }
+ end
+end
+
+InstallReceiptBuilder.npm = package_source "npm"
+InstallReceiptBuilder.pip3 = package_source "pip3"
+InstallReceiptBuilder.gem = package_source "gem"
+InstallReceiptBuilder.go = package_source "go"
+InstallReceiptBuilder.dotnet = package_source "dotnet"
+InstallReceiptBuilder.cargo = package_source "cargo"
+InstallReceiptBuilder.composer = package_source "composer"
+InstallReceiptBuilder.r_package = package_source "r_package"
+InstallReceiptBuilder.opam = package_source "opam"
+InstallReceiptBuilder.luarocks = package_source "luarocks"
+
+InstallReceiptBuilder.unmanaged = { type = "unmanaged" }
+
+---@param repo string
+---@param release string
+function InstallReceiptBuilder.github_release(repo, release)
+ return {
+ type = "github_release",
+ repo = repo,
+ release = release,
+ }
+end
+
+---@param dependency string
+function InstallReceiptBuilder.system(dependency)
+ return { type = "system", dependency = dependency }
+end
+
+---@param remote_url string
+function InstallReceiptBuilder.git_remote(remote_url)
+ return { type = "git", remote = remote_url }
+end
+
+---@class InstallReceipt
+---@field public name string
+---@field public schema_version InstallReceiptSchemaVersion
+---@field public metrics {start_time:integer, completion_time:integer}
+---@field public primary_source InstallReceiptSource
+---@field public secondary_sources InstallReceiptSource[]
+---@field public links InstallReceiptLinks
+local InstallReceipt = {}
+InstallReceipt.__index = InstallReceipt
+
+function InstallReceipt.new(props)
+ return setmetatable(props, InstallReceipt)
+end
+
+function InstallReceipt.from_json(json)
+ return InstallReceipt.new(json)
+end
+
+M.InstallReceiptBuilder = InstallReceiptBuilder
+M.InstallReceipt = InstallReceipt
+
+return M
diff --git a/lua/mason-core/result.lua b/lua/mason-core/result.lua
new file mode 100644
index 00000000..132e2758
--- /dev/null
+++ b/lua/mason-core/result.lua
@@ -0,0 +1,152 @@
+---@class Failure
+---@field error any
+local Failure = {}
+Failure.__index = Failure
+
+function Failure.new(error)
+ return setmetatable({ error = error }, Failure)
+end
+
+---@class Result
+---@field value any
+local Result = {}
+Result.__index = Result
+
+function Result.new(value)
+ return setmetatable({
+ value = value,
+ }, Result)
+end
+
+function Result.success(value)
+ return Result.new(value)
+end
+
+function Result.failure(error)
+ return Result.new(Failure.new(error))
+end
+
+function Result:get_or_nil()
+ if self:is_success() then
+ return self.value
+ end
+end
+
+function Result:get_or_else(value)
+ if self:is_success() then
+ return self.value
+ else
+ return value
+ end
+end
+
+---@param exception any @(optional) The exception to throw if the result is a failure.
+function Result:get_or_throw(exception)
+ if self:is_success() then
+ return self.value
+ else
+ if exception ~= nil then
+ error(exception, 2)
+ else
+ error(self.value.error, 2)
+ end
+ end
+end
+
+function Result:err_or_nil()
+ if self:is_failure() then
+ return self.value.error
+ end
+end
+
+function Result:is_failure()
+ return getmetatable(self.value) == Failure
+end
+
+function Result:is_success()
+ return getmetatable(self.value) ~= Failure
+end
+
+---@param mapper_fn fun(value: any): any
+function Result:map(mapper_fn)
+ if self:is_success() then
+ return Result.success(mapper_fn(self.value))
+ else
+ return self
+ end
+end
+
+---@param mapper_fn fun(value: any): any
+function Result:map_err(mapper_fn)
+ if self:is_failure() then
+ return Result.failure(mapper_fn(self.value.error))
+ else
+ return self
+ end
+end
+
+---@param mapper_fn fun(value: any): any
+function Result:map_catching(mapper_fn)
+ if self:is_success() then
+ local ok, result = pcall(mapper_fn, self.value)
+ if ok then
+ return Result.success(result)
+ else
+ return Result.failure(result)
+ end
+ else
+ return self
+ end
+end
+
+---@param recover_fn fun(value: any): any
+function Result:recover(recover_fn)
+ if self:is_failure() then
+ return Result.success(recover_fn(self:err_or_nil()))
+ else
+ return self
+ end
+end
+
+---@param recover_fn fun(value: any): any
+function Result:recover_catching(recover_fn)
+ if self:is_failure() then
+ local ok, value = pcall(recover_fn, self:err_or_nil())
+ if ok then
+ return Result.success(value)
+ else
+ return Result.failure(value)
+ end
+ else
+ return self
+ end
+end
+
+---@param fn fun(value: any): any
+function Result:on_failure(fn)
+ if self:is_failure() then
+ fn(self.value.error)
+ end
+ return self
+end
+
+---@param fn fun(value: any): any
+function Result:on_success(fn)
+ if self:is_success() then
+ fn(self.value)
+ end
+ return self
+end
+
+---@param fn fun(): any
+---@return Result
+function Result.run_catching(fn)
+ local ok, result = pcall(fn)
+ if ok then
+ return Result.success(result)
+ else
+ return Result.failure(result)
+ end
+end
+
+return Result
diff --git a/lua/mason-core/spawn.lua b/lua/mason-core/spawn.lua
new file mode 100644
index 00000000..6b783492
--- /dev/null
+++ b/lua/mason-core/spawn.lua
@@ -0,0 +1,112 @@
+local a = require "mason-core.async"
+local _ = require "mason-core.functional"
+local Result = require "mason-core.result"
+local process = require "mason-core.process"
+local platform = require "mason-core.platform"
+local log = require "mason-core.log"
+
+---@alias JobSpawn table<string, async fun(opts: JobSpawnOpts): Result>
+---@type JobSpawn
+local spawn = {
+ _aliases = {
+ npm = platform.is_win and "npm.cmd" or "npm",
+ gem = platform.is_win and "gem.cmd" or "gem",
+ composer = platform.is_win and "composer.bat" or "composer",
+ gradlew = platform.is_win and "gradlew.bat" or "gradlew",
+ -- for hererocks installations
+ luarocks = (platform.is_win and vim.fn.executable "luarocks.bat" == 1) and "luarocks.bat" or "luarocks",
+ rebar3 = platform.is_win and "rebar3.cmd" or "rebar3",
+ },
+ _flatten_cmd_args = _.compose(_.filter(_.complement(_.equals(vim.NIL))), _.flatten),
+}
+
+local function Failure(err, cmd)
+ return Result.failure(setmetatable(err, {
+ __tostring = function()
+ return ("spawn: %s failed with exit code %s and signal %s. %s"):format(
+ cmd,
+ err.exit_code or "-",
+ err.signal or "-",
+ err.stderr or ""
+ )
+ end,
+ }))
+end
+
+local is_executable = _.memoize(function(cmd)
+ if vim.in_fast_event() then
+ a.scheduler()
+ end
+ return vim.fn.executable(cmd) == 1
+end, _.identity)
+
+---@class SpawnArgs
+---@field with_paths string[] @Optional. Paths to add to the PATH environment variable.
+---@field env table<string, string> @Optional. Example { SOME_ENV = "value", SOME_OTHER_ENV = "some_value" }
+---@field env_raw string[] @Optional. Example: { "SOME_ENV=value", "SOME_OTHER_ENV=some_value" }
+---@field stdio_sink StdioSink @Optional. If provided, will be used to write to stdout and stderr.
+---@field cwd string @Optional
+---@field on_spawn fun(handle: luv_handle, stdio: luv_pipe[]) @Optional. Will be called when the process successfully spawns.
+---@field check_executable boolean @Optional. Whether to check if the provided command is executable (defaults to true).
+
+setmetatable(spawn, {
+ ---@param normalized_cmd string
+ __index = function(self, normalized_cmd)
+ ---@param args SpawnArgs
+ return function(args)
+ local cmd_args = self._flatten_cmd_args(args)
+ local env = args.env
+
+ if args.with_paths then
+ env = env or {}
+ env.PATH = process.extend_path(args.with_paths)
+ end
+
+ ---@type JobSpawnOpts
+ local spawn_args = {
+ stdio_sink = args.stdio_sink,
+ cwd = args.cwd,
+ env = env and process.graft_env(env) or args.env_raw,
+ args = cmd_args,
+ }
+
+ local stdio
+ if not spawn_args.stdio_sink then
+ stdio = process.in_memory_sink()
+ spawn_args.stdio_sink = stdio.sink
+ end
+
+ local cmd = self._aliases[normalized_cmd] or normalized_cmd
+
+ if (env and env.PATH) == nil and args.check_executable ~= false and not is_executable(cmd) then
+ log.fmt_debug("%s is not executable", cmd)
+ return Failure({
+ stderr = ("%s is not executable"):format(cmd),
+ }, cmd)
+ end
+
+ local _, exit_code, signal = a.wait(function(resolve)
+ local handle, stdio, pid = process.spawn(cmd, spawn_args, resolve)
+ if args.on_spawn and handle and stdio and pid then
+ args.on_spawn(handle, stdio, pid)
+ end
+ end)
+
+ if exit_code == 0 and signal == 0 then
+ return Result.success {
+ stdout = stdio and table.concat(stdio.buffers.stdout, "") or nil,
+ stderr = stdio and table.concat(stdio.buffers.stderr, "") or nil,
+ }
+ else
+ return Failure({
+ exit_code = exit_code,
+ signal = signal,
+ stdout = stdio and table.concat(stdio.buffers.stdout, "") or nil,
+ stderr = stdio and table.concat(stdio.buffers.stderr, "") or nil,
+ }, cmd)
+ end
+ end
+ end,
+})
+
+return spawn
diff --git a/lua/mason-core/ui/display.lua b/lua/mason-core/ui/display.lua
new file mode 100644
index 00000000..47368079
--- /dev/null
+++ b/lua/mason-core/ui/display.lua
@@ -0,0 +1,507 @@
+local log = require "mason-core.log"
+local state = require "mason-core.ui.state"
+
+local M = {}
+
+---@generic T
+---@param debounced_fn fun(arg1: T)
+---@return fun(arg1: T)
+local function debounced(debounced_fn)
+ local queued = false
+ local last_arg = nil
+ return function(a)
+ last_arg = a
+ if queued then
+ return
+ end
+ queued = true
+ vim.schedule(function()
+ debounced_fn(last_arg)
+ queued = false
+ last_arg = nil
+ end)
+ end
+end
+
+---@param line string
+---@param render_context RenderContext
+local function get_styles(line, render_context)
+ local indentation = 0
+
+ for i = 1, #render_context.applied_block_styles do
+ local styles = render_context.applied_block_styles[i]
+ for j = 1, #styles do
+ local style = styles[j]
+ if style == "INDENT" then
+ indentation = indentation + 2
+ elseif style == "CENTERED" then
+ local padding = math.floor((render_context.viewport_context.win_width - #line) / 2)
+ indentation = math.max(0, padding) -- CENTERED overrides any already applied indentation
+ end
+ end
+ end
+
+ return {
+ indentation = indentation,
+ }
+end
+
+---@param viewport_context ViewportContext
+---@param node INode
+---@param _render_context RenderContext|nil
+---@param _output RenderOutput|nil
+local function render_node(viewport_context, node, _render_context, _output)
+ ---@class RenderContext
+ ---@field viewport_context ViewportContext
+ ---@field applied_block_styles CascadingStyle[]
+ local render_context = _render_context
+ or {
+ viewport_context = viewport_context,
+ applied_block_styles = {},
+ }
+ ---@class RenderHighlight
+ ---@field hl_group string
+ ---@field line number
+ ---@field col_start number
+ ---@field col_end number
+
+ ---@class RenderKeybind
+ ---@field line number
+ ---@field key string
+ ---@field effect string
+ ---@field payload any
+
+ ---@class RenderDiagnostic
+ ---@field line number
+ ---@field diagnostic {message: string, severity: integer, source: string|nil}
+
+ ---@class RenderOutput
+ ---@field lines string[] @The buffer lines.
+ ---@field virt_texts string[][] @List of (text, highlight) tuples.
+ ---@field highlights RenderHighlight[]
+ ---@field keybinds RenderKeybind[]
+ ---@field diagnostics RenderDiagnostic[]
+ ---@field sticky_cursors { line_map: table<number, string>, id_map: table<string, number> }
+ local output = _output
+ or {
+ lines = {},
+ virt_texts = {},
+ highlights = {},
+ keybinds = {},
+ diagnostics = {},
+ sticky_cursors = { line_map = {}, id_map = {} },
+ }
+
+ if node.type == "VIRTUAL_TEXT" then
+ output.virt_texts[#output.virt_texts + 1] = {
+ line = #output.lines - 1,
+ content = node.virt_text,
+ }
+ elseif node.type == "HL_TEXT" then
+ for i = 1, #node.lines do
+ local line = node.lines[i]
+ local line_highlights = {}
+ local full_line = ""
+ for j = 1, #line do
+ local span = line[j]
+ local content, hl_group = span[1], span[2]
+ local col_start = #full_line
+ full_line = full_line .. content
+ if hl_group ~= "" then
+ line_highlights[#line_highlights + 1] = {
+ hl_group = hl_group,
+ line = #output.lines,
+ col_start = col_start,
+ col_end = col_start + #content,
+ }
+ end
+ end
+
+ local active_styles = get_styles(full_line, render_context)
+
+ -- apply indentation
+ full_line = (" "):rep(active_styles.indentation) .. full_line
+ for j = 1, #line_highlights do
+ local highlight = line_highlights[j]
+ highlight.col_start = highlight.col_start + active_styles.indentation
+ highlight.col_end = highlight.col_end + active_styles.indentation
+ output.highlights[#output.highlights + 1] = highlight
+ end
+
+ output.lines[#output.lines + 1] = full_line
+ end
+ elseif node.type == "NODE" or node.type == "CASCADING_STYLE" then
+ if node.type == "CASCADING_STYLE" then
+ render_context.applied_block_styles[#render_context.applied_block_styles + 1] = node.styles
+ end
+ for i = 1, #node.children do
+ render_node(viewport_context, node.children[i], render_context, output)
+ end
+ if node.type == "CASCADING_STYLE" then
+ render_context.applied_block_styles[#render_context.applied_block_styles] = nil
+ end
+ elseif node.type == "KEYBIND_HANDLER" then
+ output.keybinds[#output.keybinds + 1] = {
+ line = node.is_global and -1 or #output.lines,
+ key = node.key,
+ effect = node.effect,
+ payload = node.payload,
+ }
+ elseif node.type == "DIAGNOSTICS" then
+ output.diagnostics[#output.diagnostics + 1] = {
+ line = #output.lines,
+ message = node.diagnostic.message,
+ severity = node.diagnostic.severity,
+ source = node.diagnostic.source,
+ }
+ elseif node.type == "STICKY_CURSOR" then
+ output.sticky_cursors.id_map[node.id] = #output.lines
+ output.sticky_cursors.line_map[#output.lines] = node.id
+ end
+
+ return output
+end
+
+-- exported for tests
+M._render_node = render_node
+
+---@alias WindowOpts {effects: table<string, fun()>, highlight_groups: table<string, table>, border: string|table}
+
+---@param opts WindowOpenOpts
+---@param sizes_only boolean @Whether to only return properties that control the window size.
+local function create_popup_window_opts(opts, sizes_only)
+ local win_height = vim.o.lines - vim.o.cmdheight - 2 -- Add margin for status and buffer line
+ local win_width = vim.o.columns
+ local height = math.floor(win_height * 0.9)
+ local width = math.floor(win_width * 0.8)
+ local popup_layout = {
+ height = height,
+ width = width,
+ row = math.floor((win_height - height) / 2),
+ col = math.floor((win_width - width) / 2),
+ relative = "editor",
+ style = "minimal",
+ zindex = 50,
+ }
+
+ if not sizes_only then
+ popup_layout.border = opts.border
+ end
+
+ return popup_layout
+end
+
+---@param name string @Human readable identifier.
+---@param filetype string
+function M.new_view_only_win(name, filetype)
+ local namespace = vim.api.nvim_create_namespace(("installer_%s"):format(name))
+ local bufnr, renderer, mutate_state, get_state, unsubscribe, win_id, window_mgmt_augroup, autoclose_augroup, registered_keymaps, registered_keybinds, registered_effect_handlers
+ local has_initiated = false
+ ---@type WindowOpts
+ local window_opts = {}
+
+ local function delete_win_buf()
+ -- We queue the win_buf to be deleted in a schedule call, otherwise when used with folke/which-key (and
+ -- set timeoutlen=0) we run into a weird segfault.
+ -- It should probably be unnecessary once https://github.com/neovim/neovim/issues/15548 is resolved
+ vim.schedule(function()
+ if win_id and vim.api.nvim_win_is_valid(win_id) then
+ log.trace "Deleting window"
+ vim.api.nvim_win_close(win_id, true)
+ end
+ if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
+ log.trace "Deleting buffer"
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ end
+ end)
+ end
+
+ ---@param line number
+ ---@param key string
+ local function call_effect_handler(line, key)
+ local line_keybinds = registered_keybinds[line]
+ if line_keybinds then
+ local keybind = line_keybinds[key]
+ if keybind then
+ local effect_handler = registered_effect_handlers[keybind.effect]
+ if effect_handler then
+ log.fmt_trace("Calling handler for effect %s on line %d for key %s", keybind.effect, line, key)
+ effect_handler { payload = keybind.payload }
+ return true
+ end
+ end
+ end
+ return false
+ end
+
+ local function dispatch_effect(key)
+ local line = vim.api.nvim_win_get_cursor(0)[1]
+ log.fmt_trace("Dispatching effect on line %d, key %s, bufnr %s", line, key, bufnr)
+ call_effect_handler(line, key) -- line keybinds
+ call_effect_handler(-1, key) -- global keybinds
+ end
+
+ local output
+ local draw = function(view)
+ local win_valid = win_id ~= nil and vim.api.nvim_win_is_valid(win_id)
+ local buf_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
+ log.fmt_trace("got bufnr=%s", bufnr)
+ log.fmt_trace("got win_id=%s", win_id)
+
+ if not win_valid or not buf_valid then
+ -- the window has been closed or the buffer is somehow no longer valid
+ unsubscribe(true)
+ log.trace("Buffer or window is no longer valid", win_id, bufnr)
+ return
+ end
+
+ local win_width = vim.api.nvim_win_get_width(win_id)
+ ---@class ViewportContext
+ local viewport_context = {
+ win_width = win_width,
+ }
+ local cursor_pos_pre_render = vim.api.nvim_win_get_cursor(win_id)
+ local sticky_cursor
+ if output then
+ sticky_cursor = output.sticky_cursors.line_map[cursor_pos_pre_render[1]]
+ end
+
+ output = render_node(viewport_context, view)
+ local lines, virt_texts, highlights, keybinds, diagnostics =
+ output.lines, output.virt_texts, output.highlights, output.keybinds, output.diagnostics
+
+ -- set line contents
+ vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
+ vim.api.nvim_buf_set_option(bufnr, "modifiable", true)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ vim.api.nvim_buf_set_option(bufnr, "modifiable", false)
+
+ -- restore sticky cursor position
+ if sticky_cursor then
+ local new_sticky_cursor_line = output.sticky_cursors.id_map[sticky_cursor]
+ if new_sticky_cursor_line and new_sticky_cursor_line ~= cursor_pos_pre_render then
+ vim.api.nvim_win_set_cursor(win_id, { new_sticky_cursor_line, cursor_pos_pre_render[2] })
+ end
+ end
+
+ -- set virtual texts
+ for i = 1, #virt_texts do
+ local virt_text = virt_texts[i]
+ vim.api.nvim_buf_set_extmark(bufnr, namespace, virt_text.line, 0, {
+ virt_text = virt_text.content,
+ })
+ end
+
+ -- set diagnostics
+ vim.diagnostic.set(
+ namespace,
+ bufnr,
+ vim.tbl_map(function(diagnostic)
+ return {
+ lnum = diagnostic.line - 1,
+ col = 0,
+ message = diagnostic.message,
+ severity = diagnostic.severity,
+ source = diagnostic.source,
+ }
+ end, diagnostics),
+ {
+ signs = false,
+ }
+ )
+
+ -- set highlights
+ for i = 1, #highlights do
+ local highlight = highlights[i]
+ vim.api.nvim_buf_add_highlight(
+ bufnr,
+ namespace,
+ highlight.hl_group,
+ highlight.line,
+ highlight.col_start,
+ highlight.col_end
+ )
+ end
+
+ -- set keybinds
+ registered_keybinds = {}
+ for i = 1, #keybinds do
+ local keybind = keybinds[i]
+ if not registered_keybinds[keybind.line] then
+ registered_keybinds[keybind.line] = {}
+ end
+ registered_keybinds[keybind.line][keybind.key] = keybind
+ if not registered_keymaps[keybind.key] then
+ registered_keymaps[keybind.key] = true
+ vim.keymap.set("n", keybind.key, function()
+ dispatch_effect(keybind.key)
+ end, {
+ buffer = bufnr,
+ nowait = true,
+ silent = true,
+ })
+ end
+ end
+ end
+
+ ---@param opts WindowOpenOpts
+ local function open(opts)
+ bufnr = vim.api.nvim_create_buf(false, true)
+ win_id = vim.api.nvim_open_win(bufnr, true, create_popup_window_opts(opts, false))
+
+ registered_effect_handlers = window_opts.effects
+ registered_keybinds = {}
+ registered_keymaps = {}
+
+ local buf_opts = {
+ modifiable = false,
+ swapfile = false,
+ textwidth = 0,
+ buftype = "nofile",
+ bufhidden = "wipe",
+ buflisted = false,
+ filetype = filetype,
+ undolevels = -1,
+ }
+
+ local win_opts = {
+ number = false,
+ relativenumber = false,
+ wrap = false,
+ spell = false,
+ foldenable = false,
+ signcolumn = "no",
+ colorcolumn = "",
+ cursorline = true,
+ }
+
+ -- window options
+ for key, value in pairs(win_opts) do
+ vim.api.nvim_win_set_option(win_id, key, value)
+ end
+
+ -- buffer options
+ for key, value in pairs(buf_opts) do
+ vim.api.nvim_buf_set_option(bufnr, key, value)
+ end
+
+ vim.cmd [[ syntax clear ]]
+
+ window_mgmt_augroup = vim.api.nvim_create_augroup("MasonWindowMgmt", {})
+ autoclose_augroup = vim.api.nvim_create_augroup("MasonWindow", {})
+
+ vim.api.nvim_create_autocmd({ "VimResized" }, {
+ group = window_mgmt_augroup,
+ buffer = bufnr,
+ callback = function()
+ if vim.api.nvim_win_is_valid(win_id) then
+ draw(renderer(get_state()))
+ vim.api.nvim_win_set_config(win_id, create_popup_window_opts(window_opts, true))
+ end
+ end,
+ })
+
+ vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, {
+ group = autoclose_augroup,
+ buffer = bufnr,
+ callback = function()
+ -- Schedule is done because otherwise the window wont actually close in some cases (for example if
+ -- you're loading another buffer into it)
+ vim.schedule(function()
+ if vim.api.nvim_win_is_valid(win_id) then
+ vim.api.nvim_win_close(win_id, true)
+ end
+ end)
+ end,
+ })
+
+ local win_enter_aucmd
+ win_enter_aucmd = vim.api.nvim_create_autocmd({ "WinEnter" }, {
+ group = autoclose_augroup,
+ pattern = "*",
+ callback = function()
+ local buftype = vim.api.nvim_buf_get_option(0, "buftype")
+ -- This allows us to keep the floating window open for things like diagnostic popups, UI inputs รก la dressing.nvim, etc.
+ if buftype ~= "prompt" and buftype ~= "nofile" then
+ delete_win_buf()
+ vim.api.nvim_del_autocmd(win_enter_aucmd)
+ end
+ end,
+ })
+
+ return win_id
+ end
+
+ return {
+ ---@param _renderer fun(state: table): table
+ view = function(_renderer)
+ renderer = _renderer
+ end,
+ ---@param _effects table<string, fun()>
+ effects = function(_effects)
+ window_opts.effects = _effects
+ end,
+ ---@generic T : table
+ ---@param initial_state T
+ ---@return fun(mutate_fn: fun(current_state: T)), fun(): T
+ state = function(initial_state)
+ mutate_state, get_state, unsubscribe = state.create_state_container(
+ initial_state,
+ debounced(function(new_state)
+ draw(renderer(new_state))
+ end)
+ )
+
+ -- we don't need to subscribe to state changes until the window is actually opened
+ unsubscribe(true)
+
+ return mutate_state, get_state
+ end,
+ ---@param opts WindowOpts
+ init = function(opts)
+ assert(renderer ~= nil, "No view function has been registered. Call .view() before .init().")
+ assert(unsubscribe ~= nil, "No state has been registered. Call .state() before .init().")
+ window_opts = opts
+ if opts.highlight_groups then
+ for hl_name, args in pairs(opts.highlight_groups) do
+ vim.api.nvim_set_hl(0, hl_name, args)
+ end
+ end
+ has_initiated = true
+ end,
+ ---@alias WindowOpenOpts { border: string | table }
+ ---@type fun(opts: WindowOpenOpts)
+ open = vim.schedule_wrap(function(opts)
+ log.trace "Opening window"
+ assert(has_initiated, "Display has not been initiated, cannot open.")
+ if win_id and vim.api.nvim_win_is_valid(win_id) then
+ -- window is already open
+ return
+ end
+ unsubscribe(false)
+ open(opts)
+ draw(renderer(get_state()))
+ end),
+ ---@type fun()
+ close = vim.schedule_wrap(function()
+ assert(has_initiated, "Display has not been initiated, cannot close.")
+ unsubscribe(true)
+ log.fmt_trace("Closing window win_id=%s, bufnr=%s", win_id, bufnr)
+ delete_win_buf()
+ vim.api.nvim_del_augroup_by_id(window_mgmt_augroup)
+ vim.api.nvim_del_augroup_by_id(autoclose_augroup)
+ end),
+ ---@param pos number[] @(row, col) tuple
+ set_cursor = function(pos)
+ assert(win_id ~= nil, "Window has not been opened, cannot set cursor.")
+ return vim.api.nvim_win_set_cursor(win_id, pos)
+ end,
+ ---@return number[] @(row, col) tuple
+ get_cursor = function()
+ assert(win_id ~= nil, "Window has not been opened, cannot get cursor.")
+ return vim.api.nvim_win_get_cursor(win_id)
+ end,
+ }
+end
+
+return M
diff --git a/lua/mason-core/ui/init.lua b/lua/mason-core/ui/init.lua
new file mode 100644
index 00000000..0b288b20
--- /dev/null
+++ b/lua/mason-core/ui/init.lua
@@ -0,0 +1,203 @@
+local _ = require "mason-core.functional"
+local M = {}
+
+---@alias NodeType
+---| '"NODE"'
+---| '"CASCADING_STYLE"'
+---| '"VIRTUAL_TEXT"'
+---| '"DIAGNOSTICS"'
+---| '"HL_TEXT"'
+---| '"KEYBIND_HANDLER"'
+---| '"STICKY_CURSOR"'
+
+---@alias INode Node | HlTextNode | CascadingStyleNode | VirtualTextNode | KeybindHandlerNode | DiagnosticsNode | StickyCursorNode
+
+---@param children INode[]
+function M.Node(children)
+ ---@class Node
+ local node = {
+ type = "NODE",
+ children = children,
+ }
+ return node
+end
+
+---@param lines_with_span_tuples string[][]|string[]
+function M.HlTextNode(lines_with_span_tuples)
+ if type(lines_with_span_tuples[1]) == "string" then
+ -- this enables a convenience API for just rendering a single line (with just a single span)
+ lines_with_span_tuples = { { lines_with_span_tuples } }
+ end
+ ---@class HlTextNode
+ local node = {
+ type = "HL_TEXT",
+ lines = lines_with_span_tuples,
+ }
+ return node
+end
+
+local create_unhighlighted_lines = _.map(function(line)
+ return { { line, "" } }
+end)
+
+---@param lines string[]
+function M.Text(lines)
+ return M.HlTextNode(create_unhighlighted_lines(lines))
+end
+
+---@alias CascadingStyle
+---| '"INDENT"'
+---| '"CENTERED"'
+
+---@param styles CascadingStyle[]
+---@param children INode[]
+function M.CascadingStyleNode(styles, children)
+ ---@class CascadingStyleNode
+ local node = {
+ type = "CASCADING_STYLE",
+ styles = styles,
+ children = children,
+ }
+ return node
+end
+
+---@param virt_text string[][] @List of (text, highlight) tuples.
+function M.VirtualTextNode(virt_text)
+ ---@class VirtualTextNode
+ local node = {
+ type = "VIRTUAL_TEXT",
+ virt_text = virt_text,
+ }
+ return node
+end
+
+---@param diagnostic {message: string, severity: integer, source: string|nil}
+function M.DiagnosticsNode(diagnostic)
+ ---@class DiagnosticsNode
+ local node = {
+ type = "DIAGNOSTICS",
+ diagnostic = diagnostic,
+ }
+ return node
+end
+
+---@param condition boolean
+---@param node INode | fun(): INode
+---@param default_val any
+function M.When(condition, node, default_val)
+ if condition then
+ if type(node) == "function" then
+ return node()
+ else
+ return node
+ end
+ end
+ return default_val or M.Node {}
+end
+
+---@param key string @The keymap to register to. Example: "<CR>".
+---@param effect string @The effect to call when keymap is triggered by the user.
+---@param payload any @The payload to pass to the effect handler when triggered.
+---@param is_global boolean|nil @Whether to register the keybind to apply on all lines in the buffer.
+function M.Keybind(key, effect, payload, is_global)
+ ---@class KeybindHandlerNode
+ local node = {
+ type = "KEYBIND_HANDLER",
+ key = key,
+ effect = effect,
+ payload = payload,
+ is_global = is_global or false,
+ }
+ return node
+end
+
+function M.EmptyLine()
+ return M.Text { "" }
+end
+
+---@param rows string[][][] @A list of rows to include in the table. Each row consists of an array of (text, highlight) tuples (aka spans).
+function M.Table(rows)
+ local col_maxwidth = {}
+ for i = 1, #rows do
+ local row = rows[i]
+ for j = 1, #row do
+ local col = row[j]
+ local content = col[1]
+ col_maxwidth[j] = math.max(vim.api.nvim_strwidth(content), col_maxwidth[j] or 0)
+ end
+ end
+
+ for i = 1, #rows do
+ local row = rows[i]
+ for j = 1, #row do
+ local col = row[j]
+ local content = col[1]
+ col[1] = content .. string.rep(" ", col_maxwidth[j] - vim.api.nvim_strwidth(content) + 1) -- +1 for default minimum padding
+ end
+ end
+
+ return M.HlTextNode(rows)
+end
+
+---@param opts { id: string }
+function M.StickyCursor(opts)
+ ---@class StickyCursorNode
+ local node = {
+ type = "STICKY_CURSOR",
+ id = opts.id,
+ }
+ return node
+end
+
+---Makes it possible to create stateful animations by progressing from the start of a range to the end.
+---This is done in "ticks", in accordance with the provided options.
+---@param opts {range: integer[], delay_ms: integer, start_delay_ms: integer, iteration_delay_ms: integer}
+function M.animation(opts)
+ local animation_fn = opts[1]
+ local start_tick, end_tick = opts.range[1], opts.range[2]
+ local is_animating = false
+
+ local function start_animation()
+ if is_animating then
+ return
+ end
+ local tick, start
+
+ tick = function(current_tick)
+ animation_fn(current_tick)
+ if current_tick < end_tick then
+ vim.defer_fn(function()
+ tick(current_tick + 1)
+ end, opts.delay_ms)
+ else
+ is_animating = false
+ if opts.iteration_delay_ms then
+ start(opts.iteration_delay_ms)
+ end
+ end
+ end
+
+ start = function(delay_ms)
+ is_animating = true
+ if delay_ms then
+ vim.defer_fn(function()
+ tick(start_tick)
+ end, delay_ms)
+ else
+ tick(start_tick)
+ end
+ end
+
+ start(opts.start_delay_ms)
+
+ local function cancel()
+ is_animating = false
+ end
+
+ return cancel
+ end
+
+ return start_animation
+end
+
+return M
diff --git a/lua/mason-core/ui/state.lua b/lua/mason-core/ui/state.lua
new file mode 100644
index 00000000..9d7bcdda
--- /dev/null
+++ b/lua/mason-core/ui/state.lua
@@ -0,0 +1,24 @@
+local M = {}
+
+---@generic T : table
+---@param initial_state T
+---@param subscriber fun(state: T)
+function M.create_state_container(initial_state, subscriber)
+ -- we do deepcopy to make sure instances of state containers doesn't mutate the initial state
+ local state = vim.deepcopy(initial_state)
+ local has_unsubscribed = false
+
+ ---@param mutate_fn fun(current_state: table)
+ return function(mutate_fn)
+ mutate_fn(state)
+ if not has_unsubscribed then
+ subscriber(state)
+ end
+ end, function()
+ return state
+ end, function(val)
+ has_unsubscribed = val
+ end
+end
+
+return M