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