mirror of
https://github.com/neovim/neovim.git
synced 2025-09-06 11:28:22 +00:00

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.
1046 lines
36 KiB
Lua
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
|