implement find-replace

This commit is contained in:
Anthony Sottile
2019-12-07 17:13:50 -08:00
parent 33fd403cd1
commit d826cfbea1
2 changed files with 472 additions and 64 deletions

303
babi.py
View File

@@ -14,10 +14,12 @@ from typing import Any
from typing import Callable
from typing import cast
from typing import Dict
from typing import FrozenSet
from typing import Generator
from typing import IO
from typing import Iterator
from typing import List
from typing import Match
from typing import NamedTuple
from typing import Optional
from typing import Pattern
@@ -387,6 +389,31 @@ class Status:
elif key.key == ord('\r'):
return _save_history_and_get_retv()
def quick_prompt(
self,
screen: 'Screen',
prompt: str,
options: FrozenSet[str],
resize: Callable[[], None],
) -> Optional[str]:
while True:
s = prompt.ljust(curses.COLS)
if len(s) > curses.COLS:
s = f'{s[:curses.COLS - 1]}'
screen.stdscr.insstr(curses.LINES - 1, 0, s, curses.A_REVERSE)
x = min(curses.COLS - 1, len(prompt) + 1)
screen.stdscr.move(curses.LINES - 1, x)
key = _get_char(screen.stdscr)
if key.key == curses.KEY_RESIZE:
screen.resize()
resize()
elif key.keyname == b'^C':
return None
elif key.wch in options:
assert isinstance(key.wch, str) # mypy doesn't know
return key.wch
def _restore_lines_eof_invariant(lines: MutableSequenceNoSlice) -> None:
"""The file lines will always contain a blank empty string at the end to
@@ -421,6 +448,7 @@ class Action:
self, *, name: str, spy: ListSpy,
start_x: int, start_y: int, start_modified: bool,
end_x: int, end_y: int, end_modified: bool,
final: bool,
):
self.name = name
self.spy = spy
@@ -430,7 +458,7 @@ class Action:
self.end_x = end_x
self.end_y = end_y
self.end_modified = end_modified
self.final = False
self.final = final
def apply(self, file: 'File') -> 'Action':
spy = ListSpy(file.lines)
@@ -440,6 +468,7 @@ class Action:
start_modified=self.end_modified,
end_x=self.start_x, end_y=self.start_y,
end_modified=self.start_modified,
final=True,
)
self.spy.undo(spy)
@@ -454,54 +483,87 @@ def action(func: TCallable) -> TCallable:
@functools.wraps(func)
def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
if self.undo_stack:
self.undo_stack[-1].final = True
self.mark_previous_action_as_final()
return func(self, *args, **kwargs)
return cast(TCallable, action_inner)
def edit_action(name: str) -> Callable[[TCallable], TCallable]:
def edit_action(
name: str,
*,
final: bool,
) -> Callable[[TCallable], TCallable]:
def edit_action_decorator(func: TCallable) -> TCallable:
@functools.wraps(func)
def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
continue_last = (
self.undo_stack and
self.undo_stack[-1].name == name and
not self.undo_stack[-1].final
)
if continue_last:
spy = self.undo_stack[-1].spy
else:
if self.undo_stack:
self.undo_stack[-1].final = True
spy = ListSpy(self.lines)
before_x, before_line = self.x, self.cursor_y
before_modified = self.modified
assert not isinstance(self.lines, ListSpy), 'recursive action?'
orig, self.lines = self.lines, spy
try:
with self.edit_action_context(name, final=final):
return func(self, *args, **kwargs)
finally:
self.lines = orig
self.redo_stack.clear()
if continue_last:
self.undo_stack[-1].end_x = self.x
self.undo_stack[-1].end_y = self.cursor_y
self.undo_stack[-1].end_modified = self.modified
elif spy.has_modifications:
action = Action(
name=name, spy=spy,
start_x=before_x, start_y=before_line,
start_modified=before_modified,
end_x=self.x, end_y=self.cursor_y,
end_modified=self.modified,
)
self.undo_stack.append(action)
return cast(TCallable, edit_action_inner)
return edit_action_decorator
class Found(NamedTuple):
y: int
match: Match[str]
class _SearchIter:
def __init__(
self,
file: 'File',
reg: Pattern[str],
*,
offset: int,
) -> None:
self.file = file
self.reg = reg
self.offset = offset
self.wrapped = False
self._start_x = file.x + offset
self._start_y = file.cursor_y
def __iter__(self) -> '_SearchIter':
return self
def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
if (
self.wrapped and (
y > self._start_y or
y == self._start_y and match.start() >= self._start_x
)
):
raise StopIteration()
return Found(y, match)
def __next__(self) -> Tuple[int, Match[str]]:
x = self.file.x + self.offset
y = self.file.cursor_y
match = self.reg.search(self.file.lines[y], x)
if match:
return self._stop_if_past_original(y, match)
if self.wrapped:
for line_y in range(y + 1, self._start_y + 1):
match = self.reg.search(self.file.lines[line_y])
if match:
return self._stop_if_past_original(line_y, match)
else:
for line_y in range(y + 1, len(self.file.lines)):
match = self.reg.search(self.file.lines[line_y])
if match:
return self._stop_if_past_original(line_y, match)
self.wrapped = True
for line_y in range(0, self._start_y + 1):
match = self.reg.search(self.file.lines[line_y])
if match:
return self._stop_if_past_original(line_y, match)
raise StopIteration()
class File:
def __init__(self, filename: Optional[str]) -> None:
self.filename = filename
@@ -649,28 +711,73 @@ class File:
status: Status,
margin: Margin,
) -> None:
line_y = self.cursor_y
match = reg.search(self.lines[self.cursor_y], self.x + 1)
if not match:
for line_y in range(self.cursor_y + 1, len(self.lines)):
match = reg.search(self.lines[line_y])
if match:
break
search = _SearchIter(self, reg, offset=1)
try:
line_y, match = next(iter(search))
except StopIteration:
status.update('no matches')
else:
if line_y == self.cursor_y and match.start() == self.x:
status.update('this is the only occurrence')
else:
status.update('search wrapped')
for line_y in range(0, self.cursor_y + 1):
match = reg.search(self.lines[line_y])
if match:
break
if search.wrapped:
status.update('search wrapped')
self.cursor_y = line_y
self.x = match.start()
self._scroll_screen_if_needed(margin)
if match and line_y == self.cursor_y and match.start() == self.x:
status.update('this is the only occurrence')
elif match:
def replace(
self,
screen: 'Screen',
reg: Pattern[str],
replace: str,
) -> None:
self.mark_previous_action_as_final()
def highlight() -> None:
y = screen.file.rendered_y(screen.margin)
x = screen.file.rendered_x()
maxlen = curses.COLS - x
s = match[0]
if len(s) >= maxlen:
s = _scrolled_line(match[0], 0, maxlen, current=True)
screen.stdscr.addstr(y, x, s, curses.A_REVERSE)
count = 0
res: Optional[str] = ''
search = _SearchIter(self, reg, offset=0)
for line_y, match in search:
self.cursor_y = line_y
self.x = match.start()
self._scroll_screen_if_needed(margin)
self._scroll_screen_if_needed(screen.margin)
if res != 'a': # make `a` replace the rest of them
screen.draw()
highlight()
res = screen.status.quick_prompt(
screen, 'replace [y(es), n(o), a(ll)]?',
frozenset('yna'), highlight,
)
if res in {'y', 'a'}:
count += 1
with self.edit_action_context('replace', final=True):
replaced = match.expand(replace)
line = screen.file.lines[line_y]
line = line[:match.start()] + replaced + line[match.end():]
screen.file.lines[line_y] = line
screen.file.modified = True
search.offset = len(replaced)
elif res == 'n':
search.offset = 1
else:
assert res is None
screen.status.update('cancelled')
return
if res == '': # we never went through the loop
screen.status.update('no matches')
else:
status.update('no matches')
occurrences = 'occurrence' if count == 1 else 'occurrences'
screen.status.update(f'replaced {count} {occurrences}')
@action
def page_up(self, margin: Margin) -> None:
@@ -692,7 +799,7 @@ class File:
# editing
@edit_action('backspace text')
@edit_action('backspace text', final=False)
def backspace(self, margin: Margin) -> None:
# backspace at the beginning of the file does nothing
if self.cursor_y == 0 and self.x == 0:
@@ -715,7 +822,7 @@ class File:
self.x = self.x_hint = self.x - 1
self.modified = True
@edit_action('delete text')
@edit_action('delete text', final=False)
def delete(self, margin: Margin) -> None:
# noop at end of the file
if self.cursor_y == len(self.lines) - 1:
@@ -730,7 +837,7 @@ class File:
self.lines[self.cursor_y] = s[:self.x] + s[self.x + 1:]
self.modified = True
@edit_action('line break')
@edit_action('line break', final=False)
def enter(self, margin: Margin) -> None:
s = self.lines[self.cursor_y]
self.lines[self.cursor_y] = s[:self.x]
@@ -740,7 +847,7 @@ class File:
self.x = self.x_hint = 0
self.modified = True
@edit_action('cut')
@edit_action('cut', final=False)
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
if self.cursor_y == len(self.lines) - 1:
return ()
@@ -750,7 +857,7 @@ class File:
self.modified = True
return cut_buffer + (victim,)
@edit_action('uncut')
@edit_action('uncut', final=True)
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
for cut_line in cut_buffer:
line = self.lines[self.cursor_y]
@@ -788,7 +895,7 @@ class File:
b'kDN5': ctrl_down,
}
@edit_action('text')
@edit_action('text', final=False)
def c(self, wch: str, margin: Margin) -> None:
s = self.lines[self.cursor_y]
self.lines[self.cursor_y] = s[:self.x] + wch + s[self.x:]
@@ -796,6 +903,52 @@ class File:
self.modified = True
_restore_lines_eof_invariant(self.lines)
def mark_previous_action_as_final(self) -> None:
if self.undo_stack:
self.undo_stack[-1].final = True
@contextlib.contextmanager
def edit_action_context(
self, name: str,
*,
final: bool,
) -> Generator[None, None, None]:
continue_last = (
self.undo_stack and
self.undo_stack[-1].name == name and
not self.undo_stack[-1].final
)
if continue_last:
spy = self.undo_stack[-1].spy
else:
if self.undo_stack:
self.undo_stack[-1].final = True
spy = ListSpy(self.lines)
before_x, before_line = self.x, self.cursor_y
before_modified = self.modified
assert not isinstance(self.lines, ListSpy), 'recursive action?'
orig, self.lines = self.lines, spy
try:
yield
finally:
self.lines = orig
self.redo_stack.clear()
if continue_last:
self.undo_stack[-1].end_x = self.x
self.undo_stack[-1].end_y = self.cursor_y
self.undo_stack[-1].end_modified = self.modified
elif spy.has_modifications:
action = Action(
name=name, spy=spy,
start_x=before_x, start_y=before_line,
start_modified=before_modified,
end_x=self.x, end_y=self.cursor_y,
end_modified=self.modified,
final=final,
)
self.undo_stack.append(action)
def _undo_redo(
self,
op: str,
@@ -869,14 +1022,18 @@ class File:
# positioning
def rendered_y(self, margin: Margin) -> int:
return self.cursor_y - self.file_y + margin.header
def rendered_x(self) -> int:
return self.x - _line_x(self.x, curses.COLS)
def move_cursor(
self,
stdscr: 'curses._CursesWindow',
margin: Margin,
) -> None:
y = self.cursor_y - self.file_y + margin.header
x = self.x - _line_x(self.x, curses.COLS)
stdscr.move(y, x)
stdscr.move(self.rendered_y(margin), self.rendered_x())
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
to_display = min(len(self.lines) - self.file_y, margin.body_lines)
@@ -1081,6 +1238,26 @@ def _edit(screen: Screen) -> EditResult:
screen.status.update(f'invalid regex: {response!r}')
else:
screen.file.search(regex, screen.status, screen.margin)
elif key.keyname == b'^\\':
response = screen.status.prompt(
screen, 'search (to replace)',
history='search', default_prev=True,
)
if not response:
screen.status.update('cancelled')
else:
try:
regex = re.compile(response)
except re.error:
screen.status.update(f'invalid regex: {response!r}')
else:
response = screen.status.prompt(
screen, 'replace with', history='replace',
)
if response is None:
screen.status.update('cancelled')
else:
screen.file.replace(screen, regex, response)
elif key.keyname == b'^C':
screen.file.current_position(screen.status)
elif key.keyname == b'^[': # escape

View File

@@ -796,7 +796,7 @@ def test_search_history_is_saved_between_sessions(xdg_data_home):
h.press('Enter')
def test_multiple_sessions_append_to_history(xdg_data_home):
def test_search_multiple_sessions_append_to_history(xdg_data_home):
xdg_data_home.join('babi/history/search').ensure().write(
'orig\n'
'history\n',
@@ -930,6 +930,237 @@ def test_search_reverse_search_keeps_current_text_displayed():
h.press('^C')
@pytest.mark.parametrize('key', ('^C', 'Enter'))
def test_replace_cancel(key):
with run() as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press(key)
h.await_text('cancelled')
def test_replace_invalid_regex():
with run() as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('(')
h.await_text("invalid regex: '('")
def test_replace_cancel_at_replace_string():
with run() as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('hello')
h.await_text('replace with:')
h.press('^C')
h.await_text('cancelled')
def test_replace_actual_contents(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line_0')
h.await_text('replace with:')
h.press_and_enter('ohai')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('y')
h.await_text_missing('line_0')
h.await_text('ohai')
h.await_text(' *')
h.await_text('replaced 1 occurrence')
def test_replace_cancel_at_individual_replace(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter(r'line_\d')
h.await_text('replace with:')
h.press_and_enter('ohai')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('^C')
h.await_text('cancelled')
def test_replace_unknown_characters_at_individual_replace(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter(r'line_\d')
h.await_text('replace with:')
h.press_and_enter('ohai')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('?')
h.press('^C')
h.await_text('cancelled')
def test_replace_say_no_to_individual_replace(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line_[135]')
h.await_text('replace with:')
h.press_and_enter('ohai')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('y')
h.await_text_missing('line_1')
h.press('n')
h.await_text('line_3')
h.press('y')
h.await_text_missing('line_5')
h.await_text('replaced 2 occurrences')
def test_replace_all(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter(r'line_(\d)')
h.await_text('replace with:')
h.press_and_enter(r'ohai+\1')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('a')
h.await_text_missing('line')
h.await_text('ohai+1')
h.await_text('replaced 10 occurrences')
def test_replace_with_empty_string(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line_1')
h.await_text('replace with:')
h.press('Enter')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('y')
h.await_text_missing('line_1')
def test_replace_search_not_found(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('wat')
# TODO: would be nice to not prompt for a replace string in this case
h.await_text('replace with:')
h.press('Enter')
h.await_text('no matches')
def test_replace_small_window_size(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line')
h.await_text('replace with:')
h.press_and_enter('wat')
h.await_text('replace [y(es), n(o), a(ll)]?')
with h.resize(width=8, height=24):
h.await_text('replace…')
h.press('^C')
def test_replace_line_goes_off_screen():
with run() as h, and_exit(h):
h.press(f'{"a" * 20}{"b" * 90}')
h.press('^A')
h.await_text(f'{"a" * 20}{"b" * 59}»')
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('b+')
h.await_text('replace with:')
h.press_and_enter('wat')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.await_text(f'{"a" * 20}{"b" * 59}»')
h.press('y')
h.await_text(f'{"a" * 20}wat')
h.await_text('replaced 1 occurrence')
def test_replace_undo_undoes_only_one(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line')
h.await_text('replace with:')
h.press_and_enter('wat')
h.press('y')
h.await_text_missing('line_0')
h.press('y')
h.await_text_missing('line_1')
h.press('^C')
h.press('M-u')
h.await_text('line_1')
h.await_text_missing('line_0')
def test_replace_multiple_occurrences_in_line():
with run() as h, and_exit(h):
h.press('baaaaabaaaaa')
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('a+')
h.await_text('replace with:')
h.press_and_enter('q')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('a')
h.await_text('bqbq')
def test_replace_after_wrapping(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('Down')
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line_[02]')
h.await_text('replace with:')
h.press_and_enter('ohai')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('y')
h.await_text_missing('line_2')
h.press('y')
h.await_text_missing('line_0')
h.await_text('replaced 2 occurrences')
def test_replace_after_cursor_after_wrapping():
with run() as h, and_exit(h):
h.press('baaab')
h.press('Left')
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('b')
h.await_text('replace with:')
h.press_and_enter('q')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('n')
h.press('y')
h.await_text('replaced 1 occurrence')
h.await_text('qaaab')
def test_replace_separate_line_after_wrapping(ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('Down')
h.press('Down')
h.press('^\\')
h.await_text('search (to replace):')
h.press_and_enter('line_[01]')
h.await_text('replace with:')
h.press_and_enter('_')
h.await_text('replace [y(es), n(o), a(ll)]?')
h.press('y')
h.await_text_missing('line_0')
h.press('y')
h.await_text_missing('line_1')
def test_scrolling_arrow_key_movement(ten_lines):
with run(str(ten_lines), height=10) as h, and_exit(h):
h.await_text('line_7')