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