diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 99e6d54f89..b3b29d0d8a 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -1,4 +1,4 @@ -*options.txt* For Vim version 9.1. Last change: 2025 Apr 14 +*options.txt* For Vim version 9.1. Last change: 2025 Apr 15 VIM REFERENCE MANUAL by Bram Moolenaar @@ -2201,6 +2201,10 @@ A jump table for the options with a short description can be found at |Q_op|. Useful when there is additional information about the match, e.g., what file it comes from. + nearest Matches are presented in order of proximity to the cursor + position. This applies only to matches from the current + buffer. No effect if "fuzzy" is present. + noinsert Do not insert any text for a match until the user selects a match from the menu. Only works in combination with "menu" or "menuone". No effect if "longest" is present. diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt index 50fb66ff93..27470f0022 100644 --- a/runtime/doc/version9.txt +++ b/runtime/doc/version9.txt @@ -1,4 +1,4 @@ -*version9.txt* For Vim version 9.1. Last change: 2025 Apr 14 +*version9.txt* For Vim version 9.1. Last change: 2025 Apr 15 VIM REFERENCE MANUAL by Bram Moolenaar @@ -41613,6 +41613,7 @@ Completion: ~ - New option value for 'completeopt': "nosort" - do not sort completion results "preinsert" - highlight to be inserted values + "nearest" - sort completion results by distance to cursor - handle multi-line completion items as expected - improved commandline completion for the |:hi| command - New option value for 'wildmode': diff --git a/src/insexpand.c b/src/insexpand.c index 8c15adeee4..55c0e483e2 100644 --- a/src/insexpand.c +++ b/src/insexpand.c @@ -105,7 +105,7 @@ struct compl_S // cp_flags has CP_FREE_FNAME int cp_flags; // CP_ values int cp_number; // sequence number - int cp_score; // fuzzy match score + int cp_score; // fuzzy match score or proximity score int cp_in_match_array; // collected by compl_match_array int cp_user_abbr_hlattr; // highlight attribute for abbr int cp_user_kind_hlattr; // highlight attribute for kind @@ -792,6 +792,88 @@ cfc_has_mode(void) return FALSE; } +/* + * Returns TRUE if matches should be sorted based on proximity to the cursor. + */ + static int +is_nearest_active(void) +{ + unsigned int flags = get_cot_flags(); + + return (flags & COT_NEAREST) && !(flags & COT_FUZZY); +} + +/* + * Repositions a match in the completion list based on its proximity score. + * If the match is at the head and has a higher score than the next node, + * or if it's in the middle/tail and has a lower score than the previous node, + * it is moved to the correct position while maintaining ascending order. + */ + static void +reposition_match(compl_T *match) +{ + compl_T *insert_before = NULL; + compl_T *insert_after = NULL; + + // Node is at head and score is too big + if (!match->cp_prev) + { + if (match->cp_next && match->cp_next->cp_score > 0 && + match->cp_next->cp_score < match->cp_score) + { + // : compl_first_match is at head and newly inserted node + compl_first_match = compl_curr_match = match->cp_next; + // Find the correct position in ascending order + insert_before = match->cp_next; + do + { + insert_after = insert_before; + insert_before = insert_before->cp_next; + } while (insert_before && insert_before->cp_score > 0 && + insert_before->cp_score < match->cp_score); + } + else + return; + } + // Node is at tail or in the middle but score is too small + else + { + if (match->cp_prev->cp_score > 0 && match->cp_prev->cp_score > match->cp_score) + { + // : compl_curr_match (and newly inserted match) is at tail + if (!match->cp_next) + compl_curr_match = compl_curr_match->cp_prev; + // Find the correct position in ascending order + insert_after = match->cp_prev; + do + { + insert_before = insert_after; + insert_after = insert_after->cp_prev; + } while (insert_after && insert_after->cp_score > 0 && + insert_after->cp_score > match->cp_score); + } + else + return; + } + + if (insert_after) + { + // Remove the match from its current position + if (match->cp_prev) + match->cp_prev->cp_next = match->cp_next; + else + compl_first_match = match->cp_next; + if (match->cp_next) + match->cp_next->cp_prev = match->cp_prev; + + // Insert the match at the correct position + match->cp_next = insert_before; + match->cp_prev = insert_after; + insert_after->cp_next = match; + insert_before->cp_prev = match; + } +} + /* * Add a match to the list of matches. The arguments are: * str - text of the match to add @@ -849,7 +931,14 @@ ins_compl_add( && STRNCMP(match->cp_str.string, str, len) == 0 && ((int)match->cp_str.length <= len || match->cp_str.string[len] == NUL)) + { + if (is_nearest_active() && score > 0 && score < match->cp_score) + { + match->cp_score = score; + reposition_match(match); + } return NOTDONE; + } match = match->cp_next; } while (match != NULL && !is_first_match(match)); } @@ -961,6 +1050,9 @@ ins_compl_add( compl_first_match = match; compl_curr_match = match; + if (is_nearest_active() && score > 0) + reposition_match(match); + // Find the longest common string if still doing that. if (compl_get_longest && (flags & CP_ORIGINAL_TEXT) == 0 && !cfc_has_mode()) ins_compl_longest_match(match); @@ -4367,6 +4459,7 @@ get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_pos) int in_collect = (cfc_has_mode() && compl_length > 0); char_u *leader = ins_compl_leader(); int score = 0; + int in_curbuf = st->ins_buf == curbuf; // If 'infercase' is set, don't use 'smartcase' here save_p_scs = p_scs; @@ -4378,7 +4471,7 @@ get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_pos) // buffer is a good idea, on the other hand, we always set // wrapscan for curbuf to avoid missing matches -- Acevedo,Webb save_p_ws = p_ws; - if (st->ins_buf != curbuf) + if (!in_curbuf) p_ws = FALSE; else if (*st->e_cpt == '.') p_ws = TRUE; @@ -4443,7 +4536,7 @@ get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_pos) break; // when ADDING, the text before the cursor matches, skip it - if (compl_status_adding() && st->ins_buf == curbuf + if (compl_status_adding() && in_curbuf && start_pos->lnum == st->cur_match_pos->lnum && start_pos->col == st->cur_match_pos->col) continue; @@ -4454,8 +4547,16 @@ get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_pos) if (ptr == NULL || (ins_compl_has_preinsert() && STRCMP(ptr, compl_pattern.string) == 0)) continue; + if (is_nearest_active() && in_curbuf) + { + score = st->cur_match_pos->lnum - curwin->w_cursor.lnum; + if (score < 0) + score = -score; + score++; + } + if (ins_compl_add_infercase(ptr, len, p_ic, - st->ins_buf == curbuf ? NULL : st->ins_buf->b_sfname, + in_curbuf ? NULL : st->ins_buf->b_sfname, 0, cont_s_ipos, score) != NOTDONE) { if (in_collect && score == compl_first_match->cp_next->cp_score) diff --git a/src/option.h b/src/option.h index 54bdeedd75..db1030d124 100644 --- a/src/option.h +++ b/src/option.h @@ -535,6 +535,7 @@ EXTERN unsigned cot_flags; // flags from 'completeopt' #define COT_FUZZY 0x100 // TRUE: fuzzy match enabled #define COT_NOSORT 0x200 // TRUE: fuzzy match without qsort score #define COT_PREINSERT 0x400 // TRUE: preinsert +#define COT_NEAREST 0x800 // TRUE: prioritize matches close to cursor #define CFC_KEYWORD 0x001 #define CFC_FILES 0x002 diff --git a/src/optionstr.c b/src/optionstr.c index 47c340577f..62a708683e 100644 --- a/src/optionstr.c +++ b/src/optionstr.c @@ -122,7 +122,7 @@ static char *(p_fdm_values[]) = {"manual", "expr", "marker", "indent", "syntax", static char *(p_fcl_values[]) = {"all", NULL}; #endif static char *(p_cfc_values[]) = {"keyword", "files", "whole_line", NULL}; -static char *(p_cot_values[]) = {"menu", "menuone", "longest", "preview", "popup", "popuphidden", "noinsert", "noselect", "fuzzy", "nosort", "preinsert", NULL}; +static char *(p_cot_values[]) = {"menu", "menuone", "longest", "preview", "popup", "popuphidden", "noinsert", "noselect", "fuzzy", "nosort", "preinsert", "nearest", NULL}; #ifdef BACKSLASH_IN_FILENAME static char *(p_csl_values[]) = {"slash", "backslash", NULL}; #endif diff --git a/src/testdir/test_ins_complete.vim b/src/testdir/test_ins_complete.vim index 5c67dbf4f2..66beb78e65 100644 --- a/src/testdir/test_ins_complete.vim +++ b/src/testdir/test_ins_complete.vim @@ -4067,4 +4067,119 @@ func Test_complete_append_selected_match_default() delfunc PrintMenuWords endfunc +" Test 'nearest' flag of 'completeopt' +func Test_nearest_cpt_option() + + func PrintMenuWords() + let info = complete_info(["selected", "matches"]) + call map(info.matches, {_, v -> v.word}) + return info + endfunc + + new + set completeopt+=nearest + call setline(1, ["fo", "foo", "foobar"]) + exe "normal! Gof\\=PrintMenuWords()\" + call assert_equal('foobar{''matches'': [''foobar'', ''foo'', ''fo''], ''selected'': 0}', getline(4)) + %d + call setline(1, ["fo", "foo", "foobar"]) + exe "normal! Of\\=PrintMenuWords()\" + call assert_equal('foobar{''matches'': [''fo'', ''foo'', ''foobar''], ''selected'': 2}', getline(1)) + %d + + set completeopt=menu,noselect,nearest + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! Gof\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', ''fo''], ''selected'': -1}', getline(5)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! Gof\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', ''fo''], ''selected'': -1}', getline(5)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! Of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''fo'', ''foo'', ''foobar'', ''foobarbaz''], ''selected'': -1}', getline(1)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! Of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''fo'', ''foo'', ''foobar'', ''foobarbaz''], ''selected'': -1}', getline(1)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foo'', ''fo'', ''foobar'', ''foobarbaz''], ''selected'': -1}', getline(2)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foo'', ''fo'', ''foobar'', ''foobarbaz''], ''selected'': -1}', getline(2)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! jof\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foobar'', ''foo'', ''foobarbaz'', ''fo''], ''selected'': -1}', getline(3)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! jof\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foobar'', ''foo'', ''foobarbaz'', ''fo''], ''selected'': -1}', getline(3)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! 2jof\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', ''fo''], ''selected'': -1}', getline(4)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! 2jof\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', ''fo''], ''selected'': -1}', getline(4)) + + %d + set completeopt=menuone,noselect,nearest + call setline(1, "foo") + exe "normal! Of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foo''], ''selected'': -1}', getline(1)) + %d + call setline(1, "foo") + exe "normal! o\\=PrintMenuWords()\" + call assert_equal('{''matches'': [''foo''], ''selected'': -1}', getline(2)) + %d + exe "normal! o\\=PrintMenuWords()\" + call assert_equal('', getline(1)) + %d + exe "normal! o\\=PrintMenuWords()\" + call assert_equal('', getline(1)) + + " Reposition match: node is at tail but score is too small + %d + call setline(1, ["foo1", "bar1", "bar2", "foo2", "foo1"]) + exe "normal! of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foo1'', ''foo2''], ''selected'': -1}', getline(2)) + " Reposition match: node is in middle but score is too big + %d + call setline(1, ["foo1", "bar1", "bar2", "foo3", "foo1", "foo2"]) + exe "normal! of\\=PrintMenuWords()\" + call assert_equal('f{''matches'': [''foo1'', ''foo3'', ''foo2''], ''selected'': -1}', getline(2)) + + set completeopt=menu,longest,nearest + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! of\\=PrintMenuWords()\" + call assert_equal('fo{''matches'': [''foo'', ''fo'', ''foobar'', ''foobarbaz''], ''selected'': -1}', getline(2)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! 2jof\\=PrintMenuWords()\" + call assert_equal('fo{''matches'': [''foobarbaz'', ''foobar'', ''foo'', ''fo''], ''selected'': -1}', getline(4)) + + " No effect if 'fuzzy' is present + set completeopt& + set completeopt+=fuzzy,nearest + %d + call setline(1, ["foo", "fo", "foobarbaz", "foobar"]) + exe "normal! of\\=PrintMenuWords()\" + call assert_equal('fo{''matches'': [''fo'', ''foobarbaz'', ''foobar'', ''foo''], ''selected'': 0}', getline(2)) + %d + call setline(1, ["fo", "foo", "foobar", "foobarbaz"]) + exe "normal! 2jof\\=PrintMenuWords()\" + call assert_equal('foobar{''matches'': [''foobarbaz'', ''fo'', ''foo'', ''foobar''], ''selected'': 3}', getline(4)) + bw! + + set completeopt& + delfunc PrintMenuWords +endfunc + " vim: shiftwidth=2 sts=2 expandtab nofoldenable diff --git a/src/version.c b/src/version.c index bdfcde210e..ec3e1a36d4 100644 --- a/src/version.c +++ b/src/version.c @@ -704,6 +704,8 @@ static char *(features[]) = static int included_patches[] = { /* Add new patch number below this line */ +/**/ + 1308, /**/ 1307, /**/