pi-vim 0.10.0 → 0.11.0
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.
- package/README.md +83 -78
- package/index.ts +71 -36
- package/package.json +1 -1
- package/text-objects.ts +99 -74
package/README.md
CHANGED
|
@@ -12,27 +12,35 @@ Restart Pi after install.
|
|
|
12
12
|
|
|
13
13
|
## configure
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Settings are read from `~/.pi/agent/settings.json` and project `.pi/settings.json`.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Default-equivalent `settings.json`:
|
|
18
18
|
|
|
19
19
|
```json
|
|
20
20
|
{
|
|
21
21
|
"piVim": {
|
|
22
|
-
"clipboardMirror": "all"
|
|
22
|
+
"clipboardMirror": "all",
|
|
23
|
+
"modeColors": {
|
|
24
|
+
"insert": "borderMuted",
|
|
25
|
+
"normal": "borderAccent",
|
|
26
|
+
"ex": "warning"
|
|
27
|
+
},
|
|
28
|
+
"syncBorderColorWithMode": false
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|-------|----------|
|
|
29
|
-
| `all` | Mirror every unnamed-register write (default/current behavior) |
|
|
30
|
-
| `yank` | Mirror yanks only; deletes/changes update only pi-vim's internal register |
|
|
31
|
-
| `never` | Never mirror register writes to the OS clipboard |
|
|
33
|
+
All keys are optional; omitting `piVim` is equivalent. Project overrides global; project `modeColors` replaces global `modeColors`, with missing modes defaulting above.
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
`clipboardMirror`: `all` mirrors unnamed writes; `yank` mirrors yanks; `never` keeps writes internal. Non-mirrored writes stay local for `p` / `P`.
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
`syncBorderColorWithMode`: `false` keeps Pi thinking border; `true` follows mode colors.
|
|
38
|
+
|
|
39
|
+
### mode colors
|
|
40
|
+
|
|
41
|
+
`piVim.modeColors` accepts Pi theme foreground tokens. Missing, invalid, or unknown tokens use defaults above.
|
|
42
|
+
|
|
43
|
+
Usual/safest: `accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`, `thinkingText`.
|
|
36
44
|
|
|
37
45
|
## wrapping pi-vim
|
|
38
46
|
|
|
@@ -57,7 +65,7 @@ npm run hooks:install
|
|
|
57
65
|
|
|
58
66
|
## stats
|
|
59
67
|
|
|
60
|
-
- **
|
|
68
|
+
- **192 commands**: motions, operators, counts, text objects, undo/redo, ex quit
|
|
61
69
|
- **sub-µs word motions** via precomputed boundary cache (~4ms startup, ~150KB memory)
|
|
62
70
|
- **0 dependencies**
|
|
63
71
|
|
|
@@ -85,13 +93,12 @@ Requires `@mariozechner/pi-tui >= 0.47.0`. With `pi-tui >= 0.49.3` and DECSCUSR
|
|
|
85
93
|
- REPL-focused defaults; out-of-scope boundaries documented.
|
|
86
94
|
- Clipboard/register behavior is explicit and tested.
|
|
87
95
|
|
|
88
|
-
Use pi-vim for
|
|
89
|
-
full Vim parity (visual mode, macros, search, extended ex-commands, …).
|
|
96
|
+
Use pi-vim for Vim muscle-memory in Pi prompts. Skip it if you need full Vim parity (visual mode, macros, search, extended ex-commands, …).
|
|
90
97
|
|
|
91
98
|
## common recipes
|
|
92
99
|
|
|
93
100
|
| goal | keys |
|
|
94
|
-
|
|
101
|
+
|---|---|
|
|
95
102
|
| Jump to exact line 25 | `25gg` (or `25G`) |
|
|
96
103
|
| Delete two words | `2dw` |
|
|
97
104
|
| Change current whitespace-delimited WORD | `ciW` |
|
|
@@ -113,19 +120,19 @@ full Vim parity (visual mode, macros, search, extended ex-commands, …).
|
|
|
113
120
|
|
|
114
121
|
### mode switching
|
|
115
122
|
|
|
116
|
-
| key
|
|
117
|
-
|
|
118
|
-
| `Esc` / `Ctrl+[` | Insert → Normal mode
|
|
123
|
+
| key | action |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `Esc` / `Ctrl+[` | Insert → Normal mode |
|
|
119
126
|
| `Esc` / `Ctrl+[` | Normal mode → pass to Pi (aborts the agent under default Pi keybindings) |
|
|
120
|
-
| `:`
|
|
121
|
-
| `i`
|
|
122
|
-
| `a`
|
|
123
|
-
| `I`
|
|
124
|
-
| `A`
|
|
125
|
-
| `o`
|
|
126
|
-
| `O`
|
|
127
|
+
| `:` | Normal → EX mini-mode |
|
|
128
|
+
| `i` | Normal → Insert at cursor |
|
|
129
|
+
| `a` | Normal → Insert after cursor |
|
|
130
|
+
| `I` | Normal → Insert at first non-whitespace |
|
|
131
|
+
| `A` | Normal → Insert at line end |
|
|
132
|
+
| `o` | Normal → open line below + Insert |
|
|
133
|
+
| `O` | Normal → open line above + Insert |
|
|
127
134
|
|
|
128
|
-
Optional:
|
|
135
|
+
Optional: move Pi's `app.interrupt` off bare `escape` in `~/.pi/agent/keybindings.json` if it overlaps with Insert→Normal; user config wins.
|
|
129
136
|
|
|
130
137
|
#### ex mini-mode
|
|
131
138
|
|
|
@@ -145,45 +152,48 @@ Quit-only ex flows.
|
|
|
145
152
|
|
|
146
153
|
Insert-mode shortcuts (stay in Insert mode):
|
|
147
154
|
|
|
148
|
-
| key
|
|
149
|
-
|
|
150
|
-
| `Shift+Alt+A`
|
|
151
|
-
| `Shift+Alt+I`
|
|
152
|
-
| `Alt+o`
|
|
153
|
-
| `Alt+Shift+O`
|
|
155
|
+
| key | action |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `Shift+Alt+A` | Go to end of line |
|
|
158
|
+
| `Shift+Alt+I` | Go to start of line |
|
|
159
|
+
| `Alt+o` | Open line below |
|
|
160
|
+
| `Alt+Shift+O` | Open line above |
|
|
154
161
|
|
|
155
162
|
---
|
|
156
163
|
|
|
157
164
|
### navigation (normal mode)
|
|
158
165
|
|
|
159
|
-
|
|
166
|
+
Most navigation keys accept a `{count}` prefix (max: `9999`); `%` intentionally does not.
|
|
160
167
|
|
|
161
168
|
| key | action |
|
|
162
|
-
|
|
169
|
+
|---|---|
|
|
163
170
|
| `h` / `l` / `j` / `k`; `{count}h/l/j/k` | Move left/right/down/up; line moves clamp to the buffer |
|
|
164
171
|
| `0` / `^` / `_` / `$` | Line start / first non-whitespace / counted first non-whitespace / line end |
|
|
165
172
|
| `gg` / `G`; `{count}gg` / `{count}G` | Buffer start/end or absolute 1-indexed line |
|
|
166
173
|
| `w` / `b` / `e`; `{count}w/b/e` | `word` start/back/end motions |
|
|
167
174
|
| `W` / `B` / `E`; `{count}W/B/E` | whitespace-delimited `WORD` motions |
|
|
168
175
|
| `{` / `}`; `{count}{` / `{count}}` | Previous/next paragraph start |
|
|
176
|
+
| `%` | Jump to the matching `()`, `[]`, or `{}` partner |
|
|
169
177
|
|
|
170
178
|
`word` splits punctuation from keyword chars; `WORD` treats any non-whitespace run as one token (`foo-bar`, `path/to`). Paragraph starts are non-blank lines at BOF or after blank lines (`^\s*$`). `{` / `}` are navigation-only; brace operator forms (`d{`, `c}`, `y{`, …) are out of scope.
|
|
171
179
|
|
|
180
|
+
`%` uses a delimiter under the cursor or scans forward on the current logical line. It matches `()`, `[]`, `{}` buffer-wide with lexical, nested, same-delimiter, parser-unaware matching; quotes/comments and mixed delimiters are not special. Missing/unmatched sources no-op. Counts are unsupported: `{count}%` consumes the count and no-ops; counted `d%` / `y%` / `c%` cancel without writes.
|
|
181
|
+
|
|
172
182
|
---
|
|
173
183
|
|
|
174
184
|
### character-find motions (normal mode)
|
|
175
185
|
|
|
176
186
|
A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
|
|
177
187
|
|
|
178
|
-
| key
|
|
179
|
-
|
|
180
|
-
| `f{char}`
|
|
181
|
-
| `F{char}`
|
|
182
|
-
| `t{char}`
|
|
183
|
-
| `T{char}`
|
|
184
|
-
| `{count}f{char}` | Jump to Nth occurrence of `char` forward
|
|
185
|
-
| `;`
|
|
186
|
-
| `,`
|
|
188
|
+
| key | action |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `f{char}` | Jump forward to `char` (inclusive) |
|
|
191
|
+
| `F{char}` | Jump backward to `char` (inclusive) |
|
|
192
|
+
| `t{char}` | Jump forward to one before `char` (exclusive) |
|
|
193
|
+
| `T{char}` | Jump backward to one after `char` (exclusive) |
|
|
194
|
+
| `{count}f{char}` | Jump to Nth occurrence of `char` forward |
|
|
195
|
+
| `;` | Repeat last `f/F/t/T` motion |
|
|
196
|
+
| `,` | Repeat last motion in reverse direction |
|
|
187
197
|
|
|
188
198
|
Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{char}`, etc.
|
|
189
199
|
|
|
@@ -198,7 +208,7 @@ Register-writing edits write to the unnamed register. With the default clipboard
|
|
|
198
208
|
Text objects compose as `d`/`c`/`y` + `i`/`a` + object. `i` means inner; `a` means around.
|
|
199
209
|
|
|
200
210
|
| object | keys | range |
|
|
201
|
-
|
|
211
|
+
|---|---|---|
|
|
202
212
|
| word | `iw` / `aw` | Keyword word; `aw` includes spaces |
|
|
203
213
|
| WORD | `iW` / `aW` | Line-local whitespace-delimited WORD; `aW` includes adjacent whitespace |
|
|
204
214
|
| quotes | `i"` / `a"`, `i'` / `a'`, <code>i`</code> / <code>a`</code> | Smallest containing quote pair on the line |
|
|
@@ -219,12 +229,13 @@ A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for word,
|
|
|
219
229
|
WORD, char-find, and linewise motions. Maximum total count: `9999`.
|
|
220
230
|
|
|
221
231
|
| command | deletes |
|
|
222
|
-
|
|
232
|
+
|---|---|
|
|
223
233
|
| `dw` / `de` / `db`; `dW` / `dE` / `dB` | word/WORD motion ranges; `{count}` repeats |
|
|
224
234
|
| `d$` / `d0` / `d^` | To EOL / BOL / first non-whitespace |
|
|
225
235
|
| `d_` / `dd`; `d{count}_` / `{count}dd` | Current or counted whole lines |
|
|
226
236
|
| `d{count}j` / `d{count}k` / `dG` | Linewise down/up/to EOF |
|
|
227
237
|
| `df{c}` / `dt{c}` / `dF{c}` / `dT{c}`; `d{count}f{c}` | Char-find ranges |
|
|
238
|
+
| `d%` | Inclusive range through the matching pair target |
|
|
228
239
|
| `diw` / `daw`; `diW` / `daW` | Inner/around word or WORD |
|
|
229
240
|
| `d{count}iw` / `d{count}iW`; `d{count}aw` / `d{count}aW` | Counted word/WORD text objects |
|
|
230
241
|
| `di"` / `da"` (`'`, <code>`</code>) | Inside/around quotes |
|
|
@@ -235,7 +246,7 @@ WORD, char-find, and linewise motions. Maximum total count: `9999`.
|
|
|
235
246
|
Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
236
247
|
|
|
237
248
|
| command | action |
|
|
238
|
-
|
|
249
|
+
|---|---|
|
|
239
250
|
| `cw` / `ce` / `cb`; `cW` / `cE` / `cB` | Change word/WORD motion ranges + Insert |
|
|
240
251
|
| `c{count}w/e/b`; `c{count}W/E/B` | Change counted word/WORD motions + Insert |
|
|
241
252
|
| `ciw` / `caw`; `ciW` / `caW` | Change word/WORD text objects + Insert |
|
|
@@ -244,22 +255,23 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
|
|
|
244
255
|
| `ci(` / `ca(`, `ci[` / `ca[`, `ci{` / `ca{` | Change inside/around brackets + Insert |
|
|
245
256
|
| `cc` / `c_`; `c{count}_` | Change current or counted whole lines + Insert |
|
|
246
257
|
| `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
|
|
258
|
+
| `c%` | Change inclusive range through the matching pair target + Insert |
|
|
247
259
|
| … | All `d` motions apply |
|
|
248
260
|
|
|
249
261
|
#### single-key edits
|
|
250
262
|
|
|
251
263
|
A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
252
264
|
|
|
253
|
-
| key
|
|
254
|
-
|
|
255
|
-
| `x`
|
|
256
|
-
| `{count}x`
|
|
257
|
-
| `s`
|
|
258
|
-
| `S`
|
|
259
|
-
| `D`
|
|
260
|
-
| `C`
|
|
261
|
-
| `r{char}`
|
|
262
|
-
| `{count}r{char}` | Replace next `{count}` chars with `{char}`
|
|
265
|
+
| key | action |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `x` | Delete char under cursor (no-op at/past EOL) |
|
|
268
|
+
| `{count}x` | Delete `{count}` chars |
|
|
269
|
+
| `s` | Delete char under cursor + Insert mode |
|
|
270
|
+
| `S` | Delete line content + Insert mode |
|
|
271
|
+
| `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
|
|
272
|
+
| `C` | Delete cursor to EOL + Insert mode |
|
|
273
|
+
| `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
|
|
274
|
+
| `{count}r{char}` | Replace next `{count}` chars with `{char}` |
|
|
263
275
|
|
|
264
276
|
---
|
|
265
277
|
|
|
@@ -268,11 +280,12 @@ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
|
|
|
268
280
|
Same motion set as `d`. Writes to register, **no text mutation**.
|
|
269
281
|
|
|
270
282
|
| command | yanks |
|
|
271
|
-
|
|
283
|
+
|---|---|
|
|
272
284
|
| `yy` / `Y`; `{count}yy` / `{count}Y` | Whole line(s) + trailing `\n` |
|
|
273
285
|
| `y{count}j` / `y{count}k` / `yG`; `y_` / `y{count}_` | Linewise ranges |
|
|
274
286
|
| `yw` / `ye` / `yb`; `yW` / `yE` / `yB` | word/WORD motion ranges |
|
|
275
287
|
| `y$` / `y0` / `y^`; `yf{c}` | EOL / BOL / first non-whitespace / char-find |
|
|
288
|
+
| `y%` | Inclusive range through the matching pair target |
|
|
276
289
|
| `yiw` / `yaw`; `yiW` / `yaW` | Inner/around word or WORD |
|
|
277
290
|
| `yi"` / `ya"` (`'`, <code>`</code>) | Inside/around quotes |
|
|
278
291
|
| `yi(` / `ya(`, `yi[` / `ya[`, `yi{` / `ya{` | Inside/around brackets; aliases `)`, `]`, `}`, `b`, `B` |
|
|
@@ -286,12 +299,12 @@ implemented and cancel the pending operator. Linewise counted yank (`{count}yy`,
|
|
|
286
299
|
|
|
287
300
|
### put / paste
|
|
288
301
|
|
|
289
|
-
| key
|
|
290
|
-
|
|
291
|
-
| `p`
|
|
292
|
-
| `P`
|
|
293
|
-
| `{count}p`
|
|
294
|
-
| `{count}P`
|
|
302
|
+
| key | action |
|
|
303
|
+
|---|---|
|
|
304
|
+
| `p` | Put after cursor (char-wise) / new line below (line-wise) |
|
|
305
|
+
| `P` | Put before cursor (char-wise) / new line above (line-wise) |
|
|
306
|
+
| `{count}p` | Put `{count}` times after cursor |
|
|
307
|
+
| `{count}P` | Put `{count}` times before cursor |
|
|
295
308
|
|
|
296
309
|
Put reads the OS clipboard first unless the last local register write was not mirrored. Paste text ending in `\n` is line-wise.
|
|
297
310
|
|
|
@@ -325,13 +338,14 @@ Put reads the OS clipboard first unless the last local register write was not mi
|
|
|
325
338
|
## known differences from full Vim
|
|
326
339
|
|
|
327
340
|
| area | this extension | full Vim |
|
|
328
|
-
|
|
341
|
+
|---|---|---|
|
|
329
342
|
| `$` motion | Moves past the last char (readline `Ctrl+E`) | Moves to the last char |
|
|
330
343
|
| `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for both `word` and `WORD` motions | Cross-line |
|
|
331
344
|
| `0` / `$` operators | Exclusive of the anchor col | `0` is inclusive of col 0 |
|
|
332
345
|
| Undo / redo | Delegates undo to readline; normal-mode `<C-r>` redo is supported | Full per-change undo tree |
|
|
333
346
|
| Visual mode | Not implemented | `v`, `V`, `<C-v>` |
|
|
334
347
|
| Text objects | `iw` / `aw`, `iW` / `aW`, quote objects, and paren/bracket/brace objects; delimited counts cancel | Full text-object set |
|
|
348
|
+
| `%` matching | `()`, `[]`, `{}` only; lexical same-delimiter matching with no counts, quote/angle matching, parser/matchit logic, mixed-delimiter validation, or Visual `%` yet | Also supports percentage jumps and broader matching |
|
|
335
349
|
| Count prefix | Operators, motions, navigation, `x`, `r`, `p`, `P`; capped at `MAX_COUNT=9999` | Full support |
|
|
336
350
|
| Registers / macros / search | Not implemented | Supported |
|
|
337
351
|
| Ex commands | Quit-only EX mini-mode (`:q`, `:q!`, `:qa`, `:qa!`) | Full ex command-line surface |
|
|
@@ -343,18 +357,18 @@ Put reads the OS clipboard first unless the last local register write was not mi
|
|
|
343
357
|
|
|
344
358
|
Explicitly deferred:
|
|
345
359
|
|
|
346
|
-
- Visual modes (`v`, `V`, block visual)
|
|
360
|
+
- Visual modes (`v`, `V`, block visual), including Visual `%`
|
|
347
361
|
- Tag text objects (`it`, `at`)
|
|
348
362
|
- Paragraph/sentence text objects (`ip`, `ap`, `is`, `as`)
|
|
349
|
-
- Angle bracket text objects (`i<`, `a<`)
|
|
363
|
+
- Angle bracket text objects (`i<`, `a<`) or angle-bracket `%` matching
|
|
350
364
|
- Visual-mode text-object selection
|
|
351
|
-
-
|
|
365
|
+
- Quote matching via `%`, parser-aware delimiter matching, matchit-style matching, and mixed-delimiter structural validation
|
|
352
366
|
- Delimited-object counts (`d2i"`, `2ci(`, `y2a{`)
|
|
353
367
|
- Named registers (`"a`, `"b`, …), macros (`q{char}`, `@{char}`)
|
|
354
368
|
- Ex surface beyond quit (`:s`, `:g`, `:w`, `:r`, …)
|
|
355
369
|
- Search (`/`, `?`, `n`, `N`), repeat (`.`)
|
|
356
370
|
- Replace mode (`R`) — only `r{char}` is supported
|
|
357
|
-
- Count prefix beyond currently supported motions
|
|
371
|
+
- Count prefix beyond currently supported motions, including `{count}%` percent-of-file jumps
|
|
358
372
|
- No insert-mode `<C-r>` expansion, no cross-session redo persistence
|
|
359
373
|
- No upstream `pi-tui` redo prerequisite
|
|
360
374
|
- Window / tab / buffer management, plugin ecosystem compatibility
|
|
@@ -363,15 +377,6 @@ Explicitly deferred:
|
|
|
363
377
|
|
|
364
378
|
## architecture notes
|
|
365
379
|
|
|
366
|
-
- `index.ts`
|
|
367
|
-
- `motions.ts` — pure motion calculation helpers (`findWordMotionTarget`,
|
|
368
|
-
`findCharMotionTarget`); no side effects.
|
|
369
|
-
- `types.ts` — shared types and escape-sequence constants.
|
|
370
|
-
- `test/` — Node test runner suite; no browser / full runtime required.
|
|
380
|
+
- `index.ts` handles modal keys; `motions.ts` and `text-objects.ts` hold pure range logic; `types.ts` holds shared types/constants; `test/` uses Node's runner.
|
|
371
381
|
|
|
372
|
-
Run checks
|
|
373
|
-
|
|
374
|
-
```
|
|
375
|
-
cd pi-vim
|
|
376
|
-
npm run check
|
|
377
|
-
```
|
|
382
|
+
Run checks with `npm run check`.
|
package/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { type ModeColorSettings, readPiVimSettings } from "./settings.js";
|
|
26
26
|
import {
|
|
27
27
|
resolveDelimitedTextObjectRange,
|
|
28
|
+
resolveMatchingPairMotionTarget,
|
|
28
29
|
resolveWordTextObjectRange,
|
|
29
30
|
type TextObjectKind,
|
|
30
31
|
type TextObjectRange,
|
|
@@ -1408,6 +1409,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1408
1409
|
return this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
1409
1410
|
}
|
|
1410
1411
|
|
|
1412
|
+
private opDigit(data: string): boolean {
|
|
1413
|
+
if (!this.isDigit(data) || (data === "0" && !this.operatorCount))
|
|
1414
|
+
return false;
|
|
1415
|
+
this.operatorCount += data;
|
|
1416
|
+
return true;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1411
1419
|
private cancelPendingOperator(data: string): void {
|
|
1412
1420
|
this.pendingOperator = null;
|
|
1413
1421
|
this.prefixCount = "";
|
|
@@ -1526,16 +1534,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
1526
1534
|
}
|
|
1527
1535
|
|
|
1528
1536
|
private handlePendingDelete(data: string): void {
|
|
1529
|
-
if (this.
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
}
|
|
1535
|
-
} else {
|
|
1536
|
-
this.operatorCount += data;
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1537
|
+
if (this.opDigit(data)) return;
|
|
1538
|
+
|
|
1539
|
+
if (data === "%") {
|
|
1540
|
+
this.applyPercentOp();
|
|
1541
|
+
return;
|
|
1539
1542
|
}
|
|
1540
1543
|
|
|
1541
1544
|
if (data === "d") {
|
|
@@ -1578,8 +1581,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1578
1581
|
return;
|
|
1579
1582
|
}
|
|
1580
1583
|
|
|
1581
|
-
const hasCount =
|
|
1582
|
-
this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
1584
|
+
const hasCount = this.hasPendingCount();
|
|
1583
1585
|
const supportsCountedWordMotion =
|
|
1584
1586
|
data === "w" ||
|
|
1585
1587
|
data === "e" ||
|
|
@@ -1609,16 +1611,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
1609
1611
|
}
|
|
1610
1612
|
|
|
1611
1613
|
private handlePendingChange(data: string): void {
|
|
1612
|
-
if (this.
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
}
|
|
1618
|
-
} else {
|
|
1619
|
-
this.operatorCount += data;
|
|
1620
|
-
return;
|
|
1621
|
-
}
|
|
1614
|
+
if (this.opDigit(data)) return;
|
|
1615
|
+
|
|
1616
|
+
if (data === "%") {
|
|
1617
|
+
this.applyPercentOp();
|
|
1618
|
+
return;
|
|
1622
1619
|
}
|
|
1623
1620
|
|
|
1624
1621
|
if (data === "c") {
|
|
@@ -1659,8 +1656,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1659
1656
|
return;
|
|
1660
1657
|
}
|
|
1661
1658
|
|
|
1662
|
-
const hasCount =
|
|
1663
|
-
this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
1659
|
+
const hasCount = this.hasPendingCount();
|
|
1664
1660
|
const supportsCountedWordMotion =
|
|
1665
1661
|
data === "w" ||
|
|
1666
1662
|
data === "e" ||
|
|
@@ -1726,6 +1722,12 @@ export class ModalEditor extends CustomEditor {
|
|
|
1726
1722
|
return;
|
|
1727
1723
|
}
|
|
1728
1724
|
|
|
1725
|
+
if (data === "%") {
|
|
1726
|
+
this.prefixCount = "";
|
|
1727
|
+
this.operatorCount = "";
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1729
1731
|
if (data === "d" || data === "y") {
|
|
1730
1732
|
this.pendingOperator = data;
|
|
1731
1733
|
return;
|
|
@@ -1941,6 +1943,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
1941
1943
|
this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
1942
1944
|
return;
|
|
1943
1945
|
}
|
|
1946
|
+
if (data === "%") {
|
|
1947
|
+
this.moveToMatchingPairTarget();
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1944
1950
|
if (data === "B") {
|
|
1945
1951
|
this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
1946
1952
|
return;
|
|
@@ -2303,6 +2309,40 @@ export class ModalEditor extends CustomEditor {
|
|
|
2303
2309
|
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
2304
2310
|
}
|
|
2305
2311
|
|
|
2312
|
+
private getMatchingPairMotionTarget() {
|
|
2313
|
+
const cursor = this.getCursor();
|
|
2314
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
2315
|
+
return resolveMatchingPairMotionTarget(
|
|
2316
|
+
this.getText(),
|
|
2317
|
+
this.getAbsoluteIndexFromCursor(),
|
|
2318
|
+
lineStartAbs,
|
|
2319
|
+
lineStartAbs + (this.getLines()[cursor.line] ?? "").length,
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
private moveToMatchingPairTarget(): void {
|
|
2324
|
+
const target = this.getMatchingPairMotionTarget();
|
|
2325
|
+
if (target) this.moveCursorToAbsoluteIndex(target.targetAbs);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
private applyPercentOp(): void {
|
|
2329
|
+
const op = this.pendingOperator;
|
|
2330
|
+
const counted = this.hasPendingCount();
|
|
2331
|
+
this.clearPendingState();
|
|
2332
|
+
if (!op || counted) return;
|
|
2333
|
+
|
|
2334
|
+
const t = this.getMatchingPairMotionTarget();
|
|
2335
|
+
if (!t) return;
|
|
2336
|
+
|
|
2337
|
+
if (op === "y") {
|
|
2338
|
+
this.yankRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
|
|
2343
|
+
if (op === "c") this.mode = "insert";
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2306
2346
|
private getDelimitedTextObjectCursorAbs(): number {
|
|
2307
2347
|
const lines = this.getLines();
|
|
2308
2348
|
const cursor = this.getCursor();
|
|
@@ -2827,20 +2867,15 @@ export class ModalEditor extends CustomEditor {
|
|
|
2827
2867
|
if (targetCol === null) return;
|
|
2828
2868
|
|
|
2829
2869
|
this.lastCharMotion = { motion, char: targetChar };
|
|
2830
|
-
this.deleteRange(col, targetCol, true);
|
|
2870
|
+
this.deleteRange(col, targetCol, true);
|
|
2831
2871
|
}
|
|
2832
2872
|
|
|
2833
2873
|
private handlePendingYank(data: string): void {
|
|
2834
|
-
if (this.
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
}
|
|
2840
|
-
} else {
|
|
2841
|
-
this.operatorCount += data;
|
|
2842
|
-
return;
|
|
2843
|
-
}
|
|
2874
|
+
if (this.opDigit(data)) return;
|
|
2875
|
+
|
|
2876
|
+
if (data === "%") {
|
|
2877
|
+
this.applyPercentOp();
|
|
2878
|
+
return;
|
|
2844
2879
|
}
|
|
2845
2880
|
|
|
2846
2881
|
if (data === "y") {
|
|
@@ -2973,7 +3008,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
2973
3008
|
if (targetCol === null) return;
|
|
2974
3009
|
|
|
2975
3010
|
this.lastCharMotion = { motion, char: targetChar };
|
|
2976
|
-
this.yankRange(col, targetCol, true);
|
|
3011
|
+
this.yankRange(col, targetCol, true);
|
|
2977
3012
|
}
|
|
2978
3013
|
|
|
2979
3014
|
private yankRange(col: number, targetCol: number, inclusive: boolean): void {
|
package/package.json
CHANGED
package/text-objects.ts
CHANGED
|
@@ -13,6 +13,15 @@ export type DelimiterSpec = {
|
|
|
13
13
|
close: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
export type MatchingPairKind = "()" | "[]" | "{}";
|
|
17
|
+
|
|
18
|
+
export type MatchingPairMotionTarget = {
|
|
19
|
+
pair: MatchingPairKind;
|
|
20
|
+
sourceAbs: number;
|
|
21
|
+
targetAbs: number;
|
|
22
|
+
rangeAnchorAbs: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
16
25
|
function normalizeCount(count: number): number {
|
|
17
26
|
if (!Number.isFinite(count) || count < 1) return 1;
|
|
18
27
|
return Math.floor(count);
|
|
@@ -45,23 +54,7 @@ function findLogicalLineBounds(
|
|
|
45
54
|
const start = line.lastIndexOf("\n", previousSearchStart) + 1;
|
|
46
55
|
const nextNewline = line.indexOf("\n", cursorCol);
|
|
47
56
|
|
|
48
|
-
return {
|
|
49
|
-
start,
|
|
50
|
-
end: nextNewline === -1 ? line.length : nextNewline,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function findCurrentLineBounds(
|
|
55
|
-
text: string,
|
|
56
|
-
cursorAbs: number,
|
|
57
|
-
): { startAbs: number; endAbs: number } {
|
|
58
|
-
const cursor = clampCursorAbs(text, cursorAbs);
|
|
59
|
-
const bounds = findLogicalLineBounds(text, cursor);
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
startAbs: bounds.start,
|
|
63
|
-
endAbs: bounds.end,
|
|
64
|
-
};
|
|
57
|
+
return { start, end: nextNewline === -1 ? line.length : nextNewline };
|
|
65
58
|
}
|
|
66
59
|
|
|
67
60
|
function isWordTextObjectChar(
|
|
@@ -77,42 +70,49 @@ function isWhitespace(ch: string | undefined): boolean {
|
|
|
77
70
|
return ch !== undefined && /\s/.test(ch);
|
|
78
71
|
}
|
|
79
72
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (key === "(" || key === ")" || key === "b") {
|
|
90
|
-
return {
|
|
91
|
-
type: "bracket",
|
|
92
|
-
open: "(",
|
|
93
|
-
close: ")",
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (key === "[" || key === "]") {
|
|
98
|
-
return {
|
|
99
|
-
type: "bracket",
|
|
100
|
-
open: "[",
|
|
101
|
-
close: "]",
|
|
102
|
-
};
|
|
103
|
-
}
|
|
73
|
+
function pairKind(ch?: string): MatchingPairKind | null {
|
|
74
|
+
return ch === "(" || ch === ")"
|
|
75
|
+
? "()"
|
|
76
|
+
: ch === "[" || ch === "]"
|
|
77
|
+
? "[]"
|
|
78
|
+
: ch === "{" || ch === "}"
|
|
79
|
+
? "{}"
|
|
80
|
+
: null;
|
|
81
|
+
}
|
|
104
82
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
83
|
+
function scanSameDelimiterPairs(
|
|
84
|
+
text: string,
|
|
85
|
+
open: string,
|
|
86
|
+
close: string,
|
|
87
|
+
onPair: (openAbs: number, closeAbs: number) => number | null,
|
|
88
|
+
): number | null {
|
|
89
|
+
const stack: number[] = [];
|
|
90
|
+
for (let index = 0; index < text.length; index++) {
|
|
91
|
+
if (text[index] === open) stack.push(index);
|
|
92
|
+
else if (text[index] === close) {
|
|
93
|
+
const openAbs = stack.pop();
|
|
94
|
+
if (openAbs === undefined) continue;
|
|
95
|
+
const targetAbs = onPair(openAbs, index);
|
|
96
|
+
if (targetAbs !== null) return targetAbs;
|
|
97
|
+
}
|
|
111
98
|
}
|
|
112
|
-
|
|
113
99
|
return null;
|
|
114
100
|
}
|
|
115
101
|
|
|
102
|
+
export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
|
|
103
|
+
if (key === '"' || key === "'" || key === "`")
|
|
104
|
+
return { type: "quote", open: key, close: key };
|
|
105
|
+
const pair =
|
|
106
|
+
key === "(" || key === ")" || key === "b"
|
|
107
|
+
? "()"
|
|
108
|
+
: key === "[" || key === "]"
|
|
109
|
+
? "[]"
|
|
110
|
+
: key === "{" || key === "}" || key === "B"
|
|
111
|
+
? "{}"
|
|
112
|
+
: null;
|
|
113
|
+
return pair ? { type: "bracket", open: pair[0], close: pair[1] } : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
116
|
export function isEscapedDelimiter(text: string, index: number): boolean {
|
|
117
117
|
if (!Number.isInteger(index) || index <= 0 || index >= text.length)
|
|
118
118
|
return false;
|
|
@@ -135,13 +135,13 @@ export function resolveQuoteObjectRange(
|
|
|
135
135
|
if (spec?.type !== "quote") return null;
|
|
136
136
|
|
|
137
137
|
const cursor = clampCursorAbs(text, cursorAbs);
|
|
138
|
-
const bounds =
|
|
139
|
-
if (bounds.
|
|
138
|
+
const bounds = findLogicalLineBounds(text, cursor);
|
|
139
|
+
if (bounds.start >= bounds.end) return null;
|
|
140
140
|
|
|
141
141
|
let openIndex: number | null = null;
|
|
142
142
|
let bestPair: { open: number; close: number } | null = null;
|
|
143
143
|
|
|
144
|
-
for (let index = bounds.
|
|
144
|
+
for (let index = bounds.start; index < bounds.end; index++) {
|
|
145
145
|
if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
|
|
146
146
|
|
|
147
147
|
if (openIndex === null) {
|
|
@@ -149,13 +149,12 @@ export function resolveQuoteObjectRange(
|
|
|
149
149
|
continue;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
if (openIndex <= cursor && cursor <= closeIndex) {
|
|
152
|
+
if (openIndex <= cursor && cursor <= index) {
|
|
154
153
|
if (
|
|
155
154
|
bestPair === null ||
|
|
156
|
-
|
|
155
|
+
index - openIndex < bestPair.close - bestPair.open
|
|
157
156
|
) {
|
|
158
|
-
bestPair = { open: openIndex, close:
|
|
157
|
+
bestPair = { open: openIndex, close: index };
|
|
159
158
|
}
|
|
160
159
|
}
|
|
161
160
|
openIndex = null;
|
|
@@ -186,31 +185,19 @@ export function resolveBracketObjectRange(
|
|
|
186
185
|
if (open.length !== 1 || close.length !== 1 || open === close) return null;
|
|
187
186
|
|
|
188
187
|
const cursor = clampCursorAbs(text, cursorAbs);
|
|
189
|
-
|
|
190
|
-
let bestPair: { open: number; close: number } | null = null;
|
|
191
|
-
|
|
192
|
-
for (let index = 0; index < text.length; index++) {
|
|
193
|
-
const ch = text[index];
|
|
194
|
-
|
|
195
|
-
if (ch === open) {
|
|
196
|
-
openStack.push(index);
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (ch !== close) continue;
|
|
201
|
-
|
|
202
|
-
const openIndex = openStack.pop();
|
|
203
|
-
if (openIndex === undefined) continue;
|
|
188
|
+
let bestPair = null as { open: number; close: number } | null;
|
|
204
189
|
|
|
205
|
-
|
|
190
|
+
scanSameDelimiterPairs(text, open, close, (openIndex, closeIndex) => {
|
|
191
|
+
if (openIndex <= cursor && cursor <= closeIndex) {
|
|
206
192
|
if (
|
|
207
193
|
bestPair === null ||
|
|
208
|
-
|
|
194
|
+
closeIndex - openIndex < bestPair.close - bestPair.open
|
|
209
195
|
) {
|
|
210
|
-
bestPair = { open: openIndex, close:
|
|
196
|
+
bestPair = { open: openIndex, close: closeIndex };
|
|
211
197
|
}
|
|
212
198
|
}
|
|
213
|
-
|
|
199
|
+
return null;
|
|
200
|
+
});
|
|
214
201
|
|
|
215
202
|
if (bestPair === null) return null;
|
|
216
203
|
|
|
@@ -227,6 +214,44 @@ export function resolveBracketObjectRange(
|
|
|
227
214
|
};
|
|
228
215
|
}
|
|
229
216
|
|
|
217
|
+
export function resolveMatchingPairMotionTarget(
|
|
218
|
+
text: string,
|
|
219
|
+
cursorAbs: number,
|
|
220
|
+
currentLineStartAbs: number,
|
|
221
|
+
currentLineEndAbs: number,
|
|
222
|
+
): MatchingPairMotionTarget | null {
|
|
223
|
+
const start = currentLineStartAbs,
|
|
224
|
+
end = currentLineEndAbs;
|
|
225
|
+
if (!text.length || start >= end) return null;
|
|
226
|
+
const visibleEol = cursorAbs >= end;
|
|
227
|
+
let sourceAbs = visibleEol ? end - 1 : Math.max(cursorAbs, start);
|
|
228
|
+
const rangeAnchorAbs = visibleEol ? sourceAbs : cursorAbs;
|
|
229
|
+
let pair = pairKind(text[sourceAbs]);
|
|
230
|
+
for (
|
|
231
|
+
let index = sourceAbs + 1;
|
|
232
|
+
!visibleEol && !pair && index < end;
|
|
233
|
+
index++
|
|
234
|
+
) {
|
|
235
|
+
pair = pairKind(text[index]);
|
|
236
|
+
if (pair) sourceAbs = index;
|
|
237
|
+
}
|
|
238
|
+
if (!pair) return null;
|
|
239
|
+
const targetAbs = scanSameDelimiterPairs(
|
|
240
|
+
text,
|
|
241
|
+
pair[0],
|
|
242
|
+
pair[1],
|
|
243
|
+
(openAbs, closeAbs) =>
|
|
244
|
+
openAbs === sourceAbs
|
|
245
|
+
? closeAbs
|
|
246
|
+
: closeAbs === sourceAbs
|
|
247
|
+
? openAbs
|
|
248
|
+
: null,
|
|
249
|
+
);
|
|
250
|
+
if (targetAbs !== null) return { pair, sourceAbs, targetAbs, rangeAnchorAbs };
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
230
255
|
export function resolveDelimitedTextObjectRange(
|
|
231
256
|
text: string,
|
|
232
257
|
cursorAbs: number,
|