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_render::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        // Skip the 'icons' section — it is parsed separately by Theme
216        if name == "icons" {
217            continue;
218        }
219
220        let def = StyleDefinition::parse(value, name)?;
221        definitions.insert(name.to_string(), def);
222    }
223
224    // Build theme variants from definitions
225    build_variants(&definitions)
226}
227
228/// Builds theme variants from parsed style definitions.
229pub(crate) fn build_variants(
230    definitions: &HashMap<String, StyleDefinition>,
231) -> Result<ThemeVariants, StylesheetError> {
232    let mut variants = ThemeVariants::new();
233
234    for (name, def) in definitions {
235        match def {
236            StyleDefinition::Alias(target) => {
237                variants.aliases.insert(name.clone(), target.clone());
238            }
239            StyleDefinition::Attributes { base, light, dark } => {
240                // Build base style
241                let base_style = base.to_style();
242                variants.base.insert(name.clone(), base_style);
243
244                // Build light variant if overrides exist
245                if let Some(light_attrs) = light {
246                    let merged = base.merge(light_attrs);
247                    variants.light.insert(name.clone(), merged.to_style());
248                }
249
250                // Build dark variant if overrides exist
251                if let Some(dark_attrs) = dark {
252                    let merged = base.merge(dark_attrs);
253                    variants.dark.insert(name.clone(), merged.to_style());
254                }
255            }
256        }
257    }
258
259    Ok(variants)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // =========================================================================
267    // parse_stylesheet basic tests
268    // =========================================================================
269
270    #[test]
271    fn test_parse_empty_stylesheet() {
272        let yaml = "{}";
273        let variants = parse_stylesheet(yaml).unwrap();
274        assert!(variants.is_empty());
275    }
276
277    #[test]
278    fn test_parse_simple_style() {
279        let yaml = r#"
280            header:
281                fg: cyan
282                bold: true
283        "#;
284        let variants = parse_stylesheet(yaml).unwrap();
285
286        assert_eq!(variants.len(), 1);
287        assert!(variants.base().contains_key("header"));
288        assert!(variants.light().is_empty());
289        assert!(variants.dark().is_empty());
290    }
291
292    #[test]
293    fn test_parse_shorthand_style() {
294        let yaml = r#"
295            bold_text: bold
296            accent: cyan
297            warning: "yellow italic"
298        "#;
299        let variants = parse_stylesheet(yaml).unwrap();
300
301        assert_eq!(variants.base().len(), 3);
302        assert!(variants.base().contains_key("bold_text"));
303        assert!(variants.base().contains_key("accent"));
304        assert!(variants.base().contains_key("warning"));
305    }
306
307    #[test]
308    fn test_parse_alias() {
309        let yaml = r#"
310            muted:
311                dim: true
312            disabled: muted
313        "#;
314        let variants = parse_stylesheet(yaml).unwrap();
315
316        assert_eq!(variants.base().len(), 1);
317        assert_eq!(variants.aliases().len(), 1);
318        assert_eq!(
319            variants.aliases().get("disabled"),
320            Some(&"muted".to_string())
321        );
322    }
323
324    #[test]
325    fn test_parse_adaptive_style() {
326        let yaml = r#"
327            footer:
328                fg: gray
329                bold: true
330                light:
331                    fg: black
332                dark:
333                    fg: white
334        "#;
335        let variants = parse_stylesheet(yaml).unwrap();
336
337        assert!(variants.base().contains_key("footer"));
338        assert!(variants.light().contains_key("footer"));
339        assert!(variants.dark().contains_key("footer"));
340    }
341
342    #[test]
343    fn test_parse_light_only() {
344        let yaml = r#"
345            panel:
346                bg: gray
347                light:
348                    bg: white
349        "#;
350        let variants = parse_stylesheet(yaml).unwrap();
351
352        assert!(variants.base().contains_key("panel"));
353        assert!(variants.light().contains_key("panel"));
354        assert!(!variants.dark().contains_key("panel"));
355    }
356
357    #[test]
358    fn test_parse_dark_only() {
359        let yaml = r#"
360            panel:
361                bg: gray
362                dark:
363                    bg: black
364        "#;
365        let variants = parse_stylesheet(yaml).unwrap();
366
367        assert!(variants.base().contains_key("panel"));
368        assert!(!variants.light().contains_key("panel"));
369        assert!(variants.dark().contains_key("panel"));
370    }
371
372    // =========================================================================
373    // ThemeVariants::resolve tests
374    // =========================================================================
375
376    #[test]
377    fn test_resolve_no_mode() {
378        let yaml = r#"
379            header:
380                fg: cyan
381            footer:
382                fg: gray
383                light:
384                    fg: black
385                dark:
386                    fg: white
387        "#;
388        let variants = parse_stylesheet(yaml).unwrap();
389        let resolved = variants.resolve(None);
390
391        // Should have both styles from base
392        assert!(matches!(
393            resolved.get("header"),
394            Some(StyleValue::Concrete(_))
395        ));
396        assert!(matches!(
397            resolved.get("footer"),
398            Some(StyleValue::Concrete(_))
399        ));
400    }
401
402    #[test]
403    fn test_resolve_light_mode() {
404        let yaml = r#"
405            footer:
406                fg: gray
407                light:
408                    fg: black
409                dark:
410                    fg: white
411        "#;
412        let variants = parse_stylesheet(yaml).unwrap();
413        let resolved = variants.resolve(Some(ColorMode::Light));
414
415        // footer should use light variant
416        assert!(matches!(
417            resolved.get("footer"),
418            Some(StyleValue::Concrete(_))
419        ));
420    }
421
422    #[test]
423    fn test_resolve_dark_mode() {
424        let yaml = r#"
425            footer:
426                fg: gray
427                light:
428                    fg: black
429                dark:
430                    fg: white
431        "#;
432        let variants = parse_stylesheet(yaml).unwrap();
433        let resolved = variants.resolve(Some(ColorMode::Dark));
434
435        // footer should use dark variant
436        assert!(matches!(
437            resolved.get("footer"),
438            Some(StyleValue::Concrete(_))
439        ));
440    }
441
442    #[test]
443    fn test_resolve_preserves_aliases() {
444        let yaml = r#"
445            muted:
446                dim: true
447            disabled: muted
448        "#;
449        let variants = parse_stylesheet(yaml).unwrap();
450        let resolved = variants.resolve(Some(ColorMode::Light));
451
452        // muted should be concrete
453        assert!(matches!(
454            resolved.get("muted"),
455            Some(StyleValue::Concrete(_))
456        ));
457        // disabled should be alias
458        assert!(matches!(resolved.get("disabled"), Some(StyleValue::Alias(t)) if t == "muted"));
459    }
460
461    #[test]
462    fn test_resolve_non_adaptive_uses_base() {
463        let yaml = r#"
464            header:
465                fg: cyan
466                bold: true
467        "#;
468        let variants = parse_stylesheet(yaml).unwrap();
469
470        // Light mode
471        let light = variants.resolve(Some(ColorMode::Light));
472        assert!(matches!(light.get("header"), Some(StyleValue::Concrete(_))));
473
474        // Dark mode
475        let dark = variants.resolve(Some(ColorMode::Dark));
476        assert!(matches!(dark.get("header"), Some(StyleValue::Concrete(_))));
477
478        // No mode
479        let none = variants.resolve(None);
480        assert!(matches!(none.get("header"), Some(StyleValue::Concrete(_))));
481    }
482
483    // =========================================================================
484    // Error tests
485    // =========================================================================
486
487    #[test]
488    fn test_parse_invalid_yaml() {
489        let yaml = "not: [valid: yaml";
490        let result = parse_stylesheet(yaml);
491        assert!(matches!(result, Err(StylesheetError::Parse { .. })));
492    }
493
494    #[test]
495    fn test_parse_non_mapping_root() {
496        let yaml = "- item1\n- item2";
497        let result = parse_stylesheet(yaml);
498        assert!(matches!(result, Err(StylesheetError::Parse { .. })));
499    }
500
501    #[test]
502    fn test_parse_invalid_color() {
503        let yaml = r#"
504            bad:
505                fg: not_a_color
506        "#;
507        let result = parse_stylesheet(yaml);
508        assert!(result.is_err());
509    }
510
511    #[test]
512    fn test_parse_unknown_attribute() {
513        let yaml = r#"
514            bad:
515                unknown: true
516        "#;
517        let result = parse_stylesheet(yaml);
518        assert!(matches!(
519            result,
520            Err(StylesheetError::UnknownAttribute { .. })
521        ));
522    }
523
524    // =========================================================================
525    // Complex stylesheet tests
526    // =========================================================================
527
528    #[test]
529    fn test_parse_complete_stylesheet() {
530        let yaml = r##"
531            # Visual layer
532            muted:
533                dim: true
534
535            accent:
536                fg: cyan
537                bold: true
538
539            # Adaptive styles
540            background:
541                light:
542                    bg: "#f8f8f8"
543                dark:
544                    bg: "#1e1e1e"
545
546            text:
547                light:
548                    fg: "#333333"
549                dark:
550                    fg: "#d4d4d4"
551
552            border:
553                dim: true
554                light:
555                    fg: "#cccccc"
556                dark:
557                    fg: "#444444"
558
559            # Semantic layer - aliases
560            header: accent
561            footer: muted
562            timestamp: muted
563            title: accent
564            error: red
565            success: green
566            warning: "yellow bold"
567        "##;
568
569        let variants = parse_stylesheet(yaml).unwrap();
570
571        // Check counts
572        // Base: muted, accent, background, text, border, error, success, warning = 8
573        // Aliases: header, footer, timestamp, title = 4
574        assert_eq!(variants.base().len(), 8);
575        assert_eq!(variants.aliases().len(), 4);
576
577        // Check adaptive styles have light/dark variants
578        assert!(variants.light().contains_key("background"));
579        assert!(variants.light().contains_key("text"));
580        assert!(variants.light().contains_key("border"));
581        assert!(variants.dark().contains_key("background"));
582        assert!(variants.dark().contains_key("text"));
583        assert!(variants.dark().contains_key("border"));
584
585        // Check aliases
586        assert_eq!(
587            variants.aliases().get("header"),
588            Some(&"accent".to_string())
589        );
590        assert_eq!(variants.aliases().get("footer"), Some(&"muted".to_string()));
591    }
592}