use std::io;
use codespan_reporting::term::termcolor::{Color, ColorSpec, WriteColor};
use rpm_spec::printer::{PrintWriter, TokenKind};
const GREY: Color = Color::Ansi256(8);
#[derive(Debug, Clone)]
pub struct Theme {
tag_name: Option<ColorSpec>,
tag_qualifier: Option<ColorSpec>,
section_keyword: Option<ColorSpec>,
conditional_keyword: Option<ColorSpec>,
macro_def_keyword: Option<ColorSpec>,
macro_ref: Option<ColorSpec>,
shell_macro: Option<ColorSpec>,
expr_macro: Option<ColorSpec>,
string: Option<ColorSpec>,
number: Option<ColorSpec>,
operator: Option<ColorSpec>,
comment: Option<ColorSpec>,
changelog_header: Option<ColorSpec>,
shell_body: Option<ColorSpec>,
text_body: Option<ColorSpec>,
flag: Option<ColorSpec>,
}
impl Theme {
pub fn dark() -> Self {
Self {
tag_name: Some(bold(Color::Blue)),
tag_qualifier: Some(fg(Color::Cyan)),
section_keyword: Some(bold(Color::Magenta)),
conditional_keyword: Some(bold(Color::Magenta)),
macro_def_keyword: Some(bold(Color::Magenta)),
macro_ref: Some(fg(Color::Cyan)),
shell_macro: Some(italic(fg(Color::Cyan))),
expr_macro: Some(fg(Color::Cyan)),
string: Some(fg(Color::Green)),
number: Some(fg(Color::Yellow)),
operator: Some(fg(GREY)),
comment: Some(italic(fg(GREY))),
changelog_header: Some(bold(Color::Yellow)),
shell_body: None,
text_body: None,
flag: Some(fg(Color::Cyan)),
}
}
fn spec_for(&self, kind: TokenKind) -> Option<&ColorSpec> {
match kind {
TokenKind::TagName => self.tag_name.as_ref(),
TokenKind::TagQualifier => self.tag_qualifier.as_ref(),
TokenKind::SectionKeyword => self.section_keyword.as_ref(),
TokenKind::ConditionalKeyword => self.conditional_keyword.as_ref(),
TokenKind::MacroDefKeyword => self.macro_def_keyword.as_ref(),
TokenKind::MacroRef => self.macro_ref.as_ref(),
TokenKind::ShellMacro => self.shell_macro.as_ref(),
TokenKind::ExprMacro => self.expr_macro.as_ref(),
TokenKind::String => self.string.as_ref(),
TokenKind::Number => self.number.as_ref(),
TokenKind::Operator => self.operator.as_ref(),
TokenKind::Comment => self.comment.as_ref(),
TokenKind::ChangelogHeader => self.changelog_header.as_ref(),
TokenKind::ShellBody => self.shell_body.as_ref(),
TokenKind::TextBody => self.text_body.as_ref(),
TokenKind::Flag => self.flag.as_ref(),
TokenKind::Plain => None,
_ => None,
}
}
}
fn fg(c: Color) -> ColorSpec {
let mut s = ColorSpec::new();
s.set_fg(Some(c));
s
}
fn bold(c: Color) -> ColorSpec {
let mut s = ColorSpec::new();
s.set_fg(Some(c)).set_bold(true);
s
}
fn italic(mut s: ColorSpec) -> ColorSpec {
s.set_italic(true);
s
}
pub struct AnsiWriter<'a, W: WriteColor> {
out: &'a mut W,
theme: Theme,
io_error: Option<io::Error>,
}
impl<'a, W: WriteColor> AnsiWriter<'a, W> {
pub fn new(out: &'a mut W, theme: Theme) -> Self {
Self {
out,
theme,
io_error: None,
}
}
pub fn take_error(&mut self) -> Option<io::Error> {
self.io_error.take()
}
fn record(&mut self, err: io::Error) {
if self.io_error.is_none() {
self.io_error = Some(err);
}
}
pub fn has_broken_pipe(&self) -> bool {
self.io_error
.as_ref()
.map(|e| e.kind() == io::ErrorKind::BrokenPipe)
.unwrap_or(false)
}
}
impl<W: WriteColor> PrintWriter for AnsiWriter<'_, W> {
fn emit(&mut self, kind: TokenKind, text: &str) {
if text.is_empty() {
return;
}
if self.has_broken_pipe() {
return;
}
if let Some(spec) = self.theme.spec_for(kind) {
if let Err(e) = self.out.set_color(spec) {
self.record(e);
return;
}
if let Err(e) = self.out.write_all(text.as_bytes()) {
self.record(e);
return;
}
if let Err(e) = self.out.reset() {
self.record(e);
}
} else if let Err(e) = self.out.write_all(text.as_bytes()) {
self.record(e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codespan_reporting::term::termcolor::{Ansi, NoColor};
fn render_with<W>(out: &mut W, text: &str, kind: TokenKind, theme: Theme)
where
W: WriteColor,
{
let mut w = AnsiWriter::new(out, theme);
w.emit(kind, text);
}
#[test]
fn plain_kind_emits_no_ansi_escape() {
let mut buf = Vec::new();
{
let mut ansi = Ansi::new(&mut buf);
render_with(&mut ansi, "hello", TokenKind::Plain, Theme::dark());
}
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains('\x1b'), "no ANSI escape expected: {s:?}");
assert_eq!(s, "hello");
}
#[test]
fn coloured_kind_wraps_text_in_ansi_escapes() {
let mut buf = Vec::new();
{
let mut ansi = Ansi::new(&mut buf);
render_with(&mut ansi, "Name", TokenKind::TagName, Theme::dark());
}
let s = String::from_utf8(buf).unwrap();
assert!(s.contains('\x1b'), "ANSI escape expected: {s:?}");
assert!(s.contains("Name"));
assert!(s.ends_with("\x1b[0m"), "expected ANSI reset, got: {s:?}");
}
#[test]
fn no_color_sink_passes_through_verbatim() {
let mut buf = Vec::new();
{
let mut sink = NoColor::new(&mut buf);
render_with(&mut sink, "Version", TokenKind::TagName, Theme::dark());
}
let s = String::from_utf8(buf).unwrap();
assert_eq!(s, "Version", "NoColor sink must not inject escapes");
}
#[test]
fn empty_text_emits_nothing() {
let mut buf = Vec::new();
{
let mut ansi = Ansi::new(&mut buf);
render_with(&mut ansi, "", TokenKind::TagName, Theme::dark());
}
assert!(buf.is_empty());
}
#[test]
fn multiple_tokens_isolate_color_state() {
let mut buf = Vec::new();
let theme = Theme::dark();
{
let mut ansi = Ansi::new(&mut buf);
let mut w = AnsiWriter::new(&mut ansi, theme);
w.emit(TokenKind::TagName, "Name");
w.emit(TokenKind::Plain, ": ");
w.emit(TokenKind::String, "\"hi\"");
}
let s = String::from_utf8(buf).unwrap();
let reset_count = s.matches("\x1b[0m").count();
assert!(
reset_count >= 2,
"expected ≥2 resets, got {reset_count}: {s:?}"
);
assert!(s.ends_with("\x1b[0m"), "expected trailing reset: {s:?}");
}
}