side-vim 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- side_vim/__init__.py +7 -0
- side_vim/__main__.py +146 -0
- side_vim/_unicode_data.py +1469 -0
- side_vim/caret.py +131 -0
- side_vim/edits.py +84 -0
- side_vim/keys.py +134 -0
- side_vim/layouts.py +13 -0
- side_vim/mode.py +10 -0
- side_vim/motions.py +428 -0
- side_vim/notation.py +8 -0
- side_vim/parser.py +185 -0
- side_vim/scroll.py +98 -0
- side_vim/search.py +118 -0
- side_vim/text.py +146 -0
- side_vim/unicode.py +31 -0
- side_vim/vim.py +1398 -0
- side_vim-0.1.0.dist-info/METADATA +212 -0
- side_vim-0.1.0.dist-info/RECORD +20 -0
- side_vim-0.1.0.dist-info/WHEEL +4 -0
- side_vim-0.1.0.dist-info/licenses/LICENSE +21 -0
side_vim/motions.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Vim motions on plain text: where the cursor goes, and what an operator covers.
|
|
2
|
+
|
|
3
|
+
No Qt here. The text is a sequence of lines without their newlines, and each line is a
|
|
4
|
+
sequence of characters as the user sees them (grapheme clusters: a family emoji, a
|
|
5
|
+
flag or a letter plus an accent is one character). A plain str works as a line too.
|
|
6
|
+
Working on whole clusters means no command can ever cut one in half.
|
|
7
|
+
|
|
8
|
+
A position is (line, col), both 0-based, col counted in those characters. While a
|
|
9
|
+
motion runs, a position may sit on the end of a line (col == len(line)), the spot that
|
|
10
|
+
stands for the newline, just as vim's cursor may sit on the NUL at the end of a line.
|
|
11
|
+
|
|
12
|
+
The word motions follow vim's own step-by-step logic (fwd_word, end_word and bck_word
|
|
13
|
+
in vim's textobject.c), so that the odd cases, like `cw` and `dw` at the end of a line,
|
|
14
|
+
come out the same as in vim.
|
|
15
|
+
|
|
16
|
+
The column j and k aim for is a screen column, like vim's: a tab takes up to
|
|
17
|
+
`tabstop` cells, CJK characters and emoji take 2.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum
|
|
24
|
+
|
|
25
|
+
from side_vim.unicode import cell_width, char_class
|
|
26
|
+
|
|
27
|
+
# The column `$` wants: "stay at the end of the line" while moving with j and k.
|
|
28
|
+
MAXCOL = sys.maxsize
|
|
29
|
+
|
|
30
|
+
MOTIONS = frozenset({"h", "j", "k", "l", "w", "b", "e", "W", "B", "E", "0", "^", "$", "gg", "G"})
|
|
31
|
+
|
|
32
|
+
BLANKS = (" ", "\t")
|
|
33
|
+
|
|
34
|
+
# One line: a str, or a sequence of grapheme clusters.
|
|
35
|
+
type Line = Sequence[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Kind(Enum):
|
|
39
|
+
"""How an operator treats the text between the cursor and where the motion went."""
|
|
40
|
+
|
|
41
|
+
EXCLUSIVE = "exclusive" # up to, not including, the end
|
|
42
|
+
INCLUSIVE = "inclusive" # up to and including the end
|
|
43
|
+
LINEWISE = "linewise" # whole lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, order=True)
|
|
47
|
+
class Pos:
|
|
48
|
+
line: int
|
|
49
|
+
col: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Move:
|
|
54
|
+
"""Where a motion took the cursor."""
|
|
55
|
+
|
|
56
|
+
pos: Pos
|
|
57
|
+
kind: Kind
|
|
58
|
+
want: int # the screen column j and k aim for from now on
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Range:
|
|
63
|
+
"""The text an operator acts on.
|
|
64
|
+
|
|
65
|
+
Linewise: every line from start.line to end.line, both included; the columns don't
|
|
66
|
+
matter. Otherwise: from start up to, not including, end.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
start: Pos
|
|
70
|
+
end: Pos
|
|
71
|
+
linewise: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Cursor:
|
|
75
|
+
"""A position that steps through the text like vim's inc_cursor and dec_cursor."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, lines: Sequence[Line], pos: Pos, bigword: bool = False) -> None:
|
|
78
|
+
self.lines: Sequence[Line] = lines
|
|
79
|
+
self.line: int = pos.line
|
|
80
|
+
self.col: int = pos.col
|
|
81
|
+
self.bigword: bool = bigword
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def pos(self) -> Pos:
|
|
85
|
+
return Pos(self.line, self.col)
|
|
86
|
+
|
|
87
|
+
def char(self) -> str:
|
|
88
|
+
text = self.lines[self.line]
|
|
89
|
+
return text[self.col] if self.col < len(text) else ""
|
|
90
|
+
|
|
91
|
+
def cls(self) -> int:
|
|
92
|
+
return char_class(self.char(), self.bigword)
|
|
93
|
+
|
|
94
|
+
def line_empty(self) -> bool:
|
|
95
|
+
return not self.lines[self.line]
|
|
96
|
+
|
|
97
|
+
def inc(self) -> int:
|
|
98
|
+
"""Step forward. 0: moved in the line, 2: onto the line end, 1: to the next line,
|
|
99
|
+
-1: at the end of the text, didn't move."""
|
|
100
|
+
text = self.lines[self.line]
|
|
101
|
+
if self.col < len(text):
|
|
102
|
+
self.col += 1
|
|
103
|
+
return 0 if self.col < len(text) else 2
|
|
104
|
+
if self.line < len(self.lines) - 1:
|
|
105
|
+
self.line += 1
|
|
106
|
+
self.col = 0
|
|
107
|
+
return 1
|
|
108
|
+
return -1
|
|
109
|
+
|
|
110
|
+
def dec(self) -> int:
|
|
111
|
+
"""Step back. 0: moved in the line, 1: to the end of the line before,
|
|
112
|
+
-1: at the start of the text, didn't move."""
|
|
113
|
+
if self.col > 0:
|
|
114
|
+
self.col -= 1
|
|
115
|
+
return 0
|
|
116
|
+
if self.line > 0:
|
|
117
|
+
self.line -= 1
|
|
118
|
+
self.col = len(self.lines[self.line])
|
|
119
|
+
return 1
|
|
120
|
+
return -1
|
|
121
|
+
|
|
122
|
+
def skip_class(self, cls: int, forward: bool) -> bool:
|
|
123
|
+
"""Move while on characters of class cls. True if the text ran out."""
|
|
124
|
+
while self.cls() == cls:
|
|
125
|
+
if (self.inc() if forward else self.dec()) == -1:
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fwd_word(cur: _Cursor, count: int, eol: bool) -> bool:
|
|
131
|
+
"""vim's fwd_word. eol: an operator is pending, so stop at the end of the line on the
|
|
132
|
+
last word. Returns False when the cursor started on the last character of the text."""
|
|
133
|
+
while count > 0:
|
|
134
|
+
count -= 1
|
|
135
|
+
sclass = cur.cls()
|
|
136
|
+
last_line = cur.line == len(cur.lines) - 1
|
|
137
|
+
i = cur.inc()
|
|
138
|
+
if i == -1 or (i >= 1 and last_line):
|
|
139
|
+
return False
|
|
140
|
+
if i >= 1 and eol and count == 0:
|
|
141
|
+
return True
|
|
142
|
+
# one character past the end of the current word, if any
|
|
143
|
+
if sclass != 0:
|
|
144
|
+
while cur.cls() == sclass:
|
|
145
|
+
i = cur.inc()
|
|
146
|
+
if i == -1 or (i >= 1 and eol and count == 0):
|
|
147
|
+
return True
|
|
148
|
+
# then on to the next non-blank, stopping at an empty line
|
|
149
|
+
while cur.cls() == 0:
|
|
150
|
+
if cur.col == 0 and cur.line_empty():
|
|
151
|
+
break
|
|
152
|
+
i = cur.inc()
|
|
153
|
+
if i == -1 or (i >= 1 and eol and count == 0):
|
|
154
|
+
return True
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _end_word(cur: _Cursor, count: int, stop: bool) -> bool:
|
|
159
|
+
"""vim's end_word, with empty=False. stop: if already on the end of a word, stay
|
|
160
|
+
there for the first count (how `cw` works)."""
|
|
161
|
+
while count > 0:
|
|
162
|
+
count -= 1
|
|
163
|
+
sclass = cur.cls()
|
|
164
|
+
if cur.inc() == -1:
|
|
165
|
+
return False
|
|
166
|
+
if cur.cls() == sclass and sclass != 0:
|
|
167
|
+
# in the middle of a word: go to its end
|
|
168
|
+
if cur.skip_class(sclass, forward=True):
|
|
169
|
+
return False
|
|
170
|
+
elif not stop or sclass == 0:
|
|
171
|
+
# at the end of a word: skip blanks, then go to the end of the next word
|
|
172
|
+
while cur.cls() == 0:
|
|
173
|
+
if cur.inc() == -1:
|
|
174
|
+
return False
|
|
175
|
+
if cur.skip_class(cur.cls(), forward=True):
|
|
176
|
+
return False
|
|
177
|
+
_ = cur.dec() # one step too far
|
|
178
|
+
stop = False
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _bck_word(cur: _Cursor, count: int) -> bool:
|
|
183
|
+
"""vim's bck_word with stop=False. False when already at the start of the text."""
|
|
184
|
+
while count > 0:
|
|
185
|
+
count -= 1
|
|
186
|
+
if cur.dec() == -1:
|
|
187
|
+
return False
|
|
188
|
+
# skip blanks before the word, stopping at an empty line
|
|
189
|
+
empty_line = False
|
|
190
|
+
while cur.cls() == 0:
|
|
191
|
+
if cur.col == 0 and cur.line_empty():
|
|
192
|
+
empty_line = True
|
|
193
|
+
break
|
|
194
|
+
if cur.dec() == -1:
|
|
195
|
+
return True
|
|
196
|
+
if empty_line:
|
|
197
|
+
continue
|
|
198
|
+
# back to the start of this word
|
|
199
|
+
if cur.skip_class(cur.cls(), forward=False):
|
|
200
|
+
return True
|
|
201
|
+
_ = cur.inc() # one step too far
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def first_non_blank(text: Line) -> int:
|
|
206
|
+
"""The column of the first non-blank, or of the last character if the line is all
|
|
207
|
+
blanks (vim's beginline with BL_WHITE | BL_FIX)."""
|
|
208
|
+
col = 0
|
|
209
|
+
while col < len(text) - 1 and text[col] in BLANKS:
|
|
210
|
+
col += 1
|
|
211
|
+
return col
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _width(ch: str, vcol: int, tabstop: int) -> int:
|
|
215
|
+
"""Screen cells a character takes when it starts at screen column vcol."""
|
|
216
|
+
if ch == "\t":
|
|
217
|
+
return tabstop - vcol % tabstop
|
|
218
|
+
return cell_width(ch)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def screen_col(text: Line, col: int, tabstop: int = 8) -> int:
|
|
222
|
+
"""The screen column vim shows the cursor in: the first cell of a character, but the
|
|
223
|
+
last cell of a tab."""
|
|
224
|
+
vcol = 0
|
|
225
|
+
for ch in text[:col]:
|
|
226
|
+
vcol += _width(ch, vcol, tabstop)
|
|
227
|
+
if col < len(text) and text[col] == "\t":
|
|
228
|
+
vcol += _width("\t", vcol, tabstop) - 1
|
|
229
|
+
return vcol
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def column_for(text: Line, want: int, visual: bool = False, tabstop: int = 8) -> int:
|
|
233
|
+
"""The column j and k land on for a wanted screen column (vim's coladvance). In
|
|
234
|
+
Visual mode the cursor may sit on the line end."""
|
|
235
|
+
if want >= MAXCOL:
|
|
236
|
+
return max(len(text) - 1 + visual, 0)
|
|
237
|
+
vcol = col = 0
|
|
238
|
+
while vcol <= want and col < len(text):
|
|
239
|
+
vcol += _width(text[col], vcol, tabstop)
|
|
240
|
+
col += 1
|
|
241
|
+
if vcol > want or not visual:
|
|
242
|
+
col -= 1
|
|
243
|
+
return max(col, 0)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def move(
|
|
247
|
+
lines: Sequence[Line],
|
|
248
|
+
pos: Pos,
|
|
249
|
+
motion: str,
|
|
250
|
+
count: int | None = None,
|
|
251
|
+
*,
|
|
252
|
+
want: int | None = None,
|
|
253
|
+
op: str | None = None,
|
|
254
|
+
visual: bool = False,
|
|
255
|
+
tabstop: int = 8,
|
|
256
|
+
) -> Move | None:
|
|
257
|
+
"""Run a motion. None means vim would refuse it (beep), and a pending operator is
|
|
258
|
+
cancelled.
|
|
259
|
+
|
|
260
|
+
count: None when no count was typed (it matters for G and gg).
|
|
261
|
+
want: the screen column j and k aim for; defaults to the cursor's.
|
|
262
|
+
op: the pending operator ("d", "c" or "y"), or None.
|
|
263
|
+
visual: Visual mode, where the cursor may sit on the line end.
|
|
264
|
+
tabstop: how many screen columns a tab stop is apart.
|
|
265
|
+
"""
|
|
266
|
+
n = count or 1
|
|
267
|
+
text = lines[pos.line]
|
|
268
|
+
|
|
269
|
+
def moved(to: Pos, kind: Kind) -> Move:
|
|
270
|
+
return Move(to, kind, screen_col(lines[to.line], to.col, tabstop))
|
|
271
|
+
|
|
272
|
+
if motion == "h":
|
|
273
|
+
if pos.col == 0:
|
|
274
|
+
# vim beeps, but a pending operator still runs, on nothing (`ch` inserts)
|
|
275
|
+
return moved(pos, Kind.EXCLUSIVE) if op and not visual else None
|
|
276
|
+
return moved(Pos(pos.line, max(pos.col - n, 0)), Kind.EXCLUSIVE)
|
|
277
|
+
|
|
278
|
+
if motion == "l":
|
|
279
|
+
last = len(text) if visual else len(text) - 1
|
|
280
|
+
if pos.col >= last:
|
|
281
|
+
# at the end already: an operator still takes the last character, or
|
|
282
|
+
# runs on nothing on an empty line
|
|
283
|
+
if op and not visual:
|
|
284
|
+
return moved(pos, Kind.INCLUSIVE if text else Kind.EXCLUSIVE)
|
|
285
|
+
return None
|
|
286
|
+
# running out of line with an operator pending takes the last character too
|
|
287
|
+
kind = Kind.INCLUSIVE if op and not visual and pos.col + n > last else Kind.EXCLUSIVE
|
|
288
|
+
return moved(Pos(pos.line, min(pos.col + n, last)), kind)
|
|
289
|
+
|
|
290
|
+
if motion in ("j", "k"):
|
|
291
|
+
if motion == "j":
|
|
292
|
+
if pos.line >= len(lines) - 1:
|
|
293
|
+
return None
|
|
294
|
+
line = min(pos.line + n, len(lines) - 1)
|
|
295
|
+
else:
|
|
296
|
+
if pos.line == 0:
|
|
297
|
+
return None
|
|
298
|
+
line = max(pos.line - n, 0)
|
|
299
|
+
if want is None:
|
|
300
|
+
want = screen_col(text, pos.col, tabstop)
|
|
301
|
+
col = column_for(lines[line], want, visual, tabstop)
|
|
302
|
+
return Move(Pos(line, col), Kind.LINEWISE, want)
|
|
303
|
+
|
|
304
|
+
if motion == "0":
|
|
305
|
+
return moved(Pos(pos.line, 0), Kind.EXCLUSIVE)
|
|
306
|
+
|
|
307
|
+
if motion == "^":
|
|
308
|
+
return moved(Pos(pos.line, first_non_blank(text)), Kind.EXCLUSIVE)
|
|
309
|
+
|
|
310
|
+
if motion == "$":
|
|
311
|
+
if n > 1 and pos.line >= len(lines) - 1:
|
|
312
|
+
return None
|
|
313
|
+
line = min(pos.line + n - 1, len(lines) - 1)
|
|
314
|
+
col = column_for(lines[line], MAXCOL, visual, tabstop)
|
|
315
|
+
return Move(Pos(line, col), Kind.INCLUSIVE, MAXCOL)
|
|
316
|
+
|
|
317
|
+
if motion in ("gg", "G"):
|
|
318
|
+
if count is None:
|
|
319
|
+
line = 0 if motion == "gg" else len(lines) - 1
|
|
320
|
+
else:
|
|
321
|
+
line = min(max(count, 1), len(lines)) - 1
|
|
322
|
+
return moved(Pos(line, first_non_blank(lines[line])), Kind.LINEWISE)
|
|
323
|
+
|
|
324
|
+
if motion in ("w", "W", "e", "E"):
|
|
325
|
+
to, kind = _word_motion(lines, pos, motion, n, op, visual)
|
|
326
|
+
return moved(to, kind)
|
|
327
|
+
|
|
328
|
+
if motion in ("b", "B"):
|
|
329
|
+
cur = _Cursor(lines, pos, bigword=motion == "B")
|
|
330
|
+
if not _bck_word(cur, n):
|
|
331
|
+
return None
|
|
332
|
+
return moved(cur.pos, Kind.EXCLUSIVE)
|
|
333
|
+
|
|
334
|
+
raise ValueError(f"unknown motion: {motion!r}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _word_motion(
|
|
338
|
+
lines: Sequence[Line], pos: Pos, motion: str, n: int, op: str | None, visual: bool
|
|
339
|
+
) -> tuple[Pos, Kind]:
|
|
340
|
+
"""w W e E, like vim's nv_wordcmd."""
|
|
341
|
+
cur = _Cursor(lines, pos, bigword=motion in ("W", "E"))
|
|
342
|
+
word_end = motion in ("e", "E")
|
|
343
|
+
inclusive = word_end
|
|
344
|
+
stop = False
|
|
345
|
+
# "cw" on a word works like "ce": the blanks after the word stay
|
|
346
|
+
if not word_end and op == "c" and cur.char() not in ("", *BLANKS):
|
|
347
|
+
inclusive = word_end = stop = True
|
|
348
|
+
_ = _end_word(cur, n, stop) if word_end else _fwd_word(cur, n, eol=op is not None)
|
|
349
|
+
# Don't leave the cursor on the line end, unless it didn't move forward.
|
|
350
|
+
if pos < cur.pos and cur.col > 0 and cur.char() == "" and not visual:
|
|
351
|
+
cur.col -= 1
|
|
352
|
+
inclusive = True
|
|
353
|
+
return cur.pos, Kind.INCLUSIVE if inclusive else Kind.EXCLUSIVE
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _in_indent(lines: Sequence[Line], pos: Pos) -> bool:
|
|
357
|
+
"""True if only blanks come before pos in its line."""
|
|
358
|
+
return all(ch in BLANKS for ch in lines[pos.line][: pos.col])
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def operator_range(lines: Sequence[Line], start: Pos, moved: Move, op: str) -> Range:
|
|
362
|
+
"""What an operator covers, from the cursor at start to where the motion went.
|
|
363
|
+
|
|
364
|
+
Follows vim's rules: an exclusive motion that ends in column 0 of a later line
|
|
365
|
+
stops at the end of the line before, and turns linewise if it started in the
|
|
366
|
+
indent. A multi-line delete turns linewise if it started in the indent and only
|
|
367
|
+
blanks follow its end.
|
|
368
|
+
"""
|
|
369
|
+
a, b = sorted((start, moved.pos))
|
|
370
|
+
if moved.kind is Kind.LINEWISE:
|
|
371
|
+
return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
|
|
372
|
+
|
|
373
|
+
inclusive = moved.kind is Kind.INCLUSIVE
|
|
374
|
+
if not inclusive and b.col == 0 and b.line > a.line:
|
|
375
|
+
b = Pos(b.line - 1, len(lines[b.line - 1]))
|
|
376
|
+
if _in_indent(lines, a):
|
|
377
|
+
return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
|
|
378
|
+
if b.col:
|
|
379
|
+
b = Pos(b.line, b.col - 1)
|
|
380
|
+
inclusive = True
|
|
381
|
+
|
|
382
|
+
end_text = lines[b.line]
|
|
383
|
+
if op == "d" and b.line > a.line:
|
|
384
|
+
rest = end_text[b.col + (inclusive if b.col < len(end_text) else 0) :]
|
|
385
|
+
if all(ch in BLANKS for ch in rest) and _in_indent(lines, a):
|
|
386
|
+
return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
|
|
387
|
+
|
|
388
|
+
end = Pos(b.line, min(b.col + inclusive, len(end_text)))
|
|
389
|
+
return Range(a, end, linewise=False)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def line_range(lines: Sequence[Line], pos: Pos, count: int | None = None) -> Range | None:
|
|
393
|
+
"""The lines `dd`, `yy` and `cc` cover. None if the count asks for lines below the
|
|
394
|
+
last one and the cursor is already on it."""
|
|
395
|
+
n = count or 1
|
|
396
|
+
if n > 1 and pos.line >= len(lines) - 1:
|
|
397
|
+
return None
|
|
398
|
+
last = min(pos.line + n - 1, len(lines) - 1)
|
|
399
|
+
return Range(Pos(pos.line, 0), Pos(last, 0), linewise=True)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def visual_range(lines: Sequence[Line], anchor: Pos, cursor: Pos, linewise: bool) -> Range:
|
|
403
|
+
"""The text a Visual selection covers, both ends included (vim's 'selection' set to
|
|
404
|
+
inclusive). A selection that ends on the line end takes the line break, unless it's
|
|
405
|
+
the last line."""
|
|
406
|
+
a, b = sorted((anchor, cursor))
|
|
407
|
+
if linewise:
|
|
408
|
+
return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
|
|
409
|
+
text = lines[b.line]
|
|
410
|
+
if b.col < len(text):
|
|
411
|
+
end = Pos(b.line, b.col + 1)
|
|
412
|
+
elif b.line < len(lines) - 1:
|
|
413
|
+
end = Pos(b.line + 1, 0)
|
|
414
|
+
else:
|
|
415
|
+
end = Pos(b.line, len(text))
|
|
416
|
+
return Range(a, end, linewise=False)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def range_text(lines: Sequence[Line], r: Range) -> str:
|
|
420
|
+
"""The text a range covers. Linewise text ends with a newline, like vim's register."""
|
|
421
|
+
if r.linewise:
|
|
422
|
+
return "".join("".join(lines[i]) + "\n" for i in range(r.start.line, r.end.line + 1))
|
|
423
|
+
if r.start.line == r.end.line:
|
|
424
|
+
return "".join(lines[r.start.line][r.start.col : r.end.col])
|
|
425
|
+
parts = ["".join(lines[r.start.line][r.start.col :])]
|
|
426
|
+
parts.extend("".join(lines[i]) for i in range(r.start.line + 1, r.end.line))
|
|
427
|
+
parts.append("".join(lines[r.end.line][: r.end.col]))
|
|
428
|
+
return "\n".join(parts)
|
side_vim/notation.py
ADDED
side_vim/parser.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Turn vim key tokens into commands. No Qt here.
|
|
2
|
+
|
|
3
|
+
Keys come in one token at a time: a single character ("w", "3", "G"), or a name in
|
|
4
|
+
angle brackets ("<C-r>", "<Left>"). Esc never reaches the parser; the editor side
|
|
5
|
+
handles it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from side_vim.motions import MOTIONS
|
|
11
|
+
from side_vim.scroll import SCROLL_KEYS
|
|
12
|
+
|
|
13
|
+
# Keys that are other names for a motion.
|
|
14
|
+
ALIASES = {
|
|
15
|
+
"<Left>": "h",
|
|
16
|
+
"<Right>": "l",
|
|
17
|
+
"<Up>": "k",
|
|
18
|
+
"<Down>": "j",
|
|
19
|
+
"<Home>": "0",
|
|
20
|
+
"<End>": "$",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# The commands, other than motions, each with True if it changes the text. A read-only
|
|
24
|
+
# editor skips those. Every other list of keys is made from these tables.
|
|
25
|
+
|
|
26
|
+
# Operators take a motion in Normal mode (`dw`) and act on the selection in Visual mode.
|
|
27
|
+
OPERATORS = {"d": True, "c": True, "y": False}
|
|
28
|
+
|
|
29
|
+
# Keys vim treats as an operator with a fixed motion.
|
|
30
|
+
SHORTHANDS = {"x": ("d", "l"), "D": ("d", "$"), "C": ("c", "$")}
|
|
31
|
+
|
|
32
|
+
COMMON_ACTIONS = {
|
|
33
|
+
"n": False,
|
|
34
|
+
"N": False,
|
|
35
|
+
"/": False,
|
|
36
|
+
"?": False,
|
|
37
|
+
} | dict.fromkeys(SCROLL_KEYS, False)
|
|
38
|
+
|
|
39
|
+
# Commands that take a count but no motion, in Normal mode.
|
|
40
|
+
NORMAL_ACTIONS = {
|
|
41
|
+
"p": True,
|
|
42
|
+
"P": True,
|
|
43
|
+
"J": True,
|
|
44
|
+
"u": True,
|
|
45
|
+
"<C-r>": True,
|
|
46
|
+
"i": True,
|
|
47
|
+
"a": True,
|
|
48
|
+
"I": True,
|
|
49
|
+
"A": True,
|
|
50
|
+
"o": True,
|
|
51
|
+
"O": True,
|
|
52
|
+
"v": False,
|
|
53
|
+
"V": False,
|
|
54
|
+
} | COMMON_ACTIONS
|
|
55
|
+
|
|
56
|
+
# Commands that act on the selection or the mode, in Visual mode. `x` is `d` here.
|
|
57
|
+
VISUAL_ACTIONS = OPERATORS | {"p": True, "o": False, "v": False, "V": False} | COMMON_ACTIONS
|
|
58
|
+
|
|
59
|
+
# The Normal mode commands that start Insert mode.
|
|
60
|
+
INSERT_COMMANDS = frozenset({"i", "a", "I", "A", "o", "O"})
|
|
61
|
+
|
|
62
|
+
# The Ctrl keys vim takes in Normal and Visual mode. Other Ctrl keys go to the app.
|
|
63
|
+
CTRL_KEYS = frozenset(key for key in NORMAL_ACTIONS | VISUAL_ACTIONS if key.startswith("<C-"))
|
|
64
|
+
|
|
65
|
+
# The motion name for `dd`, `cc` and `yy`: the current line and count-1 lines below.
|
|
66
|
+
LINE = "line"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Command:
|
|
71
|
+
"""One complete command.
|
|
72
|
+
|
|
73
|
+
name: "move" for a plain motion, an operator ("d", "c", "y"), or one of the
|
|
74
|
+
actions above ("p", "<C-r>", "i", ...).
|
|
75
|
+
count: the count typed, or None if there was none. For an operator, the two
|
|
76
|
+
counts are multiplied (`2d3w` is `d` with 6).
|
|
77
|
+
motion: for "move" and for operators in Normal mode, the motion ("w", "gg", or
|
|
78
|
+
LINE for `dd`). None for actions and for operators in Visual mode.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
count: int | None = None
|
|
83
|
+
motion: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def changes_text(command: Command, visual: bool) -> bool:
|
|
87
|
+
"""True if the command changes the text, in Visual mode or in Normal mode."""
|
|
88
|
+
if command.name == "move":
|
|
89
|
+
return False
|
|
90
|
+
if command.name in OPERATORS:
|
|
91
|
+
return OPERATORS[command.name]
|
|
92
|
+
return (VISUAL_ACTIONS if visual else NORMAL_ACTIONS)[command.name]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Pending:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
PENDING = _Pending()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Parser:
|
|
103
|
+
"""Collects keys until they make a command."""
|
|
104
|
+
|
|
105
|
+
def __init__(self) -> None:
|
|
106
|
+
self._keys: list[str] = []
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def pending(self) -> str:
|
|
110
|
+
"""The keys typed so far for the next command, like vim's showcmd."""
|
|
111
|
+
return "".join(self._keys)
|
|
112
|
+
|
|
113
|
+
def reset(self) -> None:
|
|
114
|
+
self._keys.clear()
|
|
115
|
+
|
|
116
|
+
def feed(self, key: str, visual: bool = False) -> Command | None:
|
|
117
|
+
"""Add a key. Returns the command once it's complete, otherwise None. Keys that
|
|
118
|
+
can't make a command are dropped, and pending becomes empty."""
|
|
119
|
+
self._keys.append(key)
|
|
120
|
+
result = _parse(self._keys, visual)
|
|
121
|
+
if isinstance(result, _Pending):
|
|
122
|
+
return None
|
|
123
|
+
self._keys.clear()
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _read_count(keys: list[str], i: int) -> tuple[int | None, int]:
|
|
128
|
+
"""Read a count starting at keys[i]. A count can't start with 0, since 0 is a
|
|
129
|
+
motion."""
|
|
130
|
+
if i >= len(keys) or keys[i] not in "123456789" or len(keys[i]) != 1:
|
|
131
|
+
return None, i
|
|
132
|
+
digits = ""
|
|
133
|
+
while i < len(keys) and len(keys[i]) == 1 and keys[i] in "0123456789":
|
|
134
|
+
digits += keys[i]
|
|
135
|
+
i += 1
|
|
136
|
+
return int(digits), i
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_motion(keys: list[str], i: int) -> str | _Pending | None:
|
|
140
|
+
"""Read a motion that must end the keys. None if keys[i:] isn't one."""
|
|
141
|
+
if i == len(keys):
|
|
142
|
+
return PENDING
|
|
143
|
+
key = ALIASES.get(keys[i], keys[i])
|
|
144
|
+
if key == "g":
|
|
145
|
+
if i + 1 == len(keys):
|
|
146
|
+
return PENDING
|
|
147
|
+
return "gg" if keys[i + 1] == "g" and i + 2 == len(keys) else None
|
|
148
|
+
if key in MOTIONS and i + 1 == len(keys):
|
|
149
|
+
return key
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse(keys: list[str], visual: bool) -> Command | _Pending | None:
|
|
154
|
+
count, i = _read_count(keys, 0)
|
|
155
|
+
if i == len(keys):
|
|
156
|
+
return PENDING
|
|
157
|
+
key = keys[i]
|
|
158
|
+
|
|
159
|
+
motion = _read_motion(keys, i)
|
|
160
|
+
if motion is not None:
|
|
161
|
+
return motion if isinstance(motion, _Pending) else Command("move", count, motion)
|
|
162
|
+
|
|
163
|
+
if visual:
|
|
164
|
+
key = "d" if key == "x" else key
|
|
165
|
+
if key in VISUAL_ACTIONS and i + 1 == len(keys):
|
|
166
|
+
return Command(key, count)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if key in SHORTHANDS and i + 1 == len(keys):
|
|
170
|
+
op, fixed = SHORTHANDS[key]
|
|
171
|
+
return Command(op, count, fixed)
|
|
172
|
+
|
|
173
|
+
if key in OPERATORS:
|
|
174
|
+
count2, j = _read_count(keys, i + 1)
|
|
175
|
+
total = None if count is None and count2 is None else (count or 1) * (count2 or 1)
|
|
176
|
+
if j < len(keys) and keys[j] == key and j + 1 == len(keys):
|
|
177
|
+
return Command(key, total, LINE)
|
|
178
|
+
motion = _read_motion(keys, j)
|
|
179
|
+
if motion is None or isinstance(motion, _Pending):
|
|
180
|
+
return motion
|
|
181
|
+
return Command(key, total, motion)
|
|
182
|
+
|
|
183
|
+
if key in NORMAL_ACTIONS and i + 1 == len(keys):
|
|
184
|
+
return Command(key, count)
|
|
185
|
+
return None
|