pi-vim 0.9.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 +85 -78
- package/clipboard-policy.ts +7 -57
- package/index.ts +604 -309
- package/motions.ts +38 -15
- package/package.json +3 -3
- package/settings.ts +92 -0
- package/text-objects.ts +130 -82
- package/word-boundary-cache.ts +5 -4
package/motions.ts
CHANGED
|
@@ -49,7 +49,10 @@ export function isBlankLine(line: string | undefined): boolean {
|
|
|
49
49
|
/**
|
|
50
50
|
* Paragraph start: non-blank line at BOF or after a blank line.
|
|
51
51
|
*/
|
|
52
|
-
export function isParagraphStart(
|
|
52
|
+
export function isParagraphStart(
|
|
53
|
+
lines: readonly string[],
|
|
54
|
+
lineIndex: number,
|
|
55
|
+
): boolean {
|
|
53
56
|
if (!Number.isInteger(lineIndex)) return false;
|
|
54
57
|
if (lineIndex < 0 || lineIndex >= lines.length) return false;
|
|
55
58
|
if (isBlankLine(lines[lineIndex])) return false;
|
|
@@ -60,7 +63,10 @@ export function isParagraphStart(lines: readonly string[], lineIndex: number): b
|
|
|
60
63
|
/**
|
|
61
64
|
* One step of } motion from current line index.
|
|
62
65
|
*/
|
|
63
|
-
export function findNextParagraphStart(
|
|
66
|
+
export function findNextParagraphStart(
|
|
67
|
+
lines: readonly string[],
|
|
68
|
+
fromLine: number,
|
|
69
|
+
): number {
|
|
64
70
|
if (lines.length === 0) return 0;
|
|
65
71
|
|
|
66
72
|
const start = clampLineIndex(lines, fromLine) + 1;
|
|
@@ -74,7 +80,10 @@ export function findNextParagraphStart(lines: readonly string[], fromLine: numbe
|
|
|
74
80
|
/**
|
|
75
81
|
* One step of { motion from current line index.
|
|
76
82
|
*/
|
|
77
|
-
export function findPrevParagraphStart(
|
|
83
|
+
export function findPrevParagraphStart(
|
|
84
|
+
lines: readonly string[],
|
|
85
|
+
fromLine: number,
|
|
86
|
+
): number {
|
|
78
87
|
if (lines.length === 0) return 0;
|
|
79
88
|
|
|
80
89
|
const start = clampLineIndex(lines, fromLine) - 1;
|
|
@@ -125,16 +134,21 @@ export function reverseCharMotion(motion: CharMotion): CharMotion {
|
|
|
125
134
|
return reverseMap[motion];
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
const GRAPHEME_SEGMENTER =
|
|
129
|
-
&& typeof Intl.Segmenter === "function"
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
const GRAPHEME_SEGMENTER =
|
|
138
|
+
typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
|
|
139
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
140
|
+
: null;
|
|
132
141
|
|
|
133
|
-
export function getLineGraphemes(
|
|
142
|
+
export function getLineGraphemes(
|
|
143
|
+
line: string,
|
|
144
|
+
): Array<{ start: number; end: number }> {
|
|
134
145
|
const segments: Array<{ start: number; end: number }> = [];
|
|
135
146
|
if (GRAPHEME_SEGMENTER) {
|
|
136
147
|
for (const part of GRAPHEME_SEGMENTER.segment(line)) {
|
|
137
|
-
segments.push({
|
|
148
|
+
segments.push({
|
|
149
|
+
start: part.index,
|
|
150
|
+
end: part.index + part.segment.length,
|
|
151
|
+
});
|
|
138
152
|
}
|
|
139
153
|
return segments;
|
|
140
154
|
}
|
|
@@ -163,7 +177,7 @@ export function findCharMotionTarget(
|
|
|
163
177
|
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
164
178
|
|
|
165
179
|
const graphemes = getLineGraphemes(line);
|
|
166
|
-
let currentIndex = graphemes.findIndex(g => col < g.end);
|
|
180
|
+
let currentIndex = graphemes.findIndex((g) => col < g.end);
|
|
167
181
|
if (currentIndex === -1) currentIndex = graphemes.length;
|
|
168
182
|
|
|
169
183
|
for (let i = 0; i < steps; i++) {
|
|
@@ -179,7 +193,10 @@ export function findCharMotionTarget(
|
|
|
179
193
|
if (!g) continue;
|
|
180
194
|
// Use startsWith to allow matching base chars if targetChar lacks combining marks,
|
|
181
195
|
// or just exact match since targetChar is typically a full grapheme.
|
|
182
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
line.slice(g.start, g.end) === targetChar ||
|
|
198
|
+
line.slice(g.start, g.end).startsWith(targetChar)
|
|
199
|
+
) {
|
|
183
200
|
found = j;
|
|
184
201
|
break;
|
|
185
202
|
}
|
|
@@ -199,7 +216,10 @@ export function findCharMotionTarget(
|
|
|
199
216
|
for (let j = nextIndex; j >= 0; j--) {
|
|
200
217
|
const g = graphemes[j];
|
|
201
218
|
if (!g) continue;
|
|
202
|
-
if (
|
|
219
|
+
if (
|
|
220
|
+
line.slice(g.start, g.end) === targetChar ||
|
|
221
|
+
line.slice(g.start, g.end).startsWith(targetChar)
|
|
222
|
+
) {
|
|
203
223
|
found = j;
|
|
204
224
|
break;
|
|
205
225
|
}
|
|
@@ -243,11 +263,13 @@ export function findWordMotionTarget(
|
|
|
243
263
|
|
|
244
264
|
// Skip current word/punct block
|
|
245
265
|
if (startType !== CharType.Space) {
|
|
246
|
-
while (i < len && getCharType(line[i], semanticClass) === startType)
|
|
266
|
+
while (i < len && getCharType(line[i], semanticClass) === startType)
|
|
267
|
+
i++;
|
|
247
268
|
}
|
|
248
269
|
|
|
249
270
|
// Skip whitespace
|
|
250
|
-
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
271
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
272
|
+
i++;
|
|
251
273
|
|
|
252
274
|
return i;
|
|
253
275
|
}
|
|
@@ -256,7 +278,8 @@ export function findWordMotionTarget(
|
|
|
256
278
|
if (i < len - 1) i++;
|
|
257
279
|
|
|
258
280
|
// Skip whitespace forward
|
|
259
|
-
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
281
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
282
|
+
i++;
|
|
260
283
|
|
|
261
284
|
// Now at start of next word (or end of line). Find end.
|
|
262
285
|
if (i >= len) return len;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Vim-style modal editing for Pi's TUI editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "echo 'nothing to build'",
|
|
25
|
-
"format": "biome
|
|
26
|
-
"lint": "biome
|
|
25
|
+
"format": "biome check --write .",
|
|
26
|
+
"lint": "biome check . && eslint .",
|
|
27
27
|
"typecheck": "tsc --noEmit",
|
|
28
28
|
"test": "node --import tsx/esm --test 'test/**/*.test.ts'",
|
|
29
29
|
"check": "npm run lint && npm run typecheck && npm run test",
|
package/settings.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SettingsManager } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type ModeColorSettings = {
|
|
4
|
+
insert?: string;
|
|
5
|
+
normal?: string;
|
|
6
|
+
ex?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type PiVimSettings = {
|
|
10
|
+
clipboardMirror?: unknown;
|
|
11
|
+
modeColors?: ModeColorSettings;
|
|
12
|
+
syncBorderColorWithMode?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const M = Symbol(),
|
|
16
|
+
C = ["insert", "normal", "ex"] as const,
|
|
17
|
+
T = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
18
|
+
const rec = (v: unknown): v is Record<string, unknown> =>
|
|
19
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
function get(s: unknown, k: keyof PiVimSettings): unknown {
|
|
22
|
+
if (!rec(s) || !Object.hasOwn(s, "piVim")) return M;
|
|
23
|
+
const p = s.piVim;
|
|
24
|
+
if (!rec(p)) return p;
|
|
25
|
+
return Object.hasOwn(p, k) ? p[k] : M;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function colors(v: unknown) {
|
|
29
|
+
if (!rec(v)) return;
|
|
30
|
+
const r: ModeColorSettings = {};
|
|
31
|
+
for (const k of C) {
|
|
32
|
+
const x = v[k],
|
|
33
|
+
t = typeof x === "string" ? x.trim() : "";
|
|
34
|
+
if (T.test(t)) r[k] = t;
|
|
35
|
+
}
|
|
36
|
+
return Object.keys(r)[0] ? r : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readPiVimClipboardMirrorSetting(g: unknown, p: unknown) {
|
|
40
|
+
let v = get(p, "clipboardMirror");
|
|
41
|
+
if (v !== M) return v;
|
|
42
|
+
v = get(g, "clipboardMirror");
|
|
43
|
+
return v === M ? undefined : v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readPiVimModeColors(g: unknown, p: unknown) {
|
|
47
|
+
const v = get(p, "modeColors");
|
|
48
|
+
// Project settings are a whole-setting override. If a project checks in an
|
|
49
|
+
// invalid modeColors value, fall back to pi-vim defaults instead of leaking a
|
|
50
|
+
// developer's global colors into that project.
|
|
51
|
+
if (v !== M) return colors(v);
|
|
52
|
+
const w = get(g, "modeColors");
|
|
53
|
+
return colors(w);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readPiVimBooleanSetting(
|
|
57
|
+
g: unknown,
|
|
58
|
+
p: unknown,
|
|
59
|
+
k: "syncBorderColorWithMode",
|
|
60
|
+
) {
|
|
61
|
+
const v = get(p, k);
|
|
62
|
+
if (v !== M) return typeof v === "boolean" ? v : undefined;
|
|
63
|
+
const w = get(g, k);
|
|
64
|
+
return typeof w === "boolean" ? w : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function disk(cwd: string): PiVimSettings {
|
|
68
|
+
const s = SettingsManager.create(cwd),
|
|
69
|
+
g = s.getGlobalSettings(),
|
|
70
|
+
p = s.getProjectSettings();
|
|
71
|
+
return {
|
|
72
|
+
clipboardMirror: readPiVimClipboardMirrorSetting(g, p),
|
|
73
|
+
modeColors: readPiVimModeColors(g, p),
|
|
74
|
+
syncBorderColorWithMode: readPiVimBooleanSetting(
|
|
75
|
+
g,
|
|
76
|
+
p,
|
|
77
|
+
"syncBorderColorWithMode",
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let reader = disk;
|
|
83
|
+
export function readPiVimSettings(cwd: string) {
|
|
84
|
+
return reader(cwd);
|
|
85
|
+
}
|
|
86
|
+
export function setPiVimSettingsReaderForTests(next: typeof disk) {
|
|
87
|
+
const prev = reader;
|
|
88
|
+
reader = next;
|
|
89
|
+
return () => {
|
|
90
|
+
reader = prev;
|
|
91
|
+
};
|
|
92
|
+
}
|
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);
|
|
@@ -34,30 +43,24 @@ function clampCursorAbs(text: string, cursorAbs: number): number {
|
|
|
34
43
|
return Math.max(0, Math.min(normalized, text.length - 1));
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
function findLogicalLineBounds(
|
|
46
|
+
function findLogicalLineBounds(
|
|
47
|
+
line: string,
|
|
48
|
+
cursorCol: number,
|
|
49
|
+
): { start: number; end: number } {
|
|
38
50
|
if (line.length === 0) return { start: 0, end: 0 };
|
|
39
51
|
|
|
40
|
-
const previousSearchStart =
|
|
52
|
+
const previousSearchStart =
|
|
53
|
+
line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
|
|
41
54
|
const start = line.lastIndexOf("\n", previousSearchStart) + 1;
|
|
42
55
|
const nextNewline = line.indexOf("\n", cursorCol);
|
|
43
56
|
|
|
44
|
-
return {
|
|
45
|
-
start,
|
|
46
|
-
end: nextNewline === -1 ? line.length : nextNewline,
|
|
47
|
-
};
|
|
57
|
+
return { start, end: nextNewline === -1 ? line.length : nextNewline };
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
startAbs: bounds.start,
|
|
56
|
-
endAbs: bounds.end,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isWordTextObjectChar(ch: string | undefined, semanticClass: WordTextObjectClass): boolean {
|
|
60
|
+
function isWordTextObjectChar(
|
|
61
|
+
ch: string | undefined,
|
|
62
|
+
semanticClass: WordTextObjectClass,
|
|
63
|
+
): boolean {
|
|
61
64
|
if (ch === undefined) return false;
|
|
62
65
|
if (semanticClass === "WORD") return !/\s/.test(ch);
|
|
63
66
|
return /\w/.test(ch);
|
|
@@ -67,44 +70,52 @@ function isWhitespace(ch: string | undefined): boolean {
|
|
|
67
70
|
return ch !== undefined && /\s/.test(ch);
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (key === "(" || key === ")" || key === "b") {
|
|
80
|
-
return {
|
|
81
|
-
type: "bracket",
|
|
82
|
-
open: "(",
|
|
83
|
-
close: ")",
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (key === "[" || key === "]") {
|
|
88
|
-
return {
|
|
89
|
-
type: "bracket",
|
|
90
|
-
open: "[",
|
|
91
|
-
close: "]",
|
|
92
|
-
};
|
|
93
|
-
}
|
|
73
|
+
function pairKind(ch?: string): MatchingPairKind | null {
|
|
74
|
+
return ch === "(" || ch === ")"
|
|
75
|
+
? "()"
|
|
76
|
+
: ch === "[" || ch === "]"
|
|
77
|
+
? "[]"
|
|
78
|
+
: ch === "{" || ch === "}"
|
|
79
|
+
? "{}"
|
|
80
|
+
: null;
|
|
81
|
+
}
|
|
94
82
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
|
101
98
|
}
|
|
102
|
-
|
|
103
99
|
return null;
|
|
104
100
|
}
|
|
105
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
|
+
|
|
106
116
|
export function isEscapedDelimiter(text: string, index: number): boolean {
|
|
107
|
-
if (!Number.isInteger(index) || index <= 0 || index >= text.length)
|
|
117
|
+
if (!Number.isInteger(index) || index <= 0 || index >= text.length)
|
|
118
|
+
return false;
|
|
108
119
|
|
|
109
120
|
let backslashCount = 0;
|
|
110
121
|
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
|
|
@@ -124,13 +135,13 @@ export function resolveQuoteObjectRange(
|
|
|
124
135
|
if (spec?.type !== "quote") return null;
|
|
125
136
|
|
|
126
137
|
const cursor = clampCursorAbs(text, cursorAbs);
|
|
127
|
-
const bounds =
|
|
128
|
-
if (bounds.
|
|
138
|
+
const bounds = findLogicalLineBounds(text, cursor);
|
|
139
|
+
if (bounds.start >= bounds.end) return null;
|
|
129
140
|
|
|
130
141
|
let openIndex: number | null = null;
|
|
131
142
|
let bestPair: { open: number; close: number } | null = null;
|
|
132
143
|
|
|
133
|
-
for (let index = bounds.
|
|
144
|
+
for (let index = bounds.start; index < bounds.end; index++) {
|
|
134
145
|
if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
|
|
135
146
|
|
|
136
147
|
if (openIndex === null) {
|
|
@@ -138,10 +149,12 @@ export function resolveQuoteObjectRange(
|
|
|
138
149
|
continue;
|
|
139
150
|
}
|
|
140
151
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
if (openIndex <= cursor && cursor <= index) {
|
|
153
|
+
if (
|
|
154
|
+
bestPair === null ||
|
|
155
|
+
index - openIndex < bestPair.close - bestPair.open
|
|
156
|
+
) {
|
|
157
|
+
bestPair = { open: openIndex, close: index };
|
|
145
158
|
}
|
|
146
159
|
}
|
|
147
160
|
openIndex = null;
|
|
@@ -172,28 +185,19 @@ export function resolveBracketObjectRange(
|
|
|
172
185
|
if (open.length !== 1 || close.length !== 1 || open === close) return null;
|
|
173
186
|
|
|
174
187
|
const cursor = clampCursorAbs(text, cursorAbs);
|
|
175
|
-
|
|
176
|
-
let bestPair: { open: number; close: number } | null = null;
|
|
177
|
-
|
|
178
|
-
for (let index = 0; index < text.length; index++) {
|
|
179
|
-
const ch = text[index];
|
|
180
|
-
|
|
181
|
-
if (ch === open) {
|
|
182
|
-
openStack.push(index);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
188
|
+
let bestPair = null as { open: number; close: number } | null;
|
|
185
189
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
bestPair = { open: openIndex, close: index };
|
|
190
|
+
scanSameDelimiterPairs(text, open, close, (openIndex, closeIndex) => {
|
|
191
|
+
if (openIndex <= cursor && cursor <= closeIndex) {
|
|
192
|
+
if (
|
|
193
|
+
bestPair === null ||
|
|
194
|
+
closeIndex - openIndex < bestPair.close - bestPair.open
|
|
195
|
+
) {
|
|
196
|
+
bestPair = { open: openIndex, close: closeIndex };
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
|
-
|
|
199
|
+
return null;
|
|
200
|
+
});
|
|
197
201
|
|
|
198
202
|
if (bestPair === null) return null;
|
|
199
203
|
|
|
@@ -210,6 +214,44 @@ export function resolveBracketObjectRange(
|
|
|
210
214
|
};
|
|
211
215
|
}
|
|
212
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
|
+
|
|
213
255
|
export function resolveDelimitedTextObjectRange(
|
|
214
256
|
text: string,
|
|
215
257
|
cursorAbs: number,
|
|
@@ -224,7 +266,13 @@ export function resolveDelimitedTextObjectRange(
|
|
|
224
266
|
}
|
|
225
267
|
|
|
226
268
|
if (spec.type === "bracket") {
|
|
227
|
-
return resolveBracketObjectRange(
|
|
269
|
+
return resolveBracketObjectRange(
|
|
270
|
+
text,
|
|
271
|
+
cursorAbs,
|
|
272
|
+
kind,
|
|
273
|
+
spec.open,
|
|
274
|
+
spec.close,
|
|
275
|
+
);
|
|
228
276
|
}
|
|
229
277
|
|
|
230
278
|
return null;
|
|
@@ -244,11 +292,10 @@ export function resolveWordTextObjectRange(
|
|
|
244
292
|
const bounds = findLogicalLineBounds(line, cursor);
|
|
245
293
|
if (bounds.start >= bounds.end) return null;
|
|
246
294
|
|
|
247
|
-
const hasWordChar = (idx: number) =>
|
|
248
|
-
idx >= bounds.start
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
);
|
|
295
|
+
const hasWordChar = (idx: number) =>
|
|
296
|
+
idx >= bounds.start &&
|
|
297
|
+
idx < bounds.end &&
|
|
298
|
+
isWordTextObjectChar(line[idx], semanticClass);
|
|
252
299
|
|
|
253
300
|
let col = Math.max(bounds.start, Math.min(cursor, bounds.end - 1));
|
|
254
301
|
|
|
@@ -275,7 +322,8 @@ export function resolveWordTextObjectRange(
|
|
|
275
322
|
let remaining = normalizeCount(count) - 1;
|
|
276
323
|
while (remaining > 0) {
|
|
277
324
|
let nextWordStart = end;
|
|
278
|
-
while (nextWordStart < bounds.end && !hasWordChar(nextWordStart))
|
|
325
|
+
while (nextWordStart < bounds.end && !hasWordChar(nextWordStart))
|
|
326
|
+
nextWordStart++;
|
|
279
327
|
if (nextWordStart >= bounds.end) break;
|
|
280
328
|
|
|
281
329
|
let nextWordEnd = nextWordStart + 1;
|
package/word-boundary-cache.ts
CHANGED
|
@@ -52,7 +52,7 @@ function buildWordBoundaryData(
|
|
|
52
52
|
charTypes[i] = getCharType(line[i], semanticClass);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
for (let runStart = 0; runStart < len;) {
|
|
55
|
+
for (let runStart = 0; runStart < len; ) {
|
|
56
56
|
const runType = charTypes[runStart] ?? CharType.Space;
|
|
57
57
|
let runEnd = runStart;
|
|
58
58
|
while (runEnd + 1 < len && charTypes[runEnd + 1] === runType) {
|
|
@@ -153,9 +153,10 @@ export class WordBoundaryCache {
|
|
|
153
153
|
private readonly maxEntries: number;
|
|
154
154
|
|
|
155
155
|
constructor(maxEntries: number = DEFAULT_MAX_CACHE_ENTRIES) {
|
|
156
|
-
this.maxEntries =
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
this.maxEntries =
|
|
157
|
+
Number.isInteger(maxEntries) && maxEntries > 0
|
|
158
|
+
? maxEntries
|
|
159
|
+
: DEFAULT_MAX_CACHE_ENTRIES;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
private makeCacheKey(line: string, semanticClass: WordMotionClass): string {
|