diff --git a/Cargo.lock b/Cargo.lock index a010f00..b27d282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2778,9 +2778,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2801,9 +2801,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "rowdy" -version = "0.17.2" +version = "0.18.0" dependencies = [ "anyhow", "arboard", @@ -2885,6 +2885,7 @@ dependencies = [ "rand 0.10.1", "ratatui", "ratatui-textarea", + "regex", "reqwest", "rpassword", "semver", diff --git a/Cargo.toml b/Cargo.toml index 66dd048..60cc48b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rowdy" -version = "0.17.2" +version = "0.18.0" edition = "2024" rust-version = "1.86" license = "MIT" @@ -26,6 +26,7 @@ nucleo-matcher = "0.3.1" rand = "0.10.1" ratatui = "0.30.0" ratatui-textarea = "0.9.1" +regex = "1.12.4" reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "json"] } rpassword = "7.4.0" semver = "1" diff --git a/src/action/mod.rs b/src/action/mod.rs index 4a51834..43b4038 100644 --- a/src/action/mod.rs +++ b/src/action/mod.rs @@ -15,6 +15,7 @@ mod results; mod saved_queries; mod schema; mod session; +mod substitute; mod update; pub use saved_queries::SavedQueryAction; @@ -117,6 +118,9 @@ pub enum Action { /// any) or the statement under the cursor; `All` rewrites the /// whole buffer. FormatEditor(FormatScope), + /// A keypress in the interactive `:s///c` confirm prompt. See + /// [`SubstituteConfirmAction`]. + SubstituteConfirm(SubstituteConfirmAction), /// Autocomplete popover lifecycle and navigation. See /// `CompletionAction` for the sub-variants. Completion(CompletionAction), @@ -423,6 +427,22 @@ pub enum ResultNavAction { Bottom, } +/// Keys handled by the interactive `:s///c` confirm prompt, mirroring vim's +/// substitute-confirm vocabulary. +#[derive(Debug, Clone, Copy)] +pub enum SubstituteConfirmAction { + /// `y` — replace this match and advance. + Yes, + /// `n` — skip this match and advance. + No, + /// `a` — replace this and every remaining match. + All, + /// `l` — replace this match, then stop ("last"). + Last, + /// `q` / `Esc` — stop without replacing the current match. + Quit, +} + #[derive(Debug, Clone, Copy)] pub enum ResultColumnAction { /// Swap the focused column with the visible column to its left. @@ -442,7 +462,10 @@ pub fn apply(app: &mut App, action: Action) { Action::ResizeSchema(delta) => schema::resize_schema(app, delta), Action::SetPendingChord(c) => app.pending = c, Action::EditorEvent(ev) => { - app.editor.events.on_event(ev, &mut app.editor.state); + app.editor + .events + .on_event(ev.clone(), &mut app.editor.state); + refresh_search_highlights(app, &ev); if app.completion.is_some() { completion::refresh(app); } else { @@ -450,7 +473,18 @@ pub fn apply(app: &mut App, action: Action) { } schedule_session_save(app); } - Action::OpenCommand => app.overlay = Some(Overlay::Command(CommandBuffer::default())), + Action::OpenCommand => { + // Pressing `:` in Visual mode seeds the line with `'<,'>`, so a + // range substitute over the selection is one keystroke away. + let buf = if app.focus == Focus::Editor + && app.editor.editor_mode() == edtui::EditorMode::Visual + { + CommandBuffer::with_text("'<,'>") + } else { + CommandBuffer::default() + }; + app.overlay = Some(Overlay::Command(buf)); + } Action::Command(cmd) => apply_command(app, cmd), Action::Schema(s) => schema::apply_schema(app, s), Action::PrepareConfirmRun => query::prepare_confirm_run(app), @@ -490,6 +524,7 @@ pub fn apply(app: &mut App, action: Action) { Action::CloseHelp => app.overlay = None, Action::HelpScroll(axis, delta) => apply_help_scroll(app, axis, delta), Action::FormatEditor(scope) => format_editor(app, scope), + Action::SubstituteConfirm(a) => substitute::apply_confirm(app, a), Action::Completion(c) => completion::apply(app, c), Action::ReloadSchemaCache => schema::reload_schema_cache(app), Action::ResetSession => session::reset_session(app), @@ -798,6 +833,7 @@ fn dispatch_command(app: &mut App, cmd: command::Command) { C::Load(name) => saved_queries::apply_load(app, name), C::RunSaved(Some(name)) => saved_queries::apply_run_saved(app, name), C::RunSaved(None) => saved_queries::open_run_picker(app), + C::Substitute(sub) => substitute::run(app, sub), } } @@ -1292,6 +1328,56 @@ fn format_buffer(app: &mut App) { schedule_session_save(app); } +/// Keep `/`-search match highlights in sync after an editor event. Owns only +/// the `Search` highlight lifecycle — confirm-run and `:s///c` highlights are +/// managed by their own overlays, so this bails when another owner holds the +/// highlight vector. +fn refresh_search_highlights(app: &mut App, ev: &CtEvent) { + use crate::state::editor::HighlightOwner; + use ratatui::crossterm::event::KeyCode; + + if !matches!( + app.editor.highlight_owner, + None | Some(HighlightOwner::Search) + ) { + return; + } + + let in_search = app.editor.editor_mode() == edtui::EditorMode::Search; + let pattern = app.editor.state.search_pattern(); + + if !in_search && app.editor.highlight_owner == Some(HighlightOwner::Search) { + // Leaving Search: edtui's `StopSearch` (Esc) clears the pattern; a + // plain Esc in Normal mode also acts as `:nohlsearch`. + let plain_esc = matches!( + ev, + CtEvent::Key(k) if k.code == KeyCode::Esc && k.modifiers.is_empty() + ); + if pattern.is_empty() || plain_esc { + app.editor.state.clear_highlights(); + app.editor.highlight_owner = None; + return; + } + } else if !in_search { + // Not searching and we don't own the highlights — nothing to do. + return; + } + + let style = ratatui::style::Style::default().bg(app.theme.selection_bg); + let spans = crate::state::editor::search_match_spans(&app.editor.state.lines, &pattern); + if spans.is_empty() { + app.editor.state.clear_highlights(); + app.editor.highlight_owner = None; + return; + } + let highlights = spans + .into_iter() + .map(|(start, end)| edtui::Highlight::new(start, end, style)) + .collect(); + app.editor.state.set_highlights(highlights); + app.editor.highlight_owner = Some(HighlightOwner::Search); +} + fn format_sql(sql: &str) -> String { sqlformat::format( sql, diff --git a/src/action/query.rs b/src/action/query.rs index db5eafc..ee9d179 100644 --- a/src/action/query.rs +++ b/src/action/query.rs @@ -23,6 +23,7 @@ pub(super) fn prepare_confirm_run(app: &mut App) { app.theme.selection_fg, ); crate::state::editor::highlight_range(&mut app.editor.state, &range, style); + app.editor.highlight_owner = Some(crate::state::editor::HighlightOwner::ConfirmRun); app.overlay = Some(Overlay::ConfirmRun { statement: range.text, reason: crate::state::overlay::ConfirmRunReason::Manual, @@ -34,6 +35,7 @@ pub(super) fn confirm_run_submit(app: &mut App) { return; }; crate::state::editor::clear_confirm_highlight(&mut app.editor.state); + app.editor.highlight_owner = None; dispatch_query(app, statement); } @@ -43,6 +45,7 @@ pub(super) fn confirm_run_cancel(app: &mut App) { } app.overlay = None; crate::state::editor::clear_confirm_highlight(&mut app.editor.state); + app.editor.highlight_owner = None; } pub(super) fn run_statement_under_cursor(app: &mut App) { diff --git a/src/action/substitute.rs b/src/action/substitute.rs new file mode 100644 index 0000000..ff332a8 --- /dev/null +++ b/src/action/substitute.rs @@ -0,0 +1,200 @@ +//! Dispatch for `:[range]s/pattern/replacement/flags`. The pure parsing and +//! application live in [`crate::substitute`]; this module wires the parsed +//! command to the live editor, status bar, and (for the `c` flag) the +//! interactive confirm overlay. + +use edtui::{Highlight, Index2, Lines}; + +use crate::action::SubstituteConfirmAction; +use crate::app::App; +use crate::state::editor::{ + HighlightOwner, capture_undo, confirm_highlight_style, set_buffer_with_cursor, +}; +use crate::state::overlay::Overlay; +use crate::state::status::QueryStatus; +use crate::state::substitute_confirm::ConfirmSubstituteState; +use crate::substitute::{ + SubstituteCmd, apply_substitute, build_regex, find_matches, resolve_range, +}; + +pub fn run(app: &mut App, cmd: SubstituteCmd) { + // Resolve the pattern: an empty pattern (`:s//new/`) reuses the last + // substitute pattern, falling back to the live `/` search pattern. + let pattern = if cmd.pattern.is_empty() { + let reuse = app.last_substitute_pattern.clone().or_else(|| { + let p = app.editor.state.search_pattern(); + (!p.is_empty()).then(|| regex::escape(&p)) + }); + match reuse { + Some(p) => p, + None => return fail(app, "no previous pattern".to_string()), + } + } else { + cmd.pattern.clone() + }; + + let re = match build_regex(&pattern, cmd.flags.ignore_case) { + Ok(re) => re, + Err(e) => return fail(app, e), + }; + app.last_substitute_pattern = Some(pattern.clone()); + + let last_row = app.editor.state.lines.len().saturating_sub(1); + let cursor_row = app.editor.state.cursor.row; + let selection_rows = app + .editor + .state + .selection + .as_ref() + .map(|s| (s.start().row, s.end().row)); + let (start, end) = match resolve_range(&cmd.range, cursor_row, last_row, selection_rows) { + Ok(rows) => rows, + Err(e) => return fail(app, e), + }; + + let buffer = app.editor.text(); + let matches = find_matches(&buffer, (start, end), &re, cmd.flags.global); + if matches.is_empty() { + return fail(app, format!("Pattern not found: {pattern}")); + } + + // One snapshot before any mutation so a single `u` unwinds the whole + // substitution (including a full interactive session). + capture_undo(&mut app.editor.state); + + if cmd.flags.confirm { + let first = matches[0]; + let st = ConfirmSubstituteState::new(re, cmd.replacement, cmd.flags.global, end, first); + render_current(app, &st); + app.overlay = Some(Overlay::ConfirmSubstitute(st)); + return; + } + + let outcome = apply_substitute( + &buffer, + (start, end), + &re, + &cmd.replacement, + cmd.flags.global, + ); + set_buffer_with_cursor( + &mut app.editor.state, + &outcome.text, + outcome.last_changed_line, + outcome.last_changed_col, + ); + clear_highlights(app); + app.status = QueryStatus::Notice { + msg: substitution_message(outcome.substitutions, outcome.lines_changed), + }; + super::schedule_session_save(app); +} + +pub fn apply_confirm(app: &mut App, action: SubstituteConfirmAction) { + let Some(Overlay::ConfirmSubstitute(mut st)) = app.overlay.take() else { + return; + }; + let mut buffer = app.editor.text(); + let mut finish = false; + + use SubstituteConfirmAction::*; + match action { + Yes => buffer = st.replace_current(&buffer), + No => st.skip_current(), + Last => { + buffer = st.replace_current(&buffer); + finish = true; + } + All => { + loop { + buffer = st.replace_current(&buffer); + match st.find_next(&buffer) { + Some(m) => st.current = m, + None => break, + } + } + finish = true; + } + Quit => finish = true, + } + + if !finish { + match st.find_next(&buffer) { + Some(m) => st.current = m, + None => finish = true, + } + } + + if finish { + finalize_confirm(app, &st, &buffer); + } else { + app.editor.state.lines = Lines::from(buffer.as_str()); + app.editor.state.selection = None; + render_current(app, &st); + app.overlay = Some(Overlay::ConfirmSubstitute(st)); + } +} + +fn finalize_confirm(app: &mut App, st: &ConfirmSubstituteState, buffer: &str) { + let (row, col) = match st.last_changed_line { + Some(r) => (r, st.last_changed_col), + None => (app.editor.state.cursor.row, app.editor.state.cursor.col), + }; + set_buffer_with_cursor(&mut app.editor.state, buffer, row, col); + clear_highlights(app); + app.status = QueryStatus::Notice { + msg: substitution_message(st.substitutions, st.lines_changed), + }; + super::schedule_session_save(app); +} + +/// Highlight the current match and park the cursor on it so edtui scrolls it +/// into view. +fn render_current(app: &mut App, st: &ConfirmSubstituteState) { + let style = confirm_highlight_style(app.theme.selection_bg, app.theme.fg); + let m = st.current; + let start = Index2::new(m.line, m.start_col); + // edtui highlight ends are inclusive; a zero-width match highlights the + // single cell at its start. + let end_col = if m.end_col > m.start_col { + m.end_col - 1 + } else { + m.start_col + }; + let end = Index2::new(m.line, end_col); + app.editor.state.clear_highlights(); + app.editor + .state + .add_highlight(Highlight::new(start, end, style)); + app.editor.highlight_owner = Some(HighlightOwner::Substitute); + app.editor.state.cursor = start; +} + +fn clear_highlights(app: &mut App) { + app.editor.state.clear_highlights(); + app.editor.highlight_owner = None; +} + +fn substitution_message(substitutions: usize, lines: usize) -> String { + let plural = |n: usize| if n == 1 { "" } else { "s" }; + format!( + "{substitutions} substitution{} on {lines} line{}", + plural(substitutions), + plural(lines) + ) +} + +fn fail(app: &mut App, error: String) { + app.status = QueryStatus::Failed { error }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_singular_and_plural() { + assert_eq!(substitution_message(1, 1), "1 substitution on 1 line"); + assert_eq!(substitution_message(3, 2), "3 substitutions on 2 lines"); + } +} diff --git a/src/app.rs b/src/app.rs index ce93a50..14ca056 100644 --- a/src/app.rs +++ b/src/app.rs @@ -198,6 +198,9 @@ pub struct App { /// `/params/.json` on every submit. See /// [`crate::param_history`]. pub param_history: std::collections::HashMap, + /// Last pattern used by `:s` (or the `/` search), so a bare-pattern + /// substitute (`:s//new/`) can reuse it the way vim does. + pub last_substitute_pattern: Option, } impl App { @@ -299,6 +302,7 @@ impl App { preview_hidden: false, pending_update_prompt: None, param_history: std::collections::HashMap::new(), + last_substitute_pattern: None, } } } diff --git a/src/command.rs b/src/command.rs index 63a8de5..03e573c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -68,6 +68,10 @@ pub enum Command { /// picker overlay; with a name, dispatches straight through the /// existing query pipeline (placeholders prompt, etc.). RunSaved(Option), + /// `:[range]s/pattern/replacement/flags` — vim-style search & replace + /// in the editor buffer. Detected on the raw line before whitespace + /// splitting (patterns may contain spaces). + Substitute(crate::substitute::SubstituteCmd), } /// `:chat` subcommands. Bare `:chat` toggles the right panel between @@ -364,6 +368,11 @@ pub static COMMAND_TREE: &[CommandSpec] = &[ /// as no-op). `Err(msg)` is a user-facing error suitable for the /// status bar. pub fn parse(line: &str) -> Result, String> { + // Substitute commands can contain spaces (`:%s/foo bar/baz/`), so they + // must be matched on the raw line before any whitespace splitting. + if let Some(sub) = crate::substitute::parse_substitute(line)? { + return Ok(Some(Command::Substitute(sub))); + } let mut parts = line.split_whitespace(); let Some(cmd) = parts.next() else { return Ok(None); @@ -658,6 +667,26 @@ mod tests { assert_eq!(parse("clear"), Ok(Some(Command::Clear))); } + #[test] + fn substitute_with_spaces_in_pattern() { + let Ok(Some(Command::Substitute(sub))) = parse("%s/foo bar/baz qux/g") else { + panic!("expected substitute command"); + }; + assert_eq!(sub.pattern, "foo bar"); + assert_eq!(sub.replacement, "baz qux"); + assert!(sub.flags.global); + } + + #[test] + fn substitute_does_not_shadow_s_prefixed_commands() { + assert_eq!( + parse("save my query"), + Ok(Some(Command::Save("my query".into()))) + ); + assert!(matches!(parse("source"), Ok(Some(Command::Source)))); + assert!(matches!(parse("session 2"), Ok(Some(Command::Session(_))))); + } + #[test] fn update_command_parses() { assert_eq!(parse("update"), Ok(Some(Command::Update))); diff --git a/src/event.rs b/src/event.rs index 25058ae..b4c0a5e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -94,6 +94,7 @@ fn translate_key(app: &App, key: KeyEvent, raw: CtEvent) -> Option { Overlay::ParamsPrompt(_) => translate_params_prompt_key(key), Overlay::ConfirmSaveOverwrite { .. } => translate_save_overwrite_key(key), Overlay::SavedQueryPicker(_) => translate_saved_query_picker_key(key), + Overlay::ConfirmSubstitute(_) => translate_confirm_substitute_key(key), }; } match &app.screen { @@ -300,6 +301,22 @@ fn translate_confirm_key(key: KeyEvent) -> Option { } } +/// Interactive `:s///c` prompt: vim's y/n/a/l/q vocabulary plus Esc to stop. +/// Enter is deliberately unbound (vim's `` here is surprising). Other +/// keys are inert so a stray press doesn't dismiss the prompt. +fn translate_confirm_substitute_key(key: KeyEvent) -> Option { + use crate::action::SubstituteConfirmAction as S; + let action = match key.code { + KeyCode::Char('y') => S::Yes, + KeyCode::Char('n') => S::No, + KeyCode::Char('a') => S::All, + KeyCode::Char('l') => S::Last, + KeyCode::Char('q') | KeyCode::Esc => S::Quit, + _ => return None, + }; + Some(Action::SubstituteConfirm(action)) +} + /// Auto-update prompt: y/Y/Enter accept, n/N/Esc dismiss. fn translate_update_key(key: KeyEvent) -> Option { match key.code { diff --git a/src/main.rs b/src/main.rs index 983230a..222e6d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod sql_infer; mod sql_quote; mod state; mod subcommands; +mod substitute; mod terminal; mod ui; mod update; diff --git a/src/state/command.rs b/src/state/command.rs index 6f89950..62bb19a 100644 --- a/src/state/command.rs +++ b/src/state/command.rs @@ -118,6 +118,16 @@ impl Default for CommandBuffer { } impl CommandBuffer { + /// Open the command line pre-filled with `text`, cursor at the end. + /// Used when `:` is pressed in Visual mode to seed `'<,'>` for a + /// range substitute, the way vim does. + pub fn with_text(text: &str) -> Self { + let mut buf = Self::default(); + buf.input.insert_str(text); + buf.recompute_completion(); + buf + } + pub fn text(&self) -> &str { self.input.lines().first().map(String::as_str).unwrap_or("") } diff --git a/src/state/editor.rs b/src/state/editor.rs index 2b78333..aa72896 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -1,3 +1,4 @@ +use edtui::actions::{Execute, RemoveChar}; use edtui::{EditorEventHandler, EditorMode, EditorState, Highlight, Index2, Lines}; use ratatui::style::{Color, Style}; use sqlparser::dialect::GenericDialect; @@ -6,6 +7,23 @@ use sqlparser::tokenizer::{Token, Tokenizer}; pub struct EditorPanel { pub state: EditorState, pub events: EditorEventHandler, + /// Which feature currently owns `state.highlights`. edtui's highlight + /// vector is shared, so the `/`-search refresh, the confirm-run prompt, + /// and `:s` confirm must not clear each other's spans — each tags its + /// ownership here and only touches the highlights when it owns them. + pub highlight_owner: Option, +} + +/// Tags the current owner of `EditorState::highlights`. See +/// [`EditorPanel::highlight_owner`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightOwner { + /// `/` search match highlights. + Search, + /// The "run this statement?" confirm prompt highlight. + ConfirmRun, + /// The current match during interactive `:s///c`. + Substitute, } impl EditorPanel { @@ -14,6 +32,7 @@ impl EditorPanel { Self { state: EditorState::new(Lines::from(initial)), events: EditorEventHandler::default(), + highlight_owner: None, } } @@ -248,6 +267,57 @@ pub fn insert_text_at_cursor(state: &mut EditorState, text: &str) { state.cursor = clamp_index(&state.lines, offset_to_index(&new_chars, after_off)); } +/// Push the current buffer/cursor onto edtui's undo stack without changing +/// anything. `RemoveChar(0)` runs `state.capture()` (which is `pub(crate)`) +/// and then loops zero times, so it is a pure snapshot. Call this once before +/// a programmatic mutation of `state.lines` and a single `u` will unwind the +/// whole edit. Pinned by `undo_snapshot_round_trips` below — if a future +/// edtui drops the capture-before-loop ordering, that test fails. +pub fn capture_undo(state: &mut EditorState) { + RemoveChar(0).execute(state); +} + +/// Replace the buffer with `text` and park the cursor at `(row, col)` +/// (clamped to the new buffer), dropping any selection and returning to +/// Normal mode. Used by `:s` so the cursor lands on the last changed line, +/// vim-style. +pub fn set_buffer_with_cursor(state: &mut EditorState, text: &str, row: usize, col: usize) { + state.lines = Lines::from(text); + state.selection = None; + state.mode = EditorMode::Normal; + state.cursor = clamp_index(&state.lines, Index2::new(row, col)); +} + +/// Plain-text (case-sensitive) occurrences of `pattern` in the buffer, as +/// inclusive `(start, end)` index pairs suitable for `Highlight::new`. +/// Matches are found per-row (never across a `\n`), mirroring edtui's own +/// `/` search semantics. An empty pattern yields no spans. +pub fn search_match_spans(lines: &Lines, pattern: &str) -> Vec<(Index2, Index2)> { + if pattern.is_empty() { + return Vec::new(); + } + let pat: Vec = pattern.chars().collect(); + let text: String = lines.flatten(&Some('\n')).into_iter().collect(); + let mut spans = Vec::new(); + for (row, line) in text.split('\n').enumerate() { + let cols: Vec = line.chars().collect(); + if cols.len() < pat.len() { + continue; + } + let mut col = 0; + while col + pat.len() <= cols.len() { + if cols[col..col + pat.len()] == pat[..] { + let end = col + pat.len() - 1; + spans.push((Index2::new(row, col), Index2::new(row, end))); + col += pat.len(); + } else { + col += 1; + } + } + } + spans +} + pub fn cursor_to_offset(state: &EditorState) -> usize { let mut offset = 0; for row in 0..state.cursor.row { @@ -412,6 +482,45 @@ mod tests { assert!(state.selection.is_none()); } + #[test] + fn undo_snapshot_round_trips() { + // Pins the `RemoveChar(0)` capture trick: a snapshot then a direct + // `lines` mutation must be undoable in one `u`. + let mut state = EditorState::new(Lines::from("before")); + state.cursor = Index2::new(0, 2); + capture_undo(&mut state); + set_buffer_with_cursor(&mut state, "after", 0, 0); + assert_eq!(flatten(&state), "after"); + state.undo(); + assert_eq!(flatten(&state), "before"); + assert_eq!(state.cursor, Index2::new(0, 2)); + } + + #[test] + fn search_match_spans_finds_all_per_row() { + let lines = Lines::from("foo foo\nbar\nfoofoo"); + let spans = search_match_spans(&lines, "foo"); + assert_eq!( + spans, + vec![ + (Index2::new(0, 0), Index2::new(0, 2)), + (Index2::new(0, 4), Index2::new(0, 6)), + (Index2::new(2, 0), Index2::new(2, 2)), + (Index2::new(2, 3), Index2::new(2, 5)), + ] + ); + } + + #[test] + fn search_match_spans_utf8_and_empty() { + assert!(search_match_spans(&Lines::from("anything"), "").is_empty()); + let lines = Lines::from("café résumé"); + let spans = search_match_spans(&lines, "é"); + assert_eq!(spans[0], (Index2::new(0, 3), Index2::new(0, 3))); + assert_eq!(spans[1], (Index2::new(0, 6), Index2::new(0, 6))); + assert_eq!(spans[2], (Index2::new(0, 10), Index2::new(0, 10))); + } + #[test] fn replace_selection_text_no_op_without_selection() { let mut state = EditorState::new(Lines::from("untouched")); diff --git a/src/state/mod.rs b/src/state/mod.rs index 4304f80..0c13205 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -16,4 +16,5 @@ pub mod saved_query_picker; pub mod schema; pub mod screen; pub mod status; +pub mod substitute_confirm; pub mod theme_picker; diff --git a/src/state/overlay.rs b/src/state/overlay.rs index 8a5ae8d..92659e6 100644 --- a/src/state/overlay.rs +++ b/src/state/overlay.rs @@ -11,6 +11,7 @@ use crate::state::command::CommandBuffer; use crate::state::llm_settings::LlmSettingsState; use crate::state::params_prompt::ParamsPromptState; use crate::state::saved_query_picker::SavedQueryPickerState; +use crate::state::substitute_confirm::ConfirmSubstituteState; /// The layer that floats over the current [`crate::state::screen::Screen`]. // @@ -76,6 +77,10 @@ pub enum Overlay { /// `:run-saved`. `purpose` decides whether Enter inserts the body /// at the cursor or dispatches it through the query pipeline. SavedQueryPicker(SavedQueryPickerState), + /// Interactive `:s///c` prompt. Each keypress (`y`/`n`/`a`/`l`/`q`) + /// drives the live match scan in [`ConfirmSubstituteState`]; the + /// editor shows the current match highlighted underneath. + ConfirmSubstitute(ConfirmSubstituteState), } /// Why the confirm-run overlay opened. Drives the headline at the top diff --git a/src/state/substitute_confirm.rs b/src/state/substitute_confirm.rs new file mode 100644 index 0000000..fe6510a --- /dev/null +++ b/src/state/substitute_confirm.rs @@ -0,0 +1,277 @@ +//! State for the interactive `:s///c` confirm flow. +//! +//! Matches are scanned live rather than precomputed: each accepted +//! replacement shifts the columns of every later match on the same line, so +//! the state only ever holds the *current* match plus a resume point +//! ([`scan_row`](ConfirmSubstituteState::scan_row) / +//! [`scan_col`](ConfirmSubstituteState::scan_col)) for finding the next one. +//! +//! The command line is single-line, so the replacement can never contain a +//! raw newline — the buffer's line count is invariant, which keeps the scan +//! a simple per-line walk. + +use crate::substitute::MatchSpan; +use regex::Regex; + +#[derive(Debug)] +pub struct ConfirmSubstituteState { + pub regex: Regex, + /// Replacement in `regex`-crate syntax (already translated). + pub replacement: String, + pub global: bool, + /// Inclusive last row of the substitute range. + pub end_row: usize, + /// The match currently being prompted, in live-buffer char coords. + pub current: MatchSpan, + /// Where [`find_next`](Self::find_next) resumes scanning. + pub scan_row: usize, + pub scan_col: usize, + pub substitutions: usize, + pub lines_changed: usize, + /// Row of the last replacement made (for final cursor placement). + pub last_changed_line: Option, + pub last_changed_col: usize, +} + +impl ConfirmSubstituteState { + pub fn new( + regex: Regex, + replacement: String, + global: bool, + end_row: usize, + first: MatchSpan, + ) -> Self { + Self { + regex, + replacement, + global, + end_row, + current: first, + scan_row: first.line, + scan_col: first.start_col, + substitutions: 0, + lines_changed: 0, + last_changed_line: None, + last_changed_col: 0, + } + } + + fn current_is_zero_width(&self) -> bool { + self.current.start_col == self.current.end_col + } + + /// Replace [`current`](Self::current) in `buffer`, returning the new + /// buffer and advancing the scan resume point. Capture references in the + /// replacement are expanded against the actual match. + pub fn replace_current(&mut self, buffer: &str) -> String { + let mut lines: Vec = buffer.split('\n').map(str::to_string).collect(); + let row = self.current.line; + let line = &lines[row]; + let start_byte = char_col_to_byte(line, self.current.start_col); + let end_byte = char_col_to_byte(line, self.current.end_col); + let expanded = match self.regex.captures_at(line, start_byte) { + Some(caps) => { + let mut dst = String::new(); + caps.expand(&self.replacement, &mut dst); + dst + } + None => String::new(), + }; + let expanded_cols = expanded.chars().count(); + let new_line = format!("{}{}{}", &line[..start_byte], expanded, &line[end_byte..]); + lines[row] = new_line; + + self.substitutions += 1; + if self.last_changed_line != Some(row) { + self.lines_changed += 1; + } + self.last_changed_line = Some(row); + self.last_changed_col = self.current.start_col; + + if self.global { + self.scan_row = row; + // Continue just past the inserted text. Bump by one for a + // zero-width match with an empty expansion so we never re-match + // the same spot forever. + self.scan_col = if self.current_is_zero_width() { + self.current.start_col + expanded_cols.max(1) + } else { + self.current.start_col + expanded_cols + }; + } else { + self.scan_row = row + 1; + self.scan_col = 0; + } + lines.join("\n") + } + + /// Advance past [`current`](Self::current) without replacing it. + pub fn skip_current(&mut self) { + if self.global { + self.scan_row = self.current.line; + self.scan_col = if self.current_is_zero_width() { + self.current.start_col + 1 + } else { + self.current.end_col + }; + } else { + self.scan_row = self.current.line + 1; + self.scan_col = 0; + } + } + + /// Find the next match at or after the scan resume point, or `None` when + /// the range is exhausted. + pub fn find_next(&self, buffer: &str) -> Option { + next_match( + buffer, + &self.regex, + self.scan_row, + self.scan_col, + self.end_row, + self.global, + ) + } +} + +fn char_col_to_byte(line: &str, col: usize) -> usize { + line.char_indices() + .nth(col) + .map(|(b, _)| b) + .unwrap_or(line.len()) +} + +fn byte_to_col(line: &str, byte: usize) -> usize { + line[..byte].chars().count() +} + +/// Find the next match within `[start_row, end_row]` at or after +/// `(start_row, start_col)`. With `global` off, only the first match of each +/// line is considered (and `start_col` is ignored beyond the first row). +pub fn next_match( + buffer: &str, + re: &Regex, + start_row: usize, + start_col: usize, + end_row: usize, + global: bool, +) -> Option { + let lines: Vec<&str> = buffer.split('\n').collect(); + let mut row = start_row; + let mut first = true; + while row <= end_row && row < lines.len() { + let line = lines[row]; + let found = if global { + let from = if first { + char_col_to_byte(line, start_col) + } else { + 0 + }; + re.find_at(line, from) + } else { + re.find(line) + }; + if let Some(m) = found { + return Some(MatchSpan { + line: row, + start_col: byte_to_col(line, m.start()), + end_col: byte_to_col(line, m.end()), + }); + } + row += 1; + first = false; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::substitute::build_regex; + + fn state(buffer: &str, global: bool) -> (ConfirmSubstituteState, &str) { + let re = build_regex("x", false).unwrap(); + let end_row = buffer.split('\n').count().saturating_sub(1); + let first = next_match(buffer, &re, 0, 0, end_row, global).unwrap(); + ( + ConfirmSubstituteState::new(re, "y".to_string(), global, end_row, first), + buffer, + ) + } + + #[test] + fn global_walks_all_matches_on_a_line() { + let buffer = "x x x"; + let (mut st, mut buf) = { + let (s, b) = state("x x x", true); + (s, b.to_string()) + }; + assert_eq!(st.current.start_col, 0); + buf = st.replace_current(&buf); + assert_eq!(buf, "y x x"); + let m = st.find_next(&buf).unwrap(); + assert_eq!(m.start_col, 2); + st.current = m; + buf = st.replace_current(&buf); + assert_eq!(buf, "y y x"); + let m = st.find_next(&buf).unwrap(); + assert_eq!(m.start_col, 4); + st.current = m; + buf = st.replace_current(&buf); + assert_eq!(buf, "y y y"); + assert!(st.find_next(&buf).is_none()); + assert_eq!(st.substitutions, 3); + let _ = buffer; + } + + #[test] + fn non_global_one_per_line() { + let buffer = "x x\nx x"; + let (mut st, _) = state(buffer, false); + let mut buf = buffer.to_string(); + buf = st.replace_current(&buf); + assert_eq!(buf, "y x\nx x"); + let m = st.find_next(&buf).unwrap(); + assert_eq!(m.line, 1); + assert_eq!(m.start_col, 0); + st.current = m; + buf = st.replace_current(&buf); + assert_eq!(buf, "y x\ny x"); + assert!(st.find_next(&buf).is_none()); + assert_eq!(st.lines_changed, 2); + } + + #[test] + fn skip_then_find_next_on_same_line_global() { + let buffer = "x x"; + let (mut st, _) = state(buffer, true); + st.skip_current(); + let m = st.find_next(buffer).unwrap(); + assert_eq!(m.start_col, 2); + } + + #[test] + fn column_shift_after_longer_replacement() { + // Replacement longer than the match: the next match's resume column + // must account for the inserted text. + let re = build_regex("x", false).unwrap(); + let first = next_match("xax", &re, 0, 0, 0, true).unwrap(); + let mut st = ConfirmSubstituteState::new(re, "ZZ".to_string(), true, 0, first); + let buf = st.replace_current("xax"); + assert_eq!(buf, "ZZax"); + let m = st.find_next(&buf).unwrap(); + // original second x was at col 2 -> shifted to col 3 by the +1 growth. + assert_eq!(m.start_col, 3); + } + + #[test] + fn zero_width_does_not_loop() { + let re = build_regex("^", false).unwrap(); + let first = next_match("ab", &re, 0, 0, 0, true).unwrap(); + let mut st = ConfirmSubstituteState::new(re, "-".to_string(), true, 0, first); + let buf = st.replace_current("ab"); + assert_eq!(buf, "-ab"); + // No more `^` matches after column 0 advance. + assert!(st.find_next(&buf).is_none()); + } +} diff --git a/src/substitute.rs b/src/substitute.rs new file mode 100644 index 0000000..d43661b --- /dev/null +++ b/src/substitute.rs @@ -0,0 +1,641 @@ +//! Pure engine for the vim-style `:[range]s/pattern/replacement/flags` +//! command. Everything here operates on plain `&str`/`String` so it can be +//! unit-tested without touching `App` or edtui. +//! +//! [`parse_substitute`] turns a raw `:`-line into a [`SubstituteCmd`] (or +//! `Ok(None)` when the line isn't substitute-shaped, so the normal command +//! parser keeps it). The dispatcher in `action::substitute` resolves the +//! range against the live buffer, compiles the regex, and applies it. +//! +//! Regex flavour is the `regex` crate (NOT vim's regex). Capture references +//! in the replacement use `$1` / `${name}`; a literal `$` must be written +//! `$$`. For vim muscle-memory we also accept `\1`..`\9` (mapped to +//! `${1}`..`${9}`), `&` / `\0` for the whole match, and `\&` for a literal +//! ampersand. + +use regex::{Regex, RegexBuilder}; + +/// A fully-parsed substitute command. Field values are already unescaped +/// (the separator-escaping is resolved during parsing) and the replacement +/// is translated to `regex`-crate syntax. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubstituteCmd { + pub range: SubstituteRange, + /// Empty means "reuse the last search/substitute pattern" — resolved at + /// dispatch time, not here. + pub pattern: String, + pub replacement: String, + pub flags: SubstituteFlags, +} + +/// Which lines the substitution applies to. Resolved to a 0-based inclusive +/// row range by [`resolve_range`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubstituteRange { + /// No range prefix — the line under the cursor (`:s/…`). + CurrentLine, + /// `%` — the whole buffer (`:%s/…`). + WholeBuffer, + /// `N,M` / `.,$` / etc. — an explicit address pair. + Lines(Address, Address), + /// `'<,'>` — the current visual selection's line span. + VisualSelection, +} + +/// One endpoint of a `Lines` range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Address { + /// A 1-based absolute line number. + Line(usize), + /// `.` — the cursor line. + Current, + /// `$` — the last line. + Last, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SubstituteFlags { + /// `g` — replace every match on a line, not just the first. + pub global: bool, + /// `i` — case-insensitive matching. + pub ignore_case: bool, + /// `c` — confirm each substitution interactively. + pub confirm: bool, +} + +/// Result of a non-interactive [`apply_substitute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubstituteOutcome { + /// The full new buffer text. + pub text: String, + pub substitutions: usize, + pub lines_changed: usize, + /// 0-based row of the last line that changed (for cursor placement). + pub last_changed_line: usize, + /// Char column of the last substitution's start on that line. + pub last_changed_col: usize, +} + +/// A single match, in char coordinates (edtui `Lines` are char-indexed; the +/// `regex` crate works in bytes, so callers convert). `end_col` is exclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatchSpan { + pub line: usize, + pub start_col: usize, + pub end_col: usize, +} + +/// Parse a raw `:`-line (no leading `:`). Returns `Ok(None)` when the line +/// isn't a substitute command, so the caller falls through to the normal +/// parser — this is what keeps `:save`, `:session`, `:source` working. +pub fn parse_substitute(line: &str) -> Result, String> { + let line = line.trim_start(); + let chars: Vec = line.chars().collect(); + let mut i = 0; + + let range = scan_range(&chars, &mut i); + + // The command keyword: a run of ASCII letters. Only `s` / `substitute` + // are us; anything else (save, session, source, …) is not a substitute. + let kw_start = i; + while i < chars.len() && chars[i].is_ascii_alphabetic() { + i += 1; + } + let keyword: String = chars[kw_start..i].iter().collect(); + let is_sub_keyword = keyword == "s" || keyword == "substitute"; + if !is_sub_keyword { + return Ok(None); + } + + // The separator: any ASCII punctuation except `\`, `"`, `|` (vim's rule). + let Some(&sep) = chars.get(i) else { + // `s` / `substitute` with no separator. If the user clearly meant a + // substitute (explicit range, or the long keyword), surface the + // usage; bare `:s` falls through so the normal parser reports it. + if range != SubstituteRange::CurrentLine || keyword == "substitute" { + return Err(USAGE.to_string()); + } + return Ok(None); + }; + if !is_separator(sep) { + // e.g. `:s ` followed by a space — not substitute-shaped. + if range != SubstituteRange::CurrentLine || keyword == "substitute" { + return Err(USAGE.to_string()); + } + return Ok(None); + } + i += 1; + + let rest: String = chars[i..].iter().collect(); + let (pattern, replacement_raw, flags_raw) = split_fields(&rest, sep); + + let flags = parse_flags(&flags_raw)?; + let replacement = translate_replacement(&replacement_raw); + + Ok(Some(SubstituteCmd { + range, + pattern, + replacement, + flags, + })) +} + +const USAGE: &str = "usage: :[range]s/pattern/replacement/[flags]"; + +/// Characters allowed as the `s` separator. Vim forbids `\`, `"`, and `|`. +fn is_separator(c: char) -> bool { + c.is_ascii_punctuation() && c != '\\' && c != '"' && c != '|' +} + +/// Consume a leading range prefix, advancing `i` past it. Returns +/// `CurrentLine` (and leaves `i` untouched) when there is none. +fn scan_range(chars: &[char], i: &mut usize) -> SubstituteRange { + // `%` — whole buffer. + if chars.get(*i) == Some(&'%') { + *i += 1; + return SubstituteRange::WholeBuffer; + } + // `'<,'>` — visual selection. + if chars[*i..].starts_with(&['\'', '<', ',', '\'', '>']) { + *i += 5; + return SubstituteRange::VisualSelection; + } + // `addr[,addr]` — explicit line addresses. + let save = *i; + if let Some(first) = scan_address(chars, i) { + if chars.get(*i) == Some(&',') { + let after_comma = *i + 1; + let mut j = after_comma; + if let Some(second) = scan_address(chars, &mut j) { + *i = j; + return SubstituteRange::Lines(first, second); + } + // `,` not followed by a valid address — not a range. + *i = save; + return SubstituteRange::CurrentLine; + } + return SubstituteRange::Lines(first, first); + } + *i = save; + SubstituteRange::CurrentLine +} + +/// Parse a single address (`.`, `$`, or a run of digits), advancing `i`. +fn scan_address(chars: &[char], i: &mut usize) -> Option
{ + match chars.get(*i) { + Some('.') => { + *i += 1; + Some(Address::Current) + } + Some('$') => { + *i += 1; + Some(Address::Last) + } + Some(c) if c.is_ascii_digit() => { + let start = *i; + while *i < chars.len() && chars[*i].is_ascii_digit() { + *i += 1; + } + let n: usize = chars[start..*i].iter().collect::().parse().ok()?; + Some(Address::Line(n)) + } + _ => None, + } +} + +/// Split `rest` (everything after the opening separator) into +/// `(pattern, replacement, flags)` on unescaped separators. `\` becomes +/// a literal separator in the field; all other escapes are preserved so the +/// regex / replacement layers see them. At most two separators are honoured; +/// anything after the second is the flags field verbatim. +fn split_fields(rest: &str, sep: char) -> (String, String, String) { + let mut fields: Vec = vec![String::new()]; + let mut chars = rest.chars().peekable(); + while let Some(c) = chars.next() { + if fields.len() >= 3 { + // Past the replacement: the remainder (incl. any seps) is flags. + fields.last_mut().unwrap().push(c); + continue; + } + if c == '\\' { + match chars.next() { + Some(n) if n == sep => fields.last_mut().unwrap().push(sep), + Some(n) => { + let f = fields.last_mut().unwrap(); + f.push('\\'); + f.push(n); + } + None => fields.last_mut().unwrap().push('\\'), + } + continue; + } + if c == sep { + fields.push(String::new()); + continue; + } + fields.last_mut().unwrap().push(c); + } + let mut it = fields.into_iter(); + let pattern = it.next().unwrap_or_default(); + let replacement = it.next().unwrap_or_default(); + let flags = it.next().unwrap_or_default(); + (pattern, replacement, flags) +} + +fn parse_flags(raw: &str) -> Result { + let mut flags = SubstituteFlags::default(); + for c in raw.chars() { + match c { + 'g' => flags.global = true, + 'i' => flags.ignore_case = true, + 'c' => flags.confirm = true, + other => return Err(format!("unknown :s flag: {other}")), + } + } + Ok(flags) +} + +/// Translate a vim-flavoured replacement into `regex`-crate syntax. +/// `\1`..`\9` → `${1}`..`${9}`, `\0` / `&` → `${0}` (whole match), `\&` → +/// literal `&`, `\\` → literal `\`. `$1` / `${name}` pass through untouched +/// (so a literal `$` must be written `$$`, per the regex crate). +fn translate_replacement(repl: &str) -> String { + let mut out = String::new(); + let mut chars = repl.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + Some(d) if d.is_ascii_digit() => { + out.push_str("${"); + out.push(d); + out.push('}'); + } + Some('&') => out.push('&'), + Some('\\') => out.push('\\'), + Some(other) => out.push(other), + None => out.push('\\'), + }, + '&' => out.push_str("${0}"), + other => out.push(other), + } + } + out +} + +/// Compile the pattern, honouring the case-insensitive flag. The error string +/// is shown verbatim in the status bar. +pub fn build_regex(pattern: &str, ignore_case: bool) -> Result { + RegexBuilder::new(pattern) + .case_insensitive(ignore_case) + .build() + .map_err(|e| e.to_string()) +} + +/// Resolve a [`SubstituteRange`] to a 0-based inclusive `(start, end)` row +/// pair, clamped to the buffer. `cursor_row` / `last_row` are 0-based; +/// `selection_rows` is the visual selection span when present. +pub fn resolve_range( + range: &SubstituteRange, + cursor_row: usize, + last_row: usize, + selection_rows: Option<(usize, usize)>, +) -> Result<(usize, usize), String> { + let clamp = |r: usize| r.min(last_row); + match range { + SubstituteRange::CurrentLine => Ok((clamp(cursor_row), clamp(cursor_row))), + SubstituteRange::WholeBuffer => Ok((0, last_row)), + SubstituteRange::VisualSelection => { + let (a, b) = selection_rows.ok_or("no visual selection")?; + Ok((clamp(a.min(b)), clamp(a.max(b)))) + } + SubstituteRange::Lines(a, b) => { + let resolve = |addr: &Address| -> usize { + match addr { + // 1-based addresses → 0-based rows. + Address::Line(n) => clamp(n.saturating_sub(1)), + Address::Current => clamp(cursor_row), + Address::Last => last_row, + } + }; + let start = resolve(a); + let end = resolve(b); + if start > end { + return Err("backwards range".to_string()); + } + Ok((start, end)) + } + } +} + +/// Convert a byte offset within `line` to a char column. +fn byte_to_col(line: &str, byte: usize) -> usize { + line[..byte].chars().count() +} + +/// Find every match within the inclusive `rows` range. Without `global`, +/// only the first match per line is reported. +pub fn find_matches( + buffer: &str, + rows: (usize, usize), + re: &Regex, + global: bool, +) -> Vec { + let mut spans = Vec::new(); + for (row, line) in buffer.split('\n').enumerate() { + if row < rows.0 || row > rows.1 { + continue; + } + for m in re.find_iter(line) { + spans.push(MatchSpan { + line: row, + start_col: byte_to_col(line, m.start()), + end_col: byte_to_col(line, m.end()), + }); + if !global { + break; + } + } + } + spans +} + +/// Apply the substitution to every line in the inclusive `rows` range, +/// returning the new buffer plus statistics. Substitution is strictly +/// line-by-line (vim's default — patterns never match across `\n`). +pub fn apply_substitute( + buffer: &str, + rows: (usize, usize), + re: &Regex, + replacement: &str, + global: bool, +) -> SubstituteOutcome { + let mut out_lines: Vec = Vec::new(); + let mut substitutions = 0; + let mut lines_changed = 0; + let mut last_changed_line = 0; + let mut last_changed_col = 0; + + for (row, line) in buffer.split('\n').enumerate() { + if row < rows.0 || row > rows.1 { + out_lines.push(line.to_string()); + continue; + } + let count = if global { + re.find_iter(line).count() + } else { + usize::from(re.is_match(line)) + }; + if count == 0 { + out_lines.push(line.to_string()); + continue; + } + // Column of the last substitution start (in original coords) for + // cursor placement; the editor clamps it after the rebuild. + if let Some(m) = re + .find_iter(line) + .take(if global { count } else { 1 }) + .last() + { + last_changed_col = byte_to_col(line, m.start()); + } + let limit = if global { 0 } else { 1 }; + let replaced = re.replacen(line, limit, replacement).into_owned(); + substitutions += count; + lines_changed += 1; + last_changed_line = row; + out_lines.push(replaced); + } + + SubstituteOutcome { + text: out_lines.join("\n"), + substitutions, + lines_changed, + last_changed_line, + last_changed_col, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cmd(line: &str) -> SubstituteCmd { + parse_substitute(line) + .expect("parse ok") + .expect("is substitute") + } + + #[test] + fn parses_basic_current_line() { + let c = cmd("s/foo/bar/"); + assert_eq!(c.range, SubstituteRange::CurrentLine); + assert_eq!(c.pattern, "foo"); + assert_eq!(c.replacement, "bar"); + assert_eq!(c.flags, SubstituteFlags::default()); + } + + #[test] + fn parses_whole_buffer_global() { + let c = cmd("%s/foo/bar/g"); + assert_eq!(c.range, SubstituteRange::WholeBuffer); + assert!(c.flags.global); + } + + #[test] + fn parses_line_range() { + let c = cmd("3,7s/a/b/"); + assert_eq!( + c.range, + SubstituteRange::Lines(Address::Line(3), Address::Line(7)) + ); + } + + #[test] + fn parses_dot_dollar_range() { + let c = cmd(".,$s/a/b/"); + assert_eq!( + c.range, + SubstituteRange::Lines(Address::Current, Address::Last) + ); + } + + #[test] + fn parses_visual_selection_range_with_flags() { + let c = cmd("'<,'>s/a/b/gc"); + assert_eq!(c.range, SubstituteRange::VisualSelection); + assert!(c.flags.global); + assert!(c.flags.confirm); + } + + #[test] + fn alternate_separator() { + let c = cmd("s#a/b#c#"); + assert_eq!(c.pattern, "a/b"); + assert_eq!(c.replacement, "c"); + } + + #[test] + fn escaped_separator() { + let c = cmd(r"s/a\/b/c/"); + assert_eq!(c.pattern, "a/b"); + } + + #[test] + fn empty_pattern_reuses_last() { + let c = cmd("s//bar/"); + assert_eq!(c.pattern, ""); + assert_eq!(c.replacement, "bar"); + } + + #[test] + fn empty_replacement_is_deletion() { + let c = cmd("s/foo//"); + assert_eq!(c.replacement, ""); + } + + #[test] + fn missing_trailing_separator() { + let c = cmd("s/foo/bar"); + assert_eq!(c.pattern, "foo"); + assert_eq!(c.replacement, "bar"); + } + + #[test] + fn long_keyword() { + let c = cmd("substitute/a/b/"); + assert_eq!(c.pattern, "a"); + } + + #[test] + fn save_command_falls_through() { + assert_eq!(parse_substitute("save my query"), Ok(None)); + assert_eq!(parse_substitute("session 2"), Ok(None)); + assert_eq!(parse_substitute("source"), Ok(None)); + } + + #[test] + fn bad_flag_errors() { + assert!(parse_substitute("s/a/b/x").is_err()); + } + + #[test] + fn substitute_keyword_without_sep_errors() { + assert!(parse_substitute("substitute").is_err()); + } + + #[test] + fn translate_backref_and_amp() { + assert_eq!(translate_replacement(r"\1"), "${1}"); + assert_eq!(translate_replacement("&"), "${0}"); + assert_eq!(translate_replacement(r"\&"), "&"); + assert_eq!(translate_replacement(r"\\"), r"\"); + assert_eq!(translate_replacement("$1"), "$1"); + } + + #[test] + fn resolve_range_clamps_and_orders() { + assert_eq!( + resolve_range(&SubstituteRange::WholeBuffer, 0, 9, None), + Ok((0, 9)) + ); + assert_eq!( + resolve_range(&SubstituteRange::CurrentLine, 3, 9, None), + Ok((3, 3)) + ); + // 1-based 100 clamps to last row. + assert_eq!( + resolve_range( + &SubstituteRange::Lines(Address::Line(1), Address::Line(100)), + 0, + 9, + None + ), + Ok((0, 9)) + ); + } + + #[test] + fn resolve_range_backwards_errors() { + assert!( + resolve_range( + &SubstituteRange::Lines(Address::Line(7), Address::Line(3)), + 0, + 9, + None + ) + .is_err() + ); + } + + #[test] + fn resolve_range_visual_without_selection_errors() { + assert!(resolve_range(&SubstituteRange::VisualSelection, 0, 9, None).is_err()); + } + + #[test] + fn apply_global_vs_first() { + let re = build_regex("a", false).unwrap(); + let buf = "a a a"; + let g = apply_substitute(buf, (0, 0), &re, "X", true); + assert_eq!(g.text, "X X X"); + assert_eq!(g.substitutions, 3); + let first = apply_substitute(buf, (0, 0), &re, "X", false); + assert_eq!(first.text, "X a a"); + assert_eq!(first.substitutions, 1); + } + + #[test] + fn apply_counts_lines_changed() { + let re = build_regex("x", false).unwrap(); + let buf = "x\ny\nx"; + let out = apply_substitute(buf, (0, 2), &re, "z", true); + assert_eq!(out.text, "z\ny\nz"); + assert_eq!(out.substitutions, 2); + assert_eq!(out.lines_changed, 2); + assert_eq!(out.last_changed_line, 2); + } + + #[test] + fn apply_zero_width_anchor() { + let re = build_regex("^", false).unwrap(); + let out = apply_substitute("hello", (0, 0), &re, "-- ", false); + assert_eq!(out.text, "-- hello"); + assert_eq!(out.substitutions, 1); + } + + #[test] + fn apply_utf8_columns() { + let re = build_regex("é", false).unwrap(); + let buf = "café résumé"; + let spans = find_matches(buf, (0, 0), &re, true); + // café -> é at char col 3; résumé -> é at cols 6 and 10. + assert_eq!(spans[0].start_col, 3); + assert_eq!(spans[1].start_col, 6); + assert_eq!(spans[2].start_col, 10); + let out = apply_substitute(buf, (0, 0), &re, "e", true); + assert_eq!(out.text, "cafe resume"); + } + + #[test] + fn apply_case_insensitive() { + let re = build_regex("foo", true).unwrap(); + let out = apply_substitute("FOO foo Foo", (0, 0), &re, "bar", true); + assert_eq!(out.text, "bar bar bar"); + } + + #[test] + fn apply_capture_groups() { + let re = build_regex(r"(\w+)\.(\w+)", false).unwrap(); + // Brace form is required when a digit ref is followed by a name char. + let out = apply_substitute("schema.table", (0, 0), &re, "${2}_${1}", false); + assert_eq!(out.text, "table_schema"); + } + + #[test] + fn apply_outside_range_untouched() { + let re = build_regex("x", false).unwrap(); + let buf = "x\nx\nx"; + let out = apply_substitute(buf, (1, 1), &re, "y", true); + assert_eq!(out.text, "x\ny\nx"); + } +} diff --git a/src/ui/bottom_bar.rs b/src/ui/bottom_bar.rs index 74db6ce..70ba174 100644 --- a/src/ui/bottom_bar.rs +++ b/src/ui/bottom_bar.rs @@ -74,8 +74,27 @@ impl Widget for BottomBar<'_> { // Picker owns its own footer line. return; } + Some(Overlay::ConfirmSubstitute(st)) => { + render_substitute_confirm(st, area, buf, &self.app.theme); + return; + } None => {} } + // `/` search echo: while the editor is in Search mode, mirror the + // in-progress pattern in the bar (edtui draws the matches but not the + // pattern text itself). + if matches!(self.app.screen, Screen::Normal) + && self.app.focus == crate::state::focus::Focus::Editor + && self.app.editor.editor_mode() == edtui::EditorMode::Search + { + render_search_prompt( + &self.app.editor.state.search_pattern(), + area, + buf, + &self.app.theme, + ); + return; + } match &self.app.screen { // Modal screens own their own help text — keep the status // bar empty so the user isn't reading two things at once. @@ -159,6 +178,49 @@ fn render_confirm( Line::from(spans).render(area, buf); } +/// `/pattern` echo while the editor is in Search mode. A trailing block hints +/// that input is still live. +fn render_search_prompt(pattern: &str, area: Rect, buf: &mut Buffer, theme: &Theme) { + let line = Line::from(vec![ + Span::styled( + "/", + Style::default() + .fg(theme.fg) + .bg(theme.bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + pattern.to_string(), + Style::default().fg(theme.fg).bg(theme.bg), + ), + Span::styled("▏", Style::default().fg(theme.fg_dim).bg(theme.bg)), + ]); + line.render(area, buf); +} + +fn render_substitute_confirm( + st: &crate::state::substitute_confirm::ConfirmSubstituteState, + area: Rect, + buf: &mut Buffer, + theme: &Theme, +) { + let spans = vec![ + Span::styled("? ", Style::default().fg(theme.status_running).bg(theme.bg)), + Span::styled( + format!("replace with \"{}\"?", st.replacement), + Style::default() + .fg(theme.fg) + .bg(theme.bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " y replace · n skip · a all · l last · q/Esc stop", + Style::default().fg(theme.fg_dim).bg(theme.bg), + ), + ]; + Line::from(spans).render(area, buf); +} + fn render_update(current: &str, latest: &str, area: Rect, buf: &mut Buffer, theme: &Theme) { let line = Line::from(vec![ Span::styled("⬆ ", Style::default().fg(theme.status_running).bg(theme.bg)), diff --git a/src/ui/help_view.rs b/src/ui/help_view.rs index 142a552..f8dddaa 100644 --- a/src/ui/help_view.rs +++ b/src/ui/help_view.rs @@ -532,6 +532,43 @@ const HELP_SECTIONS: &[HelpSection] = &[ }, ], }, + HelpSection { + title: "Search & replace (editor)", + entries: &[ + HelpEntry { + keys: "/text", + desc: "Search forward; Enter keeps the pattern, Esc cancels", + }, + HelpEntry { + keys: "n / N", + desc: "Next / previous match (matches stay highlighted)", + }, + HelpEntry { + keys: "Esc", + desc: "In Normal mode, clear search highlights (like :nohlsearch)", + }, + HelpEntry { + keys: ":s/old/new/", + desc: "Substitute on the current line (flags: g all, i ignore-case, c confirm)", + }, + HelpEntry { + keys: ":%s/old/new/g", + desc: "Substitute across the whole buffer; :3,7s/… for a line range", + }, + HelpEntry { + keys: ":'<,'>s/old/new/", + desc: "Over the visual selection (pressing : in Visual pre-fills the range)", + }, + HelpEntry { + keys: "y / n / a / l / q", + desc: "With the c flag: replace / skip / all / last / stop at each match", + }, + HelpEntry { + keys: "(regex)", + desc: "Rust regex; replacement $1/${name} (also \\1, & whole match); :s//new/ reuses last pattern; any punct separator e.g. :s#a#b#", + }, + ], + }, HelpSection { title: "Auto-update prompt", entries: &[