From 3a5e5acbac98129c27f4bf12d30a62eb38280598 Mon Sep 17 00:00:00 2001 From: Matthew Wilding Date: Fri, 17 Apr 2026 00:18:02 +0800 Subject: [PATCH 1/3] Add dev mode and update doco, compliant with lazy.nvim --- README.md | 2 +- docs/spec.md | 18 +++++++ docs/tips.md | 2 +- lua/zpack/import.lua | 20 ++++++- lua/zpack/init.lua | 11 ++++ lua/zpack/plugin_loader.lua | 13 +++-- lua/zpack/registration.lua | 102 ++++++++++++++++++++++++------------ lua/zpack/startup.lua | 8 ++- lua/zpack/types.lua | 1 + lua/zpack/utils.lua | 29 ++++++++++ 10 files changed, 162 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d85db81..407f4f2 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ zpack might be for you if: As a thin layer, zpack does not provide: - UI dashboard for your plugins (see [Extensions](#extensions) for community solutions) -- Advanced profiling, dev mode, change-detection, etc. +- Advanced profiling, change-detection, etc. If you're a lazy.nvim user, see [Migrating from lazy.nvim](docs/tips.md#migrating-from-lazynvim) diff --git a/docs/spec.md b/docs/spec.md index 01624d2..ecf9870 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -7,6 +7,7 @@ src = "https://...", -- Custom git URL or local path dir = "/path/to/plugin", -- Local plugin directory (lazy.nvim compat, ~ expanded, mapped to src) url = "https://...", -- Custom git URL (lazy.nvim compat, mapped to src) + dev = true, -- Use local dev directory instead of fetching from remote. Resolves to {dev.path}/{name} (see setup config) -- Dependencies dependencies = string|string[]|zpack.Spec|zpack.Spec[], -- Plugin dependencies @@ -55,6 +56,23 @@ } ``` +### dev mode + +Set `dev = true` on any spec to load the plugin from a local directory instead of fetching it from remote. The plugin name is derived from `[1]` (the part after the `/`) or from `name` if set. + +Configure the root directory in `setup()`: + +```lua +require('zpack').setup({ + dev = { + path = '~/projects', -- default + }, + spec = { + { 'folke/snacks.nvim', dev = true }, -- loads from ~/projects/snacks.nvim + }, +}) +``` + ### zpack.Plugin Reference The plugin data object passed to hooks and trigger functions: diff --git a/docs/tips.md b/docs/tips.md index 8545f9e..cb45f98 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -4,7 +4,7 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack follows `vim.pack` conventions over lazy.nvim conventions, and is missing a few advanced features: - **version pinning**: lazy.nvim's `version` field maps to zpack's `sem_version`. See [Spec Reference](spec.md) and [version pinning examples](examples.md#version-pinning-for-lazynvim-compatibility) -- **dev mode**: Use `src = vim.fn.expand('~/projects/my_plugin.nvim')` for local development +- **dev mode**: Use `dev = true` on a spec to load from a local directory (configured via `dev.path` in setup, default: `~/projects`). See [Spec Reference](spec.md#dev-mode) - **profiling**: Use `nvim --startuptime startuptime.log`. Also refer to example [Neovim Profiler script](https://gist.github.com/zuqini/35993710f81983fbfa6baca67bdb32ed) - **default lazy plugins**: lazy.nvim's community specs silently default top-level specs for utility libraries like `plenary.nvim` to `lazy = true`, even without lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same default diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index e0b359d..00b642d 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -6,10 +6,26 @@ local M = {} local imported_modules = {} ---Normalize plugin source using priority: [1] > src > url > dir +---When dev=true, resolve to the local dev path instead of fetching from remote ---@param spec zpack.Spec ---@return string|nil source URL/path, or nil if invalid ---@return string|nil error message if validation fails local normalize_source = function(spec) + if spec.dev then + local plugin_name = spec.name + if not plugin_name then + if spec[1] then + plugin_name = spec[1]:match('([^/]+)$') or spec[1] + else + return nil, "dev = true requires [1] or name to derive the plugin directory" + end + end + local dev_plugin_path = state.dev_path .. '/' .. plugin_name + if not vim.uv.fs_stat(dev_plugin_path) then + return nil, ("Dev mode: plugin '%s' not found in '%s'"):format(plugin_name, state.dev_path) + end + return dev_plugin_path + end if spec[1] then return 'https://github.com/' .. spec[1] elseif spec.src then @@ -24,12 +40,11 @@ local normalize_source = function(spec) end ---@param spec zpack.Spec ----@return string +---@return string|nil local get_source_url = function(spec) local src, err = normalize_source(spec) if not src then utils.schedule_notify(err, vim.log.levels.ERROR) - error(err) end return src end @@ -167,6 +182,7 @@ M.import_specs = function(spec_item_or_list, ctx) end local src = get_source_url(spec) + if not src then goto continue end local is_dep = ctx.is_dependency or false spec._import_order = state.import_order diff --git a/lua/zpack/init.lua b/lua/zpack/init.lua index 1adbf7e..4af6c17 100644 --- a/lua/zpack/init.lua +++ b/lua/zpack/init.lua @@ -39,6 +39,9 @@ end ---@field cond? boolean|(fun(plugin: zpack.Plugin):boolean) ---@field confirm? boolean +---@class zpack.Config.Dev +---@field path? string Root directory for local dev plugins. Default: `~/projects` + ---@class zpack.Config.Performance ---@field vim_loader? boolean @@ -50,6 +53,7 @@ end ---@field spec? zpack.Spec[] ---@field cmd_prefix? string ---@field defaults? zpack.Config.Defaults +---@field dev? zpack.Config.Dev ---@field performance? zpack.Config.Performance ---@field profiling? zpack.Config.Profiling ---@field plugins_dir? string @deprecated Use { import = 'dir' } in spec instead @@ -59,6 +63,7 @@ end local config = { cmd_prefix = 'Z', defaults = { confirm = true }, + dev = { path = '~/projects' }, performance = { vim_loader = true }, profiling = { loader = false, require = false }, } @@ -113,6 +118,10 @@ M.setup = function(opts) config.performance = vim.tbl_extend('force', config.performance, opts.performance) end + if opts.dev ~= nil then + config.dev = vim.tbl_extend('force', config.dev, opts.dev) + end + if opts.profiling ~= nil then config.profiling = vim.tbl_extend('force', config.profiling, opts.profiling) end @@ -133,6 +142,8 @@ M.setup = function(opts) vim.loader.enable() end + state.dev_path = vim.fn.expand(config.dev.path or '~/projects') + if opts.auto_import ~= nil then deprecation.notify_removed('auto_import') end diff --git a/lua/zpack/plugin_loader.lua b/lua/zpack/plugin_loader.lua index 0939193..95ff83d 100644 --- a/lua/zpack/plugin_loader.lua +++ b/lua/zpack/plugin_loader.lua @@ -95,10 +95,15 @@ M.process_spec = function(pack_spec, opts) return end - local name = plugin.spec.name - vim.cmd.packadd({ name, bang = opts.bang }) + if utils.is_local_src(pack_spec.src) then + utils.load_local_plugin(plugin.path, not opts.bang) + else + local name = plugin.spec.name + vim.cmd.packadd({ name, bang = opts.bang }) + end - -- :packadd sources plugin/ but never after/plugin/. Source them explicitly. + -- Source after/plugin/ files (packadd never does this; for local paths + -- load_local_plugin already sourced plugin/ so we just need after/plugin/). if not opts.bang and plugin.path then utils.source_after_plugin_files(plugin.path) end @@ -137,7 +142,7 @@ M.process_spec = function(pack_spec, opts) end registry_entry.load_status = "loaded" - state.unloaded_plugin_names[name] = nil + state.unloaded_plugin_names[plugin.spec.name] = nil end return M diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua index 2e0f5fc..32af6bb 100644 --- a/lua/zpack/registration.lua +++ b/lua/zpack/registration.lua @@ -4,54 +4,88 @@ local utils = require('zpack.utils') local M = {} +---Process a single plugin registration — shared by both the vim.pack.add load +---callback (remote plugins) and the direct local-path registration path. +---@param plugin zpack.Plugin ---@param ctx zpack.ProcessContext -M.register_all = function(ctx) - local ok, err = pcall(vim.pack.add, ctx.vim_packs, { - confirm = ctx.confirm, - load = function(plugin) - local pack_spec = plugin.spec - local registry_entry = state.spec_registry[pack_spec.src] +local function register_plugin(plugin, ctx) + local pack_spec = plugin.spec + local registry_entry = state.spec_registry[pack_spec.src] - if not registry_entry or not registry_entry.merged_spec then - return - end + if not registry_entry or not registry_entry.merged_spec then + return + end - local spec = registry_entry.merged_spec --[[@as zpack.Spec]] - registry_entry.plugin = plugin - state.src_to_pack_spec[pack_spec.src] = pack_spec - if pack_spec.name then - state.name_to_src[pack_spec.name] = pack_spec.src - end + local spec = registry_entry.merged_spec --[[@as zpack.Spec]] + registry_entry.plugin = plugin + state.src_to_pack_spec[pack_spec.src] = pack_spec + if pack_spec.name then + state.name_to_src[pack_spec.name] = pack_spec.src + end - registry_entry.is_lazy_resolved = lazy.is_lazy(spec, plugin, pack_spec.src) + registry_entry.is_lazy_resolved = lazy.is_lazy(spec, plugin, pack_spec.src) - registry_entry.cond_result = utils.check_cond(spec, plugin, ctx.defaults.cond) - if not registry_entry.cond_result then - return - end + registry_entry.cond_result = utils.check_cond(spec, plugin, ctx.defaults.cond) + if not registry_entry.cond_result then + return + end - table.insert(state.registered_plugin_names, pack_spec.name) - state.unloaded_plugin_names[pack_spec.name] = true + table.insert(state.registered_plugin_names, pack_spec.name) + state.unloaded_plugin_names[pack_spec.name] = true - if spec.build then - table.insert(state.plugin_names_with_build, pack_spec.name) - end + if spec.build then + table.insert(state.plugin_names_with_build, pack_spec.name) + end - if spec.init then - table.insert(ctx.src_with_init, pack_spec.src) - end + if spec.init then + table.insert(ctx.src_with_init, pack_spec.src) + end - if registry_entry.is_lazy_resolved then - table.insert(ctx.registered_lazy_packs, pack_spec) - else - table.insert(ctx.registered_startup_packs, pack_spec) - end + if registry_entry.is_lazy_resolved then + table.insert(ctx.registered_lazy_packs, pack_spec) + else + table.insert(ctx.registered_startup_packs, pack_spec) + end +end + +---@param ctx zpack.ProcessContext +M.register_all = function(ctx) + local remote_packs = {} + local local_packs = {} + + for _, pack_spec in ipairs(ctx.vim_packs) do + if utils.is_local_src(pack_spec.src) then + table.insert(local_packs, pack_spec) + else + table.insert(remote_packs, pack_spec) + end + end + + -- Local-path plugins: vim.pack.add is designed for remote git repos and may + -- not call the load callback for local directories. Register them directly + -- with a synthetic plugin object so the rest of the pipeline (startup, + -- lazy triggers, config hooks) works normally. + for _, pack_spec in ipairs(local_packs) do + if not pack_spec.name then + pack_spec.name = utils.derive_name_from_src(pack_spec.src) + end + local plugin = { + spec = pack_spec, + path = pack_spec.src, + } + register_plugin(plugin, ctx) + end + + local ok, err = pcall(vim.pack.add, remote_packs, { + confirm = ctx.confirm, + load = function(plugin) + register_plugin(plugin, ctx) end }) if not ok then local semver_like_specs = {} - for _, pack_spec in ipairs(ctx.vim_packs) do + for _, pack_spec in ipairs(remote_packs) do if pack_spec.version and utils.is_semver_like(pack_spec.version) then table.insert(semver_like_specs, pack_spec) end diff --git a/lua/zpack/startup.lua b/lua/zpack/startup.lua index 9e1d766..9c4d225 100644 --- a/lua/zpack/startup.lua +++ b/lua/zpack/startup.lua @@ -80,9 +80,13 @@ M.process_all = function(ctx) local sorted_packs, lazy_deps_map = toposort_startup_packs(ctx.registered_startup_packs) for _, pack_spec in ipairs(sorted_packs) do - vim.cmd.packadd({ pack_spec.name, bang = not ctx.load }) + local entry = state.spec_registry[pack_spec.src] + if util.is_local_src(pack_spec.src) then + util.load_local_plugin(pack_spec.src, ctx.load) + else + vim.cmd.packadd({ pack_spec.name, bang = not ctx.load }) + end if ctx.load then - local entry = state.spec_registry[pack_spec.src] if entry and entry.plugin and entry.plugin.path then util.source_after_plugin_files(entry.plugin.path) end diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index 163e3ab..68d41bf 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -51,6 +51,7 @@ ---@field pattern? string|string[] Global fallback pattern applied to all events (unless zpack.EventSpec specifies its own) ---@field cmd? zpack.CmdValue|fun(plugin: zpack.Plugin?):zpack.CmdValue ---@field ft? zpack.FtValue|fun(plugin: zpack.Plugin?):zpack.FtValue +---@field dev? boolean Use local dev directory instead of fetching from remote. Requires `dev.path` in setup config (default: `~/projects`). Plugin name is derived from `[1]` or `name` ---@field module? boolean Auto-load when require()'d (default: true for lazy plugins) ---@field dependencies? string|string[]|zpack.Spec|zpack.Spec[] Plugin dependencies ---@field import? string Module path to import specs from (e.g., 'plugins') diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 469bafd..66640b1 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -287,6 +287,35 @@ end ---@type { [string]: true } local sourced_plugin_paths = {} +---Return true when src is a local filesystem path rather than a remote URL. +---Handles Linux/macOS (leading `/`) and Windows (drive letter `C:\` / `C:/` +---or UNC path `\\`). +---@param src string +---@return boolean +M.is_local_src = function(src) + if src:sub(1, 1) == '/' then return true end -- Unix absolute + if src:sub(1, 2) == '\\\\' then return true end -- Windows UNC \\server\share + if src:match('^%a:[/\\]') then return true end -- Windows drive C:\ or C:/ + return false +end + +---Load a plugin directly from a local filesystem path, bypassing packadd. +---packadd searches packpath by name and would find a remote-installed copy +---instead of the intended local directory. We prepend the path to rtp so it +---takes precedence, then optionally source plugin/ files the same way packadd +---normally would. +---@param path string Absolute path to the plugin directory +---@param source_plugin_files boolean Whether to source plugin/**/*.{vim,lua} +M.load_local_plugin = function(path, source_plugin_files) + vim.opt.runtimepath:prepend(path) + if source_plugin_files then + local files = vim.fn.glob(path .. '/plugin/**/*.{vim,lua}', false, true) + for _, file in ipairs(files) do + vim.cmd.source(file) + end + end +end + ---Source after/plugin/ files for a plugin. ---:packadd sources plugin/ files but never after/plugin/ files. The startup ---sequence that normally sources after/plugin/ from the rtp has already run From d38ca4f71192f0c92cb1d7c1c87488980b8631d4 Mon Sep 17 00:00:00 2001 From: Matthew Wilding Date: Fri, 17 Apr 2026 00:23:31 +0800 Subject: [PATCH 2/3] Revert err removal --- lua/zpack/import.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index 00b642d..01652c8 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -45,6 +45,7 @@ local get_source_url = function(spec) local src, err = normalize_source(spec) if not src then utils.schedule_notify(err, vim.log.levels.ERROR) + error(err) end return src end From 6a0341f74f66f3c93c49948a463024c05a4f85a4 Mon Sep 17 00:00:00 2001 From: Matthew Wilding Date: Fri, 17 Apr 2026 00:42:54 +0800 Subject: [PATCH 3/3] clean up doco --- docs/spec.md | 19 +------------------ docs/tips.md | 2 +- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index ecf9870..6a967be 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -7,7 +7,7 @@ src = "https://...", -- Custom git URL or local path dir = "/path/to/plugin", -- Local plugin directory (lazy.nvim compat, ~ expanded, mapped to src) url = "https://...", -- Custom git URL (lazy.nvim compat, mapped to src) - dev = true, -- Use local dev directory instead of fetching from remote. Resolves to {dev.path}/{name} (see setup config) + dev = true, -- Use local dev directory instead of fetching from remote. Resolves to {dev.path}/{name} -- Dependencies dependencies = string|string[]|zpack.Spec|zpack.Spec[], -- Plugin dependencies @@ -56,23 +56,6 @@ } ``` -### dev mode - -Set `dev = true` on any spec to load the plugin from a local directory instead of fetching it from remote. The plugin name is derived from `[1]` (the part after the `/`) or from `name` if set. - -Configure the root directory in `setup()`: - -```lua -require('zpack').setup({ - dev = { - path = '~/projects', -- default - }, - spec = { - { 'folke/snacks.nvim', dev = true }, -- loads from ~/projects/snacks.nvim - }, -}) -``` - ### zpack.Plugin Reference The plugin data object passed to hooks and trigger functions: diff --git a/docs/tips.md b/docs/tips.md index cb45f98..9ed8a4a 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -4,7 +4,7 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack follows `vim.pack` conventions over lazy.nvim conventions, and is missing a few advanced features: - **version pinning**: lazy.nvim's `version` field maps to zpack's `sem_version`. See [Spec Reference](spec.md) and [version pinning examples](examples.md#version-pinning-for-lazynvim-compatibility) -- **dev mode**: Use `dev = true` on a spec to load from a local directory (configured via `dev.path` in setup, default: `~/projects`). See [Spec Reference](spec.md#dev-mode) +- **dev mode**: Use `dev = true` on a spec to load from a local directory (configured via `dev.path` in setup, default: `~/projects`). See [Spec Reference](spec.md) - **profiling**: Use `nvim --startuptime startuptime.log`. Also refer to example [Neovim Profiler script](https://gist.github.com/zuqini/35993710f81983fbfa6baca67bdb32ed) - **default lazy plugins**: lazy.nvim's community specs silently default top-level specs for utility libraries like `plenary.nvim` to `lazy = true`, even without lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same default