#![allow(clippy::unwrap_used)]
#![allow(clippy::expect_used)]
use crate::segment::{Segment, Segments};
use crate::style::Style;
use regex::Regex;
use std::sync::LazyLock;
pub trait Highlighter {
fn highlight(&self, text: &str) -> Segments;
}
#[derive(Debug, Clone)]
pub struct ReprHighlighter {
number_style: Style,
string_style: Style,
bool_style: Style,
none_style: Style,
attr_style: Style,
url_style: Style,
uuid_style: Style,
}
impl Default for ReprHighlighter {
fn default() -> Self {
Self::new()
}
}
impl ReprHighlighter {
#[must_use]
pub fn new() -> Self {
Self {
number_style: Style::default().bold(),
string_style: Style::default().italic(),
bool_style: Style::default().italic().bold(),
none_style: Style::default().italic().bold(),
attr_style: Style::default(),
url_style: Style::default().underline(),
uuid_style: Style::default().bold(),
}
}
#[must_use]
pub fn number_style(mut self, style: Style) -> Self {
self.number_style = style;
self
}
#[must_use]
pub fn string_style(mut self, style: Style) -> Self {
self.string_style = style;
self
}
#[must_use]
pub fn bool_style(mut self, style: Style) -> Self {
self.bool_style = style;
self
}
#[must_use]
pub fn none_style(mut self, style: Style) -> Self {
self.none_style = style;
self
}
#[must_use]
pub fn attr_style(mut self, style: Style) -> Self {
self.attr_style = style;
self
}
#[must_use]
pub fn url_style(mut self, style: Style) -> Self {
self.url_style = style;
self
}
#[must_use]
pub fn uuid_style(mut self, style: Style) -> Self {
self.uuid_style = style;
self
}
}
static NUMBER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x)
(?P<number>
# Hex numbers
0x[0-9a-fA-F]+
|
# Binary numbers
0b[01]+
|
# Octal numbers
0o[0-7]+
|
# Float with exponent
-?[0-9]+\.?[0-9]*(?:e[+-]?[0-9]+)?
|
# Regular integers
-?[0-9]+
)
",
)
.unwrap()
});
static STRING_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?P<string>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')"#).unwrap());
static BOOL_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(?P<bool>true|false|True|False)\b").unwrap());
static NONE_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(?P<none>None|null|nil|NULL)\b").unwrap());
static URL_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?P<url>https?://[^\s<>"')]+)"#).unwrap());
static UUID_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?P<uuid>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})",
)
.unwrap()
});
impl Highlighter for ReprHighlighter {
fn highlight(&self, text: &str) -> Segments {
let mut segments = Segments::new();
let mut last_end = 0;
let mut matches: Vec<(usize, usize, &str, Style)> = Vec::new();
for cap in URL_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("url") {
matches.push((m.start(), m.end(), m.as_str(), self.url_style.clone()));
}
}
for cap in UUID_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("uuid") {
matches.push((m.start(), m.end(), m.as_str(), self.uuid_style.clone()));
}
}
for cap in STRING_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("string") {
matches.push((m.start(), m.end(), m.as_str(), self.string_style.clone()));
}
}
for cap in BOOL_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("bool") {
matches.push((m.start(), m.end(), m.as_str(), self.bool_style.clone()));
}
}
for cap in NONE_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("none") {
matches.push((m.start(), m.end(), m.as_str(), self.none_style.clone()));
}
}
for cap in NUMBER_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("number") {
matches.push((m.start(), m.end(), m.as_str(), self.number_style.clone()));
}
}
let attr_re = Regex::new(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*[=:]").unwrap();
for cap in attr_re.captures_iter(text) {
if let Some(m) = cap.get(1) {
if !self.attr_style.is_empty() {
matches.push((m.start(), m.end(), m.as_str(), self.attr_style.clone()));
}
}
}
matches.sort_by_key(|m| m.0);
let mut filtered_matches: Vec<(usize, usize, &str, Style)> = Vec::new();
for m in matches {
if filtered_matches.last().map_or(true, |last| m.0 >= last.1) {
filtered_matches.push(m);
}
}
for (start, end, matched_text, style) in filtered_matches {
if start > last_end {
segments.push(Segment::new(&text[last_end..start]));
}
if style.is_empty() {
segments.push(Segment::new(matched_text));
} else {
segments.push(Segment::styled(matched_text, style));
}
last_end = end;
}
if last_end < text.len() {
segments.push(Segment::new(&text[last_end..]));
}
if segments.is_empty() {
segments.push(Segment::new(text));
}
segments
}
}
#[derive(Debug, Clone)]
pub struct ISOHighlighter {
date_style: Style,
time_style: Style,
timezone_style: Style,
}
impl Default for ISOHighlighter {
fn default() -> Self {
Self::new()
}
}
impl ISOHighlighter {
#[must_use]
pub fn new() -> Self {
Self {
date_style: Style::default().bold(),
time_style: Style::default(),
timezone_style: Style::default().dim(),
}
}
#[must_use]
pub fn date_style(mut self, style: Style) -> Self {
self.date_style = style;
self
}
#[must_use]
pub fn time_style(mut self, style: Style) -> Self {
self.time_style = style;
self
}
#[must_use]
pub fn timezone_style(mut self, style: Style) -> Self {
self.timezone_style = style;
self
}
}
static ISO_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?P<iso>(?P<date>\d{4}-\d{2}-\d{2})(?:T(?P<time>\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?P<tz>Z|[+-]\d{2}:?\d{2})?)?)").unwrap()
});
impl Highlighter for ISOHighlighter {
fn highlight(&self, text: &str) -> Segments {
let mut segments = Segments::new();
let mut last_end = 0;
for cap in ISO_PATTERN.captures_iter(text) {
if let Some(full_match) = cap.name("iso") {
if full_match.start() > last_end {
segments.push(Segment::new(&text[last_end..full_match.start()]));
}
if let Some(date) = cap.name("date") {
segments.push(Segment::styled(date.as_str(), self.date_style.clone()));
}
if let Some(time) = cap.name("time") {
segments.push(Segment::new("T"));
segments.push(Segment::styled(time.as_str(), self.time_style.clone()));
}
if let Some(tz) = cap.name("tz") {
segments.push(Segment::styled(tz.as_str(), self.timezone_style.clone()));
}
last_end = full_match.end();
}
}
if last_end < text.len() {
segments.push(Segment::new(&text[last_end..]));
}
if segments.is_empty() {
segments.push(Segment::new(text));
}
segments
}
}
#[derive(Debug, Clone)]
pub struct RegexHighlighter {
patterns: Vec<(Regex, Style)>,
}
impl Default for RegexHighlighter {
fn default() -> Self {
Self::new()
}
}
impl RegexHighlighter {
#[must_use]
pub fn new() -> Self {
Self {
patterns: Vec::new(),
}
}
#[must_use]
pub fn pattern(mut self, pattern: &str, style: Style) -> Self {
let regex = Regex::new(pattern).expect("Invalid regex pattern");
self.patterns.push((regex, style));
self
}
pub fn try_pattern(mut self, pattern: &str, style: Style) -> Result<Self, regex::Error> {
let regex = Regex::new(pattern)?;
self.patterns.push((regex, style));
Ok(self)
}
}
impl Highlighter for RegexHighlighter {
fn highlight(&self, text: &str) -> Segments {
let mut segments = Segments::new();
let mut last_end = 0;
let mut matches: Vec<(usize, usize, Style)> = Vec::new();
for (regex, style) in &self.patterns {
for m in regex.find_iter(text) {
matches.push((m.start(), m.end(), style.clone()));
}
}
matches.sort_by_key(|m| m.0);
let mut filtered: Vec<(usize, usize, Style)> = Vec::new();
for m in matches {
if filtered.last().map_or(true, |last| m.0 >= last.1) {
filtered.push(m);
}
}
for (start, end, style) in filtered {
if start > last_end {
segments.push(Segment::new(&text[last_end..start]));
}
segments.push(Segment::styled(&text[start..end], style));
last_end = end;
}
if last_end < text.len() {
segments.push(Segment::new(&text[last_end..]));
}
if segments.is_empty() {
segments.push(Segment::new(text));
}
segments
}
}
#[derive(Debug, Clone)]
pub struct JSONHighlighter {
key_style: Style,
string_style: Style,
number_style: Style,
bool_style: Style,
null_style: Style,
bracket_style: Style,
}
impl Default for JSONHighlighter {
fn default() -> Self {
Self::new()
}
}
impl JSONHighlighter {
#[must_use]
pub fn new() -> Self {
Self {
key_style: Style::default().bold(),
string_style: Style::default().italic(),
number_style: Style::default().bold(),
bool_style: Style::default().bold().italic(),
null_style: Style::default().dim(),
bracket_style: Style::default().bold(),
}
}
#[must_use]
pub fn key_style(mut self, style: Style) -> Self {
self.key_style = style;
self
}
#[must_use]
pub fn string_style(mut self, style: Style) -> Self {
self.string_style = style;
self
}
#[must_use]
pub fn number_style(mut self, style: Style) -> Self {
self.number_style = style;
self
}
#[must_use]
pub fn bool_style(mut self, style: Style) -> Self {
self.bool_style = style;
self
}
#[must_use]
pub fn null_style(mut self, style: Style) -> Self {
self.null_style = style;
self
}
#[must_use]
pub fn bracket_style(mut self, style: Style) -> Self {
self.bracket_style = style;
self
}
}
static JSON_KEY_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#""([^"\\]|\\.)*"\s*:"#).unwrap());
impl Highlighter for JSONHighlighter {
fn highlight(&self, text: &str) -> Segments {
let mut segments = Segments::new();
let mut last_end = 0;
let mut matches: Vec<(usize, usize, &str, Style)> = Vec::new();
for m in JSON_KEY_PATTERN.find_iter(text) {
let key_text = &text[m.start()..m.end() - 1]; matches.push((m.start(), m.end() - 1, key_text, self.key_style.clone()));
}
for (i, c) in text.char_indices() {
if matches!(c, '{' | '}' | '[' | ']') {
if !matches.iter().any(|(s, e, _, _)| i >= *s && i < *e) {
matches.push((i, i + 1, &text[i..i + 1], self.bracket_style.clone()));
}
}
}
for cap in STRING_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("string") {
let is_key = text[m.end()..].trim_start().starts_with(':');
if !is_key {
matches.push((m.start(), m.end(), m.as_str(), self.string_style.clone()));
}
}
}
for cap in NUMBER_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("number") {
matches.push((m.start(), m.end(), m.as_str(), self.number_style.clone()));
}
}
for cap in BOOL_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("bool") {
matches.push((m.start(), m.end(), m.as_str(), self.bool_style.clone()));
}
}
for cap in NONE_PATTERN.captures_iter(text) {
if let Some(m) = cap.name("none") {
matches.push((m.start(), m.end(), m.as_str(), self.null_style.clone()));
}
}
matches.sort_by_key(|m| m.0);
let mut filtered: Vec<(usize, usize, &str, Style)> = Vec::new();
for m in matches {
if filtered.last().map_or(true, |last| m.0 >= last.1) {
filtered.push(m);
}
}
for (start, end, matched_text, style) in filtered {
if start > last_end {
segments.push(Segment::new(&text[last_end..start]));
}
segments.push(Segment::styled(matched_text, style));
last_end = end;
}
if last_end < text.len() {
segments.push(Segment::new(&text[last_end..]));
}
if segments.is_empty() {
segments.push(Segment::new(text));
}
segments
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_repr_highlighter_new() {
let highlighter = ReprHighlighter::new();
assert!(!highlighter.number_style.is_empty());
}
#[test]
fn test_repr_highlighter_number() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("value = 42");
let output = segments.to_ansi();
assert!(output.contains("42"));
}
#[test]
fn test_repr_highlighter_string() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("name = \"hello\"");
let output = segments.to_ansi();
assert!(output.contains("hello"));
}
#[test]
fn test_repr_highlighter_bool() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("flag = true");
let output = segments.to_ansi();
assert!(output.contains("true"));
}
#[test]
fn test_repr_highlighter_none() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("value = None");
let output = segments.to_ansi();
assert!(output.contains("None"));
}
#[test]
fn test_repr_highlighter_url() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("Visit https://example.com");
let output = segments.to_ansi();
assert!(output.contains("https://example.com"));
}
#[test]
fn test_repr_highlighter_uuid() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("id = 123e4567-e89b-12d3-a456-426614174000");
let output = segments.to_ansi();
assert!(output.contains("123e4567"));
}
#[test]
fn test_repr_highlighter_hex() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("color = 0xff00ff");
let output = segments.to_ansi();
assert!(output.contains("0xff00ff"));
}
#[test]
fn test_iso_highlighter_new() {
let highlighter = ISOHighlighter::new();
assert!(!highlighter.date_style.is_empty());
}
#[test]
fn test_iso_highlighter_date() {
let highlighter = ISOHighlighter::new();
let segments = highlighter.highlight("Created 2024-01-15");
let output = segments.to_ansi();
assert!(output.contains("2024-01-15"));
}
#[test]
fn test_iso_highlighter_datetime() {
let highlighter = ISOHighlighter::new();
let segments = highlighter.highlight("Time: 2024-01-15T10:30:00Z");
let output = segments.to_ansi();
assert!(output.contains("2024-01-15"));
assert!(output.contains("10:30:00"));
}
#[test]
fn test_regex_highlighter_new() {
let highlighter = RegexHighlighter::new();
assert!(highlighter.patterns.is_empty());
}
#[test]
fn test_regex_highlighter_pattern() {
let highlighter = RegexHighlighter::new().pattern(r"\bERROR\b", Style::default().bold());
let segments = highlighter.highlight("ERROR: something failed");
let output = segments.to_ansi();
assert!(output.contains("ERROR"));
}
#[test]
fn test_regex_highlighter_try_pattern() {
let result = RegexHighlighter::new().try_pattern(r"\bWARN\b", Style::default());
assert!(result.is_ok());
}
#[test]
fn test_regex_highlighter_invalid_pattern() {
let result = RegexHighlighter::new().try_pattern(r"[invalid", Style::default());
assert!(result.is_err());
}
#[test]
fn test_json_highlighter_new() {
let highlighter = JSONHighlighter::new();
assert!(!highlighter.key_style.is_empty());
}
#[test]
fn test_json_highlighter_key() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight(r#"{"name": "value"}"#);
let output = segments.to_ansi();
assert!(output.contains("name"));
}
#[test]
fn test_json_highlighter_number() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight(r#"{"count": 42}"#);
let output = segments.to_ansi();
assert!(output.contains("42"));
}
#[test]
fn test_json_highlighter_bool() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight(r#"{"active": true}"#);
let output = segments.to_ansi();
assert!(output.contains("true"));
}
#[test]
fn test_json_highlighter_null() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight(r#"{"value": null}"#);
let output = segments.to_ansi();
assert!(output.contains("null"));
}
#[test]
fn test_empty_text() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("");
assert!(!segments.is_empty());
}
#[test]
fn test_no_matches() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("just plain text");
let output = segments.to_ansi();
assert_eq!(output, "just plain text");
}
#[test]
fn test_repr_highlighter_default() {
let highlighter = ReprHighlighter::default();
assert!(!highlighter.number_style.is_empty());
}
#[test]
fn test_repr_highlighter_custom_styles() {
let highlighter = ReprHighlighter::new()
.number_style(Style::default().bold())
.string_style(Style::default().italic())
.bool_style(Style::default().underline())
.none_style(Style::default().dim())
.attr_style(Style::default())
.url_style(Style::default().underline())
.uuid_style(Style::default().bold());
let segments = highlighter.highlight("num = 42, str = \"test\", url = https://x.com");
assert!(!segments.is_empty());
}
#[test]
fn test_repr_highlighter_binary() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("binary = 0b101010");
let output = segments.to_ansi();
assert!(output.contains("0b101010"));
}
#[test]
fn test_repr_highlighter_octal() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("octal = 0o755");
let output = segments.to_ansi();
assert!(output.contains("0o755"));
}
#[test]
fn test_repr_highlighter_float() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("float = 42.5");
let output = segments.to_ansi();
assert!(output.contains("42.5"));
}
#[test]
fn test_repr_highlighter_negative() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("negative = -42");
let output = segments.to_ansi();
assert!(output.contains("-42"));
}
#[test]
fn test_repr_highlighter_scientific() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("scientific = 1e10");
let output = segments.to_ansi();
assert!(output.contains("1e10"));
}
#[test]
fn test_repr_highlighter_single_quote_string() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("name = 'hello'");
let output = segments.to_ansi();
assert!(output.contains("hello"));
}
#[test]
fn test_repr_highlighter_false_bool() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("flag = false");
let output = segments.to_ansi();
assert!(output.contains("false"));
}
#[test]
fn test_repr_highlighter_true_python_style() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("flag = True");
let output = segments.to_ansi();
assert!(output.contains("True"));
}
#[test]
fn test_repr_highlighter_null() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("value = null");
let output = segments.to_ansi();
assert!(output.contains("null"));
}
#[test]
fn test_repr_highlighter_nil() {
let highlighter = ReprHighlighter::new();
let segments = highlighter.highlight("value = nil");
let output = segments.to_ansi();
assert!(output.contains("nil"));
}
#[test]
fn test_repr_highlighter_attr_style() {
let highlighter = ReprHighlighter::new().attr_style(Style::default().bold());
let segments = highlighter.highlight("name = test");
assert!(!segments.is_empty());
}
#[test]
fn test_iso_highlighter_default() {
let highlighter = ISOHighlighter::default();
assert!(!highlighter.date_style.is_empty());
}
#[test]
fn test_iso_highlighter_custom_styles() {
let highlighter = ISOHighlighter::new()
.date_style(Style::default().bold())
.time_style(Style::default().italic())
.timezone_style(Style::default().dim());
let segments = highlighter.highlight("2024-01-15T10:30:00Z");
assert!(!segments.is_empty());
}
#[test]
fn test_iso_highlighter_with_offset() {
let highlighter = ISOHighlighter::new();
let segments = highlighter.highlight("Time: 2024-01-15T10:30:00+05:30");
let output = segments.to_ansi();
assert!(output.contains("2024-01-15"));
assert!(output.contains("10:30:00"));
assert!(output.contains("+05:30"));
}
#[test]
fn test_iso_highlighter_date_only() {
let highlighter = ISOHighlighter::new();
let segments = highlighter.highlight("2024-01-15");
let output = segments.to_ansi();
assert!(output.contains("2024-01-15"));
}
#[test]
fn test_iso_highlighter_no_match() {
let highlighter = ISOHighlighter::new();
let segments = highlighter.highlight("not a date");
let output = segments.to_ansi();
assert_eq!(output, "not a date");
}
#[test]
fn test_iso_highlighter_empty() {
let highlighter = ISOHighlighter::new();
let segments = highlighter.highlight("");
assert!(!segments.is_empty());
}
#[test]
fn test_regex_highlighter_default() {
let highlighter = RegexHighlighter::default();
assert!(highlighter.patterns.is_empty());
}
#[test]
fn test_regex_highlighter_multiple_patterns() {
let highlighter = RegexHighlighter::new()
.pattern(r"\bERROR\b", Style::default().bold())
.pattern(r"\bWARN\b", Style::default().italic());
let segments = highlighter.highlight("ERROR and WARN messages");
let output = segments.to_ansi();
assert!(output.contains("ERROR"));
assert!(output.contains("WARN"));
}
#[test]
fn test_regex_highlighter_no_match() {
let highlighter = RegexHighlighter::new().pattern(r"\bERROR\b", Style::default());
let segments = highlighter.highlight("no errors here");
let output = segments.to_ansi();
assert_eq!(output, "no errors here");
}
#[test]
fn test_regex_highlighter_empty() {
let highlighter = RegexHighlighter::new();
let segments = highlighter.highlight("");
assert!(!segments.is_empty());
}
#[test]
fn test_json_highlighter_default() {
let highlighter = JSONHighlighter::default();
assert!(!highlighter.key_style.is_empty());
}
#[test]
fn test_json_highlighter_custom_styles() {
let highlighter = JSONHighlighter::new()
.key_style(Style::default().bold())
.string_style(Style::default().italic())
.number_style(Style::default().bold())
.bool_style(Style::default().underline())
.null_style(Style::default().dim())
.bracket_style(Style::default().bold());
let segments = highlighter.highlight(r#"{"key": "value"}"#);
assert!(!segments.is_empty());
}
#[test]
fn test_json_highlighter_brackets() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight(r#"{"arr": [1, 2]}"#);
let output = segments.to_ansi();
assert!(output.contains("{"));
assert!(output.contains("["));
}
#[test]
fn test_json_highlighter_string_value() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight(r#"{"name": "Alice"}"#);
let output = segments.to_ansi();
assert!(output.contains("Alice"));
}
#[test]
fn test_json_highlighter_empty() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight("");
assert!(!segments.is_empty());
}
#[test]
fn test_json_highlighter_no_match() {
let highlighter = JSONHighlighter::new();
let segments = highlighter.highlight("plain text");
let output = segments.to_ansi();
assert!(output.contains("plain text"));
}
}