14 Commits

Author SHA1 Message Date
Anthony Sottile
29062628f9 v0.0.14 2020-08-24 14:03:36 -07:00
Anthony Sottile
1fab2a4b71 Merge pull request #85 from asottile/comment
add :comment command for toggling comments
2020-08-24 14:02:23 -07:00
Anthony Sottile
9f5e8c02cb add :comment command for toggling comments 2020-08-24 13:52:35 -07:00
Anthony Sottile
31624856d2 Merge pull request #84 from asottile/asottile-patch-1
add logo to README
2020-08-11 23:27:25 -07:00
Anthony Sottile
97b3b4deef add logo to README 2020-08-11 23:20:48 -07:00
Anthony Sottile
41880d5f8c v0.0.13 2020-07-24 15:28:01 -07:00
Anthony Sottile
effe988f60 Merge pull request #81 from asottile/fix_begin_end_hang
fix highlighting hang with empty begin end rules
2020-07-24 15:26:38 -07:00
Anthony Sottile
84b20a4016 fix highlighting hang with empty begin end rules 2020-07-24 15:13:35 -07:00
Anthony Sottile
5d2c9532a3 s/usually use nano/used to use nano/ 2020-07-20 20:06:24 -07:00
Anthony Sottile
33ff8d9726 v0.0.12 2020-07-13 13:33:59 -07:00
Anthony Sottile
f0b2af9a9f Merge pull request #77 from asottile/regex_flags
leverage new regex flags
2020-07-01 17:34:20 -07:00
Anthony Sottile
fc21a144aa leverage new regex flags 2020-07-01 17:07:32 -07:00
Anthony Sottile
973b4c3cf8 Merge pull request #76 from asottile/fix_background_on_close
fix race condition with ^Z on close
2020-06-29 13:37:14 -07:00
Anthony Sottile
bd60977438 fix race condition with ^Z on close 2020-06-29 13:13:14 -07:00
10 changed files with 205 additions and 108 deletions

View File

@@ -1,6 +1,8 @@
[![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) [![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) [![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 babi
==== ====
@@ -12,7 +14,7 @@ a text editor, eventually...
### why is it called babi? ### why is it called babi?
I usually use the text editor `nano`, frequently I typo this. on a qwerty I used to 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`. keyboard, when the right hand is shifted left by one, `nano` becomes `babi`.
### quitting babi ### quitting babi

View File

@@ -6,6 +6,7 @@ import hashlib
import io import io
import itertools import itertools
import os.path import os.path
import re
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import cast from typing import cast
@@ -38,6 +39,8 @@ if TYPE_CHECKING:
TCallable = TypeVar('TCallable', bound=Callable[..., Any]) TCallable = TypeVar('TCallable', bound=Callable[..., Any])
WS_RE = re.compile(r'^\s*')
def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]: def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
sha256 = hashlib.sha256() sha256 = hashlib.sha256()
@@ -650,6 +653,13 @@ class File:
self.buf.x = 0 self.buf.x = 0
self.buf.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
def _selection_lines(self) -> Tuple[int, int]:
(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) @edit_action('sort', final=True)
def sort(self, margin: Margin, reverse: bool = False) -> None: def sort(self, margin: Margin, reverse: bool = False) -> None:
self._sort(margin, 0, len(self.buf) - 1, reverse=reverse) self._sort(margin, 0, len(self.buf) - 1, reverse=reverse)
@@ -657,12 +667,46 @@ class File:
@edit_action('sort selection', final=True) @edit_action('sort selection', final=True)
@clear_selection @clear_selection
def sort_selection(self, margin: Margin, reverse: bool = False) -> None: def sort_selection(self, margin: Margin, reverse: bool = False) -> None:
(s_y, _), (e_y, _) = self.selection.get() s_y, e_y = self._selection_lines()
e_y = min(e_y + 1, len(self.buf) - 1)
if self.buf[e_y - 1] == '':
e_y -= 1
self._sort(margin, s_y, e_y, reverse=reverse) 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()}'
def _comment_add(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])
self.buf[lineno] = f'{ws_match[0]}{prefix} {line[ws_len:]}'
@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)
DISPATCH = { DISPATCH = {
# movement # movement
b'KEY_UP': up, b'KEY_UP': up,

View File

@@ -273,6 +273,7 @@ class CompiledRegsetRule(CompiledRule, Protocol):
class Entry(NamedTuple): class Entry(NamedTuple):
scope: Tuple[str, ...] scope: Tuple[str, ...]
rule: CompiledRule rule: CompiledRule
start: Tuple[str, int]
reg: _Reg = ERR_REG reg: _Reg = ERR_REG
boundary: bool = False boundary: bool = False
@@ -284,7 +285,7 @@ def _inner_capture_parse(
scope: Scope, scope: Scope,
rule: CompiledRule, rule: CompiledRule,
) -> Regions: ) -> Regions:
state = State.root(Entry(scope + rule.name, rule)) state = State.root(Entry(scope + rule.name, rule, (s, 0)))
_, regions = highlight_line(compiler, state, s, first_line=False) _, regions = highlight_line(compiler, state, s, first_line=False)
return tuple( return tuple(
r._replace(start=r.start + start, end=r.end + start) for r in regions r._replace(start=r.start + start, end=r.end + start) for r in regions
@@ -440,7 +441,8 @@ class EndRule(NamedTuple):
boundary = match.end() == len(match.string) boundary = match.end() == len(match.string)
reg = make_reg(expand_escaped(match, self.end)) reg = make_reg(expand_escaped(match, self.end))
state = state.push(Entry(next_scope, self, reg, boundary)) start = (match.string, match.start())
state = state.push(Entry(next_scope, self, start, reg, boundary))
regions = _captures(compiler, scope, match, self.begin_captures) regions = _captures(compiler, scope, match, self.begin_captures)
return state, True, regions return state, True, regions
@@ -455,7 +457,16 @@ class EndRule(NamedTuple):
if m.start() > pos: if m.start() > pos:
ret.append(Region(pos, m.start(), state.cur.scope)) ret.append(Region(pos, m.start(), state.cur.scope))
ret.extend(_captures(compiler, state.cur.scope, m, self.end_captures)) ret.extend(_captures(compiler, state.cur.scope, m, self.end_captures))
return state.pop(), m.end(), False, tuple(ret) # 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)
def search( def search(
self, self,
@@ -501,7 +512,9 @@ class WhileRule(NamedTuple):
boundary = match.end() == len(match.string) boundary = match.end() == len(match.string)
reg = make_reg(expand_escaped(match, self.while_)) reg = make_reg(expand_escaped(match, self.while_))
state = state.push_while(self, Entry(next_scope, self, reg, boundary)) start = (match.string, match.start())
entry = Entry(next_scope, self, start, reg, boundary)
state = state.push_while(self, entry)
regions = _captures(compiler, scope, match, self.begin_captures) regions = _captures(compiler, scope, match, self.begin_captures)
return state, True, regions return state, True, regions
@@ -541,7 +554,7 @@ class Compiler:
self._rule_to_grammar: Dict[_Rule, Grammar] = {} self._rule_to_grammar: Dict[_Rule, Grammar] = {}
self._c_rules: Dict[_Rule, CompiledRule] = {} self._c_rules: Dict[_Rule, CompiledRule] = {}
root = self._compile_root(grammar) root = self._compile_root(grammar)
self.root_state = State.root(Entry(root.name, root)) self.root_state = State.root(Entry(root.name, root, ('', 0)))
def _visit_rule(self, grammar: Grammar, rule: _Rule) -> _Rule: def _visit_rule(self, grammar: Grammar, rule: _Rule) -> _Rule:
self._rule_to_grammar[rule] = grammar self._rule_to_grammar[rule] = grammar

View File

@@ -2,6 +2,7 @@ import argparse
import curses import curses
import os import os
import re import re
import signal
import sys import sys
from typing import List from typing import List
from typing import Optional from typing import Optional
@@ -138,6 +139,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
else: else:
stdin = '' 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 perf_log(args.perf_log) as perf, make_stdscr() as stdscr:
if args.key_debug: if args.key_debug:
return _key_debug(stdscr, perf) return _key_debug(stdscr, perf)

View File

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

View File

@@ -457,6 +457,13 @@ class Screen:
for file in self.files: for file in self.files:
file.buf.set_tab_size(parsed_tab_size) file.buf.set_tab_size(parsed_tab_size)
self.status.update('updated!') 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: else:
self.status.update(f'invalid command: {response}') self.status.update(f'invalid command: {response}')
return None return None

View File

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

View File

@@ -0,0 +1,75 @@
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')

View File

@@ -637,3 +637,25 @@ def test_backslash_z(compiler_state):
assert regions2 == ( assert regions2 == (
Region(0, 6, ('test', 'comment')), 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

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