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 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/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/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..200f08e311 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 @@ -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 -- 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') - end - - if p.info.sha_head ~= p.info.sha_target or p.info.update_details == '' then + 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 - -- 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. @@ -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 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)