diff options
| author | Justin M. Keyes <justinkz@gmail.com> | 2026-03-15 19:02:49 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-15 19:02:49 -0400 |
| commit | 16f7440cc7b59b7e5c79f593fedc117d2d16d7dd (patch) | |
| tree | 6794a10d9c955f0ce283a16268ed8aa28c2d2cb2 /runtime | |
| parent | 747da13f44ef895e51081065546f3c465637a615 (diff) | |
feat(help): super K (":help!") guesses tag at cursor #36205
Problem:
`K` in help files may fail in some noisy text. Example:
(`fun(config: vim.lsp.ClientConfig): boolean`)
^cursor
Solution:
- `:help!` (bang, no args) activates DWIM behavior: tries `<cWORD>`,
then trims punctuation until a valid tag is found.
- Set `keywordprg=:help!` by default.
- Does not affect `CTRL-]`, that is still fully "tags" based.
Diffstat (limited to 'runtime')
| -rw-r--r-- | runtime/doc/helphelp.txt | 19 | ||||
| -rw-r--r-- | runtime/doc/news.txt | 4 | ||||
| -rw-r--r-- | runtime/doc/options.txt | 23 | ||||
| -rw-r--r-- | runtime/ftplugin/help.vim | 2 | ||||
| -rw-r--r-- | runtime/lua/vim/_core/help.lua | 195 | ||||
| -rw-r--r-- | runtime/lua/vim/_meta/options.lua | 21 |
6 files changed, 245 insertions, 19 deletions
diff --git a/runtime/doc/helphelp.txt b/runtime/doc/helphelp.txt index bb39e94abf..a8ca6db4ea 100644 --- a/runtime/doc/helphelp.txt +++ b/runtime/doc/helphelp.txt @@ -161,6 +161,18 @@ Help commands *online-help* Type |gO| to see the table of contents. + *:help!* +:h[elp]! Guesses a help tag from the |WORD| at cursor, in DWIM ("Do + What I Mean") fashion: trims punctuation using various + heuristics until a valid help tag is found. + + For example, move the cursor anywhere in this code, + then run `:help!`: > + + local v = vim.version.parse(vim.system({'foo'}):wait().stdout) +< + Then compare to `:exe 'help' expand('<cword>')`. + *{subject}* *E149* *E661* :h[elp] {subject} Like ":help", additionally jump to the tag {subject}. For example: > @@ -234,9 +246,10 @@ Help commands *online-help* :help so<C-V><CR>only < -:h[elp]! [subject] Like ":help", but in non-English help files prefer to - find a tag in a file with the same language as the - current file. See |help-translated|. +:h[elp]! {subject} Like ":help", but prefers tags with the same + |help-translated| language as the current file. + + Unrelated to |:help!| (no {subject}). *:helpc* *:helpclose* :helpc[lose] Close one help window, if there is one. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 7e5f7565b1..e35f26680a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -220,6 +220,10 @@ EDITOR whitespace in indented lines. • |:uniq| deduplicates text in the current buffer. • |omnicompletion| in `help` buffer. |ft-help-omni| +• |:help!| has DWIM ("Do What I Mean") behavior: it tries to guess the help + tag at cursor. In help buffers, 'keywordprg' defaults to ":help!". For + example, try "K" anywhere in this code: > + local v = vim.version.parse(vim.system({'foo'}):wait().stdout) • Setting "'0" in 'shada' prevents storing the jumplist in the shada file. • 'shada' now correctly respects "/0" and "f0". • |prompt-buffer| supports multiline input/paste, undo/redo, and o/O normal diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index f40a4ee622..b6d61575cc 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -3904,18 +3904,25 @@ A jump table for the options with a short description can be found at |Q_op|. 'keywordprg' 'kp' string (default ":Man", Windows: ":help") global or local to buffer |global-local| Program to use for the |K| command. Environment variables are - expanded |:set_env|. ":help" may be used to access the Vim internal - help. (Note that previously setting the global option to the empty - value did this, which is now deprecated.) - When the first character is ":", the command is invoked as a Vim - Ex command prefixed with [count]. - When "man" or "man -s" is used, Vim will automatically translate - a [count] for the "K" command to a section number. + expanded |:set_env|. + + Special cases: + - ":help" opens the |word| at cursor using |:help|. (Note that + previously setting the global option to the empty value did this, + which is now deprecated.) + - ":help!" performs |:help!| (DWIM) on the |WORD| at cursor. + - If the value starts with ":", it is invoked as an Ex command + prefixed with [count]. + - If "man" or "man -s", [count] is the manpage section number. + See |option-backslash| about including spaces and backslashes. + Example: >vim + set keywordprg=:help! set keywordprg=man\ -s set keywordprg=:Man -< This option cannot be set from a |modeline| or in the |sandbox|, for +< + This option cannot be set from a |modeline| or in the |sandbox|, for security reasons. *'langmap'* *'lmap'* *E357* *E358* diff --git a/runtime/ftplugin/help.vim b/runtime/ftplugin/help.vim index e8651b5d59..7f60953738 100644 --- a/runtime/ftplugin/help.vim +++ b/runtime/ftplugin/help.vim @@ -17,7 +17,7 @@ let b:undo_ftplugin = "setl isk< fo< tw< cole< cocu< keywordprg< omnifunc< comme setl comments= cms= -setlocal formatoptions+=tcroql textwidth=78 keywordprg=:help omnifunc=s:HelpComplete +setlocal formatoptions+=tcroql textwidth=78 keywordprg=:help! omnifunc=s:HelpComplete let &l:iskeyword='!-~,^*,^|,^",192-255' if has("conceal") setlocal cole=2 cocu=nc diff --git a/runtime/lua/vim/_core/help.lua b/runtime/lua/vim/_core/help.lua index 8eae4e993b..87f9d462de 100644 --- a/runtime/lua/vim/_core/help.lua +++ b/runtime/lua/vim/_core/help.lua @@ -128,6 +128,201 @@ function M.escape_subject(word) return word end +--- Characters that are considered punctuation for trimming help tags. +--- Dots (.) are NOT included here — they're trimmed separately as a last resort. +local trimmable_punct = { + ['('] = true, + [')'] = true, + ['<'] = true, + ['>'] = true, + ['['] = true, + [']'] = true, + ['{'] = true, + ['}'] = true, + ['`'] = true, + ['|'] = true, + ['"'] = true, + [','] = true, + ["'"] = true, + [' '] = true, + ['\t'] = true, +} + +--- Trim one layer of punctuation from a help tag string. +--- Uses cursor offset to intelligently trim: if cursor is on trimmable punctuation, +--- removes everything before cursor and skips past punctuation after cursor. +--- +---@param tag string The tag to trim +---@param offset integer Cursor position within the tag (-1 if not applicable) +---@return string? trimmed Trimmed string, or nil if unchanged +local function trim_tag(tag, offset) + if not tag or tag == '' then + return nil + end + + -- Special cases: single character tags + if tag == '|' then + return 'bar' + end + if tag == '"' then + return 'quote' + end + + local len = #tag + -- start/end are 1-indexed inclusive positions into tag + local s = 1 + local e = len + + if offset >= 0 and offset < len and trimmable_punct[tag:sub(offset + 1, offset + 1)] then + -- Heuristic: cursor is on trimmable punctuation, skip past it to the right + s = offset + 1 + while s <= e and trimmable_punct[tag:sub(s, s)] do + s = s + 1 + end + elseif offset >= 0 and offset < len then + -- Cursor is on non-trimmable char: find start of identifier at cursor + local cursor_pos = offset + 1 -- 1-indexed + while cursor_pos > s and not trimmable_punct[tag:sub(cursor_pos - 1, cursor_pos - 1)] do + cursor_pos = cursor_pos - 1 + end + s = cursor_pos + else + -- No cursor info: trim leading punctuation + while s <= e and trimmable_punct[tag:sub(s, s)] do + s = s + 1 + end + end + + -- Trim trailing punctuation + while e >= s and trimmable_punct[tag:sub(e, e)] do + e = e - 1 + end + + -- Truncate at "(" with args, e.g. "foo('bar')" => "foo". + -- But keep "()" since it's part of valid tags like "vim.fn.expand()". + for i = s, e do + if tag:sub(i, i) == '(' and not (i + 1 <= e and tag:sub(i + 1, i + 1) == ')') then + e = i - 1 + break + end + end + + -- If nothing changed, return nil + if s == 1 and e == len then + return nil + end + + -- If everything was trimmed, return nil + if s > e then + return nil + end + + return tag:sub(s, e) +end + +--- Trim namespace prefix (dots) from a help tag. +--- Only call this if regular trimming didn't find a match. +--- Returns the tag with the leftmost dot-separated segment removed. +--- +---@param tag string The tag to trim +---@return string? trimmed Trimmed string, or nil if no dots found +local function trim_tag_dots(tag) + if not tag or tag == '' then + return nil + end + local after_dot = tag:match('^[^.]+%.(.+)$') + return after_dot +end + +--- For ":help!" (bang, no args): DWIM resolve a help tag from the cursor context. +--- Gets `<cWORD>` at cursor, tries it first, then trims punctuation and dots until a valid help +--- tag is found. Falls back to `<cword>` (keyword at cursor) before dot-trimming. +--- +---@return string? resolved The resolved help tag, or nil if no match found +function M.resolve_tag() + local tag = vim.fn.expand('<cWORD>') + if not tag or tag == '' then + return nil + end + + -- Compute cursor offset within <cWORD>. + local line = vim.api.nvim_get_current_line() + local col = vim.fn.col('.') -- 1-indexed + local s = col + -- Scan backward from col('.') to find the whitespace boundary. + while s > 1 and not line:sub(s - 1, s - 1):match('%s') do + s = s - 1 + end + local offset = col - s -- 0-indexed offset within <cWORD> + + -- Try the original tag first. + if #vim.fn.getcompletion(tag, 'help') > 0 then + return tag + end + + -- Extract |tag| reference if the cursor is inside one (help's link syntax). + local pipe_tag = tag:match('|(.+)|') + if pipe_tag and #vim.fn.getcompletion(pipe_tag, 'help') > 0 then + return pipe_tag + end + + -- Iteratively trim punctuation and try again, up to 10 times. + local candidate = tag + for _ = 1, 10 do + local trimmed = trim_tag(candidate, offset) + if not trimmed then + break + end + candidate = trimmed + -- After first trim, offset is no longer valid. + offset = -1 + + if #vim.fn.getcompletion(candidate, 'help') > 0 then + return candidate + end + end + + -- Try the word (alphanumeric/underscore run) at the cursor before dot-trimming, since + -- dot-trimming strips from the left and may move away from the cursor position. + -- E.g. for '@lsp.type.function' with cursor on "lsp", the word is "lsp". + -- Note: we don't use <cword> because it depends on 'iskeyword'. + local word_s, word_e = col, col + -- If cursor is not on a word char, find the nearest word char to the right. + if not line:sub(col, col):match('[%w_]') then + while word_s <= #line and not line:sub(word_s, word_s):match('[%w_]') do + word_s = word_s + 1 + end + word_e = word_s + end + while word_s > 1 and line:sub(word_s - 1, word_s - 1):match('[%w_]') do + word_s = word_s - 1 + end + while word_e <= #line and line:sub(word_e, word_e):match('[%w_]') do + word_e = word_e + 1 + end + word_e = word_e - 1 + local cword = line:sub(word_s, word_e) + if #cword > 1 and cword ~= tag and #vim.fn.getcompletion(cword, 'help') > 0 then + return cword + end + + -- Try trimming namespace dots (left-to-right). + for _ = 1, 10 do + local trimmed = trim_tag_dots(candidate) + if not trimmed then + break + end + candidate = trimmed + + if #vim.fn.getcompletion(candidate, 'help') > 0 then + return candidate + end + end + + -- No match found: return raw <cWORD> so the caller can show it in an error message. + return tag +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. diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua index 6652045c87..ab24f03595 100644 --- a/runtime/lua/vim/_meta/options.lua +++ b/runtime/lua/vim/_meta/options.lua @@ -3834,20 +3834,27 @@ vim.go.keymodel = vim.o.keymodel vim.go.km = vim.go.keymodel --- Program to use for the `K` command. Environment variables are ---- expanded `:set_env`. ":help" may be used to access the Vim internal ---- help. (Note that previously setting the global option to the empty ---- value did this, which is now deprecated.) ---- When the first character is ":", the command is invoked as a Vim ---- Ex command prefixed with [count]. ---- When "man" or "man -s" is used, Vim will automatically translate ---- a [count] for the "K" command to a section number. +--- expanded `:set_env`. +--- +--- Special cases: +--- - ":help" opens the `word` at cursor using `:help`. (Note that +--- previously setting the global option to the empty value did this, +--- which is now deprecated.) +--- - ":help!" performs `:help!` (DWIM) on the `WORD` at cursor. +--- - If the value starts with ":", it is invoked as an Ex command +--- prefixed with [count]. +--- - If "man" or "man -s", [count] is the manpage section number. +--- --- See `option-backslash` about including spaces and backslashes. +--- --- Example: --- --- ```vim +--- set keywordprg=:help! --- set keywordprg=man\ -s --- set keywordprg=:Man --- ``` +--- --- This option cannot be set from a `modeline` or in the `sandbox`, for --- security reasons. --- |
