diff --git a/Cargo.lock b/Cargo.lock index a9d245d..061aae8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -109,6 +118,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -422,10 +437,21 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1052,6 +1078,7 @@ dependencies = [ name = "pikl-core" version = "0.1.0" dependencies = [ + "fancy-regex 0.14.0", "nucleo-matcher", "pikl-test-macros", "serde", @@ -1510,7 +1537,7 @@ dependencies = [ "anyhow", "base64", "bitflags 2.11.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset", diff --git a/crates/pikl-core/Cargo.toml b/crates/pikl-core/Cargo.toml index 60e1a8b..1446eda 100644 --- a/crates/pikl-core/Cargo.toml +++ b/crates/pikl-core/Cargo.toml @@ -14,6 +14,7 @@ serde_json = "1.0.149" thiserror = "2.0.18" tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] } nucleo-matcher = "0.3.1" +fancy-regex = "0.14" [dev-dependencies] tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] } diff --git a/crates/pikl-core/src/lib.rs b/crates/pikl-core/src/lib.rs index 0ce92b7..cc229d9 100644 --- a/crates/pikl-core/src/lib.rs +++ b/crates/pikl-core/src/lib.rs @@ -14,8 +14,12 @@ pub mod error; pub use model::event; pub use model::item; pub use model::traits; +pub use query::exact; pub use query::filter; pub use query::navigation; +pub use query::pipeline; +pub use query::regex_filter; +pub use query::strategy; pub use runtime::hook; pub use runtime::input; pub use runtime::json_menu; diff --git a/crates/pikl-core/src/query/exact.rs b/crates/pikl-core/src/query/exact.rs new file mode 100644 index 0000000..8b150ac --- /dev/null +++ b/crates/pikl-core/src/query/exact.rs @@ -0,0 +1,125 @@ +//! Exact substring filter. Case-insensitive matching, +//! results in insertion order. Fast enough that incremental +//! narrowing isn't worth the complexity. + +use crate::filter::Filter; + +/// Case-insensitive substring filter. Matches items whose +/// label contains the query as a substring (both lowercased). +/// Results are returned in insertion order, not scored. +pub struct ExactFilter { + items: Vec<(usize, String)>, + query_lower: String, + results: Vec, +} + +impl Default for ExactFilter { + fn default() -> Self { + Self::new() + } +} + +impl ExactFilter { + pub fn new() -> Self { + Self { + items: Vec::new(), + query_lower: String::new(), + results: Vec::new(), + } + } +} + +impl Filter for ExactFilter { + fn push(&mut self, index: usize, label: &str) { + self.items.push((index, label.to_lowercase())); + } + + fn set_query(&mut self, query: &str) { + self.query_lower = query.to_lowercase(); + if self.query_lower.is_empty() { + self.results = self.items.iter().map(|(idx, _)| *idx).collect(); + return; + } + self.results = self + .items + .iter() + .filter(|(_, label)| label.contains(&self.query_lower)) + .map(|(idx, _)| *idx) + .collect(); + } + + fn matched_count(&self) -> usize { + self.results.len() + } + + fn matched_index(&self, match_position: usize) -> Option { + self.results.get(match_position).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_items(f: &mut ExactFilter, labels: &[&str]) { + for (i, label) in labels.iter().enumerate() { + f.push(i, label); + } + } + + #[test] + fn empty_query_returns_all() { + let mut f = ExactFilter::new(); + push_items(&mut f, &["apple", "banana", "cherry"]); + f.set_query(""); + assert_eq!(f.matched_count(), 3); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(1)); + assert_eq!(f.matched_index(2), Some(2)); + } + + #[test] + fn case_insensitive_match() { + let mut f = ExactFilter::new(); + push_items(&mut f, &["Apple", "BANANA", "Cherry"]); + f.set_query("apple"); + assert_eq!(f.matched_count(), 1); + assert_eq!(f.matched_index(0), Some(0)); + } + + #[test] + fn substring_match() { + let mut f = ExactFilter::new(); + push_items(&mut f, &["error_log", "warning_temp", "info_log"]); + f.set_query("log"); + assert_eq!(f.matched_count(), 2); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(2)); + } + + #[test] + fn no_match() { + let mut f = ExactFilter::new(); + push_items(&mut f, &["apple", "banana"]); + f.set_query("xyz"); + assert_eq!(f.matched_count(), 0); + } + + #[test] + fn results_in_insertion_order() { + let mut f = ExactFilter::new(); + push_items(&mut f, &["cat", "concatenate", "catalog"]); + f.set_query("cat"); + assert_eq!(f.matched_count(), 3); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(1)); + assert_eq!(f.matched_index(2), Some(2)); + } + + #[test] + fn empty_items() { + let mut f = ExactFilter::new(); + f.set_query("test"); + assert_eq!(f.matched_count(), 0); + } +} diff --git a/crates/pikl-core/src/query/mod.rs b/crates/pikl-core/src/query/mod.rs index 68b94eb..edd165b 100644 --- a/crates/pikl-core/src/query/mod.rs +++ b/crates/pikl-core/src/query/mod.rs @@ -1,2 +1,6 @@ +pub mod exact; pub mod filter; pub mod navigation; +pub mod pipeline; +pub mod regex_filter; +pub mod strategy; diff --git a/crates/pikl-core/src/query/navigation.rs b/crates/pikl-core/src/query/navigation.rs index 8bbeb7f..da6611e 100644 --- a/crates/pikl-core/src/query/navigation.rs +++ b/crates/pikl-core/src/query/navigation.rs @@ -113,6 +113,34 @@ impl Viewport { } } + /// Move cursor up by `n` half-pages (half viewport height + /// each). Clamps to the top. + pub fn half_page_up(&mut self, n: usize) { + if self.height == 0 { + return; + } + let half = (self.height / 2).max(1); + let distance = half.saturating_mul(n); + self.cursor = self.cursor.saturating_sub(distance); + if self.cursor < self.scroll_offset { + self.scroll_offset = self.cursor; + } + } + + /// Move cursor down by `n` half-pages (half viewport height + /// each). Clamps to the last item. + pub fn half_page_down(&mut self, n: usize) { + if self.height == 0 || self.filtered_count == 0 { + return; + } + let half = (self.height / 2).max(1); + let distance = half.saturating_mul(n); + self.cursor = (self.cursor + distance).min(self.filtered_count - 1); + if self.cursor >= self.scroll_offset + self.height { + self.scroll_offset = self.cursor - self.height + 1; + } + } + /// Move cursor down by `n` pages (viewport height each). /// Clamps to the last item. pub fn page_down(&mut self, n: usize) { @@ -353,4 +381,106 @@ mod tests { v.set_height(3); assert!(v.cursor() < v.scroll_offset() + 3); } + + // -- half_page unit tests -- + + #[test] + fn half_page_down_basic() { + let mut v = viewport(10, 30); + v.half_page_down(1); + assert_eq!(v.cursor(), 5); + assert_eq!(v.scroll_offset(), 0); + } + + #[test] + fn half_page_down_scrolls() { + let mut v = viewport(10, 30); + // Move cursor near viewport edge, then half page down + v.half_page_down(1); // cursor 5 + v.half_page_down(1); // cursor 10, should scroll + assert_eq!(v.cursor(), 10); + assert_eq!(v.scroll_offset(), 1); + } + + #[test] + fn half_page_down_clamps() { + let mut v = viewport(10, 12); + v.half_page_down(1); // cursor 5 + v.half_page_down(1); // cursor 10 + v.half_page_down(1); // clamps to 11 + assert_eq!(v.cursor(), 11); + } + + #[test] + fn half_page_down_n_multiplier() { + let mut v = viewport(10, 30); + v.half_page_down(3); // (10/2)*3 = 15 + assert_eq!(v.cursor(), 15); + } + + #[test] + fn half_page_up_basic() { + let mut v = viewport(10, 30); + v.half_page_down(2); // cursor 10 + v.half_page_up(1); // cursor 5 + assert_eq!(v.cursor(), 5); + } + + #[test] + fn half_page_up_clamps_at_top() { + let mut v = viewport(10, 30); + v.half_page_down(1); // cursor 5 + v.half_page_up(1); // cursor 0 + assert_eq!(v.cursor(), 0); + v.half_page_up(1); // still 0 + assert_eq!(v.cursor(), 0); + } + + #[test] + fn half_page_up_scrolls() { + let mut v = viewport(10, 30); + // Scroll down far enough that offset > 0 + v.half_page_down(3); // cursor 15, offset 6 + assert!(v.scroll_offset() > 0); + // Now half page up should track cursor back + v.half_page_up(1); // cursor 10 + v.half_page_up(1); // cursor 5 + assert_eq!(v.cursor(), 5); + // Offset should have followed cursor if it went above + assert!(v.scroll_offset() <= v.cursor()); + } + + #[test] + fn half_page_height_one() { + let mut v = viewport(1, 10); + // max(1/2, 1) = 1, moves 1 item + v.half_page_down(1); + assert_eq!(v.cursor(), 1); + } + + #[test] + fn half_page_height_two() { + let mut v = viewport(2, 10); + // 2/2 = 1, moves 1 item + v.half_page_down(1); + assert_eq!(v.cursor(), 1); + } + + #[test] + fn half_page_zero_height() { + let mut v = viewport(0, 10); + v.half_page_down(1); + assert_eq!(v.cursor(), 0); + v.half_page_up(1); + assert_eq!(v.cursor(), 0); + } + + #[test] + fn half_page_empty_list() { + let mut v = viewport(10, 0); + v.half_page_down(1); + assert_eq!(v.cursor(), 0); + v.half_page_up(1); + assert_eq!(v.cursor(), 0); + } } diff --git a/crates/pikl-core/src/query/pipeline.rs b/crates/pikl-core/src/query/pipeline.rs new file mode 100644 index 0000000..69344df --- /dev/null +++ b/crates/pikl-core/src/query/pipeline.rs @@ -0,0 +1,617 @@ +//! Filter pipeline with `|` chaining. Splits a query into +//! segments, applies the appropriate filter strategy to each, +//! and chains results through stages. Supports incremental +//! caching: unchanged stages keep their results. + +use super::filter::{Filter, FuzzyFilter}; +use super::strategy::{self, FilterKind}; + +/// A multi-stage filter pipeline. Each `|` in the query +/// creates a new stage that filters the previous stage's +/// output. Implements [`Filter`] so it can be used as a +/// drop-in replacement for a single filter. +pub struct FilterPipeline { + /// Master item list: (original index, label). + items: Vec<(usize, String)>, + /// Pipeline stages, one per `|`-separated segment. + stages: Vec, + /// The last raw query string, used for diffing. + last_raw_query: String, +} + +struct PipelineStage { + /// The raw segment text (including prefix chars). + raw_segment: String, + kind: FilterKind, + inverse: bool, + /// The query text after prefix stripping. + query_text: String, + /// The strategy-specific filter (only used for fuzzy stages). + fuzzy: Option, + /// Items passing this stage (indices into master list). + cached_indices: Vec, + dirty: bool, +} + +/// Split a raw query on unescaped `|` characters, respecting +/// regex delimiters (`/pattern/` and `!/pattern/`). Returns +/// the segments with `\|` unescaped to literal `|`. +fn split_pipeline(query: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = String::new(); + let chars: Vec = query.chars().collect(); + let len = chars.len(); + let mut i = 0; + let mut in_regex = false; + // Position of the opening `/` in current segment (char count into current) + let mut regex_open_pos: usize = 0; + + while i < len { + let c = chars[i]; + + // Escaped pipe: always produce literal `|` + if c == '\\' && i + 1 < len && chars[i + 1] == '|' { + current.push('|'); + i += 2; + continue; + } + + // Detect regex opening: `/` or `!/` at start of a segment + // (current is empty or whitespace-only after a previous pipe) + if !in_regex { + let trimmed = current.trim(); + // `/pattern/` + if c == '/' && (trimmed.is_empty() || trimmed == "!") { + in_regex = true; + regex_open_pos = current.len(); + current.push(c); + i += 1; + continue; + } + } + + // Detect regex closing: `/` that is not the opening slash + if in_regex && c == '/' { + if current.len() > regex_open_pos { + // This is the closing slash + in_regex = false; + } + current.push(c); + i += 1; + continue; + } + + // Unescaped pipe outside regex: split here + if c == '|' && !in_regex { + segments.push(current.trim().to_string()); + current = String::new(); + i += 1; + continue; + } + + current.push(c); + i += 1; + } + + segments.push(current.trim().to_string()); + + // Filter out empty segments + segments.into_iter().filter(|s| !s.is_empty()).collect() +} + +impl Default for FilterPipeline { + fn default() -> Self { + Self::new() + } +} + +impl FilterPipeline { + pub fn new() -> Self { + Self { + items: Vec::new(), + stages: Vec::new(), + last_raw_query: String::new(), + } + } + + /// Evaluate all dirty stages in order. Each stage filters + /// against the previous stage's cached_indices. + fn evaluate(&mut self) { + for stage_idx in 0..self.stages.len() { + if !self.stages[stage_idx].dirty { + continue; + } + + let input_indices: Vec = if stage_idx == 0 { + self.items.iter().map(|(idx, _)| *idx).collect() + } else { + self.stages[stage_idx - 1].cached_indices.clone() + }; + + let stage = &mut self.stages[stage_idx]; + + let result = match stage.kind { + FilterKind::Fuzzy => Self::eval_fuzzy(stage, &input_indices, stage_idx), + FilterKind::Exact => { + Self::eval_simple(stage, &input_indices, &self.items, |label, query| { + label.to_lowercase().contains(&query.to_lowercase()) + }) + } + FilterKind::Regex => { + let re = fancy_regex::Regex::new(&stage.query_text).ok(); + Self::eval_simple(stage, &input_indices, &self.items, |label, _query| { + match &re { + Some(r) => r.is_match(label).unwrap_or(false), + None => true, // invalid regex matches everything + } + }) + } + }; + + self.stages[stage_idx].cached_indices = result; + self.stages[stage_idx].dirty = false; + } + } + + fn eval_fuzzy( + stage: &mut PipelineStage, + input_indices: &[usize], + stage_idx: usize, + ) -> Vec { + let Some(fuzzy) = stage.fuzzy.as_mut() else { + return Vec::new(); + }; + fuzzy.set_query(&stage.query_text); + let fuzzy_results: Vec = (0..fuzzy.matched_count()) + .filter_map(|i| fuzzy.matched_index(i)) + .collect(); + if stage.inverse { + let fuzzy_set: std::collections::HashSet = fuzzy_results.into_iter().collect(); + input_indices + .iter() + .copied() + .filter(|idx| !fuzzy_set.contains(idx)) + .collect() + } else if stage_idx == 0 { + fuzzy_results + } else { + let input_set: std::collections::HashSet = + input_indices.iter().copied().collect(); + fuzzy_results + .into_iter() + .filter(|idx| input_set.contains(idx)) + .collect() + } + } + + fn eval_simple( + stage: &PipelineStage, + input_indices: &[usize], + items: &[(usize, String)], + matcher: impl Fn(&str, &str) -> bool, + ) -> Vec { + if stage.query_text.is_empty() { + return input_indices.to_vec(); + } + if stage.inverse { + input_indices + .iter() + .copied() + .filter(|&idx| !matcher(&items[idx].1, &stage.query_text)) + .collect() + } else { + input_indices + .iter() + .copied() + .filter(|&idx| matcher(&items[idx].1, &stage.query_text)) + .collect() + } + } +} + +impl Filter for FilterPipeline { + fn push(&mut self, index: usize, label: &str) { + self.items.push((index, label.to_string())); + // Push to any existing fuzzy filters in stages + for stage in &mut self.stages { + if let Some(ref mut fuzzy) = stage.fuzzy { + fuzzy.push(index, label); + } + stage.dirty = true; + } + } + + fn set_query(&mut self, query: &str) { + self.last_raw_query = query.to_string(); + let segments = split_pipeline(query); + + // Reconcile stages with new segments + let mut new_len = segments.len(); + + // If query is empty, clear everything + if segments.is_empty() { + self.stages.clear(); + new_len = 0; + } + + // Compare position-by-position + for (i, seg) in segments.iter().enumerate() { + if i < self.stages.len() { + if self.stages[i].raw_segment == *seg { + // Unchanged: keep cache + continue; + } + // Changed: update this stage, mark dirty + let parsed = strategy::parse_segment(seg); + self.stages[i].raw_segment = seg.clone(); + self.stages[i].kind = parsed.kind; + self.stages[i].inverse = parsed.inverse; + self.stages[i].query_text = parsed.query.to_string(); + self.stages[i].dirty = true; + // Mark all downstream stages dirty too + for j in (i + 1)..self.stages.len() { + self.stages[j].dirty = true; + } + } else { + // New stage + let parsed = strategy::parse_segment(seg); + let fuzzy = if parsed.kind == FilterKind::Fuzzy { + let mut f = FuzzyFilter::new(); + for (idx, label) in &self.items { + f.push(*idx, label); + } + Some(f) + } else { + None + }; + self.stages.push(PipelineStage { + raw_segment: seg.clone(), + kind: parsed.kind, + inverse: parsed.inverse, + query_text: parsed.query.to_string(), + fuzzy, + cached_indices: Vec::new(), + dirty: true, + }); + } + } + + // Truncate extra stages + self.stages.truncate(new_len); + + // Evaluate dirty stages + self.evaluate(); + } + + fn matched_count(&self) -> usize { + match self.stages.last() { + Some(stage) => stage.cached_indices.len(), + None => self.items.len(), + } + } + + fn matched_index(&self, match_position: usize) -> Option { + match self.stages.last() { + Some(stage) => stage.cached_indices.get(match_position).copied(), + None => self.items.get(match_position).map(|(idx, _)| *idx), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_items(p: &mut FilterPipeline, labels: &[&str]) { + for (i, label) in labels.iter().enumerate() { + p.push(i, label); + } + } + + fn matched_labels<'a>(p: &FilterPipeline, labels: &'a [&str]) -> Vec<&'a str> { + (0..p.matched_count()) + .filter_map(|i| p.matched_index(i)) + .map(|idx| labels[idx]) + .collect() + } + + #[test] + fn empty_query_returns_all() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + p.set_query(""); + assert_eq!(p.matched_count(), 3); + } + + #[test] + fn single_fuzzy_stage() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + p.set_query("ban"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["banana"]); + } + + #[test] + fn single_exact_stage() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "pineapple", "cherry"]; + push_items(&mut p, labels); + p.set_query("'apple"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"apple")); + assert!(result.contains(&"pineapple")); + assert!(!result.contains(&"cherry")); + } + + #[test] + fn two_stage_pipeline() { + let mut p = FilterPipeline::new(); + let labels = &["error_log", "warning_temp", "info_log", "debug_temp"]; + push_items(&mut p, labels); + p.set_query("'log | !temp"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"error_log")); + assert!(result.contains(&"info_log")); + assert!(!result.contains(&"warning_temp")); + assert!(!result.contains(&"debug_temp")); + } + + #[test] + fn three_stage_pipeline() { + let mut p = FilterPipeline::new(); + let labels = &[ + "error_log_123", + "warning_temp_456", + "info_log_789", + "debug_temp_012", + ]; + push_items(&mut p, labels); + p.set_query("'log | !temp | /[0-9]+/"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"error_log_123")); + assert!(result.contains(&"info_log_789")); + assert_eq!(result.len(), 2); + } + + #[test] + fn incremental_stage_1_preserved() { + let mut p = FilterPipeline::new(); + let labels = &["error_log", "warning_temp", "info_log", "debug_temp"]; + push_items(&mut p, labels); + // First query + p.set_query("'log | !error"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["info_log"]); + + // Edit stage 2 only: stage 1 cache should be preserved + p.set_query("'log | !info"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["error_log"]); + } + + #[test] + fn pop_stage_on_backspace() { + let mut p = FilterPipeline::new(); + let labels = &["error_log", "warning_temp", "info_log"]; + push_items(&mut p, labels); + p.set_query("'log | !error"); + assert_eq!(matched_labels(&p, labels), vec!["info_log"]); + + // Backspace over the pipe: now just "'log" + p.set_query("'log"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"error_log")); + assert!(result.contains(&"info_log")); + assert_eq!(result.len(), 2); + } + + #[test] + fn empty_segments_skipped() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana"]; + push_items(&mut p, labels); + p.set_query("apple | | banana"); + // Middle empty segment should be ignored + // This should be equivalent to "apple | banana" + // which is fuzzy "apple" then fuzzy "banana". + // "apple" matches apple, "banana" matches banana. + // Pipeline: first stage matches apple, second stage filters that for banana. + // Neither "apple" nor "banana" matches both, so 0 results. + assert_eq!(p.matched_count(), 0); + } + + #[test] + fn escaped_pipe() { + let mut p = FilterPipeline::new(); + let labels = &["foo|bar", "foobar", "baz"]; + push_items(&mut p, labels); + p.set_query("'foo\\|bar"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["foo|bar"]); + } + + #[test] + fn pipe_inside_regex_not_split() { + let mut p = FilterPipeline::new(); + let labels = &["foo", "bar", "baz"]; + push_items(&mut p, labels); + p.set_query("/foo|bar/"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"foo")); + assert!(result.contains(&"bar")); + assert!(!result.contains(&"baz")); + } + + #[test] + fn inverse_exact() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + p.set_query("!'banana"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"apple")); + assert!(result.contains(&"cherry")); + assert!(!result.contains(&"banana")); + } + + #[test] + fn inverse_regex() { + let mut p = FilterPipeline::new(); + let labels = &["item-001", "item-abc", "item-123"]; + push_items(&mut p, labels); + p.set_query("!/[0-9]+/"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["item-abc"]); + } + + #[test] + fn add_items_picked_up() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana"]; + push_items(&mut p, labels); + p.set_query("'cherry"); + assert_eq!(p.matched_count(), 0); + + // Add new item + p.push(2, "cherry"); + // Re-evaluate with same query + p.set_query("'cherry"); + assert_eq!(p.matched_count(), 1); + assert_eq!(p.matched_index(0), Some(2)); + } + + #[test] + fn split_pipeline_basic() { + let segs = split_pipeline("foo | bar"); + assert_eq!(segs, vec!["foo", "bar"]); + } + + #[test] + fn split_pipeline_escaped() { + let segs = split_pipeline("foo\\|bar"); + assert_eq!(segs, vec!["foo|bar"]); + } + + #[test] + fn split_pipeline_regex() { + let segs = split_pipeline("/foo|bar/ | baz"); + assert_eq!(segs, vec!["/foo|bar/", "baz"]); + } + + #[test] + fn split_pipeline_empty_segments() { + let segs = split_pipeline("foo | | bar"); + assert_eq!(segs, vec!["foo", "bar"]); + } + + #[test] + fn split_pipeline_inverse_regex() { + let segs = split_pipeline("!/foo|bar/ | baz"); + assert_eq!(segs, vec!["!/foo|bar/", "baz"]); + } + + // -- Pipeline edge case tests -- + + #[test] + fn fuzzy_as_second_stage() { + let mut p = FilterPipeline::new(); + let labels = &["error_log", "warning_temp", "info_log", "debug_log"]; + push_items(&mut p, labels); + // Exact first, then fuzzy second + p.set_query("'log | debug"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["debug_log"]); + } + + #[test] + fn three_stage_edit_stage_one() { + let mut p = FilterPipeline::new(); + let labels = &[ + "error_log_123", + "warning_temp_456", + "info_log_789", + "debug_temp_012", + ]; + push_items(&mut p, labels); + p.set_query("'log | !error | /[0-9]+/"); + assert_eq!(matched_labels(&p, labels), vec!["info_log_789"]); + + // Edit stage 1: now match "temp" instead of "log" + p.set_query("'temp | !error | /[0-9]+/"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"warning_temp_456")); + assert!(result.contains(&"debug_temp_012")); + assert!(!result.contains(&"error_log_123")); + } + + #[test] + fn invalid_regex_in_pipeline() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + // Invalid regex: unclosed bracket. Should match everything (graceful degradation). + p.set_query("/[invalid/"); + assert_eq!(p.matched_count(), 3); + } + + #[test] + fn same_query_twice_stable() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + p.set_query("ban"); + let first = matched_labels(&p, labels); + p.set_query("ban"); + let second = matched_labels(&p, labels); + assert_eq!(first, second); + } + + #[test] + fn query_shrink_to_single() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + p.set_query("'ban | !x"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["banana"]); + + // Shrink back to single stage + p.set_query("'ban"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["banana"]); + } + + #[test] + fn all_items_excluded() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana"]; + push_items(&mut p, labels); + p.set_query("xyz"); + assert_eq!(p.matched_count(), 0); + } + + #[test] + fn single_regex_stage() { + let mut p = FilterPipeline::new(); + let labels = &["item-001", "item-abc", "item-123"]; + push_items(&mut p, labels); + p.set_query("/[0-9]+/"); + let result = matched_labels(&p, labels); + assert_eq!(result, vec!["item-001", "item-123"]); + } + + #[test] + fn inverse_fuzzy_stage() { + let mut p = FilterPipeline::new(); + let labels = &["apple", "banana", "cherry"]; + push_items(&mut p, labels); + p.set_query("!ban"); + let result = matched_labels(&p, labels); + assert!(result.contains(&"apple")); + assert!(result.contains(&"cherry")); + assert!(!result.contains(&"banana")); + } +} diff --git a/crates/pikl-core/src/query/regex_filter.rs b/crates/pikl-core/src/query/regex_filter.rs new file mode 100644 index 0000000..eb59508 --- /dev/null +++ b/crates/pikl-core/src/query/regex_filter.rs @@ -0,0 +1,201 @@ +//! Regex filter using fancy-regex. Gracefully degrades on +//! invalid patterns (matches everything) so the user can +//! type patterns incrementally without errors. + +use fancy_regex::Regex; + +use crate::filter::Filter; + +/// Regex filter. Case-sensitive by default; use `(?i)` in +/// the pattern for case-insensitive matching. Invalid +/// patterns match everything (graceful degradation while +/// typing). Results in insertion order. +pub struct RegexFilter { + items: Vec<(usize, String)>, + pattern: Option, + results: Vec, +} + +impl Default for RegexFilter { + fn default() -> Self { + Self::new() + } +} + +impl RegexFilter { + pub fn new() -> Self { + Self { + items: Vec::new(), + pattern: None, + results: Vec::new(), + } + } +} + +impl Filter for RegexFilter { + fn push(&mut self, index: usize, label: &str) { + self.items.push((index, label.to_string())); + } + + fn set_query(&mut self, query: &str) { + if query.is_empty() { + self.pattern = None; + self.results = self.items.iter().map(|(idx, _)| *idx).collect(); + return; + } + + // Compile pattern. If invalid, match everything. + self.pattern = Regex::new(query).ok(); + + match &self.pattern { + Some(re) => { + self.results = self + .items + .iter() + .filter(|(_, label)| re.is_match(label).unwrap_or(false)) + .map(|(idx, _)| *idx) + .collect(); + } + None => { + // Invalid pattern: match everything + self.results = self.items.iter().map(|(idx, _)| *idx).collect(); + } + } + } + + fn matched_count(&self) -> usize { + self.results.len() + } + + fn matched_index(&self, match_position: usize) -> Option { + self.results.get(match_position).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_items(f: &mut RegexFilter, labels: &[&str]) { + for (i, label) in labels.iter().enumerate() { + f.push(i, label); + } + } + + #[test] + fn empty_query_returns_all() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["apple", "banana", "cherry"]); + f.set_query(""); + assert_eq!(f.matched_count(), 3); + } + + #[test] + fn valid_pattern() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["item-001", "item-abc", "item-123"]); + f.set_query("[0-9]+"); + assert_eq!(f.matched_count(), 2); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(2)); + } + + #[test] + fn invalid_pattern_returns_all() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["apple", "banana"]); + f.set_query("[invalid"); + assert_eq!(f.matched_count(), 2); + } + + #[test] + fn case_sensitive_by_default() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["Apple", "apple", "APPLE"]); + f.set_query("^apple$"); + assert_eq!(f.matched_count(), 1); + assert_eq!(f.matched_index(0), Some(1)); + } + + #[test] + fn case_insensitive_flag() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["Apple", "apple", "APPLE"]); + f.set_query("(?i)^apple$"); + assert_eq!(f.matched_count(), 3); + } + + #[test] + fn results_in_insertion_order() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["log-3", "log-1", "log-2"]); + f.set_query("log-[0-9]"); + assert_eq!(f.matched_count(), 3); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(1)); + assert_eq!(f.matched_index(2), Some(2)); + } + + #[test] + fn no_match() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["apple", "banana"]); + f.set_query("^xyz$"); + assert_eq!(f.matched_count(), 0); + } + + #[test] + fn empty_items() { + let mut f = RegexFilter::new(); + f.set_query("test"); + assert_eq!(f.matched_count(), 0); + } + + #[test] + fn alternation_in_pattern() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["foo", "bar", "baz"]); + f.set_query("foo|bar"); + assert_eq!(f.matched_count(), 2); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(1)); + } + + // -- State transition and fancy-regex tests -- + + #[test] + fn requery_valid_invalid_valid() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["apple", "banana", "cherry"]); + // Valid pattern + f.set_query("^apple$"); + assert_eq!(f.matched_count(), 1); + // Invalid pattern: matches everything + f.set_query("[invalid"); + assert_eq!(f.matched_count(), 3); + // Valid again + f.set_query("^cherry$"); + assert_eq!(f.matched_count(), 1); + assert_eq!(f.matched_index(0), Some(2)); + } + + #[test] + fn anchored_match() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["apple", "pineapple"]); + f.set_query("^apple$"); + assert_eq!(f.matched_count(), 1); + assert_eq!(f.matched_index(0), Some(0)); + } + + #[test] + fn lookbehind() { + let mut f = RegexFilter::new(); + push_items(&mut f, &["log_error", "log_warning", "not_a_log"]); + // fancy-regex specific: positive lookbehind + f.set_query("(?<=log_)\\w+"); + assert_eq!(f.matched_count(), 2); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(1)); + } +} diff --git a/crates/pikl-core/src/query/strategy.rs b/crates/pikl-core/src/query/strategy.rs new file mode 100644 index 0000000..9cca51c --- /dev/null +++ b/crates/pikl-core/src/query/strategy.rs @@ -0,0 +1,231 @@ +//! Filter segment prefix parsing. Determines which filter +//! strategy to use based on the query prefix. + +/// The type of filter to apply for a query segment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterKind { + Fuzzy, + Exact, + Regex, +} + +/// A parsed filter segment with its kind, inversion flag, +/// and the actual query text (prefix stripped). +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedSegment<'a> { + pub kind: FilterKind, + pub inverse: bool, + pub query: &'a str, +} + +/// Parse a single filter segment's prefix to determine +/// the filter strategy and extract the query text. +/// +/// Prefix rules (order matters): +/// 1. `!/pattern/` -> Regex, inverse, inner pattern +/// 2. `/pattern/` -> Regex, inner pattern +/// 3. `!'query` -> Exact, inverse, after `!'` +/// 4. `!query` -> Fuzzy, inverse, after `!` +/// 5. `'query` -> Exact, after `'` +/// 6. Everything else -> Fuzzy +/// +/// A `/` with no closing slash is treated as fuzzy (user +/// is still typing the regex delimiter). +pub fn parse_segment(segment: &str) -> ParsedSegment<'_> { + // Check for inverse regex: !/pattern/ + if let Some(rest) = segment.strip_prefix("!/") { + if let Some(inner) = rest.strip_suffix('/') { + return ParsedSegment { + kind: FilterKind::Regex, + inverse: true, + query: inner, + }; + } + // No closing slash: treat the whole thing as fuzzy inverse + return ParsedSegment { + kind: FilterKind::Fuzzy, + inverse: true, + query: &segment[1..], + }; + } + + // Check for regex: /pattern/ + if let Some(rest) = segment.strip_prefix('/') { + if let Some(inner) = rest.strip_suffix('/') { + return ParsedSegment { + kind: FilterKind::Regex, + inverse: false, + query: inner, + }; + } + // No closing slash: treat as fuzzy (still typing) + return ParsedSegment { + kind: FilterKind::Fuzzy, + inverse: false, + query: segment, + }; + } + + // Check for inverse exact: !'query + if let Some(rest) = segment.strip_prefix("!'") { + return ParsedSegment { + kind: FilterKind::Exact, + inverse: true, + query: rest, + }; + } + + // Check for inverse fuzzy: !query + if let Some(rest) = segment.strip_prefix('!') { + return ParsedSegment { + kind: FilterKind::Fuzzy, + inverse: true, + query: rest, + }; + } + + // Check for exact: 'query + if let Some(rest) = segment.strip_prefix('\'') { + return ParsedSegment { + kind: FilterKind::Exact, + inverse: false, + query: rest, + }; + } + + // Default: fuzzy + ParsedSegment { + kind: FilterKind::Fuzzy, + inverse: false, + query: segment, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_text_is_fuzzy() { + let p = parse_segment("hello"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(!p.inverse); + assert_eq!(p.query, "hello"); + } + + #[test] + fn empty_is_fuzzy() { + let p = parse_segment(""); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(!p.inverse); + assert_eq!(p.query, ""); + } + + #[test] + fn exact_prefix() { + let p = parse_segment("'exact match"); + assert_eq!(p.kind, FilterKind::Exact); + assert!(!p.inverse); + assert_eq!(p.query, "exact match"); + } + + #[test] + fn regex_delimiters() { + let p = parse_segment("/[0-9]+/"); + assert_eq!(p.kind, FilterKind::Regex); + assert!(!p.inverse); + assert_eq!(p.query, "[0-9]+"); + } + + #[test] + fn inverse_fuzzy() { + let p = parse_segment("!temp"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(p.inverse); + assert_eq!(p.query, "temp"); + } + + #[test] + fn inverse_exact() { + let p = parse_segment("!'temp"); + assert_eq!(p.kind, FilterKind::Exact); + assert!(p.inverse); + assert_eq!(p.query, "temp"); + } + + #[test] + fn inverse_regex() { + let p = parse_segment("!/[0-9]+/"); + assert_eq!(p.kind, FilterKind::Regex); + assert!(p.inverse); + assert_eq!(p.query, "[0-9]+"); + } + + #[test] + fn unclosed_regex_is_fuzzy() { + let p = parse_segment("/still typing"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(!p.inverse); + assert_eq!(p.query, "/still typing"); + } + + #[test] + fn unclosed_inverse_regex_is_fuzzy_inverse() { + let p = parse_segment("!/still typing"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(p.inverse); + assert_eq!(p.query, "/still typing"); + } + + #[test] + fn just_slash_is_fuzzy() { + let p = parse_segment("/"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(!p.inverse); + assert_eq!(p.query, "/"); + } + + #[test] + fn empty_regex_pattern() { + let p = parse_segment("//"); + assert_eq!(p.kind, FilterKind::Regex); + assert!(!p.inverse); + assert_eq!(p.query, ""); + } + + #[test] + fn just_exclamation() { + let p = parse_segment("!"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(p.inverse); + assert_eq!(p.query, ""); + } + + #[test] + fn just_quote() { + let p = parse_segment("'"); + assert_eq!(p.kind, FilterKind::Exact); + assert!(!p.inverse); + assert_eq!(p.query, ""); + } + + // -- Double-prefix edge cases -- + + #[test] + fn double_exclamation() { + // "!!query" -> first ! is inverse, rest is "!query" which is fuzzy inverse + let p = parse_segment("!!query"); + assert_eq!(p.kind, FilterKind::Fuzzy); + assert!(p.inverse); + assert_eq!(p.query, "!query"); + } + + #[test] + fn inverse_exact_regex_like() { + // "!'[0-9]" -> exact inverse, query is "[0-9]" (not regex) + let p = parse_segment("!'[0-9]"); + assert_eq!(p.kind, FilterKind::Exact); + assert!(p.inverse); + assert_eq!(p.query, "[0-9]"); + } +} diff --git a/crates/pikl-core/src/runtime/json_menu.rs b/crates/pikl-core/src/runtime/json_menu.rs index ee81d91..4d519a2 100644 --- a/crates/pikl-core/src/runtime/json_menu.rs +++ b/crates/pikl-core/src/runtime/json_menu.rs @@ -1,25 +1,27 @@ //! JSON-backed menu implementation. Wraps `Vec` with -//! fuzzy filtering via nucleo. This is the default backend +//! pipeline filtering. This is the default backend //! for `ls | pikl` style usage. -use crate::filter::{Filter, FuzzyFilter}; +use crate::filter::Filter; use crate::item::Item; use crate::model::traits::Menu; +use crate::pipeline::FilterPipeline; /// A menu backed by a flat list of JSON items. Handles -/// filtering internally using the [`Filter`] trait (fuzzy -/// by default). The `label_key` controls which JSON key -/// is used for display labels on object items. +/// filtering internally using the [`FilterPipeline`] which +/// supports fuzzy, exact, regex, and `|`-chained queries. +/// The `label_key` controls which JSON key is used for +/// display labels on object items. pub struct JsonMenu { items: Vec, label_key: String, - filter: Box, + filter: FilterPipeline, } impl JsonMenu { /// Create a new JSON menu with the given items and label key. pub fn new(items: Vec, label_key: String) -> Self { - let mut filter = Box::new(FuzzyFilter::new()); + let mut filter = FilterPipeline::new(); for (i, item) in items.iter().enumerate() { filter.push(i, item.label()); }