standout_render/style/
parser.rs1use 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#[derive(Debug, Clone)]
57pub struct ThemeVariants {
58 base: HashMap<String, Style>,
60
61 light: HashMap<String, Style>,
63
64 dark: HashMap<String, Style>,
66
67 aliases: HashMap<String, String>,
69}
70
71impl ThemeVariants {
72 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 pub fn resolve(&self, mode: Option<ColorMode>) -> HashMap<String, StyleValue> {
91 let mut result = HashMap::new();
92
93 for (name, target) in &self.aliases {
95 result.insert(name.clone(), StyleValue::Alias(target.clone()));
96 }
97
98 let mode_styles = match mode {
100 Some(ColorMode::Light) => &self.light,
101 Some(ColorMode::Dark) => &self.dark,
102 None => &HashMap::new(), };
104
105 for (name, style) in &self.base {
106 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 pub fn base(&self) -> &HashMap<String, Style> {
116 &self.base
117 }
118
119 pub fn light(&self) -> &HashMap<String, Style> {
121 &self.light
122 }
123
124 pub fn dark(&self) -> &HashMap<String, Style> {
126 &self.dark
127 }
128
129 pub fn aliases(&self) -> &HashMap<String, String> {
131 &self.aliases
132 }
133
134 pub fn is_empty(&self) -> bool {
136 self.base.is_empty() && self.aliases.is_empty()
137 }
138
139 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
151pub fn parse_stylesheet(yaml: &str) -> Result<ThemeVariants, StylesheetError> {
194 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 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 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_variants(&definitions)
226}
227
228pub(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 let base_style = base.to_style();
242 variants.base.insert(name.clone(), base_style);
243
244 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 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 #[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 #[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 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 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 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 assert!(matches!(
454 resolved.get("muted"),
455 Some(StyleValue::Concrete(_))
456 ));
457 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 let light = variants.resolve(Some(ColorMode::Light));
472 assert!(matches!(light.get("header"), Some(StyleValue::Concrete(_))));
473
474 let dark = variants.resolve(Some(ColorMode::Dark));
476 assert!(matches!(dark.get("header"), Some(StyleValue::Concrete(_))));
477
478 let none = variants.resolve(None);
480 assert!(matches!(none.get("header"), Some(StyleValue::Concrete(_))));
481 }
482
483 #[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 #[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 assert_eq!(variants.base().len(), 8);
575 assert_eq!(variants.aliases().len(), 4);
576
577 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 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}