mirror of
https://github.com/neovim/neovim.git
synced 2026-11-06 13:26:57 +00:00
Problem: Argument type mismatches are excluded from EmmyLua checks. Solution: Correct runtime annotations and calls, document local checker workarounds, and enable the diagnostic. AI-assisted
596 lines
20 KiB
Lua
596 lines
20 KiB
Lua
local M = {}
|
|
|
|
local echo_err = require('vim._core.util').echo_err
|
|
local fs = require('vim.fs')
|
|
|
|
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'
|
|
-- Only treat '^' as control notation when followed by a caret-notation
|
|
-- char (a letter or one of "?@[\]^{"); otherwise leave it literal so
|
|
-- patterns like ':set^=' are not wrongly split. '{' is included so
|
|
-- '^{char}' matches the 'CTRL-{char}' placeholder tag.
|
|
word = word:gsub('%^([%a?@\\[\\%]{^])', '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%-[^{])([^%u_\\-])', '%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
|
|
|
|
--- Characters that are considered punctuation for trimming help tags.
|
|
--- Dots (.) are NOT included here — they're trimmed separately as a last resort.
|
|
local trimmable_punct = {
|
|
['('] = true,
|
|
[')'] = true,
|
|
['<'] = true,
|
|
['>'] = true,
|
|
['['] = true,
|
|
[']'] = true,
|
|
['{'] = true,
|
|
['}'] = true,
|
|
['`'] = true,
|
|
['|'] = true,
|
|
['"'] = true,
|
|
[','] = true,
|
|
["'"] = true,
|
|
[' '] = true,
|
|
['\t'] = true,
|
|
}
|
|
|
|
--- Trim one layer of punctuation from a help tag string.
|
|
--- Uses cursor offset to intelligently trim: if cursor is on trimmable punctuation,
|
|
--- removes everything before cursor and skips past punctuation after cursor.
|
|
---
|
|
---@param tag string The tag to trim
|
|
---@param offset integer Cursor position within the tag (-1 if not applicable)
|
|
---@return string? trimmed Trimmed string, or nil if unchanged
|
|
local function trim_tag(tag, offset)
|
|
if not tag or tag == '' then
|
|
return nil
|
|
end
|
|
|
|
-- Special cases: single character tags
|
|
if tag == '|' then
|
|
return 'bar'
|
|
end
|
|
if tag == '"' then
|
|
return 'quote'
|
|
end
|
|
|
|
local len = #tag
|
|
-- start/end are 1-indexed inclusive positions into tag
|
|
local s = 1
|
|
local e = len
|
|
|
|
if offset >= 0 and offset < len and trimmable_punct[tag:sub(offset + 1, offset + 1)] then
|
|
-- Heuristic: cursor is on trimmable punctuation, skip past it to the right
|
|
s = offset + 1
|
|
while s <= e and trimmable_punct[tag:sub(s, s)] do
|
|
s = s + 1
|
|
end
|
|
elseif offset >= 0 and offset < len then
|
|
-- Cursor is on non-trimmable char: find start of identifier at cursor
|
|
local cursor_pos = offset + 1 -- 1-indexed
|
|
while cursor_pos > s and not trimmable_punct[tag:sub(cursor_pos - 1, cursor_pos - 1)] do
|
|
cursor_pos = cursor_pos - 1
|
|
end
|
|
s = cursor_pos
|
|
else
|
|
-- No cursor info: trim leading punctuation
|
|
while s <= e and trimmable_punct[tag:sub(s, s)] do
|
|
s = s + 1
|
|
end
|
|
end
|
|
|
|
-- Trim trailing punctuation
|
|
while e >= s and trimmable_punct[tag:sub(e, e)] do
|
|
e = e - 1
|
|
end
|
|
|
|
-- Truncate function or generic arguments, e.g. "foo('bar')" => "foo", "Task<R...>" => "Task".
|
|
-- But keep "()" since it's part of valid tags like "vim.fn.expand()".
|
|
for i = s, e do
|
|
if
|
|
tag:sub(i, i) == '<'
|
|
or (tag:sub(i, i) == '(' and not (i + 1 <= e and tag:sub(i + 1, i + 1) == ')'))
|
|
then
|
|
e = i - 1
|
|
break
|
|
end
|
|
end
|
|
|
|
-- If nothing changed, return nil
|
|
if s == 1 and e == len then
|
|
return nil
|
|
end
|
|
|
|
-- If everything was trimmed, return nil
|
|
if s > e then
|
|
return nil
|
|
end
|
|
|
|
return tag:sub(s, e)
|
|
end
|
|
|
|
--- Trim namespace prefix (dots) from a help tag.
|
|
--- Only call this if regular trimming didn't find a match.
|
|
--- Returns the tag with the leftmost dot-separated segment removed.
|
|
---
|
|
---@param tag string The tag to trim
|
|
---@return string? trimmed Trimmed string, or nil if no dots found
|
|
local function trim_tag_dots(tag)
|
|
if not tag or tag == '' then
|
|
return nil
|
|
end
|
|
local after_dot = tag:match('^[^.]+%.(.+)$')
|
|
return after_dot
|
|
end
|
|
|
|
--- For ":help!" (bang, no args): DWIM resolve a help tag from the cursor context.
|
|
--- Gets `<cWORD>` at cursor, tries it first, then trims punctuation and dots until a valid help
|
|
--- tag is found. Falls back to `<cword>` (keyword at cursor) before dot-trimming.
|
|
---
|
|
---@return string? resolved The resolved help tag, or nil if no match found
|
|
function M.resolve_tag()
|
|
local tag = vim.fn.expand('<cWORD>') --[[@as string]]
|
|
if not tag or tag == '' then
|
|
return nil
|
|
end
|
|
|
|
-- In type annotations, "?" means nullable rather than a help-search wildcard.
|
|
local nullable_type = tag:match('^%(`([%w_.]+)[%[%]]*%?`%)$')
|
|
if nullable_type and #vim.fn.getcompletion(nullable_type, 'help') > 0 then
|
|
return nullable_type
|
|
end
|
|
|
|
-- Compute cursor offset within <cWORD>.
|
|
local line = vim.api.nvim_get_current_line()
|
|
local col = vim.fn.col('.') -- 1-indexed
|
|
local s = col
|
|
-- Scan backward from col('.') to find the whitespace boundary.
|
|
while s > 1 and not line:sub(s - 1, s - 1):match('%s') do
|
|
s = s - 1
|
|
end
|
|
local offset = col - s -- 0-indexed offset within <cWORD>
|
|
|
|
-- Try the original tag first.
|
|
if #vim.fn.getcompletion(tag, 'help') > 0 then
|
|
return tag
|
|
end
|
|
|
|
-- Extract |tag| reference if the cursor is inside one (help's link syntax).
|
|
local pipe_tag = tag:match('|(.+)|')
|
|
if pipe_tag and #vim.fn.getcompletion(pipe_tag, 'help') > 0 then
|
|
return pipe_tag
|
|
end
|
|
|
|
-- Iteratively trim punctuation and try again, up to 10 times.
|
|
local candidate = tag
|
|
for _ = 1, 10 do
|
|
local trimmed = trim_tag(candidate, offset)
|
|
if not trimmed then
|
|
break
|
|
end
|
|
candidate = trimmed
|
|
-- After first trim, offset is no longer valid.
|
|
offset = -1
|
|
|
|
if #vim.fn.getcompletion(candidate, 'help') > 0 then
|
|
return candidate
|
|
end
|
|
end
|
|
|
|
-- Try the word (alphanumeric/underscore run) at the cursor before dot-trimming, since
|
|
-- dot-trimming strips from the left and may move away from the cursor position.
|
|
-- E.g. for '@lsp.type.function' with cursor on "lsp", the word is "lsp".
|
|
-- Note: we don't use <cword> because it depends on 'iskeyword'.
|
|
local word_s, word_e = col, col
|
|
-- If cursor is not on a word char, find the nearest word char to the right.
|
|
if not line:sub(col, col):match('[%w_]') then
|
|
while word_s <= #line and not line:sub(word_s, word_s):match('[%w_]') do
|
|
word_s = word_s + 1
|
|
end
|
|
word_e = word_s
|
|
end
|
|
while word_s > 1 and line:sub(word_s - 1, word_s - 1):match('[%w_]') do
|
|
word_s = word_s - 1
|
|
end
|
|
while word_e <= #line and line:sub(word_e, word_e):match('[%w_]') do
|
|
word_e = word_e + 1
|
|
end
|
|
word_e = word_e - 1
|
|
local cword = line:sub(word_s, word_e)
|
|
if #cword > 1 and cword ~= tag and #vim.fn.getcompletion(cword, 'help') > 0 then
|
|
return cword
|
|
end
|
|
|
|
-- Try trimming namespace dots (left-to-right).
|
|
for _ = 1, 10 do
|
|
local trimmed = trim_tag_dots(candidate)
|
|
if not trimmed then
|
|
break
|
|
end
|
|
candidate = trimmed
|
|
|
|
if #vim.fn.getcompletion(candidate, 'help') > 0 then
|
|
return candidate
|
|
end
|
|
end
|
|
|
|
-- No match found: return raw <cWORD> so the caller can show it in an error message.
|
|
return tag
|
|
end
|
|
|
|
---Populates the |local-additions| section of a help buffer with references to locally-installed
|
|
---help files. These are help files outside of $VIMRUNTIME (typically from plugins) whose first
|
|
---line contains a tag (e.g. *plugin-name.txt*) and a short description.
|
|
---
|
|
---For each help file found in 'runtimepath', the first line is extracted and added to the buffer
|
|
---as a reference (converting '*tag*' to '|tag|'). If a translated version of a help file exists
|
|
---in the same language as the current buffer (e.g. 'plugin.nlx' alongside 'plugin.txt'), the
|
|
---translated version is preferred over the '.txt' file.
|
|
function M.local_additions()
|
|
local buf = vim.api.nvim_get_current_buf()
|
|
local bufname = vim.fs.basename(vim.api.nvim_buf_get_name(buf))
|
|
|
|
-- "help.txt" or "help.??x" where ?? is a language code, see |help-translated|.
|
|
local lang = bufname:match('^help%.(%a%a)x$')
|
|
if bufname ~= 'help.txt' and not lang then
|
|
return
|
|
end
|
|
|
|
-- Find local help files
|
|
---@type table<string, string>
|
|
local plugins = {}
|
|
local pattern = lang and ('doc/*.{txt,%sx}'):format(lang) or 'doc/*.txt'
|
|
for _, docpath in ipairs(vim.api.nvim_get_runtime_file(pattern, true)) do
|
|
if not vim.fs.relpath(assert(vim.env.VIMRUNTIME), docpath) then
|
|
-- '/path/to/doc/plugin.txt' --> 'plugin'
|
|
local plugname = vim.fs.basename(docpath):sub(1, -5)
|
|
-- prefer language-specific files over .txt
|
|
if not plugins[plugname] or vim.endswith(plugins[plugname], '.txt') then
|
|
plugins[plugname] = docpath
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Format plugin list lines
|
|
-- Default to 78 if 'textwidth' is not set (e.g. in sandbox)
|
|
local textwidth = math.max(vim.bo[buf].textwidth, 78)
|
|
local lines = {}
|
|
for _, path in vim.spairs(plugins) do
|
|
local fp = io.open(path, 'r')
|
|
if fp then
|
|
local tagline = fp:read('*l') or ''
|
|
fp:close()
|
|
---@type string, string
|
|
local plugname, desc = tagline:match('^%*([^*]+)%*%s*(.*)$')
|
|
if plugname and desc then
|
|
-- left-align taglink and right-align description by inserting spaces in between
|
|
local plug_width = vim.fn.strdisplaywidth(plugname)
|
|
local _, concealed_chars = desc:gsub('|', '')
|
|
local desc_width = vim.fn.strdisplaywidth(desc) - concealed_chars
|
|
-- max(l, 1) forces at least one space for if the description is too long
|
|
local spaces = string.rep(' ', math.max(textwidth - desc_width - plug_width - 2, 1))
|
|
local fmt = string.format('|%s|%s%s', plugname, spaces, desc)
|
|
table.insert(lines, fmt)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Add plugin list to local-additions section
|
|
for linenr, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
|
|
if line:find('*local-additions*', 1, true) then
|
|
vim._with({ buf = buf, bo = { modifiable = true, readonly = false } }, function()
|
|
vim.api.nvim_buf_set_lines(buf, linenr, linenr, true, lines)
|
|
end)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
---Language code of a help file: "en" for "*.txt", "nl" for "*.nlx". See |help-translated|.
|
|
---@param file string
|
|
---@return string?
|
|
local function helpfile_lang(file)
|
|
local ext = file:sub(-4):lower()
|
|
return ext == '.txt' and 'en' or ext:match('^%.(%a%a)x$')
|
|
end
|
|
|
|
---Report duplicate tags.
|
|
---@param tags string[] sorted tags file lines
|
|
local function report_duplicates(tags)
|
|
local prevtag
|
|
|
|
for i, tagline in ipairs(tags) do
|
|
local curtag = tagline:match('^[^\t]*')
|
|
if curtag == prevtag then
|
|
local curfn = tagline:match('\t([^\t]*)')
|
|
local prevfn = assert(tags[i - 1]):match('\t([^\t]*)')
|
|
local filenames = prevfn ~= curfn and (curfn .. ' and ' .. prevfn) or curfn
|
|
echo_err(('E154: Duplicate tag "%s" in %s'):format(curtag, filenames))
|
|
end
|
|
prevtag = curtag
|
|
end
|
|
end
|
|
|
|
local helptags_pattern ---@type vim.lpeg.Pattern
|
|
do
|
|
local P, S, R, B, C, Ct = vim.lpeg.P, vim.lpeg.S, vim.lpeg.R, vim.lpeg.B, vim.lpeg.C, vim.lpeg.Ct
|
|
local any = P(1)
|
|
local eof = -any
|
|
local space = S(' \t')
|
|
-- Accept LF, CRLF, and an unterminated final line.
|
|
local newline = P('\r') ^ -1 * P('\n')
|
|
local line_end = newline + P('\r') ^ -1 * eof
|
|
local line_start = -B(any) + B(P('\n'))
|
|
-- Assert surrounding whitespace without consuming it, so adjacent tags can share it.
|
|
local tag = (line_start + B(space))
|
|
* P('*')
|
|
* C((any - S('* \t|\n')) ^ 1)
|
|
* P('*')
|
|
* #(space + line_end)
|
|
-- An example continues through blank or indented lines.
|
|
local example_line = newline + space * (any - P('\n')) ^ 0 * (P('\n') + eof)
|
|
local example = (line_start + B(P(' ')))
|
|
* P('>')
|
|
* R('az', '09') ^ 0
|
|
* line_end
|
|
* example_line ^ 0
|
|
-- Skip ordinary text in spans; consume invalid markers one character at a time.
|
|
local text = (any - S('*>')) ^ 1 + any
|
|
helptags_pattern = Ct(((example + tag + text) ^ 0) --[[@as vim.lpeg.Pattern]])
|
|
end
|
|
|
|
---Extract tags from {file} and add to list of tags. Modifies {tags}.
|
|
---@param tags string[]
|
|
---@param file string
|
|
---@param name string Path of {file} relative to the help directory, as stored in the tags file.
|
|
local function extract_tags(tags, file, name)
|
|
local f = io.open(file, 'r')
|
|
if not f then
|
|
echo_err(('E153: Unable to open %s for reading'):format(file))
|
|
return
|
|
end
|
|
-- Read once to avoid stdio calls and locking for every line.
|
|
local source = assert(f:read('*a'))
|
|
f:close()
|
|
|
|
for _, tagname in ipairs(helptags_pattern:match(source)) do
|
|
tags[#tags + 1] = ('%s\t%s\t/*%s*'):format(tagname, name, tagname:gsub('[\\/]', '\\%0'))
|
|
end
|
|
end
|
|
|
|
--- Extract tags from helpfiles and combine in a single 'tags' file.
|
|
--- @param helpfiles string[] list of helpfiles
|
|
--- @param dir string Help directory; tag entries name the helpfiles relative to it.
|
|
--- @param outpath string path to write the 'tags' file to.
|
|
--- @param index_tag string? Filename for the "help-tags" entry, if included
|
|
--- @param ignore_writeerr boolean don't report a tags file that cannot be written
|
|
function M.gen_tagsfile(helpfiles, dir, outpath, index_tag, ignore_writeerr)
|
|
-- Avoid scanning helpfiles when the output cannot be written (:helptags ALL).
|
|
local f = io.open(outpath, 'w')
|
|
if not f then
|
|
if not ignore_writeerr then
|
|
echo_err(('E152: Cannot open %s for writing'):format(outpath))
|
|
end
|
|
return
|
|
end
|
|
|
|
---@type string[] Tags file lines: "tag<Tab>file<Tab>search command".
|
|
local tags = {}
|
|
|
|
-- (1) extract tags from all files
|
|
for _, file in ipairs(helpfiles) do
|
|
extract_tags(tags, file, fs.relpath(dir, file) or fs.basename(file))
|
|
end
|
|
|
|
if index_tag then
|
|
table.insert(tags, ('help-tags\t%s\t1'):format(index_tag))
|
|
end
|
|
|
|
-- (2) sort by byte value, as |tags-file-format| requires.
|
|
-- PUC Lua uses strcoll(), so use C collation for this sort.
|
|
-- The stdlib annotation omits nil, which queries the current locale.
|
|
--- @diagnostic disable-next-line: param-type-mismatch
|
|
local locale = assert(os.setlocale(nil, 'collate'))
|
|
os.setlocale('C', 'collate')
|
|
table.sort(tags)
|
|
os.setlocale(locale, 'collate')
|
|
|
|
-- (3) report duplicates
|
|
report_duplicates(tags)
|
|
|
|
-- (4) write tags to file
|
|
for _, tag in ipairs(tags) do
|
|
f:write(tag, '\n')
|
|
end
|
|
f:close()
|
|
end
|
|
|
|
--- Create a "tags" file for all help files in the given directory.
|
|
---
|
|
--- The directory {dir} is generally a "doc" directory that contains "*.txt"
|
|
--- helpfiles.
|
|
---
|
|
--- @param dir string? Path to directory with help files. If `nil` (or |vim.NIL|),
|
|
--- generate tags for every `doc` directory in the runtimepath.
|
|
--- @param include_index_tag? boolean (default: false) Whether to include the "help-tags" tag.
|
|
function M.gen_tags(dir, include_index_tag)
|
|
if dir == vim.NIL then
|
|
dir = nil
|
|
end
|
|
vim.validate('dir', dir, 'string', true)
|
|
vim.validate('include_index_tag', include_index_tag, 'boolean', true)
|
|
|
|
local dirs = dir and { vim.fs.normalize(dir) } or vim.api.nvim_get_runtime_file('doc', true)
|
|
local vimruntime = vim.fs.normalize(vim.fs.joinpath(assert(vim.env.VIMRUNTIME), 'doc'))
|
|
|
|
if dir and vim.fn.isdirectory(assert(dirs[1])) == 0 then
|
|
echo_err(('E150: Not a directory: %s'):format(dir))
|
|
return
|
|
end
|
|
|
|
for _, directory in ipairs(dirs) do
|
|
-- Resolve once for traversal and relpath(), avoiding getcwd() for every file.
|
|
-- Keep directory for messages and the VIMRUNTIME comparison.
|
|
local absdir = vim.fs.abspath(directory)
|
|
local files = vim.fs.find(function(name, _)
|
|
return helpfile_lang(name) ~= nil
|
|
end, {
|
|
path = absdir,
|
|
type = 'file',
|
|
follow = true,
|
|
limit = math.huge,
|
|
})
|
|
|
|
if vim.tbl_isempty(files) then
|
|
echo_err(('E151: No match: %s'):format(vim.fs.joinpath(directory, '**/*.txt')))
|
|
end
|
|
|
|
-- categorize helpfiles per language, see |help-translated|
|
|
---@type table<string, string[]>
|
|
local per_lang = {}
|
|
for _, file in ipairs(files) do
|
|
local lang = assert(helpfile_lang(file))
|
|
per_lang[lang] = per_lang[lang] or {}
|
|
table.insert(per_lang[lang], file)
|
|
end
|
|
|
|
for lang, langfiles in pairs(per_lang) do
|
|
-- English is an exception: "*.txt" files generate the "tags" file.
|
|
local tagsfile = lang == 'en' and 'tags' or ('tags-%s'):format(lang)
|
|
local outpath = vim.fs.joinpath(directory, tagsfile)
|
|
-- ":helptags ALL" walks 'runtimepath', which may contain read-only directories.
|
|
local ignore_writeerr = dir == nil
|
|
M.gen_tagsfile(
|
|
langfiles,
|
|
absdir,
|
|
outpath,
|
|
(include_index_tag or directory == vimruntime) and tagsfile or nil,
|
|
ignore_writeerr
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
return M
|