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