local fs = require('arti-pack.fs') local installer = require('arti-pack.installer') local Lock = require('arti-pack.lock') local spec_mod = require('arti-pack.spec') local M = {} -- registry has no default: it is site-specific, so any shipped value would point -- every install at a host it cannot reach. See M.setup. local DEFAULTS = { plugins = {}, auto_sync = true, concurrency = 4, timeout = 120, } M.config = vim.deepcopy(DEFAULTS) local running = false local function paths() local data = vim.fn.stdpath('data') return { pack_dir = fs.join(data, 'site', 'pack', 'arti-pack'), lock_path = fs.join(data, 'arti-pack', 'lock.json'), cache_dir = fs.join(vim.fn.stdpath('cache'), 'arti-pack'), } end local function notify(msg, level) vim.notify('[arti-pack] ' .. msg, level or vim.log.levels.INFO) end local function context() local p = paths() return { registry = M.config.registry, pack_dir = p.pack_dir, cache_dir = p.cache_dir, concurrency = M.config.concurrency, timeout = M.config.timeout, lock = Lock.new(p.lock_path):load(), } end -- A spec carrying its own url never touches the registry, so only demand one -- when something in this run would have to be resolved against it. local function registry_error(specs, registry) for _, spec in ipairs(specs) do if not spec.url then local _, err = spec_mod.check_registry(registry) return err end end end local function installed_dir(ctx, name, opt) return fs.join(ctx.pack_dir, opt and 'opt' or 'start', name) end local function finalise(ctx, results) local counts = { installed = 0, updated = 0, failed = 0 } for _, res in ipairs(results) do counts[res.status] = (counts[res.status] or 0) + 1 if res.status == 'failed' then notify(string.format('%s: %s', res.name, res.err), vim.log.levels.ERROR) end end ctx.lock:save() for _, res in ipairs(results) do if res.status ~= 'failed' then local entry = ctx.lock:get(res.name) local dir = installed_dir(ctx, res.name, entry and entry.opt) local doc = fs.join(dir, 'doc') if fs.exists(doc) then pcall(vim.cmd, 'helptags ' .. vim.fn.fnameescape(doc)) end if not (entry and entry.opt) then pcall(vim.cmd.packadd, res.name) end end end local parts = {} for _, key in ipairs({ 'installed', 'updated', 'failed' }) do if counts[key] > 0 then table.insert(parts, string.format('%d %s', counts[key], key)) end end if #parts == 0 then notify('everything up to date') else notify(table.concat(parts, ', '), counts.failed > 0 and vim.log.levels.WARN or vim.log.levels.INFO) end end function M.sync(opts) opts = opts or {} if running then return notify('a sync is already running', vim.log.levels.WARN) end local specs, errors = spec_mod.parse_all(M.config.plugins) for _, err in ipairs(errors) do notify(err, vim.log.levels.ERROR) end local ctx = context() local pending = {} for _, spec in ipairs(specs) do local current = ctx.lock:is_current(spec) and fs.exists(installed_dir(ctx, spec.name, spec.opt)) if opts.force or not current then table.insert(pending, spec) end end if #pending == 0 then return notify('everything up to date') end local reg_err = registry_error(pending, ctx.registry) if reg_err then return notify(reg_err, vim.log.levels.ERROR) end running = true notify(string.format('syncing %d plugin(s)', #pending)) installer.run(ctx, pending, function(results) running = false finalise(ctx, results) end) end function M.clean() local specs, errors = spec_mod.parse_all(M.config.plugins) for _, err in ipairs(errors) do notify(err, vim.log.levels.ERROR) end -- An unparseable entry still names a plugin the user wants, so the set of -- wanted names is incomplete; deleting from it would remove a live install. if #errors > 0 then return notify('clean aborted, no plugin spec may be in error', vim.log.levels.WARN) end local wanted = {} for _, spec in ipairs(specs) do wanted[spec.name] = true end local ctx = context() local removed = {} for _, sub in ipairs({ 'start', 'opt' }) do local root = fs.join(ctx.pack_dir, sub) for _, entry in ipairs(fs.entries(root)) do if not wanted[entry.name] then fs.rm_rf(fs.join(root, entry.name)) table.insert(removed, entry.name) end end end for _, name in ipairs(ctx.lock:names()) do if not wanted[name] then ctx.lock:remove(name) end end ctx.lock:save() if #removed == 0 then notify('nothing to clean') else table.sort(removed) notify('removed ' .. table.concat(removed, ', ')) end end function M.list() local specs, errors = spec_mod.parse_all(M.config.plugins) local ctx = context() local lines = {} for _, spec in ipairs(specs) do local entry = ctx.lock:get(spec.name) local state if not entry then state = 'missing' elseif not ctx.lock:is_current(spec) then state = 'outdated (' .. tostring(entry.tag) .. ')' elseif not fs.exists(installed_dir(ctx, spec.name, spec.opt)) then state = 'gone from disk' else state = 'installed' end table.insert( lines, string.format('%-32s %-14s %-5s %s', spec.source, spec.tag, spec.opt and 'opt' or 'start', state) ) end for _, name in ipairs(ctx.lock:names()) do local known = false for _, spec in ipairs(specs) do known = known or spec.name == name end if not known then table.insert(lines, string.format('%-32s %-14s %-5s %s', name, '-', '-', 'orphaned')) end end for _, err in ipairs(errors) do table.insert(lines, 'error: ' .. err) end if #lines == 0 then lines = { 'no plugins configured' } end notify(table.concat(lines, '\n')) return lines end function M.setup(opts) opts = opts or {} M.config = vim.tbl_deep_extend('force', vim.deepcopy(DEFAULTS), opts) -- tbl_deep_extend merges lists element-wise, which would splice defaults into -- a user's plugin list; the caller's list is always authoritative. M.config.plugins = opts.plugins or {} if opts.registry ~= nil then local _, err = spec_mod.check_registry(opts.registry) if err then notify(err, vim.log.levels.ERROR) end end local p = paths() fs.mkdirp(fs.join(p.pack_dir, 'start')) fs.mkdirp(fs.join(p.pack_dir, 'opt')) if M.config.auto_sync then vim.schedule(function() M.sync() end) end return M end return M