use core::fmt;
use regex::Regex;
use super::app::RowKey;
use super::level::{Classifier, Level};
use super::tail::{Stream, TailLine};
use super::textbox::TextBox;
mod match_edit;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Axis {
Stream,
Level,
Match,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchKind {
Literal,
Regex,
Invalid,
}
impl MatchKind {
#[must_use]
pub const fn chip_suffix(self) -> &'static str {
match self {
Self::Literal => "",
Self::Regex => " (regex)",
Self::Invalid => " (invalid regex, matches nothing)",
}
}
}
#[derive(Clone)]
struct Matcher {
text: String,
compiled: Compiled,
}
#[derive(Clone)]
enum Compiled {
Literal,
Regex(Regex),
Invalid,
}
#[cfg(test)]
thread_local! {
static COMPILES: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn compiles() -> usize {
COMPILES.with(core::cell::Cell::get)
}
impl Matcher {
fn new(text: String) -> Option<Self> {
if text.is_empty() {
return None;
}
#[cfg(test)]
COMPILES.with(|count| count.set(count.get() + 1));
let compiled = match delimited_regex(&text) {
Some(pattern) => Regex::new(pattern).map_or(Compiled::Invalid, Compiled::Regex),
None => Compiled::Literal,
};
Some(Self { text, compiled })
}
fn kind(&self) -> MatchKind {
match self.compiled {
Compiled::Literal => MatchKind::Literal,
Compiled::Regex(_) => MatchKind::Regex,
Compiled::Invalid => MatchKind::Invalid,
}
}
fn is_match(&self, haystack: &str) -> bool {
match &self.compiled {
Compiled::Literal => haystack.contains(self.text.as_str()),
Compiled::Regex(re) => re.is_match(haystack),
Compiled::Invalid => false,
}
}
fn ranges(&self, haystack: &str) -> Vec<(usize, usize)> {
match &self.compiled {
Compiled::Literal => haystack
.match_indices(self.text.as_str())
.map(|(start, matched)| (start, start + matched.len()))
.collect(),
Compiled::Invalid => Vec::new(),
Compiled::Regex(re) => re
.find_iter(haystack)
.map(|m| (m.start(), m.end()))
.collect(),
}
}
}
impl fmt::Debug for Matcher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Matcher")
.field("text", &self.text)
.field("kind", &self.kind())
.finish()
}
}
impl PartialEq for Matcher {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
}
}
impl Eq for Matcher {}
fn delimited_regex(text: &str) -> Option<&str> {
let inner = text.strip_prefix('/')?.strip_suffix('/')?;
(!inner.is_empty()).then_some(inner)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Filters {
pub stream: Option<Stream>,
pub min_level: Option<Level>,
matcher: Option<Matcher>,
order: Vec<Axis>,
}
impl Filters {
fn note_axis(&mut self, axis: Axis, now_set: bool) {
let already_set = self.order.contains(&axis);
if now_set && !already_set {
self.order.push(axis);
} else if !now_set && already_set {
self.order.retain(|set| *set != axis);
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.stream.is_none() && self.min_level.is_none() && self.matcher.is_none()
}
fn set_match(&mut self, text: String) {
let matcher = Matcher::new(text);
self.note_axis(Axis::Match, matcher.is_some());
self.matcher = matcher;
}
fn clear_match(&mut self) {
self.matcher = None;
}
#[must_use]
pub fn match_text(&self) -> Option<&str> {
self.matcher.as_ref().map(|matcher| matcher.text.as_str())
}
#[must_use]
pub fn match_kind(&self) -> Option<MatchKind> {
self.matcher.as_ref().map(Matcher::kind)
}
#[must_use]
pub fn match_ranges(&self, haystack: &str) -> Vec<(usize, usize)> {
self.matcher
.as_ref()
.map_or_else(Vec::new, |matcher| matcher.ranges(haystack))
}
fn keeps(&self, line: &TailLine, levels: &Classifier) -> bool {
if let Some(stream) = self.stream
&& line.stream != stream
{
return false;
}
if let Some(min) = self.min_level {
if let Some(level) = levels.level_of(&line.text)
&& level < min
{
return false;
}
}
if let Some(matcher) = &self.matcher
&& !matcher.is_match(&line.text)
{
return false;
}
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BleatsPane {
sheep: RowKey,
filters: Filters,
match_snapshot: Option<match_edit::MatchEdit>,
scroll_offset: usize,
following: bool,
body_rows: usize,
width: u16,
wrap: bool,
}
impl BleatsPane {
#[must_use]
pub fn new(sheep: RowKey) -> Self {
Self {
sheep,
filters: Filters::default(),
match_snapshot: None,
scroll_offset: 0,
following: true,
body_rows: 0,
width: 0,
wrap: false,
}
}
#[must_use]
pub fn sheep(&self) -> &RowKey {
&self.sheep
}
#[cfg(test)]
#[must_use]
pub(crate) fn match_filter(&self) -> Option<&str> {
self.filters.match_text()
}
#[must_use]
pub fn filters(&self) -> &Filters {
&self.filters
}
pub fn set_stream(&mut self, stream: Option<Stream>) {
self.filters.note_axis(Axis::Stream, stream.is_some());
self.filters.stream = stream;
}
pub fn set_min_level(&mut self, level: Option<Level>) {
self.filters.note_axis(Axis::Level, level.is_some());
self.filters.min_level = level;
}
pub fn set_match(&mut self, text: String) {
self.filters.set_match(text);
}
#[must_use]
pub fn visible<'a>(&self, lines: &'a [TailLine], levels: &Classifier) -> Vec<&'a TailLine> {
lines
.iter()
.filter(|line| self.filters.keeps(line, levels))
.collect()
}
#[must_use]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
#[must_use]
pub fn following(&self) -> bool {
self.following
}
#[must_use]
pub(crate) fn body_rows(&self) -> usize {
self.body_rows
}
pub fn set_rows(&mut self, rows: usize) {
self.body_rows = rows;
}
#[must_use]
pub(crate) fn width(&self) -> u16 {
self.width
}
pub fn set_width(&mut self, width: u16) {
self.width = width;
}
#[must_use]
pub fn wrapped(&self) -> bool {
self.wrap
}
pub fn toggle_wrap(&mut self) {
self.wrap = !self.wrap;
}
pub fn scroll_up(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_add(amount);
self.following = false;
}
pub fn clamp_scroll(&mut self, ceiling: usize) {
self.scroll_offset = self.scroll_offset.min(ceiling);
}
pub fn scroll_down(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
pub fn page_down(&mut self, amount: usize) {
self.scroll_down(amount.max(1));
}
pub fn jump_to_end(&mut self) {
self.scroll_offset = 0;
self.following = true;
}
pub fn toggle_follow(&mut self) {
self.following = !self.following;
if self.following {
self.scroll_offset = 0;
}
}
pub fn match_next(&mut self) {
if self.filters.match_text().is_none() {
return;
}
self.scroll_down(1);
self.following = false;
}
pub fn drop_newest_chip(&mut self) -> bool {
let Some(axis) = self.filters.order.pop() else {
return false;
};
match axis {
Axis::Stream => self.filters.stream = None,
Axis::Level => self.filters.min_level = None,
Axis::Match => self.filters.clear_match(),
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(stream: Stream, text: &str) -> TailLine {
TailLine {
stream,
text: text.to_string(),
}
}
#[test]
fn a_line_with_no_level_survives_a_minimum() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_min_level(Some(Level::Warn));
let lines = vec![
line(Stream::Out, "listening on 8080"),
line(Stream::Out, "INFO routine chatter"),
line(Stream::Out, "ERROR pool exhausted"),
];
let kept: Vec<&str> = pane
.visible(&lines, &Classifier::new(&[]))
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["listening on 8080", "ERROR pool exhausted"]);
}
#[test]
fn the_three_axes_compose_with_and() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_match("pool".to_string());
let lines = vec![
line(Stream::Err, "ERROR pool exhausted"), line(Stream::Out, "ERROR pool exhausted"), line(Stream::Err, "INFO pool warming"), line(Stream::Err, "ERROR disk full"), ];
assert_eq!(pane.visible(&lines, &Classifier::new(&[])).len(), 1);
}
#[test]
fn esc_drops_the_newest_chip_then_reports_there_are_none_left() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
assert!(pane.drop_newest_chip(), "the level chip was newest");
assert!(pane.filters().min_level.is_none());
assert!(pane.filters().stream.is_some(), "the older chip stays");
assert!(pane.drop_newest_chip(), "the stream chip goes next");
assert!(!pane.drop_newest_chip(), "nothing left to drop");
}
#[test]
fn clearing_an_older_axis_directly_removes_it_from_the_drop_order() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_stream(None);
assert!(pane.drop_newest_chip(), "level is the only chip left");
assert!(pane.filters().min_level.is_none());
assert!(
!pane.drop_newest_chip(),
"stream was cleared directly, not through drop_newest_chip, and \
must not still be sitting in the order"
);
}
#[test]
fn re_setting_an_already_set_axis_does_not_reorder_it() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_stream(Some(Stream::Out));
assert!(pane.drop_newest_chip(), "level is still the newest chip");
assert!(pane.filters().min_level.is_none());
assert!(
pane.filters().stream.is_some(),
"the re-set stream chip is still the older one"
);
}
#[test]
fn a_slash_delimited_matcher_is_read_as_a_regex() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/poo+l/".to_string());
let lines = vec![
line(Stream::Out, "pool exhausted"),
line(Stream::Out, "pooool exhausted"),
line(Stream::Out, "pol exhausted"),
line(Stream::Out, "kennel"),
];
let kept: Vec<&str> = pane
.visible(&lines, &Classifier::new(&[]))
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["pool exhausted", "pooool exhausted"]);
}
#[test]
fn an_invalid_regex_matches_no_line_and_reports_itself_invalid() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/pool(/".to_string());
let lines = vec![line(Stream::Out, "pool exhausted")];
assert!(pane.visible(&lines, &Classifier::new(&[])).is_empty());
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Invalid));
}
#[test]
fn a_plain_matcher_is_read_literally_not_as_a_regex() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("get index.html".to_string());
let lines = vec![
line(Stream::Out, "GET get index.html 200"),
line(Stream::Out, "GET get indexXhtml 200"),
];
let kept: Vec<&str> = pane
.visible(&lines, &Classifier::new(&[]))
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["GET get index.html 200"]);
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Literal));
}
#[test]
fn only_the_two_regex_states_carry_a_chip_suffix() {
assert_eq!(MatchKind::Literal.chip_suffix(), "");
assert_eq!(MatchKind::Regex.chip_suffix(), " (regex)");
assert_eq!(
MatchKind::Invalid.chip_suffix(),
" (invalid regex, matches nothing)"
);
}
#[test]
fn match_ranges_reports_every_hit_a_regex_matcher_finds() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/po+l/".to_string());
let ranges = pane.filters().match_ranges("pool then pol then pooool");
let hits: Vec<&str> = ranges
.iter()
.map(|&(start, end)| &"pool then pol then pooool"[start..end])
.collect();
assert_eq!(hits, vec!["pool", "pol", "pooool"]);
}
#[test]
fn setting_the_axis_compiles_once_and_reading_it_back_compiles_never() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
let before = compiles();
pane.set_match("/po+l/".to_string());
assert_eq!(compiles() - before, 1, "one compile, at set time");
let after_set = compiles();
let lines: Vec<TailLine> = (0..80)
.map(|i| line(Stream::Out, &format!("pool acquire {i}")))
.collect();
for feed_line in pane.visible(&lines, &Classifier::new(&[])) {
let _ = pane.filters().match_ranges(&feed_line.text);
let _ = pane.filters().match_kind();
let _ = pane.filters().match_text();
}
assert_eq!(
compiles(),
after_set,
"reading the axis back over a full feed compiles nothing"
);
}
#[test]
fn replacing_a_regex_axis_with_a_literal_drops_the_compiled_regex() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/po+l/".to_string());
pane.set_match("po+l".to_string());
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Literal));
let lines = vec![line(Stream::Out, "pool"), line(Stream::Out, "po+l")];
let kept: Vec<&str> = pane
.visible(&lines, &Classifier::new(&[]))
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(
kept,
vec!["po+l"],
"the literal matches, the regex does not"
);
}
#[test]
fn replacing_a_literal_axis_with_a_regex_compiles_the_new_text() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("pool".to_string());
pane.set_match("/po+l/".to_string());
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Regex));
let lines = vec![line(Stream::Out, "pol"), line(Stream::Out, "kennel")];
assert_eq!(
pane.visible(&lines, &Classifier::new(&[])).len(),
1,
"the regex matches `pol`"
);
}
#[test]
fn setting_the_axis_to_nothing_clears_the_matcher_it_held() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/po+l/".to_string());
pane.set_match(String::new());
assert_eq!(pane.filters().match_kind(), None);
assert_eq!(pane.filters().match_text(), None);
assert!(pane.filters().match_ranges("pool").is_empty());
assert!(pane.filters().is_empty(), "no axis is set");
}
#[test]
fn dropping_the_match_chip_clears_the_matcher_it_held() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/po+l/".to_string());
assert!(pane.drop_newest_chip(), "the match chip was newest");
assert_eq!(pane.filters().match_kind(), None);
assert!(pane.filters().match_ranges("pool").is_empty());
assert!(!pane.drop_newest_chip(), "nothing left to drop");
}
#[test]
fn n_steps_only_while_the_match_axis_is_set() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.match_next();
assert!(pane.following(), "no axis set, so `n` spent no follow flag");
assert_eq!(pane.scroll_offset(), 0, "and moved nothing");
pane.scroll_up(3);
pane.set_match("/po+l/".to_string());
pane.match_next();
assert_eq!(pane.scroll_offset(), 2, "the axis is set, so `n` stepped");
assert!(!pane.following(), "a deliberate jump clears follow");
}
#[test]
fn n_stops_stepping_once_the_match_chip_is_dropped() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match("/po+l/".to_string());
pane.scroll_up(3);
assert!(pane.drop_newest_chip());
pane.match_next();
assert_eq!(
pane.scroll_offset(),
3,
"the axis is gone, so `n` did nothing"
);
}
#[test]
fn whitespace_only_match_text_is_a_needle_not_a_cleared_axis() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
pane.set_match(" ".to_string());
assert_eq!(pane.filters().match_kind(), Some(MatchKind::Literal));
assert_eq!(pane.filters().match_text(), Some(" "));
assert!(!pane.filters().is_empty(), "the axis is set, not cleared");
let lines = vec![
line(Stream::Out, "pool exhausted"),
line(Stream::Out, "kennel"),
];
let kept: Vec<&str> = pane
.visible(&lines, &Classifier::new(&[]))
.iter()
.map(|l| l.text.as_str())
.collect();
assert_eq!(kept, vec!["pool exhausted"]);
}
#[test]
fn a_matcher_debug_names_its_kind_and_prints_its_text_once() {
let mut pane = BleatsPane::new(RowKey::Sheep(9));
for (text, expected) in [
("/po+l/", r#"Matcher { text: "/po+l/", kind: Regex }"#),
("pool", r#"Matcher { text: "pool", kind: Literal }"#),
("/pool(/", r#"Matcher { text: "/pool(/", kind: Invalid }"#),
] {
pane.set_match(text.to_string());
let matcher = pane
.filters()
.matcher
.as_ref()
.expect("set_match set the axis");
assert_eq!(format!("{matcher:?}"), expected);
}
}
#[test]
fn filter_sets_compare_by_their_match_text() {
let mut one = BleatsPane::new(RowKey::Sheep(9));
let mut two = BleatsPane::new(RowKey::Sheep(9));
one.set_match("/po+l/".to_string());
two.set_match("/po+l/".to_string());
assert_eq!(one.filters(), two.filters(), "same text, same axis");
two.set_match("/po+ls/".to_string());
assert_ne!(
one.filters(),
two.filters(),
"different text, different axis"
);
}
}