commit ea3562d739aa8f818b880147c90a816313bc3e1f
parent f8d01904915a0d3f5911c4b0c6ff64d040c3f0d6
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Tue, 30 Dec 2025 04:20:33 -0500
Merge #37142 from echasnovski/pack-safer-del
Diffstat:
5 files changed, 146 insertions(+), 64 deletions(-)
diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt
@@ -321,9 +321,10 @@ Revert plugin after an update ~
• When ready to deal with updating plugin, unfreeze it.
Remove plugins from disk ~
-• Use |vim.pack.del()| with a list of plugin names to remove. Make sure their
- specs are not included in |vim.pack.add()| call in 'init.lua' or they will
- be reinstalled.
+• Remove plugin specs from |vim.pack.add()| calls in 'init.lua' or they will
+ be reinstalled later.
+• |:restart|.
+• Use |vim.pack.del()| with a list of plugin names to remove.
*vim.pack-events*
• *PackChangedPre* - before trying to change plugin's state.
@@ -416,13 +417,16 @@ add({specs}, {opts}) *vim.pack.add()*
• {confirm}? (`boolean`) Whether to ask user to confirm
initial install. Default `true`.
-del({names}) *vim.pack.del()*
+del({names}, {opts}) *vim.pack.del()*
Remove plugins from disk
Parameters: ~
• {names} (`string[]`) List of plugin names to remove from disk. Must
be managed by |vim.pack|, not necessarily already added to
current session.
+ • {opts} (`table?`) A table with the following fields:
+ • {force}? (`boolean`) Whether to allow deleting an active
+ plugin. Default `false`.
get({names}, {opts}) *vim.pack.get()*
Gets |vim.pack| plugin info, optionally filtered by `names`.
@@ -464,7 +468,8 @@ update({names}, {opts}) *vim.pack.update()*
details of particular pending change or newer tag.
• 'textDocument/codeAction' (`gra` via |lsp-defaults| or
|vim.lsp.buf.code_action()|) - show code actions available for
- "plugin at cursor". Like "delete", "update", or "skip updating".
+ "plugin at cursor". Like "delete" (if plugin is not active),
+ "update" or "skip updating" (if there are pending updates).
Execute |:write| to confirm update, execute |:quit| to discard the
update.
• If `true`, make updates right away.
diff --git a/runtime/ftplugin/nvim-pack.lua b/runtime/ftplugin/nvim-pack.lua
@@ -25,8 +25,10 @@ for i, l in ipairs(lines) do
cur_header_hl_group = header_hl_groups[cur_group]
hi_range(i, 0, l:len(), cur_header_hl_group)
elseif l:find('^## (.+)$') ~= nil then
- -- Header 2
+ -- Header 2 with possibly "(not active)" suffix
hi_range(i, 0, l:len(), cur_header_hl_group)
+ local col = l:match('() %(not active%)$') or l:len()
+ hi_range(i, col, l:len(), 'DiagnosticError', priority + 1)
elseif cur_info ~= nil then
-- Plugin info
local end_col = l:match('(). +%b()$') or l:len()
diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua
@@ -126,8 +126,10 @@
---
---Remove plugins from disk ~
---
----- Use |vim.pack.del()| with a list of plugin names to remove. Make sure their specs
----are not included in |vim.pack.add()| call in 'init.lua' or they will be reinstalled.
+---- Remove plugin specs from |vim.pack.add()| calls in 'init.lua' or they will be
+--- reinstalled later.
+---- |:restart|.
+---- Use |vim.pack.del()| with a list of plugin names to remove.
---
---[vim.pack-events]()
---
@@ -988,11 +990,12 @@ end
--- @param p vim.pack.Plug
--- @return string
local function compute_feedback_lines_single(p)
+ local active_suffix = active_plugins[p.path] ~= nil and '' or ' (not active)'
if p.info.err ~= '' then
- return ('## %s\n\n %s'):format(p.spec.name, p.info.err:gsub('\n', '\n '))
+ return ('## %s%s\n\n %s'):format(p.spec.name, active_suffix, p.info.err:gsub('\n', '\n '))
end
- local parts = { '## ' .. p.spec.name .. '\n' }
+ local parts = { ('## %s%s\n'):format(p.spec.name, active_suffix) }
local version_suffix = p.info.version_str == '' and '' or (' (%s)'):format(p.info.version_str)
if p.info.sha_head == p.info.sha_target then
@@ -1125,7 +1128,7 @@ local function get_update_map(bufnr)
for _, l in ipairs(lines) do
local name = l:match('^## (.+)$')
if name and is_in_update then
- res[name] = true
+ res[name:gsub(' %(not active%)$', '')] = true
end
local group = l:match('^# (%S+)')
@@ -1158,8 +1161,9 @@ end
--- show more information at cursor. Like details of particular pending
--- change or newer tag.
--- - 'textDocument/codeAction' (`gra` via |lsp-defaults| or |vim.lsp.buf.code_action()|) -
---- show code actions available for "plugin at cursor". Like "delete", "update",
---- or "skip updating".
+--- show code actions available for "plugin at cursor".
+--- Like "delete" (if plugin is not active), "update" or "skip updating"
+--- (if there are pending updates).
---
--- Execute |:write| to confirm update, execute |:quit| to discard the update.
--- - If `true`, make updates right away.
@@ -1256,12 +1260,18 @@ function M.update(names, opts)
end)
end
+--- @class vim.pack.keyset.del
+--- @inlinedoc
+--- @field force? boolean Whether to allow deleting an active plugin. Default `false`.
+
--- Remove plugins from disk
---
--- @param names string[] List of plugin names to remove from disk. Must be managed
--- by |vim.pack|, not necessarily already added to current session.
-function M.del(names)
+--- @param opts? vim.pack.keyset.del
+function M.del(names, opts)
vim.validate('names', names, vim.islist, false, 'list')
+ opts = vim.tbl_extend('force', { force = false }, opts or {})
local plug_list = plug_list_from_names(names)
if #plug_list == 0 then
@@ -1271,19 +1281,31 @@ function M.del(names)
lock_read()
+ local fail_to_delete = {} --- @type string[]
for _, p in ipairs(plug_list) do
- trigger_event(p, 'PackChangedPre', 'delete')
+ if not active_plugins[p.path] or opts.force then
+ trigger_event(p, 'PackChangedPre', 'delete')
- vim.fs.rm(p.path, { recursive = true, force = true })
- active_plugins[p.path] = nil
- notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO')
+ vim.fs.rm(p.path, { recursive = true, force = true })
+ active_plugins[p.path] = nil
+ notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO')
- plugin_lock.plugins[p.spec.name] = nil
+ plugin_lock.plugins[p.spec.name] = nil
- trigger_event(p, 'PackChanged', 'delete')
+ trigger_event(p, 'PackChanged', 'delete')
+ else
+ fail_to_delete[#fail_to_delete + 1] = p.spec.name
+ end
end
lock_write()
+
+ if #fail_to_delete > 0 then
+ local plugs = table.concat(fail_to_delete, ', ')
+ local msg = ('Some plugins are active and were not deleted: %s.'):format(plugs)
+ .. ' Remove them from init.lua, restart, and try again.'
+ error(msg)
+ end
end
--- @inlinedoc
diff --git a/runtime/lua/vim/pack/_lsp.lua b/runtime/lua/vim/pack/_lsp.lua
@@ -59,7 +59,7 @@ local get_plug_data_at_lnum = function(bufnr, lnum)
if not (from <= lnum and lnum <= to) then
return {}
end
- return { group = group, name = name, from = from, to = to }
+ return { group = group, name = name:gsub(' %(not active%)$', ''), from = from, to = to }
end
--- @alias vim.pack.lsp.Position { line: integer, character: integer }
@@ -151,7 +151,9 @@ methods['textDocument/codeAction'] = function(params, callback)
new_action('Skip updating', 'skip_update_plugin'),
}, 0)
end
- vim.list_extend(res, { new_action('Delete', 'delete_plugin') })
+ if not vim.pack.get({ plug_data.name })[1].active then
+ vim.list_extend(res, { new_action('Delete', 'delete_plugin') })
+ end
callback(nil, res)
end
diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua
@@ -1384,7 +1384,7 @@ describe('vim.pack', function()
exec_lua(function()
vim.pack.add({
repos_src.fetch,
- { src = repos_src.semver, version = 'v0.3.0' },
+ -- No `semver` to test with non-active plugins
{ src = repos_src.defbranch, version = 'does-not-exist' },
})
vim.pack.update()
@@ -1409,7 +1409,7 @@ describe('vim.pack', function()
{ lnum = 9, col = 1, end_lnum = 22, end_col = 1, text = '[Namespace] Update' },
{ lnum = 11, col = 1, end_lnum = 22, end_col = 1, text = '[Module] fetch' },
{ lnum = 22, col = 1, end_lnum = 32, end_col = 1, text = '[Namespace] Same' },
- { lnum = 24, col = 1, end_lnum = 32, end_col = 1, text = '[Module] semver' },
+ { lnum = 24, col = 1, end_lnum = 32, end_col = 1, text = '[Module] semver (not active)' },
}
eq(ref_loclist, loclist)
@@ -1491,22 +1491,22 @@ describe('vim.pack', function()
-- - Should not include "namespace" header as "plugin at cursor"
assert_action({ 1, 1 }, {}, 0)
assert_action({ 2, 0 }, {}, 0)
- -- - Only deletion should be available on errored plugin
- assert_action({ 3, 1 }, { 'Delete `defbranch`' }, 0)
- assert_action({ 7, 0 }, { 'Delete `defbranch`' }, 0)
+ -- - No actions for `defbranch` since it is active and has no updates
+ assert_action({ 3, 1 }, {}, 0)
+ assert_action({ 7, 0 }, {}, 0)
-- - Should not include separator blank line as "plugin at cursor"
assert_action({ 8, 0 }, {}, 0)
assert_action({ 9, 0 }, {}, 0)
assert_action({ 10, 0 }, {}, 0)
- -- - Should also suggest updating related actions if updates available
- local fetch_actions = { 'Update `fetch`', 'Skip updating `fetch`', 'Delete `fetch`' }
+ -- - Should suggest updating related actions if updates available
+ local fetch_actions = { 'Update `fetch`', 'Skip updating `fetch`' }
assert_action({ 11, 0 }, fetch_actions, 0)
assert_action({ 14, 0 }, fetch_actions, 0)
assert_action({ 20, 0 }, fetch_actions, 0)
assert_action({ 21, 0 }, {}, 0)
assert_action({ 22, 0 }, {}, 0)
assert_action({ 23, 0 }, {}, 0)
- -- - Only deletion should be available on plugins without update
+ -- - Only deletion should be available for not active plugins
assert_action({ 24, 0 }, { 'Delete `semver`' }, 0)
assert_action({ 28, 0 }, { 'Delete `semver`' }, 0)
assert_action({ 32, 0 }, { 'Delete `semver`' }, 0)
@@ -1516,27 +1516,26 @@ describe('vim.pack', function()
matches(pattern, api.nvim_buf_get_lines(0, lnum - 1, lnum, false)[1])
end
- -- - Delete. Should remove from disk and update lockfile.
- assert_action({ 3, 0 }, { 'Delete `defbranch`' }, 1)
- eq(false, pack_exists('defbranch'))
- line_match(1, '^# Error')
- line_match(2, '^$')
- line_match(3, '^# Update')
+ -- - Delete not active plugin. Should remove from disk and update lockfile.
+ assert_action({ 24, 0 }, { 'Delete `semver`' }, 1)
+ eq(false, pack_exists('semver'))
+ line_match(22, '^# Same')
+ eq(22, api.nvim_buf_line_count(0))
- ref_lockfile.plugins.defbranch = nil
+ ref_lockfile.plugins.semver = nil
eq(ref_lockfile, get_lock_tbl())
-- - Skip udating
- assert_action({ 5, 0 }, fetch_actions, 2)
+ assert_action({ 11, 0 }, fetch_actions, 2)
eq('return "fetch main"', fn.readblob(fetch_lua_file))
- line_match(3, '^# Update')
- line_match(4, '^$')
- line_match(5, '^# Same')
+ line_match(9, '^# Update')
+ line_match(10, '^$')
+ line_match(11, '^# Same')
-- - Update plugin. Should not re-fetch new data and update lockfile.
n.exec('quit')
n.exec_lua(function()
- vim.pack.update({ 'fetch', 'semver' })
+ vim.pack.update({ 'fetch' })
end)
exec_lua('_G.echo_log = {}')
@@ -1549,8 +1548,7 @@ describe('vim.pack', function()
eq('return "fetch new 2"', fn.readblob(fetch_lua_file))
assert_progress_report('Applying updates', { 'fetch' })
line_match(1, '^# Update')
- line_match(2, '^$')
- line_match(3, '^# Same')
+ eq(1, api.nvim_buf_line_count(0))
eq(ref_lockfile, get_lock_tbl())
@@ -1622,6 +1620,29 @@ describe('vim.pack', function()
ref_fetch_lock.rev = git_get_hash('main', 'fetch')
eq(ref_fetch_lock, get_lock_tbl().plugins.fetch)
end)
+
+ it('hints about not active plugins', function()
+ exec_lua(function()
+ vim.pack.update()
+ end)
+
+ for _, l in ipairs(api.nvim_buf_get_lines(0, 0, -1, false)) do
+ if l:match('^## ') then
+ matches(' %(not active%)$', l)
+ end
+ end
+
+ -- Should also hint in `textDocument/documentSymbol` of in-process LSP,
+ -- yet still work for navigation
+ exec_lua('vim.lsp.buf.document_symbol()')
+ local loclist = fn.getloclist(0)
+ matches(' %(not active%)$', loclist[2].text)
+ matches(' %(not active%)$', loclist[4].text)
+ matches(' %(not active%)$', loclist[5].text)
+
+ n.exec('llast')
+ eq(21, api.nvim_win_get_cursor(0)[1])
+ end)
end)
it('works with not active plugins', function()
@@ -1980,7 +2001,7 @@ describe('vim.pack', function()
-- Should not include removed plugins immediately after they are removed,
-- while still returning list without holes
- exec_lua('vim.pack.del({ "defbranch" })')
+ exec_lua('vim.pack.del({ "defbranch" }, { force = true })')
local defbranch_data = make_defbranch_data(true, true)
local basic_data = make_basic_data(true, true)
eq({ { defbranch_data, basic_data }, { basic_data } }, exec_lua('return _G.get_log'))
@@ -2013,40 +2034,70 @@ describe('vim.pack', function()
describe('del()', function()
it('works', function()
exec_lua(function()
- vim.pack.add({ repos_src.plugindirs, { src = repos_src.basic, version = 'feat-branch' } })
+ local basic_spec = { src = repos_src.basic, version = 'feat-branch' }
+ vim.pack.add({ repos_src.plugindirs, repos_src.defbranch, basic_spec })
end)
- eq(true, pack_exists('basic'))
- eq(true, pack_exists('plugindirs'))
- local locked_plugins = vim.tbl_keys(get_lock_tbl().plugins)
- table.sort(locked_plugins)
- eq({ 'basic', 'plugindirs' }, locked_plugins)
+ local assert_on_disk = function(installed_map)
+ local installed = {}
+ for p_name, is_installed in pairs(installed_map) do
+ eq(is_installed, pack_exists(p_name))
+ if is_installed then
+ installed[#installed + 1] = p_name
+ end
+ end
- watch_events({ 'PackChangedPre', 'PackChanged' })
+ table.sort(installed)
+ local locked = vim.tbl_keys(get_lock_tbl().plugins)
+ table.sort(locked)
+ eq(installed, locked)
+ end
- n.exec('messages clear')
+ assert_on_disk({ basic = true, defbranch = true, plugindirs = true })
+
+ -- By default should delete only non-active plugins, even if
+ -- there is active one among input plugin names
+ n.clear()
exec_lua(function()
- vim.pack.del({ 'basic', 'plugindirs' })
+ vim.pack.add({ repos_src.defbranch })
end)
- eq(false, pack_exists('basic'))
- eq(false, pack_exists('plugindirs'))
+ watch_events({ 'PackChangedPre', 'PackChanged' })
- eq(
- "vim.pack: Removed plugin 'basic'\nvim.pack: Removed plugin 'plugindirs'",
- n.exec_capture('messages')
- )
+ local err = pcall_err(exec_lua, function()
+ vim.pack.del({ 'basic', 'defbranch', 'plugindirs' })
+ end)
+ matches('Some plugins are active and were not deleted: defbranch', err)
+
+ assert_on_disk({ basic = false, defbranch = true, plugindirs = false })
+
+ local msg = "vim.pack: Removed plugin 'basic'\nvim.pack: Removed plugin 'plugindirs'"
+ eq(msg, n.exec_capture('messages'))
-- Should trigger relevant events in order as specified in `vim.pack.add()`
local log = exec_lua('return _G.event_log')
local find_event = make_find_packchanged(log)
- eq(1, find_event('Pre', 'delete', 'basic', 'feat-branch', true))
+ eq(1, find_event('Pre', 'delete', 'basic', 'feat-branch', false))
eq(2, find_event('', 'delete', 'basic', 'feat-branch', false))
- eq(3, find_event('Pre', 'delete', 'plugindirs', nil, true))
+ eq(3, find_event('Pre', 'delete', 'plugindirs', nil, false))
eq(4, find_event('', 'delete', 'plugindirs', nil, false))
eq(4, #log)
- -- Should update lockfile
- eq({ plugins = {} }, get_lock_tbl())
+ -- Should be possible to force delete active plugins
+ n.exec('messages clear')
+ exec_lua('_G.event_log = {}')
+ exec_lua(function()
+ vim.pack.del({ 'defbranch' }, { force = true })
+ end)
+
+ assert_on_disk({ basic = false, defbranch = false, plugindirs = false })
+
+ eq("vim.pack: Removed plugin 'defbranch'", n.exec_capture('messages'))
+
+ log = exec_lua('return _G.event_log')
+ find_event = make_find_packchanged(log)
+ eq(1, find_event('Pre', 'delete', 'defbranch', nil, true))
+ eq(2, find_event('', 'delete', 'defbranch', nil, false))
+ eq(2, #log)
end)
it('works without prior `add()`', function()