diff --git a/runtime/doc/insert.txt b/runtime/doc/insert.txt index c478428d5a..122d16428d 100644 --- a/runtime/doc/insert.txt +++ b/runtime/doc/insert.txt @@ -628,7 +628,7 @@ Completion can be done for: 10. User defined completion |i_CTRL-X_CTRL-U| 11. omni completion |i_CTRL-X_CTRL-O| 12. Spelling suggestions |i_CTRL-X_s| -13. keywords in 'complete' |i_CTRL-N| |i_CTRL-P| +13. completions from 'complete' |i_CTRL-N| |i_CTRL-P| 14. contents from registers |i_CTRL-X_CTRL-R| Additionally, |i_CTRL-X_CTRL-Z| stops completion without changing the text. @@ -1082,25 +1082,23 @@ CTRL-X s Locate the word in front of the cursor and find the previous one. -Completing keywords from different sources *compl-generic* +Completing from different sources *compl-generic* *i_CTRL-N* -CTRL-N Find next match for words that start with the - keyword in front of the cursor, looking in places - specified with the 'complete' option. The found - keyword is inserted in front of the cursor. +CTRL-N Find the next match for a word ending at the cursor, + using the sources specified in the 'complete' option. + All sources complete from keywords, except functions, + which may complete from non-keyword. The matched + text is inserted before the cursor. *i_CTRL-P* -CTRL-P Find previous match for words that start with the - keyword in front of the cursor, looking in places - specified with the 'complete' option. The found - keyword is inserted in front of the cursor. +CTRL-P Same as CTRL-N, but find the previous match. - CTRL-N Search forward for next matching keyword. This - keyword replaces the previous matching keyword. + CTRL-N Search forward through the matches and insert the + next one. - CTRL-P Search backwards for next matching keyword. This - keyword replaces the previous matching keyword. + CTRL-P Search backward through the matches and insert the + previous one. CTRL-X CTRL-N or CTRL-X CTRL-P Further use of CTRL-X CTRL-N or CTRL-X CTRL-P will diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 74253532e3..33a464ea7f 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -1512,15 +1512,12 @@ A jump table for the options with a short description can be found at |Q_op|. name of a function or a |Funcref|. For |Funcref| values, spaces must be escaped with a backslash ('\'), and commas with double backslashes ('\\') (see |option-backslash|). + Unlike other sources, functions can provide completions starting + from a non-keyword character before the cursor, and their + start position for replacing text may differ from other sources. If the Dict returned by the {func} includes {"refresh": "always"}, the function will be invoked again whenever the leading text changes. - Completion matches are always inserted at the keyword - boundary, regardless of the column returned by {func} when - a:findstart is 1. This ensures compatibility with other - completion sources. - To make further modifications to the inserted text, {func} - can make use of |CompleteDonePre|. If generating matches is potentially slow, |complete_check()| should be used to avoid blocking and preserve editor responsiveness. diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua index 922f687db1..962f5d6812 100644 --- a/runtime/lua/vim/_meta/options.lua +++ b/runtime/lua/vim/_meta/options.lua @@ -1033,15 +1033,12 @@ vim.bo.cms = vim.bo.commentstring --- name of a function or a `Funcref`. For `Funcref` values, --- spaces must be escaped with a backslash ('\'), and commas with --- double backslashes ('\\') (see `option-backslash`). +--- Unlike other sources, functions can provide completions starting +--- from a non-keyword character before the cursor, and their +--- start position for replacing text may differ from other sources. --- If the Dict returned by the {func} includes {"refresh": "always"}, --- the function will be invoked again whenever the leading text --- changes. ---- Completion matches are always inserted at the keyword ---- boundary, regardless of the column returned by {func} when ---- a:findstart is 1. This ensures compatibility with other ---- completion sources. ---- To make further modifications to the inserted text, {func} ---- can make use of `CompleteDonePre`. --- If generating matches is potentially slow, `complete_check()` --- should be used to avoid blocking and preserve editor --- responsiveness. diff --git a/src/nvim/insexpand.c b/src/nvim/insexpand.c index 04ca169cdd..1190cdb0ad 100644 --- a/src/nvim/insexpand.c +++ b/src/nvim/insexpand.c @@ -313,6 +313,7 @@ typedef struct cpt_source_T { int cs_max_matches; ///< Max items to display from this source } cpt_source_T; +#define STARTCOL_NONE -9 /// Pointer to the array of completion sources static cpt_source_T *cpt_sources_array; /// Total number of completion sources specified in the 'cpt' option @@ -1336,6 +1337,62 @@ static int cp_compare_nearest(const void *a, const void *b) return (score_a > score_b) ? 1 : (score_a < score_b) ? -1 : 0; } +/// Constructs a new string by prepending text from the current line (from +/// startcol to compl_col) to the given source string. Stores the result in +/// dest. +static void prepend_startcol_text(String *dest, String *src, int startcol) +{ + int prepend_len = compl_col - startcol; + int new_length = prepend_len + (int)src->size; + + dest->size = (size_t)new_length; + dest->data = xmalloc((size_t)new_length + 1); // +1 for NUL + + char *line = ml_get(curwin->w_cursor.lnum); + + memmove(dest->data, line + startcol, (size_t)prepend_len); + memmove(dest->data + prepend_len, src->data, src->size); + dest->data[new_length] = NUL; +} + +/// Returns the completion leader string adjusted for a specific source's +/// startcol. If the source's startcol is before compl_col, prepends text from +/// the buffer line to the original compl_leader. +static String *get_leader_for_startcol(compl_T *match, bool cached) +{ + static String adjusted_leader = STRING_INIT; + + if (match == NULL) { + API_CLEAR_STRING(adjusted_leader); + return NULL; + } + + if (cpt_sources_array == NULL || compl_leader.data == NULL) { + goto theend; + } + + int cpt_idx = match->cp_cpt_source_idx; + if (cpt_idx < 0 || compl_col <= 0) { + goto theend; + } + int startcol = cpt_sources_array[cpt_idx].cs_startcol; + + if (startcol >= 0 && startcol < compl_col) { + int prepend_len = compl_col - startcol; + int new_length = prepend_len + (int)compl_leader.size; + if (cached && (size_t)new_length == adjusted_leader.size + && adjusted_leader.data != NULL) { + return &adjusted_leader; + } + + API_CLEAR_STRING(adjusted_leader); + prepend_startcol_text(&adjusted_leader, &compl_leader, startcol); + return &adjusted_leader; + } +theend: + return &compl_leader; +} + /// Set fuzzy score. static void set_fuzzy_score(void) { @@ -1343,9 +1400,12 @@ static void set_fuzzy_score(void) return; } + (void)get_leader_for_startcol(NULL, true); // Clear the cache + compl_T *comp = compl_first_match; do { - comp->cp_score = fuzzy_match_str(comp->cp_str.data, compl_leader.data); + comp->cp_score = fuzzy_match_str(comp->cp_str.data, + get_leader_for_startcol(comp, true)->data); comp = comp->cp_next; } while (comp != NULL && !is_first_match(comp)); } @@ -1422,6 +1482,8 @@ static int ins_compl_build_pum(void) match_count = xcalloc((size_t)cpt_sources_count, sizeof(int)); } + (void)get_leader_for_startcol(NULL, true); // Clear the cache + comp = compl_first_match; do { comp->cp_in_match_array = false; @@ -1432,9 +1494,11 @@ static int ins_compl_build_pum(void) comp->cp_flags &= ~CP_ICASE; } + String *leader = get_leader_for_startcol(comp, true); + if (!match_at_original_text(comp) - && (compl_leader.data == NULL - || ins_compl_equal(comp, compl_leader.data, compl_leader.size) + && (leader->data == NULL + || ins_compl_equal(comp, leader->data, leader->size) || (fuzzy_filter && comp->cp_score > 0))) { // Limit number of items from each source if max_items is set. bool match_limit_exceeded = false; @@ -2107,6 +2171,7 @@ static bool ins_compl_need_restart(void) static void ins_compl_new_leader(void) { unsigned cur_cot_flags = get_cot_flags(); + ins_compl_del_pum(); ins_compl_delete(true); ins_compl_insert_bytes(compl_leader.data + get_compl_len(), -1); @@ -4272,7 +4337,7 @@ static bool get_next_completion_match(int type, ins_compl_next_state_T *st, pos_ case CTRL_X_FUNCTION: if (ctrl_x_mode_normal()) { // Invoked by a func in 'cpt' option - get_cpt_func_completion_matches(st->func_cb, true); + get_cpt_func_completion_matches(st->func_cb); } else { expand_by_function(type, compl_pattern.data, NULL); } @@ -4365,6 +4430,10 @@ static void prepare_cpt_compl_funcs(void) while (*p == ',' || *p == ' ') { // Skip delimiters p++; } + if (*p == NUL) { + break; + } + Callback *cb = get_callback_if_cpt_func(p); if (cb) { int startcol; @@ -4376,7 +4445,10 @@ static void prepare_cpt_compl_funcs(void) } } cpt_sources_array[idx].cs_startcol = startcol; + } else { + cpt_sources_array[idx].cs_startcol = STARTCOL_NONE; } + (void)copy_option_part(&p, IObuff, IOSIZE, ","); // Advance p idx++; } @@ -4562,23 +4634,27 @@ static int ins_compl_get_exp(pos_T *ini) /// "compl_leader" is used to omit some of the matches. static void ins_compl_update_shown_match(void) { - while (!ins_compl_equal(compl_shown_match, - compl_leader.data, compl_leader.size) + (void)get_leader_for_startcol(NULL, true); // Clear the cache + String *leader = get_leader_for_startcol(compl_shown_match, true); + + while (!ins_compl_equal(compl_shown_match, leader->data, leader->size) && compl_shown_match->cp_next != NULL && !is_first_match(compl_shown_match->cp_next)) { compl_shown_match = compl_shown_match->cp_next; + leader = get_leader_for_startcol(compl_shown_match, true); } // If we didn't find it searching forward, and compl_shows_dir is // backward, find the last match. if (compl_shows_dir_backward() - && !ins_compl_equal(compl_shown_match, compl_leader.data, compl_leader.size) + && !ins_compl_equal(compl_shown_match, leader->data, leader->size) && (compl_shown_match->cp_next == NULL || is_first_match(compl_shown_match->cp_next))) { - while (!ins_compl_equal(compl_shown_match, compl_leader.data, compl_leader.size) + while (!ins_compl_equal(compl_shown_match, leader->data, leader->size) && compl_shown_match->cp_prev != NULL && !is_first_match(compl_shown_match->cp_prev)) { compl_shown_match = compl_shown_match->cp_prev; + leader = get_leader_for_startcol(compl_shown_match, true); } } } @@ -4694,6 +4770,23 @@ void ins_compl_insert(bool move_cursor) size_t leader_len = ins_compl_leader_len(); char *has_multiple = strchr(cp_str, '\n'); + // Since completion sources may provide matches with varying start + // positions, insert only the portion of the match that corresponds to the + // intended replacement range. + if (cpt_sources_array != NULL) { + int cpt_idx = compl_shown_match->cp_cpt_source_idx; + if (cpt_idx >= 0 && compl_col >= 0) { + int startcol = cpt_sources_array[cpt_idx].cs_startcol; + if (startcol >= 0 && startcol < (int)compl_col) { + int skip = (int)compl_col - startcol; + if ((size_t)skip <= cp_str_len) { + cp_str_len -= (size_t)skip; + cp_str += skip; + } + } + } + } + // Make sure we don't go over the end of the string, this can happen with // illegal bytes. if (compl_len < (int)cp_str_len) { @@ -4837,10 +4930,12 @@ static int find_next_completion_match(bool allow_get_expansion, int todo, bool a } found_end = false; } + + String *leader = get_leader_for_startcol(compl_shown_match, false); + if (!match_at_original_text(compl_shown_match) - && compl_leader.data != NULL - && !ins_compl_equal(compl_shown_match, - compl_leader.data, compl_leader.size) + && leader->data != NULL + && !ins_compl_equal(compl_shown_match, leader->data, leader->size) && !(compl_fuzzy_match && compl_shown_match->cp_score > 0)) { todo++; } else { @@ -5006,7 +5101,7 @@ void ins_compl_check_keys(int frequency, bool in_compl_func) // Check for a typed key. Do use mappings, otherwise vim_is_ctrl_x_key() // can't do its work correctly. int c = vpeekc_any(); - if (c != NUL) { + if (c != NUL && !test_disable_char_avail) { if (vim_is_ctrl_x_key(c) && c != Ctrl_X && c != Ctrl_R) { c = safe_vgetc(); // Eat the character compl_shows_dir = ins_compl_key2dir(c); @@ -5255,18 +5350,24 @@ static int get_cmdline_compl_info(char *line, colnr_T curs_col) /// compl_col, compl_length, compl_pattern, and cpt_compl_pattern. static void set_compl_globals(int startcol, colnr_T curs_col, bool is_cpt_compl) { - if (startcol < 0 || startcol > curs_col) { - startcol = curs_col; - } - int len = curs_col - startcol; + if (is_cpt_compl) { + API_CLEAR_STRING(cpt_compl_pattern); + if (startcol < compl_col) { + prepend_startcol_text(&cpt_compl_pattern, &compl_orig_text, startcol); + return; + } else { + cpt_compl_pattern = copy_string(compl_orig_text, NULL); + } + } else { + if (startcol < 0 || startcol > curs_col) { + startcol = curs_col; + } - // Re-obtain line in case it has changed - char *line = ml_get(curwin->w_cursor.lnum); + // Re-obtain line in case it has changed + char *line = ml_get(curwin->w_cursor.lnum); + int len = curs_col - startcol; - String *pattern = is_cpt_compl ? &cpt_compl_pattern : &compl_pattern; - pattern->data = xstrnsave(line + startcol, (size_t)len); - pattern->size = (size_t)len; - if (!is_cpt_compl) { + compl_pattern = cbuf_to_string(line + startcol, (size_t)len); compl_col = startcol; compl_length = len; } @@ -5390,7 +5491,10 @@ static int compl_get_info(char *line, int startcol, colnr_T curs_col, bool *line if (ctrl_x_mode_normal() || ctrl_x_mode_register() || ((ctrl_x_mode & CTRL_X_WANT_IDENT) && !thesaurus_func_complete(ctrl_x_mode))) { - return get_normal_compl_info(line, startcol, curs_col); + if (get_normal_compl_info(line, startcol, curs_col) != OK) { + return FAIL; + } + *line_invalid = true; // 'cpt' func may have invalidated "line" } else if (ctrl_x_mode_line_or_eval()) { return get_wholeline_compl_info(line, curs_col); } else if (ctrl_x_mode_files()) { @@ -5971,24 +6075,15 @@ static compl_T *remove_old_matches(void) /// Retrieve completion matches using the callback function "cb" and store the /// 'refresh:always' flag. -static void get_cpt_func_completion_matches(Callback *cb, bool restore_leader) +static void get_cpt_func_completion_matches(Callback *cb) { int startcol = cpt_sources_array[cpt_sources_index].cs_startcol; - API_CLEAR_STRING(cpt_compl_pattern); - if (startcol == -2 || startcol == -3) { return; } - if (restore_leader) { // Re-insert the text removed by ins_compl_delete() - ins_compl_insert_bytes(compl_orig_text.data + get_compl_len(), -1); - } set_compl_globals(startcol, curwin->w_cursor.col, true); - if (restore_leader) { - ins_compl_delete(false); // Undo insertion - } - expand_by_function(0, cpt_compl_pattern.data, cb); cpt_sources_array[cpt_sources_index].cs_refresh_always = compl_opt_refresh_always; compl_opt_refresh_always = false; @@ -6009,6 +6104,9 @@ static void cpt_compl_refresh(void) while (*p == ',' || *p == ' ') { // Skip delimiters p++; } + if (*p == NUL) { + break; + } if (cpt_sources_array[cpt_sources_index].cs_refresh_always) { Callback *cb = get_callback_if_cpt_func(p); @@ -6025,8 +6123,10 @@ static void cpt_compl_refresh(void) } cpt_sources_array[cpt_sources_index].cs_startcol = startcol; if (ret == OK) { - get_cpt_func_completion_matches(cb, false); + get_cpt_func_completion_matches(cb); } + } else { + cpt_sources_array[cpt_sources_index].cs_startcol = STARTCOL_NONE; } } diff --git a/src/nvim/options.lua b/src/nvim/options.lua index 5d7341fa8c..bc21674204 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -1444,15 +1444,12 @@ local options = { name of a function or a |Funcref|. For |Funcref| values, spaces must be escaped with a backslash ('\'), and commas with double backslashes ('\\') (see |option-backslash|). + Unlike other sources, functions can provide completions starting + from a non-keyword character before the cursor, and their + start position for replacing text may differ from other sources. If the Dict returned by the {func} includes {"refresh": "always"}, the function will be invoked again whenever the leading text changes. - Completion matches are always inserted at the keyword - boundary, regardless of the column returned by {func} when - a:findstart is 1. This ensures compatibility with other - completion sources. - To make further modifications to the inserted text, {func} - can make use of |CompleteDonePre|. If generating matches is potentially slow, |complete_check()| should be used to avoid blocking and preserve editor responsiveness. diff --git a/test/old/testdir/test_ins_complete.vim b/test/old/testdir/test_ins_complete.vim index 74c5ac989a..0083976dc4 100644 --- a/test/old/testdir/test_ins_complete.vim +++ b/test/old/testdir/test_ins_complete.vim @@ -137,8 +137,7 @@ func Test_omni_dash() %d set complete=o exe "normal Gofind -\" - " 'complete' inserts at 'iskeyword' boundary (so you get --help) - call assert_equal("find --help", getline('$')) + call assert_equal("find -help", getline('$')) bwipe! delfunc Omni @@ -368,7 +367,7 @@ func Test_CompleteDone_vevent_keys() call assert_equal('spell', g:complete_type) bwipe! - set completeopt& omnifunc& completefunc& spell& spelllang& dictionary& + set completeopt& omnifunc& completefunc& spell& spelllang& dictionary& complete& autocmd! CompleteDone delfunc OnDone delfunc CompleteFunc @@ -1112,6 +1111,7 @@ func Test_completefunc_invalid_data() exe "normal i\" call assert_equal('moon', getline(1)) set completefunc& complete& + delfunc! CompleteFunc bw! endfunc @@ -4943,4 +4943,120 @@ func Test_complete_fuzzy_omnifunc_backspace() unlet g:do_complete endfunc +" Test 'complete' containing F{func} that complete from nonkeyword +func Test_nonkeyword_trigger() + + " Trigger expansion even when another char is waiting in the typehead + call Ntest_override("char_avail", 1) + + let g:CallCount = 0 + func! NonKeywordComplete(findstart, base) + let line = getline('.')->strpart(0, col('.') - 1) + let nonkeyword2 = len(line) > 1 && match(line[-2:-2], '\k') != 0 + if a:findstart + return nonkeyword2 ? col('.') - 3 : (col('.') - 2) + else + let g:CallCount += 1 + return [$"{a:base}foo", $"{a:base}bar"] + endif + endfunc + + new + inoremap let b:matches = complete_info(["matches"]).matches + inoremap let b:selected = complete_info(["selected"]).selected + call setline(1, ['abc', 'abcd', 'fo', 'b', '']) + + " Test 1a: Nonkeyword before cursor lists words with at least two letters + call feedkeys("GS=\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'fo'], b:matches->mapnew('v:val.word')) + call assert_equal('=abc', getline('.')) + + " Test 1b: With F{func} nonkeyword collects matches + set complete=.,FNonKeywordComplete + for noselect in range(2) + if noselect + set completeopt+=noselect + endif + let g:CallCount = 0 + call feedkeys("S=\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'fo', '=foo', '=bar'], b:matches->mapnew('v:val.word')) + call assert_equal(1, g:CallCount) + call assert_equal(noselect ? '=' : '=abc', getline('.')) + let g:CallCount = 0 + call feedkeys("S->\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'fo', '->foo', '->bar'], b:matches->mapnew('v:val.word')) + call assert_equal(1, g:CallCount) + call assert_equal(noselect ? '->' : '->abc', getline('.')) + set completeopt& + endfor + + " Test 1c: Keyword collects from {func} + let g:CallCount = 0 + call feedkeys("Sa\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'afoo', 'abar'], b:matches->mapnew('v:val.word')) + call assert_equal(1, g:CallCount) + call assert_equal('abc', getline('.')) + + set completeopt+=noselect + let g:CallCount = 0 + call feedkeys("Sa\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'afoo', 'abar'], b:matches->mapnew('v:val.word')) + call assert_equal(1, g:CallCount) + call assert_equal('a', getline('.')) + + " Test 1d: Nonkeyword after keyword collects items again + let g:CallCount = 0 + call feedkeys("Sa\#\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'fo', '#foo', '#bar'], b:matches->mapnew('v:val.word')) + call assert_equal(2, g:CallCount) + call assert_equal('a#', getline('.')) + set completeopt& + + " Test 2: Filter nonkeyword and keyword matches with differet startpos + set completeopt+=menuone,noselect + call feedkeys("S#a\b\\\0", 'tx!') + call assert_equal(['abc', 'abcd', '#abar'], b:matches->mapnew('v:val.word')) + call assert_equal(-1, b:selected) + call assert_equal('#ab', getline('.')) + + set completeopt+=fuzzy + call feedkeys("S#a\b\\\0", 'tx!') + call assert_equal(['#abar', 'abc', 'abcd'], b:matches->mapnew('v:val.word')) + call assert_equal(-1, b:selected) + call assert_equal('#ab', getline('.')) + set completeopt& + + " Test 3: Navigate menu containing nonkeyword and keyword items + call feedkeys("S->\\\0", 'tx!') + call assert_equal(['abc', 'abcd', 'fo', '->foo', '->bar'], b:matches->mapnew('v:val.word')) + call assert_equal('->abc', getline('.')) + call feedkeys("S->" . repeat("\", 3) . "\0", 'tx!') + call assert_equal('->fo', getline('.')) + call feedkeys("S->" . repeat("\", 4) . "\0", 'tx!') + call assert_equal('->foo', getline('.')) + call feedkeys("S->" . repeat("\", 4) . "\\0", 'tx!') + call assert_equal('->fo', getline('.')) + call feedkeys("S->" . repeat("\", 5) . "\0", 'tx!') + call assert_equal('->bar', getline('.')) + call feedkeys("S->" . repeat("\", 5) . "\\0", 'tx!') + call assert_equal('->foo', getline('.')) + call feedkeys("S->" . repeat("\", 6) . "\0", 'tx!') + call assert_equal('->', getline('.')) + call feedkeys("S->" . repeat("\", 7) . "\0", 'tx!') + call assert_equal('->abc', getline('.')) + call feedkeys("S->" . repeat("\", 7) . "\0", 'tx!') + call assert_equal('->fo', getline('.')) + " Replace + call feedkeys("S# x y z\0lR\\0", 'tx!') + call assert_equal('#abcy z', getline('.')) + call feedkeys("S# x y z\0lR" . repeat("\", 4) . "\0", 'tx!') + call assert_equal('#bary z', getline('.')) + + bw! + call Ntest_override("char_avail", 0) + delfunc NonKeywordComplete + set complete& + unlet g:CallCount +endfunc + " vim: shiftwidth=2 sts=2 expandtab nofoldenable