From d01dc690e1e86911f65fcbe0b733aca613a4dcd0 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Thu, 23 Apr 2026 19:50:02 +0300 Subject: [PATCH] feat(pack): support `textDocument/documentLink` in confirmation buffer Problem: In `vim.pack.update()` confirmation buffer it might be useful to be able to use `gx` (open link at cursor) when cursor is on something like commit or tag. Solution: Add `textDocument/documentLink` method support for the in-process LSP. This may be used by LSP clients and makes `gx` automatically work. The shortcoming is that this requires tracking how to construct a URL from source and commit/tag. Currently only GitHub hosted repositories are supported. --- runtime/doc/pack.txt | 3 + runtime/lua/vim/pack.lua | 3 + runtime/lua/vim/pack/_lsp.lua | 89 ++++++++++++++++++++++++++-- test/functional/plugin/pack_spec.lua | 67 +++++++++++++++++++++ 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index ebbccba702..f01da75f7f 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -510,6 +510,9 @@ update({names}, {opts}) *vim.pack.update()* • |]]| and |[[| to navigate through plugin sections. Some features are provided via LSP: + • 'textDocument/documentLink' - compute links for plugin paths, sources, + commits, and tags. Use |gx| to open a link to an object at cursor. Only + supports GitHub hosted repositories for commit and tag links. • '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()|) - diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index ae965c68df..c6dec53d32 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -1247,6 +1247,9 @@ end --- - |]]| and |[[| to navigate through plugin sections. --- --- Some features are provided via LSP: +--- - 'textDocument/documentLink' - compute links for plugin paths, sources, +--- commits, and tags. Use |gx| to open a link to an object at cursor. +--- Only supports GitHub hosted repositories for commit and tag links. --- - '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 diff --git a/runtime/lua/vim/pack/_lsp.lua b/runtime/lua/vim/pack/_lsp.lua index a3342e9a30..869b0cf73f 100644 --- a/runtime/lua/vim/pack/_lsp.lua +++ b/runtime/lua/vim/pack/_lsp.lua @@ -1,7 +1,16 @@ local M = {} +local git_cmd = function(cmd, cwd, on_exit) + cmd = vim.list_extend({ 'git', '-c', 'gc.auto=0' }, cmd) + local env = vim.fn.environ() --- @type table + env.GIT_DIR, env.GIT_WORK_TREE = nil, nil + local sys_opts = { cwd = cwd, text = true, env = env, clear_env = true } + vim.system(cmd, sys_opts, vim.schedule_wrap(on_exit)) +end + local capabilities = { codeActionProvider = true, + documentLinkProvider = { resolveProvider = false }, documentSymbolProvider = true, executeCommandProvider = { commands = { 'delete_plugin', 'update_plugin', 'skip_update_plugin' } }, hoverProvider = true, @@ -64,6 +73,79 @@ 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.DocumentLink { range: vim.pack.lsp.Range, target: string } + +--- Finds a line range to be linked and computes the LSP style link +--- @param line string Buffer line to find a link in +--- @param pattern string Pattern matching link location and contents, like `'^Path: +()(.+)()$'` +--- @param link_type "commit"|"path"|"src"|"tag" +--- @param lnum number Line number in a buffer +--- @param src string Plugin source +--- @return vim.pack.lsp.DocumentLink? # A link structure according to the LSP specification +local function match_link(line, pattern, link_type, lnum, src) + -- Only support GitHub for now. Maybe other forges in the future. + local is_github = vim.startswith(src, 'https://github.com/') + if (link_type == 'commit' or link_type == 'tag') and not is_github then + return nil + end + + --- @type number?, string?, number? + local from, match, to = line:match(pattern) + if not (from and match and to) then + return + end + + -- Convert to UTF index used in LSP positions + from = vim.str_utfindex(line, 'utf-16', from - 1, false) + to = vim.str_utfindex(line, 'utf-16', to - 2, false) + + -- Reference: + -- - https://github.com/neovim/nvim-lspconfig/commit/e146efa + -- - https://github.com/neovim/nvim-lspconfig/commit/e146efacbafed3789ac568abcc5a981c5decaa58 + -- - https://github.com/neovim/nvim-lspconfig/releases/tag/v2.8.0 + local target = match + if link_type == 'commit' or link_type == 'tag' then + local src_noslash = src:gsub('/+$', '') + local middle = link_type == 'commit' and 'commit' or 'releases/tag' + target = ('%s/%s/%s'):format(src_noslash, middle, match) + elseif link_type == 'path' then + target = vim.uri_from_fname(match) + end + + return { + range = { + start = { line = lnum - 1, character = from }, + ['end'] = { line = lnum - 1, character = to }, + }, + target = target, + } +end + +--- @param params { textDocument: { uri: string } } +--- @param callback function +methods['textDocument/documentLink'] = function(params, callback) + local bufnr = get_confirm_bufnr(params.textDocument.uri) + if bufnr == nil then + return callback(nil, {}) + end + + --- @type vim.pack.lsp.DocumentLink[] + local links = {} + local cur_src = '' + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + for i, l in ipairs(lines) do + cur_src = l:match('^Source: +(.+)$') or cur_src + + links[#links + 1] = match_link(l, '^Path: +()(.+)()$', 'path', i, cur_src) + links[#links + 1] = match_link(l, '^Source: +()(.+)()$', 'src', i, cur_src) + links[#links + 1] = match_link(l, '^Revision[^:]*: +()(%S+)()', 'commit', i, cur_src) + -- NOTE: Assume that short revision works in the link + links[#links + 1] = match_link(l, '^[><] ()(%S+)()', 'commit', i, cur_src) + links[#links + 1] = match_link(l, '^• ()(.+)()$', 'tag', i, cur_src) + end + + return callback(nil, links) +end --- @param params { textDocument: { uri: string } } --- @param callback function @@ -211,18 +293,13 @@ methods['textDocument/hover'] = function(params, callback) 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 - - -- temporarily clear GIT env vars - local env = vim.fn.environ() --- @type table - env.GIT_DIR, env.GIT_WORK_TREE = nil, nil - vim.system(cmd, { cwd = path, env = env, clear_env = true }, vim.schedule_wrap(on_exit)) + git_cmd({ 'show', '--no-color', commit or tag }, path, on_exit) end local dispatchers = {} diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 1b97921273..755b3039a6 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -1436,7 +1436,74 @@ describe('vim.pack', function() eq(1, exec_lua('return #vim.lsp.get_clients({ bufnr = 0 })')) + -- textDocument/documentLink + --- @param ref ([number, number, number, number, string])[] + local assert_links = function(ref) + --- @type table[] + local out_links = exec_lua(function() + local params = { textDocument = vim.lsp.util.make_text_document_params(0) } + local response = vim.lsp.buf_request_sync(0, 'textDocument/documentLink', params) + return response[1].result + end) + + --- @type table[] + local ref_links = {} + for i, r in ipairs(ref) do + local start = { line = r[1], character = r[2] } + local end_ = { line = r[3], character = r[4] } + ref_links[i] = { range = { start = start, ['end'] = end_ }, target = r[5] } + end + + eq(ref_links, out_links) + end + + local fetch_src = repos_src.fetch + local fetch_path = pack_get_plug_path('fetch') + local fetch_path_uri = vim.uri_from_fname(fetch_path) + local semver_src = repos_src.semver + local semver_path = pack_get_plug_path('semver') + local semver_path_uri = vim.uri_from_fname(semver_path) + + -- With `file://` sources it can only return "Path:" and "Source:" links + local ref_file_links = { + { 11, 17, 11, 17 + fetch_path:len() - 1, fetch_path_uri }, -- Path: ... + { 12, 17, 12, 17 + fetch_src:len() - 1, fetch_src }, -- Source: ... + { 24, 10, 24, 10 + semver_path:len() - 1, semver_path_uri }, -- Path: ... + { 25, 10, 25, 10 + semver_src:len() - 1, semver_src }, -- Source: ... + } + assert_links(ref_file_links) + + -- Mock using GitHub sources which should provide all links + local fetch_github = 'https://github.com/user/fetch' + local semver_github = 'https://github.com/user/semver' + local lines = api.nvim_buf_get_lines(0, 0, -1, false) + lines[13] = lines[13]:gsub('^(Source: +)(.+)$', '%1' .. fetch_github) + lines[26] = lines[26]:gsub('^(Source: +)(.+)$', '%1' .. semver_github) + api.nvim_set_option_value('modifiable', true, { buf = 0 }) + api.nvim_buf_set_lines(0, 0, -1, false, lines) + api.nvim_set_option_value('modifiable', false, { buf = 0 }) + + local ref_github_links = { + { 11, 17, 11, 17 + fetch_path:len() - 1, fetch_path_uri }, -- Path: ... + { 12, 17, 12, 45, fetch_github }, -- Source: ... + { 13, 17, 13, 56, fetch_github .. '/commit/' .. lines[14]:match('%S+$') }, -- Revision before: ... + { 14, 17, 14, 56, fetch_github .. '/commit/' .. lines[15]:match('(%S+) %b()$') }, -- Revision before: ... + { 17, 2, 17, 8, fetch_github .. '/commit/' .. lines[18]:match('^< (%S+)') }, + { 18, 2, 18, 8, fetch_github .. '/commit/' .. lines[19]:match('^> (%S+)') }, + { 19, 2, 19, 8, fetch_github .. '/commit/' .. lines[20]:match('^> (%S+)') }, + { 24, 10, 24, 10 + semver_path:len() - 1, semver_path_uri }, -- Path: ... + { 25, 10, 25, 39, semver_github }, -- Source: ... + { 26, 10, 26, 49, semver_github .. '/commit/' .. lines[27]:match('(%S+) %b()$') }, -- Revision: ... + -- Should use UTF index + { 29, 2, 29, 7, semver_github .. '/releases/tag/v1.0.0' }, + { 30, 2, 30, 6, semver_github .. '/releases/tag/0.3.1' }, + } + assert_links(ref_github_links) + + n.exec('quit') + -- textDocument/documentSymbol + exec_lua('vim.pack.update()') exec_lua('vim.lsp.buf.document_symbol()') local loclist = vim.tbl_map(function(x) --- @param x table return {