pi-vim 0.1.3 → 0.1.7
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 +123 -68
- package/index.ts +576 -190
- package/motions.ts +122 -25
- package/package.json +4 -2
- package/word-boundary-cache.ts +24 -9
package/motions.ts
CHANGED
|
@@ -5,18 +5,105 @@
|
|
|
5
5
|
import type { CharMotion } from "./types.js";
|
|
6
6
|
|
|
7
7
|
// Character types for word boundary detection
|
|
8
|
+
export type WordMotionClass = "word" | "WORD";
|
|
9
|
+
|
|
8
10
|
enum CharType {
|
|
9
11
|
Space = 0,
|
|
10
|
-
Keyword = 1, // alphanumeric + underscore
|
|
12
|
+
Keyword = 1, // alphanumeric + underscore (or all non-space in WORD mode)
|
|
11
13
|
Other = 2, // punctuation/symbols
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
function getCharType(
|
|
16
|
+
function getCharType(
|
|
17
|
+
c: string | undefined,
|
|
18
|
+
semanticClass: WordMotionClass = "word",
|
|
19
|
+
): CharType {
|
|
15
20
|
if (!c || /\s/.test(c)) return CharType.Space;
|
|
21
|
+
if (semanticClass === "WORD") return CharType.Keyword;
|
|
16
22
|
if (/\w/.test(c)) return CharType.Keyword;
|
|
17
23
|
return CharType.Other;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
function clampLineIndex(lines: readonly string[], lineIndex: number): number {
|
|
27
|
+
if (lines.length === 0) return 0;
|
|
28
|
+
if (!Number.isFinite(lineIndex)) return 0;
|
|
29
|
+
const normalized = Math.trunc(lineIndex);
|
|
30
|
+
return Math.max(0, Math.min(normalized, lines.length - 1));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when line matches ^\s*$.
|
|
35
|
+
*/
|
|
36
|
+
export function isBlankLine(line: string | undefined): boolean {
|
|
37
|
+
if (line === undefined) return true;
|
|
38
|
+
return /^\s*$/.test(line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Paragraph start: non-blank line at BOF or after a blank line.
|
|
43
|
+
*/
|
|
44
|
+
export function isParagraphStart(lines: readonly string[], lineIndex: number): boolean {
|
|
45
|
+
if (!Number.isInteger(lineIndex)) return false;
|
|
46
|
+
if (lineIndex < 0 || lineIndex >= lines.length) return false;
|
|
47
|
+
if (isBlankLine(lines[lineIndex])) return false;
|
|
48
|
+
if (lineIndex === 0) return true;
|
|
49
|
+
return isBlankLine(lines[lineIndex - 1]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One step of } motion from current line index.
|
|
54
|
+
*/
|
|
55
|
+
export function findNextParagraphStart(lines: readonly string[], fromLine: number): number {
|
|
56
|
+
if (lines.length === 0) return 0;
|
|
57
|
+
|
|
58
|
+
const start = clampLineIndex(lines, fromLine) + 1;
|
|
59
|
+
for (let i = start; i < lines.length; i++) {
|
|
60
|
+
if (isParagraphStart(lines, i)) return i;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return lines.length - 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One step of { motion from current line index.
|
|
68
|
+
*/
|
|
69
|
+
export function findPrevParagraphStart(lines: readonly string[], fromLine: number): number {
|
|
70
|
+
if (lines.length === 0) return 0;
|
|
71
|
+
|
|
72
|
+
const start = clampLineIndex(lines, fromLine) - 1;
|
|
73
|
+
for (let i = start; i >= 0; i--) {
|
|
74
|
+
if (isParagraphStart(lines, i)) return i;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Paragraph motion target for counted { / } semantics.
|
|
82
|
+
*/
|
|
83
|
+
export function findParagraphMotionTarget(
|
|
84
|
+
lines: readonly string[],
|
|
85
|
+
fromLine: number,
|
|
86
|
+
direction: "forward" | "backward",
|
|
87
|
+
count: number = 1,
|
|
88
|
+
): number {
|
|
89
|
+
if (lines.length === 0) return 0;
|
|
90
|
+
|
|
91
|
+
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
92
|
+
let currentLine = clampLineIndex(lines, fromLine);
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < steps; i++) {
|
|
95
|
+
const nextLine =
|
|
96
|
+
direction === "forward"
|
|
97
|
+
? findNextParagraphStart(lines, currentLine)
|
|
98
|
+
: findPrevParagraphStart(lines, currentLine);
|
|
99
|
+
|
|
100
|
+
if (nextLine === currentLine) break;
|
|
101
|
+
currentLine = nextLine;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return currentLine;
|
|
105
|
+
}
|
|
106
|
+
|
|
20
107
|
/**
|
|
21
108
|
* Reverse a character motion direction (f ↔ F, t ↔ T).
|
|
22
109
|
*/
|
|
@@ -40,26 +127,35 @@ export function findCharMotionTarget(
|
|
|
40
127
|
motion: CharMotion,
|
|
41
128
|
targetChar: string,
|
|
42
129
|
isRepeat: boolean = false,
|
|
130
|
+
count: number = 1,
|
|
43
131
|
): number | null {
|
|
44
132
|
const isForward = motion === "f" || motion === "t";
|
|
45
133
|
const isTill = motion === "t" || motion === "T";
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
134
|
+
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
135
|
+
|
|
136
|
+
let currentPos = col;
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < steps; i++) {
|
|
139
|
+
const isFirst = i === 0;
|
|
140
|
+
const isFinal = i === steps - 1;
|
|
141
|
+
const tillRepeatOffset = isFirst && isTill && isRepeat ? 1 : 0;
|
|
142
|
+
|
|
143
|
+
if (isForward) {
|
|
144
|
+
const searchStart = currentPos + 1 + tillRepeatOffset;
|
|
145
|
+
const idx = line.indexOf(targetChar, searchStart);
|
|
146
|
+
if (idx === -1) return null;
|
|
147
|
+
if (isFinal) return isTill ? idx - 1 : idx;
|
|
148
|
+
currentPos = idx;
|
|
149
|
+
continue;
|
|
55
150
|
}
|
|
56
|
-
|
|
57
|
-
const searchStart =
|
|
151
|
+
|
|
152
|
+
const searchStart = currentPos - 1 - tillRepeatOffset;
|
|
58
153
|
const idx = line.lastIndexOf(targetChar, searchStart);
|
|
59
|
-
if (idx
|
|
60
|
-
|
|
61
|
-
|
|
154
|
+
if (idx === -1) return null;
|
|
155
|
+
if (isFinal) return isTill ? idx + 1 : idx;
|
|
156
|
+
currentPos = idx;
|
|
62
157
|
}
|
|
158
|
+
|
|
63
159
|
return null;
|
|
64
160
|
}
|
|
65
161
|
|
|
@@ -71,6 +167,7 @@ export function findWordMotionTarget(
|
|
|
71
167
|
col: number,
|
|
72
168
|
direction: "forward" | "backward",
|
|
73
169
|
target: "start" | "end",
|
|
170
|
+
semanticClass: WordMotionClass = "word",
|
|
74
171
|
): number {
|
|
75
172
|
const len = line.length;
|
|
76
173
|
if (len === 0) return 0;
|
|
@@ -82,15 +179,15 @@ export function findWordMotionTarget(
|
|
|
82
179
|
|
|
83
180
|
if (target === "start") {
|
|
84
181
|
// w: move to start of next word
|
|
85
|
-
const startType = getCharType(line[i]);
|
|
182
|
+
const startType = getCharType(line[i], semanticClass);
|
|
86
183
|
|
|
87
184
|
// Skip current word/punct block
|
|
88
185
|
if (startType !== CharType.Space) {
|
|
89
|
-
while (i < len && getCharType(line[i]) === startType) i++;
|
|
186
|
+
while (i < len && getCharType(line[i], semanticClass) === startType) i++;
|
|
90
187
|
}
|
|
91
188
|
|
|
92
189
|
// Skip whitespace
|
|
93
|
-
while (i < len && getCharType(line[i]) === CharType.Space) i++;
|
|
190
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++;
|
|
94
191
|
|
|
95
192
|
return i;
|
|
96
193
|
}
|
|
@@ -99,13 +196,13 @@ export function findWordMotionTarget(
|
|
|
99
196
|
if (i < len - 1) i++;
|
|
100
197
|
|
|
101
198
|
// Skip whitespace forward
|
|
102
|
-
while (i < len && getCharType(line[i]) === CharType.Space) i++;
|
|
199
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++;
|
|
103
200
|
|
|
104
201
|
// Now at start of next word (or end of line). Find end.
|
|
105
202
|
if (i >= len) return len;
|
|
106
203
|
|
|
107
|
-
const type = getCharType(line[i]);
|
|
108
|
-
while (i < len - 1 && getCharType(line[i + 1]) === type) i++;
|
|
204
|
+
const type = getCharType(line[i], semanticClass);
|
|
205
|
+
while (i < len - 1 && getCharType(line[i + 1], semanticClass) === type) i++;
|
|
109
206
|
|
|
110
207
|
return i;
|
|
111
208
|
}
|
|
@@ -115,11 +212,11 @@ export function findWordMotionTarget(
|
|
|
115
212
|
if (i > 0) i--;
|
|
116
213
|
|
|
117
214
|
// Skip whitespace backward
|
|
118
|
-
while (i > 0 && getCharType(line[i]) === CharType.Space) i--;
|
|
215
|
+
while (i > 0 && getCharType(line[i], semanticClass) === CharType.Space) i--;
|
|
119
216
|
|
|
120
217
|
// Now at end of prev word (or start of line). Find start.
|
|
121
|
-
const type = getCharType(line[i]);
|
|
122
|
-
while (i > 0 && getCharType(line[i - 1]) === type) i--;
|
|
218
|
+
const type = getCharType(line[i], semanticClass);
|
|
219
|
+
while (i > 0 && getCharType(line[i - 1], semanticClass) === type) i--;
|
|
123
220
|
|
|
124
221
|
return i;
|
|
125
222
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vim",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Vim-style modal editing for Pi's TUI editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "echo 'nothing to build'",
|
|
25
25
|
"test": "node --import tsx/esm --test 'test/**/*.test.ts'",
|
|
26
|
-
"check": "npm run test"
|
|
26
|
+
"check": "npm run test",
|
|
27
|
+
"pack:check": "node --import tsx/esm script/pack-check.ts",
|
|
28
|
+
"prepublishOnly": "npm run pack:check && npm test"
|
|
27
29
|
},
|
|
28
30
|
"pi": {
|
|
29
31
|
"extensions": [
|
package/word-boundary-cache.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Line-local cache for Vim word motion boundaries.
|
|
3
3
|
*
|
|
4
|
-
* Keyed by exact line content to avoid stale boundary reuse.
|
|
4
|
+
* Keyed by semantic class + exact line content to avoid stale boundary reuse.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { WordMotionClass } from "./motions.js";
|
|
8
|
+
|
|
7
9
|
export type WordMotionDirection = "forward" | "backward";
|
|
8
10
|
export type WordMotionTarget = "start" | "end";
|
|
9
11
|
|
|
@@ -22,13 +24,20 @@ export interface WordBoundaryData {
|
|
|
22
24
|
readonly prevNonSpaceAtOrBefore: Int32Array;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
function getCharType(
|
|
27
|
+
function getCharType(
|
|
28
|
+
ch: string | undefined,
|
|
29
|
+
semanticClass: WordMotionClass = "word",
|
|
30
|
+
): CharType {
|
|
26
31
|
if (!ch || /\s/.test(ch)) return CharType.Space;
|
|
32
|
+
if (semanticClass === "WORD") return CharType.Word;
|
|
27
33
|
if (/\w/.test(ch)) return CharType.Word;
|
|
28
34
|
return CharType.Other;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
function buildWordBoundaryData(
|
|
37
|
+
function buildWordBoundaryData(
|
|
38
|
+
line: string,
|
|
39
|
+
semanticClass: WordMotionClass = "word",
|
|
40
|
+
): WordBoundaryData {
|
|
32
41
|
const len = line.length;
|
|
33
42
|
const charTypes = new Uint8Array(len);
|
|
34
43
|
const runStartByIndex = new Int32Array(len);
|
|
@@ -40,7 +49,7 @@ function buildWordBoundaryData(line: string): WordBoundaryData {
|
|
|
40
49
|
prevNonSpaceAtOrBefore.fill(-1);
|
|
41
50
|
|
|
42
51
|
for (let i = 0; i < len; i++) {
|
|
43
|
-
charTypes[i] = getCharType(line[i]);
|
|
52
|
+
charTypes[i] = getCharType(line[i], semanticClass);
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
for (let runStart = 0; runStart < len;) {
|
|
@@ -149,11 +158,16 @@ export class WordBoundaryCache {
|
|
|
149
158
|
: DEFAULT_MAX_CACHE_ENTRIES;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
private makeCacheKey(line: string, semanticClass: WordMotionClass): string {
|
|
162
|
+
return `${semanticClass}\u0000${line}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get(line: string, semanticClass: WordMotionClass = "word"): WordBoundaryData {
|
|
166
|
+
const key = this.makeCacheKey(line, semanticClass);
|
|
167
|
+
const cached = this.entries.get(key);
|
|
154
168
|
if (cached) return cached;
|
|
155
169
|
|
|
156
|
-
const built = buildWordBoundaryData(line);
|
|
170
|
+
const built = buildWordBoundaryData(line, semanticClass);
|
|
157
171
|
|
|
158
172
|
if (this.entries.size >= this.maxEntries) {
|
|
159
173
|
const oldestKey = this.entries.keys().next().value;
|
|
@@ -162,7 +176,7 @@ export class WordBoundaryCache {
|
|
|
162
176
|
}
|
|
163
177
|
}
|
|
164
178
|
|
|
165
|
-
this.entries.set(
|
|
179
|
+
this.entries.set(key, built);
|
|
166
180
|
return built;
|
|
167
181
|
}
|
|
168
182
|
|
|
@@ -171,10 +185,11 @@ export class WordBoundaryCache {
|
|
|
171
185
|
col: number,
|
|
172
186
|
direction: WordMotionDirection,
|
|
173
187
|
target: WordMotionTarget,
|
|
188
|
+
semanticClass: WordMotionClass = "word",
|
|
174
189
|
): number | null {
|
|
175
190
|
if (!Number.isInteger(col) || col < 0) return null;
|
|
176
191
|
|
|
177
|
-
const boundaries = this.get(line);
|
|
192
|
+
const boundaries = this.get(line, semanticClass);
|
|
178
193
|
return findTargetInLine(boundaries, col, direction, target);
|
|
179
194
|
}
|
|
180
195
|
}
|