Skip to content
32 changes: 31 additions & 1 deletion doc/zpack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,11 @@ event (string|string[]|EventSpec|(string|EventSpec)[]|fn(plugin),
"BufReadPre *.lua". See |zpack.EventSpec|. Auto-sets
`lazy=true`.

Note: zpack emits `User VeryLazy` once on UIEnter (or
immediately after setup when vim has already entered), so
user configs can hook |autocmd| `User VeryLazy` for
post-setup delayed init.

*zpack-Spec.pattern*
pattern (string|string[], optional)
Global fallback pattern(s) applied to all events. If an
Expand All @@ -727,7 +732,9 @@ pattern (string|string[], optional)
cmd (string|string[]|function(plugin), optional)
Command(s) to create that trigger lazy loading. Can be a
function receiving |zpack.Plugin| that returns the value.
Auto-sets `lazy=true`.
Auto-sets `lazy=true`. Tab-completion on the command also
triggers the load so the real command's completer can
answer on the first press.

*zpack-Spec.keys*
keys (zpack.KeySpec|zpack.KeySpec[]|function(plugin), optional)
Expand Down Expand Up @@ -854,6 +861,8 @@ pattern (string|string[], optional)
[2] = function() end, -- RHS function
desc = "description", -- Keymap description
mode = "n"|{"n","v"}, -- Mode(s), default: "n"
ft = "lua"|{"lua","vim"}, -- FileType scope; install buffer-locally on matching FileType only
buffer = true|0|7, -- Buffer scope: true/0 = current buffer; integer = specific buffer
remap = true|false, -- Allow remapping, default: false
nowait = true|false, -- Default: false
expr = true|false, -- RHS is an expression, default: false
Expand Down Expand Up @@ -912,6 +921,27 @@ replace_keycodes (boolean, optional)
When `expr` is true, replace keycodes in the resulting
string. Default: true when `expr` is true; otherwise unused.

*zpack.KeySpec.buffer*
buffer (integer|boolean, optional)
Buffer scope (lazy.nvim parity). `true` or `0` installs the
keymap buffer-locally in the current buffer; an integer
installs it in that specific buffer. Honored by both the
lazy proxy and the real keymap.

*zpack.KeySpec.ft*
ft (string|string[], optional)
FileType scope (lazy.nvim parity). The keymap (both the
lazy proxy and the real keymap installed on load) is
buffer-local to buffers matching the given filetype, via
a FileType autocmd that also catches future buffers.

*zpack.KeySpec.nop*
A KeySpec whose `[2]` is `'<Nop>'` (any case) or the empty string is
installed as a real no-op keymap rather than a lazy proxy. The plugin
will not be loaded when the key is pressed, matching lazy.nvim's
`Util.is_nop` behavior — useful for suppressing default mappings the
plugin would otherwise install.

==============================================================================
11. TIPS & MIGRATION *zpack-tips-and-migration*

Expand Down
11 changes: 9 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@

-- Lazy loading triggers (auto-sets lazy=true unless overridden)
-- All triggers can also be functions that receive zpack.Plugin and return the respective type
event = string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[]|function(plugin), -- Autocommand event(s). Supports 'VeryLazy' and inline patterns: "BufReadPre *.lua"
event = string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[]|function(plugin), -- Autocommand event(s). Supports 'VeryLazy' and inline patterns: "BufReadPre *.lua". zpack auto-emits `User VeryLazy` once on UIEnter so user-config `autocmd User VeryLazy` hooks fire.
pattern = string|string[], -- Global fallback pattern(s) for all events
cmd = string|string[]|function(plugin), -- Command(s) to create
cmd = string|string[]|function(plugin), -- Command(s) to create; tab-completion on the command also triggers the load
keys = zpack.KeySpec|zpack.KeySpec[]|function(plugin), -- Keymap(s) to create
ft = string|string[]|function(plugin), -- FileType(s) to lazy load on

Expand Down Expand Up @@ -83,6 +83,8 @@ The plugin data object passed to hooks and trigger functions:
[2] = function() end, -- RHS function
desc = "description", -- Keymap description
mode = "n"|{"n","v"}, -- Mode(s), default: "n"
ft = "lua"|{"lua","vim"}, -- FileType scope; keymap installs buffer-locally on matching FileType only
buffer = true|0|7, -- Buffer scope: true/0 = current buffer; integer = specific buffer
remap = true|false, -- Allow remapping, default: false
nowait = true|false, -- Default: false
expr = true|false, -- RHS is an expression, default: false
Expand All @@ -92,6 +94,11 @@ The plugin data object passed to hooks and trigger functions:
}
```

A KeySpec whose `[2]` rhs is `<Nop>` (any case) or the empty string is
installed as a real no-op keymap rather than a lazy proxy — pressing the key
never loads the plugin. Useful for suppressing default mappings the plugin
would otherwise install. Matches lazy.nvim's `Util.is_nop` behavior.

### zpack.PluginInfo Reference

Snapshot of a registered plugin returned by the public API functions under `require('zpack.api')`. Treat as read-only. See `:help zpack-public-api` or [docs/public_api.md](public_api.md) for the full reference, including stability guarantees and field-level docs.
Expand Down
55 changes: 45 additions & 10 deletions lua/zpack/keymap.lua
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
local util = require('zpack.utils')
local state = require('zpack.state')

local M = {}

local SUPPORTED_OPTS = { 'desc', 'remap', 'nowait', 'expr', 'silent', 'replace_keycodes' }
local SUPPORTED_OPTS = { 'desc', 'remap', 'nowait', 'expr', 'silent', 'replace_keycodes', 'buffer' }

---@param lhs string
---@param rhs string|fun()
Expand Down Expand Up @@ -30,22 +31,56 @@ M.map = function(lhs, rhs, opts)
vim.keymap.set(opts.mode or { 'n' }, lhs, rhs, set_opts)
end

---Wrap `M.map` with pcall + structured notify. Used at every map site so
---one bad key spec never strands siblings or recurs as autocmd-dispatch noise.
---@param lhs string
---@param rhs string|fun()
---@param opts? table
---@param src string Plugin identifier for the failure notify
---@return boolean ok
M.try_map = function(lhs, rhs, opts, src)
local ok, err = pcall(M.map, lhs, rhs, opts)
if not ok then
util.schedule_notify(
("Failed to map %s for %s: %s"):format(lhs, src, tostring(err)),
vim.log.levels.ERROR
)
end
return ok
end

---@param key zpack.KeySpec
---@param src string
local function apply_ft_scoped(key, src)
local patterns = util.normalize_string_list(key.ft) --[[@as string[] ]]
util.install_on_ft(patterns, function(buf)
local opts = vim.tbl_extend('force', {}, key, { buffer = buf })
M.try_map(key[1], key[2], opts, src)
end, { group = state.lazy_group })
end

---@param keys zpack.KeySpec|zpack.KeySpec[]|string
---@param src string Plugin identifier for the failure notify
M.apply_keys = function(keys, src)
local key_list = util.normalize_keys(keys) --[[@as zpack.KeySpec[] ]]

for _, key in ipairs(key_list) do
if key[2] ~= nil then
-- pcall per key so one malformed spec doesn't strand its siblings.
-- lazy_trigger/keys.lua's post-load maparg gate ensures an unmapped
-- lhs doesn't fall through to bare keystrokes typed in the buffer.
local ok, err = pcall(M.map, key[1], key[2], key)
if not ok then
util.schedule_notify(
("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)),
vim.log.levels.ERROR
)
-- ft scope (lazy.nvim parity): install via FileType autocmd + iterate
-- already-matching buffers so the real keymap stays buffer-local.
-- Without this, a global apply_keys could silently overwrite a sibling
-- plugin that claimed the same lhs under a disjoint ft.
if util.normalize_ft_scope(key.ft) then
-- pcall the registration plumbing; per-buffer try_map handles its own throws.
local ok, err = pcall(apply_ft_scoped, key, src)
if not ok then
util.schedule_notify(
("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)),
vim.log.levels.ERROR
)
end
else
M.try_map(key[1], key[2], key, src)
end
end
end
Expand Down
3 changes: 3 additions & 0 deletions lua/zpack/lazy.lua
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ M.process_all = function(ctx)
end
cmd_handler.setup(ctx.registered_lazy_packs)
keys_handler.setup(ctx.registered_lazy_packs)
-- Producer of the User VeryLazy emit lives next to its per-plugin VeryLazy
-- consumer (event_handler) so the synthetic-event protocol stays in one file.
event_handler.fire_very_lazy()
end

return M
28 changes: 24 additions & 4 deletions lua/zpack/lazy_trigger/cmd.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,25 @@ M.setup = function(registered_pack_specs)
-- is silently dropped on the first lazy invocation of a register-accepting
-- command — subsequent calls go through the real command directly.
for cmd, pack_specs in pairs(cmd_to_pack_specs) do
vim.api.nvim_create_user_command(cmd, function(cmd_args)
-- Loading the claiming plugins tears down the proxy and lets the real
-- command's callback/complete take over. Shared by the invocation
-- callback and the tab-completion callback so first-tab completions
-- come from the real command, not an empty proxy.
local function load_plugins()
pcall(vim.api.nvim_del_user_command, cmd)

local any_ok = false
for _, pack_spec in ipairs(pack_specs) do
if loader.try_process_spec(pack_spec) then
any_ok = true
end
end
return any_ok
end

vim.api.nvim_create_user_command(cmd, function(cmd_args)
-- Proxy already self-deleted; nvim_cmd would error with "Not an
-- editor command" on top of the per-plugin load-failure notify.
if not any_ok then
if not load_plugins() then
return
end

Expand All @@ -62,7 +69,20 @@ M.setup = function(registered_pack_specs)
if not ok then
util.schedule_notify(("Failed to re-fire :%s: %s"):format(cmd, tostring(err)), vim.log.levels.ERROR)
end
end, { nargs = '*', bang = true, count = -1 })
end, {
nargs = '*',
bang = true,
count = -1,
-- Tabbing at the cmdline loads the plugin so the real command's
-- complete handler can return actual completions on the first press,
-- matching lazy.nvim's UX (handler/cmd.lua complete callback).
complete = function(_, line)
if not load_plugins() then
return {}
end
return vim.fn.getcompletion(line, 'cmdline')
end,
})
end
end

Expand Down
53 changes: 42 additions & 11 deletions lua/zpack/lazy_trigger/event.lua
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ local split_very_lazy = function(events)
return has_very_lazy, other_events
end

---Schedule `cb` on the next tick (if vim has entered) or on the next
---UIEnter (latched against nvim#25526). Shared by the per-plugin VeryLazy
---load and the `User VeryLazy` emit so the latch lives in one place.
---@param cb function
local function on_ui_enter_or_now(cb)
if vim.v.vim_did_enter == 1 then
vim.schedule(cb)
else
vim.api.nvim_create_autocmd('UIEnter', {
group = state.lazy_group,
once = true,
callback = util.latch_first_call(function()
vim.schedule(cb)
end),
})
end
end

---Fire `User VeryLazy` once on UIEnter (or immediately if vim has already
---entered). Matches lazy.nvim's contract so user autocmds keyed on
---`User VeryLazy` work in configs ported from lazy.nvim. Called from
---lazy.process_all AFTER per-plugin UIEnter handlers register so VeryLazy
---plugins are loaded by the time User VeryLazy fires.
M.fire_very_lazy = function()
on_ui_enter_or_now(function()
vim.api.nvim_exec_autocmds('User', { pattern = 'VeryLazy', modeline = false })
end)
end

---@param pack_spec vim.pack.Spec
---@param spec zpack.Spec
---@param event zpack.EventValue
Expand All @@ -76,19 +105,21 @@ M.setup = function(pack_spec, spec, event)

if has_very_lazy then
-- VeryLazy is synthetic (UIEnter-only); no real event to re-fire.
util.autocmd("UIEnter", function()
vim.schedule(function()
loader.try_process_spec(pack_spec)
end)
end, { group = state.lazy_group, once = true })
-- When setup() runs after UIEnter (`:luafile`, config reload), the
-- UIEnter autocmd would never fire — schedule the load directly so
-- the plugin still loads before lazy.fire_very_lazy's User VeryLazy
-- emit (which also fast-paths on vim_did_enter).
on_ui_enter_or_now(function()
loader.try_process_spec(pack_spec)
end)
end

if #other_events > 0 then
util.autocmd(other_events, function(ev)
-- Skip when a sibling event/ft has already loaded (or is mid-load,
-- when plugin/ files synchronously fire a matching autocmd during
-- packadd). "loaded" prevents double-firing via refire's FileType
-- branch; "loading" prevents spurious "Circular dependency" notify.
-- latch_first_call gates before the load_status check so a second
-- nested fire in the same tick bails before refire.exec can double-fire
-- user autocmds. The load_status gate handles other races (sibling
-- event/ft already loaded, plugin/ files re-entering synchronously).
util.autocmd(other_events, util.latch_first_call(function(ev)
local entry = state.spec_registry[pack_spec.src]
if entry and entry.load_status ~= "pending" then
return
Expand All @@ -98,7 +129,7 @@ M.setup = function(pack_spec, spec, event)
return
end
refire.exec(ev, snap)
end, { group = state.lazy_group, once = true, pattern = normalized_event.pattern })
end), { group = state.lazy_group, once = true, pattern = normalized_event.pattern })
end
end
end
Expand Down
22 changes: 17 additions & 5 deletions lua/zpack/lazy_trigger/ft.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ local M = {}
M.setup = function(pack_spec, ft)
local filetypes = util.normalize_string_list(ft)

util.autocmd("FileType", function(ev)
-- Same gate as lazy_trigger/event.lua: skip when a sibling already
-- loaded (avoid double-fire) or is mid-load (avoid spurious circular-
-- dependency notify).
-- Source the plugin's ftdetect/* now so its filetype rules are active
-- before any file is opened. Without this, `ft = '<custom>'` for a plugin
-- that ships its own filetype detection (e.g. `ftdetect/rust.vim`) silently
-- never triggers, because vim.pack defers the rules behind `:packadd`.
local registry_entry = state.spec_registry[pack_spec.src]
local plugin_path = registry_entry and registry_entry.plugin and registry_entry.plugin.path
if plugin_path then
util.source_ftdetect_files(plugin_path)
end

-- latch_first_call guards against nvim#25526; needed here because the
-- plugin's own `ftplugin/*` sourced during packadd can nest-fire FileType
-- on the same buffer before load_status flips.
util.autocmd("FileType", util.latch_first_call(function(ev)
-- Skip when a sibling already loaded (avoid double-fire) or is
-- mid-load (avoid spurious circular-dependency notify).
local entry = state.spec_registry[pack_spec.src]
if entry and entry.load_status ~= "pending" then
return
Expand All @@ -23,7 +35,7 @@ M.setup = function(pack_spec, ft)
return
end
refire.exec(ev, snap)
end, { group = state.lazy_group, pattern = filetypes, once = true })
end), { group = state.lazy_group, pattern = filetypes, once = true })
end

return M
Loading
Loading