zcolorizer 0.2.2

Real-time log file colorizer (ccze/pygments port) — fully customizable regex→token rules, swappable themes, cyberpunk by default
Documentation
//! Rules: regex patterns that tag spans of a line with a semantic token.
//!
//! Two flavors, both expressed as one [`RuleDef`]:
//!
//! * **Named-group rules** — the regex has named capture groups and each group
//!   name *is* the token, e.g. `(?P<date>\d{4}-\d\d-\d\d)\s+(?P<host>\S+)`. One
//!   regex can paint several differently-colored fields. This is the pygments
//!   "regex → token" model and the recommended way to write config rules.
//! * **Whole-match rules** — no named groups; the entire match gets `token`
//!   (defaulting to `default`). Handy for word lists.
//!
//! Rules are tried in order; earlier rules own a span and later ones can't
//! recolor bytes already claimed (see [`crate::engine`]).

use crate::theme::tokens;
use regex::Regex;
use serde::{Deserialize, Serialize};

/// Serializable rule definition (what lives in the config file).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleDef {
    /// Human-readable label (shown in `--list-rules`, errors).
    #[serde(default)]
    pub name: String,
    /// The regex. Named capture groups map to tokens of the same name.
    pub pattern: String,
    /// Token for a whole-match (no named groups) rule. Defaults to `default`.
    #[serde(default)]
    pub token: Option<String>,
    /// Case-insensitive matching (compiles with `(?i)`).
    #[serde(default)]
    pub ignore_case: bool,
}

impl RuleDef {
    pub fn new(name: &str, pattern: &str) -> RuleDef {
        RuleDef {
            name: name.into(),
            pattern: pattern.into(),
            token: None,
            ignore_case: false,
        }
    }
    pub fn with_token(name: &str, pattern: &str, token: &str) -> RuleDef {
        RuleDef {
            name: name.into(),
            pattern: pattern.into(),
            token: Some(token.into()),
            ignore_case: false,
        }
    }
    pub fn ci(mut self) -> RuleDef {
        self.ignore_case = true;
        self
    }
}

/// A compiled rule ready to match.
#[derive(Debug, Clone)]
pub struct Rule {
    pub name: String,
    pub regex: Regex,
    /// Token applied to a whole match when the regex has no named groups.
    pub whole_token: String,
    /// Token name for each named capture group, in `regex.capture_names()` order
    /// (index 0 is the implicit whole match and is `None`).
    pub group_tokens: Vec<Option<String>>,
    pub has_named_groups: bool,
}

impl Rule {
    /// Compile a [`RuleDef`]. Returns the regex error with the rule name for context.
    pub fn compile(def: &RuleDef) -> Result<Rule, crate::error::Error> {
        let pat = if def.ignore_case {
            format!("(?i){}", def.pattern)
        } else {
            def.pattern.clone()
        };
        let regex = Regex::new(&pat).map_err(|e| crate::error::Error::BadRule {
            name: def.name.clone(),
            source: e,
        })?;
        let group_tokens: Vec<Option<String>> = regex
            .capture_names()
            .map(|n| n.map(|s| s.to_string()))
            .collect();
        let has_named_groups = group_tokens.iter().skip(1).any(|n| n.is_some());
        Ok(Rule {
            name: def.name.clone(),
            regex,
            whole_token: def
                .token
                .clone()
                .unwrap_or_else(|| tokens::DEFAULT.to_string()),
            group_tokens,
            has_named_groups,
        })
    }
}

/// Compile a list of rule definitions, surfacing the first bad pattern.
pub fn compile_all(defs: &[RuleDef]) -> Result<Vec<Rule>, crate::error::Error> {
    defs.iter().map(Rule::compile).collect()
}

/// The builtin generic ruleset — a port of ccze's word-coloriser plus a handful of
/// structured-line rules. These run when the config doesn't replace them and give a
/// reasonable colorization for arbitrary logs out of the box.
#[allow(clippy::vec_init_then_push)] // grouped, commented pushes read clearer than one giant vec![]
pub fn builtin_generic() -> Vec<RuleDef> {
    use tokens::*;
    let mut r = Vec::new();

    // ---- Structured leading timestamps (named-group, so multiple fields at once) ----
    // ISO 8601: 2026-06-27T14:03:11.123Z  /  2026-06-27 14:03:11
    r.push(RuleDef::new(
        "iso-datetime",
        r"(?P<date>\d{4}-\d{2}-\d{2})[T ](?P<time>\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)",
    ));
    // Syslog: Jun 27 14:03:11
    r.push(RuleDef::new(
        "syslog-datetime",
        r"(?P<date>(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2})\s+(?P<time>\d{2}:\d{2}:\d{2})",
    ));
    // proc[pid]: — process name + bracketed pid
    r.push(RuleDef::new(
        "proc-pid",
        r"(?P<process>[\w./-]+)\[(?P<pid>\d+)\]",
    ));

    // ---- Log levels / severity keywords (whole-match word rules) ----
    r.push(
        RuleDef::with_token(
            "lvl-error",
            r"\b(ERROR|ERR|CRITICAL|CRIT|FATAL|EMERG|ALERT|PANIC)\b",
            ERROR,
        )
        .ci(),
    );
    r.push(RuleDef::with_token("lvl-warn", r"\b(WARNING|WARN)\b", WARNING).ci());
    r.push(RuleDef::with_token("lvl-debug", r"\b(DEBUG|TRACE)\b", DEBUG).ci());
    r.push(RuleDef::with_token("lvl-info", r"\b(INFO|NOTICE)\b", INFO).ci());

    // ---- ccze "good"/"bad"/"system" word lists ----
    r.push(RuleDef::with_token(
        "bad-words",
        r"\b(warn\w*|restart\w*|exit\w*|stop\w*|shut\w*|down|close\w*|unreach\w*|can'?t|cannot|skip\w*|den\w+|disabl\w+|ignor\w+|miss\w*|oops|fail\w*|unable|readonly|offline|terminat\w*|empty|virus|reject\w*|refus\w+|timed?\s?out|timeout)\b",
        BAD,
    ).ci());
    r.push(RuleDef::with_token(
        "good-words",
        r"\b(activ\w*|start\w*|read\w+|online|loaded|ok|success\w*|register\w*|detected|configured|enabl\w+|listen\w*|open\w*|complete\w*|done|connect\w*|finish\w*|clean|accept\w*|established|up)\b",
        GOOD,
    ).ci());
    r.push(RuleDef::with_token(
        "system-words",
        r"\b(ext[234]|reiserfs|xfs|btrfs|zfs|vfs|iso9?6?6?0?|isofs|ppp|bsd|linux|tcp/ip|mtrr|pci\w*|isa|scsi|ide|atapi|bios|cpu|fpu|kernel|systemd|udev|dbus)\b",
        SYSTEM,
    ).ci());

    // ---- HTTP ----
    r.push(RuleDef::with_token(
        "http-method",
        r"\b(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\b",
        HTTP_METHOD,
    ));
    // Status code in a combined/common access-log line: `… HTTP/1.1" 200 1234`.
    // Named group so only the code is painted; anchored to `HTTP/x.x"` to avoid
    // coloring every 3-digit number as a status code.
    r.push(RuleDef::new(
        "http-code",
        r#"HTTP/\d\.\d"?\s+(?P<http_code>[1-5]\d{2})\b"#,
    ));

    // ---- Network / identifiers ----
    r.push(RuleDef::with_token(
        "ipv4",
        r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
        IP,
    ));
    r.push(RuleDef::with_token(
        "ipv6",
        r"\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b",
        IP,
    ));
    r.push(RuleDef::with_token(
        "mac",
        r"\b(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b",
        MAC,
    ));
    r.push(RuleDef::with_token(
        "email",
        r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
        EMAIL,
    ));
    r.push(RuleDef::with_token("uri", r#"\b[a-z][a-z0-9+.-]*://[^\s)\]}>'"]+"#, URI).ci());
    r.push(
        RuleDef::with_token(
            "hostname",
            r"\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}\b",
            HOST,
        )
        .ci(),
    );

    // ---- Filesystem ----
    r.push(RuleDef::with_token(
        "abs-path",
        r"(?:^|\s)(?P<dir>/[\w./@+-]*)",
        DIR,
    ));

    // ---- Numbers / sizes / versions / addresses ----
    r.push(RuleDef::with_token(
        "hex-addr",
        r"\b0x[0-9a-fA-F]+\b",
        ADDRESS,
    ));
    r.push(RuleDef::with_token(
        "size",
        r"\b\d+(?:\.\d+)?\s?(?:[KMGTP]i?B|[kmgtp]b?|bytes?)\b",
        SIZE,
    ));
    r.push(
        RuleDef::with_token(
            "version",
            r"\bv?\d+\.\d+(?:\.\d+)*(?:[-+][\w.]+)?\b",
            VERSION,
        )
        .ci(),
    );
    r.push(RuleDef::with_token(
        "percentage",
        r"\b\d+(?:\.\d+)?%",
        PERCENTAGE,
    ));
    r.push(RuleDef::with_token("signal", r"\bSIG(?:HUP|INT|QUIT|ILL|TRAP|ABRT|BUS|FPE|KILL|USR1|SEGV|USR2|PIPE|ALRM|TERM|CHLD|CONT|STOP|TSTP|TTIN|TTOU)\b", SIGNAL));
    r.push(RuleDef::with_token("number", r"\b\d+\b", NUMBER));

    // ---- Quoted strings ----
    r.push(RuleDef::with_token("dq-string", r#""[^"]*""#, STRING));

    r
}

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

    #[test]
    fn builtin_ruleset_compiles() {
        let rules = compile_all(&builtin_generic()).expect("all builtin rules compile");
        assert!(!rules.is_empty());
    }

    #[test]
    fn named_groups_detected() {
        let rule = Rule::compile(&RuleDef::new("dt", r"(?P<date>\d+)-(?P<time>\d+)")).unwrap();
        assert!(rule.has_named_groups);
        assert_eq!(
            rule.group_tokens,
            vec![None, Some("date".into()), Some("time".into())]
        );
    }

    #[test]
    fn whole_match_default_token() {
        let rule = Rule::compile(&RuleDef::with_token("e", r"ERROR", "error")).unwrap();
        assert!(!rule.has_named_groups);
        assert_eq!(rule.whole_token, "error");
    }

    #[test]
    fn whole_match_without_token_falls_back_to_default() {
        // A plain `RuleDef::new` (no token) paints its match with the `default` token.
        let rule = Rule::compile(&RuleDef::new("w", r"\w+")).unwrap();
        assert!(!rule.has_named_groups);
        assert_eq!(rule.whole_token, tokens::DEFAULT);
    }

    #[test]
    fn ignore_case_compiles_and_matches_any_case() {
        let rule = Rule::compile(&RuleDef::with_token("e", r"error", "error").ci()).unwrap();
        assert!(rule.regex.is_match("ERROR"));
        assert!(rule.regex.is_match("Error"));
        assert!(rule.regex.is_match("error"));
        // `.ci()` flips the flag on the def too.
        assert!(RuleDef::new("x", "y").ci().ignore_case);
    }

    #[test]
    fn case_sensitive_by_default() {
        let rule = Rule::compile(&RuleDef::with_token("e", r"error", "error")).unwrap();
        assert!(!rule.regex.is_match("ERROR"));
        assert!(rule.regex.is_match("error"));
    }

    #[test]
    fn bad_regex_surfaces_error_with_rule_name() {
        let err = Rule::compile(&RuleDef::new("oops", r"(unclosed")).unwrap_err();
        match err {
            crate::error::Error::BadRule { name, .. } => assert_eq!(name, "oops"),
            other => panic!("expected BadRule, got {other:?}"),
        }
    }

    #[test]
    fn compile_all_fails_on_first_bad_rule() {
        let defs = vec![
            RuleDef::new("good", r"\d+"),
            RuleDef::new("bad", r"[unterminated"),
        ];
        let err = compile_all(&defs).unwrap_err();
        assert!(matches!(err, crate::error::Error::BadRule { name, .. } if name == "bad"));
    }

    #[test]
    fn unnamed_groups_are_not_named_group_rules() {
        // A regex with only *unnamed* capture groups is a whole-match rule.
        let rule = Rule::compile(&RuleDef::new("g", r"(\d+)-(\d+)")).unwrap();
        assert!(!rule.has_named_groups);
        assert_eq!(rule.whole_token, tokens::DEFAULT);
    }

    #[test]
    fn group_tokens_index_zero_is_implicit_whole_match() {
        // capture_names()'s first entry is the whole match and is always None.
        let rule = Rule::compile(&RuleDef::new("g", r"(?P<a>x)(y)(?P<b>z)")).unwrap();
        assert_eq!(rule.group_tokens.first(), Some(&None));
        // An unnamed inner group stays None; named ones carry their token.
        assert_eq!(
            rule.group_tokens,
            vec![None, Some("a".into()), None, Some("b".into())]
        );
    }
}