feat(pack): add support for sourcing manifest scripts

Problem: No way for plugins to define hooks that would be executed
  during plugin lifecycle. Like after install/update or before
  update/delete.

Solution: Automatically source scripts defined in plugin manifest file
  (if any) after triggering corresponding `PackChanged{,Pre}` events.
  During sourcing make some termporary adjustments:
  - Current directory is set to plugin's root to simplify execution of
    CLI calls like `make build`.
  - Plugin's path is ensured to be inside 'runtimepath' to allow using
    `require('plugin-module')` inside manifest scripts.

  The reason to execute after triggering event is so that there is a
  possibility for users to execute code both before the script (exactly
  on event) and after the script (more-or-less via `vim.schedule` called
  on the event).
This commit is contained in:
Evgeni Chasnovski
2026-07-31 15:17:00 +03:00
parent 4cdd6d76c5
commit 9d12df0398
4 changed files with 242 additions and 2 deletions

View File

@@ -427,6 +427,14 @@ These events can be used to execute plugin hooks. For example: >lua
Plugins can come with a special top level `pkg.json` manifest file with extra
information. If present, `vim.pack` uses it for improved user experience:
• Apply |:source| for scripts after triggering corresponding
|vim.pack-events|. This allows plugins to define hooks that will be executed
during plugin's lifetime. Sourcing is done with special context:
• The |current-directory| is temporarily set to plugin's root (to make it
easier to run |vim.system()| commands).
• Plugin's path is temporarily ensured to be inside |'runtimepath'| (so
script can use |require()| with plugin's module, possibly with explicit
|package.loaded| reset inside `"update"` scripts).
• Running |:checkhealth| for `vim.pack` will perform extra checks to ensure
healthy plugin installation.
@@ -438,6 +446,12 @@ A simple example: >json
"engines": {
"nvim": ">=0.13.0",
"vim": ">=9.1.0"
},
"scripts": {
"install": "scripts/install.lua",
"preupdate": "scripts/preupdate.vim",
"update": "scripts/update.vim",
"preuninstall": "scripts/preuninstall.lua"
}
}
<
@@ -452,6 +466,13 @@ A simple example: >json
• {nvim}? (`string`) Version range for Nvim.
• {vim}? (`string`) Version range for Vim.
• {name}? (`string`) Plugin name
• {scripts}? (`table`) Script locations (relative to plugin's root)
to |:source| after triggering corresponding
|vim.pack-events|.
• {install}? (`string`) Post install script.
• {preuninstall}? (`string`) Pre delete script.
• {preupdate}? (`string`) Pre update script.
• {update}? (`string`) Post update script.
*vim.pack.Spec*

View File

@@ -236,6 +236,14 @@
---
---Plugins can come with a special top level `pkg.json` manifest file with extra
---information. If present, `vim.pack` uses it for improved user experience:
---- Apply |:source| for scripts after triggering corresponding |vim.pack-events|.
--- This allows plugins to define hooks that will be executed during plugin's lifetime.
--- Sourcing is done with special context:
--- - The |current-directory| is temporarily set to plugin's root (to make
--- it easier to run |vim.system()| commands).
--- - Plugin's path is temporarily ensured to be inside |'runtimepath'| (so script
--- can use |require()| with plugin's module, possibly with explicit |package.loaded|
--- reset inside `"update"` scripts).
---- Running |:checkhealth| for `vim.pack` will perform extra checks to ensure
--- healthy plugin installation.
---
@@ -248,6 +256,12 @@
--- "engines": {
--- "nvim": ">=0.13.0",
--- "vim": ">=9.1.0"
--- },
--- "scripts": {
--- "install": "scripts/install.lua",
--- "preupdate": "scripts/preupdate.vim",
--- "update": "scripts/update.vim",
--- "preuninstall": "scripts/preuninstall.lua"
--- }
---}
---```
@@ -540,11 +554,21 @@ local n_active_plugins = 0
--- @field nvim? string Version range for Nvim.
--- @field vim? string Version range for Vim.
--- @class vim.pack.ManifestScripts
--- @inlinedoc
--- @field install? string Post install script.
--- @field preupdate? string Pre update script.
--- @field update? string Post update script.
--- @field preuninstall? string Pre delete script.
--- @class vim.pack.Manifest
--- @field name? string Plugin name
--- @field description? string Plugin description
--- Supported engine versions. Values should be |vim.version.range()| compatible specs.
--- @field engines? vim.pack.ManifestEngines
--- Script locations (relative to plugin's root) to |:source| after triggering
--- corresponding |vim.pack-events|.
--- @field scripts? vim.pack.ManifestScripts
--- @param path string
--- @return vim.pack.Manifest?
@@ -566,14 +590,43 @@ local function manifest_read(path)
return (ok and type(res) == 'table') and res or {}
end
--- @param p vim.pack.Plug
--- @param name string
local function source_manifest_script(p, name)
local manifest = manifest_read(p.path)
if not (type(manifest) == 'table' and (manifest.scripts or {})[name]) then
return
end
local script_path = vim.fs.joinpath(p.path, (manifest.scripts or {})[name])
vim._with({ cwd = p.path, o = { runtimepath = vim.o.runtimepath } }, function()
vim.cmd.packadd({ p.spec.name, bang = true })
---@diagnostic disable-next-line: no-unknown
local ok, err = pcall(vim.cmd.source, { script_path, magic = { file = false, bar = false } })
if not ok then
notify(err, 'WARN')
end
end)
end
local manifest_script_name_map = {
PackChangedPre = { update = 'preupdate', delete = 'preuninstall' },
PackChanged = { install = 'install', update = 'update' },
}
--- @param plugs vim.pack.Plug[]
--- @param event_name 'PackChangedPre'|'PackChanged'
--- @param kind 'install'|'update'|'delete'
local function trigger_events(plugs, event_name, kind)
local manifest_script_name = manifest_script_name_map[event_name][kind]
for _, p in ipairs(plugs) do
local active = active_plugins[p.path] ~= nil
local data = { active = active, kind = kind, spec = vim.deepcopy(p.spec), path = p.path }
api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data })
if manifest_script_name then
source_manifest_script(p, manifest_script_name)
end
end
end

View File

@@ -200,7 +200,8 @@ local function check_lockfile()
end
end
local function check_manifest(manifest, plug_name)
--- @param manifest vim.pack.Manifest
local function check_manifest(manifest, plug_name, plug_path)
local name_str = vim.inspect(plug_name)
if vim.tbl_count(manifest) == 0 then
health.warn(('Plugin %s has empty or malformed manifest file'):format(name_str))
@@ -222,6 +223,18 @@ local function check_manifest(manifest, plug_name)
return false
end
local ok_scripts = true
---@diagnostic disable-next-line: no-unknown
for name, script_path in pairs(manifest.scripts or {}) do
if vim.fn.filereadable(vim.fs.joinpath(plug_path, script_path)) == 0 then
health.warn(('Plugin %s has no %s script at %s path'):format(name_str, name, script_path))
ok_scripts = false
end
end
if not ok_scripts then
return false
end
return true
end
@@ -275,7 +288,7 @@ local function check_installed_plugin(plug_name)
-- Manifest
if info[1].manifest then
return check_manifest(info[1].manifest, plug_name)
return check_manifest(info[1].manifest, plug_name, plug_path)
end
return true

View File

@@ -253,10 +253,52 @@ function repos_setup.with_manifest()
name = 'plug',
description = 'Nvim plugin',
engine = { nvim = '>=0.12.0', vim = '>=9.1.0' },
-- Should handle both `*.lua` and `*.vim` files
scripts = {
install = 'scripts/install.lua',
preupdate = 'scripts/preupdate.vim',
update = 'scripts/update.lua',
preuninstall = 'scirpts/preuninstall.vim',
},
}
repo_write_file('manifest', 'pkg.json', vim.json.encode(manifest_tbl))
-- Create scripts that use Lua module from the same plugin
local hook_module_text = [[
return {
hook = function(script_name)
-- NOTE: share the log with the one used for watching events to test
-- relative order of events and script
_G.event_log = _G.event_log or {}
local rtp = vim.api.nvim_list_runtime_paths()
local data = { kind = script_name, cwd = vim.fs.normalize(vim.uv.cwd()), rtp = rtp }
table.insert(_G.event_log, { event = 'script', data = data })
-- Should handle errors during script exectuion
error('Error in ' .. script_name .. ' script')
end
}
]]
repo_write_file('manifest', 'lua/manifest-hook.lua', hook_module_text)
for name, script_path in pairs(manifest_tbl.scripts) do
local text = ("require('manifest-hook').hook('%s')"):format(name)
if vim.endswith(script_path, '.vim') then
text = 'lua << EOF\n' .. text .. '\nEOF'
end
repo_write_file('manifest', script_path, text)
end
git_add_commit('Initial commit', 'manifest')
-- Add extra branch to test script execution in `vim.pack.update()`
git_cmd({ 'checkout', '-b', 'other-branch' }, 'manifest')
repo_write_file('manifest', 'lua/manifest.lua', 'return "manifest other-branch"')
local other_script = "_G.other_script = true\nrequire('manifest-hook').hook('update')"
repo_write_file('manifest', 'scripts/update.lua', other_script)
git_add_commit('Other commit', 'manifest')
git_cmd({ 'checkout', 'main' }, 'manifest')
end
-- Utility --------------------------------------------------------------------
@@ -293,6 +335,17 @@ local function assert_packchanged(log_compact)
eq(expected, log)
end
local function assert_manifest_scripts(plug_path, ref_log_trimmed)
-- Should be sourced in particular order relative to `PackChanged` events
local log_trimmed = {} --- @type table[]
for i, tbl in ipairs(exec_lua('return _G.event_log')) do
local rtp_has_plug = tbl.data.rtp and vim.tbl_contains(tbl.data.rtp, plug_path) or nil
local kind, cwd = tbl.data.kind, tbl.data.cwd
log_trimmed[i] = { event = tbl.event, kind = kind, cwd = cwd, rtp_has_plug = rtp_has_plug }
end
eq(ref_log_trimmed, log_trimmed)
end
local function track_nvim_echo()
exec_lua(function()
_G.echo_log = {}
@@ -305,6 +358,17 @@ local function track_nvim_echo()
end)
end
local function is_in_echo_log(chunk_substring, chunk_hl)
---@diagnostic disable-next-line: no-unknown
for _, msg in ipairs(exec_lua('return _G.echo_log')) do
local chunk = msg[1][1] --- @type [string, string]
if chunk[1]:find(chunk_substring, 0, true) and chunk[2] == chunk_hl then
return true
end
end
return false
end
--- @param echo_log table[]?
local function assert_progress_report(echo_log, action, step_names)
echo_log = echo_log or exec_lua('return _G.echo_log')
@@ -965,6 +1029,30 @@ describe('vim.pack', function()
})
end)
it('sources relevant manifest scripts', function()
watch_events({ 'PackChangedPre', 'PackChanged' })
track_nvim_echo()
local cwd = fn.getcwd()
exec_lua(function()
vim.pack.add({ repos_src.manifest }, { load = function() end })
end)
-- Should not have side effects after executing script in special context
eq(cwd, fn.getcwd())
local plug_path = pack_get_plug_path('manifest')
eq(false, vim.tbl_contains(api.nvim_list_runtime_paths(), plug_path))
-- Should execute script in special context after triggering events
assert_manifest_scripts(plug_path, {
{ event = 'PackChangedPre', kind = 'install' },
{ event = 'PackChanged', kind = 'install' },
{ event = 'script', kind = 'install', cwd = plug_path, rtp_has_plug = true },
})
-- Should warn on errors during script execution
eq(true, is_in_echo_log('Error in install script', 'WarningMsg'))
end)
it('recognizes several `version` types', function()
local prev_commit = git_get_hash('HEAD~', 'defbranch')
exec_lua(function()
@@ -2031,6 +2119,40 @@ describe('vim.pack', function()
})
end)
it('sources relevant manifest scripts', function()
vim_pack_add({ repos_src.manifest })
n.clear()
exec_lua(function()
local specs = { { src = repos_src.manifest, version = 'other-branch' } }
vim.pack.add(specs, { load = function() end })
end)
watch_events({ 'PackChangedPre', 'PackChanged' })
track_nvim_echo()
local cwd = fn.getcwd()
exec_lua("vim.pack.update({ 'manifest' }, { force = true })")
-- Should not have side effects after executing script in special context
eq(cwd, fn.getcwd())
local plug_path = pack_get_plug_path('manifest')
eq(false, vim.tbl_contains(api.nvim_list_runtime_paths(), plug_path))
-- Should execute script in special context after triggering events
assert_manifest_scripts(plug_path, {
{ event = 'PackChangedPre', kind = 'update' },
{ event = 'script', kind = 'preupdate', cwd = plug_path, rtp_has_plug = true },
{ event = 'PackChanged', kind = 'update' },
{ event = 'script', kind = 'update', cwd = plug_path, rtp_has_plug = true },
})
-- - Should execute update script as it is *after the update*
eq(true, exec_lua('return _G.other_script'))
-- Should warn on errors during script execution
eq(true, is_in_echo_log('Error in preupdate script', 'WarningMsg'))
eq(true, is_in_echo_log('Error in update script', 'WarningMsg'))
end)
it('stashes before applying changes', function()
local fetch_lua_file = vim.fs.joinpath(pack_get_plug_path('fetch'), 'lua', 'fetch.lua')
fn.writefile({ 'A text that will be stashed' }, fetch_lua_file)
@@ -2249,6 +2371,12 @@ describe('vim.pack', function()
name = 'plug',
description = 'Nvim plugin',
engine = { nvim = '>=0.12.0', vim = '>=9.1.0' },
scripts = {
install = 'scripts/install.lua',
preupdate = 'scripts/preupdate.vim',
update = 'scripts/update.lua',
preuninstall = 'scirpts/preuninstall.vim',
},
}
eq(manifest_tbl, exec_lua('return vim.pack.get({ "manifest" })[1].manifest'))
end)
@@ -2518,6 +2646,31 @@ describe('vim.pack', function()
eq(false, vim.uv.fs_stat(get_lock_path()) ~= nil)
end)
it('sources relevant manifest scripts', function()
vim_pack_add({ repos_src.manifest })
n.clear()
watch_events({ 'PackChangedPre', 'PackChanged' })
track_nvim_echo()
local cwd = fn.getcwd()
exec_lua("vim.pack.del({ 'manifest' })")
-- Should not have side effects after executing script in special context
eq(cwd, fn.getcwd())
local plug_path = pack_get_plug_path('manifest')
eq(false, vim.tbl_contains(api.nvim_list_runtime_paths(), plug_path))
-- Should execute script in special context after triggering events
assert_manifest_scripts(plug_path, {
{ event = 'PackChangedPre', kind = 'delete' },
{ event = 'script', kind = 'preuninstall', cwd = plug_path, rtp_has_plug = true },
{ event = 'PackChanged', kind = 'delete' },
})
-- Should warn on errors during script execution
eq(true, is_in_echo_log('Error in preuninstall script', 'WarningMsg'))
end)
it('validates input', function()
local function assert(err_pat, input)
local function del_input()