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 seen: Vec<std::collections::HashSet<String>> = Vec::new();
371 let mut open: Option<(usize, usize, usize)> = None;
374 for (number, raw) in text.lines().enumerate() {
375 let line = raw.trim_end_matches('\r');
376 let stripped = line.trim();
377 let indent = line.len() - line.trim_start().len();
378 if stripped.starts_with('#') || stripped.starts_with(';') {
379 continue;
380 }
381 if stripped.is_empty() {
382 continue;
386 }
387 if let Some((section, option, first_indent)) = open {
388 if indent > first_indent {
389 let value = &mut sections[section].1[option].1;
390 value.push('\n');
391 value.push_str(stripped);
392 continue;
393 }
394 }
395 if stripped.starts_with('[') && stripped.ends_with(']') {
396 let name = stripped[1..stripped.len() - 1].to_string();
397 if sections.iter().any(|(existing, _)| *existing == name) {
398 return Err(error(
399 "DuplicateSectionError",
400 format_args!("line {}: section '{name}' already exists", number + 1),
401 ));
402 }
403 sections.push((name, Vec::new()));
404 seen.push(std::collections::HashSet::new());
405 open = None;
406 continue;
407 }
408 let Some(section) = sections.len().checked_sub(1) else {
409 return Err(error(
410 "MissingSectionHeaderError",
411 format_args!("line {}: file contains no section headers", number + 1),
412 ));
413 };
414 let Some(split) = stripped.find(['=', ':']) else {
415 return Err(error(
416 "ParsingError",
417 format_args!(
418 "line {}: source contains parsing errors: {stripped:?}",
419 number + 1
420 ),
421 ));
422 };
423 let name = stripped[..split].trim().to_lowercase();
425 let value = stripped[split + 1..].trim().to_string();
426 if !seen[section].insert(name.clone()) {
427 return Err(error(
428 "DuplicateOptionError",
429 format_args!(
430 "line {}: option '{name}' in section '{}' already exists",
431 number + 1,
432 sections[section].0
433 ),
434 ));
435 }
436 let options = &mut sections[section].1;
437 options.push((name, value));
438 open = Some((section, options.len() - 1, indent));
439 }
440 Ok(sections)
441 }
442
443 pub(super) fn styles(sections: &Sections) -> Result<Vec<(String, String)>> {
446 let find = |wanted: &str| {
447 sections
448 .iter()
449 .find(|(name, _)| name == wanted)
450 .map(|(_, options)| options.clone())
451 };
452 let own = find("styles").ok_or_else(|| error("NoSectionError", "No section: 'styles'"))?;
453 let mut merged = find("DEFAULT").unwrap_or_default();
454 let mut index: std::collections::HashMap<String, usize> = merged
457 .iter()
458 .enumerate()
459 .map(|(position, (name, _))| (name.clone(), position))
460 .collect();
461 for (name, value) in own {
462 match index.get(&name) {
463 Some(&position) => merged[position].1 = value,
464 None => {
465 index.insert(name.clone(), merged.len());
466 merged.push((name, value));
467 }
468 }
469 }
470 let lookup: std::collections::HashMap<&str, &str> = merged
471 .iter()
472 .map(|(name, value)| (name.as_str(), value.as_str()))
473 .collect();
474 merged
475 .iter()
476 .map(|(name, value)| Ok((name.clone(), interpolate(value, &lookup, 0)?)))
477 .collect()
478 }
479
480 fn interpolate(
482 value: &str,
483 options: &std::collections::HashMap<&str, &str>,
484 depth: usize,
485 ) -> Result<String> {
486 if depth > 10 {
488 return Err(error(
489 "InterpolationDepthError",
490 format_args!("interpolation too deeply recursive: {value:?}"),
491 ));
492 }
493 let mut out = String::new();
494 let mut rest = value;
495 while let Some(at) = rest.find('%') {
496 out.push_str(&rest[..at]);
497 rest = &rest[at..];
498 if let Some(after) = rest.strip_prefix("%%") {
499 out.push('%');
500 rest = after;
501 } else if let Some(after) = rest.strip_prefix("%(") {
502 let Some(close) = after.find(")s") else {
503 return Err(error(
504 "InterpolationSyntaxError",
505 format_args!("bad interpolation variable reference {rest:?}"),
506 ));
507 };
508 let key = after[..close].to_lowercase();
509 let Some(referenced) = options.get(key.as_str()) else {
510 return Err(error(
511 "InterpolationMissingOptionError",
512 format_args!("bad value substitution: key '{key}' not found"),
513 ));
514 };
515 out.push_str(&interpolate(referenced, options, depth + 1)?);
516 rest = &after[close + 2..];
517 } else {
518 return Err(error(
519 "InterpolationSyntaxError",
520 format_args!(
521 "'%' must be followed by '%' or '(', found: {:?}",
522 rest.chars().take(2).collect::<String>()
523 ),
524 ));
525 }
526 }
527 out.push_str(rest);
528 Ok(out)
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
541 fn large_theme_files_parse_in_linear_time() {
542 const COUNT: usize = 40_000;
543 let mut config = String::from("[DEFAULT]\n");
544 for index in (0..COUNT).step_by(2) {
545 config.push_str(&format!("style{index} = red\n"));
546 }
547 config.push_str("[styles]\nbase = bold\n");
548 for index in 0..COUNT {
549 config.push_str(&format!("style{index} = %(base)s color({})\n", index % 256));
550 }
551 let started = std::time::Instant::now();
552 let theme = Theme::from_file(&config, false).expect("valid theme");
553 let elapsed = started.elapsed();
554 assert!(
555 elapsed < std::time::Duration::from_secs(8),
556 "parsing {COUNT} styles took {elapsed:?}"
557 );
558 assert_eq!(theme.styles.len(), COUNT + 1);
559 assert_eq!(
560 theme.get("style39999"),
561 Some(&Style::parse("bold color(63)").unwrap())
562 );
563 config.push_str("style7 = blue\n");
565 assert!(Theme::from_file(&config, false).is_err());
566 }
567
568 #[test]
575 fn theme_lookup_beats_the_style_parser() {
576 let mut theme = Theme::default_theme();
577 theme.insert("red", Style::parse("blue").unwrap());
578 assert_eq!(
579 theme.get_style(&StyleType::Name("red".into())).unwrap(),
580 Style::parse("blue").unwrap()
581 );
582 }
583
584 #[test]
589 fn lookup_is_case_sensitive_but_parsing_is_not() {
590 let mut theme = Theme::new();
591 theme.insert("Danger", Style::parse("bold red").unwrap());
592
593 assert_eq!(
594 theme.get_style(&StyleType::Name("BOLD".into())).unwrap(),
595 Style::parse("bold").unwrap()
596 );
597 assert!(theme.get_style(&StyleType::Name("danger".into())).is_err());
599 assert_eq!(
600 theme.get_style(&StyleType::Name("Danger".into())).unwrap(),
601 Style::parse("bold red").unwrap()
602 );
603 }
604
605 #[test]
607 fn parse_fallback_understands_aliases() {
608 let theme = Theme::new();
609 assert_eq!(
610 theme.get_style(&StyleType::Name("b".into())).unwrap(),
611 Style::parse("bold").unwrap()
612 );
613 }
614
615 #[test]
619 fn unknown_names_error_but_render_null() {
620 let theme = Theme::default_theme();
621 let unknown = StyleType::Name("repr.nope".into());
622 assert!(theme.get_style(&unknown).is_err());
623 assert!(theme.get_style_or_null(&unknown).is_null());
624 }
625
626 #[test]
628 fn resolved_styles_pass_through() {
629 let mut theme = Theme::new();
630 theme.insert("bold", Style::parse("red").unwrap());
631 let style = Style::parse("bold").unwrap();
632 assert_eq!(
633 theme.get_style(&StyleType::Style(style.clone())).unwrap(),
634 style
635 );
636 }
637
638 #[test]
639 fn theme_covers_upstream() {
640 assert_eq!(DEFAULT_STYLES.len(), 154);
642 let mut names: Vec<&str> = DEFAULT_STYLES.iter().map(|(n, _)| *n).collect();
644 names.sort_unstable();
645 let before = names.len();
646 names.dedup();
647 assert_eq!(
648 names.len(),
649 before,
650 "duplicate style names in DEFAULT_STYLES"
651 );
652 }
653
654 #[test]
658 fn every_default_style_parses() {
659 let unparsed: Vec<&str> = DEFAULT_STYLES
660 .iter()
661 .filter(|(_, spec)| Style::parse(spec).is_err())
662 .map(|(name, _)| *name)
663 .collect();
664 assert!(
665 unparsed.is_empty(),
666 "specs that failed to parse: {unparsed:?}"
667 );
668 assert_eq!(Theme::default_theme().len(), DEFAULT_STYLES.len());
669 }
670
671 #[test]
672 fn resolves_a_few_known_styles() {
673 let theme = Theme::default_theme();
674 assert_eq!(
675 theme.get("repr.number"),
676 Style::parse("bold not italic cyan").ok().as_ref()
677 );
678 assert_eq!(
679 theme.get("markdown.table.header"),
680 Style::parse("not bold cyan").ok().as_ref()
681 );
682 assert!(theme.get("no.such.style").is_none());
683 }
684
685 #[test]
686 fn from_file_handles_configparser_details() {
687 let theme = Theme::from_file(
688 "[styles]\n Mixed = bold\n red\npct = link https://x/%%41\n",
689 false,
690 )
691 .unwrap();
692 assert_eq!(theme.get("mixed").unwrap().definition(), "bold red");
694 assert_eq!(theme.get("pct").unwrap().definition(), "link https://x/%41");
695 assert_eq!(theme.len(), 2);
696 let inherited = Theme::from_file("[styles]\nx = red\n", true).unwrap();
697 assert_eq!(inherited.len(), Theme::default_theme().len() + 1);
698 }
699
700 #[test]
701 fn from_file_errors_name_the_configparser_exception() {
702 let kind = |text: &str| match Theme::from_file(text, false) {
703 Err(crate::errors::RichError::ThemeConfig(message)) => {
704 message.split(':').next().unwrap().to_string()
705 }
706 other => panic!("expected a config error, got {other:?}"),
707 };
708 assert_eq!(kind("a = red\n"), "MissingSectionHeaderError");
709 assert_eq!(kind("[styles]\n[styles]\n"), "DuplicateSectionError");
710 assert_eq!(
711 kind("[styles]\na = %(nope)s\n"),
712 "InterpolationMissingOptionError"
713 );
714 assert_eq!(
715 kind("[styles]\na = %(b)s\nb = %(a)s\n"),
716 "InterpolationDepthError"
717 );
718 assert_eq!(kind("[styles]\na = %(b\n"), "InterpolationSyntaxError");
719 }
720
721 #[test]
722 fn read_reports_missing_files_as_config_errors() {
723 let missing = std::env::temp_dir().join("rich-theme-that-does-not-exist.ini");
724 let error = Theme::read(&missing, true).unwrap_err();
725 assert!(error.to_string().contains("OSError"), "{error}");
726 }
727
728 #[test]
729 fn config_round_trips_through_from_file() {
730 let theme = Theme::from_styles([("b", "bold"), ("a", "red on blue")], false).unwrap();
731 let reread = Theme::from_file(&theme.config(), false).unwrap();
732 assert_eq!(reread.config(), theme.config());
733 }
734}