patch 9.1.1308: completion: cannot order matches by distance to cursor

Commit: 
https://github.com/vim/vim/commit/b156588eb707a084bbff8685953a8892e1e45bca
Author: Girish Palya <giris...@gmail.com>
Date:   Tue Apr 15 20:16:00 2025 +0200

    patch 9.1.1308: completion: cannot order matches by distance to cursor
    
    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 `<C-N>` and `<C-P>` 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 <c-e> (or <c-n> 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("\<c-n>", "n")
        endif
    enddef
    
    inoremap <silent> <c-e> <c-r>=<SID>SkipTextChangedIEvent()<cr><c-e>
    
    inoremap <silent><expr> <tab>   pumvisible() ? "\<c-n>" : "\<tab>"
    inoremap <silent><expr> <s-tab> pumvisible() ? "\<c-p>" : "\<s-tab>"
    ```
    
    closes: #17076
    
    Signed-off-by: Girish Palya <giris...@gmail.com>
    Signed-off-by: Christian Brabandt <c...@256bit.org>

diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
index 99e6d54f8..b3b29d0d8 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 50fb66ff9..27470f002 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 8c15adeee..55c0e483e 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)
+       {
+           // <c-p>: 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)
+       {
+           // <c-n>: 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 54bdeedd7..db1030d12 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 47c340577..62a708683 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 5c67dbf4f..66beb78e6 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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('foobar{''matches'': [''foobar'', ''foo'', ''fo''], 
''selected'': 0}', getline(4))
+  %d
+  call setline(1, ["fo", "foo", "foobar"])
+  exe "normal! Of\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', 
''fo''], ''selected'': -1}', getline(5))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! Gof\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', 
''fo''], ''selected'': -1}', getline(5))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! Of\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''fo'', ''foo'', ''foobar'', 
''foobarbaz''], ''selected'': -1}', getline(1))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! Of\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''fo'', ''foo'', ''foobar'', 
''foobarbaz''], ''selected'': -1}', getline(1))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! of\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foo'', ''fo'', ''foobar'', 
''foobarbaz''], ''selected'': -1}', getline(2))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! of\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foo'', ''fo'', ''foobar'', 
''foobarbaz''], ''selected'': -1}', getline(2))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! jof\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foobar'', ''foo'', ''foobarbaz'', 
''fo''], ''selected'': -1}', getline(3))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! jof\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foobar'', ''foo'', ''foobarbaz'', 
''fo''], ''selected'': -1}', getline(3))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! 2jof\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foobarbaz'', ''foobar'', ''foo'', 
''fo''], ''selected'': -1}', getline(4))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! 2jof\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('f{''matches'': [''foo''], ''selected'': -1}', getline(1))
+  %d
+  call setline(1, "foo")
+  exe "normal! o\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('{''matches'': [''foo''], ''selected'': -1}', getline(2))
+  %d
+  exe "normal! o\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('', getline(1))
+  %d
+  exe "normal! o\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('fo{''matches'': [''foo'', ''fo'', ''foobar'', 
''foobarbaz''], ''selected'': -1}', getline(2))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! 2jof\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  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\<c-n>\<c-r>=PrintMenuWords()\<cr>"
+  call assert_equal('fo{''matches'': [''fo'', ''foobarbaz'', ''foobar'', 
''foo''], ''selected'': 0}', getline(2))
+  %d
+  call setline(1, ["fo", "foo", "foobar", "foobarbaz"])
+  exe "normal! 2jof\<c-p>\<c-r>=PrintMenuWords()\<cr>"
+  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 bdfcde210..ec3e1a36d 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,
 /**/

-- 
-- 
You received this message from the "vim_dev" maillist.
Do not top-post! Type your reply below the text you are replying to.
For more information, visit http://www.vim.org/maillist.php

--- 
You received this message because you are subscribed to the Google Groups 
"vim_dev" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to vim_dev+unsubscr...@googlegroups.com.
To view this discussion visit 
https://groups.google.com/d/msgid/vim_dev/E1u4l2q-004xuR-8r%40256bit.org.

Raspunde prin e-mail lui