hjkl-vim 0.40.0

Vim modal state types and grammar primitives for the hjkl editor stack. Pre-1.0 churn.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Vim FSM: sneak.
//!
//! Split out of the monolithic `vim.rs` (#267 follow-up).

use hjkl_vim_types::{LastChange, LastHorizontalMotion, Motion, Operator, RangeKind, TextObject};

use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc};

/// Scan the buffer from the current cursor position for the `count`-th
/// occurrence of the two-char digraph `(c1, c2)`.
///
/// - `forward=true` → scan downward (rows) and rightward (cols) past cursor.
/// - `forward=false` → scan upward and leftward.
///
/// When a match is found the cursor jumps to the first char of the digraph.
/// `last_sneak` and `last_horizontal_motion` are updated so `;`/`,` repeat.
/// No-op (cursor unchanged) when no match exists.
pub fn apply_sneak<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    c1: char,
    c2: char,
    forward: bool,
    count: usize,
) {
    let count = count.max(1);
    let (start_row, start_col) = ed.cursor();
    let row_count = buf_row_count(ed.buffer());

    let result = if forward {
        sneak_scan_forward(ed, start_row, start_col, c1, c2, count)
    } else {
        sneak_scan_backward(ed, start_row, start_col, c1, c2, count)
    };

    if let Some((row, col)) = result {
        buf_set_cursor_rc(ed.buffer_mut(), row, col);
        let _ = row_count; // suppress unused-variable warning
    }

    vim_mut(ed).last_sneak = Some(((c1, c2), forward));
    vim_mut(ed).last_horizontal_motion = LastHorizontalMotion::Sneak;
}
/// Scan forward from `(start_row, start_col)` (exclusive — start right after
/// cursor) for the `count`-th occurrence of `c1+c2`.
pub fn sneak_scan_forward<H: hjkl_engine::types::Host>(
    ed: &Editor<hjkl_buffer::View, H>,
    start_row: usize,
    start_col: usize,
    c1: char,
    c2: char,
    count: usize,
) -> Option<(usize, usize)> {
    let row_count = buf_row_count(ed.buffer());
    let mut hits = 0usize;
    for row in start_row..row_count {
        let line = buf_line(ed.buffer(), row).unwrap_or_default();
        let chars: Vec<char> = line.chars().collect();
        // On the start row begin scanning one past the current column.
        let col_start = if row == start_row { start_col + 1 } else { 0 };
        if col_start + 1 > chars.len() {
            continue;
        }
        for col in col_start..chars.len().saturating_sub(1) {
            if chars[col] == c1 && chars[col + 1] == c2 {
                hits += 1;
                if hits == count {
                    return Some((row, col));
                }
            }
        }
    }
    None
}
/// Scan backward from `(start_row, start_col)` (exclusive — start left of
/// cursor) for the `count`-th occurrence of `c1+c2`.
pub fn sneak_scan_backward<H: hjkl_engine::types::Host>(
    ed: &Editor<hjkl_buffer::View, H>,
    start_row: usize,
    start_col: usize,
    c1: char,
    c2: char,
    count: usize,
) -> Option<(usize, usize)> {
    let row_count = buf_row_count(ed.buffer());
    let mut hits = 0usize;
    // Iterate rows from start_row down to 0.
    let rows_to_scan = (0..row_count).rev().skip(row_count - start_row - 1);
    for row in rows_to_scan {
        let line = buf_line(ed.buffer(), row).unwrap_or_default();
        let chars: Vec<char> = line.chars().collect();
        // On the start row end scanning one before the current column.
        let col_end = if row == start_row {
            start_col.saturating_sub(1)
        } else if chars.is_empty() {
            continue;
        } else {
            chars.len().saturating_sub(1)
        };
        if col_end == 0 {
            continue;
        }
        // Scan cols right-to-left from col_end-1 so we match c1 at col, c2 at col+1.
        for col in (0..col_end).rev() {
            if col + 1 < chars.len() && chars[col] == c1 && chars[col + 1] == c2 {
                hits += 1;
                if hits == count {
                    return Some((row, col));
                }
            }
        }
    }
    None
}
/// Apply `op` over the sneak digraph range. Charwise exclusive from cursor up
/// to (but not including) the first char of the first match. This matches
/// vim-sneak's default `<Plug>Sneak_s` operator-pending behavior.
///
/// Example: buffer `"foo ab bar\n"`, cursor col 0, `dsab` → deletes `"foo "`
/// leaving `"ab bar\n"`.
pub fn apply_op_sneak<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    c1: char,
    c2: char,
    forward: bool,
    total_count: usize,
) {
    let start = ed.cursor();
    let result = if forward {
        sneak_scan_forward(ed, start.0, start.1, c1, c2, total_count)
    } else {
        sneak_scan_backward(ed, start.0, start.1, c1, c2, total_count)
    };
    let Some(end) = result else {
        return;
    };
    // Charwise exclusive — land the virtual cursor at end, then use
    // Exclusive range kind (end position not included).
    ed.jump_cursor(end.0, end.1);
    let end_cur = ed.cursor();
    ed.jump_cursor(start.0, start.1);
    run_operator_over_range(ed, op, start, end_cur, RangeKind::Exclusive);
    vim_mut(ed).last_sneak = Some(((c1, c2), forward));
    vim_mut(ed).last_horizontal_motion = LastHorizontalMotion::Sneak;
    if !vim(ed).replaying && op_is_change(op) {
        // No dot-repeat motion variant for sneak ops (plugin behavior,
        // not vim-core); record as a Change/Delete line op as a
        // best-effort fallback so `.` at least does something.
    }
}
/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
/// Called by `Editor::apply_op_find` (the public controller API) so the
/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
/// logic duplication.
pub fn apply_op_find_motion<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    ch: char,
    forward: bool,
    till: bool,
    total_count: usize,
) {
    let motion = Motion::Find { ch, forward, till };
    apply_op_with_motion(ed, op, &motion, total_count);
    vim_mut(ed).last_find = Some((ch, forward, till));
    if !vim(ed).replaying && op_is_change(op) {
        vim_mut(ed).last_change = Some(LastChange::OpMotion {
            op,
            motion,
            count: total_count,
            inserted: None,
        });
    }
}
/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
/// record `last_change`. Returns `false` when `ch` is not a known text-object
/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
///
/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
/// `apply_op_motion_key` but is currently unused — text objects don't repeat
/// in vim's current grammar. Kept for future-proofing.
pub fn apply_op_text_obj_inner<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    op: Operator,
    ch: char,
    inner: bool,
    total_count: usize,
) -> bool {
    // `total_count` drives bracket text objects: `2di{` targets the Nth
    // enclosing pair. Non-bracket objects ignore it (vim does too).
    let obj = match ch {
        'w' => TextObject::Word { big: false },
        'W' => TextObject::Word { big: true },
        '"' | '\'' | '`' => TextObject::Quote(ch),
        '(' | ')' | 'b' => TextObject::Bracket('('),
        '[' | ']' => TextObject::Bracket('['),
        '{' | '}' | 'B' => TextObject::Bracket('{'),
        '<' | '>' => TextObject::Bracket('<'),
        'p' => TextObject::Paragraph,
        't' => TextObject::XmlTag,
        's' => TextObject::Sentence,
        _ => return false,
    };
    apply_op_with_text_object(ed, op, obj, inner, total_count.max(1));
    if !vim(ed).replaying && op_is_change(op) {
        vim_mut(ed).last_change = Some(LastChange::OpTextObj {
            op,
            obj,
            inner,
            inserted: None,
        });
    }
    true
}
/// Move `pos` back by one character, clamped to (0, 0).
pub fn retreat_one<H: hjkl_engine::types::Host>(
    ed: &Editor<hjkl_buffer::View, H>,
    pos: (usize, usize),
) -> (usize, usize) {
    let (r, c) = pos;
    if c > 0 {
        (r, c - 1)
    } else if r > 0 {
        // Char columns, not bytes — cursor columns in this engine are always
        // char-indexed (this codebase's known char-vs-byte trap), so a
        // previous line with any multi-byte char landed on the wrong /
        // mid-codepoint column with `buf_line_bytes`.
        //
        // Deliberately NOT `- 1`: `char_len` is the "one past the last
        // char" virtual column (a legal cursor position — `View::set_cursor`
        // clamps to `line_chars`, not `line_chars - 1`). Landing the
        // INCLUSIVE visual cursor there is what makes the downstream
        // charwise-delete swallow the line's trailing newline and join with
        // the next line — verified against live nvim (and pinned by
        // `hjkl-compat-oracle`'s `vi_brace_open_trailing_charwise` case):
        // `vi{d` on `"{ a\n  b\n}\n"` from `(1,2)` deletes through to `{}`,
        // and `vi{d` on `"fn foo() {\n    body\n}\n"` from inside `body`
        // collapses to `"fn foo() {\n}\n"` — in both cases the closing
        // bracket's line gets pulled up, not left as an empty line. Landing
        // on the last REAL char (char_len - 1) instead would leave the
        // newline out of the inclusive range and wrongly preserve that
        // empty line — which is what broke the oracle gate when tried.
        let prev_len = buf_line_chars(ed.buffer(), r - 1);
        (r - 1, prev_len)
    } else {
        (0, 0)
    }
}
#[cfg(test)]
mod sneak_tests {
    use super::*;
    use hjkl_buffer::View;
    use hjkl_engine::{DefaultHost, Editor, Options};

    fn make_editor(content: &str) -> Editor<View, DefaultHost> {
        let buf = View::from_str(content);
        let host = DefaultHost::new();
        crate::vim::vim_editor(buf, host, Options::default())
    }

    /// `s ba` from [0,0] on "foo bar baz qux\n" → cursor at [0,4] (start of "ba" in "bar").
    #[test]
    fn sneak_forward_jumps_to_two_char_digraph() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 0);
        super::apply_sneak(&mut ed, 'b', 'a', true, 1);
        assert_eq!(ed.cursor(), (0, 4), "cursor should land on 'ba' in 'bar'");
    }

    /// `S ba` from [0,12] on "foo bar baz qux\n" → cursor at [0,8] ("ba" in "baz").
    #[test]
    fn sneak_backward_jumps_to_prior_match() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 12);
        super::apply_sneak(&mut ed, 'b', 'a', false, 1);
        assert_eq!(
            ed.cursor(),
            (0, 8),
            "backward sneak should find 'ba' in 'baz'"
        );
    }

    /// After sneak forward to "bar", `;` (sneak-repeat) jumps to next "ba" ("baz").
    #[test]
    fn sneak_repeat_semicolon_next_match() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 0);
        // First sneak: lands at [0,4]
        super::apply_sneak(&mut ed, 'b', 'a', true, 1);
        assert_eq!(ed.cursor(), (0, 4));
        // Repeat via execute_motion FindRepeat (which routes through sneak if last was sneak)
        execute_motion(&mut ed, Motion::FindRepeat { reverse: false }, 1);
        assert_eq!(ed.cursor(), (0, 8), "semicolon should jump to next 'ba'");
    }

    /// After sneak forward from [0,0] to [0,4], `,` (reverse) — no prior "ba" → stays.
    #[test]
    fn sneak_repeat_comma_prev_match() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 0);
        super::apply_sneak(&mut ed, 'b', 'a', true, 1);
        assert_eq!(ed.cursor(), (0, 4));
        // Reverse repeat — no "ba" before col 4, so cursor must not move.
        let pre = ed.cursor();
        execute_motion(&mut ed, Motion::FindRepeat { reverse: true }, 1);
        assert_eq!(
            ed.cursor(),
            pre,
            "comma with no prior match should leave cursor unchanged"
        );
    }

    /// `S ba` from [0,12] jumps backward.
    #[test]
    fn sneak_s_searches_backward() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 12);
        super::apply_sneak(&mut ed, 'b', 'a', false, 1);
        assert_eq!(ed.cursor(), (0, 8));
    }

    /// `2s ba` from [0,0] jumps to 2nd "ba" occurrence.
    #[test]
    fn sneak_with_count_jumps_to_nth() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 0);
        super::apply_sneak(&mut ed, 'b', 'a', true, 2);
        assert_eq!(ed.cursor(), (0, 8), "count=2 should jump to 2nd 'ba'");
    }

    /// `s xx` with no match — cursor stays put.
    #[test]
    fn sneak_no_match_cursor_stays() {
        let mut ed = make_editor("foo bar baz qux\n");
        ed.jump_cursor(0, 0);
        let pre = ed.cursor();
        super::apply_sneak(&mut ed, 'x', 'x', true, 1);
        assert_eq!(ed.cursor(), pre, "no match should leave cursor unchanged");
    }

    /// `dsab` on "hello ab world\n" from [0,0] → deletes up to 'ab', leaving "ab world\n".
    #[test]
    fn operator_pending_dsab_deletes_to_digraph() {
        let mut ed = make_editor("hello ab world\n");
        ed.jump_cursor(0, 0);
        super::apply_op_sneak(&mut ed, Operator::Delete, 'a', 'b', true, 1);
        // View content after exclusive delete from [0,0] to [0,6] (start of "ab").
        let content = ed.content();
        assert!(
            content.starts_with("ab world"),
            "dsab should delete 'hello ' leaving 'ab world'; got: {content:?}"
        );
    }

    /// Cross-line sneak: "foo\nbar baz\n", cursor [0,0], `s ba` → [1,0].
    #[test]
    fn sneak_cross_line_match() {
        let mut ed = make_editor("foo\nbar baz\n");
        ed.jump_cursor(0, 0);
        super::apply_sneak(&mut ed, 'b', 'a', true, 1);
        assert_eq!(ed.cursor(), (1, 0), "sneak should cross line boundary");
    }

    /// `last_sneak` is updated after `sneak()` so `;`/`,` can repeat.
    #[test]
    fn sneak_updates_last_sneak_state() {
        let mut ed = make_editor("foo bar baz\n");
        ed.jump_cursor(0, 0);
        super::apply_sneak(&mut ed, 'b', 'a', true, 1);
        let ls = vim(&ed).last_sneak;
        assert_eq!(
            ls,
            Some((('b', 'a'), true)),
            "last_sneak should record the digraph and direction"
        );
    }

    // ── retreat_one / vi{ column-invariant regression (audit A1) ──────────
    //
    // NOTE on the audit brief's original diagnosis: it additionally claimed
    // `retreat_one` should land on the last REAL char of the previous line
    // (`char_len - 1`), not the "one past end" virtual column (`char_len`).
    // That part was verified WRONG against live nvim and against
    // `hjkl-compat-oracle`'s `vi_brace_open_trailing_charwise` case: when the
    // bracket's inner content is non-whitespace and the close bracket sits
    // alone on its own line, real vim's `vi{d` lands the inclusive visual
    // cursor on the virtual "one past end" column (`char_len`, a legal
    // cursor position — `View::set_cursor` clamps to `line_chars`, not
    // `line_chars - 1`), which makes the charwise delete swallow the
    // newline and join the closing bracket's line up. Landing on
    // `char_len - 1` instead leaves the newline out of the range and
    // wrongly preserves that line — confirmed by running this exact fix
    // through `cargo test -p hjkl-compat-oracle` (57 -> 56) and by directly
    // diffing against `nvim --headless`. The ONLY real bug is byte-vs-char
    // (see below); the `- 1` was not warranted and is intentionally absent.

    /// Byte length and char length coincide for an all-ASCII previous line,
    /// so this does NOT discriminate old vs. new code (both compute `8`) —
    /// it's here purely to pin the "one past end" semantics as documentation
    /// alongside the multibyte case below, which DOES discriminate.
    #[test]
    fn retreat_one_vi_brace_prev_line_lands_on_char_length() {
        let mut ed = make_editor("fn foo() {\n    body\n}\n");
        ed.jump_cursor(1, 4); // cursor on 'b' of "body"
        let (_start, end, kind) =
            crate::vim::text_object::text_object_range(&ed, TextObject::Bracket('{'), true, 1)
                .expect("vi{ should resolve the enclosing braces");
        assert_eq!(kind, RangeKind::Exclusive);
        assert_eq!(
            end,
            (2, 0),
            "exclusive inner end should be col 0 of the '}}' line"
        );
        let landed = super::retreat_one(&ed, end);
        assert_eq!(
            landed,
            (1, 8),
            "visual cursor should land on char-col 8 (char_len of \"    body\"), \
             the virtual one-past-end column that makes the delete join lines"
        );
    }

    /// Multibyte variant: previous line has a 2-byte UTF-8 char (`é`), so the
    /// byte length (10) and char length (9) of the line diverge. The old
    /// buggy code used `buf_line_bytes` — a BYTE count — as a CHAR column,
    /// landing the cursor two columns too far right (mid-codepoint / out of
    /// bounds by char-column standards). This is the actual regression this
    /// fix addresses; the ascii test above only documents the mechanism.
    #[test]
    fn retreat_one_vi_brace_multibyte_prev_line_uses_char_column() {
        let mut ed = make_editor("fn foo() {\n    héllo\n}\n");
        ed.jump_cursor(1, 4); // cursor on 'h' of "héllo"
        let (_start, end, _kind) =
            crate::vim::text_object::text_object_range(&ed, TextObject::Bracket('{'), true, 1)
                .expect("vi{ should resolve the enclosing braces");
        assert_eq!(
            end,
            (2, 0),
            "exclusive inner end should be col 0 of the '}}' line"
        );
        // "    héllo" is 9 CHARS (4 spaces + h,é,l,l,o) but 10 BYTES (é is
        // 2 bytes) — the buggy code returned 10 (a byte count used as a char
        // column).
        let landed = super::retreat_one(&ed, end);
        assert_eq!(
            landed,
            (1, 9),
            "visual cursor should land on char-col 9 (char_len of \"    héllo\"), \
             not the byte length (10)"
        );
    }
}