Files
neovim/runtime/lua/vim/pack.lua
Evgeni Chasnovski d21b8c949a feat(pack): add built-in plugin manager vim.pack
Problem: No built-in plugin manager

Solution: Add built-in plugin manager

Co-authored-by: Lewis Russell <lewis6991@gmail.com>
2025-07-04 15:56:28 +03:00

1001 lines
34 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.
--- - `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
--- @param opts? { contains?: string, points_at?: string }
--- @return string[]
local function git_get_tags(cwd, opts)
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
-- 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 (pcall(x.has, x, '1'))
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, 'string')
local name = (spec.name or spec.src:gsub('%.git$', '')):match('[^/]+$')
vim.validate('spec.name', name, '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[]
-- 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)
end
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 data = { kind = kind, spec = vim.deepcopy(p.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)
print(('(vim.pack) %s: %s %s'):format(progress, title, fmt:format(...)))
-- Force redraw to show installation progress during startup
vim.cmd.redraw({ bang = true })
end)
end
local n_threads = 2 * #(uv.cpu_info() or { {} })
--- 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 = pcall(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
--- @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 = pcall(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
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
if p.info.version_str == nil then
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'))
pcall(vim.cmd.helptags, vim.fn.fnameescape(doc_dir))
end
--- @param plug_list vim.pack.Plug[]
local function install_list(plug_list)
-- Get user confirmation to install plugins
if 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)
infer_states(p)
local sha_head = assert(p.info.sha_head)
local sha_target = assert(p.info.sha_target)
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
return
end
-- Remove tags pointing at target (there might be several)
local cur_tags = git_get_tags(p.path, { points_at = sha_target })
local new_tags_arr = vim.split(p.info.update_details, '\n')
local function is_not_cur_tag(s)
return not vim.tbl_contains(cur_tags, s)
end
p.info.update_details = table.concat(vim.tbl_filter(is_not_cur_tag, new_tags_arr), '\n')
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
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 }
vim.cmd.packadd({ plug.spec.name, bang = not load })
-- 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
local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true)
--- @param path string
vim.tbl_map(function(path)
pcall(vim.cmd.source, vim.fn.fnameescape(path))
end, after_paths)
end
end
--- @class vim.pack.keyset.add
--- @inlinedoc
--- @field load? boolean Load `plugin/` files and `ftdetect/` scripts. If `false`, works like `:packadd!`. Default `true`.
--- Add plugin to current session
---
--- - For each specification check that plugin exists on disk in |vim.pack-directory|:
--- - If exists, do nothin 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| making them 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 = 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)
end
-- Register and `:packadd` those actually on disk
for _, p in ipairs(plugs) do
if p.info.installed then
pack_add(p, opts.load)
end
end
-- Delay showing all errors to have "good" plugins added first
local errors = {} --- @type string[]
for _, p in ipairs(plugs_to_install) do
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(('Errors during installation:\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 tags:\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_num = api.nvim_tabpage_get_number(0)
local win_id = api.nvim_get_current_win()
local delete_buffer = vim.schedule_wrap(function()
pcall(api.nvim_buf_delete, bufnr, { force = true })
pcall(vim.cmd.tabclose, tab_num)
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)
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(
{ '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
if not p.info.installed then
notify(("Plugin '%s' is not installed"):format(p.spec.name), 'WARN')
else
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
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