use hjkl_vim_types::{Mode, Operator, RangeKind};
use super::command::{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)
}
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)
}
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], width: usize, tabstop: usize) -> 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, width, tabstop);
wrapped.push(String::new());
} else {
paragraph.push(line.clone());
}
}
flush_paragraph(&mut paragraph, &mut wrapped, width, tabstop);
wrapped
}
fn flush_paragraph(
paragraph: &mut Vec<String>,
out: &mut Vec<String>,
width: usize,
tabstop: usize,
) {
if paragraph.is_empty() {
return;
}
out.extend(wrap_paragraph(paragraph, width, tabstop));
paragraph.clear();
}
fn wrap_paragraph(lines: &[String], width: usize, tabstop: usize) -> Vec<String> {
let (leader, tokens) = paragraph_tokens(lines);
wrap_tokens(&tokens, &leader, width, tabstop)
}
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,
width: usize,
tabstop: usize,
) -> Vec<String> {
wrap_tokens_with_map(tokens, leader, width, tabstop).0
}
fn wrap_tokens_with_map(
tokens: &[(String, String)],
leader: &str,
width: usize,
tabstop: usize,
) -> (Vec<String>, Vec<Option<(usize, usize)>>) {
let leader_width = display_width(leader, 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;
for ch in leader.chars() {
map.push(Some((0, current.chars().count())));
current.push(ch);
}
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(leader);
for ch in word.chars() {
map.push(Some((lines.len(), current.chars().count())));
current.push(ch);
}
current_width = leader_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 width = ed.settings().textwidth.max(1);
let tabstop = ed.settings().tabstop.max(1);
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, width, tabstop);
let last_offset = wrapped
.iter()
.rposition(|l| !l.trim().is_empty())
.unwrap_or(0);
let last_row = top + last_offset;
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>, usize, usize) {
let width = ed.settings().textwidth.max(1);
let tabstop = ed.settings().tabstop.max(1);
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(), width, tabstop);
}
let original: Vec<String> = (top..=bot).map(|r| rope_line_to_str(&rope, r)).collect();
let wrapped = greedy_wrap(&original, width, tabstop);
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, width, tabstop)
}
pub fn reflow_keep_cursor(
top: usize,
cursor_row: usize,
cursor_col: usize,
before_lines: &[String],
after_lines: &[String],
width: usize,
tabstop: usize,
) -> (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 {
return (top + after_i, 0);
}
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, width, tabstop);
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,
left: 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.chars().count() < left {
continue;
}
let at_col = hjkl_buffer::geom::char_col_to_visual_col(&line, left, tabstop);
let fill = indent_fill(at_col, width, expandtab, tabstop);
ed.mutate_edit(Edit::InsertStr {
at: Position::new(r, left),
text: fill,
});
}
buf_set_cursor_rc(ed.buffer_mut(), top, left);
}
pub fn outdent_block<H: hjkl_engine::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
top: usize,
bot: usize,
left: usize,
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);
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.chars().count() < left {
continue;
}
let start_col = hjkl_buffer::geom::char_col_to_visual_col(&line, left, tabstop);
let target = start_col + width;
let mut col = start_col;
let mut end = left;
let mut leftover = 0usize;
for ch in line.chars().skip(left) {
if !matches!(ch, ' ' | '\t') || col >= target {
break;
}
let next_col = if ch == '\t' {
col + tabstop - col % tabstop
} else {
col + 1
};
end += 1;
if next_col > target {
leftover = next_col - target;
break;
}
col = next_col;
}
if end > left {
ed.mutate_edit(Edit::Replace {
start: Position::new(r, left),
end: Position::new(r, end),
with: " ".repeat(leftover),
});
}
}
buf_set_cursor_rc(ed.buffer_mut(), top, left);
}
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::{
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()
}
#[test]
fn greedy_wrap_preserves_interior_runs_when_within_width() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb ccc"]), 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"]), 10, 8),
mk(&["aaa bbb", "ccc ddd", "eee"])
);
}
#[test]
fn greedy_wrap_joins_multiline_paragraph() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb", "ccc ddd"]), 10, 8),
mk(&["aaa bbb", "ccc ddd"])
);
assert_eq!(
greedy_wrap(&mk(&["aaa ", "bbb ccc"]), 40, 8),
mk(&["aaa bbb ccc"])
);
assert_eq!(
greedy_wrap(&mk(&["aaa", " bbb ccc"]), 40, 8),
mk(&["aaa bbb ccc"])
);
}
#[test]
fn greedy_wrap_reemits_leader_on_continuation_lines() {
assert_eq!(
greedy_wrap(&mk(&[" aaa bbb", "ccc"]), 10, 8),
mk(&[" aaa", " bbb ccc"])
);
}
#[test]
fn greedy_wrap_preserves_trailing_whitespace() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb ccc "]), 10, 8),
mk(&["aaa bbb", "ccc "])
);
}
#[test]
fn greedy_wrap_single_long_word_overflows_unchanged() {
assert_eq!(
greedy_wrap(&mk(&["supercalifragilistic"]), 10, 8),
mk(&["supercalifragilistic"])
);
assert_eq!(
greedy_wrap(&mk(&["aaa supercalifragilistic bbb"]), 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"]), 10, 8),
mk(&["aaa", "bbb ccc", "ddd"])
);
assert_eq!(greedy_wrap(&mk(&["aa\tbb cc"]), 40, 8), mk(&["aa\tbb cc"]));
}
#[test]
fn greedy_wrap_preserves_blank_line_separators() {
assert_eq!(
greedy_wrap(&mk(&["aaa bbb", "", "ccc ddd"]), 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, 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, 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");
});
}
}