summaryrefslogtreecommitdiffstatshomepage
path: root/runtime
diff options
context:
space:
mode:
authorJustin M. Keyes <justinkz@gmail.com>2026-03-15 19:02:49 -0400
committerGitHub <noreply@github.com>2026-03-15 19:02:49 -0400
commit16f7440cc7b59b7e5c79f593fedc117d2d16d7dd (patch)
tree6794a10d9c955f0ce283a16268ed8aa28c2d2cb2 /runtime
parent747da13f44ef895e51081065546f3c465637a615 (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.txt19
-rw-r--r--runtime/doc/news.txt4
-rw-r--r--runtime/doc/options.txt23
-rw-r--r--runtime/ftplugin/help.vim2
-rw-r--r--runtime/lua/vim/_core/help.lua195
-rw-r--r--runtime/lua/vim/_meta/options.lua21
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.
---