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
//! 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(crate) 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)
}
pub(crate) fn execute_line_op<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.
let last_content_row = if total >= 2
&& buf_line(ed.buffer(), total - 1)
.map(|s| s.is_empty())
.unwrap_or(false)
{
total - 2
} else {
total.saturating_sub(1)
};
if count >= 2 && row >= last_content_row {
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);
// Vim's `dd` / `Ndd` leaves the cursor on the *first
// non-blank* of the line that now occupies `row` — or, if
// the deletion consumed the last line, the line above it.
let total_after = buf_row_count(ed.buffer());
let raw_target = if deleted_through_last {
row.saturating_sub(1).min(total_after.saturating_sub(1))
} else {
row.min(total_after.saturating_sub(1))
};
// Clamp off the trailing phantom empty row that arises from a
// buffer with a trailing newline (stored as ["...", ""]). If
// the target row is the trailing empty row and there is a real
// content row above it, use that instead — matching vim's view
// that the trailing `\n` is a terminator, not a separator.
let target_row = if raw_target > 0
&& raw_target + 1 == total_after
&& buf_line(ed.buffer(), raw_target)
.map(|s| s.is_empty())
.unwrap_or(false)
{
raw_target - 1
} else {
raw_target
};
buf_set_cursor_rc(ed.buffer_mut(), target_row, 0);
move_first_non_whitespace(ed);
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) = reflow_rows_keep_cursor(ed, row, end_row);
let (new_row, new_col) = reflow_keep_cursor(row, 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 => {
// `==` / `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.
}
}
}