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 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 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 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 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 { content },
80 style: crate::model::Style::default(),
81 use_styles: Default::default(),
82 constraints: Default::default(),
83 annotations: Vec::new(),
84 animations: Default::default(),
85 comments: Vec::new(),
86 };
87 let idx = graph.graph.add_node(text_node);
88 graph.graph.add_edge(graph.root, idx, ());
89 graph.id_index.insert(text_id, idx);
90 }
91 graph.edges.push(edge);
92 pending_comments.clear();
93 } else if starts_with_node_keyword(rest) {
94 let mut node_data = parse_node.parse_next(&mut rest).map_err(|e| {
95 format!(
96 "line {line}: node error — expected `kind @id {{ ... }}`, got `{ctx}…`: {e}"
97 )
98 })?;
99 node_data.comments = std::mem::take(&mut pending_comments);
100 let root = graph.root;
101 insert_node_recursive(&mut graph, root, node_data);
102 } else {
103 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
105 if rest.starts_with('\n') {
106 rest = &rest[1..];
107 }
108 pending_comments.clear();
109 }
110
111 let more = collect_leading_comments(&mut rest);
114 pending_comments.extend(more);
115 }
116
117 Ok(graph)
118}
119
120fn line_number(full_input: &str, remaining: &str) -> usize {
122 let consumed = full_input.len() - remaining.len();
123 full_input[..consumed].matches('\n').count() + 1
124}
125
126fn starts_with_node_keyword(s: &str) -> bool {
127 s.starts_with("group")
128 || s.starts_with("frame")
129 || s.starts_with("rect")
130 || s.starts_with("ellipse")
131 || s.starts_with("path")
132 || s.starts_with("text")
133}
134
135fn is_generic_node_start(s: &str) -> bool {
138 let rest = match s.strip_prefix('@') {
139 Some(r) => r,
140 None => return false,
141 };
142 let after_id = rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_');
143 if after_id.len() == rest.len() {
145 return false;
146 }
147 after_id.trim_start().starts_with('{')
148}
149
150#[derive(Debug)]
152struct ParsedNode {
153 id: NodeId,
154 kind: NodeKind,
155 style: Style,
156 use_styles: Vec<NodeId>,
157 constraints: Vec<Constraint>,
158 animations: Vec<AnimKeyframe>,
159 annotations: Vec<Annotation>,
160 comments: Vec<String>,
162 children: Vec<ParsedNode>,
163}
164
165fn insert_node_recursive(
166 graph: &mut SceneGraph,
167 parent: petgraph::graph::NodeIndex,
168 parsed: ParsedNode,
169) {
170 let mut node = SceneNode::new(parsed.id, parsed.kind);
171 node.style = parsed.style;
172 node.use_styles.extend(parsed.use_styles);
173 node.constraints.extend(parsed.constraints);
174 node.animations.extend(parsed.animations);
175 node.annotations = parsed.annotations;
176 node.comments = parsed.comments;
177
178 let idx = graph.add_node(parent, node);
179
180 for child in parsed.children {
181 insert_node_recursive(graph, idx, child);
182 }
183}
184
185fn parse_import_line(input: &mut &str) -> ModalResult<Import> {
189 let _ = "import".parse_next(input)?;
190 let _ = space1.parse_next(input)?;
191 let path = parse_quoted_string
192 .map(|s| s.to_string())
193 .parse_next(input)?;
194 let _ = space1.parse_next(input)?;
195 let _ = "as".parse_next(input)?;
196 let _ = space1.parse_next(input)?;
197 let namespace = parse_identifier.map(|s| s.to_string()).parse_next(input)?;
198 skip_opt_separator(input);
199 Ok(Import { path, namespace })
200}
201
202const SECTION_SEPARATORS: &[&str] = &[
207 "─── Themes ───",
208 "─── Layout ───",
209 "─── Constraints ───",
210 "─── Flows ───",
211];
212
213fn is_section_separator(text: &str) -> bool {
215 SECTION_SEPARATORS.iter().any(|sep| text.contains(sep))
216}
217
218fn collect_leading_comments(input: &mut &str) -> Vec<String> {
221 let mut comments = Vec::new();
222 loop {
223 let before = *input;
225 *input = input.trim_start();
226 if input.starts_with('#') {
227 let end = input.find('\n').unwrap_or(input.len());
229 let text = input[1..end].trim().to_string();
230 *input = &input[end.min(input.len())..];
231 if input.starts_with('\n') {
232 *input = &input[1..];
233 }
234 if !text.is_empty() && !is_section_separator(&text) {
236 comments.push(text);
237 }
238 continue;
239 }
240 if *input == before {
241 break;
242 }
243 }
244 comments
245}
246
247fn skip_ws_and_comments(input: &mut &str) {
250 let _ = collect_leading_comments(input);
251}
252
253fn skip_space(input: &mut &str) {
255 use winnow::ascii::space0;
256 let _: Result<&str, winnow::error::ErrMode<ContextError>> = space0.parse_next(input);
257}
258
259fn parse_identifier<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
260 take_while(1.., |c: char| c.is_alphanumeric() || c == '_').parse_next(input)
261}
262
263fn parse_node_id(input: &mut &str) -> ModalResult<NodeId> {
264 preceded('@', parse_identifier)
265 .map(NodeId::intern)
266 .parse_next(input)
267}
268
269fn parse_hex_color(input: &mut &str) -> ModalResult<Color> {
270 let _ = '#'.parse_next(input)?;
271 let hex_digits: &str = take_while(1..=8, |c: char| c.is_ascii_hexdigit()).parse_next(input)?;
272 Color::from_hex(hex_digits)
273 .ok_or_else(|| winnow::error::ErrMode::Backtrack(ContextError::new()))
274}
275
276fn parse_number(input: &mut &str) -> ModalResult<f32> {
277 let start = *input;
278 if input.starts_with('-') {
279 *input = &input[1..];
280 }
281 let _ = take_while(1.., |c: char| c.is_ascii_digit()).parse_next(input)?;
282 if input.starts_with('.') {
283 *input = &input[1..];
284 let _ =
285 take_while::<_, _, ContextError>(0.., |c: char| c.is_ascii_digit()).parse_next(input);
286 }
287 let matched = &start[..start.len() - input.len()];
288 matched
289 .parse::<f32>()
290 .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
291}
292
293fn parse_quoted_string<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
294 delimited('"', take_till(0.., '"'), '"').parse_next(input)
295}
296
297fn skip_opt_separator(input: &mut &str) {
298 if input.starts_with(';') || input.starts_with('\n') {
299 *input = &input[1..];
300 }
301}
302
303fn skip_px_suffix(input: &mut &str) {
305 if input.starts_with("px") {
306 *input = &input[2..];
307 }
308}
309
310fn parse_spec_block(input: &mut &str) -> ModalResult<Vec<Annotation>> {
314 let _ = "spec".parse_next(input)?;
315 skip_space(input);
316
317 if input.starts_with('"') {
319 let desc = parse_quoted_string
320 .map(|s| s.to_string())
321 .parse_next(input)?;
322 skip_opt_separator(input);
323 return Ok(vec![Annotation::Description(desc)]);
324 }
325
326 let _ = '{'.parse_next(input)?;
328 let mut annotations = Vec::new();
329 skip_ws_and_comments(input);
330
331 while !input.starts_with('}') {
332 annotations.push(parse_spec_item.parse_next(input)?);
333 skip_ws_and_comments(input);
334 }
335
336 let _ = '}'.parse_next(input)?;
337 Ok(annotations)
338}
339
340fn parse_spec_item(input: &mut &str) -> ModalResult<Annotation> {
349 if input.starts_with('"') {
351 let desc = parse_quoted_string
352 .map(|s| s.to_string())
353 .parse_next(input)?;
354 skip_opt_separator(input);
355 return Ok(Annotation::Description(desc));
356 }
357
358 let keyword = parse_identifier.parse_next(input)?;
360 skip_space(input);
361 let _ = ':'.parse_next(input)?;
362 skip_space(input);
363
364 let value = if input.starts_with('"') {
365 parse_quoted_string
366 .map(|s| s.to_string())
367 .parse_next(input)?
368 } else {
369 let v: &str =
370 take_till(0.., |c: char| c == '\n' || c == ';' || c == '}').parse_next(input)?;
371 v.trim().to_string()
372 };
373
374 let ann = match keyword {
375 "accept" => Annotation::Accept(value),
376 "status" => Annotation::Status(value),
377 "priority" => Annotation::Priority(value),
378 "tag" => Annotation::Tag(value),
379 _ => Annotation::Description(format!("{keyword}: {value}")),
380 };
381
382 skip_opt_separator(input);
383 Ok(ann)
384}
385
386fn parse_style_block(input: &mut &str) -> ModalResult<(NodeId, Style)> {
389 let _ = alt(("theme", "style")).parse_next(input)?;
390 let _ = space1.parse_next(input)?;
391 let name = parse_identifier.map(NodeId::intern).parse_next(input)?;
392 skip_space(input);
393 let _ = '{'.parse_next(input)?;
394
395 let mut style = Style::default();
396 skip_ws_and_comments(input);
397
398 while !input.starts_with('}') {
399 parse_style_property(input, &mut style)?;
400 skip_ws_and_comments(input);
401 }
402
403 let _ = '}'.parse_next(input)?;
404 Ok((name, style))
405}
406
407fn parse_style_property(input: &mut &str, style: &mut Style) -> ModalResult<()> {
408 let prop_name = parse_identifier.parse_next(input)?;
409 skip_space(input);
410 let _ = ':'.parse_next(input)?;
411 skip_space(input);
412
413 match prop_name {
414 "fill" | "background" | "color" => {
415 style.fill = Some(parse_paint(input)?);
416 }
417 "font" => {
418 parse_font_value(input, style)?;
419 }
420 "corner" | "rounded" | "radius" => {
421 style.corner_radius = Some(parse_number.parse_next(input)?);
422 skip_px_suffix(input);
423 }
424 "opacity" => {
425 style.opacity = Some(parse_number.parse_next(input)?);
426 }
427 "align" | "text_align" => {
428 parse_align_value(input, style)?;
429 }
430 _ => {
431 let _ =
432 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
433 .parse_next(input);
434 }
435 }
436
437 skip_opt_separator(input);
438 Ok(())
439}
440
441fn weight_name_to_number(name: &str) -> Option<u16> {
443 match name {
444 "thin" => Some(100),
445 "extralight" | "extra_light" => Some(200),
446 "light" => Some(300),
447 "regular" | "normal" => Some(400),
448 "medium" => Some(500),
449 "semibold" | "semi_bold" => Some(600),
450 "bold" => Some(700),
451 "extrabold" | "extra_bold" => Some(800),
452 "black" | "heavy" => Some(900),
453 _ => None,
454 }
455}
456
457fn parse_font_value(input: &mut &str, style: &mut Style) -> ModalResult<()> {
458 let mut font = style.font.clone().unwrap_or_default();
459
460 if input.starts_with('"') {
461 let family = parse_quoted_string.parse_next(input)?;
462 font.family = family.to_string();
463 skip_space(input);
464 }
465
466 let saved = *input;
468 if let Ok(name) = parse_identifier.parse_next(input) {
469 if let Some(w) = weight_name_to_number(name) {
470 font.weight = w;
471 skip_space(input);
472 if let Ok(size) = parse_number.parse_next(input) {
473 font.size = size;
474 skip_px_suffix(input);
475 }
476 } else {
477 *input = saved; }
479 }
480
481 if *input == saved
483 && let Ok(n1) = parse_number.parse_next(input)
484 {
485 skip_space(input);
486 if let Ok(n2) = parse_number.parse_next(input) {
487 font.weight = n1 as u16;
488 font.size = n2;
489 skip_px_suffix(input);
490 } else {
491 font.size = n1;
492 skip_px_suffix(input);
493 }
494 }
495
496 style.font = Some(font);
497 Ok(())
498}
499
500fn parse_node(input: &mut &str) -> ModalResult<ParsedNode> {
503 let kind_str = if input.starts_with('@') {
505 "generic"
506 } else {
507 alt((
508 "group".value("group"),
509 "frame".value("frame"),
510 "rect".value("rect"),
511 "ellipse".value("ellipse"),
512 "path".value("path"),
513 "text".value("text"),
514 ))
515 .parse_next(input)?
516 };
517
518 skip_space(input);
519
520 let id = if input.starts_with('@') {
521 parse_node_id.parse_next(input)?
522 } else {
523 NodeId::anonymous(kind_str)
524 };
525
526 skip_space(input);
527
528 let inline_text = if kind_str == "text" && input.starts_with('"') {
529 Some(
530 parse_quoted_string
531 .map(|s| s.to_string())
532 .parse_next(input)?,
533 )
534 } else {
535 None
536 };
537
538 skip_space(input);
539 let _ = '{'.parse_next(input)?;
540
541 let mut style = Style::default();
542 let mut use_styles = Vec::new();
543 let mut constraints = Vec::new();
544 let mut animations = Vec::new();
545 let mut annotations = Vec::new();
546 let mut children = Vec::new();
547 let mut width: Option<f32> = None;
548 let mut height: Option<f32> = None;
549 let mut layout = LayoutMode::Free;
550 let mut clip = false;
551
552 skip_ws_and_comments(input);
553
554 while !input.starts_with('}') {
555 if input.starts_with("spec ") || input.starts_with("spec{") {
556 annotations.extend(parse_spec_block.parse_next(input)?);
557 } else if starts_with_child_node(input) {
558 let mut child = parse_node.parse_next(input)?;
559 child.comments = Vec::new(); children.push(child);
563 } else if input.starts_with("when") || input.starts_with("anim") {
564 animations.push(parse_anim_block.parse_next(input)?);
565 } else {
566 parse_node_property(
567 input,
568 &mut style,
569 &mut use_styles,
570 &mut constraints,
571 &mut width,
572 &mut height,
573 &mut layout,
574 &mut clip,
575 )?;
576 }
577 let _inner_comments = collect_leading_comments(input);
579 }
580
581 let _ = '}'.parse_next(input)?;
582
583 let kind = match kind_str {
584 "group" => NodeKind::Group, "frame" => NodeKind::Frame {
586 width: width.unwrap_or(200.0),
587 height: height.unwrap_or(200.0),
588 clip,
589 layout,
590 },
591 "rect" => NodeKind::Rect {
592 width: width.unwrap_or(100.0),
593 height: height.unwrap_or(100.0),
594 },
595 "ellipse" => NodeKind::Ellipse {
596 rx: width.unwrap_or(50.0),
597 ry: height.unwrap_or(50.0),
598 },
599 "text" => NodeKind::Text {
600 content: inline_text.unwrap_or_default(),
601 },
602 "path" => NodeKind::Path {
603 commands: Vec::new(),
604 },
605 "generic" => NodeKind::Generic,
606 _ => unreachable!(),
607 };
608
609 Ok(ParsedNode {
610 id,
611 kind,
612 style,
613 use_styles,
614 constraints,
615 animations,
616 annotations,
617 comments: Vec::new(),
618 children,
619 })
620}
621
622fn starts_with_child_node(input: &str) -> bool {
625 if is_generic_node_start(input) {
627 return true;
628 }
629 let keywords = &[
630 ("group", 5),
631 ("frame", 5),
632 ("rect", 4),
633 ("ellipse", 7),
634 ("path", 4),
635 ("text", 4),
636 ];
637 for &(keyword, len) in keywords {
638 if input.starts_with(keyword) {
639 if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
640 continue; }
642 if let Some(after) = input.get(len..)
643 && after.starts_with(|c: char| {
644 c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
645 })
646 {
647 return true;
648 }
649 }
650 }
651 false
652}
653
654fn named_color_to_hex(name: &str) -> Option<Color> {
656 match name {
657 "red" => Color::from_hex("#EF4444"),
658 "orange" => Color::from_hex("#F97316"),
659 "amber" | "yellow" => Color::from_hex("#F59E0B"),
660 "lime" => Color::from_hex("#84CC16"),
661 "green" => Color::from_hex("#22C55E"),
662 "teal" => Color::from_hex("#14B8A6"),
663 "cyan" => Color::from_hex("#06B6D4"),
664 "blue" => Color::from_hex("#3B82F6"),
665 "indigo" => Color::from_hex("#6366F1"),
666 "purple" | "violet" => Color::from_hex("#8B5CF6"),
667 "pink" => Color::from_hex("#EC4899"),
668 "rose" => Color::from_hex("#F43F5E"),
669 "white" => Color::from_hex("#FFFFFF"),
670 "black" => Color::from_hex("#000000"),
671 "gray" | "grey" => Color::from_hex("#6B7280"),
672 "slate" => Color::from_hex("#64748B"),
673 _ => None,
674 }
675}
676
677fn parse_paint(input: &mut &str) -> ModalResult<Paint> {
679 if input.starts_with("linear(") {
680 let _ = "linear(".parse_next(input)?;
681 let angle = parse_number.parse_next(input)?;
682 let _ = "deg".parse_next(input)?;
683 let stops = parse_gradient_stops(input)?;
684 let _ = ')'.parse_next(input)?;
685 Ok(Paint::LinearGradient { angle, stops })
686 } else if input.starts_with("radial(") {
687 let _ = "radial(".parse_next(input)?;
688 let stops = parse_gradient_stops(input)?;
689 let _ = ')'.parse_next(input)?;
690 Ok(Paint::RadialGradient { stops })
691 } else if input.starts_with('#') {
692 parse_hex_color.map(Paint::Solid).parse_next(input)
693 } else {
694 let saved = *input;
696 if let Ok(name) = parse_identifier.parse_next(input) {
697 if let Some(color) = named_color_to_hex(name) {
698 return Ok(Paint::Solid(color));
699 }
700 *input = saved;
701 }
702 parse_hex_color.map(Paint::Solid).parse_next(input)
703 }
704}
705
706fn parse_gradient_stops(input: &mut &str) -> ModalResult<Vec<GradientStop>> {
711 let mut stops = Vec::new();
712 loop {
713 skip_space(input);
714 if input.starts_with(',') {
716 let _ = ','.parse_next(input)?;
717 skip_space(input);
718 }
719 if input.is_empty() || input.starts_with(')') {
721 break;
722 }
723 let Ok(color) = parse_hex_color.parse_next(input) else {
725 break;
726 };
727 skip_space(input);
728 let offset = parse_number.parse_next(input)?;
729 stops.push(GradientStop { color, offset });
730 }
731 Ok(stops)
732}
733
734#[allow(clippy::too_many_arguments)]
735fn parse_node_property(
736 input: &mut &str,
737 style: &mut Style,
738 use_styles: &mut Vec<NodeId>,
739 constraints: &mut Vec<Constraint>,
740 width: &mut Option<f32>,
741 height: &mut Option<f32>,
742 layout: &mut LayoutMode,
743 clip: &mut bool,
744) -> ModalResult<()> {
745 let prop_name = parse_identifier.parse_next(input)?;
746 skip_space(input);
747 let _ = ':'.parse_next(input)?;
748 skip_space(input);
749
750 match prop_name {
751 "x" => {
752 let x_val = parse_number.parse_next(input)?;
753 if let Some(Constraint::Position { x, .. }) = constraints
755 .iter_mut()
756 .find(|c| matches!(c, Constraint::Position { .. }))
757 {
758 *x = x_val;
759 } else {
760 constraints.push(Constraint::Position { x: x_val, y: 0.0 });
761 }
762 }
763 "y" => {
764 let y_val = parse_number.parse_next(input)?;
765 if let Some(Constraint::Position { y, .. }) = constraints
766 .iter_mut()
767 .find(|c| matches!(c, Constraint::Position { .. }))
768 {
769 *y = y_val;
770 } else {
771 constraints.push(Constraint::Position { x: 0.0, y: y_val });
772 }
773 }
774 "w" | "width" => {
775 *width = Some(parse_number.parse_next(input)?);
776 skip_px_suffix(input);
777 skip_space(input);
778 if input.starts_with("h:") || input.starts_with("h :") {
779 let _ = "h".parse_next(input)?;
780 skip_space(input);
781 let _ = ':'.parse_next(input)?;
782 skip_space(input);
783 *height = Some(parse_number.parse_next(input)?);
784 skip_px_suffix(input);
785 }
786 }
787 "h" | "height" => {
788 *height = Some(parse_number.parse_next(input)?);
789 skip_px_suffix(input);
790 }
791 "fill" | "background" | "color" => {
792 style.fill = Some(parse_paint(input)?);
793 }
794 "bg" => {
795 style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
796 loop {
797 skip_space(input);
798 if input.starts_with("corner=") {
799 let _ = "corner=".parse_next(input)?;
800 style.corner_radius = Some(parse_number.parse_next(input)?);
801 } else if input.starts_with("shadow=(") {
802 let _ = "shadow=(".parse_next(input)?;
803 let ox = parse_number.parse_next(input)?;
804 let _ = ','.parse_next(input)?;
805 let oy = parse_number.parse_next(input)?;
806 let _ = ','.parse_next(input)?;
807 let blur = parse_number.parse_next(input)?;
808 let _ = ','.parse_next(input)?;
809 let color = parse_hex_color.parse_next(input)?;
810 let _ = ')'.parse_next(input)?;
811 style.shadow = Some(Shadow {
812 offset_x: ox,
813 offset_y: oy,
814 blur,
815 color,
816 });
817 } else {
818 break;
819 }
820 }
821 }
822 "stroke" => {
823 let color = parse_hex_color.parse_next(input)?;
824 let _ = space1.parse_next(input)?;
825 let w = parse_number.parse_next(input)?;
826 style.stroke = Some(Stroke {
827 paint: Paint::Solid(color),
828 width: w,
829 ..Stroke::default()
830 });
831 }
832 "corner" | "rounded" | "radius" => {
833 style.corner_radius = Some(parse_number.parse_next(input)?);
834 skip_px_suffix(input);
835 }
836 "opacity" => {
837 style.opacity = Some(parse_number.parse_next(input)?);
838 }
839 "align" | "text_align" => {
840 parse_align_value(input, style)?;
841 }
842 "shadow" => {
843 skip_space(input);
845 if input.starts_with('(') {
846 let _ = '('.parse_next(input)?;
847 let ox = parse_number.parse_next(input)?;
848 let _ = ','.parse_next(input)?;
849 let oy = parse_number.parse_next(input)?;
850 let _ = ','.parse_next(input)?;
851 let blur = parse_number.parse_next(input)?;
852 let _ = ','.parse_next(input)?;
853 let color = parse_hex_color.parse_next(input)?;
854 let _ = ')'.parse_next(input)?;
855 style.shadow = Some(Shadow {
856 offset_x: ox,
857 offset_y: oy,
858 blur,
859 color,
860 });
861 }
862 }
863 "label" => {
864 if input.starts_with('"') {
867 let _ = parse_quoted_string.parse_next(input)?;
868 } else {
869 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
870 c == '\n' || c == ';' || c == '}'
871 })
872 .parse_next(input);
873 }
874 }
875 "use" => {
876 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
877 }
878 "font" => {
879 parse_font_value(input, style)?;
880 }
881 "layout" => {
882 let mode_str = parse_identifier.parse_next(input)?;
883 skip_space(input);
884 let mut gap = 0.0f32;
885 let mut pad = 0.0f32;
886 loop {
887 skip_space(input);
888 if input.starts_with("gap=") {
889 let _ = "gap=".parse_next(input)?;
890 gap = parse_number.parse_next(input)?;
891 } else if input.starts_with("pad=") {
892 let _ = "pad=".parse_next(input)?;
893 pad = parse_number.parse_next(input)?;
894 } else if input.starts_with("cols=") {
895 let _ = "cols=".parse_next(input)?;
896 let _ = parse_number.parse_next(input)?;
897 } else {
898 break;
899 }
900 }
901 *layout = match mode_str {
902 "column" => LayoutMode::Column { gap, pad },
903 "row" => LayoutMode::Row { gap, pad },
904 "grid" => LayoutMode::Grid { cols: 2, gap, pad },
905 _ => LayoutMode::Free,
906 };
907 }
908 "clip" => {
909 let val = parse_identifier.parse_next(input)?;
910 *clip = val == "true";
911 }
912 _ => {
913 let _ =
914 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
915 .parse_next(input);
916 }
917 }
918
919 skip_opt_separator(input);
920 Ok(())
921}
922
923fn parse_align_value(input: &mut &str, style: &mut Style) -> ModalResult<()> {
926 use crate::model::{TextAlign, TextVAlign};
927
928 let first = parse_identifier.parse_next(input)?;
929 style.text_align = Some(match first {
930 "left" => TextAlign::Left,
931 "right" => TextAlign::Right,
932 _ => TextAlign::Center, });
934
935 skip_space(input);
937 let at_end = input.is_empty()
938 || input.starts_with('\n')
939 || input.starts_with(';')
940 || input.starts_with('}');
941 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
942 style.text_valign = Some(match second {
943 "top" => TextVAlign::Top,
944 "bottom" => TextVAlign::Bottom,
945 _ => TextVAlign::Middle,
946 });
947 }
948
949 Ok(())
950}
951
952fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
955 let _ = alt(("when", "anim")).parse_next(input)?;
956 let _ = space1.parse_next(input)?;
957 let _ = ':'.parse_next(input)?;
958 let trigger_str = parse_identifier.parse_next(input)?;
959 let trigger = match trigger_str {
960 "hover" => AnimTrigger::Hover,
961 "press" => AnimTrigger::Press,
962 "enter" => AnimTrigger::Enter,
963 other => AnimTrigger::Custom(other.to_string()),
964 };
965
966 skip_space(input);
967 let _ = '{'.parse_next(input)?;
968
969 let mut props = AnimProperties::default();
970 let mut duration_ms = 300u32;
971 let mut easing = Easing::EaseInOut;
972
973 skip_ws_and_comments(input);
974
975 while !input.starts_with('}') {
976 let prop = parse_identifier.parse_next(input)?;
977 skip_space(input);
978 let _ = ':'.parse_next(input)?;
979 skip_space(input);
980
981 match prop {
982 "fill" => {
983 props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
984 }
985 "opacity" => {
986 props.opacity = Some(parse_number.parse_next(input)?);
987 }
988 "scale" => {
989 props.scale = Some(parse_number.parse_next(input)?);
990 }
991 "rotate" => {
992 props.rotate = Some(parse_number.parse_next(input)?);
993 }
994 "ease" => {
995 let ease_name = parse_identifier.parse_next(input)?;
996 easing = match ease_name {
997 "linear" => Easing::Linear,
998 "ease_in" | "easeIn" => Easing::EaseIn,
999 "ease_out" | "easeOut" => Easing::EaseOut,
1000 "ease_in_out" | "easeInOut" => Easing::EaseInOut,
1001 "spring" => Easing::Spring,
1002 _ => Easing::EaseInOut,
1003 };
1004 skip_space(input);
1005 if let Ok(n) = parse_number.parse_next(input) {
1006 duration_ms = n as u32;
1007 if input.starts_with("ms") {
1008 *input = &input[2..];
1009 }
1010 }
1011 }
1012 _ => {
1013 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1014 c == '\n' || c == ';' || c == '}'
1015 })
1016 .parse_next(input);
1017 }
1018 }
1019
1020 skip_opt_separator(input);
1021 skip_ws_and_comments(input);
1022 }
1023
1024 let _ = '}'.parse_next(input)?;
1025
1026 Ok(AnimKeyframe {
1027 trigger,
1028 duration_ms,
1029 easing,
1030 properties: props,
1031 })
1032}
1033
1034fn parse_edge_anchor(input: &mut &str) -> ModalResult<EdgeAnchor> {
1038 skip_space(input);
1039 if input.starts_with('@') {
1040 Ok(EdgeAnchor::Node(parse_node_id.parse_next(input)?))
1041 } else {
1042 let x = parse_number.parse_next(input)?;
1043 skip_space(input);
1044 let y = parse_number.parse_next(input)?;
1045 Ok(EdgeAnchor::Point(x, y))
1046 }
1047}
1048
1049fn parse_edge_block(input: &mut &str) -> ModalResult<(Edge, Option<(NodeId, String)>)> {
1052 let _ = "edge".parse_next(input)?;
1053 let _ = space1.parse_next(input)?;
1054
1055 let id = if input.starts_with('@') {
1056 parse_node_id.parse_next(input)?
1057 } else {
1058 NodeId::anonymous("edge")
1059 };
1060
1061 skip_space(input);
1062 let _ = '{'.parse_next(input)?;
1063
1064 let mut from = None;
1065 let mut to = None;
1066 let mut text_child = None;
1067 let mut text_child_content = None; let mut style = Style::default();
1069 let mut use_styles = Vec::new();
1070 let mut arrow = ArrowKind::None;
1071 let mut curve = CurveKind::Straight;
1072 let mut annotations = Vec::new();
1073 let mut animations = Vec::new();
1074 let mut flow = None;
1075 let mut label_offset = None;
1076
1077 skip_ws_and_comments(input);
1078
1079 while !input.starts_with('}') {
1080 if input.starts_with("spec ") || input.starts_with("spec{") {
1081 annotations.extend(parse_spec_block.parse_next(input)?);
1082 } else if input.starts_with("when") || input.starts_with("anim") {
1083 animations.push(parse_anim_block.parse_next(input)?);
1084 } else if input.starts_with("text ") || input.starts_with("text@") {
1085 let node = parse_node.parse_next(input)?;
1087 if let NodeKind::Text { ref content } = node.kind {
1088 text_child = Some(node.id);
1089 text_child_content = Some((node.id, content.clone()));
1090 }
1091 } else {
1092 let prop = parse_identifier.parse_next(input)?;
1093 skip_space(input);
1094 let _ = ':'.parse_next(input)?;
1095 skip_space(input);
1096
1097 match prop {
1098 "from" => {
1099 from = Some(parse_edge_anchor(input)?);
1100 }
1101 "to" => {
1102 to = Some(parse_edge_anchor(input)?);
1103 }
1104 "label" => {
1105 let s = parse_quoted_string
1107 .map(|s| s.to_string())
1108 .parse_next(input)?;
1109 let label_id = NodeId::intern(&format!("_{}_label", id.as_str()));
1110 text_child = Some(label_id);
1111 text_child_content = Some((label_id, s));
1112 }
1113 "stroke" => {
1114 let color = parse_hex_color.parse_next(input)?;
1115 skip_space(input);
1116 let w = parse_number.parse_next(input).unwrap_or(1.0);
1117 style.stroke = Some(Stroke {
1118 paint: Paint::Solid(color),
1119 width: w,
1120 ..Stroke::default()
1121 });
1122 }
1123 "arrow" => {
1124 let kind = parse_identifier.parse_next(input)?;
1125 arrow = match kind {
1126 "none" => ArrowKind::None,
1127 "start" => ArrowKind::Start,
1128 "end" => ArrowKind::End,
1129 "both" => ArrowKind::Both,
1130 _ => ArrowKind::None,
1131 };
1132 }
1133 "curve" => {
1134 let kind = parse_identifier.parse_next(input)?;
1135 curve = match kind {
1136 "straight" => CurveKind::Straight,
1137 "smooth" => CurveKind::Smooth,
1138 "step" => CurveKind::Step,
1139 _ => CurveKind::Straight,
1140 };
1141 }
1142 "use" => {
1143 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1144 }
1145 "opacity" => {
1146 style.opacity = Some(parse_number.parse_next(input)?);
1147 }
1148 "flow" => {
1149 let kind_str = parse_identifier.parse_next(input)?;
1150 let kind = match kind_str {
1151 "pulse" => FlowKind::Pulse,
1152 "dash" => FlowKind::Dash,
1153 _ => FlowKind::Pulse,
1154 };
1155 skip_space(input);
1156 let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1157 if input.starts_with("ms") {
1158 *input = &input[2..];
1159 }
1160 flow = Some(FlowAnim {
1161 kind,
1162 duration_ms: dur,
1163 });
1164 }
1165 "label_offset" => {
1166 let ox = parse_number.parse_next(input)?;
1167 skip_space(input);
1168 let oy = parse_number.parse_next(input)?;
1169 label_offset = Some((ox, oy));
1170 }
1171 _ => {
1172 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1173 c == '\n' || c == ';' || c == '}'
1174 })
1175 .parse_next(input);
1176 }
1177 }
1178
1179 skip_opt_separator(input);
1180 }
1181 skip_ws_and_comments(input);
1182 }
1183
1184 let _ = '}'.parse_next(input)?;
1185
1186 if style.stroke.is_none() {
1188 style.stroke = Some(Stroke {
1189 paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1190 width: 1.5,
1191 ..Stroke::default()
1192 });
1193 }
1194
1195 Ok((
1196 Edge {
1197 id,
1198 from: from.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1199 to: to.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1200 text_child,
1201 style,
1202 use_styles: use_styles.into(),
1203 arrow,
1204 curve,
1205 annotations,
1206 animations: animations.into(),
1207 flow,
1208 label_offset,
1209 },
1210 text_child_content,
1211 ))
1212}
1213
1214fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1217 let node_id = parse_node_id.parse_next(input)?;
1218 skip_space(input);
1219 let _ = "->".parse_next(input)?;
1220 skip_space(input);
1221
1222 let constraint_type = parse_identifier.parse_next(input)?;
1223 skip_space(input);
1224 let _ = ':'.parse_next(input)?;
1225 skip_space(input);
1226
1227 let constraint = match constraint_type {
1228 "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1229 "offset" => {
1230 let from = parse_node_id.parse_next(input)?;
1231 let _ = space1.parse_next(input)?;
1232 let dx = parse_number.parse_next(input)?;
1233 skip_space(input);
1234 let _ = ','.parse_next(input)?;
1235 skip_space(input);
1236 let dy = parse_number.parse_next(input)?;
1237 Constraint::Offset { from, dx, dy }
1238 }
1239 "fill_parent" => {
1240 let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1241 Constraint::FillParent { pad }
1242 }
1243 "absolute" | "position" => {
1244 let x = parse_number.parse_next(input)?;
1245 skip_space(input);
1246 let _ = ','.parse_next(input)?;
1247 skip_space(input);
1248 let y = parse_number.parse_next(input)?;
1249 Constraint::Position { x, y }
1250 }
1251 _ => {
1252 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1253 Constraint::Position { x: 0.0, y: 0.0 }
1254 }
1255 };
1256
1257 if input.starts_with('\n') {
1258 *input = &input[1..];
1259 }
1260 Ok((node_id, constraint))
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::*;
1266
1267 #[test]
1268 fn parse_minimal_document() {
1269 let input = r#"
1270# Comment
1271rect @box {
1272 w: 100
1273 h: 50
1274 fill: #FF0000
1275}
1276"#;
1277 let graph = parse_document(input).expect("parse failed");
1278 let node = graph
1279 .get_by_id(NodeId::intern("box"))
1280 .expect("node not found");
1281
1282 match &node.kind {
1283 NodeKind::Rect { width, height } => {
1284 assert_eq!(*width, 100.0);
1285 assert_eq!(*height, 50.0);
1286 }
1287 _ => panic!("expected Rect"),
1288 }
1289 assert!(node.style.fill.is_some());
1290 }
1291
1292 #[test]
1293 fn parse_style_and_use() {
1294 let input = r#"
1295style accent {
1296 fill: #6C5CE7
1297}
1298
1299rect @btn {
1300 w: 200
1301 h: 48
1302 use: accent
1303}
1304"#;
1305 let graph = parse_document(input).expect("parse failed");
1306 assert!(graph.styles.contains_key(&NodeId::intern("accent")));
1307 let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1308 assert_eq!(btn.use_styles.len(), 1);
1309 }
1310
1311 #[test]
1312 fn parse_nested_group() {
1313 let input = r#"
1314group @form {
1315 layout: column gap=16 pad=32
1316
1317 text @title "Hello" {
1318 fill: #333333
1319 }
1320
1321 rect @field {
1322 w: 280
1323 h: 44
1324 }
1325}
1326"#;
1327 let graph = parse_document(input).expect("parse failed");
1328 let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1329 let children = graph.children(form_idx);
1330 assert_eq!(children.len(), 2);
1331 }
1332
1333 #[test]
1334 fn parse_animation() {
1335 let input = r#"
1336rect @btn {
1337 w: 100
1338 h: 40
1339 fill: #6C5CE7
1340
1341 anim :hover {
1342 fill: #5A4BD1
1343 scale: 1.02
1344 ease: spring 300ms
1345 }
1346}
1347"#;
1348 let graph = parse_document(input).expect("parse failed");
1349 let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1350 assert_eq!(btn.animations.len(), 1);
1351 assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1352 assert_eq!(btn.animations[0].duration_ms, 300);
1353 }
1354
1355 #[test]
1356 fn parse_constraint() {
1357 let input = r#"
1358rect @box {
1359 w: 100
1360 h: 100
1361}
1362
1363@box -> center_in: canvas
1364"#;
1365 let graph = parse_document(input).expect("parse failed");
1366 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1367 assert_eq!(node.constraints.len(), 1);
1368 match &node.constraints[0] {
1369 Constraint::CenterIn(target) => assert_eq!(target.as_str(), "canvas"),
1370 _ => panic!("expected CenterIn"),
1371 }
1372 }
1373
1374 #[test]
1375 fn parse_inline_wh() {
1376 let input = r#"
1377rect @box {
1378 w: 280 h: 44
1379 fill: #FF0000
1380}
1381"#;
1382 let graph = parse_document(input).expect("parse failed");
1383 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1384 match &node.kind {
1385 NodeKind::Rect { width, height } => {
1386 assert_eq!(*width, 280.0);
1387 assert_eq!(*height, 44.0);
1388 }
1389 _ => panic!("expected Rect"),
1390 }
1391 }
1392
1393 #[test]
1394 fn parse_empty_document() {
1395 let input = "";
1396 let graph = parse_document(input).expect("empty doc should parse");
1397 assert_eq!(graph.children(graph.root).len(), 0);
1398 }
1399
1400 #[test]
1401 fn parse_comments_only() {
1402 let input = "# This is a comment\n# Another comment\n";
1403 let graph = parse_document(input).expect("comments-only should parse");
1404 assert_eq!(graph.children(graph.root).len(), 0);
1405 }
1406
1407 #[test]
1408 fn parse_anonymous_node() {
1409 let input = "rect { w: 50 h: 50 }";
1410 let graph = parse_document(input).expect("anonymous node should parse");
1411 assert_eq!(graph.children(graph.root).len(), 1);
1412 }
1413
1414 #[test]
1415 fn parse_ellipse() {
1416 let input = r#"
1417ellipse @dot {
1418 w: 30 h: 30
1419 fill: #FF5733
1420}
1421"#;
1422 let graph = parse_document(input).expect("ellipse should parse");
1423 let dot = graph.get_by_id(NodeId::intern("dot")).unwrap();
1424 match &dot.kind {
1425 NodeKind::Ellipse { rx, ry } => {
1426 assert_eq!(*rx, 30.0);
1427 assert_eq!(*ry, 30.0);
1428 }
1429 _ => panic!("expected Ellipse"),
1430 }
1431 }
1432
1433 #[test]
1434 fn parse_text_with_content() {
1435 let input = r#"
1436text @greeting "Hello World" {
1437 font: "Inter" 600 24
1438 fill: #1A1A2E
1439}
1440"#;
1441 let graph = parse_document(input).expect("text should parse");
1442 let node = graph.get_by_id(NodeId::intern("greeting")).unwrap();
1443 match &node.kind {
1444 NodeKind::Text { content } => {
1445 assert_eq!(content, "Hello World");
1446 }
1447 _ => panic!("expected Text"),
1448 }
1449 assert!(node.style.font.is_some());
1450 let font = node.style.font.as_ref().unwrap();
1451 assert_eq!(font.family, "Inter");
1452 assert_eq!(font.weight, 600);
1453 assert_eq!(font.size, 24.0);
1454 }
1455
1456 #[test]
1457 fn parse_stroke_property() {
1458 let input = r#"
1459rect @bordered {
1460 w: 100 h: 100
1461 stroke: #DDDDDD 2
1462}
1463"#;
1464 let graph = parse_document(input).expect("stroke should parse");
1465 let node = graph.get_by_id(NodeId::intern("bordered")).unwrap();
1466 assert!(node.style.stroke.is_some());
1467 let stroke = node.style.stroke.as_ref().unwrap();
1468 assert_eq!(stroke.width, 2.0);
1469 }
1470
1471 #[test]
1472 fn parse_multiple_constraints() {
1473 let input = r#"
1474rect @a { w: 100 h: 100 }
1475rect @b { w: 50 h: 50 }
1476@a -> center_in: canvas
1477@a -> absolute: 10, 20
1478"#;
1479 let graph = parse_document(input).expect("multiple constraints should parse");
1480 let node = graph.get_by_id(NodeId::intern("a")).unwrap();
1481 assert_eq!(node.constraints.len(), 2);
1483 }
1484
1485 #[test]
1486 fn parse_comments_between_nodes() {
1487 let input = r#"
1488# First node
1489rect @a { w: 100 h: 100 }
1490# Second node
1491rect @b { w: 200 h: 200 }
1492"#;
1493 let graph = parse_document(input).expect("interleaved comments should parse");
1494 assert_eq!(graph.children(graph.root).len(), 2);
1495 }
1496 #[test]
1497 fn parse_frame() {
1498 let input = r#"
1499frame @card {
1500 w: 400 h: 300
1501 clip: true
1502 fill: #FFFFFF
1503 corner: 16
1504 layout: column gap=12 pad=20
1505}
1506"#;
1507 let graph = parse_document(input).expect("parse failed");
1508 let node = graph
1509 .get_by_id(crate::id::NodeId::intern("card"))
1510 .expect("card not found");
1511 match &node.kind {
1512 NodeKind::Frame {
1513 width,
1514 height,
1515 clip,
1516 layout,
1517 } => {
1518 assert_eq!(*width, 400.0);
1519 assert_eq!(*height, 300.0);
1520 assert!(*clip);
1521 assert!(matches!(layout, LayoutMode::Column { .. }));
1522 }
1523 other => panic!("expected Frame, got {other:?}"),
1524 }
1525 }
1526
1527 #[test]
1528 fn roundtrip_frame() {
1529 let input = r#"
1530frame @panel {
1531 w: 200 h: 150
1532 clip: true
1533 fill: #F0F0F0
1534 layout: row gap=8 pad=10
1535
1536 rect @child {
1537 w: 50 h: 50
1538 fill: #FF0000
1539 }
1540}
1541"#;
1542 let graph = parse_document(input).expect("parse failed");
1543 let emitted = crate::emitter::emit_document(&graph);
1544 let reparsed = parse_document(&emitted).expect("re-parse failed");
1545 let node = reparsed
1546 .get_by_id(crate::id::NodeId::intern("panel"))
1547 .expect("panel not found");
1548 match &node.kind {
1549 NodeKind::Frame {
1550 width,
1551 height,
1552 clip,
1553 layout,
1554 } => {
1555 assert_eq!(*width, 200.0);
1556 assert_eq!(*height, 150.0);
1557 assert!(*clip);
1558 assert!(matches!(layout, LayoutMode::Row { .. }));
1559 }
1560 other => panic!("expected Frame, got {other:?}"),
1561 }
1562 let child = reparsed
1564 .get_by_id(crate::id::NodeId::intern("child"))
1565 .expect("child not found");
1566 assert!(matches!(child.kind, NodeKind::Rect { .. }));
1567 }
1568
1569 #[test]
1570 fn roundtrip_align() {
1571 let src = r#"
1572text @title "Hello" {
1573 fill: #FFFFFF
1574 font: "Inter" 600 24
1575 align: right bottom
1576}
1577"#;
1578 let graph = parse_document(src).unwrap();
1579 let node = graph
1580 .get_by_id(crate::id::NodeId::intern("title"))
1581 .expect("node not found");
1582 assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Right));
1583 assert_eq!(
1584 node.style.text_valign,
1585 Some(crate::model::TextVAlign::Bottom)
1586 );
1587
1588 let emitted = crate::emitter::emit_document(&graph);
1590 assert!(emitted.contains("align: right bottom"));
1591
1592 let reparsed = parse_document(&emitted).unwrap();
1593 let node2 = reparsed
1594 .get_by_id(crate::id::NodeId::intern("title"))
1595 .expect("node not found after roundtrip");
1596 assert_eq!(node2.style.text_align, Some(crate::model::TextAlign::Right));
1597 assert_eq!(
1598 node2.style.text_valign,
1599 Some(crate::model::TextVAlign::Bottom)
1600 );
1601 }
1602
1603 #[test]
1604 fn parse_align_center_only() {
1605 let src = r#"
1606text @heading "Welcome" {
1607 align: center
1608}
1609"#;
1610 let graph = parse_document(src).unwrap();
1611 let node = graph
1612 .get_by_id(crate::id::NodeId::intern("heading"))
1613 .expect("node not found");
1614 assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Center));
1615 assert_eq!(node.style.text_valign, None);
1617 }
1618
1619 #[test]
1620 fn roundtrip_align_in_style_block() {
1621 let src = r#"
1622style heading_style {
1623 fill: #333333
1624 font: "Inter" 700 32
1625 align: left top
1626}
1627
1628text @main_title "Hello" {
1629 use: heading_style
1630}
1631"#;
1632 let graph = parse_document(src).unwrap();
1633
1634 let style = graph
1636 .styles
1637 .get(&crate::id::NodeId::intern("heading_style"))
1638 .expect("style not found");
1639 assert_eq!(style.text_align, Some(crate::model::TextAlign::Left));
1640 assert_eq!(style.text_valign, Some(crate::model::TextVAlign::Top));
1641
1642 let node = graph
1644 .get_by_id(crate::id::NodeId::intern("main_title"))
1645 .expect("node not found");
1646 let resolved = graph.resolve_style(node, &[]);
1647 assert_eq!(resolved.text_align, Some(crate::model::TextAlign::Left));
1648 assert_eq!(resolved.text_valign, Some(crate::model::TextVAlign::Top));
1649
1650 let emitted = crate::emitter::emit_document(&graph);
1652 assert!(emitted.contains("align: left top"));
1653 let reparsed = parse_document(&emitted).unwrap();
1654 let style2 = reparsed
1655 .styles
1656 .get(&crate::id::NodeId::intern("heading_style"))
1657 .expect("style not found after roundtrip");
1658 assert_eq!(style2.text_align, Some(crate::model::TextAlign::Left));
1659 assert_eq!(style2.text_valign, Some(crate::model::TextVAlign::Top));
1660 }
1661
1662 #[test]
1663 fn parse_font_weight_names() {
1664 let src = r#"
1665text @heading "Hello" {
1666 font: "Inter" bold 24
1667}
1668"#;
1669 let graph = parse_document(src).unwrap();
1670 let node = graph
1671 .get_by_id(crate::id::NodeId::intern("heading"))
1672 .unwrap();
1673 let font = node.style.font.as_ref().unwrap();
1674 assert_eq!(font.weight, 700);
1675 assert_eq!(font.size, 24.0);
1676 }
1677
1678 #[test]
1679 fn parse_font_weight_semibold() {
1680 let src = r#"text @t "Hi" { font: "Inter" semibold 16 }"#;
1681 let graph = parse_document(src).unwrap();
1682 let font = graph
1683 .get_by_id(crate::id::NodeId::intern("t"))
1684 .unwrap()
1685 .style
1686 .font
1687 .as_ref()
1688 .unwrap();
1689 assert_eq!(font.weight, 600);
1690 assert_eq!(font.size, 16.0);
1691 }
1692
1693 #[test]
1694 fn parse_named_color() {
1695 let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1696 let graph = parse_document(src).unwrap();
1697 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1698 assert!(
1699 node.style.fill.is_some(),
1700 "fill should be set from named color"
1701 );
1702 }
1703
1704 #[test]
1705 fn parse_named_color_blue() {
1706 let src = r#"rect @box { w: 50 h: 50 fill: blue }"#;
1707 let graph = parse_document(src).unwrap();
1708 let node = graph.get_by_id(crate::id::NodeId::intern("box")).unwrap();
1709 if let Some(crate::model::Paint::Solid(c)) = &node.style.fill {
1710 assert_eq!(c.to_hex(), "#3B82F6");
1711 } else {
1712 panic!("expected solid fill from named color");
1713 }
1714 }
1715
1716 #[test]
1717 fn parse_property_alias_background() {
1718 let src = r#"rect @r { w: 100 h: 50 background: #FF0000 }"#;
1719 let graph = parse_document(src).unwrap();
1720 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1721 assert!(node.style.fill.is_some(), "background: should map to fill");
1722 }
1723
1724 #[test]
1725 fn parse_property_alias_rounded() {
1726 let src = r#"rect @r { w: 100 h: 50 rounded: 12 }"#;
1727 let graph = parse_document(src).unwrap();
1728 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1729 assert_eq!(node.style.corner_radius, Some(12.0));
1730 }
1731
1732 #[test]
1733 fn parse_property_alias_radius() {
1734 let src = r#"rect @r { w: 100 h: 50 radius: 8 }"#;
1735 let graph = parse_document(src).unwrap();
1736 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1737 assert_eq!(node.style.corner_radius, Some(8.0));
1738 }
1739
1740 #[test]
1741 fn parse_dimension_px_suffix() {
1742 let src = r#"rect @r { w: 320px h: 200px }"#;
1743 let graph = parse_document(src).unwrap();
1744 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1745 if let crate::model::NodeKind::Rect { width, height } = &node.kind {
1746 assert_eq!(*width, 320.0);
1747 assert_eq!(*height, 200.0);
1748 } else {
1749 panic!("expected rect");
1750 }
1751 }
1752
1753 #[test]
1754 fn parse_corner_px_suffix() {
1755 let src = r#"rect @r { w: 100 h: 50 corner: 10px }"#;
1756 let graph = parse_document(src).unwrap();
1757 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1758 assert_eq!(node.style.corner_radius, Some(10.0));
1759 }
1760
1761 #[test]
1762 fn roundtrip_font_weight_name() {
1763 let src = r#"text @t "Hello" { font: "Inter" bold 18 }"#;
1764 let graph = parse_document(src).unwrap();
1765 let emitted = crate::emitter::emit_document(&graph);
1766 assert!(
1767 emitted.contains("bold"),
1768 "emitted output should use 'bold' not '700'"
1769 );
1770 let reparsed = parse_document(&emitted).unwrap();
1771 let font = reparsed
1772 .get_by_id(crate::id::NodeId::intern("t"))
1773 .unwrap()
1774 .style
1775 .font
1776 .as_ref()
1777 .unwrap();
1778 assert_eq!(font.weight, 700);
1779 }
1780
1781 #[test]
1782 fn roundtrip_named_color() {
1783 let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1784 let graph = parse_document(src).unwrap();
1785 let emitted = crate::emitter::emit_document(&graph);
1786 assert!(emitted.contains("#8B5CF6"), "purple should emit as #8B5CF6");
1788 let reparsed = parse_document(&emitted).unwrap();
1789 assert!(
1790 reparsed
1791 .get_by_id(crate::id::NodeId::intern("r"))
1792 .unwrap()
1793 .style
1794 .fill
1795 .is_some()
1796 );
1797 }
1798
1799 #[test]
1800 fn roundtrip_property_aliases() {
1801 let src = r#"rect @r { w: 200 h: 100 background: #FF0000 rounded: 12 }"#;
1802 let graph = parse_document(src).unwrap();
1803 let emitted = crate::emitter::emit_document(&graph);
1804 assert!(
1806 emitted.contains("fill:"),
1807 "background: should emit as fill:"
1808 );
1809 assert!(
1810 emitted.contains("corner:"),
1811 "rounded: should emit as corner:"
1812 );
1813 let reparsed = parse_document(&emitted).unwrap();
1814 let node = reparsed.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1815 assert!(node.style.fill.is_some());
1816 assert_eq!(node.style.corner_radius, Some(12.0));
1817 }
1818
1819 #[test]
1820 fn roundtrip_edge_label_offset() {
1821 let input = r#"
1822rect @a { w: 100 h: 50 }
1823rect @b { w: 100 h: 50 }
1824
1825edge @link {
1826 from: @a
1827 to: @b
1828 arrow: end
1829 label_offset: 15.5 -8.3
1830}
1831"#;
1832 let graph = parse_document(input).expect("parse failed");
1833 assert_eq!(graph.edges.len(), 1);
1834 let edge = &graph.edges[0];
1835 assert_eq!(edge.id, crate::id::NodeId::intern("link"));
1836 assert_eq!(edge.label_offset, Some((15.5, -8.3)));
1837
1838 let emitted = crate::emitter::emit_document(&graph);
1840 assert!(
1841 emitted.contains("label_offset:"),
1842 "emitter should include label_offset"
1843 );
1844
1845 let reparsed = parse_document(&emitted).expect("re-parse failed");
1846 assert_eq!(reparsed.edges.len(), 1);
1847 let re_edge = &reparsed.edges[0];
1848 assert_eq!(re_edge.label_offset, Some((15.5, -8.3)));
1849 }
1850}