mirror of
				https://github.com/neovim/neovim.git
				synced 2025-10-26 12:27:24 +00:00 
			
		
		
		
	 12689c73d8
			
		
	
	12689c73d8
	
	
	
		
			
			Problem: Error when adding a plugin will make all following plugins not `:packadd`ed Solution: - add() should handle errors from :packadd with pcall() Co-authored-by: Evgeni Chasnovski <evgeni.chasnovski@gmail.com> Co-authored-by: Justin M. Keyes <justinkz@gmail.com>
		
			
				
	
	
		
			1002 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
			
		
		
	
	
			1002 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)
 | |
|       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 load those actually on disk while collecting errors
 | |
|   -- Delay showing all errors to have "good" plugins added first
 | |
|   local errors = {} --- @type string[]
 | |
|   for _, p in ipairs(plugs) do
 | |
|     if p.info.installed then
 | |
|       local ok, err = pcall(pack_add, p, opts.load) --[[@as string]]
 | |
|       if not ok then
 | |
|         p.info.err = err
 | |
|       end
 | |
|     end
 | |
|     if p.info.err ~= '' then
 | |
|       errors[#errors + 1] = ('`%s`:\n%s'):format(p.spec.name, p.info.err)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   if #errors > 0 then
 | |
|     local error_str = table.concat(errors, '\n\n')
 | |
|     error(('vim.pack:\n\n%s'):format(error_str))
 | |
|   end
 | |
| end
 | |
| 
 | |
| --- @param p vim.pack.Plug
 | |
| --- @return string
 | |
| local function compute_feedback_lines_single(p)
 | |
|   if p.info.err ~= '' then
 | |
|     return ('## %s\n\n %s'):format(p.spec.name, p.info.err:gsub('\n', '\n  '))
 | |
|   end
 | |
| 
 | |
|   local parts = { '## ' .. p.spec.name .. '\n' }
 | |
|   local version_suffix = p.info.version_str == '' and '' or (' (%s)'):format(p.info.version_str)
 | |
| 
 | |
|   if p.info.sha_head == p.info.sha_target then
 | |
|     parts[#parts + 1] = table.concat({
 | |
|       'Path:   ' .. p.path,
 | |
|       'Source: ' .. p.spec.src,
 | |
|       'State:  ' .. p.info.sha_target .. version_suffix,
 | |
|     }, '\n')
 | |
| 
 | |
|     if p.info.update_details ~= '' then
 | |
|       local details = p.info.update_details:gsub('\n', '\n• ')
 | |
|       parts[#parts + 1] = '\n\nAvailable newer 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
 |