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