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 spec/note blocks are ignored (they only apply inside nodes)
56            let _ = parse_spec_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                    spec: 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    spec: 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.spec = parsed.spec;
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 `spec { ... }` block or inline `spec "description"` into raw markdown.
334/// Also accepts the legacy `note` keyword for backward compatibility.
335fn parse_spec_block(input: &mut &str) -> ModalResult<String> {
336    // Accept both `spec` (primary) and `note` (legacy)
337    let _ = alt(("spec", "note")).parse_next(input)?;
338    skip_space(input);
339
340    // Inline shorthand: `spec "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: `spec { ... }` — 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_spec_content(raw);
372    Ok(trimmed)
373}
374
375/// Dedent spec block content: remove the common leading whitespace from all lines.
376fn dedent_spec_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 spec: 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_spec_block.parse_next(input)?;
583            // Multiple spec blocks: append with newline separator
584            spec = Some(match spec {
585                Some(existing) => format!("{existing}\n\n{content}"),
586                None => content,
587            });
588        } else if starts_with_child_node(input) && !matches!(kind_str, "text" | "path") {
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 starts_with_child_node(input) {
595            // Text/Path nodes are leaf-only — silently consume and discard
596            // any nested node definitions to prevent parse errors.
597            let _discarded = parse_node.parse_next(input)?;
598        } else if input.starts_with("when") || input.starts_with("anim") {
599            animations.push(parse_anim_block.parse_next(input)?);
600        } else {
601            parse_node_property(
602                input,
603                &mut style,
604                &mut use_styles,
605                &mut constraints,
606                &mut width,
607                &mut height,
608                &mut layout,
609                &mut clip,
610                &mut place,
611                &mut locked,
612                &mut path_commands,
613                &mut image_src,
614                &mut image_fit,
615            )?;
616        }
617        // Collect comments between items; they'll be attached to the *next* child node
618        let _inner_comments = collect_leading_comments(input);
619    }
620
621    let _ = '}'.parse_next(input)?;
622
623    let kind = match kind_str {
624        "group" => NodeKind::Group, // Group is purely organizational — layout ignored
625        "frame" => NodeKind::Frame {
626            width: width.unwrap_or(200.0),
627            height: height.unwrap_or(200.0),
628            clip,
629            layout,
630        },
631        "rect" => NodeKind::Rect {
632            width: width.unwrap_or(100.0),
633            height: height.unwrap_or(100.0),
634        },
635        "ellipse" => NodeKind::Ellipse {
636            rx: width.unwrap_or(50.0),
637            ry: height.unwrap_or(50.0),
638        },
639        "text" => NodeKind::Text {
640            content: inline_text.unwrap_or_default(),
641            max_width: width,
642        },
643        "path" => NodeKind::Path {
644            commands: path_commands,
645        },
646        "image" => NodeKind::Image {
647            source: ImageSource::File(image_src.unwrap_or_default()),
648            width: width.unwrap_or(100.0),
649            height: height.unwrap_or(100.0),
650            fit: image_fit,
651        },
652        "generic" => NodeKind::Generic,
653        _ => unreachable!(),
654    };
655
656    Ok(ParsedNode {
657        id,
658        kind,
659        props: style,
660        use_styles,
661        constraints,
662        animations,
663        spec,
664        comments: Vec::new(),
665        children,
666        place,
667        locked,
668    })
669}
670
671/// Check if the current position starts a child node keyword followed by
672/// a space, @, {, or " (not a property name that happens to start with a keyword).
673fn starts_with_child_node(input: &str) -> bool {
674    // Generic nodes start with @id {
675    if is_generic_node_start(input) {
676        return true;
677    }
678    let keywords = &[
679        ("group", 5),
680        ("frame", 5),
681        ("rect", 4),
682        ("ellipse", 7),
683        ("path", 4),
684        ("image", 5),
685        ("text", 4),
686    ];
687    for &(keyword, len) in keywords {
688        if input.starts_with(keyword) {
689            if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
690                continue; // e.g. "text_align" is a property, not a text node
691            }
692            if let Some(after) = input.get(len..)
693                && after.starts_with(|c: char| {
694                    c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
695                })
696            {
697                return true;
698            }
699        }
700    }
701    false
702}
703
704/// Map named colors to hex values.
705fn named_color_to_hex(name: &str) -> Option<Color> {
706    match name {
707        "red" => Color::from_hex("#EF4444"),
708        "orange" => Color::from_hex("#F97316"),
709        "amber" | "yellow" => Color::from_hex("#F59E0B"),
710        "lime" => Color::from_hex("#84CC16"),
711        "green" => Color::from_hex("#22C55E"),
712        "teal" => Color::from_hex("#14B8A6"),
713        "cyan" => Color::from_hex("#06B6D4"),
714        "blue" => Color::from_hex("#3B82F6"),
715        "indigo" => Color::from_hex("#6366F1"),
716        "purple" | "violet" => Color::from_hex("#8B5CF6"),
717        "pink" => Color::from_hex("#EC4899"),
718        "rose" => Color::from_hex("#F43F5E"),
719        "white" => Color::from_hex("#FFFFFF"),
720        "black" => Color::from_hex("#000000"),
721        "gray" | "grey" => Color::from_hex("#6B7280"),
722        "slate" => Color::from_hex("#64748B"),
723        _ => None,
724    }
725}
726
727/// Parse a `Paint` value: `#HEX`, named color, `linear(...)`, or `radial(...)`.
728fn parse_paint(input: &mut &str) -> ModalResult<Paint> {
729    if input.starts_with("linear(") {
730        let _ = "linear(".parse_next(input)?;
731        let angle = parse_number.parse_next(input)?;
732        let _ = "deg".parse_next(input)?;
733        let stops = parse_gradient_stops(input)?;
734        let _ = ')'.parse_next(input)?;
735        Ok(Paint::LinearGradient { angle, stops })
736    } else if input.starts_with("radial(") {
737        let _ = "radial(".parse_next(input)?;
738        let stops = parse_gradient_stops(input)?;
739        let _ = ')'.parse_next(input)?;
740        Ok(Paint::RadialGradient { stops })
741    } else if input.starts_with('#') {
742        parse_hex_color.map(Paint::Solid).parse_next(input)
743    } else {
744        // Try named color (e.g. purple, red, blue)
745        let saved = *input;
746        if let Ok(name) = parse_identifier.parse_next(input) {
747            if let Some(color) = named_color_to_hex(name) {
748                return Ok(Paint::Solid(color));
749            }
750            *input = saved;
751        }
752        parse_hex_color.map(Paint::Solid).parse_next(input)
753    }
754}
755
756/// Parse gradient stops: optional leading comma, then `#HEX offset` pairs separated by commas.
757///
758/// Handles both `linear(90deg, #HEX N, #HEX N)` (comma before first stop)
759/// and `radial(#HEX N, #HEX N)` (no comma before first stop).
760fn parse_gradient_stops(input: &mut &str) -> ModalResult<Vec<GradientStop>> {
761    let mut stops = Vec::new();
762    loop {
763        skip_space(input);
764        // Consume comma separator (required between stops, optional before first)
765        if input.starts_with(',') {
766            let _ = ','.parse_next(input)?;
767            skip_space(input);
768        }
769        // Stop if we hit the closing paren or end of input
770        if input.is_empty() || input.starts_with(')') {
771            break;
772        }
773        // Try to parse a color; if it fails, we're done
774        let Ok(color) = parse_hex_color.parse_next(input) else {
775            break;
776        };
777        skip_space(input);
778        let offset = parse_number.parse_next(input)?;
779        stops.push(GradientStop { color, offset });
780    }
781    Ok(stops)
782}
783
784#[allow(clippy::too_many_arguments)]
785fn parse_node_property(
786    input: &mut &str,
787    style: &mut Properties,
788    use_styles: &mut Vec<NodeId>,
789    constraints: &mut Vec<Constraint>,
790    width: &mut Option<f32>,
791    height: &mut Option<f32>,
792    layout: &mut LayoutMode,
793    clip: &mut bool,
794    place: &mut Option<(HPlace, VPlace)>,
795    locked: &mut bool,
796    path_commands: &mut Vec<PathCmd>,
797    image_src: &mut Option<String>,
798    image_fit: &mut ImageFit,
799) -> ModalResult<()> {
800    let prop_name = parse_identifier.parse_next(input)?;
801    skip_space(input);
802    let _ = ':'.parse_next(input)?;
803    skip_space(input);
804
805    match prop_name {
806        "x" => {
807            let x_val = parse_number.parse_next(input)?;
808            // Replace existing Position constraint if present, else push new
809            if let Some(Constraint::Position { x, .. }) = constraints
810                .iter_mut()
811                .find(|c| matches!(c, Constraint::Position { .. }))
812            {
813                *x = x_val;
814            } else {
815                constraints.push(Constraint::Position { x: x_val, y: 0.0 });
816            }
817        }
818        "y" => {
819            let y_val = parse_number.parse_next(input)?;
820            if let Some(Constraint::Position { y, .. }) = constraints
821                .iter_mut()
822                .find(|c| matches!(c, Constraint::Position { .. }))
823            {
824                *y = y_val;
825            } else {
826                constraints.push(Constraint::Position { x: 0.0, y: y_val });
827            }
828        }
829        "w" | "width" => {
830            *width = Some(parse_number.parse_next(input)?);
831            skip_px_suffix(input);
832            skip_space(input);
833            if input.starts_with("h:") || input.starts_with("h :") {
834                let _ = "h".parse_next(input)?;
835                skip_space(input);
836                let _ = ':'.parse_next(input)?;
837                skip_space(input);
838                *height = Some(parse_number.parse_next(input)?);
839                skip_px_suffix(input);
840            }
841        }
842        "h" | "height" => {
843            *height = Some(parse_number.parse_next(input)?);
844            skip_px_suffix(input);
845        }
846        "fill" | "background" | "color" => {
847            style.fill = Some(parse_paint(input)?);
848        }
849        "bg" => {
850            style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
851            loop {
852                skip_space(input);
853                if input.starts_with("corner=") {
854                    let _ = "corner=".parse_next(input)?;
855                    style.corner_radius = Some(parse_number.parse_next(input)?);
856                } else if input.starts_with("shadow=(") {
857                    let _ = "shadow=(".parse_next(input)?;
858                    let ox = parse_number.parse_next(input)?;
859                    let _ = ','.parse_next(input)?;
860                    let oy = parse_number.parse_next(input)?;
861                    let _ = ','.parse_next(input)?;
862                    let blur = parse_number.parse_next(input)?;
863                    let _ = ','.parse_next(input)?;
864                    let color = parse_hex_color.parse_next(input)?;
865                    let _ = ')'.parse_next(input)?;
866                    style.shadow = Some(Shadow {
867                        offset_x: ox,
868                        offset_y: oy,
869                        blur,
870                        color,
871                    });
872                } else {
873                    break;
874                }
875            }
876        }
877        "stroke" | "border" => {
878            let color = parse_hex_color.parse_next(input)?;
879            let _ = space1.parse_next(input)?;
880            let w = parse_number.parse_next(input)?;
881            style.stroke = Some(Stroke {
882                paint: Paint::Solid(color),
883                width: w,
884                ..Stroke::default()
885            });
886        }
887        "corner" | "rounded" | "radius" => {
888            style.corner_radius = Some(parse_number.parse_next(input)?);
889            skip_px_suffix(input);
890        }
891        "opacity" => {
892            style.opacity = Some(parse_number.parse_next(input)?);
893        }
894        "align" | "text_align" => {
895            parse_align_value(input, style)?;
896        }
897        "place" => {
898            *place = Some(parse_place_value(input)?);
899        }
900        "locked" => {
901            // locked: true — parse boolean value
902            let val = parse_identifier.parse_next(input)?;
903            *locked = val == "true";
904        }
905        "shadow" => {
906            // shadow: (ox,oy,blur,#COLOR)  — colon already consumed by property parser
907            skip_space(input);
908            if input.starts_with('(') {
909                let _ = '('.parse_next(input)?;
910                let ox = parse_number.parse_next(input)?;
911                let _ = ','.parse_next(input)?;
912                let oy = parse_number.parse_next(input)?;
913                let _ = ','.parse_next(input)?;
914                let blur = parse_number.parse_next(input)?;
915                let _ = ','.parse_next(input)?;
916                let color = parse_hex_color.parse_next(input)?;
917                let _ = ')'.parse_next(input)?;
918                style.shadow = Some(Shadow {
919                    offset_x: ox,
920                    offset_y: oy,
921                    blur,
922                    color,
923                });
924            }
925        }
926        "label" => {
927            // Deprecated: label is now a text child node.
928            // Skip the value to maintain backwards compatibility with old .fd files.
929            if input.starts_with('"') {
930                let _ = parse_quoted_string.parse_next(input)?;
931            } else {
932                let _ = take_till::<_, _, ContextError>(0.., |c: char| {
933                    c == '\n' || c == ';' || c == '}'
934                })
935                .parse_next(input);
936            }
937        }
938        "use" | "apply" => {
939            use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
940        }
941        "font" => {
942            parse_font_value(input, style)?;
943        }
944        "layout" => {
945            let mode_str = parse_identifier.parse_next(input)?;
946            skip_space(input);
947            let mut gap = 0.0f32;
948            let mut pad = 0.0f32;
949            loop {
950                skip_space(input);
951                if input.starts_with("gap=") {
952                    let _ = "gap=".parse_next(input)?;
953                    gap = parse_number.parse_next(input)?;
954                } else if input.starts_with("pad=") {
955                    let _ = "pad=".parse_next(input)?;
956                    pad = parse_number.parse_next(input)?;
957                } else if input.starts_with("cols=") {
958                    let _ = "cols=".parse_next(input)?;
959                    let _ = parse_number.parse_next(input)?;
960                } else {
961                    break;
962                }
963            }
964            *layout = match mode_str {
965                "column" => LayoutMode::Column { gap, pad },
966                "row" => LayoutMode::Row { gap, pad },
967                "grid" => LayoutMode::Grid { cols: 2, gap, pad },
968                _ => LayoutMode::Free { pad: 0.0 },
969            };
970        }
971        "clip" => {
972            let val = parse_identifier.parse_next(input)?;
973            *clip = val == "true";
974        }
975        "pad" | "padding" => {
976            // Standalone pad: N — sets padding on Free frames
977            let val = parse_number.parse_next(input)?;
978            match layout {
979                LayoutMode::Free { pad } => *pad = val,
980                LayoutMode::Column { pad, .. }
981                | LayoutMode::Row { pad, .. }
982                | LayoutMode::Grid { pad, .. } => *pad = val,
983            }
984        }
985        "d" => {
986            // Parse SVG-like path commands: M x y L x y C ... Z
987            loop {
988                skip_space(input);
989                let at_end = input.is_empty()
990                    || input.starts_with('\n')
991                    || input.starts_with(';')
992                    || input.starts_with('}');
993                if at_end {
994                    break;
995                }
996                let saved = *input;
997                if let Ok(cmd_char) = take_while::<_, _, ContextError>(1..=1, |c: char| {
998                    matches!(c, 'M' | 'L' | 'Q' | 'C' | 'Z')
999                })
1000                .parse_next(input)
1001                {
1002                    skip_space(input);
1003                    match cmd_char {
1004                        "M" => {
1005                            let x = parse_number.parse_next(input)?;
1006                            skip_space(input);
1007                            let y = parse_number.parse_next(input)?;
1008                            path_commands.push(PathCmd::MoveTo(x, y));
1009                        }
1010                        "L" => {
1011                            let x = parse_number.parse_next(input)?;
1012                            skip_space(input);
1013                            let y = parse_number.parse_next(input)?;
1014                            path_commands.push(PathCmd::LineTo(x, y));
1015                        }
1016                        "Q" => {
1017                            let cx = parse_number.parse_next(input)?;
1018                            skip_space(input);
1019                            let cy = parse_number.parse_next(input)?;
1020                            skip_space(input);
1021                            let ex = parse_number.parse_next(input)?;
1022                            skip_space(input);
1023                            let ey = parse_number.parse_next(input)?;
1024                            path_commands.push(PathCmd::QuadTo(cx, cy, ex, ey));
1025                        }
1026                        "C" => {
1027                            let c1x = parse_number.parse_next(input)?;
1028                            skip_space(input);
1029                            let c1y = parse_number.parse_next(input)?;
1030                            skip_space(input);
1031                            let c2x = parse_number.parse_next(input)?;
1032                            skip_space(input);
1033                            let c2y = parse_number.parse_next(input)?;
1034                            skip_space(input);
1035                            let ex = parse_number.parse_next(input)?;
1036                            skip_space(input);
1037                            let ey = parse_number.parse_next(input)?;
1038                            path_commands.push(PathCmd::CubicTo(c1x, c1y, c2x, c2y, ex, ey));
1039                        }
1040                        "Z" => {
1041                            path_commands.push(PathCmd::Close);
1042                        }
1043                        _ => {
1044                            *input = saved;
1045                            break;
1046                        }
1047                    }
1048                } else {
1049                    *input = saved;
1050                    break;
1051                }
1052            }
1053        }
1054        "src" => {
1055            *image_src = Some(
1056                parse_quoted_string
1057                    .map(|s| s.to_string())
1058                    .parse_next(input)?,
1059            );
1060        }
1061        "fit" => {
1062            let val = parse_identifier.parse_next(input)?;
1063            *image_fit = match val {
1064                "cover" => ImageFit::Cover,
1065                "contain" => ImageFit::Contain,
1066                "fill" => ImageFit::Fill,
1067                "none" => ImageFit::None,
1068                _ => ImageFit::Cover,
1069            };
1070        }
1071        _ => {
1072            let _ =
1073                take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
1074                    .parse_next(input);
1075        }
1076    }
1077
1078    skip_opt_separator(input);
1079    Ok(())
1080}
1081
1082// ─── Alignment value parser ──────────────────────────────────────────────
1083
1084fn parse_align_value(input: &mut &str, style: &mut Properties) -> ModalResult<()> {
1085    use crate::model::{TextAlign, TextVAlign};
1086
1087    let first = parse_identifier.parse_next(input)?;
1088    style.text_align = Some(match first {
1089        "left" => TextAlign::Left,
1090        "right" => TextAlign::Right,
1091        _ => TextAlign::Center, // "center" or unknown
1092    });
1093
1094    // Check for optional vertical alignment
1095    skip_space(input);
1096    let at_end = input.is_empty()
1097        || input.starts_with('\n')
1098        || input.starts_with(';')
1099        || input.starts_with('}');
1100    if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1101        style.text_valign = Some(match second {
1102            "top" => TextVAlign::Top,
1103            "bottom" => TextVAlign::Bottom,
1104            _ => TextVAlign::Middle,
1105        });
1106    }
1107
1108    Ok(())
1109}
1110
1111// ─── Place value parser ──────────────────────────────────────────────────
1112
1113fn parse_place_value(input: &mut &str) -> ModalResult<(HPlace, VPlace)> {
1114    use crate::model::{HPlace, VPlace};
1115
1116    let first = parse_identifier.parse_next(input)?;
1117
1118    // Check for compound hyphenated form: "top-left", "bottom-right", etc.
1119    if input.starts_with('-') {
1120        let saved = *input;
1121        *input = &input[1..]; // consume '-'
1122        if let Ok(second) = parse_identifier.parse_next(input) {
1123            match (first, second) {
1124                ("top", "left") => return Ok((HPlace::Left, VPlace::Top)),
1125                ("top", "right") => return Ok((HPlace::Right, VPlace::Top)),
1126                ("bottom", "left") => return Ok((HPlace::Left, VPlace::Bottom)),
1127                ("bottom", "right") => return Ok((HPlace::Right, VPlace::Bottom)),
1128                _ => *input = saved, // restore if unrecognized
1129            }
1130        } else {
1131            *input = saved;
1132        }
1133    }
1134
1135    match first {
1136        "center" => {
1137            // Check if there's a second word (2-arg form: "center top")
1138            skip_space(input);
1139            let at_end = input.is_empty()
1140                || input.starts_with('\n')
1141                || input.starts_with(';')
1142                || input.starts_with('}');
1143            if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1144                let v = match second {
1145                    "top" => VPlace::Top,
1146                    "bottom" => VPlace::Bottom,
1147                    _ => VPlace::Middle,
1148                };
1149                return Ok((HPlace::Center, v));
1150            }
1151            Ok((HPlace::Center, VPlace::Middle))
1152        }
1153        "top" => Ok((HPlace::Center, VPlace::Top)),
1154        "bottom" => Ok((HPlace::Center, VPlace::Bottom)),
1155        _ => {
1156            // 2-arg form: "left middle", "right top", etc.
1157            let h = match first {
1158                "left" => HPlace::Left,
1159                "right" => HPlace::Right,
1160                _ => HPlace::Center,
1161            };
1162
1163            skip_space(input);
1164            let at_end = input.is_empty()
1165                || input.starts_with('\n')
1166                || input.starts_with(';')
1167                || input.starts_with('}');
1168            if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1169                let v = match second {
1170                    "top" => VPlace::Top,
1171                    "bottom" => VPlace::Bottom,
1172                    _ => VPlace::Middle,
1173                };
1174                return Ok((h, v));
1175            }
1176
1177            Ok((h, VPlace::Middle))
1178        }
1179    }
1180}
1181
1182// ─── Animation block parser ─────────────────────────────────────────────
1183
1184fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
1185    let _ = alt(("when", "anim")).parse_next(input)?;
1186    let _ = space1.parse_next(input)?;
1187    let _ = ':'.parse_next(input)?;
1188    let trigger_str = parse_identifier.parse_next(input)?;
1189    let trigger = match trigger_str {
1190        "hover" => AnimTrigger::Hover,
1191        "press" => AnimTrigger::Press,
1192        "enter" => AnimTrigger::Enter,
1193        other => AnimTrigger::Custom(other.to_string()),
1194    };
1195
1196    // Trigger-specific default durations
1197    let default_duration = match &trigger {
1198        AnimTrigger::Hover => 300u32,
1199        AnimTrigger::Press => 150u32,
1200        AnimTrigger::Enter => 500u32,
1201        AnimTrigger::Custom(_) => 300u32,
1202    };
1203
1204    skip_space(input);
1205    let _ = '{'.parse_next(input)?;
1206
1207    let mut props = AnimProperties::default();
1208    let mut duration_ms = default_duration;
1209    let mut easing = Easing::EaseInOut;
1210    let mut delay_ms: Option<u32> = None;
1211
1212    skip_ws_and_comments(input);
1213
1214    while !input.starts_with('}') {
1215        let prop = parse_identifier.parse_next(input)?;
1216        skip_space(input);
1217        let _ = ':'.parse_next(input)?;
1218        skip_space(input);
1219
1220        match prop {
1221            "fill" => {
1222                props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
1223            }
1224            "opacity" => {
1225                props.opacity = Some(parse_number.parse_next(input)?);
1226            }
1227            "scale" => {
1228                props.scale = Some(parse_number.parse_next(input)?);
1229            }
1230            "rotate" => {
1231                props.rotate = Some(parse_number.parse_next(input)?);
1232            }
1233            "ease" => {
1234                let ease_name = parse_identifier.parse_next(input)?;
1235                easing = match ease_name {
1236                    "linear" => Easing::Linear,
1237                    "ease_in" | "easeIn" => Easing::EaseIn,
1238                    "ease_out" | "easeOut" => Easing::EaseOut,
1239                    "ease_in_out" | "easeInOut" => Easing::EaseInOut,
1240                    "spring" => Easing::Spring,
1241                    _ => Easing::EaseInOut,
1242                };
1243                skip_space(input);
1244                if let Ok(n) = parse_number.parse_next(input) {
1245                    duration_ms = n as u32;
1246                    if input.starts_with("ms") {
1247                        *input = &input[2..];
1248                    }
1249                }
1250            }
1251            "delay" => {
1252                let n = parse_number.parse_next(input)?;
1253                delay_ms = Some(n as u32);
1254                if input.starts_with("ms") {
1255                    *input = &input[2..];
1256                }
1257            }
1258            _ => {
1259                let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1260                    c == '\n' || c == ';' || c == '}'
1261                })
1262                .parse_next(input);
1263            }
1264        }
1265
1266        skip_opt_separator(input);
1267        skip_ws_and_comments(input);
1268    }
1269
1270    let _ = '}'.parse_next(input)?;
1271
1272    Ok(AnimKeyframe {
1273        trigger,
1274        duration_ms,
1275        easing,
1276        properties: props,
1277        delay_ms,
1278    })
1279}
1280
1281// ─── Edge anchor parser ─────────────────────────────────────────────────
1282
1283/// Parse an edge endpoint: `@node_id` or `x y` coordinates.
1284fn parse_edge_anchor(input: &mut &str) -> ModalResult<EdgeAnchor> {
1285    skip_space(input);
1286    if input.starts_with('@') {
1287        Ok(EdgeAnchor::Node(parse_node_id.parse_next(input)?))
1288    } else {
1289        let x = parse_number.parse_next(input)?;
1290        skip_space(input);
1291        let y = parse_number.parse_next(input)?;
1292        Ok(EdgeAnchor::Point(x, y))
1293    }
1294}
1295
1296// ─── Edge defaults parser ──────────────────────────────────────────────
1297
1298fn parse_edge_defaults_block(input: &mut &str) -> ModalResult<EdgeDefaults> {
1299    let _ = "edge_defaults".parse_next(input)?;
1300    skip_space(input);
1301    let _ = '{'.parse_next(input)?;
1302
1303    let mut defaults = EdgeDefaults::default();
1304    skip_ws_and_comments(input);
1305
1306    while !input.starts_with('}') {
1307        let prop = parse_identifier.parse_next(input)?;
1308        skip_space(input);
1309        let _ = ':'.parse_next(input)?;
1310        skip_space(input);
1311
1312        match prop {
1313            "stroke" => {
1314                let color = parse_hex_color.parse_next(input)?;
1315                skip_space(input);
1316                let w = parse_number.parse_next(input).unwrap_or(1.0);
1317                defaults.props.stroke = Some(Stroke {
1318                    paint: Paint::Solid(color),
1319                    width: w,
1320                    ..Stroke::default()
1321                });
1322            }
1323            "arrow" => {
1324                let kind = parse_identifier.parse_next(input)?;
1325                defaults.arrow = Some(match kind {
1326                    "none" => ArrowKind::None,
1327                    "start" => ArrowKind::Start,
1328                    "end" => ArrowKind::End,
1329                    "both" => ArrowKind::Both,
1330                    _ => ArrowKind::None,
1331                });
1332            }
1333            "curve" => {
1334                let kind = parse_identifier.parse_next(input)?;
1335                defaults.curve = Some(match kind {
1336                    "straight" => CurveKind::Straight,
1337                    "smooth" => CurveKind::Smooth,
1338                    "step" => CurveKind::Step,
1339                    _ => CurveKind::Straight,
1340                });
1341            }
1342            "opacity" => {
1343                defaults.props.opacity = Some(parse_number.parse_next(input)?);
1344            }
1345            _ => {
1346                let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1347                    c == '\n' || c == ';' || c == '}'
1348                })
1349                .parse_next(input);
1350            }
1351        }
1352        skip_opt_separator(input);
1353        skip_ws_and_comments(input);
1354    }
1355
1356    let _ = '}'.parse_next(input)?;
1357    Ok(defaults)
1358}
1359
1360// ─── Edge block parser ─────────────────────────────────────────────────
1361
1362fn parse_edge_block(input: &mut &str) -> ModalResult<(Edge, Option<(NodeId, String)>)> {
1363    let _ = "edge".parse_next(input)?;
1364    let _ = space1.parse_next(input)?;
1365
1366    let id = if input.starts_with('@') {
1367        parse_node_id.parse_next(input)?
1368    } else {
1369        NodeId::anonymous("edge")
1370    };
1371
1372    skip_space(input);
1373    let _ = '{'.parse_next(input)?;
1374
1375    let mut from = None;
1376    let mut to = None;
1377    let mut text_child = None;
1378    let mut text_child_content = None; // (NodeId, content_string)
1379    let mut style = Properties::default();
1380    let mut use_styles = Vec::new();
1381    let mut arrow = ArrowKind::None;
1382    let mut curve = CurveKind::Straight;
1383    let mut spec: Option<String> = None;
1384    let mut animations = Vec::new();
1385    let mut flow = None;
1386    let mut label_offset = None;
1387
1388    skip_ws_and_comments(input);
1389
1390    while !input.starts_with('}') {
1391        if input.starts_with("spec ")
1392            || input.starts_with("spec{")
1393            || input.starts_with("note ")
1394            || input.starts_with("note{")
1395        {
1396            let content = parse_spec_block.parse_next(input)?;
1397            spec = Some(match spec {
1398                Some(existing) => format!("{existing}\n\n{content}"),
1399                None => content,
1400            });
1401        } else if input.starts_with("when") || input.starts_with("anim") {
1402            animations.push(parse_anim_block.parse_next(input)?);
1403        } else if input.starts_with("text ") || input.starts_with("text@") {
1404            // Nested text child: text @id "content" { ... }
1405            let node = parse_node.parse_next(input)?;
1406            if let NodeKind::Text { ref content, .. } = node.kind {
1407                text_child = Some(node.id);
1408                text_child_content = Some((node.id, content.clone()));
1409            }
1410        } else {
1411            let prop = parse_identifier.parse_next(input)?;
1412            skip_space(input);
1413            let _ = ':'.parse_next(input)?;
1414            skip_space(input);
1415
1416            match prop {
1417                "from" => {
1418                    from = Some(parse_edge_anchor(input)?);
1419                }
1420                "to" => {
1421                    to = Some(parse_edge_anchor(input)?);
1422                }
1423                "label" => {
1424                    // Backward compat: label: "string" → auto-create text child
1425                    let s = parse_quoted_string
1426                        .map(|s| s.to_string())
1427                        .parse_next(input)?;
1428                    let label_id = NodeId::intern(&format!("_{}_label", id.as_str()));
1429                    text_child = Some(label_id);
1430                    text_child_content = Some((label_id, s));
1431                }
1432                "stroke" => {
1433                    let color = parse_hex_color.parse_next(input)?;
1434                    skip_space(input);
1435                    let w = parse_number.parse_next(input).unwrap_or(1.0);
1436                    style.stroke = Some(Stroke {
1437                        paint: Paint::Solid(color),
1438                        width: w,
1439                        ..Stroke::default()
1440                    });
1441                }
1442                "arrow" => {
1443                    let kind = parse_identifier.parse_next(input)?;
1444                    arrow = match kind {
1445                        "none" => ArrowKind::None,
1446                        "start" => ArrowKind::Start,
1447                        "end" => ArrowKind::End,
1448                        "both" => ArrowKind::Both,
1449                        _ => ArrowKind::None,
1450                    };
1451                }
1452                "curve" => {
1453                    let kind = parse_identifier.parse_next(input)?;
1454                    curve = match kind {
1455                        "straight" => CurveKind::Straight,
1456                        "smooth" => CurveKind::Smooth,
1457                        "step" => CurveKind::Step,
1458                        _ => CurveKind::Straight,
1459                    };
1460                }
1461                "use" => {
1462                    use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1463                }
1464                "opacity" => {
1465                    style.opacity = Some(parse_number.parse_next(input)?);
1466                }
1467                "flow" => {
1468                    let kind_str = parse_identifier.parse_next(input)?;
1469                    let kind = match kind_str {
1470                        "pulse" => FlowKind::Pulse,
1471                        "dash" => FlowKind::Dash,
1472                        _ => FlowKind::Pulse,
1473                    };
1474                    skip_space(input);
1475                    let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1476                    if input.starts_with("ms") {
1477                        *input = &input[2..];
1478                    }
1479                    flow = Some(FlowAnim {
1480                        kind,
1481                        duration_ms: dur,
1482                    });
1483                }
1484                "label_offset" => {
1485                    let ox = parse_number.parse_next(input)?;
1486                    skip_space(input);
1487                    let oy = parse_number.parse_next(input)?;
1488                    label_offset = Some((ox, oy));
1489                }
1490                _ => {
1491                    let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1492                        c == '\n' || c == ';' || c == '}'
1493                    })
1494                    .parse_next(input);
1495                }
1496            }
1497
1498            skip_opt_separator(input);
1499        }
1500        skip_ws_and_comments(input);
1501    }
1502
1503    let _ = '}'.parse_next(input)?;
1504
1505    // Default stroke if none provided
1506    if style.stroke.is_none() {
1507        style.stroke = Some(Stroke {
1508            paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1509            width: 1.5,
1510            ..Stroke::default()
1511        });
1512    }
1513
1514    Ok((
1515        Edge {
1516            id,
1517            from: from.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1518            to: to.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1519            text_child,
1520            props: style,
1521            use_styles: use_styles.into(),
1522            arrow,
1523            curve,
1524            spec,
1525            animations: animations.into(),
1526            flow,
1527            label_offset,
1528        },
1529        text_child_content,
1530    ))
1531}
1532
1533// ─── Constraint line parser ──────────────────────────────────────────────
1534
1535fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1536    let node_id = parse_node_id.parse_next(input)?;
1537    skip_space(input);
1538    let _ = "->".parse_next(input)?;
1539    skip_space(input);
1540
1541    let constraint_type = parse_identifier.parse_next(input)?;
1542    skip_space(input);
1543    let _ = ':'.parse_next(input)?;
1544    skip_space(input);
1545
1546    let constraint = match constraint_type {
1547        "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1548        "offset" => {
1549            let from = parse_node_id.parse_next(input)?;
1550            let _ = space1.parse_next(input)?;
1551            let dx = parse_number.parse_next(input)?;
1552            skip_space(input);
1553            let _ = ','.parse_next(input)?;
1554            skip_space(input);
1555            let dy = parse_number.parse_next(input)?;
1556            Constraint::Offset { from, dx, dy }
1557        }
1558        "fill_parent" => {
1559            let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1560            Constraint::FillParent { pad }
1561        }
1562        "absolute" | "position" => {
1563            let x = parse_number.parse_next(input)?;
1564            skip_space(input);
1565            let _ = ','.parse_next(input)?;
1566            skip_space(input);
1567            let y = parse_number.parse_next(input)?;
1568            Constraint::Position { x, y }
1569        }
1570        _ => {
1571            let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1572            Constraint::Position { x: 0.0, y: 0.0 }
1573        }
1574    };
1575
1576    if input.starts_with('\n') {
1577        *input = &input[1..];
1578    }
1579    Ok((node_id, constraint))
1580}
1581
1582#[cfg(test)]
1583#[path = "parser_tests.rs"]
1584mod tests;