pack.lua 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032
  1. --- @brief
  2. ---
  3. ---WORK IN PROGRESS built-in plugin manager! Early testing of existing features
  4. ---is appreciated, but expect breaking changes without notice.
  5. ---
  6. ---Manages plugins only in a dedicated [vim.pack-directory]() (see |packages|):
  7. ---`$XDG_DATA_HOME/nvim/site/pack/core/opt`.
  8. ---Plugin's subdirectory name matches plugin's name in specification.
  9. ---It is assumed that all plugins in the directory are managed exclusively by `vim.pack`.
  10. ---
  11. ---Uses Git to manage plugins and requires present `git` executable of at
  12. ---least version 2.36. Target plugins should be Git repositories with versions
  13. ---as named tags following semver convention `v<major>.<minor>.<patch>`.
  14. ---
  15. ---Example workflows ~
  16. ---
  17. ---Basic install and management:
  18. ---
  19. ---- Add |vim.pack.add()| call(s) to 'init.lua':
  20. ---```lua
  21. ---
  22. ---vim.pack.add({
  23. --- -- Install "plugin1" and use default branch (usually `main` or `master`)
  24. --- 'https://github.com/user/plugin1',
  25. ---
  26. --- -- Same as above, but using a table (allows setting other options)
  27. --- { src = 'https://github.com/user/plugin1' },
  28. ---
  29. --- -- Specify plugin's name (here the plugin will be called "plugin2"
  30. --- -- instead of "generic-name")
  31. --- { src = 'https://github.com/user/generic-name', name = 'plugin2' },
  32. ---
  33. --- -- Specify version to follow during install and update
  34. --- {
  35. --- src = 'https://github.com/user/plugin3',
  36. --- -- Version constraint, see |vim.version.range()|
  37. --- version = vim.version.range('1.0'),
  38. --- },
  39. --- {
  40. --- src = 'https://github.com/user/plugin4',
  41. --- -- Git branch, tag, or commit hash
  42. --- version = 'main',
  43. --- },
  44. ---})
  45. ---
  46. ----- Plugin's code can be used directly after `add()`
  47. ---plugin1 = require('plugin1')
  48. ---```
  49. ---
  50. ---- Restart Nvim (for example, with |:restart|). Plugins that were not yet
  51. ---installed will be available on disk in target state after `add()` call.
  52. ---
  53. ---- To update all plugins with new changes:
  54. --- - Execute |vim.pack.update()|. This will download updates from source and
  55. --- show confirmation buffer in a separate tabpage.
  56. --- - Review changes. To confirm all updates execute |:write|.
  57. --- To discard updates execute |:quit|.
  58. ---
  59. ---Switch plugin's version:
  60. ---- Update 'init.lua' for plugin to have desired `version`. Let's say, plugin
  61. ---named 'plugin1' has changed to `vim.version.range('*')`.
  62. ---- |:restart|. The plugin's actual state on disk is not yet changed.
  63. ---- Execute `vim.pack.update({ 'plugin1' })`.
  64. ---- Review changes and either confirm or discard them. If discarded, revert
  65. ---any changes in 'init.lua' as well or you will be prompted again next time
  66. ---you run |vim.pack.update()|.
  67. ---
  68. ---Freeze plugin from being updated:
  69. ---- Update 'init.lua' for plugin to have `version` set to current commit hash.
  70. ---You can get it by running `vim.pack.update({ 'plugin-name' })` and yanking
  71. ---the word describing current state (looks like `abc12345`).
  72. ---- |:restart|.
  73. ---
  74. ---Unfreeze plugin to start receiving updates:
  75. ---- Update 'init.lua' for plugin to have `version` set to whichever version
  76. ---you want it to be updated.
  77. ---- |:restart|.
  78. ---
  79. ---Remove plugins from disk:
  80. ---- Use |vim.pack.del()| with a list of plugin names to remove. Make sure their specs
  81. ---are not included in |vim.pack.add()| call in 'init.lua' or they will be reinstalled.
  82. ---
  83. --- Available events to hook into ~
  84. ---
  85. --- - [PackChangedPre]() - before trying to change plugin's state.
  86. --- - [PackChanged]() - after plugin's state has changed.
  87. ---
  88. --- Each event populates the following |event-data| fields:
  89. --- - `kind` - one of "install" (install on disk), "update" (update existing
  90. --- plugin), "delete" (delete from disk).
  91. --- - `spec` - plugin's specification with defaults made explicit.
  92. --- - `path` - full path to plugin's directory.
  93. local api = vim.api
  94. local uv = vim.uv
  95. local async = require('vim._async')
  96. local M = {}
  97. -- Git ------------------------------------------------------------------------
  98. --- @async
  99. --- @param cmd string[]
  100. --- @param cwd? string
  101. --- @return string
  102. local function git_cmd(cmd, cwd)
  103. -- Use '-c gc.auto=0' to disable `stderr` "Auto packing..." messages
  104. cmd = vim.list_extend({ 'git', '-c', 'gc.auto=0' }, cmd)
  105. local sys_opts = { cwd = cwd, text = true, clear_env = true }
  106. local out = async.await(3, vim.system, cmd, sys_opts) --- @type vim.SystemCompleted
  107. async.await(1, vim.schedule)
  108. if out.code ~= 0 then
  109. error(out.stderr)
  110. end
  111. local stdout, stderr = assert(out.stdout), assert(out.stderr)
  112. if stderr ~= '' then
  113. vim.schedule(function()
  114. vim.notify(stderr:gsub('\n+$', ''), vim.log.levels.WARN)
  115. end)
  116. end
  117. return (stdout:gsub('\n+$', ''))
  118. end
  119. local function git_ensure_exec()
  120. if vim.fn.executable('git') == 0 then
  121. error('No `git` executable')
  122. end
  123. end
  124. --- @async
  125. --- @param url string
  126. --- @param path string
  127. local function git_clone(url, path)
  128. local cmd = { 'clone', '--quiet', '--origin', 'origin' }
  129. if vim.startswith(url, 'file://') then
  130. cmd[#cmd + 1] = '--no-hardlinks'
  131. else
  132. -- NOTE: '--also-filter-submodules' requires Git>=2.36
  133. local filter_args = { '--filter=blob:none', '--recurse-submodules', '--also-filter-submodules' }
  134. vim.list_extend(cmd, filter_args)
  135. end
  136. vim.list_extend(cmd, { '--origin', 'origin', url, path })
  137. git_cmd(cmd, uv.cwd())
  138. end
  139. --- @async
  140. --- @param rev string
  141. --- @param cwd string
  142. --- @return string
  143. local function git_get_hash(rev, cwd)
  144. -- Using `rev-list -1` shows a commit of revision, while `rev-parse` shows
  145. -- hash of revision. Those are different for annotated tags.
  146. return git_cmd({ 'rev-list', '-1', '--abbrev-commit', rev }, cwd)
  147. end
  148. --- @async
  149. --- @param cwd string
  150. --- @return string
  151. local function git_get_default_branch(cwd)
  152. local res = git_cmd({ 'rev-parse', '--abbrev-ref', 'origin/HEAD' }, cwd)
  153. return (res:gsub('^origin/', ''))
  154. end
  155. --- @async
  156. --- @param cwd string
  157. --- @return string[]
  158. local function git_get_branches(cwd)
  159. local cmd = { 'branch', '--remote', '--list', '--format=%(refname:short)', '--', 'origin/**' }
  160. local stdout = git_cmd(cmd, cwd)
  161. local res = {} --- @type string[]
  162. for l in vim.gsplit(stdout, '\n') do
  163. res[#res + 1] = l:match('^origin/(.+)$')
  164. end
  165. return res
  166. end
  167. --- @async
  168. --- @param cwd string
  169. --- @return string[]
  170. local function git_get_tags(cwd)
  171. local cmd = { 'tag', '--list', '--sort=-v:refname' }
  172. return vim.split(git_cmd(cmd, cwd), '\n')
  173. end
  174. -- Plugin operations ----------------------------------------------------------
  175. --- @return string
  176. local function get_plug_dir()
  177. return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt')
  178. end
  179. --- @param msg string|string[]
  180. --- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')?
  181. local function notify(msg, level)
  182. msg = type(msg) == 'table' and table.concat(msg, '\n') or msg
  183. vim.notify('vim.pack: ' .. msg, vim.log.levels[level or 'INFO'])
  184. vim.cmd.redraw()
  185. end
  186. --- @param x string|vim.VersionRange
  187. --- @return boolean
  188. local function is_version(x)
  189. return type(x) == 'string' or (type(x) == 'table' and pcall(x.has, x, '1'))
  190. end
  191. --- @param x string
  192. --- @return boolean
  193. local function is_semver(x)
  194. return vim.version.parse(x) ~= nil
  195. end
  196. local function is_nonempty_string(x)
  197. return type(x) == 'string' and x ~= ''
  198. end
  199. --- @return string
  200. local function get_timestamp()
  201. return vim.fn.strftime('%Y-%m-%d %H:%M:%S')
  202. end
  203. --- @class vim.pack.Spec
  204. ---
  205. --- URI from which to install and pull updates. Any format supported by `git clone` is allowed.
  206. --- @field src string
  207. ---
  208. --- Name of plugin. Will be used as directory name. Default: `src` repository name.
  209. --- @field name? string
  210. ---
  211. --- Version to use for install and updates. Can be:
  212. --- - `nil` (no value, default) to use repository's default branch (usually `main` or `master`).
  213. --- - String to use specific branch, tag, or commit hash.
  214. --- - Output of |vim.version.range()| to install the greatest/last semver tag
  215. --- inside the version constraint.
  216. --- @field version? string|vim.VersionRange
  217. --- @alias vim.pack.SpecResolved { src: string, name: string, version: nil|string|vim.VersionRange }
  218. --- @param spec string|vim.pack.Spec
  219. --- @return vim.pack.SpecResolved
  220. local function normalize_spec(spec)
  221. spec = type(spec) == 'string' and { src = spec } or spec
  222. vim.validate('spec', spec, 'table')
  223. vim.validate('spec.src', spec.src, is_nonempty_string, false, 'non-empty string')
  224. local name = spec.name or spec.src:gsub('%.git$', '')
  225. name = (type(name) == 'string' and name or ''):match('[^/]+$') or ''
  226. vim.validate('spec.name', name, is_nonempty_string, true, 'non-empty string')
  227. vim.validate('spec.version', spec.version, is_version, true, 'string or vim.VersionRange')
  228. return { src = spec.src, name = name, version = spec.version }
  229. end
  230. --- @class (private) vim.pack.PlugInfo
  231. --- @field err string The latest error when working on plugin. If non-empty,
  232. --- all further actions should not be done (including triggering events).
  233. --- @field installed? boolean Whether plugin was successfully installed.
  234. --- @field version_str? string `spec.version` with resolved version range.
  235. --- @field version_ref? string Resolved version as Git reference (if different
  236. --- from `version_str`).
  237. --- @field sha_head? string Git hash of HEAD.
  238. --- @field sha_target? string Git hash of `version_ref`.
  239. --- @field update_details? string Details about the update:: changelog if HEAD
  240. --- and target are different, available newer tags otherwise.
  241. --- @class (private) vim.pack.Plug
  242. --- @field spec vim.pack.SpecResolved
  243. --- @field path string
  244. --- @field info vim.pack.PlugInfo Gathered information about plugin.
  245. --- @param spec string|vim.pack.Spec
  246. --- @return vim.pack.Plug
  247. local function new_plug(spec)
  248. local spec_resolved = normalize_spec(spec)
  249. local path = vim.fs.joinpath(get_plug_dir(), spec_resolved.name)
  250. local info = { err = '', installed = uv.fs_stat(path) ~= nil }
  251. return { spec = spec_resolved, path = path, info = info }
  252. end
  253. --- Normalize plug array: gather non-conflicting data from duplicated entries.
  254. --- @param plugs vim.pack.Plug[]
  255. --- @return vim.pack.Plug[]
  256. local function normalize_plugs(plugs)
  257. --- @type table<string, { plug: vim.pack.Plug, id: integer }>
  258. local plug_map = {}
  259. local n = 0
  260. for _, p in ipairs(plugs) do
  261. -- Collect
  262. if not plug_map[p.path] then
  263. n = n + 1
  264. plug_map[p.path] = { plug = p, id = n }
  265. end
  266. local p_data = plug_map[p.path]
  267. -- TODO(echasnovski): if both versions are `vim.VersionRange`, collect as
  268. -- their intersection. Needs `vim.version.intersect`.
  269. p_data.plug.spec.version = vim.F.if_nil(p_data.plug.spec.version, p.spec.version)
  270. -- Ensure no conflicts
  271. local spec_ref = p_data.plug.spec
  272. local spec = p.spec
  273. if spec_ref.src ~= spec.src then
  274. local src_1 = tostring(spec_ref.src)
  275. local src_2 = tostring(spec.src)
  276. error(('Conflicting `src` for `%s`:\n%s\n%s'):format(spec.name, src_1, src_2))
  277. end
  278. if spec_ref.version ~= spec.version then
  279. local ver_1 = tostring(spec_ref.version)
  280. local ver_2 = tostring(spec.version)
  281. error(('Conflicting `version` for `%s`:\n%s\n%s'):format(spec.name, ver_1, ver_2))
  282. end
  283. end
  284. --- @type vim.pack.Plug[]
  285. local res = {}
  286. for _, p_data in pairs(plug_map) do
  287. res[p_data.id] = p_data.plug
  288. end
  289. assert(#res == n)
  290. return res
  291. end
  292. --- @param names string[]?
  293. --- @return vim.pack.Plug[]
  294. local function plug_list_from_names(names)
  295. local all_plugins = M.get()
  296. local plugs = {} --- @type vim.pack.Plug[]
  297. local used_names = {} --- @type table<string,boolean>
  298. -- Preserve plugin order; might be important during checkout or event trigger
  299. for _, p_data in ipairs(all_plugins) do
  300. -- NOTE: By default include only active plugins (and not all on disk). Using
  301. -- not active plugins might lead to a confusion as default `version` and
  302. -- user's desired one might mismatch.
  303. -- TODO(echasnovski): Consider changing this if/when there is lockfile.
  304. --- @cast names string[]
  305. if (not names and p_data.active) or vim.tbl_contains(names or {}, p_data.spec.name) then
  306. plugs[#plugs + 1] = new_plug(p_data.spec)
  307. used_names[p_data.spec.name] = true
  308. end
  309. end
  310. if vim.islist(names) and #plugs ~= #names then
  311. --- @param n string
  312. local unused = vim.tbl_filter(function(n)
  313. return not used_names[n]
  314. end, names)
  315. error('The following plugins are not installed: ' .. table.concat(unused, ', '))
  316. end
  317. return plugs
  318. end
  319. --- @param p vim.pack.Plug
  320. --- @param event_name 'PackChangedPre'|'PackChanged'
  321. --- @param kind 'install'|'update'|'delete'
  322. local function trigger_event(p, event_name, kind)
  323. local data = { kind = kind, spec = vim.deepcopy(p.spec), path = p.path }
  324. vim.api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data })
  325. end
  326. --- @param title string
  327. --- @return fun(kind: 'begin'|'report'|'end', percent: integer, fmt: string, ...:any): nil
  328. local function new_progress_report(title)
  329. -- TODO(echasnovski): currently print directly in command line because
  330. -- there is no robust built-in way of showing progress:
  331. -- - `vim.ui.progress()` is planned and is a good candidate to use here.
  332. -- - Use `'$/progress'` implementation in 'vim.pack._lsp' if there is
  333. -- a working built-in '$/progress' handler. Something like this:
  334. -- ```lua
  335. -- local progress_token_count = 0
  336. -- function M.new_progress_report(title)
  337. -- progress_token_count = progress_token_count + 1
  338. -- return vim.schedule_wrap(function(kind, msg, percent)
  339. -- local value = { kind = kind, message = msg, percentage = percent }
  340. -- dispatchers.notification(
  341. -- '$/progress',
  342. -- { token = progress_token_count, value = value }
  343. -- )
  344. -- end
  345. -- end
  346. -- ```
  347. -- Any of these choices is better as users can tweak how progress is shown.
  348. return vim.schedule_wrap(function(kind, percent, fmt, ...)
  349. local progress = kind == 'end' and 'done' or ('%3d%%'):format(percent)
  350. local details = (' %s %s'):format(title, fmt:format(...))
  351. local chunks = { { 'vim.pack', 'ModeMsg' }, { ': ' }, { progress, 'WarningMsg' }, { details } }
  352. vim.api.nvim_echo(chunks, true, { kind = 'progress' })
  353. -- Force redraw to show installation progress during startup
  354. vim.cmd.redraw({ bang = true })
  355. end)
  356. end
  357. local n_threads = 2 * #(uv.cpu_info() or { {} })
  358. local copcall = package.loaded.jit and pcall or require('coxpcall').pcall
  359. --- Execute function in parallel for each non-errored plugin in the list
  360. --- @param plug_list vim.pack.Plug[]
  361. --- @param f async fun(p: vim.pack.Plug)
  362. --- @param progress_title string
  363. local function run_list(plug_list, f, progress_title)
  364. local report_progress = new_progress_report(progress_title)
  365. -- Construct array of functions to execute in parallel
  366. local n_finished = 0
  367. local funs = {} --- @type (async fun())[]
  368. for _, p in ipairs(plug_list) do
  369. -- Run only for plugins which didn't error before
  370. if p.info.err == '' then
  371. --- @async
  372. funs[#funs + 1] = function()
  373. local ok, err = copcall(f, p) --[[@as string]]
  374. if not ok then
  375. p.info.err = err --- @as string
  376. end
  377. -- Show progress
  378. n_finished = n_finished + 1
  379. local percent = math.floor(100 * n_finished / #funs)
  380. report_progress('report', percent, '(%d/%d) - %s', n_finished, #funs, p.spec.name)
  381. end
  382. end
  383. end
  384. if #funs == 0 then
  385. return
  386. end
  387. -- Run async in parallel but wait for all to finish/timeout
  388. report_progress('begin', 0, '(0/%d)', #funs)
  389. --- @async
  390. local function joined_f()
  391. async.join(n_threads, funs)
  392. end
  393. async.run(joined_f):wait()
  394. report_progress('end', 100, '(%d/%d)', #funs, #funs)
  395. end
  396. --- @param plug_list vim.pack.Plug[]
  397. --- @return boolean
  398. local function confirm_install(plug_list)
  399. local src = {} --- @type string[]
  400. for _, p in ipairs(plug_list) do
  401. src[#src + 1] = p.spec.src
  402. end
  403. local src_text = table.concat(src, '\n')
  404. local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(src_text)
  405. local res = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No', 1, 'Question') == 1
  406. vim.cmd.redraw()
  407. return res
  408. end
  409. --- @param tags string[]
  410. --- @param version_range vim.VersionRange
  411. local function get_last_semver_tag(tags, version_range)
  412. local last_tag, last_ver_tag --- @type string, vim.Version
  413. for _, tag in ipairs(tags) do
  414. local ver_tag = vim.version.parse(tag)
  415. if ver_tag then
  416. if version_range:has(ver_tag) and (not last_ver_tag or ver_tag > last_ver_tag) then
  417. last_tag, last_ver_tag = tag, ver_tag
  418. end
  419. end
  420. end
  421. return last_tag
  422. end
  423. --- @async
  424. --- @param p vim.pack.Plug
  425. local function resolve_version(p)
  426. local function list_in_line(name, list)
  427. return #list == 0 and '' or ('\n' .. name .. ': ' .. table.concat(list, ', '))
  428. end
  429. -- Resolve only once
  430. if p.info.version_str then
  431. return
  432. end
  433. local version = p.spec.version
  434. -- Default branch
  435. if not version then
  436. p.info.version_str = git_get_default_branch(p.path)
  437. p.info.version_ref = 'origin/' .. p.info.version_str
  438. return
  439. end
  440. -- Non-version-range like version: branch, tag, or commit hash
  441. local branches = git_get_branches(p.path)
  442. local tags = git_get_tags(p.path)
  443. if type(version) == 'string' then
  444. local is_branch = vim.tbl_contains(branches, version)
  445. local is_tag_or_hash = copcall(git_get_hash, version, p.path)
  446. if not (is_branch or is_tag_or_hash) then
  447. local err = ('`%s` is not a branch/tag/commit. Available:'):format(version)
  448. .. list_in_line('Tags', tags)
  449. .. list_in_line('Branches', branches)
  450. error(err)
  451. end
  452. p.info.version_str = version
  453. p.info.version_ref = (is_branch and 'origin/' or '') .. version
  454. return
  455. end
  456. --- @cast version vim.VersionRange
  457. -- Choose the greatest/last version among all matching semver tags
  458. p.info.version_str = get_last_semver_tag(tags, version)
  459. if p.info.version_str == nil then
  460. local semver_tags = vim.tbl_filter(is_semver, tags)
  461. table.sort(semver_tags, vim.version.gt)
  462. local err = 'No versions fit constraint. Relax it or switch to branch. Available:'
  463. .. list_in_line('Versions', semver_tags)
  464. .. list_in_line('Branches', branches)
  465. error(err)
  466. end
  467. end
  468. --- @async
  469. --- @param p vim.pack.Plug
  470. local function infer_states(p)
  471. p.info.sha_head = p.info.sha_head or git_get_hash('HEAD', p.path)
  472. resolve_version(p)
  473. local target_ref = p.info.version_ref or p.info.version_str --[[@as string]]
  474. p.info.sha_target = p.info.sha_target or git_get_hash(target_ref, p.path)
  475. end
  476. --- Keep repos in detached HEAD state. Infer commit from resolved version.
  477. --- No local branches are created, branches from "origin" remote are used directly.
  478. --- @async
  479. --- @param p vim.pack.Plug
  480. --- @param timestamp string
  481. --- @param skip_same_sha boolean
  482. local function checkout(p, timestamp, skip_same_sha)
  483. infer_states(p)
  484. if skip_same_sha and p.info.sha_head == p.info.sha_target then
  485. return
  486. end
  487. trigger_event(p, 'PackChangedPre', 'update')
  488. local msg = ('vim.pack: %s Stash before checkout'):format(timestamp)
  489. git_cmd({ 'stash', '--quiet', '--message', msg }, p.path)
  490. git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path)
  491. trigger_event(p, 'PackChanged', 'update')
  492. -- (Re)Generate help tags according to the current help files.
  493. -- Also use `pcall()` because `:helptags` errors if there is no 'doc/'
  494. -- directory or if it is empty.
  495. local doc_dir = vim.fs.joinpath(p.path, 'doc')
  496. vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags'))
  497. copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } })
  498. end
  499. --- @param plug_list vim.pack.Plug[]
  500. local function install_list(plug_list)
  501. -- Get user confirmation to install plugins
  502. if not confirm_install(plug_list) then
  503. for _, p in ipairs(plug_list) do
  504. p.info.err = 'Installation was not confirmed'
  505. end
  506. return
  507. end
  508. local timestamp = get_timestamp()
  509. --- @async
  510. --- @param p vim.pack.Plug
  511. local function do_install(p)
  512. trigger_event(p, 'PackChangedPre', 'install')
  513. git_clone(p.spec.src, p.path)
  514. p.info.installed = true
  515. -- Infer default branch for fuller `event-data`
  516. p.spec.version = p.spec.version or git_get_default_branch(p.path)
  517. -- Do not skip checkout even if HEAD and target have same commit hash to
  518. -- have new repo in expected detached HEAD state and generated help files.
  519. checkout(p, timestamp, false)
  520. -- "Install" event is triggered after "update" event intentionally to have
  521. -- it indicate "plugin is installed in its correct initial version"
  522. trigger_event(p, 'PackChanged', 'install')
  523. end
  524. run_list(plug_list, do_install, 'Installing plugins')
  525. end
  526. --- @async
  527. --- @param p vim.pack.Plug
  528. local function infer_update_details(p)
  529. p.info.update_details = ''
  530. infer_states(p)
  531. local sha_head = assert(p.info.sha_head)
  532. local sha_target = assert(p.info.sha_target)
  533. -- Try showing log of changes (if any)
  534. if sha_head ~= sha_target then
  535. local range = sha_head .. '...' .. sha_target
  536. local format = '--pretty=format:%m %h │ %s%d'
  537. -- Show only tags near commits (not `origin/main`, etc.)
  538. local decorate = '--decorate-refs=refs/tags'
  539. -- `--topo-order` makes showing divergent branches nicer, but by itself
  540. -- doesn't ensure that reverted ("left", shown with `<`) and added
  541. -- ("right", shown with `>`) commits have fixed order.
  542. local l = git_cmd({ 'log', format, '--topo-order', '--left-only', decorate, range }, p.path)
  543. local r = git_cmd({ 'log', format, '--topo-order', '--right-only', decorate, range }, p.path)
  544. p.info.update_details = l == '' and r or (r == '' and l or (l .. '\n' .. r))
  545. return
  546. end
  547. -- Suggest newer semver tags (i.e. greater than greatest past semver tag)
  548. local all_semver_tags = vim.tbl_filter(is_semver, git_get_tags(p.path))
  549. if #all_semver_tags == 0 then
  550. return
  551. end
  552. local older_tags = git_cmd({ 'tag', '--list', '--no-contains', sha_head }, p.path)
  553. local cur_tags = git_cmd({ 'tag', '--list', '--points-at', sha_head }, p.path)
  554. local past_tags = vim.split(older_tags, '\n')
  555. vim.list_extend(past_tags, vim.split(cur_tags, '\n'))
  556. local any_version = vim.version.range('*') --[[@as vim.VersionRange]]
  557. local last_version = get_last_semver_tag(past_tags, any_version)
  558. local newer_semver_tags = vim.tbl_filter(function(x) --- @param x string
  559. return vim.version.gt(x, last_version)
  560. end, all_semver_tags)
  561. table.sort(newer_semver_tags, vim.version.gt)
  562. p.info.update_details = table.concat(newer_semver_tags, '\n')
  563. end
  564. --- Map from plugin path to its data.
  565. --- Use map and not array to avoid linear lookup during startup.
  566. --- @type table<string, { plug: vim.pack.Plug, id: integer }?>
  567. local active_plugins = {}
  568. local n_active_plugins = 0
  569. --- @param plug vim.pack.Plug
  570. --- @param load boolean
  571. local function pack_add(plug, load)
  572. -- Add plugin only once, i.e. no overriding of spec. This allows users to put
  573. -- plugin first to fully control its spec.
  574. if active_plugins[plug.path] then
  575. return
  576. end
  577. n_active_plugins = n_active_plugins + 1
  578. active_plugins[plug.path] = { plug = plug, id = n_active_plugins }
  579. -- NOTE: The `:packadd` specifically seems to not handle spaces in dir name
  580. vim.cmd.packadd({ vim.fn.escape(plug.spec.name, ' '), bang = not load, magic = { file = false } })
  581. -- Execute 'after/' scripts if not during startup (when they will be sourced
  582. -- automatically), as `:packadd` only sources plain 'plugin/' files.
  583. -- See https://github.com/vim/vim/issues/15584
  584. -- Deliberately do so after executing all currently known 'plugin/' files.
  585. if vim.v.vim_did_enter == 1 and load then
  586. local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true)
  587. --- @param path string
  588. vim.tbl_map(function(path)
  589. vim.cmd.source({ path, magic = { file = false } })
  590. end, after_paths)
  591. end
  592. end
  593. --- @class vim.pack.keyset.add
  594. --- @inlinedoc
  595. --- @field load? boolean Load `plugin/` files and `ftdetect/` scripts. If `false`, works like `:packadd!`. Default `true`.
  596. --- Add plugin to current session
  597. ---
  598. --- - For each specification check that plugin exists on disk in |vim.pack-directory|:
  599. --- - If exists, do nothin in this step.
  600. --- - If doesn't exist, install it by downloading from `src` into `name`
  601. --- subdirectory (via `git clone`) and update state to match `version` (via `git checkout`).
  602. --- - For each plugin execute |:packadd| making them reachable by Nvim.
  603. ---
  604. --- Notes:
  605. --- - Installation is done in parallel, but waits for all to finish before
  606. --- continuing next code execution.
  607. --- - If plugin is already present on disk, there are no checks about its present state.
  608. --- The specified `version` can be not the one actually present on disk.
  609. --- Execute |vim.pack.update()| to synchronize.
  610. --- - Adding plugin second and more times during single session does nothing:
  611. --- only the data from the first adding is registered.
  612. ---
  613. --- @param specs (string|vim.pack.Spec)[] List of plugin specifications. String item
  614. --- is treated as `src`.
  615. --- @param opts? vim.pack.keyset.add
  616. function M.add(specs, opts)
  617. vim.validate('specs', specs, vim.islist, false, 'list')
  618. opts = vim.tbl_extend('force', { load = true }, opts or {})
  619. vim.validate('opts', opts, 'table')
  620. --- @type vim.pack.Plug[]
  621. local plugs = vim.tbl_map(new_plug, specs)
  622. plugs = normalize_plugs(plugs)
  623. -- Install
  624. --- @param p vim.pack.Plug
  625. local plugs_to_install = vim.tbl_filter(function(p)
  626. return not p.info.installed
  627. end, plugs)
  628. if #plugs_to_install > 0 then
  629. git_ensure_exec()
  630. install_list(plugs_to_install)
  631. end
  632. -- Register and load those actually on disk while collecting errors
  633. -- Delay showing all errors to have "good" plugins added first
  634. local errors = {} --- @type string[]
  635. for _, p in ipairs(plugs) do
  636. if p.info.installed then
  637. local ok, err = pcall(pack_add, p, opts.load) --[[@as string]]
  638. if not ok then
  639. p.info.err = err
  640. end
  641. end
  642. if p.info.err ~= '' then
  643. errors[#errors + 1] = ('`%s`:\n%s'):format(p.spec.name, p.info.err)
  644. end
  645. end
  646. if #errors > 0 then
  647. local error_str = table.concat(errors, '\n\n')
  648. error(('vim.pack:\n\n%s'):format(error_str))
  649. end
  650. end
  651. --- @param p vim.pack.Plug
  652. --- @return string
  653. local function compute_feedback_lines_single(p)
  654. if p.info.err ~= '' then
  655. return ('## %s\n\n %s'):format(p.spec.name, p.info.err:gsub('\n', '\n '))
  656. end
  657. local parts = { '## ' .. p.spec.name .. '\n' }
  658. local version_suffix = p.info.version_str == '' and '' or (' (%s)'):format(p.info.version_str)
  659. if p.info.sha_head == p.info.sha_target then
  660. parts[#parts + 1] = table.concat({
  661. 'Path: ' .. p.path,
  662. 'Source: ' .. p.spec.src,
  663. 'State: ' .. p.info.sha_target .. version_suffix,
  664. }, '\n')
  665. if p.info.update_details ~= '' then
  666. local details = p.info.update_details:gsub('\n', '\n• ')
  667. parts[#parts + 1] = '\n\nAvailable newer versions:\n• ' .. details
  668. end
  669. else
  670. parts[#parts + 1] = table.concat({
  671. 'Path: ' .. p.path,
  672. 'Source: ' .. p.spec.src,
  673. 'State before: ' .. p.info.sha_head,
  674. 'State after: ' .. p.info.sha_target .. version_suffix,
  675. '',
  676. 'Pending updates:',
  677. p.info.update_details,
  678. }, '\n')
  679. end
  680. return table.concat(parts, '')
  681. end
  682. --- @param plug_list vim.pack.Plug[]
  683. --- @param skip_same_sha boolean
  684. --- @return string[]
  685. local function compute_feedback_lines(plug_list, skip_same_sha)
  686. -- Construct plugin line groups for better report
  687. local report_err, report_update, report_same = {}, {}, {}
  688. for _, p in ipairs(plug_list) do
  689. --- @type string[]
  690. local group_arr = p.info.err ~= '' and report_err
  691. or (p.info.sha_head ~= p.info.sha_target and report_update or report_same)
  692. group_arr[#group_arr + 1] = compute_feedback_lines_single(p)
  693. end
  694. local lines = {}
  695. --- @param header string
  696. --- @param arr string[]
  697. local function append_report(header, arr)
  698. if #arr == 0 then
  699. return
  700. end
  701. header = header .. ' ' .. string.rep('─', 79 - header:len())
  702. table.insert(lines, header)
  703. vim.list_extend(lines, arr)
  704. end
  705. append_report('# Error', report_err)
  706. append_report('# Update', report_update)
  707. if not skip_same_sha then
  708. append_report('# Same', report_same)
  709. end
  710. return vim.split(table.concat(lines, '\n\n'), '\n')
  711. end
  712. --- @param plug_list vim.pack.Plug[]
  713. local function feedback_log(plug_list)
  714. local lines = { ('========== Update %s =========='):format(get_timestamp()) }
  715. vim.list_extend(lines, compute_feedback_lines(plug_list, true))
  716. lines[#lines + 1] = ''
  717. local log_path = vim.fn.stdpath('log') .. '/nvim-pack.log'
  718. vim.fn.mkdir(vim.fs.dirname(log_path), 'p')
  719. vim.fn.writefile(lines, log_path, 'a')
  720. end
  721. --- @param lines string[]
  722. --- @param on_finish fun()
  723. local function show_confirm_buf(lines, on_finish)
  724. -- Show buffer in a separate tabpage
  725. local bufnr = api.nvim_create_buf(true, true)
  726. api.nvim_buf_set_name(bufnr, 'nvim-pack://' .. bufnr .. '/confirm-update')
  727. api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
  728. vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr() } })
  729. local tab_id = api.nvim_get_current_tabpage()
  730. local win_id = api.nvim_get_current_win()
  731. local delete_buffer = vim.schedule_wrap(function()
  732. pcall(api.nvim_buf_delete, bufnr, { force = true })
  733. if api.nvim_tabpage_is_valid(tab_id) then
  734. vim.cmd.tabclose(api.nvim_tabpage_get_number(tab_id))
  735. end
  736. vim.cmd.redraw()
  737. end)
  738. -- Define action on accepting confirm
  739. local function finish()
  740. on_finish()
  741. delete_buffer()
  742. end
  743. -- - Use `nested` to allow other events (useful for statuslines)
  744. api.nvim_create_autocmd('BufWriteCmd', { buffer = bufnr, nested = true, callback = finish })
  745. -- Define action to cancel confirm
  746. --- @type integer
  747. local cancel_au_id
  748. local function on_cancel(data)
  749. if tonumber(data.match) ~= win_id then
  750. return
  751. end
  752. pcall(api.nvim_del_autocmd, cancel_au_id)
  753. delete_buffer()
  754. end
  755. cancel_au_id = api.nvim_create_autocmd('WinClosed', { nested = true, callback = on_cancel })
  756. -- Set buffer-local options last (so that user autocmmands could override)
  757. vim.bo[bufnr].modified = false
  758. vim.bo[bufnr].modifiable = false
  759. vim.bo[bufnr].buftype = 'acwrite'
  760. vim.bo[bufnr].filetype = 'nvim-pack'
  761. -- Attach in-process LSP for more capabilities
  762. vim.lsp.buf_attach_client(bufnr, require('vim.pack._lsp').client_id)
  763. end
  764. --- @class vim.pack.keyset.update
  765. --- @inlinedoc
  766. --- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`.
  767. --- Update plugins
  768. ---
  769. --- - Download new changes from source.
  770. --- - Infer update info (current/target state, changelog, etc.).
  771. --- - Depending on `force`:
  772. --- - If `false`, show confirmation buffer. It lists data about all set to
  773. --- update plugins. Pending changes starting with `>` will be applied while
  774. --- the ones starting with `<` will be reverted.
  775. --- It has special in-process LSP server attached to provide more interactive
  776. --- features. Currently supported methods:
  777. --- - 'textDocument/documentSymbol' (`gO` via |lsp-defaults|
  778. --- or |vim.lsp.buf.document_symbol()|) - show structure of the buffer.
  779. --- - 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) -
  780. --- show more information at cursor. Like details of particular pending
  781. --- change or newer tag.
  782. ---
  783. --- Execute |:write| to confirm update, execute |:quit| to discard the update.
  784. --- - If `true`, make updates right away.
  785. ---
  786. --- Notes:
  787. --- - Every actual update is logged in "nvim-pack.log" file inside "log" |stdpath()|.
  788. ---
  789. --- @param names? string[] List of plugin names to update. Must be managed
  790. --- by |vim.pack|, not necessarily already added to current session.
  791. --- Default: names of all plugins added to current session via |vim.pack.add()|.
  792. --- @param opts? vim.pack.keyset.update
  793. function M.update(names, opts)
  794. vim.validate('names', names, vim.islist, true, 'list')
  795. opts = vim.tbl_extend('force', { force = false }, opts or {})
  796. local plug_list = plug_list_from_names(names)
  797. if #plug_list == 0 then
  798. notify('Nothing to update', 'WARN')
  799. return
  800. end
  801. git_ensure_exec()
  802. -- Perform update
  803. local timestamp = get_timestamp()
  804. --- @async
  805. --- @param p vim.pack.Plug
  806. local function do_update(p)
  807. -- Fetch
  808. -- Using '--tags --force' means conflicting tags will be synced with remote
  809. git_cmd(
  810. { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' },
  811. p.path
  812. )
  813. -- Compute change info: changelog if any, new tags if nothing to update
  814. infer_update_details(p)
  815. -- Checkout immediately if not need to confirm
  816. if opts.force then
  817. checkout(p, timestamp, true)
  818. end
  819. end
  820. local progress_title = opts.force and 'Updating' or 'Downloading updates'
  821. run_list(plug_list, do_update, progress_title)
  822. if opts.force then
  823. feedback_log(plug_list)
  824. return
  825. end
  826. -- Show report in new buffer in separate tabpage
  827. local lines = compute_feedback_lines(plug_list, false)
  828. show_confirm_buf(lines, function()
  829. -- TODO(echasnovski): Allow to not update all plugins via LSP code actions
  830. --- @param p vim.pack.Plug
  831. local plugs_to_checkout = vim.tbl_filter(function(p)
  832. return p.info.err == '' and p.info.sha_head ~= p.info.sha_target
  833. end, plug_list)
  834. if #plugs_to_checkout == 0 then
  835. notify('Nothing to update', 'WARN')
  836. return
  837. end
  838. local timestamp2 = get_timestamp()
  839. --- @async
  840. --- @param p vim.pack.Plug
  841. local function do_checkout(p)
  842. checkout(p, timestamp2, true)
  843. end
  844. run_list(plugs_to_checkout, do_checkout, 'Applying updates')
  845. feedback_log(plugs_to_checkout)
  846. end)
  847. end
  848. --- Remove plugins from disk
  849. ---
  850. --- @param names string[] List of plugin names to remove from disk. Must be managed
  851. --- by |vim.pack|, not necessarily already added to current session.
  852. function M.del(names)
  853. vim.validate('names', names, vim.islist, false, 'list')
  854. local plug_list = plug_list_from_names(names)
  855. if #plug_list == 0 then
  856. notify('Nothing to remove', 'WARN')
  857. return
  858. end
  859. for _, p in ipairs(plug_list) do
  860. trigger_event(p, 'PackChangedPre', 'delete')
  861. vim.fs.rm(p.path, { recursive = true, force = true })
  862. active_plugins[p.path] = nil
  863. notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO')
  864. trigger_event(p, 'PackChanged', 'delete')
  865. end
  866. end
  867. --- @inlinedoc
  868. --- @class vim.pack.PlugData
  869. --- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with defaults made explicit.
  870. --- @field path string Plugin's path on disk.
  871. --- @field active boolean Whether plugin was added via |vim.pack.add()| to current session.
  872. --- Get data about all plugins managed by |vim.pack|
  873. --- @return vim.pack.PlugData[]
  874. function M.get()
  875. -- Process active plugins in order they were added. Take into account that
  876. -- there might be "holes" after `vim.pack.del()`.
  877. local active = {} --- @type table<integer,vim.pack.Plug?>
  878. for _, p_active in pairs(active_plugins) do
  879. active[p_active.id] = p_active.plug
  880. end
  881. --- @type vim.pack.PlugData[]
  882. local res = {}
  883. for i = 1, n_active_plugins do
  884. if active[i] then
  885. res[#res + 1] = { spec = vim.deepcopy(active[i].spec), path = active[i].path, active = true }
  886. end
  887. end
  888. --- @async
  889. local function do_get()
  890. -- Process not active plugins
  891. local plug_dir = get_plug_dir()
  892. for n, t in vim.fs.dir(plug_dir, { depth = 1 }) do
  893. local path = vim.fs.joinpath(plug_dir, n)
  894. if t == 'directory' and not active_plugins[path] then
  895. local spec = { name = n, src = git_cmd({ 'remote', 'get-url', 'origin' }, path) }
  896. res[#res + 1] = { spec = spec, path = path, active = false }
  897. end
  898. end
  899. -- Make default `version` explicit
  900. for _, p_data in ipairs(res) do
  901. if not p_data.spec.version then
  902. p_data.spec.version = git_get_default_branch(p_data.path)
  903. end
  904. end
  905. end
  906. async.run(do_get):wait()
  907. return res
  908. end
  909. return M