From 8192277e48ae83e729d79b235602e0e0b318cc30 Mon Sep 17 00:00:00 2001 From: Tim Oram Date: Mon, 6 Mar 2023 09:15:19 -0330 Subject: Search thread --- Cargo.lock | 96 +-- Makefile.toml | 7 + src/core/src/components/search_bar/mod.rs | 4 - src/core/src/components/search_bar/options.rs | 15 - src/core/src/components/search_bar/tests.rs | 11 - src/core/src/modules/list/mod.rs | 165 +++-- src/core/src/modules/list/search/line_match.rs | 24 + src/core/src/modules/list/search/mod.rs | 6 + src/core/src/modules/list/search/search.rs | 750 +++++++++++++++++++++ src/core/src/modules/list/search/state.rs | 235 +++++++ src/core/src/modules/list/tests/activate.rs | 65 ++ src/core/src/modules/list/tests/external_editor.rs | 20 +- src/core/src/modules/list/tests/mod.rs | 1 + src/core/src/modules/list/tests/old_search.xrs | 599 ++++++++++++++++ src/core/src/modules/list/tests/search.rs | 617 +++++++---------- src/core/src/modules/list/utils.rs | 47 +- src/core/src/process/artifact.rs | 2 +- src/core/src/search/mod.rs | 2 + src/core/src/search/testutil.rs | 27 + src/core/src/testutil/action_line.rs | 2 +- src/core/src/testutil/assert_results.rs | 100 ++- src/core/src/testutil/mod.rs | 4 +- src/core/src/util.rs | 1 + 23 files changed, 2257 insertions(+), 543 deletions(-) delete mode 100644 src/core/src/components/search_bar/options.rs create mode 100644 src/core/src/modules/list/search/line_match.rs create mode 100644 src/core/src/modules/list/search/mod.rs create mode 100644 src/core/src/modules/list/search/search.rs create mode 100644 src/core/src/modules/list/search/state.rs create mode 100644 src/core/src/modules/list/tests/activate.rs create mode 100644 src/core/src/modules/list/tests/old_search.xrs create mode 100644 src/core/src/search/testutil.rs diff --git a/Cargo.lock b/Cargo.lock index d7064e1..1a5d975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "aho-corasick" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" dependencies = [ "memchr", ] @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.72" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" [[package]] name = "arrayvec" @@ -52,9 +52,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.3" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" [[package]] name = "bumpalo" @@ -357,7 +357,7 @@ name = "girt-core" version = "2.3.0" dependencies = [ "anyhow", - "bitflags 2.3.3", + "bitflags 2.4.0", "captur", "chrono", "claims", @@ -417,7 +417,7 @@ name = "girt-input" version = "2.3.0" dependencies = [ "anyhow", - "bitflags 2.3.3", + "bitflags 2.4.0", "captur", "crossbeam-channel", "crossterm", @@ -468,7 +468,7 @@ name = "girt-view" version = "2.3.0" dependencies = [ "anyhow", - "bitflags 2.3.3", + "bitflags 2.4.0", "captur", "claims", "crossbeam-channel", @@ -652,9 +652,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "memchr" @@ -736,9 +736,9 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project-lite" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c516611246607d0c04186886dbb3a754368ef82c79e9827a802c6d836dd111c" +checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" [[package]] name = "pin-utils" @@ -779,9 +779,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.32" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] @@ -856,15 +856,15 @@ checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" [[package]] name = "relative-path" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf2521270932c3c7bed1a59151222bd7643c79310f2916f01925e1e16255698" +checksum = "c707298afce11da2efef2f600116fa93ffa7a032b5d7b628aa17711ec81383ca" [[package]] name = "rstest" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b96577ca10cb3eade7b337eb46520108a67ca2818a24d0b63f41fd62bc9651c" +checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" dependencies = [ "futures", "futures-timer", @@ -874,9 +874,9 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e674cf31712b8bb15fdbca3ec0c1b9d825c5a24407ff2b7e005fb6a29ba03" +checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" dependencies = [ "cfg-if", "glob", @@ -900,11 +900,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.7" +version = "0.38.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172891ebdceb05aa0005f533a6cbfca599ddd7d966f6f5d4d9b2e70478e70399" +checksum = "19ed4fa021d81c8392ce04db050a3da9a60299050b7ae1cf482d862b54a7218f" dependencies = [ - "bitflags 2.3.3", + "bitflags 2.4.0", "errno", "libc", "linux-raw-sys", @@ -995,9 +995,9 @@ checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" [[package]] name = "syn" -version = "2.0.28" +version = "2.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567" +checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a" dependencies = [ "proc-macro2", "quote", @@ -1006,9 +1006,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.7.1" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc02fddf48964c42031a0b3fe0428320ecf3a73c401040fc0096f97794310651" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" dependencies = [ "cfg-if", "fastrand", @@ -1019,18 +1019,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" +checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" +checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" dependencies = [ "proc-macro2", "quote", @@ -1240,9 +1240,9 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", @@ -1255,45 +1255,45 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "xi-unicode" diff --git a/Makefile.toml b/Makefile.toml index 341a437..0d96586 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -173,6 +173,13 @@ install_crate = false command = "cargo" args = ["test", "--workspace"] +[tasks.test-nightly] +dependencies = ["update-rust-nightly"] +toolchain = "nightly${RUST_NIGHTLY_VERSION_PREFIX}" +install_crate = false +command = "cargo" +args = ["test", "--workspace"] + [tasks.update-rust-stable] command = "rustup" args = ["update", "stable"] diff --git a/src/core/src/components/search_bar/mod.rs b/src/core/src/components/search_bar/mod.rs index 9e1df81..da7d5fd 100644 --- a/src/core/src/components/search_bar/mod.rs +++ b/src/core/src/components/search_bar/mod.rs @@ -123,10 +123,6 @@ impl SearchBar { self.state == State::Editing } - pub(crate) fn is_searching(&self) -> bool { - self.state == State::Searching - } - pub(crate) fn build_view_line(&self) -> ViewLine { ViewLine::from(self.editable_line.line_segments()) } diff --git a/src/core/src/components/search_bar/options.rs b/src/core/src/components/search_bar/options.rs deleted file mode 100644 index 348d81c..0000000 --- a/src/core/src/components/search_bar/options.rs +++ /dev/null @@ -1,15 +0,0 @@ -use input::Event; - -pub struct Options { - pub(crate) next_result_event: Vec, - pub(crate) previous_result_event: Vec, -} - -impl Options { - pub fn new(next_result_event: Vec, previous_result_event: Vec) -> Self { - Self { - next_result_event, - previous_result_event, - } - } -} diff --git a/src/core/src/components/search_bar/tests.rs b/src/core/src/components/search_bar/tests.rs index 96619f0..7ae337e 100644 --- a/src/core/src/components/search_bar/tests.rs +++ b/src/core/src/components/search_bar/tests.rs @@ -244,17 +244,6 @@ fn is_editing() { assert!(!search_bar.is_editing()); } -#[test] -fn is_searching() { - let mut search_bar = SearchBar::new(); - search_bar.state = State::Editing; - assert!(!search_bar.is_searching()); - search_bar.state = State::Deactivated; - assert!(!search_bar.is_searching()); - search_bar.state = State::Searching; - assert!(search_bar.is_searching()); -} - #[test] fn build_view_line() { assert_rendered_output!( diff --git a/src/core/src/modules/list/mod.rs b/src/core/src/modules/list/mod.rs index f89b1ac..4e6a9b8 100644 --- a/src/core/src/modules/list/mod.rs +++ b/src/core/src/modules/list/mod.rs @@ -1,34 +1,40 @@ +mod search; #[cfg(all(unix, test))] mod tests; mod utils; use std::{cmp::min, sync::Arc}; +use bitflags::Flags; use captur::capture; use config::Config; use display::DisplayColor; -use if_chain::if_chain; use input::{InputOptions, MouseEventKind, StandardEvent}; use parking_lot::Mutex; -use todo_file::{Action, EditContext, Line, Search, TodoFile}; +use todo_file::{Action, EditContext, Line, TodoFile}; use view::{LineSegment, RenderContext, ViewData, ViewLine}; -use self::utils::{ - get_list_normal_mode_help_lines, - get_list_visual_mode_help_lines, - get_todo_line_segments, - TodoLineSegmentsOptions, +use self::{ + search::Search, + utils::{ + get_line_action_maximum_width, + get_list_normal_mode_help_lines, + get_list_visual_mode_help_lines, + get_todo_line_segments, + TodoLineSegmentsOptions, + }, }; use crate::{ components::{ edit::Edit, help::Help, search_bar::{SearchBar, SearchBarAction}, + spin_indicator::SpinIndicator, }, events::{Event, KeyBindings, MetaEvent}, module::{ExitStatus, Module, State}, - modules::list::utils::get_line_action_maximum_width, process::Results, + search::Searchable, select, }; @@ -58,9 +64,10 @@ pub(crate) struct List { edit: Edit, height: usize, normal_mode_help: Help, - search: Search, search_bar: SearchBar, + search: Search, selected_line_action: Option, + spin_indicator: SpinIndicator, state: ListState, todo_file: Arc>, view_data: ViewData, @@ -71,7 +78,12 @@ pub(crate) struct List { impl Module for List { fn activate(&mut self, _: State) -> Results { self.selected_line_action = self.todo_file.lock().get_selected_line().map(|line| *line.get_action()); - Results::new() + let searchable: Box = Box::new(self.search.clone()); + let mut results = Results::from(searchable); + if let Some(term) = self.search_bar.search_value() { + results.search_term(term); + } + results } fn build_view_data(&mut self, context: &RenderContext) -> &ViewData { @@ -141,14 +153,17 @@ impl List { updater.set_show_help(true); }); + let search = Search::new(Arc::clone(&todo_file)); + Self { auto_select_next: config.auto_select_next, edit: Edit::new(), height: 0, normal_mode_help: Help::new_from_keybindings(&get_list_normal_mode_help_lines(&config.key_bindings)), - search: Search::new(), + search, search_bar: SearchBar::new(), selected_line_action: None, + spin_indicator: SpinIndicator::new(), state: ListState::Normal, todo_file, view_data, @@ -302,7 +317,7 @@ impl List { #[allow(clippy::unused_self)] fn open_in_editor(&mut self, results: &mut Results) { - self.search_bar.reset(); + results.search_cancel(); results.state(State::ExternalEditor); } @@ -318,7 +333,20 @@ impl List { } fn search_start(&mut self) { - self.search_bar.start_search(None); + self.search_bar.start_search(Some("")); + } + + fn search_update(&mut self) { + self.spin_indicator.refresh(); + // select the first match, if it is available and has not been previously selected + if let Some(selected) = self.search.current_match() { + _ = self.update_cursor(CursorUpdate::Set(selected.index())); + } + else if !self.search_bar.is_editing() { + if let Some(selected) = self.search.next() { + _ = self.update_cursor(CursorUpdate::Set(selected)); + } + } } fn help(&mut self) { @@ -389,6 +417,7 @@ impl List { if let Some(selected_line) = todo_file.get_selected_line() { if selected_line.is_editable() { self.state = ListState::Edit; + self.edit.reset(); self.edit.set_content(selected_line.get_content()); self.edit.set_label(format!("{} ", selected_line.get_action()).as_str()); } @@ -405,11 +434,14 @@ impl List { let is_visual_mode = self.state == ListState::Visual; let selected_index = todo_file.get_selected_line_index(); let visual_index = self.visual_index_start.unwrap_or(selected_index); + let search_view_line = self.search_bar.is_editing().then(|| self.search_bar.build_view_line()); - let search_results_total = self.search_bar.is_searching().then(|| self.search.total_results()); + let search_results_total = self.search.total_results(); let search_results_current = self.search.current_result_selected(); let search_term = self.search_bar.search_value(); let search_index = self.search.current_match(); + let search_active = self.search.is_active(); + let spin_indicator = self.spin_indicator.indicator(); self.view_data.update_view_data(|updater| { capture!(todo_file); @@ -423,6 +455,7 @@ impl List { else { let maximum_action_width = get_line_action_maximum_width(&todo_file); for (index, line) in todo_file.lines_iter().enumerate() { + let search_match = self.search.match_at_index(index); let selected_line = is_visual_mode && ((visual_index <= selected_index && index >= visual_index && index <= selected_index) || (visual_index > selected_index && index >= selected_index && index <= visual_index)); @@ -436,11 +469,17 @@ impl List { if context.is_full_width() { todo_line_segment_options.insert(TodoLineSegmentsOptions::FULL_WIDTH); } - if search_index.map_or(false, |v| v == index) { + if search_index.map_or(false, |v| v.index() == index) { todo_line_segment_options.insert(TodoLineSegmentsOptions::SEARCH_LINE); } let mut view_line = ViewLine::new_with_pinned_segments( - get_todo_line_segments(line, search_term, todo_line_segment_options, maximum_action_width), + get_todo_line_segments( + line, + search_term, + search_match, + todo_line_segment_options, + maximum_action_width, + ), if line.has_reference() { 2 } else { 3 }, ) .set_selected(selected_index == index || selected_line); @@ -457,17 +496,23 @@ impl List { else if let Some(s_term) = search_term { let mut search_line_segments = vec![]; search_line_segments.push(LineSegment::new(format!("[{s_term}]: ").as_str())); - if_chain! { - if let Some(s_total) = search_results_total; - if let Some(s_index) = search_results_current; - if s_total != 0; - then { - search_line_segments.push(LineSegment::new(format!("{}/{s_total}", s_index + 1).as_str())); - } - else { - search_line_segments.push(LineSegment::new("No Results")); - } + + if search_results_total == 0 && !search_active { + search_line_segments.push(LineSegment::new("No Results")); + } + else if let Some(s_index) = search_results_current { + search_line_segments.push(LineSegment::new( + format!("{}/{search_results_total}", s_index + 1).as_str(), + )); + } + else { + search_line_segments.push(LineSegment::new(format!("-/{search_results_total}").as_str())); + } + + if search_active { + search_line_segments.push(LineSegment::new(format!(" Searching [{spin_indicator}]").as_str())); } + updater.push_trailing_line(ViewLine::from(search_line_segments)); } } @@ -568,34 +613,47 @@ impl List { } fn handle_search_input(&mut self, event: Event) -> Option { - if self.search_bar.is_active() { - let todo_file = self.todo_file.lock(); - match self.search_bar.handle_event(event) { - SearchBarAction::Start(term) => { - if term.is_empty() { - self.search.cancel(); - self.search_bar.reset(); - } - else { - self.search.next(&todo_file, term.as_str()); - } - }, - SearchBarAction::Next(term) => self.search.next(&todo_file, term.as_str()), - SearchBarAction::Previous(term) => self.search.previous(&todo_file, term.as_str()), - SearchBarAction::Cancel => { - self.search.cancel(); - return Some(Results::from(event)); - }, - SearchBarAction::None | SearchBarAction::Update(_) => return None, - } - drop(todo_file); - - if let Some(selected) = self.search.current_match() { - _ = self.update_cursor(CursorUpdate::Set(selected)); - } - return Some(Results::from(event)); + if !self.search_bar.is_active() { + return None; + } + let mut results = Results::from(event); + let todo_file = self.todo_file.lock(); + match self.search_bar.handle_event(event) { + SearchBarAction::Update(term) => { + if term.is_empty() { + results.search_cancel(); + } + else { + results.search_term(term.as_str()); + } + }, + SearchBarAction::Start(term) => { + if term.is_empty() { + results.search_cancel(); + self.search_bar.reset(); + } + else { + results.search_term(term.as_str()); + } + }, + SearchBarAction::Next(term) => { + results.search_term(term.as_str()); + _ = self.search.next(); + }, + SearchBarAction::Previous(term) => { + results.search_term(term.as_str()); + _ = self.search.previous(); + }, + SearchBarAction::Cancel => { + results.search_cancel(); + return Some(results); + }, + SearchBarAction::None => return Some(results), } - None + drop(todo_file); + + self.search_update(); + Some(results) } #[allow(clippy::integer_division)] @@ -639,6 +697,7 @@ impl List { MetaEvent::SwapSelectedDown => self.swap_selected_down(), MetaEvent::SwapSelectedUp => self.swap_selected_up(), MetaEvent::ToggleVisualMode => self.toggle_visual_mode(), + MetaEvent::SearchUpdate => self.search_update(), _ => return None, } }, diff --git a/src/core/src/modules/list/search/line_match.rs b/src/core/src/modules/list/search/line_match.rs new file mode 100644 index 0000000..f9618cd --- /dev/null +++ b/src/core/src/modules/list/search/line_match.rs @@ -0,0 +1,24 @@ +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) struct LineMatch { + index: usize, + hash: bool, + content: bool, +} + +impl LineMatch { + pub(crate) const fn new(index: usize, hash: bool, content: bool) -> Self { + Self { index, hash, content } + } + + pub(crate) const fn index(&self) -> usize { + self.index + } + + pub(crate) const fn hash(&self) -> bool { + self.hash + } + + pub(crate) const fn content(&self) -> bool { + self.content + } +} diff --git a/src/core/src/modules/list/search/mod.rs b/src/core/src/modules/list/search/mod.rs new file mode 100644 index 0000000..0bf7ea3 --- /dev/null +++ b/src/core/src/modules/list/search/mod.rs @@ -0,0 +1,6 @@ +mod line_match; +#[allow(clippy::module_inception)] +mod search; +mod state; + +pub(crate) use self::{line_match::LineMatch, search::Search, state::State}; diff --git a/src/core/src/modules/list/search/search.rs b/src/core/src/modules/list/search/search.rs new file mode 100644 index 0000000..3773ece --- /dev/null +++ b/src/core/src/modules/list/search/search.rs @@ -0,0 +1,750 @@ +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use parking_lot::{Mutex, RwLock}; +use todo_file::{Action, TodoFile}; + +use super::{LineMatch, State}; +use crate::search::{Interrupter, SearchResult, SearchState, Searchable}; + +const LOCK_DURATION: Duration = Duration::from_millis(100); + +#[derive(Clone, Debug)] +pub(crate) struct Search { + cursor: Arc, + state: Arc>, + todo_file: Arc>, +} + +impl Searchable for Search { + fn reset(&mut self) { + self.state.write().reset(); + } + + fn search(&mut self, interrupter: Interrupter, term: &str) -> SearchResult { + let Some(todo_file) = self.todo_file.try_lock_for(LOCK_DURATION) + else { + return SearchResult::None; + }; + let Some(mut state) = self.state.try_write_for(LOCK_DURATION) + else { + return SearchResult::None; + }; + if state.try_invalidate_search(todo_file.version(), term) { + self.cursor.store(0, Ordering::Release); + } + let mut has_matches = false; + let mut complete = false; + + state.set_search_state(SearchState::Active); + let mut cursor = self.cursor.load(Ordering::Acquire); + while interrupter.should_continue() { + let Some(line) = todo_file.get_line(cursor) + else { + complete = true; + break; + }; + + let action = *line.get_action(); + + let has_hash_match = match action { + Action::Break | Action::Noop | Action::Label | Action::Reset | Action::Merge | Action::Exec => false, + Action::Drop + | Action::Edit + | Action::Fixup + | Action::Pick + | Action::Reword + | Action::Squash + | Action::UpdateRef => line.get_hash().starts_with(term), + }; + let has_content_match = match action { + Action::Break | Action::Noop => false, + Action::Drop + | Action::Edit + | Action::Fixup + | Action::Pick + | Action::Reword + | Action::Squash + | Action::UpdateRef + | Action::Label + | Action::Reset + | Action::Merge + | Action::Exec => line.get_content().contains(term), + }; + + has_matches = state.push_match(LineMatch::new(cursor, has_hash_match, has_content_match)) || has_matches; + + cursor += 1; + } + + self.cursor.store(cursor, Ordering::Release); + + if has_matches { + SearchResult::Updated + } + else if complete { + state.set_search_state(SearchState::Complete); + SearchResult::Complete + } + else { + SearchResult::None + } + } +} + +impl Search { + /// Create a new instance + #[inline] + #[must_use] + pub(crate) fn new(todo_file: Arc>) -> Self { + Self { + cursor: Arc::new(AtomicUsize::new(0)), + state: Arc::new(RwLock::new(State::new())), + todo_file, + } + } + + /// Select the next search result + #[inline] + #[allow(clippy::missing_panics_doc)] + pub(crate) fn next(&mut self) -> Option { + let mut state = self.state.write(); + + if state.matches().is_empty() { + return None; + } + + let new_selected = if let Some(mut current) = state.selected() { + current += 1; + if current >= state.number_matches() { 0 } else { current } + } + else { + // select the line after the hint that matches + let mut index_match = 0; + for (i, v) in state.matches().iter().copied().enumerate() { + if v.index() >= state.match_start_hint() { + index_match = i; + break; + } + } + index_match + }; + state.set_selected(new_selected); + + let new_match_hint = state.match_value(new_selected).map_or(0, |s| s.index()); + state.set_match_start_hint(new_match_hint); + Some(new_match_hint) + } + + /// Select the previous search result + #[inline] + #[allow(clippy::missing_panics_doc)] + pub(crate) fn previous(&mut self) -> Option { + let mut state = self.state.write(); + if state.matches().is_empty() { + return None; + } + + let new_selected = if let Some(current) = state.selected() { + if current == 0 { + state.number_matches().saturating_sub(1) + } + else { + current.saturating_sub(1) + } + } + else { + // select the line previous to hint that matches + let mut index_match = state.number_matches().saturating_sub(1); + for (i, v) in state.matches().iter().copied().enumerate().rev() { + if v.index() <= state.match_start_hint() { + index_match = i; + break; + } + } + index_match + }; + state.set_selected(new_selected); + + let new_match_hint = state.match_value(new_selected).map_or(0, |s| s.index()); + state.set_match_start_hint(new_match_hint); + Some(new_match_hint) + } + + /// Set a hint for which result to select first during search + #[inline] + pub(crate) fn set_search_start_hint(&mut self, hint: usize) { + self.state.write().set_match_start_hint(hint); + } + + /// Get the index of the current selected result, if there is one + #[inline] + #[must_use] + pub(crate) fn current_match(&self) -> Option { + let state = self.state.read(); + let selected = state.selected()?; + state.match_value(selected) + } + + /// Get the index of the current selected result, if there is one + #[inline] + #[must_use] + pub(crate) fn match_at_index(&self, index: usize) -> Option { + self.state.read().match_value_for_line(index) + } + + /// Get the selected result number, if there is one + #[inline] + #[must_use] + pub(crate) fn current_result_selected(&self) -> Option { + self.state.read().selected() + } + + /// Get the total number of results + #[inline] + #[must_use] + pub(crate) fn total_results(&self) -> usize { + self.state.read().number_matches() + } + + /// Is search active + #[inline] + #[must_use] + pub(crate) fn is_active(&self) -> bool { + self.state.read().search_state() == SearchState::Active + } +} + +#[cfg(test)] +mod tests { + use claims::{assert_none, assert_some_eq}; + use rstest::rstest; + use todo_file::testutil::with_todo_file; + + use super::*; + use crate::{modules::list::search::search, search::testutil::SearchableRunner}; + + pub(crate) fn create_search(todo_file: TodoFile) -> Search { + Search::new(Arc::new(Mutex::new(todo_file))) + } + + pub(crate) fn create_and_run_search(todo_file: TodoFile, term: &str, result: SearchResult) -> Search { + let search = Search::new(Arc::new(Mutex::new(todo_file))); + assert_eq!(SearchableRunner::new(&search).run_search(term), result); + search + } + + #[test] + fn reset() { + with_todo_file(&[], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.state.write().set_search_state(SearchState::Active); + search.reset(); + assert!(!search.is_active()); + }); + } + + #[test] + fn search_empty_rebase_file() { + with_todo_file(&[], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "foo", SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn search_with_one_line_no_match() { + with_todo_file(&["pick abcdef bar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "foo", SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn search_with_incomplete() { + with_todo_file(&["pick abcdef bar"; 10], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!( + SearchableRunner::new(&search).run_search_with_time("foo", 0), + SearchResult::None + ); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn search_with_one_line_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "foo", SearchResult::Updated); + assert_eq!(search.total_results(), 1); + assert_some_eq!(search.match_at_index(0), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn search_standard_action_hash() { + with_todo_file( + &[ + "pick aaaaa no match", + "drop abcdef foo", + "edit abcdef foo", + "fixup abcdef foo", + "pick abcdef foo", + "reword abcdef foo", + "squash abcdef foo", + ], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "abcd", SearchResult::Updated); + assert_eq!(search.total_results(), 6); + assert_none!(search.match_at_index(0)); + assert_some_eq!(search.match_at_index(1), LineMatch::new(1, true, false)); + assert_some_eq!(search.match_at_index(2), LineMatch::new(2, true, false)); + assert_some_eq!(search.match_at_index(3), LineMatch::new(3, true, false)); + assert_some_eq!(search.match_at_index(4), LineMatch::new(4, true, false)); + assert_some_eq!(search.match_at_index(5), LineMatch::new(5, true, false)); + }, + ); + } + + #[test] + fn search_content() { + with_todo_file( + &[ + "pick abcdef no match", + "drop abcdef foobar", + "edit abcdef foobar", + "fixup abcdef foobar", + "pick abcdef foobar", + "reword abcdef foobar", + "squash abcdef foobar", + "label foobar", + "reset foobar", + "merge foobar", + "exec foobar", + "update-ref foobar", + ], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "ooba", SearchResult::Updated); + assert_eq!(search.total_results(), 11); + assert_none!(search.match_at_index(0)); + assert_some_eq!(search.match_at_index(1), LineMatch::new(1, false, true)); + assert_some_eq!(search.match_at_index(2), LineMatch::new(2, false, true)); + assert_some_eq!(search.match_at_index(3), LineMatch::new(3, false, true)); + assert_some_eq!(search.match_at_index(4), LineMatch::new(4, false, true)); + assert_some_eq!(search.match_at_index(5), LineMatch::new(5, false, true)); + assert_some_eq!(search.match_at_index(6), LineMatch::new(6, false, true)); + assert_some_eq!(search.match_at_index(7), LineMatch::new(7, false, true)); + assert_some_eq!(search.match_at_index(8), LineMatch::new(8, false, true)); + assert_some_eq!(search.match_at_index(9), LineMatch::new(9, false, true)); + assert_some_eq!(search.match_at_index(10), LineMatch::new(10, false, true)); + }, + ); + } + + #[test] + fn search_standard_action_hash_starts_only() { + with_todo_file(&["pick abcdef foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "def", SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[rstest] + #[case::pick("noop")] + #[case::pick("break")] + #[case::pick("pick")] + #[case::drop("drop")] + #[case::edit("edit")] + #[case::fixup("fixup")] + #[case::reword("reword")] + #[case::squash("squash")] + #[case::label("label")] + #[case::reset("reset")] + #[case::merge("merge")] + #[case::exec("exec")] + #[case::update_ref("update-ref")] + fn search_ignore_action(#[case] action: &str) { + let line = format!("{action} abcdef foo"); + with_todo_file(&[line.as_str()], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, action, SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn next_no_match() { + with_todo_file(&["pick aaa foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "miss", SearchResult::Complete); + assert_none!(search.next()); + assert_none!(search.current_match()); + }); + } + + #[test] + fn next_first_match() { + with_todo_file(&["pick aaa foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "foo", SearchResult::Updated); + assert_some_eq!(search.next(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn next_first_match_with_hint_in_range() { + with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(1); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }); + } + + #[test] + fn next_first_match_with_hint_in_range_but_behind() { + with_todo_file(&["pick aaa foo", "pick bbb miss", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(1); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 2); + assert_some_eq!(search.current_match(), LineMatch::new(2, false, true)); + }); + } + + #[test] + fn next_first_match_with_hint_in_range_wrap() { + with_todo_file( + &["pick bbb miss", "pick aaa foo", "pick aaa foo", "pick bbb miss"], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(3); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }, + ); + } + + #[test] + fn next_first_match_with_hint_out_of_range() { + with_todo_file( + &["pick bbb miss", "pick aaa foo", "pick aaa foo", "pick bbb miss"], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(99); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }, + ); + } + + #[test] + fn next_continued_match() { + with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + assert_some_eq!(search.next(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }); + } + + #[test] + fn next_continued_match_wrap_single_match() { + with_todo_file(&["pick aaa foo", "pick bbb miss"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + assert_some_eq!(search.next(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn next_continued_match_wrap() { + with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + assert_some_eq!(search.next(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + assert_some_eq!(search.next(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn next_updates_match_start_hint() { + with_todo_file(&["pick bbb miss", "pick aaa foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(99); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.next(), 1); + assert_eq!(search.state.read().match_start_hint(), 1); + }); + } + + #[test] + fn previous_no_match() { + with_todo_file(&["pick aaa foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!( + SearchableRunner::new(&search).run_search("miss"), + SearchResult::Complete + ); + assert_none!(search.previous()); + assert_none!(search.current_match()); + }); + } + + #[test] + fn previous_first_match() { + with_todo_file(&["pick aaa foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn previous_first_match_with_hint_in_range() { + with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(1); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }); + } + + #[test] + fn previous_first_match_with_hint_in_range_but_ahead() { + with_todo_file( + &["pick bbb miss", "pick aaa foo", "pick bbb miss", "pick bbb foobar"], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(2); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }, + ); + } + #[test] + fn previous_first_match_with_hint_in_range_wrap() { + with_todo_file( + &["pick bbb miss", "pick bbb miss", "pick aaa foo", "pick aaa foo"], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(1); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 3); + assert_some_eq!(search.current_match(), LineMatch::new(3, false, true)); + }, + ); + } + + #[test] + fn previous_first_match_with_hint_out_of_range() { + with_todo_file( + &["pick bbb miss", "pick aaa foo", "pick aaa foo", "pick bbb miss"], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(99); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 2); + assert_some_eq!(search.current_match(), LineMatch::new(2, false, true)); + }, + ); + } + + #[test] + fn previous_continued_match() { + with_todo_file(&["pick aaa foo", "pick aaa foo", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(2); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 2); + assert_some_eq!(search.current_match(), LineMatch::new(2, false, true)); + assert_some_eq!(search.previous(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }); + } + + #[test] + fn previous_continued_match_wrap_single_match() { + with_todo_file(&["pick aaa foo", "pick bbb miss"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + assert_some_eq!(search.previous(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn previous_continued_match_wrap() { + with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 0); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + assert_some_eq!(search.previous(), 1); + assert_some_eq!(search.current_match(), LineMatch::new(1, false, true)); + }); + } + + #[test] + fn previous_updates_match_start_hint() { + with_todo_file(&["pick bbb miss", "pick aaa foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + assert_some_eq!(search.previous(), 1); + assert_eq!(search.state.read().match_start_hint(), 1); + }); + } + + #[test] + fn set_search_start_hint() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.set_search_start_hint(42); + assert_eq!(search.state.read().match_start_hint(), 42); + }); + } + + #[test] + fn current_match_without_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!( + SearchableRunner::new(&search).run_search("miss"), + SearchResult::Complete + ); + _ = search.next(); + assert_none!(search.current_match()); + }); + } + #[test] + fn current_match_with_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + _ = search.next(); + assert_some_eq!(search.current_match(), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn match_at_index_without_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!( + SearchableRunner::new(&search).run_search("miss"), + SearchResult::Complete + ); + _ = search.next(); + assert_none!(search.match_at_index(0)); + }); + } + #[test] + fn match_at_index_with_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + _ = search.next(); + assert_some_eq!(search.match_at_index(0), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn current_result_selected_without_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!( + SearchableRunner::new(&search).run_search("miss"), + SearchResult::Complete + ); + _ = search.next(); + assert_none!(search.current_result_selected()); + }); + } + + #[test] + fn current_result_selected_with_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + _ = search.next(); + assert_some_eq!(search.current_result_selected(), 0); + }); + } + + #[test] + fn total_results() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + assert_eq!(SearchableRunner::new(&search).run_search("foo"), SearchResult::Updated); + _ = search.next(); + assert_eq!(search.total_results(), 1); + }); + } + + #[test] + fn is_active() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.state.write().set_search_state(SearchState::Active); + assert!(search.is_active()); + }); + } +} diff --git a/src/core/src/modules/list/search/state.rs b/src/core/src/modules/list/search/state.rs new file mode 100644 index 0000000..0c6d483 --- /dev/null +++ b/src/core/src/modules/list/search/state.rs @@ -0,0 +1,235 @@ +use std::collections::HashMap; + +use todo_file::Version; + +use super::line_match::LineMatch; +use crate::search::SearchState; + +/// Input thread state. +#[derive(Clone, Debug)] +pub(crate) struct State { + match_indexes: HashMap, + match_start_hint: usize, + matches: Vec, + search_term: String, + selected: Option, + state: SearchState, + todo_file_version: Version, +} + +impl State { + pub(crate) fn new() -> Self { + Self { + match_indexes: HashMap::new(), + match_start_hint: 0, + matches: vec![], + search_term: String::new(), + selected: None, + state: SearchState::Inactive, + todo_file_version: Version::sentinel(), + } + } + + pub(crate) fn reset(&mut self) { + self.match_indexes.clear(); + self.matches.clear(); + self.search_term.clear(); + self.selected = None; + self.state = SearchState::Inactive; + self.todo_file_version = Version::sentinel(); + } + + pub(crate) fn try_invalidate_search(&mut self, version: &Version, search_term: &str) -> bool { + if &self.todo_file_version != version || self.search_term != search_term { + self.search_term = String::from(search_term); + self.matches.clear(); + self.match_indexes.clear(); + self.todo_file_version = *version; + true + } + else { + false + } + } + + pub(crate) const fn search_state(&self) -> SearchState { + self.state + } + + pub(crate) fn set_search_state(&mut self, state: SearchState) { + self.state = state; + } + + pub(crate) fn push_match(&mut self, line_match: LineMatch) -> bool { + if line_match.hash() || line_match.content() { + _ = self.match_indexes.insert(line_match.index(), self.matches.len()); + self.matches.push(line_match); + true + } + else { + false + } + } + + pub(crate) const fn matches(&self) -> &Vec { + &self.matches + } + + pub(crate) fn number_matches(&self) -> usize { + self.matches.len() + } + + /// Returns the match value for a line index + pub(crate) fn match_value_for_line(&self, index: usize) -> Option { + let search_index = *self.match_indexes.get(&index)?; + self.match_value(search_index) + } + + /// Returns the match value for a search match index + pub(crate) fn match_value(&self, search_index: usize) -> Option { + self.matches.get(search_index).copied() + } + + pub(crate) fn set_selected(&mut self, selected: usize) { + self.selected = Some(selected); + } + + pub(crate) const fn selected(&self) -> Option { + self.selected + } + + pub(crate) fn set_match_start_hint(&mut self, hint: usize) { + self.match_start_hint = hint; + } + + pub(crate) const fn match_start_hint(&self) -> usize { + self.match_start_hint + } +} + +#[cfg(test)] +mod tests { + use claims::{assert_none, assert_some_eq}; + + use super::*; + + #[test] + fn try_invalidate_search_with_no_change() { + let mut state = State::new(); + assert!(!state.try_invalidate_search(&Version::sentinel(), "")); + } + + #[test] + fn try_invalidate_search_with_change_in_term() { + let mut state = State::new(); + assert!(state.try_invalidate_search(&Version::sentinel(), "foo")); + } + + #[test] + fn try_invalidate_search_with_change_in_version() { + let mut state = State::new(); + assert!(state.try_invalidate_search(&Version::new(), "")); + } + + #[test] + fn try_invalidate_search_resets_state() { + let mut state = State::new(); + state.matches.push(LineMatch::new(1, false, false)); + _ = state.match_indexes.insert(1, 1); + let version = Version::new(); + assert!(state.try_invalidate_search(&version, "foo")); + assert_eq!(state.search_term, "foo"); + assert!(state.matches().is_empty()); + assert!(state.match_indexes.is_empty()); + assert_eq!(state.todo_file_version, version); + } + + #[test] + fn search_state() { + let mut state = State::new(); + state.set_search_state(SearchState::Active); + assert_eq!(state.search_state(), SearchState::Active); + } + + #[test] + fn push_match_with_hash_match() { + let mut state = State::new(); + assert!(state.push_match(LineMatch::new(1, true, false))); + assert!(!state.matches().is_empty()); + assert_eq!(state.number_matches(), 1); + } + + #[test] + fn push_match_with_content_match() { + let mut state = State::new(); + assert!(state.push_match(LineMatch::new(1, false, true))); + assert!(!state.matches().is_empty()); + assert_eq!(state.number_matches(), 1); + } + + #[test] + fn push_match_with_hash_and_content_match() { + let mut state = State::new(); + assert!(state.push_match(LineMatch::new(1, true, true))); + assert!(!state.matches().is_empty()); + assert_eq!(state.number_matches(), 1); + } + + #[test] + fn push_match_with_no_hash_and_no_content_match() { + let mut state = State::new(); + assert!(!state.push_match(LineMatch::new(1, false, false))); + assert!(state.matches().is_empty()); + assert_eq!(state.number_matches(), 0); + } + + #[test] + fn match_value_for_line_index_miss() { + let mut state = State::new(); + assert!(state.push_match(LineMatch::new(1, false, true))); + assert_none!(state.match_value_for_line(99)); + } + + #[test] + fn match_value_for_line_index_hit() { + let mut state = State::new(); + let line_match = LineMatch::new(1, false, true); + assert!(state.push_match(line_match)); + assert_some_eq!(state.match_value_for_line(1), line_match); + } + + #[test] + fn match_value_miss() { + let mut state = State::new(); + assert!(state.push_match(LineMatch::new(1, false, true))); + assert_none!(state.match_value(99)); + } + + #[test] + fn match_value_hit() { + let mut state = State::new(); + let line_match = LineMatch::new(1, false, true); + assert!(state.push_match(line_match)); + assert_some_eq!(state.match_value(0), line_match); + } + + #[test] + fn selected_set() { + let mut state = State::new(); + state.set_selected(42); + assert_some_eq!(state.selected(), 42); + } + + #[test] + fn selected_not_set() { + let mut state = State::new(); + assert_none!(state.selected()); + } + + #[test] + fn match_start_hint() { + let mut state = State::new(); + state.set_match_start_hint(42); + assert_eq!(state.match_start_hint(), 42); + } +} diff --git a/src/core/src/modules/list/tests/activate.rs b/src/core/src/modules/list/tests/activate.rs new file mode 100644 index 0000000..f737411 --- /dev/null +++ b/src/core/src/modules/list/tests/activate.rs @@ -0,0 +1,65 @@ +use claims::{assert_none, assert_some_eq}; + +use super::*; +use crate::{ + assert_results, + process::Artifact, + search::{Interrupter, SearchResult}, + testutil::module_test, +}; + +#[derive(Clone)] +struct MockedSearchable; + +impl Searchable for MockedSearchable { + fn reset(&mut self) {} + + fn search(&mut self, _: Interrupter, term: &str) -> SearchResult { + SearchResult::None + } +} + +#[test] +fn sets_selected_line_action() { + module_test(&["pick aaa c1"], &[], |mut test_context| { + let mut module = create_list(&Config::new(), test_context.take_todo_file()); + _ = test_context.activate(&mut module, State::List); + assert_some_eq!(module.selected_line_action, Action::Pick); + }); +} + +#[test] +fn sets_selected_line_action_none_selected() { + module_test(&["pick aaa c1", "pick bbb c2"], &[], |mut test_context| { + let mut todo_file = test_context.take_todo_file(); + todo_file.set_lines(vec![]); + + let mut module = create_list(&Config::new(), todo_file); + _ = test_context.activate(&mut module, State::List); + assert_none!(module.selected_line_action); + }); +} + +#[test] +fn result() { + module_test(&["pick aaa c1", "pick bbb c2"], &[], |mut test_context| { + let mut module = create_list(&Config::new(), test_context.take_todo_file()); + assert_results!( + test_context.activate(&mut module, State::List), + Artifact::Searchable(Box::new(MockedSearchable {})) + ); + }); +} + +#[test] +fn result_with_serach_term() { + module_test(&["pick aaa c1", "pick bbb c2"], &[], |mut test_context| { + let mut module = create_list(&Config::new(), test_context.take_todo_file()); + module.search_bar.start_search(Some("foo")); + assert_results!( + test_context.activate(&mut module, State::List), + Artifact::Searchable(Box::new(MockedSearchable {})), + Artifact::SearchTerm(String::from("foo")) + ); + }); +} diff --git a/src/core/src/modules/list/tests/external_editor.rs b/src/core/src/modules/list/tests/external_editor.rs index adea271..b2168da 100644 --- a/src/core/src/modules/list/tests/external_editor.rs +++ b/src/core/src/modules/list/tests/external_editor.rs @@ -11,6 +11,7 @@ fn normal_mode_open_external_editor() { assert_results!( test_context.handle_event(&mut module), Artifact::Event(Event::from(MetaEvent::OpenInEditor)), + Artifact::SearchCancel, Artifact::ChangeState(State::ExternalEditor) ); }, @@ -31,26 +32,9 @@ fn visual_mode_open_external_editor() { assert_results!( test_context.handle_event(&mut module), Artifact::Event(Event::from(MetaEvent::OpenInEditor)), + Artifact::SearchCancel, Artifact::ChangeState(State::ExternalEditor) ); }, ); } - -#[test] -fn cancels_search() { - module_test( - &["pick aaa c1"], - &[ - Event::from(StandardEvent::SearchStart), - Event::from('x'), - Event::from(StandardEvent::SearchFinish), - Event::from(MetaEvent::OpenInEditor), - ], - |mut test_context| { - let mut module = create_list(&Config::new(), test_context.take_todo_file()); - _ = test_context.handle_all_events(&mut module); - assert!(!module.search_bar.is_searching()); - }, - ); -} diff --git a/src/core/src/modules/list/tests/mod.rs b/src/core/src/modules/list/tests/mod.rs index 97877e6..e1ea0fb 100644 --- a/src/core/src/modules/list/tests/mod.rs +++ b/src/core/src/modules/list/tests/mod.rs @@ -1,4 +1,5 @@ mod abort_and_rebase; +mod activate; mod change_action; mod edit_mode; mod external_editor; diff --git a/src/core/src/modules/list/tests/old_search.xrs b/src/core/src/modules/list/tests/old_search.xrs new file mode 100644 index 0000000..cf784f9 --- /dev/null +++ b/src/core/src/modules/list/tests/old_search.xrs @@ -0,0 +1,599 @@ +use std::time::Duration; + +use view::assert_rendered_output; + +use super::*; +use crate::{ + assert_results, + process::Artifact, + search::{testutil::SearchableRunner, Interrupter}, + testutil::{EventHandlerTestContext, module_test, ModuleTestContext}, +}; + +pub(crate) struct TestContext { + module_test_context:ModuleTestContext, + pub(crate) list: List, +} + + +impl TestContext { + fn get_build_data<'tc>(&self, module: &'tc mut dyn Module) -> &'tc ViewData { + module.build_view_data(&self.render_context) + } + + #[allow(clippy::unused_self)] + pub(crate) fn activate(&self, module: &'_ mut dyn Module, state: State) -> Results { + module.activate(state) + } + + #[allow(clippy::unused_self)] + pub(crate) fn deactivate(&mut self, module: &'_ mut dyn Module) -> Results { + module.deactivate() + } + + pub(crate) fn build_view_data<'tc>(&self, module: &'tc mut dyn Module) -> &'tc ViewData { + self.get_build_data(module) + } + + pub(crate) fn read_event(&mut self, module: &'_ mut dyn Module) -> Event { + let input_options = module.input_options(); + self.event_handler_context.event_handler.read_event( + self.event_handler_context.state.read_event(), + input_options, + |event, key_bindings| module.read_event(event, key_bindings), + ) + } + + pub(crate) fn handle_event(&mut self, module: &'_ mut dyn Module) -> Results { + let event = self.read_event(module); + let mut results = Results::new(); + results.event(event); + results.append(module.handle_event(event, &self.view_context.state)); + results + } + + pub(crate) fn handle_n_events(&mut self, module: &'_ mut dyn Module, n: usize) -> Vec { + let mut results = vec![]; + for _ in 0..n { + results.push(self.handle_event(module)); + } + results + } + + pub(crate) fn handle_all_events(&mut self, module: &'_ mut dyn Module) -> Vec { + self.handle_n_events(module, self.event_handler_context.number_events) + } + + pub(crate) fn take_todo_file(&mut self) -> TodoFile { + self.todo_file.take().expect("Cannot take the TodoFile more than once") + } +} + +#[derive(Debug, Eq, PartialEq)] +enum Action { + Start, + Finish, + FinishWithNext(usize), + FinishWithPrevious(usize), +} + +pub(crate) fn search_test(action: Action, term: &str, lines: &[&str], callback: C) +where C: FnOnce(TestContext) { + let mut events = vec![Event::from(StandardEvent::SearchStart)]; + + for c in term.chars() { + events.push(Event::from(c)) + } + + match action { + Action::Start => {}, + Action::Finish => { + events.push(Event::from(StandardEvent::SearchFinish)); + }, + Action::FinishWithNext(n) => { + events.push(Event::from(StandardEvent::SearchFinish)); + for _ in n { + events.push(Event::from(StandardEvent::SearchNext)); + } + }, + Action::FinishWithPrevious(n) => { + events.push(Event::from(StandardEvent::SearchFinish)); + for _ in n { + events.push(Event::from(StandardEvent::SearchPrevious)); + } + }, + } + module_test(lines, events.as_slice(), |mut context| { + let list = create_list(&Config::new(), context.take_todo_file()); + callback(TestContext { + event_handler_context: context.event_handler_context, + list, + }); + }); +} + +// #[test] +// fn with_match_on_hash() { +// module_test( +// &["pick aaaaaaaa comment1"], +// &[ +// Event::from(StandardEvent::SearchStart), +// Event::from('a'), +// Event::from('a'), +// Event::from('a'), +// ], +// |mut test_context| { +// let mut module = create_list(&Config::new(), test_context.take_todo_file()); +// let _ = SearchableRunner::new(&module.search).run_search("aaa"); +// let _ = test_context.handle_all_events(&mut module); +// let view_data = test_context.build_view_data(&mut module); +// assert_rendered_output!( +// view_data, +// "{TITLE}{HELP}", +// "{BODY}", +// "{Selected}{Normal} > {ActionPick}pick {IndicatorColor}aaaaaaaa{Normal} comment1{Pad( )}", +// "{TRAILING}", +// "{Normal}/aaa{Normal,Underline}" +// ); +// }, +// ); +// } +// +// #[test] +// fn with_no_match() { +// module_test( +// &["pick aaaaaaaa comment1"], +// &[Event::from(StandardEvent::SearchStart), Event::from('x')], +// |mut test_context| { +// let mut module = create_list(&Config::new(), test_context.take_todo_file()); +// let _ = SearchableRunner::new(&module.search).run_search("x"); +// let _ = test_context.handle_all_events(&mut module); +// let view_data = test_context.build_view_data(&mut module); +// assert_rendered_output!( +// view_data, +// "{TITLE}{HELP}", +// "{BODY}", +// "{Selected}{Normal} > {ActionPick}pick {Normal}aaaaaaaa comment1{Pad( )}", +// "{TRAILING}", +// "{Normal}/x{Normal,Underline}" +// ); +// }, +// ); +// } + +// #[test] +// fn start_with_matches_and_with_term() { +// module_test( +// &["pick aaaaaaaa comment1"], +// &[ +// Event::from(StandardEvent::SearchStart), +// Event::from('a'), +// Event::from(StandardEvent::SearchFinish), +// ], +// |mut test_context| { +// let mut module = create_list(&Config::new(), test_context.take_todo_file()); +// let _ = SearchableRunner::new(&module.search).run_search("a"); +// let _ = test_context.handle_all_events(&mut module); +// let view_data = test_context.build_view_data(&mut module); +// assert_rendered_output!( +// view_data, +// "{TITLE}{HELP}", +// "{BODY}", +// "{Selected}{Normal} > {ActionPick}pick {IndicatorColor,Underline}aaaaaaaa{Normal} comment1{Pad( )}", +// "{TRAILING}", +// "{Normal}[a]: 1/1" +// ); +// }, +// ); +// } +// +// #[test] +// fn start_with_no_matches_and_with_term() { +// module_test( +// &["pick aaaaaaaa comment1"], +// &[ +// Event::from(StandardEvent::SearchStart), +// Event::from('x'), +// Event::from(StandardEvent::SearchFinish), +// ], +// |mut test_context| { +// let mut module = create_list(&Config::new(), test_context.take_todo_file()); +// let _ = test_context.handle_all_events(&mut module); +// le