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    Color::from_hex(hex_digits)
241        .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(kind_str)
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("edge")
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    let mut label_offset = None;
1028
1029    skip_ws_and_comments(input);
1030
1031    while !input.starts_with('}') {
1032        if input.starts_with("spec ") || input.starts_with("spec{") {
1033            annotations.extend(parse_spec_block.parse_next(input)?);
1034        } else if input.starts_with("when") || input.starts_with("anim") {
1035            animations.push(parse_anim_block.parse_next(input)?);
1036        } else {
1037            let prop = parse_identifier.parse_next(input)?;
1038            skip_space(input);
1039            let _ = ':'.parse_next(input)?;
1040            skip_space(input);
1041
1042            match prop {
1043                "from" => {
1044                    from = Some(parse_node_id.parse_next(input)?);
1045                }
1046                "to" => {
1047                    to = Some(parse_node_id.parse_next(input)?);
1048                }
1049                "label" => {
1050                    label = Some(
1051                        parse_quoted_string
1052                            .map(|s| s.to_string())
1053                            .parse_next(input)?,
1054                    );
1055                }
1056                "stroke" => {
1057                    let color = parse_hex_color.parse_next(input)?;
1058                    skip_space(input);
1059                    let w = parse_number.parse_next(input).unwrap_or(1.0);
1060                    style.stroke = Some(Stroke {
1061                        paint: Paint::Solid(color),
1062                        width: w,
1063                        ..Stroke::default()
1064                    });
1065                }
1066                "arrow" => {
1067                    let kind = parse_identifier.parse_next(input)?;
1068                    arrow = match kind {
1069                        "none" => ArrowKind::None,
1070                        "start" => ArrowKind::Start,
1071                        "end" => ArrowKind::End,
1072                        "both" => ArrowKind::Both,
1073                        _ => ArrowKind::None,
1074                    };
1075                }
1076                "curve" => {
1077                    let kind = parse_identifier.parse_next(input)?;
1078                    curve = match kind {
1079                        "straight" => CurveKind::Straight,
1080                        "smooth" => CurveKind::Smooth,
1081                        "step" => CurveKind::Step,
1082                        _ => CurveKind::Straight,
1083                    };
1084                }
1085                "use" => {
1086                    use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1087                }
1088                "opacity" => {
1089                    style.opacity = Some(parse_number.parse_next(input)?);
1090                }
1091                "flow" => {
1092                    let kind_str = parse_identifier.parse_next(input)?;
1093                    let kind = match kind_str {
1094                        "pulse" => FlowKind::Pulse,
1095                        "dash" => FlowKind::Dash,
1096                        _ => FlowKind::Pulse,
1097                    };
1098                    skip_space(input);
1099                    let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1100                    if input.starts_with("ms") {
1101                        *input = &input[2..];
1102                    }
1103                    flow = Some(FlowAnim {
1104                        kind,
1105                        duration_ms: dur,
1106                    });
1107                }
1108                "label_offset" => {
1109                    let ox = parse_number.parse_next(input)?;
1110                    skip_space(input);
1111                    let oy = parse_number.parse_next(input)?;
1112                    label_offset = Some((ox, oy));
1113                }
1114                _ => {
1115                    let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1116                        c == '\n' || c == ';' || c == '}'
1117                    })
1118                    .parse_next(input);
1119                }
1120            }
1121
1122            skip_opt_separator(input);
1123        }
1124        skip_ws_and_comments(input);
1125    }
1126
1127    let _ = '}'.parse_next(input)?;
1128
1129    // Default stroke if none provided
1130    if style.stroke.is_none() {
1131        style.stroke = Some(Stroke {
1132            paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1133            width: 1.5,
1134            ..Stroke::default()
1135        });
1136    }
1137
1138    Ok(Edge {
1139        id,
1140        from: from.unwrap_or_else(|| NodeId::intern("_missing")),
1141        to: to.unwrap_or_else(|| NodeId::intern("_missing")),
1142        label,
1143        style,
1144        use_styles: use_styles.into(),
1145        arrow,
1146        curve,
1147        annotations,
1148        animations: animations.into(),
1149        flow,
1150        label_offset,
1151    })
1152}
1153
1154// ─── Constraint line parser ──────────────────────────────────────────────
1155
1156fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1157    let node_id = parse_node_id.parse_next(input)?;
1158    skip_space(input);
1159    let _ = "->".parse_next(input)?;
1160    skip_space(input);
1161
1162    let constraint_type = parse_identifier.parse_next(input)?;
1163    skip_space(input);
1164    let _ = ':'.parse_next(input)?;
1165    skip_space(input);
1166
1167    let constraint = match constraint_type {
1168        "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1169        "offset" => {
1170            let from = parse_node_id.parse_next(input)?;
1171            let _ = space1.parse_next(input)?;
1172            let dx = parse_number.parse_next(input)?;
1173            skip_space(input);
1174            let _ = ','.parse_next(input)?;
1175            skip_space(input);
1176            let dy = parse_number.parse_next(input)?;
1177            Constraint::Offset { from, dx, dy }
1178        }
1179        "fill_parent" => {
1180            let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1181            Constraint::FillParent { pad }
1182        }
1183        "absolute" | "position" => {
1184            let x = parse_number.parse_next(input)?;
1185            skip_space(input);
1186            let _ = ','.parse_next(input)?;
1187            skip_space(input);
1188            let y = parse_number.parse_next(input)?;
1189            Constraint::Position { x, y }
1190        }
1191        _ => {
1192            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1193            Constraint::Position { x: 0.0, y: 0.0 }
1194        }
1195    };
1196
1197    if input.starts_with('\n') {
1198        *input = &input[1..];
1199    }
1200    Ok((node_id, constraint))
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::*;
1206
1207    #[test]
1208    fn parse_minimal_document() {
1209        let input = r#"
1210# Comment
1211rect @box {
1212  w: 100
1213  h: 50
1214  fill: #FF0000
1215}
1216"#;
1217        let graph = parse_document(input).expect("parse failed");
1218        let node = graph
1219            .get_by_id(NodeId::intern("box"))
1220            .expect("node not found");
1221
1222        match &node.kind {
1223            NodeKind::Rect { width, height } => {
1224                assert_eq!(*width, 100.0);
1225                assert_eq!(*height, 50.0);
1226            }
1227            _ => panic!("expected Rect"),
1228        }
1229        assert!(node.style.fill.is_some());
1230    }
1231
1232    #[test]
1233    fn parse_style_and_use() {
1234        let input = r#"
1235style accent {
1236  fill: #6C5CE7
1237}
1238
1239rect @btn {
1240  w: 200
1241  h: 48
1242  use: accent
1243}
1244"#;
1245        let graph = parse_document(input).expect("parse failed");
1246        assert!(graph.styles.contains_key(&NodeId::intern("accent")));
1247        let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1248        assert_eq!(btn.use_styles.len(), 1);
1249    }
1250
1251    #[test]
1252    fn parse_nested_group() {
1253        let input = r#"
1254group @form {
1255  layout: column gap=16 pad=32
1256
1257  text @title "Hello" {
1258    fill: #333333
1259  }
1260
1261  rect @field {
1262    w: 280
1263    h: 44
1264  }
1265}
1266"#;
1267        let graph = parse_document(input).expect("parse failed");
1268        let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1269        let children = graph.children(form_idx);
1270        assert_eq!(children.len(), 2);
1271    }
1272
1273    #[test]
1274    fn parse_animation() {
1275        let input = r#"
1276rect @btn {
1277  w: 100
1278  h: 40
1279  fill: #6C5CE7
1280
1281  anim :hover {
1282    fill: #5A4BD1
1283    scale: 1.02
1284    ease: spring 300ms
1285  }
1286}
1287"#;
1288        let graph = parse_document(input).expect("parse failed");
1289        let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1290        assert_eq!(btn.animations.len(), 1);
1291        assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1292        assert_eq!(btn.animations[0].duration_ms, 300);
1293    }
1294
1295    #[test]
1296    fn parse_constraint() {
1297        let input = r#"
1298rect @box {
1299  w: 100
1300  h: 100
1301}
1302
1303@box -> center_in: canvas
1304"#;
1305        let graph = parse_document(input).expect("parse failed");
1306        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1307        assert_eq!(node.constraints.len(), 1);
1308        match &node.constraints[0] {
1309            Constraint::CenterIn(target) => assert_eq!(target.as_str(), "canvas"),
1310            _ => panic!("expected CenterIn"),
1311        }
1312    }
1313
1314    #[test]
1315    fn parse_inline_wh() {
1316        let input = r#"
1317rect @box {
1318  w: 280 h: 44
1319  fill: #FF0000
1320}
1321"#;
1322        let graph = parse_document(input).expect("parse failed");
1323        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1324        match &node.kind {
1325            NodeKind::Rect { width, height } => {
1326                assert_eq!(*width, 280.0);
1327                assert_eq!(*height, 44.0);
1328            }
1329            _ => panic!("expected Rect"),
1330        }
1331    }
1332
1333    #[test]
1334    fn parse_empty_document() {
1335        let input = "";
1336        let graph = parse_document(input).expect("empty doc should parse");
1337        assert_eq!(graph.children(graph.root).len(), 0);
1338    }
1339
1340    #[test]
1341    fn parse_comments_only() {
1342        let input = "# This is a comment\n# Another comment\n";
1343        let graph = parse_document(input).expect("comments-only should parse");
1344        assert_eq!(graph.children(graph.root).len(), 0);
1345    }
1346
1347    #[test]
1348    fn parse_anonymous_node() {
1349        let input = "rect { w: 50 h: 50 }";
1350        let graph = parse_document(input).expect("anonymous node should parse");
1351        assert_eq!(graph.children(graph.root).len(), 1);
1352    }
1353
1354    #[test]
1355    fn parse_ellipse() {
1356        let input = r#"
1357ellipse @dot {
1358  w: 30 h: 30
1359  fill: #FF5733
1360}
1361"#;
1362        let graph = parse_document(input).expect("ellipse should parse");
1363        let dot = graph.get_by_id(NodeId::intern("dot")).unwrap();
1364        match &dot.kind {
1365            NodeKind::Ellipse { rx, ry } => {
1366                assert_eq!(*rx, 30.0);
1367                assert_eq!(*ry, 30.0);
1368            }
1369            _ => panic!("expected Ellipse"),
1370        }
1371    }
1372
1373    #[test]
1374    fn parse_text_with_content() {
1375        let input = r#"
1376text @greeting "Hello World" {
1377  font: "Inter" 600 24
1378  fill: #1A1A2E
1379}
1380"#;
1381        let graph = parse_document(input).expect("text should parse");
1382        let node = graph.get_by_id(NodeId::intern("greeting")).unwrap();
1383        match &node.kind {
1384            NodeKind::Text { content } => {
1385                assert_eq!(content, "Hello World");
1386            }
1387            _ => panic!("expected Text"),
1388        }
1389        assert!(node.style.font.is_some());
1390        let font = node.style.font.as_ref().unwrap();
1391        assert_eq!(font.family, "Inter");
1392        assert_eq!(font.weight, 600);
1393        assert_eq!(font.size, 24.0);
1394    }
1395
1396    #[test]
1397    fn parse_stroke_property() {
1398        let input = r#"
1399rect @bordered {
1400  w: 100 h: 100
1401  stroke: #DDDDDD 2
1402}
1403"#;
1404        let graph = parse_document(input).expect("stroke should parse");
1405        let node = graph.get_by_id(NodeId::intern("bordered")).unwrap();
1406        assert!(node.style.stroke.is_some());
1407        let stroke = node.style.stroke.as_ref().unwrap();
1408        assert_eq!(stroke.width, 2.0);
1409    }
1410
1411    #[test]
1412    fn parse_multiple_constraints() {
1413        let input = r#"
1414rect @a { w: 100 h: 100 }
1415rect @b { w: 50 h: 50 }
1416@a -> center_in: canvas
1417@a -> absolute: 10, 20
1418"#;
1419        let graph = parse_document(input).expect("multiple constraints should parse");
1420        let node = graph.get_by_id(NodeId::intern("a")).unwrap();
1421        // The last constraint wins in layout, but both should be stored
1422        assert_eq!(node.constraints.len(), 2);
1423    }
1424
1425    #[test]
1426    fn parse_comments_between_nodes() {
1427        let input = r#"
1428# First node
1429rect @a { w: 100 h: 100 }
1430# Second node
1431rect @b { w: 200 h: 200 }
1432"#;
1433        let graph = parse_document(input).expect("interleaved comments should parse");
1434        assert_eq!(graph.children(graph.root).len(), 2);
1435    }
1436    #[test]
1437    fn parse_frame() {
1438        let input = r#"
1439frame @card {
1440  w: 400 h: 300
1441  clip: true
1442  fill: #FFFFFF
1443  corner: 16
1444  layout: column gap=12 pad=20
1445}
1446"#;
1447        let graph = parse_document(input).expect("parse failed");
1448        let node = graph
1449            .get_by_id(crate::id::NodeId::intern("card"))
1450            .expect("card not found");
1451        match &node.kind {
1452            NodeKind::Frame {
1453                width,
1454                height,
1455                clip,
1456                layout,
1457            } => {
1458                assert_eq!(*width, 400.0);
1459                assert_eq!(*height, 300.0);
1460                assert!(*clip);
1461                assert!(matches!(layout, LayoutMode::Column { .. }));
1462            }
1463            other => panic!("expected Frame, got {other:?}"),
1464        }
1465    }
1466
1467    #[test]
1468    fn roundtrip_frame() {
1469        let input = r#"
1470frame @panel {
1471  w: 200 h: 150
1472  clip: true
1473  fill: #F0F0F0
1474  layout: row gap=8 pad=10
1475
1476  rect @child {
1477    w: 50 h: 50
1478    fill: #FF0000
1479  }
1480}
1481"#;
1482        let graph = parse_document(input).expect("parse failed");
1483        let emitted = crate::emitter::emit_document(&graph);
1484        let reparsed = parse_document(&emitted).expect("re-parse failed");
1485        let node = reparsed
1486            .get_by_id(crate::id::NodeId::intern("panel"))
1487            .expect("panel not found");
1488        match &node.kind {
1489            NodeKind::Frame {
1490                width,
1491                height,
1492                clip,
1493                layout,
1494            } => {
1495                assert_eq!(*width, 200.0);
1496                assert_eq!(*height, 150.0);
1497                assert!(*clip);
1498                assert!(matches!(layout, LayoutMode::Row { .. }));
1499            }
1500            other => panic!("expected Frame, got {other:?}"),
1501        }
1502        // Verify child is present
1503        let child = reparsed
1504            .get_by_id(crate::id::NodeId::intern("child"))
1505            .expect("child not found");
1506        assert!(matches!(child.kind, NodeKind::Rect { .. }));
1507    }
1508
1509    #[test]
1510    fn roundtrip_align() {
1511        let src = r#"
1512text @title "Hello" {
1513  fill: #FFFFFF
1514  font: "Inter" 600 24
1515  align: right bottom
1516}
1517"#;
1518        let graph = parse_document(src).unwrap();
1519        let node = graph
1520            .get_by_id(crate::id::NodeId::intern("title"))
1521            .expect("node not found");
1522        assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Right));
1523        assert_eq!(
1524            node.style.text_valign,
1525            Some(crate::model::TextVAlign::Bottom)
1526        );
1527
1528        // Emit and re-parse
1529        let emitted = crate::emitter::emit_document(&graph);
1530        assert!(emitted.contains("align: right bottom"));
1531
1532        let reparsed = parse_document(&emitted).unwrap();
1533        let node2 = reparsed
1534            .get_by_id(crate::id::NodeId::intern("title"))
1535            .expect("node not found after roundtrip");
1536        assert_eq!(node2.style.text_align, Some(crate::model::TextAlign::Right));
1537        assert_eq!(
1538            node2.style.text_valign,
1539            Some(crate::model::TextVAlign::Bottom)
1540        );
1541    }
1542
1543    #[test]
1544    fn parse_align_center_only() {
1545        let src = r#"
1546text @heading "Welcome" {
1547  align: center
1548}
1549"#;
1550        let graph = parse_document(src).unwrap();
1551        let node = graph
1552            .get_by_id(crate::id::NodeId::intern("heading"))
1553            .expect("node not found");
1554        assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Center));
1555        // Vertical not specified — should be None
1556        assert_eq!(node.style.text_valign, None);
1557    }
1558
1559    #[test]
1560    fn roundtrip_align_in_style_block() {
1561        let src = r#"
1562style heading_style {
1563  fill: #333333
1564  font: "Inter" 700 32
1565  align: left top
1566}
1567
1568text @main_title "Hello" {
1569  use: heading_style
1570}
1571"#;
1572        let graph = parse_document(src).unwrap();
1573
1574        // Style definition should have alignment
1575        let style = graph
1576            .styles
1577            .get(&crate::id::NodeId::intern("heading_style"))
1578            .expect("style not found");
1579        assert_eq!(style.text_align, Some(crate::model::TextAlign::Left));
1580        assert_eq!(style.text_valign, Some(crate::model::TextVAlign::Top));
1581
1582        // Node using the style should inherit alignment
1583        let node = graph
1584            .get_by_id(crate::id::NodeId::intern("main_title"))
1585            .expect("node not found");
1586        let resolved = graph.resolve_style(node, &[]);
1587        assert_eq!(resolved.text_align, Some(crate::model::TextAlign::Left));
1588        assert_eq!(resolved.text_valign, Some(crate::model::TextVAlign::Top));
1589
1590        // Emit and re-parse
1591        let emitted = crate::emitter::emit_document(&graph);
1592        assert!(emitted.contains("align: left top"));
1593        let reparsed = parse_document(&emitted).unwrap();
1594        let style2 = reparsed
1595            .styles
1596            .get(&crate::id::NodeId::intern("heading_style"))
1597            .expect("style not found after roundtrip");
1598        assert_eq!(style2.text_align, Some(crate::model::TextAlign::Left));
1599        assert_eq!(style2.text_valign, Some(crate::model::TextVAlign::Top));
1600    }
1601
1602    #[test]
1603    fn parse_font_weight_names() {
1604        let src = r#"
1605text @heading "Hello" {
1606  font: "Inter" bold 24
1607}
1608"#;
1609        let graph = parse_document(src).unwrap();
1610        let node = graph
1611            .get_by_id(crate::id::NodeId::intern("heading"))
1612            .unwrap();
1613        let font = node.style.font.as_ref().unwrap();
1614        assert_eq!(font.weight, 700);
1615        assert_eq!(font.size, 24.0);
1616    }
1617
1618    #[test]
1619    fn parse_font_weight_semibold() {
1620        let src = r#"text @t "Hi" { font: "Inter" semibold 16 }"#;
1621        let graph = parse_document(src).unwrap();
1622        let font = graph
1623            .get_by_id(crate::id::NodeId::intern("t"))
1624            .unwrap()
1625            .style
1626            .font
1627            .as_ref()
1628            .unwrap();
1629        assert_eq!(font.weight, 600);
1630        assert_eq!(font.size, 16.0);
1631    }
1632
1633    #[test]
1634    fn parse_named_color() {
1635        let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1636        let graph = parse_document(src).unwrap();
1637        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1638        assert!(
1639            node.style.fill.is_some(),
1640            "fill should be set from named color"
1641        );
1642    }
1643
1644    #[test]
1645    fn parse_named_color_blue() {
1646        let src = r#"rect @box { w: 50 h: 50 fill: blue }"#;
1647        let graph = parse_document(src).unwrap();
1648        let node = graph.get_by_id(crate::id::NodeId::intern("box")).unwrap();
1649        if let Some(crate::model::Paint::Solid(c)) = &node.style.fill {
1650            assert_eq!(c.to_hex(), "#3B82F6");
1651        } else {
1652            panic!("expected solid fill from named color");
1653        }
1654    }
1655
1656    #[test]
1657    fn parse_property_alias_background() {
1658        let src = r#"rect @r { w: 100 h: 50 background: #FF0000 }"#;
1659        let graph = parse_document(src).unwrap();
1660        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1661        assert!(node.style.fill.is_some(), "background: should map to fill");
1662    }
1663
1664    #[test]
1665    fn parse_property_alias_rounded() {
1666        let src = r#"rect @r { w: 100 h: 50 rounded: 12 }"#;
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(12.0));
1670    }
1671
1672    #[test]
1673    fn parse_property_alias_radius() {
1674        let src = r#"rect @r { w: 100 h: 50 radius: 8 }"#;
1675        let graph = parse_document(src).unwrap();
1676        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1677        assert_eq!(node.style.corner_radius, Some(8.0));
1678    }
1679
1680    #[test]
1681    fn parse_dimension_px_suffix() {
1682        let src = r#"rect @r { w: 320px h: 200px }"#;
1683        let graph = parse_document(src).unwrap();
1684        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1685        if let crate::model::NodeKind::Rect { width, height } = &node.kind {
1686            assert_eq!(*width, 320.0);
1687            assert_eq!(*height, 200.0);
1688        } else {
1689            panic!("expected rect");
1690        }
1691    }
1692
1693    #[test]
1694    fn parse_corner_px_suffix() {
1695        let src = r#"rect @r { w: 100 h: 50 corner: 10px }"#;
1696        let graph = parse_document(src).unwrap();
1697        let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1698        assert_eq!(node.style.corner_radius, Some(10.0));
1699    }
1700
1701    #[test]
1702    fn roundtrip_font_weight_name() {
1703        let src = r#"text @t "Hello" { font: "Inter" bold 18 }"#;
1704        let graph = parse_document(src).unwrap();
1705        let emitted = crate::emitter::emit_document(&graph);
1706        assert!(
1707            emitted.contains("bold"),
1708            "emitted output should use 'bold' not '700'"
1709        );
1710        let reparsed = parse_document(&emitted).unwrap();
1711        let font = reparsed
1712            .get_by_id(crate::id::NodeId::intern("t"))
1713            .unwrap()
1714            .style
1715            .font
1716            .as_ref()
1717            .unwrap();
1718        assert_eq!(font.weight, 700);
1719    }
1720
1721    #[test]
1722    fn roundtrip_named_color() {
1723        let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1724        let graph = parse_document(src).unwrap();
1725        let emitted = crate::emitter::emit_document(&graph);
1726        // Named color gets emitted as hex with hint comment
1727        assert!(emitted.contains("#8B5CF6"), "purple should emit as #8B5CF6");
1728        let reparsed = parse_document(&emitted).unwrap();
1729        assert!(
1730            reparsed
1731                .get_by_id(crate::id::NodeId::intern("r"))
1732                .unwrap()
1733                .style
1734                .fill
1735                .is_some()
1736        );
1737    }
1738
1739    #[test]
1740    fn roundtrip_property_aliases() {
1741        let src = r#"rect @r { w: 200 h: 100 background: #FF0000 rounded: 12 }"#;
1742        let graph = parse_document(src).unwrap();
1743        let emitted = crate::emitter::emit_document(&graph);
1744        // Emitter uses canonical names
1745        assert!(
1746            emitted.contains("fill:"),
1747            "background: should emit as fill:"
1748        );
1749        assert!(
1750            emitted.contains("corner:"),
1751            "rounded: should emit as corner:"
1752        );
1753        let reparsed = parse_document(&emitted).unwrap();
1754        let node = reparsed.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1755        assert!(node.style.fill.is_some());
1756        assert_eq!(node.style.corner_radius, Some(12.0));
1757    }
1758
1759    #[test]
1760    fn roundtrip_edge_label_offset() {
1761        let input = r#"
1762rect @a { w: 100 h: 50 }
1763rect @b { w: 100 h: 50 }
1764
1765edge @link {
1766  from: @a
1767  to: @b
1768  arrow: end
1769  label_offset: 15.5 -8.3
1770}
1771"#;
1772        let graph = parse_document(input).expect("parse failed");
1773        assert_eq!(graph.edges.len(), 1);
1774        let edge = &graph.edges[0];
1775        assert_eq!(edge.id, crate::id::NodeId::intern("link"));
1776        assert_eq!(edge.label_offset, Some((15.5, -8.3)));
1777
1778        // Emit and re-parse
1779        let emitted = crate::emitter::emit_document(&graph);
1780        assert!(
1781            emitted.contains("label_offset:"),
1782            "emitter should include label_offset"
1783        );
1784
1785        let reparsed = parse_document(&emitted).expect("re-parse failed");
1786        assert_eq!(reparsed.edges.len(), 1);
1787        let re_edge = &reparsed.edges[0];
1788        assert_eq!(re_edge.label_offset, Some((15.5, -8.3)));
1789    }
1790}