commit b5ce7e74dcde885afeee7f0d943a196a5ec450f2
parent 29c81ba27e97765bda78c4cbf7112e9c6ed63e06
Author: Yochem van Rosmalen <git@yochem.nl>
Date: Sat, 14 Feb 2026 11:30:18 +0100
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.
Diffstat:
4 files changed, 84 insertions(+), 167 deletions(-)
diff --git 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
@@ -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<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(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
@@ -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 <CR>/<NL>.
- 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
@@ -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