diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 10127e58897a58..25a58e792ef272 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -169,7 +169,7 @@ class unix_word_rubout(KillCommand): def do(self) -> None: r = self.reader for i in range(r.get_arg()): - self.kill_range(r.bow(), r.pos) + self.kill_range(r.bow_ws(), r.pos) class kill_word(KillCommand): diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 9ab92f64d1ef63..4834fbaec1055b 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -417,6 +417,24 @@ def bow(self, p: int | None = None) -> int: p -= 1 return p + 1 + def bow_ws(self, p: int | None = None) -> int: + """Return the 0-based index of the whitespace-delimited word break + preceding p most immediately. + + p defaults to self.pos; only whitespace is considered a word + boundary, matching the behavior of unix-word-rubout in bash/readline. + See https://github.com/python/cpython/issues/146044 + """ + if p is None: + p = self.pos + b = self.buffer + p -= 1 + while p >= 0 and b[p] in " \n\t": + p -= 1 + while p >= 0 and b[p] not in " \n\t": + p -= 1 + return p + 1 + def eow(self, p: int | None = None) -> int: """Return the 0-based index of the word break following p most immediately. diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index b1b6ae16a1e592..de7558eb6e39c1 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -358,6 +358,34 @@ def test_setpos_from_xy_for_non_printing_char(self): reader.setpos_from_xy(8, 0) self.assertEqual(reader.pos, 7) + def test_bow_ws_stops_at_whitespace(self): + # See https://github.com/python/cpython/issues/146044 + reader = prepare_reader(prepare_console([])) + reader.buffer = list("foo.bar baz") + reader.pos = len(reader.buffer) + self.assertEqual(reader.bow_ws(), 8) + + def test_bow_ws_includes_punctuation_in_word(self): + reader = prepare_reader(prepare_console([])) + reader.buffer = list("foo.bar(baz) qux") + reader.pos = 12 + self.assertEqual(reader.bow_ws(), 0) + + def test_bow_vs_bow_ws(self): + reader = prepare_reader(prepare_console([])) + reader.buffer = list("foo.bar") + reader.pos = len(reader.buffer) + # bow() stops at '.' so we return the index of 'b' in "bar" + self.assertEqual(reader.bow(), 4) + # bow_ws() treats entire "foo.bar" as one word + self.assertEqual(reader.bow_ws(), 0) + + def test_bow_ws_with_tabs(self): + reader = prepare_reader(prepare_console([])) + reader.buffer = list("foo\tbar") + reader.pos = len(reader.buffer) + self.assertEqual(reader.bow_ws(), 4) + @force_colorized_test_class class TestReaderInColor(ScreenEqualMixin, TestCase): def test_syntax_highlighting_basic(self): diff --git a/Misc/NEWS.d/next/Library/2026-03-20-00-00-01.gh-issue-146044.WsCtrl.rst b/Misc/NEWS.d/next/Library/2026-03-20-00-00-01.gh-issue-146044.WsCtrl.rst new file mode 100644 index 00000000000000..d71ad1828fd5d8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-20-00-00-01.gh-issue-146044.WsCtrl.rst @@ -0,0 +1,3 @@ +Fix ``unix-word-rubout`` (Ctrl-W) in the REPL to use whitespace-only word +boundaries, matching behavior of the basic REPL. Previously it used +syntax-table boundaries which treated punctuation as word separators.