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