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, imports, 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    // Collect any leading comments before the first declaration.
23    let mut pending_comments = collect_leading_comments(&mut rest);
24
25    while !rest.is_empty() {
26        let line = line_number(input, rest);
27        let end = {
28            let max = rest.len().min(40);
29            // Find a valid UTF-8 char boundary at or before `max`
30            let mut e = max;
31            while e > 0 && !rest.is_char_boundary(e) {
32                e -= 1;
33            }
34            e
35        };
36        let ctx = &rest[..end];
37
38        if rest.starts_with("import ") {
39            let import = parse_import_line
40                .parse_next(&mut rest)
41                .map_err(|e| format!("line {line}: import error — expected `import \"path\" as name`, got `{ctx}…`: {e}"))?;
42            graph.imports.push(import);
43            pending_comments.clear();
44        } else if rest.starts_with("style ") || rest.starts_with("theme ") {
45            let (name, style) = parse_style_block
46                .parse_next(&mut rest)
47                .map_err(|e| format!("line {line}: theme/style error — expected `theme name {{ props }}`, got `{ctx}…`: {e}"))?;
48            graph.define_style(name, style);
49            pending_comments.clear();
50        } else if rest.starts_with("spec ") || rest.starts_with("spec{") {
51            // Top-level spec blocks are ignored (they only apply inside nodes)
52            let _ = parse_spec_block.parse_next(&mut rest);
53            pending_comments.clear();
54        } else if rest.starts_with('@') {
55            if is_generic_node_start(rest) {
56                let mut node_data = parse_node.parse_next(&mut rest).map_err(|e| {
57                    format!("line {line}: node error — expected `@id {{ ... }}`, got `{ctx}…`: {e}")
58                })?;
59                node_data.comments = std::mem::take(&mut pending_comments);
60                let root = graph.root;
61                insert_node_recursive(&mut graph, root, node_data);
62            } else {
63                let (node_id, constraint) = parse_constraint_line
64                    .parse_next(&mut rest)
65                    .map_err(|e| format!("line {line}: constraint error — expected `@id -> type: value`, got `{ctx}…`: {e}"))?;
66                if let Some(node) = graph.get_by_id_mut(node_id) {
67                    node.constraints.push(constraint);
68                }
69                pending_comments.clear();
70            }
71        } else if rest.starts_with("edge ") {
72            let (edge, text_child_data) = parse_edge_block
73                .parse_next(&mut rest)
74                .map_err(|e| format!("line {line}: edge error — expected `edge @id {{ from: @a to: @b }}`, got `{ctx}…`: {e}"))?;
75            // Insert text child node into graph if label: created one
76            if let Some((text_id, content)) = text_child_data {
77                let text_node = crate::model::SceneNode {
78                    id: text_id,
79                    kind: crate::model::NodeKind::Text { content },
80                    style: crate::model::Style::default(),
81                    use_styles: Default::default(),
82                    constraints: Default::default(),
83                    annotations: Vec::new(),
84                    animations: Default::default(),
85                    comments: Vec::new(),
86                };
87                let idx = graph.graph.add_node(text_node);
88                graph.graph.add_edge(graph.root, idx, ());
89                graph.id_index.insert(text_id, idx);
90            }
91            graph.edges.push(edge);
92            pending_comments.clear();
93        } else if starts_with_node_keyword(rest) {
94            let mut node_data = parse_node.parse_next(&mut rest).map_err(|e| {
95                format!(
96                    "line {line}: node error — expected `kind @id {{ ... }}`, got `{ctx}…`: {e}"
97                )
98            })?;
99            node_data.comments = std::mem::take(&mut pending_comments);
100            let root = graph.root;
101            insert_node_recursive(&mut graph, root, node_data);
102        } else {
103            // Skip unknown line
104            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
105            if rest.starts_with('\n') {
106                rest = &rest[1..];
107            }
108            pending_comments.clear();
109        }
110
111        // Collect comments between top-level declarations.
112        // They will be attached to the *next* node parsed.
113        let more = collect_leading_comments(&mut rest);
114        pending_comments.extend(more);
115    }
116
117    Ok(graph)
118}
119
120/// Compute the 1-based line number of `remaining` within `full_input`.
121fn line_number(full_input: &str, remaining: &str) -> usize {
122    let consumed = full_input.len() - remaining.len();
123    full_input[..consumed].matches('\n').count() + 1
124}
125
126fn starts_with_node_keyword(s: &str) -> bool {
127    s.starts_with("group")
128        || s.starts_with("frame")
129        || s.starts_with("rect")
130        || s.starts_with("ellipse")
131        || s.starts_with("path")
132        || s.starts_with("text")
133}
134
135/// Check if input starts with `@identifier` followed by whitespace then `{`.
136/// Distinguishes generic nodes (`@id { }`) from constraint lines (`@id -> ...`).
137fn is_generic_node_start(s: &str) -> bool {
138    let rest = match s.strip_prefix('@') {
139        Some(r) => r,
140        None => return false,
141    };
142    let after_id = rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_');
143    // Must have consumed at least one identifier char
144    if after_id.len() == rest.len() {
145        return false;
146    }
147    after_id.trim_start().starts_with('{')
148}
149
150/// Internal representation during parsing before inserting into graph.
151#[derive(Debug)]
152struct ParsedNode {
153    id: NodeId,
154    kind: NodeKind,
155    style: Style,
156    use_styles: Vec<NodeId>,
157    constraints: Vec<Constraint>,
158    animations: Vec<AnimKeyframe>,
159    annotations: Vec<Annotation>,
160    /// Comments that appeared before this node's opening `{` in the source.
161    comments: Vec<String>,
162    children: Vec<ParsedNode>,
163}
164
165fn insert_node_recursive(
166    graph: &mut SceneGraph,
167    parent: petgraph::graph::NodeIndex,
168    parsed: ParsedNode,
169) {
170    let mut node = SceneNode::new(parsed.id, parsed.kind);
171    node.style = parsed.style;
172    node.use_styles.extend(parsed.use_styles);
173    node.constraints.extend(parsed.constraints);
174    node.animations.extend(parsed.animations);
175    node.annotations = parsed.annotations;
176    node.comments = parsed.comments;
177
178    let idx = graph.add_node(parent, node);
179
180    for child in parsed.children {
181        insert_node_recursive(graph, idx, child);
182    }
183}
184
185// ─── Import parser ──────────────────────────────────────────────────────
186
187/// Parse `import "path.fd" as namespace`.
188fn parse_import_line(input: &mut &str) -> ModalResult<Import> {
189    let _ = "import".parse_next(input)?;
190    let _ = space1.parse_next(input)?;
191    let path = parse_quoted_string
192        .map(|s| s.to_string())
193        .parse_next(input)?;
194    let _ = space1.parse_next(input)?;
195    let _ = "as".parse_next(input)?;
196    let _ = space1.parse_next(input)?;
197    let namespace = parse_identifier.map(|s| s.to_string()).parse_next(input)?;
198    skip_opt_separator(input);
199    Ok(Import { path, namespace })
200}
201
202// ─── Low-level parsers ──────────────────────────────────────────────────
203
204/// Collect leading whitespace and `# comment` lines, returning the comments.
205fn collect_leading_comments(input: &mut &str) -> Vec<String> {
206    let mut comments = Vec::new();
207    loop {
208        // Skip pure whitespace (not newlines that separate nodes)
209        let before = *input;
210        *input = input.trim_start();
211        if input.starts_with('#') {
212            // Regular `# comment` — collect it
213            let end = input.find('\n').unwrap_or(input.len());
214            let text = input[1..end].trim().to_string();
215            if !text.is_empty() {
216                comments.push(text);
217            }
218            *input = &input[end.min(input.len())..];
219            if input.starts_with('\n') {
220                *input = &input[1..];
221            }
222            continue;
223        }
224        if *input == before {
225            break;
226        }
227    }
228    comments
229}
230
231/// Skip whitespace and comments without collecting them (used in style/anim blocks
232/// where comments are rare and not worth attaching to a model element).
233fn skip_ws_and_comments(input: &mut &str) {
234    let _ = collect_leading_comments(input);
235}
236
237/// Consume optional whitespace (concrete error type avoids inference issues).
238fn skip_space(input: &mut &str) {
239    use winnow::ascii::space0;
240    let _: Result<&str, winnow::error::ErrMode<ContextError>> = space0.parse_next(input);
241}
242
243fn parse_identifier<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
244    take_while(1.., |c: char| c.is_alphanumeric() || c == '_').parse_next(input)
245}
246
247fn parse_node_id(input: &mut &str) -> ModalResult<NodeId> {
248    preceded('@', parse_identifier)
249        .map(NodeId::intern)
250        .parse_next(input)
251}
252
253fn parse_hex_color(input: &mut &str) -> ModalResult<Color> {
254    let _ = '#'.parse_next(input)?;
255    let hex_digits: &str = take_while(1..=8, |c: char| c.is_ascii_hexdigit()).parse_next(input)?;
256    Color::from_hex(hex_digits)
257        .ok_or_else(|| winnow::error::ErrMode::Backtrack(ContextError::new()))
258}
259
260fn parse_number(input: &mut &str) -> ModalResult<f32> {
261    let start = *input;
262    if input.starts_with('-') {
263        *input = &input[1..];
264    }
265    let _ = take_while(1.., |c: char| c.is_ascii_digit()).parse_next(input)?;
266    if input.starts_with('.') {
267        *input = &input[1..];
268        let _ =
269            take_while::<_, _, ContextError>(0.., |c: char| c.is_ascii_digit()).parse_next(input);
270    }
271    let matched = &start[..start.len() - input.len()];
272    matched
273        .parse::<f32>()
274        .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
275}
276
277fn parse_quoted_string<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
278    delimited('"', take_till(0.., '"'), '"').parse_next(input)
279}
280
281fn skip_opt_separator(input: &mut &str) {
282    if input.starts_with(';') || input.starts_with('\n') {
283        *input = &input[1..];
284    }
285}
286
287/// Strip an optional trailing `px` unit suffix (e.g. `320px` → `320`).
288fn skip_px_suffix(input: &mut &str) {
289    if input.starts_with("px") {
290        *input = &input[2..];
291    }
292}
293
294// ─── Spec block parser ──────────────────────────────────────────────────
295
296/// Parse a `spec { ... }` block or inline `spec "description"` into annotations.
297fn parse_spec_block(input: &mut &str) -> ModalResult<Vec<Annotation>> {
298    let _ = "spec".parse_next(input)?;
299    skip_space(input);
300
301    // Inline shorthand: `spec "description"`
302    if input.starts_with('"') {
303        let desc = parse_quoted_string
304            .map(|s| s.to_string())
305            .parse_next(input)?;
306        skip_opt_separator(input);
307        return Ok(vec![Annotation::Description(desc)]);
308    }
309
310    // Block form: `spec { ... }`
311    let _ = '{'.parse_next(input)?;
312    let mut annotations = Vec::new();
313    skip_ws_and_comments(input);
314
315    while !input.starts_with('}') {
316        annotations.push(parse_spec_item.parse_next(input)?);
317        skip_ws_and_comments(input);
318    }
319
320    let _ = '}'.parse_next(input)?;
321    Ok(annotations)
322}
323
324/// Parse a single item inside a `spec { ... }` block.
325///
326/// Handles:
327/// - `"description text"` → Description
328/// - `accept: "criterion"` → Accept
329/// - `status: value` → Status
330/// - `priority: value` → Priority
331/// - `tag: value` → Tag
332fn parse_spec_item(input: &mut &str) -> ModalResult<Annotation> {
333    // Freeform description: `"text"`
334    if input.starts_with('"') {
335        let desc = parse_quoted_string
336            .map(|s| s.to_string())
337            .parse_next(input)?;
338        skip_opt_separator(input);
339        return Ok(Annotation::Description(desc));
340    }
341
342    // Typed annotation: `keyword: value`
343    let keyword = parse_identifier.parse_next(input)?;
344    skip_space(input);
345    let _ = ':'.parse_next(input)?;
346    skip_space(input);
347
348    let value = if input.starts_with('"') {
349        parse_quoted_string
350            .map(|s| s.to_string())
351            .parse_next(input)?
352    } else {
353        let v: &str =
354            take_till(0.., |c: char| c == '\n' || c == ';' || c == '}').parse_next(input)?;
355        v.trim().to_string()
356    };
357
358    let ann = match keyword {
359        "accept" => Annotation::Accept(value),
360        "status" => Annotation::Status(value),
361        "priority" => Annotation::Priority(value),
362        "tag" => Annotation::Tag(value),
363        _ => Annotation::Description(format!("{keyword}: {value}")),
364    };
365
366    skip_opt_separator(input);
367    Ok(ann)
368}
369
370// ─── Style block parser ─────────────────────────────────────────────────
371
372fn parse_style_block(input: &mut &str) -> ModalResult<(NodeId, Style)> {
373    let _ = alt(("theme", "style")).parse_next(input)?;
374    let _ = space1.parse_next(input)?;
375    let name = parse_identifier.map(NodeId::intern).parse_next(input)?;
376    skip_space(input);
377    let _ = '{'.parse_next(input)?;
378
379    let mut style = Style::default();
380    skip_ws_and_comments(input);
381
382    while !input.starts_with('}') {
383        parse_style_property(input, &mut style)?;
384        skip_ws_and_comments(input);
385    }
386
387    let _ = '}'.parse_next(input)?;
388    Ok((name, style))
389}
390
391fn parse_style_property(input: &mut &str, style: &mut Style) -> ModalResult<()> {
392    let prop_name = parse_identifier.parse_next(input)?;
393    skip_space(input);
394    let _ = ':'.parse_next(input)?;
395    skip_space(input);
396
397    match prop_name {
398        "fill" | "background" | "color" => {
399            style.fill = Some(parse_paint(input)?);
400        }
401        "font" => {
402            parse_font_value(input, style)?;
403        }
404        "corner" | "rounded" | "radius" => {
405            style.corner_radius = Some(parse_number.parse_next(input)?);
406            skip_px_suffix(input);
407        }
408        "opacity" => {
409            style.opacity = Some(parse_number.parse_next(input)?);
410        }
411        "align" | "text_align" => {
412            parse_align_value(input, style)?;
413        }
414        _ => {
415            let _ =
416                take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
417                    .parse_next(input);
418        }
419    }
420
421    skip_opt_separator(input);
422    Ok(())
423}
424
425/// Map human-readable weight names to numeric values.
426fn weight_name_to_number(name: &str) -> Option<u16> {
427    match name {
428        "thin" => Some(100),
429        "extralight" | "extra_light" => Some(200),
430        "light" => Some(300),
431        "regular" | "normal" => Some(400),
432        "medium" => Some(500),
433        "semibold" | "semi_bold" => Some(600),
434        "bold" => Some(700),
435        "extrabold" | "extra_bold" => Some(800),
436        "black" | "heavy" => Some(900),
437        _ => None,
438    }
439}
440
441fn parse_font_value(input: &mut &str, style: &mut Style) -> ModalResult<()> {
442    let mut font = style.font.clone().unwrap_or_default();
443
444    if input.starts_with('"') {
445        let family = parse_quoted_string.parse_next(input)?;
446        font.family = family.to_string();
447        skip_space(input);
448    }
449
450    // Try named weight first (e.g. bold, semibold), then numeric
451    let saved = *input;
452    if let Ok(name) = parse_identifier.parse_next(input) {
453        if let Some(w) = weight_name_to_number(name) {
454            font.weight = w;
455            skip_space(input);
456            if let Ok(size) = parse_number.parse_next(input) {
457                font.size = size;
458                skip_px_suffix(input);
459            }
460        } else {
461            *input = saved; // not a weight name, restore
462        }
463    }
464
465    // Fallback: numeric weight + size
466    if *input == saved
467        && let Ok(n1) = parse_number.parse_next(input)
468    {
469        skip_space(input);
470        if let Ok(n2) = parse_number.parse_next(input) {
471            font.weight = n1 as u16;
472            font.size = n2;
473            skip_px_suffix(input);
474        } else {
475            font.size = n1;
476            skip_px_suffix(input);
477        }
478    }
479
480    style.font = Some(font);
481    Ok(())
482}
483
484// ─── Node parser ─────────────────────────────────────────────────────────
485
486fn parse_node(input: &mut &str) -> ModalResult<ParsedNode> {
487    // Type keyword is optional — `@id { }` creates a Generic node
488    let kind_str = if input.starts_with('@') {
489        "generic"
490    } else {
491        alt((
492            "group".value("group"),
493            "frame".value("frame"),
494            "rect".value("rect"),
495            "ellipse".value("ellipse"),
496            "path".value("path"),
497            "text".value("text"),
498        ))
499        .parse_next(input)?
500    };
501
502    skip_space(input);
503
504    let id = if input.starts_with('@') {
505        parse_node_id.parse_next(input)?
506    } else {
507        NodeId::anonymous(kind_str)
508    };
509
510    skip_space(input);
511
512    let inline_text = if kind_str == "text" && input.starts_with('"') {
513        Some(
514            parse_quoted_string
515                .map(|s| s.to_string())
516                .parse_next(input)?,
517        )
518    } else {
519        None
520    };
521
522    skip_space(input);
523    let _ = '{'.parse_next(input)?;
524
525    let mut style = Style::default();
526    let mut use_styles = Vec::new();
527    let mut constraints = Vec::new();
528    let mut animations = Vec::new();
529    let mut annotations = Vec::new();
530    let mut children = Vec::new();
531    let mut width: Option<f32> = None;
532    let mut height: Option<f32> = None;
533    let mut layout = LayoutMode::Free;
534    let mut clip = false;
535
536    skip_ws_and_comments(input);
537
538    while !input.starts_with('}') {
539        if input.starts_with("spec ") || input.starts_with("spec{") {
540            annotations.extend(parse_spec_block.parse_next(input)?);
541        } else if starts_with_child_node(input) {
542            let mut child = parse_node.parse_next(input)?;
543            // Any comments collected before this child are attached to it
544            // (they were consumed by the preceding skip_ws_and_comments/collect call)
545            child.comments = Vec::new(); // placeholder; child attaches its own leading comments
546            children.push(child);
547        } else if input.starts_with("when") || input.starts_with("anim") {
548            animations.push(parse_anim_block.parse_next(input)?);
549        } else {
550            parse_node_property(
551                input,
552                &mut style,
553                &mut use_styles,
554                &mut constraints,
555                &mut width,
556                &mut height,
557                &mut layout,
558                &mut clip,
559            )?;
560        }
561        // Collect comments between items; they'll be attached to the *next* child node
562        let _inner_comments = collect_leading_comments(input);
563    }
564
565    let _ = '}'.parse_next(input)?;
566
567    let kind = match kind_str {
568        "group" => NodeKind::Group, // Group is purely organizational — layout ignored
569        "frame" => NodeKind::Frame {
570            width: width.unwrap_or(200.0),
571            height: height.unwrap_or(200.0),
572            clip,
573            layout,
574        },
575        "rect" => NodeKind::Rect {
576            width: width.unwrap_or(100.0),
577            height: height.unwrap_or(100.0),
578        },
579        "ellipse" => NodeKind::Ellipse {
580            rx: width.unwrap_or(50.0),
581            ry: height.unwrap_or(50.0),
582        },
583        "text" => NodeKind::Text {
584            content: inline_text.unwrap_or_default(),
585        },
586        "path" => NodeKind::Path {
587            commands: Vec::new(),
588        },
589        "generic" => NodeKind::Generic,
590        _ => unreachable!(),
591    };
592
593    Ok(ParsedNode {
594        id,
595        kind,
596        style,
597        use_styles,
598        constraints,
599        animations,
600        annotations,
601        comments: Vec::new(),
602        children,
603    })
604}
605
606/// Check if the current position starts a child node keyword followed by
607/// a space, @, {, or " (not a property name that happens to start with a keyword).
608fn starts_with_child_node(input: &str) -> bool {
609    // Generic nodes start with @id {
610    if is_generic_node_start(input) {
611        return true;
612    }
613    let keywords = &[
614        ("group", 5),
615        ("frame", 5),
616        ("rect", 4),
617        ("ellipse", 7),
618        ("path", 4),
619        ("text", 4),
620    ];
621    for &(keyword, len) in keywords {
622        if input.starts_with(keyword) {
623            if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
624                continue; // e.g. "text_align" is a property, not a text node
625            }
626            if let Some(after) = input.get(len..)
627                && after.starts_with(|c: char| {
628                    c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
629                })
630            {
631                return true;
632            }
633        }
634    }
635    false
636}
637
638/// Map named colors to hex values.
639fn named_color_to_hex(name: &str) -> Option<Color> {
640    match name {
641        "red" => Color::from_hex("#EF4444"),
642        "orange" => Color::from_hex("#F97316"),
643        "amber" | "yellow" => Color::from_hex("#F59E0B"),
644        "lime" => Color::from_hex("#84CC16"),
645        "green" => Color::from_hex("#22C55E"),
646        "teal" => Color::from_hex("#14B8A6"),
647        "cyan" => Color::from_hex("#06B6D4"),
648        "blue" => Color::from_hex("#3B82F6"),
649        "indigo" => Color::from_hex("#6366F1"),
650        "purple" | "violet" => Color::from_hex("#8B5CF6"),
651        "pink" => Color::from_hex("#EC4899"),
652        "rose" => Color::from_hex("#F43F5E"),
653        "white" => Color::from_hex("#FFFFFF"),
654        "black" => Color::from_hex("#000000"),
655        "gray" | "grey" => Color::from_hex("#6B7280"),
656        "slate" => Color::from_hex("#64748B"),
657        _ => None,
658    }
659}
660
661/// Parse a `Paint` value: `#HEX`, named color, `linear(...)`, or `radial(...)`.
662fn parse_paint(input: &mut &str) -> ModalResult<Paint> {
663    if input.starts_with("linear(") {
664        let _ = "linear(".parse_next(input)?;
665        let angle = parse_number.parse_next(input)?;
666        let _ = "deg".parse_next(input)?;
667        let stops = parse_gradient_stops(input)?;
668        let _ = ')'.parse_next(input)?;
669        Ok(Paint::LinearGradient { angle, stops })
670    } else if input.starts_with("radial(") {
671        let _ = "radial(".parse_next(input)?;
672        let stops = parse_gradient_stops(input)?;
673        let _ = ')'.parse_next(input)?;
674        Ok(Paint::RadialGradient { stops })
675    } else if input.starts_with('#') {
676        parse_hex_color.map(Paint::Solid).parse_next(input)
677    } else {
678        // Try named color (e.g. purple, red, blue)
679        let saved = *input;
680        if let Ok(name) = parse_identifier.parse_next(input) {
681            if let Some(color) = named_color_to_hex(name) {
682                return Ok(Paint::Solid(color));
683            }
684            *input = saved;
685        }
686        parse_hex_color.map(Paint::Solid).parse_next(input)
687    }
688}
689
690/// Parse gradient stops: optional leading comma, then `#HEX offset` pairs separated by commas.
691///
692/// Handles both `linear(90deg, #HEX N, #HEX N)` (comma before first stop)
693/// and `radial(#HEX N, #HEX N)` (no comma before first stop).
694fn parse_gradient_stops(input: &mut &str) -> ModalResult<Vec<GradientStop>> {
695    let mut stops = Vec::new();
696    loop {
697        skip_space(input);
698        // Consume comma separator (required between stops, optional before first)
699        if input.starts_with(',') {
700            let _ = ','.parse_next(input)?;
701            skip_space(input);
702        }
703        // Stop if we hit the closing paren or end of input
704        if input.is_empty() || input.starts_with(')') {
705            break;
706        }
707        // Try to parse a color; if it fails, we're done
708        let Ok(color) = parse_hex_color.parse_next(input) else {
709            break;
710        };
711        skip_space(input);
712        let offset = parse_number.parse_next(input)?;
713        stops.push(GradientStop { color, offset });
714    }
715    Ok(stops)
716}
717
718#[allow(clippy::too_many_arguments)]
719fn parse_node_property(
720    input: &mut &str,
721    style: &mut Style,
722    use_styles: &mut Vec<NodeId>,
723    constraints: &mut Vec<Constraint>,
724    width: &mut Option<f32>,
725    height: &mut Option<f32>,
726    layout: &mut LayoutMode,
727    clip: &mut bool,
728) -> ModalResult<()> {
729    let prop_name = parse_identifier.parse_next(input)?;
730    skip_space(input);
731    let _ = ':'.parse_next(input)?;
732    skip_space(input);
733
734    match prop_name {
735        "x" => {
736            let x_val = parse_number.parse_next(input)?;
737            // Replace existing Position constraint if present, else push new
738            if let Some(Constraint::Position { x, .. }) = constraints
739                .iter_mut()
740                .find(|c| matches!(c, Constraint::Position { .. }))
741            {
742                *x = x_val;
743            } else {
744                constraints.push(Constraint::Position { x: x_val, y: 0.0 });
745            }
746        }
747        "y" => {
748            let y_val = parse_number.parse_next(input)?;
749            if let Some(Constraint::Position { y, .. }) = constraints
750                .iter_mut()
751                .find(|c| matches!(c, Constraint::Position { .. }))
752            {
753                *y = y_val;
754            } else {
755                constraints.push(Constraint::Position { x: 0.0, y: y_val });
756            }
757        }
758        "w" | "width" => {
759            *width = Some(parse_number.parse_next(input)?);
760            skip_px_suffix(input);
761            skip_space(input);
762            if input.starts_with("h:") || input.starts_with("h :") {
763                let _ = "h".parse_next(input)?;
764                skip_space(input);
765                let _ = ':'.parse_next(input)?;
766                skip_space(input);
767                *height = Some(parse_number.parse_next(input)?);
768                skip_px_suffix(input);
769            }
770        }
771        "h" | "height" => {
772            *height = Some(parse_number.parse_next(input)?);
773            skip_px_suffix(input);
774        }
775        "fill" | "background" | "color" => {
776            style.fill = Some(parse_paint(input)?);
777        }
778        "bg" => {
779            style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
780            loop {
781                skip_space(input);
782                if input.starts_with("corner=") {
783                    let _ = "corner=".parse_next(input)?;
784                    style.corner_radius = Some(parse_number.parse_next(input)?);
785                } else if input.starts_with("shadow=(") {
786                    let _ = "shadow=(".parse_next(input)?;
787                    let ox = parse_number.parse_next(input)?;
788                    let _ = ','.parse_next(input)?;
789                    let oy = parse_number.parse_next(input)?;
790                    let _ = ','.parse_next(input)?;
791                    let blur = parse_number.parse_next(input)?;
792                    let _ = ','.parse_next(input)?;
793                    let color = parse_hex_color.parse_next(input)?;
794                    let _ = ')'.parse_next(input)?;
795                    style.shadow = Some(Shadow {
796                        offset_x: ox,
797                        offset_y: oy,
798                        blur,
799                        color,
800                    });
801                } else {
802                    break;
803                }
804            }
805        }
806        "stroke" => {
807            let color = parse_hex_color.parse_next(input)?;
808            let _ = space1.parse_next(input)?;
809            let w = parse_number.parse_next(input)?;
810            style.stroke = Some(Stroke {
811                paint: Paint::Solid(color),
812                width: w,
813                ..Stroke::default()
814            });
815        }
816        "corner" | "rounded" | "radius" => {
817            style.corner_radius = Some(parse_number.parse_next(input)?);
818            skip_px_suffix(input);
819        }
820        "opacity" => {
821            style.opacity = Some(parse_number.parse_next(input)?);
822        }
823        "align" | "text_align" => {
824            parse_align_value(input, style)?;
825        }
826        "shadow" => {
827            // shadow: (ox,oy,blur,#COLOR)  — colon already consumed by property parser
828            skip_space(input);
829            if input.starts_with('(') {
830                let _ = '('.parse_next(input)?;
831                let ox = parse_number.parse_next(input)?;
832                let _ = ','.parse_next(input)?;
833                let oy = parse_number.parse_next(input)?;
834                let _ = ','.parse_next(input)?;
835                let blur = parse_number.parse_next(input)?;
836                let _ = ','.parse_next(input)?;
837                let color = parse_hex_color.parse_next(input)?;
838                let _ = ')'.parse_next(input)?;
839                style.shadow = Some(Shadow {
840                    offset_x: ox,
841                    offset_y: oy,
842                    blur,
843                    color,
844                });
845            }
846        }
847        "label" => {
848            // Deprecated: label is now a text child node.
849            // Skip the value to maintain backwards compatibility with old .fd files.
850            if input.starts_with('"') {
851                let _ = parse_quoted_string.parse_next(input)?;
852            } else {
853                let _ = take_till::<_, _, ContextError>(0.., |c: char| {
854                    c == '\n' || c == ';' || c == '}'
855                })
856                .parse_next(input);
857            }
858        }
859        "use" => {
860            use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
861        }
862        "font" => {
863            parse_font_value(input, style)?;
864        }
865        "layout" => {
866            let mode_str = parse_identifier.parse_next(input)?;
867            skip_space(input);
868            let mut gap = 0.0f32;
869            let mut pad = 0.0f32;
870            loop {
871                skip_space(input);
872                if input.starts_with("gap=") {
873                    let _ = "gap=".parse_next(input)?;
874                    gap = parse_number.parse_next(input)?;
875                } else if input.starts_with("pad=") {
876                    let _ = "pad=".parse_next(input)?;
877                    pad = parse_number.parse_next(input)?;
878                } else if input.starts_with("cols=") {
879                    let _ = "cols=".parse_next(input)?;
880                    let _ = parse_number.parse_next(input)?;
881                } else {
882                    break;
883                }
884            }
885            *layout = match mode_str {
886                "column" => LayoutMode::Column { gap, pad },
887                "row" => LayoutMode::Row { gap, pad },
888                "grid" => LayoutMode::Grid { cols: 2, gap, pad },
889                _ => LayoutMode::Free,
890            };
891        }
892        "clip" => {
893            let val = parse_identifier.parse_next(input)?;
894            *clip = val == "true";
895        }
896        _ => {
897            let _ =
898                take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
899                    .parse_next(input);
900        }
901    }
902
903    skip_opt_separator(input);
904    Ok(())
905}
906
907// ─── Alignment value parser ──────────────────────────────────────────────
908
909fn parse_align_value(input: &mut &str, style: &mut Style) -> ModalResult<()> {
910    use crate::model::{TextAlign, TextVAlign};
911
912    let first = parse_identifier.parse_next(input)?;
913    style.text_align = Some(match first {
914        "left" => TextAlign::Left,
915        "right" => TextAlign::Right,
916        _ => TextAlign::Center, // "center" or unknown
917    });
918
919    // Check for optional vertical alignment
920    skip_space(input);
921    let at_end = input.is_empty()
922        || input.starts_with('\n')
923        || input.starts_with(';')
924        || input.starts_with('}');
925    if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
926        style.text_valign = Some(match second {
927            "top" => TextVAlign::Top,
928            "bottom" => TextVAlign::Bottom,
929            _ => TextVAlign::Middle,
930        });
931    }
932
933    Ok(())
934}
935
936// ─── Animation block parser ─────────────────────────────────────────────
937
938fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
939    let _ = alt(("when", "anim")).parse_next(input)?;
940    let _ = space1.parse_next(input)?;
941    let _ = ':'.parse_next(input)?;
942    let trigger_str = parse_identifier.parse_next(input)?;
943    let trigger = match trigger_str {
944        "hover" => AnimTrigger::Hover,
945        "press" => AnimTrigger::Press,
946        "enter" => AnimTrigger::Enter,
947        other => AnimTrigger::Custom(other.to_string()),
948    };
949
950    skip_space(input);
951    let _ = '{'.parse_next(input)?;
952
953    let mut props = AnimProperties::default();
954    let mut duration_ms = 300u32;
955    let mut easing = Easing::EaseInOut;
956
957    skip_ws_and_comments(input);
958
959    while !input.starts_with('}') {
960        let prop = parse_identifier.parse_next(input)?;
961        skip_space(input);
962        let _ = ':'.parse_next(input)?;
963        skip_space(input);
964
965        match prop {
966            "fill" => {
967                props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
968            }
969            "opacity" => {
970                props.opacity = Some(parse_number.parse_next(input)?);
971            }
972            "scale" => {
973                props.scale = Some(parse_number.parse_next(input)?);
974            }
975            "rotate" => {
976                props.rotate = Some(parse_number.parse_next(input)?);
977            }
978            "ease" => {
979                let ease_name = parse_identifier.parse_next(input)?;
980                easing = match ease_name {
981                    "linear" => Easing::Linear,
982                    "ease_in" | "easeIn" => Easing::EaseIn,
983                    "ease_out" | "easeOut" => Easing::EaseOut,
984                    "ease_in_out" | "easeInOut" => Easing::EaseInOut,
985                    "spring" => Easing::Spring,
986                    _ => Easing::EaseInOut,
987                };
988                skip_space(input);
989                if let Ok(n) = parse_number.parse_next(input) {
990                    duration_ms = n as u32;
991                    if input.starts_with("ms") {
992                        *input = &input[2..];
993                    }
994                }
995            }
996            _ => {
997                let _ = take_till::<_, _, ContextError>(0.., |c: char| {
998                    c == '\n' || c == ';' || c == '}'
999                })
1000                .parse_next(input);
1001            }
1002        }
1003
1004        skip_opt_separator(input);
1005        skip_ws_and_comments(input);
1006    }
1007
1008    let _ = '}'.parse_next(input)?;
1009
1010    Ok(AnimKeyframe {
1011        trigger,
1012        duration_ms,
1013        easing,
1014        properties: props,
1015    })
1016}
1017
1018// ─── Edge anchor parser ─────────────────────────────────────────────────
1019
1020/// Parse an edge endpoint: `@node_id` or `x y` coordinates.
1021fn parse_edge_anchor(input: &mut &str) -> ModalResult<EdgeAnchor> {
1022    skip_space(input);
1023    if input.starts_with('@') {
1024        Ok(EdgeAnchor::Node(parse_node_id.parse_next(input)?))
1025    } else {
1026        let x = parse_number.parse_next(input)?;
1027        skip_space(input);
1028        let y = parse_number.parse_next(input)?;
1029        Ok(EdgeAnchor::Point(x, y))
1030    }
1031}
1032
1033// ─── Edge block parser ─────────────────────────────────────────────────
1034
1035fn parse_edge_block(input: &mut &str) -> ModalResult<(Edge, Option<(NodeId, String)>)> {
1036    let _ = "edge".parse_next(input)?;
1037    let _ = space1.parse_next(input)?;
1038
1039    let id = if input.starts_with('@') {
1040        parse_node_id.parse_next(input)?
1041    } else {
1042        NodeId::anonymous("edge")
1043    };
1044
1045    skip_space(input);
1046    let _ = '{'.parse_next(input)?;
1047
1048    let mut from = None;
1049    let mut to = None;
1050    let mut text_child = None;
1051    let mut text_child_content = None; // (NodeId, content_string)
1052    let mut style = Style::default();
1053    let mut use_styles = Vec::new();
1054    let mut arrow = ArrowKind::None;
1055    let mut curve = CurveKind::Straight;
1056    let mut annotations = Vec::new();
1057    let mut animations = Vec::new();
1058    let mut flow = None;
1059    let mut label_offset = None;
1060
1061    skip_ws_and_comments(input);
1062
1063    while !input.starts_with('}') {
1064        if input.starts_with("spec ") || input.starts_with("spec{") {
1065            annotations.extend(parse_spec_block.parse_next(input)?);
1066        } else if input.starts_with("when") || input.starts_with("anim") {
1067            animations.push(parse_anim_block.parse_next(input)?);
1068        } else if input.starts_with("text ") || input.starts_with("text@") {
1069            // Nested text child: text @id "content" { ... }
1070            let node = parse_node.parse_next(input)?;
1071            if let NodeKind::Text { ref content } = node.kind {
1072                text_child = Some(node.id);
1073                text_child_content = Some((node.id, content.clone()));
1074            }
1075        } else {
1076            let prop = parse_identifier.parse_next(input)?;
1077            skip_space(input);
1078            let _ = ':'.parse_next(input)?;
1079            skip_space(input);
1080
1081            match prop {
1082                "from" => {
1083                    from = Some(parse_edge_anchor(input)?);
1084                }
1085                "to" => {
1086                    to = Some(parse_edge_anchor(input)?);
1087                }
1088                "label" => {
1089                    // Backward compat: label: "string" → auto-create text child
1090                    let s = parse_quoted_string
1091                        .map(|s| s.to_string())
1092                        .parse_next(input)?;
1093                    let label_id = NodeId::intern(&format!("_{}_label", id.as_str()));
1094                    text_child = Some(label_id);
1095                    text_child_content = Some((label_id, s));
1096                }
1097                "stroke" => {
1098                    let color = parse_hex_color.parse_next(input)?;
1099                    skip_space(input);
1100                    let w = parse_number.parse_next(input).unwrap_or(1.0);
1101                    style.stroke = Some(Stroke {
1102                        paint: Paint::Solid(color),
1103                        width: w,
1104                        ..Stroke::default()
1105                    });
1106                }
1107                "arrow" => {
1108                    let kind = parse_identifier.parse_next(input)?;
1109                    arrow = match kind {
1110                        "none" => ArrowKind::None,
1111                        "start" => ArrowKind::Start,
1112                        "end" => ArrowKind::End,
1113                        "both" => ArrowKind::Both,
1114                        _ => ArrowKind::None,
1115                    };
1116                }
1117                "curve" => {
1118                    let kind = parse_identifier.parse_next(input)?;
1119                    curve = match kind {
1120                        "straight" => CurveKind::Straight,
1121                        "smooth" => CurveKind::Smooth,
1122                        "step" => CurveKind::Step,
1123                        _ => CurveKind::Straight,
1124                    };
1125                }
1126                "use" => {
1127                    use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1128                }
1129                "opacity" => {
1130                    style.opacity = Some(parse_number.parse_next(input)?);
1131                }
1132                "flow" => {
1133                    let kind_str = parse_identifier.parse_next(input)?;
1134                    let kind = match kind_str {
1135                        "pulse" => FlowKind::Pulse,
1136                        "dash" => FlowKind::Dash,
1137                        _ => FlowKind::Pulse,
1138                    };
1139                    skip_space(input);
1140                    let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1141                    if input.starts_with("ms") {
1142                        *input = &input[2..];
1143                    }
1144                    flow = Some(FlowAnim {
1145                        kind,
1146                        duration_ms: dur,
1147                    });
1148                }
1149                "label_offset" => {
1150                    let ox = parse_number.parse_next(input)?;
1151                    skip_space(input);
1152                    let oy = parse_number.parse_next(input)?;
1153                    label_offset = Some((ox, oy));
1154                }
1155                _ => {
1156                    let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1157                        c == '\n' || c == ';' || c == '}'
1158                    })
1159                    .parse_next(input);
1160                }
1161            }
1162
1163            skip_opt_separator(input);
1164        }
1165        skip_ws_and_comments(input);
1166    }
1167
1168    let _ = '}'.parse_next(input)?;
1169
1170    // Default stroke if none provided
1171    if style.stroke.is_none() {
1172        style.stroke = Some(Stroke {
1173            paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1174            width: 1.5,
1175            ..Stroke::default()
1176        });
1177    }
1178
1179    Ok((
1180        Edge {
1181            id,
1182            from: from.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1183            to: to.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1184            text_child,
1185            style,
1186            use_styles: use_styles.into(),
1187            arrow,
1188            curve,
1189            annotations,
1190            animations: animations.into(),
1191            flow,
1192            label_offset,
1193        },
1194        text_child_content,
1195    ))
1196}
1197
1198// ─── Constraint line parser ──────────────────────────────────────────────
1199
1200fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1201    let node_id = parse_node_id.parse_next(input)?;
1202    skip_space(input);
1203    let _ = "->".parse_next(input)?;
1204    skip_space(input);
1205
1206    let constraint_type = parse_identifier.parse_next(input)?;
1207    skip_space(input);
1208    let _ = ':'.parse_next(input)?;
1209    skip_space(input);
1210
1211    let constraint = match constraint_type {
1212        "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1213        "offset" => {
1214            let from = parse_node_id.parse_next(input)?;
1215            let _ = space1.parse_next(input)?;
1216            let dx = parse_number.parse_next(input)?;
1217            skip_space(input);
1218            let _ = ','.parse_next(input)?;
1219            skip_space(input);
1220            let dy = parse_number.parse_next(input)?;
1221            Constraint::Offset { from, dx, dy }
1222        }
1223        "fill_parent" => {
1224            let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1225            Constraint::FillParent { pad }
1226        }
1227        "absolute" | "position" => {
1228            let x = parse_number.parse_next(input)?;
1229            skip_space(input);
1230            let _ = ','.parse_next(input)?;
1231            skip_space(input);
1232            let y = parse_number.parse_next(input)?;
1233            Constraint::Position { x, y }
1234        }
1235        _ => {
1236            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1237            Constraint::Position { x: 0.0, y: 0.0 }
1238        }
1239    };
1240
1241    if input.starts_with('\n') {
1242        *input = &input[1..];
1243    }
1244    Ok((node_id, constraint))
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    #[test]
1252    fn parse_minimal_document() {
1253        let input = r#"
1254# Comment
1255rect @box {
1256  w: 100
1257  h: 50
1258  fill: #FF0000
1259}
1260"#;
1261        let graph = parse_document(input).expect("parse failed");
1262        let node = graph
1263            .get_by_id(NodeId::intern("box"))
1264            .expect("node not found");
1265
1266        match &node.kind {
1267            NodeKind::Rect { width, height } => {
1268                assert_eq!(*width, 100.0);
1269                assert_eq!(*height, 50.0);
1270            }
1271            _ => panic!("expected Rect"),
1272        }
1273        assert!(node.style.fill.is_some());
1274    }
1275
1276    #[test]
1277    fn parse_style_and_use() {
1278        let input = r#"
1279style accent {
1280  fill: #6C5CE7
1281}
1282
1283rect @btn {
1284  w: 200
1285  h: 48
1286  use: accent
1287}
1288"#;
1289        let graph = parse_document(input).expect("parse failed");
1290        assert!(graph.styles.contains_key(&NodeId::intern("accent")));
1291        let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1292        assert_eq!(btn.use_styles.len(), 1);
1293    }
1294
1295    #[test]
1296    fn parse_nested_group() {
1297        let input = r#"
1298group @form {
1299  layout: column gap=16 pad=32
1300
1301  text @title "Hello" {
1302    fill: #333333
1303  }
1304
1305  rect @field {
1306    w: 280
1307    h: 44
1308  }
1309}
1310"#;
1311        let graph = parse_document(input).expect("parse failed");
1312        let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1313        let children = graph.children(form_idx);
1314        assert_eq!(children.len(), 2);
1315    }
1316
1317    #[test]
1318    fn parse_animation() {
1319        let input = r#"
1320rect @btn {
1321  w: 100
1322  h: 40
1323  fill: #6C5CE7
1324
1325  anim :hover {
1326    fill: #5A4BD1
1327    scale: 1.02
1328    ease: spring 300ms
1329  }
1330}
1331"#;
1332        let graph = parse_document(input).expect("parse failed");
1333        let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1334        assert_eq!(btn.animations.len(), 1);
1335        assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1336        assert_eq!(btn.animations[0].duration_ms, 300);
1337    }
1338
1339    #[test]
1340    fn parse_constraint() {
1341        let input = r#"
1342rect @box {
1343  w: 100
1344  h: 100
1345}
1346
1347@box -> center_in: canvas
1348"#;
1349        let graph = parse_document(input).expect("parse failed");
1350        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1351        assert_eq!(node.constraints.len(), 1);
1352        match &node.constraints[0] {
1353            Constraint::CenterIn(target) => assert_eq!(target.as_str(), "canvas"),
1354            _ => panic!("expected CenterIn"),
1355        }
1356    }
1357
1358    #[test]
1359    fn parse_inline_wh() {
1360        let input = r#"
1361rect @box {
1362  w: 280 h: 44
1363  fill: #FF0000
1364}
1365"#;
1366        let graph = parse_document(input).expect("parse failed");
1367        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1368        match &node.kind {
1369            NodeKind::Rect { width, height } => {
1370                assert_eq!(*width, 280.0);
1371                assert_eq!(*height, 44.0);
1372            }
1373            _ => panic!("expected Rect"),
1374        }
1375    }
1376
1377    #[test]
1378    fn parse_empty_document() {
1379        let input = "";
1380        let graph = parse_document(input).expect("empty doc should parse");
1381        assert_eq!(graph.children(graph.root).len(), 0);
1382    }
1383
1384    #[test]
1385    fn parse_comments_only() {
1386        let input = "# This is a comment\n# Another comment\n";
1387        let graph = parse_document(input).expect("comments-only should parse");
1388        assert_eq!(graph.children(graph.root).len(), 0);
1389    }
1390
1391    #[test]
1392    fn parse_anonymous_node() {
1393        let input = "rect { w: 50 h: 50 }";
1394        let graph = parse_document(input).expect("anonymous node should parse");
1395        assert_eq!(graph.children(graph.root).len(), 1);
1396    }
1397
1398    #[test]
1399    fn parse_ellipse() {
1400        let input = r#"
1401ellipse @dot {
1402  w: 30 h: 30
1403  fill: #FF5733
1404}
1405"#;
1406        let graph = parse_document(input).expect("ellipse should parse");
1407        let dot = graph.get_by_id(NodeId::intern("dot")).unwrap();
1408        match &dot.kind {
1409            NodeKind::Ellipse { rx, ry } => {
1410                assert_eq!(*rx, 30.0);
1411                assert_eq!(*ry, 30.0);
1412            }
1413            _ => panic!("expected Ellipse"),
1414        }
1415    }
1416
1417    #[test]
1418    fn parse_text_with_content() {
1419        let input = r#"
1420text @greeting "Hello World" {
1421  font: "Inter" 600 24
1422  fill: #1A1A2E
1423}
1424"#;
1425        let graph = parse_document(input).expect("text should parse");
1426        let node = graph.get_by_id(NodeId::intern("greeting")).unwrap();
1427        match &node.kind {
1428            NodeKind::Text { content } => {
1429                assert_eq!(content, "Hello World");
1430            }
1431            _ => panic!("expected Text"),
1432        }
1433        assert!(node.style.font.is_some());
1434        let font = node.style.font.as_ref().unwrap();
1435        assert_eq!(font.family, "Inter");
1436        assert_eq!(font.weight, 600);
1437        assert_eq!(font.size, 24.0);
1438    }
1439
1440    #[test]
1441    fn parse_stroke_property() {
1442        let input = r#"
1443rect @bordered {
1444  w: 100 h: 100
1445  stroke: #DDDDDD 2
1446}
1447"#;
1448        let graph = parse_document(input).expect("stroke should parse");
1449        let node = graph.get_by_id(NodeId::intern("bordered")).unwrap();
1450        assert!(node.style.stroke.is_some());
1451        let stroke = node.style.stroke.as_ref().unwrap();
1452        assert_eq!(stroke.width, 2.0);
1453    }
1454
1455    #[test]
1456    fn parse_multiple_constraints() {
1457        let input = r#"
1458rect @a { w: 100 h: 100 }
1459rect @b { w: 50 h: 50 }
1460@a -> center_in: canvas
1461@a -> absolute: 10, 20
1462"#;
1463        let graph = parse_document(input).expect("multiple constraints should parse");
1464        let node = graph.get_by_id(NodeId::intern("a")).unwrap();
1465        // The last constraint wins in layout, but both should be stored
1466        assert_eq!(node.constraints.len(), 2);
1467    }
1468
1469    #[test]
1470    fn parse_comments_between_nodes() {
1471        let input = r#"
1472# First node
1473rect @a { w: 100 h: 100 }
1474# Second node
1475rect @b { w: 200 h: 200 }
1476"#;
1477        let graph = parse_document(input).expect("interleaved comments should parse");
1478        assert_eq!(graph.children(graph.root).len(), 2);
1479    }
1480    #[test]
1481    fn parse_frame() {
1482        let input = r#"
1483frame @card {
1484  w: 400 h: 300
1485  clip: true
1486  fill: #FFFFFF
1487  corner: 16
1488  layout: column gap=12 pad=20
1489}
1490"#;
1491        let graph = parse_document(input).expect("parse failed");
1492        let node = graph
1493            .get_by_id(crate::id::NodeId::intern("card"))
1494            .expect("card not found");
1495        match &node.kind {
1496            NodeKind::Frame {
1497                width,
1498                height,
1499                clip,
1500                layout,
1501            } => {
1502                assert_eq!(*width, 400.0);
1503                assert_eq!(*height, 300.0);
1504                assert!(*clip);
1505                assert!(matches!(layout, LayoutMode::Column { .. }));
1506            }
1507            other => panic!("expected Frame, got {other:?}"),
1508        }
1509    }
1510
1511    #[test]
1512    fn roundtrip_frame() {
1513        let input = r#"
1514frame @panel {
1515  w: 200 h: 150
1516  clip: true
1517  fill: #F0F0F0
1518  layout: row gap=8 pad=10
1519
1520  rect @child {
1521    w: 50 h: 50
1522    fill: #FF0000
1523  }
1524}
1525"#;
1526        let graph = parse_document(input).expect("parse failed");
1527        let emitted = crate::emitter::emit_document(&graph);
1528        let reparsed = parse_document(&emitted).expect("re-parse failed");
1529        let node = reparsed
1530            .get_by_id(crate::id::NodeId::intern("panel"))
1531            .expect("panel not found");
1532        match &node.kind {
1533            NodeKind::Frame {
1534                width,
1535                height,
1536                clip,
1537                layout,
1538            } => {
1539                assert_eq!(*width, 200.0);
1540                assert_eq!(*height, 150.0);
1541                assert!(*clip);
1542                assert!(matches!(layout, LayoutMode::Row { .. }));
1543            }
1544            other => panic!("expected Frame, got {other:?}"),
1545        }
1546        // Verify child is present
1547        let child = reparsed
1548            .get_by_id(crate::id::NodeId::intern("child"))
1549            .expect("child not found");
1550        assert!(matches!(child.kind, NodeKind::Rect { .. }));
1551    }
1552
1553    #[test]
1554    fn roundtrip_align() {
1555        let src = r#"
1556text @title "Hello" {
1557  fill: #FFFFFF
1558  font: "Inter" 600 24
1559  align: right bottom
1560}
1561"#;
1562        let graph = parse_document(src).unwrap();
1563        let node = graph
1564            .get_by_id(crate::id::NodeId::intern("title"))
1565            .expect("node not found");
1566        assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Right));
1567        assert_eq!(
1568            node.style.text_valign,
1569            Some(crate::model::TextVAlign::Bottom)
1570        );
1571
1572        // Emit and re-parse
1573        let emitted = crate::emitter::emit_document(&graph);
1574        assert!(emitted.contains("align: right bottom"));
1575
1576        let reparsed = parse_document(&emitted).unwrap();
1577        let node2 = reparsed
1578            .get_by_id(crate::id::NodeId::intern("title"))
1579            .expect("node not found after roundtrip");
1580        assert_eq!(node2.style.text_align, Some(crate::model::TextAlign::Right));
1581        assert_eq!(
1582            node2.style.text_valign,
1583            Some(crate::model::TextVAlign::Bottom)
1584        );
1585    }
1586
1587    #[test]
1588    fn parse_align_center_only() {
1589        let src = r#"
1590text @heading "Welcome" {
1591  align: center
1592}
1593"#;
1594        let graph = parse_document(src).unwrap();
1595        let node = graph
1596            .get_by_id(crate::id::NodeId::intern("heading"))
1597            .expect("node not found");
1598        assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Center));
1599        // Vertical not specified — should be None
1600        assert_eq!(node.style.text_valign, None);
1601    }
1602
1603    #[test]
1604    fn roundtrip_align_in_style_block() {
1605        let src = r#"
1606style heading_style {
1607  fill: #333333
1608  font: "Inter" 700 32
1609  align: left top
1610}
1611
1612text @main_title "Hello" {
1613  use: heading_style
1614}
1615"#;
1616        let graph = parse_document(src).unwrap();
1617
1618        // Style definition should have alignment
1619        let style = graph
1620            .styles
1621            .get(&crate::id::NodeId::intern("heading_style"))
1622            .expect("style not found");
1623        assert_eq!(style.text_align, Some(crate::model::TextAlign::Left));
1624        assert_eq!(style.text_valign, Some(crate::model::TextVAlign::Top));
1625
1626        // Node using the style should inherit alignment
1627        let node = graph
1628            .get_by_id(crate::id::NodeId::intern("main_title"))
1629            .expect("node not found");
1630        let resolved = graph.resolve_style(node, &[]);
1631        assert_eq!(resolved.text_align, Some(crate::model::TextAlign::Left));
1632        assert_eq!(resolved.text_valign, Some(crate::model::TextVAlign::Top));
1633
1634        // Emit and re-parse
1635        let emitted = crate::emitter::emit_document(&graph);
1636        assert!(emitted.contains("align: left top"));
1637        let reparsed = parse_document(&emitted).unwrap();
1638        let style2 = reparsed
1639            .styles
1640            .get(&crate::id::NodeId::intern("heading_style"))
1641            .expect("style not found after roundtrip");
1642        assert_eq!(style2.text_align, Some(crate::model::TextAlign::Left));
1643        assert_eq!(style2.text_valign, Some(crate::model::TextVAlign::Top));
1644    }
1645
1646    #[test]
1647    fn parse_font_weight_names() {
1648        let src = r#"
1649text @heading "Hello" {
1650  font: "Inter" bold 24
1651}
1652"#;
1653        let graph = parse_document(src).unwrap();
1654        let node = graph
1655            .get_by_id(crate::id::NodeId::intern("heading"))
1656            .unwrap();
1657        let font = node.style.font.as_ref().unwrap();
1658        assert_eq!(font.weight, 700);
1659        assert_eq!(font.size, 24.0);
1660    }
1661
1662    #[test]
1663    fn parse_font_weight_semibold() {
1664        let src = r#"text @t "Hi" { font: "Inter" semibold 16 }"#;
1665        let graph = parse_document(src).unwrap();
1666        let font = graph
1667            .get_by_id(crate::id::NodeId::intern("t"))
1668            .unwrap()
1669            .style
1670            .font
1671            .as_ref()
1672            .unwrap();
1673        assert_eq!(font.weight, 600);
1674        assert_eq!(font.size, 16.0);
1675    }
1676
1677    #[test]
1678    fn parse_named_color() {
1679        let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1680        let graph = parse_document(src).unwrap();
1681        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1682        assert!(
1683            node.style.fill.is_some(),
1684            "fill should be set from named color"
1685        );
1686    }
1687
1688    #[test]
1689    fn parse_named_color_blue() {
1690        let src = r#"rect @box { w: 50 h: 50 fill: blue }"#;
1691        let graph = parse_document(src).unwrap();
1692        let node = graph.get_by_id(crate::id::NodeId::intern("box")).unwrap();
1693        if let Some(crate::model::Paint::Solid(c)) = &node.style.fill {
1694            assert_eq!(c.to_hex(), "#3B82F6");
1695        } else {
1696            panic!("expected solid fill from named color");
1697        }
1698    }
1699
1700    #[test]
1701    fn parse_property_alias_background() {
1702        let src = r#"rect @r { w: 100 h: 50 background: #FF0000 }"#;
1703        let graph = parse_document(src).unwrap();
1704        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1705        assert!(node.style.fill.is_some(), "background: should map to fill");
1706    }
1707
1708    #[test]
1709    fn parse_property_alias_rounded() {
1710        let src = r#"rect @r { w: 100 h: 50 rounded: 12 }"#;
1711        let graph = parse_document(src).unwrap();
1712        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1713        assert_eq!(node.style.corner_radius, Some(12.0));
1714    }
1715
1716    #[test]
1717    fn parse_property_alias_radius() {
1718        let src = r#"rect @r { w: 100 h: 50 radius: 8 }"#;
1719        let graph = parse_document(src).unwrap();
1720        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1721        assert_eq!(node.style.corner_radius, Some(8.0));
1722    }
1723
1724    #[test]
1725    fn parse_dimension_px_suffix() {
1726        let src = r#"rect @r { w: 320px h: 200px }"#;
1727        let graph = parse_document(src).unwrap();
1728        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1729        if let crate::model::NodeKind::Rect { width, height } = &node.kind {
1730            assert_eq!(*width, 320.0);
1731            assert_eq!(*height, 200.0);
1732        } else {
1733            panic!("expected rect");
1734        }
1735    }
1736
1737    #[test]
1738    fn parse_corner_px_suffix() {
1739        let src = r#"rect @r { w: 100 h: 50 corner: 10px }"#;
1740        let graph = parse_document(src).unwrap();
1741        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1742        assert_eq!(node.style.corner_radius, Some(10.0));
1743    }
1744
1745    #[test]
1746    fn roundtrip_font_weight_name() {
1747        let src = r#"text @t "Hello" { font: "Inter" bold 18 }"#;
1748        let graph = parse_document(src).unwrap();
1749        let emitted = crate::emitter::emit_document(&graph);
1750        assert!(
1751            emitted.contains("bold"),
1752            "emitted output should use 'bold' not '700'"
1753        );
1754        let reparsed = parse_document(&emitted).unwrap();
1755        let font = reparsed
1756            .get_by_id(crate::id::NodeId::intern("t"))
1757            .unwrap()
1758            .style
1759            .font
1760            .as_ref()
1761            .unwrap();
1762        assert_eq!(font.weight, 700);
1763    }
1764
1765    #[test]
1766    fn roundtrip_named_color() {
1767        let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1768        let graph = parse_document(src).unwrap();
1769        let emitted = crate::emitter::emit_document(&graph);
1770        // Named color gets emitted as hex with hint comment
1771        assert!(emitted.contains("#8B5CF6"), "purple should emit as #8B5CF6");
1772        let reparsed = parse_document(&emitted).unwrap();
1773        assert!(
1774            reparsed
1775                .get_by_id(crate::id::NodeId::intern("r"))
1776                .unwrap()
1777                .style
1778                .fill
1779                .is_some()
1780        );
1781    }
1782
1783    #[test]
1784    fn roundtrip_property_aliases() {
1785        let src = r#"rect @r { w: 200 h: 100 background: #FF0000 rounded: 12 }"#;
1786        let graph = parse_document(src).unwrap();
1787        let emitted = crate::emitter::emit_document(&graph);
1788        // Emitter uses canonical names
1789        assert!(
1790            emitted.contains("fill:"),
1791            "background: should emit as fill:"
1792        );
1793        assert!(
1794            emitted.contains("corner:"),
1795            "rounded: should emit as corner:"
1796        );
1797        let reparsed = parse_document(&emitted).unwrap();
1798        let node = reparsed.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1799        assert!(node.style.fill.is_some());
1800        assert_eq!(node.style.corner_radius, Some(12.0));
1801    }
1802
1803    #[test]
1804    fn roundtrip_edge_label_offset() {
1805        let input = r#"
1806rect @a { w: 100 h: 50 }
1807rect @b { w: 100 h: 50 }
1808
1809edge @link {
1810  from: @a
1811  to: @b
1812  arrow: end
1813  label_offset: 15.5 -8.3
1814}
1815"#;
1816        let graph = parse_document(input).expect("parse failed");
1817        assert_eq!(graph.edges.len(), 1);
1818        let edge = &graph.edges[0];
1819        assert_eq!(edge.id, crate::id::NodeId::intern("link"));
1820        assert_eq!(edge.label_offset, Some((15.5, -8.3)));
1821
1822        // Emit and re-parse
1823        let emitted = crate::emitter::emit_document(&graph);
1824        assert!(
1825            emitted.contains("label_offset:"),
1826            "emitter should include label_offset"
1827        );
1828
1829        let reparsed = parse_document(&emitted).expect("re-parse failed");
1830        assert_eq!(reparsed.edges.len(), 1);
1831        let re_edge = &reparsed.edges[0];
1832        assert_eq!(re_edge.label_offset, Some((15.5, -8.3)));
1833    }
1834}