diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 2354fbb2ec2c1e..c9482fcc9dfef4 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -66,7 +66,7 @@ def kill_range(self, start: int, end: int) -> None: b = r.buffer text = b[start:end] del b[start:end] - if is_kill(r.last_command): + if is_kill(r.last_command) and r.kill_ring: if start < r.pos: r.kill_ring[-1] = text + r.kill_ring[-1] else: diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 57526f88f9384b..c6262d0ef93376 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -4,6 +4,7 @@ from textwrap import dedent from unittest import TestCase from unittest.mock import MagicMock +from _pyrepl.readline import multiline_input from test.support import force_colorized_test_class, force_not_colorized_test_class from .support import handle_all_events, handle_events_narrow_console @@ -358,6 +359,63 @@ def test_setpos_from_xy_for_non_printing_char(self): reader.setpos_from_xy(8, 0) self.assertEqual(reader.pos, 7) + def test_empty_line_control_w_k(self): + """Test that Control-W followed by Control-K on an empty line doesn't crash.""" + events = [ + Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W + Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K + ] + reader, _ = handle_all_events(events) + self.assert_screen_equal(reader, "", clean=True) + self.assertEqual(reader.pos, 0) + + def test_control_w_delete_word(self): + """Test Control-W delete word""" + cases = ( + ("", 0, 0, []), + ("a", 1, 0, [""]), + ("abc", 3, 0, [""]), + ("abc def", 4, 0, ["def"]), + ("abc def", 7, 4, ["abc "]), + ("def xxx():xxx\n ", 18, 10, ["def xxx():"]), + ) + + for text, before_pos, after_pos, expected in cases: + with self.subTest(text=text, before_pos=before_pos): + events = itertools.chain( + code_to_events(text), + [Event(evt="key", data="left", raw=bytearray(b"\x1bOD"))] * (len(text) - before_pos), # Move cursor to specified position + [ + Event(evt="key", data="\x17", raw=bytearray(b"\x17")), # Control-W + ], + ) + reader, _ = handle_all_events(events) + self.assertEqual(reader.screen, expected) + self.assertEqual(reader.pos, after_pos) + + def test_control_k_delete_to_eol(self): + """Test Control-K delete from cursor to end of line""" + cases = ( + ("", 0, [""]), + ("a", 0, [""]), + ("abc", 1, ["a"]), + ("abc def", 4, ["abc "]), + ("def xxx():xxx\n pass", 10, ["def xxx():", " pass"]), + ) + + for text, pos, expected in cases: + with self.subTest(text=text, pos=pos): + events = itertools.chain( + code_to_events(text), + [Event(evt="key", data="left", raw=bytearray(b"\x1bOD"))] * (len(text) - pos), # Move cursor to specified position + [ + Event(evt="key", data="\x0b", raw=bytearray(b"\x0b")), # Control-K + ], + ) + reader, _ = handle_all_events(events) + self.assertEqual(reader.screen, expected) + self.assertEqual(reader.pos, pos) + @force_colorized_test_class class TestReaderInColor(ScreenEqualMixin, TestCase): def test_syntax_highlighting_basic(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst new file mode 100644 index 00000000000000..0d9621e7aeed6e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-06-44-10.gh-issue-131430.wbtIcQ.rst @@ -0,0 +1,2 @@ +Fix PyREPL crash on an empty DELETE_WORD_BACKWARDS (^W) followed by +CLEAR_TO_START (^K).