feat(core): Expand filtering into pipeline supporting multiple text search modes.
Modes include: exact match, smart-case, and regular expressions.
This commit is contained in:
33
Cargo.lock
generated
33
Cargo.lock
generated
@@ -100,7 +100,16 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -109,6 +118,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -422,10 +437,21 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set 0.5.3",
|
||||||
"regex",
|
"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]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1052,6 +1078,7 @@ dependencies = [
|
|||||||
name = "pikl-core"
|
name = "pikl-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fancy-regex 0.14.0",
|
||||||
"nucleo-matcher",
|
"nucleo-matcher",
|
||||||
"pikl-test-macros",
|
"pikl-test-macros",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1510,7 +1537,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"fancy-regex",
|
"fancy-regex 0.11.0",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
"finl_unicode",
|
"finl_unicode",
|
||||||
"fixedbitset",
|
"fixedbitset",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ serde_json = "1.0.149"
|
|||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] }
|
tokio = { version = "1.50.0", features = ["sync", "io-util", "rt"] }
|
||||||
nucleo-matcher = "0.3.1"
|
nucleo-matcher = "0.3.1"
|
||||||
|
fancy-regex = "0.14"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] }
|
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread"] }
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ pub mod error;
|
|||||||
pub use model::event;
|
pub use model::event;
|
||||||
pub use model::item;
|
pub use model::item;
|
||||||
pub use model::traits;
|
pub use model::traits;
|
||||||
|
pub use query::exact;
|
||||||
pub use query::filter;
|
pub use query::filter;
|
||||||
pub use query::navigation;
|
pub use query::navigation;
|
||||||
|
pub use query::pipeline;
|
||||||
|
pub use query::regex_filter;
|
||||||
|
pub use query::strategy;
|
||||||
pub use runtime::hook;
|
pub use runtime::hook;
|
||||||
pub use runtime::input;
|
pub use runtime::input;
|
||||||
pub use runtime::json_menu;
|
pub use runtime::json_menu;
|
||||||
|
|||||||
125
crates/pikl-core/src/query/exact.rs
Normal file
125
crates/pikl-core/src/query/exact.rs
Normal file
@@ -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<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
|
pub mod exact;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
|
pub mod pipeline;
|
||||||
|
pub mod regex_filter;
|
||||||
|
pub mod strategy;
|
||||||
|
|||||||
@@ -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).
|
/// Move cursor down by `n` pages (viewport height each).
|
||||||
/// Clamps to the last item.
|
/// Clamps to the last item.
|
||||||
pub fn page_down(&mut self, n: usize) {
|
pub fn page_down(&mut self, n: usize) {
|
||||||
@@ -353,4 +381,106 @@ mod tests {
|
|||||||
v.set_height(3);
|
v.set_height(3);
|
||||||
assert!(v.cursor() < v.scroll_offset() + 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
617
crates/pikl-core/src/query/pipeline.rs
Normal file
617
crates/pikl-core/src/query/pipeline.rs
Normal file
@@ -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<PipelineStage>,
|
||||||
|
/// 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<FuzzyFilter>,
|
||||||
|
/// Items passing this stage (indices into master list).
|
||||||
|
cached_indices: Vec<usize>,
|
||||||
|
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<String> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let chars: Vec<char> = 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<usize> = 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<usize> {
|
||||||
|
let Some(fuzzy) = stage.fuzzy.as_mut() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
fuzzy.set_query(&stage.query_text);
|
||||||
|
let fuzzy_results: Vec<usize> = (0..fuzzy.matched_count())
|
||||||
|
.filter_map(|i| fuzzy.matched_index(i))
|
||||||
|
.collect();
|
||||||
|
if stage.inverse {
|
||||||
|
let fuzzy_set: std::collections::HashSet<usize> = 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<usize> =
|
||||||
|
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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
201
crates/pikl-core/src/query/regex_filter.rs
Normal file
201
crates/pikl-core/src/query/regex_filter.rs
Normal file
@@ -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<Regex>,
|
||||||
|
results: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
231
crates/pikl-core/src/query/strategy.rs
Normal file
231
crates/pikl-core/src/query/strategy.rs
Normal file
@@ -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]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
//! JSON-backed menu implementation. Wraps `Vec<Item>` with
|
//! JSON-backed menu implementation. Wraps `Vec<Item>` with
|
||||||
//! fuzzy filtering via nucleo. This is the default backend
|
//! pipeline filtering. This is the default backend
|
||||||
//! for `ls | pikl` style usage.
|
//! for `ls | pikl` style usage.
|
||||||
|
|
||||||
use crate::filter::{Filter, FuzzyFilter};
|
use crate::filter::Filter;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
use crate::model::traits::Menu;
|
use crate::model::traits::Menu;
|
||||||
|
use crate::pipeline::FilterPipeline;
|
||||||
|
|
||||||
/// A menu backed by a flat list of JSON items. Handles
|
/// A menu backed by a flat list of JSON items. Handles
|
||||||
/// filtering internally using the [`Filter`] trait (fuzzy
|
/// filtering internally using the [`FilterPipeline`] which
|
||||||
/// by default). The `label_key` controls which JSON key
|
/// supports fuzzy, exact, regex, and `|`-chained queries.
|
||||||
/// is used for display labels on object items.
|
/// The `label_key` controls which JSON key is used for
|
||||||
|
/// display labels on object items.
|
||||||
pub struct JsonMenu {
|
pub struct JsonMenu {
|
||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
label_key: String,
|
label_key: String,
|
||||||
filter: Box<dyn Filter>,
|
filter: FilterPipeline,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JsonMenu {
|
impl JsonMenu {
|
||||||
/// Create a new JSON menu with the given items and label key.
|
/// Create a new JSON menu with the given items and label key.
|
||||||
pub fn new(items: Vec<Item>, label_key: String) -> Self {
|
pub fn new(items: Vec<Item>, label_key: String) -> Self {
|
||||||
let mut filter = Box::new(FuzzyFilter::new());
|
let mut filter = FilterPipeline::new();
|
||||||
for (i, item) in items.iter().enumerate() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
filter.push(i, item.label());
|
filter.push(i, item.label());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user