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