use hjkl_vim_types::{Mode, Operator, RangeKind};
use super::command::{build_indent, cut_vim_range_inner, indent_width, read_vim_range};
use super::*;
use crate::vim_state::vim_mut;
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{buf_line_chars, buf_row_count, buf_set_cursor_rc};
use hjkl_engine::rope_util::rope_line_to_str;
pub fn text_object_inner_quote_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
quote: char,
) -> Option<((usize, usize), (usize, usize))> {
quote_text_object(ed, quote, true, 1)
}
pub fn text_object_around_quote_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
quote: char,
) -> Option<((usize, usize), (usize, usize))> {
quote_text_object(ed, quote, false, 1)
}
pub fn text_object_inner_bracket_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
open: char,
) -> Option<((usize, usize), (usize, usize))> {
bracket_text_object(ed, open, true, 1).map(|(s, e, _kind)| (s, e))
}
pub fn text_object_around_bracket_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
open: char,
) -> Option<((usize, usize), (usize, usize))> {
bracket_text_object(ed, open, false, 1).map(|(s, e, _kind)| (s, e))
}
pub fn text_object_inner_sentence_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> Option<((usize, usize), (usize, usize))> {
sentence_text_object(ed, true, 1)
}
pub fn text_object_around_sentence_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> Option<((usize, usize), (usize, usize))> {
sentence_text_object(ed, false, 1)
}
pub fn text_object_inner_paragraph_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> Option<((usize, usize), (usize, usize))> {
paragraph_text_object(ed, true, 1)
}
pub fn text_object_around_paragraph_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> Option<((usize, usize), (usize, usize))> {
paragraph_text_object(ed, false, 1)
}
pub fn text_object_inner_tag_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> Option<((usize, usize), (usize, usize))> {
tag_text_object(ed, true)
}
pub fn text_object_around_tag_bridge<H: hjkl_engine::types::Host>(
ed: &Editor<hjkl_buffer::View, H>,
) -> Option<((usize, usize), (usize, usize))> {
tag_text_object(ed, false)
}
pub fn greedy_wrap(original: &[String], opts: WrapOpts) -> Vec<String> {
let mut wrapped: Vec<String> = Vec::new();
let mut paragraph: Vec<String> = Vec::new();
for line in original {
if line.trim().is_empty() {
flush_paragraph(&mut paragraph, &mut wrapped, opts);
wrapped.push(line.clone());
} else {
paragraph.push(line.clone());
}
}
flush_paragraph(&mut paragraph, &mut wrapped, opts);
wrapped
}
#[derive(Debug, Clone, Copy)]
pub struct WrapOpts {
pub width: usize,
pub tabstop: usize,
pub autoindent: bool,
pub expandtab: bool,
}
impl WrapOpts {
pub fn from_settings(settings: &hjkl_engine::Settings) -> Self {
Self {
width: settings.textwidth.max(1),
tabstop: settings.tabstop.max(1),
autoindent: settings.autoindent,
expandtab: settings.expandtab,
}
}
}
fn flush_paragraph(paragraph: &mut Vec<String>, out: &mut Vec<String>, opts: WrapOpts) {
if paragraph.is_empty() {
return;
}
out.extend(wrap_paragraph(paragraph, opts));
paragraph.clear();
}
fn wrap_paragraph(lines: &[String], opts: WrapOpts) -> Vec<String> {
let (leader, tokens) = paragraph_tokens(lines);
wrap_tokens(&tokens, &leader, opts)
}
fn join_paragraph(lines: &[String]) -> String {
let mut joined = lines[0].clone();
for line in &lines[1..] {
let stripped = line.trim_start_matches([' ', '\t']);
let ends_ws = joined.chars().last().is_some_and(|c| c == ' ' || c == '\t');
if !ends_ws {
joined.push(' ');
}
joined.push_str(stripped);
}
joined
}
fn split_leader(s: &str) -> (&str, &str) {
let mut end = 0usize;
for (i, c) in s.char_indices() {
if c == ' ' || c == '\t' {
end = i + c.len_utf8();
} else {
break;
}
}
s.split_at(end)
}
fn tokenize_body(body: &str) -> Vec<(String, String)> {
let mut tokens = Vec::new();
let mut chars = body.chars().peekable();
let is_ws = |c: char| c == ' ' || c == '\t';
while chars.peek().is_some() {
let mut word = String::new();
while let Some(&c) = chars.peek() {
if is_ws(c) {
break;
}
word.push(c);
chars.next();
}
let mut gap = String::new();
while let Some(&c) = chars.peek() {
if !is_ws(c) {
break;
}
gap.push(c);
chars.next();
}
tokens.push((word, gap));
}
tokens
}
fn paragraph_tokens(lines: &[String]) -> (String, Vec<(String, String)>) {
let joined = join_paragraph(lines);
let (leader, body) = split_leader(&joined);
(leader.to_string(), tokenize_body(body))
}
fn display_width(s: &str, tabstop: usize) -> usize {
hjkl_buffer::char_col_to_visual_col(s, s.chars().count(), tabstop)
}
fn wrap_tokens(tokens: &[(String, String)], leader: &str, opts: WrapOpts) -> Vec<String> {
wrap_tokens_with_map(tokens, leader, opts).0
}
fn wrap_tokens_with_map(
tokens: &[(String, String)],
leader: &str,
opts: WrapOpts,
) -> (Vec<String>, Vec<Option<(usize, usize)>>) {
let WrapOpts { width, tabstop, .. } = opts;
let leader_width = display_width(leader, tabstop);
let shown_leader = build_indent(leader_width, opts.expandtab, tabstop);
let indent = if opts.autoindent {
shown_leader.as_str()
} else {
""
};
let indent_width = display_width(indent, tabstop);
let mut lines: Vec<String> = Vec::new();
let mut map: Vec<Option<(usize, usize)>> = Vec::new();
let mut current = String::new();
let mut first = true;
let mut pending_gap: Option<(usize, String)> = None;
let shown_len = shown_leader.chars().count();
for (i, _) in leader.chars().enumerate() {
map.push(Some((0, i.min(shown_len.saturating_sub(1)))));
}
current.push_str(&shown_leader);
let mut current_width = leader_width;
for (word, gap) in tokens {
let word_width = display_width(word, tabstop);
if first {
for ch in word.chars() {
map.push(Some((lines.len(), current.chars().count())));
current.push(ch);
}
current_width += word_width;
first = false;
} else {
let (gap_start, gap_str) = pending_gap.take().expect("gap between words");
let gap_width = display_width(&gap_str, tabstop);
if current_width + gap_width + word_width > width {
lines.push(std::mem::take(&mut current));
current.push_str(indent);
for ch in word.chars() {
map.push(Some((lines.len(), current.chars().count())));
current.push(ch);
}
current_width = indent_width + word_width;
} else {
for (k, ch) in gap_str.chars().enumerate() {
map[gap_start + k] = Some((lines.len(), current.chars().count()));
current.push(ch);
}
for ch in word.chars() {
map.push(Some((lines.len(), current.chars().count())));
current.push(ch);
}
current_width += gap_width + word_width;
}
}
let gap_start = map.len();
for _ in gap.chars() {
map.push(None);
}
pending_gap = Some((gap_start, gap.clone()));
}
if let Some((gap_start, gap_str)) = pending_gap.take() {
for (k, ch) in gap_str.chars().enumerate() {
map[gap_start + k] = Some((lines.len(), current.chars().count()));
current.push(ch);
}
}
if !current.is_empty() {
lines.push(current);
}
(lines, map)
}
pub fn reflow_rows<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
) {
let opts = WrapOpts::from_settings(ed.settings());
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let n = rope.len_lines();
let bot = bot.min(n.saturating_sub(1));
if top > bot {
return;
}
let original: Vec<String> = (top..=bot).map(|r| rope_line_to_str(&rope, r)).collect();
let wrapped = greedy_wrap(&original, opts);
let last_row = top + wrapped.len().saturating_sub(1);
use hjkl_buffer::{Edit, Position};
let joined = wrapped.join("\n");
let (start, end, with) = if bot + 1 < n {
(
Position::new(top, 0),
Position::new(bot, buf_line_chars(ed.buffer(), bot)),
joined,
)
} else if top > 0 {
(
Position::new(top - 1, buf_line_chars(ed.buffer(), top - 1)),
Position::new(bot, buf_line_chars(ed.buffer(), bot)),
format!("\n{joined}"),
)
} else {
(
Position::new(0, 0),
Position::new(bot, buf_line_chars(ed.buffer(), bot)),
joined,
)
};
ed.mutate_edit(Edit::Replace { start, end, with });
buf_set_cursor_rc(ed.buffer_mut(), last_row, 0);
move_first_non_whitespace(ed);
ed.mark_content_dirty();
}
pub fn reflow_rows_keep_cursor<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
) -> (Vec<String>, Vec<String>, WrapOpts) {
let opts = WrapOpts::from_settings(ed.settings());
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let n = rope.len_lines();
let bot = bot.min(n.saturating_sub(1));
if top > bot {
return (Vec::new(), Vec::new(), opts);
}
let original: Vec<String> = (top..=bot).map(|r| rope_line_to_str(&rope, r)).collect();
let wrapped = greedy_wrap(&original, opts);
use hjkl_buffer::{Edit, Position};
let joined = wrapped.join("\n");
let (start, end, with) = if bot + 1 < n {
(
Position::new(top, 0),
Position::new(bot, buf_line_chars(ed.buffer(), bot)),
joined,
)
} else if top > 0 {
(
Position::new(top - 1, buf_line_chars(ed.buffer(), top - 1)),
Position::new(bot, buf_line_chars(ed.buffer(), bot)),
format!("\n{joined}"),
)
} else {
(
Position::new(0, 0),
Position::new(bot, buf_line_chars(ed.buffer(), bot)),
joined,
)
};
ed.mutate_edit(Edit::Replace { start, end, with });
buf_set_cursor_rc(ed.buffer_mut(), top, 0);
ed.mark_content_dirty();
(original, wrapped, opts)
}
pub fn reflow_keep_cursor(
top: usize,
cursor_row: usize,
cursor_col: usize,
before_lines: &[String],
after_lines: &[String],
opts: WrapOpts,
) -> (usize, usize) {
let relative_row = cursor_row.saturating_sub(top);
let mut before_i = 0usize;
let mut after_i = 0usize;
while before_i < before_lines.len() {
if before_lines[before_i].trim().is_empty() {
if before_i == relative_row {
let last = before_lines[before_i].chars().count().saturating_sub(1);
return (top + after_i, cursor_col.min(last));
}
before_i += 1;
after_i += 1;
continue;
}
let para_start = before_i;
while before_i < before_lines.len() && !before_lines[before_i].trim().is_empty() {
before_i += 1;
}
let para = &before_lines[para_start..before_i];
let (leader, tokens) = paragraph_tokens(para);
let (wrapped, map) = wrap_tokens_with_map(&tokens, &leader, opts);
if relative_row >= para_start && relative_row < before_i {
let offset = cursor_joined_offset(para, relative_row - para_start, cursor_col);
let (r, c) = map
.get(offset)
.copied()
.flatten()
.or_else(|| nearest_map_pos(&map, offset))
.unwrap_or((0, 0));
return (top + after_i + r, c);
}
after_i += wrapped.len();
}
let last = after_lines.len().saturating_sub(1);
let last_len = after_lines.get(last).map_or(0, |l| l.chars().count());
let col = if last_len == 0 { 0 } else { last_len - 1 };
(top + last, col)
}
fn cursor_joined_offset(para: &[String], row_in_para: usize, col: usize) -> usize {
let mut offset = 0usize;
for (i, line) in para.iter().enumerate() {
if i == row_in_para {
let (lead, _) = split_leader(line);
let lead_len = lead.chars().count();
if i == 0 {
offset += col.min(line.chars().count());
} else {
offset += col.saturating_sub(lead_len);
}
return offset;
}
let (_lead, rest) = split_leader(line);
offset += if i == 0 {
line.chars().count()
} else {
rest.chars().count()
};
let ends_ws = line.chars().last().is_some_and(|c| c == ' ' || c == '\t');
if !ends_ws {
offset += 1; }
}
offset
}
fn nearest_map_pos(map: &[Option<(usize, usize)>], offset: usize) -> Option<(usize, usize)> {
map[..offset.min(map.len())]
.iter()
.rev()
.find_map(|p| *p)
.or_else(|| map.iter().skip(offset).find_map(|p| *p))
}
pub fn apply_case_op_to_selection<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
op: Operator,
top: (usize, usize),
bot: (usize, usize),
kind: RangeKind,
) {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
let saved_yank = ed.yank();
let saved_yank_linewise = ed.yank_linewise();
let selection = read_vim_range(ed, top, bot, kind);
let n_rows_before = buf_row_count(ed.buffer());
cut_vim_range_inner(ed, top, bot, kind, false);
let transformed = match op {
Operator::Uppercase => selection.to_uppercase(),
Operator::Lowercase => selection.to_lowercase(),
Operator::ToggleCase => toggle_case_str(&selection),
Operator::Rot13 => rot13_str(&selection),
_ => unreachable!(),
};
if !transformed.is_empty() {
let (ordered_top, ordered_bot) = order(top, bot);
let n_rows = buf_row_count(ed.buffer());
let (insert_at, final_text) = if kind == RangeKind::Linewise && ordered_top.0 >= n_rows {
let last_row = n_rows.saturating_sub(1);
let last_col = buf_line_chars(ed.buffer(), last_row);
let body = transformed.trim_end_matches('\n');
(Position::new(last_row, last_col), format!("\n{}", body))
} else {
let at = match kind {
RangeKind::Linewise => Position::new(ordered_top.0, 0),
_ => Position::new(ordered_top.0, ordered_top.1),
};
let text = if kind == RangeKind::Linewise
&& n_rows == 1
&& ordered_top.0 == 0
&& n_rows_before == ordered_bot.0 - ordered_top.0 + 1
{
transformed.trim_end_matches('\n').to_string()
} else {
transformed
};
(at, text)
};
ed.mutate_edit(Edit::InsertStr {
at: insert_at,
text: final_text,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top.0, top.1);
ed.set_yank(saved_yank);
ed.set_yank_linewise(saved_yank_linewise);
vim_mut(ed).mode = Mode::Normal;
}
pub fn indent_rows<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
count: usize,
) {
ed.sync_buffer_content_from_textarea();
let width = ed.settings().shiftwidth.saturating_mul(count.max(1));
let expandtab = ed.settings().expandtab;
let tabstop = ed.settings().tabstop.max(1);
use hjkl_buffer::{Edit, Position};
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let bot = bot.min(rope.len_lines().saturating_sub(1));
for r in top..=bot {
let line = rope_line_to_str(&rope, r);
if line.is_empty() {
continue;
}
let old = indent_width(&line, tabstop);
let fill = indent_fill(0, old + width, expandtab, tabstop);
let ws = line.chars().take_while(|c| matches!(c, ' ' | '\t')).count();
ed.mutate_edit(Edit::Replace {
start: Position::new(r, 0),
end: Position::new(r, ws),
with: fill,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top, 0);
move_first_non_whitespace(ed);
}
fn indent_fill(at_col: usize, width: usize, expandtab: bool, tabstop: usize) -> String {
if expandtab {
return " ".repeat(width);
}
let tabstop = tabstop.max(1);
let target = at_col + width;
let mut out = String::new();
let mut col = at_col;
while col < target {
let next_stop = col + tabstop - col % tabstop;
if next_stop <= target {
out.push('\t');
col = next_stop;
} else {
out.push_str(&" ".repeat(target - col));
col = target;
}
}
out
}
pub fn indent_block<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
cols: BlockCols,
count: usize,
) {
ed.sync_buffer_content_from_textarea();
let width = ed.settings().shiftwidth.saturating_mul(count.max(1));
let expandtab = ed.settings().expandtab;
let tabstop = ed.settings().tabstop.max(1);
let cursor_col = block_start_col(ed, top, cols);
use hjkl_buffer::{Edit, Position};
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let bot = bot.min(rope.len_lines().saturating_sub(1));
for r in top..=bot {
let chars: Vec<char> = rope_line_to_str(&rope, r).chars().collect();
if chars.is_empty() {
continue;
}
let cells = |ch: char, vcol: usize| block_cell_width(ch, vcol, tabstop);
let (mut vcol, mut i) = (0, 0);
let (mut ws_vcol, mut ws_start) = (0, 0);
while vcol < cols.left && i < chars.len() {
let w = cells(chars[i], vcol);
vcol += w;
i += 1;
if !matches!(chars[i - 1], ' ' | '\t') {
ws_start = i;
ws_vcol = vcol;
}
}
if vcol < cols.left {
continue;
}
let mut end = i;
while end < chars.len() && matches!(chars[end], ' ' | '\t') {
vcol += cells(chars[end], vcol);
end += 1;
}
let fill = indent_fill(ws_vcol, width + vcol - ws_vcol, expandtab, tabstop);
ed.mutate_edit(Edit::Replace {
start: Position::new(r, ws_start),
end: Position::new(r, end),
with: fill,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top, cursor_col);
}
pub fn outdent_block<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
cols: BlockCols,
count: usize,
) {
ed.sync_buffer_content_from_textarea();
let width = ed.settings().shiftwidth.saturating_mul(count.max(1));
let tabstop = ed.settings().tabstop.max(1);
let cursor_col = block_start_col(ed, top, cols);
use hjkl_buffer::{Edit, Position};
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let bot = bot.min(rope.len_lines().saturating_sub(1));
for r in top..=bot {
let chars: Vec<char> = rope_line_to_str(&rope, r).chars().collect();
let cells = |ch: char, vcol: usize| block_cell_width(ch, vcol, tabstop);
let (mut vcol, mut i) = (0, 0);
while vcol < cols.left && i < chars.len() {
vcol += cells(chars[i], vcol);
i += 1;
}
if vcol < cols.left {
continue;
}
let mut non_white = i;
while non_white < chars.len() && matches!(chars[non_white], ' ' | '\t') {
vcol += cells(chars[non_white], vcol);
non_white += 1;
}
let shift = (vcol - cols.left).min(width);
if shift == 0 {
continue;
}
let dest = vcol - shift;
let (mut kept, mut kept_vcol) = (0, 0);
while kept < non_white {
let w = cells(chars[kept], kept_vcol);
if kept_vcol + w > dest {
break;
}
kept_vcol += w;
kept += 1;
}
ed.mutate_edit(Edit::Replace {
start: Position::new(r, kept),
end: Position::new(r, non_white),
with: " ".repeat(dest - kept_vcol),
});
}
buf_set_cursor_rc(ed.buffer_mut(), top, cursor_col);
}
pub fn outdent_rows<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
count: usize,
) {
ed.sync_buffer_content_from_textarea();
let width = ed.settings().shiftwidth.saturating_mul(count.max(1));
let expandtab = ed.settings().expandtab;
let tabstop = ed.settings().tabstop.max(1);
use hjkl_buffer::{Edit, Position};
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let bot = bot.min(rope.len_lines().saturating_sub(1));
for r in top..=bot {
let line = rope_line_to_str(&rope, r);
let old = indent_width(&line, tabstop);
if old == 0 {
continue;
}
let fill = indent_fill(0, old.saturating_sub(width), expandtab, tabstop);
let ws = line.chars().take_while(|c| matches!(c, ' ' | '\t')).count();
ed.mutate_edit(Edit::Replace {
start: Position::new(r, 0),
end: Position::new(r, ws),
with: fill,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top, 0);
move_first_non_whitespace(ed);
}
pub fn bracket_net(line: &str) -> i32 {
let mut net: i32 = 0;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'/' if chars.peek() == Some(&'/') => return net,
'"' => {
while let Some(c) = chars.next() {
match c {
'\\' => {
chars.next();
} '"' => break,
_ => {}
}
}
}
'\'' => {
let saved: Vec<char> = chars.clone().take(5).collect();
let close_idx = if saved.first() == Some(&'\\') {
saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
} else {
saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
};
if let Some(idx) = close_idx {
for _ in 0..=idx {
chars.next();
}
}
}
'{' | '(' | '[' => net += 1,
'}' | ')' | ']' => net -= 1,
_ => {}
}
}
net
}
pub fn auto_indent_rows<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
) {
ed.sync_buffer_content_from_textarea();
let shiftwidth = ed.settings().shiftwidth;
let expandtab = ed.settings().expandtab;
let indent_unit: String = if expandtab {
" ".repeat(shiftwidth)
} else {
"\t".to_string()
};
use hjkl_buffer::{Edit, Position};
let rope = hjkl_engine::types::Query::rope(ed.buffer());
let bot = bot.min(rope.len_lines().saturating_sub(1));
let mut depth: i32 = 0;
for r in 0..top {
let line = rope_line_to_str(&rope, r);
depth += bracket_net(&line);
if depth < 0 {
depth = 0;
}
}
for r in top..=bot {
let line = rope_line_to_str(&rope, r);
let line_chars = line.chars().count();
let trimmed_owned = line.trim_start().to_owned();
if trimmed_owned.is_empty() {
if !line.is_empty() {
ed.mutate_edit(Edit::Replace {
start: Position::new(r, 0),
end: Position::new(r, line_chars),
with: String::new(),
});
}
continue;
}
let starts_with_close = trimmed_owned
.chars()
.next()
.is_some_and(|c| matches!(c, '}' | ')' | ']'));
let starts_with_dot = trimmed_owned.starts_with('.')
&& !trimmed_owned.starts_with("..")
&& !trimmed_owned.starts_with(".;");
let effective_depth = if starts_with_close {
depth.saturating_sub(1)
} else if starts_with_dot {
depth.saturating_add(1)
} else {
depth
}
.max(0) as usize;
let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
depth += bracket_net(&trimmed_owned);
if depth < 0 {
depth = 0;
}
ed.mutate_edit(Edit::Replace {
start: Position::new(r, 0),
end: Position::new(r, line_chars),
with: new_line,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top, 0);
move_first_non_whitespace(ed);
ed.set_last_indent_range(Some((top, bot)));
}
pub fn toggle_case_str(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_lowercase() {
c.to_uppercase().collect::<String>()
} else if c.is_uppercase() {
c.to_lowercase().collect::<String>()
} else {
c.to_string()
}
})
.collect()
}
pub fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
if a <= b { (a, b) } else { (b, a) }
}
pub fn clamp_cursor_to_normal_mode<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
) {
let (row, col) = ed.cursor();
let line_chars = buf_line_chars(ed.buffer(), row);
let max_col = line_chars.saturating_sub(1);
if col > max_col {
buf_set_cursor_rc(ed.buffer_mut(), row, max_col);
}
}
#[cfg(test)]
mod tests {
use super::{
WrapOpts, apply_case_op_to_selection, greedy_wrap, indent_rows, outdent_rows,
reflow_keep_cursor, reflow_rows, toggle_case_str,
};
use hjkl_buffer::{View, rope_line_str};
use hjkl_engine::{DefaultHost, Editor, Options};
#[test]
fn toggle_case_preserves_multi_character_unicode_mappings() {
assert_eq!(toggle_case_str("Straße"), "sTRASSE");
assert_eq!(toggle_case_str("İ"), "i\u{307}");
}
#[test]
fn outdent_rows_consumes_visual_tab_width() {
let options = Options {
tabstop: 4,
shiftwidth: 4,
expandtab: false,
..Options::default()
};
let mut ed = Editor::new(View::from_str("\t\tfoo"), DefaultHost::new(), options);
outdent_rows(&mut ed, 0, 0, 1);
assert_eq!(rope_line_str(&ed.buffer().rope(), 0), "\tfoo");
}
#[test]
fn indent_rows_expandtab_recomputes_indent_as_spaces() {
let options = Options {
tabstop: 4,
shiftwidth: 4,
expandtab: true,
..Options::default()
};
let mut ed = Editor::new(View::from_str("\tabc"), DefaultHost::new(), options);
indent_rows(&mut ed, 0, 0, 1);
assert_eq!(rope_line_str(&ed.buffer().rope(), 0), " abc");
}
#[test]
fn outdent_rows_expandtab_recomputes_indent_as_spaces() {
let options = Options {
tabstop: 4,
shiftwidth: 4,
expandtab: true,
..Options::default()
};
let mut ed = Editor::new(View::from_str("\t\tabc"), DefaultHost::new(), options);
outdent_rows(&mut ed, 0, 0, 1);
assert_eq!(rope_line_str(&ed.buffer().rope(), 0), " abc");
}
#[test]
fn reflow_rows_mid_buffer_range() {
let options = Options {
textwidth: 4,
..Options::default()
};
let mut ed = Editor::new(
View::from_str("a b c d e f g h\nTAIL\n"),
DefaultHost::new(),
options,
);
reflow_rows(&mut ed, 0, 0);
let rope = ed.buffer().rope();
assert_eq!(rope_line_str(&rope, 0), "a b");
assert_eq!(rope_line_str(&rope, 1), "c d");
assert_eq!(rope_line_str(&rope, 2), "e f");
assert_eq!(rope_line_str(&rope, 3), "g h");
assert_eq!(rope_line_str(&rope, 4), "TAIL");
}
#[test]
fn reflow_rows_last_row_keeps_preceding_separator() {
let options = Options {
textwidth: 4,
..Options::default()
};
let mut ed = Editor::new(
View::from_str("KEEP\na b c d e"),
DefaultHost::new(),
options,
);
reflow_rows(&mut ed, 1, 1);
let rope = ed.buffer().rope();
assert_eq!(rope_line_str(&rope, 0), "KEEP");
assert_eq!(rope_line_str(&rope, 1), "a b");
assert_eq!(rope_line_str(&rope, 2), "c d");
assert_eq!(rope_line_str(&rope, 3), "e");
}
#[test]
fn reflow_rows_whole_buffer() {
let options = Options {
textwidth: 4,
..Options::default()
};
let mut ed = Editor::new(View::from_str("a b c d e"), DefaultHost::new(), options);
reflow_rows(&mut ed, 0, 0);
let rope = ed.buffer().rope();
assert_eq!(rope_line_str(&rope, 0), "a b");
assert_eq!(rope_line_str(&rope, 1), "c d");
assert_eq!(rope_line_str(&rope, 2), "e");
}
#[test]
fn reflow_rows_preserves_trailing_newline() {
let options = Options {
textwidth: 4,
..Options::default()
};
let mut ed = Editor::new(View::from_str("a b c d e\n"), DefaultHost::new(), options);
reflow_rows(&mut ed, 0, 0);
let rope = ed.buffer().rope();
assert_eq!(rope_line_str(&rope, 0), "a b");
assert_eq!(rope_line_str(&rope, 1), "c d");
assert_eq!(rope_line_str(&rope, 2), "e");
assert_eq!(rope_line_str(&rope, 3), "");
}
fn mk(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
fn ai(width: usize, tabstop: usize) -> WrapOpts {
WrapOpts {
width,
tabstop,
autoindent: true,
expandtab: false,
}
}
#[test]
fn greedy_wrap_preserves_interior_runs_when_within_width() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb ccc"]), ai(79, 8)),
mk(&["aaa bbb ccc"])
);
}
#[test]
fn greedy_wrap_keeps_interior_gap_drops_gap_before_break() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb ccc ddd eee"]), ai(10, 8)),
mk(&["aaa bbb", "ccc ddd", "eee"])
);
}
#[test]
fn greedy_wrap_joins_multiline_paragraph() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb", "ccc ddd"]), ai(10, 8)),
mk(&["aaa bbb", "ccc ddd"])
);
assert_eq!(
greedy_wrap(&mk(&["aaa ", "bbb ccc"]), ai(40, 8)),
mk(&["aaa bbb ccc"])
);
assert_eq!(
greedy_wrap(&mk(&["aaa", " bbb ccc"]), ai(40, 8)),
mk(&["aaa bbb ccc"])
);
}
#[test]
fn greedy_wrap_reemits_leader_on_continuation_lines() {
assert_eq!(
greedy_wrap(&mk(&[" aaa bbb", "ccc"]), ai(10, 8)),
mk(&[" aaa", " bbb ccc"])
);
}
#[test]
fn greedy_wrap_preserves_trailing_whitespace() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb ccc "]), ai(10, 8)),
mk(&["aaa bbb", "ccc "])
);
}
#[test]
fn greedy_wrap_single_long_word_overflows_unchanged() {
assert_eq!(
greedy_wrap(&mk(&["supercalifragilistic"]), ai(10, 8)),
mk(&["supercalifragilistic"])
);
assert_eq!(
greedy_wrap(&mk(&["aaa supercalifragilistic bbb"]), ai(10, 8)),
mk(&["aaa", "supercalifragilistic", "bbb"])
);
}
#[test]
fn greedy_wrap_tab_counts_tabstop_and_is_preserved() {
assert_eq!(
greedy_wrap(&mk(&["aaa\tbbb ccc ddd"]), ai(10, 8)),
mk(&["aaa", "bbb ccc", "ddd"])
);
assert_eq!(
greedy_wrap(&mk(&["aa\tbb cc"]), ai(40, 8)),
mk(&["aa\tbb cc"])
);
}
#[test]
fn greedy_wrap_preserves_blank_line_separators() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb", "", "ccc ddd"]), ai(10, 8)),
mk(&["aaa bbb", "", "ccc ddd"])
);
}
#[test]
fn reflow_keep_cursor_preserves_word_char() {
let before = mk(&["one two three four five"]);
let after = mk(&["one two", "three four", "five"]);
assert_eq!(
reflow_keep_cursor(0, 0, 8, &before, &after, ai(10, 8)),
(1, 0)
);
}
#[test]
fn reflow_keep_cursor_clamps_dropped_gap_to_previous_line() {
let before = mk(&["aaa bbb ccc ddd eee"]);
let after = mk(&["aaa bbb", "ccc ddd", "eee"]);
assert_eq!(
reflow_keep_cursor(0, 0, 9, &before, &after, ai(10, 8)),
(0, 7)
);
}
#[test]
fn case_op_preserves_clipboard_small_delete_and_unnamed() {
use hjkl_engine::{Host as _, Slot};
use hjkl_vim_types::{Operator, RangeKind};
let mut ed = crate::vim::vim_editor(
View::from_str("ab1 cd"),
DefaultHost::new(),
Options::default(),
);
ed.host_mut().write_clipboard("CLIP-SENTINEL".into());
ed.with_registers_mut(|regs| {
regs.small_delete = Slot {
text: "SMALL-SENTINEL".into(),
..Default::default()
};
regs.unnamed = Slot {
text: "PRE-YANK".into(),
..Default::default()
};
});
apply_case_op_to_selection(
&mut ed,
Operator::Uppercase,
(0, 0),
(0, 1),
RangeKind::Inclusive,
);
assert_eq!(rope_line_str(&ed.buffer().rope(), 0), "AB1 cd");
assert_eq!(ed.host_mut().read_clipboard(), Some("CLIP-SENTINEL".into()));
ed.with_registers(|regs| {
assert_eq!(regs.small_delete.text, "SMALL-SENTINEL");
assert_eq!(regs.unnamed.text, "PRE-YANK");
});
}
#[test]
fn linewise_case_op_does_not_shift_delete_ring() {
use hjkl_engine::{Host as _, Slot};
use hjkl_vim_types::{Operator, RangeKind};
let mut ed = crate::vim::vim_editor(
View::from_str("abc\ndef"),
DefaultHost::new(),
Options::default(),
);
ed.host_mut().write_clipboard("CLIP-SENTINEL".into());
ed.with_registers_mut(|regs| {
regs.delete_ring[0] = Slot {
text: "RING-SENTINEL".into(),
..Default::default()
};
regs.unnamed = Slot {
text: "PRE-YANK".into(),
..Default::default()
};
});
apply_case_op_to_selection(
&mut ed,
Operator::Uppercase,
(0, 0),
(0, 0),
RangeKind::Linewise,
);
assert_eq!(rope_line_str(&ed.buffer().rope(), 0), "ABC");
assert_eq!(rope_line_str(&ed.buffer().rope(), 1), "def");
assert_eq!(ed.host_mut().read_clipboard(), Some("CLIP-SENTINEL".into()));
ed.with_registers(|regs| {
assert_eq!(regs.delete_ring[0].text, "RING-SENTINEL");
assert_eq!(regs.unnamed.text, "PRE-YANK");
});
}
}