Skip to main content

fd_core/
parser.rs

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