tailspin 6.0.0

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

use crate::style::Style;

use super::super::span::{Collector, Finder};

#[derive(Debug)]
pub(crate) struct KeywordFinder {
    ac: AhoCorasick,
    style: Style,
    has_background: bool,
}

impl KeywordFinder {
    pub fn new(words: &[impl AsRef<[u8]>], style: Style) -> Result<Self, BuildError> {
        let ac = AhoCorasickBuilder::new()
            .match_kind(MatchKind::LeftmostFirst)
            .build(words)?;

        let has_background = style.bg.is_some();

        Ok(Self {
            ac,
            style,
            has_background,
        })
    }
}

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();
        for m in self.ac.find_iter(bytes) {
            if is_word_boundary(bytes, m.start(), m.end()) {
                if self.has_background {
                    collector.push_padded(m.start(), m.end(), self.style);
                } else {
                    collector.push(m.start(), m.end(), self.style);
                }
            }
        }
    }
}

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

    #[test]
    fn finds_keywords() {
        let finder = KeywordFinder::new(&["null", "true", "false"], Style::new().fg(Color::Red)).unwrap();
        let mut collector = Collector::new(0);
        finder.find_spans("value is null or true", &mut collector);

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

    #[test]
    fn respects_word_boundaries() {
        let finder = KeywordFinder::new(&["null"], Style::new().fg(Color::Red)).unwrap();
        let mut collector = Collector::new(0);
        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 background_style_produces_padded_ranges() {
        let finder = KeywordFinder::new(&["ERROR"], Style::new().on(Color::Red)).unwrap();
        let mut collector = Collector::new(0);
        finder.find_spans("level ERROR here", &mut collector);

        let (spans, padded) = collector.into_parts();
        assert_eq!(spans.len(), 1);
        assert_eq!(padded.len(), 1);
        assert_eq!(padded[0], spans[0].start..spans[0].end);
    }

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

        let (spans, padded) = collector.into_parts();
        assert_eq!(spans.len(), 1);
        assert!(padded.is_empty());
    }
}