Skip to main content

standout_render/style/
parser.rs

1//! Main stylesheet parser and theme variant builder.
2//!
3//! This module provides the entry point for parsing YAML stylesheets and
4//! building [`ThemeVariants`] that can be resolved based on color mode.
5//!
6//! # Architecture
7//!
8//! The parsing process has two phases:
9//!
10//! 1. Parse: YAML → `HashMap<String, StyleDefinition>`
11//! 2. Build: StyleDefinitions → `ThemeVariants` (base/light/dark style maps)
12//!
13//! During the build phase:
14//! - Aliases are recorded for later resolution
15//! - Base styles are computed from attribute definitions
16//! - Light/dark variants are computed by merging mode overrides onto base
17//!
18//! # Mode Resolution
19//!
20//! When resolving styles for a specific mode, the variant merger:
21//! - Returns the mode-specific style if one was defined
22//! - Falls back to base style if no mode override exists
23//!
24//! This means styles with no `light:` or `dark:` sections work in all modes,
25//! while adaptive styles provide mode-specific overrides.
26
27use std::collections::HashMap;
28
29use console::Style;
30
31use crate::colorspace::ThemePalette;
32
33use super::super::theme::ColorMode;
34use super::definition::StyleDefinition;
35use super::error::StylesheetError;
36use super::value::StyleValue;
37
38/// Theme variants containing styles for base, light, and dark modes.
39///
40/// Each variant is a map of style names to concrete `console::Style` values.
41/// Alias definitions are stored separately and resolved at lookup time.
42///
43/// # Resolution Strategy
44///
45/// When looking up a style for a given mode:
46///
47/// 1. If the style is an alias, follow the chain to find the concrete style
48/// 2. For concrete styles, check if a mode-specific variant exists
49/// 3. If yes, return the mode variant (base merged with mode overrides)
50/// 4. If no, return the base style
51///
52/// # Pruning
53///
54/// During construction, mode variants are only stored if they differ from base.
55/// This optimization means:
56/// - Styles with no `light:` or `dark:` sections only have base entries
57/// - Styles with overrides have entries in the relevant mode map
58#[derive(Debug, Clone)]
59pub struct ThemeVariants {
60    /// Base styles (always populated for non-alias definitions).
61    base: HashMap<String, Style>,
62
63    /// Light mode styles (only populated for styles with light overrides).
64    light: HashMap<String, Style>,
65
66    /// Dark mode styles (only populated for styles with dark overrides).
67    dark: HashMap<String, Style>,
68
69    /// Alias definitions: style name → target style name.
70    aliases: HashMap<String, String>,
71}
72
73impl ThemeVariants {
74    /// Creates empty theme variants.
75    pub fn new() -> Self {
76        Self {
77            base: HashMap::new(),
78            light: HashMap::new(),
79            dark: HashMap::new(),
80            aliases: HashMap::new(),
81        }
82    }
83
84    /// Resolves styles for the given color mode.
85    ///
86    /// Returns a `HashMap<String, StyleValue>` where:
87    /// - Aliases are preserved as `StyleValue::Alias`
88    /// - Concrete styles are `StyleValue::Concrete` with the mode-appropriate style
89    ///
90    /// For light/dark modes, mode-specific styles take precedence over base.
91    /// For unknown mode (None), only base styles are used.
92    pub fn resolve(&self, mode: Option<ColorMode>) -> HashMap<String, StyleValue> {
93        let mut result = HashMap::new();
94
95        // Add aliases
96        for (name, target) in &self.aliases {
97            result.insert(name.clone(), StyleValue::Alias(target.clone()));
98        }
99
100        // Add concrete styles based on mode
101        let mode_styles = match mode {
102            Some(ColorMode::Light) => &self.light,
103            Some(ColorMode::Dark) => &self.dark,
104            None => &HashMap::new(), // No mode-specific overrides
105        };
106
107        for (name, style) in &self.base {
108            // Check for mode-specific override
109            let style = mode_styles.get(name).unwrap_or(style);
110            result.insert(name.clone(), StyleValue::Concrete(style.clone()));
111        }
112
113        result
114    }
115
116    /// Returns the base styles map.
117    pub fn base(&self) -> &HashMap<String, Style> {
118        &self.base
119    }
120
121    /// Returns the light mode styles map.
122    pub fn light(&self) -> &HashMap<String, Style> {
123        &self.light
124    }
125
126    /// Returns the dark mode styles map.
127    pub fn dark(&self) -> &HashMap<String, Style> {
128        &self.dark
129    }
130
131    /// Returns the aliases map.
132    pub fn aliases(&self) -> &HashMap<String, String> {
133        &self.aliases
134    }
135
136    /// Returns true if no styles are defined.
137    pub fn is_empty(&self) -> bool {
138        self.base.is_empty() && self.aliases.is_empty()
139    }
140
141    /// Returns the number of defined styles (base + aliases).
142    pub fn len(&self) -> usize {
143        self.base.len() + self.aliases.len()
144    }
145}
146
147impl Default for ThemeVariants {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153/// Parses a YAML stylesheet and builds theme variants.
154///
155/// # Arguments
156///
157/// * `yaml` - YAML content as a string
158///
159/// # Returns
160///
161/// A `ThemeVariants` containing base, light, and dark style maps.
162///
163/// # Errors
164///
165/// Returns `StylesheetError` if:
166/// - YAML parsing fails
167/// - Style definitions are invalid
168/// - Colors or attributes are unrecognized
169///
170/// # Example
171///
172/// ```rust
173/// use standout_render::style::parse_stylesheet;
174///
175/// let yaml = r#"
176/// header:
177///   fg: cyan
178///   bold: true
179///
180/// muted:
181///   dim: true
182///
183/// footer:
184///   fg: gray
185///   light:
186///     fg: black
187///   dark:
188///     fg: white
189///
190/// disabled: muted
191/// "#;
192///
193/// let variants = parse_stylesheet(yaml, None).unwrap();
194/// ```
195pub fn parse_stylesheet(
196    yaml: &str,
197    palette: Option<&ThemePalette>,
198) -> Result<ThemeVariants, StylesheetError> {
199    // Parse YAML into a mapping
200    let root: serde_yaml::Value =
201        serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
202            path: None,
203            message: e.to_string(),
204        })?;
205
206    let mapping = root.as_mapping().ok_or_else(|| StylesheetError::Parse {
207        path: None,
208        message: "Stylesheet must be a YAML mapping".to_string(),
209    })?;
210
211    // Parse each style definition
212    let mut definitions: HashMap<String, StyleDefinition> = HashMap::new();
213
214    for (key, value) in mapping {
215        let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
216            path: None,
217            message: format!("Style name must be a string, got {:?}", key),
218        })?;
219
220        // Skip the 'icons' section — it is parsed separately by Theme
221        if name == "icons" {
222            continue;
223        }
224
225        let def = StyleDefinition::parse(value, name)?;
226        definitions.insert(name.to_string(), def);
227    }
228
229    // Build theme variants from definitions
230    build_variants(&definitions, palette)
231}
232
233/// Builds theme variants from parsed style definitions.
234pub(crate) fn build_variants(
235    definitions: &HashMap<String, StyleDefinition>,
236    palette: Option<&ThemePalette>,
237) -> Result<ThemeVariants, StylesheetError> {
238    let mut variants = ThemeVariants::new();
239
240    for (name, def) in definitions {
241        match def {
242            StyleDefinition::Alias(target) => {
243                variants.aliases.insert(name.clone(), target.clone());
244            }
245            StyleDefinition::Attributes { base, light, dark } => {
246                // Build base style
247                let base_style = base.to_style(palette);
248                variants.base.insert(name.clone(), base_style);
249
250                // Build light variant if overrides exist
251                if let Some(light_attrs) = light {
252                    let merged = base.merge(light_attrs);
253                    variants
254                        .light
255                        .insert(name.clone(), merged.to_style(palette));
256                }
257
258                // Build dark variant if overrides exist
259                if let Some(dark_attrs) = dark {
260                    let merged = base.merge(dark_attrs);
261                    variants.dark.insert(name.clone(), merged.to_style(palette));
262                }
263            }
264        }
265    }
266
267    Ok(variants)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    // =========================================================================
275    // parse_stylesheet basic tests
276    // =========================================================================
277
278    #[test]
279    fn test_parse_empty_stylesheet() {
280        let yaml = "{}";
281        let variants = parse_stylesheet(yaml, None).unwrap();
282        assert!(variants.is_empty());
283    }
284
285    #[test]
286    fn test_parse_simple_style() {
287        let yaml = r#"
288            header:
289                fg: cyan
290                bold: true
291        "#;
292        let variants = parse_stylesheet(yaml, None).unwrap();
293
294        assert_eq!(variants.len(), 1);
295        assert!(variants.base().contains_key("header"));
296        assert!(variants.light().is_empty());
297        assert!(variants.dark().is_empty());
298    }
299
300    #[test]
301    fn test_parse_shorthand_style() {
302        let yaml = r#"
303            bold_text: bold
304            accent: cyan
305            warning: "yellow italic"
306        "#;
307        let variants = parse_stylesheet(yaml, None).unwrap();
308
309        assert_eq!(variants.base().len(), 3);
310        assert!(variants.base().contains_key("bold_text"));
311        assert!(variants.base().contains_key("accent"));
312        assert!(variants.base().contains_key("warning"));
313    }
314
315    #[test]
316    fn test_parse_alias() {
317        let yaml = r#"
318            muted:
319                dim: true
320            disabled: muted
321        "#;
322        let variants = parse_stylesheet(yaml, None).unwrap();
323
324        assert_eq!(variants.base().len(), 1);
325        assert_eq!(variants.aliases().len(), 1);
326        assert_eq!(
327            variants.aliases().get("disabled"),
328            Some(&"muted".to_string())
329        );
330    }
331
332    #[test]
333    fn test_parse_adaptive_style() {
334        let yaml = r#"
335            footer:
336                fg: gray
337                bold: true
338                light:
339                    fg: black
340                dark:
341                    fg: white
342        "#;
343        let variants = parse_stylesheet(yaml, None).unwrap();
344
345        assert!(variants.base().contains_key("footer"));
346        assert!(variants.light().contains_key("footer"));
347        assert!(variants.dark().contains_key("footer"));
348    }
349
350    #[test]
351    fn test_parse_light_only() {
352        let yaml = r#"
353            panel:
354                bg: gray
355                light:
356                    bg: white
357        "#;
358        let variants = parse_stylesheet(yaml, None).unwrap();
359
360        assert!(variants.base().contains_key("panel"));
361        assert!(variants.light().contains_key("panel"));
362        assert!(!variants.dark().contains_key("panel"));
363    }
364
365    #[test]
366    fn test_parse_dark_only() {
367        let yaml = r#"
368            panel:
369                bg: gray
370                dark:
371                    bg: black
372        "#;
373        let variants = parse_stylesheet(yaml, None).unwrap();
374
375        assert!(variants.base().contains_key("panel"));
376        assert!(!variants.light().contains_key("panel"));
377        assert!(variants.dark().contains_key("panel"));
378    }
379
380    // =========================================================================
381    // ThemeVariants::resolve tests
382    // =========================================================================
383
384    #[test]
385    fn test_resolve_no_mode() {
386        let yaml = r#"
387            header:
388                fg: cyan
389            footer:
390                fg: gray
391                light:
392                    fg: black
393                dark:
394                    fg: white
395        "#;
396        let variants = parse_stylesheet(yaml, None).unwrap();
397        let resolved = variants.resolve(None);
398
399        // Should have both styles from base
400        assert!(matches!(
401            resolved.get("header"),
402            Some(StyleValue::Concrete(_))
403        ));
404        assert!(matches!(
405            resolved.get("footer"),
406            Some(StyleValue::Concrete(_))
407        ));
408    }
409
410    #[test]
411    fn test_resolve_light_mode() {
412        let yaml = r#"
413            footer:
414                fg: gray
415                light:
416                    fg: black
417                dark:
418                    fg: white
419        "#;
420        let variants = parse_stylesheet(yaml, None).unwrap();
421        let resolved = variants.resolve(Some(ColorMode::Light));
422
423        // footer should use light variant
424        assert!(matches!(
425            resolved.get("footer"),
426            Some(StyleValue::Concrete(_))
427        ));
428    }
429
430    #[test]
431    fn test_resolve_dark_mode() {
432        let yaml = r#"
433            footer:
434                fg: gray
435                light:
436                    fg: black
437                dark:
438                    fg: white
439        "#;
440        let variants = parse_stylesheet(yaml, None).unwrap();
441        let resolved = variants.resolve(Some(ColorMode::Dark));
442
443        // footer should use dark variant
444        assert!(matches!(
445            resolved.get("footer"),
446            Some(StyleValue::Concrete(_))
447        ));
448    }
449
450    #[test]
451    fn test_resolve_preserves_aliases() {
452        let yaml = r#"
453            muted:
454                dim: true
455            disabled: muted
456        "#;
457        let variants = parse_stylesheet(yaml, None).unwrap();
458        let resolved = variants.resolve(Some(ColorMode::Light));
459
460        // muted should be concrete
461        assert!(matches!(
462            resolved.get("muted"),
463            Some(StyleValue::Concrete(_))
464        ));
465        // disabled should be alias
466        assert!(matches!(resolved.get("disabled"), Some(StyleValue::Alias(t)) if t == "muted"));
467    }
468
469    #[test]
470    fn test_resolve_non_adaptive_uses_base() {
471        let yaml = r#"
472            header:
473                fg: cyan
474                bold: true
475        "#;
476        let variants = parse_stylesheet(yaml, None).unwrap();
477
478        // Light mode
479        let light = variants.resolve(Some(ColorMode::Light));
480        assert!(matches!(light.get("header"), Some(StyleValue::Concrete(_))));
481
482        // Dark mode
483        let dark = variants.resolve(Some(ColorMode::Dark));
484        assert!(matches!(dark.get("header"), Some(StyleValue::Concrete(_))));
485
486        // No mode
487        let none = variants.resolve(None);
488        assert!(matches!(none.get("header"), Some(StyleValue::Concrete(_))));
489    }
490
491    // =========================================================================
492    // Error tests
493    // =========================================================================
494
495    #[test]
496    fn test_parse_invalid_yaml() {
497        let yaml = "not: [valid: yaml";
498        let result = parse_stylesheet(yaml, None);
499        assert!(matches!(result, Err(StylesheetError::Parse { .. })));
500    }
501
502    #[test]
503    fn test_parse_non_mapping_root() {
504        let yaml = "- item1\n- item2";
505        let result = parse_stylesheet(yaml, None);
506        assert!(matches!(result, Err(StylesheetError::Parse { .. })));
507    }
508
509    #[test]
510    fn test_parse_invalid_color() {
511        let yaml = r#"
512            bad:
513                fg: not_a_color
514        "#;
515        let result = parse_stylesheet(yaml, None);
516        assert!(result.is_err());
517    }
518
519    #[test]
520    fn test_parse_unknown_attribute() {
521        let yaml = r#"
522            bad:
523                unknown: true
524        "#;
525        let result = parse_stylesheet(yaml, None);
526        assert!(matches!(
527            result,
528            Err(StylesheetError::UnknownAttribute { .. })
529        ));
530    }
531
532    // =========================================================================
533    // Complex stylesheet tests
534    // =========================================================================
535
536    #[test]
537    fn test_parse_complete_stylesheet() {
538        let yaml = r##"
539            # Visual layer
540            muted:
541                dim: true
542
543            accent:
544                fg: cyan
545                bold: true
546
547            # Adaptive styles
548            background:
549                light:
550                    bg: "#f8f8f8"
551                dark:
552                    bg: "#1e1e1e"
553
554            text:
555                light:
556                    fg: "#333333"
557                dark:
558                    fg: "#d4d4d4"
559
560            border:
561                dim: true
562                light:
563                    fg: "#cccccc"
564                dark:
565                    fg: "#444444"
566
567            # Semantic layer - aliases
568            header: accent
569            footer: muted
570            timestamp: muted
571            title: accent
572            error: red
573            success: green
574            warning: "yellow bold"
575        "##;
576
577        let variants = parse_stylesheet(yaml, None).unwrap();
578
579        // Check counts
580        // Base: muted, accent, background, text, border, error, success, warning = 8
581        // Aliases: header, footer, timestamp, title = 4
582        assert_eq!(variants.base().len(), 8);
583        assert_eq!(variants.aliases().len(), 4);
584
585        // Check adaptive styles have light/dark variants
586        assert!(variants.light().contains_key("background"));
587        assert!(variants.light().contains_key("text"));
588        assert!(variants.light().contains_key("border"));
589        assert!(variants.dark().contains_key("background"));
590        assert!(variants.dark().contains_key("text"));
591        assert!(variants.dark().contains_key("border"));
592
593        // Check aliases
594        assert_eq!(
595            variants.aliases().get("header"),
596            Some(&"accent".to_string())
597        );
598        assert_eq!(variants.aliases().get("footer"), Some(&"muted".to_string()));
599    }
600
601    // =========================================================================
602    // Cube color integration tests
603    // =========================================================================
604
605    #[test]
606    fn test_parse_cube_color_in_stylesheet() {
607        let yaml = r#"
608            theme_accent:
609                fg: "cube(60%, 20%, 0%)"
610                bold: true
611        "#;
612        let variants = parse_stylesheet(yaml, None).unwrap();
613        assert!(variants.base().contains_key("theme_accent"));
614    }
615
616    #[test]
617    fn test_parse_cube_color_with_palette() {
618        use crate::colorspace::{Rgb, ThemePalette};
619
620        let palette = ThemePalette::new([
621            Rgb(40, 40, 40),
622            Rgb(204, 36, 29),
623            Rgb(152, 151, 26),
624            Rgb(215, 153, 33),
625            Rgb(69, 133, 136),
626            Rgb(177, 98, 134),
627            Rgb(104, 157, 106),
628            Rgb(168, 153, 132),
629        ]);
630
631        let yaml = r#"
632            warm:
633                fg: "cube(80%, 30%, 0%)"
634            cool:
635                fg: "cube(0%, 0%, 80%)"
636        "#;
637        let variants = parse_stylesheet(yaml, Some(&palette)).unwrap();
638        assert!(variants.base().contains_key("warm"));
639        assert!(variants.base().contains_key("cool"));
640    }
641
642    #[test]
643    fn test_parse_cube_color_adaptive() {
644        let yaml = r#"
645            panel:
646                fg: "cube(50%, 50%, 50%)"
647                light:
648                    fg: "cube(20%, 20%, 20%)"
649                dark:
650                    fg: "cube(80%, 80%, 80%)"
651        "#;
652        let variants = parse_stylesheet(yaml, None).unwrap();
653        assert!(variants.base().contains_key("panel"));
654        assert!(variants.light().contains_key("panel"));
655        assert!(variants.dark().contains_key("panel"));
656    }
657}