Files
dep/lua/dep/package.lua
2025-04-23 13:53:47 -05:00

584 lines
16 KiB
Lua

local logger = require('dep.log')
---@class spec
---@field [1] string id
---@field setup function? code to run before the package is loaded
---@field load function? code to run after the package is loaded
---@field config function? code to run after the package is installed/updated
---@field lazy function? code to run which determines when the package is loaded
---@field as string? overrides the short name of the package which is usually set
--- to a substring of all the chars after '/' in spec[1]
---@field url string? the url to the git repository hosting the package
---@field branch string? the branch which the version of the package resides
---@field commit string? the commit which the version of the package resides
---@field disable boolean? if true disables the package from being loaded
---@field pin boolean? if true disables the package from being installed/updated
---@field reqs spec|spec[]|string? packages that this package requires
---@field deps spec|spec[]|string? packages that depend on this package
---@class package
---@field id string id of the package
---@field enabled boolean whether it's going to be used
---@field exists boolean if the package exists on the filesystem
---@field lazy boolean if the package is lazy loaded in any way
---@field added boolean if the package has been added in vim
---@field configured boolean if the package has been configured
---@field lazied boolean if the packages lazy loading has been set
---@field loaded boolean if a package has been loaded
---@field subtree_loaded boolean is the subtree has been loaded
---@field on_config function[] table of functions to run on config
---@field on_setup function[] table of function to run on setup
---@field on_load function[] table of functions to run on load
---@field lazy_load function[] table of functions to run which will tell the package when to load
---@field requirements package[] this package's requirements
---@field dependents package[] packages that require this package
---@field perf table performance metrics for the package
---@field name string the name of the package
---@field url string the url of the package
---@field branch string the branch of the package
---@field dir string the directory of the package
---@field commit string the commit of the package
---@field pin boolean whether to pin the package or not
local package = {}
--- the base directory for the packages
---@type string
local base_dir
--- the root package
---@type package
local root
--- list of all package in dep
---@type package[]
local packages = {}
--- check a spec to see if it's correct
---@param spec spec|string the specification to check
---@return spec|false spec if the spec is ok or false
local function check_spec(spec)
-- make sure spec is a table
if type(spec) == "string" then
spec = { spec }
end
-- make sure all the data is correct
do -- spec[1]
if type(spec[1]) ~= "string" then
logger:log("spec", "spec[1] must be a string")
return false
end
local name = spec[1]:match("^[%w-_.]+/([%w-_.]+)$")
if not name then
logger:log("spec", 'invalid name "%s"; must be in the format "user/package"', spec[1])
return false
end
end
if spec.setup ~= nil then -- spec.setup
if type(spec.setup) ~= "function" then
logger:log("spec", "spec.setup must be a function in %s", spec[1])
return false
end
end
if spec.load ~= nil then -- spec.load
if type(spec.load) ~= "function" then
logger:log("spec", "spec.load must be a function in %s", spec[1])
return false
end
end
if spec.config ~= nil then -- spec.config
if type(spec.config) ~= "function" then
logger:log("spec", "spec.config must be a function in %s", spec[1])
return false
end
end
if spec.lazy ~= nil then -- spec.lazy
if type(spec.lazy) ~= "function" then
logger:log("spec", "spec.lazy must be a function in %s", spec[1])
return false
end
end
if spec.as ~= nil then -- spec.as
if type(spec.as) ~= "string" then
logger:log("spec", "spec.as must be a string in %s", spec[1])
return false
end
end
if spec.url ~= nil then -- spec.url
if type(spec.url) ~= "string" then
logger:log("spec", "spec.url must be a string in %s", spec[1])
return false
end
end
if spec.branch ~= nil then -- spec.branch
if type(spec.branch) ~= "string" then
logger:log("spec", "spec.branch must be a string in %s", spec[1])
return false
end
end
if spec.commit ~= nil then -- spec.commit
if type(spec.commit) ~= "string" then
logger:log("spec", "spec.commit must be a string in %s", spec[1])
return false
end
end
if spec.disable ~= nil then -- spec.disable
if type(spec.disable) ~= "boolean" then
logger:log("spec", "spec.disable must be a boolean in %s", spec[1])
return false
end
end
if spec.pin ~= nil then -- spec.pin
if type(spec.pin) ~= "boolean" then
logger:log("spec", "spec.pin must be a boolean in %s", spec[1])
return false
end
end
if spec.reqs ~= nil then -- spec.reqs
local is = type(spec.reqs)
if is ~= "table" and is ~= "string" then
logger:log("spec", "spec.reqs must be a table or a string in %s", spec[1])
return false
end
-- turn an id into a spec
if (is == "string") then
spec.reqs = { spec.reqs }
end
end
if spec.deps ~= nil then -- spec.deps
local is = type(spec.deps)
if is ~= "table" and is ~= "string" then
logger:log("spec", "spec.deps must be a table or a string in %s", spec[1])
return false
end
-- turn an id into a spec
if (is == "string") then
spec.deps = { spec.deps }
end
end
return spec
end
--- tell the parent it has a child and the child it has a parent
---@param parent package? parent package if nil defaults to self
---@param child package child package
function package:link_dependency(parent, child)
parent = parent or self
if not parent.dependents[child.id] then
parent.dependents[child.id] = child
table.insert(parent.dependents, child)
end
if not child.requirements[parent.id] then
child.requirements[parent.id] = parent
table.insert(child.requirements, parent)
end
end
--- create a new package instance
---@param spec spec|string a package spec to use for the new package
---@param overrides spec? a package spec that is used to overried this package
---@return package|false package an instance of the package or false on failure
---@nodisacard
function package:new(spec, overrides)
overrides = overrides or {}
-- ensure that the spec is ok
local new_spec = check_spec(spec)
if new_spec == false then
logger:log("spec", vim.inspect(spec))
logger:log("error", "spec check failed, check DepLog")
return false
else
spec = new_spec
end
-- start initializing the package
local id = spec[1]
local o = packages[id] or {}
self.__index = self
setmetatable(o, self)
-- if package hasn't been registered already, get the inital spec regisitered
if not o.id then
o.id = id
o.enabled = true
o.exists = false
o.lazy = false
o.added = false
o.lazied = false
o.configured = false
o.loaded = false
o.subtree_loaded = false
o.on_config = {}
o.on_setup = {}
o.on_load = {}
o.lazy_load = {}
o.requirements = {}
o.dependents = {}
o.perf = {}
packages[id] = o
end
o.name = spec.as or o.name or id:match("^[%w-_.]+/([%w-_.]+)$")
o.url = spec.url or o.url or ("https://github.com/"..id..".git")
o.branch = spec.branch or o.branch
o.dir = base_dir..o.name
o.commit = spec.commit
o.pin = overrides.pin or spec.pin or o.pin
o.enabled = not overrides.disable and not spec.disable and o.enabled
o.lazy = spec.lazy ~= nil or o.lazy
-- make sure that the package exists
o.exists = vim.fn.isdirectory(o.dir) ~= 0
o.configured = o.exists
-- register all the callback functions
if spec.config ~= nil then
table.insert(o.on_config, spec.config)
end
if spec.setup ~= nil then
table.insert(o.on_setup, spec.setup)
end
if spec.load ~= nil then
table.insert(o.on_load, spec.load)
end
if spec.lazy ~= nil then
table.insert(o.lazy_load, spec.lazy)
end
-- if the current package isn't the root package then it depends on the root
-- package
if root and o ~= root then
o:link_dependency(root, o)
elseif not root then
root = o
end
-- link the dependencies
if spec.reqs then
---it is the correct type as asserted in check_spec()
---@diagnostic disable-next-line: param-type-mismatch
for _, req in pairs(spec.reqs) do
local pkg = package:new(req)
if type(pkg) == "table" then
o:link_dependency(pkg, o)
end
end
end
-- and link the dependents
if spec.deps then
---it is the correct type as asserted in check_spec()
---@diagnostic disable-next-line: param-type-mismatch
for _, dep in pairs(spec.deps) do
local pkg = package:new(dep)
if not pkg then
return false
end
o:link_dependency(o, pkg)
-- if the child package is lazy loaded make sure the child package
-- is only loaded when the parent package has finished loading
if o.lazy then
table.insert(o.on_load, function()
local ok = o:loadtree(true)
if not ok then
logger:log("lazy",
"failed to run loadtree for %s, some packages may not be loaded",
o.id)
end
end)
-- tell the dep that it's gonna be lazy
pkg.lazy = true
table.insert(pkg.lazy_load, function(_) end)
end
end
end
return o
end
--- set the base directory for packages
---@param _base_dir string base directory
function package.set_base_dir(_base_dir)
base_dir = _base_dir
end
--- get the base directory for packages
---@return string base_dir
---@nodiscard
function package.get_base_dir()
return base_dir
end
--- get the root directory
---@return package root
---@nodiscard
function package.get_root()
return root
end
--- get the packages in dep
---@return package root
---@nodiscard
function package.get_packages()
return packages
end
--- run specified hooks on the current package
---@param type "on_load"|"on_config"|"on_setup" which hook to run
---@return boolean, string|nil
function package:runhooks(type)
local hooks = self[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(self.dir)
for i = 1, #hooks do
local ok, err = pcall(hooks[i])
if not ok then
vim.fn.chdir(last_cwd)
return false, err
end
end
vim.fn.chdir(last_cwd)
self.perf[type] = os.clock() - start
logger:log("hook", "triggered %d %s %s for %s", #hooks, type,
#hooks == 1 and "hook" or "hooks", self.id)
return true
end
--- make sure a package has been loaded
---@param force boolean? force lazy packages to load
---@return boolean|table return true or false if loaded or package spec if lazy loaded
function package:ensureadded(force)
--- load a package
---@param pkg package
local function loadpkg(pkg)
-- make sure to load the dependencies first
for _, p in pairs(pkg.requirements) do
if not p.loaded then
p:ensureadded(true)
end
end
-- run setup hooks
pkg:runhooks("on_setup")
-- now start loading our plugin
local start = os.clock()
-- trigger the packadd for the plugin
---@diagnostic disable-next-line: param-type-mismatch
local ok, err = pcall(vim.cmd, "packadd "..pkg.name)
if not ok then
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
pkg.loaded = true
logger:log("load", "loaded %s", pkg.id)
-- trigger the on_load hooks
ok, err = pkg:runhooks("on_load")
if not ok then
logger:log("error", "failed to load %s; reason: %s", pkg.id, err)
return
end
end
-- make sure the package is lazy loaded if need be
if not self.added and not self.loaded and not self.lazy or force then
loadpkg(self)
elseif not self.added and self.lazy then
logger:log("lazy", "registering %d lazy hooks for %s", #self.lazy_load,
self.id)
self.lazied = true
for _, load_cond in pairs(self.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", #self.lazy_load,
self.id)
loadpkg(self)
end)
-- run it
load_cond(l)
end
return self
end
return true
end
--- load all packages in package tree
---@param force boolean? force lazy packages to load
---@return boolean boolean if tree was successfully loaded
---@nodiscard
function package:loadtree(force)
-- if the package doesn't exist or isn't enabled then don't load it
if not self.exists or not self.enabled then
return false
end
-- if the subtree is loaded then it's already loaded unless it needs forcing
if not force and self.subtree_loaded then
return true
end
-- if the package isn't lazy check that it's requirements are loaded
if not self.lazy then
for _, requirement in pairs(self.requirements) do
if not requirement.loaded and not requirement.lazy then
logger:log("error", "failed to load %s; requirement: %s isn't loaded",
self.id, requirement.id)
return false
end
end
end
-- if the package isn't loaded and isn't lazy then it should probably be
-- loaded
if not self.loaded then
local ok, err = self:ensureadded(force)
if not ok then
logger:log("error", "failed to load %s; reason: %s", self.id, err)
return false
end
end
self.subtree_loaded = true
-- make sure the dependants are loaded
for _, dependant in pairs(self.dependents) do
self.subtree_loaded = dependant:loadtree(force) and self.subtree_loaded
end
return self.subtree_loaded
end
--- unconfigure a packages tree
function package:unconfiguretree()
-- unconfigure requirements
for _, requirement in pairs(self.requirements) do
requirement.subtree_loaded = false
end
-- unconfigure dependents
for _, dependant in pairs(self.dependents) do
dependant.loaded = false
dependant.added = false
dependant.configured = false
dependant.subtree_loaded = false
end
end
--- check a list of packages for any cycles
---@param pkgs package[] list of packages
---@return package[]|false cycle the cycle that was found or false if not found
---@nodisacard
function package.findcycle(pkgs)
local index = 0
local indexes = {}
local lowlink = {}
local stack = {}
--- use tarjan algorithm to find circular dependencies (strongly connected
--- components)
---@param pkg package
local function connect(pkg)
indexes[pkg.id], lowlink[pkg.id] = index, index
stack[#stack + 1], stack[pkg.id] = pkg, true
index = index + 1
for i = 1, #pkg.dependents do
local dependent = pkg.dependents[i]
if not indexes[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], indexes[dependent.id])
end
end
if lowlink[pkg.id] == indexes[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 it explicitly specified itself as a dependency
if #cycle > 2 or pkg.dependents[pkg.id] then
return cycle
end
end
end
-- actually check the cycle
for _, pkg in pairs(pkgs) do
if not indexes[package.id] then
local cycle = connect(pkg)
if cycle then
return cycle
end
end
end
return false
end
return package