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
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
//! Vim FSM: op motion.
//!
//! Split out of the monolithic `vim.rs` (#267 follow-up).
use hjkl_vim_types::{InsertReason, Mode, Motion, Operator, RangeKind, TextObject};
use super::*;
use crate::vim_state::vim_mut;
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc};
pub fn apply_op_with_motion<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
motion: &Motion,
count: usize,
) {
let mut start = ed.cursor();
// Where the cursor is parked before the operator runs. Held separately
// from `start` because the exclusive-motion adjustment below can move the
// range's *backward* endpoint, which must not drag the parked cursor.
let cursor_restore = start;
// Tentatively apply motion to find the endpoint. Operator context
// so `l` on the last char advances past-last (standard vim
// exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
// `yl` to cover the final char.
apply_motion_cursor_ctx(ed, motion, count, true);
let mut end = ed.cursor();
// Linewise motions normally cover their row even when their endpoint has
// the same row. `_` is a successful zero-distance motion, but `j`/`k` and
// `+`/`-` fail at a buffer edge and must not operate on the current row.
if start.0 == end.0
&& matches!(
motion,
Motion::Up
| Motion::Down
| Motion::ScreenUp
| Motion::ScreenDown
| Motion::FirstNonBlankNextLine
| Motion::FirstNonBlankPrevLine
)
{
return;
}
// If the motion made no progress, abort the operator (vim behavior).
// A charwise edge motion that cannot advance leaves the buffer unchanged.
let mut kind = motion_kind(motion);
// Vim's `findpar` tail (`:h }`): a `}` that lands on the LAST line of the
// buffer sits on that line's last character and sets `*pincl`, making the
// motion inclusive — that is what lets `d}` in the final paragraph reach
// end-of-buffer instead of stopping one char short.
//
// Vim applies this to the zero-distance form too: with the cursor already
// on the last char of the last line, `}` does not move and `d}` still
// deletes that char. That case cannot be expressed as a zero-width
// INCLUSIVE range here, because the `start == end` abort below and the
// matching guard in `run_operator_over_range` reject every zero-width
// charwise range — and they must keep doing so, or `d$` on an empty line
// stops being a no-op. It is written as the equivalent one-char EXCLUSIVE
// range instead, the same shape `dl` on a line's final char produces.
//
// `last_row` is the last *content* row: ropey synthesizes a phantom empty
// final row for a trailing `\n`, and the motion itself stops on the last
// real row (the `content_row_count` clamp `hjkl_engine::motions` shares
// with `G`), so this must use the same row or `d}` never sees the landing.
let last_row = last_content_row(ed);
if matches!(motion, Motion::ParagraphNext)
&& kind == RangeKind::Exclusive
&& end.0 == last_row
&& buf_line_chars(ed.buffer(), last_row) > 0
&& end.1 + 1 == buf_line_chars(ed.buffer(), last_row)
{
if start == end {
end = (end.0, end.1 + 1);
} else {
kind = RangeKind::Inclusive;
}
}
// Linewise motions always cover their row when successful even if
// `start == end` (e.g. `d_` with count 1 on a single row deletes it).
if start == end && !matches!(kind, RangeKind::Linewise) {
return;
}
// Vim special case (`:h word`): when `w`/`W` is used with an operator and
// the last word moved over ends its line, the operated text stops at the
// end of that word instead of eating the line break into the next line's
// first word. `d2w` that ends mid-line on a later row is unaffected, since
// its last word is not at end-of-line. When the word-forward motion
// crossed onto a later row, clamp `end` back to the last non-blank char it
// moved over and make the range inclusive.
if matches!(motion, Motion::WordFwd | Motion::BigWordFwd)
&& kind == RangeKind::Exclusive
&& end.0 > start.0
&& let Some(word_end) = last_word_end_before(ed, start, end)
&& word_end.0 < end.0
{
end = word_end;
kind = RangeKind::Inclusive;
}
// Vim's exclusive-motion adjustment (`:h exclusive`), motion form — the
// same rule `apply_op_with_text_object` applies to inner bracket objects.
// When an exclusive charwise range ends in column 0 of a later row, the
// end pulls back to the end of the previous line and the motion becomes
// inclusive; when the start also sits at or before the first non-blank of
// its own line, the whole range promotes to linewise. This is what makes
// `d}` from column 0 delete its paragraph linewise (so `y}P` pastes above
// like nvim) while `d}` from mid-line stops at end-of-line instead of
// eating the line break.
if kind == RangeKind::Exclusive {
let forward = end.0 >= start.0;
let (top, bot) = if forward { (start, end) } else { (end, start) };
if bot.1 == 0 && bot.0 > top.0 {
let prev = bot.0 - 1;
let prev_len = buf_line_chars(ed.buffer(), prev);
let indent = buf_line(ed.buffer(), top.0)
.unwrap_or_default()
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
let new_bot = if top.1 <= indent {
kind = RangeKind::Linewise;
(prev, 0)
} else if prev_len > 0 {
kind = RangeKind::Inclusive;
(prev, prev_len - 1)
} else {
// Previous line is empty: nothing to include, stay exclusive.
(prev, 0)
};
if forward {
end = new_bot;
} else {
start = new_bot;
}
}
}
// Vim's "strange Vi behaviour" in `op_delete()`: a charwise DELETE that
// spans more than one line, whose range ends with nothing but whitespace
// left on the last line and whose start sits in the leading indent, is
// promoted to linewise — so `d}` over the final paragraph removes those
// rows outright instead of leaving an empty one behind, and the register
// gets linewise content. Delete only; `y}` / `c}` keep their charwise
// range, which is why this is not folded into the adjustment above.
if op == Operator::Delete && kind != RangeKind::Linewise {
let (top, bot) = order(start, end);
if bot.0 > top.0 {
let bot_line = buf_line(ed.buffer(), bot.0).unwrap_or_default();
let bot_chars: Vec<char> = bot_line.chars().collect();
let mut idx = bot.1;
if idx < bot_chars.len() && kind == RangeKind::Inclusive {
idx += 1;
}
let tail_blank = bot_chars[idx.min(bot_chars.len())..]
.iter()
.all(|c| *c == ' ' || *c == '\t');
let indent = buf_line(ed.buffer(), top.0)
.unwrap_or_default()
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
if tail_blank && top.1 <= indent {
kind = RangeKind::Linewise;
}
}
}
// Restore cursor before selecting (so Yank leaves cursor at start).
ed.jump_cursor(cursor_restore.0, cursor_restore.1);
// Comment is always linewise regardless of motion kind — toggle rows.
if op == Operator::Comment {
let top = start.0.min(end.0);
let bot = start.0.max(end.0);
ed.toggle_comment_range(top, bot);
vim_mut(ed).mode = Mode::Normal;
return;
}
run_operator_over_range(ed, op, start, end, kind);
}
/// Position of the last non-blank char in the half-open range `[start, end)`,
/// scanning rows from the bottom up. Used to clamp `dw`/`dW` at end-of-line
/// (vim's `:h word` special case): the returned position is the end of the
/// last word moved over. For a counted `d{n}w` the last word can sit on the
/// landing row itself (before `end.1`), so that row is scanned too — only
/// columns `[.., end.1)` there — which is what keeps `d2w` ending mid-line
/// (last word not at EOL, `word_end.0 == end.0`) from being clamped.
pub fn last_word_end_before<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
start: (usize, usize),
end: (usize, usize),
) -> Option<(usize, usize)> {
for r in (start.0..=end.0).rev() {
let line = buf_line(ed.buffer(), r).unwrap_or_default();
let lo = if r == start.0 { start.1 } else { 0 };
let hi = if r == end.0 {
end.1
} else {
line.chars().count()
};
let last = line
.chars()
.enumerate()
.filter(|(i, ch)| *i >= lo && *i < hi && *ch != ' ' && *ch != '\t')
.map(|(i, _)| i)
.last();
if let Some(col) = last {
return Some((r, col));
}
}
None
}
pub fn apply_op_with_text_object<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
obj: TextObject,
inner: bool,
count: usize,
) {
// Folded counts can exceed a single prefix's cap; re-clamp at vim's
// count ceiling (`:h count`).
let count = count.min(MAX_COUNT);
let Some((mut start, mut end, mut kind)) = text_object_range(ed, obj, inner, count) else {
return;
};
// vim's exclusive-motion adjustment (`:h exclusive`), applied to the
// OPERATOR form of an inner bracket object spanning multiple lines (the
// visual form keeps the raw charwise region). When the exclusive end sits
// in column 0, pull it back to the end of the previous line and make the
// motion inclusive; if the start is at or before the first non-blank of its
// line, promote to linewise. This is what makes `di{` on a contentful
// multi-line block collapse to bare braces ("{\n}") and a clean block
// delete its body linewise.
if inner
&& matches!(obj, TextObject::Bracket(_))
&& kind == RangeKind::Exclusive
&& end.0 > start.0
&& end.1 == 0
{
let prev = end.0 - 1;
let prev_len = buf_line_chars(ed.buffer(), prev);
let fnb = buf_line(ed.buffer(), start.0)
.unwrap_or_default()
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
if start.1 <= fnb {
start = (start.0, 0);
end = (prev, prev_len);
kind = RangeKind::Linewise;
} else {
end = (prev, prev_len.saturating_sub(1));
kind = RangeKind::Inclusive;
}
}
ed.jump_cursor(start.0, start.1);
run_operator_over_range(ed, op, start, end, kind);
}
pub fn motion_kind(motion: &Motion) -> RangeKind {
match motion {
Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
RangeKind::Linewise
}
Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
RangeKind::Inclusive
}
Motion::Find { .. } => RangeKind::Inclusive,
Motion::MatchBracket => RangeKind::Inclusive,
// `[(` / `])` etc. are exclusive: `d])` deletes up to but not including
// the bracket; `d[(` deletes back to but not past the open bracket.
Motion::UnmatchedBracket { .. } => RangeKind::Exclusive,
// `$` now lands on the last char — operator ranges include it.
Motion::LineEnd => RangeKind::Inclusive,
// Linewise motions: +/-/_ land on the first non-blank of a line.
Motion::FirstNonBlankNextLine
| Motion::FirstNonBlankPrevLine
| Motion::FirstNonBlankLine => RangeKind::Linewise,
// [[/]]/[][/][ are charwise exclusive (land on the brace, brace excluded from operator).
Motion::SectionBackward
| Motion::SectionForward
| Motion::SectionEndBackward
| Motion::SectionEndForward => RangeKind::Exclusive,
_ => RangeKind::Exclusive,
}
}
/// Linewise change of rows `[top_row, end_row]` (vim `cc`/`cj`/`Vc`/`cip`…).
///
/// Deletes the spanned lines, leaves one line carrying the first row's
/// leading whitespace (when `autoindent` is on), parks the cursor after
/// the indent, and enters insert mode. Records the full linewise payload
/// to the yank + delete registers and sets `change_mark_start` for the
/// `[`/`]` deferral. Calls `push_undo` internally — callers must NOT also
/// call it.
pub fn change_linewise_rows<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top_row: usize,
end_row: usize,
) {
use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
// Vim `:h '[`: stash change start for `]` deferral on insert-exit.
vim_mut(ed).change_mark_start = Some((top_row, 0));
ed.push_undo();
ed.sync_buffer_content_from_textarea();
// Read the cut payload first so yank reflects every original line.
let payload = read_vim_range(ed, (top_row, 0), (end_row, 0), RangeKind::Linewise);
// Drop every row after the first (rows [top_row+1, end_row]).
if end_row > top_row {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(top_row + 1, 0),
end: Position::new(end_row, 0),
kind: BufKind::Line,
});
}
// Preserve the first row's leading whitespace when autoindent is on;
// wipe the whole line content otherwise (cursor lands at col 0).
let indent_chars = if ed.settings().autoindent {
let line =
hjkl_buffer::rope_line_str(&hjkl_engine::types::Query::rope(ed.buffer()), top_row);
line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
} else {
0
};
let line_chars = buf_line_chars(ed.buffer(), top_row);
if line_chars > indent_chars {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(top_row, indent_chars),
end: Position::new(top_row, line_chars),
kind: BufKind::Char,
});
}
if !payload.is_empty() {
ed.record_yank_to_host(payload.clone());
let target = vim_mut(ed).pending_register.take();
ed.record_delete(payload, true, target);
}
buf_set_cursor_rc(ed.buffer_mut(), top_row, indent_chars);
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
pub fn run_operator_over_range<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
start: (usize, usize),
end: (usize, usize),
kind: RangeKind,
) {
let (top, bot) = order(start, end);
// Charwise empty range (same position). For Delete/Yank there is nothing to
// act on. For Change, vim still enters insert at that point — `ci(` on `()`
// and `ci{` on a whitespace-only block both place the cursor inside and
// start inserting without deleting anything.
if top == bot && !matches!(kind, RangeKind::Linewise) {
if op == Operator::Change {
vim_mut(ed).change_mark_start = Some(top);
ed.push_undo();
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
return;
}
match op {
Operator::Yank => {
let text = read_vim_range(ed, top, bot, kind);
if !text.is_empty() {
ed.record_yank_to_host(text.clone());
let target = vim_mut(ed).pending_register.take();
ed.record_yank(text, matches!(kind, RangeKind::Linewise), target);
}
// Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
// `]` = last yanked char. Mode-aware: linewise snaps to line
// edges; charwise uses the actual inclusive endpoint.
let rbr = match kind {
RangeKind::Linewise => {
let last_col = buf_line_chars(ed.buffer(), bot.0).saturating_sub(1);
(bot.0, last_col)
}
RangeKind::Inclusive => (bot.0, bot.1),
RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
};
ed.set_mark('[', top);
ed.set_mark(']', rbr);
buf_set_cursor_rc(ed.buffer_mut(), top.0, top.1);
}
Operator::Delete => {
ed.push_undo();
// Linewise deletes must not land the cursor on `top.0` when that
// row no longer exists after the cut (or is now the trailing
// phantom row) — mirrors the clamp in linewise.rs's `dd` path
// (H2), which `run_operator_over_range` (reached by
// motion-driven deletes like `dG`/`dj`) was missing. Capture
// `total`/`deleted_through_last` before the cut since the row
// count changes underneath it. `total` includes ropey's phantom
// trailing row, so `deleted_through_last` only fires when the
// buffer has no trailing newline (no phantom row to absorb the
// boundary) — the far more common "reaches the last content
// line" case is instead caught by the phantom-row pullback
// below, which must run unconditionally, not just when this
// flag is set.
let total = buf_row_count(ed.buffer());
let deleted_through_last = matches!(kind, RangeKind::Linewise) && bot.0 + 1 >= total;
cut_vim_range(ed, top, bot, kind);
// After a charwise / inclusive delete the buffer cursor is
// placed at `start` by the edit path. In Normal mode the
// cursor max col is `line_len - 1`; clamp it here so e.g.
// `d$` doesn't leave the cursor one past the new line end.
if !matches!(kind, RangeKind::Linewise) {
clamp_cursor_to_normal_mode(ed);
} else {
let total_after = buf_row_count(ed.buffer());
let raw_target = if deleted_through_last {
top.0.saturating_sub(1).min(total_after.saturating_sub(1))
} else {
top.0.min(total_after.saturating_sub(1))
};
// Clamp off the trailing phantom empty row that arises from
// a buffer with a trailing newline (stored as ["...", ""]) —
// same rule as linewise.rs's `dd` path.
let target_row = if raw_target > 0
&& raw_target + 1 == total_after
&& buf_line(ed.buffer(), raw_target).is_some_and(|s| s.is_empty())
{
raw_target - 1
} else {
raw_target
};
buf_set_cursor_rc(ed.buffer_mut(), target_row, 0);
}
vim_mut(ed).mode = Mode::Normal;
// Vim `:h '[` / `:h ']`: after a delete both marks park at
// the cursor position where the deletion collapsed (the join
// point). Set after the cut and clamp so the position is final.
let pos = ed.cursor();
ed.set_mark('[', pos);
ed.set_mark(']', pos);
}
Operator::Change => {
// Vim `:h '[`: `[` is set to the start of the changed range
// before the cut. `]` is deferred to insert-exit (AfterChange
// path in finish_insert_session) where the cursor sits on the
// last inserted char.
if matches!(kind, RangeKind::Linewise) {
// Linewise change (`cj`/`ck`/`cip`/`cap`/…): preserve the
// first line's indent and leave exactly one row open for
// insert. The helper handles push_undo + insert entry.
change_linewise_rows(ed, top.0, bot.0);
} else {
// Charwise change: cut the range and enter insert.
vim_mut(ed).change_mark_start = Some(top);
ed.push_undo();
cut_vim_range(ed, top, bot, kind);
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
apply_case_op_to_selection(ed, op, top, bot, kind);
}
Operator::Indent | Operator::Outdent => {
// Indent / outdent are always linewise even when triggered
// by a char-wise motion (e.g. `>w` indents the whole line).
ed.push_undo();
if op == Operator::Indent {
indent_rows(ed, top.0, bot.0, 1);
} else {
outdent_rows(ed, top.0, bot.0, 1);
}
vim_mut(ed).mode = Mode::Normal;
}
Operator::Fold => {
// Always linewise — fold the spanned rows regardless of the
// motion's natural kind. Cursor lands on `top.0` to mirror
// the visual `zf` path.
if bot.0 >= top.0 {
ed.apply_fold_op(hjkl_engine::types::FoldOp::Add {
start_row: top.0,
end_row: bot.0,
closed: true,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top.0, top.1);
vim_mut(ed).mode = Mode::Normal;
}
Operator::Reflow => {
ed.push_undo();
reflow_rows(ed, top.0, bot.0);
vim_mut(ed).mode = Mode::Normal;
}
Operator::ReflowKeepCursor => {
// `gw{motion}` — reflow like `gq` but restore the cursor to the
// character it was on before the reflow (vim's gw behaviour).
let saved = ed.cursor();
ed.push_undo();
let (before, after) = reflow_rows_keep_cursor(ed, top.0, bot.0);
let (new_row, new_col) = reflow_keep_cursor(top.0, saved.0, saved.1, &before, &after);
buf_set_cursor_rc(ed.buffer_mut(), new_row, new_col);
ed.set_sticky_col(Some(new_col));
vim_mut(ed).mode = Mode::Normal;
}
Operator::AutoIndent => {
// Always linewise — like Indent/Outdent.
ed.push_undo();
auto_indent_rows(ed, top.0, bot.0);
vim_mut(ed).mode = Mode::Normal;
}
Operator::Filter => {
// Filter is not dispatched through run_operator_over_range.
// The app calls Editor::filter_range directly with a command string.
// Reaching this arm means a caller invoked run_operator_over_range
// with Operator::Filter by mistake — silently no-op.
}
Operator::Comment => {
// Comment is dispatched through Editor::toggle_comment_range.
// Reaching this arm is a caller mistake — silently no-op.
}
}
}