use crate::fuzzy_matcher::MatchIndices;
use crate::{MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
use std::fmt::{Display, Error, Formatter};
pub struct SplitMatchEngine {
before_engine: Box<dyn MatchEngine>,
after_engine: Box<dyn MatchEngine>,
delimiter: char,
}
impl SplitMatchEngine {
pub fn new(before_engine: Box<dyn MatchEngine>, after_engine: Box<dyn MatchEngine>, delimiter: char) -> Self {
Self {
before_engine,
after_engine,
delimiter,
}
}
}
impl MatchEngine for SplitMatchEngine {
fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
let text = item.text();
let delimiter_char_idx = text.chars().position(|c| c == self.delimiter)?;
let delimiter_byte_pos = text.char_indices().nth(delimiter_char_idx).map(|(i, _)| i)?;
let text_before = &text[..delimiter_byte_pos];
let text_after = &text[delimiter_byte_pos + self.delimiter.len_utf8()..];
let before_item: &dyn SkimItem = &StringItem(text_before.to_string());
let after_item: &dyn SkimItem = &StringItem(text_after.to_string());
let before_result = self.before_engine.match_item(before_item)?;
let after_result = self.after_engine.match_item(after_item)?;
let rank = before_result.rank;
let mut combined_indices: MatchIndices = match before_result.matched_range {
MatchRange::Chars(indices) => indices,
MatchRange::CharRange(start, end) => (start..end).collect(),
MatchRange::ByteRange(start, end) => {
text_before
.char_indices()
.enumerate()
.filter(|(_, (byte_idx, _))| *byte_idx >= start && *byte_idx < end)
.map(|(char_idx, _)| char_idx)
.collect()
}
};
let offset = delimiter_char_idx + 1;
let after_indices: MatchIndices = match after_result.matched_range {
MatchRange::Chars(indices) => indices.into_iter().map(|i| i + offset).collect(),
MatchRange::CharRange(start, end) => (start..end).map(|i| i + offset).collect(),
MatchRange::ByteRange(start, end) => {
text_after
.char_indices()
.enumerate()
.filter(|(_, (byte_idx, _))| *byte_idx >= start && *byte_idx < end)
.map(|(char_idx, _)| char_idx + offset)
.collect()
}
};
combined_indices.extend(after_indices);
combined_indices.sort_unstable();
combined_indices.dedup();
Some(MatchResult {
rank,
matched_range: MatchRange::Chars(combined_indices),
})
}
}
impl Display for SplitMatchEngine {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
write!(
f,
"(Split[{}]: {} | {})",
self.delimiter, self.before_engine, self.after_engine
)
}
}
struct StringItem(String);
impl SkimItem for StringItem {
fn text(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.0)
}
}
pub struct SplitMatchEngineFactory {
inner: Box<dyn MatchEngineFactory>,
delimiter: char,
}
impl SplitMatchEngineFactory {
pub fn new(inner: impl MatchEngineFactory + 'static, delimiter: char) -> Self {
Self {
inner: Box::new(inner),
delimiter,
}
}
}
impl MatchEngineFactory for SplitMatchEngineFactory {
fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
if let Some(delimiter_pos) = query.find(self.delimiter) {
let query_before = &query[..delimiter_pos];
let query_after = &query[delimiter_pos + self.delimiter.len_utf8()..];
let before_engine = self.inner.create_engine_with_case(query_before, case);
let after_engine = self.inner.create_engine_with_case(query_after, case);
Box::new(SplitMatchEngine::new(before_engine, after_engine, self.delimiter))
} else {
self.inner.create_engine_with_case(query, case)
}
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
use crate::prelude::ExactOrFuzzyEngineFactory;
struct StubEngine(MatchRange);
impl MatchEngine for StubEngine {
fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
Some(MatchResult {
rank: crate::Rank::default(),
matched_range: self.0.clone(),
})
}
}
impl Display for StubEngine {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
write!(f, "Stub")
}
}
fn exact(query: &str) -> Box<dyn MatchEngine> {
Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
}
#[test]
fn no_delimiter_in_item_returns_none() {
let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
assert!(engine.match_item(&"no delimiter here".to_string()).is_none());
}
#[test]
fn matches_both_sides_of_delimiter() {
let engine = SplitMatchEngine::new(exact("ab"), exact("cd"), ':');
let result = engine.match_item(&"ab:cd".to_string());
assert!(result.is_some());
}
#[test]
fn char_range_results_are_offset_and_combined() {
let engine = SplitMatchEngine::new(
Box::new(StubEngine(MatchRange::CharRange(0, 2))),
Box::new(StubEngine(MatchRange::CharRange(0, 2))),
':',
);
let result = engine.match_item(&"ab:cd".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
}
#[test]
fn after_engine_failure_returns_none() {
let engine = SplitMatchEngine::new(exact("ab"), exact("zzz"), ':');
assert!(engine.match_item(&"ab:cd".to_string()).is_none());
}
#[test]
fn byte_range_results_drop_chars_outside_range() {
let engine = SplitMatchEngine::new(
Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
':',
);
let result = engine.match_item(&"abc:def".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::Chars(vec![1, 5]));
}
#[test]
fn byte_range_results_include_chars_within_range() {
let engine = SplitMatchEngine::new(
Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
':',
);
let result = engine.match_item(&"ab:cd".to_string()).unwrap();
assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
}
#[test]
fn display_shows_both_engines_and_delimiter() {
let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
let s = format!("{engine}");
assert!(s.starts_with("(Split[:]:"));
}
#[test]
fn factory_without_delimiter_passes_through() {
let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
let engine = factory.create_engine_with_case("foo", crate::CaseMatching::Smart);
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn factory_with_delimiter_builds_split_engine() {
let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
let engine = factory.create_engine_with_case("ab:cd", crate::CaseMatching::Smart);
assert!(engine.match_item(&"ab:cd".to_string()).is_some());
assert!(engine.match_item(&"ab:xy".to_string()).is_none());
}
}