From b5ce7e74dcde885afeee7f0d943a196a5ec450f2 Mon Sep 17 00:00:00 2001 From: Yochem van Rosmalen Date: Sat, 14 Feb 2026 11:30:18 +0100 Subject: [PATCH] refactor(help): move local-additions to Lua #37831 Problem: - ~200 line function of hard-to-maintain C code. - Local Addition section looks messy because of the varying description formats. Solution: - Move code to Lua. - Have a best-effort approach where short descriptions are right aligned, giving a cleaner look. Long descriptions are untouched. --- runtime/doc/help.txt | 2 +- runtime/lua/vim/_core/help.lua | 69 +++++++++++++- src/nvim/help.c | 166 ++------------------------------- test/old/testdir/test_help.vim | 14 +-- 4 files changed, 84 insertions(+), 167 deletions(-) diff --git a/runtime/doc/help.txt b/runtime/doc/help.txt index a6e21e8656..ccbe79e974 100644 --- a/runtime/doc/help.txt +++ b/runtime/doc/help.txt @@ -143,7 +143,7 @@ Standard plugins ~ See |standard-plugin-list|. Local additions ~ - *local-additions* + *local-additions* ------------------------------------------------------------------------------ Bars example *bars* diff --git a/runtime/lua/vim/_core/help.lua b/runtime/lua/vim/_core/help.lua index a8c16305f1..61495fb043 100644 --- a/runtime/lua/vim/_core/help.lua +++ b/runtime/lua/vim/_core/help.lua @@ -122,10 +122,77 @@ function M.escape_subject(word) -- E.g. '`command`,' --> 'command' (backticks are removed too, but '``' stays '``') word = word:gsub([[^'([^']*)'.*]], [['%1']]) word = word:gsub([[^{([^}]*)}.*]], '{%1}') - word = word:gsub([[^`([^`]+)`.*]], '%1') + word = word:gsub([[.*`([^`]+)`.*]], '%1') end return word 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 + 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(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 desc_width = vim.fn.strdisplaywidth(desc) + -- 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 + return M diff --git a/src/nvim/help.c b/src/nvim/help.c index 6913c4e1b3..da7aeee1ad 100644 --- a/src/nvim/help.c +++ b/src/nvim/help.c @@ -444,167 +444,17 @@ void prepare_help_buffer(void) set_buflisted(false); } -/// After reading a help file: if help.txt, populate *local-additions* +/// Populate *local-additions* in help.txt void get_local_additions(void) { - // In the "help.txt" and "help.abx" file, add the locally added help - // files. This uses the very first line in the help file. - char *const fname = path_tail(curbuf->b_fname); - if (path_fnamecmp(fname, "help.txt") == 0 - || (path_fnamencmp(fname, "help.", 5) == 0 - && ASCII_ISALPHA(fname[5]) - && ASCII_ISALPHA(fname[6]) - && TOLOWER_ASC(fname[7]) == 'x' - && fname[8] == NUL)) { - for (linenr_T lnum = 1; lnum < curbuf->b_ml.ml_line_count; lnum++) { - char *line = ml_get_buf(curbuf, lnum); - if (strstr(line, "*local-additions*") == NULL) { - continue; - } - - int lnum_start = lnum; - - // Go through all directories in 'runtimepath', skipping - // $VIMRUNTIME. - char *p = p_rtp; - while (*p != NUL) { - copy_option_part(&p, NameBuff, MAXPATHL, ","); - char *const rt = vim_getenv("VIMRUNTIME"); - if (rt != NULL - && path_full_compare(rt, NameBuff, false, true) != kEqualFiles) { - int fcount; - char **fnames; - vimconv_T vc; - - // Find all "doc/ *.txt" files in this directory. - if (!add_pathsep(NameBuff) - || xstrlcat(NameBuff, "doc/*.??[tx]", // NOLINT - sizeof(NameBuff)) >= MAXPATHL) { - emsg(_(e_fnametoolong)); - continue; - } - - // 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, &fcount, - &fnames, EW_FILE|EW_SILENT) == OK - && fcount > 0) { - char *s; - char *cp; - // If foo.abx is found use it instead of foo.txt in - // the same directory. - for (int i1 = 0; i1 < fcount; i1++) { - const char *const f1 = fnames[i1]; - const char *const t1 = path_tail(f1); - const char *const e1 = strrchr(t1, '.'); - if (e1 == NULL) { - continue; - } - if (path_fnamecmp(e1, ".txt") != 0 - && path_fnamecmp(e1, fname + 4) != 0) { - // Not .txt and not .abx, remove it. - XFREE_CLEAR(fnames[i1]); - continue; - } - - for (int i2 = i1 + 1; i2 < fcount; i2++) { - const char *const f2 = fnames[i2]; - if (f2 == NULL) { - continue; - } - const char *const t2 = path_tail(f2); - const char *const e2 = strrchr(t2, '.'); - if (e2 == NULL) { - continue; - } - if (e1 - f1 != e2 - f2 - || path_fnamencmp(f1, f2, (size_t)(e1 - f1)) != 0) { - continue; - } - if (path_fnamecmp(e1, ".txt") == 0 - && path_fnamecmp(e2, fname + 4) == 0) { - // use .abx instead of .txt - XFREE_CLEAR(fnames[i1]); - } - } - } - for (int fi = 0; fi < fcount; fi++) { - if (fnames[fi] == NULL) { - continue; - } - - FILE *const fd = os_fopen(fnames[fi], "r"); - if (fd == NULL) { - continue; - } - vim_fgets(IObuff, IOSIZE, fd); - if (IObuff[0] == '*' - && (s = vim_strchr(IObuff + 1, '*')) - != NULL) { - TriState this_utf = kNone; - // Change tag definition to a - // reference and remove /. - IObuff[0] = '|'; - *s = '|'; - while (*s != NUL) { - if (*s == '\r' || *s == '\n') { - *s = NUL; - } - // The text is utf-8 when a byte - // above 127 is found and no - // illegal byte sequence is found. - if ((uint8_t)(*s) >= 0x80 && this_utf != kFalse) { - this_utf = kTrue; - const int l = utf_ptr2len(s); - if (l == 1) { - this_utf = kFalse; - } - s += l - 1; - } - s++; - } - // The help file is latin1 or utf-8; - // conversion to the current - // 'encoding' may be required. - vc.vc_type = CONV_NONE; - convert_setup(&vc, - (this_utf == kTrue ? "utf-8" : "latin1"), - p_enc); - if (vc.vc_type == CONV_NONE) { - // No conversion needed. - cp = IObuff; - } else { - // Do the conversion. If it fails - // use the unconverted text. - cp = string_convert(&vc, IObuff, NULL); - if (cp == NULL) { - cp = IObuff; - } - } - convert_setup(&vc, NULL, NULL); - - ml_append(lnum, cp, 0, false); - if (cp != IObuff) { - xfree(cp); - } - lnum++; - } - fclose(fd); - } - FreeWild(fcount, fnames); - } - } - xfree(rt); - } - linenr_T appended = lnum - lnum_start; - if (appended) { - mark_adjust(lnum_start + 1, (linenr_T)MAXLNUM, appended, 0, kExtmarkUndo); - changed_lines_redraw_buf(curbuf, lnum_start + 1, lnum_start + 1, appended); - } - break; - } + Error err = ERROR_INIT; + Object res = NLUA_EXEC_STATIC("return require'vim._core.help'.local_additions()", + (Array)ARRAY_DICT_INIT, kRetNilBool, NULL, &err); + if (ERROR_SET(&err)) { + emsg_multiline(err.msg, "lua_error", HLF_E, true); } + api_free_object(res); + api_clear_error(&err); } /// ":exusage" diff --git a/test/old/testdir/test_help.vim b/test/old/testdir/test_help.vim index 21f8633971..09814fffe6 100644 --- a/test/old/testdir/test_help.vim +++ b/test/old/testdir/test_help.vim @@ -107,8 +107,8 @@ func Test_help_local_additions() help local-additions let lines = getline(line(".") + 1, search("^$") - 1) call assert_equal([ - \ '|mydoc-ext.txt| my extended awesome doc', - \ '|mydoc.txt| my awesome doc' + \ '|mydoc.txt| my awesome doc', + \ '|mydoc-ext.txt| my extended awesome doc' \ ], lines) call delete('Xruntime/doc/mydoc-ext.txt') close @@ -124,17 +124,17 @@ func Test_help_local_additions() help local-additions@en let lines = getline(line(".") + 1, search("^$") - 1) call assert_equal([ - \ '|mydoc.txt| my awesome doc' + \ '|mydoc.txt| my awesome doc' \ ], lines) close help local-additions@ja let lines = getline(line(".") + 1, search("^$") - 1) call assert_equal([ - \ '|mydoc.txt| my awesome doc', - \ '|help.txt| This is jax file', - \ '|work.txt| This is jax file', - \ '|work2.txt| This is jax file', + \ '|help.txt| This is jax file', + \ '|mydoc.txt| my awesome doc', + \ '|work.txt| This is jax file', + \ '|work2.txt| This is jax file', \ ], lines) close