1use 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#[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 skip_ws_and_comments(&mut rest);
23
24 while !rest.is_empty() {
25 if rest.starts_with("style ") {
26 let (name, style) = parse_style_block
27 .parse_next(&mut rest)
28 .map_err(|e| format!("Style parse error: {e}"))?;
29 graph.define_style(name, style);
30 } else if rest.starts_with("##") {
31 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
33 if rest.starts_with('\n') {
34 rest = &rest[1..];
35 }
36 } else if rest.starts_with('@') {
37 let (node_id, constraint) = parse_constraint_line
38 .parse_next(&mut rest)
39 .map_err(|e| format!("Constraint parse error: {e}"))?;
40 if let Some(node) = graph.get_by_id_mut(node_id) {
41 node.constraints.push(constraint);
42 }
43 } else if starts_with_node_keyword(rest) {
44 let node_data = parse_node
45 .parse_next(&mut rest)
46 .map_err(|e| format!("Node parse error: {e}"))?;
47 let root = graph.root;
48 insert_node_recursive(&mut graph, root, node_data);
49 } else {
50 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
52 if rest.starts_with('\n') {
53 rest = &rest[1..];
54 }
55 }
56
57 skip_ws_and_comments(&mut rest);
58 }
59
60 Ok(graph)
61}
62
63fn starts_with_node_keyword(s: &str) -> bool {
64 s.starts_with("group")
65 || s.starts_with("rect")
66 || s.starts_with("ellipse")
67 || s.starts_with("path")
68 || s.starts_with("text")
69}
70
71#[derive(Debug)]
73struct ParsedNode {
74 id: NodeId,
75 kind: NodeKind,
76 style: Style,
77 use_styles: Vec<NodeId>,
78 constraints: Vec<Constraint>,
79 animations: Vec<AnimKeyframe>,
80 annotations: Vec<Annotation>,
81 children: Vec<ParsedNode>,
82}
83
84fn insert_node_recursive(
85 graph: &mut SceneGraph,
86 parent: petgraph::graph::NodeIndex,
87 parsed: ParsedNode,
88) {
89 let mut node = SceneNode::new(parsed.id, parsed.kind);
90 node.style = parsed.style;
91 node.use_styles.extend(parsed.use_styles);
92 node.constraints.extend(parsed.constraints);
93 node.animations.extend(parsed.animations);
94 node.annotations = parsed.annotations;
95
96 let idx = graph.add_node(parent, node);
97
98 for child in parsed.children {
99 insert_node_recursive(graph, idx, child);
100 }
101}
102
103fn skip_ws_and_comments(input: &mut &str) {
106 loop {
107 let before = *input;
108 *input = input.trim_start();
110 if input.starts_with('#') {
111 if input.starts_with("##") {
113 break;
114 }
115 if let Some(pos) = input.find('\n') {
117 *input = &input[pos + 1..];
118 } else {
119 *input = "";
120 }
121 continue;
122 }
123 if *input == before {
124 break;
125 }
126 }
127}
128
129fn skip_space(input: &mut &str) {
131 use winnow::ascii::space0;
132 let _: Result<&str, winnow::error::ErrMode<ContextError>> = space0.parse_next(input);
133}
134
135fn parse_identifier<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
136 take_while(1.., |c: char| c.is_alphanumeric() || c == '_').parse_next(input)
137}
138
139fn parse_node_id(input: &mut &str) -> ModalResult<NodeId> {
140 preceded('@', parse_identifier)
141 .map(NodeId::intern)
142 .parse_next(input)
143}
144
145fn parse_hex_color(input: &mut &str) -> ModalResult<Color> {
146 let _ = '#'.parse_next(input)?;
147 let hex_digits: &str = take_while(1..=8, |c: char| c.is_ascii_hexdigit()).parse_next(input)?;
148 let hex_str = format!("#{hex_digits}");
149 Color::from_hex(&hex_str).ok_or_else(|| winnow::error::ErrMode::Backtrack(ContextError::new()))
150}
151
152fn parse_number(input: &mut &str) -> ModalResult<f32> {
153 let start = *input;
154 if input.starts_with('-') {
155 *input = &input[1..];
156 }
157 let _ = take_while(1.., |c: char| c.is_ascii_digit()).parse_next(input)?;
158 if input.starts_with('.') {
159 *input = &input[1..];
160 let _ =
161 take_while::<_, _, ContextError>(0.., |c: char| c.is_ascii_digit()).parse_next(input);
162 }
163 let matched = &start[..start.len() - input.len()];
164 matched
165 .parse::<f32>()
166 .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
167}
168
169fn parse_quoted_string<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
170 delimited('"', take_till(0.., '"'), '"').parse_next(input)
171}
172
173fn skip_opt_separator(input: &mut &str) {
174 if input.starts_with(';') || input.starts_with('\n') {
175 *input = &input[1..];
176 }
177}
178
179fn parse_annotation(input: &mut &str) -> ModalResult<Annotation> {
183 let _ = "##".parse_next(input)?;
184 skip_space(input);
185
186 let checkpoint = *input;
188 if let Ok(keyword) = parse_identifier.parse_next(input) {
189 skip_space(input);
190 if input.starts_with(':') {
191 let _ = ':'.parse_next(input)?;
192 skip_space(input);
193
194 let value = if input.starts_with('"') {
195 parse_quoted_string
196 .map(|s| s.to_string())
197 .parse_next(input)?
198 } else {
199 let v: &str = take_till(0.., |c: char| c == '\n' || c == ';').parse_next(input)?;
200 v.trim().to_string()
201 };
202
203 let ann = match keyword {
204 "accept" => Annotation::Accept(value),
205 "status" => Annotation::Status(value),
206 "priority" => Annotation::Priority(value),
207 "tag" => Annotation::Tag(value),
208 _ => Annotation::Description(format!("{keyword}: {value}")),
209 };
210
211 skip_opt_separator(input);
212 return Ok(ann);
213 }
214 *input = checkpoint;
215 } else {
216 *input = checkpoint;
217 }
218
219 skip_space(input);
221 let desc = if input.starts_with('"') {
222 parse_quoted_string
223 .map(|s| s.to_string())
224 .parse_next(input)?
225 } else {
226 let v: &str = take_till(0.., |c: char| c == '\n').parse_next(input)?;
227 v.trim().to_string()
228 };
229
230 skip_opt_separator(input);
231 Ok(Annotation::Description(desc))
232}
233
234fn parse_style_block(input: &mut &str) -> ModalResult<(NodeId, Style)> {
237 let _ = "style".parse_next(input)?;
238 let _ = space1.parse_next(input)?;
239 let name = parse_identifier.map(NodeId::intern).parse_next(input)?;
240 skip_space(input);
241 let _ = '{'.parse_next(input)?;
242
243 let mut style = Style::default();
244 skip_ws_and_comments(input);
245
246 while !input.starts_with('}') {
247 parse_style_property(input, &mut style)?;
248 skip_ws_and_comments(input);
249 }
250
251 let _ = '}'.parse_next(input)?;
252 Ok((name, style))
253}
254
255fn parse_style_property(input: &mut &str, style: &mut Style) -> ModalResult<()> {
256 let prop_name = parse_identifier.parse_next(input)?;
257 skip_space(input);
258 let _ = ':'.parse_next(input)?;
259 skip_space(input);
260
261 match prop_name {
262 "fill" => {
263 style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
264 }
265 "font" => {
266 parse_font_value(input, style)?;
267 }
268 "corner" => {
269 style.corner_radius = Some(parse_number.parse_next(input)?);
270 }
271 "opacity" => {
272 style.opacity = Some(parse_number.parse_next(input)?);
273 }
274 _ => {
275 let _ =
276 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
277 .parse_next(input);
278 }
279 }
280
281 skip_opt_separator(input);
282 Ok(())
283}
284
285fn parse_font_value(input: &mut &str, style: &mut Style) -> ModalResult<()> {
286 let mut font = style.font.clone().unwrap_or_default();
287
288 if input.starts_with('"') {
289 let family = parse_quoted_string.parse_next(input)?;
290 font.family = family.to_string();
291 skip_space(input);
292 }
293
294 if let Ok(n1) = parse_number.parse_next(input) {
295 skip_space(input);
296 if let Ok(n2) = parse_number.parse_next(input) {
297 font.weight = n1 as u16;
298 font.size = n2;
299 } else {
300 font.size = n1;
301 }
302 }
303
304 style.font = Some(font);
305 Ok(())
306}
307
308fn parse_node(input: &mut &str) -> ModalResult<ParsedNode> {
311 let kind_str = alt((
312 "group".value("group"),
313 "rect".value("rect"),
314 "ellipse".value("ellipse"),
315 "path".value("path"),
316 "text".value("text"),
317 ))
318 .parse_next(input)?;
319
320 skip_space(input);
321
322 let id = if input.starts_with('@') {
323 parse_node_id.parse_next(input)?
324 } else {
325 NodeId::anonymous()
326 };
327
328 skip_space(input);
329
330 let inline_text = if kind_str == "text" && input.starts_with('"') {
331 Some(
332 parse_quoted_string
333 .map(|s| s.to_string())
334 .parse_next(input)?,
335 )
336 } else {
337 None
338 };
339
340 skip_space(input);
341 let _ = '{'.parse_next(input)?;
342
343 let mut style = Style::default();
344 let mut use_styles = Vec::new();
345 let mut constraints = Vec::new();
346 let mut animations = Vec::new();
347 let mut annotations = Vec::new();
348 let mut children = Vec::new();
349 let mut width: Option<f32> = None;
350 let mut height: Option<f32> = None;
351 let mut layout = LayoutMode::Free;
352
353 skip_ws_and_comments(input);
354
355 while !input.starts_with('}') {
356 if input.starts_with("##") {
357 annotations.push(parse_annotation.parse_next(input)?);
358 } else if starts_with_child_node(input) {
359 children.push(parse_node.parse_next(input)?);
360 } else if input.starts_with("anim") {
361 animations.push(parse_anim_block.parse_next(input)?);
362 } else {
363 parse_node_property(
364 input,
365 &mut style,
366 &mut use_styles,
367 &mut constraints,
368 &mut width,
369 &mut height,
370 &mut layout,
371 )?;
372 }
373 skip_ws_and_comments(input);
374 }
375
376 let _ = '}'.parse_next(input)?;
377
378 let kind = match kind_str {
379 "group" => NodeKind::Group { layout },
380 "rect" => NodeKind::Rect {
381 width: width.unwrap_or(100.0),
382 height: height.unwrap_or(100.0),
383 },
384 "ellipse" => NodeKind::Ellipse {
385 rx: width.unwrap_or(50.0),
386 ry: height.unwrap_or(50.0),
387 },
388 "text" => NodeKind::Text {
389 content: inline_text.unwrap_or_default(),
390 },
391 "path" => NodeKind::Path {
392 commands: Vec::new(),
393 },
394 _ => unreachable!(),
395 };
396
397 Ok(ParsedNode {
398 id,
399 kind,
400 style,
401 use_styles,
402 constraints,
403 animations,
404 annotations,
405 children,
406 })
407}
408
409fn starts_with_child_node(input: &str) -> bool {
412 let keywords = &[
413 ("group", 5),
414 ("rect", 4),
415 ("ellipse", 7),
416 ("path", 4),
417 ("text", 4),
418 ];
419 for &(keyword, len) in keywords {
420 if input.starts_with(keyword) {
421 if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
422 continue; }
424 if let Some(after) = input.get(len..) {
425 if after.starts_with(|c: char| {
426 c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
427 }) {
428 return true;
429 }
430 }
431 }
432 }
433 false
434}
435
436fn parse_node_property(
437 input: &mut &str,
438 style: &mut Style,
439 use_styles: &mut Vec<NodeId>,
440 _constraints: &mut [Constraint],
441 width: &mut Option<f32>,
442 height: &mut Option<f32>,
443 layout: &mut LayoutMode,
444) -> ModalResult<()> {
445 let prop_name = parse_identifier.parse_next(input)?;
446 skip_space(input);
447 let _ = ':'.parse_next(input)?;
448 skip_space(input);
449
450 match prop_name {
451 "w" | "width" => {
452 *width = Some(parse_number.parse_next(input)?);
453 skip_space(input);
454 if input.starts_with("h:") || input.starts_with("h :") {
455 let _ = "h".parse_next(input)?;
456 skip_space(input);
457 let _ = ':'.parse_next(input)?;
458 skip_space(input);
459 *height = Some(parse_number.parse_next(input)?);
460 }
461 }
462 "h" | "height" => {
463 *height = Some(parse_number.parse_next(input)?);
464 }
465 "fill" => {
466 style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
467 }
468 "bg" => {
469 style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
470 loop {
471 skip_space(input);
472 if input.starts_with("corner=") {
473 let _ = "corner=".parse_next(input)?;
474 style.corner_radius = Some(parse_number.parse_next(input)?);
475 } else if input.starts_with("shadow=(") {
476 let _ = "shadow=(".parse_next(input)?;
477 let ox = parse_number.parse_next(input)?;
478 let _ = ','.parse_next(input)?;
479 let oy = parse_number.parse_next(input)?;
480 let _ = ','.parse_next(input)?;
481 let blur = parse_number.parse_next(input)?;
482 let _ = ','.parse_next(input)?;
483 let color = parse_hex_color.parse_next(input)?;
484 let _ = ')'.parse_next(input)?;
485 style.shadow = Some(Shadow {
486 offset_x: ox,
487 offset_y: oy,
488 blur,
489 color,
490 });
491 } else {
492 break;
493 }
494 }
495 }
496 "stroke" => {
497 let color = parse_hex_color.parse_next(input)?;
498 let _ = space1.parse_next(input)?;
499 let w = parse_number.parse_next(input)?;
500 style.stroke = Some(Stroke {
501 paint: Paint::Solid(color),
502 width: w,
503 ..Stroke::default()
504 });
505 }
506 "corner" => {
507 style.corner_radius = Some(parse_number.parse_next(input)?);
508 }
509 "opacity" => {
510 style.opacity = Some(parse_number.parse_next(input)?);
511 }
512 "use" => {
513 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
514 }
515 "font" => {
516 parse_font_value(input, style)?;
517 }
518 "layout" => {
519 let mode_str = parse_identifier.parse_next(input)?;
520 skip_space(input);
521 let mut gap = 0.0f32;
522 let mut pad = 0.0f32;
523 loop {
524 skip_space(input);
525 if input.starts_with("gap=") {
526 let _ = "gap=".parse_next(input)?;
527 gap = parse_number.parse_next(input)?;
528 } else if input.starts_with("pad=") {
529 let _ = "pad=".parse_next(input)?;
530 pad = parse_number.parse_next(input)?;
531 } else if input.starts_with("cols=") {
532 let _ = "cols=".parse_next(input)?;
533 let _ = parse_number.parse_next(input)?;
534 } else {
535 break;
536 }
537 }
538 *layout = match mode_str {
539 "column" => LayoutMode::Column { gap, pad },
540 "row" => LayoutMode::Row { gap, pad },
541 "grid" => LayoutMode::Grid { cols: 2, gap, pad },
542 _ => LayoutMode::Free,
543 };
544 }
545 _ => {
546 let _ =
547 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
548 .parse_next(input);
549 }
550 }
551
552 skip_opt_separator(input);
553 Ok(())
554}
555
556fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
559 let _ = "anim".parse_next(input)?;
560 let _ = space1.parse_next(input)?;
561 let _ = ':'.parse_next(input)?;
562 let trigger_str = parse_identifier.parse_next(input)?;
563 let trigger = match trigger_str {
564 "hover" => AnimTrigger::Hover,
565 "press" => AnimTrigger::Press,
566 "enter" => AnimTrigger::Enter,
567 other => AnimTrigger::Custom(other.to_string()),
568 };
569
570 skip_space(input);
571 let _ = '{'.parse_next(input)?;
572
573 let mut props = AnimProperties::default();
574 let mut duration_ms = 300u32;
575 let mut easing = Easing::EaseInOut;
576
577 skip_ws_and_comments(input);
578
579 while !input.starts_with('}') {
580 let prop = parse_identifier.parse_next(input)?;
581 skip_space(input);
582 let _ = ':'.parse_next(input)?;
583 skip_space(input);
584
585 match prop {
586 "fill" => {
587 props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
588 }
589 "opacity" => {
590 props.opacity = Some(parse_number.parse_next(input)?);
591 }
592 "scale" => {
593 props.scale = Some(parse_number.parse_next(input)?);
594 }
595 "rotate" => {
596 props.rotate = Some(parse_number.parse_next(input)?);
597 }
598 "ease" => {
599 let ease_name = parse_identifier.parse_next(input)?;
600 easing = match ease_name {
601 "linear" => Easing::Linear,
602 "ease_in" | "easeIn" => Easing::EaseIn,
603 "ease_out" | "easeOut" => Easing::EaseOut,
604 "ease_in_out" | "easeInOut" => Easing::EaseInOut,
605 "spring" => Easing::Spring,
606 _ => Easing::EaseInOut,
607 };
608 skip_space(input);
609 if let Ok(n) = parse_number.parse_next(input) {
610 duration_ms = n as u32;
611 if input.starts_with("ms") {
612 *input = &input[2..];
613 }
614 }
615 }
616 _ => {
617 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
618 c == '\n' || c == ';' || c == '}'
619 })
620 .parse_next(input);
621 }
622 }
623
624 skip_opt_separator(input);
625 skip_ws_and_comments(input);
626 }
627
628 let _ = '}'.parse_next(input)?;
629
630 Ok(AnimKeyframe {
631 trigger,
632 duration_ms,
633 easing,
634 properties: props,
635 })
636}
637
638fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
641 let node_id = parse_node_id.parse_next(input)?;
642 skip_space(input);
643 let _ = "->".parse_next(input)?;
644 skip_space(input);
645
646 let constraint_type = parse_identifier.parse_next(input)?;
647 skip_space(input);
648 let _ = ':'.parse_next(input)?;
649 skip_space(input);
650
651 let constraint = match constraint_type {
652 "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
653 "offset" => {
654 let from = parse_node_id.parse_next(input)?;
655 let _ = space1.parse_next(input)?;
656 let dx = parse_number.parse_next(input)?;
657 skip_space(input);
658 let _ = ','.parse_next(input)?;
659 skip_space(input);
660 let dy = parse_number.parse_next(input)?;
661 Constraint::Offset { from, dx, dy }
662 }
663 "fill_parent" => {
664 let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
665 Constraint::FillParent { pad }
666 }
667 "absolute" => {
668 let x = parse_number.parse_next(input)?;
669 skip_space(input);
670 let _ = ','.parse_next(input)?;
671 skip_space(input);
672 let y = parse_number.parse_next(input)?;
673 Constraint::Absolute { x, y }
674 }
675 _ => {
676 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
677 Constraint::Absolute { x: 0.0, y: 0.0 }
678 }
679 };
680
681 if input.starts_with('\n') {
682 *input = &input[1..];
683 }
684 Ok((node_id, constraint))
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn parse_minimal_document() {
693 let input = r#"
694# Comment
695rect @box {
696 w: 100
697 h: 50
698 fill: #FF0000
699}
700"#;
701 let graph = parse_document(input).expect("parse failed");
702 let node = graph
703 .get_by_id(NodeId::intern("box"))
704 .expect("node not found");
705
706 match &node.kind {
707 NodeKind::Rect { width, height } => {
708 assert_eq!(*width, 100.0);
709 assert_eq!(*height, 50.0);
710 }
711 _ => panic!("expected Rect"),
712 }
713 assert!(node.style.fill.is_some());
714 }
715
716 #[test]
717 fn parse_style_and_use() {
718 let input = r#"
719style accent {
720 fill: #6C5CE7
721}
722
723rect @btn {
724 w: 200
725 h: 48
726 use: accent
727}
728"#;
729 let graph = parse_document(input).expect("parse failed");
730 assert!(graph.styles.contains_key(&NodeId::intern("accent")));
731 let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
732 assert_eq!(btn.use_styles.len(), 1);
733 }
734
735 #[test]
736 fn parse_nested_group() {
737 let input = r#"
738group @form {
739 layout: column gap=16 pad=32
740
741 text @title "Hello" {
742 fill: #333333
743 }
744
745 rect @field {
746 w: 280
747 h: 44
748 }
749}
750"#;
751 let graph = parse_document(input).expect("parse failed");
752 let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
753 let children = graph.children(form_idx);
754 assert_eq!(children.len(), 2);
755 }
756
757 #[test]
758 fn parse_animation() {
759 let input = r#"
760rect @btn {
761 w: 100
762 h: 40
763 fill: #6C5CE7
764
765 anim :hover {
766 fill: #5A4BD1
767 scale: 1.02
768 ease: spring 300ms
769 }
770}
771"#;
772 let graph = parse_document(input).expect("parse failed");
773 let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
774 assert_eq!(btn.animations.len(), 1);
775 assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
776 assert_eq!(btn.animations[0].duration_ms, 300);
777 }
778
779 #[test]
780 fn parse_constraint() {
781 let input = r#"
782rect @box {
783 w: 100
784 h: 100
785}
786
787@box -> center_in: canvas
788"#;
789 let graph = parse_document(input).expect("parse failed");
790 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
791 assert_eq!(node.constraints.len(), 1);
792 match &node.constraints[0] {
793 Constraint::CenterIn(target) => assert_eq!(target.as_str(), "canvas"),
794 _ => panic!("expected CenterIn"),
795 }
796 }
797
798 #[test]
799 fn parse_inline_wh() {
800 let input = r#"
801rect @box {
802 w: 280 h: 44
803 fill: #FF0000
804}
805"#;
806 let graph = parse_document(input).expect("parse failed");
807 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
808 match &node.kind {
809 NodeKind::Rect { width, height } => {
810 assert_eq!(*width, 280.0);
811 assert_eq!(*height, 44.0);
812 }
813 _ => panic!("expected Rect"),
814 }
815 }
816
817 #[test]
818 fn parse_empty_document() {
819 let input = "";
820 let graph = parse_document(input).expect("empty doc should parse");
821 assert_eq!(graph.children(graph.root).len(), 0);
822 }
823
824 #[test]
825 fn parse_comments_only() {
826 let input = "# This is a comment\n# Another comment\n";
827 let graph = parse_document(input).expect("comments-only should parse");
828 assert_eq!(graph.children(graph.root).len(), 0);
829 }
830
831 #[test]
832 fn parse_anonymous_node() {
833 let input = "rect { w: 50 h: 50 }";
834 let graph = parse_document(input).expect("anonymous node should parse");
835 assert_eq!(graph.children(graph.root).len(), 1);
836 }
837
838 #[test]
839 fn parse_ellipse() {
840 let input = r#"
841ellipse @dot {
842 w: 30 h: 30
843 fill: #FF5733
844}
845"#;
846 let graph = parse_document(input).expect("ellipse should parse");
847 let dot = graph.get_by_id(NodeId::intern("dot")).unwrap();
848 match &dot.kind {
849 NodeKind::Ellipse { rx, ry } => {
850 assert_eq!(*rx, 30.0);
851 assert_eq!(*ry, 30.0);
852 }
853 _ => panic!("expected Ellipse"),
854 }
855 }
856
857 #[test]
858 fn parse_text_with_content() {
859 let input = r#"
860text @greeting "Hello World" {
861 font: "Inter" 600 24
862 fill: #1A1A2E
863}
864"#;
865 let graph = parse_document(input).expect("text should parse");
866 let node = graph.get_by_id(NodeId::intern("greeting")).unwrap();
867 match &node.kind {
868 NodeKind::Text { content } => {
869 assert_eq!(content, "Hello World");
870 }
871 _ => panic!("expected Text"),
872 }
873 assert!(node.style.font.is_some());
874 let font = node.style.font.as_ref().unwrap();
875 assert_eq!(font.family, "Inter");
876 assert_eq!(font.weight, 600);
877 assert_eq!(font.size, 24.0);
878 }
879
880 #[test]
881 fn parse_stroke_property() {
882 let input = r#"
883rect @bordered {
884 w: 100 h: 100
885 stroke: #DDDDDD 2
886}
887"#;
888 let graph = parse_document(input).expect("stroke should parse");
889 let node = graph.get_by_id(NodeId::intern("bordered")).unwrap();
890 assert!(node.style.stroke.is_some());
891 let stroke = node.style.stroke.as_ref().unwrap();
892 assert_eq!(stroke.width, 2.0);
893 }
894
895 #[test]
896 fn parse_multiple_constraints() {
897 let input = r#"
898rect @a { w: 100 h: 100 }
899rect @b { w: 50 h: 50 }
900@a -> center_in: canvas
901@a -> absolute: 10, 20
902"#;
903 let graph = parse_document(input).expect("multiple constraints should parse");
904 let node = graph.get_by_id(NodeId::intern("a")).unwrap();
905 assert_eq!(node.constraints.len(), 2);
907 }
908
909 #[test]
910 fn parse_comments_between_nodes() {
911 let input = r#"
912# First node
913rect @a { w: 100 h: 100 }
914# Second node
915rect @b { w: 200 h: 200 }
916"#;
917 let graph = parse_document(input).expect("interleaved comments should parse");
918 assert_eq!(graph.children(graph.root).len(), 2);
919 }
920}