use crate::style::Style;
use crate::text::{Span, Text};
use regex::Regex;
pub trait Highlighter {
fn highlight(&self, text: &str) -> Vec<Span>;
}
#[derive(Debug, Clone)]
pub struct RegexHighlighter {
patterns: Vec<(Regex, Style)>,
}
impl RegexHighlighter {
pub fn new() -> Self {
RegexHighlighter {
patterns: Vec::new(),
}
}
pub fn add_pattern(&mut self, pattern: &str, style: Style) -> Result<(), regex::Error> {
let regex = Regex::new(pattern)?;
self.patterns.push((regex, style));
Ok(())
}
pub fn with_pattern(mut self, pattern: &str, style: Style) -> Result<Self, regex::Error> {
self.add_pattern(pattern, style)?;
Ok(self)
}
pub fn url_highlighter(style: Style) -> Self {
let mut hl = RegexHighlighter::new();
let _ = hl.add_pattern(r"https?://[^\s]+", style);
hl
}
pub fn number_highlighter(style: Style) -> Self {
let mut hl = RegexHighlighter::new();
let _ = hl.add_pattern(r"\b\d+\.?\d*\b", style);
hl
}
pub fn email_highlighter(style: Style) -> Self {
let mut hl = RegexHighlighter::new();
let _ = hl.add_pattern(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
style,
);
hl
}
}
impl Default for RegexHighlighter {
fn default() -> Self {
Self::new()
}
}
impl Highlighter for RegexHighlighter {
fn highlight(&self, text: &str) -> Vec<Span> {
if self.patterns.is_empty() {
return vec![Span::raw(text.to_string())];
}
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));
}
}
matches.sort_by_key(|m| m.0);
let mut spans = Vec::new();
let mut last_end = 0;
for (start, end, style) in matches {
if start < last_end {
continue;
}
if start > last_end {
spans.push(Span::raw(text[last_end..start].to_string()));
}
spans.push(Span::styled(text[start..end].to_string(), style));
last_end = end;
}
if last_end < text.len() {
spans.push(Span::raw(text[last_end..].to_string()));
}
if spans.is_empty() {
vec![Span::raw(text.to_string())]
} else {
spans
}
}
}
pub fn highlight_text(text: &str, highlighter: &impl Highlighter) -> Text {
let spans = highlighter.highlight(text);
Text::from_spans(spans)
}
#[derive(Debug, Clone)]
pub struct JsonHighlighter {
string_re: Regex,
number_re: Regex,
bool_true_re: Regex,
bool_false_re: Regex,
null_re: Regex,
brace_re: Regex,
hex_re: Regex,
key_style: Style,
string_style: Style,
number_style: Style,
bool_true_style: Style,
bool_false_style: Style,
null_style: Style,
brace_style: Style,
}
impl Default for JsonHighlighter {
fn default() -> Self {
Self::new()
}
}
impl JsonHighlighter {
pub fn new() -> Self {
use crate::style::Color;
JsonHighlighter {
string_re: Regex::new(r#""(?:[^"\\]|\\.)*""#).unwrap(),
number_re: Regex::new(r"-?\b\d+\.?\d*(?:[eE][+-]?\d+)?\b").unwrap(),
bool_true_re: Regex::new(r"\btrue\b").unwrap(),
bool_false_re: Regex::new(r"\bfalse\b").unwrap(),
null_re: Regex::new(r"\bnull\b").unwrap(),
brace_re: Regex::new(r"[\{\}\[\]\(\)]").unwrap(),
hex_re: Regex::new(r"0x[0-9a-fA-F]+\b").unwrap(),
key_style: Style::new().foreground(Color::Blue).bold(),
string_style: Style::new().foreground(Color::Green),
number_style: Style::new().foreground(Color::Cyan),
bool_true_style: Style::new().foreground(Color::BrightGreen),
bool_false_style: Style::new().foreground(Color::BrightRed),
null_style: Style::new().foreground(Color::Magenta).italic(),
brace_style: Style::new().foreground(Color::White).dim(),
}
}
pub fn key_style(mut self, style: Style) -> Self {
self.key_style = style;
self
}
pub fn string_style(mut self, style: Style) -> Self {
self.string_style = style;
self
}
pub fn number_style(mut self, style: Style) -> Self {
self.number_style = style;
self
}
}
impl Highlighter for JsonHighlighter {
fn highlight(&self, text: &str) -> Vec<Span> {
let mut matches: Vec<(usize, usize, Style)> = Vec::new();
for m in self.string_re.find_iter(text) {
let rest = &text[m.end()..];
let is_key = rest.trim_start().starts_with(':');
let style = if is_key {
self.key_style
} else {
self.string_style
};
matches.push((m.start(), m.end(), style));
}
for m in self.number_re.find_iter(text) {
let overlaps = matches
.iter()
.any(|(s, e, _)| m.start() >= *s && m.end() <= *e);
if !overlaps {
matches.push((m.start(), m.end(), self.number_style));
}
}
for m in self.bool_true_re.find_iter(text) {
let overlaps = matches
.iter()
.any(|(s, e, _)| m.start() >= *s && m.end() <= *e);
if !overlaps {
matches.push((m.start(), m.end(), self.bool_true_style));
}
}
for m in self.bool_false_re.find_iter(text) {
let overlaps = matches
.iter()
.any(|(s, e, _)| m.start() >= *s && m.end() <= *e);
if !overlaps {
matches.push((m.start(), m.end(), self.bool_false_style));
}
}
for m in self.null_re.find_iter(text) {
let overlaps = matches
.iter()
.any(|(s, e, _)| m.start() >= *s && m.end() <= *e);
if !overlaps {
matches.push((m.start(), m.end(), self.null_style));
}
}
for m in self.hex_re.find_iter(text) {
let overlaps = matches
.iter()
.any(|(s, e, _)| m.start() >= *s && m.end() <= *e);
if !overlaps {
matches.push((m.start(), m.end(), self.number_style));
}
}
for m in self.brace_re.find_iter(text) {
let overlaps = matches
.iter()
.any(|(s, e, _)| m.start() >= *s && m.end() <= *e);
if !overlaps {
matches.push((m.start(), m.end(), self.brace_style));
}
}
matches.sort_by_key(|m| m.0);
let mut spans = Vec::new();
let mut last_end = 0;
for (start, end, style) in matches {
if start < last_end {
continue;
}
if start > last_end {
spans.push(Span::raw(text[last_end..start].to_string()));
}
spans.push(Span::styled(text[start..end].to_string(), style));
last_end = end;
}
if last_end < text.len() {
spans.push(Span::raw(text[last_end..].to_string()));
}
if spans.is_empty() {
vec![Span::raw(text.to_string())]
} else {
spans
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Color;
#[test]
fn test_regex_highlighter() {
let mut hl = RegexHighlighter::new();
hl.add_pattern(r"\d+", Style::new().foreground(Color::Cyan))
.unwrap();
let spans = hl.highlight("Port 8080 is open");
assert_eq!(spans.len(), 3); }
#[test]
fn test_url_highlighter() {
let hl = RegexHighlighter::url_highlighter(Style::new().foreground(Color::Blue));
let spans = hl.highlight("Visit https://example.com for info");
assert!(spans.len() > 1);
}
#[test]
fn test_json_highlighter_keys() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"name": "value"}"#);
let key_span = spans.iter().find(|s| s.text == r#""name""#).unwrap();
assert!(key_span.style.bold, "Key should be bold");
assert_eq!(
key_span.style.foreground,
Some(Color::Blue),
"Key should be blue"
);
}
#[test]
fn test_json_highlighter_string_values() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"key": "value"}"#);
let value_span = spans.iter().find(|s| s.text == r#""value""#).unwrap();
assert_eq!(
value_span.style.foreground,
Some(Color::Green),
"String value should be green"
);
assert!(!value_span.style.bold, "String value should not be bold");
}
#[test]
fn test_json_highlighter_numbers() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"count": 42}"#);
let num_span = spans.iter().find(|s| s.text == "42").unwrap();
assert_eq!(
num_span.style.foreground,
Some(Color::Cyan),
"Number should be cyan"
);
let spans = hl.highlight(r#"{"pi": 3.14}"#);
let num_span = spans.iter().find(|s| s.text == "3.14").unwrap();
assert_eq!(
num_span.style.foreground,
Some(Color::Cyan),
"Float should be cyan"
);
let spans = hl.highlight(r#"{"temp": -10}"#);
let num_span = spans.iter().find(|s| s.text == "-10").unwrap();
assert_eq!(
num_span.style.foreground,
Some(Color::Cyan),
"Negative number should be cyan"
);
let spans = hl.highlight(r#"{"big": 1.5e10}"#);
let num_span = spans.iter().find(|s| s.text == "1.5e10").unwrap();
assert_eq!(
num_span.style.foreground,
Some(Color::Cyan),
"Scientific notation should be cyan"
);
}
#[test]
fn test_json_highlighter_hex_numbers() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"hex": 0x1A2B}"#);
let hex_span = spans.iter().find(|s| s.text == "0x1A2B").unwrap();
assert_eq!(
hex_span.style.foreground,
Some(Color::Cyan),
"Hex number should be cyan"
);
}
#[test]
fn test_json_highlighter_booleans() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"active": true}"#);
let true_span = spans.iter().find(|s| s.text == "true").unwrap();
assert_eq!(
true_span.style.foreground,
Some(Color::BrightGreen),
"true should be bright green"
);
let spans = hl.highlight(r#"{"active": false}"#);
let false_span = spans.iter().find(|s| s.text == "false").unwrap();
assert_eq!(
false_span.style.foreground,
Some(Color::BrightRed),
"false should be bright red"
);
}
#[test]
fn test_json_highlighter_null() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"value": null}"#);
let null_span = spans.iter().find(|s| s.text == "null").unwrap();
assert_eq!(
null_span.style.foreground,
Some(Color::Magenta),
"null should be magenta"
);
assert!(null_span.style.italic, "null should be italic");
}
#[test]
fn test_json_highlighter_braces() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"arr": [1, 2], "obj": {}}"#);
let brace_chars = ["{", "}", "[", "]"];
for ch in &brace_chars {
let brace_span = spans.iter().find(|s| s.text == *ch);
assert!(
brace_span.is_some(),
"Brace/bracket '{}' should be found",
ch
);
let brace_span = brace_span.unwrap();
assert_eq!(
brace_span.style.foreground,
Some(Color::White),
"Brace should be white"
);
assert!(brace_span.style.dim, "Brace should be dim");
}
}
#[test]
fn test_json_highlighter_parentheses() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"func": (value)}"#);
let paren_span = spans.iter().find(|s| s.text == "(");
assert!(paren_span.is_some(), "Opening parenthesis should be found");
let paren_span = paren_span.unwrap();
assert_eq!(
paren_span.style.foreground,
Some(Color::White),
"Parenthesis should be white"
);
assert!(paren_span.style.dim, "Parenthesis should be dim");
}
#[test]
fn test_json_highlighter_no_highlight_in_strings() {
let hl = JsonHighlighter::new();
let spans = hl.highlight(r#"{"text": "true false null 123"}"#);
let value_span = spans
.iter()
.find(|s| s.text == r#""true false null 123""#)
.unwrap();
assert_eq!(
value_span.style.foreground,
Some(Color::Green),
"String content should be green"
);
let separate_true = spans
.iter()
.any(|s| s.text == "true" && s.style.foreground == Some(Color::BrightGreen));
let separate_false = spans
.iter()
.any(|s| s.text == "false" && s.style.foreground == Some(Color::BrightRed));
let separate_null = spans
.iter()
.any(|s| s.text == "null" && s.style.foreground == Some(Color::Magenta));
let separate_num = spans
.iter()
.any(|s| s.text == "123" && s.style.foreground == Some(Color::Cyan));
assert!(
!separate_true,
"Keywords inside strings should not be highlighted separately"
);
assert!(
!separate_false,
"Keywords inside strings should not be highlighted separately"
);
assert!(
!separate_null,
"Keywords inside strings should not be highlighted separately"
);
assert!(
!separate_num,
"Numbers inside strings should not be highlighted separately"
);
}
#[test]
fn test_json_highlighter_complex_json() {
let hl = JsonHighlighter::new();
let json =
r#"{"name": "John", "age": 30, "active": true, "data": null, "items": [1, 2, 3]}"#;
let spans = hl.highlight(json);
assert!(
spans.len() > 1,
"Complex JSON should have multiple styled spans"
);
let name_key = spans.iter().find(|s| s.text == r#""name""#).unwrap();
assert!(name_key.style.bold, "Key should be bold");
let name_value = spans.iter().find(|s| s.text == r#""John""#).unwrap();
assert_eq!(
name_value.style.foreground,
Some(Color::Green),
"String value should be green"
);
let age_value = spans.iter().find(|s| s.text == "30").unwrap();
assert_eq!(
age_value.style.foreground,
Some(Color::Cyan),
"Number should be cyan"
);
let active_value = spans.iter().find(|s| s.text == "true").unwrap();
assert_eq!(
active_value.style.foreground,
Some(Color::BrightGreen),
"Boolean true should be bright green"
);
let null_value = spans.iter().find(|s| s.text == "null").unwrap();
assert_eq!(
null_value.style.foreground,
Some(Color::Magenta),
"null should be magenta"
);
}
#[test]
fn test_json_highlighter_custom_styles() {
let custom_key_style = Style::new().foreground(Color::Yellow);
let custom_string_style = Style::new().foreground(Color::Red);
let custom_number_style = Style::new().foreground(Color::Magenta);
let hl = JsonHighlighter::new()
.key_style(custom_key_style)
.string_style(custom_string_style)
.number_style(custom_number_style);
let spans = hl.highlight(r#"{"key": "value", "num": 42}"#);
let key_span = spans.iter().find(|s| s.text == r#""key""#).unwrap();
assert_eq!(
key_span.style.foreground,
Some(Color::Yellow),
"Custom key style should be yellow"
);
let value_span = spans.iter().find(|s| s.text == r#""value""#).unwrap();
assert_eq!(
value_span.style.foreground,
Some(Color::Red),
"Custom string style should be red"
);
let num_span = spans.iter().find(|s| s.text == "42").unwrap();
assert_eq!(
num_span.style.foreground,
Some(Color::Magenta),
"Custom number style should be magenta"
);
}
}