tailspin 7.0.0

A log file highlighter
Documentation
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError, Match, MatchKind};

use crate::core::config::KeywordConfig;

use super::super::palette::{Palette, StyleId};
use super::super::span::{Collector, Finder};

/// Matches all configured keywords with a single automaton; each pattern
/// carries the style of the config it came from, plus whether that style
/// has a background and therefore renders as a padded badge.
#[derive(Debug, Clone)]
pub(crate) struct KeywordFinder {
    ac: AhoCorasick,
    styles: Vec<(StyleId, bool)>,
}

impl KeywordFinder {
    pub fn new(configs: &[KeywordConfig], palette: &mut Palette) -> Result<Self, BuildError> {
        let words = configs.iter().flat_map(|config| &config.words);
        let styles = configs
            .iter()
            .flat_map(|config| {
                let style = (palette.intern(config.style), config.style.bg.is_some());
                config.words.iter().map(move |_| style)
            })
            .collect();

        let ac = AhoCorasickBuilder::new().match_kind(MatchKind::Standard).build(words)?;

        Ok(Self { ac, styles })
    }
}

fn is_word_byte(b: u8) -> bool {
    b.is_ascii_uppercase() || b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'
}

fn is_word_boundary(hay: &[u8], start: usize, end: usize) -> bool {
    let left_ok = start == 0 || !is_word_byte(hay[start - 1]);
    let right_ok = end == hay.len() || !is_word_byte(hay[end]);
    left_ok && right_ok
}

impl Finder for KeywordFinder {
    fn find_spans(&self, input: &str, collector: &mut Collector) {
        let bytes = input.as_bytes();

        let mut matches: Vec<Match> = self
            .ac
            .find_overlapping_iter(bytes)
            .filter(|m| is_word_boundary(bytes, m.start(), m.end()))
            .collect();
        matches.sort_by(|a, b| a.start().cmp(&b.start()).then(b.end().cmp(&a.end())));

        let mut next_start = 0;
        for m in matches {
            if m.start() < next_start {
                continue;
            }
            next_start = m.end();

            let (style, padded) = self.styles[m.pattern().as_usize()];
            if padded {
                collector.push_padded(m.start(), m.end(), style);
            } else {
                collector.push(m.start(), m.end(), style);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::style::{Color, Style};

    fn kw(words: &[&str], style: Style) -> KeywordConfig {
        KeywordConfig {
            words: words.iter().map(ToString::to_string).collect(),
            style,
        }
    }

    fn finder(configs: &[KeywordConfig]) -> KeywordFinder {
        KeywordFinder::new(configs, &mut Palette::new()).unwrap()
    }

    #[test]
    fn finds_keywords() {
        let finder = finder(&[kw(&["null", "true", "false"], Style::new().fg(Color::Red))]);
        let texts = super::super::span_texts("value is null or true", &finder);
        assert_eq!(texts, ["null", "true"]);
    }

    #[test]
    fn respects_word_boundaries() {
        let finder = finder(&[kw(&["null"], Style::new().fg(Color::Red))]);
        let mut collector = Collector::new();
        finder.find_spans("nullable is not null", &mut collector);

        let spans = collector.into_spans();
        assert_eq!(spans.len(), 1);
        assert_eq!(&"nullable is not null"[spans[0].start..spans[0].end], "null");
    }

    #[test]
    fn longer_keyword_wins_over_its_prefix() {
        let finder = finder(&[kw(&["WARN", "WARNING"], Style::new().fg(Color::Yellow))]);
        let texts = super::super::span_texts("level WARNING here", &finder);
        assert_eq!(texts, ["WARNING"]);
    }

    #[test]
    fn longest_valid_keyword_wins_at_the_same_start() {
        let finder = finder(&[
            kw(&["connection"], Style::new().fg(Color::Yellow)),
            kw(&["connection lost"], Style::new().fg(Color::Red)),
        ]);
        let texts = super::super::span_texts("connection lost now", &finder);
        assert_eq!(texts, ["connection lost"]);
    }

    #[test]
    fn rejected_longer_keyword_does_not_shadow_its_prefix() {
        let finder = finder(&[
            kw(&["connection lost"], Style::new().fg(Color::Red)),
            kw(&["connection"], Style::new().fg(Color::Yellow)),
        ]);
        let texts = super::super::span_texts("connection lostness detected", &finder);
        assert_eq!(texts, ["connection"]);
    }

    #[test]
    fn rejected_longer_keyword_does_not_shadow_nested_keywords() {
        let finder = finder(&[
            kw(&["connection lost"], Style::new().fg(Color::Red)),
            kw(&["lost"], Style::new().fg(Color::Yellow)),
        ]);
        let texts = super::super::span_texts("myconnection lost", &finder);
        assert_eq!(texts, ["lost"]);
    }

    #[test]
    fn each_keyword_keeps_the_style_of_its_config() {
        let red = Style::new().fg(Color::Red);
        let green = Style::new().fg(Color::Green);
        let mut palette = Palette::new();
        let finder = KeywordFinder::new(&[kw(&["ERROR"], red), kw(&["SUCCESS"], green)], &mut palette).unwrap();

        let mut collector = Collector::new();
        finder.find_spans("ERROR then SUCCESS", &mut collector);

        // Re-interning a known style returns the id the finder was built with
        let spans = collector.into_spans();
        assert_eq!(spans.len(), 2);
        assert_eq!(spans[0].style, palette.intern(red));
        assert_eq!(spans[1].style, palette.intern(green));
    }

    #[test]
    fn background_style_marks_span_padded() {
        let finder = finder(&[kw(&["ERROR"], Style::new().on(Color::Red))]);
        let mut collector = Collector::new();
        finder.find_spans("level ERROR here", &mut collector);

        let spans = collector.into_spans();
        assert_eq!(spans.len(), 1);
        assert!(spans[0].padded);
    }

    #[test]
    fn foreground_only_leaves_span_unpadded() {
        let finder = finder(&[kw(&["ERROR"], Style::new().fg(Color::Red))]);
        let mut collector = Collector::new();
        finder.find_spans("level ERROR here", &mut collector);

        let spans = collector.into_spans();
        assert_eq!(spans.len(), 1);
        assert!(!spans[0].padded);
    }

    #[test]
    fn mixed_padded_and_plain_configs() {
        let badge = Style::new().on(Color::Red);
        let plain = Style::new().fg(Color::Green);
        let finder = finder(&[kw(&["ERROR"], badge), kw(&["ok"], plain)]);

        let mut collector = Collector::new();
        finder.find_spans("ERROR but ok", &mut collector);

        let spans = collector.into_spans();
        assert_eq!(spans.len(), 2);
        assert!(spans[0].padded);
        assert!(!spans[1].padded);
    }
}