From 392506d414f859154587ae0ae425660f73ef13d1 Mon Sep 17 00:00:00 2001 From: pagedmov Date: Fri, 20 Mar 2026 00:19:21 -0400 Subject: [PATCH] finished linebuf refactor, all tests passing --- src/readline/linebuf.rs | 292 +++++++++++++++++++++++++++++----- src/readline/mod.rs | 10 +- src/readline/tests.rs | 16 +- src/readline/vicmd.rs | 2 +- src/readline/vimode/visual.rs | 2 +- 5 files changed, 272 insertions(+), 50 deletions(-) diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index 3b5c7df..0b0f253 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -345,6 +345,33 @@ impl Pos { }; pub const MIN: Self = Pos { row: 0, col: 0 }; + pub fn row_col_add(&self, row: isize, col: isize) -> Self { + Self { + row: self.row.saturating_add_signed(row), + col: self.col.saturating_add_signed(col), + } + } + + pub fn col_add(&self, rhs: usize) -> Self { + self.row_col_add(0, rhs as isize) + } + + pub fn col_add_signed(&self, rhs: isize) -> Self { + self.row_col_add(0, rhs) + } + + pub fn col_sub(&self, rhs: usize) -> Self { + self.row_col_add(0, -(rhs as isize)) + } + + pub fn row_add(&self, rhs: usize) -> Self { + self.row_col_add(rhs as isize, 0) + } + + pub fn row_sub(&self, rhs: usize) -> Self { + self.row_col_add(-(rhs as isize), 0) + } + pub fn clamp_row(&mut self, other: &[T]) { self.row = self.row.clamp(0, other.len().saturating_sub(1)); } @@ -357,13 +384,17 @@ impl Pos { } } -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone)] pub enum MotionKind { + /// A flat range from one grapheme position to another + /// `start` is not necessarily less than `end`. `start` in most cases + /// is the cursor's position. Char { start: Pos, end: Pos, inclusive: bool, }, + /// A range of whole lines. Line { start: usize, end: usize, @@ -575,6 +606,15 @@ impl LineBuf { fn line_mut(&mut self, row: usize) -> &mut Line { &mut self.lines[row] } + /// Takes an inclusive range of line numbers and returns an iterator over immutable borrows of those lines. + fn line_iter(&mut self, start: usize, end: usize) -> impl Iterator { + let (start,end) = ordered(start,end); + self.lines.iter().take(end + 1).skip(start) + } + fn line_iter_mut(&mut self, start: usize, end: usize) -> impl Iterator { + let (start,end) = ordered(start,end); + self.lines.iter_mut().take(end + 1).skip(start) + } fn line_to_cursor(&self) -> &[Grapheme] { let line = self.cur_line(); let col = self.cursor.pos.col.min(line.len()); @@ -662,7 +702,6 @@ impl LineBuf { fn break_line(&mut self) { let (row, col) = self.row_col(); let level = self.calc_indent_level(); - log::debug!("level: {level}"); let mut rest = self.lines[row].split_off(col); let mut col = 0; for tab in std::iter::repeat_n(Grapheme::from('\t'), level) { @@ -1322,6 +1361,11 @@ impl LineBuf { return None; } + // If cursor is on '-', advance to the first digit + if self.gr_at(pos)?.as_char() == Some('-') { + pos = pos.col_add(1); + } + let mut start = self.scan_backward_from(pos, |g| !is_digit(g)) .map(|pos| Pos { row: pos.row, col: pos.col + 1 }) .unwrap_or(Pos::MIN); @@ -1383,6 +1427,7 @@ impl LineBuf { } else { return None }; self.replace_range(s, e, &num_fmt); + self.cursor.pos.col -= 1; Some(()) } fn replace_range(&mut self, s: Pos, e: Pos, new: &str) -> Vec { @@ -1418,6 +1463,38 @@ impl LineBuf { result } } + fn find_delim_match(&mut self) -> Option { + let is_opener = |g: &Grapheme| matches!(g.as_char(), Some(c) if "([{<".contains(c)); + let is_closer = |g: &Grapheme| matches!(g.as_char(), Some(c) if ")]}>".contains(c)); + let is_delim = |g: &Grapheme| is_opener(g) || is_closer(g); + let first = self.scan_forward(is_delim)?; + + let delim_match = if is_closer(self.gr_at(first)?) { + let opener = match self.gr_at(first)?.as_char()? { + ')' => '(', + ']' => '[', + '}' => '{', + '>' => '<', + _ => unreachable!(), + }; + self.scan_backward_from(first, |g| g.as_char() == Some(opener))? + } else if is_opener(self.gr_at(first)?) { + let closer = match self.gr_at(first)?.as_char()? { + '(' => ')', + '[' => ']', + '{' => '}', + '<' => '>', + _ => unreachable!(), + }; + self.scan_forward_from(first, |g| g.as_char() == Some(closer))? + } else { unreachable!() }; + + Some(MotionKind::Char { + start: self.cursor.pos, + end: delim_match, + inclusive: true, + }) + } /// Wrapper for eval_motion_inner that calls it with `check_hint: false` fn eval_motion(&mut self, cmd: &ViCmd) -> Option { self.eval_motion_inner(cmd, false) @@ -1432,10 +1509,11 @@ impl LineBuf { let kind = match motion { Motion::WholeLine => { - let row = (self.row() + (count.saturating_sub(1))).min(self.lines.len().saturating_sub(1)); + let start = self.row(); + let end = (self.row() + (count.saturating_sub(1))).min(self.lines.len().saturating_sub(1)); Some(MotionKind::Line { - start: row, - end: row, + start, + end, inclusive: true }) } @@ -1586,11 +1664,62 @@ impl LineBuf { end: self.lines.len().saturating_sub(1), inclusive: false }), - Motion::ToColumn => todo!(), - Motion::ToDelimMatch => todo!(), - Motion::ToBrace(direction) => todo!(), - Motion::ToBracket(direction) => todo!(), - Motion::ToParen(direction) => todo!(), + Motion::ToColumn => { + let row = self.row(); + let end = Pos { row, col: count.saturating_sub(1) }; + Some(MotionKind::Char { start: self.cursor.pos, end, inclusive: end > self.cursor.pos }) + } + + Motion::ToDelimMatch => self.find_delim_match(), + Motion::ToBracket(direction) | + Motion::ToParen(direction) | + Motion::ToBrace(direction) => { + let (opener,closer) = match motion { + Motion::ToBracket(_) => ('[', ']'), + Motion::ToParen(_) => ('(', ')'), + Motion::ToBrace(_) => ('{', '}'), + _ => unreachable!(), + }; + match direction { + Direction::Forward => { + let mut depth = 0; + let target_pos = self.scan_forward(|g| { + if g.as_char() == Some(opener) { depth += 1; } + if g.as_char() == Some(closer) { + depth -= 1; + if depth <= 0 { + return true; + } + } + false + })?; + return Some(MotionKind::Char { + start: self.cursor.pos, + end: target_pos, + inclusive: true, + }) + } + Direction::Backward => { + let mut depth = 0; + let target_pos = self.scan_backward(|g| { + if g.as_char() == Some(closer) { depth += 1; } + if g.as_char() == Some(opener) { + depth -= 1; + if depth <= 0 { + return true; + } + } + false + })?; + return Some(MotionKind::Char { + start: self.cursor.pos, + end: target_pos, + inclusive: true, + }); + } + } + } + Motion::CharRange(s, e) => { let (s, e) = ordered(*s, *e); Some(MotionKind::Char { @@ -1617,6 +1746,19 @@ impl LineBuf { self.lines = buffer; kind } + fn move_to_start(&mut self, motion: MotionKind) { + match motion { + MotionKind::Char { start, end, inclusive } => { + let (s,_) = ordered(start, end); + self.set_cursor(s); + } + MotionKind::Line { start, end, inclusive } => { + let (s,_) = ordered(start, end); + self.set_cursor(Pos { row: s, col: 0 }); + } + MotionKind::Block { start, end } => todo!(), + } + } /// Wrapper for apply_motion_inner that calls it with `accept_hint: false` fn apply_motion(&mut self, motion: MotionKind) -> ShResult<()> { self.apply_motion_inner(motion, false) @@ -1683,7 +1825,6 @@ impl LineBuf { extracted } fn yank_range(&self, motion: &MotionKind) -> Vec { - log::debug!("Yanking range: {:?}", motion); let mut tmp = Self { lines: self.lines.clone(), cursor: self.cursor, @@ -1701,7 +1842,6 @@ impl LineBuf { let mut lines = self.lines.clone(); split_lines_at(&mut lines, pos); let raw = join_lines(&lines); - log::debug!("Calculating indent level for pos {:?} with raw text:\n{:?}", pos, raw); self.indent_ctx.calculate(&raw) } @@ -1760,18 +1900,18 @@ impl LineBuf { fn inplace_mutation(&mut self, count: u16, f: impl Fn(&Grapheme) -> Grapheme) { let mut first = true; for i in 0..count { - let pos = self.cursor.pos; - let motion = MotionKind::Char { - start: pos, - end: pos, - inclusive: false, - }; - self.motion_mutation(motion, &f); - if !first { + if first { first = false } else { self.cursor.pos = self.offset_cursor(0, 1); } + let pos = self.cursor.pos; + let motion = MotionKind::Char { + start: pos, + end: pos, + inclusive: true, + }; + self.motion_mutation(motion, &f); } } fn exec_verb(&mut self, cmd: &ViCmd) -> ShResult<()> { @@ -1781,7 +1921,6 @@ impl LineBuf { motion, .. } = cmd; - log::debug!("Executing verb: {:?} with motion: {:?}", verb, motion); let Some(VerbCmd(_, verb)) = verb else { // For verb-less motions in insert mode, merge hint before evaluating // so motions like `w` can see into the hint text @@ -1855,12 +1994,14 @@ impl LineBuf { .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); + self.move_to_start(motion); } Verb::ReplaceChar(ch) => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; self.motion_mutation(motion, |_| Grapheme::from(*ch)); + self.move_to_start(motion); } Verb::ReplaceCharInplace(ch, count) => self.inplace_mutation(*count, |_| Grapheme::from(*ch)), Verb::ToggleCaseInplace(count) => { @@ -1870,6 +2011,7 @@ impl LineBuf { .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); + self.cursor.pos = self.cursor.pos.col_add(1); } Verb::ToggleCaseRange => { let Some(motion) = self.eval_motion(cmd) else { @@ -1881,6 +2023,7 @@ impl LineBuf { .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); + self.move_to_start(motion); } Verb::IncrementNumber(n) => { self.adjust_number(*n as i64); }, Verb::DecrementNumber(n) => { self.adjust_number(-(*n as i64)); }, @@ -1890,10 +2033,11 @@ impl LineBuf { }; self.motion_mutation(motion, |gr| { gr.as_char() - .map(|c| c.to_ascii_uppercase()) + .map(|c| c.to_ascii_lowercase()) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) - }) + }); + self.move_to_start(motion); } Verb::ToUpper => { let Some(motion) = self.eval_motion(cmd) else { @@ -1904,7 +2048,8 @@ impl LineBuf { .map(|c| c.to_ascii_uppercase()) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) - }) + }); + self.move_to_start(motion); } Verb::Undo => { if let Some(edit) = self.undo_stack.pop() { @@ -1920,7 +2065,6 @@ impl LineBuf { self.undo_stack.push(edit); } } - Verb::RepeatLast => todo!(), Verb::Put(anchor) => { let Some(content) = register.read_from_register() else { return Ok(()); @@ -1952,10 +2096,11 @@ impl LineBuf { self.lines[row + last].append(&mut right); let end_len = self.lines[row].len(); - let delta = end_len.saturating_sub(start_len); + let mut delta = end_len.saturating_sub(start_len); + if let Anchor::Before = anchor { delta = delta.saturating_sub(1); } if move_cursor { self.cursor.pos = self.offset_cursor(0, delta as isize); - } else if content_len > 1 { + } else if content_len > 1 || *anchor == Anchor::After { self.cursor.pos = self.offset_cursor(0, 1); } } @@ -2065,9 +2210,63 @@ impl LineBuf { } } Verb::Insert(s) => self.insert_str(s), - Verb::Indent => todo!(), - Verb::Dedent => todo!(), - Verb::Equalize => todo!(), + Verb::Indent | Verb::Dedent => { + let Some(motion) = self.eval_motion(cmd) else { + return Ok(()); + }; + let (s, e) = match motion { + MotionKind::Char { start, end, .. } => ordered(start.row, end.row), + MotionKind::Line { start, end, .. } => ordered(start, end), + MotionKind::Block { .. } => todo!(), + }; + let mut col_offset = 0; + for line in self.line_iter_mut(s, e) { + match verb { + Verb::Indent => { + line.insert(0, Grapheme::from('\t')); + col_offset += 1; + } + Verb::Dedent => { + if line.0.first().is_some_and(|c| c.as_char() == Some('\t')) { + line.0.remove(0); + col_offset -= 1; + } + } + _ => unreachable!(), + } + } + self.cursor.pos = self.cursor.pos.col_add_signed(col_offset) + } + Verb::Equalize => { + let Some(motion) = self.eval_motion(cmd) else { + return Ok(()) + }; + let (s,e) = match motion { + MotionKind::Char { start, end, inclusive } => ordered(start.row, end.row), + MotionKind::Line { start, end, inclusive } => ordered(start, end), + MotionKind::Block { start, end } => todo!(), + }; + for row in s..=e { + let line_len = self.line(row).len(); + + // we are going to calculate the level twice, once at column = 0 and once at column = line.len() + // "b-b-b-b-but the performance" i dont care. open a pull request genius + // the number of tabs we use for the line is the lesser of these two calculations + // if level_start > level_end, the line has an closer + // if level_end > level_start, the line has a opener + let level_start = self.calc_indent_level_for_pos(Pos { row, col: 0 }); + let level_end = self.calc_indent_level_for_pos(Pos { row, col: line_len }); + let num_tabs = level_start.min(level_end); + + let line = self.line_mut(row); + while line.0.first().is_some_and(|c| c.as_char() == Some('\t')) { + line.0.remove(0); + } + for tab in std::iter::repeat_n(Grapheme::from('\t'), num_tabs) { + line.insert(0, tab); + } + } + } Verb::AcceptLineOrNewline => { // If we are here, we did not accept the line // so we break to a new line @@ -2169,10 +2368,10 @@ impl LineBuf { | Verb::VisualModeBlock | Verb::CompleteBackward | Verb::VisualModeSelectLast => { - let Some(motion_kind) = self.eval_motion(cmd) else { + let Some(motion_kind) = self.eval_motion_inner(cmd, true) else { return Ok(()); }; - self.apply_motion(motion_kind)?; + self.apply_motion_inner(motion_kind, true)?; } Verb::Normal(_) | Verb::Substitute(..) @@ -2181,12 +2380,14 @@ impl LineBuf { | Verb::RepeatGlobal => { log::warn!("Verb {:?} is not implemented yet", verb); } + Verb::RepeatLast => unreachable!("Verb::RepeatLast should be handled in readline/mod.rs"), } Ok(()) } pub fn exec_cmd(&mut self, cmd: ViCmd) -> ShResult<()> { let is_char_insert = cmd.verb.as_ref().is_some_and(|v| v.1.is_char_insert()); + let starts_merge = cmd.verb.as_ref().is_some_and(|v| matches!(v.1, Verb::Change)); let is_line_motion = cmd.is_line_motion() || cmd .verb @@ -2214,6 +2415,13 @@ impl LineBuf { let new_cursor = self.cursor.pos; + // Stop merging on any non-char-insert command, even if buffer didn't change + if !is_char_insert && !is_undo_op { + if let Some(edit) = self.undo_stack.last_mut() { + edit.merging = false; + } + } + if self.lines != before && !is_undo_op { self.redo_stack.clear(); if is_char_insert { @@ -2231,11 +2439,13 @@ impl LineBuf { }); } } else { - // Stop merging on any non-insert edit - if let Some(edit) = self.undo_stack.last_mut() { - edit.merging = false; - } self.handle_edit(before, new_cursor, old_cursor); + // Change starts a new merge chain so subsequent InsertChars merge into it + if starts_merge { + if let Some(edit) = self.undo_stack.last_mut() { + edit.merging = true; + } + } } } @@ -2744,10 +2954,16 @@ struct CharClassIterRev<'a> { impl<'a> CharClassIterRev<'a> { pub fn new(lines: &'a [Line], start_pos: Pos) -> Self { + let row = start_pos.row.min(lines.len().saturating_sub(1)); + let col = if lines.is_empty() || lines[row].is_empty() { + 0 + } else { + start_pos.col.min(lines[row].len().saturating_sub(1)) + }; Self { lines, - row: start_pos.row, - col: start_pos.col, + row, + col, exhausted: false, at_boundary: false, } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index a9fef54..a341e2a 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -1275,9 +1275,7 @@ impl ShedVi { if let Some(range) = self.editor.select_range() { cmd.motion = Some(MotionCmd(1, range)) - } else { - log::warn!("You're in visual mode with no select range??"); - }; + } // Set cursor clamp BEFORE executing the command so that motions // (like EndOfLine for 'A') can reach positions valid in the new mode @@ -1322,8 +1320,6 @@ impl ShedVi { pub fn exec_cmd(&mut self, mut cmd: ViCmd, from_replay: bool) -> ShResult<()> { if cmd.verb().is_some() && let Some(range) = self.editor.select_range() { cmd.motion = Some(MotionCmd(1, range)) - } else { - log::warn!("You're in visual mode with no select range??"); }; if cmd.is_mode_transition() { @@ -1399,7 +1395,7 @@ impl ShedVi { }; let repeat_cmd = ViCmd { register: RegisterName::default(), - verb: None, + verb: cmd.verb, motion: Some(motion), raw_seq: format!("{count};"), flags: CmdFlags::empty(), @@ -1414,7 +1410,7 @@ impl ShedVi { new_motion.0 = *count; let repeat_cmd = ViCmd { register: RegisterName::default(), - verb: None, + verb: cmd.verb, motion: Some(new_motion), raw_seq: format!("{count},"), flags: CmdFlags::empty(), diff --git a/src/readline/tests.rs b/src/readline/tests.rs index 6a89acf..b50487e 100644 --- a/src/readline/tests.rs +++ b/src/readline/tests.rs @@ -423,6 +423,16 @@ vi_test! { vi_percent_bracket : "[hello] world" => "%" => "[hello] world", 6; vi_percent_from_close: "(hello) world" => "f)%" => "(hello) world", 0; vi_d_percent_paren : "(hello) world" => "d%" => " world", 0; + vi_to_paren_fwd : "foo (bar) baz" => "])" => "foo (bar) baz", 8; + vi_to_paren_bkwd : "foo (bar) baz" => "f)[(" => "foo (bar) baz", 4; + vi_to_brace_fwd : "foo {bar} baz" => "]}" => "foo {bar} baz", 8; + vi_to_brace_bkwd : "foo {bar} baz" => "f}[{" => "foo {bar} baz", 4; + vi_to_paren_nested : "((a)(b)) end" => "])" => "((a)(b)) end", 7; + vi_to_brace_nested : "{{a}{b}} end" => "]}" => "{{a}{b}} end", 7; + vi_d_to_paren_fwd : "foo (bar) baz" => "wd])" => "foo baz", 4; + vi_d_to_brace_fwd : "foo {bar} baz" => "wd]}" => "foo baz", 4; + vi_to_paren_no_match : "foo bar baz" => "])" => "foo bar baz", 0; + vi_to_brace_no_match : "foo bar baz" => "]}" => "foo bar baz", 0; vi_i_insert : "hello" => "iX\x1b" => "Xhello", 0; vi_a_append : "hello" => "aX\x1b" => "hXello", 1; vi_I_front : " hello" => "IX\x1b" => " Xhello", 2; @@ -473,10 +483,10 @@ vi_test! { vi_V_S_change : "hello world" => "VSfoo\x1b" => "foo", 2; vi_ctrl_a_inc : "num 5 end" => "w\x01" => "num 6 end", 4; vi_ctrl_x_dec : "num 5 end" => "w\x18" => "num 4 end", 4; - vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 4; - vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 4; + vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 5; + vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 5; vi_ctrl_a_count : "num 5 end" => "w3\x01" => "num 8 end", 4; - vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 4; + vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 8; vi_delete_empty : "" => "x" => "", 0; vi_undo_on_empty : "" => "u" => "", 0; vi_w_single_char : "a b c" => "w" => "a b c", 2; diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index e5c7129..a528c26 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -309,7 +309,7 @@ impl Verb { pub fn is_char_insert(&self) -> bool { matches!( self, - Self::Change | Self::InsertChar(_) | Self::ReplaceChar(_) | Self::ReplaceCharInplace(_, _) + Self::InsertChar(_) | Self::ReplaceChar(_) ) } } diff --git a/src/readline/vimode/visual.rs b/src/readline/vimode/visual.rs index 723f7a8..7f4138f 100644 --- a/src/readline/vimode/visual.rs +++ b/src/readline/vimode/visual.rs @@ -214,7 +214,7 @@ impl ViVisual { let ch = chars_clone.next()?; return Some(ViCmd { register, - verb: Some(VerbCmd(1, Verb::ReplaceCharInplace(ch, 1))), + verb: Some(VerbCmd(1, Verb::ReplaceChar(ch))), motion: None, raw_seq: self.take_cmd(), flags: CmdFlags::empty(),