diff --git a/README.md b/README.md index 808821e..27937bc 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ A package must be declared in the following format. "user/package", -- [function] Code to run after the package is loaded into neovim. - function() + load = function() require "package".setup(...) end, @@ -83,6 +83,10 @@ A package must be declared in the following format. os.execute(...) end, + -- [function] Code used to determine when the package should be loaded. + lazy = function(load) + end, + -- [string] Overrides the short name of the package. -- Defaults to a substring of the full name after '/'. as = "custom_package", @@ -95,6 +99,10 @@ A package must be declared in the following format. -- Defaults to whatever the remote configured as their HEAD, which is usually "master". branch = "develop", + -- [string] Overrides the commit ref to target + -- Defaults to the latest commit on the current branch + commit = "e76cb03", + -- [boolean] Prevents the package from being loaded. disable = true, diff --git a/lua/dep.lua b/lua/dep.lua index dccd17b..58dcc90 100644 --- a/lua/dep.lua +++ b/lua/dep.lua @@ -1,34 +1,57 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- +local logger = require('dep.log') +local proc = require('dep.proc') -local logger = require("dep.log").global -local proc = require("dep.proc") +---all functions for convenience +---@type table +local M = {} -local initialized, perf, config_path, base_dir -local packages, root +---@type boolean +local initialized -local function bench(name, code, ...) +---root package +---@type table +local root + +---performance logging +---@type table +local perf = {} + +---table of every package where their id is the index +---@type table +local packages = {} + +---path to root of where plugins are downloaded +---@type string +local base_dir + +--- get execution time of a function +---@param name string name of performance output +---@param code function function to run +---@vararg any arguments for code +function M.benchmark(name, code, ...) local start = os.clock() code(...) perf[name] = os.clock() - start end -local function get_name(id) +--- get name of package +---@param id string id of the package +---@return string id +---@nodiscard +function M.getpkgname(id) local name = id:match("^[%w-_.]+/([%w-_.]+)$") if name then return name else - error(string.format('invalid name "%s"; must be in the format "user/package"', id)) + error(string.format( + 'invalid name "%s"; must be in the format "user/package"', id)) end end -local function link_dependency(parent, child) +--- tell the parent it has a child and the child it has a parent +---@param parent table parent package +---@param child table child package +function M.link_dependency(parent, child) if not parent.dependents[child.id] then parent.dependents[child.id] = child parent.dependents[#parent.dependents + 1] = child @@ -40,7 +63,12 @@ local function link_dependency(parent, child) end end -local function register(spec, overrides) +--- register a new package according to the spec +---@param spec table|string the package spec to register +---@param overrides table|nil a package spec that is used to override options +---@return table package a table containing the package spec +---@nodiscard +function M.registerpkg(spec, overrides) overrides = overrides or {} if type(spec) ~= "table" then @@ -50,133 +78,290 @@ local function register(spec, overrides) local id = spec[1] local package = packages[id] + -- if package hasn't been registered already, get the inital spec regisitered if not package then package = { - id = id, - enabled = true, - exists = false, - added = false, - configured = false, - loaded = false, - subtree_configured = false, + id = id, -- id of the package + enabled = true, -- whether it's going to be used + exists = false, -- if the package exists on the filesystem + lazy = false, -- if the package is lazy loaded in any way + added = false, -- if the package has been added in vim + configured = false, -- if the package has been configured + loaded = false, -- if a package has been loaded subtree_loaded = false, - on_setup = {}, - on_config = {}, - on_load = {}, - dependencies = {}, -- inward edges - dependents = {}, -- outward edges - perf = {}, + on_config = {}, -- table of functions to run on config + on_setup = {}, -- table of function to run on setup + on_load = {}, -- table of functions to run on load + lazy_load = {}, -- table of functions to run which will tell the package + -- when to load + dependencies = {}, -- this package's requirements + dependents = {}, -- packages that require this package + perf = {} } packages[id] = package - packages[#packages + 1] = package end - local prev_dir = package.dir -- optimization - - package.name = spec.as or package.name or get_name(id) - package.url = spec.url or package.url or ("https://github.com/" .. id .. ".git") + -- register the rest of the package spec + package.name = spec.as or package.name or M.getpkgname(id) + package.url = spec.url or package.url or ("https://github.com/"..id..".git") package.branch = spec.branch or package.branch - package.dir = base_dir .. package.name + package.dir = base_dir..package.name + package.commit = spec.commit package.pin = overrides.pin or spec.pin or package.pin package.enabled = not overrides.disable and not spec.disable and package.enabled + package.lazy = spec.lazy or package.lazy - if prev_dir ~= package.dir then - package.exists = vim.fn.isdirectory(package.dir) ~= 0 - package.configured = package.exists - end + -- make sure that the package exists + package.exists = vim.fn.isdirectory(package.dir) ~= 0 + package.configured = package.exists - package.on_setup[#package.on_setup + 1] = spec.setup - package.on_config[#package.on_config + 1] = spec.config - package.on_load[#package.on_load + 1] = spec[2] + -- register all the callback functions + table.insert(package.on_config, spec.config) + table.insert(package.on_load, spec.load) + table.insert(package.lazy_load, spec.lazy) - -- every package is implicitly dependent on us, the package manager + -- if the current package isn't the root package then it depends on the root + -- package if root and package ~= root then - link_dependency(root, package) + M.link_dependency(root, package) end - if type(spec.requires) == "table" then - for i = 1, #spec.requires do - link_dependency(register(spec.requires[i]), package) + -- link the dependencies + if spec.requires then + if type(spec.requires) == "string" then + spec.requires = { spec.requires } + end + for _, v in pairs(spec.requires) do + M.link_dependency(M.registerpkg(v), package) end - elseif spec.requires then - link_dependency(register(spec.requires), package) end - if type(spec.deps) == "table" then - for i = 1, #spec.deps do - link_dependency(package, register(spec.deps[i])) + -- and link the dependents + if spec.deps then + if type(spec.deps) == "string" then + spec.deps = { spec.deps } + end + for _, v in pairs(spec.deps) do + local p = M.registerpkg(v) + M.link_dependency(package, p) + + -- if the child package is lazy loaded make sure the child package + -- is only loaded when the parent package has finished loading + if package.lazy then + table.insert(package.on_load, function() + M.loadtree(package, true) + end) + table.insert(p.lazy_load, function(_) end) + end end - elseif spec.deps then - link_dependency(package, register(spec.deps)) end return package end -local function register_recursive(list, overrides) +--- recurse over all packages and register them +---@param speclist table table of specs +---@param overrides table|nil a package spec that is used to override options +function M.registertree(speclist, overrides) overrides = overrides or {} + + -- make sure the overrides override and take into account the packages spec overrides = { - pin = overrides.pin or list.pin, - disable = overrides.disable or list.disable, + pin = overrides.pin or speclist.pin, + disable = overrides.disable or speclist.disable } - for i = 1, #list do - local ok, err = pcall(register, list[i], overrides) + -- recurse the packages + for i = 1, #speclist do + local ok, err = pcall(M.registerpkg, speclist[i], overrides) + + -- if erroring print out the spec and the error if not ok then - error(string.format("%s (spec=%s)", err, vim.inspect(list[i]))) - end - end - - if list.modules then - for i = 1, #list.modules do - local name, module = "", list.modules[i] - - if type(module) == "string" then - if list.modules.prefix then - module = list.modules.prefix .. module - end - - name, module = module, require(module) - end - - name = module.name or name - - local ok, err = pcall(register_recursive, module, overrides) - if not ok then - error(string.format("%s <- %s", err, name)) - end + error(string.format("%s (spec=%s)", err, vim.inspect(speclist[i]))) end end end -local function sort_dependencies() - -- we don't do topological sort, packages are loaded by traversing the graph recursively - -- any sorting is fine as long as the order is consistent and predictable - local function compare(a, b) - local a_deps, b_deps = #a.dependencies, #b.dependencies - if a_deps == b_deps then - return a.id < b.id +--- run specified hooks on specified package +---@param package table package spec +---@param type string which hook to run +---@return boolean, string|nil +function M.runhooks(package, type) + local hooks = package[type] + if #hooks == 0 then + return true + end + + local start = os.clock() + + -- chdir into the package directory to make running external commands + -- from hooks easier. + local last_cwd = vim.fn.getcwd() + vim.fn.chdir(package.dir) + + for i = 1, #hooks do + local ok, err = pcall(hooks[i]) + if not ok then + vim.fn.chdir(last_cwd) + + package.error = true + return false, err + end + end + + vim.fn.chdir(last_cwd) + package.perf[type] = os.clock() - start + + logger:log("hook", "triggered %d %s %s for %s", #hooks, type, + #hooks == 1 and "hook" or "hooks", package.id) + + return true +end + +--- make sure a package has been loaded +---@param package table package +---@param force boolean? force lazy packages to load +---@return boolean|table return true or false if loaded or package spec if lazy loaded +function M.ensureadded(package, force) + -- print("adding ~ "..package.id) + local function loadpkg(pkg) + -- make sure to load the dependencies first + for _, p in pairs(pkg.dependencies) do + if not p.loaded then + M.ensureadded(p, true) + end + end + + -- now start loading our plugin + local start = os.clock() + + -- trigger the packadd for the plugin + local ok, err = pcall(vim.cmd, "packadd " .. pkg.name) + if not ok then + pkg.error = true + return false, err + end + + pkg.added = true + pkg.perf.pack = os.clock() - start + logger:log("vim", "packadd completed for %s", pkg.id) + + -- set the package to loaded + package.loaded = true + logger:log("load", "loaded %s", package.id) + + -- trigger the on_load hooks + ok, err = M.runhooks(package, "on_load") + if not ok then + logger:log("error", "failed to load %s; reason: %s", package.id, err) + return + end + end + + if not package.added and not package.lazy or force then + loadpkg(package) + elseif not package.added and package.lazy then + logger:log("lazy", "registering %d lazy hooks for %s", #package.lazy_load, + package.id) + for _, load_cond in pairs(package.lazy_load) do + -- configure the lazy loader for the user + local l = require('lazy.utils'):new() + if l == true then + logger:log("lazy", "failed to get lazy utils") + return false + end + l:set_load(function() + logger:log("lazy", "triggered %d lazy hooks for %s", #package.lazy_load, + package.id) + loadpkg(package) + end) + + -- run it + load_cond(l) + end + return package + end + + return true +end + +--- load all packages in package tree +---@param package table package spec table +---@param force boolean? force lazy packages to load +---@return boolean boolean if tree was successfully loaded +function M.loadtree(package, force) + if not package.exists or not package.enabled or package.error then + return false + end + + if package.subtree_loaded then + return true + end + + if not package.lazy then + for i = 1, #package.dependencies do + if not package.dependencies[i].loaded then + return false + end + end + end + + if not package.loaded then + local ok, err = M.ensureadded(package, force) + if not ok then + logger:log("error", "failed to load %s; reason: %s", package.id, err) + return false + end + end + + package.subtree_loaded = true + + for i = 1, #package.dependents do + package.subtree_loaded = M.loadtree(package.dependents[i], force) and package.subtree_loaded + end + + return package.subtree_loaded +end + +--- reload all packages in package table spec +---@param force boolean|nil force all packages to load +function M.reload(force) + -- cleanup any previous errors in the package table + for i in pairs(packages) do + packages[i].error = false + end + + local reloaded = M.loadtree(root, force) + + if reloaded then + local ok, err + M.benchmark("reload", function() + ok, err = pcall(vim.cmd, + [[ + silent! helptags ALL + silent! UpdateRemotePlugins + ]]) + end) + + if ok then + logger:log("vim", "reloaded helptags and remote plugins") else - return a_deps < b_deps + logger:log("error", + "failed to reload helptags and remote plugins; reason: %s", err) end end - - table.sort(packages, compare) - - for i = 1, #packages do - table.sort(packages[i].dependencies, compare) - table.sort(packages[i].dependents, compare) - end end -local function find_cycle() +--- check if there's a circular dependency in the package tree +function M.findcycle() local index = 0 local indexes = {} local lowlink = {} local stack = {} - -- use tarjan algorithm to find circular dependencies (strongly connected components) + -- use tarjan algorithm to find circular dependencies (strongly connected + -- components) local function connect(package) indexes[package.id], lowlink[package.id] = index, index stack[#stack + 1], stack[package.id] = package, true @@ -207,8 +392,8 @@ local function find_cycle() cycle[#cycle + 1] = node until node == package - -- a node is by definition strongly connected to itself - -- ignore single-node components unless it explicitly specified itself as a dependency + -- a node is by definition strongly connected to itself ignore single-node + -- components unless it explicitly specified itself as a dependency if #cycle > 2 or package.dependents[package.id] then return cycle end @@ -227,320 +412,138 @@ local function find_cycle() end end -local function ensure_acyclic() - local cycle = find_cycle() - - if cycle then - local names = {} - for i = 1, #cycle do - names[i] = cycle[i].id - end - error("circular dependency detected in package dependency graph: " .. table.concat(names, " -> ")) - end -end - -local function run_hooks(package, type) - local hooks = package[type] - if #hooks == 0 then - return true - end - - local start = os.clock() - - -- chdir into the package directory to make running external commands - -- from hooks easier. - local last_cwd = vim.fn.getcwd() - vim.fn.chdir(package.dir) - - for i = 1, #hooks do - local ok, err = pcall(hooks[i]) - if not ok then - vim.fn.chdir(last_cwd) - - package.error = true - return false, err - end - end - - vim.fn.chdir(last_cwd) - package.perf[type] = os.clock() - start - - logger:log( - "hook", - string.format("triggered %d %s %s for %s", #hooks, type, #hooks == 1 and "hook" or "hooks", package.id) - ) - - return true -end - -local function ensure_added(package) - if not package.added then - local ok, err = run_hooks(package, "on_setup") - if not ok then - package.error = true - return false, err - end - - local start = os.clock() - - ok, err = pcall(vim.cmd, "packadd " .. package.name) - if not ok then - package.error = true - return false, err - end - - package.added = true - package.perf.pack = os.clock() - start - - logger:log("vim", string.format("packadd completed for %s", package.id)) - end - - return true -end - -local function configure_recursive(package) - if not package.exists or not package.enabled or package.error then - return - end - - if package.subtree_configured then - return true - end - +--- unconfigure a packages tree +---@param package table package to unconfigure +function M.unconfiguretree(package) + -- unconfigure dependencies for i = 1, #package.dependencies do - if not package.dependencies[i].configured then - return - end + package.dependencies[i].subtree_loaded = false end - if not package.configured then - local ok, err = ensure_added(package) - if not ok then - logger:log("error", string.format("failed to configure %s; reason: %s", package.id, err)) - return - end - - ok, err = run_hooks(package, "on_config") - if not ok then - logger:log("error", string.format("failed to configure %s; reason: %s", package.id, err)) - return - end - - package.configured = true - logger:log("config", string.format("configured %s", package.id)) - end - - package.subtree_configured = true - + -- unconfigure dependents for i = 1, #package.dependents do - package.subtree_configured = configure_recursive(package.dependents[i]) and package.subtree_configured - end + package.dependents[i].loaded = false + package.dependents[i].added = false + package.dependents[i].configured = false - return package.subtree_configured -end - -local function load_recursive(package) - if not package.exists or not package.enabled or package.error then - return - end - - if package.subtree_loaded then - return true - end - - for i = 1, #package.dependencies do - if not package.dependencies[i].loaded then - return - end - end - - if not package.loaded then - local ok, err = ensure_added(package) - if not ok then - logger:log("error", string.format("failed to configure %s; reason: %s", package.id, err)) - return - end - - ok, err = run_hooks(package, "on_load") - if not ok then - logger:log("error", string.format("failed to load %s; reason: %s", package.id, err)) - return - end - - package.loaded = true - logger:log("load", string.format("loaded %s", package.id)) - end - - package.subtree_loaded = true - - for i = 1, #package.dependents do - package.subtree_loaded = load_recursive(package.dependents[i]) and package.subtree_loaded - end - - return package.subtree_loaded -end - -local function reload_meta() - local ok, err - bench("meta", function() - ok, err = pcall( - vim.cmd, - [[ - silent! helptags ALL - silent! UpdateRemotePlugins - ]] - ) - end) - - if ok then - logger:log("vim", "reloaded helptags and remote plugins") - else - logger:log("error", string.format("failed to reload helptags and remote plugins; reason: %s", err)) + package.dependents[i].subtree_loaded = false end end -local function reload() - -- clear errors to retry - for i = 1, #packages do - packages[i].error = false - end - - local reloaded - reloaded = configure_recursive(root) or reloaded - reloaded = load_recursive(root) or reloaded - - if reloaded then - reload_meta() - end - - return reloaded -end - -local function reload_all() - for i = 1, #packages do - local package = packages[i] - package.loaded, package.subtree_loaded = false, false - end - - reload() -end - -local function clean() - vim.loop.fs_scandir( - base_dir, - vim.schedule_wrap(function(err, handle) - if err then - logger:log("error", string.format("failed to clean; reason: %s", err)) - else - local queue = {} - - while handle do - local name = vim.loop.fs_scandir_next(handle) - if name then - queue[name] = base_dir .. name - else - break - end - end - - for i = 1, #packages do - queue[packages[i].name] = nil - end - - for name, dir in pairs(queue) do - -- todo: make this async - local ok = vim.fn.delete(dir, "rf") - if ok then - logger:log("clean", string.format("deleted %s", name)) - else - logger:log("error", string.format("failed to delete %s", name)) - end - end - end - end) - ) -end - -local function mark_reconfigure(package) - local function mark_dependencies(node) - node.subtree_configured, node.subtree_loaded = false, false - - for i = 1, #node.dependencies do - mark_dependencies(node.dependencies[i]) - end - end - - local function mark_dependents(node) - node.configured, node.loaded, node.added = false, false, false - node.subtree_configured, node.subtree_loaded = false, false - - for i = 1, #node.dependents do - mark_dependents(node.dependents[i]) - end - end - - mark_dependencies(package) - mark_dependents(package) -end - -local function sync(package, cb) +--- update/download a package +---@param package table package spec +---@param cb function callback +function M.sync(package, cb) if not package.enabled then cb() return end + local function log_err(err) + logger:log("error", "failed to update %s; reason: %s", package.id, err) + end + + --- configure a package + ---@param pkg table package spec + local function configurepkg(pkg) + M.runhooks(package, "on_config") + + logger:log("config", "package: %s configured", pkg.id) + package.configured = true + end + if package.exists then if package.pin then cb() return end - local function log_err(err) - logger:log("error", string.format("failed to update %s; reason: %s", package.id, err)) + local function logerr(err) + logger:log("error", "failed to update %s; reason: %s", package.id, err) end proc.git_rev_parse(package.dir, "HEAD", function(err, before) if err then - log_err(before) + logerr(before) cb(err) else - proc.git_fetch(package.dir, "origin", package.branch or "HEAD", function(err, message) - if err then - log_err(message) - cb(err) - else - proc.git_rev_parse(package.dir, "FETCH_HEAD", function(err, after) - if err then - log_err(after) - cb(err) - elseif before == after then - logger:log("skip", string.format("skipped %s", package.id)) - cb(err) - else - proc.git_reset(package.dir, after, function(err, message) - if err then - log_err(message) - else - mark_reconfigure(package) - logger:log("update", string.format("updated %s; %s -> %s", package.id, before, after)) - end - + if package.commit then + proc.git_checkout(package.dir, package.branch, package.commit, function(err, message) + if err then + log_err(message) + cb(err) + else + proc.git_rev_parse(package.dir, package.commit, function(err, after) + if err then + log_err(after) cb(err) - end) - end - end) - end - end) + elseif before == after then + logger:log("skip", "skipped %s", package.id) + cb(err) + else + M.unconfiguretree(package) + configurepkg(package) + logger:log("update", "updated %s; %s -> %s", package.id, before, after) + end + end) + end + end) + else + proc.git_fetch(package.dir, "origin", package.branch or "HEAD", function(err, message) + if err then + log_err(message) + cb(err) + else + proc.git_rev_parse(package.dir, "FETCH_HEAD", function(err, after) + if err then + log_err(after) + cb(err) + elseif before == after then + logger:log("skip", "skipped %s", package.id) + cb(err) + else + proc.git_reset(package.dir, after, function(err, message) + if err then + log_err(message) + else + M.unconfiguretree(package) + configurepkg(package) + logger:log("update", "updated %s; %s -> %s", package.id, before, after) + end + + cb(err) + end) + end + end) + end + end) + end end end) else + logger:log("error", "%s: doesn't exist", package.id) proc.git_clone(package.dir, package.url, package.branch, function(err, message) if err then - logger:log("error", string.format("failed to install %s; reason: %s", package.id, message)) + logger:log("error", "failed to install %s; reason: %s", + package.id, message) else - package.exists = true - mark_reconfigure(package) - logger:log("install", string.format("installed %s", package.id)) + if package.commit then + proc.git_checkout(package.dir, package.branch, package.commit, function(err, message) + if err then + logger:log("error", "failed to checkout %s; reason: %s", package.id, message) + else + package.exists = true + M.unconfiguretree(package) + configurepkg(package) + logger:log("install", "installed %s", package.id) + end + end) + else + package.exists = true + M.unconfiguretree(package) + configurepkg(package) + logger:log("install", "installed %s", package.id) + end end cb(err) @@ -548,7 +551,10 @@ local function sync(package, cb) end end -local function sync_list(list, on_complete) +--- sync a tree of plugins +---@param tree table tree of plugins +---@param cb function? callback +function M.synctree(tree, cb) local progress = 0 local has_errors = false @@ -556,51 +562,53 @@ local function sync_list(list, on_complete) progress = progress + 1 has_errors = has_errors or err - if progress == #list then - clean() - reload() + if progress == #tree then + -- TODO: implement clean + -- clean() + M.reload() if has_errors then logger:log("error", "there were errors during sync; see :messages or :DepLog for more information") else - logger:log("update", string.format("synchronized %s %s", #list, #list == 1 and "package" or "packages")) + logger:log("update", "synchronized %s %s", #tree, #tree == 1 and "package" or "packages") end - if on_complete then - on_complete() + if cb then + cb() end end end - for i = 1, #list do - sync(list[i], done) + for i in pairs(tree) do + M.sync(tree[i], done) end end -local function get_commits(cb) - local results = {} - local done = 0 - for i = 1, #packages do - local package = packages[i] - - if package.exists then - proc.git_rev_parse(package.dir, "HEAD", function(err, commit) - if not err then - results[package.id] = commit - end - - done = done + 1 - if done == #packages then - cb(results) - end - end) - else - done = done + 1 - end - end -end local function print_list(cb) + local function get_commits(cb) + local results = {} + local done = 0 + for i = 1, #packages do + local package = packages[i] + + if package.exists then + proc.git_rev_parse(package.dir, "HEAD", function(err, commit) + if not err then + results[package.id] = commit + end + + done = done + 1 + if done == #packages then + cb(results) + end + end) + else + done = done + 1 + end + end + end + get_commits(function(commits) local buffer = vim.api.nvim_create_buf(true, true) local line, indent = 0, 0 @@ -777,18 +785,18 @@ local function print_list(cb) walk_graph(root) - print() - print("Debug information:") + -- print() + -- print("Debug information:") - local debug = {} - for l in vim.inspect(packages):gmatch("[^\n]+") do - debug[#debug + 1] = l - end + -- local debug = {} + -- for l in vim.inspect(packages):gmatch("[^\n]+") do + -- debug[#debug + 1] = l + -- end - vim.api.nvim_buf_set_lines(buffer, line, -1, false, debug) - vim.api.nvim_buf_set_name(buffer, "packages.dep") - vim.api.nvim_buf_set_option(buffer, "bufhidden", "wipe") - vim.api.nvim_buf_set_option(buffer, "modifiable", false) + -- vim.api.nvim_buf_set_lines(buffer, line, -1, false, debug) + -- vim.api.nvim_buf_set_name(buffer, "packages.dep") + -- vim.api.nvim_buf_set_option(buffer, "bufhidden", "wipe") + -- vim.api.nvim_buf_set_option(buffer, "modifiable", false) vim.cmd("vsp") vim.api.nvim_win_set_buf(0, buffer) @@ -799,86 +807,100 @@ local function print_list(cb) end) end -vim.cmd([[ - command! DepSync lua require("dep").sync() - command! DepReload lua require("dep").reload() - command! DepClean lua require("dep").clean() - command! DepList lua require("dep").list() - command! DepLog lua require("dep").open_log() - command! DepConfig lua require("dep").open_config() -]]) +-- basically the main function of our program +return function(opts) + logger.pipe = logger:setup() -local function wrap_api(name, fn) - return function(...) - if initialized then - local ok, err = pcall(fn, ...) - if not ok then - logger:log("error", err) - end - else - logger:log("error", string.format("cannot call %s; dep is not initialized", name)) - end + --- make comparison for table.sort + ---@param a table package spec a + ---@param b table package spec b + ---@return boolean + local function comp(a, b) + -- NOTE: this doesn't have to be in any real order, it just has to be + -- consistant, thus we can just check if the unicode value of one package + -- id is less than the other + return a.id < b.id end -end ---todo: prevent multiple execution of async routines -return setmetatable({ - sync = wrap_api("dep.sync", function(on_complete) - sync_list(packages, on_complete) - end), + initialized, err = pcall(function() + base_dir = opts.base_dir or vim.fn.stdpath("data").."/site/pack/deps/opt/" + M.benchmark("load", function() + -- register all packages + root = M.registerpkg("squibid/dep") + M.registertree(opts) - reload = wrap_api("dep.reload", reload_all), - clean = wrap_api("dep.clean", clean), - list = wrap_api("dep.list", print_list), - - open_log = wrap_api("dep.open_log", function() - vim.cmd("vsp " .. logger.path) - end), - - open_config = wrap_api("dep.open_config", function() - vim.cmd("vsp " .. config_path) - end), -}, { - __call = function(_, config) - local err - perf = {} - config_path = debug.getinfo(2, "S").source:sub(2) - - initialized, err = pcall(function() - base_dir = config.base_dir or (vim.fn.stdpath("data") .. "/site/pack/deps/opt/") - packages = {} - - bench("load", function() - root = register("squibid/dep") - register_recursive(config) - sort_dependencies() - ensure_acyclic() - end) - - reload() - - local should_sync = function(package) - if config.sync == "new" or config.sync == nil then - return not package.exists - else - return config.sync == "always" - end - end - - local targets = {} + -- sort packages + table.sort(packages, comp) + -- sort package dependencies for i = 1, #packages do - local package = packages[i] - if should_sync(package) then - targets[#targets + 1] = package - end + table.sort(packages[i].dependencies, comp) + table.sort(packages[i].dependents, comp) end - sync_list(targets) + -- make sure there arent any circular dependencies + M.findcycle() end) - if not initialized then - logger:log("error", err) + -- load packages + M.reload() + + --- check if a package should be synced + ---@param package table package table spec + ---@return boolean sync + local function shouldsync(package) + if opts.sync == "new" or opts.sync == nil then + return not package.exists + else + return opts.sync == "always" + end end - end, -}) + + -- get all package that need syncing + local targets = {} + for i in pairs(packages) do + if shouldsync(packages[i]) then + targets[i] = packages[i] + end + end + + M.synctree(targets) + end) + + if not initialized then + logger:log("error", err) + end + + vim.api.nvim_create_user_command("DepLog", function() + vim.cmd('vsp '..logger.path) + vim.opt_local.readonly = true + + local w = vim.uv.new_fs_event() + local function watch_file(fname) + local fullpath = vim.api.nvim_call_function( + 'fnamemodify', { fname, ':p' }) + w:start(fullpath, {}, vim.schedule_wrap(function(...) + vim.api.nvim_command('checktime') + -- Debounce: stop/start. + w:stop() + watch_file(fname) + end)) + end + + watch_file(logger.path) + end, {}) + + vim.api.nvim_create_user_command("DepSync", function() + M.synctree(packages) + end, {}) + + vim.api.nvim_create_user_command("DepReload", function() + M.reload() + end, {}) + + vim.api.nvim_create_user_command("DepList", function() + print_list() + end, {}) + + logger:cleanup() +end diff --git a/lua/dep/log.lua b/lua/dep/log.lua index 86994b6..3993856 100644 --- a/lua/dep/log.lua +++ b/lua/dep/log.lua @@ -1,23 +1,28 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- -local vim, setmetatable, pcall, debug, string, os, assert = vim, setmetatable, pcall, debug, string, os, assert +local logger = {} +logger.stage_colors = { + skip = "Comment", + clean = "Boolean", + install = "MoreMsg", + update = "WarningMsg", + delete = "Directory", + error = "ErrorMsg", +} + +--- create the default logging path +---@return string path to the logfile local function default_log_path() - -- ensure cache directory exists (#5) + -- create cache directory and chmod it if it doesn't already exist local path = vim.fn.stdpath("cache") if not vim.loop.fs_stat(path) then vim.loop.fs_mkdir(path, 0x1ff) -- 0777 end - return path .. "/dep.log" + return vim.fs.joinpath(path, "/dep.log") end +--- attempt to format a string +---@vararg string formating args local function try_format(...) local ok, s = pcall(string.format, ...) if ok then @@ -25,85 +30,69 @@ local function try_format(...) end end ---- Writes logs to a file and prints pretty status messages. -local Logger = setmetatable({ - __metatable = "Logger", - __index = { - --- Prints a message associated with a stage. - log = function(self, stage, message, ...) - -- calling function - local source = debug.getinfo(2, "Sl").short_src +--- setup all logging stuff +---@param path string|nil optional alternative path for the log file +---@return table +function logger:setup(path) + logger.path = path or default_log_path() + local pipe - -- format or stringify message - if type(message) == "string" then - message = try_format(message, ...) or message - else - message = vim.inspect(message) + logger.handle = assert(vim.loop.fs_open(logger.path, "w", 0x1a4)) -- 0644 + pipe = vim.loop.new_pipe() + pipe:open(logger.handle) + + return pipe +end + +--- log a message +---@param level string error level +---@param message any string message to send +---@vararg any options to go into the message +function logger:log(level, message, ...) + -- make sure the message string is actually a string, and formatted + -- appropriately + if type(message) == "string" then + message = try_format(message, ...) or message + else + message = vim.inspect(message) + end + + -- get debug info about the current function + local source = debug.getinfo(2, "Sl") + + -- schedule a log message to be sent to vim, and the log file + vim.schedule(function() + if not logger.silent then + if level == "error" then + vim.api.nvim_echo({ { string.format("[dep] %s", message) } }, true, { err = true }) + elseif logger.stage_colors[level] then + vim.api.nvim_echo({ + { "[dep]", "Identifier" }, + { " " }, + { message, logger.stage_colors[level] }, + }, true, {}) end + end - -- print and write must be done on the main event loop - vim.schedule(function() - if not self.silent then - if stage == "error" then - vim.api.nvim_err_writeln(string.format("[dep] %s", message)) - elseif self.stage_colors[stage] then - vim.api.nvim_echo({ - { "[dep]", "Identifier" }, - { " " }, - { message, self.stage_colors[stage] }, - }, true, {}) - end - end + -- write to the pipe if it's open + if logger.pipe then + logger.pipe:write(string.format("[%s] %s:%s: %s\n", os.date("%Y/%m/%d"), source.short_src:gsub('.*%/', ''), source.currentline, message)) + end + end) +end - if self.pipe then - self.pipe:write(string.format("[%s] %s: %s\n", os.date(), source, message)) - end - end) - end, +--- cleanup all logging stuff +---@param pipe table? pipe +---@param handle table? handle +function logger:cleanup(pipe, handle) + if pipe then + pipe:close() + pipe = nil + end + if handle then + vim.loop.fs_close(logger.handle) + handle = nil + end +end - --- Closes the log file handle. - close = function(self) - if self.pipe then - self.pipe:close() - self.pipe = nil - end - - if self.handle then - vim.loop.fs_close(self.handle) - self.handle = nil - end - end, - }, -}, { - --- Constructs a new `Logger`. - __call = function(mt, path) - path = path or default_log_path() - - -- clear and open log file - local handle = assert(vim.loop.fs_open(path, "w", 0x1a4)) -- 0644 - local pipe = vim.loop.new_pipe() - pipe:open(handle) - - return setmetatable({ - path = path, - handle = handle, - pipe = pipe, - silent = false, - - -- TODO: This looks good for me ;) but it should have proper vim color mapping for other people. - stage_colors = { - skip = "Comment", - clean = "Boolean", - install = "MoreMsg", - update = "WarningMsg", - delete = "Directory", - error = "ErrorMsg", - }, - }, mt) - end, -}) - -return { - Logger = Logger, - global = Logger(), -} +return logger diff --git a/lua/dep/package.lua b/lua/dep/package.lua deleted file mode 100644 index 513df8d..0000000 --- a/lua/dep/package.lua +++ /dev/null @@ -1,273 +0,0 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- -local require, type, setmetatable, error, table, assert, math, os, debug = - require, type, setmetatable, error, table, assert, math, os, debug -local logger = require("dep.log").global - -local function parse_name_from_id(id) - local name = id:match("^[%w-_.]+/([%w-_.]+)$") - if name then - return name - else - error(string.format('invalid package name "%s"; must be in the format "user/package"', id)) - end -end - -local function is_nonempty_str(s) - return type(s) == "string" and #s ~= 0 -end - ---- Package information. -local Package = setmetatable({ - __metatable = "Package", - __index = { - --- Runs all registered hooks of the given type. - run_hooks = function(self, hook) - local hooks = self["on_" .. hook] - if not hooks or #hooks == 0 then - return true - end - - local start = os.clock() - for i = 1, #hooks do - local ok, err = xpcall(hooks[i], debug.traceback) - if not ok then - return false, err - end - end - - local elapsed = os.clock() - start - self.perf.hooks[hook] = elapsed - - logger:log( - "hook", - "triggered %d %s %s for %s in %dms", - #hooks, - hook, - #hooks == 1 and "hook" or "hooks", - self.id, - elapsed - ) - - return true - end, - }, -}, { - --- Constructs a new `Package` with the given identifier. - __call = function(mt, id) - local name = parse_name_from_id(id) - return setmetatable({ - id = id, - name = name, - url = "https://github.com/" .. id .. ".git", - enabled = true, - exists = false, - added = false, - configured = false, - loaded = false, - dependencies = {}, - dependents = {}, - subtree_configured = false, - subtree_loaded = false, - on_setup = {}, - on_config = {}, - on_load = {}, - perf = { hooks = {} }, - }, mt) - end, -}) - ---- Manages a set of packages. -local PackageStore = setmetatable({ - __metatable = "PackageStore", - __index = { - --- Links the given packages such that the parent must load before the child. - link_dependency = function(self, parent, child) - if not parent.dependents[child.id] then - parent.dependents[child.id] = child - parent.dependents[#parent.dependents + 1] = child - end - - if not child.dependencies[parent.id] then - child.dependencies[parent.id] = parent - child.dependencies[#child.dependencies + 1] = parent - end - end, - - --- Ensures the given package spec table is valid. - validate_spec = function(self, spec) - assert(spec[1] ~= nil, "package id missing from spec") - assert(type(spec[1]) == "string", "package id must be a string") - parse_name_from_id(spec[1]) - - assert(spec.as == nil or is_nonempty_str(spec.as), "package name must be a string") - assert(spec.url == nil or type(spec.url) == "string", "package url must be a string") -- TODO: validate url or path - assert(spec.branch == nil or is_nonempty_str(spec.branch), "package branch must be a string") - assert(spec.pin == nil or type(spec.pin) == "boolean", "package pin must be a boolean") - assert(spec.disable == nil or type(spec.disable) == "boolean", "package disable must be a boolean") - - assert( - spec.requires == nil or type(spec.requires) == "table" or type(spec.requires) == "string", - "package requires must be a string or table" - ) - assert( - spec.deps == nil or type(spec.deps) == "table" or type(spec.deps) == "string", - "package deps must be a string or table" - ) - - assert(spec.setup == nil or type(spec.setup) == "function", "package setup must be a function") - assert(spec.config == nil or type(spec.config) == "function", "package config must be a function") - assert(spec[2] == nil or type(spec[2]) == "function", "package loader must be a function") - end, - - --- Creates or updates a package from the given spec table, and returns that package. - add_spec = function(self, spec, scope) - self:validate_spec(spec) - scope = scope or {} - - local id = spec[1] - local pkg = self[id] - - if not pkg then - pkg = Package(id) - self[id], self[#self + 1] = pkg, pkg - end - - -- blend package spec with existing package info - pkg.name = spec.as or pkg.name - pkg.url = spec.url or pkg.url - pkg.branch = spec.branch or pkg.branch - pkg.pin = scope.pin or spec.pin or pkg.pin - pkg.enabled = not scope.disable and not spec.disable and pkg.enabled - - pkg.on_setup[#pkg.on_setup + 1] = spec.setup - pkg.on_config[#pkg.on_config + 1] = spec.config - pkg.on_load[#pkg.on_load + 1] = spec[2] - - local requires = type(spec.requires) == "table" and spec.requires or { spec.requires } - local deps = type(spec.deps) == "table" and spec.deps or { spec.deps } - - -- recursively add specs for dependencies and dependents - for i = 1, #requires do - self:link_dependency(self:add_spec(requires[i], scope), pkg) - end - - for i = 1, #deps do - self:link_dependency(pkg, self:add_spec(deps[i], scope)) - end - end, - - --- Adds the given list of specs. - add_specs = function(self, specs, scope) - assert(type(specs) == "table", "package list must be a table") - assert(specs.pin == nil or type(specs.pin) == "boolean", "package list pin must be a boolean") - assert(specs.disable == nil or type(specs.disable) == "boolean", "package list disable must be a boolean") - assert(specs.modules == nil or type(specs.modules) == "table", "package list module list must be a table") - - scope = scope or {} - scope = { - -- outer scope takes precedence over inner list's overrides - pin = scope.pin or specs.pin, - disable = scope.disable or specs.disable, - } - - -- add specs in spec list - for i = 1, #specs do - self:add_spec(specs[i], scope) - end - - -- recursively add referenced spec list modules - if specs.modules then - local prefix = specs.modules.prefix or "" - for i = 1, #specs.modules do - local name = specs.modules[i] - assert(type(name) == "string", "package list inner module name must be a string") - name = prefix .. name - - local module = require(name) - assert(type(module) == "table", "package list inner module did not return a spec list table") - self:add_specs(module, scope) - end - end - end, - - --- Ensures there are no circular dependencies in this package store. - ensure_acyclic = function(self) - -- tarjan's strongly connected components algorithm - local idx, indices, lowlink, stack = 0, {}, {}, {} - - local function connect(pkg) - indices[pkg.id], lowlink[pkg.id] = idx, idx - stack[#stack + 1], stack[pkg.id] = pkg, true - idx = idx + 1 - - for i = 1, #pkg.dependents do - local dependent = pkg.dependents[i] - - if not indices[dependent.id] then - local cycle = connect(dependent) - if cycle then - return cycle - else - lowlink[pkg.id] = math.min(lowlink[pkg.id], lowlink[dependent.id]) - end - elseif stack[dependent.id] then - lowlink[pkg.id] = math.min(lowlink[pkg.id], indices[dependent.id]) - end - end - - if lowlink[pkg.id] == indices[pkg.id] then - local cycle = { pkg } - local node - - repeat - node = stack[#stack] - stack[#stack], stack[node.id] = nil, nil - cycle[#cycle + 1] = node - until node == pkg - - -- a node is by definition strongly connected to itself - -- ignore single-node components unless the package explicitly specified itself as a dependency (i.e. the user is being weird) - if #cycle > 2 or pkg.dependents[pkg.id] then - return cycle - end - end - end - - for i = 1, #self do - local pkg = self[i] - - if not indices[pkg.id] then - local cycle = connect(pkg) - if cycle then - -- found dependency cycle - local names = {} - for j = 1, #cycle do - names[j] = cycle[j].id - end - error("circular dependency detected in package dependency graph: " .. table.concat(names, " -> ")) - end - end - end - end, - }, -}, { - --- Constructs a new `PackageStore`. - __call = function(mt) - -- hash part of store maps package ids to packages - -- array part of store is a list of packages - -- all packages in a store are unique based on their id - return setmetatable({}, mt) - end, -}) - -return { - Package = Package, - PackageStore = PackageStore, -} diff --git a/lua/dep/proc.lua b/lua/dep/proc.lua index 4b95baa..85c1858 100644 --- a/lua/dep/proc.lua +++ b/lua/dep/proc.lua @@ -1,4 +1,3 @@ -local logger = require("dep.log").global local proc = {} function proc.exec(process, args, cwd, env, cb) @@ -9,15 +8,6 @@ function proc.exec(process, args, cwd, env, cb) end local function cb_exit(job_id, exit_code, _) local output = table.concat(buffer) - logger:log( - process, - string.format( - 'Job %s ["%s"] finished with exitcode %s\n%s', - job_id, - table.concat(args, '", "'), - exit_code, - output) - ) cb(exit_code ~= 0, output) end table.insert(args, 1, process) @@ -43,7 +33,7 @@ function proc.git_clone(dir, url, branch, cb) local args = { "clone", "--depth=1", "--recurse-submodules", "--shallow-submodules", url, dir } if branch then - args[#args + 1] = "--branch=" .. branch + args[#args + 1] = "--branch="..branch end proc.exec("git", args, nil, git_env, cb) @@ -61,4 +51,70 @@ function proc.git_reset(dir, treeish, cb) proc.exec("git", args, dir, git_env, cb) end +function proc.git_checkout(dir, branch, commit, cb) + local args = { "fetch", "--depth=2147483647", "origin", branch } + proc.exec("git", args, dir, git_env, function(err, message) + cb(err, message) + + args = { "checkout", commit } + proc.exec("git", args, dir, git_env, cb) + end) +end + +function proc.git_resolve_branch(url, branch, cb) + if string.match(branch or "", "*") ~= "*" then + cb(false, branch) + return + end + local buffer = {} + + local function cb_output(_, data, _) + if data[1] ~= "" then + buffer = data + end + end + + vim.fn.jobstart({ "git", "ls-remote", "--tags", "--sort", "v:refname", url, }, + { + cwd = nil, + env = { GIT_TERMINAL_PROMPT = 0 }, + stdin = nil, + on_stdout = cb_output, + on_stderr = cb_output, + on_exit = function(_, exit_code, _) + if exit_code == 0 then + -- get a list of all versions + local versions = {} + for _, v in pairs(buffer) do + local s, e = string.find(v, "refs/tags/.+") + if not s or not e then + goto continue + end + + local tag = string.sub(v, s, e) + tag = string.gsub(tag, "refs/tags/", "") + tag = string.gsub(tag, "%^{}", "") + + table.insert(versions, tag) + ::continue:: + end + + -- match the chosen version against all versions + for i = #versions, 1, -1 do + if branch == "*" then + cb(false, versions[i]) + return + else + local r = string.match(versions[i], branch) + if r then + cb(false, r) + return + end + end + end + end + end + }) +end + return proc diff --git a/lua/dep2.lua b/lua/dep2.lua deleted file mode 100644 index a4d4953..0000000 --- a/lua/dep2.lua +++ /dev/null @@ -1,12 +0,0 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- -local logger = require("dep.log").global -local store = require("dep.package").PackageStore() - --- placeholder for refactoring diff --git a/lua/lazy/utils.lua b/lua/lazy/utils.lua new file mode 100644 index 0000000..4487914 --- /dev/null +++ b/lua/lazy/utils.lua @@ -0,0 +1,97 @@ +---@class lazy +---@field load function +---@field command_ids table +---@field auto_ids table +---@field keybind_ids table +local lazy = {} + +--- create a new instance of lazy +---@return lazy +function lazy:new() + local o = {} + + setmetatable(o, self) + + o.command_ids = {} + o.auto_ids = {} + o.keybind_ids = {} + + self.__index = self + + return o +end + +--- set the loading function +---@param load function the loading function +function lazy:set_load(load) + self.load = load +end + +--- get the configured load function +---@return function load function +function lazy:get_load() + return self.load +end + +--- create a usercommand which will trigger the plugin to load +---@param name string the name of the command +---@param opts vim.api.keyset.user_command? options +function lazy:cmd(name, opts) + opts = opts or {} + vim.api.nvim_create_user_command(name, function(o) + self:cleanup() + end, opts) + + table.insert(self.command_ids, name) +end + +--- user an auto command which will trigger the plugin to load +---@param event string the event to trigger on +---@param opts vim.api.keyset.create_autocmd? options +function lazy:auto(event, opts) + opts = opts or {} + opts['once'] = true + + opts['callback'] = function() + self:cleanup() + end + + table.insert(self.auto_ids, vim.api.nvim_create_autocmd(event, opts)) +end + +--- create a keybind which will trigger the plugin to load +---@param mode string the mode to trigger in +---@param bind string the binding to use +---@param opts vim.keymap.set.Opts? options +function lazy:keymap(mode, bind, opts) + opts = opts or {} + vim.keymap.set(mode, bind, function() + self:cleanup() + + -- register keymap unload + local keys = vim.api.nvim_replace_termcodes(bind, true, false, true) + vim.api.nvim_feedkeys(keys, mode, false) + end, opts) + + table.insert(self.keybind_ids, { ['mode'] = mode, ['bind'] = bind }) +end + +--- cleanup all the callbacks, and load the plugin +function lazy:cleanup() + -- cleanup user commands + for _, v in pairs(self.command_ids) do + vim.api.nvim_del_user_command(v) + end + -- cleanup auto commands + for _, v in pairs(self.auto_ids) do + vim.api.nvim_del_autocmd(v) + end + -- cleanup keymaps + for _, v in pairs(self.keybind_ids) do + vim.keymap.del(v['mode'], v['bind'], {}) + end + -- load the plugin + self:load() +end + +return lazy