help.lua (7193B)
1 local M = {} 2 3 local tag_exceptions = { 4 -- Interpret asterisk (star, '*') literal but name it 'star' 5 ['*'] = 'star', 6 ['g*'] = 'gstar', 7 ['[*'] = '[star', 8 [']*'] = ']star', 9 [':*'] = ':star', 10 ['/*'] = '/star', 11 ['/\\*'] = '/\\\\star', 12 ['\\\\star'] = '/\\\\star', 13 ['"*'] = 'quotestar', 14 ['**'] = 'starstar', 15 ['cpo-*'] = 'cpo-star', 16 17 -- Literal question mark '?' 18 ['?'] = '?', 19 ['??'] = '??', 20 [':?'] = ':?', 21 ['?<CR>'] = '?<CR>', 22 ['g?'] = 'g?', 23 ['g?g?'] = 'g?g?', 24 ['g??'] = 'g??', 25 ['-?'] = '-?', 26 ['q?'] = 'q?', 27 ['v_g?'] = 'v_g?', 28 ['/\\?'] = '/\\\\?', 29 30 -- Backslash-escaping hell 31 ['/\\%(\\)'] = '/\\\\%(\\\\)', 32 ['/\\z(\\)'] = '/\\\\z(\\\\)', 33 ['\\='] = '\\\\=', 34 ['\\%$'] = '/\\\\%\\$', 35 36 -- Some expressions are literal but without the 'expr-' prefix. Note: not all 'expr-' subjects! 37 ['expr-!=?'] = '!=?', 38 ['expr-!~?'] = '!\\~?', 39 ['expr-<=?'] = '<=?', 40 ['expr-<?'] = '<?', 41 ['expr-==?'] = '==?', 42 ['expr-=~?'] = '=~?', 43 ['expr->=?'] = '>=?', 44 ['expr->?'] = '>?', 45 ['expr-is?'] = 'is?', 46 ['expr-isnot?'] = 'isnot?', 47 } 48 49 ---Transform a help tag query into a search pattern for find_tags(). 50 --- 51 ---This function converts user input from `:help {subject}` into a regex pattern that balances 52 ---literal matching with wildcard support. Vim help tags can contain characters that have special 53 ---meaning in regex (like *, ?, |), but we also want to support wildcard searches. 54 --- 55 ---Examples: 56 --- '*' --> 'star' (literal match for the * command help tag) 57 --- 'buffer*' --> 'buffer.*' (wildcard: find all buffer-related tags) 58 --- 'CTRL-W' --> stays as 'CTRL-W' (already in tag format) 59 --- '^A' --> 'CTRL-A' (caret notation converted to tag format) 60 --- 61 ---@param word string The help subject as entered by the user 62 ---@return string pattern The escaped regex pattern to search for in tag files 63 function M.escape_subject(word) 64 local replacement = tag_exceptions[word] 65 if replacement then 66 return replacement 67 end 68 69 -- Add prefix '/\\' to patterns starting with a backslash 70 -- Examples: \S, \%^, \%(, \zs, \z1, \@<, \@=, \@<=, \_$, \_^ 71 if word:match([[^\.$]]) or word:match('^\\[%%_z@]') then 72 word = [[/\]] .. word 73 word = word:gsub('[$.~]', [[\%0]]) 74 word = word:gsub('|', 'bar') 75 else 76 -- Fix for bracket expressions and curly braces: 77 -- '\' --> '\\' (needs to come first) 78 -- '[' --> '\[' (escape the opening bracket) 79 -- ':[' --> ':\[' (escape the opening bracket) 80 -- '\{' --> '\\{' (for '\{' pattern matching) 81 -- '(' --> '' (parentheses around option tags should be ignored) 82 word = word:gsub([[\+]], [[\\]]) 83 word = word:gsub([[^%[]], [[\[]]) 84 word = word:gsub([[^:%[]], [[:\[]]) 85 word = word:gsub([[^\{]], [[\\{]]) 86 word = word:gsub([[^%(']], [[']]) 87 88 word = word:gsub('|', 'bar') 89 word = word:gsub([["]], 'quote') 90 word = word:gsub('[$.~]', [[\%0]]) 91 word = word:gsub('%*', '.*') 92 word = word:gsub('?', '.') 93 94 -- Handle control characters. 95 -- First convert raw control chars to the caret notation 96 -- E.g. 0x01 --> '^A' etc. 97 ---@type string 98 word = word:gsub('([\1-\31])', function(ctrl_char) 99 -- '^\' needs an extra backslash 100 local repr = string.char(ctrl_char:byte() + 64):gsub([[\]], [[\\]]) 101 return '^' .. repr 102 end) 103 104 -- Change caret notation to 'CTRL-', except '^_' 105 -- E.g. 'i^G^J' --> 'iCTRL-GCTRL-J' 106 word = word:gsub('%^([^_])', 'CTRL-%1') 107 -- Add underscores around 'CTRL-X' characters 108 -- E.g. 'iCTRL-GCTRL-J' --> 'i_CTRL-G_CTRL-J' 109 -- Only exception: 'CTRL-{character}' 110 word = word:gsub('([^_])CTRL%-', '%1_CTRL-') 111 word = word:gsub('(CTRL%-[^{])([^_\\])', '%1_%2') 112 113 -- Skip function arguments 114 -- E.g. 'abs({expr})' --> 'abs' 115 -- E.g. 'abs([arg])' --> 'abs' 116 word = word:gsub('%({.*', '') 117 word = word:gsub('%(%[.*', '') 118 119 -- Skip punctuation after second apostrophe/curly brace 120 -- E.g. ''option',' --> ''option'' 121 -- E.g. '{address},' --> '{address}' 122 -- E.g. '`command`,' --> 'command' (backticks are removed too, but '``' stays '``') 123 word = word:gsub([[^'([^']*)'.*]], [['%1']]) 124 word = word:gsub([[^{([^}]*)}.*]], '{%1}') 125 word = word:gsub([[.*`([^`]+)`.*]], '%1') 126 end 127 128 return word 129 end 130 131 ---Populates the |local-additions| section of a help buffer with references to locally-installed 132 ---help files. These are help files outside of $VIMRUNTIME (typically from plugins) whose first 133 ---line contains a tag (e.g. *plugin-name.txt*) and a short description. 134 --- 135 ---For each help file found in 'runtimepath', the first line is extracted and added to the buffer 136 ---as a reference (converting '*tag*' to '|tag|'). If a translated version of a help file exists 137 ---in the same language as the current buffer (e.g. 'plugin.nlx' alongside 'plugin.txt'), the 138 ---translated version is preferred over the '.txt' file. 139 function M.local_additions() 140 local buf = vim.api.nvim_get_current_buf() 141 local bufname = vim.fs.basename(vim.api.nvim_buf_get_name(buf)) 142 143 -- "help.txt" or "help.??x" where ?? is a language code, see |help-translated|. 144 local lang = bufname:match('^help%.(%a%a)x$') 145 if bufname ~= 'help.txt' and not lang then 146 return 147 end 148 149 -- Find local help files 150 ---@type table<string, string> 151 local plugins = {} 152 local pattern = lang and ('doc/*.{txt,%sx}'):format(lang) or 'doc/*.txt' 153 for _, docpath in ipairs(vim.api.nvim_get_runtime_file(pattern, true)) do 154 if not vim.fs.relpath(vim.env.VIMRUNTIME, docpath) then 155 -- '/path/to/doc/plugin.txt' --> 'plugin' 156 local plugname = vim.fs.basename(docpath):sub(1, -5) 157 -- prefer language-specific files over .txt 158 if not plugins[plugname] or vim.endswith(plugins[plugname], '.txt') then 159 plugins[plugname] = docpath 160 end 161 end 162 end 163 164 -- Format plugin list lines 165 -- Default to 78 if 'textwidth' is not set (e.g. in sandbox) 166 local textwidth = math.max(vim.bo[buf].textwidth, 78) 167 local lines = {} 168 for _, path in vim.spairs(plugins) do 169 local fp = io.open(path, 'r') 170 if fp then 171 local tagline = fp:read('*l') or '' 172 fp:close() 173 ---@type string, string 174 local plugname, desc = tagline:match('^%*([^*]+)%*%s*(.*)$') 175 if plugname and desc then 176 -- left-align taglink and right-align description by inserting spaces in between 177 local plug_width = vim.fn.strdisplaywidth(plugname) 178 local _, concealed_chars = desc:gsub('|', '') 179 local desc_width = vim.fn.strdisplaywidth(desc) - concealed_chars 180 -- max(l, 1) forces at least one space for if the description is too long 181 local spaces = string.rep(' ', math.max(textwidth - desc_width - plug_width - 2, 1)) 182 local fmt = string.format('|%s|%s%s', plugname, spaces, desc) 183 table.insert(lines, fmt) 184 end 185 end 186 end 187 188 -- Add plugin list to local-additions section 189 for linenr, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do 190 if line:find('*local-additions*', 1, true) then 191 vim._with({ buf = buf, bo = { modifiable = true, readonly = false } }, function() 192 vim.api.nvim_buf_set_lines(buf, linenr, linenr, true, lines) 193 end) 194 break 195 end 196 end 197 end 198 199 return M