Merge #35052 test(pack): vim.pack

This commit is contained in:
Justin M. Keyes
2025-08-02 19:35:42 -04:00
committed by GitHub
6 changed files with 1240 additions and 113 deletions

View File

@@ -132,6 +132,7 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
env: env:
CC: ${{ matrix.build.cc }} CC: ${{ matrix.build.cc }}
NVIM_TEST_INTEG: ${{ matrix.build.flavor == 'release' && '1' || '0' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/setup - uses: ./.github/actions/setup

View File

@@ -291,7 +291,7 @@ Available events to hook into ~
Each event populates the following |event-data| fields: Each event populates the following |event-data| fields:
• `kind` - one of "install" (install on disk), "update" (update existing • `kind` - one of "install" (install on disk), "update" (update existing
plugin), "delete" (delete from disk). plugin), "delete" (delete from disk).
• `spec` - plugin's specification. • `spec` - plugin's specification with defaults made explicit.
• `path` - full path to plugin's directory. • `path` - full path to plugin's directory.

View File

@@ -5,6 +5,8 @@ local priority = 100
local hi_range = function(lnum, start_col, end_col, hl, pr) local hi_range = function(lnum, start_col, end_col, hl, pr)
--- @type vim.api.keyset.set_extmark --- @type vim.api.keyset.set_extmark
local opts = { end_row = lnum - 1, end_col = end_col, hl_group = hl, priority = pr or priority } local opts = { end_row = lnum - 1, end_col = end_col, hl_group = hl, priority = pr or priority }
-- Set expanding gravity for easier testing. Should not make big difference.
opts.right_gravity, opts.end_right_gravity = false, true
vim.api.nvim_buf_set_extmark(0, ns, lnum - 1, start_col, opts) vim.api.nvim_buf_set_extmark(0, ns, lnum - 1, start_col, opts)
end end
@@ -30,8 +32,10 @@ for i, l in ipairs(lines) do
hi_range(i, cur_info:len(), end_col, 'DiagnosticInfo') hi_range(i, cur_info:len(), end_col, 'DiagnosticInfo')
-- Plugin state after update -- Plugin state after update
local col = l:match('() %b()$') or l:len() local col = l:match('() %b()$')
hi_range(i, col, l:len(), 'DiagnosticHint') if col then
hi_range(i, col, l:len(), 'DiagnosticHint')
end
elseif l:match('^> ') then elseif l:match('^> ') then
-- Added change with possibly "breaking message" -- Added change with possibly "breaking message"
hi_range(i, 0, l:len(), 'Added') hi_range(i, 0, l:len(), 'Added')

View File

@@ -1,6 +1,7 @@
local M = {} local M = {}
local max_timeout = 30000 local max_timeout = 30000
local copcall = package.loaded.jit and pcall or require('coxpcall').pcall
--- @param thread thread --- @param thread thread
--- @param on_finish fun(err: string?, ...:any) --- @param on_finish fun(err: string?, ...:any)
@@ -21,7 +22,7 @@ local function resume(thread, on_finish, ...)
--- @cast fn -string --- @cast fn -string
--- @type boolean, string? --- @type boolean, string?
local ok, err = pcall(fn, function(...) local ok, err = copcall(fn, function(...)
resume(thread, on_finish, ...) resume(thread, on_finish, ...)
end) end)

View File

@@ -88,7 +88,7 @@
--- Each event populates the following |event-data| fields: --- Each event populates the following |event-data| fields:
--- - `kind` - one of "install" (install on disk), "update" (update existing --- - `kind` - one of "install" (install on disk), "update" (update existing
--- plugin), "delete" (delete from disk). --- plugin), "delete" (delete from disk).
--- - `spec` - plugin's specification. --- - `spec` - plugin's specification with defaults made explicit.
--- - `path` - full path to plugin's directory. --- - `path` - full path to plugin's directory.
local api = vim.api local api = vim.api
@@ -178,16 +178,9 @@ end
--- @async --- @async
--- @param cwd string --- @param cwd string
--- @param opts? { contains?: string, points_at?: string }
--- @return string[] --- @return string[]
local function git_get_tags(cwd, opts) local function git_get_tags(cwd)
local cmd = { 'tag', '--list', '--sort=-v:refname' } local cmd = { 'tag', '--list', '--sort=-v:refname' }
if opts and opts.contains then
vim.list_extend(cmd, { '--contains', opts.contains })
end
if opts and opts.points_at then
vim.list_extend(cmd, { '--points-at', opts.points_at })
end
return vim.split(git_cmd(cmd, cwd), '\n') return vim.split(git_cmd(cmd, cwd), '\n')
end end
@@ -202,14 +195,24 @@ end
--- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')? --- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')?
local function notify(msg, level) local function notify(msg, level)
msg = type(msg) == 'table' and table.concat(msg, '\n') or msg msg = type(msg) == 'table' and table.concat(msg, '\n') or msg
vim.notify('(vim.pack) ' .. msg, vim.log.levels[level or 'INFO']) vim.notify('vim.pack: ' .. msg, vim.log.levels[level or 'INFO'])
vim.cmd.redraw() vim.cmd.redraw()
end end
--- @param x string|vim.VersionRange --- @param x string|vim.VersionRange
--- @return boolean --- @return boolean
local function is_version(x) local function is_version(x)
return type(x) == 'string' or (pcall(x.has, x, '1')) return type(x) == 'string' or (type(x) == 'table' and pcall(x.has, x, '1'))
end
--- @param x string
--- @return boolean
local function is_semver(x)
return vim.version.parse(x) ~= nil
end
local function is_nonempty_string(x)
return type(x) == 'string' and x ~= ''
end end
--- @return string --- @return string
@@ -239,9 +242,10 @@ end
local function normalize_spec(spec) local function normalize_spec(spec)
spec = type(spec) == 'string' and { src = spec } or spec spec = type(spec) == 'string' and { src = spec } or spec
vim.validate('spec', spec, 'table') vim.validate('spec', spec, 'table')
vim.validate('spec.src', spec.src, 'string') vim.validate('spec.src', spec.src, is_nonempty_string, false, 'non-empty string')
local name = (spec.name or spec.src:gsub('%.git$', '')):match('[^/]+$') local name = spec.name or spec.src:gsub('%.git$', '')
vim.validate('spec.name', name, 'string') name = (type(name) == 'string' and name or ''):match('[^/]+$') or ''
vim.validate('spec.name', name, is_nonempty_string, true, 'non-empty string')
vim.validate('spec.version', spec.version, is_version, true, 'string or vim.VersionRange') vim.validate('spec.version', spec.version, is_version, true, 'string or vim.VersionRange')
return { src = spec.src, name = name, version = spec.version } return { src = spec.src, name = name, version = spec.version }
end end
@@ -319,6 +323,7 @@ end
local function plug_list_from_names(names) local function plug_list_from_names(names)
local all_plugins = M.get() local all_plugins = M.get()
local plugs = {} --- @type vim.pack.Plug[] local plugs = {} --- @type vim.pack.Plug[]
local used_names = {} --- @type table<string,boolean>
-- Preserve plugin order; might be important during checkout or event trigger -- Preserve plugin order; might be important during checkout or event trigger
for _, p_data in ipairs(all_plugins) do for _, p_data in ipairs(all_plugins) do
-- NOTE: By default include only active plugins (and not all on disk). Using -- NOTE: By default include only active plugins (and not all on disk). Using
@@ -328,9 +333,18 @@ local function plug_list_from_names(names)
--- @cast names string[] --- @cast names string[]
if (not names and p_data.active) or vim.tbl_contains(names or {}, p_data.spec.name) then if (not names and p_data.active) or vim.tbl_contains(names or {}, p_data.spec.name) then
plugs[#plugs + 1] = new_plug(p_data.spec) plugs[#plugs + 1] = new_plug(p_data.spec)
used_names[p_data.spec.name] = true
end end
end end
if vim.islist(names) and #plugs ~= #names then
--- @param n string
local unused = vim.tbl_filter(function(n)
return not used_names[n]
end, names)
error('The following plugins are not installed: ' .. table.concat(unused, ', '))
end
return plugs return plugs
end end
@@ -367,13 +381,16 @@ local function new_progress_report(title)
return vim.schedule_wrap(function(kind, percent, fmt, ...) return vim.schedule_wrap(function(kind, percent, fmt, ...)
local progress = kind == 'end' and 'done' or ('%3d%%'):format(percent) local progress = kind == 'end' and 'done' or ('%3d%%'):format(percent)
print(('(vim.pack) %s: %s %s'):format(progress, title, fmt:format(...))) local details = (' %s %s'):format(title, fmt:format(...))
local chunks = { { 'vim.pack', 'ModeMsg' }, { ': ' }, { progress, 'WarningMsg' }, { details } }
vim.api.nvim_echo(chunks, true, { kind = 'progress' })
-- Force redraw to show installation progress during startup -- Force redraw to show installation progress during startup
vim.cmd.redraw({ bang = true }) vim.cmd.redraw({ bang = true })
end) end)
end end
local n_threads = 2 * #(uv.cpu_info() or { {} }) local n_threads = 2 * #(uv.cpu_info() or { {} })
local copcall = package.loaded.jit and pcall or require('coxpcall').pcall
--- Execute function in parallel for each non-errored plugin in the list --- Execute function in parallel for each non-errored plugin in the list
--- @param plug_list vim.pack.Plug[] --- @param plug_list vim.pack.Plug[]
@@ -390,7 +407,7 @@ local function run_list(plug_list, f, progress_title)
if p.info.err == '' then if p.info.err == '' then
--- @async --- @async
funs[#funs + 1] = function() funs[#funs + 1] = function()
local ok, err = pcall(f, p) --[[@as string]] local ok, err = copcall(f, p) --[[@as string]]
if not ok then if not ok then
p.info.err = err --- @as string p.info.err = err --- @as string
end end
@@ -433,6 +450,21 @@ local function confirm_install(plug_list)
return res return res
end end
--- @param tags string[]
--- @param version_range vim.VersionRange
local function get_last_semver_tag(tags, version_range)
local last_tag, last_ver_tag --- @type string, vim.Version
for _, tag in ipairs(tags) do
local ver_tag = vim.version.parse(tag)
if ver_tag then
if version_range:has(ver_tag) and (not last_ver_tag or ver_tag > last_ver_tag) then
last_tag, last_ver_tag = tag, ver_tag
end
end
end
return last_tag
end
--- @async --- @async
--- @param p vim.pack.Plug --- @param p vim.pack.Plug
local function resolve_version(p) local function resolve_version(p)
@@ -458,7 +490,7 @@ local function resolve_version(p)
local tags = git_get_tags(p.path) local tags = git_get_tags(p.path)
if type(version) == 'string' then if type(version) == 'string' then
local is_branch = vim.tbl_contains(branches, version) local is_branch = vim.tbl_contains(branches, version)
local is_tag_or_hash = pcall(git_get_hash, version, p.path) local is_tag_or_hash = copcall(git_get_hash, version, p.path)
if not (is_branch or is_tag_or_hash) then if not (is_branch or is_tag_or_hash) then
local err = ('`%s` is not a branch/tag/commit. Available:'):format(version) local err = ('`%s` is not a branch/tag/commit. Available:'):format(version)
.. list_in_line('Tags', tags) .. list_in_line('Tags', tags)
@@ -473,19 +505,10 @@ local function resolve_version(p)
--- @cast version vim.VersionRange --- @cast version vim.VersionRange
-- Choose the greatest/last version among all matching semver tags -- Choose the greatest/last version among all matching semver tags
local last_ver_tag --- @type vim.Version p.info.version_str = get_last_semver_tag(tags, version)
local semver_tags = {} --- @type string[]
for _, tag in ipairs(tags) do
local ver_tag = vim.version.parse(tag)
if ver_tag then
semver_tags[#semver_tags + 1] = tag
if version:has(ver_tag) and (not last_ver_tag or ver_tag > last_ver_tag) then
p.info.version_str, last_ver_tag = tag, ver_tag
end
end
end
if p.info.version_str == nil then if p.info.version_str == nil then
local semver_tags = vim.tbl_filter(is_semver, tags)
table.sort(semver_tags, vim.version.gt)
local err = 'No versions fit constraint. Relax it or switch to branch. Available:' local err = 'No versions fit constraint. Relax it or switch to branch. Available:'
.. list_in_line('Versions', semver_tags) .. list_in_line('Versions', semver_tags)
.. list_in_line('Branches', branches) .. list_in_line('Branches', branches)
@@ -517,7 +540,7 @@ local function checkout(p, timestamp, skip_same_sha)
trigger_event(p, 'PackChangedPre', 'update') trigger_event(p, 'PackChangedPre', 'update')
local msg = ('(vim.pack) %s Stash before checkout'):format(timestamp) local msg = ('vim.pack: %s Stash before checkout'):format(timestamp)
git_cmd({ 'stash', '--quiet', '--message', msg }, p.path) git_cmd({ 'stash', '--quiet', '--message', msg }, p.path)
git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path) git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path)
@@ -529,7 +552,7 @@ local function checkout(p, timestamp, skip_same_sha)
-- directory or if it is empty. -- directory or if it is empty.
local doc_dir = vim.fs.joinpath(p.path, 'doc') local doc_dir = vim.fs.joinpath(p.path, 'doc')
vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags')) vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags'))
pcall(vim.cmd.helptags, vim.fn.fnameescape(doc_dir)) copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } })
end end
--- @param plug_list vim.pack.Plug[] --- @param plug_list vim.pack.Plug[]
@@ -551,6 +574,9 @@ local function install_list(plug_list)
git_clone(p.spec.src, p.path) git_clone(p.spec.src, p.path)
p.info.installed = true p.info.installed = true
-- Infer default branch for fuller `event-data`
p.spec.version = p.spec.version or git_get_default_branch(p.path)
-- Do not skip checkout even if HEAD and target have same commit hash to -- Do not skip checkout even if HEAD and target have same commit hash to
-- have new repo in expected detached HEAD state and generated help files. -- have new repo in expected detached HEAD state and generated help files.
checkout(p, timestamp, false) checkout(p, timestamp, false)
@@ -565,35 +591,46 @@ end
--- @async --- @async
--- @param p vim.pack.Plug --- @param p vim.pack.Plug
local function infer_update_details(p) local function infer_update_details(p)
p.info.update_details = ''
infer_states(p) infer_states(p)
local sha_head = assert(p.info.sha_head) local sha_head = assert(p.info.sha_head)
local sha_target = assert(p.info.sha_target) local sha_target = assert(p.info.sha_target)
-- Try showing log of changes (if any)
if sha_head ~= sha_target then if sha_head ~= sha_target then
-- `--topo-order` makes showing divergent branches nicer local range = sha_head .. '...' .. sha_target
-- `--decorate-refs` shows only tags near commits (not `origin/main`, etc.) local format = '--pretty=format:%m %h │ %s%d'
p.info.update_details = git_cmd({ -- Show only tags near commits (not `origin/main`, etc.)
'log', local decorate = '--decorate-refs=refs/tags'
'--pretty=format:%m %h │ %s%d', -- `--topo-order` makes showing divergent branches nicer, but by itself
'--topo-order', -- doesn't ensure that reverted ("left", shown with `<`) and added
'--decorate-refs=refs/tags', -- ("right", shown with `>`) commits have fixed order.
sha_head .. '...' .. sha_target, local l = git_cmd({ 'log', format, '--topo-order', '--left-only', decorate, range }, p.path)
}, p.path) local r = git_cmd({ 'log', format, '--topo-order', '--right-only', decorate, range }, p.path)
else p.info.update_details = l == '' and r or (r == '' and l or (l .. '\n' .. r))
p.info.update_details = table.concat(git_get_tags(p.path, { contains = sha_target }), '\n')
end
if p.info.sha_head ~= p.info.sha_target or p.info.update_details == '' then
return return
end end
-- Remove tags pointing at target (there might be several) -- Suggest newer semver tags (i.e. greater than greatest past semver tag)
local cur_tags = git_get_tags(p.path, { points_at = sha_target }) local all_semver_tags = vim.tbl_filter(is_semver, git_get_tags(p.path))
local new_tags_arr = vim.split(p.info.update_details, '\n') if #all_semver_tags == 0 then
local function is_not_cur_tag(s) return
return not vim.tbl_contains(cur_tags, s)
end end
p.info.update_details = table.concat(vim.tbl_filter(is_not_cur_tag, new_tags_arr), '\n')
local older_tags = git_cmd({ 'tag', '--list', '--no-contains', sha_head }, p.path)
local cur_tags = git_cmd({ 'tag', '--list', '--points-at', sha_head }, p.path)
local past_tags = vim.split(older_tags, '\n')
vim.list_extend(past_tags, vim.split(cur_tags, '\n'))
local any_version = vim.version.range('*') --[[@as vim.VersionRange]]
local last_version = get_last_semver_tag(past_tags, any_version)
local newer_semver_tags = vim.tbl_filter(function(x) --- @param x string
return vim.version.gt(x, last_version)
end, all_semver_tags)
table.sort(newer_semver_tags, vim.version.gt)
p.info.update_details = table.concat(newer_semver_tags, '\n')
end end
--- Map from plugin path to its data. --- Map from plugin path to its data.
@@ -614,18 +651,18 @@ local function pack_add(plug, load)
n_active_plugins = n_active_plugins + 1 n_active_plugins = n_active_plugins + 1
active_plugins[plug.path] = { plug = plug, id = n_active_plugins } active_plugins[plug.path] = { plug = plug, id = n_active_plugins }
vim.cmd.packadd({ plug.spec.name, bang = not load }) -- NOTE: The `:packadd` specifically seems to not handle spaces in dir name
vim.cmd.packadd({ vim.fn.escape(plug.spec.name, ' '), bang = not load, magic = { file = false } })
-- Execute 'after/' scripts if not during startup (when they will be sourced -- Execute 'after/' scripts if not during startup (when they will be sourced
-- automatically), as `:packadd` only sources plain 'plugin/' files. -- automatically), as `:packadd` only sources plain 'plugin/' files.
-- See https://github.com/vim/vim/issues/15584 -- See https://github.com/vim/vim/issues/15584
-- Deliberately do so after executing all currently known 'plugin/' files. -- Deliberately do so after executing all currently known 'plugin/' files.
local should_load_after_dir = vim.v.vim_did_enter == 1 and load and vim.o.loadplugins if vim.v.vim_did_enter == 1 and load then
if should_load_after_dir then
local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true) local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true)
--- @param path string --- @param path string
vim.tbl_map(function(path) vim.tbl_map(function(path)
vim.cmd.source(vim.fn.fnameescape(path)) vim.cmd.source({ path, magic = { file = false } })
end, after_paths) end, after_paths)
end end
end end
@@ -714,7 +751,7 @@ local function compute_feedback_lines_single(p)
if p.info.update_details ~= '' then if p.info.update_details ~= '' then
local details = p.info.update_details:gsub('\n', '\n') local details = p.info.update_details:gsub('\n', '\n')
parts[#parts + 1] = '\n\nAvailable newer tags:\n' .. details parts[#parts + 1] = '\n\nAvailable newer versions:\n' .. details
end end
else else
parts[#parts + 1] = table.concat({ parts[#parts + 1] = table.concat({
@@ -782,7 +819,7 @@ local function show_confirm_buf(lines, on_finish)
local bufnr = api.nvim_create_buf(true, true) local bufnr = api.nvim_create_buf(true, true)
api.nvim_buf_set_name(bufnr, 'nvim-pack://' .. bufnr .. '/confirm-update') api.nvim_buf_set_name(bufnr, 'nvim-pack://' .. bufnr .. '/confirm-update')
api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr('#') } }) vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr() } })
local tab_id = api.nvim_get_current_tabpage() local tab_id = api.nvim_get_current_tabpage()
local win_id = api.nvim_get_current_win() local win_id = api.nvim_get_current_win()
@@ -871,11 +908,6 @@ function M.update(names, opts)
--- @async --- @async
--- @param p vim.pack.Plug --- @param p vim.pack.Plug
local function do_update(p) local function do_update(p)
if not p.info.installed then
notify(('Cannot update %s - not found'):format(p.spec.name), 'WARN')
return
end
-- Fetch -- Fetch
-- Using '--tags --force' means conflicting tags will be synced with remote -- Using '--tags --force' means conflicting tags will be synced with remote
git_cmd( git_cmd(
@@ -938,17 +970,13 @@ function M.del(names)
end end
for _, p in ipairs(plug_list) do for _, p in ipairs(plug_list) do
if not p.info.installed then trigger_event(p, 'PackChangedPre', 'delete')
notify(("Plugin '%s' is not installed"):format(p.spec.name), 'WARN')
else
trigger_event(p, 'PackChangedPre', 'delete')
vim.fs.rm(p.path, { recursive = true, force = true }) vim.fs.rm(p.path, { recursive = true, force = true })
active_plugins[p.path] = nil active_plugins[p.path] = nil
notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO') notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO')
trigger_event(p, 'PackChanged', 'delete') trigger_event(p, 'PackChanged', 'delete')
end
end end
end end

File diff suppressed because it is too large Load Diff