diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index da87d13259..e8c515c9e0 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -41,12 +41,14 @@ foreach(PACKAGE ${PACKAGES}) add_custom_command(OUTPUT "${GENERATED_PACKAGE_DIR}/${PACKNAME}/doc/tags" COMMAND ${CMAKE_COMMAND} -E copy_directory ${PACKAGE} ${GENERATED_PACKAGE_DIR}/${PACKNAME} + # "-c :helptags" (E154 duplicate tags, …) does not set the exit code, so check v:errmsg. COMMAND ${CMAKE_COMMAND} -E env "VIMRUNTIME=${PROJECT_SOURCE_DIR}/runtime" ${NVIM_HOST_PRG} - -u NONE -i NONE -e --headless -c "helptags doc" -c quit + -u NONE -i NONE -e --headless -c "helptags doc" -c "exe 'cquit' !empty(v:errmsg)" DEPENDS nvim_bin nvim_runtime_deps WORKING_DIRECTORY "${GENERATED_PACKAGE_DIR}/${PACKNAME}" + VERBATIM ) set("${PACKNAME}_DOC_NAMES") @@ -73,12 +75,14 @@ add_custom_command(OUTPUT ${GENERATED_HELP_TAGS} COMMAND ${CMAKE_COMMAND} -E remove_directory doc COMMAND ${CMAKE_COMMAND} -E copy_directory ${PROJECT_SOURCE_DIR}/runtime/doc doc + # "-c :helptags" (E154 duplicate tags, …) does not set the exit code, so check v:errmsg. COMMAND ${CMAKE_COMMAND} -E env "VIMRUNTIME=${PROJECT_SOURCE_DIR}/runtime" ${NVIM_HOST_PRG} - -u NONE -i NONE -e --headless -c "helptags ++t doc" -c quit + -u NONE -i NONE -e --headless -c "helptags ++t doc" -c "exe 'cquit' !empty(v:errmsg)" DEPENDS nvim_bin nvim_runtime_deps WORKING_DIRECTORY "${GENERATED_RUNTIME_DIR}" + VERBATIM ) add_custom_target( diff --git a/runtime/doc/deprecated.txt b/runtime/doc/deprecated.txt index 58bee47466..2f7421ef48 100644 --- a/runtime/doc/deprecated.txt +++ b/runtime/doc/deprecated.txt @@ -97,7 +97,7 @@ LUA UI • *hit-enter* *hit-enter-prompt* With |ui2|, the legacy "Press ENTER" - *press-enter prompt is never triggered. + *press-enter* prompt is never triggered. ------------------------------------------------------------------------------ DEPRECATED IN 0.11 *deprecated-0.11* diff --git a/runtime/lua/vim/_core/help.lua b/runtime/lua/vim/_core/help.lua index efe57a9bcd..1e0f665566 100644 --- a/runtime/lua/vim/_core/help.lua +++ b/runtime/lua/vim/_core/help.lua @@ -1,6 +1,6 @@ local M = {} -local ts = vim.treesitter +local echo_err = require('vim._core.util').echo_err local tag_exceptions = { -- Interpret asterisk (star, '*') literal but name it 'star' @@ -397,37 +397,44 @@ function M.local_additions() end end -local query = ts.query.parse('vimdoc', '(tag (word) @tagname)') +---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 ---- @alias Tag { [1]: string, [2]: string, [3]: string} tuple of tag, file, and search command - ----Find and report duplicate tags. ----@param tags Tag[] ----@return boolean -local function find_duplicates(tags) - local prevtag, prevfn, has_duplicates = '', '', false +---Report duplicate tags (as errmsg, not exception-throwing error). +---@param tags string[] sorted tags file lines +local function report_duplicates(tags) + local prevtag, prevfn = '', '' for _, tagline in ipairs(tags) do - local curtag, curfn, _ = unpack(tagline) + local curtag, curfn = tagline:match('^([^\t]*)\t([^\t]*)') if curtag == prevtag then - has_duplicates = true local filenames = prevfn ~= curfn and (curfn .. ' and ' .. prevfn) or curfn - local msg = ('E154: Duplicate tag "%s" in %s'):format(curtag, filenames) - vim.api.nvim_echo({ { msg } }, false, { err = true }) + echo_err(('E154: Duplicate tag "%s" in %s'):format(curtag, filenames)) end prevtag = curtag prevfn = curfn end - - return has_duplicates end ---Extract tags from {file} and add to list of tags. Modifies {tags}. ----@param tags Tag[] +---@param tags string[] ---@param file string -local function extract_tags(tags, file) - local filename = vim.fs.basename(file) - local source = vim.fn.readblob(file) +---@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 ts = vim.treesitter + local ok, source = pcall(vim.fn.readblob, file) + if not ok then + echo_err(('E153: Unable to open %s for reading'):format(file)) + return + end + --- @cast source string + + local query = ts.query.parse('vimdoc', '(tag (word) @tagname)') local parser = ts.get_string_parser(source, 'vimdoc') local tree = assert(parser:parse()) @@ -435,16 +442,22 @@ local function extract_tags(tags, file) for _, match in query:iter_matches(root, source) do for id, node in pairs(match) do if query.captures[id] == 'tagname' then - -- Only accept a *tag* when there is white space (or nothing) before it - -- and it is followed by a white character or end-of-line. - local _, _, start_byte, _, _, end_byte = node[1]:parent():range(true) + -- Only accept a *tag* when it is closed, has no "|" (which would break |links|), there is + -- whitespace (or nothing) before it, and followed by whitespace or end-of-line. + local tag_node = assert(node[1]:parent()) + local _, _, start_byte = tag_node:start() + local _, _, end_byte = tag_node:end_() local before = source:sub(start_byte, start_byte) local after = source:sub(end_byte + 1, end_byte + 1) - if before:match('^[ \t\n\r]?$') and after:match('^[ \t\n\r]?$') then - local tagname = ts.get_node_text(node[1], source) + local tagname = ts.get_node_text(node[1], source) + if + source:sub(end_byte, end_byte) == '*' + and not tagname:find('|', 1, true) + and before:match('^[ \t\n\r]?$') + and after:match('^[ \t\n\r]?$') + then local escaped = tagname:gsub('[\\/]', '\\%0') - local searchcmd = '/*' .. escaped .. '*' - table.insert(tags, { tagname, filename, searchcmd }) + table.insert(tags, ('%s\t%s\t/*%s*'):format(tagname, name, escaped)) end end end @@ -453,44 +466,42 @@ 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 include_helptags_tag boolean true if the 'help-tags' tag should be included -local function gen_tagsfile(helpfiles, outpath, include_helptags_tag) - ---@type Tag[] +--- @param ignore_writeerr boolean don't report a tags file that cannot be written +local function gen_tagsfile(helpfiles, dir, outpath, include_helptags_tag, ignore_writeerr) + ---@type string[] Tags file lines: "tagfilesearch command". local tags = {} -- (1) extract tags from all files for _, file in ipairs(helpfiles) do - extract_tags(tags, file) + extract_tags(tags, file, vim.fs.relpath(dir, file) or vim.fs.basename(file)) end if include_helptags_tag then - table.insert(tags, { 'help-tags', 'tags', '1' }) + table.insert(tags, ('help-tags\t%s\t1'):format(vim.fs.basename(outpath))) end - if vim.tbl_isempty(tags) then - return - end + -- (2) sort by byte value, as |tags-file-format| requires. + -- Note: vim.fn.sort() compares bytes, PUC Lua "<" compares with strcoll(). + tags = vim.fn.sort(tags) - -- (2) sort alphabetically on tag name - table.sort(tags, function(a, b) - return a[1] < b[1] - end) - - -- (3) check duplicates - local has_duplicates = find_duplicates(tags) + -- (3) report duplicates (non-fatal errmsg: the tags file is still written) + report_duplicates(tags) -- (4) write tags to file - local f = assert(io.open(outpath, 'w')) + 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 for _, tag in ipairs(tags) do - f:write(table.concat(tag, '\t') .. '\n') + f:write(tag, '\n') end f:close() - - -- tags file has to be written before we can error - if has_duplicates then - error('duplicate tags') - end end --- Create a "tags" file for all help files in the given directory. @@ -508,36 +519,52 @@ function M.gen_tags(dir, include_index_tag) vim.validate('dir', dir, 'string', true) vim.validate('include_index_tag', include_index_tag, 'boolean', true) + if not pcall(function() + vim.treesitter.language.add('vimdoc') + end) then + echo_err('Cannot generate helptags: no "vimdoc" parser') + return + end + 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(vim.env.VIMRUNTIME, 'doc')) + if dir and vim.fn.isdirectory(dirs[1]) == 0 then + echo_err(('E150: Not a directory: %s'):format(dir)) + return + end + for _, directory in ipairs(dirs) do local files = vim.fs.find(function(name, _) - return vim.endswith(name, '.txt') + return helpfile_lang(name) ~= nil end, { path = directory, type = 'file', limit = math.huge }) - local outpath = vim.fs.joinpath(directory, 'tags') - gen_tagsfile(files, outpath, include_index_tag or directory == vimruntime) + if vim.tbl_isempty(files) then + echo_err(('E151: No match: %s'):format(vim.fs.joinpath(directory, '**/*.txt'))) + end - -- handle translated help files per language - local translated = vim.fs.find(function(name, _) - -- "*.[a-z][a-z]x", see :help help-translated - return name:match('%.%l%lx', -4) - end, { path = directory, type = 'file', limit = math.huge }) - - -- categorize translated files per two-letter language code + -- categorize helpfiles per language, see |help-translated| ---@type table local per_lang = {} - for _, file in ipairs(translated) do - -- extract language code "nl" from filename "plugin.nlx" - local lang = file:sub(-3, -2) + 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 - local tagsfile = vim.fs.joinpath(directory, 'tags-' .. lang) - gen_tagsfile(langfiles, tagsfile, include_index_tag or directory == vimruntime) + -- 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 + gen_tagsfile( + langfiles, + directory, + outpath, + include_index_tag or directory == vimruntime, + ignore_writeerr + ) end end end diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 0e986d3196..5c7cb7fda6 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -835,11 +835,12 @@ local function checkout(p, timestamp, skip_stash) plugin_lock.plugins[p.spec.name].rev = p.info.sha_target -- (Re)Generate help tags according to the current help files. - -- Also use `pcall()` because `:helptags` errors if there is no 'doc/' - -- directory or if it is empty. + -- Also use `pcall()` because `:helptags` errors if 'doc/' has no help files. local doc_dir = vim.fs.joinpath(p.path, 'doc') vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags')) - copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } }) + if vim.fn.isdirectory(doc_dir) == 1 then + copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } }) + end end --- @param plug_list vim.pack.Plug[] diff --git a/src/gen/gen_helptags.lua b/src/gen/gen_helptags.lua index 38ee913b4c..0a601163dc 100644 --- a/src/gen/gen_helptags.lua +++ b/src/gen/gen_helptags.lua @@ -1,6 +1,11 @@ ---@diagnostic disable: no-unknown --- Does the same as `nvim -c "helptags ++t doc" -c quit` +-- Does the same as `nvim -c "helptags [++t] doc" -c quit` -- without needing to run a "nvim" binary, which is needed for cross-compiling. +-- +-- Usage: nlua0 gen_helptags.lua {out} {dir} [++t] +-- The tags file is sorted by byte value, but PUC Lua "<" compares with strcoll(). +os.setlocale('C', 'collate') + local out = arg[1] local dir = arg[2] diff --git a/test/functional/ex_cmds/help_spec.lua b/test/functional/ex_cmds/help_spec.lua index 1a7df82df3..b97fdd4817 100644 --- a/test/functional/ex_cmds/help_spec.lua +++ b/test/functional/ex_cmds/help_spec.lua @@ -350,12 +350,43 @@ describe(':helptags', function() 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') + command('helptags Xhelptags/doc') - eq(eval("['Xa Xa.txt /*Xa*','Xb Xb.txt /*Xb*']"), eval("readfile('Xhelptags/doc/tags')")) + eq( + eval("['Xa Xa.txt /*Xa*','Xb Xb.txt /*Xb*','Xc sub/Xc.txt /*Xc*']"), + eval("readfile('Xhelptags/doc/tags')") + ) command('help Xa') eq('*Xa*', api.nvim_get_current_line()) + + command('help Xc') + eq('*Xc*', api.nvim_get_current_line()) + 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() @@ -369,8 +400,12 @@ describe(':helptags', function() 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() @@ -397,11 +432,23 @@ describe(':helptags', function() eq(true, msg:find('E154') ~= 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(1, eval("filereadable('Xhelptags/doc/tags-nl')")) + 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) diff --git a/test/old/testdir/runtest.vim b/test/old/testdir/runtest.vim index d2070868fc..ce2f98f091 100644 --- a/test/old/testdir/runtest.vim +++ b/test/old/testdir/runtest.vim @@ -150,6 +150,8 @@ lang mess C let &runtimepath ..= ',' .. expand($BUILD_DIR) .. '/runtime/' " Nvim: append libdir from build dir, which contains the bundled TS parsers. let &runtimepath ..= ',' .. expand($BUILD_DIR) .. '/lib/nvim/' +" Nvim: load the ":helptags" module + parser now, because tests reset 'runtimepath'. +lua pcall(vim.treesitter.language.add, 'vimdoc') let s:t_bold = &t_md let s:t_normal = &t_me