Skip to main content

rich/
style.rs

1//! Text styles.
2//!
3//! Port of upstream `rich/style.py` (core attributes). A [`Style`] holds an
4//! optional foreground/background [`Color`] plus a set of boolean attributes
5//! (bold, italic, …). Each attribute is tri-state: `Some(true)` = on,
6//! `Some(false)` = explicitly off, `None` = unset — this preserves upstream's
7//! `_set_attributes`/`_attributes` bitmask semantics under [`Style::combine`].
8
9use crate::color::{Color, ColorSystem};
10use crate::errors::{Result, RichError};
11
12/// The 13 boolean attributes, in the SGR order upstream emits them.
13const ATTR_COUNT: usize = 13;
14
15/// SGR codes per attribute index (`rich.style._STYLE_MAP`).
16const ATTR_SGR: [&str; ATTR_COUNT] = [
17    "1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53",
18];
19
20/// Canonical attribute names per index.
21const ATTR_NAMES: [&str; ATTR_COUNT] = [
22    "bold",
23    "dim",
24    "italic",
25    "underline",
26    "blink",
27    "blink2",
28    "reverse",
29    "conceal",
30    "strike",
31    "underline2",
32    "frame",
33    "encircle",
34    "overline",
35];
36
37/// Map a style word (including upstream's short aliases) to its attribute index.
38fn attribute_index(word: &str) -> Option<usize> {
39    let canonical = match word {
40        "b" => "bold",
41        "d" => "dim",
42        "i" => "italic",
43        "u" => "underline",
44        "r" => "reverse",
45        "c" => "conceal",
46        "s" => "strike",
47        "uu" => "underline2",
48        "o" => "overline",
49        other => other,
50    };
51    ATTR_NAMES.iter().position(|&n| n == canonical)
52}
53
54/// A style, or the *name* of one to be looked up later. Port of upstream's
55/// `StyleType = Union[str, "Style"]` (`rich/style.py`).
56///
57/// A [`Span`](crate::text::Span) that holds a [`Name`](StyleType::Name) is
58/// resolved when it is rendered, against the theme of the console doing the
59/// rendering — so the same [`Text`](crate::text::Text) printed to two differently
60/// themed consoles comes out in two different colours, as it does upstream.
61/// Resolving eagerly instead would freeze the colours at construction time.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum StyleType {
64    /// A theme key (`"repr.number"`) or a style definition (`"bold red"`),
65    /// resolved by [`Theme::get_style`](crate::theme::Theme::get_style).
66    Name(String),
67    /// An already-resolved style.
68    Style(Style),
69}
70
71impl Default for StyleType {
72    fn default() -> Self {
73        StyleType::Style(Style::new())
74    }
75}
76
77impl StyleType {
78    /// True when this is an already-resolved style that sets nothing. A
79    /// [`Name`](StyleType::Name) is never null — it may resolve to anything.
80    pub fn is_null_style(&self) -> bool {
81        matches!(self, StyleType::Style(style) if style.is_null())
82    }
83}
84
85impl From<Style> for StyleType {
86    fn from(style: Style) -> Self {
87        StyleType::Style(style)
88    }
89}
90
91impl From<&Style> for StyleType {
92    fn from(style: &Style) -> Self {
93        StyleType::Style(style.clone())
94    }
95}
96
97impl From<String> for StyleType {
98    fn from(name: String) -> Self {
99        StyleType::Name(name)
100    }
101}
102
103impl From<&str> for StyleType {
104    fn from(name: &str) -> Self {
105        StyleType::Name(name.to_string())
106    }
107}
108
109/// One value in a style's [`Meta`]. Upstream's meta is any marshal-able
110/// Python value; this port keeps the scalar subset plus lists.
111///
112/// Equality follows marshal's bytes: `true` is not `1`, `1` is not `1.0`, and
113/// floats compare by bit pattern (so `NaN == NaN` and `0.0 != -0.0`).
114#[derive(Debug, Clone)]
115pub enum MetaValue {
116    /// Python `None`.
117    None,
118    Bool(bool),
119    Int(i64),
120    Float(f64),
121    Str(String),
122    /// A list or tuple of values.
123    List(Vec<MetaValue>),
124}
125
126impl PartialEq for MetaValue {
127    fn eq(&self, other: &Self) -> bool {
128        match (self, other) {
129            (MetaValue::None, MetaValue::None) => true,
130            (MetaValue::Bool(a), MetaValue::Bool(b)) => a == b,
131            (MetaValue::Int(a), MetaValue::Int(b)) => a == b,
132            (MetaValue::Float(a), MetaValue::Float(b)) => a.to_bits() == b.to_bits(),
133            (MetaValue::Str(a), MetaValue::Str(b)) => a == b,
134            (MetaValue::List(a), MetaValue::List(b)) => a == b,
135            _ => false,
136        }
137    }
138}
139
140impl Eq for MetaValue {}
141
142impl std::hash::Hash for MetaValue {
143    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
144        std::mem::discriminant(self).hash(state);
145        match self {
146            MetaValue::None => {}
147            MetaValue::Bool(value) => value.hash(state),
148            MetaValue::Int(value) => value.hash(state),
149            MetaValue::Float(value) => value.to_bits().hash(state),
150            MetaValue::Str(value) => value.hash(state),
151            MetaValue::List(values) => values.hash(state),
152        }
153    }
154}
155
156/// A style's metadata: upstream's `Style.meta` dict. Upstream stores it
157/// marshal-encoded; this port keeps the entries themselves, **in insertion
158/// order**, because marshal's bytes (and so style equality) depend on it.
159/// Assigning an existing key replaces its value in place, as a `dict` does.
160#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
161pub struct Meta {
162    entries: Vec<(String, MetaValue)>,
163}
164
165impl Meta {
166    /// An empty map.
167    pub fn new() -> Self {
168        Meta::default()
169    }
170
171    /// Set `key` (`meta[key] = value`).
172    pub fn insert(&mut self, key: impl Into<String>, value: MetaValue) {
173        let key = key.into();
174        match self
175            .entries
176            .iter_mut()
177            .find(|(existing, _)| *existing == key)
178        {
179            Some((_, slot)) => *slot = value,
180            None => self.entries.push((key, value)),
181        }
182    }
183
184    /// The value for `key`.
185    pub fn get(&self, key: &str) -> Option<&MetaValue> {
186        self.entries
187            .iter()
188            .find(|(existing, _)| existing == key)
189            .map(|(_, value)| value)
190    }
191
192    /// Merge `other` in (`dict.update`): its values win.
193    pub fn update(&mut self, other: &Meta) {
194        for (key, value) in &other.entries {
195            self.insert(key.clone(), value.clone());
196        }
197    }
198
199    /// The entries in insertion order.
200    pub fn iter(&self) -> impl Iterator<Item = (&str, &MetaValue)> {
201        self.entries
202            .iter()
203            .map(|(key, value)| (key.as_str(), value))
204    }
205
206    pub fn len(&self) -> usize {
207        self.entries.len()
208    }
209
210    pub fn is_empty(&self) -> bool {
211        self.entries.is_empty()
212    }
213}
214
215impl<K: Into<String>> FromIterator<(K, MetaValue)> for Meta {
216    fn from_iter<I: IntoIterator<Item = (K, MetaValue)>>(iter: I) -> Self {
217        let mut meta = Meta::new();
218        for (key, value) in iter {
219            meta.insert(key, value);
220        }
221        meta
222    }
223}
224
225/// A terminal text style. Mirrors `rich.style.Style`.
226#[derive(Debug, Clone, PartialEq, Eq, Default)]
227pub struct Style {
228    color: Option<Color>,
229    bgcolor: Option<Color>,
230    attrs: [Option<bool>; ATTR_COUNT],
231    /// An OSC 8 hyperlink target, if any.
232    link: Option<String>,
233    /// Upstream's `_meta`: metadata that never renders but takes part in
234    /// equality and combination (`None` is upstream's `_meta = None`).
235    meta: Option<Meta>,
236}
237
238impl Style {
239    /// The empty (null) style — sets nothing.
240    pub fn new() -> Self {
241        Style::default()
242    }
243
244    /// A style carrying only a foreground and/or background color.
245    /// Port of `Style.from_color`.
246    pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
247        Style {
248            color,
249            bgcolor,
250            attrs: [None; ATTR_COUNT],
251            link: None,
252            meta: None,
253        }
254    }
255
256    pub fn with_color(mut self, color: Color) -> Self {
257        self.color = Some(color);
258        self
259    }
260
261    /// Attach an OSC 8 hyperlink target. Port of `Style(link=…)`.
262    pub fn with_link(mut self, url: impl Into<String>) -> Self {
263        self.link = Some(url.into());
264        self
265    }
266
267    /// The hyperlink target, if set.
268    pub fn link(&self) -> Option<&str> {
269        self.link.as_deref()
270    }
271
272    /// Return a copy with the hyperlink target set (`Some`) or cleared (`None`),
273    /// leaving every other attribute unchanged. Port of `Style.update_link`.
274    pub fn update_link(&self, link: Option<String>) -> Style {
275        let mut style = self.clone();
276        style.link = link;
277        style
278    }
279
280    pub fn with_bgcolor(mut self, color: Color) -> Self {
281        self.bgcolor = Some(color);
282        self
283    }
284
285    pub fn color(&self) -> Option<&Color> {
286        self.color.as_ref()
287    }
288
289    pub fn bgcolor(&self) -> Option<&Color> {
290        self.bgcolor.as_ref()
291    }
292
293    /// A copy with the foreground and background colours removed; attributes
294    /// and link survive. Port of `Style.without_color`.
295    pub fn without_color(&self) -> Style {
296        Style {
297            color: None,
298            bgcolor: None,
299            attrs: self.attrs,
300            link: self.link.clone(),
301            meta: self.meta.clone(),
302        }
303    }
304
305    /// The tri-state value of attribute `index` (see the internal `attrs` order:
306    /// 0=bold, 1=dim, 2=italic, 3=underline, 6=reverse, 8=strike, …).
307    pub fn attr(&self, index: usize) -> Option<bool> {
308        self.attrs.get(index).copied().flatten()
309    }
310
311    /// True when nothing at all is set (renders as a no-op). Non-empty
312    /// metadata counts, as upstream's `_null` includes `meta`.
313    pub fn is_null(&self) -> bool {
314        self.color.is_none()
315            && self.bgcolor.is_none()
316            && self.link.is_none()
317            && self.meta.as_ref().is_none_or(Meta::is_empty)
318            && self.attrs.iter().all(Option::is_none)
319    }
320
321    /// Attach metadata. Port of `Style(meta=…)`. An empty map leaves the style
322    /// null, but it still differs from one with no metadata at all (upstream
323    /// hashes the marshal-encoded `{}`).
324    pub fn with_meta(mut self, meta: Meta) -> Self {
325        self.meta = Some(meta);
326        self
327    }
328
329    /// The metadata, empty when none is set. Port of the `Style.meta`
330    /// property.
331    pub fn meta(&self) -> Meta {
332        self.meta.clone().unwrap_or_default()
333    }
334
335    /// The metadata as stored: `None` when the style carries none.
336    pub fn meta_ref(&self) -> Option<&Meta> {
337        self.meta.as_ref()
338    }
339
340    /// A style carrying only `meta`. Port of `Style.from_meta` (null when
341    /// `meta` is empty). Upstream also gives it a random `link_id`, which
342    /// this port does not model (see DIVERGENCES #20).
343    pub fn from_meta(meta: Meta) -> Style {
344        Style::new().with_meta(meta)
345    }
346
347    /// A style with event-handler metadata. Port of `Style.on`: each handler
348    /// is stored as `"@name"` over `meta` (default empty).
349    pub fn on(meta: Option<Meta>, handlers: &[(&str, MetaValue)]) -> Style {
350        let mut meta = meta.unwrap_or_default();
351        for (name, value) in handlers {
352            meta.insert(format!("@{name}"), value.clone());
353        }
354        Style::from_meta(meta)
355    }
356
357    /// A copy without metadata or link. Port of `Style.clear_meta_and_links`.
358    pub fn clear_meta_and_links(&self) -> Style {
359        let mut style = self.clone();
360        style.link = None;
361        style.meta = None;
362        style
363    }
364
365    /// Regenerate the style definition string. Port of `Style.__str__`.
366    ///
367    /// Attributes come first in their canonical order, then the foreground
368    /// colour, then `on <bgcolour>`, then `link <url>`. A style that sets nothing
369    /// is `"none"` — never the empty string, which upstream reserves for "no
370    /// style at all".
371    pub fn definition(&self) -> String {
372        let mut parts: Vec<String> = Vec::new();
373        for (index, name) in ATTR_NAMES.iter().enumerate() {
374            match self.attrs[index] {
375                Some(true) => parts.push((*name).to_string()),
376                Some(false) => parts.push(format!("not {name}")),
377                None => {}
378            }
379        }
380        if let Some(color) = &self.color {
381            parts.push(color.name.clone());
382        }
383        if let Some(bgcolor) = &self.bgcolor {
384            parts.push("on".to_string());
385            parts.push(bgcolor.name.clone());
386        }
387        if let Some(link) = &self.link {
388            parts.push("link".to_string());
389            parts.push(link.clone());
390        }
391        if parts.is_empty() {
392            "none".to_string()
393        } else {
394            parts.join(" ")
395        }
396    }
397
398    /// Canonicalise a style definition so that definitions with the same effect
399    /// have the same string. Port of `Style.normalize`.
400    ///
401    /// A definition that parses round-trips through [`definition`](Self::definition),
402    /// so `"b"` and `"BOLD"` both become `"bold"`. One that does not parse is
403    /// merely trimmed and lowercased — that is the path a *theme name* like
404    /// `"repr.number"` takes, and it is why theme lookups are effectively
405    /// case-insensitive on the markup side while [`Theme::get_style`] itself is
406    /// case-sensitive.
407    ///
408    /// [`Theme::get_style`]: crate::theme::Theme::get_style
409    pub fn normalize(definition: &str) -> String {
410        match Style::parse(definition) {
411            Ok(style) => style.definition(),
412            Err(_) => definition.trim().to_lowercase(),
413        }
414    }
415
416    /// Parse a style definition such as `"bold red on blue"`.
417    ///
418    /// Port of `Style.parse` covering attributes, `not <attr>`, `link <url>`, and
419    /// `<color> on <color>`. (`meta` is deferred — see DIVERGENCES.)
420    pub fn parse(definition: &str) -> Result<Self> {
421        // Port of upstream's leading guard:
422        //     if style_definition.strip() == "none" or not style_definition
423        // `none` is only valid as the WHOLE definition — upstream raises on
424        // `"bold none"`, because inside the word loop `none` is treated as a
425        // colour name and fails to parse. Many `DEFAULT_STYLES` entries are
426        // exactly `"none"`, so without this they would all be dropped.
427        if definition.is_empty() || definition.trim() == "none" {
428            return Ok(Style::new());
429        }
430        let mut style = Style::new();
431        let mut words = definition.split_whitespace();
432        while let Some(raw) = words.next() {
433            let word = raw.to_ascii_lowercase();
434            match word.as_str() {
435                "on" => {
436                    let color_word = words.next().ok_or_else(|| {
437                        RichError::StyleSyntax("color expected after 'on'".to_string())
438                    })?;
439                    style.bgcolor = Some(Color::parse(color_word)?);
440                }
441                "not" => {
442                    let attr_word = words.next().ok_or_else(|| {
443                        RichError::StyleSyntax("attribute expected after 'not'".to_string())
444                    })?;
445                    // Deliberately NOT lowercased: upstream folds case only on
446                    // the loop word, and looks the `not` operand up verbatim —
447                    // so `"not BOLD"` is a syntax error there, and must be here.
448                    let idx = attribute_index(attr_word).ok_or_else(|| {
449                        RichError::StyleSyntax(format!(
450                            "{attr_word:?} is not a recognized attribute"
451                        ))
452                    })?;
453                    style.attrs[idx] = Some(false);
454                }
455                "link" => {
456                    // A bare `link` is a syntax error upstream, not an empty
457                    // link — accepting it would emit a hyperlink to nowhere.
458                    let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
459                        RichError::StyleSyntax("URL expected after 'link'".to_string())
460                    })?;
461                    style.link = Some(url.to_string());
462                }
463                _ => {
464                    if let Some(idx) = attribute_index(&word) {
465                        style.attrs[idx] = Some(true);
466                    } else {
467                        style.color = Some(Color::parse(&word)?);
468                    }
469                }
470            }
471        }
472        Ok(style)
473    }
474
475    /// Combine two styles, `other` winning wherever it sets a value.
476    ///
477    /// Port of `Style.__add__`.
478    pub fn combine(&self, other: &Style) -> Style {
479        let mut attrs = self.attrs;
480        for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
481            if over.is_some() {
482                *slot = *over;
483            }
484        }
485        Style {
486            color: other.color.clone().or_else(|| self.color.clone()),
487            bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
488            attrs,
489            link: other.link.clone().or_else(|| self.link.clone()),
490            // `if self._meta and style._meta: {**self.meta, **style.meta}`,
491            // else `style._meta or self._meta`.
492            meta: match (&self.meta, &other.meta) {
493                (Some(mine), Some(theirs)) => {
494                    let mut merged = mine.clone();
495                    merged.update(theirs);
496                    Some(merged)
497                }
498                (mine, theirs) => theirs.clone().or_else(|| mine.clone()),
499            },
500        }
501    }
502
503    /// The SGR parameter list (e.g. `"1;31;44"`) for a given color system.
504    ///
505    /// Port of `Style._make_ansi_codes`.
506    pub fn ansi_codes(&self, system: ColorSystem) -> String {
507        let mut sgr: Vec<String> = Vec::new();
508        for (idx, attr) in self.attrs.iter().enumerate() {
509            if *attr == Some(true) {
510                sgr.push(ATTR_SGR[idx].to_string());
511            }
512        }
513        if let Some(color) = &self.color {
514            sgr.extend(color.downgrade(system).ansi_codes(true));
515        }
516        if let Some(bgcolor) = &self.bgcolor {
517            sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
518        }
519        sgr.join(";")
520    }
521
522    /// The CSS declarations for this style under `theme` (for HTML export).
523    /// Port of `Style.get_html_style`.
524    pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
525        use crate::terminal_theme::blend_rgb;
526        let mut css: Vec<String> = Vec::new();
527
528        let mut color = self.color.clone();
529        let mut bgcolor = self.bgcolor.clone();
530        // reverse (attr index 6): swap fore/background.
531        if self.attrs[6] == Some(true) {
532            std::mem::swap(&mut color, &mut bgcolor);
533        }
534        // dim (attr index 1): blend the foreground halfway to the background.
535        if self.attrs[1] == Some(true) {
536            let fg = match &color {
537                Some(c) => theme.resolve(c, true),
538                None => theme.foreground,
539            };
540            let blended = blend_rgb(fg, theme.background, 0.5);
541            color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
542        }
543
544        if let Some(c) = &color {
545            let hex = theme.resolve(c, true).hex();
546            css.push(format!("color: {hex}"));
547            css.push(format!("text-decoration-color: {hex}"));
548        }
549        if let Some(c) = &bgcolor {
550            let hex = theme.resolve(c, false).hex();
551            css.push(format!("background-color: {hex}"));
552        }
553        if self.attrs[0] == Some(true) {
554            css.push("font-weight: bold".to_string());
555        }
556        if self.attrs[2] == Some(true) {
557            css.push("font-style: italic".to_string());
558        }
559        if self.attrs[3] == Some(true) {
560            css.push("text-decoration: underline".to_string());
561        }
562        if self.attrs[8] == Some(true) {
563            css.push("text-decoration: line-through".to_string());
564        }
565        if self.attrs[12] == Some(true) {
566            css.push("text-decoration: overline".to_string());
567        }
568        css.join("; ")
569    }
570
571    /// The SVG `<text>` CSS declarations for this style under `theme`. Port of
572    /// the `get_svg_style` closure in `Console.export_svg`. Unlike
573    /// [`get_html_style`](Self::get_html_style), the colour is always resolved to
574    /// a concrete triplet (the theme fore/background stands in for a missing or
575    /// default colour), `dim` blends 40% toward the background (not 50%), and the
576    /// rules are joined with a bare `;`.
577    pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
578        use crate::terminal_theme::blend_rgb;
579        // Resolve fore/background to concrete triplets (theme defaults fill in for
580        // a None/default colour, exactly as `theme.resolve` does for `Default`).
581        let mut color = self
582            .color
583            .as_ref()
584            .map_or(theme.foreground, |c| theme.resolve(c, true));
585        let mut bgcolor = self
586            .bgcolor
587            .as_ref()
588            .map_or(theme.background, |c| theme.resolve(c, false));
589        if self.attrs[6] == Some(true) {
590            std::mem::swap(&mut color, &mut bgcolor);
591        }
592        if self.attrs[1] == Some(true) {
593            color = blend_rgb(color, bgcolor, 0.4);
594        }
595        let mut rules = vec![format!("fill: {}", color.hex())];
596        if self.attrs[0] == Some(true) {
597            rules.push("font-weight: bold".to_string());
598        }
599        if self.attrs[2] == Some(true) {
600            rules.push("font-style: italic;".to_string());
601        }
602        if self.attrs[3] == Some(true) {
603            rules.push("text-decoration: underline;".to_string());
604        }
605        if self.attrs[8] == Some(true) {
606            rules.push("text-decoration: line-through;".to_string());
607        }
608        rules.join(";")
609    }
610
611    /// Wrap `text` in this style's escape sequence for `system`.
612    ///
613    /// With `system == None` (no color) or a null style, `text` is returned
614    /// unchanged. A [`link`](Self::with_link) additionally wraps the result in an
615    /// OSC 8 hyperlink. Port of `Style.render`.
616    ///
617    /// **Divergence:** upstream tags each hyperlink with a random `id=` field (to
618    /// group multi-segment links for hover); we omit it so output is
619    /// deterministic. See docs/DIVERGENCES.md.
620    pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
621        let Some(system) = system else {
622            return text.to_string();
623        };
624        if text.is_empty() {
625            return text.to_string();
626        }
627        let codes = self.ansi_codes(system);
628        let rendered = if codes.is_empty() {
629            text.to_string()
630        } else {
631            format!("\x1b[{codes}m{text}\x1b[0m")
632        };
633        match &self.link {
634            Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
635            None => rendered,
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    /// Expectations captured from real rich 15.0.0.
645    #[test]
646    fn meta_matches_upstream() {
647        let meta = |entries: &[(&str, MetaValue)]| -> Meta { entries.iter().cloned().collect() };
648        let a = Style::parse("bold")
649            .unwrap()
650            .with_meta(meta(&[("x", MetaValue::Int(1)), ("y", MetaValue::Int(2))]));
651        let b = Style::new().with_meta(meta(&[("y", MetaValue::Int(3)), ("z", MetaValue::Int(4))]));
652        // `{'x': 1, 'y': 3, 'z': 4}`
653        assert_eq!(
654            a.combine(&b).meta(),
655            meta(&[
656                ("x", MetaValue::Int(1)),
657                ("y", MetaValue::Int(3)),
658                ("z", MetaValue::Int(4))
659            ])
660        );
661        assert!(Style::new().with_meta(Meta::new()).is_null());
662        assert!(Style::from_meta(Meta::new()).is_null());
663        let ab = meta(&[("a", MetaValue::Int(1)), ("b", MetaValue::Int(2))]);
664        let ba = meta(&[("b", MetaValue::Int(2)), ("a", MetaValue::Int(1))]);
665        assert_ne!(Style::new().with_meta(ab), Style::new().with_meta(ba));
666        assert_ne!(
667            Style::new().with_meta(meta(&[("a", MetaValue::Bool(true))])),
668            Style::new().with_meta(meta(&[("a", MetaValue::Int(1))]))
669        );
670        assert_eq!(
671            Style::on(
672                Some(meta(&[("k", MetaValue::Int(1))])),
673                &[("click", MetaValue::Str("go".into()))]
674            )
675            .meta(),
676            meta(&[
677                ("k", MetaValue::Int(1)),
678                ("@click", MetaValue::Str("go".into()))
679            ])
680        );
681        let rich = Style::parse("bold link x")
682            .unwrap()
683            .with_meta(meta(&[("a", MetaValue::Int(1))]));
684        assert_eq!(rich.definition(), "bold link x");
685        assert_eq!(rich.clear_meta_and_links(), Style::parse("bold").unwrap());
686        // Meta never renders.
687        assert_eq!(
688            rich.ansi_codes(ColorSystem::Truecolor),
689            Style::parse("bold")
690                .unwrap()
691                .ansi_codes(ColorSystem::Truecolor)
692        );
693    }
694
695    /// `normalize` round-trips a parseable definition through `definition()` and
696    /// merely trims+lowercases one that isn't. Every expectation here was taken
697    /// from real rich 15.0.0's `Style.normalize`.
698    #[test]
699    fn normalize_matches_upstream() {
700        for (input, expected) in [
701            ("b", "bold"),
702            ("bold", "bold"),
703            ("BOLD", "bold"),
704            ("  Bold  ", "bold"),
705            ("dim i", "dim italic"),
706            ("not bold", "not bold"),
707            ("bold red", "bold red"),
708            ("red on blue", "red on blue"),
709            ("link https://x", "link https://x"),
710            // Not a style definition, so it falls through to trim+lowercase —
711            // this is the path every theme name takes.
712            ("nope", "nope"),
713            ("REPR.Number", "repr.number"),
714            // `not` is case-sensitive upstream, so this fails to parse and takes
715            // the fallback, which happens to produce the same string.
716            ("not BOLD", "not bold"),
717        ] {
718            assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
719        }
720    }
721
722    /// A style that sets nothing renders as `"none"`, never as an empty string.
723    #[test]
724    fn definition_of_null_style_is_none() {
725        assert_eq!(Style::new().definition(), "none");
726        assert_eq!(Style::parse("none").unwrap().definition(), "none");
727    }
728
729    /// `not <attr>` is case-sensitive, matching upstream, which looks the operand
730    /// up without folding and raises when it misses. Accepting `not BOLD` would
731    /// silently *cancel* an enclosing bold instead of being ignored.
732    #[test]
733    fn not_operand_is_case_sensitive() {
734        assert!(Style::parse("not bold").is_ok());
735        assert!(Style::parse("not BOLD").is_err());
736    }
737
738    /// `link <url>` is a style keyword, not a colour name. Without it the whole
739    /// definition failed to parse and the hyperlink was silently dropped.
740    #[test]
741    fn parse_understands_link() {
742        let style = Style::parse("link https://example.com").expect("link parses");
743        assert_eq!(style.link.as_deref(), Some("https://example.com"));
744        assert_eq!(style.definition(), "link https://example.com");
745        // A bare `link` is a syntax error, exactly as upstream — accepting it
746        // would emit a hyperlink to nowhere.
747        assert!(Style::parse("link").is_err());
748    }
749
750    #[test]
751    fn parse_bold_red() {
752        let style = Style::parse("bold red").unwrap();
753        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
754        assert_eq!(
755            style.render("hello", Some(ColorSystem::Truecolor)),
756            "\x1b[1;31mhello\x1b[0m"
757        );
758    }
759
760    #[test]
761    fn svg_style_matches_upstream() {
762        // Captured from real rich 15.0.0 `export_svg`'s `get_svg_style` under
763        // `SVG_EXPORT_THEME`. Note: bgcolor is excluded from the fill style,
764        // `reverse` swaps to the background, and dim blends 40% toward it.
765        use crate::terminal_theme::SVG_EXPORT_THEME as theme;
766        let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
767        assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
768        assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
769        assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
770        assert_eq!(svg("dim"), "fill: #868887");
771        assert_eq!(
772            svg("underline blue on yellow"),
773            "fill: #608ab1;text-decoration: underline;"
774        );
775        assert_eq!(svg("reverse"), "fill: #292929");
776    }
777
778    #[test]
779    fn link_wraps_in_osc8() {
780        let style = Style::parse("underline blue")
781            .unwrap()
782            .with_link("https://example.com");
783        assert_eq!(
784            style.render("click", Some(ColorSystem::Truecolor)),
785            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
786        );
787        // A link-only style still wraps (no SGR inside).
788        let bare = Style::new().with_link("https://x.com");
789        assert_eq!(
790            bare.render("y", Some(ColorSystem::Truecolor)),
791            "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
792        );
793        assert!(!bare.is_null());
794    }
795
796    #[test]
797    fn parse_fg_on_bg() {
798        let style = Style::parse("white on blue").unwrap();
799        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
800    }
801
802    #[test]
803    fn combine_overrides() {
804        let base = Style::parse("bold red").unwrap();
805        let over = Style::parse("blue").unwrap();
806        let combined = base.combine(&over);
807        // bold retained from base, color replaced by blue (34)
808        assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
809    }
810
811    #[test]
812    fn no_color_system_is_plaintext() {
813        let style = Style::parse("bold red").unwrap();
814        assert_eq!(style.render("hello", None), "hello");
815    }
816
817    #[test]
818    fn null_style_does_not_wrap() {
819        let style = Style::new();
820        assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
821    }
822}