Skip to main content

xript_ratatui/
parser.rs

1use std::collections::HashMap;
2
3use ratatui::layout::{Alignment, Constraint, Direction};
4use ratatui::style::Style;
5use serde_json::Value;
6
7use crate::layout::{parse_constraint, parse_direction};
8use crate::style::parse_style;
9
10#[derive(Debug, Clone)]
11pub struct BlockProps {
12    pub title: Option<String>,
13    pub borders: Borders,
14    pub border_type: BorderType,
15    pub border_style: Style,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum Borders {
20    None,
21    All,
22    Top,
23    Bottom,
24    Left,
25    Right,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum BorderType {
30    Plain,
31    Rounded,
32    Double,
33    Thick,
34}
35
36#[derive(Debug, Clone)]
37pub struct ParagraphProps {
38    pub alignment: Alignment,
39    pub style: Style,
40    pub data_if: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct SpanProps {
45    pub style: Style,
46    pub data_bind: Option<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct LineNode {
51    pub spans: Vec<SpanNode>,
52}
53
54#[derive(Debug, Clone)]
55pub enum SpanNode {
56    Styled { props: SpanProps, text: String },
57    Text(String),
58}
59
60#[derive(Debug, Clone)]
61pub struct GaugeProps {
62    pub data_bind: Option<String>,
63    pub ratio_bind: Option<RatioBind>,
64    pub gauge_style: Style,
65    pub label: Option<String>,
66    pub data_if: Option<String>,
67}
68
69#[derive(Debug, Clone)]
70pub struct RatioBind {
71    pub numerator: String,
72    pub denominator: String,
73}
74
75#[derive(Debug, Clone)]
76pub struct LayoutProps {
77    pub direction: Direction,
78    pub constraints: Vec<Constraint>,
79    pub data_if: Option<String>,
80}
81
82#[derive(Debug, Clone)]
83pub struct ListProps {
84    pub style: Style,
85    pub data_if: Option<String>,
86}
87
88#[derive(Debug, Clone)]
89pub struct TableProps {
90    pub header: Option<Vec<String>>,
91    pub widths: Vec<Constraint>,
92    pub style: Style,
93    pub data_if: Option<String>,
94}
95
96#[derive(Debug, Clone)]
97pub struct SparklineProps {
98    pub style: Style,
99    pub data: Vec<u64>,
100    pub data_bind: Option<String>,
101    pub data_if: Option<String>,
102}
103
104#[derive(Debug, Clone)]
105pub enum WidgetNode {
106    Block {
107        props: BlockProps,
108        children: Vec<WidgetNode>,
109    },
110    Paragraph {
111        props: ParagraphProps,
112        lines: Vec<LineNode>,
113    },
114    Gauge {
115        props: GaugeProps,
116    },
117    Layout {
118        props: LayoutProps,
119        children: Vec<WidgetNode>,
120    },
121    List {
122        props: ListProps,
123        items: Vec<String>,
124    },
125    Table {
126        props: TableProps,
127        rows: Vec<Vec<String>>,
128    },
129    Sparkline {
130        props: SparklineProps,
131    },
132    Text(String),
133}
134
135pub fn parse_fragment(value: &Value, bindings: &HashMap<String, Value>) -> Option<WidgetNode> {
136    match value {
137        Value::String(s) => Some(WidgetNode::Text(s.clone())),
138        Value::Array(arr) if !arr.is_empty() => {
139            let tag = arr[0].as_str()?;
140            let (attrs, child_start) = extract_attrs(arr);
141            parse_widget(tag, &attrs, arr, child_start, bindings)
142        }
143        _ => None,
144    }
145}
146
147fn extract_attrs(arr: &[Value]) -> (Value, usize) {
148    if arr.len() > 1 {
149        if let Some(obj) = arr[1].as_object() {
150            return (Value::Object(obj.clone()), 2);
151        }
152    }
153    (Value::Object(serde_json::Map::new()), 1)
154}
155
156fn parse_widget(
157    tag: &str,
158    attrs: &Value,
159    arr: &[Value],
160    child_start: usize,
161    bindings: &HashMap<String, Value>,
162) -> Option<WidgetNode> {
163    match tag {
164        "Block" => parse_block(attrs, arr, child_start, bindings),
165        "Paragraph" => parse_paragraph(attrs, arr, child_start, bindings),
166        "Gauge" => parse_gauge(attrs),
167        "Layout" => parse_layout(attrs, arr, child_start, bindings),
168        "List" => parse_list(attrs, arr, child_start),
169        "Table" => parse_table(attrs, arr, child_start),
170        "Sparkline" => parse_sparkline(attrs),
171        _ => None,
172    }
173}
174
175fn parse_block(
176    attrs: &Value,
177    arr: &[Value],
178    child_start: usize,
179    bindings: &HashMap<String, Value>,
180) -> Option<WidgetNode> {
181    let obj = attrs.as_object();
182
183    let title = obj.and_then(|o| o.get("title")).and_then(|v| v.as_str()).map(String::from);
184    let borders = obj
185        .and_then(|o| o.get("borders"))
186        .and_then(|v| v.as_str())
187        .map(parse_borders)
188        .unwrap_or(Borders::None);
189    let border_type = obj
190        .and_then(|o| o.get("border_type"))
191        .and_then(|v| v.as_str())
192        .map(parse_border_type)
193        .unwrap_or(BorderType::Plain);
194    let border_style = obj
195        .and_then(|o| o.get("border_style"))
196        .map(|v| parse_style(v))
197        .unwrap_or_default();
198
199    let children = parse_children(arr, child_start, bindings);
200
201    Some(WidgetNode::Block {
202        props: BlockProps {
203            title,
204            borders,
205            border_type,
206            border_style,
207        },
208        children,
209    })
210}
211
212fn parse_paragraph(
213    attrs: &Value,
214    arr: &[Value],
215    child_start: usize,
216    bindings: &HashMap<String, Value>,
217) -> Option<WidgetNode> {
218    let obj = attrs.as_object();
219
220    let alignment = obj
221        .and_then(|o| o.get("alignment"))
222        .and_then(|v| v.as_str())
223        .map(parse_alignment)
224        .unwrap_or(Alignment::Left);
225    let style = obj
226        .and_then(|o| o.get("style"))
227        .map(|v| parse_style(v))
228        .unwrap_or_default();
229    let data_if = obj
230        .and_then(|o| o.get("data-if"))
231        .and_then(|v| v.as_str())
232        .map(String::from);
233
234    let mut lines = Vec::new();
235    for i in child_start..arr.len() {
236        if let Some(line) = parse_line_node(&arr[i], bindings) {
237            lines.push(line);
238        }
239    }
240
241    Some(WidgetNode::Paragraph {
242        props: ParagraphProps {
243            alignment,
244            style,
245            data_if,
246        },
247        lines,
248    })
249}
250
251fn parse_line_node(value: &Value, bindings: &HashMap<String, Value>) -> Option<LineNode> {
252    match value {
253        Value::String(s) => Some(LineNode {
254            spans: vec![SpanNode::Text(s.clone())],
255        }),
256        Value::Array(arr) if !arr.is_empty() => {
257            let tag = arr[0].as_str()?;
258            if tag != "Line" {
259                return None;
260            }
261            let (_, child_start) = extract_attrs(arr);
262            let mut spans = Vec::new();
263            for i in child_start..arr.len() {
264                if let Some(span) = parse_span_node(&arr[i], bindings) {
265                    spans.push(span);
266                }
267            }
268            Some(LineNode { spans })
269        }
270        _ => None,
271    }
272}
273
274fn parse_span_node(value: &Value, bindings: &HashMap<String, Value>) -> Option<SpanNode> {
275    match value {
276        Value::String(s) => Some(SpanNode::Text(s.clone())),
277        Value::Array(arr) if !arr.is_empty() => {
278            let tag = arr[0].as_str()?;
279            if tag != "Span" {
280                return None;
281            }
282            let (attrs, child_start) = extract_attrs(arr);
283            let obj = attrs.as_object();
284
285            let style = obj
286                .and_then(|o| o.get("style"))
287                .map(|v| parse_style(v))
288                .unwrap_or_default();
289            let data_bind = obj
290                .and_then(|o| o.get("data-bind"))
291                .and_then(|v| v.as_str())
292                .map(String::from);
293
294            let mut text = String::new();
295            for i in child_start..arr.len() {
296                if let Some(s) = arr[i].as_str() {
297                    text.push_str(s);
298                }
299            }
300
301            if let Some(ref bind_name) = data_bind {
302                if let Some(val) = bindings.get(bind_name) {
303                    text = value_to_display_string(val);
304                }
305            }
306
307            Some(SpanNode::Styled {
308                props: SpanProps { style, data_bind },
309                text,
310            })
311        }
312        _ => None,
313    }
314}
315
316fn parse_gauge(attrs: &Value) -> Option<WidgetNode> {
317    let obj = attrs.as_object();
318
319    let data_bind = obj
320        .and_then(|o| o.get("data-bind"))
321        .and_then(|v| v.as_str())
322        .map(String::from);
323    let ratio_bind = obj.and_then(|o| o.get("ratio_bind")).and_then(|v| {
324        let rb = v.as_object()?;
325        Some(RatioBind {
326            numerator: rb.get("numerator")?.as_str()?.to_string(),
327            denominator: rb.get("denominator")?.as_str()?.to_string(),
328        })
329    });
330    let gauge_style = obj
331        .and_then(|o| o.get("gauge_style"))
332        .map(|v| parse_style(v))
333        .unwrap_or_default();
334    let label = obj
335        .and_then(|o| o.get("label"))
336        .and_then(|v| v.as_str())
337        .map(String::from);
338    let data_if = obj
339        .and_then(|o| o.get("data-if"))
340        .and_then(|v| v.as_str())
341        .map(String::from);
342
343    Some(WidgetNode::Gauge {
344        props: GaugeProps {
345            data_bind,
346            ratio_bind,
347            gauge_style,
348            label,
349            data_if,
350        },
351    })
352}
353
354fn parse_layout(
355    attrs: &Value,
356    arr: &[Value],
357    child_start: usize,
358    bindings: &HashMap<String, Value>,
359) -> Option<WidgetNode> {
360    let obj = attrs.as_object();
361
362    let direction = obj
363        .and_then(|o| o.get("direction"))
364        .and_then(|v| v.as_str())
365        .map(parse_direction)
366        .unwrap_or(Direction::Vertical);
367
368    let constraints = obj
369        .and_then(|o| o.get("constraints"))
370        .and_then(|v| v.as_array())
371        .map(|arr| {
372            arr.iter()
373                .filter_map(|v| v.as_str().and_then(parse_constraint))
374                .collect()
375        })
376        .unwrap_or_default();
377
378    let data_if = obj
379        .and_then(|o| o.get("data-if"))
380        .and_then(|v| v.as_str())
381        .map(String::from);
382
383    let children = parse_children(arr, child_start, bindings);
384
385    Some(WidgetNode::Layout {
386        props: LayoutProps {
387            direction,
388            constraints,
389            data_if,
390        },
391        children,
392    })
393}
394
395fn parse_list(attrs: &Value, arr: &[Value], child_start: usize) -> Option<WidgetNode> {
396    let obj = attrs.as_object();
397
398    let style = obj
399        .and_then(|o| o.get("style"))
400        .map(|v| parse_style(v))
401        .unwrap_or_default();
402    let data_if = obj
403        .and_then(|o| o.get("data-if"))
404        .and_then(|v| v.as_str())
405        .map(String::from);
406
407    let items: Vec<String> = (child_start..arr.len())
408        .filter_map(|i| arr[i].as_str().map(String::from))
409        .collect();
410
411    Some(WidgetNode::List {
412        props: ListProps { style, data_if },
413        items,
414    })
415}
416
417fn parse_table(attrs: &Value, arr: &[Value], child_start: usize) -> Option<WidgetNode> {
418    let obj = attrs.as_object();
419
420    let header = obj.and_then(|o| o.get("header")).and_then(|v| {
421        v.as_array().map(|a| {
422            a.iter()
423                .filter_map(|v| v.as_str().map(String::from))
424                .collect()
425        })
426    });
427    let widths = obj
428        .and_then(|o| o.get("widths"))
429        .and_then(|v| v.as_array())
430        .map(|a| {
431            a.iter()
432                .filter_map(|v| v.as_str().and_then(parse_constraint))
433                .collect()
434        })
435        .unwrap_or_default();
436    let style = obj
437        .and_then(|o| o.get("style"))
438        .map(|v| parse_style(v))
439        .unwrap_or_default();
440    let data_if = obj
441        .and_then(|o| o.get("data-if"))
442        .and_then(|v| v.as_str())
443        .map(String::from);
444
445    let rows: Vec<Vec<String>> = (child_start..arr.len())
446        .filter_map(|i| {
447            arr[i].as_array().map(|row| {
448                row.iter()
449                    .filter_map(|v| v.as_str().map(String::from))
450                    .collect()
451            })
452        })
453        .collect();
454
455    Some(WidgetNode::Table {
456        props: TableProps {
457            header,
458            widths,
459            style,
460            data_if,
461        },
462        rows,
463    })
464}
465
466fn parse_sparkline(attrs: &Value) -> Option<WidgetNode> {
467    let obj = attrs.as_object();
468
469    let style = obj
470        .and_then(|o| o.get("style"))
471        .map(|v| parse_style(v))
472        .unwrap_or_default();
473    let data = obj
474        .and_then(|o| o.get("data"))
475        .and_then(|v| v.as_array())
476        .map(|a| a.iter().filter_map(|v| v.as_u64()).collect())
477        .unwrap_or_default();
478    let data_bind = obj
479        .and_then(|o| o.get("data-bind"))
480        .and_then(|v| v.as_str())
481        .map(String::from);
482    let data_if = obj
483        .and_then(|o| o.get("data-if"))
484        .and_then(|v| v.as_str())
485        .map(String::from);
486
487    Some(WidgetNode::Sparkline {
488        props: SparklineProps {
489            style,
490            data,
491            data_bind,
492            data_if,
493        },
494    })
495}
496
497fn parse_children(
498    arr: &[Value],
499    child_start: usize,
500    bindings: &HashMap<String, Value>,
501) -> Vec<WidgetNode> {
502    (child_start..arr.len())
503        .filter_map(|i| parse_fragment(&arr[i], bindings))
504        .collect()
505}
506
507fn parse_borders(s: &str) -> Borders {
508    match s {
509        "ALL" => Borders::All,
510        "TOP" => Borders::Top,
511        "BOTTOM" => Borders::Bottom,
512        "LEFT" => Borders::Left,
513        "RIGHT" => Borders::Right,
514        "NONE" => Borders::None,
515        _ => Borders::None,
516    }
517}
518
519fn parse_border_type(s: &str) -> BorderType {
520    match s {
521        "Plain" => BorderType::Plain,
522        "Rounded" => BorderType::Rounded,
523        "Double" => BorderType::Double,
524        "Thick" => BorderType::Thick,
525        _ => BorderType::Plain,
526    }
527}
528
529fn parse_alignment(s: &str) -> Alignment {
530    match s {
531        "Left" => Alignment::Left,
532        "Center" => Alignment::Center,
533        "Right" => Alignment::Right,
534        _ => Alignment::Left,
535    }
536}
537
538pub fn value_to_display_string(val: &Value) -> String {
539    match val {
540        Value::String(s) => s.clone(),
541        Value::Null => String::new(),
542        other => other.to_string(),
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use serde_json::json;
550
551    #[test]
552    fn parses_text_node() {
553        let node = parse_fragment(&json!("hello"), &HashMap::new());
554        assert!(matches!(node, Some(WidgetNode::Text(ref s)) if s == "hello"));
555    }
556
557    #[test]
558    fn parses_block_with_title() {
559        let json = json!(["Block", {"title": "Health", "borders": "ALL"}]);
560        let node = parse_fragment(&json, &HashMap::new()).unwrap();
561        match node {
562            WidgetNode::Block { props, children } => {
563                assert_eq!(props.title.as_deref(), Some("Health"));
564                assert_eq!(props.borders, Borders::All);
565                assert!(children.is_empty());
566            }
567            _ => panic!("expected Block"),
568        }
569    }
570
571    #[test]
572    fn parses_block_with_rounded_border() {
573        let json = json!(["Block", {"border_type": "Rounded"}]);
574        let node = parse_fragment(&json, &HashMap::new()).unwrap();
575        match node {
576            WidgetNode::Block { props, .. } => {
577                assert_eq!(props.border_type, BorderType::Rounded);
578            }
579            _ => panic!("expected Block"),
580        }
581    }
582
583    #[test]
584    fn parses_paragraph_with_lines() {
585        let json = json!(["Paragraph", {"alignment": "Center"},
586            ["Line", {},
587                ["Span", {"style": {"fg": "Green"}}, "hello"]
588            ]
589        ]);
590        let node = parse_fragment(&json, &HashMap::new()).unwrap();
591        match node {
592            WidgetNode::Paragraph { props, lines } => {
593                assert_eq!(props.alignment, Alignment::Center);
594                assert_eq!(lines.len(), 1);
595                assert_eq!(lines[0].spans.len(), 1);
596            }
597            _ => panic!("expected Paragraph"),
598        }
599    }
600
601    #[test]
602    fn parses_gauge_with_ratio_bind() {
603        let json = json!(["Gauge", {
604            "data-bind": "health",
605            "ratio_bind": {"numerator": "health", "denominator": "maxHealth"},
606            "gauge_style": {"fg": "Green"}
607        }]);
608        let node = parse_fragment(&json, &HashMap::new()).unwrap();
609        match node {
610            WidgetNode::Gauge { props } => {
611                assert_eq!(props.data_bind.as_deref(), Some("health"));
612                let rb = props.ratio_bind.unwrap();
613                assert_eq!(rb.numerator, "health");
614                assert_eq!(rb.denominator, "maxHealth");
615            }
616            _ => panic!("expected Gauge"),
617        }
618    }
619
620    #[test]
621    fn parses_layout_with_constraints() {
622        let json = json!(["Layout", {
623            "direction": "Vertical",
624            "constraints": ["Length:1", "Length:2", "Fill:1"]
625        }]);
626        let node = parse_fragment(&json, &HashMap::new()).unwrap();
627        match node {
628            WidgetNode::Layout { props, .. } => {
629                assert_eq!(props.direction, Direction::Vertical);
630                assert_eq!(props.constraints.len(), 3);
631            }
632            _ => panic!("expected Layout"),
633        }
634    }
635
636    #[test]
637    fn parses_list_items() {
638        let json = json!(["List", {"style": {"fg": "White"}}, "Item 1", "Item 2", "Item 3"]);
639        let node = parse_fragment(&json, &HashMap::new()).unwrap();
640        match node {
641            WidgetNode::List { props: _, items } => {
642                assert_eq!(items, vec!["Item 1", "Item 2", "Item 3"]);
643            }
644            _ => panic!("expected List"),
645        }
646    }
647
648    #[test]
649    fn parses_table_with_header_and_rows() {
650        let json = json!(["Table", {
651            "header": ["Name", "Value"],
652            "widths": ["Percentage:50", "Percentage:50"]
653        },
654            ["Alice", "100"],
655            ["Bob", "200"]
656        ]);
657        let node = parse_fragment(&json, &HashMap::new()).unwrap();
658        match node {
659            WidgetNode::Table { props, rows } => {
660                assert_eq!(props.header.as_ref().unwrap(), &["Name", "Value"]);
661                assert_eq!(props.widths.len(), 2);
662                assert_eq!(rows.len(), 2);
663                assert_eq!(rows[0], vec!["Alice", "100"]);
664            }
665            _ => panic!("expected Table"),
666        }
667    }
668
669    #[test]
670    fn parses_sparkline_with_data() {
671        let json = json!(["Sparkline", {
672            "data": [1, 3, 5, 2, 8],
673            "style": {"fg": "Yellow"}
674        }]);
675        let node = parse_fragment(&json, &HashMap::new()).unwrap();
676        match node {
677            WidgetNode::Sparkline { props } => {
678                assert_eq!(props.data, vec![1, 3, 5, 2, 8]);
679            }
680            _ => panic!("expected Sparkline"),
681        }
682    }
683
684    #[test]
685    fn resolves_data_bind_on_span() {
686        let mut bindings = HashMap::new();
687        bindings.insert("name".to_string(), json!("Hero"));
688
689        let json = json!(["Paragraph", {},
690            ["Line", {},
691                ["Span", {"data-bind": "name"}, "Unknown"]
692            ]
693        ]);
694        let node = parse_fragment(&json, &bindings).unwrap();
695        match node {
696            WidgetNode::Paragraph { lines, .. } => {
697                match &lines[0].spans[0] {
698                    SpanNode::Styled { text, .. } => assert_eq!(text, "Hero"),
699                    _ => panic!("expected Styled span"),
700                }
701            }
702            _ => panic!("expected Paragraph"),
703        }
704    }
705
706    #[test]
707    fn preserves_data_if_expression() {
708        let json = json!(["Paragraph", {"data-if": "health < 50"},
709            ["Line", {}, ["Span", {}, "Warning!"]]
710        ]);
711        let node = parse_fragment(&json, &HashMap::new()).unwrap();
712        match node {
713            WidgetNode::Paragraph { props, .. } => {
714                assert_eq!(props.data_if.as_deref(), Some("health < 50"));
715            }
716            _ => panic!("expected Paragraph"),
717        }
718    }
719
720    #[test]
721    fn parses_nested_block_with_layout() {
722        let json = json!(["Block", {"title": "Panel", "borders": "ALL"},
723            ["Layout", {"direction": "Vertical", "constraints": ["Length:1"]},
724                ["Paragraph", {}, ["Line", {}, ["Span", {}, "Content"]]]
725            ]
726        ]);
727        let node = parse_fragment(&json, &HashMap::new()).unwrap();
728        match node {
729            WidgetNode::Block { children, .. } => {
730                assert_eq!(children.len(), 1);
731                assert!(matches!(children[0], WidgetNode::Layout { .. }));
732            }
733            _ => panic!("expected Block"),
734        }
735    }
736
737    #[test]
738    fn returns_none_for_unknown_widget() {
739        let json = json!(["UnknownWidget", {}]);
740        assert!(parse_fragment(&json, &HashMap::new()).is_none());
741    }
742
743    #[test]
744    fn returns_none_for_empty_array() {
745        let json = json!([]);
746        assert!(parse_fragment(&json, &HashMap::new()).is_none());
747    }
748
749    #[test]
750    fn returns_none_for_number() {
751        let json = json!(42);
752        assert!(parse_fragment(&json, &HashMap::new()).is_none());
753    }
754
755    #[test]
756    fn parses_widget_without_attrs_object() {
757        let json = json!(["List", "Item 1", "Item 2"]);
758        let node = parse_fragment(&json, &HashMap::new()).unwrap();
759        match node {
760            WidgetNode::List { items, .. } => {
761                assert_eq!(items, vec!["Item 1", "Item 2"]);
762            }
763            _ => panic!("expected List"),
764        }
765    }
766
767    #[test]
768    fn value_to_display_string_handles_types() {
769        assert_eq!(value_to_display_string(&json!("hello")), "hello");
770        assert_eq!(value_to_display_string(&json!(42)), "42");
771        assert_eq!(value_to_display_string(&json!(null)), "");
772        assert_eq!(value_to_display_string(&json!(true)), "true");
773    }
774}