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/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(c: string | undefined): CharType {
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
- // For till repeats (;/,), we need extra offset to skip past the character we stopped before/after
48
- const tillRepeatOffset = isTill && isRepeat ? 1 : 0;
49
-
50
- if (isForward) {
51
- const searchStart = col + 1 + tillRepeatOffset;
52
- const idx = line.indexOf(targetChar, searchStart);
53
- if (idx !== -1) {
54
- return isTill ? idx - 1 : idx;
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
- } else {
57
- const searchStart = col - 1 - tillRepeatOffset;
151
+
152
+ const searchStart = currentPos - 1 - tillRepeatOffset;
58
153
  const idx = line.lastIndexOf(targetChar, searchStart);
59
- if (idx !== -1) {
60
- return isTill ? idx + 1 : idx;
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",
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": [
@@ -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(ch: string | undefined): CharType {
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(line: string): WordBoundaryData {
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
- get(line: string): WordBoundaryData {
153
- const cached = this.entries.get(line);
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(line, built);
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
  }