neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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:
Mruntime/doc/pack.txt | 15++++++++++-----
Mruntime/ftplugin/nvim-pack.lua | 4+++-
Mruntime/lua/vim/pack.lua | 50++++++++++++++++++++++++++++++++++++--------------
Mruntime/lua/vim/pack/_lsp.lua | 6++++--
Mtest/functional/plugin/pack_spec.lua | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
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()