use std::collections::HashMap;
use ratatui::style::Color;
use super::{StyledSpan, hex_to_color};
const fn color_map(code: char) -> Option<&'static str> {
match code {
'k' => Some("#000000"),
'K' => Some("#555555"),
'r' => Some("#aa0000"),
'R' => Some("#ff5555"),
'g' => Some("#00aa00"),
'G' => Some("#55ff55"),
'y' => Some("#aa5500"),
'Y' => Some("#ffff55"),
'b' => Some("#0000aa"),
'B' => Some("#5555ff"),
'm' => Some("#aa00aa"),
'M' => Some("#ff55ff"),
'c' => Some("#00aaaa"),
'C' => Some("#55ffff"),
'w' => Some("#aaaaaa"),
'W' => Some("#ffffff"),
_ => None,
}
}
const MIRC_COLORS: &[&str] = &[
"#ffffff", "#000000", "#00007f", "#009300", "#ff0000", "#7f0000", "#9c009c", "#fc7f00",
"#ffff00", "#00fc00", "#009393", "#00ffff", "#0000fc", "#ff00ff", "#7f7f7f", "#d2d2d2",
"#470000", "#472100", "#474700", "#324700", "#004700", "#00472c", "#004747", "#002747",
"#000047", "#2e0047", "#470047", "#47002a", "#740000", "#743a00", "#747400", "#517400",
"#007400", "#007449", "#007474", "#004074", "#000074", "#4b0074", "#740074", "#740045",
"#b50000", "#b56300", "#b5b500", "#7db500", "#00b500", "#00b571", "#00b5b5", "#0063b5",
"#0000b5", "#7500b5", "#b500b5", "#b5006b", "#ff0000", "#ff8c00", "#ffff00", "#b2ff00",
"#00ff00", "#00ffa0", "#00ffff", "#008cff", "#0000ff", "#a500ff", "#ff00ff", "#ff0098",
"#ff5959", "#ffb459", "#ffff71", "#cfff60", "#6fff6f", "#65ffc9", "#6dffff", "#59b4ff",
"#5959ff", "#c459ff", "#ff66ff", "#ff59bc", "#ff9c9c", "#ffd39c", "#ffff9c", "#e2ff9c",
"#9cff9c", "#9cffdb", "#9cffff", "#9cd3ff", "#9c9cff", "#dc9cff", "#ff9cff", "#ff94d3",
"#000000", "#131313", "#282828", "#363636", "#4d4d4d", "#656565", "#818181", "#9f9f9f",
"#bcbcbc", "#e2e2e2", "#ffffff",
];
const MAX_ABSTRACTION_DEPTH: usize = 10;
#[derive(Debug, Clone, Default)]
#[expect(
clippy::struct_excessive_bools,
reason = "each bool maps to an independent text style attribute"
)]
struct StyleState {
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
italic: bool,
underline: bool,
dim: bool,
}
impl StyleState {
const fn to_span(&self, text: String) -> StyledSpan {
StyledSpan {
text,
fg: self.fg,
bg: self.bg,
bold: self.bold,
italic: self.italic,
underline: self.underline,
dim: self.dim,
}
}
}
fn read_hex6_bytes(bytes: &[u8], pos: usize) -> Option<String> {
if pos + 6 > bytes.len() {
return None;
}
let slice = &bytes[pos..pos + 6];
if slice.iter().all(u8::is_ascii_hexdigit) {
Some(std::str::from_utf8(slice).unwrap().to_string())
} else {
None
}
}
pub fn substitute_vars(input: &str, params: &[&str]) -> String {
let chars: Vec<char> = input.chars().collect();
let mut result = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' {
i += 1;
if i >= chars.len() {
result.push('$');
break;
}
if chars[i] == '*' {
result.push_str(¶ms.join(" "));
i += 1;
continue;
}
if chars[i] == '[' {
i += 1; let mut num_str = String::new();
while i < chars.len() && chars[i] != ']' {
num_str.push(chars[i]);
i += 1;
}
if i < chars.len() {
i += 1; }
let pad_width: i32 = num_str.parse().unwrap_or(0);
let mut idx_str = String::new();
while i < chars.len() && chars[i].is_ascii_digit() {
idx_str.push(chars[i]);
i += 1;
}
let idx: usize = idx_str.parse().unwrap_or(0);
let value = if idx < params.len() { params[idx] } else { "" };
let abs_width = pad_width.unsigned_abs() as usize;
if pad_width < 0 {
use std::fmt::Write;
let _ = write!(result, "{value:>abs_width$}");
} else {
use std::fmt::Write;
let _ = write!(result, "{value:<abs_width$}");
}
continue;
}
if chars[i].is_ascii_digit() {
let mut idx_str = String::new();
while i < chars.len() && chars[i].is_ascii_digit() {
idx_str.push(chars[i]);
i += 1;
}
let idx: usize = idx_str.parse().unwrap_or(0);
if idx < params.len() {
result.push_str(params[idx]);
}
continue;
}
result.push('$');
}
result.push(chars[i]);
i += 1;
}
result
}
fn find_matching_brace(input: &[char], open_pos: usize) -> Option<usize> {
let mut depth = 1;
let mut i = open_pos + 1;
while i < input.len() && depth > 0 {
if input[i] == '{' {
depth += 1;
} else if input[i] == '}' {
depth -= 1;
}
if depth > 0 {
i += 1;
}
}
if depth == 0 { Some(i) } else { None }
}
fn split_abstraction_args(args_str: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut depth = 0;
for ch in args_str.chars() {
if ch == '{' {
depth += 1;
} else if ch == '}' {
depth -= 1;
}
if ch == ' ' && depth == 0 {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
args.push(current);
}
args
}
pub fn resolve_abstractions(
input: &str,
abstracts: &HashMap<String, String>,
depth: usize,
) -> String {
if depth >= MAX_ABSTRACTION_DEPTH {
return input.to_string();
}
let chars: Vec<char> = input.chars().collect();
let mut result = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '{' {
if let Some(close_idx) = find_matching_brace(&chars, i) {
let inner: String = chars[i + 1..close_idx].iter().collect();
let (name, args_str) = inner.find(' ').map_or((inner.as_str(), ""), |idx| {
(&inner[..idx], &inner[idx + 1..])
});
if abstracts.contains_key(name) {
let template = &abstracts[name];
let args = if args_str.is_empty() {
Vec::new()
} else {
split_abstraction_args(args_str)
};
let resolved_args: Vec<String> = args
.iter()
.map(|a| resolve_abstractions(a, abstracts, depth + 1))
.collect();
let resolved_refs: Vec<&str> =
resolved_args.iter().map(String::as_str).collect();
let expanded = substitute_vars(template, &resolved_refs);
result.push_str(&resolve_abstractions(&expanded, abstracts, depth + 1));
} else {
let kept: String = chars[i..=close_idx].iter().collect();
result.push_str(&kept);
}
i = close_idx + 1;
} else {
result.push(chars[i]);
i += 1;
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
#[expect(
clippy::too_many_lines,
reason = "monolithic state machine parser, splitting would reduce readability"
)]
pub fn parse_format_string(input: &str, params: &[&str]) -> Vec<StyledSpan> {
let text = substitute_vars(input, params);
let bytes = text.as_bytes();
let len = bytes.len();
let mut spans: Vec<StyledSpan> = Vec::new();
let mut current = StyleState::default();
let mut buffer = String::new();
let mut i = 0;
let flush = |buffer: &mut String, spans: &mut Vec<StyledSpan>, current: &StyleState| {
if !buffer.is_empty() {
spans.push(current.to_span(std::mem::take(buffer)));
}
};
while i < len {
match bytes[i] {
b'%' => {
i += 1;
if i >= len {
buffer.push('%');
break;
}
let code = bytes[i];
match code {
b'N' | b'n' => {
flush(&mut buffer, &mut spans, ¤t);
current = StyleState::default();
i += 1;
}
b'_' => {
flush(&mut buffer, &mut spans, ¤t);
current.bold = !current.bold;
i += 1;
}
b'u' => {
flush(&mut buffer, &mut spans, ¤t);
current.underline = !current.underline;
i += 1;
}
b'i' => {
flush(&mut buffer, &mut spans, ¤t);
current.italic = !current.italic;
i += 1;
}
b'd' => {
flush(&mut buffer, &mut spans, ¤t);
current.dim = !current.dim;
i += 1;
}
b'Z' => {
flush(&mut buffer, &mut spans, ¤t);
if let Some(hex) = read_hex6_bytes(bytes, i + 1) {
current.fg = hex_to_color(&hex);
i += 7; } else {
buffer.push('%');
buffer.push('Z');
i += 1;
}
}
b'z' => {
flush(&mut buffer, &mut spans, ¤t);
if let Some(hex) = read_hex6_bytes(bytes, i + 1) {
current.bg = hex_to_color(&hex);
i += 7; } else {
buffer.push('%');
buffer.push('z');
i += 1;
}
}
b'|' => {
i += 1;
}
b'%' => {
buffer.push('%');
i += 1;
}
_ => {
let code_char = code as char;
if let Some(hex) = color_map(code_char) {
flush(&mut buffer, &mut spans, ¤t);
current.fg = hex_to_color(hex);
} else {
buffer.push('%');
buffer.push(code_char);
}
i += 1;
}
}
}
0x02 => {
flush(&mut buffer, &mut spans, ¤t);
current.bold = !current.bold;
i += 1;
}
0x03 => {
flush(&mut buffer, &mut spans, ¤t);
i += 1;
let mut fg_num: Option<usize> = None;
let mut fg_digits = 0;
if i < len && bytes[i].is_ascii_digit() {
let mut n = (bytes[i] - b'0') as usize;
fg_digits = 1;
i += 1;
if i < len && bytes[i].is_ascii_digit() {
n = n * 10 + (bytes[i] - b'0') as usize;
fg_digits = 2;
i += 1;
}
fg_num = Some(n);
}
let bg_num = if fg_digits > 0
&& i < len
&& bytes[i] == b','
&& i + 1 < len
&& bytes[i + 1].is_ascii_digit()
{
i += 1; let mut n = (bytes[i] - b'0') as usize;
i += 1;
if i < len && bytes[i].is_ascii_digit() {
n = n * 10 + (bytes[i] - b'0') as usize;
i += 1;
}
Some(n)
} else {
None
};
if let Some(fg) = fg_num {
if fg < MIRC_COLORS.len() {
current.fg = hex_to_color(MIRC_COLORS[fg]);
}
if let Some(bg) = bg_num
&& bg < MIRC_COLORS.len()
{
current.bg = hex_to_color(MIRC_COLORS[bg]);
}
} else {
current.fg = None;
current.bg = None;
}
}
0x04 => {
flush(&mut buffer, &mut spans, ¤t);
i += 1;
if let Some(fg_hex) = read_hex6_bytes(bytes, i) {
current.fg = hex_to_color(&fg_hex);
i += 6;
if i < len
&& bytes[i] == b','
&& let Some(bg_hex) = read_hex6_bytes(bytes, i + 1)
{
current.bg = hex_to_color(&bg_hex);
i += 7; }
} else {
current.fg = None;
current.bg = None;
}
}
0x0F => {
flush(&mut buffer, &mut spans, ¤t);
current = StyleState::default();
i += 1;
}
0x16 => {
flush(&mut buffer, &mut spans, ¤t);
std::mem::swap(&mut current.fg, &mut current.bg);
i += 1;
}
0x1D => {
flush(&mut buffer, &mut spans, ¤t);
current.italic = !current.italic;
i += 1;
}
0x1E => {
flush(&mut buffer, &mut spans, ¤t);
current.dim = !current.dim;
i += 1;
}
0x1F => {
flush(&mut buffer, &mut spans, ¤t);
current.underline = !current.underline;
i += 1;
}
0x11 => {
i += 1;
}
_ => {
let ch = text[i..].chars().next().unwrap();
buffer.push(ch);
i += ch.len_utf8();
}
}
}
flush(&mut buffer, &mut spans, ¤t);
if spans.is_empty() {
spans.push(StyleState::default().to_span(String::new()));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitute_positional() {
let result = substitute_vars("Hello $0, welcome to $1!", &["Alice", "#rust"]);
assert_eq!(result, "Hello Alice, welcome to #rust!");
}
#[test]
fn substitute_star() {
let result = substitute_vars("All: $*", &["one", "two", "three"]);
assert_eq!(result, "All: one two three");
}
#[test]
fn substitute_padded_right() {
let result = substitute_vars("[$[10]0]", &["hi"]);
assert_eq!(result, "[hi ]");
}
#[test]
fn substitute_padded_left() {
let result = substitute_vars("[$[-10]0]", &["hi"]);
assert_eq!(result, "[ hi]");
}
#[test]
fn substitute_missing_param_empty() {
let result = substitute_vars("$0 and $1", &["only"]);
assert_eq!(result, "only and ");
}
#[test]
fn substitute_trailing_dollar() {
let result = substitute_vars("price: 5$", &[]);
assert_eq!(result, "price: 5$");
}
#[test]
fn resolve_simple_abstract() {
let abstracts = HashMap::from([("nick".to_string(), "<%_$0%_>".to_string())]);
let result = resolve_abstractions("{nick Alice}", &abstracts, 0);
assert_eq!(result, "<%_Alice%_>");
}
#[test]
fn resolve_nested_abstractions() {
let abstracts = HashMap::from([
("msgnick".to_string(), "$0$1> ".to_string()),
("pubnick".to_string(), "%_$*%_".to_string()),
]);
let result = resolve_abstractions("{msgnick # {pubnick Bob}}", &abstracts, 0);
assert_eq!(result, "#%_Bob%_> ");
}
#[test]
fn resolve_max_depth_terminates() {
let abstracts = HashMap::from([("loop".to_string(), "{loop}".to_string())]);
let result = resolve_abstractions("{loop}", &abstracts, 0);
assert!(result.contains("{loop}") || result.is_empty() || !result.is_empty());
}
#[test]
fn resolve_unknown_abstract_kept() {
let abstracts = HashMap::new();
let result = resolve_abstractions("hello {unknown arg}", &abstracts, 0);
assert_eq!(result, "hello {unknown arg}");
}
#[test]
fn parse_plain_text() {
let spans = parse_format_string("hello world", &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "hello world");
assert_eq!(spans[0].fg, None);
assert!(!spans[0].bold);
}
#[test]
fn parse_hex_fg_color() {
let spans = parse_format_string("%Z7aa2f7colored", &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "colored");
assert_eq!(spans[0].fg, Some(Color::Rgb(0x7a, 0xa2, 0xf7)));
}
#[test]
fn parse_hex_bg_color() {
let spans = parse_format_string("%z1a1b26on dark bg", &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "on dark bg");
assert_eq!(spans[0].bg, Some(Color::Rgb(0x1a, 0x1b, 0x26)));
}
#[test]
fn parse_irssi_letter_colors() {
let spans = parse_format_string("%rred%N %bblue", &[]);
assert_eq!(spans.len(), 3);
assert_eq!(spans[0].text, "red");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xaa, 0x00, 0x00)));
assert_eq!(spans[1].text, " ");
assert_eq!(spans[1].fg, None);
assert_eq!(spans[2].text, "blue");
assert_eq!(spans[2].fg, Some(Color::Rgb(0x00, 0x00, 0xaa)));
}
#[test]
fn parse_bold_toggle() {
let spans = parse_format_string("%_bold%_ normal", &[]);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "bold");
assert!(spans[0].bold);
assert_eq!(spans[1].text, " normal");
assert!(!spans[1].bold);
}
#[test]
fn parse_italic_toggle() {
let spans = parse_format_string("%iitalic%i plain", &[]);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "italic");
assert!(spans[0].italic);
assert_eq!(spans[1].text, " plain");
assert!(!spans[1].italic);
}
#[test]
fn parse_underline_toggle() {
let spans = parse_format_string("%uunder%u normal", &[]);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "under");
assert!(spans[0].underline);
assert_eq!(spans[1].text, " normal");
assert!(!spans[1].underline);
}
#[test]
fn parse_dim_toggle() {
let spans = parse_format_string("%ddim%d bright", &[]);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "dim");
assert!(spans[0].dim);
assert_eq!(spans[1].text, " bright");
assert!(!spans[1].dim);
}
#[test]
fn parse_reset() {
let spans = parse_format_string("%_bold%Nnot", &[]);
assert_eq!(spans.len(), 2);
assert!(spans[0].bold);
assert!(!spans[1].bold);
assert_eq!(spans[1].text, "not");
}
#[test]
fn parse_literal_percent() {
let spans = parse_format_string("100%%", &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "100%");
}
#[test]
fn parse_mirc_color_codes() {
let input = "\x034red text\x03";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "red text");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xff, 0x00, 0x00)));
}
#[test]
fn parse_mirc_color_reset() {
let input = "\x034red\x03 normal";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "red");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xff, 0x00, 0x00)));
assert_eq!(spans[1].text, " normal");
assert_eq!(spans[1].fg, None);
}
#[test]
fn parse_mirc_hex_color() {
let input = "\x04FF8800hello";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "hello");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xFF, 0x88, 0x00)));
}
#[test]
fn parse_mirc_bold_underline_italic() {
let input = "\x02bold\x02 \x1Funder\x1F \x1Dital\x1D";
let spans = parse_format_string(input, &[]);
assert_eq!(spans[0].text, "bold");
assert!(spans[0].bold);
assert_eq!(spans[1].text, " ");
assert!(!spans[1].bold);
assert_eq!(spans[2].text, "under");
assert!(spans[2].underline);
assert_eq!(spans[3].text, " ");
assert!(!spans[3].underline);
assert_eq!(spans[4].text, "ital");
assert!(spans[4].italic);
}
#[test]
fn parse_reverse() {
let input = "%Zff0000red\x16reversed";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "red");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xff, 0x00, 0x00)));
assert_eq!(spans[0].bg, None);
assert_eq!(spans[1].text, "reversed");
assert_eq!(spans[1].fg, None);
assert_eq!(spans[1].bg, Some(Color::Rgb(0xff, 0x00, 0x00)));
}
#[test]
fn full_kokoirc_timestamp_format() {
let format_str = "%Z6e738d$*%Z7aa2f7%N";
let spans = parse_format_string(format_str, &["12:34"]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "12:34");
assert_eq!(spans[0].fg, Some(Color::Rgb(0x6e, 0x73, 0x8d)));
}
#[test]
fn full_kokoirc_pubmsg_format() {
let format_str = "%Z565f89#%Z7aa2f7%_Bob%_%N%Z7aa2f7\u{276F}%N%| %Za9b1d6Hello%N";
let spans = parse_format_string(format_str, &[]);
assert_eq!(spans.len(), 5);
assert_eq!(spans[0].text, "#");
assert_eq!(spans[0].fg, Some(Color::Rgb(0x56, 0x5f, 0x89)));
assert_eq!(spans[1].text, "Bob");
assert_eq!(spans[1].fg, Some(Color::Rgb(0x7a, 0xa2, 0xf7)));
assert!(spans[1].bold);
assert_eq!(spans[2].text, "\u{276F}");
assert_eq!(spans[2].fg, Some(Color::Rgb(0x7a, 0xa2, 0xf7)));
assert_eq!(spans[3].text, " ");
assert_eq!(spans[3].fg, None);
assert_eq!(spans[4].text, "Hello");
assert_eq!(spans[4].fg, Some(Color::Rgb(0xa9, 0xb1, 0xd6)));
}
#[test]
fn parse_empty_input() {
let spans = parse_format_string("", &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "");
}
#[test]
fn parse_mirc_fg_bg_colors() {
let input = "\x034,2colored";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "colored");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xff, 0x00, 0x00))); assert_eq!(spans[0].bg, Some(Color::Rgb(0x00, 0x00, 0x7f))); }
#[test]
fn parse_mirc_hex_fg_bg() {
let input = "\x04FF8800,001122text";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "text");
assert_eq!(spans[0].fg, Some(Color::Rgb(0xFF, 0x88, 0x00)));
assert_eq!(spans[0].bg, Some(Color::Rgb(0x00, 0x11, 0x22)));
}
#[test]
fn parse_strikethrough_as_dim() {
let input = "\x1Edimmed\x1E normal";
let spans = parse_format_string(input, &[]);
assert_eq!(spans[0].text, "dimmed");
assert!(spans[0].dim);
assert_eq!(spans[1].text, " normal");
assert!(!spans[1].dim);
}
#[test]
fn parse_monospace_noop() {
let input = "before\x11after";
let spans = parse_format_string(input, &[]);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].text, "beforeafter");
}
#[test]
fn parse_reset_all_0f() {
let input = "%_bold\x0Fnot";
let spans = parse_format_string(input, &[]);
assert_eq!(spans[0].text, "bold");
assert!(spans[0].bold);
assert_eq!(spans[1].text, "not");
assert!(!spans[1].bold);
}
}