local log = require("log") local util = require("util") local config_dir = vim.fn.stdpath("config") if type(config_dir) == "table" then config_dir = assert(config_dir[1]) end local plugins_dir = vim.fs.joinpath(config_dir, "plugins") ---@param path string ---@return boolean success ---@return string? err local function exec(path) local chunk, load_err = loadfile(path) if not chunk then return false, load_err end local ok, call_err = pcall(chunk) if not ok then return false, call_err end return true end ---@class ow.Pack.Spec : vim.pack.Spec ---@field src string ---@field build? string[] | fun(self: ow.Pack.Plugin) ---@class ow.Pack.Plugin : ow.Pack.Spec ---@field name string ---@field path string ---@param name string ---@return string? local function normalize_name(name) name = name:gsub("%.lua$", "") name = name:gsub("%.nvim$", "") if name == "" then return nil end return name:lower() end ---@param name string ---@return string? local function plugin_config_path(name) local normalized = normalize_name(name) if not normalized then log.error("Invalid plugin name: %s", name) return end return vim.fs.joinpath(plugins_dir, normalized .. ".lua") end ---@param name string ---@param required boolean local function load(name, required) local path = plugin_config_path(name) if not path then return end if vim.uv.fs_stat(path) then local ok, err = exec(path) if not ok then log.error("Failed to load %s: %s", name, err) end elseif required then log.error("No config file found for %s", name) end end ---@param src string ---@return boolean local function is_url(src) return src:find("://") ~= nil end ---@param src string ---@return string? local function file_url_to_path(src) local path = src:match("^file://(/.+)$") if not path then return nil end return vim.fs.normalize(path) end ---@param path string ---@return string? local function plugin_name_from_path(path) local name = vim.fs.basename(path) if not name or name == "" or name == "." or name == ".." then return nil end return name end local data_dir = vim.fn.stdpath("data") if type(data_dir) == "table" then data_dir = assert(data_dir[1]) end local dev_opt_dir = vim.fs.joinpath(data_dir, "site", "pack", "dev", "opt") ---@param target string ---@param name string ---@return string? link ---@return boolean changed local function ensure_dev_link(target, name) if not vim.uv.fs_stat(target) then log.error("pack: dev plugin path does not exist: %s", target) return nil, false end local ok, info = pcall(vim.pack.get, { name }) if ok and info and #info > 0 then pcall(vim.pack.del, { name }, { force = true }) end vim.fn.mkdir(dev_opt_dir, "p") local link = vim.fs.joinpath(dev_opt_dir, name) local lstat = vim.uv.fs_lstat(link) if lstat then if lstat.type == "link" then if vim.uv.fs_readlink(link) == target then return link, false end local ok_unlink, err = vim.uv.fs_unlink(link) if not ok_unlink then log.error("pack: failed to unlink %s: %s", link, err) return nil, false end else log.error( "pack: %s exists and is not a symlink; refusing to overwrite", link ) return nil, false end end local ok_link, err = vim.uv.fs_symlink(target, link) if not ok_link then log.error("pack: failed to symlink %s -> %s: %s", link, target, err) return nil, false end return link, true end ---@param spec string | ow.Pack.Spec ---@return vim.pack.Spec local function to_pack_spec(spec) if type(spec) == "string" then return { src = spec } end return { src = spec.src, name = spec.name, version = spec.version, data = { build = spec.build, }, } end ---@param plugin ow.Pack.Plugin local function run_build(plugin) if type(plugin.build) == "function" then plugin.build(plugin) return elseif type(plugin.build) == "table" then local ret = vim.system(plugin.build --[[@as table]], { cwd = plugin.path }) :wait() if ret.code ~= 0 then log.error("Build failed for %s: %s", plugin.name, ret.stderr or "") end return end log.error("Invalid build parameter for %s", plugin.name) end ---@class ow.Pack.Event.Data ---@field active boolean ---@field kind "install" | "update" | "delete" ---@field spec vim.pack.Spec ---@field path string ---@class ow.Pack.Event : vim.api.keyset.create_autocmd.callback_args ---@field data ow.Pack.Event.Data ---@type uv.uv_fs_event_t? local watcher = nil ---@type ow.Util.KeyedDebounceHandle? local on_change_handle = nil ---@alias ow.Pack.Hook fun(data: ow.Pack.Event.Data) ---@class ow.Pack ---@field plugins table keyed by src local M = { plugins = {}, } ---@return string[] function M.get_names() return vim.tbl_values(vim.tbl_map(function(p) return p.name end, M.plugins)) end ---@return string[] function M.get_paths() return vim.tbl_values(vim.tbl_map(function(p) return p.path end, M.plugins)) end ---@param plugin_path string function M.unload(plugin_path) local lua_dir = vim.fs.joinpath(plugin_path, "lua") local search = lua_dir .. "/?.lua;" .. lua_dir .. "/?/init.lua" for name in pairs(package.loaded) do if package.searchpath(name, search) then package.loaded[name] = nil end end end ---@param name string ---@param required boolean function M.reload_plugin(name, required) for _, plugin in pairs(M.plugins) do if plugin.name == name then M.unload(plugin.path) break end end load(name, required) end function M.watch() if watcher then return end local w, err = vim.uv.new_fs_event() if not w then log.error("pack: failed to create fs_event: %s", err) return end local on_change, handle = util.keyed_debounce( ---@param filename string function(filename) local path = vim.fs.joinpath(plugins_dir, filename) if not vim.uv.fs_stat(path) then return end local ok, load_err = exec(path) if ok then log.info("Reloaded %s", filename) else log.error("Failed to reload %s: %s", filename, load_err) end end, 200 ) local ok, err = w:start( plugins_dir, {}, ---@param err string? ---@param filename string function(err, filename) if err then log.error("pack: watch error for %s: %s", filename, err) return end if not filename or not filename:match("%.lua$") then return end on_change(filename) end ) if not ok then log.error("pack: failed to watch %s: %s", plugins_dir, err) w:close() handle:close() return end on_change_handle = handle watcher = w end function M.unwatch() if not watcher then return end watcher:stop() watcher:close() watcher = nil if on_change_handle then on_change_handle.close() on_change_handle = nil end end ---@param specs vim.pack.Spec[] ---@param on_load boolean|fun(plug_data: {spec: vim.pack.Spec, path: string}) ---@return table function M.install(specs, on_load) ---@type table local changed = {} local group = vim.api.nvim_create_augroup("ow.Pack.install", { clear = true }) local id = vim.api.nvim_create_autocmd("PackChanged", { group = group, ---@param ev ow.Pack.Event callback = function(ev) local src = ev.data.spec.src if ev.data.kind == "install" or ev.data.kind == "update" then changed[src] = ev.data end end, }) vim.pack.add(specs, { load = on_load }) vim.api.nvim_del_autocmd(id) return changed end ---@param specs (string | ow.Pack.Spec)[] function M.setup(specs) local pack_specs = {} local order = {} local dev_changed = {} for _, spec in ipairs(specs) do local spec_t = type(spec) == "table" and spec or nil local src = type(spec) == "string" and spec or spec.src table.insert(order, src) local dev_path = file_url_to_path(src) if dev_path then local name = (spec_t and spec_t.name) or plugin_name_from_path(dev_path) if not name then log.error("pack: invalid plugin name derived from %s", src) else local link, did_change = ensure_dev_link(dev_path, name) if link then local ok_add, add_err = pcall(vim.cmd.packadd, name) if not ok_add then log.error( "pack: failed to packadd %s: %s", name, add_err ) else ---@type ow.Pack.Plugin M.plugins[src] = { src = src, name = name, version = spec_t and spec_t.version, build = spec_t and spec_t.build, path = link, } if did_change then dev_changed[src] = { path = link } end end end end elseif is_url(src) then table.insert(pack_specs, to_pack_spec(spec)) else local ok_add, add_err = pcall(vim.cmd.packadd, src) if not ok_add then log.error("pack: failed to packadd %s: %s", src, add_err) else local runtime = vim.api.nvim_get_runtime_file("pack/*/opt/" .. src, false) ---@type ow.Pack.Plugin local plugin = { src = src, name = src, path = runtime[1] or "", } M.plugins[plugin.src] = plugin end end end local changed = M.install(pack_specs, function(data) if not data.spec.name then log.error("Missing name for plugin: %s", data.spec.src) return end local d = data.spec.data or {} ---@type ow.Pack.Plugin local plugin = { src = data.spec.src, name = data.spec.name, version = data.spec.version, build = d.build, path = data.path, } M.plugins[plugin.src] = plugin vim.cmd.packadd(plugin.name) end) for src, data in pairs(dev_changed) do changed[src] = data end for _, src in ipairs(order) do local plugin = M.plugins[src] if plugin then if plugin.build then local data = changed[plugin.src] if data then plugin.path = data.path run_build(plugin) end end load(plugin.name, false) end end vim.api.nvim_create_autocmd("PackChanged", { group = vim.api.nvim_create_augroup( "ow.Pack.updates", { clear = true } ), callback = function(ev) if ev.data.kind ~= "update" then return end local plugin = M.plugins[ev.data.spec.src] if not plugin then return end plugin.path = ev.data.path if plugin.build then run_build(plugin) end M.reload_plugin(plugin.name, false) end, }) end ---@param names? string[] ---@param opts? table function M.update(names, opts) if names then local managed = {} for _, plugin in pairs(M.plugins) do if not file_url_to_path(plugin.src) and is_url(plugin.src) then managed[plugin.name] = true end end local filtered = {} for _, name in ipairs(names) do if managed[name] then table.insert(filtered, name) else log.warning("pack: skipping %s (not managed by vim.pack)", name) end end if #filtered == 0 then return end names = filtered end vim.pack.update(names, opts) end return M