use std::collections::HashMap;
use crate::errors::Result;
use crate::style::StyleType;
use crate::style::Style;
pub const DEFAULT_STYLES: &[(&str, &str)] = &[
("none", "none"),
(
"reset",
"not bold not dim not italic not underline not blink not blink2 \
not reverse not conceal not strike default on default",
),
("dim", "dim"),
("bright", "not dim"),
("bold", "bold"),
("strong", "bold"),
("code", "bold reverse"),
("italic", "italic"),
("emphasize", "italic"),
("underline", "underline"),
("blink", "blink"),
("blink2", "blink2"),
("reverse", "reverse"),
("strike", "strike"),
("black", "black"),
("red", "red"),
("green", "green"),
("yellow", "yellow"),
("magenta", "magenta"),
("cyan", "cyan"),
("white", "white"),
("inspect.attr", "italic yellow"),
("inspect.attr.dunder", "dim italic yellow"),
("inspect.callable", "bold red"),
("inspect.async_def", "italic bright_cyan"),
("inspect.def", "italic bright_cyan"),
("inspect.class", "italic bright_cyan"),
("inspect.error", "bold red"),
("inspect.equals", "none"),
("inspect.help", "cyan"),
("inspect.doc", "dim"),
("inspect.value.border", "green"),
("live.ellipsis", "bold red"),
("layout.tree.row", "not dim red"),
("layout.tree.column", "not dim blue"),
("logging.keyword", "bold yellow"),
("logging.level.notset", "dim"),
("logging.level.debug", "green"),
("logging.level.info", "blue"),
("logging.level.warning", "yellow"),
("logging.level.error", "bold red"),
("logging.level.critical", "bold reverse red"),
("log.level", "none"),
("log.time", "dim cyan"),
("log.message", "none"),
("log.path", "dim"),
("repr.ellipsis", "yellow"),
("repr.indent", "dim green"),
("repr.error", "bold red"),
("repr.str", "not bold not italic green"),
("repr.brace", "bold"),
("repr.comma", "bold"),
("repr.ipv4", "bold bright_green"),
("repr.ipv6", "bold bright_green"),
("repr.eui48", "bold bright_green"),
("repr.eui64", "bold bright_green"),
("repr.tag_start", "bold"),
("repr.tag_name", "bold bright_magenta"),
("repr.tag_contents", "default"),
("repr.tag_end", "bold"),
("repr.attrib_name", "not italic yellow"),
("repr.attrib_equal", "bold"),
("repr.attrib_value", "not italic magenta"),
("repr.number", "bold not italic cyan"),
("repr.number_complex", "bold not italic cyan"),
("repr.bool_true", "italic bright_green"),
("repr.bool_false", "italic bright_red"),
("repr.none", "italic magenta"),
("repr.url", "not bold not italic underline bright_blue"),
("repr.uuid", "not bold bright_yellow"),
("repr.call", "bold magenta"),
("repr.path", "magenta"),
("repr.filename", "bright_magenta"),
("rule.line", "bright_green"),
("rule.text", "none"),
("json.brace", "bold"),
("json.bool_true", "italic bright_green"),
("json.bool_false", "italic bright_red"),
("json.null", "italic magenta"),
("json.number", "bold not italic cyan"),
("json.str", "not bold not italic green"),
("json.key", "bold blue"),
("prompt", "none"),
("prompt.choices", "bold magenta"),
("prompt.default", "bold cyan"),
("prompt.invalid", "red"),
("prompt.invalid.choice", "red"),
("pretty", "none"),
("scope.border", "blue"),
("scope.key", "italic yellow"),
("scope.key.special", "dim italic yellow"),
("scope.equals", "red"),
("table.header", "bold"),
("table.footer", "bold"),
("table.cell", "none"),
("table.title", "italic"),
("table.caption", "dim italic"),
("traceback.error", "italic red"),
("traceback.border.syntax_error", "bright_red"),
("traceback.border", "red"),
("traceback.text", "none"),
("traceback.title", "bold red"),
("traceback.exc_type", "bold bright_red"),
("traceback.exc_value", "none"),
("traceback.offset", "bold bright_red"),
("traceback.error_range", "bold underline"),
("traceback.note", "bold green"),
("traceback.group.border", "magenta"),
("bar.back", "grey23"),
("bar.complete", "rgb(249,38,114)"),
("bar.finished", "rgb(114,156,31)"),
("bar.pulse", "rgb(249,38,114)"),
("progress.description", "none"),
("progress.filesize", "green"),
("progress.filesize.total", "green"),
("progress.download", "green"),
("progress.elapsed", "yellow"),
("progress.percentage", "magenta"),
("progress.remaining", "cyan"),
("progress.data.speed", "red"),
("progress.spinner", "green"),
("status.spinner", "green"),
("tree", "none"),
("tree.line", "none"),
("markdown.paragraph", "none"),
("markdown.text", "none"),
("markdown.em", "italic"),
("markdown.emph", "italic"),
("markdown.strong", "bold"),
("markdown.code", "bold cyan on black"),
("markdown.code_block", "cyan on black"),
("markdown.block_quote", "magenta"),
("markdown.list", "cyan"),
("markdown.item", "none"),
("markdown.item.bullet", "bold"),
("markdown.item.number", "cyan"),
("markdown.hr", "dim"),
("markdown.h1.border", "none"),
("markdown.h1", "bold underline"),
("markdown.h2", "underline magenta"),
("markdown.h3", "bold magenta"),
("markdown.h4", "italic magenta"),
("markdown.h5", "italic"),
("markdown.h6", "dim"),
("markdown.h7", "dim italic"),
("markdown.link", "bright_blue"),
("markdown.link_url", "underline blue"),
("markdown.s", "strike"),
("markdown.table.border", "cyan"),
("markdown.table.header", "not bold cyan"),
("markdown.kbd", "bold bright_yellow"),
("iso8601.date", "blue"),
("iso8601.time", "magenta"),
("iso8601.timezone", "yellow"),
];
#[derive(Debug, Clone, Default)]
pub struct Theme {
styles: HashMap<String, Style>,
}
impl Theme {
pub fn new() -> Self {
Theme::default()
}
pub fn get(&self, name: &str) -> Option<&Style> {
self.styles.get(name)
}
pub fn insert(&mut self, name: impl Into<String>, style: Style) {
self.styles.insert(name.into(), style);
}
pub fn extend_from(&mut self, other: &Theme) {
for (name, style) in &other.styles {
self.styles.insert(name.clone(), style.clone());
}
}
pub fn get_style(&self, style: &StyleType) -> Result<Style> {
match style {
StyleType::Style(style) => Ok(style.clone()),
StyleType::Name(name) => match self.styles.get(name) {
Some(style) => Ok(style.clone()),
None => Style::parse(name),
},
}
}
pub fn get_style_or_null(&self, style: &StyleType) -> Style {
self.get_style(style).unwrap_or_default()
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.styles.keys().map(String::as_str)
}
pub fn len(&self) -> usize {
self.styles.len()
}
pub fn is_empty(&self) -> bool {
self.styles.is_empty()
}
pub fn default_theme() -> Self {
let mut theme = Theme::new();
for (name, spec) in DEFAULT_STYLES {
match Style::parse(spec) {
Ok(style) => theme.insert(*name, style),
Err(_) => continue,
}
}
theme
}
pub fn from_styles<I, K, S>(styles: I, inherit: bool) -> Result<Self>
where
I: IntoIterator<Item = (K, S)>,
K: Into<String>,
S: Into<StyleType>,
{
let mut theme = if inherit {
Theme::default_theme()
} else {
Theme::new()
};
for (name, style) in styles {
let style = match style.into() {
StyleType::Style(style) => style,
StyleType::Name(definition) => Style::parse(&definition)?,
};
theme.insert(name, style);
}
Ok(theme)
}
pub fn config(&self) -> String {
let mut names: Vec<&String> = self.styles.keys().collect();
names.sort();
let mut config = String::from("[styles]\n");
let lines: Vec<String> = names
.into_iter()
.map(|name| format!("{name} = {}", self.styles[name].definition()))
.collect();
config.push_str(&lines.join("\n"));
config
}
pub fn from_file(config: &str, inherit: bool) -> Result<Self> {
let sections = config_file::parse(config)?;
let styles = config_file::styles(§ions)?;
Theme::from_styles(styles, inherit)
}
pub fn read(path: impl AsRef<std::path::Path>, inherit: bool) -> Result<Self> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|error| {
crate::errors::RichError::ThemeConfig(format!("OSError: {}: {error}", path.display()))
})?;
Theme::from_file(&text, inherit)
}
pub fn default_shared() -> &'static Theme {
static DEFAULT: std::sync::OnceLock<Theme> = std::sync::OnceLock::new();
DEFAULT.get_or_init(Theme::default_theme)
}
}
mod config_file {
use crate::errors::{Result, RichError};
pub(super) type Sections = Vec<(String, Vec<(String, String)>)>;
fn error(kind: &str, message: impl std::fmt::Display) -> RichError {
RichError::ThemeConfig(format!("{kind}: {message}"))
}
pub(super) fn parse(text: &str) -> Result<Sections> {
let mut sections: Sections = Vec::new();
let mut open: Option<(usize, usize, usize)> = None;
for (number, raw) in text.lines().enumerate() {
let line = raw.trim_end_matches('\r');
let stripped = line.trim();
let indent = line.len() - line.trim_start().len();
if stripped.starts_with('#') || stripped.starts_with(';') {
continue;
}
if stripped.is_empty() {
continue;
}
if let Some((section, option, first_indent)) = open {
if indent > first_indent {
let value = &mut sections[section].1[option].1;
value.push('\n');
value.push_str(stripped);
continue;
}
}
if stripped.starts_with('[') && stripped.ends_with(']') {
let name = stripped[1..stripped.len() - 1].to_string();
if sections.iter().any(|(existing, _)| *existing == name) {
return Err(error(
"DuplicateSectionError",
format_args!("line {}: section '{name}' already exists", number + 1),
));
}
sections.push((name, Vec::new()));
open = None;
continue;
}
let Some(section) = sections.len().checked_sub(1) else {
return Err(error(
"MissingSectionHeaderError",
format_args!("line {}: file contains no section headers", number + 1),
));
};
let Some(split) = stripped.find(['=', ':']) else {
return Err(error(
"ParsingError",
format_args!(
"line {}: source contains parsing errors: {stripped:?}",
number + 1
),
));
};
let name = stripped[..split].trim().to_lowercase();
let value = stripped[split + 1..].trim().to_string();
let options = &mut sections[section].1;
if options.iter().any(|(existing, _)| *existing == name) {
return Err(error(
"DuplicateOptionError",
format_args!(
"line {}: option '{name}' in section '{}' already exists",
number + 1,
sections[section].0
),
));
}
options.push((name, value));
open = Some((section, options.len() - 1, indent));
}
Ok(sections)
}
pub(super) fn styles(sections: &Sections) -> Result<Vec<(String, String)>> {
let find = |wanted: &str| {
sections
.iter()
.find(|(name, _)| name == wanted)
.map(|(_, options)| options.clone())
};
let own = find("styles").ok_or_else(|| error("NoSectionError", "No section: 'styles'"))?;
let mut merged = find("DEFAULT").unwrap_or_default();
for (name, value) in own {
match merged.iter_mut().find(|(existing, _)| *existing == name) {
Some(slot) => slot.1 = value,
None => merged.push((name, value)),
}
}
let lookup = merged.clone();
merged
.into_iter()
.map(|(name, value)| Ok((name, interpolate(&value, &lookup, 0)?)))
.collect()
}
fn interpolate(value: &str, options: &[(String, String)], depth: usize) -> Result<String> {
if depth > 10 {
return Err(error(
"InterpolationDepthError",
format_args!("interpolation too deeply recursive: {value:?}"),
));
}
let mut out = String::new();
let mut rest = value;
while let Some(at) = rest.find('%') {
out.push_str(&rest[..at]);
rest = &rest[at..];
if let Some(after) = rest.strip_prefix("%%") {
out.push('%');
rest = after;
} else if let Some(after) = rest.strip_prefix("%(") {
let Some(close) = after.find(")s") else {
return Err(error(
"InterpolationSyntaxError",
format_args!("bad interpolation variable reference {rest:?}"),
));
};
let key = after[..close].to_lowercase();
let Some((_, referenced)) = options.iter().find(|(name, _)| *name == key) else {
return Err(error(
"InterpolationMissingOptionError",
format_args!("bad value substitution: key '{key}' not found"),
));
};
out.push_str(&interpolate(referenced, options, depth + 1)?);
rest = &after[close + 2..];
} else {
return Err(error(
"InterpolationSyntaxError",
format_args!(
"'%' must be followed by '%' or '(', found: {:?}",
rest.chars().take(2).collect::<String>()
),
));
}
}
out.push_str(rest);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_lookup_beats_the_style_parser() {
let mut theme = Theme::default_theme();
theme.insert("red", Style::parse("blue").unwrap());
assert_eq!(
theme.get_style(&StyleType::Name("red".into())).unwrap(),
Style::parse("blue").unwrap()
);
}
#[test]
fn lookup_is_case_sensitive_but_parsing_is_not() {
let mut theme = Theme::new();
theme.insert("Danger", Style::parse("bold red").unwrap());
assert_eq!(
theme.get_style(&StyleType::Name("BOLD".into())).unwrap(),
Style::parse("bold").unwrap()
);
assert!(theme.get_style(&StyleType::Name("danger".into())).is_err());
assert_eq!(
theme.get_style(&StyleType::Name("Danger".into())).unwrap(),
Style::parse("bold red").unwrap()
);
}
#[test]
fn parse_fallback_understands_aliases() {
let theme = Theme::new();
assert_eq!(
theme.get_style(&StyleType::Name("b".into())).unwrap(),
Style::parse("bold").unwrap()
);
}
#[test]
fn unknown_names_error_but_render_null() {
let theme = Theme::default_theme();
let unknown = StyleType::Name("repr.nope".into());
assert!(theme.get_style(&unknown).is_err());
assert!(theme.get_style_or_null(&unknown).is_null());
}
#[test]
fn resolved_styles_pass_through() {
let mut theme = Theme::new();
theme.insert("bold", Style::parse("red").unwrap());
let style = Style::parse("bold").unwrap();
assert_eq!(
theme.get_style(&StyleType::Style(style.clone())).unwrap(),
style
);
}
#[test]
fn theme_covers_upstream() {
assert_eq!(DEFAULT_STYLES.len(), 154);
let mut names: Vec<&str> = DEFAULT_STYLES.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(
names.len(),
before,
"duplicate style names in DEFAULT_STYLES"
);
}
#[test]
fn every_default_style_parses() {
let unparsed: Vec<&str> = DEFAULT_STYLES
.iter()
.filter(|(_, spec)| Style::parse(spec).is_err())
.map(|(name, _)| *name)
.collect();
assert!(
unparsed.is_empty(),
"specs that failed to parse: {unparsed:?}"
);
assert_eq!(Theme::default_theme().len(), DEFAULT_STYLES.len());
}
#[test]
fn resolves_a_few_known_styles() {
let theme = Theme::default_theme();
assert_eq!(
theme.get("repr.number"),
Style::parse("bold not italic cyan").ok().as_ref()
);
assert_eq!(
theme.get("markdown.table.header"),
Style::parse("not bold cyan").ok().as_ref()
);
assert!(theme.get("no.such.style").is_none());
}
#[test]
fn from_file_handles_configparser_details() {
let theme = Theme::from_file(
"[styles]\n Mixed = bold\n red\npct = link https://x/%%41\n",
false,
)
.unwrap();
assert_eq!(theme.get("mixed").unwrap().definition(), "bold red");
assert_eq!(theme.get("pct").unwrap().definition(), "link https://x/%41");
assert_eq!(theme.len(), 2);
let inherited = Theme::from_file("[styles]\nx = red\n", true).unwrap();
assert_eq!(inherited.len(), Theme::default_theme().len() + 1);
}
#[test]
fn from_file_errors_name_the_configparser_exception() {
let kind = |text: &str| match Theme::from_file(text, false) {
Err(crate::errors::RichError::ThemeConfig(message)) => {
message.split(':').next().unwrap().to_string()
}
other => panic!("expected a config error, got {other:?}"),
};
assert_eq!(kind("a = red\n"), "MissingSectionHeaderError");
assert_eq!(kind("[styles]\n[styles]\n"), "DuplicateSectionError");
assert_eq!(
kind("[styles]\na = %(nope)s\n"),
"InterpolationMissingOptionError"
);
assert_eq!(
kind("[styles]\na = %(b)s\nb = %(a)s\n"),
"InterpolationDepthError"
);
assert_eq!(kind("[styles]\na = %(b\n"), "InterpolationSyntaxError");
}
#[test]
fn read_reports_missing_files_as_config_errors() {
let missing = std::env::temp_dir().join("rich-theme-that-does-not-exist.ini");
let error = Theme::read(&missing, true).unwrap_err();
assert!(error.to_string().contains("OSError"), "{error}");
}
#[test]
fn config_round_trips_through_from_file() {
let theme = Theme::from_styles([("b", "bold"), ("a", "red on blue")], false).unwrap();
let reread = Theme::from_file(&theme.config(), false).unwrap();
assert_eq!(reread.config(), theme.config());
}
}