Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10fca36ea3 | ||
|
|
e6aab391f6 | ||
|
|
ebee8fe6ff | ||
|
|
04fc97a8f9 | ||
|
|
c49e722498 | ||
|
|
396d0e3a93 | ||
|
|
660bf9bac0 | ||
|
|
0b3918f26f | ||
|
|
9dffc276dc | ||
|
|
ea6dbb69a6 | ||
|
|
eeeba9e11d | ||
|
|
b5538b3818 | ||
|
|
16c60d68ad | ||
|
|
27aa865989 | ||
|
|
4b13488e8f | ||
|
|
b4593d281a | ||
|
|
3ddf1c72f8 | ||
|
|
8f91b8c9ff | ||
|
|
c48d3ed741 | ||
|
|
43a650925b | ||
|
|
3116828e44 | ||
|
|
9bc43d58e4 | ||
|
|
bbc9647eb3 | ||
|
|
e36ed09f1f | ||
|
|
fb52b5f71c | ||
|
|
1916b49a06 | ||
|
|
66c178b0b7 | ||
|
|
ea4058549f | ||
|
|
cb6b431308 | ||
|
|
a8283adb54 | ||
|
|
40bf9969fb | ||
|
|
a04b8fdca6 | ||
|
|
76eef9adb6 | ||
|
|
fa962d6cb9 | ||
|
|
f40f93b983 | ||
|
|
af01959a48 | ||
|
|
16f4ec3681 | ||
|
|
291d34028a | ||
|
|
2c200b97ed | ||
|
|
fc185b0eef | ||
|
|
21357ed235 | ||
|
|
1a023d3830 | ||
|
|
61063b306c | ||
|
|
628d3ced55 | ||
|
|
b6dc975143 | ||
|
|
4537df6aa1 | ||
|
|
1d1307aa1c | ||
|
|
194d1c5b9b | ||
|
|
e59c860097 | ||
|
|
0a39d73959 | ||
|
|
f8bf24482e | ||
|
|
50079514fd | ||
|
|
96402e30cf | ||
|
|
048ed590ff | ||
|
|
ab6cfcc8c9 | ||
|
|
6348313071 | ||
|
|
c59e00975b | ||
|
|
e3ba08a331 | ||
|
|
6bdf0ff2ea | ||
|
|
bc699e60e1 | ||
|
|
151a56c7f8 | ||
|
|
d958934fdd | ||
|
|
9a8bda1f15 | ||
|
|
c7904fdf9b | ||
|
|
f6db46736c | ||
|
|
e059dc294b | ||
|
|
4dc42aa628 | ||
|
|
25659ca5c7 | ||
|
|
bd8dc3beb0 | ||
|
|
324513c36a | ||
|
|
09643b0f80 | ||
|
|
8245ee56a5 | ||
|
|
8aaee402e7 | ||
|
|
641eed65d5 | ||
|
|
694853e341 | ||
|
|
1a4d15206c | ||
|
|
4ca3b0d1e5 | ||
|
|
47868e77a2 | ||
|
|
9906e223bd | ||
|
|
a5101007cd | ||
|
|
572151197d | ||
|
|
a8a5afc6ed | ||
|
|
e523a694b6 | ||
|
|
8161c9e34f | ||
|
|
0b5e187be5 | ||
|
|
ee13b7bb78 | ||
|
|
cad35f7b4d | ||
|
|
c1a9823894 | ||
|
|
899eb6f879 | ||
|
|
945e0b1620 | ||
|
|
1f348882b8 | ||
|
|
604942306f | ||
|
|
00570f8eda | ||
|
|
51a7b10192 | ||
|
|
4d1101daf9 | ||
|
|
08ec1874d1 | ||
|
|
4881953763 | ||
|
|
8f91c12a45 | ||
|
|
5df223f81e | ||
|
|
57bae10448 | ||
|
|
a2afbfa07b | ||
|
|
229ec77f4f | ||
|
|
5a25901cdb | ||
|
|
9c5f28d475 | ||
|
|
a87497cbe2 | ||
|
|
d7622f38c6 | ||
|
|
e474396790 | ||
|
|
e6a0353650 | ||
|
|
e0a59e3f9c | ||
|
|
787dc0d18f | ||
|
|
fd9393c8b1 | ||
|
|
eb26d93e03 | ||
|
|
055d738142 | ||
|
|
29062628f9 | ||
|
|
1fab2a4b71 | ||
|
|
9f5e8c02cb | ||
|
|
31624856d2 | ||
|
|
97b3b4deef | ||
|
|
41880d5f8c | ||
|
|
effe988f60 | ||
|
|
84b20a4016 | ||
|
|
5d2c9532a3 | ||
|
|
33ff8d9726 | ||
|
|
f0b2af9a9f | ||
|
|
fc21a144aa | ||
|
|
973b4c3cf8 | ||
|
|
bd60977438 | ||
|
|
144bbb9daf | ||
|
|
7c16cd966e | ||
|
|
dd19b26fa2 | ||
|
|
dca410dd44 | ||
|
|
ed51b6e6dc | ||
|
|
18b5e258f6 | ||
|
|
e7108f843b | ||
|
|
ff8d3f10fb | ||
|
|
8f603b8e14 | ||
|
|
c184468843 | ||
|
|
c5653976c7 | ||
|
|
d81bb12ff7 | ||
|
|
afe461372e | ||
|
|
b486047e90 | ||
|
|
f3401a46c7 | ||
|
|
fbf5fc6ba2 | ||
|
|
60b0a77f05 | ||
|
|
28a73a1a8c | ||
|
|
432640eaf1 | ||
|
|
71e67a6349 | ||
|
|
a5caa9d746 | ||
|
|
599dfa1d0e | ||
|
|
3f259403fe | ||
|
|
4b27a18c0f | ||
|
|
58bc4780ca | ||
|
|
4812daf300 | ||
|
|
7d1e61f734 | ||
|
|
3e7ca8e922 | ||
|
|
843f1b6ff1 | ||
|
|
f704505ee2 | ||
|
|
b595333fc6 | ||
|
|
486af96c12 | ||
|
|
8b71d289a3 | ||
|
|
759cadd868 | ||
|
|
b9a12537b1 | ||
|
|
936fd7e3a0 | ||
|
|
2d0f3a3077 | ||
|
|
2a9eccefb2 | ||
|
|
c449f96bf0 | ||
|
|
47e008afa4 | ||
|
|
1919c2d4fe | ||
|
|
18057542bf | ||
|
|
49f95a5a2c | ||
|
|
612f09eb3a | ||
|
|
6206db3ef2 | ||
|
|
711cf65266 | ||
|
|
2b66c465a6 | ||
|
|
9f36fe2f1b | ||
|
|
3844dcf329 | ||
|
|
04aaf9530e | ||
|
|
7850481565 | ||
|
|
b536291989 | ||
|
|
f8737557d3 | ||
|
|
d597b4087d | ||
|
|
41aa025d3d | ||
|
|
de956b7bab | ||
|
|
1d3d413b93 | ||
|
|
50ad1e06f9 | ||
|
|
032c3d78fc | ||
|
|
a197645087 | ||
|
|
9f8e400d32 | ||
|
|
2123e6ee84 | ||
|
|
b529dde91a | ||
|
|
c4e2f8e9cf |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: asottile
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,4 +5,6 @@
|
|||||||
/.mypy_cache
|
/.mypy_cache
|
||||||
/.pytest_cache
|
/.pytest_cache
|
||||||
/.tox
|
/.tox
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
/venv*
|
/venv*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v2.5.0
|
rev: v4.0.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@@ -10,35 +10,35 @@ repos:
|
|||||||
- id: double-quote-string-fixer
|
- id: double-quote-string-fixer
|
||||||
- id: name-tests-test
|
- id: name-tests-test
|
||||||
- id: requirements-txt-fixer
|
- id: requirements-txt-fixer
|
||||||
- repo: https://gitlab.com/pycqa/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: 3.7.9
|
rev: 3.9.2
|
||||||
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.7
|
||||||
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.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: reorder-python-imports
|
||||||
args: [--py3-plus]
|
args: [--py3-plus, --add-import, 'from __future__ import annotations']
|
||||||
- repo: https://github.com/asottile/add-trailing-comma
|
- repo: https://github.com/asottile/add-trailing-comma
|
||||||
rev: v2.0.1
|
rev: v2.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- 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.25.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: [--py36-plus]
|
args: [--py37-plus]
|
||||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||||
rev: v1.7.0
|
rev: v1.17.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
|
||||||
rev: v0.770
|
rev: v0.910
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -1,16 +1,30 @@
|
|||||||
[](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
|
[](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
|
||||||
[](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
|
[](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
|
||||||
|
[](https://results.pre-commit.ci/latest/github/asottile/babi/master)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
babi
|
babi
|
||||||
====
|
====
|
||||||
|
|
||||||
a text editor, eventually...
|
a text editor, eventually...
|
||||||
|
|
||||||
|
### installation
|
||||||
|
|
||||||
|
`pip install babi`
|
||||||
|
|
||||||
### 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`.
|
||||||
|
|
||||||
|
### babi vs. nano
|
||||||
|
|
||||||
|
here is a youtube video where I discuss the motivation for creating and using
|
||||||
|
`babi` instead of `nano`:
|
||||||
|
|
||||||
|
[](https://youtu.be/WyR1hAGmR3g)
|
||||||
|
|
||||||
### quitting babi
|
### quitting babi
|
||||||
|
|
||||||
currently you can quit `babi` by using <kbd>^X</kbd> (or via <kbd>esc</kbd> +
|
currently you can quit `babi` by using <kbd>^X</kbd> (or via <kbd>esc</kbd> +
|
||||||
@@ -41,7 +55,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
|
||||||
@@ -63,12 +77,35 @@ in prompts (search, search replace, command):
|
|||||||
|
|
||||||
the syntax highlighting setup is a bit manual right now
|
the syntax highlighting setup is a bit manual right now
|
||||||
|
|
||||||
1. from a clone of babi, run `./bin/download-syntax` -- you will likely need
|
1. find a visual studio code theme, convert it to json (if it is not already
|
||||||
to install some additional packages to download them (`pip install cson`)
|
|
||||||
2. find a visual studio code theme, convert it to json (if it is not already
|
|
||||||
json) and put it at `~/.config/babi/theme.json`. a helper script is
|
json) and put it at `~/.config/babi/theme.json`. a helper script is
|
||||||
provided to make this easier: `./bin/download-theme NAME URL`
|
provided to make this easier: `./bin/download-theme NAME URL`
|
||||||
|
|
||||||
|
here's a modified vs dark plus theme that works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/download-theme vs-dark-asottile https://gist.github.com/asottile/b465856c82b1aaa4ba8c7c6314a72e13/raw/22d602fb355fb12b04f176a733941ba5713bc36c/vs_dark_asottile.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### keyboard shortcuts on macos
|
||||||
|
|
||||||
|
to get the most out of babi's built in keyboard shortcuts, a few settings must
|
||||||
|
be changed on macos with Terminal.app:
|
||||||
|
|
||||||
|
- in **System Preferences**: **Keyboard** > **Shortcuts** >
|
||||||
|
**Mission Control**: disable or rebind "Move left a space" and
|
||||||
|
"Move right a space" (the defaults `⌃ →` and `⌃ ←` conflict)
|
||||||
|
- in **Terminal.app**: **Terminal** > **Preferences** > **Profiles** >
|
||||||
|
**Keyboard**:
|
||||||
|
- check **Use Option as Meta key**
|
||||||
|
- ensure the following keys are enabled:
|
||||||
|
- `⌃ →`: `\033[1;5C`
|
||||||
|
- `⌃ ←`: `\033[1;5D`
|
||||||
|
- `⇧ ↑`: `\033[1;2A`
|
||||||
|
- `⇧ ↓`: `\033[1;2B`
|
||||||
|
- `⇧ →`: `\033[1;2C`
|
||||||
|
- `⇧ ←`: `\033[1;2D`
|
||||||
|
|
||||||
## demos
|
## demos
|
||||||
|
|
||||||
most things work! here's a few screenshots
|
most things work! here's a few screenshots
|
||||||
@@ -79,7 +116,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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ 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.1.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
- 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: [py37, py38, py39]
|
||||||
os: linux
|
os: linux
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from babi.main import main
|
from babi.main import main
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|||||||
314
babi/buf.py
Normal file
314
babi/buf.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import contextlib
|
||||||
|
from typing import Callable
|
||||||
|
from typing import Generator
|
||||||
|
from typing import Iterator
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from babi._types import Protocol
|
||||||
|
from babi.horizontal_scrolling import line_x
|
||||||
|
from babi.horizontal_scrolling import scrolled_line
|
||||||
|
from babi.horizontal_scrolling import wcwidth
|
||||||
|
from babi.margin import Margin
|
||||||
|
|
||||||
|
SetCallback = Callable[['Buf', int, str], None]
|
||||||
|
DelCallback = Callable[['Buf', int, str], None]
|
||||||
|
InsCallback = Callable[['Buf', int], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _offsets(s: str, tab_size: int) -> tuple[int, ...]:
|
||||||
|
ret = [0]
|
||||||
|
for c in s:
|
||||||
|
if c == '\t':
|
||||||
|
ret.append(ret[-1] + (tab_size - ret[-1] % tab_size))
|
||||||
|
else:
|
||||||
|
ret.append(ret[-1] + wcwidth(c))
|
||||||
|
return tuple(ret)
|
||||||
|
|
||||||
|
|
||||||
|
class Modification(Protocol):
|
||||||
|
def __call__(self, buf: Buf) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SetModification(NamedTuple):
|
||||||
|
idx: int
|
||||||
|
s: str
|
||||||
|
|
||||||
|
def __call__(self, buf: Buf) -> None:
|
||||||
|
buf[self.idx] = self.s
|
||||||
|
|
||||||
|
|
||||||
|
class InsModification(NamedTuple):
|
||||||
|
idx: int
|
||||||
|
s: str
|
||||||
|
|
||||||
|
def __call__(self, buf: Buf) -> None:
|
||||||
|
buf.insert(self.idx, self.s)
|
||||||
|
|
||||||
|
|
||||||
|
class DelModification(NamedTuple):
|
||||||
|
idx: int
|
||||||
|
|
||||||
|
def __call__(self, buf: Buf) -> None:
|
||||||
|
del buf[self.idx]
|
||||||
|
|
||||||
|
|
||||||
|
class Buf:
|
||||||
|
def __init__(self, lines: list[str], tab_size: int = 4) -> None:
|
||||||
|
self._lines = lines
|
||||||
|
self.expandtabs = True
|
||||||
|
self.tab_size = tab_size
|
||||||
|
self.file_y = self.y = self._x = self._x_hint = 0
|
||||||
|
|
||||||
|
self._set_callbacks: list[SetCallback] = [self._set_cb]
|
||||||
|
self._del_callbacks: list[DelCallback] = [self._del_cb]
|
||||||
|
self._ins_callbacks: list[InsCallback] = [self._ins_cb]
|
||||||
|
|
||||||
|
self._positions: list[tuple[int, ...] | None] = []
|
||||||
|
|
||||||
|
# read only interface
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f'{type(self).__name__}('
|
||||||
|
f'{self._lines!r}, x={self.x}, y={self.y}, file_y={self.file_y}'
|
||||||
|
f')'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self._lines)
|
||||||
|
|
||||||
|
def __getitem__(self, idx: int) -> str:
|
||||||
|
return self._lines[idx]
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
yield from self._lines
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._lines)
|
||||||
|
|
||||||
|
# mutators
|
||||||
|
|
||||||
|
def __setitem__(self, idx: int, val: str) -> None:
|
||||||
|
if idx < 0:
|
||||||
|
idx %= len(self)
|
||||||
|
victim = self._lines[idx]
|
||||||
|
|
||||||
|
self._lines[idx] = val
|
||||||
|
|
||||||
|
for set_callback in self._set_callbacks:
|
||||||
|
set_callback(self, idx, victim)
|
||||||
|
|
||||||
|
def __delitem__(self, idx: int) -> None:
|
||||||
|
if idx < 0:
|
||||||
|
idx %= len(self)
|
||||||
|
victim = self._lines[idx]
|
||||||
|
|
||||||
|
del self._lines[idx]
|
||||||
|
|
||||||
|
for del_callback in self._del_callbacks:
|
||||||
|
del_callback(self, idx, victim)
|
||||||
|
|
||||||
|
def insert(self, idx: int, val: str) -> None:
|
||||||
|
if idx < 0:
|
||||||
|
idx %= len(self)
|
||||||
|
|
||||||
|
self._lines.insert(idx, val)
|
||||||
|
|
||||||
|
for ins_callback in self._ins_callbacks:
|
||||||
|
ins_callback(self, idx)
|
||||||
|
|
||||||
|
# also mutators, but implemented using above functions
|
||||||
|
|
||||||
|
def append(self, val: str) -> None:
|
||||||
|
self.insert(len(self), val)
|
||||||
|
|
||||||
|
def pop(self, idx: int = -1) -> str:
|
||||||
|
victim = self[idx]
|
||||||
|
del self[idx]
|
||||||
|
return victim
|
||||||
|
|
||||||
|
def restore_eof_invariant(self) -> None:
|
||||||
|
"""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:
|
||||||
|
self._set_callbacks.append(cb)
|
||||||
|
|
||||||
|
def remove_set_callback(self, cb: SetCallback) -> None:
|
||||||
|
self._set_callbacks.remove(cb)
|
||||||
|
|
||||||
|
def add_del_callback(self, cb: DelCallback) -> None:
|
||||||
|
self._del_callbacks.append(cb)
|
||||||
|
|
||||||
|
def remove_del_callback(self, cb: DelCallback) -> None:
|
||||||
|
self._del_callbacks.remove(cb)
|
||||||
|
|
||||||
|
def add_ins_callback(self, cb: InsCallback) -> None:
|
||||||
|
self._ins_callbacks.append(cb)
|
||||||
|
|
||||||
|
def remove_ins_callback(self, cb: InsCallback) -> None:
|
||||||
|
self._ins_callbacks.remove(cb)
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def record(self) -> Generator[list[Modification], None, None]:
|
||||||
|
modifications: list[Modification] = []
|
||||||
|
|
||||||
|
def set_cb(buf: Buf, idx: int, victim: str) -> None:
|
||||||
|
modifications.append(SetModification(idx, victim))
|
||||||
|
|
||||||
|
def del_cb(buf: Buf, idx: int, victim: str) -> None:
|
||||||
|
modifications.append(InsModification(idx, victim))
|
||||||
|
|
||||||
|
def ins_cb(buf: Buf, idx: int) -> None:
|
||||||
|
modifications.append(DelModification(idx))
|
||||||
|
|
||||||
|
self.add_set_callback(set_cb)
|
||||||
|
self.add_del_callback(del_cb)
|
||||||
|
self.add_ins_callback(ins_cb)
|
||||||
|
try:
|
||||||
|
yield modifications
|
||||||
|
finally:
|
||||||
|
self.remove_ins_callback(ins_cb)
|
||||||
|
self.remove_del_callback(del_cb)
|
||||||
|
self.remove_set_callback(set_cb)
|
||||||
|
|
||||||
|
def apply(self, modifications: list[Modification]) -> list[Modification]:
|
||||||
|
with self.record() as ret_modifications:
|
||||||
|
for modification in reversed(modifications):
|
||||||
|
modification(self)
|
||||||
|
return ret_modifications
|
||||||
|
|
||||||
|
# position properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def displayable_count(self) -> int:
|
||||||
|
return len(self._lines) - self.file_y
|
||||||
|
|
||||||
|
@property
|
||||||
|
def x(self) -> int:
|
||||||
|
return self._x
|
||||||
|
|
||||||
|
@x.setter
|
||||||
|
def x(self, x: int) -> None:
|
||||||
|
self._x = x
|
||||||
|
self._x_hint = self._cursor_x
|
||||||
|
|
||||||
|
def _extend_positions(self, idx: int) -> None:
|
||||||
|
self._positions.extend([None] * (1 + idx - len(self._positions)))
|
||||||
|
|
||||||
|
def _set_cb(self, buf: Buf, idx: int, victim: str) -> None:
|
||||||
|
self._extend_positions(idx)
|
||||||
|
self._positions[idx] = None
|
||||||
|
|
||||||
|
def _del_cb(self, buf: Buf, idx: int, victim: str) -> None:
|
||||||
|
self._extend_positions(idx)
|
||||||
|
del self._positions[idx]
|
||||||
|
|
||||||
|
def _ins_cb(self, buf: Buf, idx: int) -> None:
|
||||||
|
self._extend_positions(idx)
|
||||||
|
self._positions.insert(idx, None)
|
||||||
|
|
||||||
|
def line_positions(self, idx: int) -> tuple[int, ...]:
|
||||||
|
self._extend_positions(idx)
|
||||||
|
value = self._positions[idx]
|
||||||
|
if value is None:
|
||||||
|
value = _offsets(self._lines[idx], self.tab_size)
|
||||||
|
self._positions[idx] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def line_x(self, margin: Margin) -> int:
|
||||||
|
return line_x(self._cursor_x, margin.cols)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _cursor_x(self) -> int:
|
||||||
|
return self.line_positions(self.y)[self.x]
|
||||||
|
|
||||||
|
def cursor_position(self, margin: Margin) -> tuple[int, int]:
|
||||||
|
y = self.y - self.file_y + margin.header
|
||||||
|
x = self._cursor_x - self.line_x(margin)
|
||||||
|
return y, x
|
||||||
|
|
||||||
|
# rendered lines
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tab_string(self) -> str:
|
||||||
|
if self.expandtabs:
|
||||||
|
return ' ' * self.tab_size
|
||||||
|
else:
|
||||||
|
return '\t'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# movement
|
||||||
|
|
||||||
|
def scroll_screen_if_needed(self, margin: Margin) -> None:
|
||||||
|
# if the `y` is not on screen, make it so
|
||||||
|
if not (self.file_y <= self.y < self.file_y + margin.body_lines):
|
||||||
|
self.file_y = max(self.y - margin.body_lines // 2, 0)
|
||||||
|
|
||||||
|
def _set_x_after_vertical_movement(self) -> None:
|
||||||
|
positions = self.line_positions(self.y)
|
||||||
|
x = bisect.bisect_left(positions, self._x_hint)
|
||||||
|
x = min(len(self._lines[self.y]), x)
|
||||||
|
if positions[x] > self._x_hint:
|
||||||
|
x -= 1
|
||||||
|
self._x = x
|
||||||
|
|
||||||
|
def up(self, margin: Margin) -> None:
|
||||||
|
if self.y > 0:
|
||||||
|
self.y -= 1
|
||||||
|
if self.y < self.file_y:
|
||||||
|
self.file_y = max(self.file_y - margin.scroll_amount, 0)
|
||||||
|
self._set_x_after_vertical_movement()
|
||||||
|
|
||||||
|
def down(self, margin: Margin) -> None:
|
||||||
|
if self.y < len(self._lines) - 1:
|
||||||
|
self.y += 1
|
||||||
|
if self.y >= self.file_y + margin.body_lines:
|
||||||
|
self.file_y += margin.scroll_amount
|
||||||
|
self._set_x_after_vertical_movement()
|
||||||
|
|
||||||
|
def right(self, margin: Margin) -> None:
|
||||||
|
if self.x >= len(self._lines[self.y]):
|
||||||
|
if self.y < len(self._lines) - 1:
|
||||||
|
self.down(margin)
|
||||||
|
self.x = 0
|
||||||
|
else:
|
||||||
|
self.x += 1
|
||||||
|
|
||||||
|
def left(self, margin: Margin) -> None:
|
||||||
|
if self.x == 0:
|
||||||
|
if self.y > 0:
|
||||||
|
self.up(margin)
|
||||||
|
self.x = len(self._lines[self.y])
|
||||||
|
else:
|
||||||
|
self.x -= 1
|
||||||
|
|
||||||
|
# screen movement
|
||||||
|
|
||||||
|
def file_up(self, margin: Margin) -> None:
|
||||||
|
if self.file_y > 0:
|
||||||
|
self.file_y -= 1
|
||||||
|
if self.y > self.file_y + margin.body_lines - 1:
|
||||||
|
self.up(margin)
|
||||||
|
|
||||||
|
def file_down(self, margin: Margin) -> None:
|
||||||
|
if self.file_y < len(self._lines) - 1:
|
||||||
|
self.file_y += 1
|
||||||
|
if self.y < self.file_y:
|
||||||
|
self.down(margin)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if sys.version_info >= (3, 8): # pragma: no cover (>=py38)
|
if sys.version_info >= (3, 8): # pragma: no cover (>=py38)
|
||||||
@@ -5,8 +7,6 @@ if sys.version_info >= (3, 8): # pragma: no cover (>=py38)
|
|||||||
else: # pragma: no cover (<py38)
|
else: # pragma: no cover (<py38)
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from typing import Generic
|
from typing import Generic
|
||||||
from typing import Optional
|
|
||||||
from typing import Type
|
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
TSelf = TypeVar('TSelf')
|
TSelf = TypeVar('TSelf')
|
||||||
@@ -18,8 +18,8 @@ else: # pragma: no cover (<py38)
|
|||||||
|
|
||||||
def __get__(
|
def __get__(
|
||||||
self,
|
self,
|
||||||
instance: Optional[TSelf],
|
instance: TSelf | None,
|
||||||
owner: Optional[Type[TSelf]] = None,
|
owner: type[TSelf] | None = None,
|
||||||
) -> TRet:
|
) -> TRet:
|
||||||
assert instance is not None
|
assert instance is not None
|
||||||
ret = instance.__dict__[self._func.__name__] = self._func(instance)
|
ret = instance.__dict__[self._func.__name__] = self._func(instance)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
# TODO: find a standard which defines these
|
# TODO: find a standard which defines these
|
||||||
@@ -11,7 +13,7 @@ class Color(NamedTuple):
|
|||||||
b: int
|
b: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, s: str) -> 'Color':
|
def parse(cls, s: str) -> Color:
|
||||||
if s.startswith('#') and len(s) >= 7:
|
if s.startswith('#') and len(s) >= 7:
|
||||||
return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
|
return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
|
||||||
elif s.startswith('#'):
|
elif s.startswith('#'):
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import itertools
|
import itertools
|
||||||
from typing import List
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from babi._types import Protocol
|
from babi._types import Protocol
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
@@ -19,19 +18,19 @@ class KD(Protocol):
|
|||||||
@property
|
@property
|
||||||
def n(self) -> int: ...
|
def n(self) -> int: ...
|
||||||
@property
|
@property
|
||||||
def left(self) -> Optional['KD']: ...
|
def left(self) -> KD | None: ...
|
||||||
@property
|
@property
|
||||||
def right(self) -> Optional['KD']: ...
|
def right(self) -> KD | None: ...
|
||||||
|
|
||||||
|
|
||||||
class _KD(NamedTuple):
|
class _KD(NamedTuple):
|
||||||
color: Color
|
color: Color
|
||||||
n: int
|
n: int
|
||||||
left: Optional[KD]
|
left: KD | None
|
||||||
right: Optional[KD]
|
right: KD | None
|
||||||
|
|
||||||
|
|
||||||
def _build(colors: List[Tuple[Color, int]], depth: int = 0) -> Optional[KD]:
|
def _build(colors: list[tuple[Color, int]], depth: int = 0) -> KD | None:
|
||||||
if not colors:
|
if not colors:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -46,11 +45,11 @@ def _build(colors: List[Tuple[Color, int]], depth: int = 0) -> Optional[KD]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def nearest(color: Color, colors: Optional[KD]) -> int:
|
def nearest(color: Color, colors: KD | None) -> int:
|
||||||
best = 0
|
best = 0
|
||||||
dist = 2 ** 32
|
dist = 2 ** 32
|
||||||
|
|
||||||
def _search(kd: Optional[KD], *, depth: int) -> None:
|
def _search(kd: KD | None, *, depth: int) -> None:
|
||||||
nonlocal best
|
nonlocal best
|
||||||
nonlocal dist
|
nonlocal dist
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ def nearest(color: Color, colors: Optional[KD]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def make_256() -> Optional[KD]:
|
def make_256() -> KD | None:
|
||||||
vals = (0, 95, 135, 175, 215, 255)
|
vals = (0, 95, 135, 175, 215, 255)
|
||||||
colors = [
|
colors = [
|
||||||
(Color(r, g, b), i)
|
(Color(r, g, b), i)
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import contextlib
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
from typing import Dict
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from babi import color_kd
|
from babi import color_kd
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
|
|
||||||
|
|
||||||
def _color_to_curses(color: Color) -> Tuple[int, int, int]:
|
def _color_to_curses(color: Color) -> tuple[int, int, int]:
|
||||||
factor = 1000 / 255
|
factor = 1000 / 255
|
||||||
return int(color.r * factor), int(color.g * factor), int(color.b * factor)
|
return int(color.r * factor), int(color.g * factor), int(color.b * factor)
|
||||||
|
|
||||||
|
|
||||||
class ColorManager(NamedTuple):
|
class ColorManager(NamedTuple):
|
||||||
colors: Dict[Color, int]
|
colors: dict[Color, int]
|
||||||
raw_pairs: Dict[Tuple[int, int], int]
|
raw_pairs: dict[tuple[int, int], int]
|
||||||
|
|
||||||
def init_color(self, color: Color) -> None:
|
def init_color(self, color: Color) -> None:
|
||||||
if curses.can_change_color():
|
if curses.can_change_color():
|
||||||
@@ -28,19 +26,24 @@ class ColorManager(NamedTuple):
|
|||||||
else:
|
else:
|
||||||
self.colors[color] = -1
|
self.colors[color] = -1
|
||||||
|
|
||||||
def color_pair(self, fg: Optional[Color], bg: Optional[Color]) -> int:
|
def color_pair(self, fg: Color | None, bg: Color | None) -> int:
|
||||||
fg_i = self.colors[fg] if fg is not None else -1
|
fg_i = self.colors[fg] if fg is not None else -1
|
||||||
bg_i = self.colors[bg] if bg is not None else -1
|
bg_i = self.colors[bg] if bg is not None else -1
|
||||||
return self.raw_color_pair(fg_i, bg_i)
|
return self.raw_color_pair(fg_i, bg_i)
|
||||||
|
|
||||||
def raw_color_pair(self, fg: int, bg: int) -> int:
|
def raw_color_pair(self, fg: int, bg: int) -> int:
|
||||||
with contextlib.suppress(KeyError):
|
if curses.COLORS > 0:
|
||||||
return self.raw_pairs[(fg, bg)]
|
try:
|
||||||
|
return self.raw_pairs[(fg, bg)]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
|
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
|
||||||
curses.init_pair(n, fg, bg)
|
curses.init_pair(n, fg, bg)
|
||||||
return n
|
return n
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def make(cls) -> 'ColorManager':
|
def make(cls) -> ColorManager:
|
||||||
return cls({}, {})
|
return cls({}, {})
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Generic
|
from typing import Generic
|
||||||
from typing import Iterable
|
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 +26,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)
|
||||||
|
|||||||
680
babi/file.py
680
babi/file.py
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,18 @@
|
|||||||
import contextlib
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import os.path
|
import os.path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Match
|
from typing import Match
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from typing import Sequence
|
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from typing import TypeVar
|
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
|
||||||
@@ -35,7 +32,7 @@ def uniquely_constructed(t: T) -> T:
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def _split_name(s: Optional[str]) -> Tuple[str, ...]:
|
def _split_name(s: str | None) -> tuple[str, ...]:
|
||||||
if s is None:
|
if s is None:
|
||||||
return ()
|
return ()
|
||||||
else:
|
else:
|
||||||
@@ -45,17 +42,17 @@ def _split_name(s: Optional[str]) -> Tuple[str, ...]:
|
|||||||
class _Rule(Protocol):
|
class _Rule(Protocol):
|
||||||
"""hax for recursive types python/mypy#731"""
|
"""hax for recursive types python/mypy#731"""
|
||||||
@property
|
@property
|
||||||
def name(self) -> Tuple[str, ...]: ...
|
def name(self) -> tuple[str, ...]: ...
|
||||||
@property
|
@property
|
||||||
def match(self) -> Optional[str]: ...
|
def match(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def begin(self) -> Optional[str]: ...
|
def begin(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def end(self) -> Optional[str]: ...
|
def end(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def while_(self) -> Optional[str]: ...
|
def while_(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def content_name(self) -> Tuple[str, ...]: ...
|
def content_name(self) -> tuple[str, ...]: ...
|
||||||
@property
|
@property
|
||||||
def captures(self) -> Captures: ...
|
def captures(self) -> Captures: ...
|
||||||
@property
|
@property
|
||||||
@@ -65,28 +62,45 @@ class _Rule(Protocol):
|
|||||||
@property
|
@property
|
||||||
def while_captures(self) -> Captures: ...
|
def while_captures(self) -> Captures: ...
|
||||||
@property
|
@property
|
||||||
def include(self) -> Optional[str]: ...
|
def include(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def patterns(self) -> 'Tuple[_Rule, ...]': ...
|
def patterns(self) -> tuple[_Rule, ...]: ...
|
||||||
|
@property
|
||||||
|
def repository(self) -> FChainMap[str, _Rule]: ...
|
||||||
|
|
||||||
|
|
||||||
@uniquely_constructed
|
@uniquely_constructed
|
||||||
class Rule(NamedTuple):
|
class Rule(NamedTuple):
|
||||||
name: Tuple[str, ...]
|
name: tuple[str, ...]
|
||||||
match: Optional[str]
|
match: str | None
|
||||||
begin: Optional[str]
|
begin: str | None
|
||||||
end: Optional[str]
|
end: str | None
|
||||||
while_: Optional[str]
|
while_: str | None
|
||||||
content_name: Tuple[str, ...]
|
content_name: tuple[str, ...]
|
||||||
captures: Captures
|
captures: Captures
|
||||||
begin_captures: Captures
|
begin_captures: Captures
|
||||||
end_captures: Captures
|
end_captures: Captures
|
||||||
while_captures: Captures
|
while_captures: Captures
|
||||||
include: Optional[str]
|
include: str | None
|
||||||
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')
|
||||||
@@ -96,7 +110,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:
|
||||||
@@ -104,7 +118,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:
|
||||||
@@ -112,7 +126,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:
|
||||||
@@ -120,7 +134,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:
|
||||||
@@ -142,7 +156,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 = ()
|
||||||
|
|
||||||
@@ -159,29 +173,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
|
||||||
patterns: Tuple[_Rule, ...]
|
repository: FChainMap[str, _Rule]
|
||||||
repository: FDict[str, _Rule]
|
patterns: tuple[_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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -192,54 +210,54 @@ class Region(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class State(NamedTuple):
|
class State(NamedTuple):
|
||||||
entries: Tuple['Entry', ...]
|
entries: tuple[Entry, ...]
|
||||||
while_stack: Tuple[Tuple['WhileRule', int], ...]
|
while_stack: tuple[tuple[WhileRule, int], ...]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def root(cls, entry: 'Entry') -> 'State':
|
def root(cls, entry: Entry) -> State:
|
||||||
return cls((entry,), ())
|
return cls((entry,), ())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cur(self) -> 'Entry':
|
def cur(self) -> Entry:
|
||||||
return self.entries[-1]
|
return self.entries[-1]
|
||||||
|
|
||||||
def push(self, entry: 'Entry') -> 'State':
|
def push(self, entry: Entry) -> State:
|
||||||
return self._replace(entries=(*self.entries, entry))
|
return self._replace(entries=(*self.entries, entry))
|
||||||
|
|
||||||
def pop(self) -> 'State':
|
def pop(self) -> State:
|
||||||
return self._replace(entries=self.entries[:-1])
|
return self._replace(entries=self.entries[:-1])
|
||||||
|
|
||||||
def push_while(self, rule: 'WhileRule', entry: 'Entry') -> 'State':
|
def push_while(self, rule: WhileRule, entry: Entry) -> State:
|
||||||
entries = (*self.entries, entry)
|
entries = (*self.entries, entry)
|
||||||
while_stack = (*self.while_stack, (rule, len(entries)))
|
while_stack = (*self.while_stack, (rule, len(entries)))
|
||||||
return self._replace(entries=entries, while_stack=while_stack)
|
return self._replace(entries=entries, while_stack=while_stack)
|
||||||
|
|
||||||
def pop_while(self) -> 'State':
|
def pop_while(self) -> State:
|
||||||
entries, while_stack = self.entries[:-1], self.while_stack[:-1]
|
entries, while_stack = self.entries[:-1], self.while_stack[:-1]
|
||||||
return self._replace(entries=entries, while_stack=while_stack)
|
return self._replace(entries=entries, while_stack=while_stack)
|
||||||
|
|
||||||
|
|
||||||
class CompiledRule(Protocol):
|
class CompiledRule(Protocol):
|
||||||
@property
|
@property
|
||||||
def name(self) -> Tuple[str, ...]: ...
|
def name(self) -> tuple[str, ...]: ...
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
match: Match[str],
|
match: Match[str],
|
||||||
state: State,
|
state: State,
|
||||||
) -> Tuple[State, bool, Regions]:
|
) -> tuple[State, bool, Regions]:
|
||||||
...
|
...
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
) -> tuple[State, int, bool, Regions] | None:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -247,24 +265,25 @@ class CompiledRegsetRule(CompiledRule, Protocol):
|
|||||||
@property
|
@property
|
||||||
def regset(self) -> _RegSet: ...
|
def regset(self) -> _RegSet: ...
|
||||||
@property
|
@property
|
||||||
def u_rules(self) -> Tuple[_Rule, ...]: ...
|
def u_rules(self) -> tuple[_Rule, ...]: ...
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
def _inner_capture_parse(
|
def _inner_capture_parse(
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
start: int,
|
start: int,
|
||||||
s: str,
|
s: str,
|
||||||
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
|
||||||
@@ -272,12 +291,12 @@ def _inner_capture_parse(
|
|||||||
|
|
||||||
|
|
||||||
def _captures(
|
def _captures(
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
match: Match[str],
|
match: Match[str],
|
||||||
captures: Captures,
|
captures: Captures,
|
||||||
) -> Regions:
|
) -> Regions:
|
||||||
ret: List[Region] = []
|
ret: list[Region] = []
|
||||||
pos, pos_end = match.span()
|
pos, pos_end = match.span()
|
||||||
for i, u_rule in captures:
|
for i, u_rule in captures:
|
||||||
try:
|
try:
|
||||||
@@ -326,12 +345,12 @@ def _captures(
|
|||||||
|
|
||||||
def _do_regset(
|
def _do_regset(
|
||||||
idx: int,
|
idx: int,
|
||||||
match: Optional[Match[str]],
|
match: Match[str] | None,
|
||||||
rule: CompiledRegsetRule,
|
rule: CompiledRegsetRule,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
pos: int,
|
pos: int,
|
||||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
) -> tuple[State, int, bool, Regions] | None:
|
||||||
if match is None:
|
if match is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -348,104 +367,114 @@ def _do_regset(
|
|||||||
|
|
||||||
@uniquely_constructed
|
@uniquely_constructed
|
||||||
class PatternRule(NamedTuple):
|
class PatternRule(NamedTuple):
|
||||||
name: Tuple[str, ...]
|
name: tuple[str, ...]
|
||||||
regset: _RegSet
|
regset: _RegSet
|
||||||
u_rules: Tuple[_Rule, ...]
|
u_rules: tuple[_Rule, ...]
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
match: Match[str],
|
match: Match[str],
|
||||||
state: State,
|
state: State,
|
||||||
) -> Tuple[State, bool, Regions]:
|
) -> tuple[State, bool, Regions]:
|
||||||
raise AssertionError(f'unreachable {self}')
|
raise AssertionError(f'unreachable {self}')
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
) -> tuple[State, int, bool, Regions] | None:
|
||||||
idx, match = self.regset.search(line, pos, first_line, boundary)
|
idx, match = self.regset.search(line, pos, first_line, boundary)
|
||||||
return _do_regset(idx, match, self, compiler, state, pos)
|
return _do_regset(idx, match, self, compiler, state, pos)
|
||||||
|
|
||||||
|
|
||||||
@uniquely_constructed
|
@uniquely_constructed
|
||||||
class MatchRule(NamedTuple):
|
class MatchRule(NamedTuple):
|
||||||
name: Tuple[str, ...]
|
name: tuple[str, ...]
|
||||||
captures: Captures
|
captures: Captures
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
match: Match[str],
|
match: Match[str],
|
||||||
state: State,
|
state: State,
|
||||||
) -> Tuple[State, bool, Regions]:
|
) -> tuple[State, bool, Regions]:
|
||||||
scope = state.cur.scope + self.name
|
scope = state.cur.scope + self.name
|
||||||
return state, False, _captures(compiler, scope, match, self.captures)
|
return state, False, _captures(compiler, scope, match, self.captures)
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
) -> tuple[State, int, bool, Regions] | None:
|
||||||
raise AssertionError(f'unreachable {self}')
|
raise AssertionError(f'unreachable {self}')
|
||||||
|
|
||||||
|
|
||||||
@uniquely_constructed
|
@uniquely_constructed
|
||||||
class EndRule(NamedTuple):
|
class EndRule(NamedTuple):
|
||||||
name: Tuple[str, ...]
|
name: tuple[str, ...]
|
||||||
content_name: Tuple[str, ...]
|
content_name: tuple[str, ...]
|
||||||
begin_captures: Captures
|
begin_captures: Captures
|
||||||
end_captures: Captures
|
end_captures: Captures
|
||||||
end: str
|
end: str
|
||||||
regset: _RegSet
|
regset: _RegSet
|
||||||
u_rules: Tuple[_Rule, ...]
|
u_rules: tuple[_Rule, ...]
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
match: Match[str],
|
match: Match[str],
|
||||||
state: State,
|
state: State,
|
||||||
) -> Tuple[State, bool, Regions]:
|
) -> tuple[State, bool, Regions]:
|
||||||
scope = state.cur.scope + self.name
|
scope = state.cur.scope + self.name
|
||||||
next_scope = scope + self.content_name
|
next_scope = scope + self.content_name
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def _end_ret(
|
def _end_ret(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
pos: int,
|
pos: int,
|
||||||
m: Match[str],
|
m: Match[str],
|
||||||
) -> Tuple[State, int, bool, Regions]:
|
) -> tuple[State, int, bool, Regions]:
|
||||||
ret = []
|
ret = []
|
||||||
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,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
) -> tuple[State, int, bool, Regions] | None:
|
||||||
end_match = state.cur.reg.search(line, pos, first_line, boundary)
|
end_match = state.cur.reg.search(line, pos, first_line, boundary)
|
||||||
if end_match is not None and end_match.start() == pos:
|
if end_match is not None and end_match.start() == pos:
|
||||||
return self._end_ret(compiler, state, pos, end_match)
|
return self._end_ret(compiler, state, pos, end_match)
|
||||||
@@ -462,38 +491,40 @@ class EndRule(NamedTuple):
|
|||||||
|
|
||||||
@uniquely_constructed
|
@uniquely_constructed
|
||||||
class WhileRule(NamedTuple):
|
class WhileRule(NamedTuple):
|
||||||
name: Tuple[str, ...]
|
name: tuple[str, ...]
|
||||||
content_name: Tuple[str, ...]
|
content_name: tuple[str, ...]
|
||||||
begin_captures: Captures
|
begin_captures: Captures
|
||||||
while_captures: Captures
|
while_captures: Captures
|
||||||
while_: str
|
while_: str
|
||||||
regset: _RegSet
|
regset: _RegSet
|
||||||
u_rules: Tuple[_Rule, ...]
|
u_rules: tuple[_Rule, ...]
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
match: Match[str],
|
match: Match[str],
|
||||||
state: State,
|
state: State,
|
||||||
) -> Tuple[State, bool, Regions]:
|
) -> tuple[State, bool, Regions]:
|
||||||
scope = state.cur.scope + self.name
|
scope = state.cur.scope + self.name
|
||||||
next_scope = scope + self.content_name
|
next_scope = scope + self.content_name
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def continues(
|
def continues(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Tuple[int, bool, Regions]]:
|
) -> tuple[int, bool, Regions] | None:
|
||||||
match = state.cur.reg.match(line, pos, first_line, boundary)
|
match = state.cur.reg.match(line, pos, first_line, boundary)
|
||||||
if match is None:
|
if match is None:
|
||||||
return None
|
return None
|
||||||
@@ -503,25 +534,25 @@ class WhileRule(NamedTuple):
|
|||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
) -> tuple[State, int, bool, Regions] | None:
|
||||||
idx, match = self.regset.search(line, pos, first_line, boundary)
|
idx, match = self.regset.search(line, pos, first_line, boundary)
|
||||||
return _do_regset(idx, match, self, compiler, state, pos)
|
return _do_regset(idx, match, self, compiler, state, pos)
|
||||||
|
|
||||||
|
|
||||||
class Compiler:
|
class Compiler:
|
||||||
def __init__(self, grammar: Grammar, grammars: 'Grammars') -> None:
|
def __init__(self, grammar: Grammar, grammars: Grammars) -> None:
|
||||||
self._root_scope = grammar.scope_name
|
self._root_scope = grammar.scope_name
|
||||||
self._grammars = grammars
|
self._grammars = grammars
|
||||||
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
|
||||||
@@ -531,34 +562,37 @@ 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(
|
||||||
self,
|
self,
|
||||||
grammar: Grammar,
|
grammar: Grammar,
|
||||||
rules: Tuple[_Rule, ...],
|
rules: tuple[_Rule, ...],
|
||||||
) -> Tuple[List[str], Tuple[_Rule, ...]]:
|
) -> tuple[list[str], tuple[_Rule, ...]]:
|
||||||
ret_regs = []
|
ret_regs = []
|
||||||
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:
|
||||||
@@ -618,8 +652,10 @@ class Compiler:
|
|||||||
return PatternRule(rule.name, make_regset(*regs), rules)
|
return PatternRule(rule.name, make_regset(*regs), rules)
|
||||||
|
|
||||||
def compile_rule(self, rule: _Rule) -> CompiledRule:
|
def compile_rule(self, rule: _Rule) -> CompiledRule:
|
||||||
with contextlib.suppress(KeyError):
|
try:
|
||||||
return self._c_rules[rule]
|
return self._c_rules[rule]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
grammar = self._rule_to_grammar[rule]
|
grammar = self._rule_to_grammar[rule]
|
||||||
ret = self._c_rules[rule] = self._compile_rule(grammar, rule)
|
ret = self._c_rules[rule] = self._compile_rule(grammar, rule)
|
||||||
@@ -627,41 +663,58 @@ class Compiler:
|
|||||||
|
|
||||||
|
|
||||||
class Grammars:
|
class Grammars:
|
||||||
def __init__(self, grammars: Sequence[Dict[str, Any]]) -> None:
|
def __init__(self, *directories: str) -> None:
|
||||||
self._raw = {grammar['scopeName']: grammar for grammar in grammars}
|
self._scope_to_files = {
|
||||||
self._find_scope = [
|
os.path.splitext(filename)[0]: os.path.join(directory, filename)
|
||||||
(
|
for directory in directories
|
||||||
frozenset(grammar.get('fileTypes', ())),
|
if os.path.exists(directory)
|
||||||
make_reg(grammar.get('firstLineMatch', '$impossible^')),
|
for filename in sorted(os.listdir(directory))
|
||||||
grammar['scopeName'],
|
if filename.endswith('.json')
|
||||||
)
|
}
|
||||||
for grammar in grammars
|
|
||||||
]
|
|
||||||
self._parsed: Dict[str, Grammar] = {}
|
|
||||||
self._compilers: Dict[str, Compiler] = {}
|
|
||||||
|
|
||||||
@classmethod
|
unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []}
|
||||||
def from_syntax_dir(cls, syntax_dir: str) -> 'Grammars':
|
self._raw = {'source.unknown': unknown_grammar}
|
||||||
grammars = [{'scopeName': 'source.unknown', 'patterns': []}]
|
self._file_types: list[tuple[frozenset[str], str]] = []
|
||||||
if os.path.exists(syntax_dir):
|
self._first_line: list[tuple[_Reg, str]] = []
|
||||||
for filename in os.listdir(syntax_dir):
|
self._parsed: dict[str, Grammar] = {}
|
||||||
with open(os.path.join(syntax_dir, filename)) as f:
|
self._compiled: dict[str, Compiler] = {}
|
||||||
grammars.append(json.load(f))
|
|
||||||
return cls(grammars)
|
def _raw_for_scope(self, scope: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return self._raw[scope]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
grammar_path = self._scope_to_files.pop(scope)
|
||||||
|
with open(grammar_path, encoding='UTF-8') as f:
|
||||||
|
ret = self._raw[scope] = json.load(f)
|
||||||
|
|
||||||
|
file_types = frozenset(ret.get('fileTypes', ()))
|
||||||
|
first_line = make_reg(ret.get('firstLineMatch', '$impossible^'))
|
||||||
|
|
||||||
|
self._file_types.append((file_types, scope))
|
||||||
|
self._first_line.append((first_line, scope))
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
def grammar_for_scope(self, scope: str) -> Grammar:
|
def grammar_for_scope(self, scope: str) -> Grammar:
|
||||||
with contextlib.suppress(KeyError):
|
try:
|
||||||
return self._parsed[scope]
|
return self._parsed[scope]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
ret = self._parsed[scope] = Grammar.from_data(self._raw[scope])
|
raw = self._raw_for_scope(scope)
|
||||||
|
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:
|
||||||
with contextlib.suppress(KeyError):
|
try:
|
||||||
return self._compilers[scope]
|
return self._compiled[scope]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
grammar = self.grammar_for_scope(scope)
|
grammar = self.grammar_for_scope(scope)
|
||||||
ret = self._compilers[scope] = Compiler(grammar, self)
|
ret = self._compiled[scope] = Compiler(grammar, self)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def blank_compiler(self) -> Compiler:
|
def blank_compiler(self) -> Compiler:
|
||||||
@@ -669,29 +722,35 @@ class Grammars:
|
|||||||
|
|
||||||
def compiler_for_file(self, filename: str, first_line: str) -> Compiler:
|
def compiler_for_file(self, filename: str, first_line: str) -> Compiler:
|
||||||
for tag in tags_from_filename(filename) - {'text'}:
|
for tag in tags_from_filename(filename) - {'text'}:
|
||||||
with contextlib.suppress(KeyError):
|
try:
|
||||||
|
# TODO: this doesn't always match even if we detect it
|
||||||
return self.compiler_for_scope(f'source.{tag}')
|
return self.compiler_for_scope(f'source.{tag}')
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# didn't find it in the fast path, need to read all the json
|
||||||
|
for k in tuple(self._scope_to_files):
|
||||||
|
self._raw_for_scope(k)
|
||||||
|
|
||||||
_, _, ext = os.path.basename(filename).rpartition('.')
|
_, _, ext = os.path.basename(filename).rpartition('.')
|
||||||
for extensions, first_line_match, scope_name in self._find_scope:
|
for extensions, scope in self._file_types:
|
||||||
if (
|
if ext in extensions:
|
||||||
ext in extensions or
|
return self.compiler_for_scope(scope)
|
||||||
first_line_match.match(
|
|
||||||
first_line, 0, first_line=True, boundary=True,
|
for reg, scope in self._first_line:
|
||||||
)
|
if reg.match(first_line, 0, first_line=True, boundary=True):
|
||||||
):
|
return self.compiler_for_scope(scope)
|
||||||
return self.compiler_for_scope(scope_name)
|
|
||||||
else:
|
return self.compiler_for_scope('source.unknown')
|
||||||
return self.compiler_for_scope('source.unknown')
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_line(
|
def highlight_line(
|
||||||
compiler: 'Compiler',
|
compiler: Compiler,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
) -> Tuple[State, Regions]:
|
) -> tuple[State, Regions]:
|
||||||
ret: List[Region] = []
|
ret: list[Region] = []
|
||||||
pos = 0
|
pos = 0
|
||||||
boundary = state.cur.boundary
|
boundary = state.cur.boundary
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
import contextlib
|
||||||
import os.path
|
import os.path
|
||||||
from typing import Dict
|
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from babi.user_data import xdg_data
|
from babi.user_data import xdg_data
|
||||||
|
|
||||||
|
|
||||||
class History:
|
class History:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._orig_len: Dict[str, int] = collections.defaultdict(int)
|
self._orig_len: dict[str, int] = collections.defaultdict(int)
|
||||||
self.data: Dict[str, List[str]] = collections.defaultdict(list)
|
self.data: dict[str, list[str]] = collections.defaultdict(list)
|
||||||
self.prev: Dict[str, str] = {}
|
self.prev: dict[str, str] = {}
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def save(self) -> Generator[None, None, None]:
|
def save(self) -> Generator[None, None, None]:
|
||||||
history_dir = xdg_data('history')
|
history_dir = xdg_data('history')
|
||||||
os.makedirs(history_dir, exist_ok=True)
|
os.makedirs(history_dir, exist_ok=True)
|
||||||
for filename in os.listdir(history_dir):
|
for filename in os.listdir(history_dir):
|
||||||
with open(os.path.join(history_dir, filename)) as f:
|
history_filename = os.path.join(history_dir, filename)
|
||||||
|
with open(history_filename, encoding='UTF-8') as f:
|
||||||
self.data[filename] = f.read().splitlines()
|
self.data[filename] = f.read().splitlines()
|
||||||
self._orig_len[filename] = len(self.data[filename])
|
self._orig_len[filename] = len(self.data[filename])
|
||||||
try:
|
try:
|
||||||
@@ -28,5 +29,6 @@ class History:
|
|||||||
for k, v in self.data.items():
|
for k, v in self.data.items():
|
||||||
new_history = v[self._orig_len[k]:]
|
new_history = v[self._orig_len[k]:]
|
||||||
if new_history:
|
if new_history:
|
||||||
with open(os.path.join(history_dir, k), 'a+') as f:
|
history_filename = os.path.join(history_dir, k)
|
||||||
|
with open(history_filename, 'a+', encoding='UTF-8') as f:
|
||||||
f.write('\n'.join(new_history) + '\n')
|
f.write('\n'.join(new_history) + '\n')
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from babi._types import Protocol
|
from babi._types import Protocol
|
||||||
from babi.list_spy import SequenceNoSlice
|
from babi.buf import Buf
|
||||||
|
|
||||||
|
|
||||||
class HL(NamedTuple):
|
class HL(NamedTuple):
|
||||||
@@ -23,8 +25,8 @@ class FileHL(Protocol):
|
|||||||
def include_edge(self) -> bool: ...
|
def include_edge(self) -> bool: ...
|
||||||
@property
|
@property
|
||||||
def regions(self) -> RegionsMapping: ...
|
def regions(self) -> RegionsMapping: ...
|
||||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ...
|
def highlight_until(self, lines: Buf, idx: int) -> None: ...
|
||||||
def touch(self, lineno: int) -> None: ...
|
def register_callbacks(self, buf: Buf) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class HLFactory(Protocol):
|
class HLFactory(Protocol):
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
import contextlib
|
||||||
import curses
|
import curses
|
||||||
from typing import Dict
|
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
from babi.hl.interface import HL
|
from babi.hl.interface import HL
|
||||||
from babi.hl.interface import HLs
|
from babi.hl.interface import HLs
|
||||||
from babi.list_spy import SequenceNoSlice
|
|
||||||
|
|
||||||
|
|
||||||
class Replace:
|
class Replace:
|
||||||
include_edge = True
|
include_edge = True
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
self.regions: dict[int, HLs] = collections.defaultdict(tuple)
|
||||||
|
|
||||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||||
"""our highlight regions are populated in other ways"""
|
"""our highlight regions are populated in other ways"""
|
||||||
|
|
||||||
def touch(self, lineno: int) -> None:
|
def register_callbacks(self, buf: Buf) -> None:
|
||||||
"""our highlight regions are populated in other ways"""
|
"""our highlight regions are populated in other ways"""
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import curses
|
import curses
|
||||||
from typing import Dict
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
from babi.hl.interface import HL
|
from babi.hl.interface import HL
|
||||||
from babi.hl.interface import HLs
|
from babi.hl.interface import HLs
|
||||||
from babi.list_spy import SequenceNoSlice
|
|
||||||
|
|
||||||
|
|
||||||
class Selection:
|
class Selection:
|
||||||
include_edge = True
|
include_edge = True
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
self.regions: dict[int, HLs] = collections.defaultdict(tuple)
|
||||||
self.start: Optional[Tuple[int, int]] = None
|
self.start: tuple[int, int] | None = None
|
||||||
self.end: Optional[Tuple[int, int]] = None
|
self.end: tuple[int, int] | None = None
|
||||||
|
|
||||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
def register_callbacks(self, buf: Buf) -> None:
|
||||||
|
"""our highlight regions are populated in other ways"""
|
||||||
|
|
||||||
|
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||||
if self.start is None or self.end is None:
|
if self.start is None or self.end is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -36,10 +38,7 @@ class Selection:
|
|||||||
)
|
)
|
||||||
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
|
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
|
||||||
|
|
||||||
def touch(self, lineno: int) -> None:
|
def get(self) -> tuple[tuple[int, int], tuple[int, int]]:
|
||||||
"""our highlight regions are populated in other ways"""
|
|
||||||
|
|
||||||
def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
|
||||||
assert self.start is not None and self.end is not None
|
assert self.start is not None and self.end is not None
|
||||||
if self.start < self.end:
|
if self.start < self.end:
|
||||||
return self.start, self.end
|
return self.start, self.end
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import curses
|
from __future__ import annotations
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import NamedTuple
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import functools
|
||||||
|
import math
|
||||||
|
from typing import Callable
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
from babi.color_manager import ColorManager
|
from babi.color_manager import ColorManager
|
||||||
from babi.highlight import Compiler
|
from babi.highlight import Compiler
|
||||||
from babi.highlight import Grammars
|
from babi.highlight import Grammars
|
||||||
@@ -11,14 +14,12 @@ from babi.highlight import highlight_line
|
|||||||
from babi.highlight import State
|
from babi.highlight import State
|
||||||
from babi.hl.interface import HL
|
from babi.hl.interface import HL
|
||||||
from babi.hl.interface import HLs
|
from babi.hl.interface import HLs
|
||||||
from babi.list_spy import SequenceNoSlice
|
|
||||||
from babi.theme import Style
|
from babi.theme import Style
|
||||||
from babi.theme import Theme
|
from babi.theme import Theme
|
||||||
|
from babi.user_data import prefix_data
|
||||||
from babi.user_data import xdg_config
|
from babi.user_data import xdg_config
|
||||||
from babi.user_data import xdg_data
|
from babi.user_data import xdg_data
|
||||||
|
|
||||||
A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # new in py37
|
|
||||||
|
|
||||||
|
|
||||||
class FileSyntax:
|
class FileSyntax:
|
||||||
include_edge = False
|
include_edge = False
|
||||||
@@ -33,41 +34,38 @@ class FileSyntax:
|
|||||||
self._theme = theme
|
self._theme = theme
|
||||||
self._color_manager = color_manager
|
self._color_manager = color_manager
|
||||||
|
|
||||||
self.regions: List[HLs] = []
|
self.regions: list[HLs] = []
|
||||||
self._states: List[State] = []
|
self._states: list[State] = []
|
||||||
|
|
||||||
self._hl_cache: Dict[str, Dict[State, Tuple[State, HLs]]]
|
# this will be assigned a functools.lru_cache per instance for
|
||||||
self._hl_cache = {}
|
# better hit rate and memory usage
|
||||||
|
self._hl: Callable[[State, str, bool], tuple[State, HLs]] | None
|
||||||
|
self._hl = None
|
||||||
|
|
||||||
def attr(self, style: Style) -> int:
|
def attr(self, style: Style) -> int:
|
||||||
pair = self._color_manager.color_pair(style.fg, style.bg)
|
pair = self._color_manager.color_pair(style.fg, style.bg)
|
||||||
return (
|
return (
|
||||||
curses.color_pair(pair) |
|
curses.color_pair(pair) |
|
||||||
curses.A_BOLD * style.b |
|
curses.A_BOLD * style.b |
|
||||||
A_ITALIC * style.i |
|
curses.A_ITALIC * style.i |
|
||||||
curses.A_UNDERLINE * style.u
|
curses.A_UNDERLINE * style.u
|
||||||
)
|
)
|
||||||
|
|
||||||
def _hl(
|
def _hl_uncached(
|
||||||
self,
|
self,
|
||||||
state: State,
|
state: State,
|
||||||
line: str,
|
line: str,
|
||||||
i: int,
|
first_line: bool,
|
||||||
) -> Tuple[State, HLs]:
|
) -> tuple[State, HLs]:
|
||||||
try:
|
|
||||||
return self._hl_cache[line][state]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
new_state, regions = highlight_line(
|
new_state, regions = highlight_line(
|
||||||
self._compiler, state, f'{line}\n', first_line=i == 0,
|
self._compiler, state, f'{line}\n', first_line=first_line,
|
||||||
)
|
)
|
||||||
|
|
||||||
# remove the trailing newline
|
# remove the trailing newline
|
||||||
new_end = regions[-1]._replace(end=regions[-1].end - 1)
|
new_end = regions[-1]._replace(end=regions[-1].end - 1)
|
||||||
regions = regions[:-1] + (new_end,)
|
regions = regions[:-1] + (new_end,)
|
||||||
|
|
||||||
regs: List[HL] = []
|
regs: list[HL] = []
|
||||||
for r in regions:
|
for r in regions:
|
||||||
style = self._theme.select(r.scope)
|
style = self._theme.select(r.scope)
|
||||||
if style == self._theme.default:
|
if style == self._theme.default:
|
||||||
@@ -83,25 +81,41 @@ class FileSyntax:
|
|||||||
else:
|
else:
|
||||||
regs.append(HL(x=r.start, end=r.end, attr=attr))
|
regs.append(HL(x=r.start, end=r.end, attr=attr))
|
||||||
|
|
||||||
dct = self._hl_cache.setdefault(line, {})
|
return new_state, tuple(regs)
|
||||||
ret = dct[state] = (new_state, tuple(regs))
|
|
||||||
return ret
|
def _set_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||||
|
del self.regions[idx:]
|
||||||
|
del self._states[idx:]
|
||||||
|
|
||||||
|
def _del_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||||
|
del self.regions[idx:]
|
||||||
|
del self._states[idx:]
|
||||||
|
|
||||||
|
def _ins_cb(self, lines: Buf, idx: int) -> None:
|
||||||
|
del self.regions[idx:]
|
||||||
|
del self._states[idx:]
|
||||||
|
|
||||||
|
def register_callbacks(self, buf: Buf) -> None:
|
||||||
|
buf.add_set_callback(self._set_cb)
|
||||||
|
buf.add_del_callback(self._del_cb)
|
||||||
|
buf.add_ins_callback(self._ins_cb)
|
||||||
|
|
||||||
|
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||||
|
if self._hl is None:
|
||||||
|
# the docs claim better performance with power of two sizing
|
||||||
|
size = max(4096, 2 ** (int(math.log(len(lines), 2)) + 2))
|
||||||
|
self._hl = functools.lru_cache(maxsize=size)(self._hl_uncached)
|
||||||
|
|
||||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
|
||||||
if not self._states:
|
if not self._states:
|
||||||
state = self._compiler.root_state
|
state = self._compiler.root_state
|
||||||
else:
|
else:
|
||||||
state = self._states[-1]
|
state = self._states[-1]
|
||||||
|
|
||||||
for i in range(len(self._states), idx):
|
for i in range(len(self._states), idx):
|
||||||
state, regions = self._hl(state, lines[i], i)
|
state, regions = self._hl(state, lines[i], i == 0)
|
||||||
self._states.append(state)
|
self._states.append(state)
|
||||||
self.regions.append(regions)
|
self.regions.append(regions)
|
||||||
|
|
||||||
def touch(self, lineno: int) -> None:
|
|
||||||
del self._states[lineno:]
|
|
||||||
del self.regions[lineno:]
|
|
||||||
|
|
||||||
|
|
||||||
class Syntax(NamedTuple):
|
class Syntax(NamedTuple):
|
||||||
grammars: Grammars
|
grammars: Grammars
|
||||||
@@ -116,7 +130,7 @@ class Syntax(NamedTuple):
|
|||||||
compiler = self.grammars.blank_compiler()
|
compiler = self.grammars.blank_compiler()
|
||||||
return FileSyntax(compiler, self.theme, self.color_manager)
|
return FileSyntax(compiler, self.theme, self.color_manager)
|
||||||
|
|
||||||
def _init_screen(self, stdscr: 'curses._CursesWindow') -> None:
|
def _init_screen(self, stdscr: curses._CursesWindow) -> None:
|
||||||
default_fg, default_bg = self.theme.default.fg, self.theme.default.bg
|
default_fg, default_bg = self.theme.default.fg, self.theme.default.bg
|
||||||
all_colors = {c for c in (default_fg, default_bg) if c is not None}
|
all_colors = {c for c in (default_fg, default_bg) if c is not None}
|
||||||
todo = list(self.theme.rules.children.values())
|
todo = list(self.theme.rules.children.values())
|
||||||
@@ -137,10 +151,10 @@ class Syntax(NamedTuple):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_screen(
|
def from_screen(
|
||||||
cls,
|
cls,
|
||||||
stdscr: 'curses._CursesWindow',
|
stdscr: curses._CursesWindow,
|
||||||
color_manager: ColorManager,
|
color_manager: ColorManager,
|
||||||
) -> 'Syntax':
|
) -> Syntax:
|
||||||
grammars = Grammars.from_syntax_dir(xdg_data('textmate_syntax'))
|
grammars = Grammars(prefix_data('grammar_v1'), xdg_data('grammar_v1'))
|
||||||
theme = Theme.from_filename(xdg_config('theme.json'))
|
theme = Theme.from_filename(xdg_config('theme.json'))
|
||||||
ret = cls(grammars, theme, color_manager)
|
ret = cls(grammars, theme, color_manager)
|
||||||
ret._init_screen(stdscr)
|
ret._init_screen(stdscr)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import curses
|
from __future__ import annotations
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
from babi.color_manager import ColorManager
|
from babi.color_manager import ColorManager
|
||||||
from babi.hl.interface import HL
|
from babi.hl.interface import HL
|
||||||
from babi.hl.interface import HLs
|
from babi.hl.interface import HLs
|
||||||
from babi.list_spy import SequenceNoSlice
|
|
||||||
|
|
||||||
|
|
||||||
class TrailingWhitespace:
|
class TrailingWhitespace:
|
||||||
@@ -13,7 +14,7 @@ class TrailingWhitespace:
|
|||||||
def __init__(self, color_manager: ColorManager) -> None:
|
def __init__(self, color_manager: ColorManager) -> None:
|
||||||
self._color_manager = color_manager
|
self._color_manager = color_manager
|
||||||
|
|
||||||
self.regions: List[HLs] = []
|
self.regions: list[HLs] = []
|
||||||
|
|
||||||
def _trailing_ws(self, line: str) -> HLs:
|
def _trailing_ws(self, line: str) -> HLs:
|
||||||
if not line:
|
if not line:
|
||||||
@@ -30,9 +31,23 @@ class TrailingWhitespace:
|
|||||||
attr = curses.color_pair(pair)
|
attr = curses.color_pair(pair)
|
||||||
return (HL(x=i, end=len(line), attr=attr),)
|
return (HL(x=i, end=len(line), attr=attr),)
|
||||||
|
|
||||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
def _set_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||||
|
if idx < len(self.regions):
|
||||||
|
self.regions[idx] = self._trailing_ws(lines[idx])
|
||||||
|
|
||||||
|
def _del_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||||
|
if idx < len(self.regions):
|
||||||
|
del self.regions[idx]
|
||||||
|
|
||||||
|
def _ins_cb(self, lines: Buf, idx: int) -> None:
|
||||||
|
if idx < len(self.regions):
|
||||||
|
self.regions.insert(idx, self._trailing_ws(lines[idx]))
|
||||||
|
|
||||||
|
def register_callbacks(self, buf: Buf) -> None:
|
||||||
|
buf.add_set_callback(self._set_cb)
|
||||||
|
buf.add_del_callback(self._del_cb)
|
||||||
|
buf.add_ins_callback(self._ins_cb)
|
||||||
|
|
||||||
|
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||||
for i in range(len(self.regions), idx):
|
for i in range(len(self.regions), idx):
|
||||||
self.regions.append(self._trailing_ws(lines[i]))
|
self.regions.append(self._trailing_ws(lines[i]))
|
||||||
|
|
||||||
def touch(self, lineno: int) -> None:
|
|
||||||
del self.regions[lineno:]
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
|
||||||
|
from babi.cached_property import cached_property
|
||||||
|
|
||||||
|
|
||||||
def line_x(x: int, width: int) -> int:
|
def line_x(x: int, width: int) -> int:
|
||||||
if x + 1 < width:
|
if x + 1 < width:
|
||||||
return 0
|
return 0
|
||||||
@@ -25,3 +32,17 @@ def scrolled_line(s: str, x: int, width: int) -> str:
|
|||||||
return f'{s[:width - 1]}»'
|
return f'{s[:width - 1]}»'
|
||||||
else:
|
else:
|
||||||
return s.ljust(width)
|
return s.ljust(width)
|
||||||
|
|
||||||
|
|
||||||
|
class _CalcWidth:
|
||||||
|
@cached_property
|
||||||
|
def _window(self) -> curses._CursesWindow:
|
||||||
|
return curses.newwin(1, 10)
|
||||||
|
|
||||||
|
def wcwidth(self, c: str) -> int:
|
||||||
|
self._window.addstr(0, 0, c)
|
||||||
|
return self._window.getyx()[1]
|
||||||
|
|
||||||
|
|
||||||
|
wcwidth = _CalcWidth().wcwidth
|
||||||
|
del _CalcWidth
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import functools
|
|
||||||
import sys
|
|
||||||
from typing import Callable
|
|
||||||
from typing import Iterator
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from babi._types import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class SequenceNoSlice(Protocol):
|
|
||||||
def __len__(self) -> int: ...
|
|
||||||
def __getitem__(self, idx: int) -> str: ...
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[str]:
|
|
||||||
for i in range(len(self)):
|
|
||||||
yield self[i]
|
|
||||||
|
|
||||||
|
|
||||||
class MutableSequenceNoSlice(SequenceNoSlice, Protocol):
|
|
||||||
def __setitem__(self, idx: int, val: str) -> None: ...
|
|
||||||
def __delitem__(self, idx: int) -> None: ...
|
|
||||||
def insert(self, idx: int, val: str) -> None: ...
|
|
||||||
|
|
||||||
def append(self, val: str) -> None:
|
|
||||||
self.insert(len(self), val)
|
|
||||||
|
|
||||||
def pop(self, idx: int = -1) -> str:
|
|
||||||
victim = self[idx]
|
|
||||||
del self[idx]
|
|
||||||
return victim
|
|
||||||
|
|
||||||
|
|
||||||
def _del(lst: MutableSequenceNoSlice, *, idx: int) -> None:
|
|
||||||
del lst[idx]
|
|
||||||
|
|
||||||
|
|
||||||
def _set(lst: MutableSequenceNoSlice, *, idx: int, val: str) -> None:
|
|
||||||
lst[idx] = val
|
|
||||||
|
|
||||||
|
|
||||||
def _ins(lst: MutableSequenceNoSlice, *, idx: int, val: str) -> None:
|
|
||||||
lst.insert(idx, val)
|
|
||||||
|
|
||||||
|
|
||||||
class ListSpy(MutableSequenceNoSlice):
|
|
||||||
def __init__(self, lst: MutableSequenceNoSlice) -> None:
|
|
||||||
self._lst = lst
|
|
||||||
self._undo: List[Callable[[MutableSequenceNoSlice], None]] = []
|
|
||||||
self.min_line_touched = sys.maxsize
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f'{type(self).__name__}({self._lst})'
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self._lst)
|
|
||||||
|
|
||||||
def __getitem__(self, idx: int) -> str:
|
|
||||||
return self._lst[idx]
|
|
||||||
|
|
||||||
def __setitem__(self, idx: int, val: str) -> None:
|
|
||||||
self._undo.append(functools.partial(_set, idx=idx, val=self._lst[idx]))
|
|
||||||
self.min_line_touched = min(idx, self.min_line_touched)
|
|
||||||
self._lst[idx] = val
|
|
||||||
|
|
||||||
def __delitem__(self, idx: int) -> None:
|
|
||||||
if idx < 0:
|
|
||||||
idx %= len(self)
|
|
||||||
self._undo.append(functools.partial(_ins, idx=idx, val=self._lst[idx]))
|
|
||||||
self.min_line_touched = min(idx, self.min_line_touched)
|
|
||||||
del self._lst[idx]
|
|
||||||
|
|
||||||
def insert(self, idx: int, val: str) -> None:
|
|
||||||
if idx < 0:
|
|
||||||
idx %= len(self)
|
|
||||||
self._undo.append(functools.partial(_del, idx=idx))
|
|
||||||
self.min_line_touched = min(idx, self.min_line_touched)
|
|
||||||
self._lst.insert(idx, val)
|
|
||||||
|
|
||||||
def undo(self, lst: MutableSequenceNoSlice) -> None:
|
|
||||||
for fn in reversed(self._undo):
|
|
||||||
fn(lst)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_modifications(self) -> bool:
|
|
||||||
return bool(self._undo)
|
|
||||||
111
babi/main.py
111
babi/main.py
@@ -1,10 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from typing import Optional
|
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
from babi.file import File
|
from babi.file import File
|
||||||
from babi.perf import Perf
|
from babi.perf import Perf
|
||||||
from babi.perf import perf_log
|
from babi.perf import perf_log
|
||||||
@@ -13,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)
|
||||||
@@ -38,37 +43,38 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
|
|||||||
|
|
||||||
|
|
||||||
def c_main(
|
def c_main(
|
||||||
stdscr: 'curses._CursesWindow',
|
stdscr: curses._CursesWindow,
|
||||||
args: argparse.Namespace,
|
filenames: list[str | None],
|
||||||
|
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.lines = ['']
|
screen.file.buf = Buf([''])
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
screen.status.update('press q to quit')
|
screen.status.update('press q to quit')
|
||||||
@@ -76,7 +82,7 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
|||||||
screen.file.move_cursor(screen.stdscr, screen.margin)
|
screen.file.move_cursor(screen.stdscr, screen.margin)
|
||||||
|
|
||||||
key = screen.get_char()
|
key = screen.get_char()
|
||||||
screen.file.lines.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
|
screen.file.buf.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
|
||||||
screen.file.down(screen.margin)
|
screen.file.down(screen.margin)
|
||||||
if key.wch == curses.KEY_RESIZE:
|
if key.wch == curses.KEY_RESIZE:
|
||||||
screen.resize()
|
screen.resize()
|
||||||
@@ -84,7 +90,38 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
def _filenames(filenames: list[str]) -> tuple[list[str | None], list[int]]:
|
||||||
|
if not filenames:
|
||||||
|
return [None], [0]
|
||||||
|
|
||||||
|
ret_filenames: list[str | None] = []
|
||||||
|
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: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('filenames', metavar='filename', nargs='*')
|
parser.add_argument('filenames', metavar='filename', nargs='*')
|
||||||
parser.add_argument('--perf-log')
|
parser.add_argument('--perf-log')
|
||||||
@@ -95,17 +132,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
|
|
||||||
if '-' in args.filenames:
|
if '-' in args.filenames:
|
||||||
print('reading stdin...', file=sys.stderr)
|
print('reading stdin...', file=sys.stderr)
|
||||||
stdin = sys.stdin.read()
|
stdin = sys.stdin.buffer.read().decode()
|
||||||
tty = os.open(CONSOLE, os.O_RDONLY)
|
tty = os.open(CONSOLE, os.O_RDONLY)
|
||||||
os.dup2(tty, sys.stdin.fileno())
|
os.dup2(tty, sys.stdin.fileno())
|
||||||
else:
|
else:
|
||||||
stdin = ''
|
stdin = ''
|
||||||
|
|
||||||
with make_stdscr() as stdscr:
|
# 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:
|
||||||
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__':
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
class Margin(NamedTuple):
|
class Margin(NamedTuple):
|
||||||
header: bool
|
lines: int
|
||||||
footer: bool
|
cols: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def header(self) -> bool:
|
||||||
|
return self.lines > 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def footer(self) -> bool:
|
||||||
|
return self.lines > 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def body_lines(self) -> int:
|
def body_lines(self) -> int:
|
||||||
return curses.LINES - self.header - self.footer
|
return self.lines - self.header - self.footer
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def page_size(self) -> int:
|
def page_size(self) -> int:
|
||||||
@@ -17,11 +27,11 @@ class Margin(NamedTuple):
|
|||||||
else:
|
else:
|
||||||
return self.body_lines - 2
|
return self.body_lines - 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scroll_amount(self) -> int:
|
||||||
|
# integer round up without banker's rounding (so 1/2 => 1 instead of 0)
|
||||||
|
return int(self.lines / 2 + .5)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_current_screen(cls) -> 'Margin':
|
def from_current_screen(cls) -> Margin:
|
||||||
if curses.LINES == 1:
|
return cls(curses.LINES, curses.COLS)
|
||||||
return cls(header=False, footer=False)
|
|
||||||
elif curses.LINES == 2:
|
|
||||||
return cls(header=False, footer=True)
|
|
||||||
else:
|
|
||||||
return cls(header=True, footer=True)
|
|
||||||
|
|||||||
17
babi/perf.py
17
babi/perf.py
@@ -1,18 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import cProfile
|
import cProfile
|
||||||
import time
|
import time
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
|
|
||||||
class Perf:
|
class Perf:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._prof: Optional[cProfile.Profile] = None
|
self._prof: cProfile.Profile | None = None
|
||||||
self._records: List[Tuple[str, float]] = []
|
self._records: list[tuple[str, float]] = []
|
||||||
self._name: Optional[str] = None
|
self._name: str | None = None
|
||||||
self._time: Optional[float] = None
|
self._time: float | None = None
|
||||||
|
|
||||||
def start(self, name: str) -> None:
|
def start(self, name: str) -> None:
|
||||||
if self._prof:
|
if self._prof:
|
||||||
@@ -36,14 +35,14 @@ class Perf:
|
|||||||
def save_profiles(self, filename: str) -> None:
|
def save_profiles(self, filename: str) -> None:
|
||||||
assert self._prof is not None
|
assert self._prof is not None
|
||||||
self._prof.dump_stats(f'{filename}.pstats')
|
self._prof.dump_stats(f'{filename}.pstats')
|
||||||
with open(filename, 'w') as f:
|
with open(filename, 'w', encoding='UTF-8') as f:
|
||||||
f.write('μs\tevent\n')
|
f.write('μs\tevent\n')
|
||||||
for name, duration in self._records:
|
for name, duration in self._records:
|
||||||
f.write(f'{int(duration * 1000 * 1000)}\t{name}\n')
|
f.write(f'{int(duration * 1000 * 1000)}\t{name}\n')
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def perf_log(filename: Optional[str]) -> Generator[Perf, None, None]:
|
def perf_log(filename: str | None) -> Generator[Perf, None, None]:
|
||||||
perf = Perf()
|
perf = Perf()
|
||||||
if filename is None:
|
if filename is None:
|
||||||
yield perf
|
yield perf
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
import enum
|
import enum
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from babi.horizontal_scrolling import line_x
|
from babi.horizontal_scrolling import line_x
|
||||||
from babi.horizontal_scrolling import scrolled_line
|
from babi.horizontal_scrolling import scrolled_line
|
||||||
@@ -16,7 +14,7 @@ PromptResult = enum.Enum('PromptResult', 'CANCELLED')
|
|||||||
|
|
||||||
|
|
||||||
class Prompt:
|
class Prompt:
|
||||||
def __init__(self, screen: 'Screen', prompt: str, lst: List[str]) -> None:
|
def __init__(self, screen: Screen, prompt: str, lst: list[str]) -> None:
|
||||||
self._screen = screen
|
self._screen = screen
|
||||||
self._prompt = prompt
|
self._prompt = prompt
|
||||||
self._lst = lst
|
self._lst = lst
|
||||||
@@ -31,20 +29,21 @@ class Prompt:
|
|||||||
def _s(self, s: str) -> None:
|
def _s(self, s: str) -> None:
|
||||||
self._lst[self._y] = s
|
self._lst[self._y] = s
|
||||||
|
|
||||||
def _render_prompt(self, *, base: Optional[str] = None) -> None:
|
def _render_prompt(self, *, base: str | None = None) -> None:
|
||||||
base = base or self._prompt
|
base = base or self._prompt
|
||||||
if not base or curses.COLS < 7:
|
if not base or self._screen.margin.cols < 7:
|
||||||
prompt_s = ''
|
prompt_s = ''
|
||||||
elif len(base) > curses.COLS - 6:
|
elif len(base) > self._screen.margin.cols - 6:
|
||||||
prompt_s = f'{base[:curses.COLS - 7]}…: '
|
prompt_s = f'{base[:self._screen.margin.cols - 7]}…: '
|
||||||
else:
|
else:
|
||||||
prompt_s = f'{base}: '
|
prompt_s = f'{base}: '
|
||||||
width = curses.COLS - len(prompt_s)
|
width = self._screen.margin.cols - len(prompt_s)
|
||||||
line = scrolled_line(self._s, self._x, width)
|
line = scrolled_line(self._s, self._x, width)
|
||||||
cmd = f'{prompt_s}{line}'
|
cmd = f'{prompt_s}{line}'
|
||||||
self._screen.stdscr.insstr(curses.LINES - 1, 0, cmd, curses.A_REVERSE)
|
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)
|
x = len(prompt_s) + self._x - line_x(self._x, width)
|
||||||
self._screen.stdscr.move(curses.LINES - 1, x)
|
self._screen.stdscr.move(prompt_line, x)
|
||||||
|
|
||||||
def _up(self) -> None:
|
def _up(self) -> None:
|
||||||
self._y = max(0, self._y - 1)
|
self._y = max(0, self._y - 1)
|
||||||
@@ -99,7 +98,7 @@ class Prompt:
|
|||||||
def _resize(self) -> None:
|
def _resize(self) -> None:
|
||||||
self._screen.resize()
|
self._screen.resize()
|
||||||
|
|
||||||
def _check_failed(self, idx: int, s: str) -> Tuple[bool, int]:
|
def _check_failed(self, idx: int, s: str) -> tuple[bool, int]:
|
||||||
failed = False
|
failed = False
|
||||||
for search_idx in range(idx, -1, -1):
|
for search_idx in range(idx, -1, -1):
|
||||||
if s in self._lst[search_idx]:
|
if s in self._lst[search_idx]:
|
||||||
@@ -110,7 +109,7 @@ class Prompt:
|
|||||||
failed = True
|
failed = True
|
||||||
return failed, idx
|
return failed, idx
|
||||||
|
|
||||||
def _reverse_search(self) -> Union[None, str, PromptResult]:
|
def _reverse_search(self) -> None | str | PromptResult:
|
||||||
reverse_s = ''
|
reverse_s = ''
|
||||||
idx = self._y
|
idx = self._y
|
||||||
while True:
|
while True:
|
||||||
@@ -126,7 +125,7 @@ class Prompt:
|
|||||||
key = self._screen.get_char()
|
key = self._screen.get_char()
|
||||||
if key.keyname == b'KEY_RESIZE':
|
if key.keyname == b'KEY_RESIZE':
|
||||||
self._screen.resize()
|
self._screen.resize()
|
||||||
elif key.keyname == b'KEY_BACKSPACE' or key.keyname == b'^H':
|
elif key.keyname == b'KEY_BACKSPACE':
|
||||||
reverse_s = reverse_s[:-1]
|
reverse_s = reverse_s[:-1]
|
||||||
elif key.keyname == b'^R':
|
elif key.keyname == b'^R':
|
||||||
idx = max(0, idx - 1)
|
idx = max(0, idx - 1)
|
||||||
@@ -163,7 +162,6 @@ class Prompt:
|
|||||||
b'kLFT5': _ctrl_left,
|
b'kLFT5': _ctrl_left,
|
||||||
# editing
|
# editing
|
||||||
b'KEY_BACKSPACE': _backspace,
|
b'KEY_BACKSPACE': _backspace,
|
||||||
b'^H': _backspace, # ^Backspace
|
|
||||||
b'KEY_DC': _delete,
|
b'KEY_DC': _delete,
|
||||||
b'^K': _cut_to_end,
|
b'^K': _cut_to_end,
|
||||||
# misc
|
# misc
|
||||||
@@ -177,7 +175,7 @@ class Prompt:
|
|||||||
self._s = self._s[:self._x] + c + self._s[self._x:]
|
self._s = self._s[:self._x] + c + self._s[self._x:]
|
||||||
self._x += len(c)
|
self._x += len(c)
|
||||||
|
|
||||||
def run(self) -> Union[PromptResult, str]:
|
def run(self) -> PromptResult | str:
|
||||||
while True:
|
while True:
|
||||||
self._render_prompt()
|
self._render_prompt()
|
||||||
|
|
||||||
|
|||||||
127
babi/reg.py
127
babi/reg.py
@@ -1,93 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import re
|
import re
|
||||||
from typing import Match
|
from typing import Match
|
||||||
from typing import Optional
|
|
||||||
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(self._pattern)
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def _reg_no_A(self) -> onigurumacffi._Pattern:
|
|
||||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'A'))
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def _reg_no_G(self) -> onigurumacffi._Pattern:
|
|
||||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'G'))
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def _reg_no_A_no_G(self) -> onigurumacffi._Pattern:
|
|
||||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'AG'))
|
|
||||||
|
|
||||||
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,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Match[str]]:
|
) -> Match[str] | None:
|
||||||
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,
|
||||||
@@ -95,54 +51,27 @@ class _Reg:
|
|||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Optional[Match[str]]:
|
) -> Match[str] | None:
|
||||||
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,
|
||||||
pos: int,
|
pos: int,
|
||||||
first_line: bool,
|
first_line: bool,
|
||||||
boundary: bool,
|
boundary: bool,
|
||||||
) -> Tuple[int, Optional[Match[str]]]:
|
) -> tuple[int, Match[str] | None]:
|
||||||
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('$ ^')
|
||||||
|
|||||||
218
babi/screen.py
218
babi/screen.py
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import curses
|
import curses
|
||||||
import enum
|
import enum
|
||||||
@@ -7,12 +9,8 @@ import re
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from typing import List
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from typing import Pattern
|
from typing import Pattern
|
||||||
from typing import Tuple
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from babi.color_manager import ColorManager
|
from babi.color_manager import ColorManager
|
||||||
from babi.file import Action
|
from babi.file import Action
|
||||||
@@ -38,6 +36,8 @@ EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV OPEN')
|
|||||||
SEQUENCE_KEYNAME = {
|
SEQUENCE_KEYNAME = {
|
||||||
'\x1bOH': b'KEY_HOME',
|
'\x1bOH': b'KEY_HOME',
|
||||||
'\x1bOF': b'KEY_END',
|
'\x1bOF': b'KEY_END',
|
||||||
|
'\x1b[1~': b'KEY_HOME',
|
||||||
|
'\x1b[4~': b'KEY_END',
|
||||||
'\x1b[1;2A': b'KEY_SR',
|
'\x1b[1;2A': b'KEY_SR',
|
||||||
'\x1b[1;2B': b'KEY_SF',
|
'\x1b[1;2B': b'KEY_SF',
|
||||||
'\x1b[1;2C': b'KEY_SRIGHT',
|
'\x1b[1;2C': b'KEY_SRIGHT',
|
||||||
@@ -60,36 +60,73 @@ SEQUENCE_KEYNAME = {
|
|||||||
'\x1b[1;6D': b'kLFT6', # Shift + ^Left
|
'\x1b[1;6D': b'kLFT6', # Shift + ^Left
|
||||||
'\x1b[1;6H': b'kHOM6', # Shift + ^Home
|
'\x1b[1;6H': b'kHOM6', # Shift + ^Home
|
||||||
'\x1b[1;6F': b'kEND6', # Shift + ^End
|
'\x1b[1;6F': b'kEND6', # Shift + ^End
|
||||||
|
'\x1b[~': b'KEY_BTAB', # Shift + Tab
|
||||||
|
}
|
||||||
|
KEYNAME_REWRITE = {
|
||||||
|
# windows-curses: numeric pad arrow keys
|
||||||
|
# - some overlay keyboards pick these as well
|
||||||
|
# - in xterm it seems these are mapped automatically
|
||||||
|
b'KEY_A2': b'KEY_UP',
|
||||||
|
b'KEY_C2': b'KEY_DOWN',
|
||||||
|
b'KEY_B3': b'KEY_RIGHT',
|
||||||
|
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
|
||||||
|
b'ALT_U': b'M-u',
|
||||||
|
# windows-curses: arguably these names are better than the xterm names
|
||||||
|
b'CTL_UP': b'kUP5',
|
||||||
|
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',
|
||||||
|
# macos: (sends this for backspace key, others interpret this as well)
|
||||||
|
b'^?': b'KEY_BACKSPACE',
|
||||||
|
# linux, perhaps others
|
||||||
|
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
|
||||||
|
b'^D': b'KEY_DC',
|
||||||
|
b'PADENTER': b'^M', # Enter on numpad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Key(NamedTuple):
|
class Key(NamedTuple):
|
||||||
wch: Union[int, str]
|
wch: int | str
|
||||||
keyname: bytes
|
keyname: bytes
|
||||||
|
|
||||||
|
|
||||||
class Screen:
|
class Screen:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stdscr: 'curses._CursesWindow',
|
stdscr: curses._CursesWindow,
|
||||||
filenames: List[Optional[str]],
|
filenames: list[str | None],
|
||||||
|
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()
|
||||||
self.perf = perf
|
self.perf = perf
|
||||||
self.status = Status()
|
self.status = Status()
|
||||||
self.margin = Margin.from_current_screen()
|
self.margin = Margin.from_current_screen()
|
||||||
self.cut_buffer: Tuple[str, ...] = ()
|
self.cut_buffer: tuple[str, ...] = ()
|
||||||
self.cut_selection = False
|
self.cut_selection = False
|
||||||
self._buffered_input: Union[int, str, None] = None
|
self._buffered_input: int | str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file(self) -> File:
|
def file(self) -> File:
|
||||||
@@ -105,7 +142,7 @@ class Screen:
|
|||||||
else:
|
else:
|
||||||
files = ''
|
files = ''
|
||||||
version_width = len(VERSION_STR) + 2
|
version_width = len(VERSION_STR) + 2
|
||||||
centered = filename.center(curses.COLS)[version_width:]
|
centered = filename.center(self.margin.cols)[version_width:]
|
||||||
s = f' {VERSION_STR} {files}{centered}{files}'
|
s = f' {VERSION_STR} {files}{centered}{files}'
|
||||||
self.stdscr.insstr(0, 0, s, curses.A_REVERSE)
|
self.stdscr.insstr(0, 0, s, curses.A_REVERSE)
|
||||||
|
|
||||||
@@ -191,7 +228,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:
|
||||||
@@ -202,12 +242,10 @@ class Screen:
|
|||||||
elif isinstance(wch, str) and wch.isprintable():
|
elif isinstance(wch, str) and wch.isprintable():
|
||||||
wch = self._get_string(wch)
|
wch = self._get_string(wch)
|
||||||
return Key(wch, b'STRING')
|
return Key(wch, b'STRING')
|
||||||
elif wch == '\x7f': # pragma: no cover (macos)
|
|
||||||
keyname = curses.keyname(curses.KEY_BACKSPACE)
|
|
||||||
return Key(wch, keyname)
|
|
||||||
|
|
||||||
key = wch if isinstance(wch, int) else ord(wch)
|
key = wch if isinstance(wch, int) else ord(wch)
|
||||||
keyname = curses.keyname(key)
|
keyname = curses.keyname(key)
|
||||||
|
keyname = KEYNAME_REWRITE.get(keyname, keyname)
|
||||||
return Key(wch, keyname)
|
return Key(wch, keyname)
|
||||||
|
|
||||||
def get_char(self) -> Key:
|
def get_char(self) -> Key:
|
||||||
@@ -225,24 +263,25 @@ class Screen:
|
|||||||
def resize(self) -> None:
|
def resize(self) -> None:
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
self.margin = Margin.from_current_screen()
|
self.margin = Margin.from_current_screen()
|
||||||
self.file.scroll_screen_if_needed(self.margin)
|
self.file.buf.scroll_screen_if_needed(self.margin)
|
||||||
self.draw()
|
self.draw()
|
||||||
|
|
||||||
def quick_prompt(
|
def quick_prompt(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
opt_strs: Tuple[str, ...],
|
opt_strs: tuple[str, ...],
|
||||||
) -> Union[str, PromptResult]:
|
) -> str | PromptResult:
|
||||||
opts = [opt[0] for opt in opt_strs]
|
opts = {opt[0] for opt in opt_strs}
|
||||||
while True:
|
while True:
|
||||||
x = 0
|
x = 0
|
||||||
|
prompt_line = self.margin.lines - 1
|
||||||
|
|
||||||
def _write(s: str, *, attr: int = curses.A_REVERSE) -> None:
|
def _write(s: str, *, attr: int = curses.A_REVERSE) -> None:
|
||||||
nonlocal x
|
nonlocal x
|
||||||
|
|
||||||
if x >= curses.COLS:
|
if x >= self.margin.cols:
|
||||||
return
|
return
|
||||||
self.stdscr.insstr(curses.LINES - 1, x, s, attr)
|
self.stdscr.insstr(prompt_line, x, s, attr)
|
||||||
x += len(s)
|
x += len(s)
|
||||||
|
|
||||||
_write(prompt)
|
_write(prompt)
|
||||||
@@ -254,33 +293,33 @@ class Screen:
|
|||||||
_write(', ')
|
_write(', ')
|
||||||
_write(']?')
|
_write(']?')
|
||||||
|
|
||||||
if x < curses.COLS - 1:
|
if x < self.margin.cols - 1:
|
||||||
s = ' ' * (curses.COLS - x)
|
s = ' ' * (self.margin.cols - x)
|
||||||
self.stdscr.insstr(curses.LINES - 1, x, s, curses.A_REVERSE)
|
self.stdscr.insstr(prompt_line, x, s, curses.A_REVERSE)
|
||||||
x += 1
|
x += 1
|
||||||
else:
|
else:
|
||||||
x = curses.COLS - 1
|
x = self.margin.cols - 1
|
||||||
self.stdscr.insstr(curses.LINES - 1, x, '…', curses.A_REVERSE)
|
self.stdscr.insstr(prompt_line, x, '…', curses.A_REVERSE)
|
||||||
|
|
||||||
self.stdscr.move(curses.LINES - 1, x)
|
self.stdscr.move(prompt_line, x)
|
||||||
|
|
||||||
key = self.get_char()
|
key = self.get_char()
|
||||||
if key.keyname == b'KEY_RESIZE':
|
if key.keyname == b'KEY_RESIZE':
|
||||||
self.resize()
|
self.resize()
|
||||||
elif key.keyname == b'^C':
|
elif key.keyname == b'^C':
|
||||||
return self.status.cancelled()
|
return self.status.cancelled()
|
||||||
elif isinstance(key.wch, str) and key.wch in opts:
|
elif isinstance(key.wch, str) and key.wch.lower() in opts:
|
||||||
return key.wch
|
return key.wch.lower()
|
||||||
|
|
||||||
def prompt(
|
def prompt(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
*,
|
*,
|
||||||
allow_empty: bool = False,
|
allow_empty: bool = False,
|
||||||
history: Optional[str] = None,
|
history: str | None = None,
|
||||||
default_prev: bool = False,
|
default_prev: bool = False,
|
||||||
default: Optional[str] = None,
|
default: str | None = None,
|
||||||
) -> Union[str, PromptResult]:
|
) -> str | PromptResult:
|
||||||
default = default or ''
|
default = default or ''
|
||||||
self.status.clear()
|
self.status.clear()
|
||||||
if history is not None:
|
if history is not None:
|
||||||
@@ -317,9 +356,9 @@ class Screen:
|
|||||||
self.file.go_to_line(lineno, self.margin)
|
self.file.go_to_line(lineno, self.margin)
|
||||||
|
|
||||||
def current_position(self) -> None:
|
def current_position(self) -> None:
|
||||||
line = f'line {self.file.y + 1}'
|
line = f'line {self.file.buf.y + 1}'
|
||||||
col = f'col {self.file.x + 1}'
|
col = f'col {self.file.buf.x + 1}'
|
||||||
line_count = max(len(self.file.lines) - 1, 1)
|
line_count = max(len(self.file.buf) - 1, 1)
|
||||||
lines_word = 'line' if line_count == 1 else 'lines'
|
lines_word = 'line' if line_count == 1 else 'lines'
|
||||||
self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
|
self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
|
||||||
|
|
||||||
@@ -337,7 +376,7 @@ class Screen:
|
|||||||
else:
|
else:
|
||||||
self.file.uncut(self.cut_buffer, self.margin)
|
self.file.uncut(self.cut_buffer, self.margin)
|
||||||
|
|
||||||
def _get_search_re(self, prompt: str) -> Union[Pattern[str], PromptResult]:
|
def _get_search_re(self, prompt: str) -> Pattern[str] | PromptResult:
|
||||||
response = self.prompt(prompt, history='search', default_prev=True)
|
response = self.prompt(prompt, history='search', default_prev=True)
|
||||||
if response is PromptResult.CANCELLED:
|
if response is PromptResult.CANCELLED:
|
||||||
return response
|
return response
|
||||||
@@ -350,15 +389,15 @@ class Screen:
|
|||||||
def _undo_redo(
|
def _undo_redo(
|
||||||
self,
|
self,
|
||||||
op: str,
|
op: str,
|
||||||
from_stack: List[Action],
|
from_stack: list[Action],
|
||||||
to_stack: List[Action],
|
to_stack: list[Action],
|
||||||
) -> None:
|
) -> None:
|
||||||
if not from_stack:
|
if not from_stack:
|
||||||
self.status.update(f'nothing to {op}!')
|
self.status.update(f'nothing to {op}!')
|
||||||
else:
|
else:
|
||||||
action = from_stack.pop()
|
action = from_stack.pop()
|
||||||
to_stack.append(action.apply(self.file))
|
to_stack.append(action.apply(self.file))
|
||||||
self.file.scroll_screen_if_needed(self.margin)
|
self.file.buf.scroll_screen_if_needed(self.margin)
|
||||||
self.status.update(f'{op}: {action.name}')
|
self.status.update(f'{op}: {action.name}')
|
||||||
self.file.selection.clear()
|
self.file.selection.clear()
|
||||||
|
|
||||||
@@ -382,9 +421,13 @@ class Screen:
|
|||||||
if response is not PromptResult.CANCELLED:
|
if response is not PromptResult.CANCELLED:
|
||||||
self.file.replace(self, search_response, response)
|
self.file.replace(self, search_response, response)
|
||||||
|
|
||||||
def command(self) -> Optional[EditResult]:
|
def command(self) -> EditResult | None:
|
||||||
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()
|
||||||
|
elif response == ':q!':
|
||||||
return EditResult.EXIT
|
return EditResult.EXIT
|
||||||
elif response == ':w':
|
elif response == ':w':
|
||||||
self.save()
|
self.save()
|
||||||
@@ -397,11 +440,45 @@ 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!')
|
||||||
|
elif response.startswith(':expandtabs'):
|
||||||
|
for file in self.files:
|
||||||
|
file.buf.expandtabs = True
|
||||||
|
self.status.update('updated!')
|
||||||
|
elif response.startswith(':noexpandtabs'):
|
||||||
|
for file in self.files:
|
||||||
|
file.buf.expandtabs = False
|
||||||
|
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:
|
||||||
self.status.update(f'invalid command: {response}')
|
self.status.update(f'invalid command: {response}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save(self) -> Optional[PromptResult]:
|
def save(self) -> PromptResult | None:
|
||||||
self.file.finalize_previous_action()
|
self.file.finalize_previous_action()
|
||||||
|
|
||||||
# TODO: make directories if they don't exist
|
# TODO: make directories if they don't exist
|
||||||
@@ -415,26 +492,32 @@ class Screen:
|
|||||||
else:
|
else:
|
||||||
self.file.filename = filename
|
self.file.filename = filename
|
||||||
|
|
||||||
if os.path.isfile(self.file.filename):
|
if not os.path.isfile(self.file.filename):
|
||||||
with open(self.file.filename) as f:
|
sha256: str | None = None
|
||||||
*_, sha256 = get_lines(f)
|
|
||||||
else:
|
else:
|
||||||
sha256 = hashlib.sha256(b'').hexdigest()
|
with open(self.file.filename, encoding='UTF-8', newline='') as f:
|
||||||
|
*_, sha256 = get_lines(f)
|
||||||
|
|
||||||
contents = self.file.nl.join(self.file.lines)
|
contents = self.file.nl.join(self.file.buf)
|
||||||
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
|
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
|
||||||
|
|
||||||
# the file on disk is the same as when we opened it
|
# the file on disk is the same as when we opened it
|
||||||
if sha256 not in (self.file.sha256, sha256_to_save):
|
if sha256 not in (None, self.file.sha256, sha256_to_save):
|
||||||
self.status.update('(file changed on disk, not implemented)')
|
self.status.update('(file changed on disk, not implemented)')
|
||||||
return PromptResult.CANCELLED
|
return PromptResult.CANCELLED
|
||||||
|
|
||||||
with open(self.file.filename, 'w') as f:
|
try:
|
||||||
f.write(contents)
|
with open(
|
||||||
|
self.file.filename, 'w', encoding='UTF-8', newline='',
|
||||||
|
) as f:
|
||||||
|
f.write(contents)
|
||||||
|
except OSError as e:
|
||||||
|
self.status.update(f'cannot save file: {e}')
|
||||||
|
return PromptResult.CANCELLED
|
||||||
|
|
||||||
self.file.modified = False
|
self.file.modified = False
|
||||||
self.file.sha256 = sha256_to_save
|
self.file.sha256 = sha256_to_save
|
||||||
num_lines = len(self.file.lines) - 1
|
num_lines = len(self.file.buf) - 1
|
||||||
lines = 'lines' if num_lines != 1 else 'line'
|
lines = 'lines' if num_lines != 1 else 'line'
|
||||||
self.status.update(f'saved! ({num_lines} {lines} written)')
|
self.status.update(f'saved! ({num_lines} {lines} written)')
|
||||||
|
|
||||||
@@ -447,7 +530,7 @@ class Screen:
|
|||||||
first = False
|
first = False
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save_filename(self) -> Optional[PromptResult]:
|
def save_filename(self) -> PromptResult | None:
|
||||||
response = self.prompt('enter filename', default=self.file.filename)
|
response = self.prompt('enter filename', default=self.file.filename)
|
||||||
if response is PromptResult.CANCELLED:
|
if response is PromptResult.CANCELLED:
|
||||||
return PromptResult.CANCELLED
|
return PromptResult.CANCELLED
|
||||||
@@ -455,16 +538,16 @@ class Screen:
|
|||||||
self.file.filename = response
|
self.file.filename = response
|
||||||
return self.save()
|
return self.save()
|
||||||
|
|
||||||
def open_file(self) -> Optional[EditResult]:
|
def open_file(self) -> EditResult | None:
|
||||||
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:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def quit_save_modified(self) -> Optional[EditResult]:
|
def quit_save_modified(self) -> EditResult | None:
|
||||||
if self.file.modified:
|
if self.file.modified:
|
||||||
response = self.quick_prompt(
|
response = self.quick_prompt(
|
||||||
'file is modified - save', ('yes', 'no'),
|
'file is modified - save', ('yes', 'no'),
|
||||||
@@ -482,10 +565,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,
|
||||||
@@ -495,6 +581,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,
|
||||||
@@ -508,9 +595,12 @@ class Screen:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _init_screen() -> 'curses._CursesWindow':
|
def _init_screen() -> curses._CursesWindow:
|
||||||
# set the escape delay so curses does not pause waiting for sequences
|
# set the escape delay so curses does not pause waiting for sequences
|
||||||
if sys.version_info >= (3, 9): # pragma: no cover
|
if (
|
||||||
|
sys.version_info >= (3, 9) and
|
||||||
|
hasattr(curses, 'set_escdelay')
|
||||||
|
): # pragma: no cover
|
||||||
curses.set_escdelay(25)
|
curses.set_escdelay(25)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
os.environ.setdefault('ESCDELAY', '25')
|
os.environ.setdefault('ESCDELAY', '25')
|
||||||
@@ -531,7 +621,7 @@ def _init_screen() -> 'curses._CursesWindow':
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def make_stdscr() -> Generator['curses._CursesWindow', None, None]:
|
def make_stdscr() -> Generator[curses._CursesWindow, None, None]:
|
||||||
"""essentially `curses.wrapper` but split out to implement ^Z"""
|
"""essentially `curses.wrapper` but split out to implement ^Z"""
|
||||||
try:
|
try:
|
||||||
yield _init_screen()
|
yield _init_screen()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
|
||||||
from babi.margin import Margin
|
from babi.margin import Margin
|
||||||
@@ -16,16 +18,16 @@ class Status:
|
|||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self._status = ''
|
self._status = ''
|
||||||
|
|
||||||
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
|
def draw(self, stdscr: curses._CursesWindow, margin: Margin) -> None:
|
||||||
if margin.footer or self._status:
|
if margin.footer or self._status:
|
||||||
stdscr.insstr(curses.LINES - 1, 0, ' ' * curses.COLS)
|
stdscr.insstr(margin.lines - 1, 0, ' ' * margin.cols)
|
||||||
if self._status:
|
if self._status:
|
||||||
status = f' {self._status} '
|
status = f' {self._status} '
|
||||||
x = (curses.COLS - len(status)) // 2
|
x = (margin.cols - len(status)) // 2
|
||||||
if x < 0:
|
if x < 0:
|
||||||
x = 0
|
x = 0
|
||||||
status = status.strip()
|
status = status.strip()
|
||||||
stdscr.insstr(curses.LINES - 1, x, status, curses.A_REVERSE)
|
stdscr.insstr(margin.lines - 1, x, status, curses.A_REVERSE)
|
||||||
|
|
||||||
def tick(self, margin: Margin) -> None:
|
def tick(self, margin: Margin) -> None:
|
||||||
# when the window is only 1-tall, hide the status quicker
|
# when the window is only 1-tall, hide the status quicker
|
||||||
|
|||||||
70
babi/textmate_demo.py
Normal file
70
babi/textmate_demo.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from babi.highlight import Compiler
|
||||||
|
from babi.highlight import Grammars
|
||||||
|
from babi.highlight import highlight_line
|
||||||
|
from babi.theme import Style
|
||||||
|
from babi.theme import Theme
|
||||||
|
from babi.user_data import prefix_data
|
||||||
|
from babi.user_data import xdg_config
|
||||||
|
|
||||||
|
|
||||||
|
def print_styled(s: str, style: Style) -> None:
|
||||||
|
color_s = ''
|
||||||
|
undo_s = ''
|
||||||
|
if style.fg is not None:
|
||||||
|
color_s += '\x1b[38;2;{r};{g};{b}m'.format(**style.fg._asdict())
|
||||||
|
undo_s += '\x1b[39m'
|
||||||
|
if style.bg is not None:
|
||||||
|
color_s += '\x1b[48;2;{r};{g};{b}m'.format(**style.bg._asdict())
|
||||||
|
undo_s += '\x1b[49m'
|
||||||
|
if style.b:
|
||||||
|
color_s += '\x1b[1m'
|
||||||
|
undo_s += '\x1b[22m'
|
||||||
|
if style.i:
|
||||||
|
color_s += '\x1b[3m'
|
||||||
|
undo_s += '\x1b[23m'
|
||||||
|
if style.u:
|
||||||
|
color_s += '\x1b[4m'
|
||||||
|
undo_s += '\x1b[24m'
|
||||||
|
print(f'{color_s}{s}{undo_s}', end='', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _highlight_output(theme: Theme, compiler: Compiler, filename: str) -> int:
|
||||||
|
state = compiler.root_state
|
||||||
|
|
||||||
|
if theme.default.bg is not None:
|
||||||
|
print('\x1b[48;2;{r};{g};{b}m'.format(**theme.default.bg._asdict()))
|
||||||
|
with open(filename, encoding='UTF-8') as f:
|
||||||
|
for line_idx, line in enumerate(f):
|
||||||
|
first_line = line_idx == 0
|
||||||
|
state, regions = highlight_line(compiler, state, line, first_line)
|
||||||
|
for start, end, scope in regions:
|
||||||
|
print_styled(line[start:end], theme.select(scope))
|
||||||
|
print('\x1b[m', end='')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--theme', default=xdg_config('theme.json'))
|
||||||
|
parser.add_argument('--grammar-dir', default=prefix_data('grammar_v1'))
|
||||||
|
parser.add_argument('filename')
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
with open(args.filename, encoding='UTF-8') as f:
|
||||||
|
first_line = next(f, '')
|
||||||
|
|
||||||
|
theme = Theme.from_filename(args.theme)
|
||||||
|
|
||||||
|
grammars = Grammars(args.grammar_dir)
|
||||||
|
compiler = grammars.compiler_for_file(args.filename, first_line)
|
||||||
|
|
||||||
|
return _highlight_output(theme, compiler, args.filename)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit(main())
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import os.path
|
import os.path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from babi._types import Protocol
|
from babi._types import Protocol
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
@@ -13,32 +12,32 @@ from babi.fdict import FDict
|
|||||||
|
|
||||||
|
|
||||||
class Style(NamedTuple):
|
class Style(NamedTuple):
|
||||||
fg: Optional[Color]
|
fg: Color | None
|
||||||
bg: Optional[Color]
|
bg: Color | None
|
||||||
b: bool
|
b: bool
|
||||||
i: bool
|
i: bool
|
||||||
u: bool
|
u: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def blank(cls) -> 'Style':
|
def blank(cls) -> Style:
|
||||||
return cls(fg=None, bg=None, b=False, i=False, u=False)
|
return cls(fg=None, bg=None, b=False, i=False, u=False)
|
||||||
|
|
||||||
|
|
||||||
class PartialStyle(NamedTuple):
|
class PartialStyle(NamedTuple):
|
||||||
fg: Optional[Color] = None
|
fg: Color | None = None
|
||||||
bg: Optional[Color] = None
|
bg: Color | None = None
|
||||||
b: Optional[bool] = None
|
b: bool | None = None
|
||||||
i: Optional[bool] = None
|
i: bool | None = None
|
||||||
u: Optional[bool] = None
|
u: bool | None = None
|
||||||
|
|
||||||
def overlay_on(self, dct: Dict[str, Any]) -> None:
|
def overlay_on(self, dct: dict[str, Any]) -> None:
|
||||||
for attr in self._fields:
|
for attr in self._fields:
|
||||||
value = getattr(self, attr)
|
value = getattr(self, attr)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
dct[attr] = value
|
dct[attr] = value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dct(cls, dct: Dict[str, Any]) -> 'PartialStyle':
|
def from_dct(cls, dct: dict[str, Any]) -> PartialStyle:
|
||||||
kv = cls()._asdict()
|
kv = cls()._asdict()
|
||||||
if 'foreground' in dct:
|
if 'foreground' in dct:
|
||||||
kv['fg'] = Color.parse(dct['foreground'])
|
kv['fg'] = Color.parse(dct['foreground'])
|
||||||
@@ -57,7 +56,7 @@ class _TrieNode(Protocol):
|
|||||||
@property
|
@property
|
||||||
def style(self) -> PartialStyle: ...
|
def style(self) -> PartialStyle: ...
|
||||||
@property
|
@property
|
||||||
def children(self) -> FDict[str, '_TrieNode']: ...
|
def children(self) -> FDict[str, _TrieNode]: ...
|
||||||
|
|
||||||
|
|
||||||
class TrieNode(NamedTuple):
|
class TrieNode(NamedTuple):
|
||||||
@@ -65,7 +64,7 @@ class TrieNode(NamedTuple):
|
|||||||
children: FDict[str, _TrieNode]
|
children: FDict[str, _TrieNode]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dct(cls, dct: Dict[str, Any]) -> _TrieNode:
|
def from_dct(cls, dct: dict[str, Any]) -> _TrieNode:
|
||||||
children = FDict({
|
children = FDict({
|
||||||
k: TrieNode.from_dct(v) for k, v in dct['children'].items()
|
k: TrieNode.from_dct(v) for k, v in dct['children'].items()
|
||||||
})
|
})
|
||||||
@@ -77,7 +76,7 @@ class Theme(NamedTuple):
|
|||||||
rules: _TrieNode
|
rules: _TrieNode
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=None)
|
@functools.lru_cache(maxsize=None)
|
||||||
def select(self, scope: Tuple[str, ...]) -> Style:
|
def select(self, scope: tuple[str, ...]) -> Style:
|
||||||
if not scope:
|
if not scope:
|
||||||
return self.default
|
return self.default
|
||||||
else:
|
else:
|
||||||
@@ -92,7 +91,7 @@ class Theme(NamedTuple):
|
|||||||
return Style(**style)
|
return Style(**style)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dct(cls, data: Dict[str, Any]) -> 'Theme':
|
def from_dct(cls, data: dict[str, Any]) -> Theme:
|
||||||
default = Style.blank()._asdict()
|
default = Style.blank()._asdict()
|
||||||
|
|
||||||
for k in ('foreground', 'editor.foreground'):
|
for k in ('foreground', 'editor.foreground'):
|
||||||
@@ -105,7 +104,7 @@ class Theme(NamedTuple):
|
|||||||
default['bg'] = Color.parse(data['colors'][k])
|
default['bg'] = Color.parse(data['colors'][k])
|
||||||
break
|
break
|
||||||
|
|
||||||
root: Dict[str, Any] = {'children': {}}
|
root: dict[str, Any] = {'children': {}}
|
||||||
rules = data.get('tokenColors', []) + data.get('settings', [])
|
rules = data.get('tokenColors', []) + data.get('settings', [])
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
if 'scope' not in rule:
|
if 'scope' not in rule:
|
||||||
@@ -139,13 +138,13 @@ class Theme(NamedTuple):
|
|||||||
return cls(Style(**default), TrieNode.from_dct(root))
|
return cls(Style(**default), TrieNode.from_dct(root))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def blank(cls) -> 'Theme':
|
def blank(cls) -> Theme:
|
||||||
return cls(Style.blank(), TrieNode.from_dct({'children': {}}))
|
return cls(Style.blank(), TrieNode.from_dct({'children': {}}))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_filename(cls, filename: str) -> 'Theme':
|
def from_filename(cls, filename: str) -> Theme:
|
||||||
if not os.path.exists(filename):
|
if not os.path.exists(filename):
|
||||||
return cls.blank()
|
return cls.blank()
|
||||||
else:
|
else:
|
||||||
with open(filename) as f:
|
with open(filename, encoding='UTF-8') as f:
|
||||||
return cls.from_dct(json.load(f))
|
return cls.from_dct(json.load(f))
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def _xdg(*path: str, env: str, default: str) -> str:
|
def _xdg(*path: str, env: str, default: str) -> str:
|
||||||
@@ -14,3 +17,7 @@ def xdg_data(*path: str) -> str:
|
|||||||
|
|
||||||
def xdg_config(*path: str) -> str:
|
def xdg_config(*path: str) -> str:
|
||||||
return _xdg(*path, env='XDG_CONFIG_HOME', default='~/.config')
|
return _xdg(*path, env='XDG_CONFIG_HOME', default='~/.config')
|
||||||
|
|
||||||
|
|
||||||
|
def prefix_data(*path: str) -> str:
|
||||||
|
return os.path.join(sys.prefix, 'share/babi', *path)
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import enum
|
|
||||||
import json
|
|
||||||
import os.path
|
|
||||||
import plistlib
|
|
||||||
import urllib.request
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
import cson # pip install cson
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_DIR = os.path.join(
|
|
||||||
os.environ.get('XDG_DATA_HOME') or
|
|
||||||
os.path.expanduser('~/.local/share'),
|
|
||||||
'babi/textmate_syntax',
|
|
||||||
)
|
|
||||||
Ext = enum.Enum('Ext', 'CSON PLIST JSON')
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_cson(src: bytes) -> str:
|
|
||||||
return json.dumps(cson.loads(src))
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_json(src: bytes) -> str:
|
|
||||||
return json.dumps(json.loads(src))
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_plist(src: bytes) -> str:
|
|
||||||
return json.dumps(plistlib.loads(src))
|
|
||||||
|
|
||||||
|
|
||||||
EXT_CONVERT = {
|
|
||||||
Ext.CSON: _convert_cson,
|
|
||||||
Ext.JSON: _convert_json,
|
|
||||||
Ext.PLIST: _convert_plist,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Syntax(NamedTuple):
|
|
||||||
name: str
|
|
||||||
ext: Ext
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
SYNTAXES = (
|
|
||||||
Syntax('c', Ext.JSON, 'https://raw.githubusercontent.com/jeff-hykin/cpp-textmate-grammar/53e39b1c/syntaxes/c.tmLanguage.json'), # noqa: E501
|
|
||||||
Syntax('css', Ext.CSON, 'https://raw.githubusercontent.com/atom/language-css/9feb69c081308b63f78bb0d6a2af2ff5eb7d869b/grammars/css.cson'), # noqa: E501
|
|
||||||
Syntax('docker', Ext.PLIST, 'https://raw.githubusercontent.com/moby/moby/c7ad2b866/contrib/syntax/textmate/Docker.tmbundle/Syntaxes/Dockerfile.tmLanguage'), # noqa: E501
|
|
||||||
Syntax('diff', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/diff.tmbundle/0593bb77/Syntaxes/Diff.plist'), # noqa: E501
|
|
||||||
Syntax('html', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/html.tmbundle/0c3d5ee5/Syntaxes/HTML.plist'), # noqa: E501
|
|
||||||
Syntax('html-derivative', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/html.tmbundle/0c3d5ee54de3a993f747f54186b73a4d2d3c44a2/Syntaxes/HTML%20(Derivative).tmLanguage'), # noqa: E501
|
|
||||||
Syntax('ini', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/ini.tmbundle/7d8c7b55/Syntaxes/Ini.plist'), # noqa: E501
|
|
||||||
Syntax('json', Ext.PLIST, 'https://raw.githubusercontent.com/microsoft/vscode-JSON.tmLanguage/d113e90937ed3ecc31ac54750aac2e8efa08d784/JSON.tmLanguage'), # noqa: E501
|
|
||||||
Syntax('make', Ext.PLIST, 'https://raw.githubusercontent.com/fadeevab/make.tmbundle/fd57c0552/Syntaxes/Makefile.plist'), # noqa: E501
|
|
||||||
Syntax('markdown', Ext.PLIST, 'https://raw.githubusercontent.com/microsoft/vscode-markdown-tm-grammar/59a5962/syntaxes/markdown.tmLanguage'), # noqa: E501
|
|
||||||
Syntax('powershell', Ext.PLIST, 'https://raw.githubusercontent.com/PowerShell/EditorSyntax/4a0a0766/PowerShellSyntax.tmLanguage'), # noqa: E501
|
|
||||||
Syntax('puppet', Ext.PLIST, 'https://raw.githubusercontent.com/lingua-pupuli/puppet-editor-syntax/dc414b8a/syntaxes/puppet.tmLanguage'), # noqa: E501
|
|
||||||
Syntax('python', Ext.PLIST, 'https://raw.githubusercontent.com/MagicStack/MagicPython/c9b3409d/grammars/MagicPython.tmLanguage'), # noqa: E501
|
|
||||||
# TODO: https://github.com/zargony/atom-language-rust/pull/149
|
|
||||||
Syntax('rust', Ext.CSON, 'https://raw.githubusercontent.com/asottile/atom-language-rust/e113ca67/grammars/rust.cson'), # noqa: E501
|
|
||||||
Syntax('shell', Ext.CSON, 'https://raw.githubusercontent.com/atom/language-shellscript/7008ea926867d8a231003e78094091471c4fccf8/grammars/shell-unix-bash.cson'), # noqa: E501
|
|
||||||
# TODO: https://github.com/atom/language-xml/pull/99
|
|
||||||
Syntax('xml', Ext.CSON, 'https://raw.githubusercontent.com/asottile/language-xml/2d76bc1f/grammars/xml.cson'), # noqa: E501
|
|
||||||
Syntax('yaml', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/yaml.tmbundle/e54ceae3/Syntaxes/YAML.tmLanguage'), # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument('--dest', default=DEFAULT_DIR)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
os.makedirs(args.dest, exist_ok=True)
|
|
||||||
for syntax in SYNTAXES:
|
|
||||||
print(f'downloading {syntax.name}...')
|
|
||||||
resp = urllib.request.urlopen(syntax.url).read()
|
|
||||||
converted = EXT_CONVERT[syntax.ext](resp)
|
|
||||||
with open(os.path.join(args.dest, f'{syntax.name}.json'), 'w') as f:
|
|
||||||
f.write(converted)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
exit(main())
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -39,7 +41,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)
|
||||||
|
|
||||||
|
|||||||
20
setup.cfg
20
setup.cfg
@@ -1,6 +1,6 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
name = babi
|
name = babi
|
||||||
version = 0.0.2
|
version = 0.0.22
|
||||||
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
|
||||||
@@ -13,30 +13,32 @@ classifiers =
|
|||||||
License :: OSI Approved :: MIT License
|
License :: OSI Approved :: MIT License
|
||||||
Programming Language :: Python :: 3
|
Programming Language :: Python :: 3
|
||||||
Programming Language :: Python :: 3 :: Only
|
Programming Language :: Python :: 3 :: Only
|
||||||
Programming Language :: Python :: 3.6
|
|
||||||
Programming Language :: Python :: 3.7
|
Programming Language :: Python :: 3.7
|
||||||
Programming Language :: Python :: 3.8
|
Programming Language :: Python :: 3.8
|
||||||
|
Programming Language :: Python :: 3.9
|
||||||
Programming Language :: Python :: Implementation :: CPython
|
Programming Language :: Python :: Implementation :: CPython
|
||||||
Programming Language :: Python :: Implementation :: PyPy
|
Programming Language :: Python :: Implementation :: PyPy
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
packages = find:
|
packages = find:
|
||||||
install_requires =
|
install_requires =
|
||||||
|
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.7
|
||||||
|
|
||||||
[options.entry_points]
|
|
||||||
console_scripts =
|
|
||||||
babi = babi.main:main
|
|
||||||
|
|
||||||
[options.packages.find]
|
[options.packages.find]
|
||||||
exclude =
|
exclude =
|
||||||
tests*
|
tests*
|
||||||
testing*
|
testing*
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
console_scripts =
|
||||||
|
babi = babi.main:main
|
||||||
|
babi-textmate-demo = babi.textmate_demo:main
|
||||||
|
|
||||||
[bdist_wheel]
|
[bdist_wheel]
|
||||||
universal = True
|
universal = True
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ disallow_any_generics = true
|
|||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
disallow_untyped_defs = true
|
disallow_untyped_defs = true
|
||||||
no_implicit_optional = true
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
[mypy-testing.*]
|
[mypy-testing.*]
|
||||||
disallow_untyped_defs = false
|
disallow_untyped_defs = false
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -1,2 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
setup()
|
setup()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import curses
|
import curses
|
||||||
import enum
|
import enum
|
||||||
import re
|
import re
|
||||||
from typing import List
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from hecate import Runner
|
from hecate import Runner
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ def to_attrs(screen, width):
|
|||||||
fg = bg = -1
|
fg = bg = -1
|
||||||
attr = 0
|
attr = 0
|
||||||
idx = 0
|
idx = 0
|
||||||
ret: List[List[Tuple[int, int, int]]]
|
ret: list[list[tuple[int, int, int]]]
|
||||||
ret = [[] for _ in range(len(screen.splitlines()))]
|
ret = [[] for _ in range(len(screen.splitlines()))]
|
||||||
|
|
||||||
for tp, match in tokenize_colors(screen):
|
for tp, match in tokenize_colors(screen):
|
||||||
|
|||||||
2
testing/vsc_test/.gitignore
vendored
Normal file
2
testing/vsc_test/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/package-lock.json
|
||||||
5
testing/vsc_test/package.json
Normal file
5
testing/vsc_test/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": [
|
||||||
|
"vscode-textmate"
|
||||||
|
]
|
||||||
|
}
|
||||||
51
testing/vsc_test/vsc.js
Normal file
51
testing/vsc_test/vsc.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const vsctm = require('vscode-textmate');
|
||||||
|
|
||||||
|
if (process.argv.length < 4) {
|
||||||
|
console.log('usage: t.js GRAMMAR FILE');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grammar = process.argv[2];
|
||||||
|
const file = process.argv[3];
|
||||||
|
|
||||||
|
const scope = JSON.parse(fs.readFileSync(grammar, {encoding: 'UTF-8'})).scopeName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to read a file as a promise
|
||||||
|
*/
|
||||||
|
function readFile(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fs.readFile(path, (error, data) => error ? reject(error) : resolve(data));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a registry that can create a grammar from a scope name.
|
||||||
|
const registry = new vsctm.Registry({
|
||||||
|
loadGrammar: (scopeName) => {
|
||||||
|
if (scopeName === scope) {
|
||||||
|
return readFile(grammar).then(data => vsctm.parseRawGrammar(data.toString(), grammar))
|
||||||
|
}
|
||||||
|
console.log(`Unknown scope name: ${scopeName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the JavaScript grammar and any other grammars included by it async.
|
||||||
|
registry.loadGrammar(scope).then(grammar => {
|
||||||
|
const text = fs.readFileSync(file, {encoding: 'UTF-8'}).trimEnd('\n').split(/\n/);
|
||||||
|
let ruleStack = vsctm.INITIAL;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const line = text[i];
|
||||||
|
const lineTokens = grammar.tokenizeLine(line, ruleStack);
|
||||||
|
console.log(`\nTokenizing line: ${line}`);
|
||||||
|
for (let j = 0; j < lineTokens.tokens.length; j++) {
|
||||||
|
const token = lineTokens.tokens[j];
|
||||||
|
console.log(` - token from ${token.startIndex} to ${token.endIndex} ` +
|
||||||
|
`(${line.substring(token.startIndex, token.endIndex)}) ` +
|
||||||
|
`with scopes ${token.scopes.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ruleStack = lineTokens.ruleStack;
|
||||||
|
}
|
||||||
|
});
|
||||||
180
tests/buf_test.py
Normal file
180
tests/buf_test.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_repr():
|
||||||
|
ret = repr(Buf(['a', 'b', 'c']))
|
||||||
|
assert ret == "Buf(['a', 'b', 'c'], x=0, y=0, file_y=0)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_item_retrieval():
|
||||||
|
buf = Buf(['a', 'b', 'c'])
|
||||||
|
assert buf[1] == 'b'
|
||||||
|
assert buf[-1] == 'c'
|
||||||
|
with pytest.raises(IndexError):
|
||||||
|
buf[3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_del():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
del buf[1]
|
||||||
|
|
||||||
|
assert lst == ['a', 'c']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_del_with_negative():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
del buf[-1]
|
||||||
|
|
||||||
|
assert lst == ['a', 'b']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_insert():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf.insert(1, 'q')
|
||||||
|
|
||||||
|
assert lst == ['a', 'q', 'b', 'c']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_insert_with_negative():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf.insert(-1, 'q')
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'q', 'c']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_set_value():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf[1] = 'hello'
|
||||||
|
|
||||||
|
assert lst == ['a', 'hello', 'c']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_set_value_idx_negative():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf[-1] = 'hello'
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'hello']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_multiple_modifications():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf[1] = 'hello'
|
||||||
|
buf.insert(1, 'ohai')
|
||||||
|
del buf[0]
|
||||||
|
|
||||||
|
assert lst == ['ohai', 'hello', 'c']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_iter():
|
||||||
|
buf = Buf(['a', 'b', 'c'])
|
||||||
|
buf_iter = iter(buf)
|
||||||
|
assert next(buf_iter) == 'a'
|
||||||
|
assert next(buf_iter) == 'b'
|
||||||
|
assert next(buf_iter) == 'c'
|
||||||
|
with pytest.raises(StopIteration):
|
||||||
|
next(buf_iter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_append():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf.append('q')
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c', 'q']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_pop_default():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf.pop()
|
||||||
|
|
||||||
|
assert lst == ['a', 'b']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buf_pop_idx():
|
||||||
|
lst = ['a', 'b', 'c']
|
||||||
|
|
||||||
|
buf = Buf(lst)
|
||||||
|
|
||||||
|
with buf.record() as modifications:
|
||||||
|
buf.pop(1)
|
||||||
|
|
||||||
|
assert lst == ['a', 'c']
|
||||||
|
|
||||||
|
buf.apply(modifications)
|
||||||
|
|
||||||
|
assert lst == ['a', 'b', 'c']
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from babi import color_kd
|
from babi import color_kd
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
|
|||||||
19
tests/conftest.py
Normal file
19
tests/conftest.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from babi.highlight import Grammars
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_grammars(tmpdir):
|
||||||
|
grammar_dir = tmpdir.join('grammars').ensure_dir()
|
||||||
|
|
||||||
|
def make_grammars(*grammar_dcts):
|
||||||
|
for grammar in grammar_dcts:
|
||||||
|
filename = f'{grammar["scopeName"]}.json'
|
||||||
|
grammar_dir.join(filename).write(json.dumps(grammar))
|
||||||
|
return Grammars(grammar_dir)
|
||||||
|
return make_grammars
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from babi.fdict import FChainMap
|
||||||
from babi.fdict import FDict
|
from babi.fdict import FDict
|
||||||
|
|
||||||
|
|
||||||
@@ -5,3 +10,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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|||||||
207
tests/features/comment_test.py
Normal file
207
tests/features/comment_test.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from testing.runner import and_exit
|
||||||
|
from testing.runner import trigger_command_mode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def three_lines_with_indentation(tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('line_0\n line_1\n line_2')
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
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_empty_line_trailing_whitespace(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('1\n\n2\n')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('S-Down')
|
||||||
|
h.press('S-Down')
|
||||||
|
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('# 1\n#\n# 2')
|
||||||
|
|
||||||
|
|
||||||
|
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_some_code_with_indentation(run, three_lines_with_indentation):
|
||||||
|
with run(str(three_lines_with_indentation)) 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\n line_2\n')
|
||||||
|
|
||||||
|
h.press('S-Up')
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('line_0\n line_1\n line_2\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_some_code_on_indent_part(run, three_lines_with_indentation):
|
||||||
|
with run(str(three_lines_with_indentation)) as h, and_exit(h):
|
||||||
|
h.press('Down')
|
||||||
|
h.press('S-Down')
|
||||||
|
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('line_0\n # line_1\n # line_2\n')
|
||||||
|
|
||||||
|
h.press('S-Up')
|
||||||
|
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('line_0\n line_1\n line_2\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_some_code_on_tabs_part(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('line_0\n\tline_1\n\t\tline_2')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.await_text('line_0\n line_1\n line_2')
|
||||||
|
h.press('Down')
|
||||||
|
h.press('S-Down')
|
||||||
|
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('line_0\n # line_1\n # line_2')
|
||||||
|
|
||||||
|
h.press('S-Up')
|
||||||
|
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('line_0\n line_1\n line_2')
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('comment', ('# ', '#'))
|
||||||
|
def test_remove_comment_with_comment_elsewhere_in_line(run, tmpdir, comment):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write(f'{comment}print("not a # comment here!")\n')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':comment')
|
||||||
|
|
||||||
|
h.await_text('\nprint("not a # comment here!")\n')
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import List
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Tuple
|
|
||||||
from typing import Union
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,6 +15,13 @@ from babi.screen import VERSION_STR
|
|||||||
from testing.runner import PrintsErrorRunner
|
from testing.runner import PrintsErrorRunner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def prefix_home(tmpdir):
|
||||||
|
prefix_home = tmpdir.join('prefix_home')
|
||||||
|
with mock.patch.object(sys, 'prefix', str(prefix_home)):
|
||||||
|
yield prefix_home
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def xdg_data_home(tmpdir):
|
def xdg_data_home(tmpdir):
|
||||||
data_home = tmpdir.join('data_home')
|
data_home = tmpdir.join('data_home')
|
||||||
@@ -39,7 +45,6 @@ def ten_lines(tmpdir):
|
|||||||
|
|
||||||
class Screen:
|
class Screen:
|
||||||
def __init__(self, width, height):
|
def __init__(self, width, height):
|
||||||
self.disabled = True
|
|
||||||
self.nodelay = False
|
self.nodelay = False
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
@@ -57,6 +62,16 @@ class Screen:
|
|||||||
self._prev_screenshot = ret
|
self._prev_screenshot = ret
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
def addstr(self, y, x, s, attr):
|
||||||
|
self.lines[y] = self.lines[y][:x] + s + self.lines[y][x + len(s):]
|
||||||
|
|
||||||
|
line_attr = self.attrs[y]
|
||||||
|
new = [attr] * len(s)
|
||||||
|
self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):]
|
||||||
|
|
||||||
|
self.y = y
|
||||||
|
self.x = x + len(s)
|
||||||
|
|
||||||
def insstr(self, y, x, s, attr):
|
def insstr(self, y, x, s, attr):
|
||||||
line = self.lines[y]
|
line = self.lines[y]
|
||||||
self.lines[y] = (line[:x] + s + line[x:])[:self.width]
|
self.lines[y] = (line[:x] + s + line[x:])[:self.width]
|
||||||
@@ -66,6 +81,7 @@ class Screen:
|
|||||||
self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width]
|
self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width]
|
||||||
|
|
||||||
def chgat(self, y, x, n, attr):
|
def chgat(self, y, x, n, attr):
|
||||||
|
assert n >= 0 # TODO: switch to > 0, we should never do 0-length
|
||||||
self.attrs[y][x:x + n] = [attr] * n
|
self.attrs[y][x:x + n] = [attr] * n
|
||||||
|
|
||||||
def move(self, y, x):
|
def move(self, y, x):
|
||||||
@@ -131,7 +147,7 @@ class AssertScreenLineEquals(NamedTuple):
|
|||||||
|
|
||||||
class AssertScreenAttrEquals(NamedTuple):
|
class AssertScreenAttrEquals(NamedTuple):
|
||||||
n: int
|
n: int
|
||||||
attr: List[Tuple[int, int, int]]
|
attr: list[tuple[int, int, int]]
|
||||||
|
|
||||||
def __call__(self, screen: Screen) -> None:
|
def __call__(self, screen: Screen) -> None:
|
||||||
assert screen.attrs[self.n] == self.attr
|
assert screen.attrs[self.n] == self.attr
|
||||||
@@ -153,7 +169,7 @@ class Resize(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class KeyPress(NamedTuple):
|
class KeyPress(NamedTuple):
|
||||||
wch: Union[int, str]
|
wch: int | str
|
||||||
|
|
||||||
def __call__(self, screen: Screen) -> None:
|
def __call__(self, screen: Screen) -> None:
|
||||||
raise AssertionError('unreachable')
|
raise AssertionError('unreachable')
|
||||||
@@ -166,7 +182,8 @@ class CursesError(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
class CursesScreen:
|
class CursesScreen:
|
||||||
def __init__(self, runner):
|
def __init__(self, screen, runner):
|
||||||
|
self._screen = screen
|
||||||
self._runner = runner
|
self._runner = runner
|
||||||
self._bkgd_attr = (-1, -1, 0)
|
self._bkgd_attr = (-1, -1, 0)
|
||||||
|
|
||||||
@@ -190,20 +207,26 @@ class CursesScreen:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def nodelay(self, val):
|
def nodelay(self, val):
|
||||||
self._runner.screen.nodelay = val
|
self._screen.nodelay = val
|
||||||
|
|
||||||
|
def addstr(self, y, x, s, attr=0):
|
||||||
|
self._screen.addstr(y, x, s, self._to_attr(attr))
|
||||||
|
|
||||||
def insstr(self, y, x, s, attr=0):
|
def insstr(self, y, x, s, attr=0):
|
||||||
self._runner.screen.insstr(y, x, s, self._to_attr(attr))
|
self._screen.insstr(y, x, s, self._to_attr(attr))
|
||||||
|
|
||||||
def clrtoeol(self):
|
def clrtoeol(self):
|
||||||
s = self._runner.screen.width * ' '
|
s = self._screen.width * ' '
|
||||||
self.insstr(self._runner.screen.y, self._runner.screen.x, s)
|
self.insstr(self._screen.y, self._screen.x, s)
|
||||||
|
|
||||||
def chgat(self, y, x, n, attr):
|
def chgat(self, y, x, n, attr):
|
||||||
self._runner.screen.chgat(y, x, n, self._to_attr(attr))
|
self._screen.chgat(y, x, n, self._to_attr(attr))
|
||||||
|
|
||||||
def move(self, y, x):
|
def move(self, y, x):
|
||||||
self._runner.screen.move(y, x)
|
self._screen.move(y, x)
|
||||||
|
|
||||||
|
def getyx(self):
|
||||||
|
return self._screen.y, self._screen.x
|
||||||
|
|
||||||
def get_wch(self):
|
def get_wch(self):
|
||||||
return self._runner._get_wch()
|
return self._runner._get_wch()
|
||||||
@@ -212,7 +235,7 @@ class CursesScreen:
|
|||||||
class Key(NamedTuple):
|
class Key(NamedTuple):
|
||||||
tmux: str
|
tmux: str
|
||||||
curses: bytes
|
curses: bytes
|
||||||
wch: Union[int, str]
|
wch: int | str
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def value(self) -> int:
|
def value(self) -> int:
|
||||||
@@ -266,6 +289,7 @@ KEYS = [
|
|||||||
Key('^_', b'^_', '\x1f'),
|
Key('^_', b'^_', '\x1f'),
|
||||||
Key('^\\', b'^\\', '\x1c'),
|
Key('^\\', b'^\\', '\x1c'),
|
||||||
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
|
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
|
||||||
|
Key('^D', b'^D', '\x04'),
|
||||||
]
|
]
|
||||||
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
|
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
|
||||||
KEYS_CURSES = {k.value: k.curses for k in KEYS}
|
KEYS_CURSES = {k.value: k.curses for k in KEYS}
|
||||||
@@ -275,10 +299,11 @@ class DeferredRunner:
|
|||||||
def __init__(self, command, width=80, height=24, term='screen'):
|
def __init__(self, command, width=80, height=24, term='screen'):
|
||||||
self.command = command
|
self.command = command
|
||||||
self._i = 0
|
self._i = 0
|
||||||
self._ops: List[Op] = []
|
self._ops: list[Op] = []
|
||||||
self.color_pairs = {0: (7, 0)}
|
self.color_pairs = {0: (7, 0)}
|
||||||
self.screen = Screen(width, height)
|
self.screen = Screen(width, height)
|
||||||
self._n_colors, self._can_change_color = {
|
self._n_colors, self._can_change_color = {
|
||||||
|
'xterm-mono': (0, False),
|
||||||
'screen': (8, False),
|
'screen': (8, False),
|
||||||
'screen-256color': (256, False),
|
'screen-256color': (256, False),
|
||||||
'xterm-256color': (256, True),
|
'xterm-256color': (256, True),
|
||||||
@@ -365,8 +390,9 @@ class DeferredRunner:
|
|||||||
def _curses__noop(self, *_, **__):
|
def _curses__noop(self, *_, **__):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
_curses_cbreak = _curses_noecho = _curses_nonl = _curses__noop
|
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
|
||||||
_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
|
||||||
|
|
||||||
@@ -392,11 +418,10 @@ class DeferredRunner:
|
|||||||
|
|
||||||
def _curses_initscr(self):
|
def _curses_initscr(self):
|
||||||
self._curses_update_lines_cols()
|
self._curses_update_lines_cols()
|
||||||
self.screen.disabled = False
|
return CursesScreen(self.screen, self)
|
||||||
return CursesScreen(self)
|
|
||||||
|
|
||||||
def _curses_endwin(self):
|
def _curses_newwin(self, height, width):
|
||||||
self.screen.disabled = True
|
return CursesScreen(Screen(width, height), self)
|
||||||
|
|
||||||
def _curses_not_implemented(self, fn):
|
def _curses_not_implemented(self, fn):
|
||||||
def fn_inner(*args, **kwargs):
|
def fn_inner(*args, **kwargs):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|
||||||
@@ -132,3 +134,21 @@ def test_selection_cut_uncut_selection_offscreen_x(run):
|
|||||||
h.await_text_missing('hello')
|
h.await_text_missing('hello')
|
||||||
h.press('^K')
|
h.press('^K')
|
||||||
h.await_text('hello\n')
|
h.await_text('hello\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_cut_uncut_at_end_of_file(run, ten_lines):
|
||||||
|
with run(str(ten_lines)) as h, and_exit(h):
|
||||||
|
h.press('S-Down')
|
||||||
|
h.press('S-Right')
|
||||||
|
h.press('^K')
|
||||||
|
h.await_text_missing('line_0')
|
||||||
|
h.await_text_missing('line_1')
|
||||||
|
h.await_text('ine_1')
|
||||||
|
|
||||||
|
h.press('^End')
|
||||||
|
h.press('^U')
|
||||||
|
h.await_text('line_0\nl\n')
|
||||||
|
h.await_cursor_position(x=1, y=11)
|
||||||
|
|
||||||
|
h.press('Down')
|
||||||
|
h.await_cursor_position(x=0, y=12)
|
||||||
|
|||||||
47
tests/features/expandtabs_test.py
Normal file
47
tests/features/expandtabs_test.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from testing.runner import and_exit
|
||||||
|
from testing.runner import trigger_command_mode
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_expandtabs(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('a')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('Left')
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':expandtabs')
|
||||||
|
h.await_text('updated!')
|
||||||
|
h.press('Tab')
|
||||||
|
h.press('^S')
|
||||||
|
assert f.read() == ' a\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_noexpandtabs(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('a')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('Left')
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':noexpandtabs')
|
||||||
|
h.await_text('updated!')
|
||||||
|
h.press('Tab')
|
||||||
|
h.press('^S')
|
||||||
|
assert f.read() == '\ta\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_indent_with_expandtabs(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('a\nb\nc')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':noexpandtabs')
|
||||||
|
h.await_text('updated!')
|
||||||
|
for _ in range(3):
|
||||||
|
h.press('S-Down')
|
||||||
|
h.press('Tab')
|
||||||
|
h.press('^S')
|
||||||
|
assert f.read() == '\ta\n\tb\n\tc\n'
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
from testing.runner import trigger_command_mode
|
||||||
|
|
||||||
|
|
||||||
def test_indent_at_beginning_of_line(run):
|
def test_indent_at_beginning_of_line(run):
|
||||||
@@ -12,11 +15,12 @@ def test_indent_at_beginning_of_line(run):
|
|||||||
|
|
||||||
def test_indent_not_full_tab(run):
|
def test_indent_not_full_tab(run):
|
||||||
with run() as h, and_exit(h):
|
with run() as h, and_exit(h):
|
||||||
h.press('h')
|
h.press('hello')
|
||||||
|
h.press('Home')
|
||||||
|
h.press('Right')
|
||||||
h.press('Tab')
|
h.press('Tab')
|
||||||
h.press('ello')
|
|
||||||
h.await_text('h ello')
|
h.await_text('h ello')
|
||||||
h.await_cursor_position(x=8, y=1)
|
h.await_cursor_position(x=4, y=1)
|
||||||
|
|
||||||
|
|
||||||
def test_indent_fixes_eof(run):
|
def test_indent_fixes_eof(run):
|
||||||
@@ -86,6 +90,20 @@ def test_dedent_selection(run, tmpdir):
|
|||||||
h.await_text('\n1\n2\n 3\n')
|
h.await_text('\n1\n2\n 3\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedent_selection_with_noexpandtabs(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('1\n\t2\n\t\t3\n')
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':noexpandtabs')
|
||||||
|
h.await_text('updated!')
|
||||||
|
for _ in range(3):
|
||||||
|
h.press('S-Down')
|
||||||
|
h.press('BTab')
|
||||||
|
h.press('^S')
|
||||||
|
assert f.read() == '1\n2\n\t3\n'
|
||||||
|
|
||||||
|
|
||||||
def test_dedent_beginning_of_line(run, tmpdir):
|
def test_dedent_beginning_of_line(run, tmpdir):
|
||||||
f = tmpdir.join('f')
|
f = tmpdir.join('f')
|
||||||
f.write(' hi\n')
|
f.write(' hi\n')
|
||||||
|
|||||||
30
tests/features/initial_position_test.py
Normal file
30
tests/features/initial_position_test.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
|
||||||
from babi.screen import VERSION_STR
|
from babi.screen import VERSION_STR
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
@@ -411,3 +413,30 @@ def test_sequence_handling(run_only_fake):
|
|||||||
h.press(' test7')
|
h.press(' test7')
|
||||||
h.await_text('test1 test2 test3 test4 test5 test6 test7')
|
h.await_text('test1 test2 test3 test4 test5 test6 test7')
|
||||||
h.await_text(r'\x1b[1;')
|
h.await_text(r'\x1b[1;')
|
||||||
|
|
||||||
|
|
||||||
|
def test_indentation_using_tabs(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write(f'123456789\n\t12\t{"x" * 20}\n')
|
||||||
|
|
||||||
|
with run(str(f), width=20) as h, and_exit(h):
|
||||||
|
h.await_text('123456789\n 12 xxxxxxxxxxx»\n')
|
||||||
|
|
||||||
|
h.press('Down')
|
||||||
|
h.await_cursor_position(x=0, y=2)
|
||||||
|
h.press('Up')
|
||||||
|
h.await_cursor_position(x=0, y=1)
|
||||||
|
|
||||||
|
h.press('Right')
|
||||||
|
h.await_cursor_position(x=1, y=1)
|
||||||
|
h.press('Down')
|
||||||
|
h.await_cursor_position(x=0, y=2)
|
||||||
|
h.press('Up')
|
||||||
|
h.await_cursor_position(x=1, y=1)
|
||||||
|
|
||||||
|
h.press('Down')
|
||||||
|
h.await_cursor_position(x=0, y=2)
|
||||||
|
h.press('Right')
|
||||||
|
h.await_cursor_position(x=4, y=2)
|
||||||
|
h.press('Up')
|
||||||
|
h.await_cursor_position(x=4, y=1)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
@@ -30,7 +32,8 @@ def test_replace_cancel_at_replace_string(run):
|
|||||||
h.await_text('cancelled')
|
h.await_text('cancelled')
|
||||||
|
|
||||||
|
|
||||||
def test_replace_actual_contents(run, ten_lines):
|
@pytest.mark.parametrize('key', ('y', 'Y'))
|
||||||
|
def test_replace_actual_contents(run, ten_lines, key):
|
||||||
with run(str(ten_lines)) as h, and_exit(h):
|
with run(str(ten_lines)) as h, and_exit(h):
|
||||||
h.press('^\\')
|
h.press('^\\')
|
||||||
h.await_text('search (to replace):')
|
h.await_text('search (to replace):')
|
||||||
@@ -38,7 +41,7 @@ def test_replace_actual_contents(run, ten_lines):
|
|||||||
h.await_text('replace with:')
|
h.await_text('replace with:')
|
||||||
h.press_and_enter('ohai')
|
h.press_and_enter('ohai')
|
||||||
h.await_text('replace [yes, no, all]?')
|
h.await_text('replace [yes, no, all]?')
|
||||||
h.press('y')
|
h.press(key)
|
||||||
h.await_text_missing('line_0')
|
h.await_text_missing('line_0')
|
||||||
h.await_text('ohai')
|
h.await_text('ohai')
|
||||||
h.await_text(' *')
|
h.await_text(' *')
|
||||||
@@ -272,3 +275,31 @@ def test_replace_separate_line_after_wrapping(run, ten_lines):
|
|||||||
h.await_text_missing('line_0')
|
h.await_text_missing('line_0')
|
||||||
h.press('y')
|
h.press('y')
|
||||||
h.await_text_missing('line_1')
|
h.await_text_missing('line_1')
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_with_newline_characters(run, ten_lines):
|
||||||
|
with run(str(ten_lines)) as h, and_exit(h):
|
||||||
|
h.press('^\\')
|
||||||
|
h.await_text('search (to replace):')
|
||||||
|
h.press_and_enter('(line)_([01])')
|
||||||
|
h.await_text('replace with:')
|
||||||
|
h.press_and_enter(r'\1\n\2')
|
||||||
|
h.await_text('replace [yes, no, all]?')
|
||||||
|
h.press('a')
|
||||||
|
h.await_text_missing('line_0')
|
||||||
|
h.await_text_missing('line_1')
|
||||||
|
h.await_text('line\n0\nline\n1\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_with_multiple_newline_characters(run, ten_lines):
|
||||||
|
with run(str(ten_lines)) as h, and_exit(h):
|
||||||
|
h.press('^\\')
|
||||||
|
h.await_text('search (to replace):')
|
||||||
|
h.press_and_enter('(li)(ne)_(1)')
|
||||||
|
h.await_text('replace with:')
|
||||||
|
h.press_and_enter(r'\1\n\2\n\3\n')
|
||||||
|
h.await_text('replace [yes, no, all]?')
|
||||||
|
h.press('a')
|
||||||
|
|
||||||
|
h.await_text_missing('line_1')
|
||||||
|
h.await_text('li\nne\n1\n\nline_2')
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from babi.screen import VERSION_STR
|
from babi.screen import VERSION_STR
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
from testing.runner import trigger_command_mode
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_newlines(run, tmpdir):
|
def test_mixed_newlines(run, tmpdir):
|
||||||
@@ -12,6 +15,31 @@ def test_mixed_newlines(run, tmpdir):
|
|||||||
h.await_text(r"mixed newlines will be converted to '\n'")
|
h.await_text(r"mixed newlines will be converted to '\n'")
|
||||||
|
|
||||||
|
|
||||||
|
def test_modify_file_with_windows_newlines(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write_binary(b'foo\r\nbar\r\n')
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
# should not start modified
|
||||||
|
h.await_text_missing('*')
|
||||||
|
h.press('Enter')
|
||||||
|
h.await_text('*')
|
||||||
|
h.press('^S')
|
||||||
|
h.await_text('saved!')
|
||||||
|
assert f.read_binary() == b'\r\nfoo\r\nbar\r\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_file_with_multiple_lines_at_end_maintains_those(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('foo\n\n')
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('a')
|
||||||
|
h.await_text('*')
|
||||||
|
h.press('^S')
|
||||||
|
h.await_text('saved!')
|
||||||
|
|
||||||
|
assert f.read() == 'afoo\n\n'
|
||||||
|
|
||||||
|
|
||||||
def test_new_file(run):
|
def test_new_file(run):
|
||||||
with run('this_is_a_new_file') as h, and_exit(h):
|
with run('this_is_a_new_file') as h, and_exit(h):
|
||||||
h.await_text('this_is_a_new_file')
|
h.await_text('this_is_a_new_file')
|
||||||
@@ -102,12 +130,24 @@ def test_save_file_when_it_did_not_exist(run, tmpdir):
|
|||||||
assert f.read() == 'hello world\n'
|
assert f.read() == 'hello world\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_file_permission_denied(run, tmpdir):
|
||||||
|
f = tmpdir.join('f').ensure()
|
||||||
|
f.chmod(0o400)
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('hello world')
|
||||||
|
h.press('^S')
|
||||||
|
# the filename message is missing as it is too long to be captured
|
||||||
|
h.await_text('cannot save file: [Errno 13] Permission denied:')
|
||||||
|
h.await_text(' *')
|
||||||
|
|
||||||
|
|
||||||
def test_save_via_ctrl_o(run, tmpdir):
|
def test_save_via_ctrl_o(run, tmpdir):
|
||||||
f = tmpdir.join('f')
|
f = tmpdir.join('f')
|
||||||
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'
|
||||||
@@ -124,6 +164,18 @@ def test_save_via_ctrl_o_set_filename(run, tmpdir):
|
|||||||
assert f.read() == 'hello world\n'
|
assert f.read() == 'hello world\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_via_ctrl_o_new_filename(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('wat\n')
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('^O')
|
||||||
|
h.await_text('enter filename: ')
|
||||||
|
h.press_and_enter('new')
|
||||||
|
h.await_text('saved! (1 line written)')
|
||||||
|
assert f.read() == 'wat\n'
|
||||||
|
assert tmpdir.join('fnew').read() == 'wat\n'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('key', ('^C', 'Enter'))
|
@pytest.mark.parametrize('key', ('^C', 'Enter'))
|
||||||
def test_save_via_ctrl_o_cancelled(run, key):
|
def test_save_via_ctrl_o_cancelled(run, key):
|
||||||
with run() as h, and_exit(h):
|
with run() as h, and_exit(h):
|
||||||
@@ -189,3 +241,38 @@ def test_save_on_exit_resize(run, tmpdir):
|
|||||||
h.await_text('file is modified - save [yes, no]?')
|
h.await_text('file is modified - save [yes, no]?')
|
||||||
h.press('^C')
|
h.press('^C')
|
||||||
h.await_text('cancelled')
|
h.await_text('cancelled')
|
||||||
|
|
||||||
|
|
||||||
|
def test_vim_save_on_exit_cancel_yn(run):
|
||||||
|
with run() as h, and_exit(h):
|
||||||
|
h.press('hello')
|
||||||
|
h.await_text('hello')
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':q')
|
||||||
|
h.await_text('file is modified - save [yes, no]?')
|
||||||
|
h.press('^C')
|
||||||
|
h.await_text('cancelled')
|
||||||
|
|
||||||
|
|
||||||
|
def test_vim_save_on_exit(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
with run(str(f)) as h:
|
||||||
|
h.press('hello')
|
||||||
|
h.await_text('hello')
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':q')
|
||||||
|
h.await_text('file is modified - save [yes, no]?')
|
||||||
|
h.press('y')
|
||||||
|
h.await_text('enter filename: ')
|
||||||
|
h.press('Enter')
|
||||||
|
h.await_exit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vim_force_exit(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
with run(str(f)) as h:
|
||||||
|
h.press('hello')
|
||||||
|
h.await_text('hello')
|
||||||
|
trigger_command_mode(h)
|
||||||
|
h.press_and_enter(':q!')
|
||||||
|
h.await_exit()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
@@ -21,6 +23,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 +44,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):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ THEME = json.dumps({
|
|||||||
'settings': {'foreground': '#5f0000', 'background': '#ff5f5f'},
|
'settings': {'foreground': '#5f0000', 'background': '#ff5f5f'},
|
||||||
},
|
},
|
||||||
{'scope': 'tqs', 'settings': {'foreground': '#00005f'}},
|
{'scope': 'tqs', 'settings': {'foreground': '#00005f'}},
|
||||||
|
{'scope': 'qmark', 'settings': {'foreground': '#5f0000'}},
|
||||||
{'scope': 'b', 'settings': {'fontStyle': 'bold'}},
|
{'scope': 'b', 'settings': {'fontStyle': 'bold'}},
|
||||||
{'scope': 'i', 'settings': {'fontStyle': 'italic'}},
|
{'scope': 'i', 'settings': {'fontStyle': 'italic'}},
|
||||||
{'scope': 'u', 'settings': {'fontStyle': 'underline'}},
|
{'scope': 'u', 'settings': {'fontStyle': 'underline'}},
|
||||||
@@ -28,6 +31,7 @@ SYNTAX = json.dumps({
|
|||||||
{'match': r'#.*$\n?', 'name': 'comment'},
|
{'match': r'#.*$\n?', 'name': 'comment'},
|
||||||
{'match': r'^-.*$\n?', 'name': 'diffremove'},
|
{'match': r'^-.*$\n?', 'name': 'diffremove'},
|
||||||
{'begin': '"""', 'end': '"""', 'name': 'tqs'},
|
{'begin': '"""', 'end': '"""', 'name': 'tqs'},
|
||||||
|
{'match': r'\?', 'name': 'qmark'},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
DEMO_S = '''\
|
DEMO_S = '''\
|
||||||
@@ -43,7 +47,7 @@ still more
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def theme_and_grammar(xdg_data_home, xdg_config_home):
|
def theme_and_grammar(xdg_data_home, xdg_config_home):
|
||||||
xdg_config_home.join('babi/theme.json').ensure().write(THEME)
|
xdg_config_home.join('babi/theme.json').ensure().write(THEME)
|
||||||
xdg_data_home.join('babi/textmate_syntax/demo.json').ensure().write(SYNTAX)
|
xdg_data_home.join('babi/grammar_v1/demo.json').ensure().write(SYNTAX)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -97,3 +101,62 @@ def test_syntax_highlighting_off_screen_does_not_crash(run, tmpdir):
|
|||||||
h.await_text('"""b"""')
|
h.await_text('"""b"""')
|
||||||
expected = [(236, 40, 0)] * 11 + [(17, 40, 0)] * 7 + [(236, 40, 0)] * 2
|
expected = [(236, 40, 0)] * 11 + [(17, 40, 0)] * 7 + [(236, 40, 0)] * 2
|
||||||
h.assert_screen_attr_equals(1, expected)
|
h.assert_screen_attr_equals(1, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_syntax_highlighting_one_off_left_of_screen(run, tmpdir):
|
||||||
|
f = tmpdir.join('f.demo')
|
||||||
|
f.write(f'{"x" * 11}?123456789')
|
||||||
|
|
||||||
|
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||||
|
h.await_text('xxx?123')
|
||||||
|
expected = [(236, 40, 0)] * 11 + [(52, 40, 0)] + [(236, 40, 0)] * 8
|
||||||
|
h.assert_screen_attr_equals(1, expected)
|
||||||
|
|
||||||
|
h.press('End')
|
||||||
|
h.await_text_missing('?')
|
||||||
|
h.assert_screen_attr_equals(1, [(236, 40, 0)] * 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_syntax_highlighting_to_edge_of_screen(run, tmpdir):
|
||||||
|
f = tmpdir.join('f.demo')
|
||||||
|
f.write(f'# {"x" * 18}')
|
||||||
|
|
||||||
|
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||||
|
h.await_text('# xxx')
|
||||||
|
h.assert_screen_attr_equals(1, [(243, 40, 0)] * 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_syntax_highlighting_with_tabs(run, tmpdir):
|
||||||
|
f = tmpdir.join('f.demo')
|
||||||
|
f.write('\t# 12345678901234567890\n')
|
||||||
|
|
||||||
|
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||||
|
h.await_text('1234567890')
|
||||||
|
expected = 4 * [(236, 40, 0)] + 15 * [(243, 40, 0)] + [(236, 40, 0)]
|
||||||
|
h.assert_screen_attr_equals(1, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_syntax_highlighting_tabs_after_line_creation(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
# trailing whitespace is used to trigger highlighting
|
||||||
|
f.write('foo\n\txx \ny \n')
|
||||||
|
|
||||||
|
with run(str(f), term='screen-256color') as h, and_exit(h):
|
||||||
|
# this looks weird, but it populates the width cache
|
||||||
|
h.press('Down')
|
||||||
|
h.press('Down')
|
||||||
|
h.press('Down')
|
||||||
|
|
||||||
|
# press enter after the tab
|
||||||
|
h.press('Up')
|
||||||
|
h.press('Up')
|
||||||
|
h.press('Right')
|
||||||
|
h.press('Right')
|
||||||
|
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
|
||||||
|
|||||||
32
tests/features/tabsize_test.py
Normal file
32
tests/features/tabsize_test.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
@@ -50,6 +52,18 @@ def test_backspace_at_end_of_file_still_allows_scrolling_down(run, tmpdir):
|
|||||||
h.await_text_missing('*')
|
h.await_text_missing('*')
|
||||||
|
|
||||||
|
|
||||||
|
def test_backspace_deletes_newline_at_end_of_file(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('foo\n\n')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.press('^End')
|
||||||
|
h.press('BSpace')
|
||||||
|
h.press('^S')
|
||||||
|
|
||||||
|
assert f.read() == 'foo\n'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('key', ('BSpace', '^H'))
|
@pytest.mark.parametrize('key', ('BSpace', '^H'))
|
||||||
def test_backspace_deletes_text(run, tmpdir, key):
|
def test_backspace_deletes_text(run, tmpdir, key):
|
||||||
f = tmpdir.join('f')
|
f = tmpdir.join('f')
|
||||||
@@ -72,14 +86,15 @@ def test_delete_at_end_of_file(run, tmpdir):
|
|||||||
h.await_text_missing('*')
|
h.await_text_missing('*')
|
||||||
|
|
||||||
|
|
||||||
def test_delete_removes_character_afterwards(run, tmpdir):
|
@pytest.mark.parametrize('key', ('DC', '^D'))
|
||||||
|
def test_delete_removes_character_afterwards(run, tmpdir, key):
|
||||||
f = tmpdir.join('f')
|
f = tmpdir.join('f')
|
||||||
f.write('hello world')
|
f.write('hello world')
|
||||||
|
|
||||||
with run(str(f)) as h, and_exit(h):
|
with run(str(f)) as h, and_exit(h):
|
||||||
h.await_text('hello world')
|
h.await_text('hello world')
|
||||||
h.press('Right')
|
h.press('Right')
|
||||||
h.press('DC')
|
h.press(key)
|
||||||
h.await_text('hllo world')
|
h.await_text('hllo world')
|
||||||
h.await_text('f *')
|
h.await_text('f *')
|
||||||
|
|
||||||
@@ -97,6 +112,24 @@ def test_delete_at_end_of_line(run, tmpdir):
|
|||||||
h.await_text('f *')
|
h.await_text('f *')
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_at_end_of_last_line(run, tmpdir):
|
||||||
|
f = tmpdir.join('f')
|
||||||
|
f.write('hello\n')
|
||||||
|
|
||||||
|
with run(str(f)) as h, and_exit(h):
|
||||||
|
h.await_text('hello')
|
||||||
|
h.press('End')
|
||||||
|
h.press('DC')
|
||||||
|
# should not make the file modified
|
||||||
|
h.await_text_missing('*')
|
||||||
|
|
||||||
|
# delete should still be functional
|
||||||
|
h.press('Left')
|
||||||
|
h.press('Left')
|
||||||
|
h.press('DC')
|
||||||
|
h.await_text('helo')
|
||||||
|
|
||||||
|
|
||||||
def test_press_enter_beginning_of_file(run, tmpdir):
|
def test_press_enter_beginning_of_file(run, tmpdir):
|
||||||
f = tmpdir.join('f')
|
f = tmpdir.join('f')
|
||||||
f.write('hello world')
|
f.write('hello world')
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from testing.runner import and_exit
|
from testing.runner import and_exit
|
||||||
|
|
||||||
|
|
||||||
@@ -9,7 +13,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 +22,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(' *')
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -8,7 +10,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'>"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,39 @@
|
|||||||
from babi.highlight import Grammars
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from babi.highlight import highlight_line
|
from babi.highlight import highlight_line
|
||||||
from babi.highlight import Region
|
from babi.highlight import Region
|
||||||
|
|
||||||
|
|
||||||
def test_grammar_matches_extension_only_name():
|
def test_grammar_matches_extension_only_name(make_grammars):
|
||||||
data = {'scopeName': 'shell', 'patterns': [], 'fileTypes': ['bashrc']}
|
data = {'scopeName': 'shell', 'patterns': [], 'fileTypes': ['bashrc']}
|
||||||
grammars = Grammars([data])
|
grammars = make_grammars(data)
|
||||||
compiler = grammars.compiler_for_file('.bashrc', 'alias nano=babi')
|
compiler = grammars.compiler_for_file('.bashrc', 'alias nano=babi')
|
||||||
assert compiler.root_state.entries[0].scope[0] == 'shell'
|
assert compiler.root_state.entries[0].scope[0] == 'shell'
|
||||||
|
|
||||||
|
|
||||||
def test_grammar_matches_via_identify_tag():
|
def test_grammar_matches_via_identify_tag(make_grammars):
|
||||||
data = {'scopeName': 'source.ini', 'patterns': []}
|
grammars = make_grammars({'scopeName': 'source.ini', 'patterns': []})
|
||||||
grammars = Grammars([data])
|
|
||||||
compiler = grammars.compiler_for_file('setup.cfg', '')
|
compiler = grammars.compiler_for_file('setup.cfg', '')
|
||||||
assert compiler.root_state.entries[0].scope[0] == 'source.ini'
|
assert compiler.root_state.entries[0].scope[0] == 'source.ini'
|
||||||
|
|
||||||
|
|
||||||
def _compiler_state(*grammar_dcts):
|
@pytest.fixture
|
||||||
grammars = Grammars(grammar_dcts)
|
def compiler_state(make_grammars):
|
||||||
compiler = grammars.compiler_for_scope(grammar_dcts[0]['scopeName'])
|
def _compiler_state(*grammar_dcts):
|
||||||
return compiler, compiler.root_state
|
grammars = make_grammars(*grammar_dcts)
|
||||||
|
compiler = grammars.compiler_for_scope(grammar_dcts[0]['scopeName'])
|
||||||
|
return compiler, compiler.root_state
|
||||||
|
return _compiler_state
|
||||||
|
|
||||||
|
|
||||||
def test_backslash_a():
|
def test_backslash_a(compiler_state):
|
||||||
grammar = {
|
grammar = {
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [{'name': 'aaa', 'match': r'\Aa+'}],
|
'patterns': [{'name': 'aaa', 'match': r'\Aa+'}],
|
||||||
}
|
}
|
||||||
compiler, state = _compiler_state(grammar)
|
compiler, state = compiler_state(grammar)
|
||||||
|
|
||||||
state, (region_0,) = highlight_line(compiler, state, 'aaa', True)
|
state, (region_0,) = highlight_line(compiler, state, 'aaa', True)
|
||||||
state, (region_1,) = highlight_line(compiler, state, 'aaa', False)
|
state, (region_1,) = highlight_line(compiler, state, 'aaa', False)
|
||||||
@@ -51,8 +56,8 @@ BEGIN_END_NO_NL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_backslash_g_inline():
|
def test_backslash_g_inline(compiler_state):
|
||||||
compiler, state = _compiler_state(BEGIN_END_NO_NL)
|
compiler, state = compiler_state(BEGIN_END_NO_NL)
|
||||||
|
|
||||||
_, regions = highlight_line(compiler, state, 'xaax', True)
|
_, regions = highlight_line(compiler, state, 'xaax', True)
|
||||||
assert regions == (
|
assert regions == (
|
||||||
@@ -63,8 +68,8 @@ def test_backslash_g_inline():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_backslash_g_next_line():
|
def test_backslash_g_next_line(compiler_state):
|
||||||
compiler, state = _compiler_state(BEGIN_END_NO_NL)
|
compiler, state = compiler_state(BEGIN_END_NO_NL)
|
||||||
|
|
||||||
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
||||||
state, regions2 = highlight_line(compiler, state, 'aax\n', False)
|
state, regions2 = highlight_line(compiler, state, 'aax\n', False)
|
||||||
@@ -81,8 +86,8 @@ def test_backslash_g_next_line():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_end_before_other_match():
|
def test_end_before_other_match(compiler_state):
|
||||||
compiler, state = _compiler_state(BEGIN_END_NO_NL)
|
compiler, state = compiler_state(BEGIN_END_NO_NL)
|
||||||
|
|
||||||
state, regions = highlight_line(compiler, state, 'xazzx', True)
|
state, regions = highlight_line(compiler, state, 'xazzx', True)
|
||||||
|
|
||||||
@@ -107,8 +112,8 @@ BEGIN_END_NL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_backslash_g_captures_nl():
|
def test_backslash_g_captures_nl(compiler_state):
|
||||||
compiler, state = _compiler_state(BEGIN_END_NL)
|
compiler, state = compiler_state(BEGIN_END_NL)
|
||||||
|
|
||||||
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
||||||
state, regions2 = highlight_line(compiler, state, 'aax\n', False)
|
state, regions2 = highlight_line(compiler, state, 'aax\n', False)
|
||||||
@@ -124,8 +129,8 @@ def test_backslash_g_captures_nl():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_backslash_g_captures_nl_next_line():
|
def test_backslash_g_captures_nl_next_line(compiler_state):
|
||||||
compiler, state = _compiler_state(BEGIN_END_NL)
|
compiler, state = compiler_state(BEGIN_END_NL)
|
||||||
|
|
||||||
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
||||||
state, regions2 = highlight_line(compiler, state, 'aa\n', False)
|
state, regions2 = highlight_line(compiler, state, 'aa\n', False)
|
||||||
@@ -147,8 +152,8 @@ def test_backslash_g_captures_nl_next_line():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_while_no_nl():
|
def test_while_no_nl(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [{
|
'patterns': [{
|
||||||
'begin': '> ',
|
'begin': '> ',
|
||||||
@@ -182,8 +187,8 @@ def test_while_no_nl():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_complex_captures():
|
def test_complex_captures(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -213,8 +218,8 @@ def test_complex_captures():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_multiple_applied_to_same_capture():
|
def test_captures_multiple_applied_to_same_capture(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -256,8 +261,8 @@ def test_captures_multiple_applied_to_same_capture():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_ignores_empty():
|
def test_captures_ignores_empty(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [{
|
'patterns': [{
|
||||||
'match': '(.*) hi',
|
'match': '(.*) hi',
|
||||||
@@ -279,8 +284,8 @@ def test_captures_ignores_empty():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_ignores_invalid_out_of_bounds():
|
def test_captures_ignores_invalid_out_of_bounds(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [{'match': '.', 'captures': {'1': {'name': 'oob'}}}],
|
'patterns': [{'match': '.', 'captures': {'1': {'name': 'oob'}}}],
|
||||||
})
|
})
|
||||||
@@ -292,8 +297,8 @@ def test_captures_ignores_invalid_out_of_bounds():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_begin_end():
|
def test_captures_begin_end(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -314,8 +319,8 @@ def test_captures_begin_end():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_while_captures():
|
def test_captures_while_captures(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -343,8 +348,8 @@ def test_captures_while_captures():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_implies_begin_end_captures():
|
def test_captures_implies_begin_end_captures(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -364,8 +369,8 @@ def test_captures_implies_begin_end_captures():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_captures_implies_begin_while_captures():
|
def test_captures_implies_begin_while_captures(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -392,8 +397,8 @@ def test_captures_implies_begin_while_captures():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_include_self():
|
def test_include_self(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -416,8 +421,8 @@ def test_include_self():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_include_repository_rule():
|
def test_include_repository_rule(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [{'include': '#impl'}],
|
'patterns': [{'include': '#impl'}],
|
||||||
'repository': {
|
'repository': {
|
||||||
@@ -438,8 +443,40 @@ def test_include_repository_rule():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_include_other_grammar():
|
def test_include_with_nested_repositories(compiler_state):
|
||||||
compiler, state = _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):
|
||||||
|
compiler, state = compiler_state(
|
||||||
{
|
{
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
@@ -494,8 +531,8 @@ def test_include_other_grammar():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_include_base():
|
def test_include_base(compiler_state):
|
||||||
compiler, state = _compiler_state(
|
compiler, state = compiler_state(
|
||||||
{
|
{
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
@@ -542,8 +579,8 @@ def test_include_base():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rule_with_begin_and_no_end():
|
def test_rule_with_begin_and_no_end(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [
|
'patterns': [
|
||||||
{
|
{
|
||||||
@@ -566,8 +603,8 @@ def test_rule_with_begin_and_no_end():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_begin_end_substitute_special_chars():
|
def test_begin_end_substitute_special_chars(compiler_state):
|
||||||
compiler, state = _compiler_state({
|
compiler, state = compiler_state({
|
||||||
'scopeName': 'test',
|
'scopeName': 'test',
|
||||||
'patterns': [{'begin': r'(\*)', 'end': r'\1', 'name': 'italic'}],
|
'patterns': [{'begin': r'(\*)', 'end': r'\1', 'name': 'italic'}],
|
||||||
})
|
})
|
||||||
@@ -579,3 +616,48 @@ def test_begin_end_substitute_special_chars():
|
|||||||
Region(1, 7, ('test', 'italic')),
|
Region(1, 7, ('test', 'italic')),
|
||||||
Region(7, 8, ('test', 'italic')),
|
Region(7, 8, ('test', 'italic')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backslash_z(compiler_state):
|
||||||
|
# similar to text.git-commit grammar, \z matches nothing!
|
||||||
|
compiler, state = compiler_state({
|
||||||
|
'scopeName': 'test',
|
||||||
|
'patterns': [
|
||||||
|
{'begin': '#', 'end': r'\z', 'name': 'comment'},
|
||||||
|
{'name': 'other', 'match': '.'},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
state, regions1 = highlight_line(compiler, state, '# comment', True)
|
||||||
|
state, regions2 = highlight_line(compiler, state, 'other?', False)
|
||||||
|
|
||||||
|
assert regions1 == (
|
||||||
|
Region(0, 1, ('test', 'comment')),
|
||||||
|
Region(1, 9, ('test', 'comment')),
|
||||||
|
)
|
||||||
|
|
||||||
|
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',)),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import curses
|
import curses
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from babi.buf import Buf
|
||||||
from babi.color_manager import ColorManager
|
from babi.color_manager import ColorManager
|
||||||
from babi.highlight import Grammars
|
from babi.hl.interface import HL
|
||||||
from babi.hl.syntax import Syntax
|
from babi.hl.syntax import Syntax
|
||||||
from babi.theme import Color
|
from babi.theme import Color
|
||||||
from babi.theme import Theme
|
from babi.theme import Theme
|
||||||
@@ -71,8 +74,8 @@ THEME = Theme.from_dct({
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def syntax(tmpdir):
|
def syntax(make_grammars):
|
||||||
return Syntax(Grammars.from_syntax_dir(tmpdir), THEME, ColorManager.make())
|
return Syntax(make_grammars(), THEME, ColorManager.make())
|
||||||
|
|
||||||
|
|
||||||
def test_init_screen_low_color(stdscr, syntax):
|
def test_init_screen_low_color(stdscr, syntax):
|
||||||
@@ -149,3 +152,20 @@ def test_style_attributes_applied(stdscr, syntax):
|
|||||||
style = THEME.select(('keyword.python',))
|
style = THEME.select(('keyword.python',))
|
||||||
attr = syntax.blank_file_highlighter().attr(style)
|
attr = syntax.blank_file_highlighter().attr(style)
|
||||||
assert attr == 2 << 8 | curses.A_BOLD
|
assert attr == 2 << 8 | curses.A_BOLD
|
||||||
|
|
||||||
|
|
||||||
|
def test_syntax_highlight_cache_first_line(stdscr, make_grammars):
|
||||||
|
with FakeCurses.patch(n_colors=256, can_change_color=False):
|
||||||
|
grammars = make_grammars({
|
||||||
|
'scopeName': 'source.demo',
|
||||||
|
'fileTypes': ['demo'],
|
||||||
|
'patterns': [{'match': r'\Aint', 'name': 'keyword'}],
|
||||||
|
})
|
||||||
|
syntax = Syntax(grammars, THEME, ColorManager.make())
|
||||||
|
syntax._init_screen(stdscr)
|
||||||
|
file_hl = syntax.file_highlighter('foo.demo', '')
|
||||||
|
file_hl.highlight_until(Buf(['int', 'int']), 2)
|
||||||
|
assert file_hl.regions == [
|
||||||
|
(HL(0, 3, curses.A_BOLD | 2 << 8),),
|
||||||
|
(),
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from babi.list_spy import ListSpy
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_repr():
|
|
||||||
assert repr(ListSpy(['a', 'b', 'c'])) == "ListSpy(['a', 'b', 'c'])"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_item_retrieval():
|
|
||||||
spy = ListSpy(['a', 'b', 'c'])
|
|
||||||
assert spy[1] == 'b'
|
|
||||||
assert spy[-1] == 'c'
|
|
||||||
with pytest.raises(IndexError):
|
|
||||||
spy[3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_del():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
del spy[1]
|
|
||||||
|
|
||||||
assert lst == ['a', 'c']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_del_with_negative():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
del spy[-1]
|
|
||||||
|
|
||||||
assert lst == ['a', 'b']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_insert():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy.insert(1, 'q')
|
|
||||||
|
|
||||||
assert lst == ['a', 'q', 'b', 'c']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_insert_with_negative():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy.insert(-1, 'q')
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'q', 'c']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_set_value():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy[1] = 'hello'
|
|
||||||
|
|
||||||
assert lst == ['a', 'hello', 'c']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_multiple_modifications():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy[1] = 'hello'
|
|
||||||
spy.insert(1, 'ohai')
|
|
||||||
del spy[0]
|
|
||||||
|
|
||||||
assert lst == ['ohai', 'hello', 'c']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_iter():
|
|
||||||
spy = ListSpy(['a', 'b', 'c'])
|
|
||||||
spy_iter = iter(spy)
|
|
||||||
assert next(spy_iter) == 'a'
|
|
||||||
assert next(spy_iter) == 'b'
|
|
||||||
assert next(spy_iter) == 'c'
|
|
||||||
with pytest.raises(StopIteration):
|
|
||||||
next(spy_iter)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_append():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy.append('q')
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c', 'q']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_pop_default():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy.pop()
|
|
||||||
|
|
||||||
assert lst == ['a', 'b']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_spy_pop_idx():
|
|
||||||
lst = ['a', 'b', 'c']
|
|
||||||
|
|
||||||
spy = ListSpy(lst)
|
|
||||||
spy.pop(1)
|
|
||||||
|
|
||||||
assert lst == ['a', 'c']
|
|
||||||
|
|
||||||
spy.undo(lst)
|
|
||||||
|
|
||||||
assert lst == ['a', 'b', 'c']
|
|
||||||
21
tests/main_test.py
Normal file
21
tests/main_test.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import onigurumacffi
|
import onigurumacffi
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -35,9 +37,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'
|
||||||
|
|
||||||
|
|||||||
86
tests/textmate_demo_test.py
Normal file
86
tests/textmate_demo_test.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from babi.textmate_demo import main
|
||||||
|
|
||||||
|
THEME = {
|
||||||
|
'colors': {'foreground': '#ffffff', 'background': '#000000'},
|
||||||
|
'tokenColors': [
|
||||||
|
{'scope': 'bold', 'settings': {'fontStyle': 'bold'}},
|
||||||
|
{'scope': 'italic', 'settings': {'fontStyle': 'italic'}},
|
||||||
|
{'scope': 'underline', 'settings': {'fontStyle': 'underline'}},
|
||||||
|
{'scope': 'comment', 'settings': {'foreground': '#1e77d3'}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
GRAMMAR = {
|
||||||
|
'scopeName': 'source.demo',
|
||||||
|
'fileTypes': ['demo'],
|
||||||
|
'patterns': [
|
||||||
|
{'match': r'\*[^*]*\*', 'name': 'bold'},
|
||||||
|
{'match': '/[^/]*/', 'name': 'italic'},
|
||||||
|
{'match': '_[^_]*_', 'name': 'underline'},
|
||||||
|
{'match': '#.*', 'name': 'comment'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def theme_grammars(tmpdir):
|
||||||
|
theme = tmpdir.join('config/theme.json').ensure()
|
||||||
|
theme.write(json.dumps(THEME))
|
||||||
|
grammars = tmpdir.join('grammar_v1').ensure_dir()
|
||||||
|
grammars.join('source.demo.json').write(json.dumps(GRAMMAR))
|
||||||
|
return theme, grammars
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic(theme_grammars, tmpdir, capsys):
|
||||||
|
theme, grammars = theme_grammars
|
||||||
|
|
||||||
|
f = tmpdir.join('f.demo')
|
||||||
|
f.write('*bold*/italic/_underline_# comment\n')
|
||||||
|
|
||||||
|
assert not main((
|
||||||
|
'--theme', str(theme), '--grammar-dir', str(grammars),
|
||||||
|
str(f),
|
||||||
|
))
|
||||||
|
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
|
||||||
|
assert out == (
|
||||||
|
'\x1b[48;2;0;0;0m\n'
|
||||||
|
'\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[1m'
|
||||||
|
'*bold*'
|
||||||
|
'\x1b[39m\x1b[49m\x1b[22m'
|
||||||
|
'\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[3m'
|
||||||
|
'/italic/'
|
||||||
|
'\x1b[39m\x1b[49m\x1b[23m'
|
||||||
|
'\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[4m'
|
||||||
|
'_underline_'
|
||||||
|
'\x1b[39m\x1b[49m\x1b[24m'
|
||||||
|
'\x1b[38;2;30;119;211m\x1b[48;2;0;0;0m'
|
||||||
|
'# comment'
|
||||||
|
'\x1b[39m\x1b[49m\x1b'
|
||||||
|
'[38;2;255;255;255m\x1b[48;2;0;0;0m\n\x1b[39m\x1b[49m'
|
||||||
|
'\x1b[m'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_with_blank_theme(theme_grammars, tmpdir, capsys):
|
||||||
|
theme, grammars = theme_grammars
|
||||||
|
theme.write('{}')
|
||||||
|
|
||||||
|
f = tmpdir.join('f.demo')
|
||||||
|
f.write('*bold*/italic/_underline_# comment\n')
|
||||||
|
|
||||||
|
assert not main((
|
||||||
|
'--theme', str(theme), '--grammar-dir', str(grammars),
|
||||||
|
str(f),
|
||||||
|
))
|
||||||
|
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
|
||||||
|
assert out == '*bold*/italic/_underline_# comment\n\x1b[m'
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from babi.color import Color
|
from babi.color import Color
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user