commit cbfc3d1cdc199ce65368a2f40dc4b1ddc4331714
parent 3694fcec286763d21e30eb3f479aea4fe6d8d873
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Fri, 4 Jul 2025 06:32:55 -0700
Merge #34009 vim.pack
Diffstat:
14 files changed, 1614 insertions(+), 0 deletions(-)
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
@@ -2526,6 +2526,192 @@ vim.loader.reset({path}) *vim.loader.reset()*
==============================================================================
+Lua module: vim.pack *vim.pack*
+
+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.
+
+
+*vim.pack.Spec*
+
+ Fields: ~
+ • {src} (`string`) URI from which to install and pull updates. Any
+ format supported by `git clone` is allowed.
+ • {name}? (`string`) Name of plugin. Will be used as directory name.
+ Default: `src` repository name.
+ • {version}? (`string|vim.VersionRange`) 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.
+
+
+vim.pack.add({specs}, {opts}) *vim.pack.add()*
+ 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.
+
+ Parameters: ~
+ • {specs} (`(string|vim.pack.Spec)[]`) List of plugin specifications.
+ String item is treated as `src`.
+ • {opts} (`table?`) A table with the following fields:
+ • {load}? (`boolean`) Load `plugin/` files and `ftdetect/`
+ scripts. If `false`, works like `:packadd!`. Default
+ `true`.
+
+vim.pack.del({names}) *vim.pack.del()*
+ Remove plugins from disk
+
+ Parameters: ~
+ • {names} (`string[]`) List of plugin names to remove from disk. Must
+ be managed by |vim.pack|, not necessarily already added to
+ current session.
+
+vim.pack.get() *vim.pack.get()*
+ Get data about all plugins managed by |vim.pack|
+
+ Return: ~
+ (`table[]`) A list of objects with the following fields:
+ • {spec} (`vim.pack.SpecResolved`) A |vim.pack.Spec| with defaults
+ made explicit.
+ • {path} (`string`) Plugin's path on disk.
+ • {active} (`boolean`) Whether plugin was added via |vim.pack.add()|
+ to current session.
+
+vim.pack.update({names}, {opts}) *vim.pack.update()*
+ 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()|.
+
+ Parameters: ~
+ • {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()|.
+ • {opts} (`table?`) A table with the following fields:
+ • {force}? (`boolean`) Whether to skip confirmation and make
+ updates immediately. Default `false`.
+
+
+==============================================================================
Lua module: vim.uri *vim.uri*
vim.uri_decode({str}) *vim.uri_decode()*
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -202,6 +202,7 @@ LUA
• |vim.version.range()| output can be converted to human-readable string with |tostring()|.
• |vim.version.intersect()| computes intersection of two version ranges.
• |Iter:take()| and |Iter:skip()| now optionally accept predicates.
+• Built-in plugin manager |vim.pack|
OPTIONS
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
@@ -2476,6 +2476,8 @@ A jump table for the options with a short description can be found at |Q_op|.
|MenuPopup|,
|ModeChanged|,
|OptionSet|,
+ |PackChanged|,
+ |PackChangedPre|,
|QuickFixCmdPost|,
|QuickFixCmdPre|,
|QuitPre|,
diff --git a/runtime/example_init.lua b/runtime/example_init.lua
@@ -86,3 +86,8 @@ end, { desc = 'Print the git blame for the current line' })
-- For example, to add the "nohlsearch" package to automatically turn off search highlighting after
-- 'updatetime' and when going to insert mode
vim.cmd('packadd! nohlsearch')
+
+-- [[ Install plugins ]]
+-- Nvim functionality can be extended by installing external plugins.
+-- One way to do it is with a built-in plugin manager. See `:h vim.pack`.
+vim.pack.add({ 'https://github.com/neovim/nvim-lspconfig' })
diff --git a/runtime/ftplugin/nvim-pack.lua b/runtime/ftplugin/nvim-pack.lua
@@ -0,0 +1,47 @@
+local ns = vim.api.nvim_create_namespace('nvim.pack.confirm')
+vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
+
+local priority = 100
+local hi_range = function(lnum, start_col, end_col, hl, pr)
+ --- @type vim.api.keyset.set_extmark
+ local opts = { end_row = lnum - 1, end_col = end_col, hl_group = hl, priority = pr or priority }
+ vim.api.nvim_buf_set_extmark(0, ns, lnum - 1, start_col, opts)
+end
+
+local header_hl_groups =
+ { Error = 'DiagnosticError', Update = 'DiagnosticWarn', Same = 'DiagnosticHint' }
+local cur_header_hl_group = nil
+
+local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
+for i, l in ipairs(lines) do
+ local cur_group = l:match('^# (%S+)')
+ local cur_info = l:match('^Path: +') or l:match('^Source: +') or l:match('^State[^:]*: +')
+ if cur_group ~= nil then
+ --- @cast cur_group string
+ -- Header 1
+ cur_header_hl_group = header_hl_groups[cur_group]
+ hi_range(i, 0, l:len(), cur_header_hl_group)
+ elseif l:find('^## (.+)$') ~= nil then
+ -- Header 2
+ hi_range(i, 0, l:len(), cur_header_hl_group)
+ elseif cur_info ~= nil then
+ -- Plugin info
+ local end_col = l:match('(). +%b()$') or l:len()
+ hi_range(i, cur_info:len(), end_col, 'DiagnosticInfo')
+
+ -- Plugin state after update
+ local col = l:match('() %b()$') or l:len()
+ hi_range(i, col, l:len(), 'DiagnosticHint')
+ elseif l:match('^> ') then
+ -- Added change with possibly "breaking message"
+ hi_range(i, 0, l:len(), 'Added')
+ local col = l:match('│() %S+!:') or l:match('│() %S+%b()!:') or l:len()
+ hi_range(i, col, l:len(), 'DiagnosticWarn', priority + 1)
+ elseif l:match('^< ') then
+ -- Removed change
+ hi_range(i, 0, l:len(), 'Removed')
+ elseif l:match('^• ') then
+ -- Available newer tags
+ hi_range(i, 4, l:len(), 'DiagnosticHint')
+ end
+end
diff --git a/runtime/lua/vim/_async.lua b/runtime/lua/vim/_async.lua
@@ -0,0 +1,109 @@
+local M = {}
+
+local max_timeout = 30000
+
+--- @param thread thread
+--- @param on_finish fun(err: string?, ...:any)
+--- @param ... any
+local function resume(thread, on_finish, ...)
+ --- @type {n: integer, [1]:boolean, [2]:string|function}
+ local ret = vim.F.pack_len(coroutine.resume(thread, ...))
+ local stat = ret[1]
+
+ if not stat then
+ -- Coroutine had error
+ on_finish(ret[2] --[[@as string]])
+ elseif coroutine.status(thread) == 'dead' then
+ -- Coroutine finished
+ on_finish(nil, unpack(ret, 2, ret.n))
+ else
+ local fn = ret[2]
+ --- @cast fn -string
+
+ --- @type boolean, string?
+ local ok, err = pcall(fn, function(...)
+ resume(thread, on_finish, ...)
+ end)
+
+ if not ok then
+ on_finish(err)
+ end
+ end
+end
+
+--- @param func async fun(): ...:any
+--- @param on_finish? fun(err: string?, ...:any)
+function M.run(func, on_finish)
+ local res --- @type {n:integer, [integer]:any}?
+ resume(coroutine.create(func), function(err, ...)
+ res = vim.F.pack_len(err, ...)
+ if on_finish then
+ on_finish(err, ...)
+ end
+ end)
+
+ return {
+ --- @param timeout? integer
+ --- @return any ... return values of `func`
+ wait = function(_self, timeout)
+ vim.wait(timeout or max_timeout, function()
+ return res ~= nil
+ end)
+ assert(res, 'timeout')
+ if res[1] then
+ error(res[1])
+ end
+ return unpack(res, 2, res.n)
+ end,
+ }
+end
+
+--- Asynchronous blocking wait
+--- @async
+--- @param argc integer
+--- @param fun function
+--- @param ... any func arguments
+--- @return any ...
+function M.await(argc, fun, ...)
+ assert(coroutine.running(), 'Async.await() must be called from an async function')
+ local args = vim.F.pack_len(...) --- @type {n:integer, [integer]:any}
+
+ --- @param callback fun(...:any)
+ return coroutine.yield(function(callback)
+ args[argc] = assert(callback)
+ fun(unpack(args, 1, math.max(argc, args.n)))
+ end)
+end
+
+--- @async
+--- @param max_jobs integer
+--- @param funs (async fun())[]
+function M.join(max_jobs, funs)
+ if #funs == 0 then
+ return
+ end
+
+ max_jobs = math.min(max_jobs, #funs)
+
+ --- @type (async fun())[]
+ local remaining = { select(max_jobs + 1, unpack(funs)) }
+ local to_go = #funs
+
+ M.await(1, function(on_finish)
+ local function run_next()
+ to_go = to_go - 1
+ if to_go == 0 then
+ on_finish()
+ elseif #remaining > 0 then
+ local next_fun = table.remove(remaining)
+ M.run(next_fun, run_next)
+ end
+ end
+
+ for i = 1, max_jobs do
+ M.run(funs[i], run_next)
+ end
+ end)
+end
+
+return M
diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua
@@ -39,6 +39,7 @@ for k, v in pairs({
health = true,
secure = true,
snippet = true,
+ pack = true,
_watch = true,
}) do
vim._submodules[k] = v
diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua
@@ -2137,6 +2137,8 @@ vim.go.ei = vim.go.eventignore
--- `MenuPopup`,
--- `ModeChanged`,
--- `OptionSet`,
+--- `PackChanged`,
+--- `PackChangedPre`,
--- `QuickFixCmdPost`,
--- `QuickFixCmdPre`,
--- `QuitPre`,
diff --git a/runtime/lua/vim/health/health.lua b/runtime/lua/vim/health/health.lua
@@ -409,6 +409,25 @@ local function check_external_tools()
else
health.warn('ripgrep not available')
end
+
+ -- `vim.pack` requires `git` executable with version at least 2.36
+ if vim.fn.executable('git') == 1 then
+ local git = vim.fn.exepath('git')
+ local out = vim.system({ 'git', 'version' }, {}):wait().stdout or ''
+ local version = vim.version.parse(out)
+ if version < vim.version.parse('2.36') then
+ local msg = string.format(
+ 'git is available (%s), but needs at least version 2.36 (not %s) to work with `vim.pack`',
+ git,
+ tostring(version)
+ )
+ health.warn(msg)
+ else
+ health.ok(('%s (%s)'):format(vim.trim(out), git))
+ end
+ else
+ health.warn('git not available (required by `vim.pack`)')
+ end
end
function M.check()
diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua
@@ -0,0 +1,1000 @@
+--- @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
diff --git a/runtime/lua/vim/pack/_lsp.lua b/runtime/lua/vim/pack/_lsp.lua
@@ -0,0 +1,168 @@
+local M = {}
+
+local capabilities = {
+ codeActionProvider = true,
+ documentSymbolProvider = true,
+ hoverProvider = true,
+}
+--- @type table<string,function>
+local methods = {}
+
+--- @param callback function
+function methods.initialize(_, callback)
+ return callback(nil, { capabilities = capabilities })
+end
+
+--- @param callback function
+function methods.shutdown(_, callback)
+ return callback(nil, nil)
+end
+
+local get_confirm_bufnr = function(uri)
+ return tonumber(uri:match('^nvim%-pack://(%d+)/confirm%-update$'))
+end
+
+--- @param params { textDocument: { uri: string } }
+--- @param callback function
+methods['textDocument/documentSymbol'] = function(params, callback)
+ local bufnr = get_confirm_bufnr(params.textDocument.uri)
+ if bufnr == nil then
+ return callback(nil, {})
+ end
+
+ --- @alias vim.pack.lsp.Position { line: integer, character: integer }
+ --- @alias vim.pack.lsp.Range { start: vim.pack.lsp.Position, end: vim.pack.lsp.Position }
+ --- @alias vim.pack.lsp.Symbol {
+ --- name: string,
+ --- kind: number,
+ --- range: vim.pack.lsp.Range,
+ --- selectionRange: vim.pack.lsp.Range,
+ --- children: vim.pack.lsp.Symbol[]?,
+ --- }
+
+ --- @return vim.pack.lsp.Symbol?
+ local new_symbol = function(name, start_line, end_line, kind)
+ if name == nil then
+ return nil
+ end
+ local range = {
+ start = { line = start_line, character = 0 },
+ ['end'] = { line = end_line, character = 0 },
+ }
+ return { name = name, kind = kind, range = range, selectionRange = range }
+ end
+
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+
+ --- @return vim.pack.lsp.Symbol[]
+ local parse_headers = function(pattern, start_line, end_line, kind)
+ local res, cur_match, cur_start = {}, nil, nil
+ for i = start_line, end_line do
+ local m = lines[i + 1]:match(pattern)
+ if m ~= nil and m ~= cur_match then
+ table.insert(res, new_symbol(cur_match, cur_start, i, kind))
+ cur_match, cur_start = m, i
+ end
+ end
+ table.insert(res, new_symbol(cur_match, cur_start, end_line, kind))
+ return res
+ end
+
+ local group_kind = vim.lsp.protocol.SymbolKind.Namespace
+ local symbols = parse_headers('^# (%S+)', 0, #lines - 1, group_kind)
+
+ local plug_kind = vim.lsp.protocol.SymbolKind.Module
+ for _, group in ipairs(symbols) do
+ local start_line, end_line = group.range.start.line, group.range['end'].line
+ group.children = parse_headers('^## (.+)$', start_line, end_line, plug_kind)
+ end
+
+ return callback(nil, symbols)
+end
+
+--- @param callback function
+methods['textDocument/codeAction'] = function(_, callback)
+ -- TODO(echasnovski)
+ -- Suggested actions for "plugin under cursor":
+ -- - Delete plugin from disk.
+ -- - Update only this plugin.
+ -- - Exclude this plugin from update.
+ return callback(_, {})
+end
+
+--- @param params { textDocument: { uri: string }, position: { line: integer, character: integer } }
+--- @param callback function
+methods['textDocument/hover'] = function(params, callback)
+ local bufnr = get_confirm_bufnr(params.textDocument.uri)
+ if bufnr == nil then
+ return
+ end
+
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+ local lnum = params.position.line + 1
+ local commit = lines[lnum]:match('^[<>] (%x+) │') or lines[lnum]:match('^State.*:%s+(%x+)')
+ local tag = lines[lnum]:match('^• (.+)$')
+ if commit == nil and tag == nil then
+ return
+ end
+
+ local path, path_lnum = nil, lnum - 1
+ while path == nil and path_lnum >= 1 do
+ path = lines[path_lnum]:match('^Path:%s+(.+)$')
+ path_lnum = path_lnum - 1
+ end
+ if path == nil then
+ return
+ end
+
+ local cmd = { 'git', 'show', '--no-color', commit or tag }
+ --- @param sys_out vim.SystemCompleted
+ local on_exit = function(sys_out)
+ local markdown = '```diff\n' .. sys_out.stdout .. '\n```'
+ local res = { contents = { kind = vim.lsp.protocol.MarkupKind.Markdown, value = markdown } }
+ callback(nil, res)
+ end
+ vim.system(cmd, { cwd = path }, vim.schedule_wrap(on_exit))
+end
+
+local dispatchers = {}
+
+-- TODO: Simplify after `vim.lsp.server` is a thing
+-- https://github.com/neovim/neovim/pull/24338
+local cmd = function(disp)
+ -- Store dispatchers to use for showing progress notifications
+ dispatchers = disp
+ local res, closing, request_id = {}, false, 0
+
+ function res.request(method, params, callback)
+ local method_impl = methods[method]
+ if method_impl ~= nil then
+ method_impl(params, callback)
+ end
+ request_id = request_id + 1
+ return true, request_id
+ end
+
+ function res.notify(method, _)
+ if method == 'exit' then
+ dispatchers.on_exit(0, 15)
+ end
+ return false
+ end
+
+ function res.is_closing()
+ return closing
+ end
+
+ function res.terminate()
+ closing = true
+ end
+
+ return res
+end
+
+M.client_id = assert(
+ vim.lsp.start({ cmd = cmd, name = 'vim.pack', root_dir = vim.uv.cwd() }, { attach = false })
+)
+
+return M
diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua
@@ -144,6 +144,7 @@ local config = {
'_inspector.lua',
'shared.lua',
'loader.lua',
+ 'pack.lua',
'uri.lua',
'ui.lua',
'_extui.lua',
@@ -167,6 +168,7 @@ local config = {
'runtime/lua/vim/_options.lua',
'runtime/lua/vim/shared.lua',
'runtime/lua/vim/loader.lua',
+ 'runtime/lua/vim/pack.lua',
'runtime/lua/vim/uri.lua',
'runtime/lua/vim/ui.lua',
'runtime/lua/vim/_extui.lua',
diff --git a/src/nvim/auevents.lua b/src/nvim/auevents.lua
@@ -87,6 +87,8 @@ return {
QuickFixCmdPost = false, -- after :make, :grep etc.
QuickFixCmdPre = false, -- before :make, :grep etc.
QuitPre = false, -- before :quit
+ PackChangedPre = false, -- before trying to change state of `vim.pack` plugin
+ PackChanged = false, -- after changing state of `vim.pack` plugin
RecordingEnter = true, -- when starting to record a macro
RecordingLeave = true, -- just before a macro stops recording
RemoteReply = false, -- upon string reception from a remote vim
@@ -158,6 +160,8 @@ return {
LspProgress = true,
LspRequest = true,
LspTokenUpdate = true,
+ PackChangedPre = true,
+ PackChanged = true,
RecordingEnter = true,
RecordingLeave = true,
Signal = true,
diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua
@@ -0,0 +1,68 @@
+describe('vim.pack', function()
+ describe('add()', function()
+ pending('works', function()
+ -- TODO
+ end)
+
+ pending('respects after/', function()
+ -- TODO
+ -- Should source 'after/plugin/' directory (even nested files) after
+ -- all 'plugin/' files are sourced in all plugins from input.
+ --
+ -- Should add 'after/' directory (if present) to 'runtimepath'
+ end)
+
+ pending('normalizes each spec', function()
+ -- TODO
+
+ -- TODO: Should properly infer `name` from `src` (as its basename
+ -- minus '.git' suffix) but allow '.git' suffix in explicit `name`
+ end)
+
+ pending('normalizes spec array', function()
+ -- TODO
+ -- Should silently ignore full duplicates (same `src`+`version`)
+ -- and error on conflicts.
+ end)
+
+ pending('installs', function()
+ -- TODO
+
+ -- TODO: Should block code flow until all plugins are available on disk
+ -- and `:packadd` all of them (even just now installed) as a result.
+ end)
+ end)
+
+ describe('update()', function()
+ pending('works', function()
+ -- TODO
+
+ -- TODO: Should work with both added and not added plugins
+ end)
+
+ pending('suggests newer tags if there are no updates', function()
+ -- TODO
+
+ -- TODO: Should not suggest tags that point to the current state.
+ -- Even if there is one/several and located at start/middle/end.
+ end)
+ end)
+
+ describe('get()', function()
+ pending('works', function()
+ -- TODO
+ end)
+
+ pending('works after `del()`', function()
+ -- TODO: Should not include removed plugins and still return list
+
+ -- TODO: Should return corrent list inside `PackChanged` "delete" event
+ end)
+ end)
+
+ describe('del()', function()
+ pending('works', function()
+ -- TODO
+ end)
+ end)
+end)