diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7e8f445512..e0b75406f4 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -42,10 +42,11 @@ foreach(PACKAGE ${PACKAGES}) COMMAND ${CMAKE_COMMAND} -E copy_directory ${PACKAGE} ${GENERATED_PACKAGE_DIR}/${PACKNAME} COMMAND ${NVIM_HOST_PRG} - -u NONE -i NONE -e --headless -c "helptags doc" -c quit + -u NONE -l ${PROJECT_SOURCE_DIR}/src/gen/gen_helptags.lua doc/tags doc DEPENDS nvim_bin nvim_runtime_deps + ${PROJECT_SOURCE_DIR}/src/gen/gen_helptags.lua WORKING_DIRECTORY "${GENERATED_PACKAGE_DIR}/${PACKNAME}" ) @@ -74,10 +75,11 @@ add_custom_command(OUTPUT ${GENERATED_HELP_TAGS} COMMAND ${CMAKE_COMMAND} -E copy_directory ${PROJECT_SOURCE_DIR}/runtime/doc doc COMMAND ${NVIM_HOST_PRG} - -u NONE -i NONE -e --headless -c "helptags ++t doc" -c quit + -u NONE -l ${PROJECT_SOURCE_DIR}/src/gen/gen_helptags.lua doc/tags doc ++t DEPENDS nvim_bin nvim_runtime_deps + ${PROJECT_SOURCE_DIR}/src/gen/gen_helptags.lua WORKING_DIRECTORY "${GENERATED_RUNTIME_DIR}" ) 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/doc/helphelp.txt b/runtime/doc/helphelp.txt index a8ca6db4ea..f6c78db845 100644 --- a/runtime/doc/helphelp.txt +++ b/runtime/doc/helphelp.txt @@ -377,6 +377,7 @@ file. The files in $VIMRUNTIME/doc are skipped. sorted. When there are duplicates an error message is given. An existing tags file is silently overwritten. + Requires the "vimdoc" |treesitter| parser. The optional "++t" argument forces adding the "help-tags" tag. This is also done when the {dir} is diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 50d163b8de..04e1178301 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -529,6 +529,10 @@ These existing features changed their behavior. • |undo| no longer restores the old (wrong) position of a mark that you moved (|m|, |:mark|) later. The mark shifts with the text it was moved to, the same as a mark the change never touched. +• |:helptags| finds help tags with the "vimdoc" |treesitter| parser (and thus + requires it to be installed). +• `:helptags ALL` reports |E152| for "doc" directories it cannot write, + instead of silently skipping them. ============================================================================== REMOVED FEATURES *news-removed* diff --git a/runtime/gen_runtime.zig b/runtime/gen_runtime.zig index 7d8ac02977..7ee70631cc 100644 --- a/runtime/gen_runtime.zig +++ b/runtime/gen_runtime.zig @@ -31,6 +31,7 @@ pub fn nvim_gen_runtime( const file = gen_step.addOutputFileArg("tags"); _ = gen_runtime.addCopyFile(file, "doc/tags"); gen_step.addDirectoryArg(b.path("runtime/doc")); + gen_step.addArg("++t"); gen_step.has_side_effects = true; // workaround: missing detection of input changes } diff --git a/runtime/lua/vim/_core/help.lua b/runtime/lua/vim/_core/help.lua index 0dad8f93ff..a78f325ffb 100644 --- a/runtime/lua/vim/_core/help.lua +++ b/runtime/lua/vim/_core/help.lua @@ -1,5 +1,7 @@ local M = {} +local echo_err = require('vim._core.util').echo_err + local tag_exceptions = { -- Interpret asterisk (star, '*') literal but name it 'star' ['*'] = 'star', @@ -395,4 +397,178 @@ function M.local_additions() 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 (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 = tagline:match('^([^\t]*)\t([^\t]*)') + if curtag == prevtag then + local filenames = prevfn ~= curfn and (curfn .. ' and ' .. prevfn) or curfn + echo_err(('E154: Duplicate tag "%s" in %s'):format(curtag, filenames)) + end + prevtag = curtag + prevfn = curfn + end +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 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 + -- The grammar treats "\r" as part of a word, so CRLF files would yield bogus tags. + source = source:gsub('\r\n', '\n') + + local query = ts.query.parse('vimdoc', '(tag (word) @tagname)') + local parser = ts.get_string_parser(source, 'vimdoc') + + local tree = assert(parser:parse()) + local root = tree[1]:root() + 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 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) + 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') + table.insert(tags, ('%s\t%s\t/*%s*'):format(tagname, name, escaped)) + end + end + end + 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 include_helptags_tag boolean true if the 'help-tags' tag should be included +--- @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, vim.fs.relpath(dir, file) or vim.fs.basename(file)) + end + + if include_helptags_tag then + table.insert(tags, ('help-tags\t%s\t1'):format(vim.fs.basename(outpath))) + 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) + + -- (3) report duplicates (non-fatal errmsg: the tags file is still written) + report_duplicates(tags) + + -- (4) write tags to file + 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(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) + + 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 helpfile_lang(name) ~= nil + end, { path = directory, type = 'file', 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 + 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 + gen_tagsfile( + langfiles, + directory, + outpath, + include_index_tag or directory == vimruntime, + ignore_writeerr + ) + end + end +end + return M 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..3007b5ea66 100644 --- a/src/gen/gen_helptags.lua +++ b/src/gen/gen_helptags.lua @@ -1,8 +1,14 @@ ---@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] +local add_help_tags = arg[3] == '++t' local dirfd = assert(vim.uv.fs_opendir(dir, nil, 1)) local files = {} @@ -55,7 +61,9 @@ for _, fn in ipairs(files) do end end -table.insert(tags, { 'help-tags', 'tags' }) +if add_help_tags then + table.insert(tags, { 'help-tags', 'tags' }) +end table.sort(tags, function(a, b) return a[1] < b[1] end) diff --git a/src/nvim/help.c b/src/nvim/help.c index fcf32d7263..940096f926 100644 --- a/src/nvim/help.c +++ b/src/nvim/help.c @@ -476,316 +476,24 @@ void ex_viusage(exarg_T *eap) do_cmdline_cmd("help normal-index"); } -/// Generate tags in one help directory -/// -/// @param dir Path to the doc directory -/// @param ext Suffix of the help files (".txt", ".itx", ".frx", etc.) -/// @param tagname Name of the tags file ("tags" for English, "tags-fr" for -/// French) -/// @param add_help_tags Whether to add the "help-tags" tag -/// @param ignore_writeerr ignore write error -static void helptags_one(char *dir, const char *ext, const char *tagfname, bool add_help_tags, - bool ignore_writeerr) - FUNC_ATTR_NONNULL_ALL -{ - garray_T ga; - int filecount; - char **files; - char *s; - - // Find all *.txt files. - size_t dirlen = xstrlcpy(NameBuff, dir, sizeof(NameBuff)); - if (dirlen >= MAXPATHL - || xstrlcat(NameBuff, "/**/*", sizeof(NameBuff)) >= MAXPATHL // NOLINT - || xstrlcat(NameBuff, ext, sizeof(NameBuff)) >= MAXPATHL) { - emsg(_(e_fnametoolong)); - return; - } - - // Note: We cannot just do `&NameBuff` because it is a statically sized array - // so `NameBuff == &NameBuff` according to C semantics. - char *buff_list[1] = { NameBuff }; - const int res = gen_expand_wildcards(1, buff_list, &filecount, &files, - EW_FILE|EW_SILENT); - if (res == FAIL || filecount == 0) { - if (!got_int) { - semsg(_("E151: No match: %s"), NameBuff); - } - if (res != FAIL) { - FreeWild(filecount, files); - } - return; - } - - // Open the tags file for writing. - // Do this before scanning through all the files. - memcpy(NameBuff, dir, dirlen + 1); - if (!add_pathsep(NameBuff) - || xstrlcat(NameBuff, tagfname, sizeof(NameBuff)) >= MAXPATHL) { - emsg(_(e_fnametoolong)); - return; - } - - FILE *const fd_tags = os_fopen(NameBuff, "w"); - if (fd_tags == NULL) { - if (!ignore_writeerr) { - semsg(_("E152: Cannot open %s for writing"), NameBuff); - } - FreeWild(filecount, files); - return; - } - - // If using the "++t" argument or generating tags for "$VIMRUNTIME/doc" - // add the "help-tags" tag. - ga_init(&ga, (int)sizeof(char *), 100); - if (add_help_tags - || path_equal("$VIMRUNTIME/doc", dir, kPathCmpExpand)) { - size_t s_len = 18 + strlen(tagfname); - s = xmalloc(s_len); - snprintf(s, s_len, "help-tags\t%s\t1\n", tagfname); - GA_APPEND(char *, &ga, s); - } - - // Go over all the files and extract the tags. - for (int fi = 0; fi < filecount && !got_int; fi++) { - FILE *const fd = os_fopen(files[fi], "r"); - if (fd == NULL) { - semsg(_("E153: Unable to open %s for reading"), files[fi]); - continue; - } - const char *const fname = files[fi] + dirlen + 1; - - bool in_example = false; - while (!vim_fgets(IObuff, IOSIZE, fd) && !got_int) { - if (in_example) { - // skip over example; a non-white in the first column ends it - if (vim_strchr(" \t\n\r", (uint8_t)IObuff[0])) { - continue; - } - in_example = false; - } - char *p1 = vim_strchr(IObuff, '*'); // find first '*' - while (p1 != NULL) { - char *p2 = strchr(p1 + 1, '*'); // Find second '*'. - if (p2 != NULL && p2 > p1 + 1) { // Skip "*" and "**". - for (s = p1 + 1; s < p2; s++) { - if (*s == ' ' || *s == '\t' || *s == '|') { - break; - } - } - - // Only accept a *tag* when it consists of valid - // characters, there is white space before it and is - // followed by a white character or end-of-line. - if (s == p2 - && (p1 == IObuff || p1[-1] == ' ' || p1[-1] == '\t') - && (vim_strchr(" \t\n\r", (uint8_t)s[1]) != NULL - || s[1] == NUL)) { - *p2 = NUL; - p1++; - size_t s_len = (size_t)(p2 - p1) + strlen(fname) + 2; - s = xmalloc(s_len); - GA_APPEND(char *, &ga, s); - snprintf(s, s_len, "%s\t%s", p1, fname); - - // find next '*' - p2 = vim_strchr(p2 + 1, '*'); - } - } - p1 = p2; - } - size_t off = strlen(IObuff); - if (off >= 2 && IObuff[off - 1] == '\n') { - off -= 2; - while (off > 0 && (ASCII_ISLOWER(IObuff[off]) || ascii_isdigit(IObuff[off]))) { - off--; - } - if (IObuff[off] == '>' && (off == 0 || IObuff[off - 1] == ' ')) { - in_example = true; - } - } - line_breakcheck(); - } - - fclose(fd); - } - - FreeWild(filecount, files); - - if (!got_int && ga.ga_data != NULL) { - // Sort the tags. - sort_strings(ga.ga_data, ga.ga_len); - - // Check for duplicates. - for (int i = 1; i < ga.ga_len; i++) { - char *p1 = ((char **)ga.ga_data)[i - 1]; - char *p2 = ((char **)ga.ga_data)[i]; - while (*p1 == *p2) { - if (*p2 == '\t') { - *p2 = NUL; - vim_snprintf(NameBuff, MAXPATHL, - _("E154: Duplicate tag \"%s\" in file %s/%s"), - ((char **)ga.ga_data)[i], dir, p2 + 1); - emsg(NameBuff); - *p2 = '\t'; - break; - } - p1++; - p2++; - } - } - - // Write the tags into the file. - for (int i = 0; i < ga.ga_len; i++) { - s = ((char **)ga.ga_data)[i]; - if (strncmp(s, "help-tags\t", 10) == 0) { - // help-tags entry was added in formatted form - fputs(s, fd_tags); - } else { - fprintf(fd_tags, "%s\t/" "*", s); - for (char *p1 = s; *p1 != '\t'; p1++) { - // insert backslash before '\\' and '/' - if (*p1 == '\\' || *p1 == '/') { - putc('\\', fd_tags); - } - putc(*p1, fd_tags); - } - fprintf(fd_tags, "*\n"); - } - } - } - - GA_DEEP_CLEAR_PTR(&ga); - fclose(fd_tags); // there is no check for an error... -} - -/// Generate tags in one help directory, taking care of translations. -static void do_helptags(char *dirname, bool add_help_tags, bool ignore_writeerr) - FUNC_ATTR_NONNULL_ALL -{ - garray_T ga; - char lang[2]; - char ext[5]; - char fname[8]; - int filecount; - char **files; - - // Get a list of all files in the help directory and in subdirectories. - xstrlcpy(NameBuff, dirname, sizeof(NameBuff)); - if (!add_pathsep(NameBuff) - || xstrlcat(NameBuff, "**", sizeof(NameBuff)) >= MAXPATHL) { - emsg(_(e_fnametoolong)); - return; - } - - // Note: We cannot just do `&NameBuff` because it is a statically sized array - // so `NameBuff == &NameBuff` according to C semantics. - char *buff_list[1] = { NameBuff }; - if (gen_expand_wildcards(1, buff_list, &filecount, &files, - EW_FILE|EW_SILENT) == FAIL - || filecount == 0) { - semsg(_("E151: No match: %s"), NameBuff); - return; - } - - // Go over all files in the directory to find out what languages are - // present. - int j; - ga_init(&ga, 1, 10); - for (int i = 0; i < filecount; i++) { - int len = (int)strlen(files[i]); - if (len <= 4) { - continue; - } - - if (STRICMP(files[i] + len - 4, ".txt") == 0) { - // ".txt" -> language "en" - lang[0] = 'e'; - lang[1] = 'n'; - } else if (files[i][len - 4] == '.' - && ASCII_ISALPHA(files[i][len - 3]) - && ASCII_ISALPHA(files[i][len - 2]) - && TOLOWER_ASC(files[i][len - 1]) == 'x') { - // ".abx" -> language "ab" - lang[0] = (char)TOLOWER_ASC(files[i][len - 3]); - lang[1] = (char)TOLOWER_ASC(files[i][len - 2]); - } else { - continue; - } - - // Did we find this language already? - for (j = 0; j < ga.ga_len; j += 2) { - if (strncmp(lang, ((char *)ga.ga_data) + j, 2) == 0) { - break; - } - } - if (j == ga.ga_len) { - // New language, add it. - ga_grow(&ga, 2); - ((char *)ga.ga_data)[ga.ga_len++] = lang[0]; - ((char *)ga.ga_data)[ga.ga_len++] = lang[1]; - } - } - - // Loop over the found languages to generate a tags file for each one. - for (j = 0; j < ga.ga_len; j += 2) { - STRCPY(fname, "tags-xx"); - fname[5] = ((char *)ga.ga_data)[j]; - fname[6] = ((char *)ga.ga_data)[j + 1]; - if (fname[5] == 'e' && fname[6] == 'n') { - // English is an exception: use ".txt" and "tags". - fname[4] = NUL; - STRCPY(ext, ".txt"); - } else { - // Language "ab" uses ".abx" and "tags-ab". - STRCPY(ext, ".xxx"); - ext[1] = fname[5]; - ext[2] = fname[6]; - } - helptags_one(dirname, ext, fname, add_help_tags, ignore_writeerr); - } - - ga_clear(&ga); - FreeWild(filecount, files); -} - -static bool helptags_cb(int num_fnames, char **fnames, bool all, void *cookie) - FUNC_ATTR_NONNULL_ALL -{ - for (int i = 0; i < num_fnames; i++) { - do_helptags(fnames[i], *(bool *)cookie, true); - if (!all) { - return true; - } - } - - return num_fnames > 0; -} - /// ":helptags" void ex_helptags(exarg_T *eap) { - expand_T xpc; bool add_help_tags = false; - // Check for ":helptags ++t {dir}". + // Check for ++t in ":helptags ++t {dir}". if (strncmp(eap->arg, "++t", 3) == 0 && ascii_iswhite(eap->arg[3])) { add_help_tags = true; eap->arg = skipwhite(eap->arg + 3); } + typval_T tv_args[] = { + { .v_type = VAR_STRING, .vval.v_string = eap->arg }, + { .v_type = VAR_BOOL, .vval.v_bool = add_help_tags ? kBoolVarTrue : kBoolVarFalse }, + { .v_type = VAR_UNKNOWN }, + }; if (strcmp(eap->arg, "ALL") == 0) { - do_in_path(p_rtp, "", "doc", DIP_ALL + DIP_DIR, helptags_cb, &add_help_tags); - } else { - ExpandInit(&xpc); - xpc.xp_context = EXPAND_DIRECTORIES; - char *dirname = - ExpandOne(&xpc, eap->arg, NULL, WILD_LIST_NOTFOUND|WILD_SILENT, WILD_EXPAND_FREE); - if (dirname == NULL || !os_isdir(dirname)) { - semsg(_("E150: Not a directory: %s"), eap->arg); - } else { - do_helptags(dirname, add_help_tags, false); - } - xfree(dirname); + tv_args[0] = (typval_T){ .v_type = VAR_SPECIAL, .vval.v_special = kSpecialVarNull }; } + nlua_call_typval("vim._core.help", "gen_tags", tv_args, NULL); } diff --git a/test/functional/ex_cmds/help_spec.lua b/test/functional/ex_cmds/help_spec.lua index dcba0ac872..6c76255a38 100644 --- a/test/functional/ex_cmds/help_spec.lua +++ b/test/functional/ex_cmds/help_spec.lua @@ -1,11 +1,12 @@ local t = require('test.testutil') local n = require('test.functional.testnvim')() -local describe, it, before_each, finally = t.describe, t.it, t.before_each, t.finally +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 pcall_err = t.pcall_err local fn = n.fn local api = n.api local mkdir = t.mkdir @@ -324,3 +325,132 @@ describe(':help', function() 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') + + command('helptags Xhelptags/doc') + + eq( + eval("['Xa Xa.txt /*Xa*','Xb Xb.txt /*Xb*','Xc sub/Xc.txt /*Xc*','Xe Xe.txt /*Xe*']"), + 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() + 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') ~= 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') ~= 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) 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