use aho_corasick::AhoCorasick;
use ratatui::text::{Line, Span};
use regex::Regex;
pub type StyleId = u8;
pub const SEARCH_STYLE_ID: StyleId = u8::MAX;
pub const CURRENT_SEARCH_STYLE_ID: StyleId = u8::MAX - 1;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FilterDecision {
Include,
Exclude,
Neutral,
}
pub trait Filter: Send + Sync {
fn evaluate(&self, line: &[u8], collector: &mut MatchCollector) -> FilterDecision;
}
pub fn render_line<'a>(col: &MatchCollector, styles: &[ratatui::style::Style]) -> Line<'a> {
if col.spans.is_empty() {
let text = std::str::from_utf8(col.line).unwrap_or("").to_string();
return Line::from(text);
}
let line_len = col.line.len();
let mut valid: Vec<(usize, usize, u32, StyleId)> = col
.spans
.iter()
.filter(|s| s.start < s.end && s.end <= line_len)
.map(|s| (s.start, s.end, s.priority, s.style))
.collect();
if valid.is_empty() {
let text = std::str::from_utf8(col.line).unwrap_or("").to_string();
return Line::from(text);
}
valid.sort_unstable_by_key(|&(start, _, _, _)| start);
let mut boundaries: Vec<usize> = Vec::with_capacity(valid.len() * 2 + 2);
boundaries.push(0);
boundaries.push(line_len);
for &(start, end, _, _) in &valid {
boundaries.push(start);
boundaries.push(end);
}
boundaries.sort_unstable();
boundaries.dedup();
let mut active: Vec<(u32, usize, StyleId)> = Vec::new(); let mut span_idx = 0usize;
let mut events: Vec<(usize, usize, ratatui::style::Style)> =
Vec::with_capacity(boundaries.len());
for w in boundaries.windows(2) {
let (seg_s, seg_e) = (w[0], w[1]);
if seg_s >= seg_e {
continue;
}
while span_idx < valid.len() && valid[span_idx].0 <= seg_s {
let (_, end, priority, style) = valid[span_idx];
active.push((priority, end, style));
span_idx += 1;
}
active.retain(|&(_, end, _)| end > seg_s);
if active.is_empty() {
continue;
}
let composed = compose_segment_style(&active, styles);
if composed.fg.is_none() && composed.bg.is_none() {
continue;
}
if let Some(last) = events.last_mut()
&& last.1 == seg_s
&& last.2 == composed
{
last.1 = seg_e;
} else {
events.push((seg_s, seg_e, composed));
}
}
let mut spans: Vec<Span<'a>> = Vec::new();
let mut pos = 0usize;
for (start, end, style) in events {
if start > pos {
let text = std::str::from_utf8(&col.line[pos..start])
.unwrap_or("")
.to_string();
if !text.is_empty() {
spans.push(Span::raw(text));
}
}
if end > start {
let text = std::str::from_utf8(&col.line[start..end])
.unwrap_or("")
.to_string();
if !text.is_empty() {
spans.push(Span::styled(text, style));
}
}
pos = end.max(pos);
}
if pos < line_len {
let text = std::str::from_utf8(&col.line[pos..])
.unwrap_or("")
.to_string();
if !text.is_empty() {
spans.push(Span::raw(text));
}
}
Line::from(spans)
}
fn compose_segment_style(
active: &[(u32, usize, StyleId)],
styles: &[ratatui::style::Style],
) -> ratatui::style::Style {
let mut best_fg: Option<(u32, ratatui::style::Color)> = None;
let mut best_bg: Option<(u32, ratatui::style::Color)> = None;
for &(priority, _, style_id) in active {
let style = styles.get(style_id as usize).copied().unwrap_or_default();
if let Some(fg) = style.fg
&& best_fg.is_none_or(|(p, _)| priority > p)
{
best_fg = Some((priority, fg));
}
if let Some(bg) = style.bg
&& best_bg.is_none_or(|(p, _)| priority > p)
{
best_bg = Some((priority, bg));
}
}
let mut composed = ratatui::style::Style::default();
if let Some((_, fg)) = best_fg {
composed = composed.fg(fg);
}
if let Some((_, bg)) = best_bg {
composed = composed.bg(bg);
}
composed
}
#[derive(Debug, Clone)]
pub struct MatchSpan {
pub start: usize,
pub end: usize,
pub style: StyleId,
pub priority: u32, }
pub struct MatchCollector<'a> {
pub line: &'a [u8],
pub spans: Vec<MatchSpan>,
current_priority: u32,
}
impl<'a> MatchCollector<'a> {
pub fn new(line: &'a [u8]) -> Self {
Self {
line,
spans: Vec::with_capacity(8),
current_priority: 0,
}
}
pub fn with_priority(&mut self, priority: u32) -> &mut Self {
self.current_priority = priority;
self
}
pub fn push(&mut self, start: usize, end: usize, style: StyleId) {
self.spans.push(MatchSpan {
start,
end,
style,
priority: self.current_priority,
});
}
}
fn is_regex_pattern(pattern: &str) -> bool {
pattern.chars().any(|c| {
matches!(
c,
'.' | '+' | '*' | '?' | '[' | ']' | '(' | ')' | '{' | '}' | '\\' | '^' | '$' | '|'
)
})
}
pub struct SubstringFilter {
ac: AhoCorasick,
decision: FilterDecision,
style_id: StyleId,
match_only: bool,
}
impl SubstringFilter {
pub fn new(
pattern: &str,
decision: FilterDecision,
match_only: bool,
style_id: StyleId,
) -> Option<Self> {
let ac = AhoCorasick::builder()
.ascii_case_insensitive(false)
.build([pattern])
.inspect_err(|e| {
tracing::error!("Failed to build Aho-Corasick automaton: {}", e);
})
.ok()?;
Some(SubstringFilter {
ac,
decision,
style_id,
match_only,
})
}
}
impl Filter for SubstringFilter {
fn evaluate(&self, line: &[u8], collector: &mut MatchCollector) -> FilterDecision {
let mut found = false;
for mat in self.ac.find_iter(line) {
found = true;
if matches!(self.decision, FilterDecision::Include) && self.match_only {
collector.push(mat.start(), mat.end(), self.style_id);
}
}
if found {
if matches!(self.decision, FilterDecision::Include) && !self.match_only {
collector.push(0, line.len(), self.style_id);
}
self.decision
} else {
FilterDecision::Neutral
}
}
}
pub struct RegexFilter {
re: Regex,
decision: FilterDecision,
style_id: StyleId,
match_only: bool,
}
impl RegexFilter {
pub fn new(
pattern: &str,
decision: FilterDecision,
match_only: bool,
style_id: StyleId,
) -> Option<Self> {
Regex::new(pattern).ok().map(|re| RegexFilter {
re,
decision,
style_id,
match_only,
})
}
}
impl Filter for RegexFilter {
fn evaluate(&self, line: &[u8], collector: &mut MatchCollector) -> FilterDecision {
let text = match std::str::from_utf8(line) {
Ok(s) => s,
Err(_) => return FilterDecision::Neutral,
};
let mut found = false;
for mat in self.re.find_iter(text) {
found = true;
if matches!(self.decision, FilterDecision::Include) && self.match_only {
collector.push(mat.start(), mat.end(), self.style_id);
}
}
if found {
if matches!(self.decision, FilterDecision::Include) && !self.match_only {
collector.push(0, line.len(), self.style_id);
}
self.decision
} else {
FilterDecision::Neutral
}
}
}
pub fn build_filter(
pattern: &str,
decision: FilterDecision,
match_only: bool,
style_id: StyleId,
) -> Option<Box<dyn Filter>> {
if is_regex_pattern(pattern) {
RegexFilter::new(pattern, decision, match_only, style_id)
.map(|f| Box::new(f) as Box<dyn Filter>)
} else {
SubstringFilter::new(pattern, decision, match_only, style_id)
.map(|f| Box::new(f) as Box<dyn Filter>)
}
}
pub struct FilterManager {
filters: Vec<Box<dyn Filter>>,
has_include_filters: bool,
}
impl FilterManager {
pub fn new(filters: Vec<Box<dyn Filter>>, has_include_filters: bool) -> Self {
FilterManager {
filters,
has_include_filters,
}
}
pub fn empty() -> Self {
FilterManager {
filters: Vec::new(),
has_include_filters: false,
}
}
pub fn has_include(&self) -> bool {
self.has_include_filters
}
pub fn evaluate_text(&self, line: &[u8]) -> FilterDecision {
let mut dummy = MatchCollector::new(line);
for filter in &self.filters {
match filter.evaluate(line, &mut dummy) {
d @ (FilterDecision::Include | FilterDecision::Exclude) => return d,
FilterDecision::Neutral => {}
}
}
FilterDecision::Neutral
}
pub fn is_visible(&self, line: &[u8]) -> bool {
let mut dummy = MatchCollector::new(line);
for filter in &self.filters {
match filter.evaluate(line, &mut dummy) {
FilterDecision::Include => return true,
FilterDecision::Exclude => return false,
FilterDecision::Neutral => {}
}
}
!self.has_include_filters
}
pub fn evaluate_line<'a>(&self, line: &'a [u8]) -> MatchCollector<'a> {
let mut collector = MatchCollector::new(line);
for filter in &self.filters {
filter.evaluate(line, &mut collector);
}
collector
}
pub fn evaluate_into(&self, collector: &mut MatchCollector<'_>) {
let line = collector.line;
for filter in &self.filters {
filter.evaluate(line, collector);
}
}
pub fn filter_count(&self) -> usize {
self.filters.len()
}
pub fn count_line_matches(&self, line: &[u8], counts: &[std::sync::atomic::AtomicUsize]) {
for (i, filter) in self.filters.iter().enumerate() {
let mut dummy = MatchCollector::new(line);
if filter.evaluate(line, &mut dummy) != FilterDecision::Neutral
&& let Some(c) = counts.get(i)
{
c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
pub fn compute_visible(&self, reader: &crate::file_reader::FileReader) -> Vec<usize> {
use rayon::prelude::*;
let count = reader.line_count();
let has_include = self.has_include_filters;
let filters = &self.filters;
let visible: Vec<usize> = (0..count)
.into_par_iter()
.filter(|&idx| {
let line = reader.get_line(idx);
let mut dummy = MatchCollector::new(line);
for filter in filters.iter() {
match filter.evaluate(line, &mut dummy) {
FilterDecision::Include => return true,
FilterDecision::Exclude => return false,
FilterDecision::Neutral => {}
}
}
!has_include
})
.collect();
visible
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::file_reader::FileReader;
use std::io::Write;
use tempfile::NamedTempFile;
fn make_reader(lines: &[&str]) -> (NamedTempFile, FileReader) {
let mut f = NamedTempFile::new().unwrap();
for line in lines {
writeln!(f, "{}", line).unwrap();
}
let path = f.path().to_str().unwrap().to_string();
let reader = FileReader::new(&path).unwrap();
(f, reader)
}
#[test]
fn test_substring_filter_include() {
let line = b"ERROR: connection refused";
let f = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let mut col = MatchCollector::new(line);
assert_eq!(f.evaluate(line, &mut col), FilterDecision::Include);
let no_match = b"INFO: all good";
let mut col2 = MatchCollector::new(no_match);
assert_eq!(f.evaluate(no_match, &mut col2), FilterDecision::Neutral);
}
#[test]
fn test_substring_filter_exclude() {
let line = b"DEBUG: verbose output";
let f = SubstringFilter::new("DEBUG", FilterDecision::Exclude, false, 0).unwrap();
let mut col = MatchCollector::new(line);
assert_eq!(f.evaluate(line, &mut col), FilterDecision::Exclude);
let no_match = b"INFO: important";
let mut col2 = MatchCollector::new(no_match);
assert_eq!(f.evaluate(no_match, &mut col2), FilterDecision::Neutral);
}
#[test]
fn test_substring_filter_match_only_spans() {
let line = b"ERROR: something went wrong";
let f = SubstringFilter::new("ERROR", FilterDecision::Include, true, 1).unwrap();
let mut col = MatchCollector::new(line);
f.evaluate(line, &mut col);
assert_eq!(col.spans.len(), 1);
assert_eq!(col.spans[0].start, 0);
assert_eq!(col.spans[0].end, 5);
assert_eq!(col.spans[0].style, 1);
}
#[test]
fn test_regex_filter_include() {
let line = b"GET /api/users 200 OK";
let f = RegexFilter::new(r"\d{3}", FilterDecision::Include, true, 0).unwrap();
let mut col = MatchCollector::new(line);
assert_eq!(f.evaluate(line, &mut col), FilterDecision::Include);
assert_eq!(col.spans.len(), 1);
assert_eq!(&line[col.spans[0].start..col.spans[0].end], b"200");
}
#[test]
fn test_regex_filter_invalid_pattern() {
assert!(RegexFilter::new("[invalid", FilterDecision::Include, false, 0).is_none());
}
#[test]
fn test_build_filter_selects_substring_for_literal() {
let f = build_filter("error", FilterDecision::Include, false, 0);
assert!(f.is_some());
}
#[test]
fn test_build_filter_selects_regex_for_pattern() {
let f = build_filter(r"error\d+", FilterDecision::Include, false, 0);
assert!(f.is_some());
}
#[test]
fn test_filter_manager_no_filters_all_visible() {
let fm = FilterManager::empty();
assert!(fm.is_visible(b"anything"));
assert!(fm.is_visible(b""));
}
#[test]
fn test_filter_manager_include_filter() {
let f = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(f)], true);
assert!(fm.is_visible(b"ERROR: bad things"));
assert!(!fm.is_visible(b"INFO: all good"));
}
#[test]
fn test_filter_manager_exclude_filter() {
let f = SubstringFilter::new("DEBUG", FilterDecision::Exclude, false, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(f)], false);
assert!(fm.is_visible(b"INFO: something"));
assert!(!fm.is_visible(b"DEBUG: verbose"));
}
#[test]
fn test_filter_manager_include_then_exclude() {
let exc = SubstringFilter::new("minor", FilterDecision::Exclude, false, 1).unwrap();
let inc = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(exc), Box::new(inc)], true);
assert!(fm.is_visible(b"ERROR: critical failure")); assert!(!fm.is_visible(b"ERROR: minor issue")); assert!(!fm.is_visible(b"INFO: unrelated")); }
#[test]
fn test_filter_manager_compute_visible() {
let (_f, reader) = make_reader(&[
"ERROR: bad",
"INFO: good",
"ERROR: also bad",
"DEBUG: verbose",
]);
let inc = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(inc)], true);
let visible = fm.compute_visible(&reader);
assert_eq!(visible, vec![0, 2]);
}
#[test]
fn test_filter_manager_compute_visible_exclude() {
let (_f, reader) = make_reader(&["ERROR: bad", "DEBUG: verbose", "INFO: good"]);
let exc = SubstringFilter::new("DEBUG", FilterDecision::Exclude, false, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(exc)], false);
let visible = fm.compute_visible(&reader);
assert_eq!(visible, vec![0, 2]);
}
#[test]
fn test_render_line_no_spans() {
let line = b"plain text";
let col = MatchCollector::new(line);
let styles: Vec<ratatui::style::Style> = vec![];
let rendered = render_line(&col, &styles);
let text: String = rendered.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "plain text");
}
#[test]
fn test_render_line_with_span() {
let line = b"hello world";
let mut col = MatchCollector::new(line);
let style = ratatui::style::Style::default().fg(ratatui::style::Color::Red);
let styles = vec![style];
col.push(6, 11, 0); let rendered = render_line(&col, &styles);
assert!(rendered.spans.len() >= 2);
}
#[test]
fn test_evaluate_line_collects_spans() {
let line = b"ERROR: connection refused to host";
let f = SubstringFilter::new("ERROR", FilterDecision::Include, true, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(f)], true);
let col = fm.evaluate_line(line);
assert!(!col.spans.is_empty());
assert_eq!(&line[col.spans[0].start..col.spans[0].end], b"ERROR");
}
#[test]
fn test_render_line_overlapping_spans_priority() {
let line = b"hello world";
let style_lo = ratatui::style::Style::default().fg(ratatui::style::Color::Blue);
let style_hi = ratatui::style::Style::default().fg(ratatui::style::Color::Red);
let styles = vec![style_lo, style_hi];
let mut col = MatchCollector::new(line);
col.with_priority(0);
col.push(0, 5, 0); col.with_priority(10);
col.push(0, 5, 1);
let rendered = render_line(&col, &styles);
let hello_span = rendered
.spans
.iter()
.find(|s| s.content.as_ref() == "hello");
assert!(hello_span.is_some());
assert_eq!(
hello_span.unwrap().style.fg,
Some(ratatui::style::Color::Red)
);
}
#[test]
fn test_render_line_adjacent_same_style_merged() {
let line = b"abcdef";
let style = ratatui::style::Style::default().fg(ratatui::style::Color::Green);
let styles = vec![style];
let mut col = MatchCollector::new(line);
col.push(0, 3, 0); col.push(3, 6, 0);
let rendered = render_line(&col, &styles);
let styled: Vec<_> = rendered
.spans
.iter()
.filter(|s| s.style.fg.is_some())
.collect();
assert_eq!(styled.len(), 1);
assert_eq!(styled[0].content.as_ref(), "abcdef");
}
#[test]
fn test_render_line_composes_fg_and_bg_from_different_spans() {
let line = b"hello world";
let style_fg = ratatui::style::Style::default().fg(ratatui::style::Color::Yellow);
let style_bg = ratatui::style::Style::default().bg(ratatui::style::Color::DarkGray);
let styles = vec![style_fg, style_bg];
let mut col = MatchCollector::new(line);
col.with_priority(0);
col.push(0, 5, 0); col.with_priority(0);
col.push(0, 5, 1);
let rendered = render_line(&col, &styles);
let hello_span = rendered
.spans
.iter()
.find(|s| s.content.as_ref() == "hello");
assert!(hello_span.is_some());
let span = hello_span.unwrap();
assert_eq!(span.style.fg, Some(ratatui::style::Color::Yellow));
assert_eq!(span.style.bg, Some(ratatui::style::Color::DarkGray));
}
#[test]
fn test_render_line_higher_priority_fg_wins_over_lower() {
let line = b"hello";
let style_lo = ratatui::style::Style::default().fg(ratatui::style::Color::Blue);
let style_hi = ratatui::style::Style::default().fg(ratatui::style::Color::Red);
let styles = vec![style_lo, style_hi];
let mut col = MatchCollector::new(line);
col.with_priority(0);
col.push(0, 5, 0); col.with_priority(10);
col.push(0, 5, 1);
let rendered = render_line(&col, &styles);
let span = rendered
.spans
.iter()
.find(|s| s.content.as_ref() == "hello");
assert!(span.is_some());
assert_eq!(span.unwrap().style.fg, Some(ratatui::style::Color::Red));
}
#[test]
fn test_render_line_higher_priority_bg_wins_independent_of_fg() {
let line = b"hello";
let style_lo = ratatui::style::Style::default().fg(ratatui::style::Color::Cyan);
let style_hi = ratatui::style::Style::default().bg(ratatui::style::Color::Red);
let styles = vec![style_lo, style_hi];
let mut col = MatchCollector::new(line);
col.with_priority(0);
col.push(0, 5, 0); col.with_priority(10);
col.push(0, 5, 1);
let rendered = render_line(&col, &styles);
let span = rendered
.spans
.iter()
.find(|s| s.content.as_ref() == "hello");
assert!(span.is_some());
assert_eq!(span.unwrap().style.fg, Some(ratatui::style::Color::Cyan));
assert_eq!(span.unwrap().style.bg, Some(ratatui::style::Color::Red));
}
#[test]
fn test_filter_count_returns_number_of_compiled_filters() {
let f1 = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let f2 = SubstringFilter::new("DEBUG", FilterDecision::Exclude, false, 1).unwrap();
let fm = FilterManager::new(vec![Box::new(f1), Box::new(f2)], true);
assert_eq!(fm.filter_count(), 2);
}
#[test]
fn test_filter_count_empty() {
let fm = FilterManager::empty();
assert_eq!(fm.filter_count(), 0);
}
#[test]
fn test_count_line_matches_independent_no_short_circuit() {
let line = b"ERROR DEBUG both";
let f1 = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let f2 = SubstringFilter::new("DEBUG", FilterDecision::Exclude, false, 1).unwrap();
let fm = FilterManager::new(vec![Box::new(f1), Box::new(f2)], true);
let counts: Vec<std::sync::atomic::AtomicUsize> = (0..2)
.map(|_| std::sync::atomic::AtomicUsize::new(0))
.collect();
fm.count_line_matches(line, &counts);
assert_eq!(counts[0].load(std::sync::atomic::Ordering::Relaxed), 1);
assert_eq!(counts[1].load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[test]
fn test_count_line_matches_only_matching_filters_increment() {
let line = b"INFO: all good";
let f_error = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let f_info = SubstringFilter::new("INFO", FilterDecision::Include, false, 1).unwrap();
let fm = FilterManager::new(vec![Box::new(f_error), Box::new(f_info)], true);
let counts: Vec<std::sync::atomic::AtomicUsize> = (0..2)
.map(|_| std::sync::atomic::AtomicUsize::new(0))
.collect();
fm.count_line_matches(line, &counts);
assert_eq!(counts[0].load(std::sync::atomic::Ordering::Relaxed), 0);
assert_eq!(counts[1].load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[test]
fn test_count_line_matches_accumulates_across_lines() {
let f = SubstringFilter::new("ERROR", FilterDecision::Include, false, 0).unwrap();
let fm = FilterManager::new(vec![Box::new(f)], true);
let counts: Vec<std::sync::atomic::AtomicUsize> = (0..1)
.map(|_| std::sync::atomic::AtomicUsize::new(0))
.collect();
fm.count_line_matches(b"ERROR: first", &counts);
fm.count_line_matches(b"INFO: skip", &counts);
fm.count_line_matches(b"ERROR: second", &counts);
assert_eq!(counts[0].load(std::sync::atomic::Ordering::Relaxed), 2);
}
}