Files
neovim/runtime/lua/vim/_core/help.lua
Yochem van Rosmalen a6252c6683 refactor(help): move escaping logic to Lua #37757
Problem:
Escaping logic for {subject} in ex cmd `:help {subject}` is done in a
messy 200+ lines C function which is hard to maintain and improve.

Solution:
Rewrite in Lua. Use `string.gsub()` instead of looping over characters
to improve clarity and add many more tests to be able to confidently
improve current code later on.
2026-02-10 07:43:17 -05:00

132 lines
4.2 KiB
Lua

local M = {}
local tag_exceptions = {
-- Interpret asterisk (star, '*') literal but name it 'star'
['*'] = 'star',
['g*'] = 'gstar',
['[*'] = '[star',
[']*'] = ']star',
[':*'] = ':star',
['/*'] = '/star',
['/\\*'] = '/\\\\star',
['\\\\star'] = '/\\\\star',
['"*'] = 'quotestar',
['**'] = 'starstar',
['cpo-*'] = 'cpo-star',
-- Literal question mark '?'
['?'] = '?',
['??'] = '??',
[':?'] = ':?',
['?<CR>'] = '?<CR>',
['g?'] = 'g?',
['g?g?'] = 'g?g?',
['g??'] = 'g??',
['-?'] = '-?',
['q?'] = 'q?',
['v_g?'] = 'v_g?',
['/\\?'] = '/\\\\?',
-- Backslash-escaping hell
['/\\%(\\)'] = '/\\\\%(\\\\)',
['/\\z(\\)'] = '/\\\\z(\\\\)',
['\\='] = '\\\\=',
['\\%$'] = '/\\\\%\\$',
-- Some expressions are literal but without the 'expr-' prefix. Note: not all 'expr-' subjects!
['expr-!=?'] = '!=?',
['expr-!~?'] = '!\\~?',
['expr-<=?'] = '<=?',
['expr-<?'] = '<?',
['expr-==?'] = '==?',
['expr-=~?'] = '=~?',
['expr->=?'] = '>=?',
['expr->?'] = '>?',
['expr-is?'] = 'is?',
['expr-isnot?'] = 'isnot?',
}
---Transform a help tag query into a search pattern for find_tags().
---
---This function converts user input from `:help {subject}` into a regex pattern that balances
---literal matching with wildcard support. Vim help tags can contain characters that have special
---meaning in regex (like *, ?, |), but we also want to support wildcard searches.
---
---Examples:
--- '*' --> 'star' (literal match for the * command help tag)
--- 'buffer*' --> 'buffer.*' (wildcard: find all buffer-related tags)
--- 'CTRL-W' --> stays as 'CTRL-W' (already in tag format)
--- '^A' --> 'CTRL-A' (caret notation converted to tag format)
---
---@param word string The help subject as entered by the user
---@return string pattern The escaped regex pattern to search for in tag files
function M.escape_subject(word)
local replacement = tag_exceptions[word]
if replacement then
return replacement
end
-- Add prefix '/\\' to patterns starting with a backslash
-- Examples: \S, \%^, \%(, \zs, \z1, \@<, \@=, \@<=, \_$, \_^
if word:match([[^\.$]]) or word:match('^\\[%%_z@]') then
word = [[/\]] .. word
word = word:gsub('[$.~]', [[\%0]])
word = word:gsub('|', 'bar')
else
-- Fix for bracket expressions and curly braces:
-- '\' --> '\\' (needs to come first)
-- '[' --> '\[' (escape the opening bracket)
-- ':[' --> ':\[' (escape the opening bracket)
-- '\{' --> '\\{' (for '\{' pattern matching)
-- '(' --> '' (parentheses around option tags should be ignored)
word = word:gsub([[\+]], [[\\]])
word = word:gsub([[^%[]], [[\[]])
word = word:gsub([[^:%[]], [[:\[]])
word = word:gsub([[^\{]], [[\\{]])
word = word:gsub([[^%(']], [[']])
word = word:gsub('|', 'bar')
word = word:gsub([["]], 'quote')
word = word:gsub('[$.~]', [[\%0]])
word = word:gsub('%*', '.*')
word = word:gsub('?', '.')
-- Handle control characters.
-- First convert raw control chars to the caret notation
-- E.g. 0x01 --> '^A' etc.
---@type string
word = word:gsub('([\1-\31])', function(ctrl_char)
-- '^\' needs an extra backslash
local repr = string.char(ctrl_char:byte() + 64):gsub([[\]], [[\\]])
return '^' .. repr
end)
-- Change caret notation to 'CTRL-', except '^_'
-- E.g. 'i^G^J' --> 'iCTRL-GCTRL-J'
word = word:gsub('%^([^_])', 'CTRL-%1')
-- Add underscores around 'CTRL-X' characters
-- E.g. 'iCTRL-GCTRL-J' --> 'i_CTRL-G_CTRL-J'
-- Only exception: 'CTRL-{character}'
word = word:gsub('([^_])CTRL%-', '%1_CTRL-')
word = word:gsub('(CTRL%-[^{])([^_\\])', '%1_%2')
-- Skip function arguments
-- E.g. 'abs({expr})' --> 'abs'
-- E.g. 'abs([arg])' --> 'abs'
word = word:gsub('%({.*', '')
word = word:gsub('%(%[.*', '')
-- Skip punctuation after second apostrophe/curly brace
-- E.g. ''option',' --> ''option''
-- E.g. '{address},' --> '{address}'
-- E.g. '`command`,' --> 'command' (backticks are removed too, but '``' stays '``')
word = word:gsub([[^'([^']*)'.*]], [['%1']])
word = word:gsub([[^{([^}]*)}.*]], '{%1}')
word = word:gsub([[^`([^`]+)`.*]], '%1')
end
return word
end
return M