Skip to main content

rich/
theme.rs

1//! Themes — named styles.
2//!
3//! Port of upstream `rich/theme.py` + `rich/default_styles.py`. A [`Theme`] maps
4//! style names (e.g. `"repr.number"`, `"markdown.h1"`) to [`Style`]s so that
5//! markup tags and highlighters can refer to styles by name.
6//!
7//! [`DEFAULT_STYLES`] is the complete upstream table — it is the single source
8//! of truth for named styles in this crate; nothing else should keep its own
9//! name→style map.
10
11use std::collections::HashMap;
12
13use crate::errors::Result;
14use crate::style::StyleType;
15
16use crate::style::Style;
17
18/// Upstream's `rich.default_styles.DEFAULT_STYLES`, verbatim.
19///
20/// Captured from real rich 15.0.0 (`str(style)` for each entry), so the specs
21/// are exactly what upstream parses. `theme_covers_upstream` asserts the count,
22/// and `every_default_style_parses` asserts we can actually parse all of them —
23/// a spec this crate's `Style::parse` cannot handle would otherwise be dropped
24/// silently and leave a named style resolving to nothing.
25pub 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/// A named collection of styles. Mirrors `rich.theme.Theme`.
187#[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    /// Look up a style by name.
198    pub fn get(&self, name: &str) -> Option<&Style> {
199        self.styles.get(name)
200    }
201
202    /// Insert or replace a named style.
203    pub fn insert(&mut self, name: impl Into<String>, style: Style) {
204        self.styles.insert(name.into(), style);
205    }
206
207    /// Insert every style of `other`, replacing same-named ones: upstream's
208    /// `{**base, **other.styles}` merge.
209    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    /// Resolve a [`StyleType`] against this theme. Port of `Console.get_style`.
216    ///
217    /// An already-resolved style passes straight through. A name is looked up in
218    /// the theme **first**, and only then parsed as a style definition — the
219    /// order matters, because the default theme itself defines bare words like
220    /// `none`, `bold` and `red`, and a custom theme has to be able to shadow
221    /// them.
222    ///
223    /// The lookup is case-sensitive while the parse fallback is not, which
224    /// reproduces an asymmetry upstream really has: `"BOLD"` misses the theme but
225    /// still parses to bold, whereas a theme key `"Danger"` is never found by a
226    /// span naming `"danger"`.
227    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    /// As [`get_style`](Self::get_style), but an unresolvable name yields the
238    /// null style instead of an error. Port of upstream's
239    /// `get_style(..., default=Style.null())`, which is what the render path
240    /// uses — an unknown name must not blow up a print.
241    pub fn get_style_or_null(&self, style: &StyleType) -> Style {
242        self.get_style(style).unwrap_or_default()
243    }
244
245    /// The names of every style in this theme, in no particular order.
246    pub fn names(&self) -> impl Iterator<Item = &str> {
247        self.styles.keys().map(String::as_str)
248    }
249
250    /// How many named styles this theme holds.
251    pub fn len(&self) -> usize {
252        self.styles.len()
253    }
254
255    /// Whether this theme holds no styles.
256    pub fn is_empty(&self) -> bool {
257        self.styles.is_empty()
258    }
259
260    /// The complete upstream default theme — every entry of [`DEFAULT_STYLES`].
261    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                // Unreachable in practice: `every_default_style_parses` fails
267                // the build if a spec stops parsing. Skipping keeps a bad spec
268                // from poisoning every other named style at runtime.
269                Err(_) => continue,
270            }
271        }
272        theme
273    }
274
275    /// Build a theme from `(name, style)` pairs. Port of
276    /// `Theme(styles, inherit=True)`: with `inherit` the upstream default styles
277    /// are included first and the given styles override them; without it the
278    /// theme holds only the given styles.
279    ///
280    /// Each style may be a definition to parse or an already-built [`Style`],
281    /// as upstream accepts `Union[str, Style]`. A definition that does not parse
282    /// is an error, as upstream's `Style.parse` raises.
283    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    /// The contents of a config file for this theme. Port of `Theme.config`:
305    /// a `[styles]` section with one `name = style` line per style, sorted by
306    /// name, each style written as its definition (`str(Style)`).
307    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    /// Load a theme from config-file text. Port of `Theme.from_file`, which
320    /// reads the `[styles]` section with Python's `configparser`.
321    ///
322    /// The `configparser` behaviour a theme file can observe is reproduced:
323    /// option names are lower-cased; `=` and `:` both separate name from value;
324    /// full-line `#` and `;` comments are skipped; indented lines continue the
325    /// previous value; `[DEFAULT]` options apply to `[styles]`; `%%` is a
326    /// literal `%` and `%(name)s` interpolates. A missing `[styles]` section, a
327    /// duplicate option, a line with no value and a lone `%` are errors, as they
328    /// are upstream.
329    pub fn from_file(config: &str, inherit: bool) -> Result<Self> {
330        let sections = config_file::parse(config)?;
331        let styles = config_file::styles(&sections)?;
332        Theme::from_styles(styles, inherit)
333    }
334
335    /// Read a theme from a config file on disk. Port of `Theme.read`.
336    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    /// The shared default theme, for callers that only need to resolve a name
345    /// (e.g. the built-in highlighters) and have no `Console` to hand.
346    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
352/// The subset of Python's `configparser` (default `ConfigParser()` settings)
353/// that `Theme.from_file` depends on.
354mod config_file {
355    use crate::errors::{Result, RichError};
356
357    /// Sections in file order, each with its options in file order.
358    pub(super) type Sections = Vec<(String, Vec<(String, String)>)>;
359
360    /// Errors carry the `configparser` exception name upstream would raise,
361    /// e.g. `NoSectionError: No section: 'styles'`.
362    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        // The option most recently started, as (section index, option index,
369        // indentation of its first line); indented lines continue it.
370        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                // configparser keeps blank lines inside a value but strips
380                // trailing ones; a style definition is whitespace-split, so
381                // dropping them is equivalent.
382                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            // `optionxform` lower-cases option names.
420            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    /// `config.items("styles")`: `[DEFAULT]` options, overridden by the
440    /// section's own, with `BasicInterpolation` applied to every value.
441    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    /// `BasicInterpolation`: `%%` is `%`, `%(name)s` is another option's value.
464    fn interpolate(value: &str, options: &[(String, String)], depth: usize) -> Result<String> {
465        // configparser's MAX_INTERPOLATION_DEPTH.
466        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    /// The theme is consulted *before* the style parser. The default theme
516    /// defines bare words like `red` itself, so parse-first would make a custom
517    /// theme unable to shadow them.
518    ///
519    /// Verified against real rich 15.0.0: with `Theme({"red": "blue"})`,
520    /// `Console.get_style("red")` returns blue.
521    #[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    /// Upstream's case handling is asymmetric, and this pins it: the theme
532    /// lookup is case-sensitive, but the parse fallback is not. So `"BOLD"`
533    /// misses the theme and still parses to bold, while a theme key `"Danger"`
534    /// is never found by a span naming `"danger"`.
535    #[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        // "danger" is not a style definition either, so it does not resolve.
545        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    /// The parse fallback inherits `Style::parse`'s single-letter aliases.
553    #[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    /// An unknown name is an error from `get_style` and the null style from
563    /// `get_style_or_null` — the render path uses the latter so a typo cannot
564    /// blow up a print.
565    #[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    /// An already-resolved style passes through untouched, theme or no theme.
574    #[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        // rich 15.0.0 ships exactly this many named styles.
588        assert_eq!(DEFAULT_STYLES.len(), 154);
589        // No duplicate names (a duplicate would silently shadow).
590        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    /// Every upstream spec must parse. A failure here means `Style::parse` is
602    /// missing syntax upstream uses, and that style would silently vanish from
603    /// the theme rather than resolving.
604    #[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        // Keys lower-case; indented lines continue the value; `%%` is `%`.
640        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}