Files
neovim/test/functional/ex_cmds/help_spec.lua
Lewis Russell d663d8f4b3 perf(help): generate helptags with LPeg
Problem: Generating helptags builds and queries a full vimdoc syntax
tree. This is much slower than the previous scanner and also happens
before discovering that the tags file cannot be written.

Solution: Extract tags with an LPeg grammar, reading each file once and
skipping example blocks and ordinary text. Open the output first so
unwritable directories can return immediately.

This restores legacy handling of tags inside inline code spans. On an
arm64 Mac, the 5.36 MB runtime corpus takes 24 ms instead of 604 ms,
with identical generated tags.

AI-assisted
2026-09-10 12:34:13 +01:00

524 lines
17 KiB
Lua

local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local describe, it, before_each, after_each, finally =
t.describe, t.it, t.before_each, t.after_each, t.finally
local clear = n.clear
local eval = n.eval
local command = n.command
local eq = t.eq
local fn = n.fn
local api = n.api
local mkdir = t.mkdir
local rmdir = n.rmdir
local write_file = t.write_file
local cursor = n.api.nvim_win_set_cursor
local function buf_word()
local word = n.fn.expand('<cWORD>')
local bufname = n.fn.fnamemodify(n.fn.bufname('%'), ':t')
return { word, bufname }
end
local function open_helptag()
-- n.exec [[:normal! K]]
n.exec [[:help!]]
local rv = buf_word()
if n.fn.winnr('$') > 1 then
n.command('close')
end
return rv
end
local function set_lines(text)
n.exec_lua(
[[
vim.cmd'%delete _'
vim.api.nvim_paste(vim.text.indent(-1, ...), false, -1)
]],
text
)
end
describe(':help', function()
before_each(clear)
it('{subject}', function()
n.command('helptags ++t $VIMRUNTIME/doc')
local function check_tag(cmd, tag)
local cmd_ok = t.pcall(n.command, cmd)
local found = n.api.nvim_get_current_line():find(tag, 1, true)
local errmsg = (not cmd_ok and 'command failed') or (not found and 'tag not found') or '?'
assert(
cmd_ok and found,
string.format('Expected `:%s` to jump to tag `%s`, but %s', cmd, tag, errmsg)
)
n.command('helpclose')
end
check_tag('help', '*help.txt*')
check_tag('help |', '*bar*')
check_tag('help "*', '*quotestar*')
check_tag('help ch??khealth', '*:checkhealth*')
check_tag([[help \\star]], [[*/\star*]])
check_tag('help /*', [[*/\star*]])
check_tag('help ?', '*?*')
check_tag('help ??', '*??*')
check_tag('help expr-!=?', '*expr-!=?*')
check_tag('help /<cr>', '*/<CR>*')
check_tag([[help %(\\)]], [[*/\%(\)*]])
check_tag('help %^', [[/\%^]])
check_tag('help /_^G', '/_CTRL-G')
check_tag([[help \0]], [[\0]])
check_tag('help !', '*!*')
check_tag('help #{}', '*#{}*')
check_tag('help %:8', '*%:8*')
check_tag('help &', '*&*')
check_tag([[help '']], [[*''*]])
check_tag([[help '(]], [[*'(*]])
check_tag([[help '0]], [[*'0*]])
check_tag([[help 'ac']], [[*'ac'*]])
check_tag([[help '{]], [[*'{*]])
check_tag('help )', '*)*')
check_tag('help +', '*+*')
check_tag('help +opt', '*++opt*')
check_tag('help --', '*--*')
check_tag('help -?', '*-?*')
check_tag('help .', '*.*')
check_tag('help :', '*:*')
check_tag([[help :'}]], [[*:'}*]])
check_tag('help :,', '*:,*')
check_tag('help :<abuf>', '*:<abuf>*')
check_tag([[help :\|]], [[*:\bar*]])
check_tag([[help :\\|]], [[*:\bar*]])
check_tag('help _', '*_*')
check_tag('help `', '*`*')
check_tag('help `(', '*`(*')
check_tag([[help `:ls`.]], [[*:ls*]])
check_tag('help [', '*[*')
check_tag('help [#', '*[#*')
check_tag([[help [']], [[*['*]])
check_tag('help [(', '*[(*')
check_tag('help [++opt]', '*[++opt]*')
check_tag('help [:tab:]', '*[:tab:]*')
check_tag('help [count]', '*[count]*')
check_tag('help :[range]', '*:[range]*')
check_tag('help [<space>', '[<Space>')
check_tag('help ]_^D', ']_CTRL-D')
check_tag('help :set^=', '*:set^=*')
check_tag([[help $HOME]], [[*$HOME*]])
check_tag('help <C-pagedown>', '*CTRL-<PageDown>*')
check_tag('help ^A', '*CTRL-A*')
check_tag('help ^W_+', '*CTRL-W_+*')
check_tag('help ^W<up>', '*CTRL-W_<Up>*')
check_tag('help ^W>', '*CTRL-W_>*')
check_tag('help ^W^]', '*CTRL-W_CTRL-]*')
check_tag('help ^W^', '*CTRL-W_^*')
check_tag('help ^W|', '*CTRL-W_bar*')
check_tag('help ^Wg<tab>', '*CTRL-W_g<Tab>*')
check_tag('help ^]', '*CTRL-]*')
check_tag('help ^{char}', 'CTRL-{char}')
check_tag('help [^L', '[_CTRL-L')
check_tag('help <C-', '*<C-*')
check_tag('help <S-CR>', '*<S-CR>*')
check_tag('help <<', '*<<*')
check_tag('help <>', '*<>*')
check_tag([[help i^x^y]], '*i_CTRL-X_CTRL-Y*')
check_tag([[help CTRL-\_CTRL-N]], [[*CTRL-\_CTRL-N*]])
check_tag([[help i_CTRL-U-default]], [[*i_CTRL-U-default*]])
check_tag([[help expr-=~?]], [[*expr-=~?*]])
check_tag([[help map-CTRL-C]], '*map-CTRL-C*')
check_tag([=[help telnet-CTRL-]]=], '*telnet-CTRL-]*')
check_tag([[help c_CTRL-SHIFT-V]], '*c_CTRL-SHIFT-V*')
check_tag([[help i_CTRL-SHIFT-V]], '*i_CTRL-SHIFT-V*')
check_tag([[help c_CTRL-SHIFT-Q]], '*c_CTRL-SHIFT-Q*')
check_tag([[exe "help i\<C-\>\<C-G>"]], [[*i_CTRL-\_CTRL-G*]])
check_tag([[exe "help \<C-V>"]], '*CTRL-V*')
check_tag([[exe "help! arglistid([{winnr})"]], '*arglistid()*')
check_tag([[exe "help! 'autoindent'."]], [[*'autoindent'*]])
check_tag('exusage', '*:index*')
check_tag('viusage', '*normal-index*')
-- Test cases for removed exceptions
check_tag('help /\\(\\)', '*/\\(\\)*')
check_tag('help :s\\=', '*:s\\=*')
check_tag([[help expr-']], [[*expr-'*]])
check_tag('help expr-barbar', '*expr-barbar*')
check_tag([[help s/\9]], [[*s/\9*]])
check_tag([[help s/\U]], [[*s/\U*]])
check_tag([[help s/\~]], [[*s/\~*]])
check_tag([[help \|]], [[*/\bar*]])
end)
it('":help!" (bang + no args) guesses the best tag near cursor', function()
n.command('helptags ++t $VIMRUNTIME/doc')
-- n.command('enew')
-- n.command('set filetype=help')
-- n.command [[set keywordprg=:help]]
-- Failure modes:
set_lines ''
cursor(0, { 1, 1 })
t.matches('E349: No identifier under cursor', t.pcall_err(n.exec, [[:help!]]))
set_lines 'xxxxxxxxx'
cursor(0, { 1, 4 })
t.matches('E149: No help for xxxxxxxxx', t.pcall_err(n.exec, [[:help!]]))
-- Success:
set_lines 'some plain text'
cursor(0, { 1, 5 }) -- on 'plain'
eq({ '*ft-plaintex-syntax*', 'syntax.txt' }, open_helptag())
set_lines ':help command'
cursor(0, { 1, 4 })
eq({ '*:help*', 'helphelp.txt' }, open_helptag())
set_lines ' :help command'
cursor(0, { 1, 5 })
eq({ '*:command*', 'map.txt' }, open_helptag())
set_lines 'v:version name'
cursor(0, { 1, 5 })
eq({ '*v:version*', 'vvars.txt' }, open_helptag())
cursor(0, { 1, 2 })
eq({ '*v:version*', 'vvars.txt' }, open_helptag())
set_lines "See 'option' for more."
cursor(0, { 1, 6 }) -- on 'option'
eq({ "*'option'*", 'helphelp.txt' }, open_helptag())
set_lines ':command-nargs'
cursor(0, { 1, 7 }) -- on 'nargs'
eq({ '*:command-nargs*', 'map.txt' }, open_helptag())
set_lines '|("vim.lsp.foldtext()")|'
cursor(0, { 1, 10 })
eq({ '*vim.lsp.foldtext()*', 'lsp.txt' }, open_helptag())
set_lines 'nvim_buf_detach_event[{buf}]'
cursor(0, { 1, 10 })
eq({ '*nvim_buf_detach_event*', 'api.txt' }, open_helptag())
set_lines '{buf}'
cursor(0, { 1, 1 })
eq({ '*:buf*', 'windows.txt' }, open_helptag())
set_lines '(`vim.lsp.ClientConfig`)'
cursor(0, { 1, 1 })
eq({ '*vim.lsp.ClientConfig*', 'lsp.txt' }, open_helptag())
set_lines "vim.lsp.enable('clangd')"
cursor(0, { 1, 3 })
eq({ '*vim.lsp.enable()*', 'lsp.txt' }, open_helptag())
set_lines "vim.lsp.enable('clangd')"
cursor(0, { 1, 6 })
eq({ '*vim.lsp.enable()*', 'lsp.txt' }, open_helptag())
set_lines "vim.lsp.enable('clangd')"
cursor(0, { 1, 9 })
eq({ '*vim.lsp.enable()*', 'lsp.txt' }, open_helptag())
set_lines 'assert(vim.lsp.get_client_by_id(client_id))'
cursor(0, { 1, 12 })
eq({ '*vim.lsp.get_client_by_id()*', 'lsp.txt' }, open_helptag())
set_lines "vim.api.nvim_create_autocmd('LspAttach', {"
cursor(0, { 1, 7 })
eq({ '*nvim_create_autocmd()*', 'api.txt' }, open_helptag())
-- Falls back to <cword> when all trimming fails.
set_lines "'@lsp.type.function'"
cursor(0, { 1, 2 }) -- on 'lsp'
eq({ '*lsp*', 'lsp.txt' }, open_helptag())
set_lines "'@lsp.type.function'"
cursor(0, { 1, 14 }) -- on 'function'
eq({ '*:function*', 'userfunc.txt' }, open_helptag())
set_lines ' • `@lsp.type.<type>.<ft>` for the type'
cursor(0, { 1, 6 }) -- on backtick '`' (byte 6, after 2 spaces + 3-byte '•' + space)
eq({ '*lsp*', 'lsp.txt' }, open_helptag())
set_lines [[
- `root_dir` usages akin to >lua
root_dir = require'lspconfig.util'.root_pattern(...)
<
require'lspconfig.util'.root_pattern(...)
]]
cursor(0, { 2, 17 }) -- on "require"
eq({ '*require()*', 'luaref.txt' }, open_helptag())
set_lines '`:lsp restart`. You'
cursor(0, { 1, 6 }) -- on "restart"
eq({ '*:restart*', 'gui.txt' }, open_helptag())
--
-- Test with actual helpfiles. This affects getcompletion(…,'help') ...
--
n.command(':help lua')
n.feed('gg/package.searchpath<cr>')
eq({ "vim.cmd.edit(package.searchpath('jit.p',", 'lua.txt' }, buf_word())
-- ^ cursor on "package"
n.command(':help!')
eq({ '*packages*', 'pack.txt' }, buf_word())
n.command(':help lsp')
n.feed('gg/type.<lt>type><cr>')
eq({ '`@lsp.type.<type>.<ft>`', 'lsp.txt' }, buf_word())
-- ^ cursor on "type"
n.command(':help!')
eq({ '*type()*', 'vimfn.txt' }, buf_word())
n.feed('<c-o>f<lt>')
eq({ '`@lsp.type.<type>.<ft>`', 'lsp.txt' }, buf_word())
-- ^ cursor on "<"
n.command(':help!')
n.command(':help lsp')
n.feed('gg/codelens.run()|<cr>')
eq({ '|vim.lsp.codelens.run()|', 'lsp.txt' }, buf_word())
-- ^ cursor on "codelens"
n.command(':help!')
eq({ '*vim.lsp.codelens.run()*', 'lsp.txt' }, buf_word())
end)
it('K resolves generic type names in help', function()
command('helptags ++t $VIMRUNTIME/doc')
for _, ty in ipairs({
'vim.async.Task<R>',
'vim.async.Task<R...>',
'vim.async.Task<R>[]',
}) do
command('help lua-async')
command('setlocal keywordprg=:help!')
assert(fn.search('\\V(`' .. ty .. '`)', 'W') > 0)
command('normal! fTK')
eq({ '*vim.async.Task*', 'lua-async.txt' }, buf_word())
end
end)
it('K resolves nullable type names in help', function()
command('helptags ++t $VIMRUNTIME/doc')
for _, ref in ipairs({
{ 'lsp', 'vim.lsp.Client', '?' },
{ 'diagnostic', 'vim.Diagnostic', '?' },
{ 'diagnostic', 'vim.Diagnostic', '[]?' },
}) do
command('help ' .. ref[1])
command('setlocal keywordprg=:help!')
assert(fn.search('\\V(`' .. ref[2] .. ref[3] .. '`)', 'W') > 0)
command('normal! 2lK')
eq({ '*' .. ref[2] .. '*', ref[1] .. '.txt' }, buf_word())
end
end)
it('window closed makes cursor return to a valid win/buf #9773', function()
n.add_builddir_to_rtp()
command('help help')
eq(1001, fn.win_getid())
command('quit')
eq(1000, fn.win_getid())
command('autocmd WinNew * wincmd p')
command('help help')
-- Window 1002 is opened, but the autocmd switches back to 1000 and
-- creates the help buffer there instead.
eq(1000, fn.win_getid())
command('quit')
-- Before #9773, Nvim would crash on quitting the help window.
eq(1002, fn.win_getid())
end)
it('multibyte help tags work #23975', function()
mkdir('Xhelptags')
finally(function()
rmdir('Xhelptags')
end)
mkdir('Xhelptags/doc')
write_file('Xhelptags/doc/Xhelptags.txt', '*…*')
command('helptags Xhelptags/doc')
command('set rtp+=Xhelptags')
command('help …')
eq('*…*', api.nvim_get_current_line())
end)
end)
describe(':helptags', function()
before_each(function()
for _, sfx in ipairs({ '', '2' }) do
fn.mkdir(('Xhelptags%s/doc'):format(sfx), 'p')
for _, tag in ipairs({ 'Xa', 'Xb' }) do
write_file(('Xhelptags%s/doc/%s%s.txt'):format(sfx, tag, sfx), ('*%s%s*'):format(tag, sfx))
end
end
clear()
command('set rtp+=Xhelptags,Xhelptags2')
end)
after_each(function()
rmdir('Xhelptags')
rmdir('Xhelptags2')
end)
it('requires an argument', function()
local msg = t.pcall_err(command, 'helptags')
eq(true, msg:find('E471') ~= nil)
end)
it('{dir}', function()
-- Helpfiles in sub-directories are found and named relative to {dir}.
fn.mkdir('Xhelptags/doc/sub', 'p')
-- A "|" in a tag would break |links|, and "*Xd" is not closed, so neither is a tag.
write_file('Xhelptags/doc/sub/Xc.txt', '*Xc*\n*X|c*\n*Xd\n')
-- CRLF helpfile: "\r" must not confuse the parser into finding tags in an example.
write_file('Xhelptags/doc/Xe.txt', '*Xe*\r\n>\r\n\t+-----+\r\n\t|/* a.c */ |/* b.c */ |\r\n')
for _, dir in ipairs({
'Xhelptags/doc',
'./Xhelptags/doc',
fn.fnamemodify('Xhelptags/doc', ':p'),
}) do
command('helptags ' .. fn.fnameescape(dir))
eq({
'Xa\tXa.txt\t/*Xa*',
'Xb\tXb.txt\t/*Xb*',
'Xc\tsub/Xc.txt\t/*Xc*',
'Xe\tXe.txt\t/*Xe*',
}, fn.readfile('Xhelptags/doc/tags'))
end
command('help Xa')
eq('*Xa*', api.nvim_get_current_line())
command('help Xc')
eq('*Xc*', api.nvim_get_current_line())
end)
it('ignores tags in examples and resumes after unindented text', function()
for _, newline in ipairs({ '\n', '\r\n' }) do
write_file(
'Xhelptags/doc/Xa.txt',
table.concat({
'*Xa* *Xz*',
'Some ordinary prose.',
'prefix*invalid* *invalid*suffix *bad|tag* **',
'>lua',
'',
' *example*',
'End of example.',
'',
' *Xc*',
'More ordinary prose.',
'*Xd*', -- No final newline.
}, newline),
true
)
command('helptags Xhelptags/doc')
eq({
'Xa\tXa.txt\t/*Xa*',
'Xb\tXb.txt\t/*Xb*',
'Xc\tXa.txt\t/*Xc*',
'Xd\tXa.txt\t/*Xd*',
'Xz\tXa.txt\t/*Xz*',
}, fn.readfile('Xhelptags/doc/tags'))
end
end)
it('overwrites existing tags file', function()
write_file('Xhelptags/doc/tags', 'Xstale Xold.txt /*Xstale*', nil, true)
write_file('Xhelptags/doc/Xa.txt', 'no tags here', nil, true)
write_file('Xhelptags/doc/Xb.txt', 'no tags here', nil, true)
command('helptags Xhelptags/doc')
eq({}, eval("readfile('Xhelptags/doc/tags')"))
end)
it('reports E150 E151 E152', function()
eq(true, t.pcall_err(command, 'helptags Xhelptags/doc/Xa.txt'):find('E150') ~= nil)
fn.mkdir('Xhelptags/Xempty', 'p')
eq(true, t.pcall_err(command, 'helptags Xhelptags/Xempty'):find('E151') ~= nil)
-- A directory named "tags" cannot be opened for writing.
fn.mkdir('Xhelptags/doc/tags', 'p')
eq(true, t.pcall_err(command, 'helptags Xhelptags/doc'):find('E152') ~= nil)
end)
it('ALL', function()
command('helptags ALL')
eq(eval("['Xa Xa.txt /*Xa*','Xb Xb.txt /*Xb*']"), eval("readfile('Xhelptags/doc/tags')"))
eq(eval("['Xa2 Xa2.txt /*Xa2*','Xb2 Xb2.txt /*Xb2*']"), eval("readfile('Xhelptags2/doc/tags')"))
command('help Xa2')
eq('*Xa2*', api.nvim_get_current_line())
end)
it('++t', function()
write_file('Xhelptags/doc/Xa.nlx', '*Xa*', nil, true)
command('helptags ++t Xhelptags/doc')
eq('help-tags tags 1', eval("readfile('Xhelptags/doc/tags')[-1]"))
-- Each language gets a "help-tags" tag naming its own tags file.
eq('help-tags tags-nl 1', eval("readfile('Xhelptags/doc/tags-nl')[-1]"))
end)
it('generates help-tag tag for VIMRUNTIME', function()
command('let $VIMRUNTIME="Xhelptags"')
command('helptags Xhelptags/doc')
eq('help-tags tags 1', eval("readfile('Xhelptags/doc/tags')[-1]"))
end)
it('errors on duplicate tags', function()
-- duplicate tags in different files
write_file('Xhelptags/doc/Xd.txt', '*Xa*', nil, true)
local msg = t.pcall_err(command, 'helptags Xhelptags/doc')
eq(true, msg:find('E154: Duplicate tag "Xa" in Xd.txt and Xa.txt', 1, true) ~= nil)
-- tags file should still be generated
eq(1, eval("filereadable('Xhelptags/doc/tags')"))
os.remove('Xhelptags/doc/Xd.txt')
-- duplicate tags in same file
write_file('Xhelptags/doc/Xa.txt', '\n*Xa*', nil, true)
msg = t.pcall_err(command, 'helptags Xhelptags/doc')
eq(true, msg:find('E154: Duplicate tag "Xa" in Xa.txt', 1, true) ~= nil)
eq(1, eval("filereadable('Xhelptags/doc/tags')"))
-- Duplicates do not abort the run: other languages are still processed.
write_file('Xhelptags/doc/Xa.nlx', '*Xnl*', nil, true)
msg = t.pcall_err(command, 'helptags Xhelptags/doc')
eq(true, msg:find('E154') ~= nil)
eq(false, msg:find('E5108') ~= nil)
eq(1, eval("filereadable('Xhelptags/doc/tags-nl')"))
end)
it('with translated help files', function()
write_file('Xhelptags/doc/Xa.nlx', '*Xa*', nil, true)
-- The suffix is matched case-insensitively.
write_file('Xhelptags/doc/Xc.FRX', '*Xc*', nil, true)
command('helptags Xhelptags/doc')
eq(eval("['Xa Xa.nlx /*Xa*']"), eval("readfile('Xhelptags/doc/tags-nl')"))
eq(eval("['Xc Xc.FRX /*Xc*']"), eval("readfile('Xhelptags/doc/tags-fr')"))
end)
end)