paq.lua (20418B) - raw


      1 -- This file is a modified version of paq-nvim (https://github.com/savq/paq-nvim),
      2 -- which has the following notice.
      3 -- Last update: 2023-11-06 (commit 07eb567dbb70044cabc622900b2bf755f7aecb99)
      4 
      5 -- MIT License
      6 --
      7 -- Copyright (c) 2020 Sergio Alejandro Vargas
      8 --
      9 -- Permission is hereby granted, free of charge, to any person obtaining a copy
     10 -- of this software and associated documentation files (the "Software"), to deal
     11 -- in the Software without restriction, including without limitation the rights
     12 -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     13 -- copies of the Software, and to permit persons to whom the Software is
     14 -- furnished to do so, subject to the following conditions:
     15 --
     16 -- The above copyright notice and this permission notice shall be included in all
     17 -- copies or substantial portions of the Software.
     18 --
     19 -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     20 -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     21 -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     22 -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
     23 -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
     24 -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
     25 -- SOFTWARE.
     26 
     27 -- VARS: {{{
     28 
     29 ---@alias Path string
     30 
     31 local uv = vim.loop
     32 
     33 ---@class setup_opts
     34 ---@field path Path
     35 ---@field opt boolean
     36 ---@field verbose boolean
     37 ---@field log Path
     38 ---@field lock Path
     39 ---@field url_format string
     40 ---@field clone_args string[]
     41 local Config = {
     42     path = vim.fn.stdpath("data") .. "/site/pack/paqs/",
     43     opt = false,
     44     verbose = false,
     45     url_format = "https://github.com/%s.git",
     46     log = vim.fn.stdpath(vim.fn.has("nvim-0.8") == 1 and "log" or "cache") .. "/paq.log",
     47     lock = vim.fn.stdpath("data") .. "/paq-lock.json",
     48     clone_args = { "--recurse-submodules", "--shallow-submodules", "--no-single-branch" }
     49 }
     50 
     51 ---@enum Messages
     52 local Messages = {
     53     install = { ok = "Installed", err = "Failed to install" },
     54     update = { ok = "Updated", err = "Failed to update", nop = "(up-to-date)" },
     55     remove = { ok = "Removed", err = "Failed to remove" },
     56     build = { ok = "Built", err = "Failed to build" },
     57 }
     58 
     59 local Lock = {}     -- Table of pgks loaded from the lockfile
     60 local Packages = {} -- Table of pkgs loaded from the user configuration
     61 
     62 ---@enum Status
     63 local Status = {
     64     INSTALLED = 0,
     65     CLONED = 1,
     66     UPDATED = 2,
     67     REMOVED = 3,
     68     TO_INSTALL = 4,
     69     TO_MOVE = 5,
     70     TO_RECLONE = 6,
     71 }
     72 
     73 -- stylua: ignore
     74 local Filter = {
     75     installed   = function(p) return p.status ~= Status.REMOVED and p.status ~= Status.TO_INSTALL end,
     76     not_removed = function(p) return p.status ~= Status.REMOVED end,
     77     removed     = function(p) return p.status == Status.REMOVED end,
     78     to_install  = function(p) return p.status == Status.TO_INSTALL end,
     79     to_update   = function(p) return p.status ~= Status.REMOVED and p.status ~= Status.TO_INSTALL and not p.pin end,
     80     to_move     = function(p) return p.status == Status.TO_MOVE end,
     81     to_reclone  = function(p) return p.status == Status.TO_RECLONE end,
     82 }
     83 
     84 -- Copy environment variables once. Doing it for every process seems overkill.
     85 local Env = {}
     86 for var, val in pairs(uv.os_environ()) do
     87     table.insert(Env, string.format("%s=%s", var, val))
     88 end
     89 table.insert(Env, "GIT_TERMINAL_PROMPT=0")
     90 
     91 -- }}}
     92 -- UTILS: {{{
     93 
     94 ---@return Package
     95 local function find_unlisted()
     96     local unlisted = {}
     97     -- TODO(breaking): Replace with `vim.fs.dir`
     98     for _, packdir in pairs { "start", "opt" } do
     99         local path = Config.path .. packdir
    100         local handle = uv.fs_scandir(path)
    101         while handle do
    102             local name, t = uv.fs_scandir_next(handle)
    103             if t == "directory" and name ~= "paq-nvim" then
    104                 local dir = path .. "/" .. name
    105                 local pkg = Packages[name]
    106                 if not pkg or pkg.dir ~= dir then
    107                     table.insert(unlisted, { name = name, dir = dir })
    108                 end
    109             elseif not name then
    110                 break
    111             end
    112         end
    113     end
    114     return unlisted
    115 end
    116 
    117 ---@param dir Path
    118 ---@return string
    119 local function get_git_hash(dir)
    120     local first_line = function(path)
    121         local file = io.open(path)
    122         if file then
    123             local line = file:read()
    124             file:close()
    125             return line
    126         end
    127     end
    128     local head_ref = first_line(dir .. "/.git/HEAD")
    129     return head_ref and first_line(dir .. "/.git/" .. head_ref:gsub("ref: ", ""))
    130 end
    131 
    132 ---@param process string
    133 ---@param args string[]
    134 ---@param cwd string?
    135 ---@param cb function
    136 ---@param print_stdout boolean?
    137 local function run(process, args, cwd, cb, print_stdout)
    138     local log = uv.fs_open(Config.log, "a+", 0x1A4)
    139     local stderr = uv.new_pipe(false)
    140     stderr:open(log)
    141     local handle, pid
    142     handle, pid = uv.spawn(
    143         process,
    144         { args = args, cwd = cwd, stdio = { nil, print_stdout and stderr, stderr }, env = Env },
    145         vim.schedule_wrap(function(code)
    146             uv.fs_close(log)
    147             stderr:close()
    148             handle:close()
    149             cb(code == 0)
    150         end)
    151     )
    152     if not handle then
    153         vim.notify(string.format(" Paq: Failed to spawn %s (%s)", process, pid))
    154     end
    155 end
    156 
    157 ---Return an interator that walks `dir` in post-order.
    158 ---@param dir Path
    159 local function walkdir(dir)
    160     return coroutine.wrap(function()
    161         local handle = uv.fs_scandir(dir)
    162         while handle do
    163             local name, t = uv.fs_scandir_next(handle)
    164             if not name then
    165                 return
    166             elseif t == "directory" then
    167                 for child, t in walkdir(dir .. "/" .. name) do
    168                     coroutine.yield(child, t)
    169                 end
    170             end
    171             coroutine.yield(dir .. "/" .. name, t)
    172         end
    173     end)
    174 end
    175 
    176 ---@param dir Path
    177 local function rmdir(dir)
    178     for name, t in walkdir(dir) do
    179         local ok = (t == "directory") and uv.fs_rmdir(name) or uv.fs_unlink(name)
    180         if not ok then
    181             return ok
    182         end
    183     end
    184     return uv.fs_rmdir(dir)
    185 end
    186 
    187 
    188 -- }}}
    189 -- LOGGING: {{{
    190 
    191 ---@param pkg Package
    192 ---@param prev_hash string
    193 ---@param cur_hash string
    194 local function log_update_changes(pkg, prev_hash, cur_hash)
    195     local output = { "\n\n" .. pkg.name .. " updated:\n" }
    196     local stdout = uv.new_pipe()
    197     local options = {
    198         args = { "log", "--pretty=format:* %s", prev_hash .. ".." .. cur_hash },
    199         cwd = pkg.dir,
    200         stdio = { nil, stdout, nil },
    201     }
    202     local handle
    203     handle, _ = uv.spawn("git", options, function(code)
    204         assert(code == 0, "Exited(" .. code .. ")")
    205         handle:close()
    206         local log = uv.fs_open(Config.log, "a+", 0x1A4)
    207         uv.fs_write(log, output, nil, nil)
    208         uv.fs_close(log)
    209     end)
    210     stdout:read_start(function(err, data)
    211         assert(not err, err)
    212         table.insert(output, data)
    213     end)
    214 end
    215 
    216 ---@param name string
    217 ---@param msg_op Messages
    218 ---@param result boolean
    219 ---@param n integer
    220 ---@param total integer
    221 local function report(name, msg_op, result, n, total)
    222     local count = n and string.format(" [%d/%d]", n, total) or ""
    223     vim.notify(
    224         string.format(" Paq:%s %s %s", count, msg_op[result], name),
    225         result == "err" and vim.log.levels.ERROR or vim.log.levels.INFO
    226     )
    227 end
    228 
    229 ---Object to track result of operations (installs, updates, etc.)
    230 ---@param total integer
    231 ---@param callback function
    232 local function new_counter(total, callback)
    233     return coroutine.wrap(function()
    234         local c = { ok = 0, err = 0, nop = 0 }
    235         while c.ok + c.err + c.nop < total do
    236             local name, msg_op, result = coroutine.yield(true)
    237             c[result] = c[result] + 1
    238             if result ~= "nop" or Config.verbose then
    239                 report(name, msg_op, result, c.ok + c.nop, total)
    240             end
    241         end
    242         callback(c.ok, c.err, c.nop)
    243         return true
    244     end)
    245 end
    246 
    247 
    248 -- }}}
    249 -- LOCKFILE: {{{
    250 
    251 local function lock_write()
    252     -- remove run key since can have a function in it, and
    253     -- json.encode doesn't support functions
    254     local pkgs = vim.deepcopy(Packages)
    255     for p, _ in pairs(pkgs) do
    256         pkgs[p].build = nil
    257     end
    258     local file = uv.fs_open(Config.lock, "w", 438)
    259     if file then
    260         local ok, result = pcall(vim.json.encode, pkgs)
    261         if not ok then
    262             error(result)
    263         end
    264         assert(uv.fs_write(file, result))
    265         assert(uv.fs_close(file))
    266     end
    267     Lock = Packages
    268 end
    269 
    270 local function lock_load()
    271     -- don't really know why 438 see ':h uv_fs_t'
    272     local file = uv.fs_open(Config.lock, "r", 438)
    273     if file then
    274         local stat = assert(uv.fs_fstat(file))
    275         local data = assert(uv.fs_read(file, stat.size, 0))
    276         assert(uv.fs_close(file))
    277         local ok, result = pcall(vim.json.decode, data)
    278         if ok then
    279             Lock = not vim.tbl_isempty(result) and result or Packages
    280             -- Repopulate 'build' key so 'vim.deep_equal' works
    281             for name, pkg in pairs(result) do
    282                 pkg.build = Packages[name] and Packages[name].build or nil
    283             end
    284         end
    285     else
    286         lock_write()
    287         Lock = Packages
    288     end
    289 end
    290 
    291 -- }}}
    292 -- PKGS: {{{
    293 
    294 ---@class Package
    295 ---@field name string
    296 ---@field as string
    297 ---@field branch string
    298 ---@field shallow boolean
    299 ---@field dir string
    300 ---@field status Status
    301 ---@field hash string
    302 ---@field pin boolean
    303 ---@field build string | function
    304 ---@field url string
    305 
    306 ---@param pkg Package
    307 ---@param counter function
    308 ---@param build_queue table
    309 local function clone(pkg, counter, build_queue)
    310     local args = vim.list_extend({ "clone", pkg.url }, Config.clone_args)
    311     if pkg.shallow then
    312         vim.list_extend(args, { "--depth=1" })
    313     end
    314     if pkg.branch then
    315         vim.list_extend(args, { "-b", pkg.branch })
    316     end
    317     table.insert(args, pkg.dir)
    318     run("git", args, nil, function(ok)
    319         if ok then
    320             pkg.status = Status.CLONED
    321             lock_write()
    322             if pkg.build then
    323                 table.insert(build_queue, pkg)
    324             end
    325         end
    326         counter(pkg.name, Messages.install, ok and "ok" or "err")
    327     end)
    328 end
    329 
    330 ---@param pkg Package
    331 ---@param counter function
    332 ---@param build_queue table
    333 local function pull(pkg, counter, build_queue)
    334     local prev_hash = Lock[pkg.name] and Lock[pkg.name].hash or pkg.hash
    335     run("git", { "pull", "--recurse-submodules", "--update-shallow" }, pkg.dir, function(ok)
    336         if not ok then
    337             counter(pkg.name, Messages.update, "err")
    338         else
    339             local cur_hash = pkg.hash
    340             if cur_hash ~= prev_hash then
    341                 log_update_changes(pkg, prev_hash, cur_hash)
    342                 pkg.status = Status.UPDATED
    343                 lock_write()
    344                 counter(pkg.name, Messages.update, "ok")
    345                 if pkg.build then
    346                     table.insert(build_queue, pkg)
    347                 end
    348             else
    349                 counter(pkg.name, Messages.update, "nop")
    350             end
    351         end
    352     end)
    353 end
    354 
    355 ---@param pkg Package
    356 ---@param counter function
    357 ---@param build_queue table
    358 local function clone_or_pull(pkg, counter, build_queue)
    359     if Filter.to_update(pkg) then
    360         pull(pkg, counter, build_queue)
    361     elseif Filter.to_install(pkg) then
    362         clone(pkg, counter, build_queue)
    363     end
    364 end
    365 
    366 ---Move package to wanted location.
    367 ---@param src Package
    368 ---@param dst Package
    369 local function move(src, dst)
    370     local ok = uv.fs_rename(src.dir, dst.dir)
    371     if ok then
    372         dst.status = Status.INSTALLED
    373         lock_write()
    374     end
    375 end
    376 
    377 ---@param pkg Package
    378 local function run_build(pkg)
    379     local t = type(pkg.build)
    380     if t == "function" then
    381         local ok = pcall(pkg.build)
    382         report(pkg.name, Messages.build, ok and "ok" or "err")
    383     elseif t == "string" and pkg.build:sub(1, 1) == ":" then
    384         local ok = pcall(vim.cmd, pkg.build)
    385         report(pkg.name, Messages.build, ok and "ok" or "err")
    386     elseif t == "string" then
    387         local args = {}
    388         for word in pkg.build:gmatch("%S+") do
    389             table.insert(args, word)
    390         end
    391         run(table.remove(args, 1), args, pkg.dir, function(ok)
    392             report(pkg.name, Messages.build, ok and "ok" or "err")
    393         end)
    394     end
    395 end
    396 
    397 ---@param pkg Package
    398 local function reclone(pkg, counter, build_queue)
    399     local ok = rmdir(pkg.dir)
    400     if not ok then
    401         return
    402     end
    403     local args = vim.list_extend({ "clone", pkg.url }, Config.clone_args)
    404     if pkg.branch then
    405         vim.list_extend(args, { "-b", pkg.branch })
    406     end
    407     table.insert(args, pkg.dir)
    408     run("git", args, nil, function(ok)
    409         if ok then
    410             pkg.status = Status.INSTALLED
    411             pkg.hash = get_git_hash(pkg.dir)
    412             lock_write()
    413             if pkg.build then
    414                 table.insert(build_queue, pkg)
    415             end
    416         end
    417     end)
    418 end
    419 
    420 local function resolve(pkg, counter, build_queue)
    421     if Filter.to_move(pkg) then
    422         move(pkg, Packages[pkg.name])
    423     elseif Filter.to_reclone(pkg) then
    424         reclone(Packages[pkg.name], counter, build_queue)
    425     end
    426 end
    427 
    428 ---@param pkg Package
    429 local function register(pkg)
    430     if type(pkg) == "string" then
    431         pkg = { pkg }
    432     end
    433     local url = pkg.url
    434         or (pkg[1]:match("^https?://") and pkg[1])                      -- [1] is a URL
    435         or string.format(Config.url_format, pkg[1])                     -- [1] is a repository name
    436     local name = pkg.as or url:gsub("%.git$", ""):match("/([%w-_.]+)$") -- Infer name from `url`
    437     if not name then
    438         return vim.notify(" Paq: Failed to parse " .. vim.inspect(pkg), vim.log.levels.ERROR)
    439     end
    440     local opt = pkg.opt or Config.opt and pkg.opt == nil
    441     local dir = Config.path .. (opt and "opt/" or "start/") .. name
    442     Packages[name] = {
    443         name = name,
    444         branch = pkg.branch,
    445         shallow = pkg.shallow == nil or pkg.shallow,
    446         dir = dir,
    447         status = uv.fs_stat(dir) and Status.INSTALLED or Status.TO_INSTALL,
    448         hash = get_git_hash(dir),
    449         pin = pkg.pin,
    450         build = pkg.build or pkg.run,
    451         url = url,
    452     }
    453     if pkg.run then
    454         vim.deprecate("`run` option", "`build`", "3.0", "Paq", false)
    455     end
    456 end
    457 
    458 ---@param pkg Package
    459 ---@param counter function
    460 local function remove(pkg, counter)
    461     local ok = rmdir(pkg.dir)
    462     counter(pkg.name, Messages.remove, ok and "ok" or "err")
    463     if ok then
    464         Packages[pkg.name] = { name = pkg.name, status = Status.REMOVED }
    465         lock_write()
    466     end
    467 end
    468 
    469 ---@alias Operation
    470 ---| '"install"'
    471 ---| '"update"'
    472 ---| '"remove"'
    473 ---| '"build"'
    474 ---| '"resolve"'
    475 ---| '"sync"'
    476 
    477 ---Boilerplate around operations (autocmds, counter initialization, etc.)
    478 ---@param op Operation
    479 ---@param fn function
    480 ---@param pkgs Package[]
    481 ---@param silent boolean?
    482 local function exe_op(op, fn, pkgs, silent)
    483     if #pkgs == 0 then
    484         if not silent then
    485             vim.notify(" Paq: Nothing to " .. op)
    486         end
    487         vim.cmd("doautocmd User PaqDone" .. op:gsub("^%l", string.upper))
    488         return
    489     end
    490 
    491     local build_queue = {}
    492 
    493     local function after(ok, err, nop)
    494         local summary = " Paq: %s complete. %d ok; %d errors;" .. (nop > 0 and " %d no-ops" or "")
    495         vim.notify(string.format(summary, op, ok, err, nop))
    496         vim.cmd("packloadall! | silent! helptags ALL")
    497         if #build_queue ~= 0 then
    498             exe_op("build", run_build, build_queue)
    499         end
    500         vim.cmd("doautocmd User PaqDone" .. op:gsub("^%l", string.upper))
    501     end
    502 
    503     local counter = new_counter(#pkgs, after)
    504     counter() -- Initialize counter
    505 
    506     for _, pkg in pairs(pkgs) do
    507         fn(pkg, counter, build_queue)
    508     end
    509 end
    510 
    511 -- }}}
    512 -- DIFFS: {{{
    513 
    514 local function diff_gather()
    515     local diffs = {}
    516     for name, lock_pkg in pairs(Lock) do
    517         local pack_pkg = Packages[name]
    518         if pack_pkg and Filter.not_removed(lock_pkg) and not vim.deep_equal(lock_pkg, pack_pkg) then
    519             for k, v in pairs {
    520                 dir = Status.TO_MOVE,
    521                 branch = Status.TO_RECLONE,
    522                 url = Status.TO_RECLONE,
    523             } do
    524                 if lock_pkg[k] ~= pack_pkg[k] then
    525                     lock_pkg.status = v
    526                     table.insert(diffs, lock_pkg)
    527                 end
    528             end
    529         end
    530     end
    531     return diffs
    532 end
    533 
    534 
    535 -- }}}
    536 -- PUBLIC API: {{{
    537 
    538 local paq = {}
    539 
    540 ---Installs all packages listed in your configuration. If a package is already
    541 ---installed, the function ignores it. If a package has a `build` argument,
    542 ---it'll be executed after the package is installed.
    543 function paq.install() exe_op("install", clone, vim.tbl_filter(Filter.to_install, Packages)) end
    544 
    545 ---Updates the installed packages listed in your configuration. If a package
    546 ---hasn't been installed with |PaqInstall|, the function ignores it. If a
    547 ---package had changes and it has a `build` argument, then the `build` argument
    548 ---will be executed.
    549 function paq.update() exe_op("update", pull, vim.tbl_filter(Filter.to_update, Packages)) end
    550 
    551 ---Removes packages found on |paq-dir| that aren't listed in your
    552 ---configuration.
    553 function paq.clean() exe_op("remove", remove, find_unlisted()) end
    554 
    555 ---Executes |paq.clean|, |paq.update|, and |paq.install|. Note that all
    556 ---paq operations are performed asynchronously, so messages might be printed
    557 ---out of order.
    558 function paq:sync()
    559     self:clean()
    560     exe_op("sync", clone_or_pull, vim.tbl_filter(Filter.not_removed, Packages))
    561 end
    562 
    563 ---@param opts setup_opts
    564 function paq:setup(opts)
    565     for k, v in pairs(opts) do
    566         Config[k] = v
    567     end
    568     return self
    569 end
    570 
    571 function paq.list()
    572     local installed = vim.tbl_filter(Filter.installed, Lock)
    573     local removed = vim.tbl_filter(Filter.removed, Lock)
    574     local function sort_by_name(t)
    575         table.sort(t, function(a, b) return a.name < b.name end)
    576     end
    577     sort_by_name(installed)
    578     sort_by_name(removed)
    579     local markers = { "+", "*" }
    580     for header, pkgs in pairs { ["Installed packages:"] = installed, ["Recently removed:"] = removed } do
    581         if #pkgs ~= 0 then
    582             print(header)
    583             for _, pkg in ipairs(pkgs) do
    584                 print(" ", markers[pkg.status] or " ", pkg.name)
    585             end
    586         end
    587     end
    588 end
    589 
    590 function paq.log_open() vim.cmd("sp " .. Config.log) end
    591 
    592 function paq.log_clean() return assert(uv.fs_unlink(Config.log)) and vim.notify(" Paq: log file deleted") end
    593 
    594 local meta = {}
    595 
    596 ---The `paq` module is itself a callable object. It takes as argument a list of
    597 ---packages. Each element of the list can be a table or a string.
    598 ---
    599 ---When the element is a table, the first value has to be a string with the
    600 ---name of the repository, like: `'<GitHub-username>/<repository-name>'`.
    601 ---The other key-value pairs in the table have to be named explicitly, see
    602 ---|paq-options|. When the element is a string, it works as if it was the first
    603 ---value of the table, and all other options will be set to their default
    604 ---values.
    605 ---
    606 ---Note: Lua can elide parentheses when passing a single table argument to a
    607 ---function, so you can always call `paq` without parentheses.
    608 ---See |luaref-langFuncCalls|.
    609 function meta:__call(pkgs)
    610     Packages = {}
    611     vim.tbl_map(register, pkgs)
    612     lock_load()
    613     exe_op("resolve", resolve, diff_gather(), true)
    614     return self
    615 end
    616 
    617 setmetatable(paq, meta)
    618 
    619 for cmd_name, fn in pairs {
    620     PaqInstall = paq.install,
    621     PaqUpdate = paq.update,
    622     PaqClean = paq.clean,
    623     PaqList = paq.list,
    624     PaqLogOpen = paq.log_open,
    625     PaqLogClean = paq.log_clean,
    626 }
    627 do
    628     vim.api.nvim_create_user_command(cmd_name, function(_) fn() end, { bar = true })
    629 end
    630 
    631 -- stylua: ignore
    632 do
    633     local build_cmd_opts = {
    634         bar = true,
    635         nargs = 1,
    636         complete = function() return vim.tbl_keys(vim.tbl_map(function(pkg) return pkg.build end, Packages)) end,
    637     }
    638     vim.api.nvim_create_user_command("PaqSync", function() paq:sync() end, { bar = true })
    639     vim.api.nvim_create_user_command("PaqBuild", function(a) run_build(Packages[a.args]) end, build_cmd_opts)
    640     vim.api.nvim_create_user_command("PaqRunHook", function(a)
    641         vim.deprecate("`PaqRunHook` command", "`PaqBuild`", "3.0", "Paq", false)
    642         run_build(Packages[a.args])
    643     end, build_cmd_opts)
    644 end
    645 
    646 return paq
    647 
    648 -- }}}
    649 -- vim: foldmethod=marker foldlevel=1