mirror of
https://github.com/neovim/neovim
synced 2025-07-15 16:51:49 +00:00
Problem: No built-in plugin manager Solution: Add built-in plugin manager Co-authored-by: Lewis Russell <lewis6991@gmail.com>
1001 lines
34 KiB
Lua
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
|