aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim
diff options
context:
space:
mode:
authorYochem van Rosmalen <git@yochem.nl>2026-01-14 12:03:21 +0100
committerJustin M. Keyes <justinkz@gmail.com>2026-08-25 10:52:12 +0200
commitb36b3d7f3a07cfcc4e76079188bfd7d3976a9cd3 (patch)
treec648aca0553d78c7e4d240113c41d17357f2cd68 /runtime/lua/vim
parenta1de07418b89f1b30f9ca088306b2c1615f928c3 (diff)
downloadrneovim-b36b3d7f3a07cfcc4e76079188bfd7d3976a9cd3.tar.gz
rneovim-b36b3d7f3a07cfcc4e76079188bfd7d3976a9cd3.tar.bz2
rneovim-b36b3d7f3a07cfcc4e76079188bfd7d3976a9cd3.zip
feat(help): generate :helptags using Treesitter
Problem: Tags are manually parsed in C which is not flexible and prone to errors. Extending the help system to allow for other formats (e.g. Markdown) would require a large rewrite in the C core, while with Treesitter it only needs a query update. Solution: Use the power of treesitter to extract the tags from helpfiles. - build: set `$VIMRUNTIME` when generating helptags, like `cmake/Util.cmake` already does for other generators. - fix(help): only accept tags delimited by whitespace. The old C parser only accepted a `*tag*` preceded by start-of-line or whitespace and followed by whitespace or end-of-line. The vimdoc parser also captures tags followed by other text, e.g. `*$XDG_STATE_HOME*/.../logs` in starting.txt, which caused an E154 duplicate tag error for docs that were previously fine.
Diffstat (limited to 'runtime/lua/vim')
-rw-r--r--runtime/lua/vim/_core/help.lua147
1 files changed, 147 insertions, 0 deletions
diff --git a/runtime/lua/vim/_core/help.lua b/runtime/lua/vim/_core/help.lua
index 0dad8f93ff..efe57a9bcd 100644
--- a/runtime/lua/vim/_core/help.lua
+++ b/runtime/lua/vim/_core/help.lua
@@ -1,5 +1,7 @@
local M = {}
+local ts = vim.treesitter
+
local tag_exceptions = {
-- Interpret asterisk (star, '*') literal but name it 'star'
['*'] = 'star',
@@ -395,4 +397,149 @@ function M.local_additions()
end
end
+local query = ts.query.parse('vimdoc', '(tag (word) @tagname)')
+
+--- @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
+
+ for _, tagline in ipairs(tags) do
+ local curtag, curfn, _ = unpack(tagline)
+ 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 })
+ 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 file string
+local function extract_tags(tags, file)
+ local filename = vim.fs.basename(file)
+ local source = vim.fn.readblob(file)
+ 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 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)
+ 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 escaped = tagname:gsub('[\\/]', '\\%0')
+ local searchcmd = '/*' .. escaped .. '*'
+ table.insert(tags, { tagname, filename, searchcmd })
+ end
+ end
+ end
+ end
+end
+
+--- Extract tags from helpfiles and combine in a single 'tags' file.
+--- @param helpfiles string[] list of helpfiles
+--- @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[]
+ local tags = {}
+
+ -- (1) extract tags from all files
+ for _, file in ipairs(helpfiles) do
+ extract_tags(tags, file)
+ end
+
+ if include_helptags_tag then
+ table.insert(tags, { 'help-tags', 'tags', '1' })
+ end
+
+ if vim.tbl_isempty(tags) then
+ return
+ end
+
+ -- (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)
+
+ -- (4) write tags to file
+ local f = assert(io.open(outpath, 'w'))
+ for _, tag in ipairs(tags) do
+ f:write(table.concat(tag, '\t') .. '\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.
+---
+--- 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(vim.env.VIMRUNTIME, 'doc'))
+
+ for _, directory in ipairs(dirs) do
+ local files = vim.fs.find(function(name, _)
+ return vim.endswith(name, '.txt')
+ 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)
+
+ -- 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
+ ---@type table<string, string[]>
+ local per_lang = {}
+ for _, file in ipairs(translated) do
+ -- extract language code "nl" from filename "plugin.nlx"
+ local lang = file:sub(-3, -2)
+ 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)
+ end
+ end
+end
+
return M