183 Commits

Author SHA1 Message Date
Anthony Sottile
c4cba4a884 Merge pull request #172 from asottile/all-repos_autofix_gh-funding-default
Use org-default .github/FUNDING.yml
2021-11-23 11:53:51 -05:00
Anthony Sottile
38faec4519 Use org-default .github/FUNDING.yml
Committed via https://github.com/asottile/all-repos
2021-11-23 11:16:13 -05:00
Anthony Sottile
b10afd5bd8 Merge pull request #171 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-22 17:06:47 -05:00
pre-commit-ci[bot]
b1080319ae [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/add-trailing-comma: v2.2.0 → v2.2.1](https://github.com/asottile/add-trailing-comma/compare/v2.2.0...v2.2.1)
- [github.com/asottile/pyupgrade: v2.29.0 → v2.29.1](https://github.com/asottile/pyupgrade/compare/v2.29.0...v2.29.1)
- [github.com/asottile/setup-cfg-fmt: v1.19.0 → v1.20.0](https://github.com/asottile/setup-cfg-fmt/compare/v1.19.0...v1.20.0)
2021-11-22 21:43:03 +00:00
Anthony Sottile
89dee66711 Merge pull request #170 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-01 17:24:53 -04:00
pre-commit-ci[bot]
3fb588bba9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-11-01 21:16:08 +00:00
pre-commit-ci[bot]
99be8b59c9 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/setup-cfg-fmt: v1.18.0 → v1.19.0](https://github.com/asottile/setup-cfg-fmt/compare/v1.18.0...v1.19.0)
2021-11-01 21:15:36 +00:00
Anthony Sottile
d4da5eb800 Merge pull request #169 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-25 13:48:37 -07:00
pre-commit-ci[bot]
0c3abfc727 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/add-trailing-comma: v2.1.0 → v2.2.0](https://github.com/asottile/add-trailing-comma/compare/v2.1.0...v2.2.0)
2021-10-25 20:21:08 +00:00
Anthony Sottile
c3a34c6262 Merge pull request #168 from asottile/all-repos_autofix_exit-main-2
exit(main()) -> raise SystemExit(main()) pt2
2021-10-24 08:11:16 -07:00
Anthony Sottile
ae30b5763c exit(main()) -> raise SystemExit(main()) pt2
Committed via https://github.com/asottile/all-repos
2021-10-24 07:15:30 -07:00
Anthony Sottile
1cad38d632 Merge pull request #167 from asottile/all-repos_autofix_systemexit-main
replace exit(main()) with raise SystemExit(main())
2021-10-23 11:00:46 -07:00
Anthony Sottile
d2a7014925 replace exit(main()) with raise SystemExit(main())
Committed via https://github.com/asottile/all-repos
2021-10-23 13:22:47 -04:00
Anthony Sottile
9eae3da253 Merge pull request #166 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-11 13:54:46 -07:00
pre-commit-ci[bot]
075338563e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-10-11 20:29:12 +00:00
pre-commit-ci[bot]
a7e83ef089 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 3.9.2 → 4.0.1](https://github.com/PyCQA/flake8/compare/3.9.2...4.0.1)
- [github.com/asottile/setup-cfg-fmt: v1.17.0 → v1.18.0](https://github.com/asottile/setup-cfg-fmt/compare/v1.17.0...v1.18.0)
- [github.com/pre-commit/mirrors-mypy: v0.910 → v0.910-1](https://github.com/pre-commit/mirrors-mypy/compare/v0.910...v0.910-1)
2021-10-11 20:24:11 +00:00
Anthony Sottile
03656c04bb Merge pull request #165 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-10-04 13:28:38 -07:00
pre-commit-ci[bot]
4be3cbff35 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.28.0 → v2.29.0](https://github.com/asottile/pyupgrade/compare/v2.28.0...v2.29.0)
2021-10-04 20:19:23 +00:00
Anthony Sottile
99b3371739 v0.0.24 2021-10-03 18:55:37 -04:00
Anthony Sottile
3c9c6173c3 Merge pull request #164 from AndrewLaneX/replace-errors
Handle errors when replacing
2021-10-03 15:53:22 -07:00
Andrew Lane
84015d3ac4 Handle errors when replacing 2021-10-03 23:45:29 +03:00
Anthony Sottile
d7ffdd1db8 Merge pull request #163 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-09-27 13:26:18 -07:00
pre-commit-ci[bot]
4e0c02eaa2 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.26.0 → v2.28.0](https://github.com/asottile/pyupgrade/compare/v2.26.0...v2.28.0)
2021-09-27 20:16:08 +00:00
Anthony Sottile
79919ece2a Merge pull request #162 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-09-13 13:30:33 -07:00
pre-commit-ci[bot]
f0ad0e4977 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.25.0 → v2.26.0](https://github.com/asottile/pyupgrade/compare/v2.25.0...v2.26.0)
2021-09-13 20:11:28 +00:00
Anthony Sottile
f18796b78d Merge pull request #161 from asottile/a-italic
A_ITALIC is not always present unlike what's documented
2021-09-12 06:47:05 -07:00
Anthony Sottile
1698533787 A_ITALIC is not always present unlike what's documented 2021-09-12 06:42:28 -07:00
Anthony Sottile
10fca36ea3 v0.0.22 2021-09-11 16:30:27 -04:00
Anthony Sottile
e6aab391f6 Merge pull request #158 from asottile/drop_py36
drop python 3.6
2021-09-11 12:27:26 -07:00
Anthony Sottile
ebee8fe6ff drop python 3.6 2021-09-11 15:21:27 -04:00
Anthony Sottile
04fc97a8f9 Merge pull request #157 from asottile/save-file-new-name
allow saving a file with a new name
2021-09-11 11:24:48 -07:00
Anthony Sottile
c49e722498 allow saving a file with a new name 2021-09-11 14:02:02 -04:00
Anthony Sottile
396d0e3a93 Merge pull request #156 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-08-30 16:17:14 -04:00
pre-commit-ci[bot]
660bf9bac0 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.24.0 → v2.25.0](https://github.com/asottile/pyupgrade/compare/v2.24.0...v2.25.0)
2021-08-30 19:56:51 +00:00
Anthony Sottile
0b3918f26f Merge pull request #155 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-08-23 16:18:43 -04:00
pre-commit-ci[bot]
9dffc276dc [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.23.3 → v2.24.0](https://github.com/asottile/pyupgrade/compare/v2.23.3...v2.24.0)
2021-08-23 19:21:33 +00:00
Anthony Sottile
ea6dbb69a6 Merge pull request #154 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-08-09 13:22:08 -07:00
pre-commit-ci[bot]
eeeba9e11d [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.23.1 → v2.23.3](https://github.com/asottile/pyupgrade/compare/v2.23.1...v2.23.3)
2021-08-09 19:16:56 +00:00
Anthony Sottile
b5538b3818 Merge pull request #153 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-08-02 15:32:10 -04:00
pre-commit-ci[bot]
16c60d68ad [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.23.0 → v2.23.1](https://github.com/asottile/pyupgrade/compare/v2.23.0...v2.23.1)
2021-08-02 19:16:02 +00:00
Anthony Sottile
27aa865989 Merge pull request #152 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-07-26 14:41:32 -04:00
pre-commit-ci[bot]
4b13488e8f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder_python_imports: v2.5.0 → v2.6.0](https://github.com/asottile/reorder_python_imports/compare/v2.5.0...v2.6.0)
- [github.com/asottile/pyupgrade: v2.21.2 → v2.23.0](https://github.com/asottile/pyupgrade/compare/v2.21.2...v2.23.0)
2021-07-26 18:25:12 +00:00
Anthony Sottile
b4593d281a Merge pull request #150 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-07-19 18:53:22 -04:00
pre-commit-ci[bot]
3ddf1c72f8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.21.0 → v2.21.2](https://github.com/asottile/pyupgrade/compare/v2.21.0...v2.21.2)
2021-07-19 22:33:22 +00:00
Anthony Sottile
8f91b8c9ff Merge pull request #149 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-07-12 20:16:41 -04:00
pre-commit-ci[bot]
c48d3ed741 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.20.0 → v2.21.0](https://github.com/asottile/pyupgrade/compare/v2.20.0...v2.21.0)
2021-07-12 23:50:32 +00:00
Anthony Sottile
43a650925b Merge pull request #148 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-07-05 18:43:23 -04:00
pre-commit-ci[bot]
3116828e44 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.19.4 → v2.20.0](https://github.com/asottile/pyupgrade/compare/v2.19.4...v2.20.0)
2021-07-05 22:26:18 +00:00
Anthony Sottile
9bc43d58e4 Merge pull request #146 from asottile/all-repos_autofix_mypy-settings
stricter mypy settings
2021-06-29 19:41:08 -05:00
Anthony Sottile
bbc9647eb3 Merge pull request #147 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-06-28 18:14:01 -07:00
pre-commit-ci[bot]
e36ed09f1f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-mypy: v0.902 → v0.910](https://github.com/pre-commit/mirrors-mypy/compare/v0.902...v0.910)
2021-06-28 18:01:19 +00:00
Anthony Sottile
fb52b5f71c remove unused type ignore 2021-06-21 19:22:12 -07:00
Anthony Sottile
1916b49a06 stricter mypy settings
Committed via https://github.com/asottile/all-repos
2021-06-21 19:11:43 -07:00
Anthony Sottile
66c178b0b7 Merge pull request #145 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-06-14 11:16:52 -07:00
pre-commit-ci[bot]
ea4058549f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.19.1 → v2.19.4](https://github.com/asottile/pyupgrade/compare/v2.19.1...v2.19.4)
- [github.com/pre-commit/mirrors-mypy: v0.812 → v0.902](https://github.com/pre-commit/mirrors-mypy/compare/v0.812...v0.902)
2021-06-14 17:57:33 +00:00
Anthony Sottile
cb6b431308 Merge pull request #144 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-06-07 19:21:23 -07:00
pre-commit-ci[bot]
a8283adb54 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.19.0 → v2.19.1](https://github.com/asottile/pyupgrade/compare/v2.19.0...v2.19.1)
2021-06-08 02:05:58 +00:00
Anthony Sottile
40bf9969fb Merge pull request #143 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-05-31 11:08:51 -07:00
pre-commit-ci[bot]
a04b8fdca6 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.18.2 → v2.19.0](https://github.com/asottile/pyupgrade/compare/v2.18.2...v2.19.0)
2021-05-31 17:49:36 +00:00
Anthony Sottile
76eef9adb6 Merge pull request #142 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-05-24 11:23:34 -07:00
pre-commit-ci[bot]
fa962d6cb9 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.16.0 → v2.18.2](https://github.com/asottile/pyupgrade/compare/v2.16.0...v2.18.2)
2021-05-24 17:36:32 +00:00
Anthony Sottile
f40f93b983 Merge pull request #141 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-05-17 11:19:19 -07:00
pre-commit-ci[bot]
af01959a48 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v3.4.0 → v4.0.1](https://github.com/pre-commit/pre-commit-hooks/compare/v3.4.0...v4.0.1)
- [github.com/asottile/pyupgrade: v2.15.0 → v2.16.0](https://github.com/asottile/pyupgrade/compare/v2.15.0...v2.16.0)
2021-05-17 17:43:01 +00:00
Anthony Sottile
16f4ec3681 Merge pull request #140 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-05-10 14:10:55 -07:00
pre-commit-ci[bot]
291d34028a [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 3.9.1 → 3.9.2](https://github.com/PyCQA/flake8/compare/3.9.1...3.9.2)
- [github.com/asottile/pyupgrade: v2.14.0 → v2.15.0](https://github.com/asottile/pyupgrade/compare/v2.14.0...v2.15.0)
2021-05-10 20:47:33 +00:00
Anthony Sottile
2c200b97ed Merge pull request #138 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-05-03 10:58:17 -07:00
pre-commit-ci[bot]
fc185b0eef [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-autopep8: v1.5.6 → v1.5.7](https://github.com/pre-commit/mirrors-autopep8/compare/v1.5.6...v1.5.7)
- [github.com/asottile/pyupgrade: v2.13.0 → v2.14.0](https://github.com/asottile/pyupgrade/compare/v2.13.0...v2.14.0)
2021-05-03 17:39:51 +00:00
Anthony Sottile
21357ed235 Merge pull request #137 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-04-26 11:07:57 -07:00
pre-commit-ci[bot]
1a023d3830 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder_python_imports: v2.4.0 → v2.5.0](https://github.com/asottile/reorder_python_imports/compare/v2.4.0...v2.5.0)
- [github.com/asottile/pyupgrade: v2.12.0 → v2.13.0](https://github.com/asottile/pyupgrade/compare/v2.12.0...v2.13.0)
2021-04-26 17:34:09 +00:00
Anthony Sottile
61063b306c Merge pull request #136 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-04-19 12:57:00 -07:00
pre-commit-ci[bot]
628d3ced55 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 3.9.0 → 3.9.1](https://github.com/PyCQA/flake8/compare/3.9.0...3.9.1)
2021-04-19 17:19:18 +00:00
Anthony Sottile
b6dc975143 Merge pull request #135 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-04-12 11:27:43 -07:00
pre-commit-ci[bot]
4537df6aa1 [pre-commit.ci] pre-commit autoupdate 2021-04-12 17:22:50 +00:00
Anthony Sottile
1d1307aa1c Merge pull request #134 from asottile/all-repos_autofix_azure-pipelines-autoupdate
Update azure-pipelines template repositories
2021-04-08 19:39:56 -07:00
Anthony Sottile
194d1c5b9b Update azure-pipelines template repositories
Committed via https://github.com/asottile/all-repos
2021-04-08 19:19:17 -07:00
Anthony Sottile
e59c860097 Merge pull request #133 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-04-05 11:28:42 -07:00
pre-commit-ci[bot]
0a39d73959 [pre-commit.ci] pre-commit autoupdate 2021-04-05 17:19:35 +00:00
Anthony Sottile
f8bf24482e Merge pull request #131 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-03-22 12:09:30 -07:00
pre-commit-ci[bot]
50079514fd [pre-commit.ci] pre-commit autoupdate 2021-03-22 17:13:52 +00:00
Anthony Sottile
96402e30cf Merge pull request #130 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-03-15 12:12:32 -07:00
pre-commit-ci[bot]
048ed590ff [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-03-15 17:13:12 +00:00
pre-commit-ci[bot]
ab6cfcc8c9 [pre-commit.ci] pre-commit autoupdate 2021-03-15 17:11:31 +00:00
Anthony Sottile
6348313071 Merge pull request #129 from asottile/document_macos
document how to get more keyboard shortcuts working on macos
2021-03-05 10:03:21 -08:00
Anthony Sottile
c59e00975b document how to get more keyboard shortcuts working on macos 2021-03-05 09:21:22 -08:00
Anthony Sottile
e3ba08a331 Merge pull request #127 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-02-22 09:31:19 -08:00
pre-commit-ci[bot]
6bdf0ff2ea [pre-commit.ci] pre-commit autoupdate 2021-02-22 17:12:30 +00:00
Anthony Sottile
bc699e60e1 Merge pull request #126 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-02-08 09:18:34 -08:00
pre-commit-ci[bot]
151a56c7f8 [pre-commit.ci] pre-commit autoupdate 2021-02-08 16:58:08 +00:00
Anthony Sottile
d958934fdd Merge pull request #125 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-02-01 12:46:03 -08:00
pre-commit-ci[bot]
9a8bda1f15 [pre-commit.ci] pre-commit autoupdate 2021-02-01 16:57:52 +00:00
Anthony Sottile
c7904fdf9b v0.0.21 2021-01-29 20:31:27 -08:00
Anthony Sottile
f6db46736c Merge pull request #124 from theendlessriver13/io_error_save
fix crashing on permission denied error
2021-01-29 20:30:34 -08:00
theendlessriver13
e059dc294b fix crashing on permission denied 2021-01-30 04:09:22 +01:00
Anthony Sottile
4dc42aa628 Merge pull request #123 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-01-25 12:10:22 -08:00
pre-commit-ci[bot]
25659ca5c7 [pre-commit.ci] pre-commit autoupdate 2021-01-25 16:48:35 +00:00
Anthony Sottile
bd8dc3beb0 v0.0.20 2021-01-18 15:08:22 -08:00
Anthony Sottile
324513c36a Merge pull request #122 from asottile/backspace_blank_line_at_eof
fix backspacing an extra blank line at end of file
2021-01-18 15:07:48 -08:00
Anthony Sottile
09643b0f80 fix backspacing an extra blank line at end of file 2021-01-18 14:57:34 -08:00
Anthony Sottile
8245ee56a5 Merge pull request #119 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-01-04 09:18:29 -08:00
pre-commit-ci[bot]
8aaee402e7 [pre-commit.ci] pre-commit autoupdate 2021-01-04 16:53:18 +00:00
Anthony Sottile
641eed65d5 Merge pull request #118 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2020-12-21 09:32:09 -08:00
pre-commit-ci[bot]
694853e341 [pre-commit.ci] pre-commit autoupdate 2020-12-21 16:51:45 +00:00
Anthony Sottile
1a4d15206c Merge pull request #113 from asottile/case_insensitive_short_answers
ignore case for quick prompt answers
2020-11-20 12:34:40 -08:00
Anthony Sottile
4ca3b0d1e5 ignore case for quick prompt answers 2020-11-20 12:22:55 -08:00
Anthony Sottile
47868e77a2 Merge pull request #112 from asottile/all-repos_autofix_gh-sponsors
Add link to GitHub Sponsors
2020-11-19 17:12:44 -08:00
Anthony Sottile
9906e223bd Add link to GitHub Sponsors
at the time of writing I am currently unemployed.  I'd love to make open
source a full time career.  if you or your company is deriving value from
this free software, please consider [sponsoring].

[sponsoring]: https://github.com/sponsors/asottile

Committed via https://github.com/asottile/all-repos
2020-11-19 16:58:49 -08:00
Anthony Sottile
a5101007cd Merge pull request #111 from rmorshea/patch-1
Add video explaining babi vs nano
2020-11-18 12:08:01 -08:00
Ryan Morshead
572151197d Add video explaining babi vs nano
closes: #110
2020-11-18 11:52:50 -08:00
Anthony Sottile
a8a5afc6ed Merge pull request #108 from asottile/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2020-11-16 09:46:42 -08:00
pre-commit-ci[bot]
e523a694b6 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2020-11-16 17:12:39 +00:00
pre-commit-ci[bot]
8161c9e34f [pre-commit.ci] pre-commit autoupdate 2020-11-16 17:11:07 +00:00
Anthony Sottile
0b5e187be5 v0.0.19 2020-11-07 12:04:31 -08:00
Anthony Sottile
ee13b7bb78 Merge pull request #106 from asottile/missing_escdelay_some_curses
fix set_escdelay when the curses library does not support it
2020-11-07 12:03:36 -08:00
Anthony Sottile
cad35f7b4d Merge pull request #107 from asottile/pci
use pre-commit.ci
2020-11-07 12:03:13 -08:00
Anthony Sottile
c1a9823894 use pre-commit.ci 2020-11-07 11:51:07 -08:00
Anthony Sottile
899eb6f879 fix set_escdelay when the curses library does not support it 2020-11-07 11:25:04 -08:00
Anthony Sottile
945e0b1620 Merge pull request #104 from villelaitila/feature/101-ctrl-d-to-forward-delete
Familiar forward deletion keyboard shortcut Ctrl-D (#101)
2020-11-03 10:43:38 -08:00
Ville Laitila
1f348882b8 Familiar forward deletion keyboard shortcut Ctrl-D
With help of the mapping rules, this is easily implemented and all
the unit tests will also test usage of Ctrl-D already as Del char
behaves similarly.
2020-11-03 10:32:17 -08:00
Anthony Sottile
604942306f v0.0.18 2020-10-24 12:56:04 -07:00
Anthony Sottile
00570f8eda Merge pull request #100 from theendlessriver13/fix_keys_for_win_terminal
fix keys for new windows terminal
2020-10-24 12:55:42 -07:00
Jonas Kittner
51a7b10192 fix keys for windows terminal when using xterm 2020-10-24 21:46:03 +02:00
Anthony Sottile
4d1101daf9 Merge pull request #96 from brynphillips/fix_comment_whitespace
fix whitespace on added comment
2020-09-04 22:19:59 -07:00
shazbot
08ec1874d1 fix whitespace on blank line with added comment 2020-09-04 22:11:04 -07:00
Anthony Sottile
4881953763 v0.0.17 2020-09-04 14:20:32 -07:00
Anthony Sottile
8f91c12a45 Merge pull request #89 from AndrewLaneX/expandtabs
Add :expandtabs and :noexpandtabs
2020-09-04 14:19:45 -07:00
Andrew Lane
5df223f81e Add :expandtabs and :noexpandtabs 2020-09-04 17:07:12 -04:00
Anthony Sottile
57bae10448 Merge pull request #87 from KeisukeFD/fix_comment
fix comments behavior on multiple lines with indentation #86
2020-09-04 13:52:29 -07:00
Valentin Malissen
a2afbfa07b fix comments behavior on multiple lines with indentation 2020-09-04 13:43:22 -07:00
Anthony Sottile
229ec77f4f Merge pull request #94 from 7brokenmirrors/utf8-default-encoding
Add checks for UTF-8
2020-09-01 10:04:29 -07:00
7brokenmirrors
5a25901cdb Add "encoding='UTF-8'" to open() calls 2020-09-01 09:54:56 -07:00
Anthony Sottile
9c5f28d475 Merge pull request #93 from asottile/harden_indent_test
improve indent-mod test
2020-08-29 19:29:33 -07:00
Anthony Sottile
a87497cbe2 improve indent-mod test 2020-08-29 19:20:00 -07:00
Anthony Sottile
d7622f38c6 v0.0.16 2020-08-29 13:07:01 -07:00
Anthony Sottile
e474396790 Merge pull request #92 from asottile/xterm_mono
do not crash if the terminal does not have color support
2020-08-29 13:06:34 -07:00
Anthony Sottile
e6a0353650 do not crash if the terminal does not have color support 2020-08-29 12:40:12 -07:00
Anthony Sottile
e0a59e3f9c v0.0.15 2020-08-28 21:17:51 -07:00
Anthony Sottile
787dc0d18f Merge pull request #91 from asottile/fix_cursor_position_comment
fix position changes when commenting and cursor is before comment
2020-08-28 21:17:27 -07:00
Anthony Sottile
fd9393c8b1 fix position changes when commenting and cursor is before comment 2020-08-28 21:09:16 -07:00
Anthony Sottile
eb26d93e03 Merge pull request #90 from asottile/fix_out_of_bounds_on_uncomment
Fix out of bounds on uncomment
2020-08-28 21:00:52 -07:00
Anthony Sottile
055d738142 Fix out of bounds on uncomment 2020-08-28 20:52:01 -07:00
Anthony Sottile
29062628f9 v0.0.14 2020-08-24 14:03:36 -07:00
Anthony Sottile
1fab2a4b71 Merge pull request #85 from asottile/comment
add :comment command for toggling comments
2020-08-24 14:02:23 -07:00
Anthony Sottile
9f5e8c02cb add :comment command for toggling comments 2020-08-24 13:52:35 -07:00
Anthony Sottile
31624856d2 Merge pull request #84 from asottile/asottile-patch-1
add logo to README
2020-08-11 23:27:25 -07:00
Anthony Sottile
97b3b4deef add logo to README 2020-08-11 23:20:48 -07:00
Anthony Sottile
41880d5f8c v0.0.13 2020-07-24 15:28:01 -07:00
Anthony Sottile
effe988f60 Merge pull request #81 from asottile/fix_begin_end_hang
fix highlighting hang with empty begin end rules
2020-07-24 15:26:38 -07:00
Anthony Sottile
84b20a4016 fix highlighting hang with empty begin end rules 2020-07-24 15:13:35 -07:00
Anthony Sottile
5d2c9532a3 s/usually use nano/used to use nano/ 2020-07-20 20:06:24 -07:00
Anthony Sottile
33ff8d9726 v0.0.12 2020-07-13 13:33:59 -07:00
Anthony Sottile
f0b2af9a9f Merge pull request #77 from asottile/regex_flags
leverage new regex flags
2020-07-01 17:34:20 -07:00
Anthony Sottile
fc21a144aa leverage new regex flags 2020-07-01 17:07:32 -07:00
Anthony Sottile
973b4c3cf8 Merge pull request #76 from asottile/fix_background_on_close
fix race condition with ^Z on close
2020-06-29 13:37:14 -07:00
Anthony Sottile
bd60977438 fix race condition with ^Z on close 2020-06-29 13:13:14 -07:00
Anthony Sottile
144bbb9daf v0.0.11 2020-05-27 15:50:30 -07:00
Anthony Sottile
7c16cd966e Merge pull request #72 from asottile/pypy3_ci
re-enable pypy3 testing
2020-05-27 15:48:20 -07:00
Anthony Sottile
dd19b26fa2 re-enable pypy3 testing 2020-05-27 15:33:31 -07:00
Anthony Sottile
dca410dd44 Merge pull request #69 from YouTwitFace/add-tab-size
Add a vim style command to change the tab size
2020-05-27 15:31:06 -07:00
YouTwitFace
ed51b6e6dc Add :tabsize and :tabstop 2020-05-27 15:21:17 -07:00
Anthony Sottile
18b5e258f6 Merge pull request #71 from asottile/escdelay_tests
test py39
2020-05-26 11:41:15 -07:00
Anthony Sottile
e7108f843b test py39 2020-05-26 11:17:17 -07:00
Anthony Sottile
ff8d3f10fb Merge pull request #70 from asottile/asottile-patch-1
Fix typo in README
2020-05-26 11:09:22 -07:00
Anthony Sottile
8f603b8e14 Fix typo in README 2020-05-26 11:04:30 -07:00
Anthony Sottile
c184468843 v0.0.10 2020-05-24 18:31:48 -07:00
Anthony Sottile
c5653976c7 Merge pull request #68 from asottile/fix_macos_full_screen
fix fullscreen on macos in Terminal
2020-05-24 18:29:45 -07:00
Anthony Sottile
d81bb12ff7 fix fullscreen on macos in Terminal 2020-05-24 18:18:35 -07:00
Anthony Sottile
afe461372e Merge pull request #65 from YouTwitFace/add-reverse-sort
Add reverse sort
2020-05-20 18:04:10 -07:00
YouTwitFace
b486047e90 Add reverse sort 2020-05-20 17:07:40 -07:00
Anthony Sottile
f3401a46c7 v0.0.9 2020-05-13 16:20:48 -07:00
Anthony Sottile
fbf5fc6ba2 Merge pull request #63 from theendlessriver13/fix_redo_on_win
add key for redo so it works on win
2020-05-13 15:55:59 -07:00
Jonas Kittner
60b0a77f05 add key for redo so it works on win
- added new key to test
2020-05-14 00:17:21 +02:00
Anthony Sottile
28a73a1a8c Merge pull request #64 from theendlessriver13/add_numpad_enter
add numpad enter
2020-05-13 14:41:20 -07:00
Anthony Sottile
432640eaf1 Merge pull request #62 from theendlessriver13/fix_windows_problems
fix babi crashing on win when trying to run it in the background
2020-05-13 14:32:39 -07:00
Jonas Kittner
71e67a6349 add numpad enter 2020-05-13 23:06:24 +02:00
Jonas Kittner
a5caa9d746 fix background crash on win 2020-05-13 22:37:57 +02:00
Anthony Sottile
599dfa1d0e pre-commit autoupdate 2020-05-11 17:27:40 -07:00
Anthony Sottile
3f259403fe v0.0.8 2020-05-08 15:56:59 -07:00
Anthony Sottile
4b27a18c0f Merge pull request #61 from theendlessriver13/fix_CTL_HOME_END_on_win
fix jump to top/end of file on windows
2020-05-08 15:56:16 -07:00
Jonas Kittner
58bc4780ca fix jump to top/end of file on windows 2020-05-09 00:47:18 +02:00
Anthony Sottile
4812daf300 Implement open-with-offset
Resolves #60
2020-04-17 19:31:51 -07:00
Anthony Sottile
7d1e61f734 v0.0.7 2020-04-04 17:44:40 -07:00
Anthony Sottile
3e7ca8e922 make grammar loading more deterministic 2020-04-04 17:44:27 -07:00
Anthony Sottile
843f1b6ff1 remove stray print 2020-04-04 13:13:53 -07:00
76 changed files with 1239 additions and 521 deletions

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.5.0
rev: v4.0.1
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -10,35 +10,35 @@ repos:
- id: double-quote-string-fixer
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
hooks:
- id: flake8
additional_dependencies: [flake8-typing-imports==1.7.0]
- repo: https://github.com/pre-commit/mirrors-autopep8
rev: v1.5
rev: v1.5.7
hooks:
- id: autopep8
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.1.0
rev: v2.6.0
hooks:
- id: reorder-python-imports
args: [--py3-plus]
args: [--py3-plus, --add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/add-trailing-comma
rev: v2.0.1
rev: v2.2.1
hooks:
- id: add-trailing-comma
args: [--py36-plus]
- repo: https://github.com/asottile/pyupgrade
rev: v2.1.0
rev: v2.29.1
hooks:
- id: pyupgrade
args: [--py36-plus]
args: [--py37-plus]
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.7.0
rev: v1.20.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.770
rev: v0.910-1
hooks:
- id: mypy

View File

@@ -1,5 +1,8 @@
[![Build Status](https://dev.azure.com/asottile/asottile/_apis/build/status/asottile.babi?branchName=master)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
[![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/asottile/asottile/29/master.svg)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/babi/master.svg)](https://results.pre-commit.ci/latest/github/asottile/babi/master)
![babi logo](https://user-images.githubusercontent.com/1810591/89981369-9ed84e80-dc28-11ea-9708-5f4c49c09632.png)
babi
====
@@ -12,9 +15,16 @@ a text editor, eventually...
### 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`.
### babi vs. nano
here is a youtube video where I discuss the motivation for creating and using
`babi` instead of `nano`:
[![youtube video about babi](https://img.youtube.com/vi/WyR1hAGmR3g/mqdefault.jpg)](https://youtu.be/WyR1hAGmR3g)
### quitting babi
currently you can quit `babi` by using <kbd>^X</kbd> (or via <kbd>esc</kbd> +
@@ -45,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
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>^\\</kbd>: search and replace
- <kbd>^C</kbd>: show the current position in the file
@@ -77,6 +87,25 @@ here's a modified vs dark plus theme that works:
./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
most things work! here's a few screenshots
@@ -87,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
goes off screen either from resize events or from movement. babi can edit
multiple files. babi has a command mode (so you can quit it like vim
<kbd>:q</kbd>!). babi also support syntax highlighting
<kbd>:q</kbd>!). babi also supports syntax highlighting
![](https://i.fluffy.cc/5WFZBJ4mWs7wtThD9strQnGlJqw4Z9KS.png)

View File

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

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
from babi.main import main
if __name__ == '__main__':
exit(main())
raise SystemExit(main())

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:

View File

@@ -1,12 +1,11 @@
from __future__ import annotations
import bisect
import contextlib
from typing import Callable
from typing import Generator
from typing import Iterator
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol
from babi.horizontal_scrolling import line_x
@@ -19,25 +18,25 @@ DelCallback = Callable[['Buf', int, str], None]
InsCallback = Callable[['Buf', int], None]
def _offsets(s: str) -> Tuple[int, ...]:
def _offsets(s: str, tab_size: int) -> tuple[int, ...]:
ret = [0]
for c in s:
if c == '\t':
ret.append(ret[-1] + (4 - ret[-1] % 4))
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: ...
def __call__(self, buf: Buf) -> None: ...
class SetModification(NamedTuple):
idx: int
s: str
def __call__(self, buf: 'Buf') -> None:
def __call__(self, buf: Buf) -> None:
buf[self.idx] = self.s
@@ -45,27 +44,29 @@ class InsModification(NamedTuple):
idx: int
s: str
def __call__(self, buf: 'Buf') -> None:
def __call__(self, buf: Buf) -> None:
buf.insert(self.idx, self.s)
class DelModification(NamedTuple):
idx: int
def __call__(self, buf: 'Buf') -> None:
def __call__(self, buf: Buf) -> None:
del buf[self.idx]
class Buf:
def __init__(self, lines: List[str]) -> None:
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._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[Optional[Tuple[int, ...]]] = []
self._positions: list[tuple[int, ...] | None] = []
# read only interface
@@ -136,6 +137,10 @@ class Buf:
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:
@@ -157,16 +162,16 @@ class Buf:
self._ins_callbacks.remove(cb)
@contextlib.contextmanager
def record(self) -> Generator[List[Modification], None, None]:
modifications: List[Modification] = []
def record(self) -> Generator[list[Modification], None, None]:
modifications: list[Modification] = []
def set_cb(buf: 'Buf', idx: int, victim: str) -> None:
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:
def del_cb(buf: Buf, idx: int, victim: str) -> None:
modifications.append(InsModification(idx, victim))
def ins_cb(buf: 'Buf', idx: int) -> None:
def ins_cb(buf: Buf, idx: int) -> None:
modifications.append(DelModification(idx))
self.add_set_callback(set_cb)
@@ -179,7 +184,7 @@ class Buf:
self.remove_del_callback(del_cb)
self.remove_set_callback(set_cb)
def apply(self, modifications: List[Modification]) -> List[Modification]:
def apply(self, modifications: list[Modification]) -> list[Modification]:
with self.record() as ret_modifications:
for modification in reversed(modifications):
modification(self)
@@ -203,23 +208,24 @@ class Buf:
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:
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:
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:
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, ...]:
def line_positions(self, idx: int) -> tuple[int, ...]:
self._extend_positions(idx)
value = self._positions[idx]
if value is None:
value = self._positions[idx] = _offsets(self._lines[idx])
value = _offsets(self._lines[idx], self.tab_size)
self._positions[idx] = value
return value
def line_x(self, margin: Margin) -> int:
@@ -229,16 +235,24 @@ class Buf:
def _cursor_x(self) -> int:
return self.line_positions(self.y)[self.x]
def cursor_position(self, margin: Margin) -> Tuple[int, int]:
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
return scrolled_line(self._lines[idx].expandtabs(4), x, margin.cols)
expanded = self._lines[idx].expandtabs(self.tab_size)
return scrolled_line(expanded, x, margin.cols)
# movement

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import sys
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)
from typing import Callable
from typing import Generic
from typing import Optional
from typing import Type
from typing import TypeVar
TSelf = TypeVar('TSelf')
@@ -18,8 +18,8 @@ else: # pragma: no cover (<py38)
def __get__(
self,
instance: Optional[TSelf],
owner: Optional[Type[TSelf]] = None,
instance: TSelf | None,
owner: type[TSelf] | None = None,
) -> TRet:
assert instance is not None
ret = instance.__dict__[self._func.__name__] = self._func(instance)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import NamedTuple
# TODO: find a standard which defines these
@@ -11,7 +13,7 @@ class Color(NamedTuple):
b: int
@classmethod
def parse(cls, s: str) -> 'Color':
def parse(cls, s: str) -> Color:
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))
elif s.startswith('#'):

View File

@@ -1,9 +1,8 @@
from __future__ import annotations
import functools
import itertools
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol
from babi.color import Color
@@ -19,19 +18,19 @@ class KD(Protocol):
@property
def n(self) -> int: ...
@property
def left(self) -> Optional['KD']: ...
def left(self) -> KD | None: ...
@property
def right(self) -> Optional['KD']: ...
def right(self) -> KD | None: ...
class _KD(NamedTuple):
color: Color
n: int
left: Optional[KD]
right: Optional[KD]
left: KD | None
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:
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
dist = 2 ** 32
def _search(kd: Optional[KD], *, depth: int) -> None:
def _search(kd: KD | None, *, depth: int) -> None:
nonlocal best
nonlocal dist
@@ -77,7 +76,7 @@ def nearest(color: Color, colors: Optional[KD]) -> int:
@functools.lru_cache(maxsize=1)
def make_256() -> Optional[KD]:
def make_256() -> KD | None:
vals = (0, 95, 135, 175, 215, 255)
colors = [
(Color(r, g, b), i)

View File

@@ -1,21 +1,20 @@
from __future__ import annotations
import curses
from typing import Dict
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi import color_kd
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
return int(color.r * factor), int(color.g * factor), int(color.b * factor)
class ColorManager(NamedTuple):
colors: Dict[Color, int]
raw_pairs: Dict[Tuple[int, int], int]
colors: dict[Color, int]
raw_pairs: dict[tuple[int, int], int]
def init_color(self, color: Color) -> None:
if curses.can_change_color():
@@ -27,21 +26,24 @@ class ColorManager(NamedTuple):
else:
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
bg_i = self.colors[bg] if bg is not None else -1
return self.raw_color_pair(fg_i, bg_i)
def raw_color_pair(self, fg: int, bg: int) -> int:
try:
return self.raw_pairs[(fg, bg)]
except KeyError:
pass
if curses.COLORS > 0:
try:
return self.raw_pairs[(fg, bg)]
except KeyError:
pass
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg)
return n
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg)
return n
else:
return 0
@classmethod
def make(cls) -> 'ColorManager':
def make(cls) -> ColorManager:
return cls({}, {})

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Generic
from typing import Iterable
from typing import Mapping

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import collections
import contextlib
import curses
@@ -6,20 +8,17 @@ import hashlib
import io
import itertools
import os.path
import re
from typing import Any
from typing import Callable
from typing import cast
from typing import Generator
from typing import IO
from typing import List
from typing import Match
from typing import NamedTuple
from typing import Optional
from typing import Pattern
from typing import Tuple
from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union
from babi.buf import Buf
from babi.buf import Modification
@@ -38,8 +37,10 @@ if TYPE_CHECKING:
TCallable = TypeVar('TCallable', bound=Callable[..., Any])
WS_RE = re.compile(r'^\s*')
def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
def get_lines(sio: IO[str]) -> tuple[list[str], str, bool, str]:
sha256 = hashlib.sha256()
lines = []
newlines = collections.Counter({'\n': 0}) # default to `\n`
@@ -61,7 +62,7 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
class Action:
def __init__(
self, *, name: str, modifications: List[Modification],
self, *, name: str, modifications: list[Modification],
start_x: int, start_y: int, start_modified: bool,
end_x: int, end_y: int, end_modified: bool,
final: bool,
@@ -76,7 +77,7 @@ class Action:
self.end_modified = end_modified
self.final = final
def apply(self, file: 'File') -> 'Action':
def apply(self, file: File) -> Action:
action = Action(
name=self.name, modifications=file.buf.apply(self.modifications),
start_x=self.end_x, start_y=self.end_y,
@@ -95,7 +96,7 @@ class Action:
def action(func: TCallable) -> TCallable:
@functools.wraps(func)
def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
def action_inner(self: File, *args: Any, **kwargs: Any) -> Any:
self.finalize_previous_action()
return func(self, *args, **kwargs)
return cast(TCallable, action_inner)
@@ -108,7 +109,7 @@ def edit_action(
) -> Callable[[TCallable], TCallable]:
def edit_action_decorator(func: TCallable) -> TCallable:
@functools.wraps(func)
def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
def edit_action_inner(self: File, *args: Any, **kwargs: Any) -> Any:
with self.edit_action_context(name, final=final):
return func(self, *args, **kwargs)
return cast(TCallable, edit_action_inner)
@@ -117,7 +118,7 @@ def edit_action(
def keep_selection(func: TCallable) -> TCallable:
@functools.wraps(func)
def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
def keep_selection_inner(self: File, *args: Any, **kwargs: Any) -> Any:
with self.select():
return func(self, *args, **kwargs)
return cast(TCallable, keep_selection_inner)
@@ -125,7 +126,7 @@ def keep_selection(func: TCallable) -> TCallable:
def clear_selection(func: TCallable) -> TCallable:
@functools.wraps(func)
def clear_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
def clear_selection_inner(self: File, *args: Any, **kwargs: Any) -> Any:
ret = func(self, *args, **kwargs)
self.selection.clear()
return ret
@@ -140,7 +141,7 @@ class Found(NamedTuple):
class _SearchIter:
def __init__(
self,
file: 'File',
file: File,
reg: Pattern[str],
*,
offset: int,
@@ -152,7 +153,7 @@ class _SearchIter:
self._start_x = file.buf.x + offset
self._start_y = file.buf.y
def __iter__(self) -> '_SearchIter':
def __iter__(self) -> _SearchIter:
return self
def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
@@ -165,7 +166,7 @@ class _SearchIter:
raise StopIteration()
return Found(y, match)
def __next__(self) -> Tuple[int, Match[str]]:
def __next__(self) -> tuple[int, Match[str]]:
x = self.file.buf.x + self.offset
y = self.file.buf.y
@@ -197,25 +198,32 @@ class _SearchIter:
class File:
def __init__(
self,
filename: Optional[str],
filename: str | None,
initial_line: int,
color_manager: ColorManager,
hl_factories: Tuple[HLFactory, ...],
hl_factories: tuple[HLFactory, ...],
) -> None:
self.filename = filename
self.initial_line = initial_line
self.modified = False
self.buf = Buf([])
self.nl = '\n'
self.sha256: Optional[str] = None
self.sha256: str | None = None
self._in_edit_action = False
self.undo_stack: List[Action] = []
self.redo_stack: List[Action] = []
self.undo_stack: list[Action] = []
self.redo_stack: list[Action] = []
self._hl_factories = hl_factories
self._trailing_whitespace = TrailingWhitespace(color_manager)
self._replace_hl = Replace()
self.selection = Selection()
self._file_hls: Tuple[FileHL, ...] = ()
self._file_hls: tuple[FileHL, ...] = ()
def ensure_loaded(self, status: Status, stdin: str) -> None:
def ensure_loaded(
self,
status: Status,
margin: Margin,
stdin: str,
) -> None:
if self.buf:
return
@@ -226,7 +234,7 @@ class File:
sio = io.StringIO(stdin)
lines, self.nl, mixed, self.sha256 = get_lines(sio)
elif self.filename is not None and os.path.isfile(self.filename):
with open(self.filename, newline='') as f:
with open(self.filename, encoding='UTF-8', newline='') as f:
lines, self.nl, mixed, self.sha256 = get_lines(f)
else:
if self.filename is not None:
@@ -237,7 +245,7 @@ class File:
status.update('(new file)')
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
self.buf = Buf(lines)
self.buf = Buf(lines, self.buf.tab_size)
if mixed:
status.update(f'mixed newlines will be converted to {self.nl!r}')
@@ -257,6 +265,8 @@ class File:
for file_hl in self._file_hls:
file_hl.register_callbacks(self.buf)
self.go_to_line(self.initial_line, margin)
def __repr__(self) -> str:
return f'<{type(self).__name__} {self.filename!r}>'
@@ -383,14 +393,14 @@ class File:
@clear_selection
def replace(
self,
screen: 'Screen',
screen: Screen,
reg: Pattern[str],
replace: str,
) -> None:
self.finalize_previous_action()
count = 0
res: Union[str, PromptResult] = ''
res: str | PromptResult = ''
search = _SearchIter(self, reg, offset=0)
for line_y, match in search:
end = match.end()
@@ -464,7 +474,11 @@ class File:
if self.buf.y == 0 and self.buf.x == 0:
pass
# backspace at the end of the file does not change the contents
elif self.buf.y == len(self.buf) - 1:
elif (
self.buf.y == len(self.buf) - 1 and
# still allow backspace if there are 2+ blank lines
self.buf[self.buf.y - 1] != ''
):
self.buf.left(margin)
# at the beginning of the line, we join the current line and
# the previous line
@@ -512,20 +526,29 @@ class File:
assert self.selection.start is not None
sel_y, sel_x = self.selection.start
(s_y, _), (e_y, _) = self.selection.get()
tab_string = self.buf.tab_string
tab_size = len(tab_string)
for l_y in range(s_y, e_y + 1):
if self.buf[l_y]:
self.buf[l_y] = ' ' * 4 + self.buf[l_y]
self.buf[l_y] = tab_string + self.buf[l_y]
if l_y == self.buf.y:
self.buf.x += 4
self.buf.x += tab_size
if l_y == sel_y and sel_x != 0:
sel_x += 4
sel_x += tab_size
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
@edit_action('insert tab', final=False)
def _tab(self, margin: Margin) -> None:
n = 4 - self.buf.x % 4
tab_string = self.buf.tab_string
if tab_string == '\t':
n = 1
else:
n = self.buf.tab_size - self.buf.x % self.buf.tab_size
tab_string = tab_string[:n]
line = self.buf[self.buf.y]
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
self.buf[self.buf.y] = (
line[:self.buf.x] + tab_string + line[self.buf.x:]
)
self.buf.x += n
self.buf.restore_eof_invariant()
@@ -535,11 +558,10 @@ class File:
else:
self._tab(margin)
@staticmethod
def _dedent_line(s: str) -> int:
bound = min(len(s), 4)
def _dedent_line(self, s: str) -> int:
bound = min(len(s), len(self.buf.tab_string))
i = 0
while i < bound and s[i] == ' ':
while i < bound and s[i] in (' ', '\t'):
i += 1
return i
@@ -573,7 +595,7 @@ class File:
@edit_action('cut selection', final=True)
@clear_selection
def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
def cut_selection(self, margin: Margin) -> tuple[str, ...]:
ret = []
(s_y, s_x), (e_y, e_x) = self.selection.get()
if s_y == e_y:
@@ -593,7 +615,7 @@ class File:
self.buf.scroll_screen_if_needed(margin)
return tuple(ret)
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
def cut(self, cut_buffer: tuple[str, ...]) -> tuple[str, ...]:
# only continue a cut if the last action is a non-final cut
if not self._continue_last_action('cut'):
cut_buffer = ()
@@ -606,7 +628,7 @@ class File:
self.buf.x = 0
return cut_buffer + (victim,)
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
def _uncut(self, cut_buffer: tuple[str, ...], margin: Margin) -> None:
for cut_line in cut_buffer:
line = self.buf[self.buf.y]
before, after = line[:self.buf.x], line[self.buf.x:]
@@ -617,14 +639,14 @@ class File:
@edit_action('uncut', final=True)
@clear_selection
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
def uncut(self, cut_buffer: tuple[str, ...], margin: Margin) -> None:
self._uncut(cut_buffer, margin)
@edit_action('uncut selection', final=True)
@clear_selection
def uncut_selection(
self,
cut_buffer: Tuple[str, ...], margin: Margin,
cut_buffer: tuple[str, ...], margin: Margin,
) -> None:
self._uncut(cut_buffer, margin)
self.buf.up(margin)
@@ -632,9 +654,9 @@ class File:
self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
self.buf.restore_eof_invariant()
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
def _sort(self, margin: Margin, s_y: int, e_y: int, reverse: bool) -> None:
# self.buf intentionally does not support slicing so we use islice
lines = sorted(itertools.islice(self.buf, s_y, e_y))
lines = sorted(itertools.islice(self.buf, s_y, e_y), reverse=reverse)
for i, line in zip(range(s_y, e_y), lines):
self.buf[i] = line
@@ -642,18 +664,78 @@ class File:
self.buf.x = 0
self.buf.scroll_screen_if_needed(margin)
@edit_action('sort', final=True)
def sort(self, margin: Margin) -> None:
self._sort(margin, 0, len(self.buf) - 1)
@edit_action('sort selection', final=True)
@clear_selection
def sort_selection(self, margin: Margin) -> None:
def _selection_lines(self) -> tuple[int, int]:
(s_y, _), (e_y, _) = self.selection.get()
e_y = min(e_y + 1, len(self.buf) - 1)
if self.buf[e_y - 1] == '':
e_y -= 1
self._sort(margin, s_y, e_y)
return s_y, e_y
@edit_action('sort', final=True)
def sort(self, margin: Margin, reverse: bool = False) -> None:
self._sort(margin, 0, len(self.buf) - 1, reverse=reverse)
@edit_action('sort selection', final=True)
@clear_selection
def sort_selection(self, margin: Margin, reverse: bool = False) -> None:
s_y, e_y = self._selection_lines()
self._sort(margin, s_y, e_y, reverse=reverse)
def _is_commented(self, lineno: int, prefix: str) -> bool:
return self.buf[lineno].lstrip().startswith(prefix)
def _indent(self, lineno: int) -> str:
ws_match = WS_RE.match(self.buf[lineno])
assert ws_match is not None
return ws_match[0]
def _minimum_indent_for_selection(self) -> int:
s_y, e_y = self._selection_lines()
return min(len(self._indent(lineno)) for lineno in range(s_y, e_y))
def _comment_remove(self, lineno: int, prefix: str) -> None:
line = self.buf[lineno]
indent = self._indent(lineno)
ws_len = len(indent)
if line.startswith(f'{prefix} ', ws_len):
self.buf[lineno] = f'{indent}{line[ws_len + len(prefix) + 1:]}'
elif line.startswith(prefix, ws_len):
self.buf[lineno] = f'{indent}{line[ws_len + len(prefix):]}'
if self.buf.y == lineno and self.buf.x > ws_len:
self.buf.x -= len(line) - len(self.buf[lineno])
def _comment_add(self, lineno: int, prefix: str, s_offset: int) -> None:
line = self.buf[lineno]
if not line:
self.buf[lineno] = f'{prefix}'
else:
self.buf[lineno] = f'{line[:s_offset]}{prefix} {line[s_offset:]}'
if lineno == self.buf.y and self.buf.x > s_offset:
self.buf.x += len(self.buf[lineno]) - len(line)
@edit_action('comment', final=True)
def toggle_comment(self, prefix: str) -> None:
if self._is_commented(self.buf.y, prefix):
self._comment_remove(self.buf.y, prefix)
else:
ws_len = len(self._indent(self.buf.y))
self._comment_add(self.buf.y, prefix, ws_len)
@edit_action('comment selection', final=True)
@clear_selection
def toggle_comment_selection(self, prefix: str) -> None:
s_y, e_y = self._selection_lines()
commented = self._is_commented(s_y, prefix)
minimum_indent = self._minimum_indent_for_selection()
for lineno in range(s_y, e_y):
if commented:
self._comment_remove(lineno, prefix)
else:
self._comment_add(lineno, prefix, minimum_indent)
DISPATCH = {
# movement
@@ -768,12 +850,12 @@ class File:
def move_cursor(
self,
stdscr: 'curses._CursesWindow',
stdscr: curses._CursesWindow,
margin: Margin,
) -> None:
stdscr.move(*self.buf.cursor_position(margin))
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
def draw(self, stdscr: curses._CursesWindow, margin: Margin) -> None:
to_display = min(self.buf.displayable_count, margin.body_lines)
for file_hl in self._file_hls:

View File

@@ -1,13 +1,11 @@
from __future__ import annotations
import functools
import json
import os.path
from typing import Any
from typing import Dict
from typing import FrozenSet
from typing import List
from typing import Match
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from typing import TypeVar
@@ -34,7 +32,7 @@ def uniquely_constructed(t: T) -> T:
return t
def _split_name(s: Optional[str]) -> Tuple[str, ...]:
def _split_name(s: str | None) -> tuple[str, ...]:
if s is None:
return ()
else:
@@ -44,17 +42,17 @@ def _split_name(s: Optional[str]) -> Tuple[str, ...]:
class _Rule(Protocol):
"""hax for recursive types python/mypy#731"""
@property
def name(self) -> Tuple[str, ...]: ...
def name(self) -> tuple[str, ...]: ...
@property
def match(self) -> Optional[str]: ...
def match(self) -> str | None: ...
@property
def begin(self) -> Optional[str]: ...
def begin(self) -> str | None: ...
@property
def end(self) -> Optional[str]: ...
def end(self) -> str | None: ...
@property
def while_(self) -> Optional[str]: ...
def while_(self) -> str | None: ...
@property
def content_name(self) -> Tuple[str, ...]: ...
def content_name(self) -> tuple[str, ...]: ...
@property
def captures(self) -> Captures: ...
@property
@@ -64,39 +62,39 @@ class _Rule(Protocol):
@property
def while_captures(self) -> Captures: ...
@property
def include(self) -> Optional[str]: ...
def include(self) -> str | None: ...
@property
def patterns(self) -> 'Tuple[_Rule, ...]': ...
def patterns(self) -> tuple[_Rule, ...]: ...
@property
def repository(self) -> 'FChainMap[str, _Rule]': ...
def repository(self) -> FChainMap[str, _Rule]: ...
@uniquely_constructed
class Rule(NamedTuple):
name: Tuple[str, ...]
match: Optional[str]
begin: Optional[str]
end: Optional[str]
while_: Optional[str]
content_name: Tuple[str, ...]
name: tuple[str, ...]
match: str | None
begin: str | None
end: str | None
while_: str | None
content_name: tuple[str, ...]
captures: Captures
begin_captures: Captures
end_captures: Captures
while_captures: Captures
include: Optional[str]
patterns: Tuple[_Rule, ...]
include: str | None
patterns: tuple[_Rule, ...]
repository: FChainMap[str, _Rule]
@classmethod
def make(
cls,
dct: Dict[str, Any],
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_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)
@@ -183,15 +181,15 @@ class Rule(NamedTuple):
class Grammar(NamedTuple):
scope_name: str
repository: FChainMap[str, _Rule]
patterns: Tuple[_Rule, ...]
patterns: tuple[_Rule, ...]
@classmethod
def make(cls, data: Dict[str, Any]) -> 'Grammar':
def make(cls, data: dict[str, Any]) -> Grammar:
scope_name = data['scopeName']
if 'repository' in data:
# this looks odd, but it's so we can have a self-referential
# immutable-after-construction chain map
repository_dct: Dict[str, _Rule] = {}
repository_dct: dict[str, _Rule] = {}
repository = FChainMap(repository_dct)
for k, dct in data['repository'].items():
repository_dct[k] = Rule.make(dct, repository)
@@ -212,54 +210,54 @@ class Region(NamedTuple):
class State(NamedTuple):
entries: Tuple['Entry', ...]
while_stack: Tuple[Tuple['WhileRule', int], ...]
entries: tuple[Entry, ...]
while_stack: tuple[tuple[WhileRule, int], ...]
@classmethod
def root(cls, entry: 'Entry') -> 'State':
def root(cls, entry: Entry) -> State:
return cls((entry,), ())
@property
def cur(self) -> 'Entry':
def cur(self) -> Entry:
return self.entries[-1]
def push(self, entry: 'Entry') -> 'State':
def push(self, entry: Entry) -> State:
return self._replace(entries=(*self.entries, entry))
def pop(self) -> 'State':
def pop(self) -> State:
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)
while_stack = (*self.while_stack, (rule, len(entries)))
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]
return self._replace(entries=entries, while_stack=while_stack)
class CompiledRule(Protocol):
@property
def name(self) -> Tuple[str, ...]: ...
def name(self) -> tuple[str, ...]: ...
def start(
self,
compiler: 'Compiler',
compiler: Compiler,
match: Match[str],
state: State,
) -> Tuple[State, bool, Regions]:
) -> tuple[State, bool, Regions]:
...
def search(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
pos: int,
first_line: bool,
boundary: bool,
) -> Optional[Tuple[State, int, bool, Regions]]:
) -> tuple[State, int, bool, Regions] | None:
...
@@ -267,24 +265,25 @@ class CompiledRegsetRule(CompiledRule, Protocol):
@property
def regset(self) -> _RegSet: ...
@property
def u_rules(self) -> Tuple[_Rule, ...]: ...
def u_rules(self) -> tuple[_Rule, ...]: ...
class Entry(NamedTuple):
scope: Tuple[str, ...]
scope: tuple[str, ...]
rule: CompiledRule
start: tuple[str, int]
reg: _Reg = ERR_REG
boundary: bool = False
def _inner_capture_parse(
compiler: 'Compiler',
compiler: Compiler,
start: int,
s: str,
scope: Scope,
rule: CompiledRule,
) -> 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)
return tuple(
r._replace(start=r.start + start, end=r.end + start) for r in regions
@@ -292,12 +291,12 @@ def _inner_capture_parse(
def _captures(
compiler: 'Compiler',
compiler: Compiler,
scope: Scope,
match: Match[str],
captures: Captures,
) -> Regions:
ret: List[Region] = []
ret: list[Region] = []
pos, pos_end = match.span()
for i, u_rule in captures:
try:
@@ -346,12 +345,12 @@ def _captures(
def _do_regset(
idx: int,
match: Optional[Match[str]],
match: Match[str] | None,
rule: CompiledRegsetRule,
compiler: 'Compiler',
compiler: Compiler,
state: State,
pos: int,
) -> Optional[Tuple[State, int, bool, Regions]]:
) -> tuple[State, int, bool, Regions] | None:
if match is None:
return None
@@ -368,104 +367,114 @@ def _do_regset(
@uniquely_constructed
class PatternRule(NamedTuple):
name: Tuple[str, ...]
name: tuple[str, ...]
regset: _RegSet
u_rules: Tuple[_Rule, ...]
u_rules: tuple[_Rule, ...]
def start(
self,
compiler: 'Compiler',
compiler: Compiler,
match: Match[str],
state: State,
) -> Tuple[State, bool, Regions]:
) -> tuple[State, bool, Regions]:
raise AssertionError(f'unreachable {self}')
def search(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
pos: int,
first_line: 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)
return _do_regset(idx, match, self, compiler, state, pos)
@uniquely_constructed
class MatchRule(NamedTuple):
name: Tuple[str, ...]
name: tuple[str, ...]
captures: Captures
def start(
self,
compiler: 'Compiler',
compiler: Compiler,
match: Match[str],
state: State,
) -> Tuple[State, bool, Regions]:
) -> tuple[State, bool, Regions]:
scope = state.cur.scope + self.name
return state, False, _captures(compiler, scope, match, self.captures)
def search(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
pos: int,
first_line: bool,
boundary: bool,
) -> Optional[Tuple[State, int, bool, Regions]]:
) -> tuple[State, int, bool, Regions] | None:
raise AssertionError(f'unreachable {self}')
@uniquely_constructed
class EndRule(NamedTuple):
name: Tuple[str, ...]
content_name: Tuple[str, ...]
name: tuple[str, ...]
content_name: tuple[str, ...]
begin_captures: Captures
end_captures: Captures
end: str
regset: _RegSet
u_rules: Tuple[_Rule, ...]
u_rules: tuple[_Rule, ...]
def start(
self,
compiler: 'Compiler',
compiler: Compiler,
match: Match[str],
state: State,
) -> Tuple[State, bool, Regions]:
) -> tuple[State, bool, Regions]:
scope = state.cur.scope + self.name
next_scope = scope + self.content_name
boundary = match.end() == len(match.string)
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)
return state, True, regions
def _end_ret(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
pos: int,
m: Match[str],
) -> Tuple[State, int, bool, Regions]:
) -> tuple[State, int, bool, Regions]:
ret = []
if m.start() > pos:
ret.append(Region(pos, m.start(), state.cur.scope))
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(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
pos: int,
first_line: 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)
if end_match is not None and end_match.start() == pos:
return self._end_ret(compiler, state, pos, end_match)
@@ -482,38 +491,40 @@ class EndRule(NamedTuple):
@uniquely_constructed
class WhileRule(NamedTuple):
name: Tuple[str, ...]
content_name: Tuple[str, ...]
name: tuple[str, ...]
content_name: tuple[str, ...]
begin_captures: Captures
while_captures: Captures
while_: str
regset: _RegSet
u_rules: Tuple[_Rule, ...]
u_rules: tuple[_Rule, ...]
def start(
self,
compiler: 'Compiler',
compiler: Compiler,
match: Match[str],
state: State,
) -> Tuple[State, bool, Regions]:
) -> tuple[State, bool, Regions]:
scope = state.cur.scope + self.name
next_scope = scope + self.content_name
boundary = match.end() == len(match.string)
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)
return state, True, regions
def continues(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
pos: int,
first_line: bool,
boundary: bool,
) -> Optional[Tuple[int, bool, Regions]]:
) -> tuple[int, bool, Regions] | None:
match = state.cur.reg.match(line, pos, first_line, boundary)
if match is None:
return None
@@ -523,25 +534,25 @@ class WhileRule(NamedTuple):
def search(
self,
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
pos: int,
first_line: 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)
return _do_regset(idx, match, self, compiler, state, pos)
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._grammars = grammars
self._rule_to_grammar: Dict[_Rule, Grammar] = {}
self._c_rules: Dict[_Rule, CompiledRule] = {}
self._rule_to_grammar: dict[_Rule, Grammar] = {}
self._c_rules: dict[_Rule, CompiledRule] = {}
root = self._compile_root(grammar)
self.root_state = State.root(Entry(root.name, root))
self.root_state = State.root(Entry(root.name, root, ('', 0)))
def _visit_rule(self, grammar: Grammar, rule: _Rule) -> _Rule:
self._rule_to_grammar[rule] = grammar
@@ -553,7 +564,7 @@ class Compiler:
grammar: Grammar,
repository: FChainMap[str, _Rule],
s: str,
) -> Tuple[List[str], Tuple[_Rule, ...]]:
) -> tuple[list[str], tuple[_Rule, ...]]:
if s == '$self':
return self._patterns(grammar, grammar.patterns)
elif s == '$base':
@@ -573,10 +584,10 @@ class Compiler:
def _patterns(
self,
grammar: Grammar,
rules: Tuple[_Rule, ...],
) -> Tuple[List[str], Tuple[_Rule, ...]]:
rules: tuple[_Rule, ...],
) -> tuple[list[str], tuple[_Rule, ...]]:
ret_regs = []
ret_rules: List[_Rule] = []
ret_rules: list[_Rule] = []
for rule in rules:
if rule.include is not None:
tmp_regs, tmp_rules = self._include(
@@ -657,25 +668,25 @@ class Grammars:
os.path.splitext(filename)[0]: os.path.join(directory, filename)
for directory in directories
if os.path.exists(directory)
for filename in os.listdir(directory)
for filename in sorted(os.listdir(directory))
if filename.endswith('.json')
}
unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []}
self._raw = {'source.unknown': unknown_grammar}
self._file_types: List[Tuple[FrozenSet[str], str]] = []
self._first_line: List[Tuple[_Reg, str]] = []
self._parsed: Dict[str, Grammar] = {}
self._compiled: Dict[str, Compiler] = {}
self._file_types: list[tuple[frozenset[str], str]] = []
self._first_line: list[tuple[_Reg, str]] = []
self._parsed: dict[str, Grammar] = {}
self._compiled: dict[str, Compiler] = {}
def _raw_for_scope(self, scope: str) -> Dict[str, Any]:
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) as f:
with open(grammar_path, encoding='UTF-8') as f:
ret = self._raw[scope] = json.load(f)
file_types = frozenset(ret.get('fileTypes', ()))
@@ -734,12 +745,12 @@ class Grammars:
def highlight_line(
compiler: 'Compiler',
compiler: Compiler,
state: State,
line: str,
first_line: bool,
) -> Tuple[State, Regions]:
ret: List[Region] = []
) -> tuple[State, Regions]:
ret: list[Region] = []
pos = 0
boundary = state.cur.boundary

View File

@@ -1,25 +1,26 @@
from __future__ import annotations
import collections
import contextlib
import os.path
from typing import Dict
from typing import Generator
from typing import List
from babi.user_data import xdg_data
class History:
def __init__(self) -> None:
self._orig_len: Dict[str, int] = collections.defaultdict(int)
self.data: Dict[str, List[str]] = collections.defaultdict(list)
self.prev: Dict[str, str] = {}
self._orig_len: dict[str, int] = collections.defaultdict(int)
self.data: dict[str, list[str]] = collections.defaultdict(list)
self.prev: dict[str, str] = {}
@contextlib.contextmanager
def save(self) -> Generator[None, None, None]:
history_dir = xdg_data('history')
os.makedirs(history_dir, exist_ok=True)
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._orig_len[filename] = len(self.data[filename])
try:
@@ -28,5 +29,6 @@ class History:
for k, v in self.data.items():
new_history = v[self._orig_len[k]:]
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')

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import NamedTuple
from typing import Tuple

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
import collections
import contextlib
import curses
from typing import Dict
from typing import Generator
from babi.buf import Buf
@@ -13,7 +14,7 @@ class Replace:
include_edge = True
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: Buf, idx: int) -> None:
"""our highlight regions are populated in other ways"""

View File

@@ -1,8 +1,7 @@
from __future__ import annotations
import collections
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
@@ -13,9 +12,9 @@ class Selection:
include_edge = True
def __init__(self) -> None:
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
self.start: Optional[Tuple[int, int]] = None
self.end: Optional[Tuple[int, int]] = None
self.regions: dict[int, HLs] = collections.defaultdict(tuple)
self.start: tuple[int, int] | None = None
self.end: tuple[int, int] | None = None
def register_callbacks(self, buf: Buf) -> None:
"""our highlight regions are populated in other ways"""
@@ -39,7 +38,7 @@ class Selection:
)
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
def get(self) -> tuple[tuple[int, int], tuple[int, int]]:
assert self.start is not None and self.end is not None
if self.start < self.end:
return self.start, self.end

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import curses
import functools
import math
from typing import Callable
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi.buf import Buf
from babi.color_manager import ColorManager
@@ -21,7 +20,7 @@ from babi.user_data import prefix_data
from babi.user_data import xdg_config
from babi.user_data import xdg_data
A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # new in py37
A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # not always present
class FileSyntax:
@@ -37,12 +36,12 @@ class FileSyntax:
self._theme = theme
self._color_manager = color_manager
self.regions: List[HLs] = []
self._states: List[State] = []
self.regions: list[HLs] = []
self._states: list[State] = []
# this will be assigned a functools.lru_cache per instance for
# better hit rate and memory usage
self._hl: Optional[Callable[[State, str, bool], Tuple[State, HLs]]]
self._hl: Callable[[State, str, bool], tuple[State, HLs]] | None
self._hl = None
def attr(self, style: Style) -> int:
@@ -59,7 +58,7 @@ class FileSyntax:
state: State,
line: str,
first_line: bool,
) -> Tuple[State, HLs]:
) -> tuple[State, HLs]:
new_state, regions = highlight_line(
self._compiler, state, f'{line}\n', first_line=first_line,
)
@@ -68,7 +67,7 @@ class FileSyntax:
new_end = regions[-1]._replace(end=regions[-1].end - 1)
regions = regions[:-1] + (new_end,)
regs: List[HL] = []
regs: list[HL] = []
for r in regions:
style = self._theme.select(r.scope)
if style == self._theme.default:
@@ -115,8 +114,7 @@ class FileSyntax:
state = self._states[-1]
for i in range(len(self._states), idx):
# https://github.com/python/mypy/issues/8579
state, regions = self._hl(state, lines[i], i == 0) # type: ignore
state, regions = self._hl(state, lines[i], i == 0)
self._states.append(state)
self.regions.append(regions)
@@ -134,7 +132,7 @@ class Syntax(NamedTuple):
compiler = self.grammars.blank_compiler()
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
all_colors = {c for c in (default_fg, default_bg) if c is not None}
todo = list(self.theme.rules.children.values())
@@ -155,9 +153,9 @@ class Syntax(NamedTuple):
@classmethod
def from_screen(
cls,
stdscr: 'curses._CursesWindow',
stdscr: curses._CursesWindow,
color_manager: ColorManager,
) -> 'Syntax':
) -> Syntax:
grammars = Grammars(prefix_data('grammar_v1'), xdg_data('grammar_v1'))
theme = Theme.from_filename(xdg_config('theme.json'))
ret = cls(grammars, theme, color_manager)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import curses
from typing import List
from babi.buf import Buf
from babi.color_manager import ColorManager
@@ -13,7 +14,7 @@ class TrailingWhitespace:
def __init__(self, color_manager: ColorManager) -> None:
self._color_manager = color_manager
self.regions: List[HLs] = []
self.regions: list[HLs] = []
def _trailing_ws(self, line: str) -> HLs:
if not line:

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import curses
from babi.cached_property import cached_property
@@ -34,7 +36,7 @@ def scrolled_line(s: str, x: int, width: int) -> str:
class _CalcWidth:
@cached_property
def _window(self) -> 'curses._CursesWindow':
def _window(self) -> curses._CursesWindow:
return curses.newwin(1, 10)
def wcwidth(self, c: str) -> int:

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
import argparse
import curses
import os
import re
import signal
import sys
from typing import Optional
from typing import Sequence
from babi.buf import Buf
@@ -14,10 +17,11 @@ from babi.screen import make_stdscr
from babi.screen import Screen
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
POSITION_RE = re.compile(r'^\+-?\d+$')
def _edit(screen: Screen, stdin: str) -> EditResult:
screen.file.ensure_loaded(screen.status, stdin)
screen.file.ensure_loaded(screen.status, screen.margin, stdin)
while True:
screen.status.tick(screen.margin)
@@ -39,36 +43,37 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
def c_main(
stdscr: 'curses._CursesWindow',
args: argparse.Namespace,
stdscr: curses._CursesWindow,
filenames: list[str | None],
positions: list[int],
stdin: str,
perf: Perf,
) -> int:
with perf_log(args.perf_log) as perf:
screen = Screen(stdscr, args.filenames or [None], perf)
with screen.history.save():
while screen.files:
screen.i = screen.i % len(screen.files)
res = _edit(screen, stdin)
if res == EditResult.EXIT:
del screen.files[screen.i]
# always go to the next file except at the end
screen.i = min(screen.i, len(screen.files) - 1)
screen.status.clear()
elif res == EditResult.NEXT:
screen.i += 1
screen.status.clear()
elif res == EditResult.PREV:
screen.i -= 1
screen.status.clear()
elif res == EditResult.OPEN:
screen.i = len(screen.files) - 1
else:
raise AssertionError(f'unreachable {res}')
screen = Screen(stdscr, filenames, positions, perf)
with screen.history.save():
while screen.files:
screen.i = screen.i % len(screen.files)
res = _edit(screen, stdin)
if res == EditResult.EXIT:
del screen.files[screen.i]
# always go to the next file except at the end
screen.i = min(screen.i, len(screen.files) - 1)
screen.status.clear()
elif res == EditResult.NEXT:
screen.i += 1
screen.status.clear()
elif res == EditResult.PREV:
screen.i -= 1
screen.status.clear()
elif res == EditResult.OPEN:
screen.i = len(screen.files) - 1
else:
raise AssertionError(f'unreachable {res}')
return 0
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
screen = Screen(stdscr, ['<<key debug>>'], Perf())
def _key_debug(stdscr: curses._CursesWindow, perf: Perf) -> int:
screen = Screen(stdscr, ['<<key debug>>'], [0], perf)
screen.file.buf = Buf([''])
while True:
@@ -85,7 +90,38 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
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.add_argument('filenames', metavar='filename', nargs='*')
parser.add_argument('--perf-log')
@@ -96,18 +132,24 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
if '-' in args.filenames:
print('reading stdin...', file=sys.stderr)
stdin = sys.stdin.read()
stdin = sys.stdin.buffer.read().decode()
tty = os.open(CONSOLE, os.O_RDONLY)
os.dup2(tty, sys.stdin.fileno())
else:
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:
return _key_debug(stdscr)
return _key_debug(stdscr, perf)
else:
return c_main(stdscr, args, stdin)
filenames, positions = _filenames(args.filenames)
return c_main(stdscr, filenames, positions, stdin, perf)
if __name__ == '__main__':
exit(main())
raise SystemExit(main())

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import curses
from typing import NamedTuple
@@ -31,5 +33,5 @@ class Margin(NamedTuple):
return int(self.lines / 2 + .5)
@classmethod
def from_current_screen(cls) -> 'Margin':
def from_current_screen(cls) -> Margin:
return cls(curses.LINES, curses.COLS)

View File

@@ -1,18 +1,17 @@
from __future__ import annotations
import contextlib
import cProfile
import time
from typing import Generator
from typing import List
from typing import Optional
from typing import Tuple
class Perf:
def __init__(self) -> None:
self._prof: Optional[cProfile.Profile] = None
self._records: List[Tuple[str, float]] = []
self._name: Optional[str] = None
self._time: Optional[float] = None
self._prof: cProfile.Profile | None = None
self._records: list[tuple[str, float]] = []
self._name: str | None = None
self._time: float | None = None
def start(self, name: str) -> None:
if self._prof:
@@ -36,14 +35,14 @@ class Perf:
def save_profiles(self, filename: str) -> None:
assert self._prof is not None
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')
for name, duration in self._records:
f.write(f'{int(duration * 1000 * 1000)}\t{name}\n')
@contextlib.contextmanager
def perf_log(filename: Optional[str]) -> Generator[Perf, None, None]:
def perf_log(filename: str | None) -> Generator[Perf, None, None]:
perf = Perf()
if filename is None:
yield perf

View File

@@ -1,10 +1,8 @@
from __future__ import annotations
import curses
import enum
from typing import List
from typing import Optional
from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
from babi.horizontal_scrolling import line_x
from babi.horizontal_scrolling import scrolled_line
@@ -16,7 +14,7 @@ PromptResult = enum.Enum('PromptResult', 'CANCELLED')
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._prompt = prompt
self._lst = lst
@@ -31,7 +29,7 @@ class Prompt:
def _s(self, s: str) -> None:
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
if not base or self._screen.margin.cols < 7:
prompt_s = ''
@@ -100,7 +98,7 @@ class Prompt:
def _resize(self) -> None:
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
for search_idx in range(idx, -1, -1):
if s in self._lst[search_idx]:
@@ -111,7 +109,7 @@ class Prompt:
failed = True
return failed, idx
def _reverse_search(self) -> Union[None, str, PromptResult]:
def _reverse_search(self) -> None | str | PromptResult:
reverse_s = ''
idx = self._y
while True:
@@ -177,7 +175,7 @@ class Prompt:
self._s = self._s[:self._x] + c + self._s[self._x:]
self._x += len(c)
def run(self) -> Union[PromptResult, str]:
def run(self) -> PromptResult | str:
while True:
self._render_prompt()

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import contextlib
import curses
import enum
@@ -5,14 +7,11 @@ import hashlib
import os
import re
import signal
import sre_parse
import sys
from typing import Generator
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import Pattern
from typing import Tuple
from typing import Union
from babi.color_manager import ColorManager
from babi.file import Action
@@ -38,6 +37,8 @@ EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV OPEN')
SEQUENCE_KEYNAME = {
'\x1bOH': b'KEY_HOME',
'\x1bOF': b'KEY_END',
'\x1b[1~': b'KEY_HOME',
'\x1b[4~': b'KEY_END',
'\x1b[1;2A': b'KEY_SR',
'\x1b[1;2B': b'KEY_SF',
'\x1b[1;2C': b'KEY_SRIGHT',
@@ -60,6 +61,7 @@ SEQUENCE_KEYNAME = {
'\x1b[1;6D': b'kLFT6', # Shift + ^Left
'\x1b[1;6H': b'kHOM6', # Shift + ^Home
'\x1b[1;6F': b'kEND6', # Shift + ^End
'\x1b[~': b'KEY_BTAB', # Shift + Tab
}
KEYNAME_REWRITE = {
# windows-curses: numeric pad arrow keys
@@ -81,8 +83,11 @@ KEYNAME_REWRITE = {
b'CTL_DOWN': b'kDN5',
b'CTL_RIGHT': b'kRIT5',
b'CTL_LEFT': b'kLFT5',
b'CTL_HOME': b'kHOM5',
b'CTL_END': b'kEND5',
b'ALT_RIGHT': b'kRIT3',
b'ALT_LEFT': b'kLFT3',
b'ALT_E': b'M-e',
# windows-curses: idk why these are different
b'KEY_SUP': b'KEY_SR',
b'KEY_SDOWN': b'KEY_SF',
@@ -90,36 +95,39 @@ KEYNAME_REWRITE = {
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):
wch: Union[int, str]
wch: int | str
keyname: bytes
class Screen:
def __init__(
self,
stdscr: 'curses._CursesWindow',
filenames: List[Optional[str]],
stdscr: curses._CursesWindow,
filenames: list[str | None],
initial_lines: list[int],
perf: Perf,
) -> None:
self.stdscr = stdscr
self.color_manager = ColorManager.make()
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
self.files = [
File(filename, self.color_manager, self.hl_factories)
for filename in filenames
File(filename, line, self.color_manager, self.hl_factories)
for filename, line in zip(filenames, initial_lines)
]
self.i = 0
self.history = History()
self.perf = perf
self.status = Status()
self.margin = Margin.from_current_screen()
self.cut_buffer: Tuple[str, ...] = ()
self.cut_buffer: tuple[str, ...] = ()
self.cut_selection = False
self._buffered_input: Union[int, str, None] = None
self._buffered_input: int | str | None = None
@property
def file(self) -> File:
@@ -221,7 +229,10 @@ class Screen:
if self._buffered_input is not None:
wch, self._buffered_input = self._buffered_input, None
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':
wch = self._get_sequence(wch)
if len(wch) == 2:
@@ -259,9 +270,9 @@ class Screen:
def quick_prompt(
self,
prompt: str,
opt_strs: Tuple[str, ...],
) -> Union[str, PromptResult]:
opts = [opt[0] for opt in opt_strs]
opt_strs: tuple[str, ...],
) -> str | PromptResult:
opts = {opt[0] for opt in opt_strs}
while True:
x = 0
prompt_line = self.margin.lines - 1
@@ -298,18 +309,18 @@ class Screen:
self.resize()
elif key.keyname == b'^C':
return self.status.cancelled()
elif isinstance(key.wch, str) and key.wch in opts:
return key.wch
elif isinstance(key.wch, str) and key.wch.lower() in opts:
return key.wch.lower()
def prompt(
self,
prompt: str,
*,
allow_empty: bool = False,
history: Optional[str] = None,
history: str | None = None,
default_prev: bool = False,
default: Optional[str] = None,
) -> Union[str, PromptResult]:
default: str | None = None,
) -> str | PromptResult:
default = default or ''
self.status.clear()
if history is not None:
@@ -366,7 +377,7 @@ class Screen:
else:
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)
if response is PromptResult.CANCELLED:
return response
@@ -379,8 +390,8 @@ class Screen:
def _undo_redo(
self,
op: str,
from_stack: List[Action],
to_stack: List[Action],
from_stack: list[Action],
to_stack: list[Action],
) -> None:
if not from_stack:
self.status.update(f'nothing to {op}!')
@@ -409,11 +420,18 @@ class Screen:
'replace with', history='replace', allow_empty=True,
)
if response is not PromptResult.CANCELLED:
self.file.replace(self, search_response, response)
try:
sre_parse.parse_template(response, search_response)
except re.error:
self.status.update('invalid replacement string')
else:
self.file.replace(self, search_response, response)
def command(self) -> Optional[EditResult]:
def command(self) -> EditResult | None:
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
@@ -428,11 +446,45 @@ class Screen:
else:
self.file.sort(self.margin)
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}')
return None
def save(self) -> Optional[PromptResult]:
def save(self) -> PromptResult | None:
self.file.finalize_previous_action()
# TODO: make directories if they don't exist
@@ -446,22 +498,28 @@ class Screen:
else:
self.file.filename = filename
if os.path.isfile(self.file.filename):
with open(self.file.filename, newline='') as f:
*_, sha256 = get_lines(f)
if not os.path.isfile(self.file.filename):
sha256: str | None = None
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.buf)
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
# 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)')
return PromptResult.CANCELLED
with open(self.file.filename, 'w', newline='') as f:
f.write(contents)
try:
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.sha256 = sha256_to_save
@@ -478,7 +536,7 @@ class Screen:
first = False
return None
def save_filename(self) -> Optional[PromptResult]:
def save_filename(self) -> PromptResult | None:
response = self.prompt('enter filename', default=self.file.filename)
if response is PromptResult.CANCELLED:
return PromptResult.CANCELLED
@@ -486,16 +544,16 @@ class Screen:
self.file.filename = response
return self.save()
def open_file(self) -> Optional[EditResult]:
def open_file(self) -> EditResult | None:
response = self.prompt('enter filename', history='open')
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)
return EditResult.OPEN
else:
return None
def quit_save_modified(self) -> Optional[EditResult]:
def quit_save_modified(self) -> EditResult | None:
if self.file.modified:
response = self.quick_prompt(
'file is modified - save', ('yes', 'no'),
@@ -513,10 +571,13 @@ class Screen:
return EditResult.EXIT
def background(self) -> None:
curses.endwin()
os.kill(os.getpid(), signal.SIGSTOP)
self.stdscr = _init_screen()
self.resize()
if sys.platform == 'win32': # pragma: win32 cover
self.status.update('cannot run babi in background on Windows')
else: # pragma: win32 no cover
curses.endwin()
os.kill(os.getpid(), signal.SIGSTOP)
self.stdscr = _init_screen()
self.resize()
DISPATCH = {
b'KEY_RESIZE': resize,
@@ -526,6 +587,7 @@ class Screen:
b'^U': uncut,
b'M-u': undo,
b'M-U': redo,
b'M-e': redo,
b'^W': search,
b'^\\': replace,
b'^[': command,
@@ -539,9 +601,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
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)
else: # pragma: no cover
os.environ.setdefault('ESCDELAY', '25')
@@ -562,7 +627,7 @@ def _init_screen() -> 'curses._CursesWindow':
@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"""
try:
yield _init_screen()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import curses
from babi.margin import Margin
@@ -16,7 +18,7 @@ class Status:
def clear(self) -> None:
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:
stdscr.insstr(margin.lines - 1, 0, ' ' * margin.cols)
if self._status:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import argparse
from typing import Optional
from typing import Sequence
from babi.highlight import Compiler
@@ -37,7 +38,7 @@ def _highlight_output(theme: Theme, compiler: Compiler, filename: str) -> int:
if theme.default.bg is not None:
print('\x1b[48;2;{r};{g};{b}m'.format(**theme.default.bg._asdict()))
with open(filename) as f:
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)
@@ -47,14 +48,14 @@ def _highlight_output(theme: Theme, compiler: Compiler, filename: str) -> int:
return 0
def main(argv: Optional[Sequence[str]] = None) -> int:
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) as f:
with open(args.filename, encoding='UTF-8') as f:
first_line = next(f, '')
theme = Theme.from_filename(args.theme)
@@ -66,4 +67,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
if __name__ == '__main__':
exit(main())
raise SystemExit(main())

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import functools
import json
import os.path
from typing import Any
from typing import Dict
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol
from babi.color import Color
@@ -13,32 +12,32 @@ from babi.fdict import FDict
class Style(NamedTuple):
fg: Optional[Color]
bg: Optional[Color]
fg: Color | None
bg: Color | None
b: bool
i: bool
u: bool
@classmethod
def blank(cls) -> 'Style':
def blank(cls) -> Style:
return cls(fg=None, bg=None, b=False, i=False, u=False)
class PartialStyle(NamedTuple):
fg: Optional[Color] = None
bg: Optional[Color] = None
b: Optional[bool] = None
i: Optional[bool] = None
u: Optional[bool] = None
fg: Color | None = None
bg: Color | None = None
b: bool | None = None
i: bool | None = 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:
value = getattr(self, attr)
if value is not None:
dct[attr] = value
@classmethod
def from_dct(cls, dct: Dict[str, Any]) -> 'PartialStyle':
def from_dct(cls, dct: dict[str, Any]) -> PartialStyle:
kv = cls()._asdict()
if 'foreground' in dct:
kv['fg'] = Color.parse(dct['foreground'])
@@ -57,7 +56,7 @@ class _TrieNode(Protocol):
@property
def style(self) -> PartialStyle: ...
@property
def children(self) -> FDict[str, '_TrieNode']: ...
def children(self) -> FDict[str, _TrieNode]: ...
class TrieNode(NamedTuple):
@@ -65,7 +64,7 @@ class TrieNode(NamedTuple):
children: FDict[str, _TrieNode]
@classmethod
def from_dct(cls, dct: Dict[str, Any]) -> _TrieNode:
def from_dct(cls, dct: dict[str, Any]) -> _TrieNode:
children = FDict({
k: TrieNode.from_dct(v) for k, v in dct['children'].items()
})
@@ -77,7 +76,7 @@ class Theme(NamedTuple):
rules: _TrieNode
@functools.lru_cache(maxsize=None)
def select(self, scope: Tuple[str, ...]) -> Style:
def select(self, scope: tuple[str, ...]) -> Style:
if not scope:
return self.default
else:
@@ -92,7 +91,7 @@ class Theme(NamedTuple):
return Style(**style)
@classmethod
def from_dct(cls, data: Dict[str, Any]) -> 'Theme':
def from_dct(cls, data: dict[str, Any]) -> Theme:
default = Style.blank()._asdict()
for k in ('foreground', 'editor.foreground'):
@@ -105,7 +104,7 @@ class Theme(NamedTuple):
default['bg'] = Color.parse(data['colors'][k])
break
root: Dict[str, Any] = {'children': {}}
root: dict[str, Any] = {'children': {}}
rules = data.get('tokenColors', []) + data.get('settings', [])
for rule in rules:
if 'scope' not in rule:
@@ -139,13 +138,13 @@ class Theme(NamedTuple):
return cls(Style(**default), TrieNode.from_dct(root))
@classmethod
def blank(cls) -> 'Theme':
def blank(cls) -> Theme:
return cls(Style.blank(), TrieNode.from_dct({'children': {}}))
@classmethod
def from_filename(cls, filename: str) -> 'Theme':
def from_filename(cls, filename: str) -> Theme:
if not os.path.exists(filename):
return cls.blank()
else:
with open(filename) as f:
with open(filename, encoding='UTF-8') as f:
return cls.from_dct(json.load(f))

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import os.path
import sys

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import io
import json
@@ -39,7 +41,6 @@ def json_with_comments(s: bytes) -> Any:
idx = match.end()
match = TOKEN.search(s, idx)
print(bio.getvalue())
bio.seek(0)
return json.load(bio)
@@ -86,4 +87,4 @@ def main() -> int:
if __name__ == '__main__':
exit(main())
raise SystemExit(main())

View File

@@ -1,6 +1,6 @@
[metadata]
name = babi
version = 0.0.6
version = 0.0.24
description = a text editor
long_description = file: README.md
long_description_content_type = text/markdown
@@ -13,9 +13,10 @@ classifiers =
License :: OSI Approved :: MIT License
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: Implementation :: PyPy
@@ -24,21 +25,21 @@ packages = find:
install_requires =
babi-grammars
identify
onigurumacffi>=0.0.10
importlib_metadata>=1;python_version<"3.8"
onigurumacffi>=0.0.18
importlib-metadata>=1;python_version<"3.8"
windows-curses;sys_platform=="win32"
python_requires = >=3.6.1
[options.entry_points]
console_scripts =
babi = babi.main:main
babi-textmate-demo = babi.textmate_demo:main
python_requires = >=3.7
[options.packages.find]
exclude =
tests*
testing*
[options.entry_points]
console_scripts =
babi = babi.main:main
babi-textmate-demo = babi.textmate_demo:main
[bdist_wheel]
universal = True
@@ -52,6 +53,8 @@ disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
[mypy-testing.*]
disallow_untyped_defs = false

View File

@@ -1,2 +1,4 @@
from __future__ import annotations
from setuptools import setup
setup()

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
import contextlib
import curses
import enum
import re
from typing import List
from typing import Tuple
from hecate import Runner
@@ -34,7 +34,7 @@ def to_attrs(screen, width):
fg = bg = -1
attr = 0
idx = 0
ret: List[List[Tuple[int, int, int]]]
ret: list[list[tuple[int, int, int]]]
ret = [[] for _ in range(len(screen.splitlines()))]
for tp, match in tokenize_colors(screen):

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from babi.buf import Buf

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from babi import color_kd
from babi.color import Color

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from babi.color import Color

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from babi.color import Color

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import json
import pytest

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from babi.fdict import FChainMap

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit

View 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')

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import contextlib
import curses
import os
import sys
from typing import List
from typing import NamedTuple
from typing import Tuple
from typing import Union
from unittest import mock
import pytest
@@ -148,7 +147,7 @@ class AssertScreenLineEquals(NamedTuple):
class AssertScreenAttrEquals(NamedTuple):
n: int
attr: List[Tuple[int, int, int]]
attr: list[tuple[int, int, int]]
def __call__(self, screen: Screen) -> None:
assert screen.attrs[self.n] == self.attr
@@ -170,7 +169,7 @@ class Resize(NamedTuple):
class KeyPress(NamedTuple):
wch: Union[int, str]
wch: int | str
def __call__(self, screen: Screen) -> None:
raise AssertionError('unreachable')
@@ -236,7 +235,7 @@ class CursesScreen:
class Key(NamedTuple):
tmux: str
curses: bytes
wch: Union[int, str]
wch: int | str
@property
def value(self) -> int:
@@ -290,6 +289,7 @@ KEYS = [
Key('^_', b'^_', '\x1f'),
Key('^\\', b'^\\', '\x1c'),
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
Key('^D', b'^D', '\x04'),
]
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
KEYS_CURSES = {k.value: k.curses for k in KEYS}
@@ -299,10 +299,11 @@ class DeferredRunner:
def __init__(self, command, width=80, height=24, term='screen'):
self.command = command
self._i = 0
self._ops: List[Op] = []
self._ops: list[Op] = []
self.color_pairs = {0: (7, 0)}
self.screen = Screen(width, height)
self._n_colors, self._can_change_color = {
'xterm-mono': (0, False),
'screen': (8, False),
'screen-256color': (256, False),
'xterm-256color': (256, True),
@@ -391,6 +392,7 @@ class DeferredRunner:
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
_curses_set_escdelay = _curses__noop
_curses_error = curses.error # so we don't mock the exception

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from testing.runner import and_exit

View 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'

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit

View File

@@ -1,4 +1,7 @@
from __future__ import annotations
from testing.runner import and_exit
from testing.runner import trigger_command_mode
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):
with run() as h, and_exit(h):
h.press('h')
h.press('hello')
h.press('Home')
h.press('Right')
h.press('Tab')
h.press('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):
@@ -86,6 +90,20 @@ def test_dedent_selection(run, tmpdir):
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):
f = tmpdir.join('f')
f.write(' hi\n')

View 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)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import curses
from babi.screen import VERSION_STR

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit
@@ -20,6 +22,16 @@ def test_replace_invalid_regex(run):
h.await_text("invalid regex: '('")
def test_replace_invalid_replacement(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_0')
h.await_text('replace with:')
h.press_and_enter('\\')
h.await_text('invalid replacement string')
def test_replace_cancel_at_replace_string(run):
with run() as h, and_exit(h):
h.press('^\\')
@@ -30,7 +42,8 @@ def test_replace_cancel_at_replace_string(run):
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):
h.press('^\\')
h.await_text('search (to replace):')
@@ -38,7 +51,7 @@ def test_replace_actual_contents(run, ten_lines):
h.await_text('replace with:')
h.press_and_enter('ohai')
h.await_text('replace [yes, no, all]?')
h.press('y')
h.press(key)
h.await_text_missing('line_0')
h.await_text('ohai')
h.await_text(' *')

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from babi.screen import VERSION_STR
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit
@@ -128,12 +130,24 @@ def test_save_file_when_it_did_not_exist(run, tmpdir):
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):
f = tmpdir.join('f')
with run(str(f)) as h, and_exit(h):
h.press('hello world')
h.press('^O')
h.await_text(f'enter filename: ')
h.await_text('enter filename: ')
h.press('Enter')
h.await_text('saved! (1 line written)')
assert f.read() == 'hello world\n'
@@ -150,6 +164,18 @@ def test_save_via_ctrl_o_set_filename(run, tmpdir):
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'))
def test_save_via_ctrl_o_cancelled(run, key):
with run() as h, and_exit(h):
@@ -237,7 +263,7 @@ def test_vim_save_on_exit(run, tmpdir):
h.press_and_enter(':q')
h.await_text('file is modified - save [yes, no]?')
h.press('y')
h.await_text(f'enter filename: ')
h.await_text('enter filename: ')
h.press('Enter')
h.await_exit()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
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'
def test_reverse_sort_entire_file(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
trigger_command_mode(h)
h.press_and_enter(':sort!')
h.await_text('sorted!')
h.await_cursor_position(x=0, y=1)
h.press('^S')
assert unsorted.read() == 'd\nc\nb\na\n'
def test_sort_selection(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
h.press('S-Down')
@@ -32,6 +44,18 @@ def test_sort_selection(run, unsorted):
assert unsorted.read() == 'b\nd\nc\na\n'
def test_reverse_sort_selection(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
h.press('Down')
h.press('S-Down')
trigger_command_mode(h)
h.press_and_enter(':sort!')
h.await_text('sorted!')
h.await_cursor_position(x=0, y=2)
h.press('^S')
assert unsorted.read() == 'd\nc\nb\na\n'
def test_sort_selection_does_not_include_eof(run, unsorted):
with run(str(unsorted)) as h, and_exit(h):
for _ in range(5):

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from testing.runner import and_exit

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import shlex
import sys

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import shlex
import sys

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import curses
import json
@@ -153,3 +155,8 @@ def test_syntax_highlighting_tabs_after_line_creation(run, tmpdir):
h.press('Enter')
h.await_text('foo\n x\nx\ny\n')
def test_does_not_crash_with_no_color_support(run):
with run(term='xterm-mono') as h, and_exit(h):
pass

View File

@@ -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)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
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('*')
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'))
def test_backspace_deletes_text(run, tmpdir, key):
f = tmpdir.join('f')
@@ -72,14 +86,15 @@ def test_delete_at_end_of_file(run, tmpdir):
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.write('hello world')
with run(str(f)) as h, and_exit(h):
h.await_text('hello world')
h.press('Right')
h.press('DC')
h.press(key)
h.await_text('hllo world')
h.await_text('f *')

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import curses
from testing.runner import and_exit

View File

@@ -1,3 +1,7 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit
@@ -9,7 +13,8 @@ def test_nothing_to_undo_redo(run):
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):
h.press('hello')
h.await_text('hello')
@@ -17,7 +22,7 @@ def test_undo_redo(run):
h.await_text('undo: text')
h.await_text_missing('hello')
h.await_text_missing(' *')
h.press('M-U')
h.press(r)
h.await_text('redo: text')
h.await_text('hello')
h.await_text(' *')

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import io
import pytest
@@ -8,7 +10,7 @@ from babi.file import get_lines
def test_position_repr():
ret = repr(File('f.txt', ColorManager.make(), ()))
ret = repr(File('f.txt', 0, ColorManager.make(), ()))
assert ret == "<File 'f.txt'>"

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import contextlib
import curses
from unittest import mock

21
tests/main_test.py Normal file
View 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)

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import json
import pytest

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from babi.color import Color

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import os
from unittest import mock

View File

@@ -1,5 +1,5 @@
[tox]
envlist = py36,py37,pre-commit
envlist = py37,pre-commit
[testenv]
deps = -rrequirements-dev.txt