Files
neovim/runtime/lua/vim/pack.lua
Evgeni Chasnovski cef31fde6a feat(pack): ensure order of PackChanged{Pre,} events #40455
Problem: due to totally async install/update/checkout there is no
  guaranteed order of `PackChanged{Pre,}` events across different
  plugins. This might lead to conflicts when callback for some "main"
  plugin relies on features from "dependency" plugin: i.e. callback for
  "main" plugin can trigger before installing/updating "dependency"
  plugin. The installation order can be enforced by separate
  vim.pack.add() calls, but update/checkout can not.

Solution: Trigger events in bulk independently of async execution:
  - `PackChangedPre` before any action for all input plugins in order
    they are supplied. It will also trigger even if an action will fail.
  - `PackChanged` after all actions finished for all sucessfully
    affected plugins in order they are supplied.

  This also comes with a couple of side effect changes:
  - `PackChangedPre kind=delete` is now also triggered even if the
    delete won't be done. This makes it more aligned with `kind=install`
    and `kind=update`.
  - Force update (`:packupdate!`) and "udpate LSP action" now do two
    async steps: download/compute updates and apply them. This also
    results in two progress reports.
    This is mostly a by-product of the implementation (there has to be
    a pre-computation of target revision for all plugins before doing
    `PackChangedPre` in bulk before possibly applying an update), but I
    kind of like it more this way as it is more explicit of what's going
    on. If absolutely not acceptable, there might be some hacks to
    mitigate it at least in code action, but I'd keep it like this.
2026-06-27 14:35:19 -04:00

1589 lines
55 KiB
Lua

--- @brief
---
--- Install, update, and delete external plugins. WARNING: It is still considered
--- experimental, yet should be stable enough for daily use.
---
---Manages plugins only in a dedicated [vim.pack-directory]() (see |packages|):
---`site/pack/core/opt` subdirectory of "data" |standard-path|. Subdirectory `site` of "data"
---standard path needs to be part of 'packpath'. It usually is, but might not be
---in cases like |--clean| or setting |$XDG_DATA_HOME| during startup.
---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.
---Target plugins should be Git repositories with versions as named tags
---following semver convention `v<major>.<minor>.<patch>` (with or without `v` prefix).
---Like `v1.2.0` or `1.2.0`, but not `1.2` or `v1`.
---
---The latest state of all managed plugins is stored inside a [vim.pack-lockfile]()
---located at `$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json`. It is a JSON file that
---is used to persistently track data about plugins.
---For a more robust config treat lockfile like its part: put under version control, etc.
---In this case all plugins from the lockfile will be installed at once (in alphabetical order) and
---at lockfile's revision (instead of inferring from `version`). This is done on the very first
---`vim.pack` function call to ensure that lockfile is aligned with what is actually on the disk.
---Lockfile should not be edited by hand. Corrupted data for installed plugins is repaired
---(including after deleting whole file), but `version` fields will be missing
---for not yet added plugins.
---
---[vim.pack-examples]()
---
---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 after `add()` call. Their revision is
---taken from |vim.pack-lockfile| (if present) or inferred from the `version`.
---
---- To update all plugins with new changes:
--- - Execute |:packupdate|. 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|.
--- - (Optionally) |:restart| to start using code from updated plugins.
---
---Use shorter source ~
---
--- Create custom Lua helpers:
---
---```lua
---
---local gh = function(x) return 'https://github.com/' .. x end
---local cb = function(x) return 'https://codeberg.org/' .. x end
---vim.pack.add({ gh('user/plugin1'), cb('user/plugin2') })
---```
---
---Another approach is to utilize Git's `insteadOf` configuration:
---- `git config --global url."https://github.com/".insteadOf "gh:"`
---- `git config --global url."https://codeberg.org/".insteadOf "cb:"`
---- In 'init.lua': `vim.pack.add({ 'gh:user/plugin1', 'cb:user/plugin2' })`.
--- These sources will be used verbatim in |vim.pack-lockfile|, so reusing
--- the config on different machine will require the same Git configuration.
---
---Explore installed plugins ~
---
---- `:packupdate ++offline`
---- Navigate between plugins with `[[` and `]]`. List them with `gO`
--- (|vim.lsp.buf.document_symbol()|).
---
---Switch plugin's version and/or source ~
---
---- Update 'init.lua' for plugin to have desired `version` and/or `src`.
--- Let's say, the switch is for plugin named 'plugin1'.
---- |:restart|. The plugin's state on disk (revision and/or tracked source)
--- is not yet changed. Only plugin's `version` in |vim.pack-lockfile| is updated.
---- Execute `:packupdate plugin1`. The plugin's source is updated. If only
--- switching version, also pass the `++offline` argument.
---- Review changes and either confirm or discard them. If discarded, revert
--- `version` change in 'init.lua' as well or you will be prompted again next time
--- you run `:packupdate`.
---
---Freeze plugin from being updated ~
---
---- Update 'init.lua' for plugin to have `version` set to current revision.
---Get it from |vim.pack-lockfile| (plugin's field `rev`; 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|.
---
---Revert plugin after an update ~
---
---- Revert the |vim.pack-lockfile| to the state before the update:
--- - If Git tracked: `git checkout HEAD -- nvim-pack-lock.json`
--- - If not tracked: examine log file ("nvim-pack.log" at "log" |standard-path|),
--- locate the revisions before the latest update, and (carefully) adjust
--- current lockfile to have those revisions.
---- |:restart|.
---- `:packupdate ++offline ++lockfile plugin`.
--- Read and confirm.
---
---Synchronize config across machines ~
---
---- On main machine:
--- - Add |vim.pack-lockfile| to VCS.
--- - Push to the remote server.
---- On secondary machine:
--- - Pull from the server.
--- - |:restart|. New plugins (not present locally, but present in the lockfile)
--- are installed at proper revision. If some installation has failed but
--- you know it should not (like due to bad Internet connection),
--- revert |vim.pack-lockfile| and |:restart| again.
--- - `:packupdate ++lockfile`. Read and confirm.
--- - Manually delete outdated plugins (present locally, but were not present
--- in the lockfile prior to restart) with `:packdel plugin`.
--- They can be located by examining the VCS difference of the lockfile
--- (`git diff -- nvim-pack-lock.json` for Git).
---
---Remove plugins from disk ~
---
---- Remove plugin specs from |vim.pack.add()| calls in 'init.lua' or they will be
--- reinstalled later.
---- |:restart|.
---- Use |:packdel| with plugin names to remove. Use `:packdel ++all` to delete
--- all inactive plugins.
---
---Check for pending updates ~
---
---- Run `vim.pack.get(nil, { offline = false })` and check the output for items
--- with different `rev` and `rev_to` fields. To not download new updates
--- from source, use plain `vim.pack.get()`.
---
--- <pre>help
--- Commands *vim.pack-commands* *E5807*
---
--- *:packu* *:packupdate* *E5808*
--- :packu[pdate][!] [++offline] [++lockfile] [name]
---
--- Interactively update the specified plugins. Skips confirmation when `!` is
--- given. If no plugin names are provided, update all plugins.
---
--- When `++offline` is given, skip downloading new updates.
---
--- When `++lockfile` is given, use revisions from the lockfile.
---
--- *:packd* *:packdel* *E5809* *E5810* *E5811*
--- :packd[el][!] {name}
---
--- Remove the specified plugins. Can only remove inactive plugins unless `!`
--- is given.
---
--- :packd[el][!] ++all
---
--- Remove all inactive plugins. When `!` is given, instead remove all plugins.
--- </pre>
---
---[vim.pack-events]()
---
---Performing actions via `vim.pack` functions can trigger these events:
---- [PackChangedPre]() - before trying to change plugin's state.
---- [PackChanged]() - after plugin's state has changed.
---
---Events are triggered in bulk respecting order of plugins in which they are supplied.
---First all `PackChangedPre`, then perform all actions, and only after - all
---`PackChanged` for successful actions. This provides more control for connected
---plugins, like by specifying dependencies before the plugin itself.
---
---The |event-data| has these keys (type: `vim.event.packchanged.data`):
---- `active` - whether plugin was added via |vim.pack.add()| to current session.
---- `kind` - one of "install" (install on disk; before loading),
--- "update" (update already installed plugin; might be not loaded),
--- "delete" (delete from disk).
---- `spec` - plugin's specification with defaults made explicit.
---- `path` - full path to plugin's directory.
---
--- These events can be used to execute plugin hooks. For example:
---```lua
---local hooks = function(ev)
--- -- Use available |event-data|
--- local name, kind = ev.data.spec.name, ev.data.kind
---
--- -- Run build script after plugin's code has changed
--- if name == 'plug-1' and (kind == 'install' or kind == 'update') then
--- -- Append `:wait()` if you need synchronous execution
--- vim.system({ 'make' }, { cwd = ev.data.path })
--- end
---
--- -- If action relies on code from the plugin (like user command or
--- -- Lua code), make sure to explicitly load it first
--- if name == 'plug-2' and kind == 'update' then
--- if not ev.data.active then
--- vim.cmd.packadd('plug-2')
--- end
--- vim.cmd('PlugTwoUpdate')
--- require('plug2').after_update()
--- end
---end
---
----- If hooks need to run on install, run this before `vim.pack.add()`
----- To act on install from lockfile, run before very first `vim.pack.add()`
---vim.api.nvim_create_autocmd('PackChanged', { callback = hooks })
---```
local api = vim.api
local uv = vim.uv
local async = require('vim._async')
local util = require('vim._core.util')
local nvim_on = util.nvim_on
local N_ = vim.fn.gettext
local M = {}
--- @class (private) vim.pack.LockData
--- @field rev string Latest recorded revision.
--- @field src string Plugin source.
--- @field version? string|vim.VersionRange Plugin `version`, as supplied in `spec`.
--- @class (private) vim.pack.Lock
--- @field plugins table<string, vim.pack.LockData> Map from plugin name to its lock data.
--- @type vim.pack.Lock
local plugin_lock
--- @return string
local function get_plug_dir()
return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt')
end
local function lock_get_path()
return vim.fs.joinpath(vim.fn.stdpath('config'), 'nvim-pack-lock.json')
end
-- 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 env = vim.fn.environ() --- @type table<string,string>
env.GIT_DIR, env.GIT_WORK_TREE = nil, nil
local sys_opts = { cwd = cwd, text = true, env = env, 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 parse_semver(x)
return vim.version.parse(x, { strict = true })
end
--- @type vim.Version
local git_version
local function git_ensure_exec()
local ok, sys = pcall(vim.system, { 'git', 'version' })
if not ok then
error('No `git` executable')
end
git_version = vim.version.parse(sys:wait().stdout) --[[@as vim.Version]]
end
--- @async
--- @param url string
--- @param path string
local function git_clone(url, path)
local cmd = { 'clone', '--quiet', '--no-checkout' }
if vim.startswith(url, 'file://') then
cmd[#cmd + 1] = '--no-hardlinks'
elseif git_version >= parse_semver('2.27.0') then
cmd[#cmd + 1] = '--filter=blob:none'
end
vim.list_extend(cmd, { '--origin', 'origin', url, path })
git_cmd(cmd, uv.cwd())
end
--- @async
--- @param ref string
--- @param cwd string
--- @return string
local function git_get_hash(ref, cwd)
-- Using `rev-list -1` shows a commit of reference, while `rev-parse` shows
-- hash of reference. Those are different for annotated tags.
return git_cmd({ 'rev-list', '-1', ref }, cwd)
end
--- @async
--- @param cwd string
local function git_fetch(cwd)
-- Using '--tags --force' means conflicting tags will be synced with remote
local args = { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' }
git_cmd(args, 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 def_branch = git_get_default_branch(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
local branch = l:match('^origin/(.+)$')
local pos = branch == def_branch and 1 or (#res + 1)
table.insert(res, pos, branch)
end
return res
end
--- @async
--- @param cwd string
--- @return string[]
local function git_get_tags(cwd)
local tags = git_cmd({ 'tag', '--list', '--sort=-v:refname' }, cwd)
return tags == '' and {} or vim.split(tags, '\n')
end
-- Plugin operations ----------------------------------------------------------
--- @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 parse_semver(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
---
--- @field data? any Arbitrary data associated with a plugin.
--- @alias vim.pack.SpecResolved { src: string, name: string, version: nil|string|vim.VersionRange, data: any|nil }
--- @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, data = spec.data }
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 updated? boolean Whether plugin was successfully updated.
--- @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
--- @param plug_dir string?
--- @return vim.pack.Plug
local function new_plug(spec, plug_dir)
local spec_resolved = normalize_spec(spec)
local path = vim.fs.joinpath(plug_dir or get_plug_dir(), spec_resolved.name)
local info = { err = '', installed = plugin_lock.plugins[spec_resolved.name] ~= 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.nonnil(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 p_data_list = M.get(names, { info = false })
local plug_dir = get_plug_dir()
local plugs = {} --- @type vim.pack.Plug[]
for _, p_data in ipairs(p_data_list) do
plugs[#plugs + 1] = new_plug(p_data.spec, plug_dir)
end
return plugs
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 plugs vim.pack.Plug[]
--- @param event_name 'PackChangedPre'|'PackChanged'
--- @param kind 'install'|'update'|'delete'
local function trigger_events(plugs, event_name, kind)
for _, p in ipairs(plugs) do
local active = active_plugins[p.path] ~= nil
local data = { active = active, kind = kind, spec = vim.deepcopy(p.spec), path = p.path }
api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data })
end
end
--- @param action string
--- @return fun(kind: 'begin'|'report'|'end', percent: integer, fmt: string, ...:any): nil
local function new_progress_report(action)
local progress = { kind = 'progress', source = 'vim.pack', title = 'vim.pack' }
local headless = #api.nvim_list_uis() == 0
return vim.schedule_wrap(function(kind, percent, fmt, ...)
progress.status = kind == 'end' and 'success' or 'running'
progress.percent = percent
local msg = ('%s %s'):format(action, fmt:format(...))
progress.id = api.nvim_echo({ { msg } }, kind ~= 'report', progress)
-- Force redraw to show installation progress during startup
-- TODO: redraw! not needed with ui2.
if not headless then
vim.cmd.redraw({ bang = true })
end
end)
end
local copcall = package.loaded.jit and pcall or require('coxpcall').pcall
local function async_join_run_wait(funs)
local n_threads = 2 * (uv.available_parallelism() or 1)
--- @async
local function joined_f()
async.join(n_threads, funs)
end
async.run(joined_f):wait()
end
--- 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_action string
local function run_list(plug_list, f, progress_action)
local report_progress = new_progress_report(progress_action)
-- 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_join_run_wait(funs)
report_progress('end', 100, '(%d/%d)', #funs, #funs)
end
local confirm_all = false
--- @param plug_list vim.pack.Plug[]
--- @return boolean
local function confirm_install(plug_list)
if confirm_all then
return true
end
-- Gather pretty aligned list of plugins to install
local name_width, name_max_width = {}, 0 --- @type integer[], integer
for i, p in ipairs(plug_list) do
name_width[i] = api.nvim_strwidth(p.spec.name)
name_max_width = math.max(name_max_width, name_width[i])
end
local lines = {} --- @type string[]
for i, p in ipairs(plug_list) do
local pad = (' '):rep(name_max_width - name_width[i] + 1)
lines[i] = ('%s%sfrom %s'):format(p.spec.name, pad, p.spec.src)
end
local text = table.concat(lines, '\n')
local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(text)
local choice = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No\n&Always', 1, 'Question')
confirm_all = choice == 3
vim.cmd.redraw()
return choice ~= 2
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 = parse_semver(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 ('\n%s: %s'):format(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_revisions(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_stash? boolean
local function checkout(p, timestamp, skip_stash)
infer_revisions(p)
if not skip_stash then
local stash_cmd = { 'stash' }
if git_version > parse_semver('2.13.0') then
-- Use 'push' to avoid a 'stash -m' bug in versions prior to git v2.26
stash_cmd[#stash_cmd + 1] = 'push'
stash_cmd[#stash_cmd + 1] = '--message'
stash_cmd[#stash_cmd + 1] = ('vim.pack: %s Stash before checkout'):format(timestamp)
end
stash_cmd[#stash_cmd + 1] = '--quiet'
git_cmd(stash_cmd, p.path)
end
git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path)
local submodule_cmd = { 'submodule', 'update', '--init', '--recursive' }
if git_version >= parse_semver('2.36.0') then
submodule_cmd[#submodule_cmd + 1] = '--filter=blob:none'
end
git_cmd(submodule_cmd, p.path)
plugin_lock.plugins[p.spec.name].rev = p.info.sha_target
-- (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)
local timestamp = get_timestamp()
--- @async
--- @param p vim.pack.Plug
local function do_install(p)
git_clone(p.spec.src, p.path)
plugin_lock.plugins[p.spec.name].src = p.spec.src
-- Prefer revision from the lockfile instead of using `version`
p.info.sha_target = (plugin_lock.plugins[p.spec.name] or {}).rev
checkout(p, timestamp, true)
p.info.installed = true
end
-- Install possibly after user confirmation
if not confirm or confirm_install(plug_list) then
trigger_events(plug_list, 'PackChangedPre', 'install')
run_list(plug_list, do_install, 'Installing plugins')
local installed = vim.tbl_filter(function(p) --- @param p vim.pack.Plug
return p.info.installed
end, plug_list)
trigger_events(installed, 'PackChanged', 'install')
end
-- Ensure that not fully installed plugins are absent on disk and in lockfile
for _, p in ipairs(plug_list) do
if not (p.info.installed and uv.fs_stat(p.path) ~= nil) then
plugin_lock.plugins[p.spec.name] = nil
vim.fs.rm(p.path, { recursive = true, force = true })
end
end
end
--- @async
--- @param p vim.pack.Plug
local function infer_update_details(p)
p.info.update_details = ''
infer_revisions(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 = ''
if git_version >= parse_semver('2.13.0') then
older_tags = git_cmd({ 'tag', '--list', '--no-contains', sha_head }, p.path)
end
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
--- @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 } })
-- The `:packadd` only sources plain 'plugin/' files. Execute 'after/' scripts
-- if not during startup (when they will be sourced later, even if
-- `vim.pack.add` is inside user's 'plugin/')
-- 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
local function lock_write()
-- Serialize `version`
local lock = vim.deepcopy(plugin_lock)
for _, l_data in pairs(lock.plugins) do
local version = l_data.version
if version then
l_data.version = type(version) == 'string' and ("'%s'"):format(version) or tostring(version)
end
end
local path = lock_get_path()
vim.fn.mkdir(vim.fs.dirname(path), 'p')
local fd = assert(uv.fs_open(path, 'w', 438))
local data = vim.json.encode(lock, { indent = ' ', sort_keys = true })
assert(uv.fs_write(fd, data .. '\n'))
assert(uv.fs_close(fd))
end
--- @param names string[]
local function lock_repair(names, plug_dir)
--- @async
local function f()
for _, name in ipairs(names) do
local path = vim.fs.joinpath(plug_dir, name)
-- Try reusing existing table to preserve maybe present `version`
local data = plugin_lock.plugins[name] or {}
data.rev = git_get_hash('HEAD', path)
data.src = git_cmd({ 'remote', 'get-url', 'origin' }, path)
plugin_lock.plugins[name] = data
end
end
async.run(f):wait()
end
--- Sync lockfile data and installed plugins:
--- - Install plugins that have proper lockfile data but are not on disk.
--- - Repair corrupted lock data for installed plugins.
--- - Remove unrepairable corrupted lock data and plugins.
--- @param confirm boolean
--- @param specs vim.pack.Spec[] Plugin specs provided by the user. Can contain
--- fields outside of what is in the lockfile to be passed down to events.
local function lock_sync(confirm, specs)
if type(plugin_lock.plugins) ~= 'table' then
plugin_lock.plugins = {}
end
-- Compute installed plugins
local plug_dir = get_plug_dir()
if vim.uv.fs_stat(plug_dir) == nil then
vim.fn.mkdir(plug_dir, 'p')
end
-- NOTE: The directory traversal is done on every startup, but it is very fast.
-- Also, single `vim.fs.dir()` scales better than on demand `uv.fs_stat()` checks.
local installed = {} --- @type table<string,string>
for name, fs_type in vim.fs.dir(plug_dir) do
installed[name] = fs_type
plugin_lock.plugins[name] = plugin_lock.plugins[name] or {}
end
-- Traverse once optimizing for "regular startup" (no repair, no install)
local to_install = {} --- @type vim.pack.Plug[]
local to_repair = {} --- @type string[]
local to_remove = {} --- @type string[]
for name, data in pairs(plugin_lock.plugins) do
if type(data) ~= 'table' then
data = {} ---@diagnostic disable-line: missing-fields
plugin_lock.plugins[name] = data
end
-- Deserialize `version`
local version = data.version
if type(version) == 'string' then
data.version = version:match("^'(.+)'$") or vim.version.range(version)
end
-- Synchronize
local is_bad_lock = type(data.rev) ~= 'string' or type(data.src) ~= 'string'
local is_bad_plugin = installed[name] and installed[name] ~= 'directory'
if is_bad_lock or is_bad_plugin then
local t = installed[name] == 'directory' and to_repair or to_remove
t[#t + 1] = name
elseif not installed[name] then
local spec ---@type vim.pack.Spec
-- Try reusing spec from user's `vim.pack.add()` (matters for events)
-- Delay until this point when shaving milliseconds shouldn't matter much
for _, s in ipairs(specs) do
local ok, s_norm = pcall(normalize_spec, s)
if ok and s_norm.name == name then
spec = vim.deepcopy(s_norm)
end
end
-- Force fields relevant to actual installation, try to preserve others
spec = spec or {}
spec.src = data.src
spec.name = name
spec.version = spec.version or data.version
to_install[#to_install + 1] = new_plug(spec, plug_dir)
end
end
-- Perform actions if needed
if #to_install > 0 then
table.sort(to_install, function(a, b)
return a.spec.name < b.spec.name
end)
git_ensure_exec()
install_list(to_install, confirm)
lock_write()
end
if #to_repair > 0 then
lock_repair(to_repair, plug_dir)
table.sort(to_repair)
notify('Repaired corrupted lock data for plugins: ' .. table.concat(to_repair, ', '), 'WARN')
lock_write()
end
if #to_remove > 0 then
for _, name in ipairs(to_remove) do
plugin_lock.plugins[name] = nil
vim.fs.rm(vim.fs.joinpath(plug_dir, name), { recursive = true, force = true })
end
table.sort(to_remove)
notify('Removed corrupted lock data for plugins: ' .. table.concat(to_remove, ', '), 'WARN')
lock_write()
end
end
local function lock_read(confirm, specs)
if plugin_lock then
return
end
local fd = uv.fs_open(lock_get_path(), 'r', 438)
if fd then
local stat = assert(uv.fs_fstat(fd))
local data = assert(uv.fs_read(fd, stat.size, 0))
assert(uv.fs_close(fd))
plugin_lock = vim.json.decode(data)
else
plugin_lock = { plugins = {} }
end
lock_sync(vim.nonnil(confirm, true), vim.nonnil(specs, {}))
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 |init.lua| sourcing 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, check if its `src` is the same as input. If not - delete
--- immediately to clean install from the new source. Otherwise do nothing.
--- - If doesn't exist, install it by downloading from `src` into `name`
--- subdirectory (via partial blobless `git clone`) and update revision
--- to match `version` (via `git checkout`). Plugin will not be on disk if
--- any step resulted in an error.
--- - 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 current revision.
--- 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_init == 1, confirm = true }, opts or {})
vim.validate('opts', opts, 'table')
lock_read(opts.confirm, specs)
local plug_dir = get_plug_dir()
local plugs = {} --- @type vim.pack.Plug[]
for i = 1, #specs do
plugs[i] = new_plug(specs[i], plug_dir)
end
plugs = normalize_plugs(plugs)
-- Pre-process
local plugs_to_install = {} --- @type vim.pack.Plug[]
local needs_lock_write = false
for _, p in ipairs(plugs) do
-- Detect `version` change
local p_lock = plugin_lock.plugins[p.spec.name] or {}
needs_lock_write = needs_lock_write or p_lock.version ~= p.spec.version
p_lock.version = p.spec.version
plugin_lock.plugins[p.spec.name] = p_lock
-- Register for install
if not p.info.installed then
plugs_to_install[#plugs_to_install + 1] = p
needs_lock_write = true
end
end
-- Install
if #plugs_to_install > 0 then
git_ensure_exec()
install_list(plugs_to_install, opts.confirm)
end
if needs_lock_write then
lock_write()
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)
local active_suffix = active_plugins[p.path] ~= nil and '' or ' (not active)'
if p.info.err ~= '' then
return ('## %s%s\n\n %s'):format(p.spec.name, active_suffix, p.info.err:gsub('\n', '\n '))
end
local parts = { ('## %s%s\n'):format(p.spec.name, active_suffix) }
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,
'Revision: ' .. 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,
'Revision before: ' .. p.info.sha_head,
'Revision 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(bufnr: integer)
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://confirm#' .. bufnr)
api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr() } })
local win_id = api.nvim_get_current_win()
local delete_buffer = vim.schedule_wrap(function()
pcall(api.nvim_win_close, win_id, true)
pcall(api.nvim_buf_delete, bufnr, { force = true })
vim.cmd.redraw()
end)
-- Define action on accepting confirm
local function finish()
on_finish(bufnr)
delete_buffer()
end
-- - Use `nested` to allow other events (useful for statuslines)
nvim_on('BufWriteCmd', nil, { buf = bufnr, nested = true }, finish)
-- Define action to cancel confirm
--- @type integer
local cancel_au_id
local function on_cancel(data)
if vim._tointeger(data.match) ~= win_id then
return
end
pcall(api.nvim_del_autocmd, cancel_au_id)
delete_buffer()
end
cancel_au_id = nvim_on('WinClosed', nil, { nested = true }, 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
--- Get map of plugin names that need update based on confirmation buffer
--- content: all plugin sections present in "# Update" section.
--- @param bufnr integer
--- @return table<string,boolean>
local function get_update_map(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
--- @type table<string,boolean>, boolean
local res, is_in_update = {}, false
for _, l in ipairs(lines) do
local name = l:match('^## (.+)$')
if name and is_in_update then
res[name:gsub(' %(not active%)$', '')] = true
end
local group = l:match('^# (%S+)')
if group then
is_in_update = group == 'Update'
end
end
return res
end
--- Checkout plugins to the update target
--- @param plug_list vim.pack.Plug[]
local function update_list(plug_list)
trigger_events(plug_list, 'PackChangedPre', 'update')
local timestamp = get_timestamp()
--- @async
--- @param p vim.pack.Plug
local function do_update(p)
checkout(p, timestamp)
p.info.updated = true
end
run_list(plug_list, do_update, 'Applying updates')
local updated = vim.tbl_filter(function(p) --- @param p vim.pack.Plug
return p.info.updated
end, plug_list)
trigger_events(updated, 'PackChanged', 'update')
end
--- @class vim.pack.keyset.update
--- @inlinedoc
--- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`.
---
--- @field offline? boolean Whether to skip downloading new updates. Default: `false`.
---
--- How to compute a new plugin revision. One of:
--- - "version" (default): use latest revision matching `version` from plugin specification.
--- - "lockfile": use revision from the lockfile. For reverting or performing controlled update.
--- @field target? string
--- Update plugins
---
--- - Download new changes from source.
--- - Infer update info (current/target revisions, changelog, etc.).
--- - If `force` is `false` (default), show confirmation buffer.
--- If `force` is `true`, make updates right away.
---
--- Notes:
--- - Every actual update is logged in "nvim-pack.log" file inside "log" |standard-path|.
--- - It doesn't update source's default branch if it has changed (like from `master` to `main`).
--- To have `version = nil` point to a new default branch, re-install the plugin
--- (|vim.pack.del()| + |vim.pack.add()|).
---
--- Confirmation buffer ~
---
--- The goal of the confirmation buffer is to show update details for the user to read, confirm
--- (execute |:write|) or deny (execute |:quit|) the update.
---
--- Pending changes starting with `>` will be applied while the ones starting with `<` will be
--- reverted.
---
--- There are convenience buffer-local mappings:
--- - |]]| and |[[| to navigate through plugin sections.
---
--- Some features are provided via LSP:
--- - 'textDocument/documentLink' - compute links for plugin paths, sources,
--- commits, and tags. Makes a best effort educated guess about a link structure.
--- Use |gx| to open a link to an object at cursor.
--- - '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.
--- - 'textDocument/codeAction' (`gra` via |lsp-defaults| or |vim.lsp.buf.code_action()|) - show
--- code actions relevant for "plugin at cursor". Like "delete" (after extra confirmation for
--- active plugins), "update" or "skip updating" (if there are pending updates).
---
--- @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 managed by |vim.pack|.
--- @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, offline = false, target = 'version' }, opts or {})
local plug_list = plug_list_from_names(names)
if #plug_list == 0 then
if opts._ex then
util.echo_err(N_('E5808: Nothing to update'))
end
return
end
git_ensure_exec()
lock_read()
-- Infer update details
local needs_lock_write = opts.force --- @type boolean
--- @async
--- @param p vim.pack.Plug
local function infer_details(p)
local l_data = plugin_lock.plugins[p.spec.name]
-- Ensure proper `origin` if needed
if l_data.src ~= p.spec.src then
git_cmd({ 'remote', 'set-url', 'origin', p.spec.src }, p.path)
plugin_lock.plugins[p.spec.name].src = p.spec.src
needs_lock_write = true
end
-- Fetch
if not opts.offline then
git_fetch(p.path)
end
-- Compute change info: changelog if any, new tags if nothing to update
if opts.target == 'lockfile' then
p.info.version_str = '*lockfile*'
p.info.sha_target = l_data.rev
end
infer_update_details(p)
end
local infer_title = opts.offline and 'Computing updates' or 'Downloading updates'
run_list(plug_list, infer_details, infer_title)
-- Update and show report
if opts.force then
local plugs_to_update = vim.tbl_filter(function(p) --- @param p vim.pack.Plug
return p.info.sha_head ~= p.info.sha_target
end, plug_list)
update_list(plugs_to_update)
end
if needs_lock_write then
lock_write()
end
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(bufnr)
local to_update = get_update_map(bufnr)
if not next(to_update) then
notify('Nothing to update', 'WARN')
return
end
--- @param p vim.pack.Plug
local plugs_to_update = vim.tbl_filter(function(p)
return to_update[p.spec.name]
end, plug_list)
update_list(plugs_to_update)
lock_write()
feedback_log(plugs_to_update)
end)
end
--- @class vim.pack.keyset.del
--- @inlinedoc
--- @field force? boolean Whether to allow deleting an active plugin. Default `false`.
--- 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.
--- @param opts? vim.pack.keyset.del
function M.del(names, opts)
vim.validate('names', names, vim.islist, false, 'list')
opts = vim.tbl_extend('force', { force = false }, opts or {})
local plug_list = plug_list_from_names(names)
if #plug_list == 0 then
if opts._ex then
util.echo_err(N_('E5809: Nothing to remove'))
end
return
end
lock_read()
trigger_events(plug_list, 'PackChangedPre', 'delete')
local deleted = {} --- @type vim.pack.Plug[]
local deleted_names = {} --- @type string[]
local not_deleted_names = {} --- @type string[]
for _, p in ipairs(plug_list) do
if not active_plugins[p.path] or opts.force then
vim.fs.rm(p.path, { recursive = true, force = true })
active_plugins[p.path] = nil
plugin_lock.plugins[p.spec.name] = nil
deleted[#deleted + 1] = p
deleted_names[#deleted_names + 1] = p.spec.name
else
not_deleted_names[#not_deleted_names + 1] = p.spec.name
end
end
trigger_events(deleted, 'PackChanged', 'delete')
lock_write()
if #deleted_names > 0 then
local suffix = #deleted_names == 1 and '' or 's'
local plugs = table.concat(deleted_names, ', ')
notify(('Removed plugin%s: %s'):format(suffix, plugs), 'INFO')
end
if #not_deleted_names > 0 then
local plugs = table.concat(not_deleted_names, ', ')
if opts._ex then
util.echo_err(N_('E5810: Some plugins are active and were not deleted: %s'):format(plugs))
return
else
local msg = ('Some plugins are active and were not deleted: %s.'):format(plugs)
.. ' Remove them from init.lua, restart, and try again.'
error(msg)
end
end
end
--- @inlinedoc
--- @class vim.pack.PlugData
--- @field active boolean Whether plugin was added via |vim.pack.add()| to current session.
--- @field branches? string[] Available Git branches (first is default). Missing if `info=false`.
--- @field path string Plugin's path on disk.
--- @field rev string Current Git revision. Taken from |vim.pack-lockfile| if `info=false`.
--- Git revision of a pending update. The same as used during |vim.pack.update()| and which
--- points to a resolved `spec.version`. Missing if `info=false`.
--- @field rev_to? string
--- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with resolved `name`.
--- @field tags? string[] Available Git tags. Missing if `info=false`.
--- @class vim.pack.keyset.get
--- @inlinedoc
--- @field info? boolean Whether to include extra plugin info. Default `true`.
--- Whether to skip downloading new updates. Requires `info=true`. Default: `true`.
--- @field offline? boolean
--- @param p_data_list vim.pack.PlugData[]
--- @param offline boolean
local function add_p_data_info(p_data_list, offline)
local funs = {} --- @type (async fun())[]
local plug_dir = get_plug_dir()
for i, p_data in ipairs(p_data_list) do
local plug = new_plug(p_data.spec, plug_dir)
local path = p_data.path
--- @async
funs[i] = function()
p_data.branches = git_get_branches(path)
p_data.tags = git_get_tags(path)
if not offline then
git_fetch(path)
end
infer_revisions(plug)
p_data.rev = plug.info.sha_head
p_data.rev_to = plug.info.sha_target
end
end
async_join_run_wait(funs)
end
--- Gets |vim.pack| plugin info, optionally filtered by `names`.
--- @param names? string[] List of plugin names. Default: all plugins managed by |vim.pack|.
--- @param opts? vim.pack.keyset.get
--- @return vim.pack.PlugData[]
function M.get(names, opts)
vim.validate('names', names, vim.islist, true, 'list')
opts = vim.tbl_extend('force', { info = true, offline = true }, opts or {})
-- 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
lock_read()
local res = {} --- @type vim.pack.PlugData[]
local used_names = {} --- @type table<string,boolean>
for i = 1, n_active_plugins do
if active[i] and (not names or vim.tbl_contains(names, active[i].spec.name)) then
local name = active[i].spec.name
local spec = vim.deepcopy(active[i].spec)
local rev = (plugin_lock.plugins[name] or {}).rev
res[#res + 1] = { spec = spec, path = active[i].path, rev = rev, active = true }
used_names[name] = true
end
end
local plug_dir = get_plug_dir()
for name, l_data in vim.spairs(plugin_lock.plugins) do
local path = vim.fs.joinpath(plug_dir, name)
local is_in_names = not names or vim.tbl_contains(names, name)
if not active_plugins[path] and is_in_names then
local spec = { name = name, src = l_data.src, version = l_data.version }
res[#res + 1] = { spec = spec, path = path, rev = l_data.rev, active = false }
used_names[name] = true
end
end
if names ~= nil then
-- Align result with input
local names_order = {} --- @type table<string,integer>
for i, n in ipairs(names) do
if not used_names[n] then
error(('Plugin `%s` is not installed'):format(tostring(n)))
end
names_order[n] = i
end
table.sort(res, function(a, b)
return names_order[a.spec.name] < names_order[b.spec.name]
end)
end
if opts.info then
git_ensure_exec()
add_p_data_info(res, opts.offline)
end
return res
end
--- @param skip_inactive? boolean
--- @return string[] plugin_names
function M._get_names(skip_inactive)
local names = {} --- @type string[]
for _, plugin_data in ipairs(vim.pack.get(nil, { info = false })) do
if not (skip_inactive and plugin_data.active) then
names[#names + 1] = plugin_data.spec.name
end
end
return names
end
return M