mod action;
mod theme;
pub use action::Direction;
pub use theme::EditorTheme;
use crate::buffer::Buffer;
use crate::cursor::{Cursor, Selection, grapheme_column, offset_at_column, prev_grapheme_boundary};
use crate::marker::{LineMarkers, MarkerKind, OrderedMarker, UnorderedMarker};
use crate::table::{RowKind, TableCell, TableInfo};
struct LineContext {
line: LineMarkers,
}
#[derive(Clone, Default)]
struct TabCycleCache {
line_idx: usize,
states: Vec<String>,
}
fn ancestor_of_kind<'a>(node: tree_sitter::Node<'a>, kind: &str) -> Option<tree_sitter::Node<'a>> {
let mut current = Some(node);
while let Some(n) = current {
if n.kind() == kind {
return Some(n);
}
current = n.parent();
}
None
}
fn unescaped_pipe_indices(s: &str) -> Vec<usize> {
let bytes = s.as_bytes();
let mut out = Vec::new();
for i in 0..bytes.len() {
if bytes[i] == b'|' {
let mut backslashes = 0;
let mut j = i;
while j > 0 && bytes[j - 1] == b'\\' {
backslashes += 1;
j -= 1;
}
if backslashes % 2 == 0 {
out.push(i);
}
}
}
out
}
fn pipe_row_ncols(trimmed: &str, pipes: &[usize]) -> usize {
let mut segs: Vec<&str> = Vec::with_capacity(pipes.len() + 1);
let mut start = 0;
for &p in pipes {
segs.push(&trimmed[start..p]);
start = p + 1;
}
segs.push(&trimmed[start..]);
if segs.first().is_some_and(|s| s.is_empty()) {
segs.remove(0);
}
if segs.last().is_some_and(|s| s.is_empty()) {
segs.pop();
}
segs.len()
}
fn looks_like_delimiter_row(trimmed: &str) -> bool {
!trimmed.is_empty()
&& trimmed.contains('-')
&& trimmed
.chars()
.all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
}
pub struct EditorState {
pub buffer: Buffer,
pub selection: Selection,
tab_cycle_cache: Option<TabCycleCache>,
goal_column: Option<(usize, usize)>,
}
impl EditorState {
pub fn new(content: &str) -> Self {
let buffer: Buffer = content.parse().unwrap_or_default();
Self {
buffer,
selection: Selection::new(0, 0),
tab_cycle_cache: None,
goal_column: None,
}
}
pub fn cursor(&self) -> Cursor {
self.selection.cursor()
}
pub fn text(&self) -> String {
self.buffer.text()
}
pub fn set_cursor(&mut self, offset: usize) {
let offset = offset.min(self.buffer.len_bytes());
self.selection = Selection::new(offset, offset);
}
fn set_cursor_to(&mut self, c: Cursor) {
self.selection = Selection::new(c.offset, c.offset);
}
pub fn cursor_in_direction(&mut self, direction: Direction) -> Cursor {
let c = self.cursor();
if matches!(direction, Direction::Up | Direction::Down) {
let line = self.buffer.byte_to_line(c.offset);
let column = match self.goal_column {
Some((col, at)) if at == c.offset => col,
_ => grapheme_column(&self.buffer, c.offset),
};
let offset = if direction == Direction::Up {
if line == 0 {
Cursor::start().offset
} else {
offset_at_column(&self.buffer, line - 1, column)
}
} else if line >= self.buffer.line_count().saturating_sub(1) {
Cursor::end(&self.buffer).offset
} else {
offset_at_column(&self.buffer, line + 1, column)
};
self.goal_column = Some((column, offset));
return Cursor { offset };
}
match direction {
Direction::Left => c.move_left(&self.buffer),
Direction::Right => c.move_right(&self.buffer),
Direction::LineStart => c.move_to_line_start(&self.buffer),
Direction::LineEnd => c.move_to_line_end(&self.buffer),
Direction::DocStart => Cursor::start(),
Direction::DocEnd => Cursor::end(&self.buffer),
Direction::Up | Direction::Down => unreachable!("handled above"),
}
}
pub fn move_left(&mut self) {
let c = self.cursor_in_direction(Direction::Left);
self.set_cursor_to(c);
}
pub fn move_right(&mut self) {
let c = self.cursor_in_direction(Direction::Right);
self.set_cursor_to(c);
}
pub fn move_up(&mut self) {
let c = self.cursor_in_direction(Direction::Up);
self.set_cursor_to(c);
}
pub fn move_down(&mut self) {
let c = self.cursor_in_direction(Direction::Down);
self.set_cursor_to(c);
}
pub fn move_to_line_start(&mut self) {
self.set_cursor_to(self.cursor().move_to_line_start(&self.buffer));
}
pub fn move_to_line_end(&mut self) {
self.set_cursor_to(self.cursor().move_to_line_end(&self.buffer));
}
pub fn insert_text(&mut self, text: &str) {
self.tab_cycle_cache = None;
let cursor_before = self.cursor().offset;
let insert_pos = if !self.selection.is_collapsed() {
let range = self.selection.range();
self.buffer.delete(range.clone(), cursor_before);
range.start
} else {
cursor_before
};
self.buffer.insert(insert_pos, text, insert_pos);
let new_pos = insert_pos + text.len();
self.selection = Selection::new(new_pos, new_pos);
self.propagate_checkbox_after_edit();
}
fn find_line_at(&self, byte_pos: usize) -> Option<(usize, LineMarkers)> {
let idx = self.buffer.byte_to_line(byte_pos);
if idx < self.buffer.line_count() {
Some((idx, self.buffer.line_markers(idx)))
} else {
None
}
}
pub fn cursor_in_code_block(&self) -> bool {
let Some(tree) = self.buffer.tree() else {
return false;
};
let cursor_offset = self.cursor().offset;
let root = tree.block_tree().root_node();
let Some(node) = root.descendant_for_byte_range(cursor_offset, cursor_offset) else {
return false;
};
ancestor_of_kind(node, "fenced_code_block").is_some()
}
fn line_context(&self) -> Option<LineContext> {
let cursor_offset = self.cursor().offset;
let line_idx = self.buffer.byte_to_line(cursor_offset);
if line_idx >= self.buffer.line_count() {
return None;
}
let line = self.buffer.line_markers(line_idx);
Some(LineContext { line })
}
pub fn maybe_complete_blockquote_marker(&mut self) -> bool {
let cursor_pos = self.cursor().offset;
if cursor_pos == 0 {
return false;
}
if self.buffer.byte_at(cursor_pos - 1) != Some(b'>') {
return false;
}
if self.buffer.byte_at(cursor_pos) == Some(b' ') {
return false;
}
let line_idx = self.buffer.byte_to_line(cursor_pos);
if line_idx >= self.buffer.line_count() {
return false;
}
let line = self.buffer.line_markers(line_idx);
let has_blockquote = line
.markers
.iter()
.any(|m| matches!(m.kind, MarkerKind::BlockQuote));
if !has_blockquote {
return false;
}
self.insert_text(" ");
true
}
pub fn maybe_complete_code_fence(&mut self) {
let cursor_pos = self.cursor().offset;
if cursor_pos < 3 {
return;
}
let fence_char = self.buffer.byte_at(cursor_pos - 1);
if fence_char != Some(b'`') && fence_char != Some(b'~') {
return;
}
if self.buffer.byte_at(cursor_pos - 2) != fence_char
|| self.buffer.byte_at(cursor_pos - 3) != fence_char
{
return;
}
let line_idx = self.buffer.byte_to_line(cursor_pos);
let line_start = self.buffer.line_to_byte(line_idx);
let before_fence = self.buffer.slice_cow(line_start..(cursor_pos - 3));
let trimmed = before_fence.trim();
if !trimmed.is_empty() && !trimmed.chars().all(|c| c == '>') {
return;
}
let closing = if fence_char == Some(b'`') {
"\n```"
} else {
"\n~~~"
};
self.buffer.insert(cursor_pos, closing, cursor_pos);
}
pub fn try_insert_space(&mut self) -> bool {
if self.cursor_in_code_block() {
self.insert_text(" ");
return true;
}
let cursor = self.cursor();
let line_start = cursor.move_to_line_start(&self.buffer).offset;
if cursor.offset == line_start || self.cursor_at_blockquote_content_start() {
return false;
}
self.insert_text(" ");
true
}
fn cursor_at_blockquote_content_start(&self) -> bool {
let cursor_pos = self.cursor().offset;
let line_idx = self.buffer.byte_to_line(cursor_pos);
if line_idx >= self.buffer.line_count() {
return false;
}
let line = self.buffer.line_markers(line_idx);
if !line.is_blockquote_only() {
return false;
}
if let Some(marker_range) = line.marker_range() {
cursor_pos == marker_range.end
} else {
false
}
}
pub fn tab(&mut self) {
if self.table_cell_nav(true) {
return;
}
let Some((states, current_idx, prefix_end)) = self.get_tab_cycle_state() else {
return;
};
if states.len() <= 1 {
return;
}
let next_idx = (current_idx + 1) % states.len();
self.set_line_prefix(&states[next_idx], prefix_end);
self.propagate_checkbox_after_edit();
}
fn shift_tab_cycle(&mut self) {
let Some((states, current_idx, prefix_end)) = self.get_tab_cycle_state() else {
return;
};
if states.len() <= 1 {
return;
}
let prev_idx = if current_idx == 0 {
states.len() - 1
} else {
current_idx - 1
};
self.set_line_prefix(&states[prev_idx], prefix_end);
self.propagate_checkbox_after_edit();
}
fn get_tab_cycle_state(&mut self) -> Option<(Vec<String>, usize, usize)> {
let cursor_offset = self.cursor().offset;
let line_idx = self.buffer.byte_to_line(cursor_offset);
let line_start = self.buffer.line_to_byte(line_idx);
let current_checkbox = self.buffer.line_markers(line_idx).checkbox();
let states = if self
.tab_cycle_cache
.as_ref()
.is_some_and(|c| c.line_idx == line_idx)
{
self.tab_cycle_cache.as_ref().unwrap().states.clone()
} else {
let states = self.build_cycle_states_from_tree(cursor_offset, current_checkbox);
self.tab_cycle_cache = Some(TabCycleCache {
line_idx,
states: states.clone(),
});
states
};
if states.len() <= 1 {
return None;
}
let line_end = self
.buffer
.line_to_byte(line_idx + 1)
.min(self.buffer.len_bytes());
let line_text = self.buffer.slice_cow(line_start..line_end);
let mut best_match: Option<(usize, &str)> = None;
for (idx, state) in states.iter().enumerate() {
if line_text.starts_with(state)
&& (best_match.is_none() || state.len() > best_match.unwrap().1.len())
{
best_match = Some((idx, state));
}
}
let (current_idx, prefix_end) = match best_match {
Some((idx, state)) => (idx, line_start + state.len()),
None => (0, line_start), };
Some((states, current_idx, prefix_end))
}
pub fn build_cycle_states_from_tree(
&self,
cursor_offset: usize,
checkbox_state: Option<bool>,
) -> Vec<String> {
let Some(tree) = self.buffer.tree() else {
return vec![String::new()];
};
let root = tree.block_tree().root_node();
let cursor_line_idx = self.buffer.byte_to_line(cursor_offset);
let line_start = self.buffer.line_to_byte(cursor_line_idx);
let lookup_offset = if line_start > 0 { line_start - 1 } else { 0 };
let node = root.descendant_for_byte_range(lookup_offset, lookup_offset);
let Some(node) = node else {
return vec![String::new()];
};
let context_node = if self.is_in_error_node(node) {
self.find_context_from_error(node).unwrap_or(node)
} else {
node
};
let mut nodes_to_process: Vec<tree_sitter::Node> = Vec::new();
let mut blockquote_prefix = String::new();
let mut current = Some(context_node);
while let Some(n) = current {
if n.kind() == "block_quote" {
if let Some(marker_node) = n
.children(&mut n.walk())
.find(|c| c.kind() == "block_quote_marker")
{
let marker_text = self
.buffer
.slice_cow(marker_node.start_byte()..marker_node.end_byte());
blockquote_prefix = format!("{}{}", marker_text, blockquote_prefix);
}
} else if n.kind() == "list_item" {
nodes_to_process.push(n);
}
current = n.parent();
}
let mut list_levels: Vec<(usize, String, usize, bool)> = Vec::new();
for n in nodes_to_process {
let mut marker_text = String::new();
let mut list_marker_len = 0;
let mut marker_start = 0;
let mut is_ordered = false;
for child in n.children(&mut n.walk()) {
match child.kind() {
"list_marker_minus" | "list_marker_plus" | "list_marker_star" => {
marker_start = child.start_byte();
let text = self.buffer.slice_cow(child.start_byte()..child.end_byte());
list_marker_len = text.len();
marker_text.push_str(&text);
}
"list_marker_dot" | "list_marker_parenthesis" => {
marker_start = child.start_byte();
let text = self.buffer.slice_cow(child.start_byte()..child.end_byte());
list_marker_len = text.len();
marker_text.push_str(&text);
is_ordered = true;
}
"task_list_marker_checked" | "task_list_marker_unchecked" => {
let checkbox_text = match checkbox_state {
Some(true) => "[x]",
Some(false) | None => "[ ]",
};
marker_text.push_str(checkbox_text);
marker_text.push(' ');
}
_ => {}
}
}
if !marker_text.is_empty() {
let line_idx = self.buffer.byte_to_line(marker_start);
let line_start = self.buffer.line_to_byte(line_idx);
let absolute_indent = marker_start - line_start;
let indent = absolute_indent.saturating_sub(blockquote_prefix.len());
list_levels.push((indent, marker_text, list_marker_len, is_ordered));
}
}
if list_levels.is_empty() && blockquote_prefix.is_empty() {
return vec![String::new()];
}
list_levels.reverse();
let mut states = Vec::new();
if !blockquote_prefix.is_empty() {
states.push(blockquote_prefix.clone());
}
for (indent, marker, list_marker_len, is_ordered) in &list_levels {
let sibling_marker = if *is_ordered {
Self::increment_ordered_marker(marker)
} else {
marker.clone()
};
states.push(format!(
"{}{}{}",
blockquote_prefix,
" ".repeat(*indent),
sibling_marker
));
states.push(format!(
"{}{}",
blockquote_prefix,
" ".repeat(indent + list_marker_len)
));
}
if let Some((deepest_indent, deepest_marker, list_marker_len, is_ordered)) =
list_levels.last()
{
let deeper_indent = deepest_indent + list_marker_len;
let nested_marker = if *is_ordered {
Self::reset_ordered_marker(deepest_marker)
} else {
deepest_marker.clone()
};
states.push(format!(
"{}{}{}",
blockquote_prefix,
" ".repeat(deeper_indent),
nested_marker
));
}
states.push(String::new());
states
}
fn increment_ordered_marker(marker: &str) -> String {
let num_end = marker
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(marker.len());
if num_end == 0 {
return marker.to_string();
}
let num: usize = marker[..num_end].parse().unwrap_or(1);
format!("{}{}", num + 1, &marker[num_end..])
}
fn reset_ordered_marker(marker: &str) -> String {
let num_end = marker
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(marker.len());
if num_end == 0 {
return marker.to_string();
}
format!("1{}", &marker[num_end..])
}
fn is_in_error_node(&self, node: tree_sitter::Node) -> bool {
ancestor_of_kind(node, "ERROR").is_some()
}
fn find_context_from_error<'a>(
&self,
node: tree_sitter::Node<'a>,
) -> Option<tree_sitter::Node<'a>> {
let mut current = Some(node);
while let Some(n) = current {
if n.kind() == "ERROR" {
if let Some(prev) = n.prev_sibling() {
return self.find_last_list_item(prev);
}
return None;
}
current = n.parent();
}
None
}
fn find_last_list_item<'a>(
&self,
node: tree_sitter::Node<'a>,
) -> Option<tree_sitter::Node<'a>> {
let mut result: Option<tree_sitter::Node<'a>> = None;
if node.kind() == "list_item" {
result = Some(node);
}
let child_count = node.child_count();
for i in (0..child_count).rev() {
if let Some(child) = node.child(i as u32)
&& let Some(found) = self.find_last_list_item(child)
{
return Some(found);
}
}
result
}
fn list_item_at(&self, byte_offset: usize) -> Option<tree_sitter::Node<'_>> {
let tree = self.buffer.tree()?;
let root = tree.block_tree().root_node();
let node = root.descendant_for_byte_range(byte_offset, byte_offset)?;
ancestor_of_kind(node, "list_item")
}
fn direct_checkbox(&self, list_item: tree_sitter::Node) -> Option<(usize, bool)> {
let mut cursor = list_item.walk();
if cursor.goto_first_child() {
loop {
let child = cursor.node();
match child.kind() {
"task_list_marker_checked" => return Some((child.start_byte(), true)),
"task_list_marker_unchecked" => return Some((child.start_byte(), false)),
_ => {}
}
if !cursor.goto_next_sibling() {
break;
}
}
}
None
}
fn find_nested_checkboxes(&self, list_item_node: tree_sitter::Node) -> Vec<(usize, bool)> {
let mut checkboxes = Vec::new();
let mut cursor = list_item_node.walk();
loop {
let node = cursor.node();
match node.kind() {
"task_list_marker_checked" => {
checkboxes.push((node.start_byte(), true));
}
"task_list_marker_unchecked" => {
checkboxes.push((node.start_byte(), false));
}
_ => {}
}
if cursor.goto_first_child() {
continue;
}
if cursor.goto_next_sibling() {
continue;
}
loop {
if !cursor.goto_parent() {
return checkboxes;
}
if cursor.node().id() == list_item_node.id() {
return checkboxes;
}
if cursor.goto_next_sibling() {
break;
}
}
}
}
pub fn build_nested_context(&self, cursor_offset: usize) -> Vec<MarkerKind> {
let Some(tree) = self.buffer.tree() else {
return Vec::new();
};
let root = tree.block_tree().root_node();
let lookup_offset = if cursor_offset > 0
&& root
.descendant_for_byte_range(cursor_offset, cursor_offset)
.map(|n| n.kind() == "document")
.unwrap_or(true)
{
cursor_offset - 1
} else {
cursor_offset
};
let Some(node) = root.descendant_for_byte_range(lookup_offset, lookup_offset) else {
return Vec::new();
};
let mut markers_reversed = Vec::new();
let mut current = Some(node);
while let Some(n) = current {
match n.kind() {
"block_quote" => {
markers_reversed.push(MarkerKind::BlockQuote);
}
"list_item" => {
let mut list_marker: Option<MarkerKind> = None;
let mut checkbox: Option<MarkerKind> = None;
let mut cursor = n.walk();
if cursor.goto_first_child() {
loop {
let child = cursor.node();
match child.kind() {
"task_list_marker_checked" => {
checkbox = Some(MarkerKind::Checkbox { checked: true });
}
"task_list_marker_unchecked" => {
checkbox = Some(MarkerKind::Checkbox { checked: false });
}
"list_marker_minus" => {
list_marker = Some(MarkerKind::ListItem {
ordered: false,
unordered_marker: Some(UnorderedMarker::Minus),
ordered_marker: None,
number: None,
});
}
"list_marker_star" => {
list_marker = Some(MarkerKind::ListItem {
ordered: false,
unordered_marker: Some(UnorderedMarker::Star),
ordered_marker: None,
number: None,
});
}
"list_marker_plus" => {
list_marker = Some(MarkerKind::ListItem {
ordered: false,
unordered_marker: Some(UnorderedMarker::Plus),
ordered_marker: None,
number: None,
});
}
"list_marker_dot" | "list_marker_parenthesis" => {
let marker_text =
self.buffer.slice_cow(child.start_byte()..child.end_byte());
let number = marker_text
.trim()
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.ok();
let ordered_marker =
Some(if child.kind() == "list_marker_dot" {
OrderedMarker::Dot
} else {
OrderedMarker::Parenthesis
});
list_marker = Some(MarkerKind::ListItem {
ordered: true,
unordered_marker: None,
ordered_marker,
number,
});
}
_ => {}
}
if !cursor.goto_next_sibling() {
break;
}
}
}
if let Some(cb) = checkbox {
markers_reversed.push(cb);
}
if let Some(lm) = list_marker {
markers_reversed.push(lm);
}
}
"fenced_code_block" => {
let mut cursor = n.walk();
let mut language = None;
if cursor.goto_first_child() {
loop {
let child = cursor.node();
if child.kind() == "info_string" {
language = Some(
self.buffer
.slice_cow(child.start_byte()..child.end_byte())
.to_string(),
);
break;
}
if !cursor.goto_next_sibling() {
break;
}
}
}
markers_reversed.push(MarkerKind::CodeBlockFence {
language,
is_opening: true,
});
}
_ => {}
}
current = n.parent();
}
markers_reversed.reverse();
markers_reversed
}
fn find_parent_checkbox(&self, list_item_start: usize) -> Option<(usize, bool)> {
let our_list_item = self.list_item_at(list_item_start)?;
let parent = ancestor_of_kind(our_list_item.parent()?, "list_item")?;
self.direct_checkbox(parent)
}
fn find_sibling_checkboxes(&self, list_item_start: usize) -> Vec<(usize, bool)> {
let our_list_item = match self.list_item_at(list_item_start) {
Some(n) => n,
None => return Vec::new(),
};
let parent_list = match our_list_item.parent() {
Some(p) if p.kind() == "list" => p,
_ => return Vec::new(),
};
let mut siblings = Vec::new();
let mut cursor = parent_list.walk();
if cursor.goto_first_child() {
loop {
let child = cursor.node();
if child.kind() == "list_item"
&& let Some(cb) = self.direct_checkbox(child)
{
siblings.push(cb);
}
if !cursor.goto_next_sibling() {
break;
}
}
}
siblings
}
fn set_line_prefix(&mut self, new_prefix: &str, prefix_end: usize) {
let cursor_offset = self.cursor().offset;
let line_idx = self.buffer.byte_to_line(cursor_offset);
let line_start = self.buffer.line_to_byte(line_idx);
let old_prefix_len = prefix_end - line_start;
let new_prefix_len = new_prefix.len();
let len_diff = new_prefix_len as isize - old_prefix_len as isize;
if prefix_end > line_start {
self.buffer.delete(line_start..prefix_end, cursor_offset);
}
if !new_prefix.is_empty() {
self.buffer.insert(line_start, new_prefix, line_start);
}
let new_cursor = if cursor_offset >= prefix_end {
(cursor_offset as isize + len_diff) as usize
} else {
line_start + new_prefix_len
};
self.selection = Selection::new(new_cursor, new_cursor);
}
fn table_context_at_line(&self, line_idx: usize) -> Option<(&TableInfo, RowKind)> {
if line_idx >= self.buffer.line_count() {
return None;
}
let line_start = self.buffer.line_to_byte(line_idx);
let table = self
.buffer
.parsed()
.tables
.iter()
.find(|t| line_start >= t.block.start && line_start < t.block.end)?;
let kind = if table.header.line.contains(&line_start) {
RowKind::Header
} else if table.delimiter_line.contains(&line_start) {
RowKind::Delimiter
} else {
RowKind::Body(
table
.body
.iter()
.position(|r| r.line.contains(&line_start))?,
)
};
Some((table, kind))
}
fn table_context_at_cursor(&self) -> Option<(TableInfo, RowKind)> {
let line_idx = self.buffer.byte_to_line(self.cursor().offset);
self.table_context_at_line(line_idx)
.map(|(t, k)| (t.clone(), k))
}
fn insert_table_row_after_line(&mut self, line_idx: usize, ncols: usize) -> usize {
let content_end = if line_idx + 1 < self.buffer.line_count() {
self.buffer.line_to_byte(line_idx + 1).saturating_sub(1)
} else {
self.buffer.len_bytes()
};
let row = format!("|{}", " |".repeat(ncols));
self.set_cursor(content_end);
self.insert_text(&format!("\n{row}"));
content_end + 3
}
pub fn maybe_create_table(&mut self) -> bool {
if self.cursor_in_code_block() {
return false;
}
let cursor = self.cursor().offset;
let line_idx = self.buffer.byte_to_line(cursor);
if line_idx >= self.buffer.line_count() {
return false;
}
if cursor != self.cursor().move_to_line_end(&self.buffer).offset {
return false;
}
if self.table_context_at_cursor().is_some() {
return false;
}
let line_start = self.buffer.line_to_byte(line_idx);
let ncols = {
let line_text = self.buffer.slice_cow(line_start..cursor);
let trimmed = line_text.trim();
let pipes = unescaped_pipe_indices(trimmed);
if pipes.is_empty() {
return false;
}
let ncols = pipe_row_ncols(trimmed, &pipes);
let bounded = trimmed.starts_with('|') && trimmed.ends_with('|');
if ncols == 0 || !bounded {
return false;
}
ncols
};
if line_idx + 1 < self.buffer.line_count() {
let next_start = self.buffer.line_to_byte(line_idx + 1);
let next_end = if line_idx + 2 < self.buffer.line_count() {
self.buffer.line_to_byte(line_idx + 2).saturating_sub(1)
} else {
self.buffer.len_bytes()
};
let next_text = self.buffer.slice_cow(next_start..next_end);
if looks_like_delimiter_row(next_text.trim()) {
return false;
}
}
let delim = format!("|{}", " --- |".repeat(ncols));
let body = format!("|{}", " |".repeat(ncols));
self.insert_text(&format!("\n{delim}\n{body}"));
let body_start = cursor + 1 + delim.len() + 1;
self.set_cursor(body_start + 2);
true
}
fn table_cell_landing(&self, cell: &TableCell) -> usize {
if cell.content.start < cell.content.end {
return cell.content.start;
}
let closing = cell.content.start;
let mut open = closing;
while open > 0 && self.buffer.byte_at(open - 1) != Some(b'|') {
open -= 1;
}
(open + 1).min(closing.saturating_sub(1)).max(open)
}
fn table_cell_nav(&mut self, forward: bool) -> bool {
let cursor = self.cursor().offset;
let Some((table, kind)) = self.table_context_at_cursor() else {
return false;
};
let nrows = 1 + table.body.len();
let cells_of = |row_pos: usize| -> &Vec<TableCell> {
if row_pos == 0 {
&table.header.cells
} else {
&table.body[row_pos - 1].cells
}
};
let row_pos = match kind {
RowKind::Header => Some(0),
RowKind::Body(i) => Some(i + 1),
RowKind::Delimiter => None,
};
let target: Option<usize> = match row_pos {
None => {
if forward {
table
.body
.first()
.and_then(|r| r.cells.first())
.map(|c| self.table_cell_landing(c))
} else {
Some(
table
.header
.cells
.last()
.map_or(cursor, |c| self.table_cell_landing(c)),
)
}
}
Some(row_pos) => {
let cells = cells_of(row_pos);
let cell_idx = cells
.iter()
.position(|c| c.content.end >= cursor)
.unwrap_or(cells.len().saturating_sub(1));
if forward {
if cell_idx + 1 < cells.len() {
Some(self.table_cell_landing(&cells[cell_idx + 1]))
} else if row_pos + 1 < nrows {
cells_of(row_pos + 1)
.first()
.map(|c| self.table_cell_landing(c))
} else {
None
}
} else if cell_idx > 0 {
Some(self.table_cell_landing(&cells[cell_idx - 1]))
} else if row_pos > 0 {
cells_of(row_pos - 1)
.last()
.map(|c| self.table_cell_landing(c))
} else {
Some(cursor)
}
}
};
match target {
Some(off) => self.set_cursor(off),
None => {
let anchor = table
.body
.last()
.map(|r| r.line.start)
.unwrap_or(table.delimiter_line.start);
let anchor_line = self.buffer.byte_to_line(anchor);
let off = self.insert_table_row_after_line(anchor_line, table.ncols);
self.set_cursor(off);
}
}
true
}
fn maybe_add_table_row(&mut self) -> bool {
let line_idx = self.buffer.byte_to_line(self.cursor().offset);
let Some((table, kind)) = self.table_context_at_cursor() else {
return false;
};
let anchor_line = match kind {
RowKind::Body(_) => line_idx,
RowKind::Header | RowKind::Delimiter => {
self.buffer.byte_to_line(table.delimiter_line.start)
}
};
let off = self.insert_table_row_after_line(anchor_line, table.ncols);
self.set_cursor(off);
true
}
pub fn enter(&mut self) {
self.insert_text("\n");
}
pub fn shift_enter(&mut self) {
if self.maybe_create_table() {
return;
}
if self.maybe_add_table_row() {
return;
}
if self.cursor_in_code_block() {
let indent = self.current_line_leading_whitespace();
self.insert_text("\n");
if !indent.is_empty() {
self.insert_text(&indent);
}
return;
}
let Some(ctx) = self.line_context() else {
self.insert_text("\n");
return;
};
let continuation = ctx.line.continuation_rope(self.buffer.rope());
self.insert_text("\n");
if !continuation.is_empty() {
self.insert_text(&continuation);
}
}
fn current_line_leading_whitespace(&self) -> String {
let cursor = self.cursor();
let line_start = cursor.move_to_line_start(&self.buffer).offset;
let line_end = cursor.move_to_line_end(&self.buffer).offset;
let line_text = self.buffer.slice_cow(line_start..line_end);
line_text
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect()
}
pub fn shift_alt_enter(&mut self) {
let indent = {
let Some(ctx) = self.line_context() else {
self.insert_text("\n");
return;
};
let has_list = ctx
.line
.markers
.iter()
.any(|m| matches!(m.kind, MarkerKind::ListItem { .. }));
let has_blockquote = ctx
.line
.markers
.iter()
.any(|m| matches!(m.kind, MarkerKind::BlockQuote));
if has_blockquote && !has_list {
" ".to_string()
} else {
ctx.line.nested_paragraph_indent(self.buffer.rope())
}
};
self.insert_text("\n");
if !indent.is_empty() {
self.insert_text(&indent);
}
}
pub fn shift_tab(&mut self) {
if self.table_cell_nav(false) {
return;
}
self.shift_tab_cycle();
}
fn backspace_range_with_type(
&self,
cursor_pos: usize,
) -> Option<(std::ops::Range<usize>, bool)> {
let (_, line) = self.find_line_at(cursor_pos)?;
for marker in &line.markers {
if cursor_pos == marker.range.end {
let is_indent = matches!(marker.kind, MarkerKind::Indent);
return Some((marker.range.clone(), is_indent));
}
}
None
}
fn find_empty_code_block_range(&self, cursor_pos: usize) -> Option<std::ops::Range<usize>> {
let tree = self.buffer.tree()?;
let root = tree.block_tree().root_node();
let node = root.descendant_for_byte_range(cursor_pos.saturating_sub(1), cursor_pos)?;
let code_block = ancestor_of_kind(node, "fenced_code_block")?;
let block_start = code_block.start_byte();
let block_end = code_block.end_byte();
let block_text = self.buffer.slice_cow(block_start..block_end);
let first_newline = block_text.find('\n')?;
let content_start = block_start + first_newline + 1;
let content = self.buffer.slice_cow(content_start..block_end);
let trimmed = content.trim();
if trimmed == "```" || trimmed == "~~~" {
let mut end = block_end;
if self.buffer.byte_at(end.saturating_sub(1)) == Some(b'\n') {
end -= 1;
}
Some(block_start..end)
} else {
None
}
}
pub fn delete_backward(&mut self) {
self.tab_cycle_cache = None;
if !self.selection.is_collapsed() {
self.delete_selection();
self.propagate_checkbox_after_edit();
return;
}
if self.cursor().offset == 0 {
return;
}
let cursor_pos = self.cursor().offset;
if let Some((marker_range, _is_indent)) = self.backspace_range_with_type(cursor_pos) {
if let Some(block_range) = self.find_empty_code_block_range(cursor_pos) {
self.buffer.delete(block_range.clone(), cursor_pos);
self.selection = Selection::new(block_range.start, block_range.start);
self.propagate_checkbox_after_edit();
return;
}
self.buffer.delete(marker_range.clone(), cursor_pos);
self.selection = Selection::new(marker_range.start, marker_range.start);
self.propagate_checkbox_after_edit();
return;
}
let new_pos = prev_grapheme_boundary(&self.buffer, cursor_pos);
self.buffer.delete(new_pos..cursor_pos, cursor_pos);
self.selection = Selection::new(new_pos, new_pos);
self.propagate_checkbox_after_edit();
}
fn delete_selection(&mut self) {
let range = self.selection.range();
let cursor_before = self.cursor().offset;
self.buffer.delete(range.clone(), cursor_before);
self.selection = Selection::new(range.start, range.start);
}
pub fn delete_forward(&mut self) {
self.tab_cycle_cache = None;
if !self.selection.is_collapsed() {
self.delete_selection();
} else if self.cursor().offset < self.buffer.len_bytes() {
let cursor_before = self.cursor().offset;
let next = self.cursor().move_right(&self.buffer);
self.buffer
.delete(cursor_before..next.offset, cursor_before);
}
self.propagate_checkbox_after_edit();
}
pub fn handle_click(&mut self, buffer_offset: usize, shift_held: bool, click_count: usize) {
if shift_held {
self.selection = self.selection.extend_to(buffer_offset);
} else {
match click_count {
2 => {
self.selection = Selection::select_word_at(buffer_offset, &self.buffer);
}
3 => {
self.selection = Selection::select_line_at(buffer_offset, &self.buffer);
}
_ => {
self.selection = Selection::new(buffer_offset, buffer_offset);
}
}
}
}
pub fn handle_drag(&mut self, buffer_offset: usize) {
self.selection = self.selection.extend_to(buffer_offset);
}
fn checkbox_byte_offset(&self, line: &LineMarkers, is_checked: bool) -> Option<usize> {
let line_text = self.buffer.slice_cow(line.range.clone());
let pattern = if is_checked { "[x]" } else { "[ ]" };
let relative = line_text
.find(pattern)
.or_else(|| is_checked.then(|| line_text.find("[X]")).flatten())?;
Some(line.range.start + relative)
}
pub fn toggle_checkbox(&mut self, line_number: usize) {
let head_before = self.buffer.undo_head();
let cursor_before = self.cursor().offset;
let text_before = self.buffer.text();
let (is_checked, checkbox_byte_start) = {
if line_number >= self.buffer.line_count() {
return;
}
let line = self.buffer.line_markers(line_number);
let Some(is_checked) = line.checkbox() else {
return;
};
let Some(checkbox_byte_start) = self.checkbox_byte_offset(&line, is_checked) else {
return;
};
(is_checked, checkbox_byte_start)
};
let new_checked = !is_checked;
let mut cursor_pos = self.cursor().offset;
let list_item_node = self.list_item_at(checkbox_byte_start);
let mut checkboxes_to_toggle: Vec<(usize, bool)> = Vec::new();
if let Some(node) = list_item_node {
let nested = self.find_nested_checkboxes(node);
for (offset, currently_checked) in nested {
if currently_checked != new_checked {
checkboxes_to_toggle.push((offset, currently_checked));
}
}
} else {
checkboxes_to_toggle.push((checkbox_byte_start, is_checked));
}
checkboxes_to_toggle.sort_by_key(|c| std::cmp::Reverse(c.0));
for (offset, _currently_checked) in &checkboxes_to_toggle {
self.set_checkbox(*offset, new_checked, &mut cursor_pos);
}
self.propagate_checkbox_up(checkbox_byte_start, new_checked, &mut cursor_pos);
let text_after = self.buffer.text();
self.buffer.coalesce_since(
head_before,
&text_before,
&text_after,
cursor_before,
cursor_pos,
);
self.selection = Selection::new(cursor_pos, cursor_pos);
}
fn propagate_checkbox_up(
&mut self,
list_item_start: usize,
checked: bool,
cursor_pos: &mut usize,
) {
let parent_info = self.find_parent_checkbox(list_item_start);
let Some((parent_offset, parent_checked)) = parent_info else {
return;
};
if checked {
let siblings = self.find_sibling_checkboxes(list_item_start);
let all_checked = siblings.iter().all(|(_, is_checked)| *is_checked);
if all_checked && !parent_checked {
self.set_checkbox(parent_offset, true, cursor_pos);
self.propagate_checkbox_up(parent_offset, true, cursor_pos);
}
} else {
if parent_checked {
self.set_checkbox(parent_offset, false, cursor_pos);
self.propagate_checkbox_up(parent_offset, false, cursor_pos);
}
}
}
fn propagate_checkbox_after_edit(&mut self) {
let cursor_offset = self.cursor().offset;
let line_idx = self.buffer.byte_to_line(cursor_offset);
let markers = self.buffer.line_markers(line_idx);
if let Some(is_checked) = markers.checkbox() {
if let Some(checkbox_byte_start) = self.checkbox_byte_offset(&markers, is_checked) {
let mut cursor_pos = cursor_offset;
self.propagate_checkbox_up(checkbox_byte_start, is_checked, &mut cursor_pos);
self.selection = Selection::new(cursor_pos, cursor_pos);
}
} else {
self.propagate_from_parent_checkbox();
}
}
fn propagate_from_parent_checkbox(&mut self) {
let cursor_offset = self.cursor().offset;
let parent_info = self.find_parent_checkbox(cursor_offset).or_else(|| {
if cursor_offset > 0 {
self.find_parent_checkbox(cursor_offset - 1)
} else {
None
}
});
let Some(parent_info) = parent_info else {
return;
};
let sibling_offset =
if self.find_sibling_checkboxes(cursor_offset).is_empty() && cursor_offset > 0 {
cursor_offset - 1
} else {
cursor_offset
};
let (parent_checkbox_offset, parent_checked) = parent_info;
let siblings = self.find_sibling_checkboxes(sibling_offset);
if siblings.is_empty() {
return;
}
let all_siblings_checked = siblings.iter().all(|(_, checked)| *checked);
let mut cursor_pos = cursor_offset;
if all_siblings_checked && !parent_checked {
self.set_checkbox(parent_checkbox_offset, true, &mut cursor_pos);
self.propagate_checkbox_up(parent_checkbox_offset, true, &mut cursor_pos);
self.selection = Selection::new(cursor_pos, cursor_pos);
} else if !all_siblings_checked && parent_checked {
self.set_checkbox(parent_checkbox_offset, false, &mut cursor_pos);
self.propagate_checkbox_up(parent_checkbox_offset, false, &mut cursor_pos);
self.selection = Selection::new(cursor_pos, cursor_pos);
}
}
fn set_checkbox(&mut self, checkbox_offset: usize, checked: bool, cursor_pos: &mut usize) {
let content_start = checkbox_offset + 1; let content_end = content_start + 1;
let new_content = if checked { "x" } else { " " };
self.buffer
.replace(content_start..content_end, new_content, *cursor_pos);
let line = self.buffer.byte_to_line(checkbox_offset);
let adjustment = self.toggle_line_strikethrough(line, checked, *cursor_pos);
*cursor_pos = (*cursor_pos as isize + adjustment) as usize;
}
fn toggle_line_strikethrough(
&mut self,
line_idx: usize,
add_strikethrough: bool,
cursor_pos: usize,
) -> isize {
self.tab_cycle_cache = None;
if line_idx >= self.buffer.line_count() {
return 0;
}
let line = self.buffer.line_markers(line_idx);
let content_start = line.content_start();
let content_end = line.range.end;
if content_start >= content_end {
return 0;
}
let content = self.buffer.slice_cow(content_start..content_end);
let trimmed = content.trim();
if trimmed.is_empty() {
return 0;
}
if add_strikethrough {
if trimmed.starts_with("~~") && trimmed.ends_with("~~") {
return 0;
}
let leading_ws = content.len() - content.trim_start().len();
let trailing_ws = content.len() - content.trim_end().len();
let text_start = content_start + leading_ws;
let text_end = content_end - trailing_ws;
let wrapped = format!("~~{trimmed}~~");
self.buffer
.replace(text_start..text_end, &wrapped, cursor_pos);
let mut adjustment: isize = 0;
if cursor_pos > text_start {
adjustment += 2;
}
if cursor_pos > text_end {
adjustment += 2;
}
adjustment
} else {
let leading_ws = content.len() - content.trim_start().len();
let text_start = content_start + leading_ws;
if trimmed.starts_with("~~") && trimmed.ends_with("~~") && trimmed.len() >= 4 {
let trailing_ws = content.len() - content.trim_end().len();
let text_end = content_end - trailing_ws;
let inner = trimmed[2..trimmed.len() - 2].to_string();
self.buffer
.replace(text_start..text_end, &inner, cursor_pos);
let mut adjustment: isize = 0;
if cursor_pos > text_start + 2 {
adjustment -= 2;
}
if cursor_pos > text_end {
adjustment -= 2;
}
adjustment
} else {
0
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn trim_raw(s: &str) -> &str {
s.strip_prefix('\n').unwrap_or(s)
}
fn editor_with_cursor(input: &str) -> EditorState {
let input = trim_raw(input);
let cursor_pos = input
.find('|')
.expect("Input must contain | for cursor position");
let content = input.replace('|', "");
let mut state = EditorState::new(&content);
state.set_cursor(cursor_pos);
state
}
fn assert_editor_eq(state: &EditorState, expected: &str) {
let expected = trim_raw(expected);
let text = state.text();
let cursor = state.cursor().offset;
let mut actual = String::new();
actual.push_str(&text[..cursor]);
actual.push('|');
actual.push_str(&text[cursor..]);
assert_eq!(actual, expected);
}
fn assert_selection_eq(state: &EditorState, expected: &str) {
let expected = trim_raw(expected);
let text = state.text();
let selection = &state.selection;
let anchor = selection.anchor;
let head = selection.head;
let start = anchor.min(head);
let end = anchor.max(head);
let is_collapsed = anchor == head;
let mut actual = String::new();
let mut byte_pos = 0;
for c in text.chars() {
if !is_collapsed && byte_pos == start {
actual.push('<');
}
if byte_pos == head {
actual.push('|');
}
if !is_collapsed && byte_pos == end {
actual.push('>');
}
actual.push(c);
byte_pos += c.len_utf8();
}
if !is_collapsed && byte_pos == start {
actual.push('<');
}
if byte_pos == head {
actual.push('|');
}
if !is_collapsed && byte_pos == end {
actual.push('>');
}
assert_eq!(actual, expected, "Selection mismatch");
}
mod click_tests {
use super::*;
#[test]
fn click_sets_cursor() {
let mut state = editor_with_cursor("hello| world");
state.handle_click(0, false, 1);
assert_editor_eq(&state, "|hello world");
}
#[test]
fn click_middle() {
let mut state = editor_with_cursor("|hello world");
state.handle_click(6, false, 1);
assert_editor_eq(&state, "hello |world");
}
#[test]
fn shift_click_extends_selection() {
let mut state = editor_with_cursor("hello| world");
state.handle_click(11, true, 1);
assert_selection_eq(&state, "hello< world|>");
}
#[test]
fn shift_click_backward() {
let mut state = editor_with_cursor("hello| world");
state.handle_click(0, true, 1);
assert_selection_eq(&state, "<|hello> world");
}
#[test]
fn double_click_selects_word() {
let mut state = editor_with_cursor("|hello world");
state.handle_click(2, false, 2);
assert_selection_eq(&state, "<hello|> world");
}
#[test]
fn double_click_second_word() {
let mut state = editor_with_cursor("|hello world");
state.handle_click(8, false, 2);
assert_selection_eq(&state, "hello <world|>");
}
#[test]
fn triple_click_selects_line() {
let mut state = editor_with_cursor("|hello world");
state.handle_click(2, false, 3);
assert_selection_eq(&state, "<hello world|>");
}
#[test]
fn drag_extends_selection() {
let mut state = editor_with_cursor("|hello world");
state.handle_click(0, false, 1);
state.handle_drag(5);
assert_selection_eq(&state, "<hello|> world");
}
#[test]
fn drag_backward() {
let mut state = editor_with_cursor("hello world|");
state.handle_click(11, false, 1);
state.handle_drag(6);
assert_selection_eq(&state, "hello <|world>");
}
}
mod cursor_movement_tests {
use super::*;
#[test]
fn move_left() {
let mut state = editor_with_cursor("hel|lo");
state.move_left();
assert_editor_eq(&state, "he|llo");
}
#[test]
fn move_left_at_start() {
let mut state = editor_with_cursor("|hello");
state.move_left();
assert_editor_eq(&state, "|hello");
}
#[test]
fn move_right() {
let mut state = editor_with_cursor("he|llo");
state.move_right();
assert_editor_eq(&state, "hel|lo");
}
#[test]
fn move_right_at_end() {
let mut state = editor_with_cursor("hello|");
state.move_right();
assert_editor_eq(&state, "hello|");
}
#[test]
fn move_up() {
let mut state = editor_with_cursor("line one\nline |two\nline three");
state.move_up();
assert_editor_eq(&state, "line |one\nline two\nline three");
}
#[test]
fn sticky_goal_column_survives_short_line() {
let mut state = editor_with_cursor("0123456|789\nab\nXYZ0123456");
state.move_down(); state.move_down(); assert_editor_eq(&state, "0123456789\nab\nXYZ0123|456");
}
#[test]
fn goal_column_reset_by_horizontal_move() {
let mut state = editor_with_cursor("0123456|789\nab\nXYZ0123456");
state.move_down(); state.move_left(); state.move_down(); assert_editor_eq(&state, "0123456789\nab\nX|YZ0123456");
}
#[test]
fn move_up_from_first_line() {
let mut state = editor_with_cursor("hel|lo\nworld");
state.move_up();
assert_editor_eq(&state, "|hello\nworld");
}
#[test]
fn move_down() {
let mut state = editor_with_cursor("line |one\nline two\nline three");
state.move_down();
assert_editor_eq(&state, "line one\nline |two\nline three");
}
#[test]
fn move_down_from_last_line() {
let mut state = editor_with_cursor("hello\nwor|ld");
state.move_down();
assert_editor_eq(&state, "hello\nworld|");
}
#[test]
fn move_up_preserves_column() {
let mut state = editor_with_cursor("short\nlonger line|");
state.move_up();
assert_editor_eq(&state, "short|\nlonger line");
}
#[test]
fn move_to_line_start() {
let mut state = editor_with_cursor("hello\nwor|ld");
state.move_to_line_start();
assert_editor_eq(&state, "hello\n|world");
}
#[test]
fn move_to_line_end() {
let mut state = editor_with_cursor("hello\nwor|ld");
state.move_to_line_end();
assert_editor_eq(&state, "hello\nworld|");
}
}
mod raw_enter_tests {
use super::*;
#[test]
fn enter_on_paragraph_inserts_newline() {
let mut state = editor_with_cursor("Hello world|");
state.enter();
assert_editor_eq(&state, "Hello world\n|");
}
#[test]
fn enter_on_heading_inserts_newline() {
let mut state = editor_with_cursor("# Hello|");
state.enter();
assert_editor_eq(&state, "# Hello\n|");
}
#[test]
fn enter_on_list_item_inserts_newline_no_marker() {
let mut state = editor_with_cursor("- item one|");
state.enter();
assert_editor_eq(&state, "- item one\n|");
}
#[test]
fn enter_on_blockquote_inserts_newline_no_marker() {
let mut state = editor_with_cursor("> quote|");
state.enter();
assert_editor_eq(&state, "> quote\n|");
}
#[test]
fn enter_on_nested_container_inserts_newline_no_markers() {
let mut state = editor_with_cursor("> - item|");
state.enter();
assert_editor_eq(&state, "> - item\n|");
}
#[test]
fn enter_on_empty_list_item_inserts_newline_keeps_marker() {
let mut state = editor_with_cursor("- item one\n- |");
state.enter();
assert_editor_eq(&state, "- item one\n- \n|");
}
#[test]
fn enter_on_empty_blockquote_inserts_newline_keeps_marker() {
let mut state = editor_with_cursor("> quote one\n> |");
state.enter();
assert_editor_eq(&state, "> quote one\n> \n|");
}
#[test]
fn enter_in_code_block_inserts_newline() {
let mut state = editor_with_cursor("```rust\nlet x = 1;|");
state.enter();
assert_editor_eq(&state, "```rust\nlet x = 1;\n|");
}
#[test]
fn enter_on_code_fence_inserts_newline() {
let mut state = editor_with_cursor("```rust|");
state.enter();
assert_editor_eq(&state, "```rust\n|");
}
#[test]
fn enter_preserves_soft_wrap_style() {
let mut state = editor_with_cursor("First sentence.\nSecond sentence.|");
state.enter();
assert_editor_eq(&state, "First sentence.\nSecond sentence.\n|");
}
#[test]
fn shift_enter_on_list_item_continues_list() {
let mut state = editor_with_cursor("- item one|");
state.shift_enter();
assert_editor_eq(&state, "- item one\n- |");
}
#[test]
fn shift_enter_on_blockquote_continues_blockquote() {
let mut state = editor_with_cursor("> quote|");
state.shift_enter();
assert_editor_eq(&state, "> quote\n> |");
}
#[test]
fn shift_enter_on_nested_container_continues_all() {
let mut state = editor_with_cursor("> - item|");
state.shift_enter();
assert_editor_eq(&state, "> - item\n> - |");
}
#[test]
fn shift_enter_on_paragraph_just_inserts_newline() {
let mut state = editor_with_cursor("Hello world|");
state.shift_enter();
assert_editor_eq(&state, "Hello world\n|");
}
#[test]
fn shift_enter_on_heading_just_inserts_newline() {
let mut state = editor_with_cursor("# Hello|");
state.shift_enter();
assert_editor_eq(&state, "# Hello\n|");
}
#[test]
fn shift_alt_enter_on_list_item_creates_indent() {
let mut state = editor_with_cursor("- item one|");
state.shift_alt_enter();
assert_editor_eq(&state, "- item one\n |");
}
#[test]
fn shift_alt_enter_on_blockquote_creates_indent_outside() {
let mut state = editor_with_cursor("> quote|");
state.shift_alt_enter();
assert_editor_eq(&state, "> quote\n |");
}
#[test]
fn shift_alt_enter_on_nested_container_creates_indent_inside() {
let mut state = editor_with_cursor("> - item|");
state.shift_alt_enter();
assert_editor_eq(&state, "> - item\n> |");
}
#[test]
fn shift_alt_enter_on_paragraph_just_inserts_newline() {
let mut state = editor_with_cursor("Hello world|");
state.shift_alt_enter();
assert_editor_eq(&state, "Hello world\n|");
}
}
mod raw_backspace_tests {
use super::*;
#[test]
fn backspace_deletes_char() {
let mut state = editor_with_cursor("hello|");
state.delete_backward();
assert_editor_eq(&state, "hell|");
}
#[test]
fn backspace_at_line_start_joins_lines() {
let mut state = editor_with_cursor("line one\n|line two");
state.delete_backward();
assert_editor_eq(&state, "line one|line two");
}
#[test]
fn backspace_deletes_whole_zwj_emoji() {
let mut state = editor_with_cursor("a👨👩👧👦|b");
state.delete_backward();
assert_editor_eq(&state, "a|b");
}
#[test]
fn backspace_deletes_whole_combining_accent() {
let mut state = editor_with_cursor("e\u{301}|");
state.delete_backward();
assert_editor_eq(&state, "|");
}
#[test]
fn backspace_deletes_entire_list_marker() {
let mut state = editor_with_cursor("- |");
state.delete_backward();
assert_editor_eq(&state, "|");
}
#[test]
fn backspace_deletes_innermost_marker_first() {
let mut state = editor_with_cursor("> - |");
state.delete_backward();
assert_editor_eq(&state, "> |");
}
#[test]
fn backspace_then_deletes_outer_marker() {
let mut state = editor_with_cursor("> |");
state.delete_backward();
assert_editor_eq(&state, "|");
}
#[test]
fn backspace_deletes_entire_indent() {
let mut state = editor_with_cursor("- item\n |text");
state.delete_backward();
assert_editor_eq(&state, "- item\n|text");
}
#[test]
fn backspace_in_middle_of_text_deletes_char() {
let mut state = editor_with_cursor("- item o|ne");
state.delete_backward();
assert_editor_eq(&state, "- item |ne");
}
#[test]
fn backspace_on_empty_line_after_list_joins() {
let mut state = editor_with_cursor("- item one\n|");
state.delete_backward();
assert_editor_eq(&state, "- item one|");
}
#[test]
fn backspace_sequence_through_markers_and_join() {
let mut state = editor_with_cursor("- item one\n- |");
state.delete_backward();
assert_editor_eq(&state, "- item one\n|");
state.delete_backward();
assert_editor_eq(&state, "- item one|");
}
#[test]
fn backspace_with_content_after_cursor_deletes_marker() {
let mut state = editor_with_cursor("- |two");
state.delete_backward();
assert_editor_eq(&state, "|two");
}
#[test]
fn backspace_deletes_entire_task_list_marker() {
let mut state = editor_with_cursor("- [ ] |");
state.delete_backward();
assert_editor_eq(&state, "- |");
state.delete_backward();
assert_editor_eq(&state, "|");
}
#[test]
fn backspace_deletes_checked_task_list_marker() {
let mut state = editor_with_cursor("- [x] |");
state.delete_backward();
assert_editor_eq(&state, "- |");
state.delete_backward();
assert_editor_eq(&state, "|");
}
}
mod raw_tab_tests {
use super::*;
#[test]
fn tab_on_empty_line_after_list_adds_marker() {
let mut state = editor_with_cursor("- item\n|");
state.tab();
assert_editor_eq(&state, "- item\n- |");
}
#[test]
fn tab_twice_after_list_adds_nested_marker() {
let mut state = editor_with_cursor("- item\n|");
state.tab();
state.tab();
assert_editor_eq(&state, "- item\n |"); state.tab();
assert_editor_eq(&state, "- item\n - |"); }
#[test]
fn tab_three_times_cycles_back() {
let mut state = editor_with_cursor("- item\n|");
state.tab();
state.tab();
state.tab();
state.tab();
assert_editor_eq(&state, "- item\n|");
}
#[test]
fn tab_cycles_ordered_list_after_checkbox() {
let mut state = editor_with_cursor("## Writ\n- [ ] item\n\n1. hey\n|");
state.tab();
assert_editor_eq(&state, "## Writ\n- [ ] item\n\n1. hey\n2. |");
state.tab();
assert_editor_eq(&state, "## Writ\n- [ ] item\n\n1. hey\n |");
state.tab();
assert_editor_eq(&state, "## Writ\n- [ ] item\n\n1. hey\n 1. |");
state.tab();
assert_editor_eq(&state, "## Writ\n- [ ] item\n\n1. hey\n|");
}
#[test]
fn tab_indents_line_with_content() {
let mut state = editor_with_cursor("1. hey\n2. asdf|");
state.tab();
assert_editor_eq(&state, "1. hey\n asdf|"); state.tab();
assert_editor_eq(&state, "1. hey\n 1. asdf|"); }
#[test]
fn tab_preserves_unchecked_checkbox_state() {
let mut state = editor_with_cursor("- [x] hey\n- [ ] |");
state.tab();
assert_editor_eq(&state, "- [x] hey\n |"); state.tab();
assert_editor_eq(&state, "- [x] hey\n - [ ] |"); state.tab();
assert_editor_eq(&state, "- [x] hey\n|");
state.tab();
assert_editor_eq(&state, "- [x] hey\n- [ ] |");
}
#[test]
fn tab_preserves_checked_checkbox_state() {
let mut state = editor_with_cursor("- [ ] hey\n- [x] |");
state.tab();
assert_editor_eq(&state, "- [ ] hey\n |"); state.tab();
assert_editor_eq(&state, "- [ ] hey\n - [x] |"); state.tab();
assert_editor_eq(&state, "- [ ] hey\n|");
state.tab();
assert_editor_eq(&state, "- [ ] hey\n- [x] |");
}
#[test]
fn tab_new_checkbox_defaults_unchecked() {
let mut state = editor_with_cursor("- [x] ~~hey~~\n|");
state.tab(); assert_editor_eq(&state, "- [x] ~~hey~~\n- [ ] |");
state.tab(); assert_editor_eq(&state, "- [x] ~~hey~~\n |");
state.tab(); assert_editor_eq(&state, "- [x] ~~hey~~\n - [ ] |");
}
#[test]
fn typing_after_tab_propagates_checkbox() {
let mut state = editor_with_cursor("- [x] hey\n|");
state.tab(); assert_editor_eq(&state, "- [x] hey\n- [ ] |");
state.tab(); assert_editor_eq(&state, "- [x] hey\n |");
state.tab(); assert_editor_eq(&state, "- [x] hey\n - [ ] |");
state.insert_text("a");
assert_editor_eq(&state, "- [ ] hey\n - [ ] a|");
}
#[test]
fn delete_backward_propagates_checkbox() {
let mut state = editor_with_cursor("- [x] hey\n - [ ] ab|");
state.delete_backward();
assert_editor_eq(&state, "- [ ] hey\n - [ ] a|");
}
#[test]
fn delete_forward_propagates_checkbox() {
let mut state = editor_with_cursor("- [x] hey\n - [ ] |ab");
state.delete_forward();
assert_editor_eq(&state, "- [ ] hey\n - [ ] |b");
}
#[test]
fn delete_checkbox_marker_rechecks_parent() {
let mut state = editor_with_cursor("- [x] ~~parent~~\n - [x] ~~nested~~\n|");
state.tab();
state.tab();
state.tab();
assert_editor_eq(&state, "- [x] ~~parent~~\n - [x] ~~nested~~\n - [ ] |");
state.insert_text("new");
assert_editor_eq(&state, "- [ ] parent\n - [x] ~~nested~~\n - [ ] new|");
state.delete_backward();
state.delete_backward();
state.delete_backward();
assert_editor_eq(&state, "- [ ] parent\n - [x] ~~nested~~\n - [ ] |");
state.delete_backward();
assert_editor_eq(&state, "- [ ] parent\n - [x] ~~nested~~\n - |");
state.delete_backward();
assert_editor_eq(&state, "- [x] ~~parent~~\n - [x] ~~nested~~\n |");
}
#[test]
fn tab_with_blank_line_between_still_works() {
let mut state = editor_with_cursor("- item\n\n|");
state.tab();
assert_editor_eq(&state, "- item\n\n- |");
}
#[test]
fn tab_with_two_blank_lines_still_works() {
let mut state = editor_with_cursor("- item\n\n\n|");
state.tab();
assert_editor_eq(&state, "- item\n\n\n- |");
}
#[test]
fn tab_on_blockquote_context_adds_marker() {
let mut state = editor_with_cursor("> quote\n|");
state.tab();
assert_editor_eq(&state, "> quote\n> |");
}
#[test]
fn tab_twice_on_blockquote_context_cycles_back() {
let mut state = editor_with_cursor("> quote\n|");
state.tab();
state.tab();
assert_editor_eq(&state, "> quote\n|");
}
#[test]
fn tab_on_nested_context_cycles() {
let mut state = editor_with_cursor("> - item\n|");
state.tab();
assert_editor_eq(&state, "> - item\n> |");
state.tab();
assert_editor_eq(&state, "> - item\n> - |");
state.tab();
assert_editor_eq(&state, "> - item\n> |");
state.tab();
assert_editor_eq(&state, "> - item\n> - |");
state.tab();
assert_editor_eq(&state, "> - item\n|");
}
#[test]
fn shift_tab_cycles_backwards() {
let mut state = editor_with_cursor("- item\n|");
state.shift_tab();
assert_editor_eq(&state, "- item\n - |");
}
#[test]
fn shift_tab_from_marker_goes_to_empty() {
let mut state = editor_with_cursor("- item\n- |");
state.shift_tab();
assert_editor_eq(&state, "- item\n|");
}
#[test]
fn shift_tab_from_nested_marker_goes_to_marker() {
let mut state = editor_with_cursor("- item\n - |");
state.shift_tab();
assert_editor_eq(&state, "- item\n |"); state.shift_tab();
assert_editor_eq(&state, "- item\n- |");
}
#[test]
fn tab_after_blank_line_includes_para_indent() {
let mut state = editor_with_cursor("- parent\n - nested\n\n|");
state.tab();
assert_editor_eq(&state, "- parent\n - nested\n\n- |");
state.tab();
assert_editor_eq(&state, "- parent\n - nested\n\n |");
state.tab();
assert_editor_eq(&state, "- parent\n - nested\n\n - |");
state.tab();
assert_editor_eq(&state, "- parent\n - nested\n\n |");
state.tab();
assert_editor_eq(&state, "- parent\n - nested\n\n - |");
state.tab();
assert_editor_eq(&state, "- parent\n - nested\n\n|"); }
#[test]
fn tab_no_blank_line_includes_para_indent() {
let mut state = editor_with_cursor("- parent item\n - nested with tab\n|");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n- |");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n |");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n - |");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n |");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n - |");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n|");
}
#[test]
fn tab_with_trailing_newline() {
let mut state = editor_with_cursor("- parent item\n - nested with tab\n|\n");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n- |\n");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n |\n");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n - |\n");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n |\n");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n - |\n");
state.tab();
assert_editor_eq(&state, "- parent item\n - nested with tab\n|\n");
}
#[test]
fn tab_task_list_uses_list_marker_width_not_full_marker() {
let mut state = editor_with_cursor("- [ ] hey\n\n|");
state.tab();
assert_editor_eq(&state, "- [ ] hey\n\n- [ ] |");
state.tab();
assert_editor_eq(&state, "- [ ] hey\n\n |");
state.tab();
assert_editor_eq(&state, "- [ ] hey\n\n - [ ] |");
state.tab();
assert_editor_eq(&state, "- [ ] hey\n\n|");
}
}
mod table_editing_tests {
use super::*;
const TABLE_2X1: &str = "| a | b |\n| --- | --- |\n| 1 | 2 |\n";
#[test]
fn enter_inserts_raw_newline_never_creates_table() {
let mut state = EditorState::new("| a | b |");
state.set_cursor(9); state.enter();
assert_eq!(state.text(), "| a | b |\n");
}
#[test]
fn shift_enter_completes_lone_pipe_header_into_table() {
let mut state = EditorState::new("| a | b |");
state.set_cursor(9);
state.shift_enter();
assert_eq!(state.text(), "| a | b |\n| --- | --- |\n| | |");
assert_eq!(state.cursor().offset, 26);
}
#[test]
fn create_rejects_unbounded_pipe_row() {
let mut state = EditorState::new("a | b");
state.set_cursor(5);
assert!(!state.maybe_create_table());
assert_eq!(state.text(), "a | b");
}
#[test]
fn create_does_nothing_on_existing_table_row() {
let mut state = EditorState::new("| a | b |\n| --- | --- |\n");
state.set_cursor(9);
assert!(!state.maybe_create_table());
assert_eq!(state.text(), "| a | b |\n| --- | --- |\n");
}
#[test]
fn create_does_nothing_inside_code_block() {
let mut state = EditorState::new("```\n| a | b |\n```\n");
state.set_cursor(13); assert!(!state.maybe_create_table());
assert_eq!(state.text(), "```\n| a | b |\n```\n");
}
#[test]
fn create_does_nothing_on_plain_paragraph() {
let mut state = EditorState::new("hello world");
state.set_cursor(11);
assert!(!state.maybe_create_table());
assert_eq!(state.text(), "hello world");
}
#[test]
fn create_does_nothing_when_cursor_not_at_line_end() {
let mut state = EditorState::new("| a | b |");
state.set_cursor(4); assert!(!state.maybe_create_table());
assert_eq!(state.text(), "| a | b |");
}
#[test]
fn tab_moves_to_next_cell_in_row() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(2); state.tab();
assert_eq!(state.cursor().offset, 6); }
#[test]
fn tab_at_row_end_moves_to_next_row_first_cell() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(6); state.tab();
assert_eq!(state.cursor().offset, 26); }
#[test]
fn tab_on_last_cell_of_last_row_creates_new_row() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(30); state.tab();
assert_eq!(
state.text(),
"| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n"
);
assert_eq!(state.cursor().offset, 36); }
#[test]
fn shift_tab_moves_to_previous_cell() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(6); state.shift_tab();
assert_eq!(state.cursor().offset, 2); }
#[test]
fn shift_tab_at_row_start_moves_to_previous_row_last_cell() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(26); state.shift_tab();
assert_eq!(state.cursor().offset, 6); }
#[test]
fn shift_tab_on_header_first_cell_is_noop() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(2);
state.shift_tab();
assert_eq!(state.cursor().offset, 2);
assert_eq!(state.text(), TABLE_2X1);
}
#[test]
fn shift_enter_in_table_adds_matched_row() {
let mut state = EditorState::new(TABLE_2X1);
state.set_cursor(26); state.shift_enter();
assert_eq!(
state.text(),
"| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |\n"
);
assert_eq!(state.cursor().offset, 36);
}
#[test]
fn tab_lands_on_content_char_in_nonempty_cell() {
let mut state = EditorState::new("| ab | cd |\n| --- | --- |\n");
state.set_cursor(2); state.tab();
assert_eq!(state.cursor().offset, 7);
assert_eq!(state.buffer.byte_at(7), Some(b'c'));
}
#[test]
fn tab_into_empty_cell_lands_between_pipes() {
let mut state = EditorState::new("| a | b |\n| --- | --- |\n| | |\n");
state.set_cursor(6); state.tab();
let off = state.cursor().offset;
assert!(off > 24 && off < 27, "strictly between the cell's pipes");
assert_ne!(state.buffer.byte_at(off), Some(b'|'), "not on a pipe");
}
#[test]
fn tab_through_freshly_created_empty_row_never_on_pipe() {
let mut state = EditorState::new("| a | b |");
state.set_cursor(9);
state.shift_enter(); for _ in 0..2 {
let off = state.cursor().offset;
assert_ne!(
state.buffer.byte_at(off),
Some(b'|'),
"caret never on a pipe"
);
state.tab();
}
}
#[test]
fn move_down_from_paragraph_enters_table_block() {
let mut state =
EditorState::new("para\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter\n");
state.set_cursor(0); state.move_down();
let (table, _) = state
.table_context_at_cursor()
.expect("cursor is in the table after move_down");
assert!(
table.block.contains(&state.cursor().offset),
"cursor offset is within the table block"
);
}
#[test]
fn move_down_passes_through_every_table_row() {
let mut state =
EditorState::new("para\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter\n");
state.set_cursor(0);
let mut in_table_lines = 0;
for _ in 0..5 {
state.move_down();
if state.table_context_at_cursor().is_some() {
in_table_lines += 1;
}
}
assert_eq!(in_table_lines, 3, "caret lands on each table row in turn");
}
#[test]
fn cursor_at_first_table_line_start_is_in_table() {
let mut state =
EditorState::new("para\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter\n");
let start = state.buffer.line_to_byte(1); state.set_cursor(start);
assert!(
state.table_context_at_cursor().is_some(),
"the left edge / line start of a table row counts as in-table"
);
}
#[test]
fn tab_outside_table_still_cycles_list() {
let mut state = editor_with_cursor("- item\n|");
state.tab();
assert_editor_eq(&state, "- item\n- |");
}
#[test]
fn shift_enter_outside_table_still_continues_list() {
let mut state = editor_with_cursor("- item one|");
state.shift_enter();
assert_editor_eq(&state, "- item one\n- |");
}
}
mod raw_cursor_movement_tests {
use super::*;
#[test]
fn move_left_through_marker_is_atomic() {
let mut state = editor_with_cursor("- |item");
state.move_left();
assert_editor_eq(&state, "|- item");
}
#[test]
fn move_right_through_marker_is_atomic() {
let mut state = editor_with_cursor("|- item");
state.move_right();
assert_editor_eq(&state, "- |item");
}
#[test]
fn move_left_through_nested_markers_one_at_a_time() {
let mut state = editor_with_cursor("> - |item");
state.move_left();
assert_editor_eq(&state, "> |- item");
state.move_left();
assert_editor_eq(&state, "|> - item");
}
#[test]
fn move_left_does_not_skip_blank_lines() {
let mut state = editor_with_cursor("line one\n\n|line three");
state.move_left();
assert_editor_eq(&state, "line one\n|\nline three");
}
#[test]
fn move_left_from_blank_line_goes_to_previous() {
let mut state = editor_with_cursor("line one\n|\nline three");
state.move_left();
assert_editor_eq(&state, "line one|\n\nline three");
}
#[test]
fn move_up_maintains_column_in_content_area() {
let mut state = editor_with_cursor("- item one\n- item |two");
state.move_up();
assert_editor_eq(&state, "- item |one\n- item two");
}
#[test]
fn move_left_through_blockquote_ordered_list() {
let mut state = editor_with_cursor("> 1. |");
state.move_left();
assert_editor_eq(&state, "> |1. ");
state.move_left();
assert_editor_eq(&state, "|> 1. ");
}
}
mod checkbox_propagation_tests {
use super::*;
#[test]
fn check_parent_checks_all_children() {
let mut state = editor_with_cursor("- [ ] |parent\n - [ ] child1\n - [ ] child2\n");
state.toggle_checkbox(0);
let text = state.text();
assert!(text.contains("[x] ~~parent~~"), "parent should be checked");
assert!(text.contains("[x] ~~child1~~"), "child1 should be checked");
assert!(text.contains("[x] ~~child2~~"), "child2 should be checked");
}
#[test]
fn uncheck_parent_unchecks_all_children() {
let mut state =
editor_with_cursor("- [x] ~~|parent~~\n - [x] ~~child1~~\n - [x] ~~child2~~\n");
state.toggle_checkbox(0);
let text = state.text();
assert!(text.contains("[ ] parent"), "parent should be unchecked");
assert!(text.contains("[ ] child1"), "child1 should be unchecked");
assert!(text.contains("[ ] child2"), "child2 should be unchecked");
assert!(!text.contains("~~"), "no strikethrough should remain");
}
#[test]
fn check_all_siblings_checks_parent() {
let mut state =
editor_with_cursor("- [ ] parent\n - [x] ~~child1~~\n - [ ] |child2\n");
state.toggle_checkbox(2);
let text = state.text();
assert!(
text.contains("[x] ~~parent~~"),
"parent should be auto-checked"
);
assert!(
text.contains("[x] ~~child1~~"),
"child1 should remain checked"
);
assert!(text.contains("[x] ~~child2~~"), "child2 should be checked");
}
#[test]
fn uncheck_child_unchecks_parent() {
let mut state =
editor_with_cursor("- [x] ~~parent~~\n - [x] ~~|child1~~\n - [x] ~~child2~~\n");
state.toggle_checkbox(1);
let text = state.text();
assert!(text.contains("[ ] parent"), "parent should be unchecked");
assert!(text.contains("[ ] child1"), "child1 should be unchecked");
assert!(
text.contains("[x] ~~child2~~"),
"child2 should remain checked"
);
}
#[test]
fn checkbox_cascade_caches_match_fresh_parse() {
let mut state = editor_with_cursor("- [ ] |parent\n - [ ] child1\n - [ ] child2\n");
state.toggle_checkbox(0); let mut fresh: Buffer = state.text().parse().unwrap();
assert_eq!(
*state.buffer.render_snapshot().inline_styles,
*fresh.render_snapshot().inline_styles,
"styles after the cascade equal a fresh parse"
);
}
#[test]
fn caches_not_frozen_after_cascade() {
let mut state = editor_with_cursor("- [ ] |task\nplain line\n");
state.toggle_checkbox(0);
let at = state.text().find("plain").unwrap();
state.set_cursor(at);
state.insert_text("**bold** ");
let mut fresh: Buffer = state.text().parse().unwrap();
assert_eq!(
*state.buffer.render_snapshot().inline_styles,
*fresh.render_snapshot().inline_styles,
"caches re-derived after a post-cascade edit (not frozen)"
);
}
#[test]
fn deeply_nested_propagation_down() {
let mut state = editor_with_cursor("- [ ] |level1\n - [ ] level2\n - [ ] level3\n");
state.toggle_checkbox(0);
let text = state.text();
assert!(text.contains("[x] ~~level1~~"), "level1 should be checked");
assert!(text.contains("[x] ~~level2~~"), "level2 should be checked");
assert!(text.contains("[x] ~~level3~~"), "level3 should be checked");
}
#[test]
fn deeply_nested_propagation_up() {
let mut state = editor_with_cursor("- [ ] level1\n - [ ] level2\n - [ ] |level3\n");
state.toggle_checkbox(2);
let text = state.text();
assert!(
text.contains("[x] ~~level1~~"),
"level1 should be auto-checked"
);
assert!(
text.contains("[x] ~~level2~~"),
"level2 should be auto-checked"
);
assert!(text.contains("[x] ~~level3~~"), "level3 should be checked");
}
#[test]
fn mixed_siblings_parent_stays_unchecked() {
let mut state = editor_with_cursor("- [ ] parent\n - [ ] |child1\n - [ ] child2\n");
state.toggle_checkbox(1);
let text = state.text();
assert!(text.contains("[ ] parent"), "parent should stay unchecked");
assert!(text.contains("[x] ~~child1~~"), "child1 should be checked");
assert!(text.contains("[ ] child2"), "child2 should stay unchecked");
}
}
mod checkbox_undo_tests {
use super::*;
#[test]
fn single_undo_reverts_cascade_to_children_and_parent() {
let before = trim_raw("- [ ] parent\n - [x] ~~child1~~\n - [ ] child2\n");
let mut state =
editor_with_cursor("- [ ] parent\n - [x] ~~child1~~\n - [ ] |child2\n");
state.toggle_checkbox(2);
assert!(
state.text().contains("[x] ~~parent~~"),
"parent auto-checked"
);
assert!(state.text().contains("[x] ~~child2~~"), "child2 checked");
state.buffer.undo();
assert_eq!(state.text(), before, "one undo reverts the whole cascade");
assert!(!state.buffer.can_undo(), "toggle was a single undo entry");
}
#[test]
fn redo_reapplies_full_cascade() {
let mut state =
editor_with_cursor("- [ ] parent\n - [x] ~~child1~~\n - [ ] |child2\n");
state.toggle_checkbox(2);
let after = state.text();
state.buffer.undo();
state.buffer.redo();
assert_eq!(state.text(), after, "one redo re-applies the whole cascade");
}
#[test]
fn toggle_leaf_box_text_and_single_entry() {
let mut state = editor_with_cursor("- [ ] |task\n");
state.toggle_checkbox(0);
assert_eq!(state.text(), "- [x] ~~task~~\n");
state.buffer.undo();
assert_eq!(state.text(), "- [ ] task\n");
assert!(!state.buffer.can_undo(), "leaf toggle is one undo entry");
}
#[test]
fn toggle_parent_all_children_text() {
let mut state = editor_with_cursor("- [ ] |parent\n - [ ] child1\n - [ ] child2\n");
state.toggle_checkbox(0);
assert_eq!(
state.text(),
"- [x] ~~parent~~\n - [x] ~~child1~~\n - [x] ~~child2~~\n"
);
}
#[test]
fn uncheck_cascades_to_parent_text() {
let mut state =
editor_with_cursor("- [x] ~~parent~~\n - [x] ~~|child1~~\n - [x] ~~child2~~\n");
state.toggle_checkbox(1);
assert_eq!(
state.text(),
"- [ ] parent\n - [ ] child1\n - [x] ~~child2~~\n"
);
}
}
mod strikethrough_tests {
use super::*;
#[test]
fn strikethrough_add_remove_round_trips() {
let mut state = EditorState::new("hello world\n");
state.toggle_line_strikethrough(0, true, 0);
assert_eq!(state.text(), "~~hello world~~\n");
state.toggle_line_strikethrough(0, false, 0);
assert_eq!(
state.text(),
"hello world\n",
"round-trip is byte-identical"
);
}
#[test]
fn strikethrough_add_is_single_undo() {
let mut state = EditorState::new("hello world\n");
state.toggle_line_strikethrough(0, true, 0);
assert_eq!(state.text(), "~~hello world~~\n");
state.buffer.undo();
assert_eq!(
state.text(),
"hello world\n",
"one undo reverts the whole strikethrough toggle"
);
assert!(!state.buffer.can_undo(), "no further undo entries remain");
}
#[test]
fn typing_coalesces_into_word_undo_steps() {
let mut state = EditorState::new("");
for c in "hi there".chars() {
state.insert_text(&c.to_string());
}
assert_eq!(state.text(), "hi there");
state.buffer.undo();
assert_eq!(state.text(), "hi ", "undo removes the whole last word");
state.buffer.undo();
assert_eq!(state.text(), "hi", "undo removes the space");
state.buffer.undo();
assert_eq!(state.text(), "", "undo removes the first word");
}
#[test]
fn backspace_coalesces_into_one_undo() {
let mut state = EditorState::new("word\n");
state.set_cursor(4); for _ in 0..4 {
state.delete_backward();
}
assert_eq!(state.text(), "\n");
state.buffer.undo();
assert_eq!(
state.text(),
"word\n",
"one undo restores the backspaced word"
);
}
#[test]
fn paste_is_its_own_undo_step() {
let mut state = EditorState::new("");
state.insert_text("ab"); for c in "cd".chars() {
state.insert_text(&c.to_string());
}
assert_eq!(state.text(), "abcd");
state.buffer.undo();
assert_eq!(state.text(), "ab", "typing after a paste undoes separately");
state.buffer.undo();
assert_eq!(state.text(), "", "the paste is its own step");
}
#[test]
fn strikethrough_remove_is_single_undo() {
let mut state = EditorState::new("~~hello world~~\n");
state.toggle_line_strikethrough(0, false, 0);
assert_eq!(state.text(), "hello world\n");
state.buffer.undo();
assert_eq!(state.text(), "~~hello world~~\n", "one undo restores `~~`");
assert!(!state.buffer.can_undo(), "no further undo entries remain");
}
#[test]
fn strikethrough_preserves_surrounding_whitespace() {
let mut state = EditorState::new(" hello \n");
state.toggle_line_strikethrough(0, true, 0);
assert_eq!(state.text(), " ~~hello~~ \n");
state.toggle_line_strikethrough(0, false, 0);
assert_eq!(state.text(), " hello \n");
}
}
}
#[cfg(test)]
mod nested_context_tests {
use super::*;
#[test]
fn nested_context_simple_list() {
let state = EditorState::new("- item\n");
let cursor_offset = 2; let markers = state.build_nested_context(cursor_offset);
assert_eq!(markers.len(), 1);
assert!(matches!(
markers[0],
MarkerKind::ListItem { ordered: false, .. }
));
}
#[test]
fn nested_context_nested_list() {
let state = EditorState::new("- parent\n - child\n");
let cursor_offset = 14; let markers = state.build_nested_context(cursor_offset);
assert_eq!(markers.len(), 2);
assert!(matches!(
markers[0],
MarkerKind::ListItem { ordered: false, .. }
));
assert!(matches!(
markers[1],
MarkerKind::ListItem { ordered: false, .. }
));
}
#[test]
fn nested_context_checkbox_nested() {
let state = EditorState::new("- [x] parent\n - [ ] child\n");
let cursor_offset = 20; let markers = state.build_nested_context(cursor_offset);
assert_eq!(markers.len(), 4);
assert!(matches!(
markers[0],
MarkerKind::ListItem { ordered: false, .. }
));
assert!(matches!(markers[1], MarkerKind::Checkbox { checked: true }));
assert!(matches!(
markers[2],
MarkerKind::ListItem { ordered: false, .. }
));
assert!(matches!(
markers[3],
MarkerKind::Checkbox { checked: false }
));
}
#[test]
fn nested_context_blockquote_list() {
let state = EditorState::new("> - item\n");
let cursor_offset = 4; let markers = state.build_nested_context(cursor_offset);
assert_eq!(markers.len(), 2);
assert!(matches!(markers[0], MarkerKind::BlockQuote));
assert!(matches!(
markers[1],
MarkerKind::ListItem { ordered: false, .. }
));
}
#[test]
fn nested_context_ordered_list() {
let state = EditorState::new("1. first\n2. second\n");
let cursor_offset = 12; let markers = state.build_nested_context(cursor_offset);
assert_eq!(markers.len(), 1);
assert!(matches!(
markers[0],
MarkerKind::ListItem { ordered: true, .. }
));
}
#[test]
fn nested_context_empty_line() {
let state = EditorState::new("hello\n");
let cursor_offset = 2; let markers = state.build_nested_context(cursor_offset);
assert_eq!(markers.len(), 0);
}
}
#[cfg(test)]
mod debug_tree_structure {
use super::*;
#[test]
fn check_blockquote_list_paragraph() {
let state = EditorState::new("> - hey\n> paragraph\n");
if let Some(tree) = state.buffer.tree() {
let root = tree.block_tree().root_node();
eprintln!("Tree: {}", root.to_sexp());
}
}
#[test]
fn check_simple_list_paragraph() {
let state = EditorState::new("- hey\n paragraph\n");
if let Some(tree) = state.buffer.tree() {
let root = tree.block_tree().root_node();
eprintln!("Tree: {}", root.to_sexp());
}
}
}
#[cfg(test)]
mod debug_tree_detail {
use super::*;
#[test]
fn show_tree_detail() {
let content = "> - hey\n> paragraph\n";
eprintln!("Content: {:?}", content);
eprintln!("Bytes:");
for (i, b) in content.bytes().enumerate() {
eprintln!(" {}: {:?} ({})", i, b as char, b);
}
let state = EditorState::new(content);
if let Some(tree) = state.buffer.tree() {
let root = tree.block_tree().root_node();
eprintln!("\nTree: {}", root.to_sexp());
fn print_node(node: tree_sitter::Node, indent: usize) {
eprintln!(
"{}{} [{}-{}]",
" ".repeat(indent),
node.kind(),
node.start_byte(),
node.end_byte()
);
for child in node.children(&mut node.walk()) {
print_node(child, indent + 1);
}
}
print_node(root, 0);
}
}
}