Files
neovim/runtime/lua/vim/pack.lua
Evgeni Chasnovski acff86601e feat(pack): allow skip install confirmation in add()
Problem: No way to skip install confirmation in `add()`. Having install
  confirmation by default is a more secure design. However, users are
  usually aware of the fact that plugin will be installed and there is
  currently no way to skip confirmation.

  Plus it can introduce inconvenience on the clean config initialization
  if it is modularized with many `vim.pack.add()` calls (leads to
  confirming installation many times in a row).

Solution: Add `opts.confirm` option that can skip install confirmation.
2025-08-09 17:54:39 +03:00

1046 lines
36 KiB
Lua

--- @brief
---
---WORK IN PROGRESS built-in plugin manager! Early testing of existing features
---is appreciated, but expect breaking changes without notice.
---
---Manages plugins only in a dedicated [vim.pack-directory]() (see |packages|):
---`$XDG_DATA_HOME/nvim/site/pack/core/opt`.
---Plugin's subdirectory name matches plugin's name in specification.
---It is assumed that all plugins in the directory are managed exclusively by `vim.pack`.
---
---Uses Git to manage plugins and requires present `git` executable of at
---least version 2.36. Target plugins should be Git repositories with versions
---as named tags following semver convention `v<major>.<minor>.<patch>`.
---
---Example workflows ~
---
---Basic install and management:
---
---- Add |vim.pack.add()| call(s) to 'init.lua':
---```lua
---
---vim.pack.add({
--- -- Install "plugin1" and use default branch (usually `main` or `master`)
--- 'https://github.com/user/plugin1',
---
--- -- Same as above, but using a table (allows setting other options)
--- { src = 'https://github.com/user/plugin1' },
---
--- -- Specify plugin's name (here the plugin will be called "plugin2"
--- -- instead of "generic-name")
--- { src = 'https://github.com/user/generic-name', name = 'plugin2' },
---
--- -- Specify version to follow during install and update
--- {
--- src = 'https://github.com/user/plugin3',
--- -- Version constraint, see |vim.version.range()|
--- version = vim.version.range('1.0'),
--- },
--- {
--- src = 'https://github.com/user/plugin4',
--- -- Git branch, tag, or commit hash
--- version = 'main',
--- },
---})
---
----- Plugin's code can be used directly after `add()`
---plugin1 = require('plugin1')
---```
---
---- Restart Nvim (for example, with |:restart|). Plugins that were not yet
---installed will be available on disk in target state after `add()` call.
---
---- To update all plugins with new changes:
--- - Execute |vim.pack.update()|. This will download updates from source and
--- show confirmation buffer in a separate tabpage.
--- - Review changes. To confirm all updates execute |:write|.
--- To discard updates execute |:quit|.
---
---Switch plugin's version:
---- Update 'init.lua' for plugin to have desired `version`. Let's say, plugin
---named 'plugin1' has changed to `vim.version.range('*')`.
---- |:restart|. The plugin's actual state on disk is not yet changed.
---- Execute `vim.pack.update({ 'plugin1' })`.
---- Review changes and either confirm or discard them. If discarded, revert
---any changes in 'init.lua' as well or you will be prompted again next time
---you run |vim.pack.update()|.
---
---Freeze plugin from being updated:
---- Update 'init.lua' for plugin to have `version` set to current commit hash.
---You can get it by running `vim.pack.update({ 'plugin-name' })` and yanking
---the word describing current state (looks like `abc12345`).
---- |:restart|.
---
---Unfreeze plugin to start receiving updates:
---- Update 'init.lua' for plugin to have `version` set to whichever version
---you want it to be updated.
---- |:restart|.
---
---Remove plugins from disk:
---- Use |vim.pack.del()| with a list of plugin names to remove. Make sure their specs
---are not included in |vim.pack.add()| call in 'init.lua' or they will be reinstalled.
---
--- Available events to hook into ~
---
--- - [PackChangedPre]() - before trying to change plugin's state.
--- - [PackChanged]() - after plugin's state has changed.
---
--- 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 with defaults made explicit.
--- - `path` - full path to plugin's directory.
local api = vim.api
local uv = vim.uv
local async = require('vim._async')
local M = {}
-- Git ------------------------------------------------------------------------
--- @async
--- @param cmd string[]
--- @param cwd? string
--- @return string
local function git_cmd(cmd, cwd)
-- Use '-c gc.auto=0' to disable `stderr` "Auto packing..." messages
cmd = vim.list_extend({ 'git', '-c', 'gc.auto=0' }, cmd)
local sys_opts = { cwd = cwd, text = true, clear_env = true }
local out = async.await(3, vim.system, cmd, sys_opts) --- @type vim.SystemCompleted
async.await(1, vim.schedule)
if out.code ~= 0 then
error(out.stderr)
end
local stdout, stderr = assert(out.stdout), assert(out.stderr)
if stderr ~= '' then
vim.schedule(function()
vim.notify(stderr:gsub('\n+$', ''), vim.log.levels.WARN)
end)
end
return (stdout:gsub('\n+$', ''))
end
local function git_ensure_exec()
if vim.fn.executable('git') == 0 then
error('No `git` executable')
end
end
--- @async
--- @param url string
--- @param path string
local function git_clone(url, path)
local cmd = { 'clone', '--quiet', '--origin', 'origin' }
if vim.startswith(url, 'file://') then
cmd[#cmd + 1] = '--no-hardlinks'
else
-- NOTE: '--also-filter-submodules' requires Git>=2.36
local filter_args = { '--filter=blob:none', '--recurse-submodules', '--also-filter-submodules' }
vim.list_extend(cmd, filter_args)
end
vim.list_extend(cmd, { '--origin', 'origin', url, path })
git_cmd(cmd, uv.cwd())
end
--- @async
--- @param rev string
--- @param cwd string
--- @return string
local function git_get_hash(rev, cwd)
-- Using `rev-list -1` shows a commit of revision, while `rev-parse` shows
-- hash of revision. Those are different for annotated tags.
return git_cmd({ 'rev-list', '-1', '--abbrev-commit', rev }, cwd)
end
--- @async
--- @param cwd string
--- @return string
local function git_get_default_branch(cwd)
local res = git_cmd({ 'rev-parse', '--abbrev-ref', 'origin/HEAD' }, cwd)
return (res:gsub('^origin/', ''))
end
--- @async
--- @param cwd string
--- @return string[]
local function git_get_branches(cwd)
local cmd = { 'branch', '--remote', '--list', '--format=%(refname:short)', '--', 'origin/**' }
local stdout = git_cmd(cmd, cwd)
local res = {} --- @type string[]
for l in vim.gsplit(stdout, '\n') do
res[#res + 1] = l:match('^origin/(.+)$')
end
return res
end
--- @async
--- @param cwd string
--- @return string[]
local function git_get_tags(cwd)
local cmd = { 'tag', '--list', '--sort=-v:refname' }
return vim.split(git_cmd(cmd, cwd), '\n')
end
-- Plugin operations ----------------------------------------------------------
--- @return string
local function get_plug_dir()
return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt')
end
--- @param msg string|string[]
--- @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.cmd.redraw()
end
--- @param x string|vim.VersionRange
--- @return boolean
local function is_version(x)
return type(x) == 'string' or (type(x) == 'table' and pcall(x.has, x, '1'))
end
--- @param x string
--- @return boolean
local function is_semver(x)
return vim.version.parse(x) ~= nil
end
local function is_nonempty_string(x)
return type(x) == 'string' and x ~= ''
end
--- @return string
local function get_timestamp()
return vim.fn.strftime('%Y-%m-%d %H:%M:%S')
end
--- @class vim.pack.Spec
---
--- URI from which to install and pull updates. Any format supported by `git clone` is allowed.
--- @field src string
---
--- Name of plugin. Will be used as directory name. Default: `src` repository name.
--- @field name? string
---
--- Version to use for install and updates. Can be:
--- - `nil` (no value, default) to use repository's default branch (usually `main` or `master`).
--- - String to use specific branch, tag, or commit hash.
--- - Output of |vim.version.range()| to install the greatest/last semver tag
--- inside the version constraint.
--- @field version? string|vim.VersionRange
--- @alias vim.pack.SpecResolved { src: string, name: string, version: nil|string|vim.VersionRange }
--- @param spec string|vim.pack.Spec
--- @return vim.pack.SpecResolved
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, 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
--- @class (private) vim.pack.PlugInfo
--- @field err string The latest error when working on plugin. If non-empty,
--- all further actions should not be done (including triggering events).
--- @field installed? boolean Whether plugin was successfully installed.
--- @field version_str? string `spec.version` with resolved version range.
--- @field version_ref? string Resolved version as Git reference (if different
--- from `version_str`).
--- @field sha_head? string Git hash of HEAD.
--- @field sha_target? string Git hash of `version_ref`.
--- @field update_details? string Details about the update:: changelog if HEAD
--- and target are different, available newer tags otherwise.
--- @class (private) vim.pack.Plug
--- @field spec vim.pack.SpecResolved
--- @field path string
--- @field info vim.pack.PlugInfo Gathered information about plugin.
--- @param spec string|vim.pack.Spec
--- @return vim.pack.Plug
local function new_plug(spec)
local spec_resolved = normalize_spec(spec)
local path = vim.fs.joinpath(get_plug_dir(), spec_resolved.name)
local info = { err = '', installed = uv.fs_stat(path) ~= nil }
return { spec = spec_resolved, path = path, info = info }
end
--- Normalize plug array: gather non-conflicting data from duplicated entries.
--- @param plugs vim.pack.Plug[]
--- @return vim.pack.Plug[]
local function normalize_plugs(plugs)
--- @type table<string, { plug: vim.pack.Plug, id: integer }>
local plug_map = {}
local n = 0
for _, p in ipairs(plugs) do
-- Collect
if not plug_map[p.path] then
n = n + 1
plug_map[p.path] = { plug = p, id = n }
end
local p_data = plug_map[p.path]
-- TODO(echasnovski): if both versions are `vim.VersionRange`, collect as
-- their intersection. Needs `vim.version.intersect`.
p_data.plug.spec.version = vim.F.if_nil(p_data.plug.spec.version, p.spec.version)
-- Ensure no conflicts
local spec_ref = p_data.plug.spec
local spec = p.spec
if spec_ref.src ~= spec.src then
local src_1 = tostring(spec_ref.src)
local src_2 = tostring(spec.src)
error(('Conflicting `src` for `%s`:\n%s\n%s'):format(spec.name, src_1, src_2))
end
if spec_ref.version ~= spec.version then
local ver_1 = tostring(spec_ref.version)
local ver_2 = tostring(spec.version)
error(('Conflicting `version` for `%s`:\n%s\n%s'):format(spec.name, ver_1, ver_2))
end
end
--- @type vim.pack.Plug[]
local res = {}
for _, p_data in pairs(plug_map) do
res[p_data.id] = p_data.plug
end
assert(#res == n)
return res
end
--- @param names string[]?
--- @return vim.pack.Plug[]
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
-- not active plugins might lead to a confusion as default `version` and
-- user's desired one might mismatch.
-- TODO(echasnovski): Consider changing this if/when there is lockfile.
--- @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
--- @param p vim.pack.Plug
--- @param event_name 'PackChangedPre'|'PackChanged'
--- @param kind 'install'|'update'|'delete'
local function trigger_event(p, event_name, kind)
local spec = vim.deepcopy(p.spec)
-- Infer default branch for fuller `event-data` (if possible)
-- Doing it only on event trigger level allows keeping `spec` close to what
-- user supplied without performance issues during startup.
spec.version = spec.version or (uv.fs_stat(p.path) and git_get_default_branch(p.path))
local data = { kind = kind, spec = spec, path = p.path }
vim.api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data })
end
--- @param title string
--- @return fun(kind: 'begin'|'report'|'end', percent: integer, fmt: string, ...:any): nil
local function new_progress_report(title)
-- TODO(echasnovski): currently print directly in command line because
-- there is no robust built-in way of showing progress:
-- - `vim.ui.progress()` is planned and is a good candidate to use here.
-- - Use `'$/progress'` implementation in 'vim.pack._lsp' if there is
-- a working built-in '$/progress' handler. Something like this:
-- ```lua
-- local progress_token_count = 0
-- function M.new_progress_report(title)
-- progress_token_count = progress_token_count + 1
-- return vim.schedule_wrap(function(kind, msg, percent)
-- local value = { kind = kind, message = msg, percentage = percent }
-- dispatchers.notification(
-- '$/progress',
-- { token = progress_token_count, value = value }
-- )
-- end
-- end
-- ```
-- Any of these choices is better as users can tweak how progress is shown.
return vim.schedule_wrap(function(kind, percent, fmt, ...)
local progress = kind == 'end' and 'done' or ('%3d%%'):format(percent)
local details = (' %s %s'):format(title, fmt:format(...))
local chunks = { { 'vim.pack', 'ModeMsg' }, { ': ' }, { progress, 'WarningMsg' }, { details } }
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[]
--- @param f async fun(p: vim.pack.Plug)
--- @param progress_title string
local function run_list(plug_list, f, progress_title)
local report_progress = new_progress_report(progress_title)
-- Construct array of functions to execute in parallel
local n_finished = 0
local funs = {} --- @type (async fun())[]
for _, p in ipairs(plug_list) do
-- Run only for plugins which didn't error before
if p.info.err == '' then
--- @async
funs[#funs + 1] = function()
local ok, err = copcall(f, p) --[[@as string]]
if not ok then
p.info.err = err --- @as string
end
-- Show progress
n_finished = n_finished + 1
local percent = math.floor(100 * n_finished / #funs)
report_progress('report', percent, '(%d/%d) - %s', n_finished, #funs, p.spec.name)
end
end
end
if #funs == 0 then
return
end
-- Run async in parallel but wait for all to finish/timeout
report_progress('begin', 0, '(0/%d)', #funs)
--- @async
local function joined_f()
async.join(n_threads, funs)
end
async.run(joined_f):wait()
report_progress('end', 100, '(%d/%d)', #funs, #funs)
end
--- @param plug_list vim.pack.Plug[]
--- @return boolean
local function confirm_install(plug_list)
local src = {} --- @type string[]
for _, p in ipairs(plug_list) do
src[#src + 1] = p.spec.src
end
local src_text = table.concat(src, '\n')
local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(src_text)
local res = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No', 1, 'Question') == 1
vim.cmd.redraw()
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)
local function list_in_line(name, list)
return #list == 0 and '' or ('\n' .. name .. ': ' .. table.concat(list, ', '))
end
-- Resolve only once
if p.info.version_str then
return
end
local version = p.spec.version
-- Default branch
if not version then
p.info.version_str = git_get_default_branch(p.path)
p.info.version_ref = 'origin/' .. p.info.version_str
return
end
-- Non-version-range like version: branch, tag, or commit hash
local branches = git_get_branches(p.path)
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 = 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)
.. list_in_line('Branches', branches)
error(err)
end
p.info.version_str = version
p.info.version_ref = (is_branch and 'origin/' or '') .. version
return
end
--- @cast version vim.VersionRange
-- Choose the greatest/last version among all matching semver tags
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)
error(err)
end
end
--- @async
--- @param p vim.pack.Plug
local function infer_states(p)
p.info.sha_head = p.info.sha_head or git_get_hash('HEAD', p.path)
resolve_version(p)
local target_ref = p.info.version_ref or p.info.version_str --[[@as string]]
p.info.sha_target = p.info.sha_target or git_get_hash(target_ref, p.path)
end
--- Keep repos in detached HEAD state. Infer commit from resolved version.
--- No local branches are created, branches from "origin" remote are used directly.
--- @async
--- @param p vim.pack.Plug
--- @param timestamp string
--- @param skip_same_sha boolean
local function checkout(p, timestamp, skip_same_sha)
infer_states(p)
if skip_same_sha and p.info.sha_head == p.info.sha_target then
return
end
trigger_event(p, 'PackChangedPre', 'update')
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)
trigger_event(p, 'PackChanged', 'update')
-- (Re)Generate help tags according to the current help files.
-- Also use `pcall()` because `:helptags` errors if there is no 'doc/'
-- directory or if it is empty.
local doc_dir = vim.fs.joinpath(p.path, 'doc')
vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags'))
copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } })
end
--- @param plug_list vim.pack.Plug[]
local function install_list(plug_list, confirm)
-- Get user confirmation to install plugins
if confirm and not confirm_install(plug_list) then
for _, p in ipairs(plug_list) do
p.info.err = 'Installation was not confirmed'
end
return
end
local timestamp = get_timestamp()
--- @async
--- @param p vim.pack.Plug
local function do_install(p)
trigger_event(p, 'PackChangedPre', 'install')
git_clone(p.spec.src, p.path)
p.info.installed = true
-- 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)
-- "Install" event is triggered after "update" event intentionally to have
-- it indicate "plugin is installed in its correct initial version"
trigger_event(p, 'PackChanged', 'install')
end
run_list(plug_list, do_install, 'Installing plugins')
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
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
-- 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
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.
--- Use map and not array to avoid linear lookup during startup.
--- @type table<string, { plug: vim.pack.Plug, id: integer }?>
local active_plugins = {}
local n_active_plugins = 0
--- @param plug vim.pack.Plug
--- @param load boolean|fun(plug_data: {spec: vim.pack.Spec, path: string})
local function pack_add(plug, load)
-- Add plugin only once, i.e. no overriding of spec. This allows users to put
-- plugin first to fully control its spec.
if active_plugins[plug.path] then
return
end
n_active_plugins = n_active_plugins + 1
active_plugins[plug.path] = { plug = plug, id = n_active_plugins }
if vim.is_callable(load) then
load({ spec = vim.deepcopy(plug.spec), path = plug.path })
return
end
-- 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.
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({ path, magic = { file = false } })
end, after_paths)
end
end
--- @class vim.pack.keyset.add
--- @inlinedoc
--- Load `plugin/` files and `ftdetect/` scripts. If `false`, works like `:packadd!`.
--- If function, called with plugin data and is fully responsible for loading plugin.
--- Default `false` during startup and `true` afterwards.
--- @field load? boolean|fun(plug_data: {spec: vim.pack.Spec, path: string})
---
--- @field confirm? boolean Whether to ask user to confirm initial install. Default `true`.
--- Add plugin to current session
---
--- - For each specification check that plugin exists on disk in |vim.pack-directory|:
--- - If exists, do nothing in this step.
--- - If doesn't exist, install it by downloading from `src` into `name`
--- subdirectory (via `git clone`) and update state to match `version` (via `git checkout`).
--- - For each plugin execute |:packadd| (or customizable `load` function) making
--- it reachable by Nvim.
---
--- Notes:
--- - Installation is done in parallel, but waits for all to finish before
--- continuing next code execution.
--- - If plugin is already present on disk, there are no checks about its present state.
--- The specified `version` can be not the one actually present on disk.
--- Execute |vim.pack.update()| to synchronize.
--- - Adding plugin second and more times during single session does nothing:
--- only the data from the first adding is registered.
---
--- @param specs (string|vim.pack.Spec)[] List of plugin specifications. String item
--- is treated as `src`.
--- @param opts? vim.pack.keyset.add
function M.add(specs, opts)
vim.validate('specs', specs, vim.islist, false, 'list')
opts = vim.tbl_extend('force', { load = vim.v.vim_did_enter == 1, confirm = true }, opts or {})
vim.validate('opts', opts, 'table')
--- @type vim.pack.Plug[]
local plugs = vim.tbl_map(new_plug, specs)
plugs = normalize_plugs(plugs)
-- Install
--- @param p vim.pack.Plug
local plugs_to_install = vim.tbl_filter(function(p)
return not p.info.installed
end, plugs)
if #plugs_to_install > 0 then
git_ensure_exec()
install_list(plugs_to_install, opts.confirm)
end
-- Register and load those actually on disk while collecting errors
-- Delay showing all errors to have "good" plugins added first
local errors = {} --- @type string[]
for _, p in ipairs(plugs) do
if p.info.installed then
local ok, err = pcall(pack_add, p, opts.load) --[[@as string]]
if not ok then
p.info.err = err
end
end
if p.info.err ~= '' then
errors[#errors + 1] = ('`%s`:\n%s'):format(p.spec.name, p.info.err)
end
end
if #errors > 0 then
local error_str = table.concat(errors, '\n\n')
error(('vim.pack:\n\n%s'):format(error_str))
end
end
--- @param p vim.pack.Plug
--- @return string
local function compute_feedback_lines_single(p)
if p.info.err ~= '' then
return ('## %s\n\n %s'):format(p.spec.name, p.info.err:gsub('\n', '\n '))
end
local parts = { '## ' .. p.spec.name .. '\n' }
local version_suffix = p.info.version_str == '' and '' or (' (%s)'):format(p.info.version_str)
if p.info.sha_head == p.info.sha_target then
parts[#parts + 1] = table.concat({
'Path: ' .. p.path,
'Source: ' .. p.spec.src,
'State: ' .. p.info.sha_target .. version_suffix,
}, '\n')
if p.info.update_details ~= '' then
local details = p.info.update_details:gsub('\n', '\n')
parts[#parts + 1] = '\n\nAvailable newer versions:\n' .. details
end
else
parts[#parts + 1] = table.concat({
'Path: ' .. p.path,
'Source: ' .. p.spec.src,
'State before: ' .. p.info.sha_head,
'State after: ' .. p.info.sha_target .. version_suffix,
'',
'Pending updates:',
p.info.update_details,
}, '\n')
end
return table.concat(parts, '')
end
--- @param plug_list vim.pack.Plug[]
--- @param skip_same_sha boolean
--- @return string[]
local function compute_feedback_lines(plug_list, skip_same_sha)
-- Construct plugin line groups for better report
local report_err, report_update, report_same = {}, {}, {}
for _, p in ipairs(plug_list) do
--- @type string[]
local group_arr = p.info.err ~= '' and report_err
or (p.info.sha_head ~= p.info.sha_target and report_update or report_same)
group_arr[#group_arr + 1] = compute_feedback_lines_single(p)
end
local lines = {}
--- @param header string
--- @param arr string[]
local function append_report(header, arr)
if #arr == 0 then
return
end
header = header .. ' ' .. string.rep('', 79 - header:len())
table.insert(lines, header)
vim.list_extend(lines, arr)
end
append_report('# Error', report_err)
append_report('# Update', report_update)
if not skip_same_sha then
append_report('# Same', report_same)
end
return vim.split(table.concat(lines, '\n\n'), '\n')
end
--- @param plug_list vim.pack.Plug[]
local function feedback_log(plug_list)
local lines = { ('========== Update %s =========='):format(get_timestamp()) }
vim.list_extend(lines, compute_feedback_lines(plug_list, true))
lines[#lines + 1] = ''
local log_path = vim.fn.stdpath('log') .. '/nvim-pack.log'
vim.fn.mkdir(vim.fs.dirname(log_path), 'p')
vim.fn.writefile(lines, log_path, 'a')
end
--- @param lines string[]
--- @param on_finish fun()
local function show_confirm_buf(lines, on_finish)
-- Show buffer in a separate tabpage
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() } })
local tab_id = api.nvim_get_current_tabpage()
local win_id = api.nvim_get_current_win()
local delete_buffer = vim.schedule_wrap(function()
pcall(api.nvim_buf_delete, bufnr, { force = true })
if api.nvim_tabpage_is_valid(tab_id) then
vim.cmd.tabclose(api.nvim_tabpage_get_number(tab_id))
end
vim.cmd.redraw()
end)
-- Define action on accepting confirm
local function finish()
on_finish()
delete_buffer()
end
-- - Use `nested` to allow other events (useful for statuslines)
api.nvim_create_autocmd('BufWriteCmd', { buffer = bufnr, nested = true, callback = finish })
-- Define action to cancel confirm
--- @type integer
local cancel_au_id
local function on_cancel(data)
if tonumber(data.match) ~= win_id then
return
end
pcall(api.nvim_del_autocmd, cancel_au_id)
delete_buffer()
end
cancel_au_id = api.nvim_create_autocmd('WinClosed', { nested = true, callback = on_cancel })
-- Set buffer-local options last (so that user autocmmands could override)
vim.bo[bufnr].modified = false
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].buftype = 'acwrite'
vim.bo[bufnr].filetype = 'nvim-pack'
-- Attach in-process LSP for more capabilities
vim.lsp.buf_attach_client(bufnr, require('vim.pack._lsp').client_id)
end
--- @class vim.pack.keyset.update
--- @inlinedoc
--- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`.
--- Update plugins
---
--- - Download new changes from source.
--- - Infer update info (current/target state, changelog, etc.).
--- - Depending on `force`:
--- - If `false`, show confirmation buffer. It lists data about all set to
--- update plugins. Pending changes starting with `>` will be applied while
--- the ones starting with `<` will be reverted.
--- It has special in-process LSP server attached to provide more interactive
--- features. Currently supported methods:
--- - 'textDocument/documentSymbol' (`gO` via |lsp-defaults|
--- or |vim.lsp.buf.document_symbol()|) - show structure of the buffer.
--- - 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) -
--- show more information at cursor. Like details of particular pending
--- change or newer tag.
---
--- Execute |:write| to confirm update, execute |:quit| to discard the update.
--- - If `true`, make updates right away.
---
--- Notes:
--- - Every actual update is logged in "nvim-pack.log" file inside "log" |stdpath()|.
---
--- @param names? string[] List of plugin names to update. Must be managed
--- by |vim.pack|, not necessarily already added to current session.
--- Default: names of all plugins added to current session via |vim.pack.add()|.
--- @param opts? vim.pack.keyset.update
function M.update(names, opts)
vim.validate('names', names, vim.islist, true, 'list')
opts = vim.tbl_extend('force', { force = false }, opts or {})
local plug_list = plug_list_from_names(names)
if #plug_list == 0 then
notify('Nothing to update', 'WARN')
return
end
git_ensure_exec()
-- Perform update
local timestamp = get_timestamp()
--- @async
--- @param p vim.pack.Plug
local function do_update(p)
-- Fetch
-- Using '--tags --force' means conflicting tags will be synced with remote
git_cmd(
{ 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' },
p.path
)
-- Compute change info: changelog if any, new tags if nothing to update
infer_update_details(p)
-- Checkout immediately if not need to confirm
if opts.force then
checkout(p, timestamp, true)
end
end
local progress_title = opts.force and 'Updating' or 'Downloading updates'
run_list(plug_list, do_update, progress_title)
if opts.force then
feedback_log(plug_list)
return
end
-- Show report in new buffer in separate tabpage
local lines = compute_feedback_lines(plug_list, false)
show_confirm_buf(lines, function()
-- TODO(echasnovski): Allow to not update all plugins via LSP code actions
--- @param p vim.pack.Plug
local plugs_to_checkout = vim.tbl_filter(function(p)
return p.info.err == '' and p.info.sha_head ~= p.info.sha_target
end, plug_list)
if #plugs_to_checkout == 0 then
notify('Nothing to update', 'WARN')
return
end
local timestamp2 = get_timestamp()
--- @async
--- @param p vim.pack.Plug
local function do_checkout(p)
checkout(p, timestamp2, true)
end
run_list(plugs_to_checkout, do_checkout, 'Applying updates')
feedback_log(plugs_to_checkout)
end)
end
--- Remove plugins from disk
---
--- @param names string[] List of plugin names to remove from disk. Must be managed
--- by |vim.pack|, not necessarily already added to current session.
function M.del(names)
vim.validate('names', names, vim.islist, false, 'list')
local plug_list = plug_list_from_names(names)
if #plug_list == 0 then
notify('Nothing to remove', 'WARN')
return
end
for _, p in ipairs(plug_list) do
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')
trigger_event(p, 'PackChanged', 'delete')
end
end
--- @inlinedoc
--- @class vim.pack.PlugData
--- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with defaults made explicit.
--- @field path string Plugin's path on disk.
--- @field active boolean Whether plugin was added via |vim.pack.add()| to current session.
--- Get data about all plugins managed by |vim.pack|
--- @return vim.pack.PlugData[]
function M.get()
-- Process active plugins in order they were added. Take into account that
-- there might be "holes" after `vim.pack.del()`.
local active = {} --- @type table<integer,vim.pack.Plug?>
for _, p_active in pairs(active_plugins) do
active[p_active.id] = p_active.plug
end
--- @type vim.pack.PlugData[]
local res = {}
for i = 1, n_active_plugins do
if active[i] then
res[#res + 1] = { spec = vim.deepcopy(active[i].spec), path = active[i].path, active = true }
end
end
--- @async
local function do_get()
-- Process not active plugins
local plug_dir = get_plug_dir()
for n, t in vim.fs.dir(plug_dir, { depth = 1 }) do
local path = vim.fs.joinpath(plug_dir, n)
if t == 'directory' and not active_plugins[path] then
local spec = { name = n, src = git_cmd({ 'remote', 'get-url', 'origin' }, path) }
res[#res + 1] = { spec = spec, path = path, active = false }
end
end
-- Make default `version` explicit
for _, p_data in ipairs(res) do
if not p_data.spec.version then
p_data.spec.version = git_get_default_branch(p_data.path)
end
end
end
async.run(do_get):wait()
return res
end
return M