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