commit 93f5bd0caf9037f95ee77288bfd424008bc3a14b
parent 3f416ef524a0bb939554f6b73a25ac885dfdf574
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Sat, 2 Aug 2025 19:35:42 -0400
Merge #35052 test(pack): vim.pack
Diffstat:
6 files changed, 1238 insertions(+), 111 deletions(-)
diff --git 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
diff --git 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/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/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
@@ -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
@@ -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
@@ -202,14 +195,24 @@ 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
--- @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
+
+--- @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
--- @return string
@@ -239,9 +242,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
@@ -319,6 +323,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<string,boolean>
-- 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
@@ -328,9 +333,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
@@ -367,13 +381,16 @@ 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)
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 +407,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
@@ -433,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)
@@ -458,7 +490,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)
@@ -473,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)
@@ -517,7 +540,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)
@@ -529,7 +552,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, { doc_dir, magic = { file = false } })
end
--- @param plug_list vim.pack.Plug[]
@@ -551,6 +574,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)
@@ -565,35 +591,46 @@ 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)
- else
- p.info.update_details = table.concat(git_get_tags(p.path, { contains = sha_target }), '\n')
+ 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
- if p.info.sha_head ~= p.info.sha_target or p.info.update_details == '' then
+ -- 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
- -- 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)
- 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.
@@ -614,18 +651,18 @@ 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.
-- 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)
- vim.cmd.source(vim.fn.fnameescape(path))
+ vim.cmd.source({ path, magic = { file = false } })
end, after_paths)
end
end
@@ -714,7 +751,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({
@@ -782,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()
@@ -871,11 +908,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(
@@ -938,17 +970,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
diff --git 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<string,string>
+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<string,function>
+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<string,boolean>
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+
+ it('triggers relevant events', function()
+ watch_events({ 'PackChangedPre', 'PackChanged' })
+
+ 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)
- pending('reports errors after loading', function()
- -- TODO
- -- Should handle (not let it terminate the function) and report errors from pack_add()
+ 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)
- 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('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)
- pending('normalizes each spec', function()
- -- TODO
+ 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'))
- -- TODO: Should properly infer `name` from `src` (as its basename
- -- minus '.git' suffix) but allow '.git' suffix in explicit `name`
+ eq(true, pack_exists('gitsuffix'))
+ eq(true, pack_exists('dirs'))
end)
- pending('normalizes spec array', function()
- -- TODO
- -- Should silently ignore full duplicates (same `src`+`version`)
- -- and error on conflicts.
+ 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)
- pending('installs', function()
- -- TODO
+ 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
- -- TODO: Should block code flow until all plugins are available on disk
- -- and `:packadd` all of them (even just now installed) as a result.
+ -- 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<string,string>
+
+ 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)
+
+ 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)
+
+ 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 })'))
- -- TODO: Should work with both added and not added plugins
+ -- 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 <marvim@neovim%.io>\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)
- pending('suggests newer tags if there are no updates', function()
- -- TODO
+ 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
- -- TODO: Should not suggest tags that point to the current state.
- -- Even if there is one/several and located at start/middle/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)