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
//! Vim FSM: linewise.
//!
//! Split out of the monolithic `vim.rs` (#267 follow-up).
use hjkl_vim_types::{Mode, Operator, RangeKind};
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};
/// Expand a linewise `[start, end]` row range so it fully covers every CLOSED
/// fold it overlaps — vim's rule that a linewise operator on a closed fold acts
/// on the whole fold. Loops until stable so nested closed folds are absorbed.
pub fn expand_linewise_over_closed_folds(
buf: &hjkl_buffer::View,
mut start: usize,
mut end: usize,
) -> (usize, usize) {
let folds = buf.folds();
if folds.is_empty() {
return (start, end);
}
loop {
let mut changed = false;
for f in &folds {
if !f.closed {
continue;
}
// Does this closed fold overlap the current range?
if f.start_row <= end && f.end_row >= start {
if f.start_row < start {
start = f.start_row;
changed = true;
}
if f.end_row > end {
end = f.end_row;
changed = true;
}
}
}
if !changed {
break;
}
}
(start, end)
}
/// Park the cursor where vim's linewise delete leaves it (`dd`, `dj`, `dap`,
/// `Vd`, …): the first non-blank of the row that now holds `top`, or of the
/// row above when the delete took the buffer's last line — never the phantom
/// empty row after a trailing newline, which vim treats as a terminator.
/// `deleted_through_last` has to be measured before the cut, while the row
/// count still includes the deleted rows.
pub fn land_after_linewise_delete<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
deleted_through_last: bool,
) {
let total_after = buf_row_count(ed.buffer());
let raw_target = if deleted_through_last {
top.saturating_sub(1).min(total_after.saturating_sub(1))
} else {
top.min(total_after.saturating_sub(1))
};
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);
move_first_non_whitespace(ed);
}
/// A doubled line operator (`dd`, `>>`, `3gUU`, …) and its `.` repeat.
pub fn execute_line_op<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
count: usize,
) {
// vim's `nv_lineop`: with no count the doubled operator's motion stays on
// the line and lands on its first non-blank; the earlier of that and the
// cursor becomes the operator's start — the cursor the undo step saves,
// so `>>u` returns there. With a count the motion ends on a later line
// and the start keeps its column. `gqq` / `gww` keep it either way.
if count == 1
&& !matches!(
op,
Operator::Yank | Operator::Reflow | Operator::ReflowKeepCursor
)
{
let (row, col) = ed.cursor();
move_first_non_whitespace(ed);
if ed.cursor().1 > col {
buf_set_cursor_rc(ed.buffer_mut(), row, col);
}
}
execute_line_op_from_cursor(ed, op, count);
}
/// `op` over `count` lines from the cursor's row, starting where the cursor
/// stands — the Visual-line `.` repeat, which has no `nv_lineop` motion.
pub fn execute_line_op_from_cursor<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
count: usize,
) {
line_op_body(ed, op, count);
remember_operator_column(ed);
}
fn line_op_body<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
count: usize,
) {
// Folded counts (`2d3d` → 6) can exceed a single prefix's cap; re-clamp
// at vim's count ceiling (`:h count`).
let count = count.min(MAX_COUNT);
let (row, col) = ed.cursor();
let total = buf_row_count(ed.buffer());
// Vim: `[count]op` for a linewise operator implies a `count_` motion that
// moves `count - 1` lines down. On the last line that motion can't move at
// all, so the whole operator aborts (E16) — `2dd`/`2yy`/`5>>`/`5<<` on the
// final line are no-ops, not "operate on the one remaining line". When the
// cursor is above the last line the motion clamps to the buffer end instead.
//
// A trailing newline is stored as a phantom empty final row, so the last
// *content* line is one above it; use that as the boundary.
if count >= 2 && row >= last_content_row(ed) {
return;
}
let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
// Vim: a linewise operator (`dd`/`yy`/`cc`/`>>`/…) with the cursor on a
// CLOSED fold operates on the ENTIRE fold, not just the cursor line. Expand
// the `[row, end_row]` range to cover any closed fold it touches (repeats
// until stable so nested folds are absorbed too).
let (row, end_row) = expand_linewise_over_closed_folds(ed.buffer(), row, end_row);
match op {
Operator::Yank => {
// yy must not move the cursor.
let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
if !text.is_empty() {
ed.record_yank_to_host(text.clone());
let target = vim_mut(ed).pending_register.take();
ed.record_yank(text, true, target);
}
// Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
// (top_row, 0), `]` = (bot_row, last_col).
let last_col = buf_line_chars(ed.buffer(), end_row).saturating_sub(1);
ed.set_mark('[', (row, 0));
ed.set_mark(']', (end_row, last_col));
buf_set_cursor_rc(ed.buffer_mut(), row, col);
vim_mut(ed).mode = Mode::Normal;
}
Operator::Delete => {
ed.push_undo();
let deleted_through_last = end_row + 1 >= total;
cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
land_after_linewise_delete(ed, row, deleted_through_last);
ed.set_sticky_col(Some(ed.cursor().1));
vim_mut(ed).mode = Mode::Normal;
// Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
// post-delete cursor position (the join point).
let pos = ed.cursor();
ed.set_mark('[', pos);
ed.set_mark(']', pos);
}
Operator::Change => {
// `cc` / `3cc`: delegate to the shared linewise-change helper
// which preserves the first line's indent, leaves one row open,
// and enters insert mode.
change_linewise_rows(ed, row, end_row);
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase | Operator::Rot13 => {
// `gUU` / `guu` / `g~~` / `g??` — linewise case/rot13 transform over
// [row, end_row]. Preserve cursor on `row` (first non-blank
// lines up with vim's behaviour).
apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
// After case-op on a linewise range vim puts the cursor on
// the first non-blank of the starting line.
move_first_non_whitespace(ed);
}
Operator::Indent | Operator::Outdent => {
// `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
ed.push_undo();
if op == Operator::Indent {
indent_rows(ed, row, end_row, 1);
} else {
outdent_rows(ed, row, end_row, 1);
}
ed.set_sticky_col(Some(ed.cursor().1));
vim_mut(ed).mode = Mode::Normal;
}
// No doubled form — `zfzf` is two consecutive `zf` chords.
Operator::Fold => unreachable!("Fold has no line-op double"),
Operator::Reflow => {
// `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
ed.push_undo();
reflow_rows(ed, row, end_row);
move_first_non_whitespace(ed);
ed.set_sticky_col(Some(ed.cursor().1));
vim_mut(ed).mode = Mode::Normal;
}
Operator::ReflowKeepCursor => {
// `gww` / `Ngww` — reflow `count` rows starting at the cursor,
// but leave the cursor at the character it was on before reflow.
let saved = ed.cursor();
ed.push_undo();
let (before, after, opts) = reflow_rows_keep_cursor(ed, row, end_row);
let (new_row, new_col) =
reflow_keep_cursor(row, saved.0, saved.1, &before, &after, opts);
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 => {
// `==` / `N==` — auto-indent `count` rows starting at cursor.
ed.push_undo();
auto_indent_rows(ed, row, end_row);
ed.set_sticky_col(Some(ed.cursor().1));
vim_mut(ed).mode = Mode::Normal;
}
Operator::Filter => {
// Filter is dispatched through Editor::filter_range, not here.
}
Operator::Comment => {
// Comment is dispatched through Editor::toggle_comment_range, not here.
// The doubled `gcc` path calls toggle_comment_range directly in
// apply_after_g, then records last_change. execute_line_op should
// not be reached for Comment — no-op if it is.
}
}
}