34 Commits

Author SHA1 Message Date
Anthony Sottile
144bbb9daf v0.0.11 2020-05-27 15:50:30 -07:00
Anthony Sottile
7c16cd966e Merge pull request #72 from asottile/pypy3_ci
re-enable pypy3 testing
2020-05-27 15:48:20 -07:00
Anthony Sottile
dd19b26fa2 re-enable pypy3 testing 2020-05-27 15:33:31 -07:00
Anthony Sottile
dca410dd44 Merge pull request #69 from YouTwitFace/add-tab-size
Add a vim style command to change the tab size
2020-05-27 15:31:06 -07:00
YouTwitFace
ed51b6e6dc Add :tabsize and :tabstop 2020-05-27 15:21:17 -07:00
Anthony Sottile
18b5e258f6 Merge pull request #71 from asottile/escdelay_tests
test py39
2020-05-26 11:41:15 -07:00
Anthony Sottile
e7108f843b test py39 2020-05-26 11:17:17 -07:00
Anthony Sottile
ff8d3f10fb Merge pull request #70 from asottile/asottile-patch-1
Fix typo in README
2020-05-26 11:09:22 -07:00
Anthony Sottile
8f603b8e14 Fix typo in README 2020-05-26 11:04:30 -07:00
Anthony Sottile
c184468843 v0.0.10 2020-05-24 18:31:48 -07:00
Anthony Sottile
c5653976c7 Merge pull request #68 from asottile/fix_macos_full_screen
fix fullscreen on macos in Terminal
2020-05-24 18:29:45 -07:00
Anthony Sottile
d81bb12ff7 fix fullscreen on macos in Terminal 2020-05-24 18:18:35 -07:00
Anthony Sottile
afe461372e Merge pull request #65 from YouTwitFace/add-reverse-sort
Add reverse sort
2020-05-20 18:04:10 -07:00
YouTwitFace
b486047e90 Add reverse sort 2020-05-20 17:07:40 -07:00
Anthony Sottile
f3401a46c7 v0.0.9 2020-05-13 16:20:48 -07:00
Anthony Sottile
fbf5fc6ba2 Merge pull request #63 from theendlessriver13/fix_redo_on_win
add key for redo so it works on win
2020-05-13 15:55:59 -07:00
Jonas Kittner
60b0a77f05 add key for redo so it works on win
- added new key to test
2020-05-14 00:17:21 +02:00
Anthony Sottile
28a73a1a8c Merge pull request #64 from theendlessriver13/add_numpad_enter
add numpad enter
2020-05-13 14:41:20 -07:00
Anthony Sottile
432640eaf1 Merge pull request #62 from theendlessriver13/fix_windows_problems
fix babi crashing on win when trying to run it in the background
2020-05-13 14:32:39 -07:00
Jonas Kittner
71e67a6349 add numpad enter 2020-05-13 23:06:24 +02:00
Jonas Kittner
a5caa9d746 fix background crash on win 2020-05-13 22:37:57 +02:00
Anthony Sottile
599dfa1d0e pre-commit autoupdate 2020-05-11 17:27:40 -07:00
Anthony Sottile
3f259403fe v0.0.8 2020-05-08 15:56:59 -07:00
Anthony Sottile
4b27a18c0f Merge pull request #61 from theendlessriver13/fix_CTL_HOME_END_on_win
fix jump to top/end of file on windows
2020-05-08 15:56:16 -07:00
Jonas Kittner
58bc4780ca fix jump to top/end of file on windows 2020-05-09 00:47:18 +02:00
Anthony Sottile
4812daf300 Implement open-with-offset
Resolves #60
2020-04-17 19:31:51 -07:00
Anthony Sottile
7d1e61f734 v0.0.7 2020-04-04 17:44:40 -07:00
Anthony Sottile
3e7ca8e922 make grammar loading more deterministic 2020-04-04 17:44:27 -07:00
Anthony Sottile
843f1b6ff1 remove stray print 2020-04-04 13:13:53 -07:00
Anthony Sottile
f704505ee2 v0.0.6 2020-04-04 13:04:34 -07:00
Anthony Sottile
b595333fc6 Fix grammars where rules have local repositorys
for example: ruby
2020-04-04 13:03:33 -07:00
Anthony Sottile
486af96c12 Merge pull request #53 from brynphillips/PS-key-fix
Ps key fix
2020-04-03 10:36:12 -07:00
Bryn Phillips
8b71d289a3 Fixed PgDn 2020-04-03 10:28:40 -07:00
Bryn Phillips
759cadd868 Fixes for Win PS keys 2020-04-03 10:26:17 -07:00
21 changed files with 387 additions and 98 deletions

View File

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

View File

@@ -45,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 - <kbd>tab</kbd> / <kbd>shift-tab</kbd>: indent or dedent current line (or
selection) selection)
- <kbd>^K</kbd> / <kbd>^U</kbd>: cut and uncut the 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>: undo / redo - <kbd>M-u</kbd> / <kbd>M-U</kbd> or <kbd>M-e</kbd>: undo / redo
- <kbd>^W</kbd>: search - <kbd>^W</kbd>: search
- <kbd>^\\</kbd>: search and replace - <kbd>^\\</kbd>: search and replace
- <kbd>^C</kbd>: show the current position in the file - <kbd>^C</kbd>: show the current position in the file
@@ -87,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 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 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 multiple files. babi has a command mode (so you can quit it like vim
<kbd>:q</kbd>!). babi also support syntax highlighting <kbd>:q</kbd>!). babi also supports syntax highlighting
![](https://i.fluffy.cc/5WFZBJ4mWs7wtThD9strQnGlJqw4Z9KS.png) ![](https://i.fluffy.cc/5WFZBJ4mWs7wtThD9strQnGlJqw4Z9KS.png)

View File

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

View File

@@ -19,11 +19,11 @@ DelCallback = Callable[['Buf', int, str], None]
InsCallback = Callable[['Buf', int], None] InsCallback = Callable[['Buf', int], None]
def _offsets(s: str) -> Tuple[int, ...]: def _offsets(s: str, tab_size: int) -> Tuple[int, ...]:
ret = [0] ret = [0]
for c in s: for c in s:
if c == '\t': if c == '\t':
ret.append(ret[-1] + (4 - ret[-1] % 4)) ret.append(ret[-1] + (tab_size - ret[-1] % tab_size))
else: else:
ret.append(ret[-1] + wcwidth(c)) ret.append(ret[-1] + wcwidth(c))
return tuple(ret) return tuple(ret)
@@ -57,8 +57,9 @@ class DelModification(NamedTuple):
class Buf: class Buf:
def __init__(self, lines: List[str]) -> None: def __init__(self, lines: List[str], tab_size: int = 4) -> None:
self._lines = lines self._lines = lines
self.tab_size = tab_size
self.file_y = self.y = self._x = self._x_hint = 0 self.file_y = self.y = self._x = self._x_hint = 0
self._set_callbacks: List[SetCallback] = [self._set_cb] self._set_callbacks: List[SetCallback] = [self._set_cb]
@@ -136,6 +137,10 @@ class Buf:
if self[-1] != '': if self[-1] != '':
self.append('') self.append('')
def set_tab_size(self, tab_size: int) -> None:
self.tab_size = tab_size
self._positions = [None]
# event handling # event handling
def add_set_callback(self, cb: SetCallback) -> None: def add_set_callback(self, cb: SetCallback) -> None:
@@ -219,7 +224,8 @@ class Buf:
self._extend_positions(idx) self._extend_positions(idx)
value = self._positions[idx] value = self._positions[idx]
if value is None: if value is None:
value = self._positions[idx] = _offsets(self._lines[idx]) value = _offsets(self._lines[idx], self.tab_size)
self._positions[idx] = value
return value return value
def line_x(self, margin: Margin) -> int: def line_x(self, margin: Margin) -> int:
@@ -238,7 +244,8 @@ class Buf:
def rendered_line(self, idx: int, margin: Margin) -> str: def rendered_line(self, idx: int, margin: Margin) -> str:
x = self._cursor_x if idx == self.y else 0 x = self._cursor_x if idx == self.y else 0
return scrolled_line(self._lines[idx].expandtabs(4), x, margin.cols) expanded = self._lines[idx].expandtabs(self.tab_size)
return scrolled_line(expanded, x, margin.cols)
# movement # movement

View File

@@ -3,8 +3,10 @@ from typing import Iterable
from typing import Mapping from typing import Mapping
from typing import TypeVar from typing import TypeVar
TKey = TypeVar('TKey') from babi._types import Protocol
TValue = TypeVar('TValue')
TKey = TypeVar('TKey', contravariant=True)
TValue = TypeVar('TValue', covariant=True)
class FDict(Generic[TKey, TValue]): class FDict(Generic[TKey, TValue]):
@@ -22,3 +24,21 @@ class FDict(Generic[TKey, TValue]):
def values(self) -> Iterable[TValue]: def values(self) -> Iterable[TValue]:
return self._dct.values() return self._dct.values()
class Indexable(Generic[TKey, TValue], Protocol):
def __getitem__(self, key: TKey) -> TValue: ...
class FChainMap(Generic[TKey, TValue]):
def __init__(self, *mappings: Indexable[TKey, TValue]) -> None:
self._mappings = mappings
def __getitem__(self, key: TKey) -> TValue:
for mapping in reversed(self._mappings):
try:
return mapping[key]
except KeyError:
pass
else:
raise KeyError(key)

View File

@@ -198,10 +198,12 @@ class File:
def __init__( def __init__(
self, self,
filename: Optional[str], filename: Optional[str],
initial_line: int,
color_manager: ColorManager, color_manager: ColorManager,
hl_factories: Tuple[HLFactory, ...], hl_factories: Tuple[HLFactory, ...],
) -> None: ) -> None:
self.filename = filename self.filename = filename
self.initial_line = initial_line
self.modified = False self.modified = False
self.buf = Buf([]) self.buf = Buf([])
self.nl = '\n' self.nl = '\n'
@@ -215,7 +217,12 @@ class File:
self.selection = Selection() self.selection = Selection()
self._file_hls: Tuple[FileHL, ...] = () self._file_hls: Tuple[FileHL, ...] = ()
def ensure_loaded(self, status: Status, stdin: str) -> None: def ensure_loaded(
self,
status: Status,
margin: Margin,
stdin: str,
) -> None:
if self.buf: if self.buf:
return return
@@ -237,7 +244,7 @@ class File:
status.update('(new file)') status.update('(new file)')
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO('')) lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
self.buf = Buf(lines) self.buf = Buf(lines, self.buf.tab_size)
if mixed: if mixed:
status.update(f'mixed newlines will be converted to {self.nl!r}') status.update(f'mixed newlines will be converted to {self.nl!r}')
@@ -257,6 +264,8 @@ class File:
for file_hl in self._file_hls: for file_hl in self._file_hls:
file_hl.register_callbacks(self.buf) file_hl.register_callbacks(self.buf)
self.go_to_line(self.initial_line, margin)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<{type(self).__name__} {self.filename!r}>' return f'<{type(self).__name__} {self.filename!r}>'
@@ -514,16 +523,16 @@ class File:
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
for l_y in range(s_y, e_y + 1): for l_y in range(s_y, e_y + 1):
if self.buf[l_y]: if self.buf[l_y]:
self.buf[l_y] = ' ' * 4 + self.buf[l_y] self.buf[l_y] = ' ' * self.buf.tab_size + self.buf[l_y]
if l_y == self.buf.y: if l_y == self.buf.y:
self.buf.x += 4 self.buf.x += self.buf.tab_size
if l_y == sel_y and sel_x != 0: if l_y == sel_y and sel_x != 0:
sel_x += 4 sel_x += self.buf.tab_size
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x) self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
@edit_action('insert tab', final=False) @edit_action('insert tab', final=False)
def _tab(self, margin: Margin) -> None: def _tab(self, margin: Margin) -> None:
n = 4 - self.buf.x % 4 n = self.buf.tab_size - self.buf.x % self.buf.tab_size
line = self.buf[self.buf.y] line = self.buf[self.buf.y]
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:] self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
self.buf.x += n self.buf.x += n
@@ -535,9 +544,8 @@ class File:
else: else:
self._tab(margin) self._tab(margin)
@staticmethod def _dedent_line(self, s: str) -> int:
def _dedent_line(s: str) -> int: bound = min(len(s), self.buf.tab_size)
bound = min(len(s), 4)
i = 0 i = 0
while i < bound and s[i] == ' ': while i < bound and s[i] == ' ':
i += 1 i += 1
@@ -632,9 +640,9 @@ class File:
self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1) self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
self.buf.restore_eof_invariant() self.buf.restore_eof_invariant()
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None: def _sort(self, margin: Margin, s_y: int, e_y: int, reverse: bool) -> None:
# self.buf intentionally does not support slicing so we use islice # self.buf intentionally does not support slicing so we use islice
lines = sorted(itertools.islice(self.buf, s_y, e_y)) lines = sorted(itertools.islice(self.buf, s_y, e_y), reverse=reverse)
for i, line in zip(range(s_y, e_y), lines): for i, line in zip(range(s_y, e_y), lines):
self.buf[i] = line self.buf[i] = line
@@ -643,17 +651,17 @@ class File:
self.buf.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@edit_action('sort', final=True) @edit_action('sort', final=True)
def sort(self, margin: Margin) -> None: def sort(self, margin: Margin, reverse: bool = False) -> None:
self._sort(margin, 0, len(self.buf) - 1) self._sort(margin, 0, len(self.buf) - 1, reverse=reverse)
@edit_action('sort selection', final=True) @edit_action('sort selection', final=True)
@clear_selection @clear_selection
def sort_selection(self, margin: Margin) -> None: def sort_selection(self, margin: Margin, reverse: bool = False) -> None:
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
e_y = min(e_y + 1, len(self.buf) - 1) e_y = min(e_y + 1, len(self.buf) - 1)
if self.buf[e_y - 1] == '': if self.buf[e_y - 1] == '':
e_y -= 1 e_y -= 1
self._sort(margin, s_y, e_y) self._sort(margin, s_y, e_y, reverse=reverse)
DISPATCH = { DISPATCH = {
# movement # movement

View File

@@ -14,7 +14,7 @@ from typing import TypeVar
from identify.identify import tags_from_filename from identify.identify import tags_from_filename
from babi._types import Protocol from babi._types import Protocol
from babi.fdict import FDict from babi.fdict import FChainMap
from babi.reg import _Reg from babi.reg import _Reg
from babi.reg import _RegSet from babi.reg import _RegSet
from babi.reg import ERR_REG from babi.reg import ERR_REG
@@ -67,6 +67,8 @@ class _Rule(Protocol):
def include(self) -> Optional[str]: ... def include(self) -> Optional[str]: ...
@property @property
def patterns(self) -> 'Tuple[_Rule, ...]': ... def patterns(self) -> 'Tuple[_Rule, ...]': ...
@property
def repository(self) -> 'FChainMap[str, _Rule]': ...
@uniquely_constructed @uniquely_constructed
@@ -83,9 +85,24 @@ class Rule(NamedTuple):
while_captures: Captures while_captures: Captures
include: Optional[str] include: Optional[str]
patterns: Tuple[_Rule, ...] patterns: Tuple[_Rule, ...]
repository: FChainMap[str, _Rule]
@classmethod @classmethod
def from_dct(cls, dct: Dict[str, Any]) -> _Rule: def make(
cls,
dct: Dict[str, Any],
parent_repository: FChainMap[str, _Rule],
) -> _Rule:
if 'repository' in dct:
# this looks odd, but it's so we can have a self-referential
# immutable-after-construction chain map
repository_dct: Dict[str, _Rule] = {}
repository = FChainMap(parent_repository, repository_dct)
for k, sub_dct in dct['repository'].items():
repository_dct[k] = Rule.make(sub_dct, repository)
else:
repository = parent_repository
name = _split_name(dct.get('name')) name = _split_name(dct.get('name'))
match = dct.get('match') match = dct.get('match')
begin = dct.get('begin') begin = dct.get('begin')
@@ -95,7 +112,7 @@ class Rule(NamedTuple):
if 'captures' in dct: if 'captures' in dct:
captures = tuple( captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['captures'].items() for k, v in dct['captures'].items()
) )
else: else:
@@ -103,7 +120,7 @@ class Rule(NamedTuple):
if 'beginCaptures' in dct: if 'beginCaptures' in dct:
begin_captures = tuple( begin_captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['beginCaptures'].items() for k, v in dct['beginCaptures'].items()
) )
else: else:
@@ -111,7 +128,7 @@ class Rule(NamedTuple):
if 'endCaptures' in dct: if 'endCaptures' in dct:
end_captures = tuple( end_captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['endCaptures'].items() for k, v in dct['endCaptures'].items()
) )
else: else:
@@ -119,7 +136,7 @@ class Rule(NamedTuple):
if 'whileCaptures' in dct: if 'whileCaptures' in dct:
while_captures = tuple( while_captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['whileCaptures'].items() for k, v in dct['whileCaptures'].items()
) )
else: else:
@@ -141,7 +158,7 @@ class Rule(NamedTuple):
include = dct.get('include') include = dct.get('include')
if 'patterns' in dct: if 'patterns' in dct:
patterns = tuple(Rule.from_dct(d) for d in dct['patterns']) patterns = tuple(Rule.make(d, repository) for d in dct['patterns'])
else: else:
patterns = () patterns = ()
@@ -158,29 +175,33 @@ class Rule(NamedTuple):
while_captures=while_captures, while_captures=while_captures,
include=include, include=include,
patterns=patterns, patterns=patterns,
repository=repository,
) )
@uniquely_constructed @uniquely_constructed
class Grammar(NamedTuple): class Grammar(NamedTuple):
scope_name: str scope_name: str
repository: FChainMap[str, _Rule]
patterns: Tuple[_Rule, ...] patterns: Tuple[_Rule, ...]
repository: FDict[str, _Rule]
@classmethod @classmethod
def from_data(cls, data: Dict[str, Any]) -> 'Grammar': def make(cls, data: Dict[str, Any]) -> 'Grammar':
scope_name = data['scopeName'] scope_name = data['scopeName']
patterns = tuple(Rule.from_dct(dct) for dct in data['patterns'])
if 'repository' in data: if 'repository' in data:
repository = FDict({ # this looks odd, but it's so we can have a self-referential
k: Rule.from_dct(dct) for k, dct in data['repository'].items() # immutable-after-construction chain map
}) repository_dct: Dict[str, _Rule] = {}
repository = FChainMap(repository_dct)
for k, dct in data['repository'].items():
repository_dct[k] = Rule.make(dct, repository)
else: else:
repository = FDict({}) repository = FChainMap()
patterns = tuple(Rule.make(d, repository) for d in data['patterns'])
return cls( return cls(
scope_name=scope_name, scope_name=scope_name,
patterns=patterns,
repository=repository, repository=repository,
patterns=patterns,
) )
@@ -530,22 +551,23 @@ class Compiler:
def _include( def _include(
self, self,
grammar: Grammar, grammar: Grammar,
repository: FChainMap[str, _Rule],
s: str, s: str,
) -> Tuple[List[str], Tuple[_Rule, ...]]: ) -> Tuple[List[str], Tuple[_Rule, ...]]:
if s == '$self': if s == '$self':
return self._patterns(grammar, grammar.patterns) return self._patterns(grammar, grammar.patterns)
elif s == '$base': elif s == '$base':
grammar = self._grammars.grammar_for_scope(self._root_scope) grammar = self._grammars.grammar_for_scope(self._root_scope)
return self._include(grammar, '$self') return self._include(grammar, grammar.repository, '$self')
elif s.startswith('#'): elif s.startswith('#'):
return self._patterns(grammar, (grammar.repository[s[1:]],)) return self._patterns(grammar, (repository[s[1:]],))
elif '#' not in s: elif '#' not in s:
grammar = self._grammars.grammar_for_scope(s) grammar = self._grammars.grammar_for_scope(s)
return self._include(grammar, '$self') return self._include(grammar, grammar.repository, '$self')
else: else:
scope, _, s = s.partition('#') scope, _, s = s.partition('#')
grammar = self._grammars.grammar_for_scope(scope) grammar = self._grammars.grammar_for_scope(scope)
return self._include(grammar, f'#{s}') return self._include(grammar, grammar.repository, f'#{s}')
@functools.lru_cache(maxsize=None) @functools.lru_cache(maxsize=None)
def _patterns( def _patterns(
@@ -557,7 +579,9 @@ class Compiler:
ret_rules: List[_Rule] = [] ret_rules: List[_Rule] = []
for rule in rules: for rule in rules:
if rule.include is not None: if rule.include is not None:
tmp_regs, tmp_rules = self._include(grammar, rule.include) tmp_regs, tmp_rules = self._include(
grammar, rule.repository, rule.include,
)
ret_regs.extend(tmp_regs) ret_regs.extend(tmp_regs)
ret_rules.extend(tmp_rules) ret_rules.extend(tmp_rules)
elif rule.match is None and rule.begin is None and rule.patterns: elif rule.match is None and rule.begin is None and rule.patterns:
@@ -633,7 +657,7 @@ class Grammars:
os.path.splitext(filename)[0]: os.path.join(directory, filename) os.path.splitext(filename)[0]: os.path.join(directory, filename)
for directory in directories for directory in directories
if os.path.exists(directory) if os.path.exists(directory)
for filename in os.listdir(directory) for filename in sorted(os.listdir(directory))
if filename.endswith('.json') if filename.endswith('.json')
} }
@@ -669,7 +693,7 @@ class Grammars:
pass pass
raw = self._raw_for_scope(scope) raw = self._raw_for_scope(scope)
ret = self._parsed[scope] = Grammar.from_data(raw) ret = self._parsed[scope] = Grammar.make(raw)
return ret return ret
def compiler_for_scope(self, scope: str) -> Compiler: def compiler_for_scope(self, scope: str) -> Compiler:

View File

@@ -1,9 +1,12 @@
import argparse import argparse
import curses import curses
import os import os
import re
import sys import sys
from typing import List
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from typing import Tuple
from babi.buf import Buf from babi.buf import Buf
from babi.file import File from babi.file import File
@@ -14,10 +17,11 @@ from babi.screen import make_stdscr
from babi.screen import Screen from babi.screen import Screen
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty' CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
POSITION_RE = re.compile(r'^\+-?\d+$')
def _edit(screen: Screen, stdin: str) -> EditResult: def _edit(screen: Screen, stdin: str) -> EditResult:
screen.file.ensure_loaded(screen.status, stdin) screen.file.ensure_loaded(screen.status, screen.margin, stdin)
while True: while True:
screen.status.tick(screen.margin) screen.status.tick(screen.margin)
@@ -40,35 +44,36 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
def c_main( def c_main(
stdscr: 'curses._CursesWindow', stdscr: 'curses._CursesWindow',
args: argparse.Namespace, filenames: List[Optional[str]],
positions: List[int],
stdin: str, stdin: str,
perf: Perf,
) -> int: ) -> int:
with perf_log(args.perf_log) as perf: screen = Screen(stdscr, filenames, positions, perf)
screen = Screen(stdscr, args.filenames or [None], perf) with screen.history.save():
with screen.history.save(): while screen.files:
while screen.files: screen.i = screen.i % len(screen.files)
screen.i = screen.i % len(screen.files) res = _edit(screen, stdin)
res = _edit(screen, stdin) if res == EditResult.EXIT:
if res == EditResult.EXIT: del screen.files[screen.i]
del screen.files[screen.i] # always go to the next file except at the end
# always go to the next file except at the end screen.i = min(screen.i, len(screen.files) - 1)
screen.i = min(screen.i, len(screen.files) - 1) screen.status.clear()
screen.status.clear() elif res == EditResult.NEXT:
elif res == EditResult.NEXT: screen.i += 1
screen.i += 1 screen.status.clear()
screen.status.clear() elif res == EditResult.PREV:
elif res == EditResult.PREV: screen.i -= 1
screen.i -= 1 screen.status.clear()
screen.status.clear() elif res == EditResult.OPEN:
elif res == EditResult.OPEN: screen.i = len(screen.files) - 1
screen.i = len(screen.files) - 1 else:
else: raise AssertionError(f'unreachable {res}')
raise AssertionError(f'unreachable {res}')
return 0 return 0
def _key_debug(stdscr: 'curses._CursesWindow') -> int: def _key_debug(stdscr: 'curses._CursesWindow', perf: Perf) -> int:
screen = Screen(stdscr, ['<<key debug>>'], Perf()) screen = Screen(stdscr, ['<<key debug>>'], [0], perf)
screen.file.buf = Buf(['']) screen.file.buf = Buf([''])
while True: while True:
@@ -85,6 +90,37 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
return 0 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: def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('filenames', metavar='filename', nargs='*') parser.add_argument('filenames', metavar='filename', nargs='*')
@@ -102,11 +138,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
else: else:
stdin = '' stdin = ''
with 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) return _key_debug(stdscr, perf)
else: else:
return c_main(stdscr, args, stdin) filenames, positions = _filenames(args.filenames)
return c_main(stdscr, filenames, positions, stdin, perf)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -69,6 +69,11 @@ KEYNAME_REWRITE = {
b'KEY_C2': b'KEY_DOWN', b'KEY_C2': b'KEY_DOWN',
b'KEY_B3': b'KEY_RIGHT', b'KEY_B3': b'KEY_RIGHT',
b'KEY_B1': b'KEY_LEFT', b'KEY_B1': b'KEY_LEFT',
b'PADSTOP': b'KEY_DC',
b'KEY_A3': b'KEY_PPAGE',
b'KEY_C3': b'KEY_NPAGE',
b'KEY_A1': b'KEY_HOME',
b'KEY_C1': b'KEY_END',
# windows-curses: map to our M- names # windows-curses: map to our M- names
b'ALT_U': b'M-u', b'ALT_U': b'M-u',
# windows-curses: arguably these names are better than the xterm names # windows-curses: arguably these names are better than the xterm names
@@ -76,8 +81,11 @@ KEYNAME_REWRITE = {
b'CTL_DOWN': b'kDN5', b'CTL_DOWN': b'kDN5',
b'CTL_RIGHT': b'kRIT5', b'CTL_RIGHT': b'kRIT5',
b'CTL_LEFT': b'kLFT5', b'CTL_LEFT': b'kLFT5',
b'CTL_HOME': b'kHOM5',
b'CTL_END': b'kEND5',
b'ALT_RIGHT': b'kRIT3', b'ALT_RIGHT': b'kRIT3',
b'ALT_LEFT': b'kLFT3', b'ALT_LEFT': b'kLFT3',
b'ALT_E': b'M-e',
# windows-curses: idk why these are different # windows-curses: idk why these are different
b'KEY_SUP': b'KEY_SR', b'KEY_SUP': b'KEY_SR',
b'KEY_SDOWN': b'KEY_SF', b'KEY_SDOWN': b'KEY_SF',
@@ -85,6 +93,7 @@ KEYNAME_REWRITE = {
b'^?': b'KEY_BACKSPACE', b'^?': b'KEY_BACKSPACE',
# linux, perhaps others # linux, perhaps others
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
b'PADENTER': b'^M', # Enter on numpad
} }
@@ -98,14 +107,15 @@ class Screen:
self, self,
stdscr: 'curses._CursesWindow', stdscr: 'curses._CursesWindow',
filenames: List[Optional[str]], filenames: List[Optional[str]],
initial_lines: List[int],
perf: Perf, perf: Perf,
) -> None: ) -> None:
self.stdscr = stdscr self.stdscr = stdscr
self.color_manager = ColorManager.make() self.color_manager = ColorManager.make()
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),) self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
self.files = [ self.files = [
File(filename, self.color_manager, self.hl_factories) File(filename, line, self.color_manager, self.hl_factories)
for filename in filenames for filename, line in zip(filenames, initial_lines)
] ]
self.i = 0 self.i = 0
self.history = History() self.history = History()
@@ -216,7 +226,10 @@ class Screen:
if self._buffered_input is not None: if self._buffered_input is not None:
wch, self._buffered_input = self._buffered_input, None wch, self._buffered_input = self._buffered_input, None
else: else:
wch = self.stdscr.get_wch() try:
wch = self.stdscr.get_wch()
except curses.error: # pragma: no cover (macos bug?)
wch = self.stdscr.get_wch()
if isinstance(wch, str) and wch == '\x1b': if isinstance(wch, str) and wch == '\x1b':
wch = self._get_sequence(wch) wch = self._get_sequence(wch)
if len(wch) == 2: if len(wch) == 2:
@@ -408,7 +421,9 @@ class Screen:
def command(self) -> Optional[EditResult]: def command(self) -> Optional[EditResult]:
response = self.prompt('', history='command') response = self.prompt('', history='command')
if response == ':q': if response is PromptResult.CANCELLED:
pass
elif response == ':q':
return self.quit_save_modified() return self.quit_save_modified()
elif response == ':q!': elif response == ':q!':
return EditResult.EXIT return EditResult.EXIT
@@ -423,7 +438,26 @@ class Screen:
else: else:
self.file.sort(self.margin) self.file.sort(self.margin)
self.status.update('sorted!') self.status.update('sorted!')
elif response is not PromptResult.CANCELLED: 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!')
else:
self.status.update(f'invalid command: {response}') self.status.update(f'invalid command: {response}')
return None return None
@@ -484,7 +518,7 @@ class Screen:
def open_file(self) -> Optional[EditResult]: def open_file(self) -> Optional[EditResult]:
response = self.prompt('enter filename', history='open') response = self.prompt('enter filename', history='open')
if response is not PromptResult.CANCELLED: if response is not PromptResult.CANCELLED:
opened = File(response, self.color_manager, self.hl_factories) opened = File(response, 0, self.color_manager, self.hl_factories)
self.files.append(opened) self.files.append(opened)
return EditResult.OPEN return EditResult.OPEN
else: else:
@@ -508,10 +542,13 @@ class Screen:
return EditResult.EXIT return EditResult.EXIT
def background(self) -> None: def background(self) -> None:
curses.endwin() if sys.platform == 'win32': # pragma: win32 cover
os.kill(os.getpid(), signal.SIGSTOP) self.status.update('cannot run babi in background on Windows')
self.stdscr = _init_screen() else: # pragma: win32 no cover
self.resize() curses.endwin()
os.kill(os.getpid(), signal.SIGSTOP)
self.stdscr = _init_screen()
self.resize()
DISPATCH = { DISPATCH = {
b'KEY_RESIZE': resize, b'KEY_RESIZE': resize,
@@ -521,6 +558,7 @@ class Screen:
b'^U': uncut, b'^U': uncut,
b'M-u': undo, b'M-u': undo,
b'M-U': redo, b'M-U': redo,
b'M-e': redo,
b'^W': search, b'^W': search,
b'^\\': replace, b'^\\': replace,
b'^[': command, b'^[': command,

View File

@@ -39,7 +39,6 @@ def json_with_comments(s: bytes) -> Any:
idx = match.end() idx = match.end()
match = TOKEN.search(s, idx) match = TOKEN.search(s, idx)
print(bio.getvalue())
bio.seek(0) bio.seek(0)
return json.load(bio) return json.load(bio)

View File

@@ -1,6 +1,6 @@
[metadata] [metadata]
name = babi name = babi
version = 0.0.5 version = 0.0.11
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

View File

@@ -1,3 +1,6 @@
import pytest
from babi.fdict import FChainMap
from babi.fdict import FDict from babi.fdict import FDict
@@ -5,3 +8,21 @@ def test_fdict_repr():
# mostly because this shouldn't get hit elsewhere but is uesful for # mostly because this shouldn't get hit elsewhere but is uesful for
# debugging purposes # debugging purposes
assert repr(FDict({1: 2, 3: 4})) == 'FDict({1: 2, 3: 4})' assert repr(FDict({1: 2, 3: 4})) == 'FDict({1: 2, 3: 4})'
def test_f_chain_map():
chain_map = FChainMap({1: 2}, {3: 4}, FDict({1: 5}))
assert chain_map[1] == 5
assert chain_map[3] == 4
with pytest.raises(KeyError) as excinfo:
chain_map[2]
k, = excinfo.value.args
assert k == 2
def test_f_chain_map_extend():
chain_map = FChainMap({1: 2})
assert chain_map[1] == 2
chain_map = FChainMap(chain_map, {1: 5})
assert chain_map[1] == 5

View File

@@ -391,6 +391,7 @@ class DeferredRunner:
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop _curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _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 _curses_error = curses.error # so we don't mock the exception

View File

@@ -0,0 +1,28 @@
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

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

View File

@@ -21,6 +21,16 @@ def test_sort_entire_file(run, unsorted):
assert unsorted.read() == 'a\nb\nc\nd\n' 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): def test_sort_selection(run, unsorted):
with run(str(unsorted)) as h, and_exit(h): with run(str(unsorted)) as h, and_exit(h):
h.press('S-Down') h.press('S-Down')
@@ -32,6 +42,18 @@ def test_sort_selection(run, unsorted):
assert unsorted.read() == 'b\nd\nc\na\n' 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): def test_sort_selection_does_not_include_eof(run, unsorted):
with run(str(unsorted)) as h, and_exit(h): with run(str(unsorted)) as h, and_exit(h):
for _ in range(5): for _ in range(5):

View File

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

View File

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

View File

@@ -441,6 +441,38 @@ def test_include_repository_rule(compiler_state):
) )
def test_include_with_nested_repositories(compiler_state):
compiler, state = compiler_state({
'scopeName': 'test',
'patterns': [{
'begin': '<', 'end': '>', 'name': 'b',
'patterns': [
{'include': '#rule1'},
{'include': '#rule2'},
{'include': '#rule3'},
],
'repository': {
'rule2': {'match': '2', 'name': 'inner2'},
'rule3': {'match': '3', 'name': 'inner3'},
},
}],
'repository': {
'rule1': {'match': '1', 'name': 'root1'},
'rule2': {'match': '2', 'name': 'root2'},
},
})
state, regions = highlight_line(compiler, state, '<123>', first_line=True)
assert regions == (
Region(0, 1, ('test', 'b')),
Region(1, 2, ('test', 'b', 'root1')),
Region(2, 3, ('test', 'b', 'inner2')),
Region(3, 4, ('test', 'b', 'inner3')),
Region(4, 5, ('test', 'b')),
)
def test_include_other_grammar(compiler_state): def test_include_other_grammar(compiler_state):
compiler, state = compiler_state( compiler, state = compiler_state(
{ {

19
tests/main_test.py Normal file
View File

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