Skip to content

Commit aa5b41a

Browse files
committed
Implement basic find commands
1 parent 1406699 commit aa5b41a

File tree

3 files changed

+302
-0
lines changed

3 files changed

+302
-0
lines changed

Lib/_pyrepl/reader.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ def make_default_commands() -> dict[CommandName, type[Command]]:
163163
(r"e", "end-of-word"),
164164
(r"^", "first-non-whitespace-character"),
165165

166+
# Find motions
167+
(r"f", "vi-find-char"),
168+
(r"F", "vi-find-char-back"),
169+
(r"t", "vi-till-char"),
170+
(r"T", "vi-till-char-back"),
171+
(r";", "vi-repeat-find"),
172+
(r",", "vi-repeat-find-opposite"),
173+
166174
# Edit commands
167175
(r"x", "delete"),
168176
(r"i", "vi-insert-mode"),
@@ -295,6 +303,12 @@ class Reader:
295303
threading_hook: Callback | None = None
296304
use_vi_mode: bool = False
297305
vi_mode: ViMode = ViMode.INSERT
306+
# Vi find state (for f/F/t/T and ;/,)
307+
last_find_char: str | None = None
308+
last_find_direction: str | None = None # "forward" or "backward"
309+
last_find_inclusive: bool = True # f/F=True, t/T=False
310+
pending_find_direction: str | None = None
311+
pending_find_inclusive: bool = True
298312

299313
## cached metadata to speed up screen refreshes
300314
@dataclass
@@ -685,6 +699,24 @@ def vi_bow_ws(self, p: int | None = None) -> int:
685699

686700
return p
687701

702+
def find_char_forward(self, char: str, p: int | None = None) -> int | None:
703+
"""Find next occurrence of char after p. Returns index or None."""
704+
if p is None:
705+
p = self.pos
706+
for i in range(p + 1, len(self.buffer)):
707+
if self.buffer[i] == char:
708+
return i
709+
return None
710+
711+
def find_char_backward(self, char: str, p: int | None = None) -> int | None:
712+
"""Find previous occurrence of char before p. Returns index or None."""
713+
if p is None:
714+
p = self.pos
715+
for i in range(p - 1, -1, -1):
716+
if self.buffer[i] == char:
717+
return i
718+
return None
719+
688720
def bol(self, p: int | None = None) -> int:
689721
"""Return the 0-based index of the line break preceding p most
690722
immediately.

Lib/_pyrepl/vi_commands.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
from .commands import Command, MotionCommand, KillCommand
6+
from . import input as _input
67

78

89
# ============================================================================
@@ -138,3 +139,188 @@ class vi_delete_to_eol(KillCommand):
138139
def do(self) -> None:
139140
r = self.reader
140141
self.kill_range(r.pos, r.eol())
142+
143+
144+
# ============================================================================
145+
# Find Commands (f/F/t/T)
146+
# ============================================================================
147+
148+
def _make_find_char_keymap() -> tuple[tuple[str, str], ...]:
149+
"""Create a keymap where all printable ASCII maps to vi-find-execute.
150+
151+
Once vi-find-execute is called, it will pop our input translator."""
152+
entries = []
153+
for i in range(32, 127):
154+
if i == 92: # backslash needs escaping
155+
entries.append((r"\\", "vi-find-execute"))
156+
else:
157+
entries.append((chr(i), "vi-find-execute"))
158+
entries.append((r"\<escape>", "vi-find-cancel"))
159+
return tuple(entries)
160+
161+
162+
_find_char_keymap = _make_find_char_keymap()
163+
164+
165+
class vi_find_char(Command):
166+
"""Start forward find (f). Waits for target character."""
167+
def do(self) -> None:
168+
r = self.reader
169+
r.pending_find_direction = "forward"
170+
r.pending_find_inclusive = True
171+
trans = _input.KeymapTranslator(
172+
_find_char_keymap,
173+
invalid_cls="vi-find-cancel",
174+
character_cls="vi-find-execute"
175+
)
176+
r.push_input_trans(trans)
177+
178+
179+
class vi_find_char_back(Command):
180+
"""Start backward find (F). Waits for target character."""
181+
def do(self) -> None:
182+
r = self.reader
183+
r.pending_find_direction = "backward"
184+
r.pending_find_inclusive = True
185+
trans = _input.KeymapTranslator(
186+
_find_char_keymap,
187+
invalid_cls="vi-find-cancel",
188+
character_cls="vi-find-execute"
189+
)
190+
r.push_input_trans(trans)
191+
192+
193+
class vi_till_char(Command):
194+
"""Start forward till (t). Waits for target character."""
195+
def do(self) -> None:
196+
r = self.reader
197+
r.pending_find_direction = "forward"
198+
r.pending_find_inclusive = False
199+
trans = _input.KeymapTranslator(
200+
_find_char_keymap,
201+
invalid_cls="vi-find-cancel",
202+
character_cls="vi-find-execute"
203+
)
204+
r.push_input_trans(trans)
205+
206+
207+
class vi_till_char_back(Command):
208+
"""Start backward till (T). Waits for target character."""
209+
def do(self) -> None:
210+
r = self.reader
211+
r.pending_find_direction = "backward"
212+
r.pending_find_inclusive = False
213+
trans = _input.KeymapTranslator(
214+
_find_char_keymap,
215+
invalid_cls="vi-find-cancel",
216+
character_cls="vi-find-execute"
217+
)
218+
r.push_input_trans(trans)
219+
220+
221+
class vi_find_execute(MotionCommand):
222+
"""Execute the pending find with the pressed character."""
223+
def do(self) -> None:
224+
r = self.reader
225+
r.pop_input_trans()
226+
227+
char = self.event[-1]
228+
if not char:
229+
return
230+
231+
direction = r.pending_find_direction
232+
inclusive = r.pending_find_inclusive
233+
234+
# Store for repeat with ; and ,
235+
r.last_find_char = char
236+
r.last_find_direction = direction
237+
r.last_find_inclusive = inclusive
238+
239+
r.pending_find_direction = None
240+
self._execute_find(char, direction, inclusive)
241+
242+
def _execute_find(self, char: str, direction: str | None, inclusive: bool) -> None:
243+
r = self.reader
244+
for _ in range(r.get_arg()):
245+
if direction == "forward":
246+
new_pos = r.find_char_forward(char)
247+
if new_pos is not None:
248+
if not inclusive:
249+
new_pos -= 1
250+
if new_pos > r.pos:
251+
r.pos = new_pos
252+
else:
253+
new_pos = r.find_char_backward(char)
254+
if new_pos is not None:
255+
if not inclusive:
256+
new_pos += 1
257+
if new_pos < r.pos:
258+
r.pos = new_pos
259+
260+
261+
class vi_find_cancel(Command):
262+
"""Cancel pending find operation."""
263+
def do(self) -> None:
264+
r = self.reader
265+
r.pop_input_trans()
266+
r.pending_find_direction = None
267+
268+
269+
# ============================================================================
270+
# Repeat Find Commands (; and ,)
271+
# ============================================================================
272+
273+
class vi_repeat_find(MotionCommand):
274+
"""Repeat last f/F/t/T in the same direction (;)."""
275+
def do(self) -> None:
276+
r = self.reader
277+
if r.last_find_char is None:
278+
return
279+
280+
char = r.last_find_char
281+
direction = r.last_find_direction
282+
inclusive = r.last_find_inclusive
283+
284+
for _ in range(r.get_arg()):
285+
if direction == "forward":
286+
new_pos = r.find_char_forward(char)
287+
if new_pos is not None:
288+
if not inclusive:
289+
new_pos -= 1
290+
if new_pos > r.pos:
291+
r.pos = new_pos
292+
else:
293+
new_pos = r.find_char_backward(char)
294+
if new_pos is not None:
295+
if not inclusive:
296+
new_pos += 1
297+
if new_pos < r.pos:
298+
r.pos = new_pos
299+
300+
301+
class vi_repeat_find_opposite(MotionCommand):
302+
"""Repeat last f/F/t/T in opposite direction (,)."""
303+
def do(self) -> None:
304+
r = self.reader
305+
if r.last_find_char is None:
306+
return
307+
308+
char = r.last_find_char
309+
direction = "backward" if r.last_find_direction == "forward" else "forward"
310+
inclusive = r.last_find_inclusive
311+
312+
for _ in range(r.get_arg()):
313+
if direction == "forward":
314+
new_pos = r.find_char_forward(char)
315+
if new_pos is not None:
316+
if not inclusive:
317+
new_pos -= 1
318+
if new_pos > r.pos:
319+
r.pos = new_pos
320+
else:
321+
new_pos = r.find_char_backward(char)
322+
if new_pos is not None:
323+
if not inclusive:
324+
new_pos += 1
325+
if new_pos < r.pos:
326+
r.pos = new_pos

Lib/test/test_pyrepl/test_reader.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,90 @@ def test_vi_word_boundaries(self):
10581058
self.assertEqual(reader.pos, expected_pos,
10591059
f"Expected pos {expected_pos} but got {reader.pos} for '{text}' with keys '{keys}'")
10601060

1061+
def test_find_char_forward(self):
1062+
events = itertools.chain(
1063+
code_to_events("hello world"),
1064+
[
1065+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1066+
Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL
1067+
Event(evt="key", data="f", raw=bytearray(b"f")), # Find
1068+
Event(evt="key", data="o", raw=bytearray(b"o")), # Target char
1069+
],
1070+
)
1071+
reader, _ = self._run_vi(events)
1072+
self.assertEqual(reader.pos, 4)
1073+
self.assertEqual(reader.buffer[reader.pos], 'o')
1074+
1075+
def test_find_char_backward(self):
1076+
events = itertools.chain(
1077+
code_to_events("hello world"),
1078+
[
1079+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1080+
Event(evt="key", data="F", raw=bytearray(b"F")), # Find back
1081+
Event(evt="key", data="l", raw=bytearray(b"l")), # Target char
1082+
],
1083+
)
1084+
reader, _ = self._run_vi(events)
1085+
self.assertEqual(reader.buffer[reader.pos], 'l')
1086+
1087+
def test_till_char_forward(self):
1088+
events = itertools.chain(
1089+
code_to_events("hello world"),
1090+
[
1091+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1092+
Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL
1093+
Event(evt="key", data="t", raw=bytearray(b"t")), # Till
1094+
Event(evt="key", data="o", raw=bytearray(b"o")), # Target char
1095+
],
1096+
)
1097+
reader, _ = self._run_vi(events)
1098+
self.assertEqual(reader.pos, 3)
1099+
self.assertEqual(reader.buffer[reader.pos], 'l')
1100+
1101+
def test_semicolon_repeat_find(self):
1102+
events = itertools.chain(
1103+
code_to_events("hello world"),
1104+
[
1105+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1106+
Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL
1107+
Event(evt="key", data="f", raw=bytearray(b"f")), # Find
1108+
Event(evt="key", data="o", raw=bytearray(b"o")), # First 'o'
1109+
Event(evt="key", data=";", raw=bytearray(b";")), # Repeat - second 'o'
1110+
],
1111+
)
1112+
reader, _ = self._run_vi(events)
1113+
self.assertEqual(reader.pos, 7)
1114+
self.assertEqual(reader.buffer[reader.pos], 'o')
1115+
1116+
def test_comma_repeat_find_opposite(self):
1117+
events = itertools.chain(
1118+
code_to_events("hello world"),
1119+
[
1120+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1121+
Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL
1122+
Event(evt="key", data="f", raw=bytearray(b"f")), # Find forward
1123+
Event(evt="key", data="l", raw=bytearray(b"l")), # First 'l' at pos 2
1124+
Event(evt="key", data=";", raw=bytearray(b";")), # Second 'l' at pos 3
1125+
Event(evt="key", data=",", raw=bytearray(b",")), # Reverse - back to pos 2
1126+
],
1127+
)
1128+
reader, _ = self._run_vi(events)
1129+
self.assertEqual(reader.pos, 2)
1130+
self.assertEqual(reader.buffer[reader.pos], 'l')
1131+
1132+
def test_find_char_escape_cancels(self):
1133+
events = itertools.chain(
1134+
code_to_events("hello"),
1135+
[
1136+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal
1137+
Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL
1138+
Event(evt="key", data="f", raw=bytearray(b"f")), # Start find
1139+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to cancel
1140+
],
1141+
)
1142+
reader, _ = self._run_vi(events)
1143+
self.assertEqual(reader.pos, 0)
1144+
10611145

10621146
@force_not_colorized_test_class
10631147
class TestHistoricalReaderBindings(TestCase):

0 commit comments

Comments
 (0)