Skip to main content

fd_core/
parser.rs

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