1use std::collections::HashMap;
12
13use crate::errors::Result;
14use crate::style::StyleType;
15
16use crate::style::Style;
17
18pub const DEFAULT_STYLES: &[(&str, &str)] = &[
26 ("none", "none"),
27 (
28 "reset",
29 "not bold not dim not italic not underline not blink not blink2 \
30 not reverse not conceal not strike default on default",
31 ),
32 ("dim", "dim"),
33 ("bright", "not dim"),
34 ("bold", "bold"),
35 ("strong", "bold"),
36 ("code", "bold reverse"),
37 ("italic", "italic"),
38 ("emphasize", "italic"),
39 ("underline", "underline"),
40 ("blink", "blink"),
41 ("blink2", "blink2"),
42 ("reverse", "reverse"),
43 ("strike", "strike"),
44 ("black", "black"),
45 ("red", "red"),
46 ("green", "green"),
47 ("yellow", "yellow"),
48 ("magenta", "magenta"),
49 ("cyan", "cyan"),
50 ("white", "white"),
51 ("inspect.attr", "italic yellow"),
52 ("inspect.attr.dunder", "dim italic yellow"),
53 ("inspect.callable", "bold red"),
54 ("inspect.async_def", "italic bright_cyan"),
55 ("inspect.def", "italic bright_cyan"),
56 ("inspect.class", "italic bright_cyan"),
57 ("inspect.error", "bold red"),
58 ("inspect.equals", "none"),
59 ("inspect.help", "cyan"),
60 ("inspect.doc", "dim"),
61 ("inspect.value.border", "green"),
62 ("live.ellipsis", "bold red"),
63 ("layout.tree.row", "not dim red"),
64 ("layout.tree.column", "not dim blue"),
65 ("logging.keyword", "bold yellow"),
66 ("logging.level.notset", "dim"),
67 ("logging.level.debug", "green"),
68 ("logging.level.info", "blue"),
69 ("logging.level.warning", "yellow"),
70 ("logging.level.error", "bold red"),
71 ("logging.level.critical", "bold reverse red"),
72 ("log.level", "none"),
73 ("log.time", "dim cyan"),
74 ("log.message", "none"),
75 ("log.path", "dim"),
76 ("repr.ellipsis", "yellow"),
77 ("repr.indent", "dim green"),
78 ("repr.error", "bold red"),
79 ("repr.str", "not bold not italic green"),
80 ("repr.brace", "bold"),
81 ("repr.comma", "bold"),
82 ("repr.ipv4", "bold bright_green"),
83 ("repr.ipv6", "bold bright_green"),
84 ("repr.eui48", "bold bright_green"),
85 ("repr.eui64", "bold bright_green"),
86 ("repr.tag_start", "bold"),
87 ("repr.tag_name", "bold bright_magenta"),
88 ("repr.tag_contents", "default"),
89 ("repr.tag_end", "bold"),
90 ("repr.attrib_name", "not italic yellow"),
91 ("repr.attrib_equal", "bold"),
92 ("repr.attrib_value", "not italic magenta"),
93 ("repr.number", "bold not italic cyan"),
94 ("repr.number_complex", "bold not italic cyan"),
95 ("repr.bool_true", "italic bright_green"),
96 ("repr.bool_false", "italic bright_red"),
97 ("repr.none", "italic magenta"),
98 ("repr.url", "not bold not italic underline bright_blue"),
99 ("repr.uuid", "not bold bright_yellow"),
100 ("repr.call", "bold magenta"),
101 ("repr.path", "magenta"),
102 ("repr.filename", "bright_magenta"),
103 ("rule.line", "bright_green"),
104 ("rule.text", "none"),
105 ("json.brace", "bold"),
106 ("json.bool_true", "italic bright_green"),
107 ("json.bool_false", "italic bright_red"),
108 ("json.null", "italic magenta"),
109 ("json.number", "bold not italic cyan"),
110 ("json.str", "not bold not italic green"),
111 ("json.key", "bold blue"),
112 ("prompt", "none"),
113 ("prompt.choices", "bold magenta"),
114 ("prompt.default", "bold cyan"),
115 ("prompt.invalid", "red"),
116 ("prompt.invalid.choice", "red"),
117 ("pretty", "none"),
118 ("scope.border", "blue"),
119 ("scope.key", "italic yellow"),
120 ("scope.key.special", "dim italic yellow"),
121 ("scope.equals", "red"),
122 ("table.header", "bold"),
123 ("table.footer", "bold"),
124 ("table.cell", "none"),
125 ("table.title", "italic"),
126 ("table.caption", "dim italic"),
127 ("traceback.error", "italic red"),
128 ("traceback.border.syntax_error", "bright_red"),
129 ("traceback.border", "red"),
130 ("traceback.text", "none"),
131 ("traceback.title", "bold red"),
132 ("traceback.exc_type", "bold bright_red"),
133 ("traceback.exc_value", "none"),
134 ("traceback.offset", "bold bright_red"),
135 ("traceback.error_range", "bold underline"),
136 ("traceback.note", "bold green"),
137 ("traceback.group.border", "magenta"),
138 ("bar.back", "grey23"),
139 ("bar.complete", "rgb(249,38,114)"),
140 ("bar.finished", "rgb(114,156,31)"),
141 ("bar.pulse", "rgb(249,38,114)"),
142 ("progress.description", "none"),
143 ("progress.filesize", "green"),
144 ("progress.filesize.total", "green"),
145 ("progress.download", "green"),
146 ("progress.elapsed", "yellow"),
147 ("progress.percentage", "magenta"),
148 ("progress.remaining", "cyan"),
149 ("progress.data.speed", "red"),
150 ("progress.spinner", "green"),
151 ("status.spinner", "green"),
152 ("tree", "none"),
153 ("tree.line", "none"),
154 ("markdown.paragraph", "none"),
155 ("markdown.text", "none"),
156 ("markdown.em", "italic"),
157 ("markdown.emph", "italic"),
158 ("markdown.strong", "bold"),
159 ("markdown.code", "bold cyan on black"),
160 ("markdown.code_block", "cyan on black"),
161 ("markdown.block_quote", "magenta"),
162 ("markdown.list", "cyan"),
163 ("markdown.item", "none"),
164 ("markdown.item.bullet", "bold"),
165 ("markdown.item.number", "cyan"),
166 ("markdown.hr", "dim"),
167 ("markdown.h1.border", "none"),
168 ("markdown.h1", "bold underline"),
169 ("markdown.h2", "underline magenta"),
170 ("markdown.h3", "bold magenta"),
171 ("markdown.h4", "italic magenta"),
172 ("markdown.h5", "italic"),
173 ("markdown.h6", "dim"),
174 ("markdown.h7", "dim italic"),
175 ("markdown.link", "bright_blue"),
176 ("markdown.link_url", "underline blue"),
177 ("markdown.s", "strike"),
178 ("markdown.table.border", "cyan"),
179 ("markdown.table.header", "not bold cyan"),
180 ("markdown.kbd", "bold bright_yellow"),
181 ("iso8601.date", "blue"),
182 ("iso8601.time", "magenta"),
183 ("iso8601.timezone", "yellow"),
184];
185
186#[derive(Debug, Clone, Default)]
188pub struct Theme {
189 styles: HashMap<String, Style>,
190}
191
192impl Theme {
193 pub fn new() -> Self {
194 Theme::default()
195 }
196
197 pub fn get(&self, name: &str) -> Option<&Style> {
199 self.styles.get(name)
200 }
201
202 pub fn insert(&mut self, name: impl Into<String>, style: Style) {
204 self.styles.insert(name.into(), style);
205 }
206
207 pub fn extend_from(&mut self, other: &Theme) {
210 for (name, style) in &other.styles {
211 self.styles.insert(name.clone(), style.clone());
212 }
213 }
214
215 pub fn get_style(&self, style: &StyleType) -> Result<Style> {
228 match style {
229 StyleType::Style(style) => Ok(style.clone()),
230 StyleType::Name(name) => match self.styles.get(name) {
231 Some(style) => Ok(style.clone()),
232 None => Style::parse(name),
233 },
234 }
235 }
236
237 pub fn get_style_or_null(&self, style: &StyleType) -> Style {
242 self.get_style(style).unwrap_or_default()
243 }
244
245 pub fn names(&self) -> impl Iterator<Item = &str> {
247 self.styles.keys().map(String::as_str)
248 }
249
250 pub fn len(&self) -> usize {
252 self.styles.len()
253 }
254
255 pub fn is_empty(&self) -> bool {
257 self.styles.is_empty()
258 }
259
260 pub fn default_theme() -> Self {
262 let mut theme = Theme::new();
263 for (name, spec) in DEFAULT_STYLES {
264 match Style::parse(spec) {
265 Ok(style) => theme.insert(*name, style),
266 Err(_) => continue,
270 }
271 }
272 theme
273 }
274
275 pub fn from_styles<I, K, S>(styles: I, inherit: bool) -> Result<Self>
284 where
285 I: IntoIterator<Item = (K, S)>,
286 K: Into<String>,
287 S: Into<StyleType>,
288 {
289 let mut theme = if inherit {
290 Theme::default_theme()
291 } else {
292 Theme::new()
293 };
294 for (name, style) in styles {
295 let style = match style.into() {
296 StyleType::Style(style) => style,
297 StyleType::Name(definition) => Style::parse(&definition)?,
298 };
299 theme.insert(name, style);
300 }
301 Ok(theme)
302 }
303
304 pub fn config(&self) -> String {
308 let mut names: Vec<&String> = self.styles.keys().collect();
309 names.sort();
310 let mut config = String::from("[styles]\n");
311 let lines: Vec<String> = names
312 .into_iter()
313 .map(|name| format!("{name} = {}", self.styles[name].definition()))
314 .collect();
315 config.push_str(&lines.join("\n"));
316 config
317 }
318
319 pub fn from_file(config: &str, inherit: bool) -> Result<Self> {
330 let sections = config_file::parse(config)?;
331 let styles = config_file::styles(§ions)?;
332 Theme::from_styles(styles, inherit)
333 }
334
335 pub fn read(path: impl AsRef<std::path::Path>, inherit: bool) -> Result<Self> {
337 let path = path.as_ref();
338 let text = std::fs::read_to_string(path).map_err(|error| {
339 crate::errors::RichError::ThemeConfig(format!("OSError: {}: {error}", path.display()))
340 })?;
341 Theme::from_file(&text, inherit)
342 }
343
344 pub fn default_shared() -> &'static Theme {
347 static DEFAULT: std::sync::OnceLock<Theme> = std::sync::OnceLock::new();
348 DEFAULT.get_or_init(Theme::default_theme)
349 }
350}
351
352mod config_file {
355 use crate::errors::{Result, RichError};
356
357 pub(super) type Sections = Vec<(String, Vec<(String, String)>)>;
359
360 fn error(kind: &str, message: impl std::fmt::Display) -> RichError {
363 RichError::ThemeConfig(format!("{kind}: {message}"))
364 }
365
366 pub(super) fn parse(text: &str) -> Result<Sections> {
367 let mut sections: Sections = Vec::new();
368 let mut open: Option<(usize, usize, usize)> = None;
371 for (number, raw) in text.lines().enumerate() {
372 let line = raw.trim_end_matches('\r');
373 let stripped = line.trim();
374 let indent = line.len() - line.trim_start().len();
375 if stripped.starts_with('#') || stripped.starts_with(';') {
376 continue;
377 }
378 if stripped.is_empty() {
379 continue;
383 }
384 if let Some((section, option, first_indent)) = open {
385 if indent > first_indent {
386 let value = &mut sections[section].1[option].1;
387 value.push('\n');
388 value.push_str(stripped);
389 continue;
390 }
391 }
392 if stripped.starts_with('[') && stripped.ends_with(']') {
393 let name = stripped[1..stripped.len() - 1].to_string();
394 if sections.iter().any(|(existing, _)| *existing == name) {
395 return Err(error(
396 "DuplicateSectionError",
397 format_args!("line {}: section '{name}' already exists", number + 1),
398 ));
399 }
400 sections.push((name, Vec::new()));
401 open = None;
402 continue;
403 }
404 let Some(section) = sections.len().checked_sub(1) else {
405 return Err(error(
406 "MissingSectionHeaderError",
407 format_args!("line {}: file contains no section headers", number + 1),
408 ));
409 };
410 let Some(split) = stripped.find(['=', ':']) else {
411 return Err(error(
412 "ParsingError",
413 format_args!(
414 "line {}: source contains parsing errors: {stripped:?}",
415 number + 1
416 ),
417 ));
418 };
419 let name = stripped[..split].trim().to_lowercase();
421 let value = stripped[split + 1..].trim().to_string();
422 let options = &mut sections[section].1;
423 if options.iter().any(|(existing, _)| *existing == name) {
424 return Err(error(
425 "DuplicateOptionError",
426 format_args!(
427 "line {}: option '{name}' in section '{}' already exists",
428 number + 1,
429 sections[section].0
430 ),
431 ));
432 }
433 options.push((name, value));
434 open = Some((section, options.len() - 1, indent));
435 }
436 Ok(sections)
437 }
438
439 pub(super) fn styles(sections: &Sections) -> Result<Vec<(String, String)>> {
442 let find = |wanted: &str| {
443 sections
444 .iter()
445 .find(|(name, _)| name == wanted)
446 .map(|(_, options)| options.clone())
447 };
448 let own = find("styles").ok_or_else(|| error("NoSectionError", "No section: 'styles'"))?;
449 let mut merged = find("DEFAULT").unwrap_or_default();
450 for (name, value) in own {
451 match merged.iter_mut().find(|(existing, _)| *existing == name) {
452 Some(slot) => slot.1 = value,
453 None => merged.push((name, value)),
454 }
455 }
456 let lookup = merged.clone();
457 merged
458 .into_iter()
459 .map(|(name, value)| Ok((name, interpolate(&value, &lookup, 0)?)))
460 .collect()
461 }
462
463 fn interpolate(value: &str, options: &[(String, String)], depth: usize) -> Result<String> {
465 if depth > 10 {
467 return Err(error(
468 "InterpolationDepthError",
469 format_args!("interpolation too deeply recursive: {value:?}"),
470 ));
471 }
472 let mut out = String::new();
473 let mut rest = value;
474 while let Some(at) = rest.find('%') {
475 out.push_str(&rest[..at]);
476 rest = &rest[at..];
477 if let Some(after) = rest.strip_prefix("%%") {
478 out.push('%');
479 rest = after;
480 } else if let Some(after) = rest.strip_prefix("%(") {
481 let Some(close) = after.find(")s") else {
482 return Err(error(
483 "InterpolationSyntaxError",
484 format_args!("bad interpolation variable reference {rest:?}"),
485 ));
486 };
487 let key = after[..close].to_lowercase();
488 let Some((_, referenced)) = options.iter().find(|(name, _)| *name == key) else {
489 return Err(error(
490 "InterpolationMissingOptionError",
491 format_args!("bad value substitution: key '{key}' not found"),
492 ));
493 };
494 out.push_str(&interpolate(referenced, options, depth + 1)?);
495 rest = &after[close + 2..];
496 } else {
497 return Err(error(
498 "InterpolationSyntaxError",
499 format_args!(
500 "'%' must be followed by '%' or '(', found: {:?}",
501 rest.chars().take(2).collect::<String>()
502 ),
503 ));
504 }
505 }
506 out.push_str(rest);
507 Ok(out)
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
522 fn theme_lookup_beats_the_style_parser() {
523 let mut theme = Theme::default_theme();
524 theme.insert("red", Style::parse("blue").unwrap());
525 assert_eq!(
526 theme.get_style(&StyleType::Name("red".into())).unwrap(),
527 Style::parse("blue").unwrap()
528 );
529 }
530
531 #[test]
536 fn lookup_is_case_sensitive_but_parsing_is_not() {
537 let mut theme = Theme::new();
538 theme.insert("Danger", Style::parse("bold red").unwrap());
539
540 assert_eq!(
541 theme.get_style(&StyleType::Name("BOLD".into())).unwrap(),
542 Style::parse("bold").unwrap()
543 );
544 assert!(theme.get_style(&StyleType::Name("danger".into())).is_err());
546 assert_eq!(
547 theme.get_style(&StyleType::Name("Danger".into())).unwrap(),
548 Style::parse("bold red").unwrap()
549 );
550 }
551
552 #[test]
554 fn parse_fallback_understands_aliases() {
555 let theme = Theme::new();
556 assert_eq!(
557 theme.get_style(&StyleType::Name("b".into())).unwrap(),
558 Style::parse("bold").unwrap()
559 );
560 }
561
562 #[test]
566 fn unknown_names_error_but_render_null() {
567 let theme = Theme::default_theme();
568 let unknown = StyleType::Name("repr.nope".into());
569 assert!(theme.get_style(&unknown).is_err());
570 assert!(theme.get_style_or_null(&unknown).is_null());
571 }
572
573 #[test]
575 fn resolved_styles_pass_through() {
576 let mut theme = Theme::new();
577 theme.insert("bold", Style::parse("red").unwrap());
578 let style = Style::parse("bold").unwrap();
579 assert_eq!(
580 theme.get_style(&StyleType::Style(style.clone())).unwrap(),
581 style
582 );
583 }
584
585 #[test]
586 fn theme_covers_upstream() {
587 assert_eq!(DEFAULT_STYLES.len(), 154);
589 let mut names: Vec<&str> = DEFAULT_STYLES.iter().map(|(n, _)| *n).collect();
591 names.sort_unstable();
592 let before = names.len();
593 names.dedup();
594 assert_eq!(
595 names.len(),
596 before,
597 "duplicate style names in DEFAULT_STYLES"
598 );
599 }
600
601 #[test]
605 fn every_default_style_parses() {
606 let unparsed: Vec<&str> = DEFAULT_STYLES
607 .iter()
608 .filter(|(_, spec)| Style::parse(spec).is_err())
609 .map(|(name, _)| *name)
610 .collect();
611 assert!(
612 unparsed.is_empty(),
613 "specs that failed to parse: {unparsed:?}"
614 );
615 assert_eq!(Theme::default_theme().len(), DEFAULT_STYLES.len());
616 }
617
618 #[test]
619 fn resolves_a_few_known_styles() {
620 let theme = Theme::default_theme();
621 assert_eq!(
622 theme.get("repr.number"),
623 Style::parse("bold not italic cyan").ok().as_ref()
624 );
625 assert_eq!(
626 theme.get("markdown.table.header"),
627 Style::parse("not bold cyan").ok().as_ref()
628 );
629 assert!(theme.get("no.such.style").is_none());
630 }
631
632 #[test]
633 fn from_file_handles_configparser_details() {
634 let theme = Theme::from_file(
635 "[styles]\n Mixed = bold\n red\npct = link https://x/%%41\n",
636 false,
637 )
638 .unwrap();
639 assert_eq!(theme.get("mixed").unwrap().definition(), "bold red");
641 assert_eq!(theme.get("pct").unwrap().definition(), "link https://x/%41");
642 assert_eq!(theme.len(), 2);
643 let inherited = Theme::from_file("[styles]\nx = red\n", true).unwrap();
644 assert_eq!(inherited.len(), Theme::default_theme().len() + 1);
645 }
646
647 #[test]
648 fn from_file_errors_name_the_configparser_exception() {
649 let kind = |text: &str| match Theme::from_file(text, false) {
650 Err(crate::errors::RichError::ThemeConfig(message)) => {
651 message.split(':').next().unwrap().to_string()
652 }
653 other => panic!("expected a config error, got {other:?}"),
654 };
655 assert_eq!(kind("a = red\n"), "MissingSectionHeaderError");
656 assert_eq!(kind("[styles]\n[styles]\n"), "DuplicateSectionError");
657 assert_eq!(
658 kind("[styles]\na = %(nope)s\n"),
659 "InterpolationMissingOptionError"
660 );
661 assert_eq!(
662 kind("[styles]\na = %(b)s\nb = %(a)s\n"),
663 "InterpolationDepthError"
664 );
665 assert_eq!(kind("[styles]\na = %(b\n"), "InterpolationSyntaxError");
666 }
667
668 #[test]
669 fn read_reports_missing_files_as_config_errors() {
670 let missing = std::env::temp_dir().join("rich-theme-that-does-not-exist.ini");
671 let error = Theme::read(&missing, true).unwrap_err();
672 assert!(error.to_string().contains("OSError"), "{error}");
673 }
674
675 #[test]
676 fn config_round_trips_through_from_file() {
677 let theme = Theme::from_styles([("b", "bold"), ("a", "red on blue")], false).unwrap();
678 let reread = Theme::from_file(&theme.config(), false).unwrap();
679 assert_eq!(reread.config(), theme.config());
680 }
681}