commit fcabbc2283c5d217d879d4ac0cc6c8501f15fc64
parent ac8ae1596cda8a96af0c26046463ba6327cfb0f8
Author: glepnir <glephunter@gmail.com>
Date: Sat, 26 Apr 2025 13:06:43 +0800
vim-patch:9.1.1341: cannot define completion triggers
Problem: Cannot define completion triggers and act upon it
Solution: add the new option 'isexpand' and add the complete_match()
function to return the completion matches according to the
'isexpand' setting (glepnir)
Currently, completion trigger position is determined solely by the
'iskeyword' pattern (\k\+$), which causes issues when users need
different completion behaviors - such as triggering after '/' for
comments or '.' for methods. Modifying 'iskeyword' to include these
characters has undesirable side effects on other Vim functionality that
relies on keyword definitions.
Introduce a new buffer-local option 'isexpand' that allows specifying
different completion triggers and add the complete_match() function that
finds the appropriate start column for completion based on these
triggers, scanning backwards from cursor position.
This separation of concerns allows customized completion behavior
without affecting iskeyword-dependent features. The option's
buffer-local nature enables per-filetype completion triggers.
closes: vim/vim#16716
https://github.com/vim/vim/commit/bcd5995b40a1c26e735bc326feb2e3ac4b05426b
Co-authored-by: glepnir <glephunter@gmail.com>
Diffstat:
13 files changed, 434 insertions(+), 0 deletions(-)
diff --git a/runtime/doc/builtin.txt b/runtime/doc/builtin.txt
@@ -1263,6 +1263,58 @@ complete_info([{what}]) *complete_info()*
Return: ~
(`table`)
+complete_match([{lnum}, {col}]) *complete_match()*
+ Returns a List of matches found according to the 'isexpand'
+ option. Each match is represented as a List containing
+ [startcol, trigger_text] where:
+ - startcol: column position where completion should start,
+ or -1 if no trigger position is found. For multi-character
+ triggers, returns the column of the first character.
+ - trigger_text: the matching trigger string from 'isexpand',
+ or empty string if no match was found or when using the
+ default 'iskeyword' pattern.
+
+ When 'isexpand' is empty, uses the 'iskeyword' pattern
+ "\k\+$" to find the start of the current keyword.
+
+ When no arguments are provided, uses the current cursor
+ position.
+
+ Examples: >
+ set isexpand=.,->,/,/*,abc
+ func CustomComplete()
+ let res = complete_match()
+ if res->len() == 0 | return | endif
+ let [col, trigger] = res[0]
+ let items = []
+ if trigger == '/*'
+ let items = ['/** */']
+ elseif trigger == '/'
+ let items = ['/*! */', '// TODO:', '// fixme:']
+ elseif trigger == '.'
+ let items = ['length()']
+ elseif trigger =~ '^\->'
+ let items = ['map()', 'reduce()']
+ elseif trigger =~ '^\abc'
+ let items = ['def', 'ghk']
+ endif
+ if items->len() > 0
+ let startcol = trigger =~ '^/' ? col : col + len(trigger)
+ call complete(startcol, items)
+ endif
+ endfunc
+ inoremap <Tab> <Cmd>call CustomComplete()<CR>
+<
+
+ Return type: list<list<any>>
+
+ Parameters: ~
+ • {lnum} (`integer??`)
+ • {col} (`integer??`)
+
+ Return: ~
+ (`table`)
+
confirm({msg} [, {choices} [, {default} [, {type}]]]) *confirm()*
confirm() offers the user a dialog, from which a choice can be
made. It returns the number of the choice. For the first
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
@@ -3537,6 +3537,19 @@ A jump table for the options with a short description can be found at |Q_op|.
and there is a letter before it, the completed part is made uppercase.
With 'noinfercase' the match is used as-is.
+ *'isexpand'* *'ise'*
+'isexpand' 'ise' string (default "")
+ global or local to buffer |global-local|
+ Defines characters and patterns for completion in insert mode. Used by
+ the |complete_match()| function to determine the starting position for
+ completion. This is a comma-separated list of triggers. Each trigger
+ can be:
+ - A single character like "." or "/"
+ - A sequence of characters like "->", "/*", or "/**"
+
+ Note: Use "\\," to add a literal comma as trigger character, see
+ |option-backslash|.
+
*'isfname'* *'isf'*
'isfname' 'isf' string (default for Windows:
"@,48-57,/,\,.,-,_,+,,,#,$,%,{,},[,],@-@,!,~,="
diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua
@@ -3429,6 +3429,24 @@ vim.o.inf = vim.o.infercase
vim.bo.infercase = vim.o.infercase
vim.bo.inf = vim.bo.infercase
+--- Defines characters and patterns for completion in insert mode. Used by
+--- the `complete_match()` function to determine the starting position for
+--- completion. This is a comma-separated list of triggers. Each trigger
+--- can be:
+--- - A single character like "." or "/"
+--- - A sequence of characters like "->", "/*", or "/**"
+---
+--- Note: Use "\\," to add a literal comma as trigger character, see
+--- `option-backslash`.
+---
+--- @type string
+vim.o.isexpand = ""
+vim.o.ise = vim.o.isexpand
+vim.bo.isexpand = vim.o.isexpand
+vim.bo.ise = vim.bo.isexpand
+vim.go.isexpand = vim.o.isexpand
+vim.go.ise = vim.go.isexpand
+
--- The characters specified by this option are included in file names and
--- path names. Filenames are used for commands like "gf", "[i" and in
--- the tags file. It is also used for "\f" in a `pattern`.
diff --git a/runtime/lua/vim/_meta/vimfn.lua b/runtime/lua/vim/_meta/vimfn.lua
@@ -1109,6 +1109,55 @@ function vim.fn.complete_check() end
--- @return table
function vim.fn.complete_info(what) end
+--- Returns a List of matches found according to the 'isexpand'
+--- option. Each match is represented as a List containing
+--- [startcol, trigger_text] where:
+--- - startcol: column position where completion should start,
+--- or -1 if no trigger position is found. For multi-character
+--- triggers, returns the column of the first character.
+--- - trigger_text: the matching trigger string from 'isexpand',
+--- or empty string if no match was found or when using the
+--- default 'iskeyword' pattern.
+---
+--- When 'isexpand' is empty, uses the 'iskeyword' pattern
+--- "\k\+$" to find the start of the current keyword.
+---
+--- When no arguments are provided, uses the current cursor
+--- position.
+---
+--- Examples: >
+--- set isexpand=.,->,/,/*,abc
+--- func CustomComplete()
+--- let res = complete_match()
+--- if res->len() == 0 | return | endif
+--- let [col, trigger] = res[0]
+--- let items = []
+--- if trigger == '/*'
+--- let items = ['/** */']
+--- elseif trigger == '/'
+--- let items = ['/*! */', '// TODO:', '// fixme:']
+--- elseif trigger == '.'
+--- let items = ['length()']
+--- elseif trigger =~ '^\->'
+--- let items = ['map()', 'reduce()']
+--- elseif trigger =~ '^\abc'
+--- let items = ['def', 'ghk']
+--- endif
+--- if items->len() > 0
+--- let startcol = trigger =~ '^/' ? col : col + len(trigger)
+--- call complete(startcol, items)
+--- endif
+--- endfunc
+--- inoremap <Tab> <Cmd>call CustomComplete()<CR>
+--- <
+---
+--- Return type: list<list<any>>
+---
+--- @param lnum? integer?
+--- @param col? integer?
+--- @return table
+function vim.fn.complete_match(lnum, col) end
+
--- confirm() offers the user a dialog, from which a choice can be
--- made. It returns the number of the choice. For the first
--- choice this is 1.
diff --git a/src/nvim/buffer.c b/src/nvim/buffer.c
@@ -2083,6 +2083,7 @@ void free_buf_options(buf_T *buf, bool free_p_ff)
clear_string_option(&buf->b_p_cinw);
clear_string_option(&buf->b_p_cot);
clear_string_option(&buf->b_p_cpt);
+ clear_string_option(&buf->b_p_ise);
clear_string_option(&buf->b_p_cfu);
callback_free(&buf->b_cfu_cb);
clear_string_option(&buf->b_p_ofu);
diff --git a/src/nvim/buffer_defs.h b/src/nvim/buffer_defs.h
@@ -558,6 +558,7 @@ struct file_buffer {
char *b_p_fo; ///< 'formatoptions'
char *b_p_flp; ///< 'formatlistpat'
int b_p_inf; ///< 'infercase'
+ char *b_p_ise; ///< 'isexpand'
char *b_p_isk; ///< 'iskeyword'
char *b_p_def; ///< 'define' local value
char *b_p_inc; ///< 'include'
diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua
@@ -1477,6 +1477,59 @@ M.funcs = {
returns = 'table',
signature = 'complete_info([{what}])',
},
+ complete_match = {
+ args = { 0, 2 },
+ base = 0,
+ desc = [=[
+ Returns a List of matches found according to the 'isexpand'
+ option. Each match is represented as a List containing
+ [startcol, trigger_text] where:
+ - startcol: column position where completion should start,
+ or -1 if no trigger position is found. For multi-character
+ triggers, returns the column of the first character.
+ - trigger_text: the matching trigger string from 'isexpand',
+ or empty string if no match was found or when using the
+ default 'iskeyword' pattern.
+
+ When 'isexpand' is empty, uses the 'iskeyword' pattern
+ "\k\+$" to find the start of the current keyword.
+
+ When no arguments are provided, uses the current cursor
+ position.
+
+ Examples: >
+ set isexpand=.,->,/,/*,abc
+ func CustomComplete()
+ let res = complete_match()
+ if res->len() == 0 | return | endif
+ let [col, trigger] = res[0]
+ let items = []
+ if trigger == '/*'
+ let items = ['/** */']
+ elseif trigger == '/'
+ let items = ['/*! */', '// TODO:', '// fixme:']
+ elseif trigger == '.'
+ let items = ['length()']
+ elseif trigger =~ '^\->'
+ let items = ['map()', 'reduce()']
+ elseif trigger =~ '^\abc'
+ let items = ['def', 'ghk']
+ endif
+ if items->len() > 0
+ let startcol = trigger =~ '^/' ? col : col + len(trigger)
+ call complete(startcol, items)
+ endif
+ endfunc
+ inoremap <Tab> <Cmd>call CustomComplete()<CR>
+<
+
+ Return type: list<list<any>>
+ ]=],
+ name = 'complete_match',
+ params = { { 'lnum', 'integer?' }, { 'col', 'integer?' } },
+ returns = 'table',
+ signature = 'complete_match([{lnum}, {col}])',
+ },
confirm = {
args = { 1, 4 },
base = 1,
diff --git a/src/nvim/insexpand.c b/src/nvim/insexpand.c
@@ -3081,6 +3081,104 @@ void f_complete_check(typval_T *argvars, typval_T *rettv, EvalFuncData fptr)
RedrawingDisabled = saved;
}
+/// Add match item to the return list.
+/// Returns FAIL if out of memory, OK otherwise.
+static int add_match_to_list(typval_T *rettv, char *str, int pos)
+{
+ list_T *match = tv_list_alloc(kListLenMayKnow);
+ if (match == NULL) {
+ return FAIL;
+ }
+
+ tv_list_append_number(match, pos + 1);
+ tv_list_append_string(match, str, -1);
+ tv_list_append_list(rettv->vval.v_list, match);
+ return OK;
+}
+
+/// "complete_match()" function
+void f_complete_match(typval_T *argvars, typval_T *rettv, EvalFuncData fptr)
+{
+
+ tv_list_alloc_ret(rettv, kListLenUnknown);
+
+ char *ise = curbuf->b_p_ise[0] != NUL ? curbuf->b_p_ise : p_ise;
+
+ linenr_T lnum = 0;
+ colnr_T col = 0;
+ char part[MAXPATHL];
+ if (argvars[0].v_type == VAR_UNKNOWN) {
+ lnum = curwin->w_cursor.lnum;
+ col = curwin->w_cursor.col;
+ } else if (argvars[1].v_type == VAR_UNKNOWN) {
+ emsg(_(e_invarg));
+ return;
+ } else {
+ lnum = (linenr_T)tv_get_number(&argvars[0]);
+ col = (colnr_T)tv_get_number(&argvars[1]);
+ if (lnum < 1 || lnum > curbuf->b_ml.ml_line_count) {
+ semsg(_(e_invalid_line_number_nr), lnum);
+ return;
+ }
+ if (col < 1 || col > ml_get_buf_len(curbuf, lnum)) {
+ semsg(_(e_invalid_column_number_nr), col + 1);
+ return;
+ }
+ }
+
+ char *line = ml_get_buf(curbuf, lnum);
+ if (line == NULL) {
+ return;
+ }
+
+ char *before_cursor = xstrnsave(line, (size_t)col);
+ if (before_cursor == NULL) {
+ return;
+ }
+
+ if (ise == NULL || *ise == NUL) {
+ regmatch_T regmatch;
+ regmatch.regprog = vim_regcomp("\\k\\+$", RE_MAGIC);
+ if (regmatch.regprog != NULL) {
+ if (vim_regexec_nl(®match, before_cursor, (colnr_T)0)) {
+ int bytepos = (int)(regmatch.startp[0] - before_cursor);
+ char *trig = xstrnsave(regmatch.startp[0], (size_t)(regmatch.endp[0] - regmatch.startp[0]));
+ if (trig == NULL) {
+ xfree(before_cursor);
+ return;
+ }
+
+ int ret = add_match_to_list(rettv, trig, bytepos);
+ xfree(trig);
+ if (ret == FAIL) {
+ xfree(trig);
+ vim_regfree(regmatch.regprog);
+ return;
+ }
+ }
+ vim_regfree(regmatch.regprog);
+ }
+ } else {
+ char *p = ise;
+ char *cur_end = before_cursor + (int)strlen(before_cursor);
+
+ while (*p != NUL) {
+ size_t len = copy_option_part(&p, part, MAXPATHL, ",");
+ if (len > 0 && (int)len <= col) {
+ if (strncmp(cur_end - len, part, len) == 0) {
+ int bytepos = col - (int)len;
+ if (add_match_to_list(rettv, part, bytepos) == FAIL) {
+ xfree(before_cursor);
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ xfree(before_cursor);
+}
+
/// Return Insert completion mode name string
static char *ins_compl_mode(void)
{
diff --git a/src/nvim/option.c b/src/nvim/option.c
@@ -4460,6 +4460,8 @@ void *get_varp_scope_from(vimoption_T *p, int opt_flags, buf_T *buf, win_T *win)
return &(buf->b_p_def);
case kOptInclude:
return &(buf->b_p_inc);
+ case kOptIsexpand:
+ return &(buf->b_p_ise);
case kOptCompleteopt:
return &(buf->b_p_cot);
case kOptDictionary:
@@ -4545,6 +4547,8 @@ void *get_varp_from(vimoption_T *p, buf_T *buf, win_T *win)
return *buf->b_p_def != NUL ? &(buf->b_p_def) : p->var;
case kOptInclude:
return *buf->b_p_inc != NUL ? &(buf->b_p_inc) : p->var;
+ case kOptIsexpand:
+ return *buf->b_p_ise != NUL ? &(buf->b_p_ise) : p->var;
case kOptCompleteopt:
return *buf->b_p_cot != NUL ? &(buf->b_p_cot) : p->var;
case kOptDictionary:
diff --git a/src/nvim/option_vars.h b/src/nvim/option_vars.h
@@ -376,6 +376,7 @@ EXTERN char *p_indk; ///< 'indentkeys'
EXTERN char *p_icm; ///< 'inccommand'
EXTERN char *p_isf; ///< 'isfname'
EXTERN char *p_isi; ///< 'isident'
+EXTERN char *p_ise; ///< 'isexpand'
EXTERN char *p_isk; ///< 'iskeyword'
EXTERN char *p_isp; ///< 'isprint'
EXTERN int p_js; ///< 'joinspaces'
diff --git a/src/nvim/options.lua b/src/nvim/options.lua
@@ -4629,6 +4629,29 @@ local options = {
immutable = true,
},
{
+ abbreviation = 'ise',
+ cb = 'did_set_isexpand',
+ defaults = '',
+ deny_duplicates = false,
+ desc = [=[
+ Defines characters and patterns for completion in insert mode. Used by
+ the |complete_match()| function to determine the starting position for
+ completion. This is a comma-separated list of triggers. Each trigger
+ can be:
+ - A single character like "." or "/"
+ - A sequence of characters like "->", "/*", or "/**"
+
+ Note: Use "\\," to add a literal comma as trigger character, see
+ |option-backslash|.
+ ]=],
+ full_name = 'isexpand',
+ list = 'onecomma',
+ scope = { 'global', 'buf' },
+ short_desc = N_('Defines characters and patterns for completion in insert mode'),
+ type = 'string',
+ varname = 'p_ise',
+ },
+ {
abbreviation = 'isf',
cb = 'did_set_isopt',
defaults = {
diff --git a/src/nvim/optionstr.c b/src/nvim/optionstr.c
@@ -85,6 +85,7 @@ void didset_string_options(void)
check_str_opt(kOptBackupcopy, NULL);
check_str_opt(kOptBelloff, NULL);
check_str_opt(kOptCompletefuzzycollect, NULL);
+ check_str_opt(kOptIsexpand, NULL);
check_str_opt(kOptCompleteopt, NULL);
check_str_opt(kOptSessionoptions, NULL);
check_str_opt(kOptViewoptions, NULL);
@@ -1316,6 +1317,44 @@ const char *did_set_inccommand(optset_T *args FUNC_ATTR_UNUSED)
return did_set_str_generic(args);
}
+/// The 'isexpand' option is changed.
+const char *did_set_isexpand(optset_T *args)
+{
+ char *ise = p_ise;
+ char *p;
+ bool last_was_comma = false;
+
+ if (args->os_flags & OPT_LOCAL) {
+ ise = curbuf->b_p_ise;
+ }
+
+ for (p = ise; *p != NUL;) {
+ if (*p == '\\' && p[1] == ',') {
+ p += 2;
+ last_was_comma = false;
+ continue;
+ }
+
+ if (*p == ',') {
+ if (last_was_comma) {
+ return e_invarg;
+ }
+ last_was_comma = true;
+ p++;
+ continue;
+ }
+
+ last_was_comma = false;
+ MB_PTR_ADV(p);
+ }
+
+ if (last_was_comma) {
+ return e_invarg;
+ }
+
+ return NULL;
+}
+
/// The 'iskeyword' option is changed.
const char *did_set_iskeyword(optset_T *args)
{
diff --git a/test/old/testdir/test_ins_complete.vim b/test/old/testdir/test_ins_complete.vim
@@ -3598,4 +3598,86 @@ func Test_nearest_cpt_option()
delfunc PrintMenuWords
endfunc
+func Test_complete_match()
+ set isexpand=.,/,->,abc,/*,_
+ func TestComplete()
+ let res = complete_match()
+ if res->len() == 0
+ return
+ endif
+ let [startcol, expandchar] = res[0]
+
+ if startcol >= 0
+ let line = getline('.')
+
+ let items = []
+ if expandchar == '/*'
+ let items = ['/** */']
+ elseif expandchar =~ '^/'
+ let items = ['/*! */', '// TODO:', '// fixme:']
+ elseif expandchar =~ '^\.' && startcol < 4
+ let items = ['length()', 'push()', 'pop()', 'slice()']
+ elseif expandchar =~ '^\.' && startcol > 4
+ let items = ['map()', 'filter()', 'reduce()']
+ elseif expandchar =~ '^\abc'
+ let items = ['def', 'ghk']
+ elseif expandchar =~ '^\->'
+ let items = ['free()', 'xfree()']
+ else
+ let items = ['test1', 'test2', 'test3']
+ endif
+
+ call complete(expandchar =~ '^/' ? startcol : startcol + strlen(expandchar), items)
+ endif
+ endfunc
+
+ new
+ inoremap <buffer> <F5> <cmd>call TestComplete()<CR>
+
+ call feedkeys("S/*\<F5>\<C-Y>", 'tx')
+ call assert_equal('/** */', getline('.'))
+
+ call feedkeys("S/\<F5>\<C-N>\<C-Y>", 'tx')
+ call assert_equal('// TODO:', getline('.'))
+
+ call feedkeys("Swp.\<F5>\<C-N>\<C-Y>", 'tx')
+ call assert_equal('wp.push()', getline('.'))
+
+ call feedkeys("Swp.property.\<F5>\<C-N>\<C-Y>", 'tx')
+ call assert_equal('wp.property.filter()', getline('.'))
+
+ call feedkeys("Sp->\<F5>\<C-N>\<C-Y>", 'tx')
+ call assert_equal('p->xfree()', getline('.'))
+
+ call feedkeys("Swp->property.\<F5>\<C-Y>", 'tx')
+ call assert_equal('wp->property.map()', getline('.'))
+
+ call feedkeys("Sabc\<F5>\<C-Y>", 'tx')
+ call assert_equal('abcdef', getline('.'))
+
+ call feedkeys("S_\<F5>\<C-Y>", 'tx')
+ call assert_equal('_test1', getline('.'))
+
+ set ise&
+ call feedkeys("Sabc \<ESC>:let g:result=complete_match()\<CR>", 'tx')
+ call assert_equal([[1, 'abc']], g:result)
+
+ call assert_fails('call complete_match(99, 0)', 'E966:')
+ call assert_fails('call complete_match(1, 99)', 'E964:')
+ call assert_fails('call complete_match(1)', 'E474:')
+
+ set ise=你好,好
+ call feedkeys("S你好 \<ESC>:let g:result=complete_match()\<CR>", 'tx')
+ call assert_equal([[1, '你好'], [4, '好']], g:result)
+
+ set ise=\\,,->
+ call feedkeys("Sabc, \<ESC>:let g:result=complete_match()\<CR>", 'tx')
+ call assert_equal([[4, ',']], g:result)
+
+ bw!
+ unlet g:result
+ set isexpand&
+ delfunc TestComplete
+endfunc
+
" vim: shiftwidth=2 sts=2 expandtab nofoldenable