From 1ee18b40611487e50abbec425d74d3af7de7897d Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Tue, 29 Jul 2025 16:00:03 +0300 Subject: [PATCH 01/13] fix(pack): use 'coxpcall.lua' on non-LuaJIT Problem: `attempt to yield across metamethod/C-call boundary` error when trying to use `vim.pack.add()`. Solution: use `pcall()` variant from 'coxpcall' on non-LuaJIT version of Lua. --- runtime/lua/vim/_async.lua | 3 ++- runtime/lua/vim/pack.lua | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/runtime/lua/vim/_async.lua b/runtime/lua/vim/_async.lua index 8abe206a51..47f37b675b 100644 --- a/runtime/lua/vim/_async.lua +++ b/runtime/lua/vim/_async.lua @@ -1,6 +1,7 @@ local M = {} local max_timeout = 30000 +local copcall = package.loaded.jit and pcall or require('coxpcall').pcall --- @param thread thread --- @param on_finish fun(err: string?, ...:any) @@ -21,7 +22,7 @@ local function resume(thread, on_finish, ...) --- @cast fn -string --- @type boolean, string? - local ok, err = pcall(fn, function(...) + local ok, err = copcall(fn, function(...) resume(thread, on_finish, ...) end) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index cf5dc00d01..210e01b9e6 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -374,6 +374,7 @@ local function new_progress_report(title) end 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 --- @param plug_list vim.pack.Plug[] @@ -390,7 +391,7 @@ local function run_list(plug_list, f, progress_title) if p.info.err == '' then --- @async funs[#funs + 1] = function() - local ok, err = pcall(f, p) --[[@as string]] + local ok, err = copcall(f, p) --[[@as string]] if not ok then p.info.err = err --- @as string end @@ -458,7 +459,7 @@ local function resolve_version(p) local tags = git_get_tags(p.path) if type(version) == 'string' then 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 local err = ('`%s` is not a branch/tag/commit. Available:'):format(version) .. list_in_line('Tags', tags) @@ -529,7 +530,7 @@ local function checkout(p, timestamp, skip_same_sha) -- directory or if it is empty. local doc_dir = vim.fs.joinpath(p.path, 'doc') vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags')) - pcall(vim.cmd.helptags, vim.fn.fnameescape(doc_dir)) + copcall(vim.cmd.helptags, vim.fn.fnameescape(doc_dir)) end --- @param plug_list vim.pack.Plug[] From 28e2a5c1513dd3adb6fc085a92f66bf7d7786317 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Tue, 29 Jul 2025 16:06:01 +0300 Subject: [PATCH 02/13] fix(pack): improve `vim.pack.add()` input validation --- runtime/lua/vim/pack.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 210e01b9e6..0a0635b6e0 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -209,7 +209,11 @@ end --- @param x string|vim.VersionRange --- @return boolean 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 + +local function is_nonempty_string(x) + return type(x) == 'string' and x ~= '' end --- @return string @@ -239,9 +243,10 @@ end local function normalize_spec(spec) spec = type(spec) == 'string' and { src = spec } or spec vim.validate('spec', spec, 'table') - vim.validate('spec.src', spec.src, 'string') - local name = (spec.name or spec.src:gsub('%.git$', '')):match('[^/]+$') - vim.validate('spec.name', name, 'string') + vim.validate('spec.src', spec.src, is_nonempty_string, false, 'non-empty string') + local name = spec.name or spec.src:gsub('%.git$', '') + 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') return { src = spec.src, name = name, version = spec.version } end From 3f34f083db0694032477c8fa6a1ca85c52d59bec Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Tue, 29 Jul 2025 16:06:40 +0300 Subject: [PATCH 03/13] fix(pack): ignore 'loadplugins' when sourcing 'after/plugin' scripts Problem: Sourcing of 'after/plugin' scripts depends on the value of 'loadplugins' option. It is redundant, as it has effect only during startup, while it is combined with `vim.v.vim_did_enter == 1` (i.e. "it is after starting up") condition. Solution: Ignore it. --- runtime/lua/vim/pack.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 0a0635b6e0..879be4b97d 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -626,8 +626,7 @@ local function pack_add(plug, load) -- automatically), as `:packadd` only sources plain 'plugin/' files. -- See https://github.com/vim/vim/issues/15584 -- 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 should_load_after_dir then + if vim.v.vim_did_enter == 1 and load then local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true) --- @param path string vim.tbl_map(function(path) From a203961535057ca335b8a02b65c032a287bba37a Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Tue, 29 Jul 2025 16:16:40 +0300 Subject: [PATCH 04/13] feat(pack): use colored `nvim_echo` chunks to show progress report Problem: using `print()` to show progress report writes to `stdout` when in `--headless` mode (interferes with the testing output) and doesn't allow coloring. Solution: use `nvim_echo` with colored chunks. --- runtime/lua/vim/pack.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 879be4b97d..7d83921763 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -372,7 +372,9 @@ local function new_progress_report(title) return vim.schedule_wrap(function(kind, percent, fmt, ...) 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 vim.cmd.redraw({ bang = true }) end) From 267bbe64cb4fc1e5d3055307d11872f948a4e44f Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Tue, 29 Jul 2025 20:15:58 +0300 Subject: [PATCH 05/13] fix(pack): validate installed plugins in `update()` and `del()` Problem: Currently `update()` and `del()` silently ignore input plugin names that are not for already installed plugin. This might lead to confusion because they are not explicitly reported. Solution: Check that all input names are for installed plugins and error otherwise. --- runtime/lua/vim/pack.lua | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 7d83921763..3693fbe5b1 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -324,6 +324,7 @@ end local function plug_list_from_names(names) local all_plugins = M.get() local plugs = {} --- @type vim.pack.Plug[] + local used_names = {} --- @type table -- Preserve plugin order; might be important during checkout or event trigger for _, p_data in ipairs(all_plugins) do -- NOTE: By default include only active plugins (and not all on disk). Using @@ -333,9 +334,18 @@ local function plug_list_from_names(names) --- @cast names string[] 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) + used_names[p_data.spec.name] = true 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 end @@ -878,11 +888,6 @@ function M.update(names, opts) --- @async --- @param p vim.pack.Plug 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 -- Using '--tags --force' means conflicting tags will be synced with remote git_cmd( @@ -945,17 +950,13 @@ function M.del(names) end for _, p in ipairs(plug_list) do - if not p.info.installed then - notify(("Plugin '%s' is not installed"):format(p.spec.name), 'WARN') - else - trigger_event(p, 'PackChangedPre', 'delete') + 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') - trigger_event(p, 'PackChanged', 'delete') - end + trigger_event(p, 'PackChanged', 'delete') end end From 8200223ee7a8dd9882367582c4a6e1d280012fff Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:57:59 +0300 Subject: [PATCH 06/13] fix(pack): use `vim.pack:` as message/progress prefix --- runtime/lua/vim/pack.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 3693fbe5b1..c75f185de3 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -202,7 +202,7 @@ end --- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')? local function notify(msg, level) 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() end @@ -382,8 +382,8 @@ local function new_progress_report(title) return vim.schedule_wrap(function(kind, percent, fmt, ...) local progress = kind == 'end' and 'done' or ('%3d%%'):format(percent) - local details = (': %s %s'):format(title, fmt:format(...)) - local chunks = { { '(vim.pack)', 'ModeMsg' }, { ' ' }, { progress, 'WarningMsg' }, { details } } + 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 vim.cmd.redraw({ bang = true }) @@ -535,7 +535,7 @@ local function checkout(p, timestamp, skip_same_sha) 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({ 'checkout', '--quiet', p.info.sha_target }, p.path) From ed73ed82837455ad5dae6d6917a20c3679016343 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:03 +0300 Subject: [PATCH 07/13] fix(pack): ensure explicit default `version` in events (where possible) Problem: Both `PackChangedPre` and `PackChanged` contain |event-data| with plugin's `spec`. It looks like a good idea to have all its triggers contain the same format across all kinds ("install", "update", "delete"). There are several choices: - Have it be as verbatim as supplied to `vim.pack.add()`, i.e. can be either string or table. A bit too ambiguous. - Have it be table with `src` and `name` inferred. This requires less work for "install", but more work for "update" and "delete" (since they use `vim.pack.get()` which already infers default `version`). - Have it be table with *all* defaults made explicit. This looks like the best approach, but requires extra care to only infer default `version` when needed (i.e. avoid inferring during regular load) because it is costly in terms of startup time. This might also introduce inconsistency when dealing with lockfile(s) as information there should be as close to what user supplied as possible. Address that when dealing with lockfile. Solution: Ensure explicit `version` in all events where possible. --- runtime/doc/pack.txt | 2 +- runtime/lua/vim/pack.lua | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index e1f59ea32e..e3143372fc 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -291,7 +291,7 @@ Available events to hook into ~ Each event populates the following |event-data| fields: • `kind` - one of "install" (install on disk), "update" (update existing plugin), "delete" (delete from disk). -• `spec` - plugin's specification. +• `spec` - plugin's specification with defaults made explicit. • `path` - full path to plugin's directory. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index c75f185de3..78945e3950 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -88,7 +88,7 @@ --- Each event populates the following |event-data| fields: --- - `kind` - one of "install" (install on disk), "update" (update existing --- plugin), "delete" (delete from disk). ---- - `spec` - plugin's specification. +--- - `spec` - plugin's specification with defaults made explicit. --- - `path` - full path to plugin's directory. local api = vim.api @@ -569,6 +569,9 @@ local function install_list(plug_list) git_clone(p.spec.src, p.path) 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 -- have new repo in expected detached HEAD state and generated help files. checkout(p, timestamp, false) From 6721128cc8341b9519a1b46eb8932524613cf7ed Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:59:47 +0300 Subject: [PATCH 08/13] refactor(pack): use `vim.cmd` methods with structured input --- runtime/lua/vim/pack.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 78945e3950..7ebd9b21e8 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -547,7 +547,7 @@ local function checkout(p, timestamp, skip_same_sha) -- directory or if it is empty. local doc_dir = vim.fs.joinpath(p.path, 'doc') vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags')) - copcall(vim.cmd.helptags, vim.fn.fnameescape(doc_dir)) + copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } }) end --- @param plug_list vim.pack.Plug[] @@ -635,7 +635,8 @@ local function pack_add(plug, load) n_active_plugins = n_active_plugins + 1 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 -- automatically), as `:packadd` only sources plain 'plugin/' files. @@ -645,7 +646,7 @@ local function pack_add(plug, load) local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true) --- @param path string vim.tbl_map(function(path) - vim.cmd.source(vim.fn.fnameescape(path)) + vim.cmd.source({ path, magic = { file = false } }) end, after_paths) end end From 89a72f11e5fe8c06a2b8e8ae994359d3316ee576 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:13 +0300 Subject: [PATCH 09/13] fix(pack): make newer version suggestions more robust Problem: New version suggestions in update confirmation buffer might include semver tags that were committed later but for versions that are not greater than current. Like if versions committed in order `v0.2.0` - `v0.3.0` - `v0.2.1` - `v0.3.1`, then when on `v0.3.0` both `v0.2.1` and `v0.3.1` are suggested, but only the latter is newer as a version. This is because those tags are computed with post-processed `git tag --list --contains HEAD`. Solution: Compute all semver tags and filter only those greater than the latest version available at HEAD. --- runtime/lua/vim/pack.lua | 78 +++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 7ebd9b21e8..db3777103f 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -178,16 +178,9 @@ end --- @async --- @param cwd string ---- @param opts? { contains?: string, points_at?: string } --- @return string[] -local function git_get_tags(cwd, opts) +local function git_get_tags(cwd) 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') end @@ -212,6 +205,12 @@ local function is_version(x) 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 @@ -451,6 +450,21 @@ local function confirm_install(plug_list) return res 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 --- @param p vim.pack.Plug local function resolve_version(p) @@ -491,19 +505,10 @@ local function resolve_version(p) --- @cast version vim.VersionRange -- Choose the greatest/last version among all matching semver tags - local last_ver_tag --- @type vim.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 - + p.info.version_str = get_last_semver_tag(tags, version) 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:' .. list_in_line('Versions', semver_tags) .. list_in_line('Branches', branches) @@ -590,6 +595,7 @@ local function infer_update_details(p) local sha_head = assert(p.info.sha_head) local sha_target = assert(p.info.sha_target) + -- Try showing log of changes (if any) if sha_head ~= sha_target then -- `--topo-order` makes showing divergent branches nicer -- `--decorate-refs` shows only tags near commits (not `origin/main`, etc.) @@ -600,21 +606,29 @@ local function infer_update_details(p) '--decorate-refs=refs/tags', sha_head .. '...' .. sha_target, }, p.path) - else - 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 end - -- Remove tags pointing at target (there might be several) - local cur_tags = git_get_tags(p.path, { points_at = sha_target }) - local new_tags_arr = vim.split(p.info.update_details, '\n') - local function is_not_cur_tag(s) - return not vim.tbl_contains(cur_tags, s) + -- Suggest newer semver tags (i.e. greater than greatest past semver tag) + local all_semver_tags = vim.tbl_filter(is_semver, git_get_tags(p.path)) + if #all_semver_tags == 0 then + return 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 --- Map from plugin path to its data. @@ -735,7 +749,7 @@ local function compute_feedback_lines_single(p) if p.info.update_details ~= '' then 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 else parts[#parts + 1] = table.concat({ From d3bea4ace94a16e75f7dfe609fba0ae9e5775308 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:17 +0300 Subject: [PATCH 10/13] fix(pack): ensure consistent order of changelog during update Problem: Left (`<` prefix) and right (`>` prefix) parts of update changelog come in not fixed order: it can be left-right or right-left. Solution: Ensure left-right order. --- runtime/lua/vim/pack.lua | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index db3777103f..d793b36a0e 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -591,21 +591,23 @@ end --- @async --- @param p vim.pack.Plug local function infer_update_details(p) + p.info.update_details = '' infer_states(p) local sha_head = assert(p.info.sha_head) local sha_target = assert(p.info.sha_target) -- Try showing log of changes (if any) if sha_head ~= sha_target then - -- `--topo-order` makes showing divergent branches nicer - -- `--decorate-refs` shows only tags near commits (not `origin/main`, etc.) - p.info.update_details = git_cmd({ - 'log', - '--pretty=format:%m %h │ %s%d', - '--topo-order', - '--decorate-refs=refs/tags', - sha_head .. '...' .. sha_target, - }, p.path) + local range = sha_head .. '...' .. sha_target + local format = '--pretty=format:%m %h │ %s%d' + -- Show only tags near commits (not `origin/main`, etc.) + local decorate = '--decorate-refs=refs/tags' + -- `--topo-order` makes showing divergent branches nicer, but by itself + -- doesn't ensure that reverted ("left", shown with `<`) and added + -- ("right", shown with `>`) commits have fixed order. + local l = git_cmd({ 'log', format, '--topo-order', '--left-only', decorate, range }, p.path) + local r = git_cmd({ 'log', format, '--topo-order', '--right-only', decorate, range }, p.path) + p.info.update_details = l == '' and r or (r == '' and l or (l .. '\n' .. r)) return end From 2e62f3576f7f0e7844e3fef2792503c7e10c1801 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:20 +0300 Subject: [PATCH 11/13] fix(pack): open confirmation buffer in tabpage after the current one --- runtime/lua/vim/pack.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index d793b36a0e..200f08e311 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -819,7 +819,7 @@ local function show_confirm_buf(lines, on_finish) local bufnr = api.nvim_create_buf(true, true) api.nvim_buf_set_name(bufnr, 'nvim-pack://' .. bufnr .. '/confirm-update') 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 win_id = api.nvim_get_current_win() From 52a596b0aaece2fc9744ea20b24adc26c213be34 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:23 +0300 Subject: [PATCH 12/13] ci: enable integration tests on Release builds --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19d44f295f..fbb4271f99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,6 +132,7 @@ jobs: timeout-minutes: 45 env: CC: ${{ matrix.build.cc }} + NVIM_TEST_INTEG: ${{ matrix.build.flavor == 'release' && '1' || '0' }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup From 6e2720090130f65e244a42a595a274dd5bad3d37 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 2 Aug 2025 14:58:27 +0300 Subject: [PATCH 13/13] test(pack): add `vim.pack` tests --- runtime/ftplugin/nvim-pack.lua | 8 +- test/functional/plugin/pack_spec.lua | 1169 +++++++++++++++++++++++++- 2 files changed, 1137 insertions(+), 40 deletions(-) diff --git a/runtime/ftplugin/nvim-pack.lua b/runtime/ftplugin/nvim-pack.lua index 464fd88c0d..2929077255 100644 --- a/runtime/ftplugin/nvim-pack.lua +++ b/runtime/ftplugin/nvim-pack.lua @@ -5,6 +5,8 @@ local priority = 100 local hi_range = function(lnum, start_col, end_col, hl, pr) --- @type vim.api.keyset.set_extmark 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) end @@ -30,8 +32,10 @@ for i, l in ipairs(lines) do hi_range(i, cur_info:len(), end_col, 'DiagnosticInfo') -- Plugin state after update - local col = l:match('() %b()$') or l:len() - hi_range(i, col, l:len(), 'DiagnosticHint') + local col = l:match('() %b()$') + if col then + hi_range(i, col, l:len(), 'DiagnosticHint') + end elseif l:match('^> ') then -- Added change with possibly "breaking message" hi_range(i, 0, l:len(), 'Added') diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 3442c0079a..c45693f34f 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -1,73 +1,1166 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local Screen = require('test.functional.ui.screen') +local skip_integ = os.getenv('NVIM_TEST_INTEG') ~= '1' + +local api = n.api +local fn = n.fn + +local eq = t.eq +local matches = t.matches +local pcall_err = t.pcall_err +local exec_lua = n.exec_lua + +-- Helpers ==================================================================== +-- Installed plugins ---------------------------------------------------------- + +local function pack_get_dir() + return vim.fs.joinpath(fn.stdpath('data'), 'site', 'pack', 'core', 'opt') +end + +local function pack_get_plug_path(plug_name) + return vim.fs.joinpath(pack_get_dir(), plug_name) +end + +local function pack_exists(plug_name) + local path = vim.fs.joinpath(pack_get_dir(), plug_name) + return vim.uv.fs_stat(path) ~= nil +end + +-- Test repos (to be installed) ----------------------------------------------- + +local repos_dir = vim.fs.abspath('test/functional/lua/pack-test-repos') + +--- Map from repo name to its proper `src` used in plugin spec +--- @type table +local repos_src = {} + +local function repo_get_path(repo_name) + vim.validate('repo_name', repo_name, 'string') + return vim.fs.joinpath(repos_dir, repo_name) +end + +local function repo_write_file(repo_name, rel_path, text, no_dedent, append) + local path = vim.fs.joinpath(repo_get_path(repo_name), rel_path) + fn.mkdir(vim.fs.dirname(path), 'p') + t.write_file(path, text, no_dedent, append) +end + +--- @return vim.SystemCompleted +local function system_sync(cmd, opts) + return exec_lua(function() + local obj = vim.system(cmd, opts) + + if opts and opts.timeout then + -- Minor delay before calling wait() so the timeout uv timer can have a headstart over the + -- internal call to vim.wait() in wait(). + vim.wait(10) + end + + local res = obj:wait() + + -- Check the process is no longer running + assert(not vim.api.nvim_get_proc(obj.pid), 'process still exists') + + return res + end) +end + +local function git_cmd(cmd, repo_name) + local git_cmd_prefix = { + 'git', + '-c', + 'gc.auto=0', + '-c', + 'user.name=Marvim', + '-c', + 'user.email=marvim@neovim.io', + '-c', + 'init.defaultBranch=main', + } + + cmd = vim.list_extend(git_cmd_prefix, cmd) + local cwd = repo_get_path(repo_name) + local sys_opts = { cwd = cwd, text = true, clear_env = true } + local out = system_sync(cmd, sys_opts) + if out.code ~= 0 then + error(out.stderr) + end + return (out.stdout:gsub('\n+$', '')) +end + +local function init_test_repo(repo_name) + local path = repo_get_path(repo_name) + fn.mkdir(path, 'p') + repos_src[repo_name] = 'file://' .. path + + git_cmd({ 'init' }, repo_name) +end + +local function git_add_commit(msg, repo_name) + git_cmd({ 'add', '*' }, repo_name) + git_cmd({ 'commit', '-m', msg }, repo_name) +end + +local function git_get_hash(rev, repo_name) + return git_cmd({ 'rev-list', '-1', '--abbrev-commit', rev }, repo_name) +end + +-- Common test repos ---------------------------------------------------------- +--- @type table +local repos_setup = {} + +function repos_setup.basic() + init_test_repo('basic') + + repo_write_file('basic', 'lua/basic.lua', 'return "basic init"') + git_add_commit('Initial commit for "basic"', 'basic') + repo_write_file('basic', 'lua/basic.lua', 'return "basic main"') + git_add_commit('Commit in `main` but not in `feat-branch`', 'basic') + + git_cmd({ 'checkout', 'main~' }, 'basic') + git_cmd({ 'checkout', '-b', 'feat-branch' }, 'basic') + + repo_write_file('basic', 'lua/basic.lua', 'return "basic some-tag"') + git_add_commit('Add commit for some tag', 'basic') + git_cmd({ 'tag', 'some-tag' }, 'basic') + + repo_write_file('basic', 'lua/basic.lua', 'return "basic feat-branch"') + git_add_commit('Add important feature', 'basic') + + -- Make sure that `main` is the default remote branch + git_cmd({ 'checkout', 'main' }, 'basic') +end + +function repos_setup.plugindirs() + init_test_repo('plugindirs') + + repo_write_file('plugindirs', 'lua/plugindirs.lua', 'return "plugindirs main"') + repo_write_file('plugindirs', 'plugin/dirs.lua', 'vim.g._plugin = true') + repo_write_file('plugindirs', 'plugin/dirs.vim', 'let g:_plugin_vim=v:true') + repo_write_file('plugindirs', 'plugin/sub/dirs.lua', 'vim.g._plugin_sub = true') + repo_write_file('plugindirs', 'plugin/bad % name.lua', 'vim.g._plugin_bad = true') + repo_write_file('plugindirs', 'after/plugin/dirs.lua', 'vim.g._after_plugin = true') + repo_write_file('plugindirs', 'after/plugin/dirs.vim', 'let g:_after_plugin_vim=v:true') + repo_write_file('plugindirs', 'after/plugin/sub/dirs.lua', 'vim.g._after_plugin_sub = true') + repo_write_file('plugindirs', 'after/plugin/bad % name.lua', 'vim.g._after_plugin_bad = true') + git_add_commit('Initial commit for "plugindirs"', 'plugindirs') +end + +function repos_setup.helptags() + init_test_repo('helptags') + repo_write_file('helptags', 'lua/helptags.lua', 'return "helptags main"') + repo_write_file('helptags', 'doc/my-test-help.txt', '*my-test-help*') + repo_write_file('helptags', 'doc/bad % name.txt', '*my-test-help-bad*') + repo_write_file('helptags', 'doc/bad % dir/file.txt', '*my-test-help-sub-bad*') + git_add_commit('Initial commit for "helptags"', 'helptags') +end + +function repos_setup.pluginerr() + init_test_repo('pluginerr') + + repo_write_file('pluginerr', 'lua/pluginerr.lua', 'return "pluginerr main"') + repo_write_file('pluginerr', 'plugin/err.lua', 'error("Wow, an error")') + git_add_commit('Initial commit for "pluginerr"', 'pluginerr') +end + +function repos_setup.defbranch() + init_test_repo('defbranch') + + repo_write_file('defbranch', 'lua/defbranch.lua', 'return "defbranch main"') + git_add_commit('Initial commit for "defbranch"', 'defbranch') + + -- Make `dev` the default remote branch + git_cmd({ 'checkout', '-b', 'dev' }, 'defbranch') + + repo_write_file('defbranch', 'lua/defbranch.lua', 'return "defbranch dev"') + git_add_commit('Add to new default branch', 'defbranch') +end + +function repos_setup.gitsuffix() + init_test_repo('gitsuffix.git') + + repo_write_file('gitsuffix.git', 'lua/gitsuffix.lua', 'return "gitsuffix main"') + git_add_commit('Initial commit for "gitsuffix"', 'gitsuffix.git') +end + +function repos_setup.semver() + init_test_repo('semver') + + local add_tag = function(name) + repo_write_file('semver', 'lua/semver.lua', 'return "semver ' .. name .. '"') + git_add_commit('Add version ' .. name, 'semver') + git_cmd({ 'tag', name }, 'semver') + end + + add_tag('v0.0.1') + add_tag('v0.0.2') + add_tag('v0.1.0') + add_tag('v0.1.1') + add_tag('v0.2.0-dev') + add_tag('v0.2.0') + add_tag('v0.3.0') + repo_write_file('semver', 'lua/semver.lua', 'return "semver middle-commit') + git_add_commit('Add middle commit', 'semver') + add_tag('0.3.1') + add_tag('v0.4') + add_tag('non-semver') + add_tag('v0.2.1') -- Intentionally add version not in order + add_tag('v1.0.0') +end + +-- Utility -------------------------------------------------------------------- + +local function watch_events(event) + exec_lua(function() + _G.event_log = _G.event_log or {} --- @type table[] + vim.api.nvim_create_autocmd(event, { + callback = function(ev) + table.insert(_G.event_log, { event = ev.event, match = ev.match, data = ev.data }) + end, + }) + end) +end + +--- @param log table[] +local function find_in_log(log, event, kind, repo_name, version) + local path = pack_get_plug_path(repo_name) + local spec = { name = repo_name, src = repos_src[repo_name], version = version } + local data = { kind = kind, path = path, spec = spec } + local entry = { event = event, match = vim.fs.abspath(path), data = data } + + local res = 0 + for i, tbl in ipairs(log) do + if vim.deep_equal(tbl, entry) then + res = i + break + end + end + eq(true, res > 0) + + return res +end + +local function validate_progress_report(title, step_names) + -- NOTE: Assumes that message history contains only progress report messages + local messages = vim.split(n.exec_capture('messages'), '\n') + local n_steps = #step_names + eq(n_steps + 2, #messages) + + local init_msg = ('vim.pack: 0%% %s (0/%d)'):format(title, n_steps) + eq(init_msg, messages[1]) + + local steps_seen = {} --- @type table + for i = 1, n_steps do + local percent = math.floor(100 * i / n_steps) + local msg = ('vim.pack: %3d%% %s (%d/%d)'):format(percent, title, i, n_steps) + -- NOTE: There is no guaranteed order (as it is async), so check that some + -- expected step name is used + local pattern = '^' .. vim.pesc(msg) .. ' %- (%S+)$' + local step = messages[i + 1]:match(pattern) + eq(true, vim.tbl_contains(step_names, step)) + steps_seen[step] = true + end + + -- Should report all steps + eq(n_steps, vim.tbl_count(steps_seen)) + + local final_msg = ('vim.pack: done %s (%d/%d)'):format(title, n_steps, n_steps) + eq(final_msg, messages[n_steps + 2]) +end + +local function is_jit() + return exec_lua('return package.loaded.jit ~= nil') +end + +-- Tests ====================================================================== + describe('vim.pack', function() + setup(function() + n.clear() + for _, r_setup in pairs(repos_setup) do + r_setup() + end + end) + + before_each(function() + n.clear() + end) + + after_each(function() + vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) + end) + + teardown(function() + vim.fs.rm(repos_dir, { force = true, recursive = true }) + end) + describe('add()', function() - pending('works', function() - -- TODO + it('installs only once', function() + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + n.clear() + + watch_events({ 'PackChanged' }) + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + eq(exec_lua('return #_G.event_log'), 0) end) - pending('reports errors after loading', function() - -- TODO - -- Should handle (not let it terminate the function) and report errors from pack_add() + it('asks for installation confirmation', function() + exec_lua(function() + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.confirm = function(...) + _G.confirm_args = { ... } + -- Do not confirm installation to see what happens + return 0 + end + end) + + local err = pcall_err(exec_lua, function() + vim.pack.add({ repos_src.basic }) + end) + + matches('`basic`:\nInstallation was not confirmed', err) + eq(false, exec_lua('return pcall(require, "basic")')) + + local confirm_msg = 'These plugins will be installed:\n\n' .. repos_src.basic .. '\n' + eq({ confirm_msg, 'Proceed? &Yes\n&No', 1, 'Question' }, exec_lua('return _G.confirm_args')) end) - pending('respects after/', function() - -- TODO - -- Should source 'after/plugin/' directory (even nested files) after - -- all 'plugin/' files are sourced in all plugins from input. - -- - -- Should add 'after/' directory (if present) to 'runtimepath' + it('installs at proper version', function() + local out = exec_lua(function() + vim.pack.add({ + { src = repos_src.basic, version = 'feat-branch' }, + }) + -- Should have plugin available immediately after + return require('basic') + end) + + eq('basic feat-branch', out) + + local rtp = vim.tbl_map(t.fix_slashes, api.nvim_list_runtime_paths()) + local plug_path = pack_get_plug_path('basic') + local after_dir = vim.fs.joinpath(plug_path, 'after') + eq(true, vim.tbl_contains(rtp, plug_path)) + -- No 'after/' directory in runtimepath because it is not present in plugin + eq(false, vim.tbl_contains(rtp, after_dir)) end) - pending('normalizes each spec', function() - -- TODO - - -- TODO: Should properly infer `name` from `src` (as its basename - -- minus '.git' suffix) but allow '.git' suffix in explicit `name` + it('can install from the Internet', function() + t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test') + exec_lua(function() + vim.pack.add({ 'https://github.com/neovim/nvim-lspconfig' }) + end) + eq(true, exec_lua('return pcall(require, "lspconfig")')) end) - pending('normalizes spec array', function() - -- TODO - -- Should silently ignore full duplicates (same `src`+`version`) - -- and error on conflicts. + it('shows progress report during installation', function() + exec_lua(function() + vim.pack.add({ repos_src.basic, repos_src.defbranch }) + end) + validate_progress_report('Installing plugins', { 'basic', 'defbranch' }) end) - pending('installs', function() - -- TODO + it('triggers relevant events', function() + watch_events({ 'PackChangedPre', 'PackChanged' }) - -- TODO: Should block code flow until all plugins are available on disk - -- and `:packadd` all of them (even just now installed) as a result. + exec_lua(function() + -- Should provide event-data respecting manual and inferred default `version` + vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' }, repos_src.defbranch }) + end) + + local log = exec_lua('return _G.event_log') + local installpre_basic = find_in_log(log, 'PackChangedPre', 'install', 'basic', 'feat-branch') + local installpre_defbranch = find_in_log(log, 'PackChangedPre', 'install', 'defbranch', nil) + local updatepre_basic = find_in_log(log, 'PackChangedPre', 'update', 'basic', 'feat-branch') + local updatepre_defbranch = find_in_log(log, 'PackChangedPre', 'update', 'defbranch', 'dev') + local update_basic = find_in_log(log, 'PackChanged', 'update', 'basic', 'feat-branch') + local update_defbranch = find_in_log(log, 'PackChanged', 'update', 'defbranch', 'dev') + local install_basic = find_in_log(log, 'PackChanged', 'install', 'basic', 'feat-branch') + local install_defbranch = find_in_log(log, 'PackChanged', 'install', 'defbranch', 'dev') + eq(8, #log) + + -- NOTE: There is no guaranteed installation order among separate plugins (as it is async) + eq(true, installpre_basic < updatepre_basic) + eq(true, updatepre_basic < update_basic) + -- NOTE: "Install" is after "update" to indicate installation at correct version + eq(true, update_basic < install_basic) + + eq(true, installpre_defbranch < updatepre_defbranch) + eq(true, updatepre_defbranch < update_defbranch) + eq(true, update_defbranch < install_defbranch) + end) + + it('recognizes several `version` types', function() + local prev_commit = git_get_hash('HEAD~', 'defbranch') + exec_lua(function() + vim.pack.add({ + { src = repos_src.basic, version = 'some-tag' }, -- Tag + { src = repos_src.defbranch, version = prev_commit }, -- Commit hash + { src = repos_src.semver, version = vim.version.range('<1') }, -- Semver constraint + }) + end) + + eq('basic some-tag', exec_lua('return require("basic")')) + eq('defbranch main', exec_lua('return require("defbranch")')) + eq('semver v0.4', exec_lua('return require("semver")')) + end) + + it('respects plugin/ and after/plugin/ scripts', function() + local function validate(load, ref) + local opts = { load = load } + local out = exec_lua(function() + -- Should handle bad plugin directory name + vim.pack.add({ { src = repos_src.plugindirs, name = 'plugin % dirs' } }, opts) + return { + vim.g._plugin, + vim.g._plugin_vim, + vim.g._plugin_sub, + vim.g._plugin_bad, + vim.g._after_plugin, + vim.g._after_plugin_vim, + vim.g._after_plugin_sub, + vim.g._after_plugin_bad, + } + end) + + eq(ref, out) + + -- Should add necessary directories to runtimepath regardless of `opts.load` + local rtp = vim.tbl_map(t.fix_slashes, api.nvim_list_runtime_paths()) + local plug_path = pack_get_plug_path('plugin % dirs') + local after_dir = vim.fs.joinpath(plug_path, 'after') + eq(true, vim.tbl_contains(rtp, plug_path)) + eq(true, vim.tbl_contains(rtp, after_dir)) + end + + validate(nil, { true, true, true, true, true, true, true, true }) + + n.clear() + validate(false, {}) + end) + + it('generates help tags', function() + exec_lua(function() + vim.pack.add({ { src = repos_src.helptags, name = 'help tags' } }) + end) + local target_tags = fn.getcompletion('my-test', 'help') + table.sort(target_tags) + eq({ 'my-test-help', 'my-test-help-bad', 'my-test-help-sub-bad' }, target_tags) + end) + + it('reports install/load errors after loading all input', function() + t.skip(not is_jit(), "Non LuaJIT reports errors differently due to 'coxpcall'") + local validate = function(err_pat) + local err = pcall_err(exec_lua, function() + vim.pack.add({ + { src = repos_src.basic, version = 'wrong-version' }, -- Error during initial checkout + { src = repos_src.semver, version = vim.version.range('>=2.0.0') }, -- Missing version + { src = repos_src.plugindirs, version = 'main' }, + { src = repos_src.pluginerr, version = 'main' }, -- Error during 'plugin/' source + }) + end) + + matches(err_pat, err) + + -- Should have processed non-errored 'plugin/' and add to 'rtp' + eq('plugindirs main', exec_lua('return require("plugindirs")')) + eq(true, exec_lua('return vim.g._plugin')) + + -- Should add plugin to 'rtp' even if 'plugin/' has error + eq('pluginerr main', exec_lua('return require("pluginerr")')) + end + + -- During initial install + local err_pat_parts = { + 'vim%.pack', + '`basic`:\n', + -- Should report available branches and tags if revision is absent + '`wrong%-version`', + 'Available:\nTags: some%-tag\nBranches: feat%-branch, main', + -- Should report available branches and versions if no constraint match + '`semver`', + 'Available:\nVersions: v1%.0%.0, v0%.4, 0%.3%.1, v0%.3%.0.*\nBranches: main\n', + '`pluginerr`:\n', + 'Wow, an error', + } + validate(table.concat(err_pat_parts, '.*')) + + -- During loading already installed plugin. + n.clear() + -- NOTE: There is no error for wrong `version`, because there is no check + -- for already installed plugins. Might change in the future. + validate('vim%.pack.*`pluginerr`:\n.*Wow, an error') + end) + + it('normalizes each spec', function() + exec_lua(function() + vim.pack.add({ + repos_src.basic, -- String should be inferred as `{ src = ... }` + { src = repos_src.defbranch }, -- Default `version` is remote's default branch + { src = repos_src['gitsuffix.git'] }, -- Default `name` comes from `src` repo name + { src = repos_src.plugindirs, name = 'plugin/dirs' }, -- Ensure proper directory name + }) + end) + + eq('basic main', exec_lua('return require("basic")')) + eq('defbranch dev', exec_lua('return require("defbranch")')) + eq('gitsuffix main', exec_lua('return require("gitsuffix")')) + eq(true, exec_lua('return vim.g._plugin')) + + eq(true, pack_exists('gitsuffix')) + eq(true, pack_exists('dirs')) + end) + + it('handles problematic names', function() + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, name = 'bad % name' } }) + end) + eq('basic main', exec_lua('return require("basic")')) + end) + + it('validates input', function() + local validate = function(err_pat, input) + local add_input = function() + vim.pack.add(input) + end + matches(err_pat, pcall_err(exec_lua, add_input)) + end + + -- Separate spec entries + validate('list', repos_src.basic) + validate('spec:.*table', { 1 }) + validate('spec%.src:.*string', { { src = 1 } }) + validate('spec%.src:.*non%-empty string', { { src = '' } }) + validate('spec%.name:.*string', { { src = repos_src.basic, name = 1 } }) + validate('spec%.name:.*non%-empty string', { { src = repos_src.basic, name = '' } }) + validate( + 'spec%.version:.*string or vim%.VersionRange', + { { src = repos_src.basic, version = 1 } } + ) + + -- Conflicts in input array + local version_conflict = { + { src = repos_src.basic, version = 'feat-branch' }, + { src = repos_src.basic, version = 'main' }, + } + validate('Conflicting `version` for `basic`.*feat%-branch.*main', version_conflict) + + local src_conflict = { + { src = repos_src.basic, name = 'my-plugin' }, + { src = repos_src.semver, name = 'my-plugin' }, + } + validate('Conflicting `src` for `my%-plugin`.*basic.*semver', src_conflict) end) end) describe('update()', function() - pending('works', function() - -- TODO + -- Lua source code for the tested plugin named "fetch" + local fetch_lua_file = vim.fs.joinpath(pack_get_plug_path('fetch'), 'lua', 'fetch.lua') + -- Table with hashes used to test confirmation buffer and log content + local hashes --- @type table - -- TODO: Should work with both added and not added plugins + before_each(function() + -- Create a dedicated clean repo for which "push changes" will be mocked + init_test_repo('fetch') + + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch init"') + git_add_commit('Initial commit for "fetch"', 'fetch') + + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch main"') + git_add_commit('Commit from `main` to be removed', 'fetch') + + hashes = { fetch_head = git_get_hash('HEAD', 'fetch') } + + -- Install initial versions of tested plugins + exec_lua(function() + vim.pack.add({ + repos_src.fetch, + { src = repos_src.semver, version = 'v0.3.0' }, + repos_src.defbranch, + }) + end) + n.clear() + + -- Mock remote repo update + -- - Force push + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new"') + git_cmd({ 'add', '*' }, 'fetch') + git_cmd({ 'commit', '--amend', '-m', 'Commit to be added 1' }, 'fetch') + + -- - Presence of a tag (should be shown in changelog) + git_cmd({ 'tag', 'dev-tag' }, 'fetch') + + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 2"') + git_add_commit('Commit to be added 2', 'fetch') end) - pending('suggests newer tags if there are no updates', function() - -- TODO + after_each(function() + pcall(vim.fs.rm, repo_get_path('fetch'), { force = true, recursive = true }) + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + pcall(vim.fs.rm, log_path, { force = true }) + end) - -- TODO: Should not suggest tags that point to the current state. - -- Even if there is one/several and located at start/middle/end. + describe('confirmation buffer', function() + it('works', function() + exec_lua(function() + vim.pack.add({ + repos_src.fetch, + { src = repos_src.semver, version = 'v0.3.0' }, + { src = repos_src.defbranch, version = 'does-not-exist' }, + }) + end) + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + + exec_lua(function() + -- Enable highlighting of special filetype + vim.cmd('filetype plugin on') + vim.pack.update() + end) + + -- Buffer should be special and shown in a separate tabpage + eq(2, #api.nvim_list_tabpages()) + eq(2, fn.tabpagenr()) + eq(api.nvim_get_option_value('filetype', {}), 'nvim-pack') + eq(api.nvim_get_option_value('modifiable', {}), false) + eq(api.nvim_get_option_value('buftype', {}), 'acwrite') + local confirm_bufnr = api.nvim_get_current_buf() + local confirm_winnr = api.nvim_get_current_win() + local confirm_tabpage = api.nvim_get_current_tabpage() + eq(api.nvim_buf_get_name(0), 'nvim-pack://' .. confirm_bufnr .. '/confirm-update') + + -- Adjust lines for a more robust screenshot testing + local fetch_src = repos_src.fetch + local fetch_path = pack_get_plug_path('fetch') + local semver_src = repos_src.semver + local semver_path = pack_get_plug_path('semver') + + exec_lua(function() + -- Replace matches in line to preserve extmark highlighting + local function replace_in_line(i, pattern, repl) + local line = vim.api.nvim_buf_get_lines(0, i - 1, i, false)[1] + local from, to = line:find(pattern) + while from and to do + vim.api.nvim_buf_set_text(0, i - 1, from - 1, i - 1, to, { repl }) + line = vim.api.nvim_buf_get_lines(0, i - 1, i, false)[1] + from, to = line:find(pattern) + end + end + + vim.bo.modifiable = true + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local pack_runtime = vim.fs.joinpath(vim.env.VIMRUNTIME, 'lua', 'vim', 'pack.lua') + -- NOTE: replace path to `vim.pack` in error traceback accounting for + -- possibly different slashes on Windows + local pack_runtime_pattern = vim.pesc(pack_runtime):gsub('/', '[\\/]') .. ':%d+' + for i = 1, #lines do + replace_in_line(i, pack_runtime_pattern, 'VIM_PACK_RUNTIME') + replace_in_line(i, vim.pesc(fetch_path), 'FETCH_PATH') + replace_in_line(i, vim.pesc(fetch_src), 'FETCH_SRC') + replace_in_line(i, vim.pesc(semver_path), 'SEMVER_PATH') + replace_in_line(i, vim.pesc(semver_src), 'SEMVER_SRC') + end + vim.bo.modified = false + vim.bo.modifiable = false + end) + + -- Use screenshot to test highlighting, otherwise prefer text matching. + -- This requires computing target hashes on each test run because they + -- change due to source repos being cleanly created on each file test. + local screen + screen = Screen.new(85, 35) + + hashes.fetch_new = git_get_hash('HEAD', 'fetch') + hashes.fetch_new_prev = git_get_hash('HEAD~', 'fetch') + hashes.semver_head = git_get_hash('v0.3.0', 'semver') + + local tab_name = 'n' .. (t.is_os('win') and ':' or '') .. '//2/confirm-update' + + local screen_lines = { + ('{24: [No Name] }{5: %s }{2:%s }{24:X}|'):format( + tab_name, + t.is_os('win') and '' or ' ' + ), + '{19:^# Error ────────────────────────────────────────────────────────────────────────} |', + ' |', + '{19:## defbranch} |', + ' |', + ' VIM_PACK_RUNTIME: `does-not-exist` is not a branch/tag/commit. Available: |', + ' Tags: |', + ' Branches: dev, main |', + ' |', + '{101:# Update ───────────────────────────────────────────────────────────────────────} |', + ' |', + '{101:## fetch} |', + 'Path: {103:FETCH_PATH} |', + 'Source: {103:FETCH_SRC} |', + ('State before: {103:%s} |'):format( + hashes.fetch_head + ), + ('State after: {103:%s} {102:(main)} |'):format( + hashes.fetch_new + ), + ' |', + 'Pending updates: |', + ('{104:< %s │ Commit from `main` to be removed} |'):format( + hashes.fetch_head + ), + ('{105:> %s │ Commit to be added 2} |'):format( + hashes.fetch_new + ), + ('{105:> %s │ Commit to be added 1 (tag: dev-tag)} |'):format( + hashes.fetch_new_prev + ), + ' |', + '{102:# Same ─────────────────────────────────────────────────────────────────────────} |', + ' |', + '{102:## semver} |', + 'Path: {103:SEMVER_PATH} |', + 'Source: {103:SEMVER_SRC} |', + ('State: {103:%s} {102:(v0.3.0)} |'):format( + hashes.semver_head + ), + ' |', + 'Available newer versions: |', + '• {102:v1.0.0} |', + '• {102:v0.4} |', + '• {102:0.3.1} |', + '{1:~ }|', + ' |', + } + + screen:add_extra_attr_ids({ + [101] = { foreground = Screen.colors.Orange }, + [102] = { foreground = Screen.colors.LightGray }, + [103] = { foreground = Screen.colors.LightBlue }, + [104] = { foreground = Screen.colors.NvimDarkRed }, + [105] = { foreground = Screen.colors.NvimDarkGreen }, + }) + -- NOTE: Non LuaJIT reports errors differently due to 'coxpcall' + if is_jit() then + screen:expect(table.concat(screen_lines, '\n')) + end + + -- `:write` should confirm + n.exec('write') + + -- - Apply changes immediately + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + + -- - Clean up buffer+window+tabpage + eq(false, api.nvim_buf_is_valid(confirm_bufnr)) + eq(false, api.nvim_win_is_valid(confirm_winnr)) + eq(false, api.nvim_tabpage_is_valid(confirm_tabpage)) + + -- - Write to log file + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + local log_lines = fn.readfile(log_path) + matches('========== Update %d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d ==========', log_lines[1]) + local ref_log_lines = { + '# Update ───────────────────────────────────────────────────────────────────────', + '', + '## fetch', + 'Path: ' .. fetch_path, + 'Source: ' .. fetch_src, + 'State before: ' .. hashes.fetch_head, + 'State after: ' .. hashes.fetch_new .. ' (main)', + '', + 'Pending updates:', + '< ' .. hashes.fetch_head .. ' │ Commit from `main` to be removed', + '> ' .. hashes.fetch_new .. ' │ Commit to be added 2', + '> ' .. hashes.fetch_new_prev .. ' │ Commit to be added 1 (tag: dev-tag)', + '', + } + eq(ref_log_lines, vim.list_slice(log_lines, 2)) + end) + + it('can be dismissed with `:quit`', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update({ 'fetch' }) + end) + eq('nvim-pack', api.nvim_get_option_value('filetype', {})) + + -- Should not apply updates + n.exec('quit') + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + end) + + it('closes full tabpage', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update() + end) + + -- Confirm with `:write` + local confirm_tabpage = api.nvim_get_current_tabpage() + n.exec('-tab split other-tab') + local other_tabpage = api.nvim_get_current_tabpage() + n.exec('tabnext') + n.exec('write') + eq(true, api.nvim_tabpage_is_valid(other_tabpage)) + eq(false, api.nvim_tabpage_is_valid(confirm_tabpage)) + + -- Not confirm with `:quit` + n.exec('tab split other-tab-2') + local other_tabpage_2 = api.nvim_get_current_tabpage() + exec_lua(function() + vim.pack.update() + end) + confirm_tabpage = api.nvim_get_current_tabpage() + + -- - Temporary split window in tabpage should not matter + n.exec('vsplit other-buf') + n.exec('wincmd w') + + n.exec('tabclose ' .. api.nvim_tabpage_get_number(other_tabpage_2)) + eq(confirm_tabpage, api.nvim_get_current_tabpage()) + n.exec('quit') + eq(false, api.nvim_tabpage_is_valid(confirm_tabpage)) + end) + + it('has in-process LSP features', function() + t.skip(not is_jit(), "Non LuaJIT reports errors differently due to 'coxpcall'") + exec_lua(function() + vim.pack.add({ + repos_src.fetch, + { src = repos_src.semver, version = 'v0.3.0' }, + { src = repos_src.defbranch, version = 'does-not-exist' }, + }) + vim.pack.update() + end) + + eq(1, exec_lua('return #vim.lsp.get_clients({ bufnr = 0 })')) + + -- textDocument/documentSymbol + exec_lua('vim.lsp.buf.document_symbol()') + local loclist = vim.tbl_map(function(x) --- @param x table + return { + lnum = x.lnum, --- @type integer + col = x.col, --- @type integer + end_lnum = x.end_lnum, --- @type integer + end_col = x.end_col, --- @type integer + text = x.text, --- @type string + } + end, fn.getloclist(0)) + local ref_loclist = { + { lnum = 1, col = 1, end_lnum = 9, end_col = 1, text = '[Namespace] Error' }, + { lnum = 3, col = 1, end_lnum = 9, end_col = 1, text = '[Module] defbranch' }, + { 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' }, + } + eq(ref_loclist, loclist) + + n.exec('lclose') + + -- textDocument/hover + local confirm_winnr = api.nvim_get_current_win() + local validate_hover = function(pos, commit_msg) + api.nvim_win_set_cursor(0, pos) + exec_lua(function() + vim.lsp.buf.hover() + -- Default hover is async shown in floating window + vim.wait(1000, function() + return #vim.api.nvim_tabpage_list_wins(0) > 1 + end) + end) + + local all_wins = api.nvim_tabpage_list_wins(0) + eq(2, #all_wins) + local float_winnr = all_wins[1] == confirm_winnr and all_wins[2] or all_wins[1] + eq(true, api.nvim_win_get_config(float_winnr).relative ~= '') + + local float_buf = api.nvim_win_get_buf(float_winnr) + local text = table.concat(api.nvim_buf_get_lines(float_buf, 0, -1, false), '\n') + + local ref_pattern = 'Marvim \nDate:.*' .. vim.pesc(commit_msg) + matches(ref_pattern, text) + end + + validate_hover({ 14, 0 }, 'Commit from `main` to be removed') + validate_hover({ 15, 0 }, 'Commit to be added 2') + validate_hover({ 18, 0 }, 'Commit from `main` to be removed') + validate_hover({ 19, 0 }, 'Commit to be added 2') + validate_hover({ 20, 0 }, 'Commit to be added 1') + validate_hover({ 27, 0 }, 'Add version v0.3.0') + 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') + end) + + it('suggests newer versions when on non-tagged commit', function() + local commit = git_get_hash('0.3.1~', 'semver') + exec_lua(function() + -- Make fresh install for cleaner test + vim.pack.del({ 'semver' }) + vim.pack.add({ { src = repos_src.semver, version = commit } }) + vim.pack.update({ 'semver' }) + end) + + -- Should correctly infer that 0.3.0 is the latest version and suggest + -- versions greater than that + local confirm_text = table.concat(api.nvim_buf_get_lines(0, 0, -1, false), '\n') + matches('Available newer versions:\n• v1%.0%.0\n• v0%.4\n• 0%.3%.1$', confirm_text) + end) + end) + + it('works with not active plugins', function() + exec_lua(function() + -- No plugins are added, but they are installed in `before_each()` + vim.pack.update({ 'fetch' }) + end) + eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) + n.exec('write') + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + end) + + it('can force update', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update({ 'fetch' }, { force = true }) + end) + + -- Apply changes immediately + local fetch_src = repos_src.fetch + local fetch_path = pack_get_plug_path('fetch') + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + + -- No special buffer/window/tabpage + eq(1, #api.nvim_list_tabpages()) + eq(1, #api.nvim_list_wins()) + eq('', api.nvim_get_option_value('filetype', {})) + + -- Write to log file + hashes.fetch_new = git_get_hash('HEAD', 'fetch') + hashes.fetch_new_prev = git_get_hash('HEAD~', 'fetch') + + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + local log_lines = fn.readfile(log_path) + matches('========== Update %d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d ==========', log_lines[1]) + local ref_log_lines = { + '# Update ───────────────────────────────────────────────────────────────────────', + '', + '## fetch', + 'Path: ' .. fetch_path, + 'Source: ' .. fetch_src, + 'State before: ' .. hashes.fetch_head, + 'State after: ' .. hashes.fetch_new .. ' (main)', + '', + 'Pending updates:', + '< ' .. hashes.fetch_head .. ' │ Commit from `main` to be removed', + '> ' .. hashes.fetch_new .. ' │ Commit to be added 2', + '> ' .. hashes.fetch_new_prev .. ' │ Commit to be added 1 (tag: dev-tag)', + '', + } + eq(ref_log_lines, vim.list_slice(log_lines, 2)) + end) + + it('shows progress report', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + vim.pack.update() + end) + + -- During initial download + validate_progress_report('Downloading updates', { 'fetch', 'defbranch' }) + n.exec('messages clear') + + -- During application (only for plugins that have updates) + n.exec('write') + validate_progress_report('Applying updates', { 'fetch' }) + + -- During force update + n.clear() + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 3"') + git_add_commit('Commit to be added 3', 'fetch') + + exec_lua(function() + vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + vim.pack.update(nil, { force = true }) + end) + validate_progress_report('Updating', { 'fetch', 'defbranch' }) + end) + + it('triggers relevant events', function() + watch_events({ 'PackChangedPre', 'PackChanged' }) + exec_lua(function() + vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + _G.event_log = {} + vim.pack.update() + end) + eq({}, exec_lua('return _G.event_log')) + + -- Should trigger relevant events only for actually updated plugins + n.exec('write') + local log = exec_lua('return _G.event_log') + eq(1, find_in_log(log, 'PackChangedPre', 'update', 'fetch', 'main')) + eq(2, find_in_log(log, 'PackChanged', 'update', 'fetch', 'main')) + eq(2, #log) + end) + + it('stashes before applying changes', function() + fn.writefile({ 'A text that will be stashed' }, fetch_lua_file) + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + vim.pack.update() + vim.cmd('write') + end) + + local fetch_path = pack_get_plug_path('fetch') + local stash_list = system_sync({ 'git', 'stash', 'list' }, { cwd = fetch_path }).stdout or '' + matches('vim%.pack: %d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d Stash before checkout', stash_list) + + -- Update should still be applied + eq({ 'return "fetch new 2"' }, fn.readfile(fetch_lua_file)) + end) + + it('validates input', function() + local validate = function(err_pat, input) + local update_input = function() + vim.pack.update(input) + end + matches(err_pat, pcall_err(exec_lua, update_input)) + end + + validate('list', 1) + + -- Should first check if every plugin name represents installed plugin + -- If not - stop early before any update + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + + validate('The following plugins are not installed: aaa, ccc', { 'aaa', 'basic', 'ccc' }) + + -- Empty list is allowed with warning + n.exec('messages clear') + exec_lua(function() + vim.pack.update({}) + end) + eq('vim.pack: Nothing to update', n.exec_capture('messages')) end) end) describe('get()', function() - pending('works', function() - -- TODO + local basic_spec = { name = 'basic', src = repos_src.basic, version = 'main' } + local basic_path = pack_get_plug_path('basic') + local defbranch_spec = { name = 'defbranch', src = repos_src.defbranch, version = 'dev' } + local defbranch_path = pack_get_plug_path('defbranch') + + it('returns list of available plugins', function() + -- Should work just after installation + exec_lua(function() + vim.pack.add({ repos_src.defbranch, repos_src.basic }) + end) + eq({ + -- Should preserve order in which plugins were `vim.pack.add()`ed + { active = true, path = defbranch_path, spec = defbranch_spec }, + { active = true, path = basic_path, spec = basic_spec }, + }, exec_lua('return vim.pack.get()')) + + -- Should also list non-active plugins + n.clear() + + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + eq({ + -- Should first list active, then non-active + { active = true, path = basic_path, spec = basic_spec }, + { active = false, path = defbranch_path, spec = defbranch_spec }, + }, exec_lua('return vim.pack.get()')) end) - pending('works after `del()`', function() - -- TODO: Should not include removed plugins and still return list + it('works with `del()`', function() + exec_lua(function() + vim.pack.add({ repos_src.defbranch, repos_src.basic }) + end) - -- TODO: Should return corrent list inside `PackChanged` "delete" event + exec_lua(function() + _G.get_log = {} + vim.api.nvim_create_autocmd({ 'PackChangedPre', 'PackChanged' }, { + callback = function() + table.insert(_G.get_log, vim.pack.get()) + end, + }) + end) + + -- Should not include removed plugins immediately after they are removed, + -- while still returning list without holes + exec_lua('vim.pack.del({ "defbranch" })') + eq({ + { + { active = true, path = defbranch_path, spec = defbranch_spec }, + { active = true, path = basic_path, spec = basic_spec }, + }, + { + { active = true, path = basic_path, spec = basic_spec }, + }, + }, exec_lua('return _G.get_log')) end) end) describe('del()', function() - pending('works', function() - -- TODO + it('works', function() + exec_lua(function() + vim.pack.add({ repos_src.plugindirs, { src = repos_src.basic, version = 'feat-branch' } }) + end) + eq(true, pack_exists('basic')) + eq(true, pack_exists('plugindirs')) + + watch_events({ 'PackChangedPre', 'PackChanged' }) + + n.exec('messages clear') + exec_lua(function() + vim.pack.del({ 'basic', 'plugindirs' }) + end) + eq(false, pack_exists('basic')) + eq(false, pack_exists('plugindirs')) + + eq( + "vim.pack: Removed plugin 'plugindirs'\nvim.pack: Removed plugin 'basic'", + n.exec_capture('messages') + ) + + -- Should trigger relevant events in order as specified in `vim.pack.add()` + local log = exec_lua('return _G.event_log') + eq(1, find_in_log(log, 'PackChangedPre', 'delete', 'plugindirs', 'main')) + eq(2, find_in_log(log, 'PackChanged', 'delete', 'plugindirs', 'main')) + eq(3, find_in_log(log, 'PackChangedPre', 'delete', 'basic', 'feat-branch')) + eq(4, find_in_log(log, 'PackChanged', 'delete', 'basic', 'feat-branch')) + eq(4, #log) + end) + + it('validates input', function() + local validate = function(err_pat, input) + local del_input = function() + vim.pack.del(input) + end + matches(err_pat, pcall_err(exec_lua, del_input)) + end + + validate('list', nil) + + -- Should first check if every plugin name represents installed plugin + -- If not - stop early before any delete + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + + validate('The following plugins are not installed: aaa, ccc', { 'aaa', 'basic', 'ccc' }) + eq(true, pack_exists('basic')) + + -- Empty list is allowed with warning + n.exec('messages clear') + exec_lua(function() + vim.pack.del({}) + end) + eq('vim.pack: Nothing to remove', n.exec_capture('messages')) end) end) end)