From f3aeb9b19b4384be10ebd6fcd498c2442aa40a6f Mon Sep 17 00:00:00 2001 From: tompng Date: Sat, 25 Jul 2026 16:10:42 +0900 Subject: [PATCH 1/2] Do not filter completion candidates by completion target Aligns tab completion with GNU Readline and readline-ext: candidates returned by completion_proc are used as-is, and filtering them is completion_proc's responsibility. completion_case_fold now only affects the common prefix computation. When the candidates have no common prefix, TAB leaves the line unchanged but still displays the candidate list on the next TAB, matching GNU Readline. Co-Authored-By: Claude Fable 5 --- lib/reline/line_editor.rb | 37 ++++++++++++------------ test/reline/test_key_actor_emacs.rb | 45 ++++++++++++++++++++++++++--- test/reline/test_key_actor_vi.rb | 10 +++++++ 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index c8b251f78e..577536480e 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -813,23 +813,20 @@ def editing_mode @menu_info = MenuInfo.new(list) end - private def filter_normalize_candidates(target, list) - target = target.downcase if @config.completion_ignore_case - list.select do |item| - next unless item - unless Encoding.compatible?(target.encoding, item.encoding) - # Workaround for Readline test - if defined?(::Readline) && ::Readline == ::Reline + # Candidates are not filtered by target. Readline (and readline-ext) treats + # filtering as completion_proc's responsibility and uses the returned + # candidates as-is. + private def normalize_candidates(target, list) + candidates = list.compact + if defined?(::Readline) && ::Readline == ::Reline + # Workaround for Readline test + candidates.each do |item| + unless Encoding.compatible?(target.encoding, item.encoding) raise Encoding::CompatibilityError, "incompatible character encodings: #{target.encoding} and #{item.encoding}" end end - - if @config.completion_ignore_case - item.downcase.start_with?(target) - else - item.start_with?(target) - end - end.map do |item| + end + candidates.map do |item| item.unicode_normalize rescue Encoding::CompatibilityError item @@ -837,7 +834,8 @@ def editing_mode end private def perform_completion(preposing, target, postposing, quote, list) - candidates = filter_normalize_candidates(target, list) + candidates = normalize_candidates(target, list) + return if candidates.empty? case @completion_state when CompletionState::PERFECT_MATCH @@ -855,7 +853,6 @@ def editing_mode end completed = Reline::Unicode.common_prefix(candidates, ignore_case: @config.completion_ignore_case) - return if completed.empty? append_character = '' if candidates.include?(completed) @@ -873,6 +870,10 @@ def editing_mode @completion_state = CompletionState::MENU menu(candidates) if @config.show_all_if_ambiguous end + # Aligned with GNU readline: TAB completion with no common prefix leaves + # the line as-is but still displays candidates on the next TAB. + return if completed.empty? + @buffer_of_lines[@line_index] = (preposing + completed + append_character + postposing).split("\n")[@line_index] || String.new(encoding: encoding) line_to_pointer = (preposing + completed + append_character).split("\n")[@line_index] || String.new(encoding: encoding) @byte_pointer = line_to_pointer.bytesize @@ -908,7 +909,7 @@ def dialog_proc_scope_completion_journey_data list = call_completion_proc(preposing, target, postposing, quote) return unless list.is_a?(Array) - candidates = list.select{ |item| item.start_with?(target) } + candidates = list.compact return if candidates.empty? pre = preposing.split("\n", -1).last || '' @@ -1784,7 +1785,7 @@ def finish pre, target, post, quote = retrieve_completion_block result = call_completion_proc(pre, target, post, quote) if result.is_a?(Array) - candidates = filter_normalize_candidates(target, result) + candidates = normalize_candidates(target, result) menu(candidates) end end diff --git a/test/reline/test_key_actor_emacs.rb b/test/reline/test_key_actor_emacs.rb index 7c1dfd0189..9288e535dc 100644 --- a/test/reline/test_key_actor_emacs.rb +++ b/test/reline/test_key_actor_emacs.rb @@ -746,6 +746,8 @@ def test_em_delete_or_list foo_bar foo_baz qux + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -792,6 +794,8 @@ def test_completion foo_bar foo_baz qux + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -863,6 +867,8 @@ def test_completion_with_indent foo_bar foo_baz qux + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -883,6 +889,8 @@ def test_completion_with_perfect_match %w{ foo foo_bar + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -989,6 +997,8 @@ def test_completion_with_completion_ignore_case foo_bar Foo_baz qux + }.select { |s| + s.downcase.start_with?(word.downcase) }.map { |i| i.encode(@encoding) } @@ -997,15 +1007,17 @@ def test_completion_with_completion_ignore_case assert_line_around_cursor('fo', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) input_keys("\C-i") - assert_line_around_cursor('foo_', '') + # completion_ignore_case is false: the candidates have no common prefix + assert_line_around_cursor('fo', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) input_keys("\C-i") - assert_line_around_cursor('foo_', '') - assert_equal(%w{foo_foo foo_bar}, @line_editor.instance_variable_get(:@menu_info).list) + assert_line_around_cursor('fo', '') + assert_equal(%w{foo_foo foo_bar Foo_baz}, @line_editor.instance_variable_get(:@menu_info).list) @config.completion_ignore_case = true + input_keys('o') input_keys("\C-i") + # completion_ignore_case affects the common prefix computation assert_line_around_cursor('foo_', '') - assert_equal(%w{foo_foo foo_bar Foo_baz}, @line_editor.instance_variable_get(:@menu_info).list) input_keys('a') input_keys("\C-i") assert_line_around_cursor('foo_a', '') @@ -1018,6 +1030,27 @@ def test_completion_with_completion_ignore_case assert_line_around_cursor('Foo_baz', '') end + # Readline never filters candidates by the input word. Filtering is + # completion_proc's responsibility. + def test_completion_does_not_filter_candidates + @line_editor.completion_proc = proc { |word| %w{ABCX abcy} } + input_keys('z') + input_keys("\C-i") + assert_line_around_cursor('z', '') + assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) + input_keys("\C-i") + assert_line_around_cursor('z', '') + assert_equal(%w{ABCX abcy}, @line_editor.instance_variable_get(:@menu_info).list) + end + + def test_completion_replaces_word_with_ignore_case_common_prefix + @config.completion_ignore_case = true + @line_editor.completion_proc = proc { |word| %w{ABCX abcy} } + input_keys('z') + input_keys("\C-i") + assert_line_around_cursor('ABC', '') + end + def test_completion_in_middle_of_line @line_editor.completion_proc = proc { |word| %w{ @@ -1025,6 +1058,8 @@ def test_completion_in_middle_of_line foo_bar foo_baz qux + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -1044,6 +1079,8 @@ def test_completion_with_nil_value foo_bar Foo_baz qux + }.select { |s| + s.downcase.start_with?(word.downcase) }.map { |i| i.encode(@encoding) }.prepend(nil) diff --git a/test/reline/test_key_actor_vi.rb b/test/reline/test_key_actor_vi.rb index 083433f9a8..ca4f1acd33 100644 --- a/test/reline/test_key_actor_vi.rb +++ b/test/reline/test_key_actor_vi.rb @@ -566,6 +566,8 @@ def test_completion_journey %w{ foo_bar foo_bar_baz + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -591,6 +593,8 @@ def test_completion_journey_reverse %w{ foo_bar foo_bar_baz + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -616,6 +620,8 @@ def test_completion_journey_in_middle_of_line %w{ foo_bar foo_bar_baz + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -643,6 +649,8 @@ def test_completion %w{ foo_bar foo_bar_baz + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } @@ -705,6 +713,8 @@ def test_completion_with_disable_completion %w{ foo_bar foo_bar_baz + }.select { |s| + s.start_with?(word) }.map { |i| i.encode(@encoding) } From 859428870e97c6af9ed21bfc4a41e8ddbedd85b1 Mon Sep 17 00:00:00 2001 From: tompng Date: Sat, 25 Jul 2026 16:12:45 +0900 Subject: [PATCH 2/2] Add set_completion_candidates test helper Replaces the repeated completion_proc boilerplate (prefix filtering and test-encoding conversion) in key actor tests. Tests exercising special procs (nil-containing, intentionally unfiltered, word-dependent) keep assigning completion_proc directly. Co-Authored-By: Claude Fable 5 --- test/reline/helper.rb | 11 +++ test/reline/test_key_actor_emacs.rb | 104 +++------------------------- test/reline/test_key_actor_vi.rb | 75 ++------------------ 3 files changed, 28 insertions(+), 162 deletions(-) diff --git a/test/reline/helper.rb b/test/reline/helper.rb index 6f470a617f..ad69760f7a 100644 --- a/test/reline/helper.rb +++ b/test/reline/helper.rb @@ -112,6 +112,17 @@ def input_keys(input) end end + # Readline-compatible completion_proc: prefix-filtering candidates is + # completion_proc's responsibility, not Reline's. + def set_completion_candidates(candidates, ignore_case: false) + @line_editor.completion_proc = proc { |word| + word = word.downcase if ignore_case + candidates.select { |s| + (ignore_case ? s.downcase : s).start_with?(word) + }.map { |s| convert_str(s) } + } + end + def set_line_around_cursor(before, after) input_keys("\C-a\C-k") input_keys(after) diff --git a/test/reline/test_key_actor_emacs.rb b/test/reline/test_key_actor_emacs.rb index 9288e535dc..52dd67430c 100644 --- a/test/reline/test_key_actor_emacs.rb +++ b/test/reline/test_key_actor_emacs.rb @@ -740,18 +740,7 @@ def test_em_upper_case_with_complex_example end def test_em_delete_or_list - @line_editor.completion_proc = proc { |word| - %w{ - foo_foo - foo_bar - foo_baz - qux - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_foo foo_bar foo_baz qux]) input_keys('fooo') assert_line_around_cursor('fooo', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) @@ -767,15 +756,7 @@ def test_em_delete_or_list end def test_completion_duplicated_list - @line_editor.completion_proc = proc { |word| - %w{ - foo_foo - foo_foo - foo_bar - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_foo foo_foo foo_bar]) input_keys('foo_') assert_line_around_cursor('foo_', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) @@ -788,18 +769,7 @@ def test_completion_duplicated_list end def test_completion - @line_editor.completion_proc = proc { |word| - %w{ - foo_foo - foo_bar - foo_baz - qux - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_foo foo_bar foo_baz qux]) input_keys('fo') assert_line_around_cursor('fo', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) @@ -833,15 +803,7 @@ def test_completion def test_autocompletion @config.autocompletion = true - @line_editor.completion_proc = proc { |word| - %w{ - Readline - Regexp - RegexpError - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[Readline Regexp RegexpError]) input_keys('Re') assert_line_around_cursor('Re', '') input_keys("\C-i") @@ -861,18 +823,7 @@ def test_autocompletion end def test_completion_with_indent - @line_editor.completion_proc = proc { |word| - %w{ - foo_foo - foo_bar - foo_baz - qux - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_foo foo_bar foo_baz qux]) input_keys(' fo') assert_line_around_cursor(' fo', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) @@ -885,16 +836,7 @@ def test_completion_with_indent end def test_completion_with_perfect_match - @line_editor.completion_proc = proc { |word| - %w{ - foo - foo_bar - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo foo_bar]) matched = nil @line_editor.dig_perfect_match_proc = proc { |m| matched = m @@ -951,9 +893,7 @@ def test_continuous_completion_disabled_with_perfect_match end def test_completion_append_character - @line_editor.completion_proc = proc { |word| - %w[foo_ foo_foo foo_bar].select { |s| s.start_with? word } - } + set_completion_candidates(%w[foo_ foo_foo foo_bar]) @line_editor.completion_append_character = 'X' input_keys('f') input_keys("\C-i") @@ -967,9 +907,7 @@ def test_completion_append_character end def test_completion_with_quote_append - @line_editor.completion_proc = proc { |word| - %w[foo bar baz].select { |s| s.start_with? word } - } + set_completion_candidates(%w[foo bar baz]) set_line_around_cursor('x = "b', '') input_keys("\C-i") assert_line_around_cursor('x = "ba', '') @@ -991,18 +929,7 @@ def test_completion_with_quote_append end def test_completion_with_completion_ignore_case - @line_editor.completion_proc = proc { |word| - %w{ - foo_foo - foo_bar - Foo_baz - qux - }.select { |s| - s.downcase.start_with?(word.downcase) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_foo foo_bar Foo_baz qux], ignore_case: true) input_keys('fo') assert_line_around_cursor('fo', '') assert_equal(nil, @line_editor.instance_variable_get(:@menu_info)) @@ -1052,18 +979,7 @@ def test_completion_replaces_word_with_ignore_case_common_prefix end def test_completion_in_middle_of_line - @line_editor.completion_proc = proc { |word| - %w{ - foo_foo - foo_bar - foo_baz - qux - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_foo foo_bar foo_baz qux]) input_keys('abcde fo ABCDE') assert_line_around_cursor('abcde fo ABCDE', '') input_keys("\C-b" * 6 + "\C-i") diff --git a/test/reline/test_key_actor_vi.rb b/test/reline/test_key_actor_vi.rb index ca4f1acd33..253545adad 100644 --- a/test/reline/test_key_actor_vi.rb +++ b/test/reline/test_key_actor_vi.rb @@ -562,16 +562,7 @@ def test_vi_list_or_eof_with_non_empty_line end def test_completion_journey - @line_editor.completion_proc = proc { |word| - %w{ - foo_bar - foo_bar_baz - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_bar foo_bar_baz]) input_keys('foo') assert_line_around_cursor('foo', '') input_keys("\C-n") @@ -589,16 +580,7 @@ def test_completion_journey end def test_completion_journey_reverse - @line_editor.completion_proc = proc { |word| - %w{ - foo_bar - foo_bar_baz - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_bar foo_bar_baz]) input_keys('foo') assert_line_around_cursor('foo', '') input_keys("\C-p") @@ -616,16 +598,7 @@ def test_completion_journey_reverse end def test_completion_journey_in_middle_of_line - @line_editor.completion_proc = proc { |word| - %w{ - foo_bar - foo_bar_baz - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_bar foo_bar_baz]) input_keys('abcde fo ABCDE') assert_line_around_cursor('abcde fo ABCDE', '') input_keys("\C-[" + 'h' * 5 + "i\C-n") @@ -645,16 +618,7 @@ def test_completion_journey_in_middle_of_line end def test_completion - @line_editor.completion_proc = proc { |word| - %w{ - foo_bar - foo_bar_baz - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_bar foo_bar_baz]) input_keys('foo') assert_line_around_cursor('foo', '') input_keys("\C-i") @@ -663,15 +627,7 @@ def test_completion def test_autocompletion_with_upward_navigation @config.autocompletion = true - @line_editor.completion_proc = proc { |word| - %w{ - Readline - Regexp - RegexpError - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[Readline Regexp RegexpError]) input_keys('Re') assert_line_around_cursor('Re', '') input_keys("\C-i") @@ -686,15 +642,7 @@ def test_autocompletion_with_upward_navigation def test_autocompletion_with_upward_navigation_and_menu_complete_backward @config.autocompletion = true - @line_editor.completion_proc = proc { |word| - %w{ - Readline - Regexp - RegexpError - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[Readline Regexp RegexpError]) input_keys('Re') assert_line_around_cursor('Re', '') input_keys("\C-i") @@ -709,16 +657,7 @@ def test_autocompletion_with_upward_navigation_and_menu_complete_backward def test_completion_with_disable_completion @config.disable_completion = true - @line_editor.completion_proc = proc { |word| - %w{ - foo_bar - foo_bar_baz - }.select { |s| - s.start_with?(word) - }.map { |i| - i.encode(@encoding) - } - } + set_completion_candidates(%w[foo_bar foo_bar_baz]) input_keys('foo') assert_line_around_cursor('foo', '') input_keys("\C-i")