From a4b8e69f3aafeebccf1b0f1ac0e6ad7745a5d404 Mon Sep 17 00:00:00 2001 From: kokotatan Date: Sat, 3 Oct 2026 23:13:31 +0900 Subject: [PATCH] fix(completion): preserve original prefix length before lowercasing `WordCompleter(ignore_case=True)` calculates the replacement offset after lowercasing the typed prefix. Characters such as capital dotted I expand to multiple code points, so `Buffer.apply_completion` deletes preceding text along with the word being completed. Compute `Completion.start_position` from the original prefix and keep the existing lowercase matching behavior. Add 32 real buffer regressions and controls across case sensitivity, middle matching and both word modes. Eight fail against the old implementation and all pass after the fix. All hooks, Ruff, typos, three-platform strict mypy and distribution builds pass. The full Windows suite has 179 passes and four unchanged baseline failures; deselecting those gives 179 passes and five skips. The changed module has 100% statement and branch coverage. Sphinx renders with three existing Windows autodoc warnings, so strict documentation checks fail. Assisted-by: OpenAI GPT-6 --- CHANGELOG | 7 +++++ .../completion/word_completer.py | 4 ++- tests/test_completion.py | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index e8afc07cb..24985a5cd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,13 @@ CHANGELOG ========= +Unreleased +---------- + +Fixes: +- Preserve preceding text when case-insensitive word completion lowercases + characters into multiple code points. + 3.0.53: 2026-07-26 ------------------ diff --git a/src/prompt_toolkit/completion/word_completer.py b/src/prompt_toolkit/completion/word_completer.py index 563fdadcb..e44f218c8 100644 --- a/src/prompt_toolkit/completion/word_completer.py +++ b/src/prompt_toolkit/completion/word_completer.py @@ -70,6 +70,8 @@ def get_completions( WORD=self.WORD, pattern=self.pattern ) + # Lowercasing can change the number of characters in the prefix. + start_position = -len(word_before_cursor) if self.ignore_case: word_before_cursor = word_before_cursor.lower() @@ -89,7 +91,7 @@ def word_matches(word: str) -> bool: display_meta = self.meta_dict.get(a, "") yield Completion( text=a, - start_position=-len(word_before_cursor), + start_position=start_position, display=display, display_meta=display_meta, ) diff --git a/tests/test_completion.py b/tests/test_completion.py index 8b3541af0..e8cf8c9dd 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -6,6 +6,9 @@ import tempfile from contextlib import contextmanager +import pytest + +from prompt_toolkit.buffer import Buffer from prompt_toolkit.completion import ( CompleteEvent, FuzzyWordCompleter, @@ -295,6 +298,31 @@ def test_word_completer_match_middle(): assert [c.text for c in completions] == ["abc", "abca"] +@pytest.mark.parametrize( + "word, candidate", + [("İ", "İstanbul"), ("İİ", "İİstanbul"), ("I", "Istanbul"), ("i", "istanbul")], +) +@pytest.mark.parametrize("ignore_case", [False, True]) +@pytest.mark.parametrize("match_middle", [False, True]) +@pytest.mark.parametrize("WORD", [False, True]) +def test_word_completer_preserves_text_before_case_expanding_word( + word, candidate, ignore_case, match_middle, WORD +): + if match_middle: + candidate = "city" + candidate + completer = WordCompleter( + [candidate], ignore_case=ignore_case, match_middle=match_middle, WORD=WORD + ) + buffer = Buffer(document=Document("before " + word)) + (completion,) = completer.get_completions(buffer.document, CompleteEvent()) + + buffer.apply_completion(completion) + + assert buffer.text == "before " + candidate + assert buffer.cursor_position == len(buffer.text) + assert completion.start_position == -len(word) + + def test_word_completer_sentence(): # With sentence=True completer = WordCompleter(