diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9108f75..411774e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,10 +33,5 @@ jobs: - name: Lint run: cargo clippy - - name: Run tests (Windows) - if: matrix.os == 'windows-latest' - run: cargo test --verbose -- --test-threads=1 - - - name: Run tests (Unix) - if: matrix.os != 'windows-latest' + - name: Run tests run: cargo test --verbose diff --git a/Cargo.lock b/Cargo.lock index 632cc7f..15c1d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.6.0" @@ -404,6 +419,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.2.0" @@ -1032,6 +1058,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b202022bb57c049555430e11fc22fea12909276a80a4c3d368da36ac1d88ed" +dependencies = [ + "sdd", +] + [[package]] name = "scooter" version = "0.2.1" @@ -1042,6 +1077,7 @@ dependencies = [ "crossterm", "dirs", "etcetera", + "fancy-regex", "futures", "ignore", "itertools", @@ -1052,6 +1088,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serial_test", "similar", "simple-log", "tempfile", @@ -1064,6 +1101,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c1eeaf4b6a87c7479688c6d52b9f1153cedd3c489300564f932b065c6eab95" + [[package]] name = "serde" version = "1.0.215" @@ -1119,6 +1162,31 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1471,7 +1539,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 48e6fb4..2a29674 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ content_inspector = "0.2.4" crossterm = { version = "0.27", features = ["event-stream"] } dirs = "5.0.1" etcetera = "0.8.0" +fancy-regex = "0.14.0" futures = "0.3.31" ignore = "0.4.23" itertools = "0.13.0" @@ -27,6 +28,7 @@ ratatui = "0.27.0" regex = "1.11.1" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" +serial_test = "3.2.0" similar = "2.6.0" simple-log = "2.1.1" tokio = { version = "1.41.1", features = ["full"] } diff --git a/src/app.rs b/src/app.rs index 6c0174c..9426833 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,5 @@ +use anyhow::Error; +use fancy_regex::Regex as FancyRegex; use ignore::WalkState; use itertools::Itertools; use parking_lot::{ @@ -117,9 +119,9 @@ impl ReplaceState { pub struct SearchInProgressState { pub search_state: SearchState, pub last_render: Instant, - pub handle: JoinHandle<()>, - pub processing_sender: UnboundedSender, - pub processing_receiver: UnboundedReceiver, + handle: JoinHandle<()>, + processing_sender: UnboundedSender, + processing_receiver: UnboundedReceiver, } impl SearchInProgressState { @@ -151,7 +153,7 @@ pub enum Screen { } impl Screen { - pub fn search_results_mut(&mut self) -> &mut SearchState { + fn search_results_mut(&mut self) -> &mut SearchState { match self { Screen::SearchProgressing(SearchInProgressState { search_state, .. }) => search_state, Screen::SearchComplete(search_state) => search_state, @@ -182,6 +184,7 @@ pub struct SearchFields { pub fields: [SearchField; NUM_SEARCH_FIELDS], pub highlighted: usize, pub show_error_popup: bool, + advanced_regex: bool, } macro_rules! define_field_accessor { @@ -264,9 +267,15 @@ impl SearchFields { ], highlighted: 0, show_error_popup: false, + advanced_regex: false, } } + pub fn with_advanced_regex(mut self, advanced_regex: bool) -> Self { + self.advanced_regex = advanced_regex; + self + } + fn highlighted_field_impl(&self) -> &SearchField { &self.fields[self.highlighted] } @@ -306,11 +315,29 @@ impl SearchFields { let search_text = search.text(); let result = if self.fixed_strings().checked { SearchType::Fixed(search_text) + } else if self.advanced_regex { + SearchType::PatternAdvanced(FancyRegex::new(&search_text)?) } else { SearchType::Pattern(Regex::new(&search_text)?) }; Ok(result) } + + pub fn path_pattern_parsed(&self) -> anyhow::Result> { + let path_patt_text = &self.path_pattern().text; + let result = if path_patt_text.is_empty() { + None + } else { + Some({ + if self.advanced_regex { + SearchType::PatternAdvanced(FancyRegex::new(path_patt_text)?) + } else { + SearchType::Pattern(Regex::new(path_patt_text)?) + } + }) + }; + Ok(result) + } } enum ValidatedField { @@ -321,11 +348,10 @@ enum ValidatedField { pub struct App { pub current_screen: Screen, pub search_fields: SearchFields, - pub directory: PathBuf, - pub include_hidden: bool, + directory: PathBuf, + include_hidden: bool, - pub running: bool, - pub app_event_sender: UnboundedSender, + app_event_sender: UnboundedSender, } const BINARY_EXTENSIONS: &[&str] = &["png", "gif", "jpg", "jpeg", "ico", "svg", "pdf"]; @@ -334,20 +360,22 @@ impl App { pub fn new( directory: Option, include_hidden: bool, + advanced_regex: bool, app_event_sender: UnboundedSender, ) -> Self { let directory = match directory { Some(d) => d, None => std::env::current_dir().unwrap(), }; + let search_fields = + SearchFields::with_values("", "", false, "").with_advanced_regex(advanced_regex); Self { current_screen: Screen::SearchFields, - search_fields: SearchFields::with_values("", "", false, ""), - directory, // TODO: add this as a field that can be edited, e.g. allow glob patterns + search_fields, + directory, include_hidden, - running: true, app_event_sender, } } @@ -366,6 +394,7 @@ impl App { *self = Self::new( Some(self.directory.clone()), self.include_hidden, + self.search_fields.advanced_regex, self.app_event_sender.clone(), ); } @@ -615,13 +644,18 @@ impl App { }) } + fn is_regex_error(e: &Error) -> bool { + e.downcast_ref::().is_some() + || e.downcast_ref::().is_some() + } + fn validate_fields( &mut self, background_processing_sender: UnboundedSender, ) -> anyhow::Result> { let search_pattern = match self.search_fields.search_type() { Err(e) => { - if e.downcast_ref::().is_some() { + if Self::is_regex_error(&e) { self.search_fields .search_mut() .set_error("Couldn't parse regex".to_owned(), e.to_string()); @@ -633,19 +667,14 @@ impl App { Ok(p) => ValidatedField::Parsed(p), }; - let path_pattern_text = self.search_fields.path_pattern().text(); - let path_pattern = if path_pattern_text.is_empty() { - ValidatedField::Parsed(None) - } else { - match Regex::new(path_pattern_text.as_str()) { - Err(e) => { - self.search_fields - .path_pattern_mut() - .set_error("Couldn't parse regex".to_owned(), e.to_string()); - ValidatedField::Error - } - Ok(r) => ValidatedField::Parsed(Some(r)), + let path_pattern = match self.search_fields.path_pattern_parsed() { + Err(e) => { + self.search_fields + .path_pattern_mut() + .set_error("Couldn't parse regex".to_owned(), e.to_string()); + ValidatedField::Error } + Ok(r) => ValidatedField::Parsed(r), }; let (search_pattern, path_pattern) = match (search_pattern, path_pattern) { @@ -923,7 +952,7 @@ mod tests { fn build_test_app(results: Vec) -> App { let event_handler = EventHandler::new(); - let mut app = App::new(None, false, event_handler.app_event_sender); + let mut app = App::new(None, false, false, event_handler.app_event_sender); app.current_screen = Screen::SearchComplete(SearchState { results, selected: 0, diff --git a/src/fields.rs b/src/fields.rs index e996933..ae386f9 100644 --- a/src/fields.rs +++ b/src/fields.rs @@ -7,7 +7,7 @@ use ratatui::{ Frame, }; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct FieldError { pub short: String, pub long: String, diff --git a/src/main.rs b/src/main.rs index 7746a84..a2aec9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,10 @@ struct Args { default_value = DEFAULT_LOG_LEVEL )] log_level: LevelFilter, + + /// Use advanced regex features (including negative look-ahead), at the cost of performance + #[arg(short = 'a', long, default_value = "false")] + advanced_regex: bool, } fn parse_log_level(s: &str) -> Result { @@ -52,8 +56,6 @@ async fn main() -> anyhow::Result<()> { setup_logging(args.log_level)?; - let args = Args::parse(); - let directory = match args.directory { None => None, Some(d) => Some(validate_directory(&d)?), @@ -61,7 +63,12 @@ async fn main() -> anyhow::Result<()> { let app_events_handler = EventHandler::new(); let app_event_sender = app_events_handler.app_event_sender.clone(); - let mut app = App::new(directory, args.hidden, app_event_sender); + let mut app = App::new( + directory, + args.hidden, + args.advanced_regex, + app_event_sender, + ); let backend = CrosstermBackend::new(io::stdout()); let terminal = Terminal::new(backend)?; @@ -69,7 +76,7 @@ async fn main() -> anyhow::Result<()> { tui.init()?; tui.draw(&mut app)?; - while app.running { + loop { let EventHandlingResult { exit, rerender } = tokio::select! { Some(event) = tui.events.receiver.recv() => { match event { diff --git a/src/parsed_fields.rs b/src/parsed_fields.rs index be0965d..320d96b 100644 --- a/src/parsed_fields.rs +++ b/src/parsed_fields.rs @@ -1,4 +1,5 @@ use content_inspector::{inspect, ContentType}; +use fancy_regex::Regex as FancyRegex; use ignore::{WalkBuilder, WalkParallel}; use log::warn; use regex::Regex; @@ -17,6 +18,7 @@ use crate::{ #[derive(Clone, Debug)] pub enum SearchType { Pattern(Regex), + PatternAdvanced(FancyRegex), Fixed(String), } @@ -24,7 +26,7 @@ pub enum SearchType { pub struct ParsedFields { search_pattern: SearchType, replace_string: String, - path_pattern: Option, + path_pattern: Option, // TODO: `root_dir` and `include_hidden` are duplicated across this and App root_dir: PathBuf, include_hidden: bool, @@ -36,7 +38,7 @@ impl ParsedFields { pub fn new( search_pattern: SearchType, replace_string: String, - path_pattern: Option, + path_pattern: Option, root_dir: PathBuf, include_hidden: bool, background_processing_sender: UnboundedSender, @@ -53,7 +55,14 @@ impl ParsedFields { pub fn handle_path(&self, path: &Path) { if let Some(ref p) = self.path_pattern { - let matches_pattern = p.is_match(relative_path_from(&self.root_dir, path).as_str()); + let relative_path = relative_path_from(&self.root_dir, path); + let relative_path = relative_path.as_str(); + + let matches_pattern = match p { + SearchType::Pattern(ref p) => p.is_match(relative_path), + SearchType::PatternAdvanced(ref p) => p.is_match(relative_path).unwrap(), + SearchType::Fixed(ref s) => relative_path.contains(s), + }; if !matches_pattern { return; } @@ -116,6 +125,13 @@ impl ParsedFields { None } } + SearchType::PatternAdvanced(ref p) => { + // TODO: try catch + match p.is_match(&line) { + Ok(true) => Some(p.replace_all(&line, &self.replace_string).to_string()), + _ => None, + } + } }; maybe_replacement.map(|replacement| SearchResult { diff --git a/src/ui.rs b/src/ui.rs index 1e11369..d813b36 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,7 +3,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}, style::{Color, Style, Stylize}, text::{Line, Span, Text}, - widgets::{Block, Clear, List, ListItem, Paragraph, Wrap}, + widgets::{Block, Clear, List, ListItem, Paragraph}, Frame, }; use similar::{Change, ChangeTag, TextDiff}; @@ -64,7 +64,7 @@ fn render_search_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { .lines() .map(|line| { Line::from(vec![Span::styled( - format!(" {line}"), + line.to_string(), Style::default().fg(Color::Red), )]) }) @@ -85,13 +85,11 @@ fn render_search_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { Constraint::Length(content_height), ); - let popup = Paragraph::new(error_lines) - .block( - Block::bordered() - .title("Errors") - .title_alignment(Alignment::Center), - ) - .wrap(Wrap { trim: true }); + let popup = Paragraph::new(error_lines).block( + Block::bordered() + .title("Errors") + .title_alignment(Alignment::Center), + ); frame.render_widget(Clear, popup_area); frame.render_widget(popup, popup_area); } else if let Some(cursor_idx) = app.search_fields.highlighted_field().read().cursor_idx() { diff --git a/tests/app.rs b/tests/app.rs index 69f5656..8d7447b 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -2,14 +2,16 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifi use scooter::{ App, EventHandler, ReplaceResult, ReplaceState, Screen, SearchFields, SearchResult, SearchState, }; +use serial_test::serial; use std::cmp::max; -use std::fs::{self, create_dir_all, File}; -use std::io::Write; +use std::fs::{self, create_dir_all}; use std::mem; use std::path::{Path, PathBuf}; use std::thread::sleep; use std::time::{Duration, Instant}; use tempfile::TempDir; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; #[tokio::test] async fn test_search_state() { @@ -82,7 +84,7 @@ async fn test_replace_state() { #[tokio::test] async fn test_app_reset() { let events = EventHandler::new(); - let mut app = App::new(None, false, events.app_event_sender); + let mut app = App::new(None, false, false, events.app_event_sender); app.current_screen = Screen::Results(ReplaceState { num_successes: 5, num_ignored: 2, @@ -98,7 +100,7 @@ async fn test_app_reset() { #[tokio::test] async fn test_back_from_results() { let events = EventHandler::new(); - let mut app = App::new(None, false, events.app_event_sender); + let mut app = App::new(None, false, false, events.app_event_sender); app.current_screen = Screen::SearchComplete(SearchState { results: vec![], selected: 0, @@ -125,7 +127,7 @@ async fn test_back_from_results() { #[tokio::test] async fn test_error_popup() { let events = EventHandler::new(); - let mut app = App::new(None, false, events.app_event_sender.clone()); + let mut app = App::new(None, false, false, events.app_event_sender.clone()); app.current_screen = Screen::SearchFields; app.search_fields = SearchFields::with_values("search invalid regex(", "replacement", false, ""); @@ -167,15 +169,14 @@ macro_rules! create_test_files { let path = Path::new(&path); create_dir_all(path.parent().unwrap()).unwrap(); { - let mut file = File::create(path).unwrap(); - file.write_all(contents.as_bytes()).unwrap(); - file.sync_all().unwrap(); + let mut file = File::create(path).await.unwrap(); + file.write_all(contents.as_bytes()).await.unwrap(); + file.sync_all().await.unwrap(); } )+ #[cfg(windows)] sleep(Duration::from_millis(100)); - temp_dir } }; @@ -281,6 +282,7 @@ fn setup_app(temp_dir: &TempDir, search_fields: SearchFields, include_hidden: bo let mut app = App::new( Some(temp_dir.path().to_path_buf()), include_hidden, + false, events.app_event_sender, ); app.search_fields = search_fields; @@ -348,112 +350,233 @@ async fn search_and_replace_test( } } -#[tokio::test] -async fn test_perform_search_fixed_string() { - let temp_dir = create_test_files! { - "file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "file2.txt" => { - "Another test file", - "With different content", - "Also for testing", - }, - "file3.txt" => { - "something", - "123 bar[a-b]+.*bar)(baz 456", - "something", - } - }; - - search_and_replace_test( - &temp_dir, - SearchFields::with_values(".*", "example", true, ""), - false, - vec![ - (Path::new("file1.txt"), 0), - (Path::new("file2.txt"), 0), - (Path::new("file3.txt"), 1), - ], - ) - .await; - - assert_test_files! { - &temp_dir, - "file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "file2.txt" => { - "Another test file", - "With different content", - "Also for testing", - }, - "file3.txt" => { - "something", - "123 bar[a-b]+examplebar)(baz 456", - "something", +macro_rules! test_with_both_regex_modes { + ($name:ident, $test_fn:expr) => { + mod $name { + use super::*; + + // TODO: run max n at a time, rather than serially + #[tokio::test] + #[serial] + async fn with_advanced_regex() { + ($test_fn)(true).await; + } + + #[tokio::test] + #[serial] + async fn without_advanced_regex() { + ($test_fn)(false).await; + } } }; } -#[tokio::test] -async fn test_update_search_results_regex() { - let temp_dir = &create_test_files! { - "file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "file2.txt" => { - "Another test file", - "With different content", - "Also for testing", - }, - "file3.txt" => { - "something", - "123 bar[a-b]+.*bar)(baz 456", - "something", - } - }; - - search_and_replace_test( - temp_dir, - SearchFields::with_values(r"\b\w+ing\b", "VERB", false, ""), - false, - vec![ - (Path::new("file1.txt"), 1), - (Path::new("file2.txt"), 1), - (Path::new("file3.txt"), 2), - ], - ) - .await; - - assert_test_files! { - temp_dir, - "file1.txt" => { - "This is a test file", - "It contains some test content", - "For VERB purposes", - }, - "file2.txt" => { - "Another test file", - "With different content", - "Also for VERB", - }, - "file3.txt" => { - "VERB", - "123 bar[a-b]+.*bar)(baz 456", - "VERB", - } - }; -} +test_with_both_regex_modes!( + test_perform_search_fixed_string, + |advanced_regex: bool| async move { + let temp_dir = create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; + + let search_fields = SearchFields::with_values(".*", "example", true, "") + .with_advanced_regex(advanced_regex); + search_and_replace_test( + &temp_dir, + search_fields, + false, + vec![ + (Path::new("file1.txt"), 0), + (Path::new("file2.txt"), 0), + (Path::new("file3.txt"), 1), + ], + ) + .await; + + assert_test_files! { + &temp_dir, + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+examplebar)(baz 456", + "something", + } + }; + } +); + +test_with_both_regex_modes!( + test_update_search_results_regex, + |advanced_regex: bool| async move { + let temp_dir = &create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; + + let search_fields = SearchFields::with_values(r"\b\w+ing\b", "VERB", false, "") + .with_advanced_regex(advanced_regex); + search_and_replace_test( + temp_dir, + search_fields, + false, + vec![ + (Path::new("file1.txt"), 1), + (Path::new("file2.txt"), 1), + (Path::new("file3.txt"), 2), + ], + ) + .await; + + assert_test_files! { + temp_dir, + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For VERB purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for VERB", + }, + "file3.txt" => { + "VERB", + "123 bar[a-b]+.*bar)(baz 456", + "VERB", + } + }; + } +); + +test_with_both_regex_modes!( + test_update_search_results_no_matches, + |advanced_regex: bool| async move { + let temp_dir = &create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; + + let search_fields = + SearchFields::with_values("nonexistent-string", "replacement", true, "") + .with_advanced_regex(advanced_regex); + search_and_replace_test( + temp_dir, + search_fields, + false, + vec![ + (Path::new("file1.txt"), 0), + (Path::new("file2.txt"), 0), + (Path::new("file3.txt"), 0), + ], + ) + .await; + + assert_test_files! { + temp_dir, + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; + } +); + +test_with_both_regex_modes!( + test_update_search_results_invalid_regex, + |advanced_regex: bool| async move { + let temp_dir = &create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; + + let search_fields = SearchFields::with_values("[invalid regex", "replacement", false, "") + .with_advanced_regex(advanced_regex); + let mut app = setup_app(temp_dir, search_fields, false); + + let res = app.perform_search_if_valid(); + assert!(!res.exit); + assert!(matches!(app.current_screen, Screen::SearchFields)); + process_bp_events(&mut app).await; + assert!(!wait_for_screen!(&app, Screen::SearchComplete)); // We shouldn't get to the SearchComplete page, so assert that we never get there + assert!(matches!(app.current_screen, Screen::SearchFields)); + } +); #[tokio::test] -async fn test_update_search_results_no_matches() { +#[serial] +async fn test_advanced_regex_negative_lookahead() { let temp_dir = &create_test_files! { "file1.txt" => { "This is a test file", @@ -472,13 +595,15 @@ async fn test_update_search_results_no_matches() { } }; + let search_fields = + SearchFields::with_values("(test)(?!ing)", "BAR", false, "").with_advanced_regex(true); search_and_replace_test( temp_dir, - SearchFields::with_values(r"nonexistent-string", "replacement", true, ""), + search_fields, false, vec![ - (Path::new("file1.txt"), 0), - (Path::new("file2.txt"), 0), + (Path::new("file1.txt"), 2), + (Path::new("file2.txt"), 1), (Path::new("file3.txt"), 0), ], ) @@ -487,12 +612,12 @@ async fn test_update_search_results_no_matches() { assert_test_files! { temp_dir, "file1.txt" => { - "This is a test file", - "It contains some test content", + "This is a BAR file", + "It contains some BAR content", "For testing purposes", }, "file2.txt" => { - "Another test file", + "Another BAR file", "With different content", "Also for testing", }, @@ -504,91 +629,63 @@ async fn test_update_search_results_no_matches() { }; } -#[tokio::test] -async fn test_update_search_results_invalid_regex() { - let temp_dir = &create_test_files! { - "file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "file2.txt" => { - "Another test file", - "With different content", - "Also for testing", - }, - "file3.txt" => { - "something", - "123 bar[a-b]+.*bar)(baz 456", - "something", - } - }; - - let search_fields = SearchFields::with_values(r"[invalid regex", "replacement", false, ""); - let mut app = setup_app(temp_dir, search_fields, false); - - let res = app.perform_search_if_valid(); - assert!(!res.exit); - assert!(matches!(app.current_screen, Screen::SearchFields)); - process_bp_events(&mut app).await; - assert!(!wait_for_screen!(&app, Screen::SearchComplete)); // We shouldn't get to the SearchComplete page, so assert that we never get there - assert!(matches!(app.current_screen, Screen::SearchFields)); -} - -#[tokio::test] -async fn test_update_search_results_filtered_dir() { - let temp_dir = &create_test_files! { - "dir1/file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "dir2/file2.txt" => { - "Another test file", - "With different content", - "Also for testing", - }, - "dir2/file3.txt" => { - "something", - "123 bar[a-b]+.*bar)(baz 456", - "something testing", - } - }; - - search_and_replace_test( - temp_dir, - SearchFields::with_values(r"testing", "f", false, "dir2"), - false, - vec![ - (&Path::new("dir1").join("file1.txt"), 0), - (&Path::new("dir2").join("file2.txt"), 1), - (&Path::new("dir2").join("file3.txt"), 1), - ], - ) - .await; - - assert_test_files! { - temp_dir, - "dir1/file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "dir2/file2.txt" => { - "Another test file", - "With different content", - "Also for f", - }, - "dir2/file3.txt" => { - "something", - "123 bar[a-b]+.*bar)(baz 456", - "something f", - } - }; -} +test_with_both_regex_modes!( + test_update_search_results_filtered_dir, + |advanced_regex: bool| async move { + let temp_dir = &create_test_files! { + "dir1/file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "dir2/file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "dir2/file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something testing", + } + }; + + let search_fields = SearchFields::with_values("testing", "f", false, "dir2") + .with_advanced_regex(advanced_regex); + search_and_replace_test( + temp_dir, + search_fields, + false, + vec![ + (&Path::new("dir1").join("file1.txt"), 0), + (&Path::new("dir2").join("file2.txt"), 1), + (&Path::new("dir2").join("file3.txt"), 1), + ], + ) + .await; + + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "dir2/file2.txt" => { + "Another test file", + "With different content", + "Also for f", + }, + "dir2/file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something f", + } + }; + } +); -#[tokio::test] -async fn test_ignores_gif_file() { +test_with_both_regex_modes!(test_ignores_gif_file, |advanced_regex: bool| async move { let temp_dir = &create_test_files! { "dir1/file1.txt" => { "This is a text file", @@ -601,9 +698,11 @@ async fn test_ignores_gif_file() { } }; + let search_fields = + SearchFields::with_values("is", "", false, "").with_advanced_regex(advanced_regex); search_and_replace_test( temp_dir, - SearchFields::with_values(r"is", "", false, ""), + search_fields, false, vec![ (&Path::new("dir1").join("file1.txt"), 1), @@ -625,87 +724,95 @@ async fn test_ignores_gif_file() { "Th a text file", } }; -} - -#[tokio::test] -async fn test_ignores_hidden_files_by_default() { - let temp_dir = &create_test_files! { - "dir1/file1.txt" => { - "This is a text file", - }, - ".dir2/file2.rs" => { - "This is a file in a hidden directory", - }, - ".file3.txt" => { - "This is a hidden text file", - } - }; - - search_and_replace_test( - temp_dir, - SearchFields::with_values(r"\bis\b", "REPLACED", false, ""), - false, - vec![ - (&Path::new("dir1").join("file1.txt"), 1), - (&Path::new(".dir2").join("file2.rs"), 0), - (Path::new(".file3.txt"), 0), - ], - ) - .await; - - assert_test_files! { - temp_dir, - "dir1/file1.txt" => { - "This REPLACED a text file", - }, - ".dir2/file2.rs" => { - "This is a file in a hidden directory", - }, - ".file3.txt" => { - "This is a hidden text file", - } - }; -} - -#[tokio::test] -async fn test_includes_hidden_files_with_flag() { - let temp_dir = &create_test_files! { - "dir1/file1.txt" => { - "This is a text file", - }, - ".dir2/file2.rs" => { - "This is a file in a hidden directory", - }, - ".file3.txt" => { - "This is a hidden text file", - } - }; +}); + +test_with_both_regex_modes!( + test_ignores_hidden_files_by_default, + |advanced_regex: bool| async move { + let temp_dir = &create_test_files! { + "dir1/file1.txt" => { + "This is a text file", + }, + ".dir2/file2.rs" => { + "This is a file in a hidden directory", + }, + ".file3.txt" => { + "This is a hidden text file", + } + }; + + let search_fields = SearchFields::with_values(r"\bis\b", "REPLACED", false, "") + .with_advanced_regex(advanced_regex); + search_and_replace_test( + temp_dir, + search_fields, + false, + vec![ + (&Path::new("dir1").join("file1.txt"), 1), + (&Path::new(".dir2").join("file2.rs"), 0), + (Path::new(".file3.txt"), 0), + ], + ) + .await; - search_and_replace_test( - temp_dir, - SearchFields::with_values(r"\bis\b", "REPLACED", false, ""), - true, - vec![ - (&Path::new("dir1").join("file1.txt"), 1), - (&Path::new(".dir2").join("file2.rs"), 1), - (Path::new(".file3.txt"), 1), - ], - ) - .await; + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "This REPLACED a text file", + }, + ".dir2/file2.rs" => { + "This is a file in a hidden directory", + }, + ".file3.txt" => { + "This is a hidden text file", + } + }; + } +); + +test_with_both_regex_modes!( + test_includes_hidden_files_with_flag, + |advanced_regex: bool| async move { + let temp_dir = &create_test_files! { + "dir1/file1.txt" => { + "This is a text file", + }, + ".dir2/file2.rs" => { + "This is a file in a hidden directory", + }, + ".file3.txt" => { + "This is a hidden text file", + } + }; + + let search_fields = SearchFields::with_values(r"\bis\b", "REPLACED", false, "") + .with_advanced_regex(advanced_regex); + search_and_replace_test( + temp_dir, + search_fields, + true, + vec![ + (&Path::new("dir1").join("file1.txt"), 1), + (&Path::new(".dir2").join("file2.rs"), 1), + (Path::new(".file3.txt"), 1), + ], + ) + .await; - assert_test_files! { - temp_dir, - "dir1/file1.txt" => { - "This REPLACED a text file", - }, - ".dir2/file2.rs" => { - "This REPLACED a file in a hidden directory", - }, - ".file3.txt" => { - "This REPLACED a hidden text file", - } - }; -} + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "This REPLACED a text file", + }, + ".dir2/file2.rs" => { + "This REPLACED a file in a hidden directory", + }, + ".file3.txt" => { + "This REPLACED a hidden text file", + } + }; + } +); // TODO: // - Add: diff --git a/tests/fields.rs b/tests/fields.rs index eae423f..96d458b 100644 --- a/tests/fields.rs +++ b/tests/fields.rs @@ -1,10 +1,5 @@ -use parking_lot::RwLock; use ratatui::crossterm::event::{KeyCode, KeyModifiers}; -use scooter::{ - parsed_fields::SearchType, CheckboxField, Field, FieldName, SearchField, SearchFields, - TextField, -}; -use std::sync::Arc; +use scooter::{parsed_fields::SearchType, CheckboxField, SearchFields, TextField}; #[test] fn test_text_field_operations() { @@ -72,28 +67,7 @@ fn test_checkbox_field() { #[test] fn test_search_fields() { - let mut search_fields = SearchFields { - fields: [ - SearchField { - name: FieldName::Search, - field: Arc::new(RwLock::new(Field::text(""))), - }, - SearchField { - name: FieldName::Replace, - field: Arc::new(RwLock::new(Field::text(""))), - }, - SearchField { - name: FieldName::FixedStrings, - field: Arc::new(RwLock::new(Field::checkbox(false))), - }, - SearchField { - name: FieldName::PathPattern, - field: Arc::new(RwLock::new(Field::text(""))), - }, - ], - highlighted: 0, - show_error_popup: false, - }; + let mut search_fields = SearchFields::with_values("", "", false, ""); // Test focus navigation assert_eq!(search_fields.highlighted, 0); @@ -139,7 +113,7 @@ fn test_search_fields() { let search_type = search_fields.search_type().unwrap(); match search_type { SearchType::Fixed(s) => assert_eq!(s, "test search"), - SearchType::Pattern(_) => panic!("Expected Fixed, got Pattern"), + _ => panic!("Expected Fixed, got {:?}", search_type), } search_fields @@ -149,6 +123,6 @@ fn test_search_fields() { let search_type = search_fields.search_type().unwrap(); match search_type { SearchType::Pattern(_) => {} - SearchType::Fixed(_) => panic!("Expected Pattern, got Fixed"), + _ => panic!("Expected Pattern, got {:?}", search_type), } }