diff options
| author | William Boman <william@redwill.se> | 2022-07-08 18:34:38 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-07-08 18:34:38 +0200 |
| commit | 976aa4fbee8a070f362cab6f6ec84e9251a90cf9 (patch) | |
| tree | 5e8d9c9c59444a25c7801b8f39763c4ba6e1f76d /lua/mason-core | |
| parent | feat: add gotests, gomodifytags, impl (#28) (diff) | |
| download | mason-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')
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 |
