use std::borrow::Cow;
use crate::color::Style;
use crate::rules::Rule;
use crate::theme::{tokens, Theme};
#[derive(Debug, Clone)]
pub struct Colorizer {
rules: Vec<Rule>,
theme: Theme,
}
impl Colorizer {
pub fn new(rules: Vec<Rule>, theme: Theme) -> Colorizer {
Colorizer { rules, theme }
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn rules(&self) -> &[Rule] {
&self.rules
}
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
pub fn colorize_line(&self, line: &str) -> String {
let sanitized = sanitize(line);
let line: &str = &sanitized;
if line.is_empty() {
return String::new();
}
let len = line.len();
let mut owner = vec![usize::MAX; len];
let mut token_names: Vec<&str> = Vec::new();
for rule in &self.rules {
if rule.has_named_groups {
for caps in rule.regex.captures_iter(line) {
for (gi, tok) in rule.group_tokens.iter().enumerate() {
let Some(tok) = tok else { continue };
if let Some(m) = caps.get(gi) {
claim(&mut owner, &mut token_names, m.start(), m.end(), tok);
}
}
}
} else {
for m in rule.regex.find_iter(line) {
claim(
&mut owner,
&mut token_names,
m.start(),
m.end(),
&rule.whole_token,
);
}
}
}
self.render(line, &owner, &token_names)
}
fn render(&self, line: &str, owner: &[usize], token_names: &[&str]) -> String {
let default_style = self.theme.style(tokens::DEFAULT);
let styles: Vec<Style> = token_names.iter().map(|n| self.theme.style(n)).collect();
let mut out = String::with_capacity(line.len() + 16);
let mut i = 0;
let n = line.len();
while i < n {
let cur = owner[i];
let mut j = i + 1;
while j < n && owner[j] == cur {
j += 1;
}
let style = if cur == usize::MAX {
default_style
} else {
styles[cur]
};
out.push_str(&style.paint(&line[i..j]));
i = j;
}
out
}
pub fn colorize_text(&self, text: &str) -> String {
let mut out = String::with_capacity(text.len() + text.len() / 8);
for line in text.split_inclusive('\n') {
if let Some(stripped) = line.strip_suffix('\n') {
out.push_str(&self.colorize_line(stripped));
out.push('\n');
} else {
out.push_str(&self.colorize_line(line));
}
}
out
}
}
fn sanitize(line: &str) -> Cow<'_, str> {
let is_junk = |c: char| {
let u = c as u32;
(u < 0x20 && c != '\t') || u == 0x7f || (0x80..=0x9f).contains(&u)
};
if !line.chars().any(is_junk) {
return Cow::Borrowed(line);
}
let mut out = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\u{1b}' => match chars.peek() {
Some('[') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
Some(']') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c == '\u{07}' {
break;
}
if c == '\u{1b}' {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
}
}
Some(_) => {
chars.next();
}
None => {}
},
'\t' => out.push('\t'),
c if is_junk(c) => {} c => out.push(c),
}
}
Cow::Owned(out)
}
fn claim<'a>(
owner: &mut [usize],
names: &mut Vec<&'a str>,
start: usize,
end: usize,
token: &'a str,
) {
let id = match names.iter().position(|n| *n == token) {
Some(i) => i,
None => {
names.push(token);
names.len() - 1
}
};
for b in &mut owner[start..end] {
if *b == usize::MAX {
*b = id;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{builtin_generic, compile_all};
use crate::theme::cyberpunk;
fn engine() -> Colorizer {
let rules = compile_all(&builtin_generic()).unwrap();
Colorizer::new(rules, cyberpunk())
}
#[test]
fn empty_line() {
assert_eq!(engine().colorize_line(""), "");
}
#[test]
fn plain_line_gets_base_style() {
let out = engine().colorize_line("hello world");
assert!(out.contains("\x1b["));
assert!(out.contains("hello"));
}
#[test]
fn error_word_is_painted_bold() {
let out = engine().colorize_line("ERROR something broke");
assert!(out.contains("\x1b[1;"));
assert!(out.contains("ERROR"));
}
#[test]
fn newlines_preserved() {
let out = engine().colorize_text("a\nb\n");
assert_eq!(out.matches('\n').count(), 2);
}
#[test]
fn strips_existing_escapes_no_corruption() {
let out = engine().colorize_line("ERROR \x1b[31mred\x1b[0m done");
assert!(out.contains("red"));
assert!(out.contains("done"));
assert!(!out.contains("\x1b[\x1b["));
assert!(!out.contains("\x1b[m") || out.contains("\x1b[0m"));
assert!(!out.contains("[31m"));
}
#[test]
fn drops_binary_control_bytes() {
let out = engine().colorize_line("a\x00b\x07c\x7fd\u{0099}e");
assert!(out.contains('a') && out.contains('e'));
for junk in ['\u{0}', '\u{7}', '\u{7f}', '\u{99}'] {
assert!(
!out.contains(junk),
"control char {:#x} leaked",
junk as u32
);
}
}
#[test]
fn pure_control_line_is_empty() {
assert_eq!(engine().colorize_line("\x1b[0m\x00\x07"), "");
}
#[test]
fn tabs_preserved() {
assert!(engine().colorize_line("a\tb").contains('\t'));
}
#[test]
fn earlier_rule_wins_overlap() {
let out = engine().colorize_line("192.168.0.1");
assert!(out.contains("38;5;135"));
assert_eq!(out.matches("\x1b[0m").count(), 1);
}
#[test]
fn multibyte_chars_survive_span_slicing() {
let out = engine().colorize_line("héllo wörld 42 ☃");
assert!(out.contains("héllo"), "accented word preserved");
assert!(out.contains("wörld"), "second accented word preserved");
assert!(out.contains('☃'), "snowman survives");
assert!(out.contains("42"));
}
#[test]
fn multibyte_directly_adjacent_to_match() {
let out = engine().colorize_line("café42");
assert!(out.contains("café"));
assert!(out.contains("42"));
}
#[test]
fn strips_osc_hyperlink_escape() {
let out = engine().colorize_line("see \x1b]8;;https://x.com\x1b\\link\x1b]8;;\x1b\\ here");
assert!(out.contains("link") && out.contains("here"));
assert!(!out.contains("https://x.com"), "OSC payload stripped");
assert!(!out.contains("8;;"), "no OSC fragment leaked");
}
#[test]
fn strips_osc_terminated_by_bel() {
let out = engine().colorize_line("a\x1b]0;window title\x07b");
assert!(out.contains('a') && out.contains('b'));
assert!(!out.contains("window title"));
}
#[test]
fn set_theme_recolors_without_recompiling() {
let mut cz = engine();
let before = cz.colorize_line("ERROR boom");
cz.set_theme(crate::theme::ccze_classic());
let after = cz.colorize_line("ERROR boom");
assert_ne!(before, after, "swapping the theme changes the output");
assert!(after.contains("ERROR"));
}
#[test]
fn colorize_text_without_trailing_newline() {
let out = engine().colorize_text("first\nsecond");
assert_eq!(out.matches('\n').count(), 1);
assert!(out.contains("first") && out.contains("second"));
assert!(!out.ends_with('\n'));
}
#[test]
fn colorize_text_preserves_blank_lines() {
let out = engine().colorize_text("a\n\nb\n");
assert_eq!(out.matches('\n').count(), 3);
}
#[test]
fn default_cyberpunk_constructor_works() {
let cz = Colorizer::default_cyberpunk();
assert_eq!(cz.theme().name, crate::theme::DEFAULT_THEME);
assert!(!cz.rules().is_empty());
}
#[test]
fn whitespace_only_line_keeps_its_spaces() {
let out = engine().colorize_line(" ");
assert!(out.contains(" "));
}
#[test]
fn later_rule_paints_only_unclaimed_tail() {
let defs = vec![
crate::rules::RuleDef::with_token("first", "abc", "error"),
crate::rules::RuleDef::with_token("second", "bcd", "info"),
];
let cz = Colorizer::new(compile_all(&defs).unwrap(), cyberpunk());
let out = cz.colorize_line("abcd");
let err = cz.theme().style("error");
let info = cz.theme().style("info");
assert!(out.contains(&err.paint("abc")), "first rule owns abc");
assert!(
out.contains(&info.paint("d")),
"second rule paints only the tail d"
);
}
#[test]
fn esc_two_byte_escape_dropped() {
let out = engine().colorize_line("X\x1b(Y");
assert!(out.contains('X') && out.contains('Y'));
}
#[test]
fn lone_trailing_esc_is_dropped() {
let out = engine().colorize_line("done\x1b");
assert!(out.contains("done"));
}
}