From fcabbc2283c5d217d879d4ac0cc6c8501f15fc64 Mon Sep 17 00:00:00 2001 From: glepnir Date: Sat, 26 Apr 2025 13:06:43 +0800 Subject: [PATCH] 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 --- runtime/doc/builtin.txt | 52 ++++++++++++++ runtime/doc/options.txt | 13 ++++ runtime/lua/vim/_meta/options.lua | 18 +++++ runtime/lua/vim/_meta/vimfn.lua | 49 +++++++++++++ src/nvim/buffer.c | 1 + src/nvim/buffer_defs.h | 1 + src/nvim/eval.lua | 53 ++++++++++++++ src/nvim/insexpand.c | 98 ++++++++++++++++++++++++++ src/nvim/option.c | 4 ++ src/nvim/option_vars.h | 1 + src/nvim/options.lua | 23 ++++++ src/nvim/optionstr.c | 39 ++++++++++ test/old/testdir/test_ins_complete.vim | 82 +++++++++++++++++++++ 13 files changed, 434 insertions(+) diff --git a/runtime/doc/builtin.txt b/runtime/doc/builtin.txt index c95a16cd25..84f782ac4f 100644 --- 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 call CustomComplete() +< + + Return type: list> + + 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 index 6779c867ce..9bad6ebeea 100644 --- 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 index 1e0e40a3a3..13dec50231 100644 --- 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 index 5ba94663cd..14a078e5f4 100644 --- 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 call CustomComplete() +--- < +--- +--- Return type: list> +--- +--- @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 index 5fec2f1211..9d4365cdfc 100644 --- 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 index cd7cf408d6..d178518892 100644 --- 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 index 0013134857..d27f98831f 100644 --- 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 call CustomComplete() +< + + Return type: list> + ]=], + 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 index cd536144a0..eb9ae3c160 100644 --- 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 index 59f9b21fe5..026e73938d 100644 --- 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 index d8d3e1e124..9080469a09 100644 --- 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 index 624e4166a9..160e0f8078 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -4628,6 +4628,29 @@ local options = { type = 'boolean', 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', diff --git a/src/nvim/optionstr.c b/src/nvim/optionstr.c index e26099d71b..c66526318e 100644 --- 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 index ded4f7392e..9bc004733b 100644 --- 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 call TestComplete() + + call feedkeys("S/*\\", 'tx') + call assert_equal('/** */', getline('.')) + + call feedkeys("S/\\\", 'tx') + call assert_equal('// TODO:', getline('.')) + + call feedkeys("Swp.\\\", 'tx') + call assert_equal('wp.push()', getline('.')) + + call feedkeys("Swp.property.\\\", 'tx') + call assert_equal('wp.property.filter()', getline('.')) + + call feedkeys("Sp->\\\", 'tx') + call assert_equal('p->xfree()', getline('.')) + + call feedkeys("Swp->property.\\", 'tx') + call assert_equal('wp->property.map()', getline('.')) + + call feedkeys("Sabc\\", 'tx') + call assert_equal('abcdef', getline('.')) + + call feedkeys("S_\\", 'tx') + call assert_equal('_test1', getline('.')) + + set ise& + call feedkeys("Sabc \:let g:result=complete_match()\", '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你好 \:let g:result=complete_match()\", 'tx') + call assert_equal([[1, '你好'], [4, '好']], g:result) + + set ise=\\,,-> + call feedkeys("Sabc, \:let g:result=complete_match()\", 'tx') + call assert_equal([[4, ',']], g:result) + + bw! + unlet g:result + set isexpand& + delfunc TestComplete +endfunc + " vim: shiftwidth=2 sts=2 expandtab nofoldenable