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 names of each section, so a duplicate is found without
369        // rescanning the section (which made large files quadratic).
370        let mut seen: Vec<std::collections::HashSet<String>> = Vec::new();
371        // The option most recently started, as (section index, option index,
372        // indentation of its first line); indented lines continue it.
373        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                // configparser keeps blank lines inside a value but strips
383                // trailing ones; a style definition is whitespace-split, so
384                // dropping them is equivalent.
385                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            // `optionxform` lower-cases option names.
424            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    /// `config.items("styles")`: `[DEFAULT]` options, overridden by the
444    /// section's own, with `BasicInterpolation` applied to every value.
445    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        // Name -> position in `merged`, keeping configparser's order: the
455        // `[DEFAULT]` options first, then the section's new ones.
456        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    /// `BasicInterpolation`: `%%` is `%`, `%(name)s` is another option's value.
481    fn interpolate(
482        value: &str,
483        options: &std::collections::HashMap<&str, &str>,
484        depth: usize,
485    ) -> Result<String> {
486        // configparser's MAX_INTERPOLATION_DEPTH.
487        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    /// Parsing a theme file is linear: 40 000 styles (with `[DEFAULT]`
537    /// overrides and interpolation) took ~32 s in a debug build when every
538    /// option was checked against every earlier one. The bound is generous so
539    /// only a quadratic regression trips it.
540    #[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        // Duplicate detection still works at scale.
564        config.push_str("style7 = blue\n");
565        assert!(Theme::from_file(&config, false).is_err());
566    }
567
568    /// The theme is consulted *before* the style parser. The default theme
569    /// defines bare words like `red` itself, so parse-first would make a custom
570    /// theme unable to shadow them.
571    ///
572    /// Verified against real rich 15.0.0: with `Theme({"red": "blue"})`,
573    /// `Console.get_style("red")` returns blue.
574    #[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    /// Upstream's case handling is asymmetric, and this pins it: the theme
585    /// lookup is case-sensitive, but the parse fallback is not. So `"BOLD"`
586    /// misses the theme and still parses to bold, while a theme key `"Danger"`
587    /// is never found by a span naming `"danger"`.
588    #[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        // "danger" is not a style definition either, so it does not resolve.
598        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    /// The parse fallback inherits `Style::parse`'s single-letter aliases.
606    #[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    /// An unknown name is an error from `get_style` and the null style from
616    /// `get_style_or_null` — the render path uses the latter so a typo cannot
617    /// blow up a print.
618    #[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    /// An already-resolved style passes through untouched, theme or no theme.
627    #[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        // rich 15.0.0 ships exactly this many named styles.
641        assert_eq!(DEFAULT_STYLES.len(), 154);
642        // No duplicate names (a duplicate would silently shadow).
643        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    /// Every upstream spec must parse. A failure here means `Style::parse` is
655    /// missing syntax upstream uses, and that style would silently vanish from
656    /// the theme rather than resolving.
657    #[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        // Keys lower-case; indented lines continue the value; `%%` is `%`.
693        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}