diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index 4ea0e61c3f..fac2376a97 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -402,6 +402,9 @@ update({names}, {opts}) *vim.pack.update()* • 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) - 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". Execute |:write| to confirm update, execute |:quit| to discard the update. • If `true`, make updates right away. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 35952243b9..b7e396febe 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -897,7 +897,7 @@ local function feedback_log(plug_list) end --- @param lines string[] ---- @param on_finish fun() +--- @param on_finish fun(bufnr: integer) local function show_confirm_buf(lines, on_finish) -- Show buffer in a separate tabpage local bufnr = api.nvim_create_buf(true, true) @@ -917,7 +917,7 @@ local function show_confirm_buf(lines, on_finish) -- Define action on accepting confirm local function finish() - on_finish() + on_finish(bufnr) delete_buffer() end -- - Use `nested` to allow other events (useful for statuslines) @@ -945,6 +945,28 @@ local function show_confirm_buf(lines, on_finish) vim.lsp.buf_attach_client(bufnr, require('vim.pack._lsp').client_id) end +--- Get map of plugin names that need update based on confirmation buffer +--- content: all plugin sections present in "# Update" section. +--- @param bufnr integer +--- @return table +local function get_update_map(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + --- @type table, boolean + local res, is_in_update = {}, false + for _, l in ipairs(lines) do + local name = l:match('^## (.+)$') + if name and is_in_update then + res[name] = true + end + + local group = l:match('^# (%S+)') + if group then + is_in_update = group == 'Update' + end + end + return res +end + --- @class vim.pack.keyset.update --- @inlinedoc --- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`. @@ -966,6 +988,9 @@ end --- - 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) - --- 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". --- --- Execute |:write| to confirm update, execute |:quit| to discard the update. --- - If `true`, make updates right away. @@ -996,11 +1021,11 @@ function M.update(names, opts) --- @param p vim.pack.Plug local function do_update(p) -- Fetch - -- Using '--tags --force' means conflicting tags will be synced with remote - git_cmd( - { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' }, - p.path - ) + if not opts._offline then + -- Using '--tags --force' means conflicting tags will be synced with remote + local args = { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' } + git_cmd(args, p.path) + end -- Compute change info: changelog if any, new tags if nothing to update infer_update_details(p) @@ -1010,7 +1035,8 @@ function M.update(names, opts) checkout(p, timestamp, true) end end - local progress_title = opts.force and 'Updating' or 'Downloading updates' + local progress_title = opts.force and (opts._offline and 'Applying updates' or 'Updating') + or 'Downloading updates' run_list(plug_list, do_update, progress_title) if opts.force then @@ -1021,17 +1047,18 @@ function M.update(names, opts) -- Show report in new buffer in separate tabpage local lines = compute_feedback_lines(plug_list, false) - show_confirm_buf(lines, function() - -- TODO(echasnovski): Allow to not update all plugins via LSP code actions - --- @param p vim.pack.Plug - local plugs_to_checkout = vim.tbl_filter(function(p) - return p.info.err == '' and p.info.sha_head ~= p.info.sha_target - end, plug_list) - if #plugs_to_checkout == 0 then + show_confirm_buf(lines, function(bufnr) + local to_update = get_update_map(bufnr) + if not next(to_update) then notify('Nothing to update', 'WARN') return end + --- @param p vim.pack.Plug + local plugs_to_checkout = vim.tbl_filter(function(p) + return to_update[p.spec.name] + end, plug_list) + local timestamp2 = get_timestamp() --- @async --- @param p vim.pack.Plug diff --git a/runtime/lua/vim/pack/_lsp.lua b/runtime/lua/vim/pack/_lsp.lua index 52dff730c2..2484150208 100644 --- a/runtime/lua/vim/pack/_lsp.lua +++ b/runtime/lua/vim/pack/_lsp.lua @@ -3,6 +3,7 @@ local M = {} local capabilities = { codeActionProvider = true, documentSymbolProvider = true, + executeCommandProvider = { commands = { 'delete_plugin', 'update_plugin', 'skip_update_plugin' } }, hoverProvider = true, } --- @type table @@ -22,6 +23,48 @@ local get_confirm_bufnr = function(uri) return tonumber(uri:match('^nvim%-pack://(%d+)/confirm%-update$')) end +local group_header_pattern = '^# (%S+)' +local plugin_header_pattern = '^## (.+)$' + +--- @return { group: string?, name: string?, from: integer?, to: integer? } +local get_plug_data_at_lnum = function(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + --- @type string, string, integer, integer + local group, name, from, to + for i = lnum, 1, -1 do + group = group or lines[i]:match(group_header_pattern) --[[@as string]] + -- If group is found earlier than name - `lnum` is for group header line + -- If later - proper group header line. + if group then + break + end + name = name or lines[i]:match(plugin_header_pattern) --[[@as string]] + from = (not from and name) and i or from --[[@as integer]] + end + if not (group and name and from) then + return {} + end + --- @cast group string + --- @cast from integer + + for i = lnum + 1, #lines do + if lines[i]:match(group_header_pattern) or lines[i]:match(plugin_header_pattern) then + -- Do not include blank line before next section + to = i - 2 + break + end + end + to = to or #lines + + if not (from <= lnum and lnum <= to) then + return {} + end + return { group = group, name = name, from = from, to = to } +end + +--- @alias vim.pack.lsp.Position { line: integer, character: integer } +--- @alias vim.pack.lsp.Range { start: vim.pack.lsp.Position, end: vim.pack.lsp.Position } + --- @param params { textDocument: { uri: string } } --- @param callback function methods['textDocument/documentSymbol'] = function(params, callback) @@ -30,8 +73,6 @@ methods['textDocument/documentSymbol'] = function(params, callback) return callback(nil, {}) end - --- @alias vim.pack.lsp.Position { line: integer, character: integer } - --- @alias vim.pack.lsp.Range { start: vim.pack.lsp.Position, end: vim.pack.lsp.Position } --- @alias vim.pack.lsp.Symbol { --- name: string, --- kind: number, @@ -69,28 +110,81 @@ methods['textDocument/documentSymbol'] = function(params, callback) end local group_kind = vim.lsp.protocol.SymbolKind.Namespace - local symbols = parse_headers('^# (%S+)', 0, #lines - 1, group_kind) + local symbols = parse_headers(group_header_pattern, 0, #lines - 1, group_kind) local plug_kind = vim.lsp.protocol.SymbolKind.Module for _, group in ipairs(symbols) do local start_line, end_line = group.range.start.line, group.range['end'].line - group.children = parse_headers('^## (.+)$', start_line, end_line, plug_kind) + group.children = parse_headers(plugin_header_pattern, start_line, end_line, plug_kind) end return callback(nil, symbols) end +--- @alias vim.pack.lsp.CodeActionContext { diagnostics: table, only: table?, triggerKind: integer? } + +--- @param params { textDocument: { uri: string }, range: vim.pack.lsp.Range, context: vim.pack.lsp.CodeActionContext } --- @param callback function -methods['textDocument/codeAction'] = function(_, callback) - -- TODO(echasnovski) - -- Suggested actions for "plugin under cursor": - -- - Delete plugin from disk. - -- - Update only this plugin. - -- - Exclude this plugin from update. - return callback(_, {}) +methods['textDocument/codeAction'] = function(params, callback) + local bufnr = get_confirm_bufnr(params.textDocument.uri) + local empty_kind = vim.lsp.protocol.CodeActionKind.Empty + local only = params.context.only or { empty_kind } + if not (bufnr and vim.tbl_contains(only, empty_kind)) then + return callback(nil, {}) + end + local plug_data = get_plug_data_at_lnum(bufnr, params.range.start.line + 1) + if not plug_data.name then + return callback(nil, {}) + end + + local function new_action(title, command) + return { + title = ('%s `%s`'):format(title, plug_data.name), + command = { title = title, command = command, arguments = { bufnr, plug_data } }, + } + end + + local res = {} + if plug_data.group == 'Update' then + vim.list_extend(res, { + new_action('Update', 'update_plugin'), + new_action('Skip updating', 'skip_update_plugin'), + }, 0) + end + vim.list_extend(res, { new_action('Delete', 'delete_plugin') }) + callback(nil, res) end ---- @param params { textDocument: { uri: string }, position: { line: integer, character: integer } } +local commands = { + update_plugin = function(plug_data) + vim.pack.update({ plug_data.name }, { force = true, _offline = true }) + end, + skip_update_plugin = function(_) end, + delete_plugin = function(plug_data) + vim.pack.del({ plug_data.name }) + end, +} + +-- NOTE: Use `vim.schedule_wrap` to avoid press-enter after choosing code +-- action via built-in `vim.fn.inputlist()` +--- @param params { command: string, arguments: table } +--- @param callback function +methods['workspace/executeCommand'] = vim.schedule_wrap(function(params, callback) + --- @type integer, table + local bufnr, plug_data = unpack(params.arguments) + local ok, err = pcall(commands[params.command], plug_data) + if not ok then + return callback({ code = 1, message = err }, {}) + end + + -- Remove plugin lines (including blank line) to not later act on plugin + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, plug_data.from - 2, plug_data.to, false, {}) + vim.bo[bufnr].modifiable, vim.bo[bufnr].modified = false, false + callback(nil, {}) +end) + +--- @param params { textDocument: { uri: string }, position: vim.pack.lsp.Position } --- @param callback function methods['textDocument/hover'] = function(params, callback) local bufnr = get_confirm_bufnr(params.textDocument.uri) diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 78da56ef86..c00c40791a 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -1136,6 +1136,7 @@ describe('vim.pack', function() it('has in-process LSP features', function() t.skip(not is_jit(), "Non LuaJIT reports errors differently due to 'coxpcall'") + track_nvim_echo() exec_lua(function() vim.pack.add({ repos_src.fetch, @@ -1203,6 +1204,116 @@ describe('vim.pack', function() validate_hover({ 30, 0 }, 'Add version v1.0.0') validate_hover({ 31, 0 }, 'Add version v0.4') validate_hover({ 32, 0 }, 'Add version 0.3.1') + + -- textDocument/codeAction + n.exec_lua(function() + -- Mock `vim.ui.select()` which is a default code action selection + _G.select_idx = 0 + + ---@diagnostic disable-next-line: duplicate-set-field + vim.ui.select = function(items, _, on_choice) + _G.select_items = items + local idx = _G.select_idx + if idx > 0 then + on_choice(items[idx], idx) + -- Minor delay before continue because LSP cmd execution is async + vim.wait(10) + end + end + end) + + local ref_lockfile = get_lock_tbl() --- @type vim.pack.Lock + + local function validate_action(pos, action_titles, select_idx) + api.nvim_win_set_cursor(0, pos) + + local lines = api.nvim_buf_get_lines(0, 0, -1, false) + n.exec_lua(function() + _G.select_items = nil + _G.select_idx = select_idx + vim.lsp.buf.code_action() + end) + local titles = vim.tbl_map(function(x) --- @param x table + return x.action.title + end, n.exec_lua('return _G.select_items or {}')) + eq(titles, action_titles) + + -- If no action is asked (like via cancel), should not delete lines + if select_idx <= 0 then + eq(lines, api.nvim_buf_get_lines(0, 0, -1, false)) + end + end + + -- - Should not include "namespace" header as "plugin at cursor" + validate_action({ 1, 1 }, {}, 0) + validate_action({ 2, 0 }, {}, 0) + -- - Only deletion should be available on errored plugin + validate_action({ 3, 1 }, { 'Delete `defbranch`' }, 0) + validate_action({ 7, 0 }, { 'Delete `defbranch`' }, 0) + -- - Should not include separator blank line as "plugin at cursor" + validate_action({ 8, 0 }, {}, 0) + validate_action({ 9, 0 }, {}, 0) + validate_action({ 10, 0 }, {}, 0) + -- - Should also suggest updating related actions if updates available + local fetch_actions = { 'Update `fetch`', 'Skip updating `fetch`', 'Delete `fetch`' } + validate_action({ 11, 0 }, fetch_actions, 0) + validate_action({ 14, 0 }, fetch_actions, 0) + validate_action({ 20, 0 }, fetch_actions, 0) + validate_action({ 21, 0 }, {}, 0) + validate_action({ 22, 0 }, {}, 0) + validate_action({ 23, 0 }, {}, 0) + -- - Only deletion should be available on plugins without update + validate_action({ 24, 0 }, { 'Delete `semver`' }, 0) + validate_action({ 28, 0 }, { 'Delete `semver`' }, 0) + validate_action({ 32, 0 }, { 'Delete `semver`' }, 0) + + -- - Should correctly perform action and remove plugin's lines + local function line_match(lnum, pattern) + matches(pattern, api.nvim_buf_get_lines(0, lnum - 1, lnum, false)[1]) + end + + -- - Delete. Should remove from disk and update lockfile. + validate_action({ 3, 0 }, { 'Delete `defbranch`' }, 1) + eq(false, pack_exists('defbranch')) + line_match(1, '^# Error') + line_match(2, '^$') + line_match(3, '^# Update') + + ref_lockfile.plugins.defbranch = nil + eq(ref_lockfile, get_lock_tbl()) + + -- - Skip udating + validate_action({ 5, 0 }, fetch_actions, 2) + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + line_match(3, '^# Update') + line_match(4, '^$') + line_match(5, '^# Same') + + -- - Update plugin. Should not re-fetch new data and update lockfile. + n.exec('quit') + n.exec_lua(function() + vim.pack.update({ 'fetch', 'semver' }) + end) + exec_lua('_G.echo_log = {}') + + ref_lockfile.plugins.fetch.rev = git_get_hash('main', 'fetch') + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 3"') + git_add_commit('Commit to be added 3', 'fetch') + + validate_action({ 3, 0 }, fetch_actions, 1) + + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + validate_progress_report('Applying updates', { 'fetch' }) + line_match(1, '^# Update') + line_match(2, '^$') + line_match(3, '^# Same') + + eq(ref_lockfile, get_lock_tbl()) + + -- - Can still respect `:write` after action + n.exec('write') + eq('vim.pack: Nothing to update', n.exec_capture('1messages')) + eq(api.nvim_get_option_value('filetype', {}), '') end) it('has buffer-local mappings', function()