Skip to main content

fd_core/
parser.rs

1//! Parser for the FD text format → SceneGraph.
2//!
3//! Built on `winnow` 0.7 for efficient, streaming parsing.
4//! Handles: comments, style definitions, node declarations
5//! (group, rect, ellipse, path, text), inline properties, animations,
6//! and top-level constraints.
7
8use crate::id::NodeId;
9use crate::model::*;
10use winnow::ascii::space1;
11use winnow::combinator::{alt, delimited, opt, preceded};
12use winnow::error::ContextError;
13use winnow::prelude::*;
14use winnow::token::{take_till, take_while};
15
16/// Parse an FD document string into a `SceneGraph`.
17#[must_use = "parsing result should be used"]
18pub fn parse_document(input: &str) -> Result<SceneGraph, String> {
19    let mut graph = SceneGraph::new();
20    let mut rest = input;
21
22    skip_ws_and_comments(&mut rest);
23
24    while !rest.is_empty() {
25        if rest.starts_with("style ") {
26            let (name, style) = parse_style_block
27                .parse_next(&mut rest)
28                .map_err(|e| format!("Style parse error: {e}"))?;
29            graph.define_style(name, style);
30        } else if rest.starts_with("##") {
31            // Top-level annotations are ignored (they only apply inside nodes)
32            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
33            if rest.starts_with('\n') {
34                rest = &rest[1..];
35            }
36        } else if rest.starts_with('@') {
37            let (node_id, constraint) = parse_constraint_line
38                .parse_next(&mut rest)
39                .map_err(|e| format!("Constraint parse error: {e}"))?;
40            if let Some(node) = graph.get_by_id_mut(node_id) {
41                node.constraints.push(constraint);
42            }
43        } else if starts_with_node_keyword(rest) {
44            let node_data = parse_node
45                .parse_next(&mut rest)
46                .map_err(|e| format!("Node parse error: {e}"))?;
47            let root = graph.root;
48            insert_node_recursive(&mut graph, root, node_data);
49        } else {
50            // Skip unknown line
51            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
52            if rest.starts_with('\n') {
53                rest = &rest[1..];
54            }
55        }
56
57        skip_ws_and_comments(&mut rest);
58    }
59
60    Ok(graph)
61}
62
63fn starts_with_node_keyword(s: &str) -> bool {
64    s.starts_with("group")
65        || s.starts_with("rect")
66        || s.starts_with("ellipse")
67        || s.starts_with("path")
68        || s.starts_with("text")
69}
70
71/// Internal representation during parsing before inserting into graph.
72#[derive(Debug)]
73struct ParsedNode {
74    id: NodeId,
75    kind: NodeKind,
76    style: Style,
77    use_styles: Vec<NodeId>,
78    constraints: Vec<Constraint>,
79    animations: Vec<AnimKeyframe>,
80    annotations: Vec<Annotation>,
81    children: Vec<ParsedNode>,
82}
83
84fn insert_node_recursive(
85    graph: &mut SceneGraph,
86    parent: petgraph::graph::NodeIndex,
87    parsed: ParsedNode,
88) {
89    let mut node = SceneNode::new(parsed.id, parsed.kind);
90    node.style = parsed.style;
91    node.use_styles.extend(parsed.use_styles);
92    node.constraints.extend(parsed.constraints);
93    node.animations.extend(parsed.animations);
94    node.annotations = parsed.annotations;
95
96    let idx = graph.add_node(parent, node);
97
98    for child in parsed.children {
99        insert_node_recursive(graph, idx, child);
100    }
101}
102
103// ─── Low-level parsers ──────────────────────────────────────────────────
104
105fn skip_ws_and_comments(input: &mut &str) {
106    loop {
107        let before = *input;
108        // Skip whitespace manually
109        *input = input.trim_start();
110        if input.starts_with('#') {
111            // ## is an annotation — don't skip it
112            if input.starts_with("##") {
113                break;
114            }
115            // Regular comment — skip to end of line
116            if let Some(pos) = input.find('\n') {
117                *input = &input[pos + 1..];
118            } else {
119                *input = "";
120            }
121            continue;
122        }
123        if *input == before {
124            break;
125        }
126    }
127}
128
129/// Consume optional whitespace (concrete error type avoids inference issues).
130fn skip_space(input: &mut &str) {
131    use winnow::ascii::space0;
132    let _: Result<&str, winnow::error::ErrMode<ContextError>> = space0.parse_next(input);
133}
134
135fn parse_identifier<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
136    take_while(1.., |c: char| c.is_alphanumeric() || c == '_').parse_next(input)
137}
138
139fn parse_node_id(input: &mut &str) -> ModalResult<NodeId> {
140    preceded('@', parse_identifier)
141        .map(NodeId::intern)
142        .parse_next(input)
143}
144
145fn parse_hex_color(input: &mut &str) -> ModalResult<Color> {
146    let _ = '#'.parse_next(input)?;
147    let hex_digits: &str = take_while(1..=8, |c: char| c.is_ascii_hexdigit()).parse_next(input)?;
148    let hex_str = format!("#{hex_digits}");
149    Color::from_hex(&hex_str).ok_or_else(|| winnow::error::ErrMode::Backtrack(ContextError::new()))
150}
151
152fn parse_number(input: &mut &str) -> ModalResult<f32> {
153    let start = *input;
154    if input.starts_with('-') {
155        *input = &input[1..];
156    }
157    let _ = take_while(1.., |c: char| c.is_ascii_digit()).parse_next(input)?;
158    if input.starts_with('.') {
159        *input = &input[1..];
160        let _ =
161            take_while::<_, _, ContextError>(0.., |c: char| c.is_ascii_digit()).parse_next(input);
162    }
163    let matched = &start[..start.len() - input.len()];
164    matched
165        .parse::<f32>()
166        .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
167}
168
169fn parse_quoted_string<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
170    delimited('"', take_till(0.., '"'), '"').parse_next(input)
171}
172
173fn skip_opt_separator(input: &mut &str) {
174    if input.starts_with(';') || input.starts_with('\n') {
175        *input = &input[1..];
176    }
177}
178
179// ─── Annotation parser ──────────────────────────────────────────────────
180
181/// Parse a `##` annotation line into an `Annotation`.
182fn parse_annotation(input: &mut &str) -> ModalResult<Annotation> {
183    let _ = "##".parse_next(input)?;
184    skip_space(input);
185
186    // Try typed annotations: `keyword: value`
187    let checkpoint = *input;
188    if let Ok(keyword) = parse_identifier.parse_next(input) {
189        skip_space(input);
190        if input.starts_with(':') {
191            let _ = ':'.parse_next(input)?;
192            skip_space(input);
193
194            let value = if input.starts_with('"') {
195                parse_quoted_string
196                    .map(|s| s.to_string())
197                    .parse_next(input)?
198            } else {
199                let v: &str = take_till(0.., |c: char| c == '\n' || c == ';').parse_next(input)?;
200                v.trim().to_string()
201            };
202
203            let ann = match keyword {
204                "accept" => Annotation::Accept(value),
205                "status" => Annotation::Status(value),
206                "priority" => Annotation::Priority(value),
207                "tag" => Annotation::Tag(value),
208                _ => Annotation::Description(format!("{keyword}: {value}")),
209            };
210
211            skip_opt_separator(input);
212            return Ok(ann);
213        }
214        *input = checkpoint;
215    } else {
216        *input = checkpoint;
217    }
218
219    // Freeform description: `## "text"` or `## bare text`
220    skip_space(input);
221    let desc = if input.starts_with('"') {
222        parse_quoted_string
223            .map(|s| s.to_string())
224            .parse_next(input)?
225    } else {
226        let v: &str = take_till(0.., |c: char| c == '\n').parse_next(input)?;
227        v.trim().to_string()
228    };
229
230    skip_opt_separator(input);
231    Ok(Annotation::Description(desc))
232}
233
234// ─── Style block parser ─────────────────────────────────────────────────
235
236fn parse_style_block(input: &mut &str) -> ModalResult<(NodeId, Style)> {
237    let _ = "style".parse_next(input)?;
238    let _ = space1.parse_next(input)?;
239    let name = parse_identifier.map(NodeId::intern).parse_next(input)?;
240    skip_space(input);
241    let _ = '{'.parse_next(input)?;
242
243    let mut style = Style::default();
244    skip_ws_and_comments(input);
245
246    while !input.starts_with('}') {
247        parse_style_property(input, &mut style)?;
248        skip_ws_and_comments(input);
249    }
250
251    let _ = '}'.parse_next(input)?;
252    Ok((name, style))
253}
254
255fn parse_style_property(input: &mut &str, style: &mut Style) -> ModalResult<()> {
256    let prop_name = parse_identifier.parse_next(input)?;
257    skip_space(input);
258    let _ = ':'.parse_next(input)?;
259    skip_space(input);
260
261    match prop_name {
262        "fill" => {
263            style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
264        }
265        "font" => {
266            parse_font_value(input, style)?;
267        }
268        "corner" => {
269            style.corner_radius = Some(parse_number.parse_next(input)?);
270        }
271        "opacity" => {
272            style.opacity = Some(parse_number.parse_next(input)?);
273        }
274        _ => {
275            let _ =
276                take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
277                    .parse_next(input);
278        }
279    }
280
281    skip_opt_separator(input);
282    Ok(())
283}
284
285fn parse_font_value(input: &mut &str, style: &mut Style) -> ModalResult<()> {
286    let mut font = style.font.clone().unwrap_or_default();
287
288    if input.starts_with('"') {
289        let family = parse_quoted_string.parse_next(input)?;
290        font.family = family.to_string();
291        skip_space(input);
292    }
293
294    if let Ok(n1) = parse_number.parse_next(input) {
295        skip_space(input);
296        if let Ok(n2) = parse_number.parse_next(input) {
297            font.weight = n1 as u16;
298            font.size = n2;
299        } else {
300            font.size = n1;
301        }
302    }
303
304    style.font = Some(font);
305    Ok(())
306}
307
308// ─── Node parser ─────────────────────────────────────────────────────────
309
310fn parse_node(input: &mut &str) -> ModalResult<ParsedNode> {
311    let kind_str = alt((
312        "group".value("group"),
313        "rect".value("rect"),
314        "ellipse".value("ellipse"),
315        "path".value("path"),
316        "text".value("text"),
317    ))
318    .parse_next(input)?;
319
320    skip_space(input);
321
322    let id = if input.starts_with('@') {
323        parse_node_id.parse_next(input)?
324    } else {
325        NodeId::anonymous()
326    };
327
328    skip_space(input);
329
330    let inline_text = if kind_str == "text" && input.starts_with('"') {
331        Some(
332            parse_quoted_string
333                .map(|s| s.to_string())
334                .parse_next(input)?,
335        )
336    } else {
337        None
338    };
339
340    skip_space(input);
341    let _ = '{'.parse_next(input)?;
342
343    let mut style = Style::default();
344    let mut use_styles = Vec::new();
345    let mut constraints = Vec::new();
346    let mut animations = Vec::new();
347    let mut annotations = Vec::new();
348    let mut children = Vec::new();
349    let mut width: Option<f32> = None;
350    let mut height: Option<f32> = None;
351    let mut layout = LayoutMode::Free;
352
353    skip_ws_and_comments(input);
354
355    while !input.starts_with('}') {
356        if input.starts_with("##") {
357            annotations.push(parse_annotation.parse_next(input)?);
358        } else if starts_with_child_node(input) {
359            children.push(parse_node.parse_next(input)?);
360        } else if input.starts_with("anim") {
361            animations.push(parse_anim_block.parse_next(input)?);
362        } else {
363            parse_node_property(
364                input,
365                &mut style,
366                &mut use_styles,
367                &mut constraints,
368                &mut width,
369                &mut height,
370                &mut layout,
371            )?;
372        }
373        skip_ws_and_comments(input);
374    }
375
376    let _ = '}'.parse_next(input)?;
377
378    let kind = match kind_str {
379        "group" => NodeKind::Group { layout },
380        "rect" => NodeKind::Rect {
381            width: width.unwrap_or(100.0),
382            height: height.unwrap_or(100.0),
383        },
384        "ellipse" => NodeKind::Ellipse {
385            rx: width.unwrap_or(50.0),
386            ry: height.unwrap_or(50.0),
387        },
388        "text" => NodeKind::Text {
389            content: inline_text.unwrap_or_default(),
390        },
391        "path" => NodeKind::Path {
392            commands: Vec::new(),
393        },
394        _ => unreachable!(),
395    };
396
397    Ok(ParsedNode {
398        id,
399        kind,
400        style,
401        use_styles,
402        constraints,
403        animations,
404        annotations,
405        children,
406    })
407}
408
409/// Check if the current position starts a child node keyword followed by
410/// a space, @, {, or " (not a property name that happens to start with a keyword).
411fn starts_with_child_node(input: &str) -> bool {
412    let keywords = &[
413        ("group", 5),
414        ("rect", 4),
415        ("ellipse", 7),
416        ("path", 4),
417        ("text", 4),
418    ];
419    for &(keyword, len) in keywords {
420        if input.starts_with(keyword) {
421            if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
422                continue; // e.g. "text_align" is a property, not a text node
423            }
424            if let Some(after) = input.get(len..) {
425                if after.starts_with(|c: char| {
426                    c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
427                }) {
428                    return true;
429                }
430            }
431        }
432    }
433    false
434}
435
436fn parse_node_property(
437    input: &mut &str,
438    style: &mut Style,
439    use_styles: &mut Vec<NodeId>,
440    _constraints: &mut [Constraint],
441    width: &mut Option<f32>,
442    height: &mut Option<f32>,
443    layout: &mut LayoutMode,
444) -> ModalResult<()> {
445    let prop_name = parse_identifier.parse_next(input)?;
446    skip_space(input);
447    let _ = ':'.parse_next(input)?;
448    skip_space(input);
449
450    match prop_name {
451        "w" | "width" => {
452            *width = Some(parse_number.parse_next(input)?);
453            skip_space(input);
454            if input.starts_with("h:") || input.starts_with("h :") {
455                let _ = "h".parse_next(input)?;
456                skip_space(input);
457                let _ = ':'.parse_next(input)?;
458                skip_space(input);
459                *height = Some(parse_number.parse_next(input)?);
460            }
461        }
462        "h" | "height" => {
463            *height = Some(parse_number.parse_next(input)?);
464        }
465        "fill" => {
466            style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
467        }
468        "bg" => {
469            style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
470            loop {
471                skip_space(input);
472                if input.starts_with("corner=") {
473                    let _ = "corner=".parse_next(input)?;
474                    style.corner_radius = Some(parse_number.parse_next(input)?);
475                } else if input.starts_with("shadow=(") {
476                    let _ = "shadow=(".parse_next(input)?;
477                    let ox = parse_number.parse_next(input)?;
478                    let _ = ','.parse_next(input)?;
479                    let oy = parse_number.parse_next(input)?;
480                    let _ = ','.parse_next(input)?;
481                    let blur = parse_number.parse_next(input)?;
482                    let _ = ','.parse_next(input)?;
483                    let color = parse_hex_color.parse_next(input)?;
484                    let _ = ')'.parse_next(input)?;
485                    style.shadow = Some(Shadow {
486                        offset_x: ox,
487                        offset_y: oy,
488                        blur,
489                        color,
490                    });
491                } else {
492                    break;
493                }
494            }
495        }
496        "stroke" => {
497            let color = parse_hex_color.parse_next(input)?;
498            let _ = space1.parse_next(input)?;
499            let w = parse_number.parse_next(input)?;
500            style.stroke = Some(Stroke {
501                paint: Paint::Solid(color),
502                width: w,
503                ..Stroke::default()
504            });
505        }
506        "corner" => {
507            style.corner_radius = Some(parse_number.parse_next(input)?);
508        }
509        "opacity" => {
510            style.opacity = Some(parse_number.parse_next(input)?);
511        }
512        "use" => {
513            use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
514        }
515        "font" => {
516            parse_font_value(input, style)?;
517        }
518        "layout" => {
519            let mode_str = parse_identifier.parse_next(input)?;
520            skip_space(input);
521            let mut gap = 0.0f32;
522            let mut pad = 0.0f32;
523            loop {
524                skip_space(input);
525                if input.starts_with("gap=") {
526                    let _ = "gap=".parse_next(input)?;
527                    gap = parse_number.parse_next(input)?;
528                } else if input.starts_with("pad=") {
529                    let _ = "pad=".parse_next(input)?;
530                    pad = parse_number.parse_next(input)?;
531                } else if input.starts_with("cols=") {
532                    let _ = "cols=".parse_next(input)?;
533                    let _ = parse_number.parse_next(input)?;
534                } else {
535                    break;
536                }
537            }
538            *layout = match mode_str {
539                "column" => LayoutMode::Column { gap, pad },
540                "row" => LayoutMode::Row { gap, pad },
541                "grid" => LayoutMode::Grid { cols: 2, gap, pad },
542                _ => LayoutMode::Free,
543            };
544        }
545        _ => {
546            let _ =
547                take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
548                    .parse_next(input);
549        }
550    }
551
552    skip_opt_separator(input);
553    Ok(())
554}
555
556// ─── Animation block parser ─────────────────────────────────────────────
557
558fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
559    let _ = "anim".parse_next(input)?;
560    let _ = space1.parse_next(input)?;
561    let _ = ':'.parse_next(input)?;
562    let trigger_str = parse_identifier.parse_next(input)?;
563    let trigger = match trigger_str {
564        "hover" => AnimTrigger::Hover,
565        "press" => AnimTrigger::Press,
566        "enter" => AnimTrigger::Enter,
567        other => AnimTrigger::Custom(other.to_string()),
568    };
569
570    skip_space(input);
571    let _ = '{'.parse_next(input)?;
572
573    let mut props = AnimProperties::default();
574    let mut duration_ms = 300u32;
575    let mut easing = Easing::EaseInOut;
576
577    skip_ws_and_comments(input);
578
579    while !input.starts_with('}') {
580        let prop = parse_identifier.parse_next(input)?;
581        skip_space(input);
582        let _ = ':'.parse_next(input)?;
583        skip_space(input);
584
585        match prop {
586            "fill" => {
587                props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
588            }
589            "opacity" => {
590                props.opacity = Some(parse_number.parse_next(input)?);
591            }
592            "scale" => {
593                props.scale = Some(parse_number.parse_next(input)?);
594            }
595            "rotate" => {
596                props.rotate = Some(parse_number.parse_next(input)?);
597            }
598            "ease" => {
599                let ease_name = parse_identifier.parse_next(input)?;
600                easing = match ease_name {
601                    "linear" => Easing::Linear,
602                    "ease_in" | "easeIn" => Easing::EaseIn,
603                    "ease_out" | "easeOut" => Easing::EaseOut,
604                    "ease_in_out" | "easeInOut" => Easing::EaseInOut,
605                    "spring" => Easing::Spring,
606                    _ => Easing::EaseInOut,
607                };
608                skip_space(input);
609                if let Ok(n) = parse_number.parse_next(input) {
610                    duration_ms = n as u32;
611                    if input.starts_with("ms") {
612                        *input = &input[2..];
613                    }
614                }
615            }
616            _ => {
617                let _ = take_till::<_, _, ContextError>(0.., |c: char| {
618                    c == '\n' || c == ';' || c == '}'
619                })
620                .parse_next(input);
621            }
622        }
623
624        skip_opt_separator(input);
625        skip_ws_and_comments(input);
626    }
627
628    let _ = '}'.parse_next(input)?;
629
630    Ok(AnimKeyframe {
631        trigger,
632        duration_ms,
633        easing,
634        properties: props,
635    })
636}
637
638// ─── Constraint line parser ──────────────────────────────────────────────
639
640fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
641    let node_id = parse_node_id.parse_next(input)?;
642    skip_space(input);
643    let _ = "->".parse_next(input)?;
644    skip_space(input);
645
646    let constraint_type = parse_identifier.parse_next(input)?;
647    skip_space(input);
648    let _ = ':'.parse_next(input)?;
649    skip_space(input);
650
651    let constraint = match constraint_type {
652        "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
653        "offset" => {
654            let from = parse_node_id.parse_next(input)?;
655            let _ = space1.parse_next(input)?;
656            let dx = parse_number.parse_next(input)?;
657            skip_space(input);
658            let _ = ','.parse_next(input)?;
659            skip_space(input);
660            let dy = parse_number.parse_next(input)?;
661            Constraint::Offset { from, dx, dy }
662        }
663        "fill_parent" => {
664            let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
665            Constraint::FillParent { pad }
666        }
667        "absolute" => {
668            let x = parse_number.parse_next(input)?;
669            skip_space(input);
670            let _ = ','.parse_next(input)?;
671            skip_space(input);
672            let y = parse_number.parse_next(input)?;
673            Constraint::Absolute { x, y }
674        }
675        _ => {
676            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
677            Constraint::Absolute { x: 0.0, y: 0.0 }
678        }
679    };
680
681    if input.starts_with('\n') {
682        *input = &input[1..];
683    }
684    Ok((node_id, constraint))
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn parse_minimal_document() {
693        let input = r#"
694# Comment
695rect @box {
696  w: 100
697  h: 50
698  fill: #FF0000
699}
700"#;
701        let graph = parse_document(input).expect("parse failed");
702        let node = graph
703            .get_by_id(NodeId::intern("box"))
704            .expect("node not found");
705
706        match &node.kind {
707            NodeKind::Rect { width, height } => {
708                assert_eq!(*width, 100.0);
709                assert_eq!(*height, 50.0);
710            }
711            _ => panic!("expected Rect"),
712        }
713        assert!(node.style.fill.is_some());
714    }
715
716    #[test]
717    fn parse_style_and_use() {
718        let input = r#"
719style accent {
720  fill: #6C5CE7
721}
722
723rect @btn {
724  w: 200
725  h: 48
726  use: accent
727}
728"#;
729        let graph = parse_document(input).expect("parse failed");
730        assert!(graph.styles.contains_key(&NodeId::intern("accent")));
731        let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
732        assert_eq!(btn.use_styles.len(), 1);
733    }
734
735    #[test]
736    fn parse_nested_group() {
737        let input = r#"
738group @form {
739  layout: column gap=16 pad=32
740
741  text @title "Hello" {
742    fill: #333333
743  }
744
745  rect @field {
746    w: 280
747    h: 44
748  }
749}
750"#;
751        let graph = parse_document(input).expect("parse failed");
752        let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
753        let children = graph.children(form_idx);
754        assert_eq!(children.len(), 2);
755    }
756
757    #[test]
758    fn parse_animation() {
759        let input = r#"
760rect @btn {
761  w: 100
762  h: 40
763  fill: #6C5CE7
764
765  anim :hover {
766    fill: #5A4BD1
767    scale: 1.02
768    ease: spring 300ms
769  }
770}
771"#;
772        let graph = parse_document(input).expect("parse failed");
773        let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
774        assert_eq!(btn.animations.len(), 1);
775        assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
776        assert_eq!(btn.animations[0].duration_ms, 300);
777    }
778
779    #[test]
780    fn parse_constraint() {
781        let input = r#"
782rect @box {
783  w: 100
784  h: 100
785}
786
787@box -> center_in: canvas
788"#;
789        let graph = parse_document(input).expect("parse failed");
790        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
791        assert_eq!(node.constraints.len(), 1);
792        match &node.constraints[0] {
793            Constraint::CenterIn(target) => assert_eq!(target.as_str(), "canvas"),
794            _ => panic!("expected CenterIn"),
795        }
796    }
797
798    #[test]
799    fn parse_inline_wh() {
800        let input = r#"
801rect @box {
802  w: 280 h: 44
803  fill: #FF0000
804}
805"#;
806        let graph = parse_document(input).expect("parse failed");
807        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
808        match &node.kind {
809            NodeKind::Rect { width, height } => {
810                assert_eq!(*width, 280.0);
811                assert_eq!(*height, 44.0);
812            }
813            _ => panic!("expected Rect"),
814        }
815    }
816
817    #[test]
818    fn parse_empty_document() {
819        let input = "";
820        let graph = parse_document(input).expect("empty doc should parse");
821        assert_eq!(graph.children(graph.root).len(), 0);
822    }
823
824    #[test]
825    fn parse_comments_only() {
826        let input = "# This is a comment\n# Another comment\n";
827        let graph = parse_document(input).expect("comments-only should parse");
828        assert_eq!(graph.children(graph.root).len(), 0);
829    }
830
831    #[test]
832    fn parse_anonymous_node() {
833        let input = "rect { w: 50 h: 50 }";
834        let graph = parse_document(input).expect("anonymous node should parse");
835        assert_eq!(graph.children(graph.root).len(), 1);
836    }
837
838    #[test]
839    fn parse_ellipse() {
840        let input = r#"
841ellipse @dot {
842  w: 30 h: 30
843  fill: #FF5733
844}
845"#;
846        let graph = parse_document(input).expect("ellipse should parse");
847        let dot = graph.get_by_id(NodeId::intern("dot")).unwrap();
848        match &dot.kind {
849            NodeKind::Ellipse { rx, ry } => {
850                assert_eq!(*rx, 30.0);
851                assert_eq!(*ry, 30.0);
852            }
853            _ => panic!("expected Ellipse"),
854        }
855    }
856
857    #[test]
858    fn parse_text_with_content() {
859        let input = r#"
860text @greeting "Hello World" {
861  font: "Inter" 600 24
862  fill: #1A1A2E
863}
864"#;
865        let graph = parse_document(input).expect("text should parse");
866        let node = graph.get_by_id(NodeId::intern("greeting")).unwrap();
867        match &node.kind {
868            NodeKind::Text { content } => {
869                assert_eq!(content, "Hello World");
870            }
871            _ => panic!("expected Text"),
872        }
873        assert!(node.style.font.is_some());
874        let font = node.style.font.as_ref().unwrap();
875        assert_eq!(font.family, "Inter");
876        assert_eq!(font.weight, 600);
877        assert_eq!(font.size, 24.0);
878    }
879
880    #[test]
881    fn parse_stroke_property() {
882        let input = r#"
883rect @bordered {
884  w: 100 h: 100
885  stroke: #DDDDDD 2
886}
887"#;
888        let graph = parse_document(input).expect("stroke should parse");
889        let node = graph.get_by_id(NodeId::intern("bordered")).unwrap();
890        assert!(node.style.stroke.is_some());
891        let stroke = node.style.stroke.as_ref().unwrap();
892        assert_eq!(stroke.width, 2.0);
893    }
894
895    #[test]
896    fn parse_multiple_constraints() {
897        let input = r#"
898rect @a { w: 100 h: 100 }
899rect @b { w: 50 h: 50 }
900@a -> center_in: canvas
901@a -> absolute: 10, 20
902"#;
903        let graph = parse_document(input).expect("multiple constraints should parse");
904        let node = graph.get_by_id(NodeId::intern("a")).unwrap();
905        // The last constraint wins in layout, but both should be stored
906        assert_eq!(node.constraints.len(), 2);
907    }
908
909    #[test]
910    fn parse_comments_between_nodes() {
911        let input = r#"
912# First node
913rect @a { w: 100 h: 100 }
914# Second node
915rect @b { w: 200 h: 200 }
916"#;
917        let graph = parse_document(input).expect("interleaved comments should parse");
918        assert_eq!(graph.children(graph.root).len(), 2);
919    }
920}