tailspin 7.0.0

A log file highlighter
Documentation
use crate::cli::builtins::builtin_keywords;
use std::collections::HashSet;
use tailspin::config::KeywordConfig;
use tailspin::style::{Color, Style};

/// Builtin keywords ride the `keywords` highlight group; keywords the user
/// asked for explicitly (theme `[[keywords]]` and `--highlight`) always apply.
pub fn collect_keywords(
    color_word: &[(Color, Vec<String>)],
    include_builtins: bool,
    theme_keywords: Vec<KeywordConfig>,
) -> Vec<KeywordConfig> {
    let builtin = if include_builtins {
        builtin_keywords()
    } else {
        Vec::new()
    };
    let from_cli = get_keywords_from_cli(color_word);

    dedupe_last_wins(builtin.into_iter().chain(theme_keywords).chain(from_cli).collect())
}

fn dedupe_last_wins(mut configs: Vec<KeywordConfig>) -> Vec<KeywordConfig> {
    let mut seen: HashSet<String> = HashSet::new();
    for config in configs.iter_mut().rev() {
        config.words.retain(|w| seen.insert(w.clone()));
    }
    configs.retain(|c| !c.words.is_empty());
    configs
}

fn get_keywords_from_cli(color_word: &[(Color, Vec<String>)]) -> Vec<KeywordConfig> {
    color_word
        .iter()
        .flat_map(|(color, words)| {
            words.iter().map(move |word| KeywordConfig {
                style: Style::new().fg(*color),
                words: vec![word.clone()],
            })
        })
        .collect()
}

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

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

    #[test]
    fn later_definition_overrides_earlier() {
        let builtin = kw(&["GET"], Style::new().fg(Color::Black).on(Color::Green));
        let user = kw(&["GET"], Style::new().fg(Color::Green).on(Color::Black));

        let result = dedupe_last_wins(vec![builtin, user.clone()]);

        assert_eq!(result, vec![user]);
    }

    #[test]
    fn unrelated_words_in_overridden_group_survive() {
        let builtin = kw(&["GET", "POST"], Style::new().fg(Color::Black).on(Color::Green));
        let user = kw(&["GET"], Style::new().fg(Color::Green));

        let result = dedupe_last_wins(vec![builtin, user.clone()]);

        assert_eq!(
            result,
            vec![kw(&["POST"], Style::new().fg(Color::Black).on(Color::Green)), user,]
        );
    }

    #[test]
    fn cli_overrides_theme_overrides_builtin() {
        let builtin = kw(&["GET"], Style::new().fg(Color::Red));
        let theme = kw(&["GET"], Style::new().fg(Color::Yellow));
        let cli = kw(&["GET"], Style::new().fg(Color::Green));

        let result = dedupe_last_wins(vec![builtin, theme, cli.clone()]);

        assert_eq!(result, vec![cli]);
    }

    #[test]
    fn theme_internal_duplicates_use_last_wins() {
        let first = kw(&["GET"], Style::new().fg(Color::Red));
        let second = kw(&["GET"], Style::new().fg(Color::Blue));

        let result = dedupe_last_wins(vec![first, second.clone()]);

        assert_eq!(result, vec![second]);
    }

    #[test]
    fn no_duplicates_passes_through_unchanged() {
        let configs = vec![
            kw(&["GET"], Style::new().fg(Color::Red)),
            kw(&["POST"], Style::new().fg(Color::Blue)),
        ];

        let result = dedupe_last_wins(configs.clone());

        assert_eq!(result, configs);
    }

    #[test]
    fn case_sensitive_dedup() {
        let lower = kw(&["get"], Style::new().fg(Color::Red));
        let upper = kw(&["GET"], Style::new().fg(Color::Blue));

        let result = dedupe_last_wins(vec![lower.clone(), upper.clone()]);

        assert_eq!(result, vec![lower, upper]);
    }

    #[test]
    fn empty_input_returns_empty() {
        let result = dedupe_last_wins(Vec::new());
        assert!(result.is_empty());
    }
}