commit 47b0a718c3d96de4b5cb0e36f2bddf39b23099d0
parent a5d69326860fa28a4dd4921e4bdb662d2dcd0355
Author: Yochem van Rosmalen <git@yochem.nl>
Date: Tue, 16 Sep 2025 00:38:49 +0200
feat(help): gx opens help tag in web browser #35778
Problem:
`gx` does not work on tags in help buffers to open the documentation of that tag in the browser.
Solution:
Get the `optionlink`, `taglink` and `tag` TS nodes and set extmark "url" property.
`gx` then discovers the extmark "url" and opens it.
Diffstat:
3 files changed, 96 insertions(+), 37 deletions(-)
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -194,6 +194,8 @@ EDITOR
and |:vimgrep| commands.
• For security, 'exrc' no longer shows an "(a)llow" choice. Instead you must
"(v)iew" then run `:trust`.
+• |gx| in help buffers opens the online documentation for the tag under the
+ cursor.
EVENTS
diff --git a/runtime/ftplugin/help.lua b/runtime/ftplugin/help.lua
@@ -66,51 +66,85 @@ vim.keymap.set('n', '[[', function()
require('vim.treesitter._headings').jump({ count = -1 })
end, { buffer = 0, silent = false, desc = 'Jump to previous section' })
--- Add "runnables" for Lua/Vimscript code examples.
----@type table<integer, { lang: string, code: string }>
-local code_blocks = {}
local parser = assert(vim.treesitter.get_parser(0, 'vimdoc', { error = false }))
-local query = vim.treesitter.query.parse(
- 'vimdoc',
- [[
- (codeblock
- (language) @_lang
- .
- (code) @code
- (#any-of? @_lang "lua" "vim")
- (#set! @code lang @_lang))
-]]
-)
local root = parser:parse()[1]:root()
-for _, match, metadata in query:iter_matches(root, 0, 0, -1) do
- for id, nodes in pairs(match) do
- local name = query.captures[id]
- local node = nodes[1]
- local start, _, end_ = node:parent():range()
-
- if name == 'code' then
- local code = vim.treesitter.get_node_text(node, 0)
- local lang_node = match[metadata[id].lang][1] --[[@as TSNode]]
- local lang = vim.treesitter.get_node_text(lang_node, 0)
- for i = start + 1, end_ do
- code_blocks[i] = { lang = lang, code = code }
+-- Add "runnables" for Lua/Vimscript code examples.
+do
+ ---@type table<integer, { lang: string, code: string }>
+ local code_blocks = {}
+ local query = vim.treesitter.query.parse(
+ 'vimdoc',
+ [[
+ (codeblock
+ (language) @_lang
+ .
+ (code) @code
+ (#any-of? @_lang "lua" "vim")
+ (#set! @code lang @_lang))
+ ]]
+ )
+
+ for _, match, metadata in query:iter_matches(root, 0, 0, -1) do
+ for id, nodes in pairs(match) do
+ local name = query.captures[id]
+ local node = nodes[1]
+ local start, _, end_ = node:parent():range()
+
+ if name == 'code' then
+ local code = vim.treesitter.get_node_text(node, 0)
+ local lang_node = match[metadata[id].lang][1] --[[@as TSNode]]
+ local lang = vim.treesitter.get_node_text(lang_node, 0)
+ for i = start + 1, end_ do
+ code_blocks[i] = { lang = lang, code = code }
+ end
end
end
end
+
+ vim.keymap.set('n', 'g==', function()
+ local pos = vim.api.nvim_win_get_cursor(0)[1]
+ local code_block = code_blocks[pos]
+ if not code_block then
+ vim.print('No code block found')
+ elseif code_block.lang == 'lua' then
+ vim.cmd.lua(code_block.code)
+ elseif code_block.lang == 'vim' then
+ vim.cmd(code_block.code)
+ end
+ end, { buffer = true })
end
-vim.keymap.set('n', 'g==', function()
- local pos = vim.api.nvim_win_get_cursor(0)[1]
- local code_block = code_blocks[pos]
- if not code_block then
- vim.print('No code block found')
- elseif code_block.lang == 'lua' then
- vim.cmd.lua(code_block.code)
- elseif code_block.lang == 'vim' then
- vim.cmd(code_block.code)
+do
+ local ns = vim.api.nvim_create_namespace('nvim.help.urls')
+ local base = 'https://neovim.io/doc/user/helptag.html?tag='
+ local query = vim.treesitter.query.parse(
+ 'vimdoc',
+ [[
+ ((optionlink) @helplink)
+ (taglink
+ text: (_) @helplink)
+ (tag
+ text: (_) @helplink)
+ ]]
+ )
+
+ for _, match, _ in query:iter_matches(root, 0, 0, -1) do
+ for id, nodes in pairs(match) do
+ if query.captures[id] == 'helplink' then
+ for _, node in ipairs(nodes) do
+ local start_line, start_col, end_line, end_col = node:range()
+ local tag = vim.treesitter.get_node_text(node, 0)
+ vim.api.nvim_buf_set_extmark(0, ns, start_line, start_col, {
+ end_line = end_line,
+ end_col = end_col,
+ url = base .. vim.uri_encode(tag),
+ })
+ end
+ end
+ end
end
-end, { buffer = true })
+end
vim.b.undo_ftplugin = (vim.b.undo_ftplugin or '')
.. '\n sil! exe "nunmap <buffer> gO" | sil! exe "nunmap <buffer> g=="'
diff --git a/test/functional/lua/ui_spec.lua b/test/functional/lua/ui_spec.lua
@@ -13,7 +13,7 @@ local poke_eventloop = n.poke_eventloop
describe('vim.ui', function()
before_each(function()
- clear()
+ clear({ args_rm = { '-u' }, args = { '--clean' } })
end)
describe('select()', function()
@@ -180,5 +180,28 @@ describe('vim.ui', function()
end, { n.testprg('printargs-test'), 'arg1' })
)
end)
+
+ it('gx on a help tag opens URL', function()
+ n.command('helptags $VIMRUNTIME/doc')
+ n.command('help nvim.txt')
+
+ local link_ns = n.api.nvim_create_namespace('nvim.help.urls')
+ local tag = n.api.nvim_buf_get_extmarks(0, link_ns, 0, -1, {
+ limit = 1,
+ details = true,
+ })[1]
+
+ local url = tag[4].url
+ assert(url)
+
+ --- points to the neovim.io site
+ eq(true, vim.startswith(url, 'https://neovim.io/doc'))
+
+ -- tag is URI encoded
+ local param = url:match('%?tag=(.*)')
+ local tagname =
+ n.api.nvim_buf_get_text(0, tag[2], tag[3], tag[4].end_row, tag[4].end_col, {})[1]
+ eq(vim.uri_encode(tagname), param)
+ end)
end)
end)