Skip to content

Commit 5014d0b

Browse files
authored
Add regression tests for hyphen word motions (#156)
Change-Id: Ic9119dc0b38a8208622ecb8a5c028775aca5a1da Signed-off-by: Thomas Kosiewski <tk@coder.com> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 58352fd commit 5014d0b

File tree

2 files changed

+110
-22
lines changed

2 files changed

+110
-22
lines changed

src/utils/vim.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,69 @@ describe("Vim Command Integration Tests", () => {
133133
});
134134
});
135135

136+
describe("Navigation", () => {
137+
test("w moves to next word", () => {
138+
const state = executeVimCommands(
139+
{ ...initialState, text: "hello world foo", cursor: 0, mode: "normal" },
140+
["w"]
141+
);
142+
expect(state.cursor).toBe(6);
143+
});
144+
145+
test("b moves to previous word", () => {
146+
const state = executeVimCommands(
147+
{ ...initialState, text: "hello world foo", cursor: 12, mode: "normal" },
148+
["b"]
149+
);
150+
expect(state.cursor).toBe(6);
151+
});
152+
153+
test("$ moves to end of line", () => {
154+
const state = executeVimCommands(
155+
{ ...initialState, text: "hello world", cursor: 0, mode: "normal" },
156+
["$"]
157+
);
158+
expect(state.cursor).toBe(10); // On last char, not past it
159+
});
160+
161+
test("0 moves to start of line", () => {
162+
const state = executeVimCommands(
163+
{ ...initialState, text: "hello world", cursor: 10, mode: "normal" },
164+
["0"]
165+
);
166+
expect(state.cursor).toBe(0);
167+
});
168+
169+
test("w skips punctuation separators like hyphen", () => {
170+
const initial = {
171+
...initialState,
172+
text: "asd-f asdf asdf",
173+
cursor: 0,
174+
mode: "normal" as const,
175+
};
176+
177+
const afterFirstW = executeVimCommands(initial, ["w"]);
178+
expect(afterFirstW.cursor).toBe(4);
179+
180+
const afterSecondW = executeVimCommands(afterFirstW, ["w"]);
181+
expect(afterSecondW.cursor).toBe(6);
182+
});
183+
184+
test("e moves past punctuation to end of next word", () => {
185+
const state = executeVimCommands(
186+
{
187+
...initialState,
188+
text: "asd-f asdf asdf",
189+
cursor: 3,
190+
mode: "normal",
191+
},
192+
["e"]
193+
);
194+
195+
expect(state.cursor).toBe(4);
196+
});
197+
});
198+
136199
describe("Simple Edits", () => {
137200
test("x deletes character under cursor", () => {
138201
const state = executeVimCommands(

src/utils/vim.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,35 @@ export function moveVertical(
128128
* In normal mode, cursor should never go past the last character.
129129
*/
130130
export function moveWordForward(text: string, cursor: number): number {
131-
let i = cursor;
132131
const n = text.length;
133-
while (i < n && /[A-Za-z0-9_]/.test(text[i])) i++;
134-
while (i < n && /\s/.test(text[i])) i++;
135-
// Clamp to last character position in normal mode (never past the end)
136-
return Math.min(i, Math.max(0, n - 1));
132+
if (n === 0) return 0;
133+
134+
let i = Math.max(0, Math.min(cursor, n - 1));
135+
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
136+
137+
const advancePastWord = (idx: number): number => {
138+
let j = idx;
139+
while (j < n && isWord(text[j])) j++;
140+
return j;
141+
};
142+
143+
const advanceToWord = (idx: number): number => {
144+
let j = idx;
145+
while (j < n && !isWord(text[j])) j++;
146+
return j;
147+
};
148+
149+
if (isWord(text[i])) {
150+
i = advancePastWord(i);
151+
}
152+
153+
i = advanceToWord(i);
154+
155+
if (i >= n) {
156+
return Math.max(0, n - 1);
157+
}
158+
159+
return i;
137160
}
138161

139162
/**
@@ -144,32 +167,34 @@ export function moveWordForward(text: string, cursor: number): number {
144167
*/
145168
export function moveWordEnd(text: string, cursor: number): number {
146169
const n = text.length;
170+
if (n === 0) return 0;
147171
if (cursor >= n - 1) return Math.max(0, n - 1);
148172

149-
let i = cursor;
173+
const clamp = Math.max(0, Math.min(cursor, n - 1));
150174
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
151175

152-
// If on a word char, check if we're at the end of it
153-
if (isWord(text[i])) {
154-
// If next char is not a word char, we're at the end - move to next word
155-
if (i < n - 1 && !isWord(text[i + 1])) {
156-
// Skip whitespace to find next word
157-
i++;
158-
while (i < n - 1 && !isWord(text[i])) i++;
159-
// Move to end of next word
160-
while (i < n - 1 && isWord(text[i + 1])) i++;
161-
return i;
162-
}
163-
// Not at end yet, move to end of current word
176+
if (!isWord(text[clamp])) {
177+
let i = clamp;
178+
while (i < n && !isWord(text[i])) i++;
179+
if (i >= n) return Math.max(0, n - 1);
164180
while (i < n - 1 && isWord(text[i + 1])) i++;
165181
return i;
166182
}
167183

168-
// If on whitespace, skip to next word then go to its end
169-
while (i < n - 1 && !isWord(text[i])) i++;
170-
while (i < n - 1 && isWord(text[i + 1])) i++;
184+
let endOfCurrent = clamp;
185+
while (endOfCurrent < n - 1 && isWord(text[endOfCurrent + 1])) endOfCurrent++;
186+
187+
if (clamp < endOfCurrent) {
188+
return endOfCurrent;
189+
}
190+
191+
let j = endOfCurrent + 1;
192+
while (j < n && !isWord(text[j])) j++;
193+
if (j >= n) return Math.max(0, n - 1);
171194

172-
return Math.min(i, Math.max(0, n - 1));
195+
let endOfNext = j;
196+
while (endOfNext < n - 1 && isWord(text[endOfNext + 1])) endOfNext++;
197+
return endOfNext;
173198
}
174199

175200
/**

0 commit comments

Comments
 (0)