Skip to main content

standout_render/style/
definition.rs

1//! Style definition types for stylesheet parsing.
2//!
3//! This module defines [`StyleDefinition`], the parsed representation of a single
4//! style entry in a YAML stylesheet. Styles can be:
5//!
6//! - Alias: Reference to another style by name
7//! - Attributes: Direct style with optional light/dark overrides
8//!
9//! # YAML Formats
10//!
11//! ```yaml
12//! # Alias - string value that's a valid style name
13//! disabled: muted
14//!
15//! # Shorthand - string with color/attribute keywords
16//! warning: "yellow bold"
17//!
18//! # Full definition - mapping with attributes
19//! header:
20//!   fg: cyan
21//!   bold: true
22//!
23//! # Adaptive definition - base plus light/dark overrides
24//! panel:
25//!   fg: gray
26//!   light:
27//!     fg: black
28//!   dark:
29//!     fg: white
30//! ```
31
32use super::attributes::{parse_shorthand, StyleAttributes};
33use super::error::StylesheetError;
34
35/// Parsed style definition from YAML.
36///
37/// Represents a single style entry before building into `console::Style`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum StyleDefinition {
40    /// Alias to another style by name.
41    ///
42    /// Alias chains are resolved during theme building.
43    Alias(String),
44
45    /// Concrete style definition with optional mode overrides.
46    ///
47    /// - `base`: Attributes shared across all modes
48    /// - `light`: Optional overrides for light mode (merged onto base)
49    /// - `dark`: Optional overrides for dark mode (merged onto base)
50    Attributes {
51        /// Base style attributes (used when no mode override exists).
52        base: StyleAttributes,
53        /// Light mode overrides (merged onto base).
54        light: Option<StyleAttributes>,
55        /// Dark mode overrides (merged onto base).
56        dark: Option<StyleAttributes>,
57    },
58}
59
60impl StyleDefinition {
61    /// Parses a style definition from a YAML value.
62    ///
63    /// Determines the definition type based on the value structure:
64    /// - String → Alias or Shorthand (depends on content)
65    /// - Mapping → Full definition with optional light/dark
66    pub fn parse(value: &serde_yaml::Value, style_name: &str) -> Result<Self, StylesheetError> {
67        match value {
68            serde_yaml::Value::String(s) => Self::parse_string(s, style_name),
69            serde_yaml::Value::Mapping(map) => Self::parse_mapping(map, style_name),
70            _ => Err(StylesheetError::InvalidDefinition {
71                style: style_name.to_string(),
72                message: format!("Expected string or mapping, got {:?}", value),
73                path: None,
74            }),
75        }
76    }
77
78    /// Parses a string value as either an alias or shorthand.
79    ///
80    /// Heuristic: If the string contains spaces or known attribute keywords,
81    /// treat it as shorthand. Otherwise, treat it as an alias.
82    fn parse_string(s: &str, style_name: &str) -> Result<Self, StylesheetError> {
83        let s = s.trim();
84
85        // Empty string is invalid
86        if s.is_empty() {
87            return Err(StylesheetError::InvalidDefinition {
88                style: style_name.to_string(),
89                message: "Empty style definition".to_string(),
90                path: None,
91            });
92        }
93
94        // If it contains spaces, it's definitely shorthand
95        if s.contains(' ') {
96            let attrs = parse_shorthand(s, style_name)?;
97            return Ok(StyleDefinition::Attributes {
98                base: attrs,
99                light: None,
100                dark: None,
101            });
102        }
103
104        // Single word: could be alias, color shorthand, or attribute shorthand
105        // Try to parse as shorthand first (covers colors and attributes like "bold")
106        match parse_shorthand(s, style_name) {
107            Ok(attrs) => {
108                // Check if this looks like an alias (valid identifier, not a color or attribute)
109                if is_likely_alias(s) {
110                    // It's an alias
111                    Ok(StyleDefinition::Alias(s.to_string()))
112                } else {
113                    // It's shorthand (color or attribute)
114                    Ok(StyleDefinition::Attributes {
115                        base: attrs,
116                        light: None,
117                        dark: None,
118                    })
119                }
120            }
121            Err(_) => {
122                // Not valid shorthand, must be an alias
123                Ok(StyleDefinition::Alias(s.to_string()))
124            }
125        }
126    }
127
128    /// Parses a mapping value as a full style definition.
129    fn parse_mapping(map: &serde_yaml::Mapping, style_name: &str) -> Result<Self, StylesheetError> {
130        // Parse base attributes (excludes light/dark keys)
131        let base = StyleAttributes::parse_mapping(map, style_name)?;
132
133        // Parse light mode overrides if present
134        let light = if let Some(light_val) = map.get(serde_yaml::Value::String("light".into())) {
135            let light_map =
136                light_val
137                    .as_mapping()
138                    .ok_or_else(|| StylesheetError::InvalidDefinition {
139                        style: style_name.to_string(),
140                        message: "'light' must be a mapping".to_string(),
141                        path: None,
142                    })?;
143            Some(StyleAttributes::parse_mapping(light_map, style_name)?)
144        } else {
145            None
146        };
147
148        // Parse dark mode overrides if present
149        let dark = if let Some(dark_val) = map.get(serde_yaml::Value::String("dark".into())) {
150            let dark_map =
151                dark_val
152                    .as_mapping()
153                    .ok_or_else(|| StylesheetError::InvalidDefinition {
154                        style: style_name.to_string(),
155                        message: "'dark' must be a mapping".to_string(),
156                        path: None,
157                    })?;
158            Some(StyleAttributes::parse_mapping(dark_map, style_name)?)
159        } else {
160            None
161        };
162
163        Ok(StyleDefinition::Attributes { base, light, dark })
164    }
165
166    /// Returns true if this is an alias definition.
167    pub fn is_alias(&self) -> bool {
168        matches!(self, StyleDefinition::Alias(_))
169    }
170
171    /// Returns the alias target if this is an alias, None otherwise.
172    pub fn alias_target(&self) -> Option<&str> {
173        match self {
174            StyleDefinition::Alias(target) => Some(target),
175            _ => None,
176        }
177    }
178}
179
180/// Checks if a single-word string is likely an alias rather than shorthand.
181///
182/// Returns true for strings that don't match known colors or attributes.
183fn is_likely_alias(s: &str) -> bool {
184    let lower = s.to_lowercase();
185
186    // Known attribute keywords (not aliases)
187    let attributes = [
188        "bold",
189        "dim",
190        "italic",
191        "underline",
192        "blink",
193        "reverse",
194        "hidden",
195        "strikethrough",
196    ];
197
198    if attributes.contains(&lower.as_str()) {
199        return false;
200    }
201
202    // Known color names (not aliases)
203    let colors = [
204        "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "grey",
205    ];
206
207    if colors.contains(&lower.as_str()) {
208        return false;
209    }
210
211    // Bright colors (not aliases)
212    if lower.starts_with("bright_") {
213        return false;
214    }
215
216    // Hex colors (not aliases)
217    if s.starts_with('#') {
218        return false;
219    }
220
221    // Everything else is likely an alias
222    true
223}
224
225#[cfg(test)]
226mod tests {
227    use super::super::color::ColorDef;
228    use super::*;
229    use console::Color;
230
231    // =========================================================================
232    // Alias parsing tests
233    // =========================================================================
234
235    #[test]
236    fn test_parse_alias() {
237        let value = serde_yaml::Value::String("muted".into());
238        let def = StyleDefinition::parse(&value, "test").unwrap();
239        assert!(matches!(def, StyleDefinition::Alias(s) if s == "muted"));
240    }
241
242    #[test]
243    fn test_parse_alias_with_underscore() {
244        let value = serde_yaml::Value::String("my_style".into());
245        let def = StyleDefinition::parse(&value, "test").unwrap();
246        assert!(matches!(def, StyleDefinition::Alias(s) if s == "my_style"));
247    }
248
249    #[test]
250    fn test_parse_alias_with_hyphen() {
251        let value = serde_yaml::Value::String("my-style".into());
252        let def = StyleDefinition::parse(&value, "test").unwrap();
253        assert!(matches!(def, StyleDefinition::Alias(s) if s == "my-style"));
254    }
255
256    // =========================================================================
257    // Shorthand parsing tests
258    // =========================================================================
259
260    #[test]
261    fn test_parse_shorthand_single_attribute() {
262        let value = serde_yaml::Value::String("bold".into());
263        let def = StyleDefinition::parse(&value, "test").unwrap();
264        match def {
265            StyleDefinition::Attributes { base, light, dark } => {
266                assert_eq!(base.bold, Some(true));
267                assert!(light.is_none());
268                assert!(dark.is_none());
269            }
270            _ => panic!("Expected Attributes"),
271        }
272    }
273
274    #[test]
275    fn test_parse_shorthand_single_color() {
276        let value = serde_yaml::Value::String("cyan".into());
277        let def = StyleDefinition::parse(&value, "test").unwrap();
278        match def {
279            StyleDefinition::Attributes { base, .. } => {
280                assert_eq!(base.fg, Some(ColorDef::Named(Color::Cyan)));
281            }
282            _ => panic!("Expected Attributes"),
283        }
284    }
285
286    #[test]
287    fn test_parse_shorthand_multiple() {
288        let value = serde_yaml::Value::String("yellow bold italic".into());
289        let def = StyleDefinition::parse(&value, "test").unwrap();
290        match def {
291            StyleDefinition::Attributes { base, .. } => {
292                assert_eq!(base.fg, Some(ColorDef::Named(Color::Yellow)));
293                assert_eq!(base.bold, Some(true));
294                assert_eq!(base.italic, Some(true));
295            }
296            _ => panic!("Expected Attributes"),
297        }
298    }
299
300    // =========================================================================
301    // Full definition parsing tests
302    // =========================================================================
303
304    #[test]
305    fn test_parse_mapping_simple() {
306        let yaml = r#"
307            fg: cyan
308            bold: true
309        "#;
310        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
311        let def = StyleDefinition::parse(&value, "test").unwrap();
312
313        match def {
314            StyleDefinition::Attributes { base, light, dark } => {
315                assert_eq!(base.fg, Some(ColorDef::Named(Color::Cyan)));
316                assert_eq!(base.bold, Some(true));
317                assert!(light.is_none());
318                assert!(dark.is_none());
319            }
320            _ => panic!("Expected Attributes"),
321        }
322    }
323
324    #[test]
325    fn test_parse_mapping_with_light_dark() {
326        let yaml = r#"
327            fg: gray
328            bold: true
329            light:
330                fg: black
331            dark:
332                fg: white
333        "#;
334        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
335        let def = StyleDefinition::parse(&value, "test").unwrap();
336
337        match def {
338            StyleDefinition::Attributes { base, light, dark } => {
339                assert_eq!(base.fg, Some(ColorDef::Named(Color::White))); // gray maps to white
340                assert_eq!(base.bold, Some(true));
341
342                let light = light.expect("light should be Some");
343                assert_eq!(light.fg, Some(ColorDef::Named(Color::Black)));
344                assert!(light.bold.is_none()); // Not overridden in light
345
346                let dark = dark.expect("dark should be Some");
347                assert_eq!(dark.fg, Some(ColorDef::Named(Color::White)));
348                assert!(dark.bold.is_none()); // Not overridden in dark
349            }
350            _ => panic!("Expected Attributes"),
351        }
352    }
353
354    #[test]
355    fn test_parse_mapping_only_light() {
356        let yaml = r#"
357            fg: gray
358            light:
359                fg: black
360        "#;
361        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
362        let def = StyleDefinition::parse(&value, "test").unwrap();
363
364        match def {
365            StyleDefinition::Attributes { light, dark, .. } => {
366                assert!(light.is_some());
367                assert!(dark.is_none());
368            }
369            _ => panic!("Expected Attributes"),
370        }
371    }
372
373    #[test]
374    fn test_parse_mapping_only_dark() {
375        let yaml = r#"
376            fg: gray
377            dark:
378                fg: white
379        "#;
380        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
381        let def = StyleDefinition::parse(&value, "test").unwrap();
382
383        match def {
384            StyleDefinition::Attributes { light, dark, .. } => {
385                assert!(light.is_none());
386                assert!(dark.is_some());
387            }
388            _ => panic!("Expected Attributes"),
389        }
390    }
391
392    // =========================================================================
393    // Edge cases
394    // =========================================================================
395
396    #[test]
397    fn test_parse_empty_string_error() {
398        let value = serde_yaml::Value::String("".into());
399        let result = StyleDefinition::parse(&value, "test");
400        assert!(result.is_err());
401    }
402
403    #[test]
404    fn test_parse_whitespace_only_error() {
405        let value = serde_yaml::Value::String("   ".into());
406        let result = StyleDefinition::parse(&value, "test");
407        assert!(result.is_err());
408    }
409
410    #[test]
411    fn test_parse_invalid_type_error() {
412        let value = serde_yaml::Value::Number(42.into());
413        let result = StyleDefinition::parse(&value, "test");
414        assert!(result.is_err());
415    }
416
417    #[test]
418    fn test_parse_light_not_mapping_error() {
419        let yaml = r#"
420            fg: cyan
421            light: invalid
422        "#;
423        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
424        let result = StyleDefinition::parse(&value, "test");
425        assert!(matches!(
426            result,
427            Err(StylesheetError::InvalidDefinition { .. })
428        ));
429    }
430
431    // =========================================================================
432    // is_alias and alias_target tests
433    // =========================================================================
434
435    #[test]
436    fn test_is_alias_true() {
437        let def = StyleDefinition::Alias("target".into());
438        assert!(def.is_alias());
439        assert_eq!(def.alias_target(), Some("target"));
440    }
441
442    #[test]
443    fn test_is_alias_false() {
444        let def = StyleDefinition::Attributes {
445            base: StyleAttributes::new(),
446            light: None,
447            dark: None,
448        };
449        assert!(!def.is_alias());
450        assert!(def.alias_target().is_none());
451    }
452
453    // =========================================================================
454    // is_likely_alias tests
455    // =========================================================================
456
457    #[test]
458    fn test_is_likely_alias_true() {
459        assert!(is_likely_alias("muted"));
460        assert!(is_likely_alias("accent"));
461        assert!(is_likely_alias("my_style"));
462        assert!(is_likely_alias("headerStyle"));
463    }
464
465    #[test]
466    fn test_is_likely_alias_false_for_colors() {
467        assert!(!is_likely_alias("red"));
468        assert!(!is_likely_alias("cyan"));
469        assert!(!is_likely_alias("bright_red"));
470    }
471
472    #[test]
473    fn test_is_likely_alias_false_for_attributes() {
474        assert!(!is_likely_alias("bold"));
475        assert!(!is_likely_alias("italic"));
476        assert!(!is_likely_alias("dim"));
477    }
478}