1 Commits
v0.0.16 ... wc

Author SHA1 Message Date
Anthony Sottile
9b55ebfd0e wip 2020-04-17 17:09:16 -07:00
27 changed files with 325 additions and 572 deletions

View File

@@ -11,16 +11,16 @@ repos:
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://gitlab.com/pycqa/flake8
rev: 3.8.0
rev: 3.7.9
hooks:
- id: flake8
additional_dependencies: [flake8-typing-imports==1.7.0]
- repo: https://github.com/pre-commit/mirrors-autopep8
rev: v1.5.2
rev: v1.5
hooks:
- id: autopep8
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.3.0
rev: v2.1.0
hooks:
- id: reorder-python-imports
args: [--py3-plus]
@@ -30,12 +30,12 @@ repos:
- id: add-trailing-comma
args: [--py36-plus]
- repo: https://github.com/asottile/pyupgrade
rev: v2.4.1
rev: v2.1.0
hooks:
- id: pyupgrade
args: [--py36-plus]
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.9.0
rev: v1.7.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/pre-commit/mirrors-mypy

View File

@@ -1,8 +1,6 @@
[![Build Status](https://dev.azure.com/asottile/asottile/_apis/build/status/asottile.babi?branchName=master)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
[![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/asottile/asottile/29/master.svg)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
![babi logo](https://user-images.githubusercontent.com/1810591/89981369-9ed84e80-dc28-11ea-9708-5f4c49c09632.png)
babi
====
@@ -14,7 +12,7 @@ a text editor, eventually...
### why is it called babi?
I used to use the text editor `nano`, frequently I typo this. on a qwerty
I usually use the text editor `nano`, frequently I typo this. on a qwerty
keyboard, when the right hand is shifted left by one, `nano` becomes `babi`.
### quitting babi
@@ -47,7 +45,7 @@ these are all of the current key bindings in babi
- <kbd>tab</kbd> / <kbd>shift-tab</kbd>: indent or dedent current line (or
selection)
- <kbd>^K</kbd> / <kbd>^U</kbd>: cut and uncut the current line (or selection)
- <kbd>M-u</kbd> / <kbd>M-U</kbd> or <kbd>M-e</kbd>: undo / redo
- <kbd>M-u</kbd> / <kbd>M-U</kbd>: undo / redo
- <kbd>^W</kbd>: search
- <kbd>^\\</kbd>: search and replace
- <kbd>^C</kbd>: show the current position in the file
@@ -89,7 +87,7 @@ this opens the file, displays it, and can be edited and can save! unknown keys
are displayed as errors in the status bar. babi will scroll if the cursor
goes off screen either from resize events or from movement. babi can edit
multiple files. babi has a command mode (so you can quit it like vim
<kbd>:q</kbd>!). babi also supports syntax highlighting
<kbd>:q</kbd>!). babi also support syntax highlighting
![](https://i.fluffy.cc/5WFZBJ4mWs7wtThD9strQnGlJqw4Z9KS.png)

View File

@@ -10,11 +10,11 @@ resources:
type: github
endpoint: github
name: asottile/azure-pipeline-templates
ref: refs/tags/v2.0.0
ref: refs/tags/v1.0.0
jobs:
- template: job--pre-commit.yml@asottile
- template: job--python-tox.yml@asottile
parameters:
toxenvs: [pypy3, py36, py37, py38, py39]
toxenvs: [py36, py37, py38]
os: linux

View File

@@ -19,11 +19,11 @@ DelCallback = Callable[['Buf', int, str], None]
InsCallback = Callable[['Buf', int], None]
def _offsets(s: str, tab_size: int) -> Tuple[int, ...]:
def _offsets(s: str) -> Tuple[int, ...]:
ret = [0]
for c in s:
if c == '\t':
ret.append(ret[-1] + (tab_size - ret[-1] % tab_size))
ret.append(ret[-1] + (4 - ret[-1] % 4))
else:
ret.append(ret[-1] + wcwidth(c))
return tuple(ret)
@@ -57,9 +57,8 @@ class DelModification(NamedTuple):
class Buf:
def __init__(self, lines: List[str], tab_size: int = 4) -> None:
def __init__(self, lines: List[str]) -> None:
self._lines = lines
self.tab_size = tab_size
self.file_y = self.y = self._x = self._x_hint = 0
self._set_callbacks: List[SetCallback] = [self._set_cb]
@@ -131,16 +130,12 @@ class Buf:
return victim
def restore_eof_invariant(self) -> None:
"""the file lines will always contain a blank empty string at the end'
"""the file lines will always contain a blank empty string at the end
to simplify rendering. call this whenever the last line may change
"""
if self[-1] != '':
self.append('')
def set_tab_size(self, tab_size: int) -> None:
self.tab_size = tab_size
self._positions = [None]
# event handling
def add_set_callback(self, cb: SetCallback) -> None:
@@ -224,8 +219,7 @@ class Buf:
self._extend_positions(idx)
value = self._positions[idx]
if value is None:
value = _offsets(self._lines[idx], self.tab_size)
self._positions[idx] = value
value = self._positions[idx] = _offsets(self._lines[idx])
return value
def line_x(self, margin: Margin) -> int:
@@ -243,9 +237,10 @@ class Buf:
# rendered lines
def rendered_line(self, idx: int, margin: Margin) -> str:
x = self._cursor_x if idx == self.y else 0
expanded = self._lines[idx].expandtabs(self.tab_size)
return scrolled_line(expanded, x, margin.cols)
line = self._lines[idx]
positions = self.line_positions(idx)
cursor_x = self._cursor_x if idx == self.y else 0
return scrolled_line(line, positions, cursor_x, margin.cols)
# movement
@@ -280,7 +275,7 @@ class Buf:
if self.x >= len(self._lines[self.y]):
if self.y < len(self._lines) - 1:
self.down(margin)
self.x = 0
self.home()
else:
self.x += 1
@@ -288,10 +283,16 @@ class Buf:
if self.x == 0:
if self.y > 0:
self.up(margin)
self.x = len(self._lines[self.y])
self.end()
else:
self.x -= 1
def home(self) -> None:
self.x = 0
def end(self) -> None:
self.x = len(self._lines[self.y])
# screen movement
def file_up(self, margin: Margin) -> None:
@@ -305,3 +306,9 @@ class Buf:
self.file_y += 1
if self.y < self.file_y:
self.down(margin)
# key input
def c(self, s: str) -> None:
self[self.y] = self[self.y][:self.x] + s + self[self.y][self.x:]
self.x += len(s)

View File

@@ -33,17 +33,14 @@ class ColorManager(NamedTuple):
return self.raw_color_pair(fg_i, bg_i)
def raw_color_pair(self, fg: int, bg: int) -> int:
if curses.COLORS > 0:
try:
return self.raw_pairs[(fg, bg)]
except KeyError:
pass
try:
return self.raw_pairs[(fg, bg)]
except KeyError:
pass
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg)
return n
else:
return 0
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg)
return n
@classmethod
def make(cls) -> 'ColorManager':

View File

@@ -6,7 +6,6 @@ import hashlib
import io
import itertools
import os.path
import re
from typing import Any
from typing import Callable
from typing import cast
@@ -39,8 +38,6 @@ if TYPE_CHECKING:
TCallable = TypeVar('TCallable', bound=Callable[..., Any])
WS_RE = re.compile(r'^\s*')
def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
sha256 = hashlib.sha256()
@@ -201,12 +198,10 @@ class File:
def __init__(
self,
filename: Optional[str],
initial_line: int,
color_manager: ColorManager,
hl_factories: Tuple[HLFactory, ...],
) -> None:
self.filename = filename
self.initial_line = initial_line
self.modified = False
self.buf = Buf([])
self.nl = '\n'
@@ -220,12 +215,7 @@ class File:
self.selection = Selection()
self._file_hls: Tuple[FileHL, ...] = ()
def ensure_loaded(
self,
status: Status,
margin: Margin,
stdin: str,
) -> None:
def ensure_loaded(self, status: Status, stdin: str) -> None:
if self.buf:
return
@@ -247,7 +237,7 @@ class File:
status.update('(new file)')
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
self.buf = Buf(lines, self.buf.tab_size)
self.buf = Buf(lines)
if mixed:
status.update(f'mixed newlines will be converted to {self.nl!r}')
@@ -267,8 +257,6 @@ class File:
for file_hl in self._file_hls:
file_hl.register_callbacks(self.buf)
self.go_to_line(self.initial_line, margin)
def __repr__(self) -> str:
return f'<{type(self).__name__} {self.filename!r}>'
@@ -292,11 +280,11 @@ class File:
@action
def home(self, margin: Margin) -> None:
self.buf.x = 0
self.buf.home()
@action
def end(self, margin: Margin) -> None:
self.buf.x = len(self.buf[self.buf.y])
self.buf.end()
@action
def ctrl_up(self, margin: Margin) -> None:
@@ -526,16 +514,16 @@ class File:
(s_y, _), (e_y, _) = self.selection.get()
for l_y in range(s_y, e_y + 1):
if self.buf[l_y]:
self.buf[l_y] = ' ' * self.buf.tab_size + self.buf[l_y]
self.buf[l_y] = ' ' * 4 + self.buf[l_y]
if l_y == self.buf.y:
self.buf.x += self.buf.tab_size
self.buf.x += 4
if l_y == sel_y and sel_x != 0:
sel_x += self.buf.tab_size
sel_x += 4
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
@edit_action('insert tab', final=False)
def _tab(self, margin: Margin) -> None:
n = self.buf.tab_size - self.buf.x % self.buf.tab_size
n = 4 - self.buf.x % 4
line = self.buf[self.buf.y]
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
self.buf.x += n
@@ -547,8 +535,9 @@ class File:
else:
self._tab(margin)
def _dedent_line(self, s: str) -> int:
bound = min(len(s), self.buf.tab_size)
@staticmethod
def _dedent_line(s: str) -> int:
bound = min(len(s), 4)
i = 0
while i < bound and s[i] == ' ':
i += 1
@@ -643,9 +632,9 @@ class File:
self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
self.buf.restore_eof_invariant()
def _sort(self, margin: Margin, s_y: int, e_y: int, reverse: bool) -> None:
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
# self.buf intentionally does not support slicing so we use islice
lines = sorted(itertools.islice(self.buf, s_y, e_y), reverse=reverse)
lines = sorted(itertools.islice(self.buf, s_y, e_y))
for i, line in zip(range(s_y, e_y), lines):
self.buf[i] = line
@@ -653,64 +642,18 @@ class File:
self.buf.x = 0
self.buf.scroll_screen_if_needed(margin)
def _selection_lines(self) -> Tuple[int, int]:
@edit_action('sort', final=True)
def sort(self, margin: Margin) -> None:
self._sort(margin, 0, len(self.buf) - 1)
@edit_action('sort selection', final=True)
@clear_selection
def sort_selection(self, margin: Margin) -> None:
(s_y, _), (e_y, _) = self.selection.get()
e_y = min(e_y + 1, len(self.buf) - 1)
if self.buf[e_y - 1] == '':
e_y -= 1
return s_y, e_y
@edit_action('sort', final=True)
def sort(self, margin: Margin, reverse: bool = False) -> None:
self._sort(margin, 0, len(self.buf) - 1, reverse=reverse)
@edit_action('sort selection', final=True)
@clear_selection
def sort_selection(self, margin: Margin, reverse: bool = False) -> None:
s_y, e_y = self._selection_lines()
self._sort(margin, s_y, e_y, reverse=reverse)
def _is_commented(self, lineno: int, prefix: str) -> bool:
return self.buf[lineno].lstrip().startswith(prefix)
def _comment_remove(self, lineno: int, prefix: str) -> None:
line = self.buf[lineno]
ws_match = WS_RE.match(line)
assert ws_match is not None
ws_len = len(ws_match[0])
rest_offset = ws_len + len(prefix)
if line.startswith(prefix, ws_len):
self.buf[lineno] = f'{ws_match[0]}{line[rest_offset:].lstrip()}'
if self.buf.y == lineno and self.buf.x > ws_len:
self.buf.x -= len(line) - len(self.buf[lineno])
def _comment_add(self, lineno: int, prefix: str) -> None:
prefix = f'{prefix} '
line = self.buf[lineno]
ws_match = WS_RE.match(line)
assert ws_match is not None
ws_len = len(ws_match[0])
self.buf[lineno] = f'{ws_match[0]}{prefix}{line[ws_len:]}'
if lineno == self.buf.y and self.buf.x > ws_len:
self.buf.x += len(prefix)
@edit_action('comment', final=True)
def toggle_comment(self, prefix: str) -> None:
if self._is_commented(self.buf.y, prefix):
self._comment_remove(self.buf.y, prefix)
else:
self._comment_add(self.buf.y, prefix)
@edit_action('comment selection', final=True)
@clear_selection
def toggle_comment_selection(self, prefix: str) -> None:
s_y, e_y = self._selection_lines()
commented = self._is_commented(s_y, prefix)
for lineno in range(s_y, e_y):
if commented:
self._comment_remove(lineno, prefix)
else:
self._comment_add(lineno, prefix)
self._sort(margin, s_y, e_y)
DISPATCH = {
# movement
@@ -755,10 +698,8 @@ class File:
@edit_action('text', final=False)
@clear_selection
def c(self, wch: str, margin: Margin) -> None:
s = self.buf[self.buf.y]
self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:]
self.buf.x += len(wch)
def c(self, wch: str) -> None:
self.buf.c(wch)
self.buf.restore_eof_invariant()
def finalize_previous_action(self) -> None:

View File

@@ -273,7 +273,6 @@ class CompiledRegsetRule(CompiledRule, Protocol):
class Entry(NamedTuple):
scope: Tuple[str, ...]
rule: CompiledRule
start: Tuple[str, int]
reg: _Reg = ERR_REG
boundary: bool = False
@@ -285,7 +284,7 @@ def _inner_capture_parse(
scope: Scope,
rule: CompiledRule,
) -> Regions:
state = State.root(Entry(scope + rule.name, rule, (s, 0)))
state = State.root(Entry(scope + rule.name, rule))
_, regions = highlight_line(compiler, state, s, first_line=False)
return tuple(
r._replace(start=r.start + start, end=r.end + start) for r in regions
@@ -441,8 +440,7 @@ class EndRule(NamedTuple):
boundary = match.end() == len(match.string)
reg = make_reg(expand_escaped(match, self.end))
start = (match.string, match.start())
state = state.push(Entry(next_scope, self, start, reg, boundary))
state = state.push(Entry(next_scope, self, reg, boundary))
regions = _captures(compiler, scope, match, self.begin_captures)
return state, True, regions
@@ -457,16 +455,7 @@ class EndRule(NamedTuple):
if m.start() > pos:
ret.append(Region(pos, m.start(), state.cur.scope))
ret.extend(_captures(compiler, state.cur.scope, m, self.end_captures))
# this is probably a bug in the grammar, but it pushed and popped at
# the same position.
# we'll advance the highlighter by one position to get past the loop
# this appears to be what vs code does as well
if state.entries[-1].start == (m.string, m.end()):
ret.append(Region(m.end(), m.end() + 1, state.cur.scope))
end = m.end() + 1
else:
end = m.end()
return state.pop(), end, False, tuple(ret)
return state.pop(), m.end(), False, tuple(ret)
def search(
self,
@@ -512,9 +501,7 @@ class WhileRule(NamedTuple):
boundary = match.end() == len(match.string)
reg = make_reg(expand_escaped(match, self.while_))
start = (match.string, match.start())
entry = Entry(next_scope, self, start, reg, boundary)
state = state.push_while(self, entry)
state = state.push_while(self, Entry(next_scope, self, reg, boundary))
regions = _captures(compiler, scope, match, self.begin_captures)
return state, True, regions
@@ -554,7 +541,7 @@ class Compiler:
self._rule_to_grammar: Dict[_Rule, Grammar] = {}
self._c_rules: Dict[_Rule, CompiledRule] = {}
root = self._compile_root(grammar)
self.root_state = State.root(Entry(root.name, root, ('', 0)))
self.root_state = State.root(Entry(root.name, root))
def _visit_rule(self, grammar: Grammar, rule: _Rule) -> _Rule:
self._rule_to_grammar[rule] = grammar

View File

@@ -1,4 +1,6 @@
import bisect
import curses
from typing import Tuple
from babi.cached_property import cached_property
@@ -18,18 +20,37 @@ def line_x(x: int, width: int) -> int:
)
def scrolled_line(s: str, x: int, width: int) -> str:
l_x = line_x(x, width)
def scrolled_line(
s: str,
positions: Tuple[int, ...],
cursor_x: int,
width: int,
) -> str:
l_x = line_x(cursor_x, width)
if l_x:
s = f'«{s[l_x + 1:]}'
if len(s) > width:
return f'{s[:width - 1]}»'
l_x_min = l_x + 1
start = bisect.bisect_left(positions, l_x_min)
pad_left = '«' * (positions[start] - l_x)
l_x_max = l_x + width
if positions[-1] > l_x_max:
end_max = l_x_max - 1
end = bisect.bisect_left(positions, end_max)
if positions[end] > end_max:
end -= 1
pad_right = '»' * (l_x_max - positions[end])
return f'{pad_left}{s[start:end].expandtabs(4)}{pad_right}'
else:
return s.ljust(width)
elif len(s) > width:
return f'{s[:width - 1]}»'
return f'{pad_left}{s[start:]}'.ljust(width)
elif positions[-1] > width:
end_max = width - 1
end = bisect.bisect_left(positions, end_max)
if positions[end] > end_max:
end -= 1
pad_right = '»' * (width - positions[end])
return f'{s[:end].expandtabs(4)}{pad_right}'
else:
return s.ljust(width)
return s.expandtabs(4).ljust(width)
class _CalcWidth:

View File

@@ -1,13 +1,9 @@
import argparse
import curses
import os
import re
import signal
import sys
from typing import List
from typing import Optional
from typing import Sequence
from typing import Tuple
from babi.buf import Buf
from babi.file import File
@@ -18,11 +14,10 @@ from babi.screen import make_stdscr
from babi.screen import Screen
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
POSITION_RE = re.compile(r'^\+-?\d+$')
def _edit(screen: Screen, stdin: str) -> EditResult:
screen.file.ensure_loaded(screen.status, screen.margin, stdin)
screen.file.ensure_loaded(screen.status, stdin)
while True:
screen.status.tick(screen.margin)
@@ -38,43 +33,42 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
return ret
elif key.keyname == b'STRING':
assert isinstance(key.wch, str), key.wch
screen.file.c(key.wch, screen.margin)
screen.file.c(key.wch)
else:
screen.status.update(f'unknown key: {key}')
def c_main(
stdscr: 'curses._CursesWindow',
filenames: List[Optional[str]],
positions: List[int],
args: argparse.Namespace,
stdin: str,
perf: Perf,
) -> int:
screen = Screen(stdscr, filenames, positions, perf)
with screen.history.save():
while screen.files:
screen.i = screen.i % len(screen.files)
res = _edit(screen, stdin)
if res == EditResult.EXIT:
del screen.files[screen.i]
# always go to the next file except at the end
screen.i = min(screen.i, len(screen.files) - 1)
screen.status.clear()
elif res == EditResult.NEXT:
screen.i += 1
screen.status.clear()
elif res == EditResult.PREV:
screen.i -= 1
screen.status.clear()
elif res == EditResult.OPEN:
screen.i = len(screen.files) - 1
else:
raise AssertionError(f'unreachable {res}')
with perf_log(args.perf_log) as perf:
screen = Screen(stdscr, args.filenames or [None], perf)
with screen.history.save():
while screen.files:
screen.i = screen.i % len(screen.files)
res = _edit(screen, stdin)
if res == EditResult.EXIT:
del screen.files[screen.i]
# always go to the next file except at the end
screen.i = min(screen.i, len(screen.files) - 1)
screen.status.clear()
elif res == EditResult.NEXT:
screen.i += 1
screen.status.clear()
elif res == EditResult.PREV:
screen.i -= 1
screen.status.clear()
elif res == EditResult.OPEN:
screen.i = len(screen.files) - 1
else:
raise AssertionError(f'unreachable {res}')
return 0
def _key_debug(stdscr: 'curses._CursesWindow', perf: Perf) -> int:
screen = Screen(stdscr, ['<<key debug>>'], [0], perf)
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
screen = Screen(stdscr, ['<<key debug>>'], Perf())
screen.file.buf = Buf([''])
while True:
@@ -91,37 +85,6 @@ def _key_debug(stdscr: 'curses._CursesWindow', perf: Perf) -> int:
return 0
def _filenames(filenames: List[str]) -> Tuple[List[Optional[str]], List[int]]:
if not filenames:
return [None], [0]
ret_filenames: List[Optional[str]] = []
ret_positions = []
filenames_iter = iter(filenames)
for filename in filenames_iter:
if POSITION_RE.match(filename):
# in the success case we get:
#
# position_s = +...
# filename = (the next thing)
#
# in the error case we only need to reset `position_s` as
# `filename` is already correct
position_s = filename
try:
filename = next(filenames_iter)
except StopIteration:
position_s = '+0'
ret_positions.append(int(position_s[1:]))
ret_filenames.append(filename)
else:
ret_positions.append(0)
ret_filenames.append(filename)
return ret_filenames, ret_positions
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument('filenames', metavar='filename', nargs='*')
@@ -139,17 +102,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
else:
stdin = ''
# ignore backgrounding signals, we'll handle those in curses
# fixes a problem with ^Z on termination which would break the terminal
if sys.platform != 'win32': # pragma: win32 no cover # pragma: no branch
signal.signal(signal.SIGTSTP, signal.SIG_IGN)
with perf_log(args.perf_log) as perf, make_stdscr() as stdscr:
with make_stdscr() as stdscr:
if args.key_debug:
return _key_debug(stdscr, perf)
return _key_debug(stdscr)
else:
filenames, positions = _filenames(args.filenames)
return c_main(stdscr, filenames, positions, stdin, perf)
return c_main(stdscr, args, stdin)
if __name__ == '__main__':

View File

@@ -6,8 +6,7 @@ from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
from babi.horizontal_scrolling import line_x
from babi.horizontal_scrolling import scrolled_line
from babi.buf import Buf
if TYPE_CHECKING:
from babi.main import Screen # XXX: circular
@@ -19,17 +18,25 @@ class Prompt:
def __init__(self, screen: 'Screen', prompt: str, lst: List[str]) -> None:
self._screen = screen
self._prompt = prompt
self._lst = lst
self._y = len(lst) - 1
self._buf = Buf(lst)
self._buf.y = self._buf.file_y = len(lst) - 1
self._x = len(self._s)
@property
def _x(self) -> int:
return self._buf.x
@_x.setter
def _x(self, x: int) -> None:
self._buf.x = x
@property
def _s(self) -> str:
return self._lst[self._y]
return self._buf[self._buf.y]
@_s.setter
def _s(self, s: str) -> None:
self._lst[self._y] = s
self._buf[self._buf.y] = s
def _render_prompt(self, *, base: Optional[str] = None) -> None:
base = base or self._prompt
@@ -39,24 +46,26 @@ class Prompt:
prompt_s = f'{base[:self._screen.margin.cols - 7]}…: '
else:
prompt_s = f'{base}: '
width = self._screen.margin.cols - len(prompt_s)
line = scrolled_line(self._s, self._x, width)
cmd = f'{prompt_s}{line}'
margin = self._screen.margin._replace(cols=width)
cmd = f'{prompt_s}{self._buf.rendered_line(self._buf.y, margin)}'
prompt_line = self._screen.margin.lines - 1
self._screen.stdscr.insstr(prompt_line, 0, cmd, curses.A_REVERSE)
x = len(prompt_s) + self._x - line_x(self._x, width)
self._screen.stdscr.move(prompt_line, x)
_, x_off = self._buf.cursor_position(margin)
self._screen.stdscr.move(prompt_line, len(prompt_s) + x_off)
def _up(self) -> None:
self._y = max(0, self._y - 1)
self._x = len(self._s)
self._buf.up(self._screen.margin)
self._x = len(self._buf[self._buf.y])
def _down(self) -> None:
self._y = min(len(self._lst) - 1, self._y + 1)
self._x = len(self._s)
self._buf.down(self._screen.margin)
self._x = len(self._buf[self._buf.y])
def _right(self) -> None:
self._x = min(len(self._s), self._x + 1)
self._x = min(len(self._buf[self._buf.y]), self._x + 1)
def _left(self) -> None:
self._x = max(0, self._x - 1)
@@ -65,11 +74,11 @@ class Prompt:
self._x = 0
def _end(self) -> None:
self._x = len(self._s)
self._x = len(self._buf[self._buf.y])
def _ctrl_left(self) -> None:
if self._x <= 1:
self._x = 0
self._buf.home()
else:
self._x -= 1
tp = self._s[self._x - 1].isalnum()
@@ -78,7 +87,7 @@ class Prompt:
def _ctrl_right(self) -> None:
if self._x >= len(self._s) - 1:
self._x = len(self._s)
self._buf.end()
else:
self._x += 1
tp = self._s[self._x].isalnum()
@@ -103,9 +112,9 @@ class Prompt:
def _check_failed(self, idx: int, s: str) -> Tuple[bool, int]:
failed = False
for search_idx in range(idx, -1, -1):
if s in self._lst[search_idx]:
idx = self._y = search_idx
self._x = self._lst[search_idx].index(s)
if s in self._buf[search_idx]:
idx = self._buf.y = search_idx
self._x = self._buf[search_idx].index(s)
break
else:
failed = True
@@ -113,7 +122,7 @@ class Prompt:
def _reverse_search(self) -> Union[None, str, PromptResult]:
reverse_s = ''
idx = self._y
idx = self._buf.y
while True:
fail, idx = self._check_failed(idx, reverse_s)
@@ -174,8 +183,7 @@ class Prompt:
}
def _c(self, c: str) -> None:
self._s = self._s[:self._x] + c + self._s[self._x:]
self._x += len(c)
self._buf.c(c)
def run(self) -> Union[PromptResult, str]:
while True:

View File

@@ -6,36 +6,80 @@ from typing import Tuple
import onigurumacffi
from babi.cached_property import cached_property
_BACKREF_RE = re.compile(r'((?<!\\)(?:\\\\)*)\\([0-9]+)')
_FLAGS = {
# (first_line, boundary)
(False, False): (
onigurumacffi.OnigSearchOption.NOT_END_STRING |
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING |
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION
),
(False, True): (
onigurumacffi.OnigSearchOption.NOT_END_STRING |
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING
),
(True, False): (
onigurumacffi.OnigSearchOption.NOT_END_STRING |
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION
),
(True, True): onigurumacffi.OnigSearchOption.NOT_END_STRING,
}
def _replace_esc(s: str, chars: str) -> str:
"""replace the given escape sequences of `chars` with \\uffff"""
for c in chars:
if f'\\{c}' in s:
break
else:
return s
b = []
i = 0
length = len(s)
while i < length:
try:
sbi = s.index('\\', i)
except ValueError:
b.append(s[i:])
break
if sbi > i:
b.append(s[i:sbi])
b.append('\\')
i = sbi + 1
if i < length:
if s[i] in chars:
b.append('\uffff')
else:
b.append(s[i])
i += 1
return ''.join(b)
class _Reg:
def __init__(self, s: str) -> None:
self._pattern = s
self._reg = onigurumacffi.compile(self._pattern)
def __repr__(self) -> str:
return f'{type(self).__name__}({self._pattern!r})'
@cached_property
def _reg(self) -> onigurumacffi._Pattern:
return onigurumacffi.compile(_replace_esc(self._pattern, 'z'))
@cached_property
def _reg_no_A(self) -> onigurumacffi._Pattern:
return onigurumacffi.compile(_replace_esc(self._pattern, 'Az'))
@cached_property
def _reg_no_G(self) -> onigurumacffi._Pattern:
return onigurumacffi.compile(_replace_esc(self._pattern, 'Gz'))
@cached_property
def _reg_no_A_no_G(self) -> onigurumacffi._Pattern:
return onigurumacffi.compile(_replace_esc(self._pattern, 'AGz'))
def _get_reg(
self,
first_line: bool,
boundary: bool,
) -> onigurumacffi._Pattern:
if boundary:
if first_line:
return self._reg
else:
return self._reg_no_A
else:
if first_line:
return self._reg_no_G
else:
return self._reg_no_A_no_G
def search(
self,
line: str,
@@ -43,7 +87,7 @@ class _Reg:
first_line: bool,
boundary: bool,
) -> Optional[Match[str]]:
return self._reg.search(line, pos, flags=_FLAGS[first_line, boundary])
return self._get_reg(first_line, boundary).search(line, pos)
def match(
self,
@@ -52,18 +96,36 @@ class _Reg:
first_line: bool,
boundary: bool,
) -> Optional[Match[str]]:
return self._reg.match(line, pos, flags=_FLAGS[first_line, boundary])
return self._get_reg(first_line, boundary).match(line, pos)
class _RegSet:
def __init__(self, *s: str) -> None:
self._patterns = s
self._set = onigurumacffi.compile_regset(*self._patterns)
def __repr__(self) -> str:
args = ', '.join(repr(s) for s in self._patterns)
return f'{type(self).__name__}({args})'
@cached_property
def _set(self) -> onigurumacffi._RegSet:
return onigurumacffi.compile_regset(*self._patterns)
@cached_property
def _set_no_A(self) -> onigurumacffi._RegSet:
patterns = (_replace_esc(p, 'A') for p in self._patterns)
return onigurumacffi.compile_regset(*patterns)
@cached_property
def _set_no_G(self) -> onigurumacffi._RegSet:
patterns = (_replace_esc(p, 'G') for p in self._patterns)
return onigurumacffi.compile_regset(*patterns)
@cached_property
def _set_no_A_no_G(self) -> onigurumacffi._RegSet:
patterns = (_replace_esc(p, 'AG') for p in self._patterns)
return onigurumacffi.compile_regset(*patterns)
def search(
self,
line: str,
@@ -71,7 +133,16 @@ class _RegSet:
first_line: bool,
boundary: bool,
) -> Tuple[int, Optional[Match[str]]]:
return self._set.search(line, pos, flags=_FLAGS[first_line, boundary])
if boundary:
if first_line:
return self._set.search(line, pos)
else:
return self._set_no_A.search(line, pos)
else:
if first_line:
return self._set_no_G.search(line, pos)
else:
return self._set_no_A_no_G.search(line, pos)
def expand_escaped(match: Match[str], s: str) -> str:
@@ -80,4 +151,4 @@ def expand_escaped(match: Match[str], s: str) -> str:
make_reg = functools.lru_cache(maxsize=None)(_Reg)
make_regset = functools.lru_cache(maxsize=None)(_RegSet)
ERR_REG = make_reg('$ ^')
ERR_REG = make_reg(')this pattern always triggers an error when used(')

View File

@@ -81,11 +81,8 @@ KEYNAME_REWRITE = {
b'CTL_DOWN': b'kDN5',
b'CTL_RIGHT': b'kRIT5',
b'CTL_LEFT': b'kLFT5',
b'CTL_HOME': b'kHOM5',
b'CTL_END': b'kEND5',
b'ALT_RIGHT': b'kRIT3',
b'ALT_LEFT': b'kLFT3',
b'ALT_E': b'M-e',
# windows-curses: idk why these are different
b'KEY_SUP': b'KEY_SR',
b'KEY_SDOWN': b'KEY_SF',
@@ -93,7 +90,6 @@ KEYNAME_REWRITE = {
b'^?': b'KEY_BACKSPACE',
# linux, perhaps others
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
b'PADENTER': b'^M', # Enter on numpad
}
@@ -107,15 +103,14 @@ class Screen:
self,
stdscr: 'curses._CursesWindow',
filenames: List[Optional[str]],
initial_lines: List[int],
perf: Perf,
) -> None:
self.stdscr = stdscr
self.color_manager = ColorManager.make()
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
self.files = [
File(filename, line, self.color_manager, self.hl_factories)
for filename, line in zip(filenames, initial_lines)
File(filename, self.color_manager, self.hl_factories)
for filename in filenames
]
self.i = 0
self.history = History()
@@ -226,10 +221,7 @@ class Screen:
if self._buffered_input is not None:
wch, self._buffered_input = self._buffered_input, None
else:
try:
wch = self.stdscr.get_wch()
except curses.error: # pragma: no cover (macos bug?)
wch = self.stdscr.get_wch()
wch = self.stdscr.get_wch()
if isinstance(wch, str) and wch == '\x1b':
wch = self._get_sequence(wch)
if len(wch) == 2:
@@ -421,9 +413,7 @@ class Screen:
def command(self) -> Optional[EditResult]:
response = self.prompt('', history='command')
if response is PromptResult.CANCELLED:
pass
elif response == ':q':
if response == ':q':
return self.quit_save_modified()
elif response == ':q!':
return EditResult.EXIT
@@ -438,33 +428,7 @@ class Screen:
else:
self.file.sort(self.margin)
self.status.update('sorted!')
elif response == ':sort!':
if self.file.selection.start:
self.file.sort_selection(self.margin, reverse=True)
else:
self.file.sort(self.margin, reverse=True)
self.status.update('sorted!')
elif response.startswith((':tabstop ', ':tabsize ')):
_, _, tab_size = response.partition(' ')
try:
parsed_tab_size = int(tab_size)
except ValueError:
self.status.update(f'invalid size: {tab_size}')
else:
if parsed_tab_size <= 0:
self.status.update(f'invalid size: {parsed_tab_size}')
else:
for file in self.files:
file.buf.set_tab_size(parsed_tab_size)
self.status.update('updated!')
elif response == ':comment' or response.startswith(':comment '):
_, _, comment = response.partition(' ')
comment = (comment or '#').strip()
if self.file.selection.start:
self.file.toggle_comment_selection(comment)
else:
self.file.toggle_comment(comment)
else:
elif response is not PromptResult.CANCELLED:
self.status.update(f'invalid command: {response}')
return None
@@ -525,7 +489,7 @@ class Screen:
def open_file(self) -> Optional[EditResult]:
response = self.prompt('enter filename', history='open')
if response is not PromptResult.CANCELLED:
opened = File(response, 0, self.color_manager, self.hl_factories)
opened = File(response, self.color_manager, self.hl_factories)
self.files.append(opened)
return EditResult.OPEN
else:
@@ -549,13 +513,10 @@ class Screen:
return EditResult.EXIT
def background(self) -> None:
if sys.platform == 'win32': # pragma: win32 cover
self.status.update('cannot run babi in background on Windows')
else: # pragma: win32 no cover
curses.endwin()
os.kill(os.getpid(), signal.SIGSTOP)
self.stdscr = _init_screen()
self.resize()
curses.endwin()
os.kill(os.getpid(), signal.SIGSTOP)
self.stdscr = _init_screen()
self.resize()
DISPATCH = {
b'KEY_RESIZE': resize,
@@ -565,7 +526,6 @@ class Screen:
b'^U': uncut,
b'M-u': undo,
b'M-U': redo,
b'M-e': redo,
b'^W': search,
b'^\\': replace,
b'^[': command,

View File

@@ -3,3 +3,4 @@ coverage
git+https://github.com/asottile/hecate@875567f
pytest
remote-pdb
wcwidth

View File

@@ -1,6 +1,6 @@
[metadata]
name = babi
version = 0.0.16
version = 0.0.7
description = a text editor
long_description = file: README.md
long_description_content_type = text/markdown
@@ -24,7 +24,7 @@ packages = find:
install_requires =
babi-grammars
identify
onigurumacffi>=0.0.18
onigurumacffi>=0.0.10
importlib_metadata>=1;python_version<"3.8"
windows-curses;sys_platform=="win32"
python_requires = >=3.6.1

View File

@@ -1,114 +0,0 @@
from testing.runner import and_exit
from testing.runner import trigger_command_mode
def test_comment_some_code(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('S-Down')
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_text('# line_0\n# line_1\nline_2\n')
def test_comment_some_code_with_alternate_comment_character(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('S-Down')
trigger_command_mode(h)
h.press_and_enter(':comment //')
h.await_text('// line_0\n// line_1\nline_2\n')
def test_comment_partially_commented(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('#')
h.press('S-Down')
h.await_text('#line_0\nline_1\nline_2')
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_text('line_0\nline_1\nline_2\n')
def test_comment_partially_uncommented(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('Down')
h.press('#')
h.press('Up')
h.press('S-Down')
h.await_text('line_0\n#line_1\nline_2')
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_text('# line_0\n# #line_1\nline_2\n')
def test_comment_single_line(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_text('# line_0\nline_1\n')
def test_uncomment_single_line(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('#')
h.await_text('#line_0\nline_1\n')
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_text('line_0\nline_1\n')
def test_comment_with_trailing_whitespace(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
trigger_command_mode(h)
h.press_and_enter(':comment // ')
h.await_text('// line_0\nline_1\n')
def test_comment_cursor_at_end_of_line(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('# ')
h.press('End')
h.await_cursor_position(x=8, y=1)
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_cursor_position(x=6, y=1)
def test_add_comment_moves_cursor(run, ten_lines):
with run(str(ten_lines)) as h, and_exit(h):
h.press('End')
h.await_cursor_position(x=6, y=1)
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_cursor_position(x=8, y=1)
def test_do_not_move_if_cursor_before_comment(run, tmpdir):
f = tmpdir.join('f')
f.write('\t\tfoo')
with run(str(f)) as h, and_exit(h):
h.press('Right')
h.await_cursor_position(x=4, y=1)
trigger_command_mode(h)
h.press_and_enter(':comment')
h.await_cursor_position(x=4, y=1)

View File

@@ -9,6 +9,7 @@ from typing import Union
from unittest import mock
import pytest
import wcwidth
from babi._types import Protocol
from babi.main import main
@@ -71,7 +72,7 @@ class Screen:
self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):]
self.y = y
self.x = x + len(s)
self.x = x + wcwidth.wcswidth(s)
def insstr(self, y, x, s, attr):
line = self.lines[y]
@@ -303,7 +304,6 @@ class DeferredRunner:
self.color_pairs = {0: (7, 0)}
self.screen = Screen(width, height)
self._n_colors, self._can_change_color = {
'xterm-mono': (0, False),
'screen': (8, False),
'screen-256color': (256, False),
'xterm-256color': (256, True),
@@ -392,7 +392,6 @@ class DeferredRunner:
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
_curses_set_escdelay = _curses__noop
_curses_error = curses.error # so we don't mock the exception

View File

@@ -1,28 +0,0 @@
from testing.runner import and_exit
def test_open_file_named_plus_something(run):
with run('+3') as h, and_exit(h):
h.await_text(' +3')
def test_initial_position_one_file(run, tmpdir):
f = tmpdir.join('f')
f.write('hello\nworld\n')
with run('+2', str(f)) as h, and_exit(h):
h.await_cursor_position(x=0, y=2)
def test_initial_position_multiple_files(run, tmpdir):
f = tmpdir.join('f')
f.write('1\n2\n3\n4\n')
g = tmpdir.join('g')
g.write('5\n6\n7\n8\n')
with run('+2', str(f), '+3', str(g)) as h, and_exit(h):
h.await_cursor_position(x=0, y=2)
h.press('^X')
h.await_cursor_position(x=0, y=3)

View File

@@ -415,10 +415,18 @@ def test_sequence_handling(run_only_fake):
def test_indentation_using_tabs(run, tmpdir):
f = tmpdir.join('f')
f.write(f'123456789\n\t12\t{"x" * 20}\n')
f.write(
f'123456789\n'
f'\t12\t{"x" * 20}\n'
f'\tnot long\n',
)
with run(str(f), width=20) as h, and_exit(h):
h.await_text('123456789\n 12 xxxxxxxxxxx»\n')
h.await_text(
'123456789\n'
' 12 xxxxxxxxxxx»\n'
' not long\n',
)
h.press('Down')
h.await_cursor_position(x=0, y=2)
@@ -438,3 +446,43 @@ def test_indentation_using_tabs(run, tmpdir):
h.await_cursor_position(x=4, y=2)
h.press('Up')
h.await_cursor_position(x=4, y=1)
def test_movement_with_wide_characters(run, tmpdir):
f = tmpdir.join('f')
f.write(
f'{"🙃" * 20}\n'
f'a{"🙃" * 20}\n',
)
with run(str(f), width=20) as h, and_exit(h):
h.await_text(
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)
for _ in range(10):
h.press('Right')
h.await_text(
'««🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)
for _ in range(6):
h.press('Right')
h.await_text(
'««🙃🙃🙃🙃🙃🙃🙃\n'
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)
h.press('Down')
h.await_text(
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'«🙃🙃🙃🙃🙃🙃🙃🙃\n',
)
h.press('Left')
h.await_text(
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'«🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)

View File

@@ -133,7 +133,7 @@ def test_save_via_ctrl_o(run, tmpdir):
with run(str(f)) as h, and_exit(h):
h.press('hello world')
h.press('^O')
h.await_text('enter filename: ')
h.await_text(f'enter filename: ')
h.press('Enter')
h.await_text('saved! (1 line written)')
assert f.read() == 'hello world\n'
@@ -237,7 +237,7 @@ def test_vim_save_on_exit(run, tmpdir):
h.press_and_enter(':q')
h.await_text('file is modified - save [yes, no]?')
h.press('y')
h.await_text('enter filename: ')
h.await_text(f'enter filename: ')
h.press('Enter')
h.await_exit()

View File

@@ -21,16 +21,6 @@ def test_sort_entire_file(run, unsorted):
assert unsorted.read() == 'a\nb\nc\nd\n'
def test_reverse_sort_entire_file(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
trigger_command_mode(h)
h.press_and_enter(':sort!')
h.await_text('sorted!')
h.await_cursor_position(x=0, y=1)
h.press('^S')
assert unsorted.read() == 'd\nc\nb\na\n'
def test_sort_selection(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
h.press('S-Down')
@@ -42,18 +32,6 @@ def test_sort_selection(run, unsorted):
assert unsorted.read() == 'b\nd\nc\na\n'
def test_reverse_sort_selection(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
h.press('Down')
h.press('S-Down')
trigger_command_mode(h)
h.press_and_enter(':sort!')
h.await_text('sorted!')
h.await_cursor_position(x=0, y=2)
h.press('^S')
assert unsorted.read() == 'd\nc\nb\na\n'
def test_sort_selection_does_not_include_eof(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
for _ in range(5):

View File

@@ -153,8 +153,3 @@ def test_syntax_highlighting_tabs_after_line_creation(run, tmpdir):
h.press('Enter')
h.await_text('foo\n x\nx\ny\n')
def test_does_not_crash_with_no_color_support(run):
with run(term='xterm-mono') as h, and_exit(h):
pass

View File

@@ -1,30 +0,0 @@
import pytest
from testing.runner import and_exit
from testing.runner import trigger_command_mode
@pytest.mark.parametrize('setting', ('tabsize', 'tabstop'))
def test_set_tabstop(run, setting):
with run() as h, and_exit(h):
h.press('a')
h.press('Left')
trigger_command_mode(h)
h.press_and_enter(f':{setting} 2')
h.await_text('updated!')
h.press('Tab')
h.await_text('\n a')
h.await_cursor_position(x=2, y=1)
@pytest.mark.parametrize('tabsize', ('-1', '0', 'wat'))
def test_set_invalid_tabstop(run, tabsize):
with run() as h, and_exit(h):
h.press('a')
h.press('Left')
trigger_command_mode(h)
h.press_and_enter(f':tabstop {tabsize}')
h.await_text(f'invalid size: {tabsize}')
h.press('Tab')
h.await_text(' a')
h.await_cursor_position(x=4, y=1)

View File

@@ -1,5 +1,3 @@
import pytest
from testing.runner import and_exit
@@ -11,8 +9,7 @@ def test_nothing_to_undo_redo(run):
h.await_text('nothing to redo!')
@pytest.mark.parametrize('r', ('M-U', 'M-e'))
def test_undo_redo(run, r):
def test_undo_redo(run):
with run() as h, and_exit(h):
h.press('hello')
h.await_text('hello')
@@ -20,7 +17,7 @@ def test_undo_redo(run, r):
h.await_text('undo: text')
h.await_text_missing('hello')
h.await_text_missing(' *')
h.press(r)
h.press('M-U')
h.await_text('redo: text')
h.await_text('hello')
h.await_text(' *')

View File

@@ -8,7 +8,7 @@ from babi.file import get_lines
def test_position_repr():
ret = repr(File('f.txt', 0, ColorManager.make(), ()))
ret = repr(File('f.txt', ColorManager.make(), ()))
assert ret == "<File 'f.txt'>"

View File

@@ -637,25 +637,3 @@ def test_backslash_z(compiler_state):
assert regions2 == (
Region(0, 6, ('test', 'comment')),
)
def test_buggy_begin_end_grammar(compiler_state):
# before this would result in an infinite loop of start / end
compiler, state = compiler_state({
'scopeName': 'test',
'patterns': [
{
'begin': '(?=</style)',
'end': '(?=</style)',
'name': 'css',
},
],
})
state, regions = highlight_line(compiler, state, 'test </style', True)
assert regions == (
Region(0, 5, ('test',)),
Region(5, 6, ('test', 'css')),
Region(6, 12, ('test',)),
)

View File

@@ -1,19 +0,0 @@
import pytest
from babi import main
@pytest.mark.parametrize(
('in_filenames', 'expected_filenames', 'expected_positions'),
(
([], [None], [0]),
(['+3'], ['+3'], [0]),
(['f'], ['f'], [0]),
(['+3', 'f'], ['f'], [3]),
(['+-3', 'f'], ['f'], [-3]),
(['+3', '+3'], ['+3'], [3]),
(['+2', 'f', '+5', 'g'], ['f', 'g'], [2, 5]),
),
)
def test_filenames(in_filenames, expected_filenames, expected_positions):
filenames, positions = main._filenames(in_filenames)

View File

@@ -35,8 +35,9 @@ def test_reg_other_escapes_left_untouched():
def test_reg_not_out_of_bounds_at_end():
# the only way this is triggerable is with an illegal regex, we'd rather
# produce an error about the regex being wrong than an IndexError
reg = _Reg('\\A\\')
with pytest.raises(onigurumacffi.OnigError) as excinfo:
_Reg('\\A\\')
reg.search('\\', 0, first_line=False, boundary=False)
msg, = excinfo.value.args
assert msg == 'end pattern at escape'