diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index 3ea4976..48e03e8 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -44,15 +44,31 @@ local get_source_url = function(spec) return src end +---Check if a table has any non-integer keys +---@param tbl table +---@return boolean +local has_non_integer_keys = function(tbl) + for k in pairs(tbl) do + if type(k) ~= "number" then + return true + end + end + return false +end + ---Check if value is a single spec (not a list of specs) +---A single spec has a source identifier ([1]=string, src, dir, url) and may have +---spec fields (opts, config, etc). A list of specs has tables or strings as elements. ---@param value zpack.Spec|zpack.Spec[] ---@return boolean local is_single_spec = function(value) - return type(value[1]) == "string" - or value.src ~= nil - or value.dir ~= nil - or value.url ~= nil - or value.import ~= nil + if value.src ~= nil or value.dir ~= nil or value.url ~= nil or value.import ~= nil then + return true + end + if type(value[1]) == "string" then + return value[2] == nil or has_non_integer_keys(value) + end + return false end ---Check if spec is an import spec @@ -77,7 +93,10 @@ local normalize_dependencies = function(deps, parent_src) ) return {} end - if type(deps[1]) == "string" and deps.src == nil and deps.dir == nil and deps.url == nil then + if is_single_spec(deps) then + return { deps } + end + if type(deps[1]) == "string" then local result = {} for i, dep in ipairs(deps) do if type(dep) == "string" then @@ -93,7 +112,7 @@ local normalize_dependencies = function(deps, parent_src) end return result end - return is_single_spec(deps) and { deps } or deps + return deps end ---Load a spec module and import its specs diff --git a/lua/zpack/lazy_trigger/event.lua b/lua/zpack/lazy_trigger/event.lua index a02f901..c33c08e 100644 --- a/lua/zpack/lazy_trigger/event.lua +++ b/lua/zpack/lazy_trigger/event.lua @@ -82,8 +82,9 @@ M.setup = function(pack_spec, spec, event) end if #other_events > 0 then - util.autocmd(other_events, function() + util.autocmd(other_events, function(ev) loader.process_spec(pack_spec) + vim.api.nvim_exec_autocmds(ev.event, { buffer = ev.buf, modeline = false }) end, { group = state.lazy_group, once = true, pattern = normalized_event.pattern }) end end diff --git a/lua/zpack/plugin_loader.lua b/lua/zpack/plugin_loader.lua index ae01c4e..dcd5c8b 100644 --- a/lua/zpack/plugin_loader.lua +++ b/lua/zpack/plugin_loader.lua @@ -95,6 +95,23 @@ M.process_spec = function(pack_spec, opts) registry_entry.load_status = "loading" + local spec = registry_entry.merged_spec + local plugin = registry_entry.plugin + + if not plugin then + utils.schedule_notify(("Cannot load %s: plugin not registered"):format(pack_spec.src), vim.log.levels.ERROR) + return + end + + local name = plugin.spec.name + vim.cmd.packadd({ name, bang = opts.bang }) + + -- packadd may skip sourcing plugin files when vim.pack.add() already + -- added the plugin to the rtp. Source them explicitly. + if not opts.bang and plugin.path then + utils.source_plugin_files(plugin.path) + end + local deps = state.dependency_graph[pack_spec.src] if deps then for dep_src in pairs(deps) do @@ -113,17 +130,6 @@ M.process_spec = function(pack_spec, opts) end end - local spec = registry_entry.merged_spec - local plugin = registry_entry.plugin - - if not plugin then - utils.schedule_notify(("Cannot load %s: plugin not registered"):format(pack_spec.src), vim.log.levels.ERROR) - return - end - - local name = plugin.spec.name - vim.cmd.packadd({ name, bang = opts.bang }) - if spec.config or spec.opts ~= nil then M.run_config(pack_spec.src, plugin, spec) end diff --git a/lua/zpack/startup.lua b/lua/zpack/startup.lua index bae7df0..835dbcb 100644 --- a/lua/zpack/startup.lua +++ b/lua/zpack/startup.lua @@ -74,6 +74,12 @@ M.process_all = function(ctx) for _, pack_spec in ipairs(sorted_packs) do vim.cmd.packadd({ pack_spec.name, bang = not ctx.load }) + if ctx.load then + local entry = state.spec_registry[pack_spec.src] + if entry and entry.plugin and entry.plugin.path then + util.source_plugin_files(entry.plugin.path) + end + end end for _, pack_spec in ipairs(sorted_packs) do diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index c966033..0d029b3 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -258,4 +258,44 @@ M.resolve_main = function(plugin, spec) return nil end +---Track which plugin paths have had their files sourced +---@type { [string]: true } +local sourced_plugin_paths = {} + +---Source plugin/ and after/plugin/ files for a plugin. +---packadd may skip sourcing when vim.pack.add() already added the rtp entry. +---@param plugin_path string +M.source_plugin_files = function(plugin_path) + if sourced_plugin_paths[plugin_path] then + return + end + sourced_plugin_paths[plugin_path] = true + + local dirs = { + plugin_path .. "/plugin", + plugin_path .. "/after/plugin", + } + for _, dir in ipairs(dirs) do + local handle = vim.uv.fs_scandir(dir) + if handle then + while true do + local fname = vim.uv.fs_scandir_next(handle) + if not fname then break end + if fname:sub(-4) == ".lua" or fname:sub(-4) == ".vim" then + local already_sourced = false + for _, s in ipairs(vim.fn.getscriptinfo()) do + if s.name == dir .. "/" .. fname then + already_sourced = true + break + end + end + if not already_sourced then + vim.cmd.source(dir .. "/" .. fname) + end + end + end + end + end +end + return M diff --git a/tests/is_single_spec_test.lua b/tests/is_single_spec_test.lua new file mode 100644 index 0000000..65b71dc --- /dev/null +++ b/tests/is_single_spec_test.lua @@ -0,0 +1,149 @@ +local helpers = require('helpers') + +return function() + helpers.describe("is_single_spec heuristic", function() + helpers.test("single spec with string source and opts is detected", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { 'test/plugin', opts = { foo = true } }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local entry = state.spec_registry['https://github.com/test/plugin'] + + helpers.assert_not_nil(entry, "plugin should be registered") + helpers.assert_equal(entry.merged_spec.opts.foo, true, "opts should be preserved") + + helpers.cleanup_test_env() + end) + + helpers.test("list of bare string specs is treated as list", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { 'test/plugin-a' }, + { 'test/plugin-b' }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + + helpers.assert_not_nil(state.spec_registry['https://github.com/test/plugin-a']) + helpers.assert_not_nil(state.spec_registry['https://github.com/test/plugin-b']) + + helpers.cleanup_test_env() + end) + + helpers.test("single dependency spec with opts preserves all fields", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/parent', + dependencies = { + 'test/dep', + init = function() _G._test_dep_init_called = true end, + opts = { select = { lookahead = true } }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local dep_entry = state.spec_registry['https://github.com/test/dep'] + + helpers.assert_not_nil(dep_entry, "dependency should be registered") + helpers.assert_not_nil(dep_entry.merged_spec.opts, "opts should be preserved on dependency") + helpers.assert_equal(dep_entry.merged_spec.opts.select.lookahead, true, + "opts fields should be preserved") + helpers.assert_not_nil(dep_entry.merged_spec.init, "init should be preserved on dependency") + + _G._test_dep_init_called = nil + helpers.cleanup_test_env() + end) + + helpers.test("list of string dependencies are all registered", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/parent', + dependencies = { 'test/dep-a', 'test/dep-b' }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + + helpers.assert_not_nil(state.spec_registry['https://github.com/test/dep-a'], + "first string dep should be registered") + helpers.assert_not_nil(state.spec_registry['https://github.com/test/dep-b'], + "second string dep should be registered") + + helpers.cleanup_test_env() + end) + + helpers.test("list of table specs is treated as list", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { 'test/plugin-a', opts = {} }, + { 'test/plugin-b', opts = { bar = true } }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + + helpers.assert_not_nil(state.spec_registry['https://github.com/test/plugin-a'], + "first spec should be registered") + helpers.assert_not_nil(state.spec_registry['https://github.com/test/plugin-b'], + "second spec should be registered") + + helpers.cleanup_test_env() + end) + + helpers.test("single dependency with config preserves config function", function() + helpers.setup_test_env() + local config_called = false + + require('zpack').setup({ + spec = { + { + 'test/parent', + dependencies = { + 'test/dep-with-config', + config = function() config_called = true end, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local dep_entry = state.spec_registry['https://github.com/test/dep-with-config'] + + helpers.assert_not_nil(dep_entry, "dependency should be registered") + helpers.assert_not_nil(dep_entry.merged_spec.config, "config should be preserved on dependency") + + helpers.cleanup_test_env() + end) + end) +end diff --git a/tests/run_all.lua b/tests/run_all.lua index 9776cc8..ccb43aa 100644 --- a/tests/run_all.lua +++ b/tests/run_all.lua @@ -28,6 +28,8 @@ local test_modules = { 'zclean_test', 'zupdate_test', 'zrestore_test', + 'is_single_spec_test', + 'source_plugin_files_test', } print("\n" .. string.rep("=", 60)) diff --git a/tests/source_plugin_files_test.lua b/tests/source_plugin_files_test.lua new file mode 100644 index 0000000..efb05f0 --- /dev/null +++ b/tests/source_plugin_files_test.lua @@ -0,0 +1,126 @@ +local helpers = require('helpers') + +return function() + helpers.describe("source_plugin_files", function() + helpers.test("sources lua files from plugin/ directory", function() + helpers.setup_test_env() + + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local plugin_dir = tmpdir .. "/plugin" + vim.fn.mkdir(plugin_dir, "p") + + local test_file = plugin_dir .. "/test_plugin.lua" + local f = io.open(test_file, "w") + f:write("_G._test_source_plugin_ran = true\n") + f:close() + + _G._test_source_plugin_ran = nil + utils.source_plugin_files(tmpdir) + + helpers.assert_true(_G._test_source_plugin_ran == true, + "plugin/ lua file should be sourced") + + _G._test_source_plugin_ran = nil + vim.fn.delete(tmpdir, "rf") + helpers.cleanup_test_env() + end) + + helpers.test("sources lua files from after/plugin/ directory", function() + helpers.setup_test_env() + + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local after_dir = tmpdir .. "/after/plugin" + vim.fn.mkdir(after_dir, "p") + + local test_file = after_dir .. "/test_after.lua" + local f = io.open(test_file, "w") + f:write("_G._test_source_after_ran = true\n") + f:close() + + _G._test_source_after_ran = nil + utils.source_plugin_files(tmpdir) + + helpers.assert_true(_G._test_source_after_ran == true, + "after/plugin/ lua file should be sourced") + + _G._test_source_after_ran = nil + vim.fn.delete(tmpdir, "rf") + helpers.cleanup_test_env() + end) + + helpers.test("does not source same path twice", function() + helpers.setup_test_env() + + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local plugin_dir = tmpdir .. "/plugin" + vim.fn.mkdir(plugin_dir, "p") + + local test_file = plugin_dir .. "/counter.lua" + local f = io.open(test_file, "w") + f:write("_G._test_source_count = (_G._test_source_count or 0) + 1\n") + f:close() + + _G._test_source_count = nil + utils.source_plugin_files(tmpdir) + utils.source_plugin_files(tmpdir) + + helpers.assert_equal(_G._test_source_count, 1, + "file should only be sourced once even with multiple calls") + + _G._test_source_count = nil + vim.fn.delete(tmpdir, "rf") + helpers.cleanup_test_env() + end) + + helpers.test("handles missing directories gracefully", function() + helpers.setup_test_env() + + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + + -- Should not error when plugin/ and after/plugin/ don't exist + local ok, err = pcall(utils.source_plugin_files, tmpdir) + helpers.assert_true(ok, "should not error on missing directories: " .. tostring(err)) + + vim.fn.delete(tmpdir, "rf") + helpers.cleanup_test_env() + end) + + helpers.test("skips non-lua non-vim files", function() + helpers.setup_test_env() + + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local plugin_dir = tmpdir .. "/plugin" + vim.fn.mkdir(plugin_dir, "p") + + -- Create a .txt file that should be ignored + local txt_file = plugin_dir .. "/readme.txt" + local f = io.open(txt_file, "w") + f:write("_G._test_txt_sourced = true\n") + f:close() + + -- Create a .lua file that should be sourced + local lua_file = plugin_dir .. "/real.lua" + f = io.open(lua_file, "w") + f:write("_G._test_lua_sourced = true\n") + f:close() + + _G._test_txt_sourced = nil + _G._test_lua_sourced = nil + utils.source_plugin_files(tmpdir) + + helpers.assert_nil(_G._test_txt_sourced, ".txt file should not be sourced") + helpers.assert_true(_G._test_lua_sourced == true, ".lua file should be sourced") + + _G._test_txt_sourced = nil + _G._test_lua_sourced = nil + vim.fn.delete(tmpdir, "rf") + helpers.cleanup_test_env() + end) + end) +end