diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 3df04c4845..840d7a57bb 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -2525,6 +2525,192 @@ vim.loader.reset({path}) *vim.loader.reset()* • {path} (`string?`) path to 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..`. + +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* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index a9524eb35b..b0471cb2de 100644 --- 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 index ad58a74b31..f72b558dab 100644 --- 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 index 5a55ea8346..f598ae84bd 100644 --- 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 new file mode 100644 index 0000000000..464fd88c0d --- /dev/null +++ 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/_editor.lua b/runtime/lua/vim/_editor.lua index ffec37bb11..3cacff7b42 100644 --- 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 index 585a02f330..3efbd04741 100644 --- 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 index 14bc7b53e5..6cb772118a 100644 --- 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 new file mode 100644 index 0000000000..b2f9bcedcf --- /dev/null +++ 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..`. +--- +---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 + 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 +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 + 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 new file mode 100644 index 0000000000..52dff730c2 --- /dev/null +++ b/runtime/lua/vim/pack/_lsp.lua @@ -0,0 +1,168 @@ +local M = {} + +local capabilities = { + codeActionProvider = true, + documentSymbolProvider = true, + hoverProvider = true, +} +--- @type table +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 index 0d9f3cdcba..501f51bc9c 100755 --- 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 index ce5058d7de..6fd62771cf 100644 --- 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 new file mode 100644 index 0000000000..4fd7ef97a1 --- /dev/null +++ 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)