From 1de276bbcd8f1b3c5ef1d0bd6d4e112cae119337 Mon Sep 17 00:00:00 2001 From: zeertzjq Date: Wed, 16 Apr 2025 10:48:08 +0800 Subject: [PATCH] vim-patch:9.1.1308: completion: cannot order matches by distance to cursor (#33491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: During insert-mode completion, the most relevant match is often the one closest to the cursor—frequently just above the current line. However, both `` and `` tend to rank candidates from the current buffer that appear above the cursor near the bottom of the completion menu, rather than near the top. This ordering can feel unintuitive, especially when `noselect` is active, as it doesn't prioritize the most contextually relevant suggestions. Solution: This change introduces a new sub-option value "nearest" for the 'completeopt' setting. When enabled, matches from the current buffer are prioritized based on their proximity to the cursor position, improving the relevance of suggestions during completion (Girish Palya). Key Details: - Option: "nearest" added to 'completeopt' - Applies to: Matches from the current buffer only - Effect: Sorts completion candidates by their distance from the cursor - Interaction with other options: - Has no effect if the `fuzzy` option is also present This feature is helpful especially when working within large buffers where multiple similar matches may exist at different locations. You can test this feature with auto-completion using the snippet below. Try it in a large file like `vim/src/insexpand.c`, where you'll encounter many potential matches. You'll notice that the popup menu now typically surfaces the most relevant matches—those closest to the cursor—at the top. Sorting by spatial proximity (i.e., contextual relevance) often produces more useful matches than sorting purely by lexical distance ("fuzzy"). Another way to sort matches is by recency, using an LRU (Least Recently Used) cache—essentially ranking candidates based on how recently they were used. However, this is often overkill in practice, as spatial proximity (as provided by the "nearest" option) is usually sufficient to surface the most relevant matches. ```vim set cot=menuone,popup,noselect,nearest inf def SkipTextChangedIEvent(): string # Suppress next event caused by (or when no matches found) set eventignore+=TextChangedI timer_start(1, (_) => { set eventignore-=TextChangedI }) return '' enddef autocmd TextChangedI * InsComplete() def InsComplete() if getcharstr(1) == '' && getline('.')->strpart(0, col('.') - 1) =~ '\k$' SkipTextChangedIEvent() feedkeys("\", "n") endif enddef inoremap =SkipTextChangedIEvent() inoremap pumvisible() ? "\" : "\" inoremap pumvisible() ? "\" : "\" ``` closes: vim/vim#17076 https://github.com/vim/vim/commit/b156588eb707a084bbff8685953a8892e1e45bca Co-authored-by: Girish Palya --- runtime/doc/news.txt | 1 + runtime/doc/options.txt | 4 + runtime/lua/vim/_meta/options.lua | 4 + src/nvim/insexpand.c | 93 +++++++++++++++++++- src/nvim/options.lua | 5 ++ test/old/testdir/test_ins_complete.vim | 115 +++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 4 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 3fd83d9e6f..48162b9fa2 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -79,6 +79,7 @@ LUA OPTIONS • 'chistory' and 'lhistory' set size of the |quickfix-stack|. +• 'completeopt' flag "nearset" sorts completion results by distance to cursor. • 'diffopt' `inline:` configures diff highlighting for changes within a line. • 'pummaxwidth' sets maximum width for the completion popup menu. • 'shelltemp' defaults to "false". diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 3201ac14f6..c298924d3e 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -1590,6 +1590,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/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua index 31bcc6485b..a3a5750e78 100644 --- a/runtime/lua/vim/_meta/options.lua +++ b/runtime/lua/vim/_meta/options.lua @@ -1123,6 +1123,10 @@ vim.go.cia = vim.go.completeitemalign --- 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/src/nvim/insexpand.c b/src/nvim/insexpand.c index e7f1779995..b2c45128e2 100644 --- a/src/nvim/insexpand.c +++ b/src/nvim/insexpand.c @@ -164,7 +164,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 bool 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 @@ -816,6 +816,74 @@ static inline void free_cptext(char *const *const cptext) } } +/// Returns true if matches should be sorted based on proximity to the cursor. +static bool is_nearest_active(void) +{ + unsigned flags = get_cot_flags(); + return (flags & kOptCotFlagNearest) && !(flags & kOptCotFlagFuzzy); +} + +/// 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; + + if (!match->cp_prev) { // Node is at head and score is too big + 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; + } + } else { // Node is at tail or in the middle but score is too small + 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 /// /// @param[in] str text of the match to add @@ -872,6 +940,10 @@ static int ins_compl_add(char *const str, int len, char *const fname, char *cons if (!match_at_original_text(match) && strncmp(match->cp_str.data, str, (size_t)len) == 0 && ((int)match->cp_str.size <= len || match->cp_str.data[len] == NUL)) { + if (is_nearest_active() && score > 0 && score < match->cp_score) { + match->cp_score = score; + reposition_match(match); + } if (cptext_allocated) { free_cptext(cptext); } @@ -977,6 +1049,10 @@ static int ins_compl_add(char *const str, int len, char *const fname, char *cons } 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); @@ -3746,6 +3822,7 @@ static int get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_ bool in_collect = (cfc_has_mode() && compl_length > 0); char *leader = ins_compl_leader(); int score = 0; + const bool in_curbuf = st->ins_buf == curbuf; // If 'infercase' is set, don't use 'smartcase' here const int save_p_scs = p_scs; @@ -3759,7 +3836,7 @@ static int get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_ // buffers is a good idea, on the other hand, we always set // wrapscan for curbuf to avoid missing matches -- Acevedo,Webb const int save_p_ws = p_ws; - if (st->ins_buf != curbuf) { + if (!in_curbuf) { p_ws = false; } else if (*st->e_cpt == '.') { p_ws = true; @@ -3822,7 +3899,7 @@ static int get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_ } // 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; @@ -3837,8 +3914,16 @@ static int get_next_default_completion(ins_compl_next_state_T *st, pos_T *start_ 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) { compl_num_bests++; diff --git a/src/nvim/options.lua b/src/nvim/options.lua index 9670e93bd1..78616abaa1 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -1548,6 +1548,7 @@ local options = { 'fuzzy', 'nosort', 'preinsert', + 'nearest', }, flags = true, deny_duplicates = true, @@ -1579,6 +1580,10 @@ local options = { 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/test/old/testdir/test_ins_complete.vim b/test/old/testdir/test_ins_complete.vim index 5dd29bcc0a..c5e2bc8a74 100644 --- a/test/old/testdir/test_ins_complete.vim +++ b/test/old/testdir/test_ins_complete.vim @@ -3457,4 +3457,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