From beca8d777a0f0c410c54e44f0db6a76617fdd606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 1 Nov 2025 16:33:39 +0000 Subject: [PATCH 01/21] Implement vi mode and basic motions in pyrepl --- Lib/_pyrepl/commands.py | 26 +- Lib/_pyrepl/historical_reader.py | 20 +- Lib/_pyrepl/reader.py | 182 +++++++++++- Lib/_pyrepl/readline.py | 11 +- Lib/_pyrepl/types.py | 6 + Lib/_pyrepl/vi_commands.py | 85 ++++++ Lib/test/test_pyrepl/support.py | 8 + Lib/test/test_pyrepl/test_reader.py | 439 +++++++++++++++++++++++++++- 8 files changed, 759 insertions(+), 18 deletions(-) create mode 100644 Lib/_pyrepl/vi_commands.py diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 10127e58897a58..13342c8cea6414 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -23,6 +23,8 @@ import os import time +from .types import ViMode + # Categories of actions: # killing # yanking @@ -325,10 +327,19 @@ def do(self) -> None: b = r.buffer for _ in range(r.get_arg()): p = r.pos + 1 - if p <= len(b): - r.pos = p + # In vi normal mode, don't move past the last character + if r.vi_mode == ViMode.NORMAL: + eol_pos = r.eol() + max_pos = max(r.bol(), eol_pos - 1) if eol_pos > r.bol() else r.bol() + if p <= max_pos: + r.pos = p + else: + self.reader.error("end of line") else: - self.reader.error("end of buffer") + if p <= len(b): + r.pos = p + else: + self.reader.error("end of buffer") class beginning_of_line(MotionCommand): @@ -338,7 +349,14 @@ def do(self) -> None: class end_of_line(MotionCommand): def do(self) -> None: - self.reader.pos = self.reader.eol() + r = self.reader + eol_pos = r.eol() + if r.vi_mode == ViMode.NORMAL: + bol_pos = r.bol() + # Don't go past the last character (but stay at bol if line is empty) + r.pos = max(bol_pos, eol_pos - 1) if eol_pos > bol_pos else bol_pos + else: + r.pos = eol_pos class home(MotionCommand): diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index c4b95fa2e81ee6..014ff3603086c1 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -257,19 +257,27 @@ def __post_init__(self) -> None: ) def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]: - return super().collect_keymap() + ( + bindings: list[tuple[KeySpec, CommandName]] = [ (r"\C-n", "next-history"), (r"\C-p", "previous-history"), (r"\C-o", "operate-and-get-next"), (r"\C-r", "reverse-history-isearch"), (r"\C-s", "forward-history-isearch"), - (r"\M-r", "restore-history"), - (r"\M-.", "yank-arg"), (r"\", "history-search-forward"), - (r"\x1b[6~", "history-search-forward"), (r"\", "history-search-backward"), - (r"\x1b[5~", "history-search-backward"), - ) + ] + + if not self.use_vi_mode: + bindings.extend( + [ + (r"\M-r", "restore-history"), + (r"\M-.", "yank-arg"), + (r"\x1b[6~", "history-search-forward"), + (r"\x1b[5~", "history-search-backward"), + ] + ) + + return super().collect_keymap() + tuple(bindings) def select_item(self, i: int) -> None: self.transient_history[self.historyi] = self.get_unicode() diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 0ebd9162eca4bb..99e59784634a08 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -21,6 +21,7 @@ from __future__ import annotations +import itertools import sys import _colorize @@ -30,6 +31,7 @@ from . import commands, console, input from .utils import wlen, unbracket, disp_str, gen_colors, THEME from .trace import trace +from . import vi_commands # types @@ -41,6 +43,9 @@ SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3) +from .types import ViMode + + def make_default_syntax_table() -> dict[str, int]: # XXX perhaps should use some unicodedata here? st: dict[str, int] = {} @@ -54,10 +59,11 @@ def make_default_syntax_table() -> dict[str, int]: def make_default_commands() -> dict[CommandName, type[Command]]: result: dict[CommandName, type[Command]] = {} - for v in vars(commands).values(): - if isinstance(v, type) and issubclass(v, Command) and v.__name__[0].islower(): - result[v.__name__] = v - result[v.__name__.replace("_", "-")] = v + all_commands = itertools.chain(vars(commands).values(), vars(vi_commands).values()) + for cmd in all_commands: + if isinstance(cmd, type) and issubclass(cmd, Command) and cmd.__name__[0].islower(): + result[cmd.__name__] = cmd + result[cmd.__name__.replace("_", "-")] = cmd return result @@ -131,6 +137,67 @@ def make_default_commands() -> dict[CommandName, type[Command]]: ) +vi_insert_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple( + [binding for binding in default_keymap if not binding[0].startswith((r"\M-", r"\x1b", r"\EOF", r"\EOH"))] + + [(r"\", "vi-normal-mode")] +) + + +vi_normal_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple( + [ + # Basic motions + (r"h", "left"), + (r"j", "down"), + (r"k", "up"), + (r"l", "right"), + (r"0", "beginning-of-line"), + (r"$", "end-of-line"), + (r"w", "vi-forward-word"), + (r"b", "backward-word"), + (r"e", "end-of-word"), + (r"^", "first-non-whitespace-character"), + + # Edit commands + (r"x", "delete"), + (r"i", "vi-insert-mode"), + (r"a", "vi-append-mode"), + (r"A", "vi-append-eol"), + (r"I", "vi-insert-bol"), + (r"o", "vi-open-below"), + (r"O", "vi-open-above"), + + # Special keys still work in normal mode + (r"\", "left"), + (r"\", "right"), + (r"\", "up"), + (r"\", "down"), + (r"\", "beginning-of-line"), + (r"\", "end-of-line"), + (r"\", "delete"), + (r"\", "left"), + + # Control keys (important ones that work in both modes) + (r"\C-c", "interrupt"), + (r"\C-d", "delete"), + (r"\C-l", "clear-screen"), + (r"\C-r", "reverse-history-isearch"), + + # Digit args for counts (1-9, not 0 which is BOL) + (r"1", "digit-arg"), + (r"2", "digit-arg"), + (r"3", "digit-arg"), + (r"4", "digit-arg"), + (r"5", "digit-arg"), + (r"6", "digit-arg"), + (r"7", "digit-arg"), + (r"8", "digit-arg"), + (r"9", "digit-arg"), + + (r"\", "invalid-key"), + ] +) + + @dataclass(slots=True) class Reader: """The Reader class implements the bare bones of a command reader, @@ -214,6 +281,8 @@ class Reader: scheduled_commands: list[str] = field(default_factory=list) can_colorize: bool = False threading_hook: Callback | None = None + use_vi_mode: bool = False + vi_mode: ViMode = ViMode.INSERT ## cached metadata to speed up screen refreshes @dataclass @@ -281,6 +350,11 @@ def __post_init__(self) -> None: self.last_refresh_cache.dimensions = (0, 0) def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]: + if self.use_vi_mode: + if self.vi_mode == ViMode.INSERT: + return vi_insert_keymap + elif self.vi_mode == ViMode.NORMAL: + return vi_normal_keymap return default_keymap def calc_screen(self) -> list[str]: @@ -433,6 +507,57 @@ def eow(self, p: int | None = None) -> int: p += 1 return p + def vi_eow(self, p: int | None = None) -> int: + """Return the 0-based index of the last character of the word + following p most immediately (vi 'e' semantics). + + Unlike eow(), this returns the position ON the last word character, + not past it. p defaults to self.pos; word boundaries are determined + using self.syntax_table.""" + if p is None: + p = self.pos + st = self.syntax_table + b = self.buffer + + # If we're already at the end of a word, move past it + if (p < len(b) and st.get(b[p], SYNTAX_WORD) == SYNTAX_WORD and + (p + 1 >= len(b) or st.get(b[p + 1], SYNTAX_WORD) != SYNTAX_WORD)): + p += 1 + + # Skip non-word characters to find the start of next word + while p < len(b) and st.get(b[p], SYNTAX_WORD) != SYNTAX_WORD: + p += 1 + + # Move to the last character of this word (not past it) + while p + 1 < len(b) and st.get(b[p + 1], SYNTAX_WORD) == SYNTAX_WORD: + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + + def vi_forward_word(self, p: int | None = None) -> int: + """Return the 0-based index of the first character of the next word + (vi 'w' semantics). + + Unlike eow(), this lands ON the first character of the next word, + not past it. p defaults to self.pos; word boundaries are determined + using self.syntax_table.""" + if p is None: + p = self.pos + st = self.syntax_table + b = self.buffer + + # Skip the rest of the current word if we're on one + while p < len(b) and st.get(b[p], SYNTAX_WORD) == SYNTAX_WORD: + p += 1 + + # Skip non-word characters to find the start of next word + while p < len(b) and st.get(b[p], SYNTAX_WORD) != SYNTAX_WORD: + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + def bol(self, p: int | None = None) -> int: """Return the 0-based index of the line break preceding p most immediately. @@ -458,6 +583,18 @@ def eol(self, p: int | None = None) -> int: p += 1 return p + def first_non_whitespace(self, p: int | None = None) -> int: + """Return the 0-based index of the first non-whitespace character + on the current line. + + p defaults to self.pos.""" + bol_pos = self.bol(p) + eol_pos = self.eol(p) + pos = bol_pos + while pos < eol_pos and self.buffer[pos].isspace() and self.buffer[pos] != '\n': + pos += 1 + return pos + def max_column(self, y: int) -> int: """Return the last x-offset for line y""" return self.screeninfo[y][0] + sum(self.screeninfo[y][1]) @@ -589,6 +726,8 @@ def prepare(self) -> None: self.pos = 0 self.dirty = True self.last_command = None + if self.use_vi_mode: + self.enter_insert_mode() self.calc_screen() except BaseException: self.restore() @@ -760,3 +899,38 @@ def bind(self, spec: KeySpec, command: CommandName) -> None: def get_unicode(self) -> str: """Return the current buffer as a unicode string.""" return "".join(self.buffer) + + def enter_insert_mode(self) -> None: + if self.vi_mode == ViMode.INSERT: + return + + self.vi_mode = ViMode.INSERT + + # Switch translator to insert mode keymap + self.keymap = self.collect_keymap() + self.input_trans = input.KeymapTranslator( + self.keymap, invalid_cls="invalid-key", character_cls="self-insert" + ) + + self.dirty = True + + def enter_normal_mode(self) -> None: + if self.vi_mode == ViMode.NORMAL: + return + + self.vi_mode = ViMode.NORMAL + + # Switch translator to normal mode keymap + self.keymap = self.collect_keymap() + self.input_trans = input.KeymapTranslator( + self.keymap, invalid_cls="invalid-key", character_cls="invalid-key" + ) + + # In vi normal mode, cursor should be ON a character, not after the last one + # If we're past the end of line, move back to the last character + bol_pos = self.bol() + eol_pos = self.eol() + if self.pos >= eol_pos and eol_pos > bol_pos: + self.pos = eol_pos - 1 + + self.dirty = True diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..c9467ba742278f 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -65,7 +65,6 @@ MoreLinesCallable = Callable[[str], bool] - __all__ = [ "add_history", "clear_history", @@ -344,6 +343,10 @@ def do(self) -> None: # ____________________________________________________________ +def _is_vi_mode_enabled() -> bool: + return os.environ.get("PYREPL_VI_MODE", "").lower() in {"1", "true", "on", "yes"} + + @dataclass(slots=True) class _ReadlineWrapper: f_in: int = -1 @@ -362,7 +365,11 @@ def __post_init__(self) -> None: def get_reader(self) -> ReadlineAlikeReader: if self.reader is None: console = Console(self.f_in, self.f_out, encoding=ENCODING) - self.reader = ReadlineAlikeReader(console=console, config=self.config) + self.reader = ReadlineAlikeReader( + console=console, + config=self.config, + use_vi_mode=_is_vi_mode_enabled() + ) return self.reader def input(self, prompt: object = "") -> str: diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py index c5b7ebc1a406bd..0a99f258e07b4c 100644 --- a/Lib/_pyrepl/types.py +++ b/Lib/_pyrepl/types.py @@ -1,3 +1,4 @@ +import enum from collections.abc import Callable, Iterator type Callback = Callable[[], object] @@ -8,3 +9,8 @@ type Completer = Callable[[str, int], str | None] type CharBuffer = list[str] type CharWidths = list[int] + + +class ViMode(str, enum.Enum): + INSERT = "insert" + NORMAL = "normal" diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py new file mode 100644 index 00000000000000..10090bd7d7a7a6 --- /dev/null +++ b/Lib/_pyrepl/vi_commands.py @@ -0,0 +1,85 @@ +""" +Vi-specific commands for pyrepl. +""" + +from .commands import Command, MotionCommand + + +# ============================================================================ +# Vi-specific Motion Commands +# ============================================================================ + +class first_non_whitespace_character(MotionCommand): + def do(self) -> None: + self.reader.pos = self.reader.first_non_whitespace() + + +class end_of_word(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = r.vi_eow() + + +class vi_forward_word(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = r.vi_forward_word() + + +# ============================================================================ +# Mode Switching Commands +# ============================================================================ + +class vi_normal_mode(Command): + def do(self) -> None: + self.reader.enter_normal_mode() + + +class vi_insert_mode(Command): + def do(self) -> None: + self.reader.enter_insert_mode() + + +class vi_append_mode(Command): + def do(self) -> None: + if self.reader.pos < len(self.reader.buffer): + self.reader.pos += 1 + self.reader.enter_insert_mode() + + +class vi_append_eol(Command): + def do(self) -> None: + while self.reader.pos < len(self.reader.buffer): + if self.reader.buffer[self.reader.pos] == '\n': + break + self.reader.pos += 1 + self.reader.enter_insert_mode() + + +class vi_insert_bol(Command): + def do(self) -> None: + self.reader.pos = self.reader.first_non_whitespace() + self.reader.enter_insert_mode() + + +class vi_open_below(Command): + def do(self) -> None: + while self.reader.pos < len(self.reader.buffer): + if self.reader.buffer[self.reader.pos] == '\n': + break + self.reader.pos += 1 + + self.reader.insert('\n') + self.reader.enter_insert_mode() + + +class vi_open_above(Command): + def do(self) -> None: + while self.reader.pos > 0 and self.reader.buffer[self.reader.pos - 1] != '\n': + self.reader.pos -= 1 + + self.reader.insert('\n') + self.reader.pos -= 1 + self.reader.enter_insert_mode() diff --git a/Lib/test/test_pyrepl/support.py b/Lib/test/test_pyrepl/support.py index 4f7f9d77933336..c5ce4a066ab3c5 100644 --- a/Lib/test/test_pyrepl/support.py +++ b/Lib/test/test_pyrepl/support.py @@ -83,6 +83,14 @@ def get_prompt(lineno, cursor_on_line) -> str: return reader +def prepare_vi_reader(console: Console, **kwargs): + reader = prepare_reader(console, **kwargs) + reader.use_vi_mode = True + reader.enter_normal_mode() + reader.enter_insert_mode() + return reader + + def prepare_console(events: Iterable[Event], **kwargs) -> MagicMock | Console: console = MagicMock() console.get_event.side_effect = events diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index b1b6ae16a1e592..15c0430b38902d 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -8,12 +8,12 @@ from .support import handle_all_events, handle_events_narrow_console from .support import ScreenEqualMixin, code_to_events -from .support import prepare_reader, prepare_console +from .support import prepare_reader, prepare_console, prepare_vi_reader from _pyrepl.console import Event from _pyrepl.reader import Reader +from _pyrepl.types import ViMode from _colorize import default_theme - overrides = {"reset": "z", "soft_keyword": "K"} colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.syntax.items()} @@ -552,9 +552,444 @@ def test_syntax_highlighting_literal_brace_in_fstring_or_tstring(self): self.maxDiff=None self.assert_screen_equal(reader, expected) + def test_vi_escape_switches_to_normal_mode(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="h", raw=bytearray(b"h")), + ], + ) + reader, _ = handle_all_events( + events, + prepare_reader=prepare_vi_reader, + ) + self.assertEqual(reader.get_unicode(), "hello") + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertEqual(reader.pos, len("hello") - 2) # After 'h' left movement + def test_control_characters(self): code = 'flag = "🏳️‍🌈"' events = code_to_events(code) reader, _ = handle_all_events(events) self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) + + +@force_not_colorized_test_class +class TestViMode(TestCase): + def _run_vi(self, events, prepare_reader_hook=prepare_vi_reader): + return handle_all_events(events, prepare_reader=prepare_reader_hook) + + def test_insert_typing_and_ctrl_a_e(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x01", raw=bytearray(b"\x01")), # Ctrl-A + Event(evt="key", data="X", raw=bytearray(b"X")), + Event(evt="key", data="\x05", raw=bytearray(b"\x05")), # Ctrl-E + Event(evt="key", data="!", raw=bytearray(b"!")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "Xhello!") + self.assertTrue(reader.vi_mode == ViMode.INSERT) + + def test_escape_switches_to_normal_mode_and_is_idempotent(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="h", raw=bytearray(b"h")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello") + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertEqual(reader.pos, len("hello") - 2) # After 'h' left movement + + def test_normal_mode_motion_and_edit_commands(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # go to bol + Event(evt="key", data="l", raw=bytearray(b"l")), # right + Event(evt="key", data="x", raw=bytearray(b"x")), # delete + Event(evt="key", data="a", raw=bytearray(b"a")), # append + Event(evt="key", data="!", raw=bytearray(b"!")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hl!lo") + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + + def test_open_below_and_above(self): + events = itertools.chain( + code_to_events("first"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="o", raw=bytearray(b"o")), + ], + code_to_events("second"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="O", raw=bytearray(b"O")), + ], + code_to_events("zero"), + [Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\nzero\nsecond") + + def test_mode_resets_to_insert_on_prepare(self): + events = itertools.chain( + code_to_events("text"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, console = self._run_vi(events) + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + reader.prepare() + self.assertTrue(reader.vi_mode == ViMode.INSERT) + console.prepare.assert_called() # ensure console prepare called again + + def test_translator_stack_preserves_mode(self): + events_insert_path = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), # Ctrl-R + Event(evt="key", data="h", raw=bytearray(b"h")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events_insert_path) + self.assertTrue(reader.vi_mode == ViMode.INSERT) + + events_normal_path = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events_normal_path) + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + + def test_insert_bol_and_append_eol(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="I", raw=bytearray(b"I")), # Insert at BOL + Event(evt="key", data="[", raw=bytearray(b"[")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # Back to normal + Event(evt="key", data="A", raw=bytearray(b"A")), # Append at EOL + Event(evt="key", data="]", raw=bytearray(b"]")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "[hello]") + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + + def test_insert_mode_from_normal(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to beginning + Event(evt="key", data="l", raw=bytearray(b"l")), # Move right + Event(evt="key", data="l", raw=bytearray(b"l")), # Move right again + Event(evt="key", data="i", raw=bytearray(b"i")), # Insert mode + Event(evt="key", data="X", raw=bytearray(b"X")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "heXllo") + self.assertTrue(reader.vi_mode == ViMode.INSERT) + + def test_hjkl_motions(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to start of line + Event(evt="key", data="l", raw=bytearray(b"l")), # Right (h->e) + Event(evt="key", data="l", raw=bytearray(b"l")), # Right (e->l) + Event(evt="key", data="h", raw=bytearray(b"h")), # Left (l->e) + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 'e' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hllo") + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + + def test_dollar_end_of_line(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="$", raw=bytearray(b"$")), # End (on last char) + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 'o' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hell") + + def test_word_motions(self): + events = itertools.chain( + code_to_events("one two"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="w", raw=bytearray(b"w")), # Forward word + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete first char of 'two' + ], + ) + reader, _ = self._run_vi(events) + self.assertIn("one", reader.get_unicode()) + self.assertNotEqual(reader.get_unicode(), "one two") # Something was deleted + + def test_repeat_counts(self): + events = itertools.chain( + code_to_events("abcdefghij"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="3", raw=bytearray(b"3")), # Count 3 + Event(evt="key", data="l", raw=bytearray(b"l")), # Move right 3 times + Event(evt="key", data="2", raw=bytearray(b"2")), # Count 2 + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 2 chars (d, e) + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "abcfghij") + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + + def test_multiline_navigation(self): + # Test j/k navigation across multiple lines + code = "first\nsecond\nthird" + events = itertools.chain( + code_to_events(code), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # Up to "second" + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning of line + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 's' + Event(evt="key", data="j", raw=bytearray(b"j")), # Down to "third" + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 't' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\necond\nhird") + + def test_arrow_keys_in_normal_mode(self): + events = itertools.chain( + code_to_events("test"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="left", raw=bytearray(b"\x1b[D")), # Left arrow + Event(evt="key", data="left", raw=bytearray(b"\x1b[D")), # Left arrow + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 'e' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "tst") + + def test_escape_in_normal_mode_is_noop(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC again (no-op) + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC again (no-op) + ], + ) + reader, _ = self._run_vi(events) + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertEqual(reader.get_unicode(), "hello") + + def test_backspace_in_normal_mode(self): + events = itertools.chain( + code_to_events("abcd"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="\x7f", raw=bytearray(b"\x7f")), # Backspace + Event(evt="key", data="\x7f", raw=bytearray(b"\x7f")), # Backspace again + ], + ) + reader, _ = self._run_vi(events) + self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertIsNotNone(reader.get_unicode()) + + def test_end_of_word_motion(self): + events = itertools.chain( + code_to_events("hello world test"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="e", raw=bytearray(b"e")), # End of "hello" + ], + ) + reader, _ = self._run_vi(events) + # Should be on 'o' of "hello" (last char of word) + self.assertEqual(reader.pos, 4) + self.assertEqual(reader.buffer[reader.pos], 'o') + + # Test multiple 'e' commands + events2 = itertools.chain( + code_to_events("one two three"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="e", raw=bytearray(b"e")), # End of "one" + Event(evt="key", data="e", raw=bytearray(b"e")), # End of "two" + ], + ) + reader2, _ = self._run_vi(events2) + # Should be on 'o' of "two" + self.assertEqual(reader2.buffer[reader2.pos], 'o') + + def test_backward_word_motion(self): + # Test from end of buffer + events = itertools.chain( + code_to_events("one two"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC at end + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to start of "two" + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 4) # At 't' of "two" + self.assertEqual(reader.buffer[reader.pos], 't') + + # Test multiple backwards + events2 = itertools.chain( + code_to_events("one two three"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to "three" + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to "two" + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to "one" + ], + ) + reader2, _ = self._run_vi(events2) + # Should be at beginning of "one" + self.assertEqual(reader2.pos, 0) + self.assertEqual(reader2.buffer[reader2.pos], 'o') + + def test_first_non_whitespace_character(self): + events = itertools.chain( + code_to_events(" hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="^", raw=bytearray(b"^")), # First non-ws + ], + ) + reader, _ = self._run_vi(events) + # Should be at 'h' of "hello", skipping the 3 spaces + self.assertEqual(reader.pos, 3) + self.assertEqual(reader.buffer[reader.pos], 'h') + + # Test with tabs and spaces + events2 = itertools.chain( + code_to_events("\t text"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL first + Event(evt="key", data="^", raw=bytearray(b"^")), # Then to first non-ws + ], + ) + reader2, _ = self._run_vi(events2) + self.assertEqual(reader2.buffer[reader2.pos], 't') + + def test_word_motion_edge_cases(self): + # Test with punctuation - underscore should be a word boundary + events = itertools.chain( + code_to_events("hello_world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="w", raw=bytearray(b"w")), # Forward word + ], + ) + reader, _ = self._run_vi(events) + # 'w' moves to next word, underscore is not alphanumeric so treated as boundary + self.assertIn(reader.pos, [5, 6]) # Could be on '_' or 'w' depending on implementation + + # Test 'e' at end of buffer stays in bounds + events2 = itertools.chain( + code_to_events("end"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="e", raw=bytearray(b"e")), # Already at end of word + Event(evt="key", data="e", raw=bytearray(b"e")), # Should stay in bounds + ], + ) + reader2, _ = self._run_vi(events2) + # Should not go past end of buffer + self.assertLessEqual(reader2.pos, len(reader2.buffer) - 1) + + # Test 'b' at beginning doesn't crash + events3 = itertools.chain( + code_to_events("start"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="b", raw=bytearray(b"b")), # Should stay at 0 + ], + ) + reader3, _ = self._run_vi(events3) + self.assertEqual(reader3.pos, 0) + + def test_repeat_count_with_word_motions(self): + events = itertools.chain( + code_to_events("one two three four"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="2", raw=bytearray(b"2")), # Count 2 + Event(evt="key", data="w", raw=bytearray(b"w")), # Forward 2 words + ], + ) + reader, _ = self._run_vi(events) + # Should be at start of "three" (2 words forward from "one") + self.assertEqual(reader.buffer[reader.pos], 't') # 't' of "three" + + # Test with 'e' + events2 = itertools.chain( + code_to_events("alpha beta gamma"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="2", raw=bytearray(b"2")), + Event(evt="key", data="e", raw=bytearray(b"e")), # End of 2nd word + ], + ) + reader2, _ = self._run_vi(events2) + # Should be at end of "beta" + self.assertEqual(reader2.buffer[reader2.pos], 'a') # Last 'a' of "beta" + + +@force_not_colorized_test_class +class TestHistoricalReaderBindings(TestCase): + def test_meta_bindings_present_only_in_emacs_mode(self): + console = prepare_console(iter(())) + reader = prepare_reader(console) + emacs_keymap = dict(reader.collect_keymap()) + self.assertIn(r"\M-r", emacs_keymap) + self.assertIn(r"\x1b[6~", emacs_keymap) + + reader.use_vi_mode = True + reader.enter_insert_mode() + vi_keymap = dict(reader.collect_keymap()) + self.assertNotIn(r"\M-r", vi_keymap) + self.assertNotIn(r"\x1b[6~", vi_keymap) + self.assertIn(r"\C-r", vi_keymap) From a054e5d2f303673c1b8c0957421e97575ff15432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 1 Nov 2025 16:35:00 +0000 Subject: [PATCH 02/21] Fix multi-line prompt display The first line of multi-line input should display ps1 (primary prompt) not ps2 (continuation prompt). This is important for vi mode where users navigate through existing multi-line code with cursor movements. --- Lib/_pyrepl/reader.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 99e59784634a08..8a290e2a506559 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -618,9 +618,15 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: elif self.paste_mode: prompt = "(paste) " elif "\n" in self.buffer: + newline_count = self.buffer.count("\n") + ends_with_newline = bool(self.buffer) and self.buffer[-1] == "\n" if lineno == 0: - prompt = self.ps2 - elif self.ps4 and lineno == self.buffer.count("\n"): + prompt = self.ps1 + elif lineno < newline_count: + prompt = self.ps3 + elif ends_with_newline and lineno == newline_count: + prompt = self.ps3 + elif self.ps4 and lineno == newline_count: prompt = self.ps4 else: prompt = self.ps3 From cd7b85d4efb4183371630629466d0402dab5ece5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 25 Oct 2025 14:52:29 +0100 Subject: [PATCH 03/21] Properly detect single ESC key --- Lib/_pyrepl/base_eventqueue.py | 48 ++++++++++++++++++++++++++++++++++ Lib/_pyrepl/unix_console.py | 14 ++++++++++ Lib/_pyrepl/windows_console.py | 19 ++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index 0589a0f437ec7c..773224b5d1a65f 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -31,6 +31,8 @@ from .trace import trace class BaseEventQueue: + _ESCAPE_TIMEOUT_MS = 50 + def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: self.compiled_keymap = keymap.compile_keymap(keymap_dict) self.keymap = self.compiled_keymap @@ -38,6 +40,7 @@ def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: self.encoding = encoding self.events: deque[Event] = deque() self.buf = bytearray() + self._pending_escape_deadline: float | None = None def get(self) -> Event | None: """ @@ -69,6 +72,48 @@ def insert(self, event: Event) -> None: trace('added event {event}', event=event) self.events.append(event) + def has_pending_escape_sequence(self) -> bool: + """ + Check if there's a potential escape sequence waiting for more input. + + Returns True if we have exactly one byte (ESC) in the buffer and + we're in the middle of keymap navigation, indicating we're waiting + to see if more bytes will arrive to complete an escape sequence. + """ + return ( + len(self.buf) == 1 + and self.buf[0] == 27 # ESC byte + and self.keymap is not self.compiled_keymap + ) + + def should_emit_standalone_escape(self, current_time_ms: float) -> bool: + """ + Check if a pending ESC should be emitted as a standalone escape key. + """ + if not self.has_pending_escape_sequence(): + return False + + if self._pending_escape_deadline is None: + self._pending_escape_deadline = current_time_ms + self._ESCAPE_TIMEOUT_MS + return False + + return current_time_ms >= self._pending_escape_deadline + + def emit_standalone_escape(self) -> None: + """ + Emit the buffered ESC byte as a standalone escape key event. + """ + self.keymap = self.compiled_keymap + # Standalone ESC event + self.insert(Event('key', '\033', b'\033')) + + # Just in case there are remaining bytes in the buffer + remaining = self.flush_buf()[1:] + for byte in remaining: + self.push(byte) + + self._pending_escape_deadline = None + def push(self, char: int | bytes) -> None: """ Processes a character by updating the buffer and handling special key mappings. @@ -78,6 +123,9 @@ def push(self, char: int | bytes) -> None: char = ord_char.to_bytes() self.buf.append(ord_char) + if self._pending_escape_deadline is not None: + self._pending_escape_deadline = None + if char in self.keymap: if self.keymap is self.compiled_keymap: # sanity check, buffer is empty when a special key comes diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 09247de748ee3b..ce60366d95cde4 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -419,6 +419,19 @@ def get_event(self, block: bool = True) -> Event | None: return None while self.event_queue.empty(): + if self.event_queue.has_pending_escape_sequence(): + current_time_ms = time.monotonic() * 1000 + + if self.event_queue.should_emit_standalone_escape(current_time_ms): + self.event_queue.emit_standalone_escape() + break + + if not self.wait(timeout=10): + current_time_ms = time.monotonic() * 1000 + if self.event_queue.should_emit_standalone_escape(current_time_ms): + self.event_queue.emit_standalone_escape() + continue + while True: try: self.push_char(self.__read(1)) @@ -445,6 +458,7 @@ def wait(self, timeout: float | None = None) -> bool: or bool(self.pollob.poll(timeout)) ) + def set_cursor_vis(self, visible): """ Set the visibility of the cursor. diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index c56dcd6d7dd434..b16817a31e16bc 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -446,6 +446,24 @@ def get_event(self, block: bool = True) -> Event | None: return None while self.event_queue.empty(): + # Check if we have a pending escape sequence that needs timeout handling + if self.event_queue.has_pending_escape_sequence(): + import time + current_time_ms = time.monotonic() * 1000 + + if self.event_queue.should_emit_standalone_escape(current_time_ms): + # Timeout expired - emit the ESC as a standalone key + self.event_queue.emit_standalone_escape() + break + + # Wait for a short time to check for more input + if not self.wait(timeout=10): + # Check again after timeout + current_time_ms = time.monotonic() * 1000 + if self.event_queue.should_emit_standalone_escape(current_time_ms): + self.event_queue.emit_standalone_escape() + continue + rec = self._read_input() if rec is None: return None @@ -583,6 +601,7 @@ def repaint(self) -> None: raise NotImplementedError("No repaint support") + # Windows interop class CONSOLE_SCREEN_BUFFER_INFO(Structure): _fields_ = [ From 17ef56b925893cbc02e2f0433593528603d13f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Fri, 21 Nov 2025 21:35:42 +0000 Subject: [PATCH 04/21] Fix word boundary handling In emacs mode _ is not a word boundary but in vi it is. --- Lib/_pyrepl/reader.py | 44 +++++++++++++++++++++-------- Lib/test/test_pyrepl/test_reader.py | 44 +++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 8a290e2a506559..698fa15347c51f 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -57,6 +57,10 @@ def make_default_syntax_table() -> dict[str, int]: return st +def _is_vi_word_char(c: str) -> bool: + return c.isalnum() or c == '_' + + def make_default_commands() -> dict[CommandName, type[Command]]: result: dict[CommandName, type[Command]] = {} all_commands = itertools.chain(vars(commands).values(), vars(vi_commands).values()) @@ -512,24 +516,23 @@ def vi_eow(self, p: int | None = None) -> int: following p most immediately (vi 'e' semantics). Unlike eow(), this returns the position ON the last word character, - not past it. p defaults to self.pos; word boundaries are determined - using self.syntax_table.""" + not past it. p defaults to self.pos; word boundaries use vi rules + (alphanumeric + underscore).""" if p is None: p = self.pos - st = self.syntax_table b = self.buffer # If we're already at the end of a word, move past it - if (p < len(b) and st.get(b[p], SYNTAX_WORD) == SYNTAX_WORD and - (p + 1 >= len(b) or st.get(b[p + 1], SYNTAX_WORD) != SYNTAX_WORD)): + if (p < len(b) and _is_vi_word_char(b[p]) and + (p + 1 >= len(b) or not _is_vi_word_char(b[p + 1]))): p += 1 # Skip non-word characters to find the start of next word - while p < len(b) and st.get(b[p], SYNTAX_WORD) != SYNTAX_WORD: + while p < len(b) and not _is_vi_word_char(b[p]): p += 1 # Move to the last character of this word (not past it) - while p + 1 < len(b) and st.get(b[p + 1], SYNTAX_WORD) == SYNTAX_WORD: + while p + 1 < len(b) and _is_vi_word_char(b[p + 1]): p += 1 # Clamp to valid buffer range @@ -540,24 +543,41 @@ def vi_forward_word(self, p: int | None = None) -> int: (vi 'w' semantics). Unlike eow(), this lands ON the first character of the next word, - not past it. p defaults to self.pos; word boundaries are determined - using self.syntax_table.""" + not past it. p defaults to self.pos; word boundaries use vi rules + (alphanumeric + underscore).""" if p is None: p = self.pos - st = self.syntax_table b = self.buffer # Skip the rest of the current word if we're on one - while p < len(b) and st.get(b[p], SYNTAX_WORD) == SYNTAX_WORD: + while p < len(b) and _is_vi_word_char(b[p]): p += 1 # Skip non-word characters to find the start of next word - while p < len(b) and st.get(b[p], SYNTAX_WORD) != SYNTAX_WORD: + while p < len(b) and not _is_vi_word_char(b[p]): p += 1 # Clamp to valid buffer range return min(p, len(b) - 1) if b else 0 + def vi_bow(self, p: int | None = None) -> int: + """Return the 0-based index of the beginning of the word preceding p + (vi 'b' semantics). + + p defaults to self.pos; word boundaries use vi rules + (alphanumeric + underscore).""" + if p is None: + p = self.pos + b = self.buffer + p -= 1 + # Skip non-word characters + while p >= 0 and not _is_vi_word_char(b[p]): + p -= 1 + # Skip word characters to find beginning of word + while p >= 0 and _is_vi_word_char(b[p]): + p -= 1 + return p + 1 + def bol(self, p: int | None = None) -> int: """Return the 0-based index of the line break preceding p most immediately. diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 15c0430b38902d..a0a663396d1721 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -911,7 +911,7 @@ def test_first_non_whitespace_character(self): self.assertEqual(reader2.buffer[reader2.pos], 't') def test_word_motion_edge_cases(self): - # Test with punctuation - underscore should be a word boundary + # Test with underscore - in vi mode, underscore IS a word character events = itertools.chain( code_to_events("hello_world"), [ @@ -921,8 +921,9 @@ def test_word_motion_edge_cases(self): ], ) reader, _ = self._run_vi(events) - # 'w' moves to next word, underscore is not alphanumeric so treated as boundary - self.assertIn(reader.pos, [5, 6]) # Could be on '_' or 'w' depending on implementation + # In vi mode, underscore is part of word, so 'w' goes past end of "hello_world" + # which clamps to end of buffer (pos 10, on 'd') + self.assertEqual(reader.pos, 10) # Test 'e' at end of buffer stays in bounds events2 = itertools.chain( @@ -977,6 +978,43 @@ def test_repeat_count_with_word_motions(self): # Should be at end of "beta" self.assertEqual(reader2.buffer[reader2.pos], 'a') # Last 'a' of "beta" + def test_vi_word_boundaries(self): + """Test vi word motions match vim behavior for word characters. + + In vi, word characters are alphanumeric + underscore. + """ + # Test cases: (text, start_key_sequence, expected_pos, description) + test_cases = [ + # Underscore is part of word in vi, unlike emacs mode + ("function_name", "0w", 12, "underscore is word char, w clamps to end"), + ("hello_world test", "0w", 12, "underscore word, then to next word"), + ("get_value(x)", "0w", 10, "underscore word, skip ( to x"), + + # Basic word motion + ("hello world", "0w", 6, "basic word jump"), + ("one two", "0w", 5, "double space handled"), + ("abc def ghi", "0ww", 8, "two w's"), + + # End of word (e) - lands ON last char + ("function_name", "0e", 12, "e lands on last char of underscore word"), + ("foo bar", "0e", 2, "e lands on last char of foo"), + ("one two three", "0ee", 6, "two e's land on end of two"), + ] + + for text, keys, expected_pos, desc in test_cases: + with self.subTest(text=text, keys=keys, desc=desc): + key_events = [] + for k in keys: + key_events.append(Event(evt="key", data=k, raw=bytearray(k.encode()))) + events = itertools.chain( + code_to_events(text), + [Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))], # ESC + key_events, + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, expected_pos, + f"Expected pos {expected_pos} but got {reader.pos} for '{text}' with keys '{keys}'") + @force_not_colorized_test_class class TestHistoricalReaderBindings(TestCase): From 0b14fce888b7fe8f0a44f66da02997676d4abd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Fri, 21 Nov 2025 21:36:53 +0000 Subject: [PATCH 05/21] Add a couple of compound delete commands --- Lib/_pyrepl/reader.py | 6 ++++++ Lib/_pyrepl/vi_commands.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 698fa15347c51f..df1823ee37374e 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -170,6 +170,12 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"o", "vi-open-below"), (r"O", "vi-open-above"), + # Delete commands + (r"dw", "vi-delete-word"), + (r"dd", "vi-delete-line"), + (r"d0", "vi-delete-to-bol"), + (r"d$", "vi-delete-to-eol"), + # Special keys still work in normal mode (r"\", "left"), (r"\", "right"), diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 10090bd7d7a7a6..0b3e163774019c 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -2,7 +2,7 @@ Vi-specific commands for pyrepl. """ -from .commands import Command, MotionCommand +from .commands import Command, MotionCommand, KillCommand # ============================================================================ @@ -83,3 +83,38 @@ def do(self) -> None: self.reader.insert('\n') self.reader.pos -= 1 self.reader.enter_insert_mode() + + +# ============================================================================ +# Delete Commands +# ============================================================================ + +class vi_delete_word(KillCommand): + """Delete from cursor to start of next word (dw).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + end = r.vi_forward_word() + if end > r.pos: + self.kill_range(r.pos, end) + + +class vi_delete_line(KillCommand): + """Delete entire line content (dd).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.bol(), r.eol()) + + +class vi_delete_to_bol(KillCommand): + """Delete from cursor to beginning of line (d0).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.bol(), r.pos) + + +class vi_delete_to_eol(KillCommand): + """Delete from cursor to end of line (d$).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.pos, r.eol()) From 23babd6a9ec1e3461e22d809fb03f5b123ac6ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Fri, 21 Nov 2025 21:46:34 +0000 Subject: [PATCH 06/21] Handle punctuation as separate words in vi motions Vi has three character classes: word chars (alnum + _), punctuation (non-word, non-whitespace), and whitespace. Now w, e, and b treat punctuation sequences as separate words, matching vim behavior. --- Lib/_pyrepl/reader.py | 109 ++++++++++++++++++++-------- Lib/test/test_pyrepl/test_reader.py | 22 ++++-- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index df1823ee37374e..ff0e01775a0f95 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -521,46 +521,79 @@ def vi_eow(self, p: int | None = None) -> int: """Return the 0-based index of the last character of the word following p most immediately (vi 'e' semantics). - Unlike eow(), this returns the position ON the last word character, - not past it. p defaults to self.pos; word boundaries use vi rules - (alphanumeric + underscore).""" + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'e' moves to the end + of the current or next word/punctuation sequence.""" if p is None: p = self.pos b = self.buffer - # If we're already at the end of a word, move past it - if (p < len(b) and _is_vi_word_char(b[p]) and - (p + 1 >= len(b) or not _is_vi_word_char(b[p + 1]))): - p += 1 + if not b: + return 0 + + # Helper to check if at end of current sequence + def at_sequence_end(pos: int) -> bool: + if pos >= len(b) - 1: + return True + curr_is_word = _is_vi_word_char(b[pos]) + next_is_word = _is_vi_word_char(b[pos + 1]) + curr_is_space = b[pos].isspace() + next_is_space = b[pos + 1].isspace() + if curr_is_word: + return not next_is_word + elif not curr_is_space: + # Punctuation - at end if next is word or whitespace + return next_is_word or next_is_space + return True - # Skip non-word characters to find the start of next word - while p < len(b) and not _is_vi_word_char(b[p]): + # If already at end of a word/punctuation, move forward + if p < len(b) and at_sequence_end(p): p += 1 - # Move to the last character of this word (not past it) - while p + 1 < len(b) and _is_vi_word_char(b[p + 1]): + # Skip whitespace + while p < len(b) and b[p].isspace(): p += 1 - # Clamp to valid buffer range - return min(p, len(b) - 1) if b else 0 + if p >= len(b): + return len(b) - 1 + + # Move to end of current word or punctuation sequence + if _is_vi_word_char(b[p]): + while p + 1 < len(b) and _is_vi_word_char(b[p + 1]): + p += 1 + else: + # Punctuation sequence + while p + 1 < len(b) and not _is_vi_word_char(b[p + 1]) and not b[p + 1].isspace(): + p += 1 + + return min(p, len(b) - 1) def vi_forward_word(self, p: int | None = None) -> int: """Return the 0-based index of the first character of the next word (vi 'w' semantics). - Unlike eow(), this lands ON the first character of the next word, - not past it. p defaults to self.pos; word boundaries use vi rules - (alphanumeric + underscore).""" + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'w' moves to the start + of the next word or punctuation sequence.""" if p is None: p = self.pos b = self.buffer - # Skip the rest of the current word if we're on one - while p < len(b) and _is_vi_word_char(b[p]): - p += 1 - - # Skip non-word characters to find the start of next word - while p < len(b) and not _is_vi_word_char(b[p]): + if not b or p >= len(b): + return max(0, len(b) - 1) if b else 0 + + # Skip current word or punctuation sequence + if _is_vi_word_char(b[p]): + # On a word char - skip word chars + while p < len(b) and _is_vi_word_char(b[p]): + p += 1 + elif not b[p].isspace(): + # On punctuation - skip punctuation + while p < len(b) and not _is_vi_word_char(b[p]) and not b[p].isspace(): + p += 1 + + # Skip whitespace to find next word or punctuation + while p < len(b) and b[p].isspace(): p += 1 # Clamp to valid buffer range @@ -570,19 +603,35 @@ def vi_bow(self, p: int | None = None) -> int: """Return the 0-based index of the beginning of the word preceding p (vi 'b' semantics). - p defaults to self.pos; word boundaries use vi rules - (alphanumeric + underscore).""" + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'b' moves to the start + of the current or previous word/punctuation sequence.""" if p is None: p = self.pos b = self.buffer + + if not b or p <= 0: + return 0 + p -= 1 - # Skip non-word characters - while p >= 0 and not _is_vi_word_char(b[p]): - p -= 1 - # Skip word characters to find beginning of word - while p >= 0 and _is_vi_word_char(b[p]): + + # Skip whitespace going backward + while p >= 0 and b[p].isspace(): p -= 1 - return p + 1 + + if p < 0: + return 0 + + # Now skip the word or punctuation sequence we landed in + if _is_vi_word_char(b[p]): + while p > 0 and _is_vi_word_char(b[p - 1]): + p -= 1 + else: + # Punctuation sequence + while p > 0 and not _is_vi_word_char(b[p - 1]) and not b[p - 1].isspace(): + p -= 1 + + return p def bol(self, p: int | None = None) -> int: """Return the 0-based index of the line break preceding p most diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index a0a663396d1721..181e3ff705cac2 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -979,16 +979,26 @@ def test_repeat_count_with_word_motions(self): self.assertEqual(reader2.buffer[reader2.pos], 'a') # Last 'a' of "beta" def test_vi_word_boundaries(self): - """Test vi word motions match vim behavior for word characters. + """Test vi word motions match vim behavior. - In vi, word characters are alphanumeric + underscore. + Vi has three character classes: + 1. Word chars: alphanumeric + underscore + 2. Punctuation: non-word, non-whitespace (forms separate words) + 3. Whitespace: delimiters """ # Test cases: (text, start_key_sequence, expected_pos, description) test_cases = [ # Underscore is part of word in vi, unlike emacs mode ("function_name", "0w", 12, "underscore is word char, w clamps to end"), - ("hello_world test", "0w", 12, "underscore word, then to next word"), - ("get_value(x)", "0w", 10, "underscore word, skip ( to x"), + ("hello_world test", "0w", 12, "underscore word to end"), + + # Punctuation is a separate word + ("foo.bar", "0w", 3, "w stops at dot (punctuation)"), + ("foo.bar", "0ww", 4, "second w goes to bar"), + ("foo..bar", "0w", 3, "w stops at first dot"), + ("foo..bar", "0ww", 5, "second w skips dot sequence to bar"), + ("get_value(x)", "0w", 9, "underscore word stops at ("), + ("get_value(x)", "0ww", 10, "second w goes to x"), # Basic word motion ("hello world", "0w", 6, "basic word jump"), @@ -998,7 +1008,9 @@ def test_vi_word_boundaries(self): # End of word (e) - lands ON last char ("function_name", "0e", 12, "e lands on last char of underscore word"), ("foo bar", "0e", 2, "e lands on last char of foo"), - ("one two three", "0ee", 6, "two e's land on end of two"), + ("foo.bar", "0e", 2, "e lands on last o of foo"), + ("foo.bar", "0ee", 3, "second e lands on dot"), + ("foo.bar", "0eee", 6, "third e lands on last r of bar"), ] for text, keys, expected_pos, desc in test_cases: From 237f559506bfe76c6f75339593ee32bf7888ea72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Fri, 21 Nov 2025 21:47:05 +0000 Subject: [PATCH 07/21] Use vi-specific backward word logic Use vi_backward_word on b key in vi mode as the original emacs mode implementation respects different word boundaries. --- Lib/_pyrepl/reader.py | 2 +- Lib/_pyrepl/vi_commands.py | 7 +++++++ Lib/test/test_pyrepl/test_reader.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index ff0e01775a0f95..67fe13362831a6 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -157,7 +157,7 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"0", "beginning-of-line"), (r"$", "end-of-line"), (r"w", "vi-forward-word"), - (r"b", "backward-word"), + (r"b", "vi-backward-word"), (r"e", "end-of-word"), (r"^", "first-non-whitespace-character"), diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 0b3e163774019c..95c5fbd1ae065a 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -28,6 +28,13 @@ def do(self) -> None: r.pos = r.vi_forward_word() +class vi_backward_word(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = r.vi_bow() + + # ============================================================================ # Mode Switching Commands # ============================================================================ diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 181e3ff705cac2..0fe1b4d276b89e 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1011,6 +1011,16 @@ def test_vi_word_boundaries(self): ("foo.bar", "0e", 2, "e lands on last o of foo"), ("foo.bar", "0ee", 3, "second e lands on dot"), ("foo.bar", "0eee", 6, "third e lands on last r of bar"), + + # Backward word (b) - cursor at end after ESC + ("foo bar", "$b", 4, "b from end lands on bar"), + ("foo bar", "$bb", 0, "two b's lands on foo"), + ("foo.bar", "$b", 4, "b from end lands on bar"), + ("foo.bar", "$bb", 3, "second b lands on dot"), + ("foo.bar", "$bbb", 0, "third b lands on foo"), + ("get_value(x)", "$b", 10, "b from end lands on x"), + ("get_value(x)", "$bb", 9, "second b lands on ("), + ("get_value(x)", "$bbb", 0, "third b lands on get_value"), ] for text, keys, expected_pos, desc in test_cases: From 1406699f3bcf5128b03f0eff6f8fea62893de159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 08:36:25 +0000 Subject: [PATCH 08/21] Implement W and B vi motions --- Lib/_pyrepl/reader.py | 52 +++++++++++++++++++++++++++++ Lib/_pyrepl/vi_commands.py | 13 ++++++++ Lib/test/test_pyrepl/test_reader.py | 21 ++++++++++++ 3 files changed, 86 insertions(+) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 67fe13362831a6..0b6a9c5542f1da 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -157,7 +157,9 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"0", "beginning-of-line"), (r"$", "end-of-line"), (r"w", "vi-forward-word"), + (r"W", "vi-forward-word-ws"), (r"b", "vi-backward-word"), + (r"B", "vi-backward-word-ws"), (r"e", "end-of-word"), (r"^", "first-non-whitespace-character"), @@ -599,6 +601,29 @@ def vi_forward_word(self, p: int | None = None) -> int: # Clamp to valid buffer range return min(p, len(b) - 1) if b else 0 + def vi_forward_word_ws(self, p: int | None = None) -> int: + """Return the 0-based index of the first character of the next WORD + (vi 'W' semantics). + + Treats white space as the only separator.""" + if p is None: + p = self.pos + b = self.buffer + + if not b or p >= len(b): + return max(0, len(b) - 1) if b else 0 + + # Skip all non-whitespace (the current WORD) + while p < len(b) and not b[p].isspace(): + p += 1 + + # Skip whitespace to find next WORD + while p < len(b) and b[p].isspace(): + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + def vi_bow(self, p: int | None = None) -> int: """Return the 0-based index of the beginning of the word preceding p (vi 'b' semantics). @@ -633,6 +658,33 @@ def vi_bow(self, p: int | None = None) -> int: return p + def vi_bow_ws(self, p: int | None = None) -> int: + """Return the 0-based index of the beginning of the WORD preceding p + (vi 'B' semantics). + + Treats white space as the only separator.""" + if p is None: + p = self.pos + b = self.buffer + + if not b or p <= 0: + return 0 + + p -= 1 + + # Skip whitespace going backward + while p >= 0 and b[p].isspace(): + p -= 1 + + if p < 0: + return 0 + + # Now skip the WORD we landed in + while p > 0 and not b[p - 1].isspace(): + p -= 1 + + return p + def bol(self, p: int | None = None) -> int: """Return the 0-based index of the line break preceding p most immediately. diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 95c5fbd1ae065a..5d6fbc293a34e9 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -27,6 +27,11 @@ def do(self) -> None: for _ in range(r.get_arg()): r.pos = r.vi_forward_word() +class vi_forward_word_ws(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = r.vi_forward_word_ws() class vi_backward_word(MotionCommand): def do(self) -> None: @@ -35,6 +40,14 @@ def do(self) -> None: r.pos = r.vi_bow() +class vi_backward_word_ws(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = r.vi_bow_ws() + + + # ============================================================================ # Mode Switching Commands # ============================================================================ diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 0fe1b4d276b89e..83d17f826e6604 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1021,6 +1021,27 @@ def test_vi_word_boundaries(self): ("get_value(x)", "$b", 10, "b from end lands on x"), ("get_value(x)", "$bb", 9, "second b lands on ("), ("get_value(x)", "$bbb", 0, "third b lands on get_value"), + + # W (WORD motion by whitespace-delimited words) + ("foo.bar baz", "0W", 8, "W skips punctuation to baz"), + ("one,two three", "0W", 8, "W skips comma to three"), + ("hello world", "0W", 8, "W handles multiple spaces"), + ("get_value(x)", "0W", 11, "W clamps to end (no whitespace)"), + + # Backward W (B) + ("foo.bar baz", "$B", 8, "B from end lands on baz"), + ("foo.bar baz", "$BB", 0, "second B lands on foo.bar"), + ("one,two three", "$B", 8, "B from end lands on three"), + ("one,two three", "$BB", 0, "second B lands on one,two"), + ("hello world", "$B", 8, "B from end lands on world"), + ("hello world", "$BB", 0, "second B lands on hello"), + + # Edge cases + (" spaces", "0w", 3, "w from BOL skips leading spaces"), + ("trailing ", "0w", 10, "w clamps at end after trailing spaces"), + ("a", "0w", 0, "w on single char stays in bounds"), + ("", "0w", 0, "w on empty buffer stays at 0"), + ("a b c", "0www", 4, "multiple w's work correctly"), ] for text, keys, expected_pos, desc in test_cases: From aa5b41a12090e1fb6102dacbef90ce8a1c31aab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 13:10:19 +0000 Subject: [PATCH 09/21] Implement basic find commands --- Lib/_pyrepl/reader.py | 32 +++++ Lib/_pyrepl/vi_commands.py | 186 ++++++++++++++++++++++++++++ Lib/test/test_pyrepl/test_reader.py | 84 +++++++++++++ 3 files changed, 302 insertions(+) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 0b6a9c5542f1da..a8502287f24240 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -163,6 +163,14 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"e", "end-of-word"), (r"^", "first-non-whitespace-character"), + # Find motions + (r"f", "vi-find-char"), + (r"F", "vi-find-char-back"), + (r"t", "vi-till-char"), + (r"T", "vi-till-char-back"), + (r";", "vi-repeat-find"), + (r",", "vi-repeat-find-opposite"), + # Edit commands (r"x", "delete"), (r"i", "vi-insert-mode"), @@ -295,6 +303,12 @@ class Reader: threading_hook: Callback | None = None use_vi_mode: bool = False vi_mode: ViMode = ViMode.INSERT + # Vi find state (for f/F/t/T and ;/,) + last_find_char: str | None = None + last_find_direction: str | None = None # "forward" or "backward" + last_find_inclusive: bool = True # f/F=True, t/T=False + pending_find_direction: str | None = None + pending_find_inclusive: bool = True ## cached metadata to speed up screen refreshes @dataclass @@ -685,6 +699,24 @@ def vi_bow_ws(self, p: int | None = None) -> int: return p + def find_char_forward(self, char: str, p: int | None = None) -> int | None: + """Find next occurrence of char after p. Returns index or None.""" + if p is None: + p = self.pos + for i in range(p + 1, len(self.buffer)): + if self.buffer[i] == char: + return i + return None + + def find_char_backward(self, char: str, p: int | None = None) -> int | None: + """Find previous occurrence of char before p. Returns index or None.""" + if p is None: + p = self.pos + for i in range(p - 1, -1, -1): + if self.buffer[i] == char: + return i + return None + def bol(self, p: int | None = None) -> int: """Return the 0-based index of the line break preceding p most immediately. diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 5d6fbc293a34e9..4820dd244afe65 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -3,6 +3,7 @@ """ from .commands import Command, MotionCommand, KillCommand +from . import input as _input # ============================================================================ @@ -138,3 +139,188 @@ class vi_delete_to_eol(KillCommand): def do(self) -> None: r = self.reader self.kill_range(r.pos, r.eol()) + + +# ============================================================================ +# Find Commands (f/F/t/T) +# ============================================================================ + +def _make_find_char_keymap() -> tuple[tuple[str, str], ...]: + """Create a keymap where all printable ASCII maps to vi-find-execute. + + Once vi-find-execute is called, it will pop our input translator.""" + entries = [] + for i in range(32, 127): + if i == 92: # backslash needs escaping + entries.append((r"\\", "vi-find-execute")) + else: + entries.append((chr(i), "vi-find-execute")) + entries.append((r"\", "vi-find-cancel")) + return tuple(entries) + + +_find_char_keymap = _make_find_char_keymap() + + +class vi_find_char(Command): + """Start forward find (f). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.pending_find_direction = "forward" + r.pending_find_inclusive = True + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_find_char_back(Command): + """Start backward find (F). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.pending_find_direction = "backward" + r.pending_find_inclusive = True + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_till_char(Command): + """Start forward till (t). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.pending_find_direction = "forward" + r.pending_find_inclusive = False + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_till_char_back(Command): + """Start backward till (T). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.pending_find_direction = "backward" + r.pending_find_inclusive = False + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_find_execute(MotionCommand): + """Execute the pending find with the pressed character.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() + + char = self.event[-1] + if not char: + return + + direction = r.pending_find_direction + inclusive = r.pending_find_inclusive + + # Store for repeat with ; and , + r.last_find_char = char + r.last_find_direction = direction + r.last_find_inclusive = inclusive + + r.pending_find_direction = None + self._execute_find(char, direction, inclusive) + + def _execute_find(self, char: str, direction: str | None, inclusive: bool) -> None: + r = self.reader + for _ in range(r.get_arg()): + if direction == "forward": + new_pos = r.find_char_forward(char) + if new_pos is not None: + if not inclusive: + new_pos -= 1 + if new_pos > r.pos: + r.pos = new_pos + else: + new_pos = r.find_char_backward(char) + if new_pos is not None: + if not inclusive: + new_pos += 1 + if new_pos < r.pos: + r.pos = new_pos + + +class vi_find_cancel(Command): + """Cancel pending find operation.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() + r.pending_find_direction = None + + +# ============================================================================ +# Repeat Find Commands (; and ,) +# ============================================================================ + +class vi_repeat_find(MotionCommand): + """Repeat last f/F/t/T in the same direction (;).""" + def do(self) -> None: + r = self.reader + if r.last_find_char is None: + return + + char = r.last_find_char + direction = r.last_find_direction + inclusive = r.last_find_inclusive + + for _ in range(r.get_arg()): + if direction == "forward": + new_pos = r.find_char_forward(char) + if new_pos is not None: + if not inclusive: + new_pos -= 1 + if new_pos > r.pos: + r.pos = new_pos + else: + new_pos = r.find_char_backward(char) + if new_pos is not None: + if not inclusive: + new_pos += 1 + if new_pos < r.pos: + r.pos = new_pos + + +class vi_repeat_find_opposite(MotionCommand): + """Repeat last f/F/t/T in opposite direction (,).""" + def do(self) -> None: + r = self.reader + if r.last_find_char is None: + return + + char = r.last_find_char + direction = "backward" if r.last_find_direction == "forward" else "forward" + inclusive = r.last_find_inclusive + + for _ in range(r.get_arg()): + if direction == "forward": + new_pos = r.find_char_forward(char) + if new_pos is not None: + if not inclusive: + new_pos -= 1 + if new_pos > r.pos: + r.pos = new_pos + else: + new_pos = r.find_char_backward(char) + if new_pos is not None: + if not inclusive: + new_pos += 1 + if new_pos < r.pos: + r.pos = new_pos diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 83d17f826e6604..5c6bf7fa86a207 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1058,6 +1058,90 @@ def test_vi_word_boundaries(self): self.assertEqual(reader.pos, expected_pos, f"Expected pos {expected_pos} but got {reader.pos} for '{text}' with keys '{keys}'") + def test_find_char_forward(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Find + Event(evt="key", data="o", raw=bytearray(b"o")), # Target char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 4) + self.assertEqual(reader.buffer[reader.pos], 'o') + + def test_find_char_backward(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="F", raw=bytearray(b"F")), # Find back + Event(evt="key", data="l", raw=bytearray(b"l")), # Target char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.buffer[reader.pos], 'l') + + def test_till_char_forward(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="t", raw=bytearray(b"t")), # Till + Event(evt="key", data="o", raw=bytearray(b"o")), # Target char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 3) + self.assertEqual(reader.buffer[reader.pos], 'l') + + def test_semicolon_repeat_find(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Find + Event(evt="key", data="o", raw=bytearray(b"o")), # First 'o' + Event(evt="key", data=";", raw=bytearray(b";")), # Repeat - second 'o' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 7) + self.assertEqual(reader.buffer[reader.pos], 'o') + + def test_comma_repeat_find_opposite(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Find forward + Event(evt="key", data="l", raw=bytearray(b"l")), # First 'l' at pos 2 + Event(evt="key", data=";", raw=bytearray(b";")), # Second 'l' at pos 3 + Event(evt="key", data=",", raw=bytearray(b",")), # Reverse - back to pos 2 + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 2) + self.assertEqual(reader.buffer[reader.pos], 'l') + + def test_find_char_escape_cancels(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Start find + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to cancel + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 0) + @force_not_colorized_test_class class TestHistoricalReaderBindings(TestCase): From e8afc9e90f79b77a480b1b51b370483e04ddeb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 13:29:39 +0000 Subject: [PATCH 10/21] Move vi find state into a class Let's not clutter the Reader with more vi state fields. --- Lib/_pyrepl/reader.py | 9 ++---- Lib/_pyrepl/types.py | 15 ++++++++++ Lib/_pyrepl/vi_commands.py | 57 ++++++++++++++++++++------------------ 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index a8502287f24240..72d5a17d944b33 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -43,7 +43,7 @@ SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3) -from .types import ViMode +from .types import ViMode, ViFindState def make_default_syntax_table() -> dict[str, int]: @@ -303,12 +303,7 @@ class Reader: threading_hook: Callback | None = None use_vi_mode: bool = False vi_mode: ViMode = ViMode.INSERT - # Vi find state (for f/F/t/T and ;/,) - last_find_char: str | None = None - last_find_direction: str | None = None # "forward" or "backward" - last_find_inclusive: bool = True # f/F=True, t/T=False - pending_find_direction: str | None = None - pending_find_inclusive: bool = True + vi_find: ViFindState = field(default_factory=ViFindState) ## cached metadata to speed up screen refreshes @dataclass diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py index 0a99f258e07b4c..47272eb39b3307 100644 --- a/Lib/_pyrepl/types.py +++ b/Lib/_pyrepl/types.py @@ -1,5 +1,6 @@ import enum from collections.abc import Callable, Iterator +from dataclasses import dataclass, field type Callback = Callable[[], object] type SimpleContextManager = Iterator[None] @@ -14,3 +15,17 @@ class ViMode(str, enum.Enum): INSERT = "insert" NORMAL = "normal" + + +class ViFindDirection(str, enum.Enum): + FORWARD = "forward" + BACKWARD = "backward" + + +@dataclass +class ViFindState: + last_char: str | None = None + last_direction: ViFindDirection | None = None + last_inclusive: bool = True # f/F=True, t/T=False + pending_direction: ViFindDirection | None = None + pending_inclusive: bool = True diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 4820dd244afe65..709452bb3fd3ea 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -4,6 +4,7 @@ from .commands import Command, MotionCommand, KillCommand from . import input as _input +from .types import ViFindDirection # ============================================================================ @@ -166,8 +167,8 @@ class vi_find_char(Command): """Start forward find (f). Waits for target character.""" def do(self) -> None: r = self.reader - r.pending_find_direction = "forward" - r.pending_find_inclusive = True + r.vi_find.pending_direction = ViFindDirection.FORWARD + r.vi_find.pending_inclusive = True trans = _input.KeymapTranslator( _find_char_keymap, invalid_cls="vi-find-cancel", @@ -180,8 +181,8 @@ class vi_find_char_back(Command): """Start backward find (F). Waits for target character.""" def do(self) -> None: r = self.reader - r.pending_find_direction = "backward" - r.pending_find_inclusive = True + r.vi_find.pending_direction = ViFindDirection.BACKWARD + r.vi_find.pending_inclusive = True trans = _input.KeymapTranslator( _find_char_keymap, invalid_cls="vi-find-cancel", @@ -194,8 +195,8 @@ class vi_till_char(Command): """Start forward till (t). Waits for target character.""" def do(self) -> None: r = self.reader - r.pending_find_direction = "forward" - r.pending_find_inclusive = False + r.vi_find.pending_direction = ViFindDirection.FORWARD + r.vi_find.pending_inclusive = False trans = _input.KeymapTranslator( _find_char_keymap, invalid_cls="vi-find-cancel", @@ -208,8 +209,8 @@ class vi_till_char_back(Command): """Start backward till (T). Waits for target character.""" def do(self) -> None: r = self.reader - r.pending_find_direction = "backward" - r.pending_find_inclusive = False + r.vi_find.pending_direction = ViFindDirection.BACKWARD + r.vi_find.pending_inclusive = False trans = _input.KeymapTranslator( _find_char_keymap, invalid_cls="vi-find-cancel", @@ -228,21 +229,21 @@ def do(self) -> None: if not char: return - direction = r.pending_find_direction - inclusive = r.pending_find_inclusive + direction = r.vi_find.pending_direction + inclusive = r.vi_find.pending_inclusive # Store for repeat with ; and , - r.last_find_char = char - r.last_find_direction = direction - r.last_find_inclusive = inclusive + r.vi_find.last_char = char + r.vi_find.last_direction = direction + r.vi_find.last_inclusive = inclusive - r.pending_find_direction = None + r.vi_find.pending_direction = None self._execute_find(char, direction, inclusive) - def _execute_find(self, char: str, direction: str | None, inclusive: bool) -> None: + def _execute_find(self, char: str, direction: ViFindDirection | None, inclusive: bool) -> None: r = self.reader for _ in range(r.get_arg()): - if direction == "forward": + if direction == ViFindDirection.FORWARD: new_pos = r.find_char_forward(char) if new_pos is not None: if not inclusive: @@ -263,7 +264,7 @@ class vi_find_cancel(Command): def do(self) -> None: r = self.reader r.pop_input_trans() - r.pending_find_direction = None + r.vi_find.pending_direction = None # ============================================================================ @@ -274,15 +275,15 @@ class vi_repeat_find(MotionCommand): """Repeat last f/F/t/T in the same direction (;).""" def do(self) -> None: r = self.reader - if r.last_find_char is None: + if r.vi_find.last_char is None: return - char = r.last_find_char - direction = r.last_find_direction - inclusive = r.last_find_inclusive + char = r.vi_find.last_char + direction = r.vi_find.last_direction + inclusive = r.vi_find.last_inclusive for _ in range(r.get_arg()): - if direction == "forward": + if direction == ViFindDirection.FORWARD: new_pos = r.find_char_forward(char) if new_pos is not None: if not inclusive: @@ -302,15 +303,17 @@ class vi_repeat_find_opposite(MotionCommand): """Repeat last f/F/t/T in opposite direction (,).""" def do(self) -> None: r = self.reader - if r.last_find_char is None: + if r.vi_find.last_char is None: return - char = r.last_find_char - direction = "backward" if r.last_find_direction == "forward" else "forward" - inclusive = r.last_find_inclusive + char = r.vi_find.last_char + direction = (ViFindDirection.BACKWARD + if r.vi_find.last_direction == ViFindDirection.FORWARD + else ViFindDirection.FORWARD) + inclusive = r.vi_find.last_inclusive for _ in range(r.get_arg()): - if direction == "forward": + if direction == ViFindDirection.FORWARD: new_pos = r.find_char_forward(char) if new_pos is not None: if not inclusive: From f8634250575d9408c5528171c39f9318d3f6ff19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 14:12:32 +0000 Subject: [PATCH 11/21] Implement 'cw' and 'r' commands --- Lib/_pyrepl/reader.py | 6 +++ Lib/_pyrepl/vi_commands.py | 64 +++++++++++++++++++++++++---- Lib/test/test_pyrepl/test_reader.py | 31 ++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 72d5a17d944b33..06260e0453b867 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -186,6 +186,12 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"d0", "vi-delete-to-bol"), (r"d$", "vi-delete-to-eol"), + # Change commands + (r"cw", "vi-change-word"), + + # Replace commands + (r"r", "vi-replace-char"), + # Special keys still work in normal mode (r"\", "left"), (r"\", "right"), diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 709452bb3fd3ea..5b6ff21e8cf32a 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -146,21 +146,21 @@ def do(self) -> None: # Find Commands (f/F/t/T) # ============================================================================ -def _make_find_char_keymap() -> tuple[tuple[str, str], ...]: - """Create a keymap where all printable ASCII maps to vi-find-execute. +def _make_char_capture_keymap(execute_cmd: str, cancel_cmd: str) -> tuple[tuple[str, str], ...]: + """Create a keymap where all printable ASCII maps to execute_cmd. - Once vi-find-execute is called, it will pop our input translator.""" + Once execute_cmd is called, it will pop our input translator.""" entries = [] for i in range(32, 127): if i == 92: # backslash needs escaping - entries.append((r"\\", "vi-find-execute")) + entries.append((r"\\", execute_cmd)) else: - entries.append((chr(i), "vi-find-execute")) - entries.append((r"\", "vi-find-cancel")) + entries.append((chr(i), execute_cmd)) + entries.append((r"\", cancel_cmd)) return tuple(entries) - -_find_char_keymap = _make_find_char_keymap() +_find_char_keymap = _make_char_capture_keymap("vi-find-execute", "vi-find-cancel") +_replace_char_keymap = _make_char_capture_keymap("vi-replace-execute", "vi-replace-cancel") class vi_find_char(Command): @@ -327,3 +327,51 @@ def do(self) -> None: new_pos += 1 if new_pos < r.pos: r.pos = new_pos + + +# ============================================================================ +# Change Commands +# ============================================================================ + +class vi_change_word(KillCommand): + """Change from cursor to end of word (cw).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + end = r.vi_eow() + 1 # +1 to include last char + if end > r.pos: + self.kill_range(r.pos, end) + r.enter_insert_mode() + + +# ============================================================================ +# Replace Commands +# ============================================================================ + +class vi_replace_char(Command): + """Replace character under cursor with next typed character (r).""" + def do(self) -> None: + r = self.reader + translator = _input.KeymapTranslator( + _replace_char_keymap, + invalid_cls="vi-replace-cancel", + character_cls="vi-replace-execute" + ) + r.push_input_trans(translator) + +class vi_replace_execute(Command): + """Execute character replacement with the pressed character.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() + pending_char = self.event[-1] + if not pending_char or r.pos >= len(r.buffer): + return + r.buffer[r.pos] = pending_char + r.dirty = True + +class vi_replace_cancel(Command): + """Cancel pending replace operation.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 5c6bf7fa86a207..816266e8219433 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1142,6 +1142,37 @@ def test_find_char_escape_cancels(self): reader, _ = self._run_vi(events) self.assertEqual(reader.pos, 0) + def test_change_word(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="c", raw=bytearray(b"c")), # change + Event(evt="key", data="w", raw=bytearray(b"w")), # word + ], + code_to_events("hi"), # replacement text + ) + reader, _ = self._run_vi(events) + self.assertEqual("".join(reader.buffer), "hi world") + + def test_replace_char(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="l", raw=bytearray(b"l")), # move right to 'e' + Event(evt="key", data="l", raw=bytearray(b"l")), # move right to first 'l' + Event(evt="key", data="r", raw=bytearray(b"r")), # replace + Event(evt="key", data="X", raw=bytearray(b"X")), # replacement char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual("".join(reader.buffer), "heXlo") + self.assertEqual(reader.pos, 2) # cursor stays on replaced char + self.assertEqual(reader.vi_mode, ViMode.NORMAL) # stays in normal mode + @force_not_colorized_test_class class TestHistoricalReaderBindings(TestCase): From fa1a5781dca061441a2f15b4c7ba5675e139161b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 16:12:05 +0000 Subject: [PATCH 12/21] Implement undo --- Lib/_pyrepl/commands.py | 6 +++ Lib/_pyrepl/reader.py | 23 ++++++++- Lib/_pyrepl/types.py | 5 ++ Lib/_pyrepl/vi_commands.py | 20 ++++++++ Lib/test/test_pyrepl/test_reader.py | 79 +++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 13342c8cea6414..a1a663c8aa2a0a 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -61,6 +61,8 @@ def do(self) -> None: class KillCommand(Command): + modifies_buffer = True + def kill_range(self, start: int, end: int) -> None: if start == end: return @@ -422,6 +424,8 @@ def do(self) -> None: class backspace(EditCommand): + modifies_buffer = True + def do(self) -> None: r = self.reader b = r.buffer @@ -435,6 +439,8 @@ def do(self) -> None: class delete(EditCommand): + modifies_buffer = True + def do(self) -> None: r = self.reader b = r.buffer diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 06260e0453b867..2598acd0411d93 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -36,11 +36,12 @@ # types Command = commands.Command -from .types import Callback, SimpleContextManager, KeySpec, CommandName +from .types import Callback, SimpleContextManager, KeySpec, CommandName, ViUndoState # syntax classes SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3) +MAX_VI_UNDO_STACK_SIZE = 100 from .types import ViMode, ViFindState @@ -192,6 +193,9 @@ def make_default_commands() -> dict[CommandName, type[Command]]: # Replace commands (r"r", "vi-replace-char"), + # Undo commands + (r"u", "vi-undo"), + # Special keys still work in normal mode (r"\", "left"), (r"\", "right"), @@ -310,6 +314,7 @@ class Reader: use_vi_mode: bool = False vi_mode: ViMode = ViMode.INSERT vi_find: ViFindState = field(default_factory=ViFindState) + undo_stack: list[ViUndoState] = field(default_factory=list) ## cached metadata to speed up screen refreshes @dataclass @@ -894,6 +899,7 @@ def prepare(self) -> None: self.last_command = None if self.use_vi_mode: self.enter_insert_mode() + self.undo_stack.clear() self.calc_screen() except BaseException: self.restore() @@ -958,6 +964,13 @@ def do_cmd(self, cmd: tuple[str, list[str]]) -> None: return # nothing to do command = command_type(self, *cmd) # type: ignore[arg-type] + + # Save undo state in vi mode if the command modifies the buffer + if self.use_vi_mode and getattr(command_type, 'modifies_buffer', False): + self.undo_stack.append(ViUndoState( + buffer_snapshot=self.buffer.copy(), + pos_snapshot=self.pos, + )) command.do() self.after_command(command) @@ -1072,6 +1085,14 @@ def enter_insert_mode(self) -> None: self.vi_mode = ViMode.INSERT + if len(self.undo_stack) > MAX_VI_UNDO_STACK_SIZE: + self.undo_stack.pop(0) + + self.undo_stack.append(ViUndoState( + buffer_snapshot=self.buffer.copy(), + pos_snapshot=self.pos, + )) + # Switch translator to insert mode keymap self.keymap = self.collect_keymap() self.input_trans = input.KeymapTranslator( diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py index 47272eb39b3307..777c63347166b1 100644 --- a/Lib/_pyrepl/types.py +++ b/Lib/_pyrepl/types.py @@ -29,3 +29,8 @@ class ViFindState: last_inclusive: bool = True # f/F=True, t/T=False pending_direction: ViFindDirection | None = None pending_inclusive: bool = True + +@dataclass +class ViUndoState: + buffer_snapshot: CharBuffer = field(default_factory=list) + pos_snapshot: int = 0 diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 5b6ff21e8cf32a..b2ce45644c839a 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -5,6 +5,7 @@ from .commands import Command, MotionCommand, KillCommand from . import input as _input from .types import ViFindDirection +from .trace import trace # ============================================================================ @@ -361,6 +362,8 @@ def do(self) -> None: class vi_replace_execute(Command): """Execute character replacement with the pressed character.""" + modifies_buffer = True + def do(self) -> None: r = self.reader r.pop_input_trans() @@ -375,3 +378,20 @@ class vi_replace_cancel(Command): def do(self) -> None: r = self.reader r.pop_input_trans() + + +# ============================================================================ +# Undo Commands +# ============================================================================ + +class vi_undo(Command): + """Undo last change (u).""" + def do(self) -> None: + r = self.reader + trace("vi_undo: undo_stack size =", len(r.undo_stack)) + if not r.undo_stack: + return + state = r.undo_stack.pop() + r.buffer[:] = state.buffer_snapshot + r.pos = state.pos_snapshot + r.dirty = True diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 816266e8219433..41aeb57d305f12 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1173,6 +1173,85 @@ def test_replace_char(self): self.assertEqual(reader.pos, 2) # cursor stays on replaced char self.assertEqual(reader.vi_mode, ViMode.NORMAL) # stays in normal mode + def test_undo_after_insert(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "") + self.assertEqual(reader.vi_mode, ViMode.NORMAL) + + def test_undo_after_delete_word(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="d", raw=bytearray(b"d")), # delete + Event(evt="key", data="w", raw=bytearray(b"w")), # word + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello world") + + def test_undo_after_x_delete(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="x", raw=bytearray(b"x")), # delete char + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello") + + def test_undo_stack_cleared_on_prepare(self): + events = itertools.chain( + code_to_events("hello"), + [Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))], + ) + reader, console = self._run_vi(events) + self.assertGreater(len(reader.undo_stack), 0) + reader.prepare() + self.assertEqual(len(reader.undo_stack), 0) + + def test_multiple_undo(self): + events = itertools.chain( + code_to_events("a"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="a", raw=bytearray(b"a")), # append + ], + code_to_events("b"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="u", raw=bytearray(b"u")), # undo 'b' + Event(evt="key", data="u", raw=bytearray(b"u")), # undo 'a' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "") + + def test_undo_after_replace(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="r", raw=bytearray(b"r")), # replace + Event(evt="key", data="X", raw=bytearray(b"X")), # replacement + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello") + @force_not_colorized_test_class class TestHistoricalReaderBindings(TestCase): From 0a8a1e366d5f93a5f5cbeb1c298e68501afa4a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 16:30:43 +0000 Subject: [PATCH 13/21] Implement D, X, C and s commands --- Lib/_pyrepl/reader.py | 4 +++ Lib/_pyrepl/vi_commands.py | 29 +++++++++++++++- Lib/test/test_pyrepl/test_reader.py | 54 +++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 2598acd0411d93..80cc4ae55fe4d6 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -186,9 +186,13 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"dd", "vi-delete-line"), (r"d0", "vi-delete-to-bol"), (r"d$", "vi-delete-to-eol"), + (r"D", "vi-delete-to-eol"), + (r"X", "vi-delete-char-before"), # Change commands (r"cw", "vi-change-word"), + (r"C", "vi-change-to-eol"), + (r"s", "vi-substitute-char"), # Replace commands (r"r", "vi-replace-char"), diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index b2ce45644c839a..90e018b2b732e9 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -137,12 +137,21 @@ def do(self) -> None: class vi_delete_to_eol(KillCommand): - """Delete from cursor to end of line (d$).""" + """Delete from cursor to end of line (d$ or D).""" def do(self) -> None: r = self.reader self.kill_range(r.pos, r.eol()) +class vi_delete_char_before(KillCommand): + """Delete character before cursor (X).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + if r.pos > 0: + self.kill_range(r.pos - 1, r.pos) + + # ============================================================================ # Find Commands (f/F/t/T) # ============================================================================ @@ -345,6 +354,24 @@ def do(self) -> None: r.enter_insert_mode() +class vi_change_to_eol(KillCommand): + """Change from cursor to end of line (C).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.pos, r.eol()) + r.enter_insert_mode() + + +class vi_substitute_char(KillCommand): + """Delete character under cursor and enter insert mode (s).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + if r.pos < len(r.buffer): + self.kill_range(r.pos, r.pos + 1) + r.enter_insert_mode() + + # ============================================================================ # Replace Commands # ============================================================================ diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 41aeb57d305f12..fa96ce1c29c7a5 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1252,6 +1252,60 @@ def test_undo_after_replace(self): reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "hello") + def test_D_delete_to_eol(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="w", raw=bytearray(b"w")), # forward word + Event(evt="key", data="D", raw=bytearray(b"D")), # delete to EOL + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello ") + + def test_C_change_to_eol(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="w", raw=bytearray(b"w")), # forward word + Event(evt="key", data="C", raw=bytearray(b"C")), # change to EOL + ], + code_to_events("there"), + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello there") + self.assertEqual(reader.vi_mode, ViMode.INSERT) + + def test_s_substitute_char(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="s", raw=bytearray(b"s")), # substitute + ], + code_to_events("j"), + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "jello") + self.assertEqual(reader.vi_mode, ViMode.INSERT) + + def test_X_delete_char_before(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="X", raw=bytearray(b"X")), # delete before + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "helo") + self.assertEqual(reader.vi_mode, ViMode.NORMAL) + @force_not_colorized_test_class class TestHistoricalReaderBindings(TestCase): From d3e358da5cb524035066adeab21ecf9b9513aa20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 16:37:11 +0000 Subject: [PATCH 14/21] Add tests for multiline editing --- Lib/test/test_pyrepl/test_reader.py | 96 +++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index fa96ce1c29c7a5..0b466ac6395f74 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -1306,6 +1306,102 @@ def test_X_delete_char_before(self): self.assertEqual(reader.get_unicode(), "helo") self.assertEqual(reader.vi_mode, ViMode.NORMAL) + def test_dd_deletes_current_line(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("second"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("third"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up to second + Event(evt="key", data="d", raw=bytearray(b"d")), # dd + Event(evt="key", data="d", raw=bytearray(b"d")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\n\nthird") + + def test_j_k_navigation_between_lines(self): + events = itertools.chain( + code_to_events("short"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("longer line"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up + Event(evt="key", data="$", raw=bytearray(b"$")), # end of line + Event(evt="key", data="j", raw=bytearray(b"j")), # down + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "short\nlonger line") + # Cursor should be somewhere on second line + self.assertGreater(reader.pos, 6) # past the newline + + def test_dollar_stops_at_line_end(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("second"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up to first + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="$", raw=bytearray(b"$")), # EOL + ], + ) + reader, _ = self._run_vi(events) + # $ should stop at end of "first", not go to newline or beyond + self.assertEqual(reader.pos, 4) # 'first'[4] = 't' + + def test_zero_goes_to_line_start(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("second"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL of second + ], + ) + reader, _ = self._run_vi(events) + # 0 should go to start of "second" line (position 6) + self.assertEqual(reader.pos, 6) + + def test_o_opens_line_below(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("third"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up to first + Event(evt="key", data="o", raw=bytearray(b"o")), # open below + ], + code_to_events("second"), + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\nsecond\nthird") + self.assertEqual(reader.vi_mode, ViMode.INSERT) + + def test_w_motion_crosses_lines(self): + events = itertools.chain( + code_to_events("end"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("start"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up + Event(evt="key", data="$", raw=bytearray(b"$")), # end of "end" + Event(evt="key", data="w", raw=bytearray(b"w")), # word forward + ], + ) + reader, _ = self._run_vi(events) + # w from end of first line should go to start of "start" + self.assertEqual(reader.pos, 4) # position of 's' in "start" + @force_not_colorized_test_class class TestHistoricalReaderBindings(TestCase): From aeb8345582db35647db19c07c86c16437bf99f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 18:26:57 +0000 Subject: [PATCH 15/21] Avoid polluting base classes with vi undo logic --- Lib/_pyrepl/commands.py | 6 ------ Lib/_pyrepl/reader.py | 6 +++--- Lib/_pyrepl/vi_commands.py | 28 +++++++++++++++++++--------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index a1a663c8aa2a0a..13342c8cea6414 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -61,8 +61,6 @@ def do(self) -> None: class KillCommand(Command): - modifies_buffer = True - def kill_range(self, start: int, end: int) -> None: if start == end: return @@ -424,8 +422,6 @@ def do(self) -> None: class backspace(EditCommand): - modifies_buffer = True - def do(self) -> None: r = self.reader b = r.buffer @@ -439,8 +435,6 @@ def do(self) -> None: class delete(EditCommand): - modifies_buffer = True - def do(self) -> None: r = self.reader b = r.buffer diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 80cc4ae55fe4d6..ac7b529b2004a2 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -173,7 +173,7 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r",", "vi-repeat-find-opposite"), # Edit commands - (r"x", "delete"), + (r"x", "vi-delete"), (r"i", "vi-insert-mode"), (r"a", "vi-append-mode"), (r"A", "vi-append-eol"), @@ -207,12 +207,12 @@ def make_default_commands() -> dict[CommandName, type[Command]]: (r"\", "down"), (r"\", "beginning-of-line"), (r"\", "end-of-line"), - (r"\", "delete"), + (r"\", "vi-delete"), (r"\", "left"), # Control keys (important ones that work in both modes) (r"\C-c", "interrupt"), - (r"\C-d", "delete"), + (r"\C-d", "vi-delete"), (r"\C-l", "clear-screen"), (r"\C-r", "reverse-history-isearch"), diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 90e018b2b732e9..5f8edd02867f16 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -2,12 +2,17 @@ Vi-specific commands for pyrepl. """ -from .commands import Command, MotionCommand, KillCommand +from .commands import Command, MotionCommand, KillCommand, delete from . import input as _input from .types import ViFindDirection from .trace import trace +class ViKillCommand(KillCommand): + """Base class for Vi kill commands that modify the buffer.""" + modifies_buffer = True + + # ============================================================================ # Vi-specific Motion Commands # ============================================================================ @@ -112,7 +117,12 @@ def do(self) -> None: # Delete Commands # ============================================================================ -class vi_delete_word(KillCommand): +class vi_delete(delete): + """Delete character under cursor (x).""" + modifies_buffer = True + + +class vi_delete_word(ViKillCommand): """Delete from cursor to start of next word (dw).""" def do(self) -> None: r = self.reader @@ -122,28 +132,28 @@ def do(self) -> None: self.kill_range(r.pos, end) -class vi_delete_line(KillCommand): +class vi_delete_line(ViKillCommand): """Delete entire line content (dd).""" def do(self) -> None: r = self.reader self.kill_range(r.bol(), r.eol()) -class vi_delete_to_bol(KillCommand): +class vi_delete_to_bol(ViKillCommand): """Delete from cursor to beginning of line (d0).""" def do(self) -> None: r = self.reader self.kill_range(r.bol(), r.pos) -class vi_delete_to_eol(KillCommand): +class vi_delete_to_eol(ViKillCommand): """Delete from cursor to end of line (d$ or D).""" def do(self) -> None: r = self.reader self.kill_range(r.pos, r.eol()) -class vi_delete_char_before(KillCommand): +class vi_delete_char_before(ViKillCommand): """Delete character before cursor (X).""" def do(self) -> None: r = self.reader @@ -343,7 +353,7 @@ def do(self) -> None: # Change Commands # ============================================================================ -class vi_change_word(KillCommand): +class vi_change_word(ViKillCommand): """Change from cursor to end of word (cw).""" def do(self) -> None: r = self.reader @@ -354,7 +364,7 @@ def do(self) -> None: r.enter_insert_mode() -class vi_change_to_eol(KillCommand): +class vi_change_to_eol(ViKillCommand): """Change from cursor to end of line (C).""" def do(self) -> None: r = self.reader @@ -362,7 +372,7 @@ def do(self) -> None: r.enter_insert_mode() -class vi_substitute_char(KillCommand): +class vi_substitute_char(ViKillCommand): """Delete character under cursor and enter insert mode (s).""" def do(self) -> None: r = self.reader From f40ecaf33969f7e6c264775c5f000e4a5974d291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 18:50:55 +0000 Subject: [PATCH 16/21] Reorder imports --- Lib/_pyrepl/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index ac7b529b2004a2..697c00308fd631 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -29,9 +29,9 @@ from dataclasses import dataclass, field, fields from . import commands, console, input +from . import vi_commands from .utils import wlen, unbracket, disp_str, gen_colors, THEME from .trace import trace -from . import vi_commands # types From 86defc0fdba7746a9f3486a9205677eeecba32d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sat, 22 Nov 2025 19:05:34 +0000 Subject: [PATCH 17/21] Simplify ViMode enum --- Lib/_pyrepl/commands.py | 6 ++--- Lib/_pyrepl/reader.py | 16 +++++------ Lib/_pyrepl/types.py | 5 ++-- Lib/_pyrepl/unix_console.py | 1 - Lib/test/test_pyrepl/test_reader.py | 42 ++++++++++++++--------------- 5 files changed, 34 insertions(+), 36 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 13342c8cea6414..b1e7d1ed1d2325 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -23,7 +23,7 @@ import os import time -from .types import ViMode +from .types import VI_MODE_NORMAL # Categories of actions: # killing @@ -328,7 +328,7 @@ def do(self) -> None: for _ in range(r.get_arg()): p = r.pos + 1 # In vi normal mode, don't move past the last character - if r.vi_mode == ViMode.NORMAL: + if r.vi_mode == VI_MODE_NORMAL: eol_pos = r.eol() max_pos = max(r.bol(), eol_pos - 1) if eol_pos > r.bol() else r.bol() if p <= max_pos: @@ -351,7 +351,7 @@ class end_of_line(MotionCommand): def do(self) -> None: r = self.reader eol_pos = r.eol() - if r.vi_mode == ViMode.NORMAL: + if r.vi_mode == VI_MODE_NORMAL: bol_pos = r.bol() # Don't go past the last character (but stay at bol if line is empty) r.pos = max(bol_pos, eol_pos - 1) if eol_pos > bol_pos else bol_pos diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 697c00308fd631..7474740d4af685 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -44,7 +44,7 @@ MAX_VI_UNDO_STACK_SIZE = 100 -from .types import ViMode, ViFindState +from .types import VI_MODE_INSERT, VI_MODE_NORMAL, ViFindState def make_default_syntax_table() -> dict[str, int]: @@ -316,7 +316,7 @@ class Reader: can_colorize: bool = False threading_hook: Callback | None = None use_vi_mode: bool = False - vi_mode: ViMode = ViMode.INSERT + vi_mode: int = VI_MODE_INSERT vi_find: ViFindState = field(default_factory=ViFindState) undo_stack: list[ViUndoState] = field(default_factory=list) @@ -387,9 +387,9 @@ def __post_init__(self) -> None: def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]: if self.use_vi_mode: - if self.vi_mode == ViMode.INSERT: + if self.vi_mode == VI_MODE_INSERT: return vi_insert_keymap - elif self.vi_mode == ViMode.NORMAL: + elif self.vi_mode == VI_MODE_NORMAL: return vi_normal_keymap return default_keymap @@ -1084,10 +1084,10 @@ def get_unicode(self) -> str: return "".join(self.buffer) def enter_insert_mode(self) -> None: - if self.vi_mode == ViMode.INSERT: + if self.vi_mode == VI_MODE_INSERT: return - self.vi_mode = ViMode.INSERT + self.vi_mode = VI_MODE_INSERT if len(self.undo_stack) > MAX_VI_UNDO_STACK_SIZE: self.undo_stack.pop(0) @@ -1106,10 +1106,10 @@ def enter_insert_mode(self) -> None: self.dirty = True def enter_normal_mode(self) -> None: - if self.vi_mode == ViMode.NORMAL: + if self.vi_mode == VI_MODE_NORMAL: return - self.vi_mode = ViMode.NORMAL + self.vi_mode = VI_MODE_NORMAL # Switch translator to normal mode keymap self.keymap = self.collect_keymap() diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py index 777c63347166b1..a8dc7704503d99 100644 --- a/Lib/_pyrepl/types.py +++ b/Lib/_pyrepl/types.py @@ -12,9 +12,8 @@ type CharWidths = list[int] -class ViMode(str, enum.Enum): - INSERT = "insert" - NORMAL = "normal" +VI_MODE_INSERT = 0 +VI_MODE_NORMAL = 1 class ViFindDirection(str, enum.Enum): diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index ce60366d95cde4..9f69f65d40fd65 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -458,7 +458,6 @@ def wait(self, timeout: float | None = None) -> bool: or bool(self.pollob.poll(timeout)) ) - def set_cursor_vis(self, visible): """ Set the visibility of the cursor. diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 0b466ac6395f74..8b1bb952ccc043 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -11,7 +11,7 @@ from .support import prepare_reader, prepare_console, prepare_vi_reader from _pyrepl.console import Event from _pyrepl.reader import Reader -from _pyrepl.types import ViMode +from _pyrepl.types import VI_MODE_INSERT, VI_MODE_NORMAL from _colorize import default_theme overrides = {"reset": "z", "soft_keyword": "K"} @@ -565,7 +565,7 @@ def test_vi_escape_switches_to_normal_mode(self): prepare_reader=prepare_vi_reader, ) self.assertEqual(reader.get_unicode(), "hello") - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) self.assertEqual(reader.pos, len("hello") - 2) # After 'h' left movement def test_control_characters(self): @@ -593,7 +593,7 @@ def test_insert_typing_and_ctrl_a_e(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "Xhello!") - self.assertTrue(reader.vi_mode == ViMode.INSERT) + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) def test_escape_switches_to_normal_mode_and_is_idempotent(self): events = itertools.chain( @@ -606,7 +606,7 @@ def test_escape_switches_to_normal_mode_and_is_idempotent(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "hello") - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) self.assertEqual(reader.pos, len("hello") - 2) # After 'h' left movement def test_normal_mode_motion_and_edit_commands(self): @@ -624,7 +624,7 @@ def test_normal_mode_motion_and_edit_commands(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "hl!lo") - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) def test_open_below_and_above(self): events = itertools.chain( @@ -652,9 +652,9 @@ def test_mode_resets_to_insert_on_prepare(self): ], ) reader, console = self._run_vi(events) - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) reader.prepare() - self.assertTrue(reader.vi_mode == ViMode.INSERT) + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) console.prepare.assert_called() # ensure console prepare called again def test_translator_stack_preserves_mode(self): @@ -667,7 +667,7 @@ def test_translator_stack_preserves_mode(self): ], ) reader, _ = self._run_vi(events_insert_path) - self.assertTrue(reader.vi_mode == ViMode.INSERT) + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) events_normal_path = itertools.chain( code_to_events("hello"), @@ -678,7 +678,7 @@ def test_translator_stack_preserves_mode(self): ], ) reader, _ = self._run_vi(events_normal_path) - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) def test_insert_bol_and_append_eol(self): events = itertools.chain( @@ -695,7 +695,7 @@ def test_insert_bol_and_append_eol(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "[hello]") - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) def test_insert_mode_from_normal(self): events = itertools.chain( @@ -711,7 +711,7 @@ def test_insert_mode_from_normal(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "heXllo") - self.assertTrue(reader.vi_mode == ViMode.INSERT) + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) def test_hjkl_motions(self): events = itertools.chain( @@ -727,7 +727,7 @@ def test_hjkl_motions(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "hllo") - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) def test_dollar_end_of_line(self): events = itertools.chain( @@ -770,7 +770,7 @@ def test_repeat_counts(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "abcfghij") - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) def test_multiline_navigation(self): # Test j/k navigation across multiple lines @@ -813,7 +813,7 @@ def test_escape_in_normal_mode_is_noop(self): ], ) reader, _ = self._run_vi(events) - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) self.assertEqual(reader.get_unicode(), "hello") def test_backspace_in_normal_mode(self): @@ -826,7 +826,7 @@ def test_backspace_in_normal_mode(self): ], ) reader, _ = self._run_vi(events) - self.assertTrue(reader.vi_mode == ViMode.NORMAL) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) self.assertIsNotNone(reader.get_unicode()) def test_end_of_word_motion(self): @@ -1171,7 +1171,7 @@ def test_replace_char(self): reader, _ = self._run_vi(events) self.assertEqual("".join(reader.buffer), "heXlo") self.assertEqual(reader.pos, 2) # cursor stays on replaced char - self.assertEqual(reader.vi_mode, ViMode.NORMAL) # stays in normal mode + self.assertEqual(reader.vi_mode, VI_MODE_NORMAL) # stays in normal mode def test_undo_after_insert(self): events = itertools.chain( @@ -1183,7 +1183,7 @@ def test_undo_after_insert(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "") - self.assertEqual(reader.vi_mode, ViMode.NORMAL) + self.assertEqual(reader.vi_mode, VI_MODE_NORMAL) def test_undo_after_delete_word(self): events = itertools.chain( @@ -1278,7 +1278,7 @@ def test_C_change_to_eol(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "hello there") - self.assertEqual(reader.vi_mode, ViMode.INSERT) + self.assertEqual(reader.vi_mode, VI_MODE_INSERT) def test_s_substitute_char(self): events = itertools.chain( @@ -1292,7 +1292,7 @@ def test_s_substitute_char(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "jello") - self.assertEqual(reader.vi_mode, ViMode.INSERT) + self.assertEqual(reader.vi_mode, VI_MODE_INSERT) def test_X_delete_char_before(self): events = itertools.chain( @@ -1304,7 +1304,7 @@ def test_X_delete_char_before(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "helo") - self.assertEqual(reader.vi_mode, ViMode.NORMAL) + self.assertEqual(reader.vi_mode, VI_MODE_NORMAL) def test_dd_deletes_current_line(self): events = itertools.chain( @@ -1384,7 +1384,7 @@ def test_o_opens_line_below(self): ) reader, _ = self._run_vi(events) self.assertEqual(reader.get_unicode(), "first\nsecond\nthird") - self.assertEqual(reader.vi_mode, ViMode.INSERT) + self.assertEqual(reader.vi_mode, VI_MODE_INSERT) def test_w_motion_crosses_lines(self): events = itertools.chain( From 12197cbff2a305dc4961e5dad118167d20a0f686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Thu, 27 Nov 2025 21:10:22 +0000 Subject: [PATCH 18/21] Move most of the vi-specific logic out of Reader --- Lib/_pyrepl/reader.py | 188 ------------------------------------ Lib/_pyrepl/vi_commands.py | 35 ++++--- Lib/_pyrepl/vi_motions.py | 189 +++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 201 deletions(-) create mode 100644 Lib/_pyrepl/vi_motions.py diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 7474740d4af685..1f2f678140c00c 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -58,10 +58,6 @@ def make_default_syntax_table() -> dict[str, int]: return st -def _is_vi_word_char(c: str) -> bool: - return c.isalnum() or c == '_' - - def make_default_commands() -> dict[CommandName, type[Command]]: result: dict[CommandName, type[Command]] = {} all_commands = itertools.chain(vars(commands).values(), vars(vi_commands).values()) @@ -543,190 +539,6 @@ def eow(self, p: int | None = None) -> int: p += 1 return p - def vi_eow(self, p: int | None = None) -> int: - """Return the 0-based index of the last character of the word - following p most immediately (vi 'e' semantics). - - Vi has three character classes: word chars (alnum + _), punctuation - (non-word, non-whitespace), and whitespace. 'e' moves to the end - of the current or next word/punctuation sequence.""" - if p is None: - p = self.pos - b = self.buffer - - if not b: - return 0 - - # Helper to check if at end of current sequence - def at_sequence_end(pos: int) -> bool: - if pos >= len(b) - 1: - return True - curr_is_word = _is_vi_word_char(b[pos]) - next_is_word = _is_vi_word_char(b[pos + 1]) - curr_is_space = b[pos].isspace() - next_is_space = b[pos + 1].isspace() - if curr_is_word: - return not next_is_word - elif not curr_is_space: - # Punctuation - at end if next is word or whitespace - return next_is_word or next_is_space - return True - - # If already at end of a word/punctuation, move forward - if p < len(b) and at_sequence_end(p): - p += 1 - - # Skip whitespace - while p < len(b) and b[p].isspace(): - p += 1 - - if p >= len(b): - return len(b) - 1 - - # Move to end of current word or punctuation sequence - if _is_vi_word_char(b[p]): - while p + 1 < len(b) and _is_vi_word_char(b[p + 1]): - p += 1 - else: - # Punctuation sequence - while p + 1 < len(b) and not _is_vi_word_char(b[p + 1]) and not b[p + 1].isspace(): - p += 1 - - return min(p, len(b) - 1) - - def vi_forward_word(self, p: int | None = None) -> int: - """Return the 0-based index of the first character of the next word - (vi 'w' semantics). - - Vi has three character classes: word chars (alnum + _), punctuation - (non-word, non-whitespace), and whitespace. 'w' moves to the start - of the next word or punctuation sequence.""" - if p is None: - p = self.pos - b = self.buffer - - if not b or p >= len(b): - return max(0, len(b) - 1) if b else 0 - - # Skip current word or punctuation sequence - if _is_vi_word_char(b[p]): - # On a word char - skip word chars - while p < len(b) and _is_vi_word_char(b[p]): - p += 1 - elif not b[p].isspace(): - # On punctuation - skip punctuation - while p < len(b) and not _is_vi_word_char(b[p]) and not b[p].isspace(): - p += 1 - - # Skip whitespace to find next word or punctuation - while p < len(b) and b[p].isspace(): - p += 1 - - # Clamp to valid buffer range - return min(p, len(b) - 1) if b else 0 - - def vi_forward_word_ws(self, p: int | None = None) -> int: - """Return the 0-based index of the first character of the next WORD - (vi 'W' semantics). - - Treats white space as the only separator.""" - if p is None: - p = self.pos - b = self.buffer - - if not b or p >= len(b): - return max(0, len(b) - 1) if b else 0 - - # Skip all non-whitespace (the current WORD) - while p < len(b) and not b[p].isspace(): - p += 1 - - # Skip whitespace to find next WORD - while p < len(b) and b[p].isspace(): - p += 1 - - # Clamp to valid buffer range - return min(p, len(b) - 1) if b else 0 - - def vi_bow(self, p: int | None = None) -> int: - """Return the 0-based index of the beginning of the word preceding p - (vi 'b' semantics). - - Vi has three character classes: word chars (alnum + _), punctuation - (non-word, non-whitespace), and whitespace. 'b' moves to the start - of the current or previous word/punctuation sequence.""" - if p is None: - p = self.pos - b = self.buffer - - if not b or p <= 0: - return 0 - - p -= 1 - - # Skip whitespace going backward - while p >= 0 and b[p].isspace(): - p -= 1 - - if p < 0: - return 0 - - # Now skip the word or punctuation sequence we landed in - if _is_vi_word_char(b[p]): - while p > 0 and _is_vi_word_char(b[p - 1]): - p -= 1 - else: - # Punctuation sequence - while p > 0 and not _is_vi_word_char(b[p - 1]) and not b[p - 1].isspace(): - p -= 1 - - return p - - def vi_bow_ws(self, p: int | None = None) -> int: - """Return the 0-based index of the beginning of the WORD preceding p - (vi 'B' semantics). - - Treats white space as the only separator.""" - if p is None: - p = self.pos - b = self.buffer - - if not b or p <= 0: - return 0 - - p -= 1 - - # Skip whitespace going backward - while p >= 0 and b[p].isspace(): - p -= 1 - - if p < 0: - return 0 - - # Now skip the WORD we landed in - while p > 0 and not b[p - 1].isspace(): - p -= 1 - - return p - - def find_char_forward(self, char: str, p: int | None = None) -> int | None: - """Find next occurrence of char after p. Returns index or None.""" - if p is None: - p = self.pos - for i in range(p + 1, len(self.buffer)): - if self.buffer[i] == char: - return i - return None - - def find_char_backward(self, char: str, p: int | None = None) -> int | None: - """Find previous occurrence of char before p. Returns index or None.""" - if p is None: - p = self.pos - for i in range(p - 1, -1, -1): - if self.buffer[i] == char: - return i - return None - def bol(self, p: int | None = None) -> int: """Return the 0-based index of the line break preceding p most immediately. diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py index 5f8edd02867f16..e3b187565e5b22 100644 --- a/Lib/_pyrepl/vi_commands.py +++ b/Lib/_pyrepl/vi_commands.py @@ -6,6 +6,15 @@ from . import input as _input from .types import ViFindDirection from .trace import trace +from .vi_motions import ( + vi_eow, + vi_forward_word as _vi_forward_word, + vi_forward_word_ws as _vi_forward_word_ws, + vi_bow, + vi_bow_ws, + find_char_forward, + find_char_backward, +) class ViKillCommand(KillCommand): @@ -26,33 +35,33 @@ class end_of_word(MotionCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - r.pos = r.vi_eow() + r.pos = vi_eow(r.buffer, r.pos) class vi_forward_word(MotionCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - r.pos = r.vi_forward_word() + r.pos = _vi_forward_word(r.buffer, r.pos) class vi_forward_word_ws(MotionCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - r.pos = r.vi_forward_word_ws() + r.pos = _vi_forward_word_ws(r.buffer, r.pos) class vi_backward_word(MotionCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - r.pos = r.vi_bow() + r.pos = vi_bow(r.buffer, r.pos) class vi_backward_word_ws(MotionCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - r.pos = r.vi_bow_ws() + r.pos = vi_bow_ws(r.buffer, r.pos) @@ -127,7 +136,7 @@ class vi_delete_word(ViKillCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - end = r.vi_forward_word() + end = _vi_forward_word(r.buffer, r.pos) if end > r.pos: self.kill_range(r.pos, end) @@ -264,14 +273,14 @@ def _execute_find(self, char: str, direction: ViFindDirection | None, inclusive: r = self.reader for _ in range(r.get_arg()): if direction == ViFindDirection.FORWARD: - new_pos = r.find_char_forward(char) + new_pos = find_char_forward(r.buffer, r.pos, char) if new_pos is not None: if not inclusive: new_pos -= 1 if new_pos > r.pos: r.pos = new_pos else: - new_pos = r.find_char_backward(char) + new_pos = find_char_backward(r.buffer, r.pos, char) if new_pos is not None: if not inclusive: new_pos += 1 @@ -304,14 +313,14 @@ def do(self) -> None: for _ in range(r.get_arg()): if direction == ViFindDirection.FORWARD: - new_pos = r.find_char_forward(char) + new_pos = find_char_forward(r.buffer, r.pos, char) if new_pos is not None: if not inclusive: new_pos -= 1 if new_pos > r.pos: r.pos = new_pos else: - new_pos = r.find_char_backward(char) + new_pos = find_char_backward(r.buffer, r.pos, char) if new_pos is not None: if not inclusive: new_pos += 1 @@ -334,14 +343,14 @@ def do(self) -> None: for _ in range(r.get_arg()): if direction == ViFindDirection.FORWARD: - new_pos = r.find_char_forward(char) + new_pos = find_char_forward(r.buffer, r.pos, char) if new_pos is not None: if not inclusive: new_pos -= 1 if new_pos > r.pos: r.pos = new_pos else: - new_pos = r.find_char_backward(char) + new_pos = find_char_backward(r.buffer, r.pos, char) if new_pos is not None: if not inclusive: new_pos += 1 @@ -358,7 +367,7 @@ class vi_change_word(ViKillCommand): def do(self) -> None: r = self.reader for _ in range(r.get_arg()): - end = r.vi_eow() + 1 # +1 to include last char + end = vi_eow(r.buffer, r.pos) + 1 # +1 to include last char if end > r.pos: self.kill_range(r.pos, end) r.enter_insert_mode() diff --git a/Lib/_pyrepl/vi_motions.py b/Lib/_pyrepl/vi_motions.py new file mode 100644 index 00000000000000..bd0a1b929af0ef --- /dev/null +++ b/Lib/_pyrepl/vi_motions.py @@ -0,0 +1,189 @@ +"""Pure functions for vi motion operations. + +These functions implement vi-style cursor motions without depending on Reader state. +""" + +def _is_vi_word_char(c: str) -> bool: + return c.isalnum() or c == '_' + + +def vi_eow(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the last character of the word + following pos most immediately (vi 'e' semantics). + + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'e' moves to the end + of the current or next word/punctuation sequence.""" + b = buffer + p = pos + + if not b: + return 0 + + # Helper to check if at end of current sequence + def at_sequence_end(pos: int) -> bool: + if pos >= len(b) - 1: + return True + curr_is_word = _is_vi_word_char(b[pos]) + next_is_word = _is_vi_word_char(b[pos + 1]) + curr_is_space = b[pos].isspace() + next_is_space = b[pos + 1].isspace() + if curr_is_word: + return not next_is_word + elif not curr_is_space: + # Punctuation - at end if next is word or whitespace + return next_is_word or next_is_space + return True + + # If already at end of a word/punctuation, move forward + if p < len(b) and at_sequence_end(p): + p += 1 + + # Skip whitespace + while p < len(b) and b[p].isspace(): + p += 1 + + if p >= len(b): + return len(b) - 1 + + # Move to end of current word or punctuation sequence + if _is_vi_word_char(b[p]): + while p + 1 < len(b) and _is_vi_word_char(b[p + 1]): + p += 1 + else: + # Punctuation sequence + while p + 1 < len(b) and not _is_vi_word_char(b[p + 1]) and not b[p + 1].isspace(): + p += 1 + + return min(p, len(b) - 1) + + +def vi_forward_word(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the first character of the next word + (vi 'w' semantics). + + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'w' moves to the start + of the next word or punctuation sequence.""" + b = buffer + p = pos + + if not b or p >= len(b): + return max(0, len(b) - 1) if b else 0 + + # Skip current word or punctuation sequence + if _is_vi_word_char(b[p]): + # On a word char - skip word chars + while p < len(b) and _is_vi_word_char(b[p]): + p += 1 + elif not b[p].isspace(): + # On punctuation - skip punctuation + while p < len(b) and not _is_vi_word_char(b[p]) and not b[p].isspace(): + p += 1 + + # Skip whitespace to find next word or punctuation + while p < len(b) and b[p].isspace(): + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + + +def vi_forward_word_ws(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the first character of the next WORD + (vi 'W' semantics). + + Treats white space as the only separator.""" + b = buffer + p = pos + + if not b or p >= len(b): + return max(0, len(b) - 1) if b else 0 + + # Skip all non-whitespace (the current WORD) + while p < len(b) and not b[p].isspace(): + p += 1 + + # Skip whitespace to find next WORD + while p < len(b) and b[p].isspace(): + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + + +def vi_bow(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the beginning of the word preceding pos + (vi 'b' semantics). + + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'b' moves to the start + of the current or previous word/punctuation sequence.""" + b = buffer + p = pos + + if not b or p <= 0: + return 0 + + p -= 1 + + # Skip whitespace going backward + while p >= 0 and b[p].isspace(): + p -= 1 + + if p < 0: + return 0 + + # Now skip the word or punctuation sequence we landed in + if _is_vi_word_char(b[p]): + while p > 0 and _is_vi_word_char(b[p - 1]): + p -= 1 + else: + # Punctuation sequence + while p > 0 and not _is_vi_word_char(b[p - 1]) and not b[p - 1].isspace(): + p -= 1 + + return p + + +def vi_bow_ws(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the beginning of the WORD preceding pos + (vi 'B' semantics). + + Treats white space as the only separator.""" + b = buffer + p = pos + + if not b or p <= 0: + return 0 + + p -= 1 + + # Skip whitespace going backward + while p >= 0 and b[p].isspace(): + p -= 1 + + if p < 0: + return 0 + + # Now skip the WORD we landed in + while p > 0 and not b[p - 1].isspace(): + p -= 1 + + return p + + +def find_char_forward(buffer: list[str], pos: int, char: str) -> int | None: + """Find next occurrence of char after pos. Returns index or None.""" + for i in range(pos + 1, len(buffer)): + if buffer[i] == char: + return i + return None + + +def find_char_backward(buffer: list[str], pos: int, char: str) -> int | None: + """Find previous occurrence of char before pos. Returns index or None.""" + for i in range(pos - 1, -1, -1): + if buffer[i] == char: + return i + return None From d7311af7ad8ad8cecd376a2b996d03e819bd12bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Mon, 1 Dec 2025 22:18:40 +0000 Subject: [PATCH 19/21] Fix off by one in undo stack size check --- Lib/_pyrepl/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 1f2f678140c00c..f53a4fc249c184 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -901,7 +901,7 @@ def enter_insert_mode(self) -> None: self.vi_mode = VI_MODE_INSERT - if len(self.undo_stack) > MAX_VI_UNDO_STACK_SIZE: + if len(self.undo_stack) >= MAX_VI_UNDO_STACK_SIZE: self.undo_stack.pop(0) self.undo_stack.append(ViUndoState( From cbe77074b8cc2237a2f373f3fa5742711729089d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Mon, 1 Dec 2025 22:21:29 +0000 Subject: [PATCH 20/21] Fix unbounded undo stack growth bug --- Lib/_pyrepl/reader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index f53a4fc249c184..8d949e0e57aeba 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -780,9 +780,10 @@ def do_cmd(self, cmd: tuple[str, list[str]]) -> None: return # nothing to do command = command_type(self, *cmd) # type: ignore[arg-type] - # Save undo state in vi mode if the command modifies the buffer if self.use_vi_mode and getattr(command_type, 'modifies_buffer', False): + if len(self.undo_stack) > MAX_VI_UNDO_STACK_SIZE: + self.undo_stack.pop(0) self.undo_stack.append(ViUndoState( buffer_snapshot=self.buffer.copy(), pos_snapshot=self.pos, From a6fb2669cc36cf9f1a1fd17881a68d8201cf7396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Mon, 1 Dec 2025 22:22:58 +0000 Subject: [PATCH 21/21] Move import to top of file --- Lib/_pyrepl/windows_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index b16817a31e16bc..1e01d025c2d61c 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -22,6 +22,7 @@ import io import os import sys +import time import ctypes import types @@ -448,7 +449,6 @@ def get_event(self, block: bool = True) -> Event | None: while self.event_queue.empty(): # Check if we have a pending escape sequence that needs timeout handling if self.event_queue.has_pending_escape_sequence(): - import time current_time_ms = time.monotonic() * 1000 if self.event_queue.should_emit_standalone_escape(current_time_ms):