use super::{ansi, Formatter};
use crate::events::HighlightEvent;
use crate::languages::Language;
use crate::themes::{Style, Theme};
use derive_builder::Builder;
use std::io::{self, Write};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Background {
#[default]
Inherit,
Theme,
Color(String),
}
#[derive(Builder, Clone, Debug)]
#[builder(default)]
pub struct Terminal {
#[builder(setter(custom))]
language: Language,
theme: Option<Theme>,
background: Background,
width: Option<usize>,
}
impl TerminalBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn language(&mut self, language: Language) -> &mut Self {
self.language = Some(language);
self
}
#[deprecated(note = "use `.language(...)` instead")]
pub fn lang(&mut self, language: Language) -> &mut Self {
self.language(language)
}
}
impl Terminal {
pub fn new(
language: Language,
theme: Option<Theme>,
background: Background,
width: Option<usize>,
) -> Self {
Self {
language,
theme,
background,
width,
}
}
fn fallback_bg(&self) -> Option<&str> {
match &self.background {
Background::Inherit => None,
Background::Theme => self.theme.as_ref().and_then(Theme::bg),
Background::Color(color) => Some(color.as_str()),
}
}
}
impl Default for Terminal {
fn default() -> Self {
Self {
language: Language::PlainText,
theme: None,
background: Background::Inherit,
width: None,
}
}
}
impl Formatter for Terminal {
fn render(
&self,
source: &str,
events: &[HighlightEvent],
output: &mut dyn Write,
) -> io::Result<()> {
let source_bytes = source.as_bytes();
let mut scope_stack: Vec<(usize, String)> = Vec::new();
let mut line_width = 0usize;
let fallback_bg = self.fallback_bg();
for event in events {
match event {
HighlightEvent::Source { start, end } => {
if *start > *end || *end > source_bytes.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid source range: {}..{} (len={})",
start,
end,
source_bytes.len()
),
));
}
let text = std::str::from_utf8(&source_bytes[*start..*end])
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let styled = scope_stack.last().and_then(|(scope_index, language)| {
let theme = self.theme.as_ref()?;
let scope = crate::highlights::HIGHLIGHT_NAMES
.get(*scope_index)
.copied()
.unwrap_or("");
let specialized = format!("{}.{}", scope, language);
theme
.get_style(&specialized)
.or_else(|| theme.get_style(scope))
});
if fallback_bg.is_none() {
write!(output, "{}", paint_with_background(text, styled, None))?;
continue;
}
for segment in text.split_inclusive('\n') {
let has_newline = segment.ends_with('\n');
let content = if has_newline {
&segment[..segment.len() - 1]
} else {
segment
};
if !content.is_empty() {
write!(
output,
"{}",
paint_with_background(content, styled, fallback_bg)
)?;
line_width += display_width(content);
}
if has_newline {
write_line_padding(output, fallback_bg, self.width, line_width)?;
writeln!(output)?;
line_width = 0;
}
}
}
HighlightEvent::Start {
scope_index,
language,
} => scope_stack.push((*scope_index, language.clone())),
HighlightEvent::End => {
scope_stack.pop();
}
}
}
if !source.ends_with('\n') {
write_line_padding(output, fallback_bg, self.width, line_width)?;
}
Ok(())
}
}
fn paint_with_background(text: &str, style: Option<&Style>, fallback_bg: Option<&str>) -> String {
match (style, fallback_bg) {
(Some(style), Some(fallback_bg)) if style.bg.is_none() => {
let mut style = style.clone();
style.bg = Some(fallback_bg.to_string());
ansi::paint(text, &style)
}
(Some(style), _) => ansi::paint(text, style),
(None, Some(fallback_bg)) => ansi::paint(
text,
&Style {
bg: Some(fallback_bg.to_string()),
..Default::default()
},
),
(None, None) => text.to_string(),
}
}
fn write_line_padding(
output: &mut dyn Write,
fallback_bg: Option<&str>,
width: Option<usize>,
line_width: usize,
) -> io::Result<()> {
let Some(fallback_bg) = fallback_bg else {
return Ok(());
};
let Some(width) = width else {
return Ok(());
};
if line_width >= width {
return Ok(());
}
let padding = " ".repeat(width - line_width);
write!(
output,
"{}",
paint_with_background(&padding, None, Some(fallback_bg))
)
}
fn display_width(text: &str) -> usize {
text.chars()
.map(|ch| match ch {
'\t' => 4,
_ => 1,
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn theme_with_background(bg: &str) -> Theme {
Theme {
name: "test".to_string(),
highlights: BTreeMap::from([(
"normal".to_string(),
Style {
bg: Some(bg.to_string()),
..Default::default()
},
)]),
..Default::default()
}
}
fn theme_with_scope_style(scope: &str, style: Style) -> Theme {
Theme {
name: "test".to_string(),
highlights: BTreeMap::from([(scope.to_string(), style)]),
..Default::default()
}
}
#[test]
fn paint_with_background_applies_background_to_unstyled_text() {
let painted = paint_with_background("abc", None, Some("#282a36"));
assert_eq!(painted, "\u{1b}[0m\u{1b}[48;2;40;42;54mabc\u{1b}[0m");
}
#[test]
fn paint_with_background_uses_fallback_when_style_has_no_background() {
let style = Style {
fg: Some("#8be9fd".to_string()),
bold: true,
..Default::default()
};
let painted = paint_with_background("fn", Some(&style), Some("#282a36"));
assert!(painted.contains("\u{1b}[38;2;139;233;253m"));
assert!(painted.contains("\u{1b}[48;2;40;42;54m"));
assert!(painted.contains("\u{1b}[1m"));
}
#[test]
fn paint_with_background_preserves_explicit_style_background() {
let style = Style {
fg: Some("#8be9fd".to_string()),
bg: Some("#ff0000".to_string()),
..Default::default()
};
let painted = paint_with_background("fn", Some(&style), Some("#282a36"));
assert!(painted.contains("\u{1b}[48;2;255;0;0m"));
assert!(!painted.contains("\u{1b}[48;2;40;42;54m"));
}
#[test]
fn paint_with_background_resets_around_newlines() {
let style = Style {
fg: Some("#8be9fd".to_string()),
..Default::default()
};
let painted = paint_with_background("a\nb", Some(&style), Some("#282a36"));
assert_eq!(
painted,
"\u{1b}[0m\u{1b}[38;2;139;233;253m\u{1b}[48;2;40;42;54ma\u{1b}[0m\n\u{1b}[38;2;139;233;253m\u{1b}[48;2;40;42;54mb\u{1b}[0m"
);
}
#[test]
fn render_pads_lines_to_width_with_custom_background() {
let formatter = Terminal::new(
Language::PlainText,
None,
Background::Color("#282a36".to_string()),
Some(5),
);
let events = [HighlightEvent::Source { start: 0, end: 2 }];
let mut output = Vec::new();
formatter.render("hi", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"\u{1b}[0m\u{1b}[48;2;40;42;54mhi\u{1b}[0m\u{1b}[0m\u{1b}[48;2;40;42;54m \u{1b}[0m"
);
}
#[test]
fn render_pads_each_line_before_newline() {
let formatter = Terminal::new(
Language::PlainText,
None,
Background::Color("#282a36".to_string()),
Some(4),
);
let events = [HighlightEvent::Source { start: 0, end: 3 }];
let mut output = Vec::new();
formatter.render("a\nb", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"\u{1b}[0m\u{1b}[48;2;40;42;54ma\u{1b}[0m\u{1b}[0m\u{1b}[48;2;40;42;54m \u{1b}[0m\n\u{1b}[0m\u{1b}[48;2;40;42;54mb\u{1b}[0m\u{1b}[0m\u{1b}[48;2;40;42;54m \u{1b}[0m"
);
}
#[test]
fn render_uses_theme_background_when_requested() {
let formatter = Terminal::new(
Language::PlainText,
Some(theme_with_background("#282a36")),
Background::Theme,
None,
);
let events = [HighlightEvent::Source { start: 0, end: 2 }];
let mut output = Vec::new();
formatter.render("hi", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"\u{1b}[0m\u{1b}[48;2;40;42;54mhi\u{1b}[0m"
);
}
#[test]
fn render_preserves_styled_newline_when_background_is_inherited() {
let scope = crate::highlights::HIGHLIGHT_NAMES[0];
let formatter = Terminal::new(
Language::PlainText,
Some(theme_with_scope_style(
scope,
Style {
fg: Some("#ffffff".to_string()),
..Default::default()
},
)),
Background::Inherit,
None,
);
let events = [
HighlightEvent::Start {
scope_index: 0,
language: "plaintext".to_string(),
},
HighlightEvent::Source { start: 0, end: 2 },
HighlightEvent::End,
];
let mut output = Vec::new();
formatter.render("a\n", &events, &mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"\u{1b}[0m\u{1b}[38;2;255;255;255ma\n\u{1b}[0m"
);
}
}