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