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 let hex_str = format!("#{hex_digits}");
241 Color::from_hex(&hex_str).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()
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()
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
1028 skip_ws_and_comments(input);
1029
1030 while !input.starts_with('}') {
1031 if input.starts_with("spec ") || input.starts_with("spec{") {
1032 annotations.extend(parse_spec_block.parse_next(input)?);
1033 } else if input.starts_with("when") || input.starts_with("anim") {
1034 animations.push(parse_anim_block.parse_next(input)?);
1035 } else {
1036 let prop = parse_identifier.parse_next(input)?;
1037 skip_space(input);
1038 let _ = ':'.parse_next(input)?;
1039 skip_space(input);
1040
1041 match prop {
1042 "from" => {
1043 from = Some(parse_node_id.parse_next(input)?);
1044 }
1045 "to" => {
1046 to = Some(parse_node_id.parse_next(input)?);
1047 }
1048 "label" => {
1049 label = Some(
1050 parse_quoted_string
1051 .map(|s| s.to_string())
1052 .parse_next(input)?,
1053 );
1054 }
1055 "stroke" => {
1056 let color = parse_hex_color.parse_next(input)?;
1057 skip_space(input);
1058 let w = parse_number.parse_next(input).unwrap_or(1.0);
1059 style.stroke = Some(Stroke {
1060 paint: Paint::Solid(color),
1061 width: w,
1062 ..Stroke::default()
1063 });
1064 }
1065 "arrow" => {
1066 let kind = parse_identifier.parse_next(input)?;
1067 arrow = match kind {
1068 "none" => ArrowKind::None,
1069 "start" => ArrowKind::Start,
1070 "end" => ArrowKind::End,
1071 "both" => ArrowKind::Both,
1072 _ => ArrowKind::None,
1073 };
1074 }
1075 "curve" => {
1076 let kind = parse_identifier.parse_next(input)?;
1077 curve = match kind {
1078 "straight" => CurveKind::Straight,
1079 "smooth" => CurveKind::Smooth,
1080 "step" => CurveKind::Step,
1081 _ => CurveKind::Straight,
1082 };
1083 }
1084 "use" => {
1085 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1086 }
1087 "opacity" => {
1088 style.opacity = Some(parse_number.parse_next(input)?);
1089 }
1090 "flow" => {
1091 let kind_str = parse_identifier.parse_next(input)?;
1092 let kind = match kind_str {
1093 "pulse" => FlowKind::Pulse,
1094 "dash" => FlowKind::Dash,
1095 _ => FlowKind::Pulse,
1096 };
1097 skip_space(input);
1098 let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1099 if input.starts_with("ms") {
1100 *input = &input[2..];
1101 }
1102 flow = Some(FlowAnim {
1103 kind,
1104 duration_ms: dur,
1105 });
1106 }
1107 _ => {
1108 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1109 c == '\n' || c == ';' || c == '}'
1110 })
1111 .parse_next(input);
1112 }
1113 }
1114
1115 skip_opt_separator(input);
1116 }
1117 skip_ws_and_comments(input);
1118 }
1119
1120 let _ = '}'.parse_next(input)?;
1121
1122 if style.stroke.is_none() {
1124 style.stroke = Some(Stroke {
1125 paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1126 width: 1.5,
1127 ..Stroke::default()
1128 });
1129 }
1130
1131 Ok(Edge {
1132 id,
1133 from: from.unwrap_or_else(|| NodeId::intern("_missing")),
1134 to: to.unwrap_or_else(|| NodeId::intern("_missing")),
1135 label,
1136 style,
1137 use_styles: use_styles.into(),
1138 arrow,
1139 curve,
1140 annotations,
1141 animations: animations.into(),
1142 flow,
1143 })
1144}
1145
1146fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1149 let node_id = parse_node_id.parse_next(input)?;
1150 skip_space(input);
1151 let _ = "->".parse_next(input)?;
1152 skip_space(input);
1153
1154 let constraint_type = parse_identifier.parse_next(input)?;
1155 skip_space(input);
1156 let _ = ':'.parse_next(input)?;
1157 skip_space(input);
1158
1159 let constraint = match constraint_type {
1160 "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1161 "offset" => {
1162 let from = parse_node_id.parse_next(input)?;
1163 let _ = space1.parse_next(input)?;
1164 let dx = parse_number.parse_next(input)?;
1165 skip_space(input);
1166 let _ = ','.parse_next(input)?;
1167 skip_space(input);
1168 let dy = parse_number.parse_next(input)?;
1169 Constraint::Offset { from, dx, dy }
1170 }
1171 "fill_parent" => {
1172 let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1173 Constraint::FillParent { pad }
1174 }
1175 "absolute" | "position" => {
1176 let x = parse_number.parse_next(input)?;
1177 skip_space(input);
1178 let _ = ','.parse_next(input)?;
1179 skip_space(input);
1180 let y = parse_number.parse_next(input)?;
1181 Constraint::Position { x, y }
1182 }
1183 _ => {
1184 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1185 Constraint::Position { x: 0.0, y: 0.0 }
1186 }
1187 };
1188
1189 if input.starts_with('\n') {
1190 *input = &input[1..];
1191 }
1192 Ok((node_id, constraint))
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198
1199 #[test]
1200 fn parse_minimal_document() {
1201 let input = r#"
1202# Comment
1203rect @box {
1204 w: 100
1205 h: 50
1206 fill: #FF0000
1207}
1208"#;
1209 let graph = parse_document(input).expect("parse failed");
1210 let node = graph
1211 .get_by_id(NodeId::intern("box"))
1212 .expect("node not found");
1213
1214 match &node.kind {
1215 NodeKind::Rect { width, height } => {
1216 assert_eq!(*width, 100.0);
1217 assert_eq!(*height, 50.0);
1218 }
1219 _ => panic!("expected Rect"),
1220 }
1221 assert!(node.style.fill.is_some());
1222 }
1223
1224 #[test]
1225 fn parse_style_and_use() {
1226 let input = r#"
1227style accent {
1228 fill: #6C5CE7
1229}
1230
1231rect @btn {
1232 w: 200
1233 h: 48
1234 use: accent
1235}
1236"#;
1237 let graph = parse_document(input).expect("parse failed");
1238 assert!(graph.styles.contains_key(&NodeId::intern("accent")));
1239 let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1240 assert_eq!(btn.use_styles.len(), 1);
1241 }
1242
1243 #[test]
1244 fn parse_nested_group() {
1245 let input = r#"
1246group @form {
1247 layout: column gap=16 pad=32
1248
1249 text @title "Hello" {
1250 fill: #333333
1251 }
1252
1253 rect @field {
1254 w: 280
1255 h: 44
1256 }
1257}
1258"#;
1259 let graph = parse_document(input).expect("parse failed");
1260 let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1261 let children = graph.children(form_idx);
1262 assert_eq!(children.len(), 2);
1263 }
1264
1265 #[test]
1266 fn parse_animation() {
1267 let input = r#"
1268rect @btn {
1269 w: 100
1270 h: 40
1271 fill: #6C5CE7
1272
1273 anim :hover {
1274 fill: #5A4BD1
1275 scale: 1.02
1276 ease: spring 300ms
1277 }
1278}
1279"#;
1280 let graph = parse_document(input).expect("parse failed");
1281 let btn = graph.get_by_id(NodeId::intern("btn")).unwrap();
1282 assert_eq!(btn.animations.len(), 1);
1283 assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1284 assert_eq!(btn.animations[0].duration_ms, 300);
1285 }
1286
1287 #[test]
1288 fn parse_constraint() {
1289 let input = r#"
1290rect @box {
1291 w: 100
1292 h: 100
1293}
1294
1295@box -> center_in: canvas
1296"#;
1297 let graph = parse_document(input).expect("parse failed");
1298 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1299 assert_eq!(node.constraints.len(), 1);
1300 match &node.constraints[0] {
1301 Constraint::CenterIn(target) => assert_eq!(target.as_str(), "canvas"),
1302 _ => panic!("expected CenterIn"),
1303 }
1304 }
1305
1306 #[test]
1307 fn parse_inline_wh() {
1308 let input = r#"
1309rect @box {
1310 w: 280 h: 44
1311 fill: #FF0000
1312}
1313"#;
1314 let graph = parse_document(input).expect("parse failed");
1315 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1316 match &node.kind {
1317 NodeKind::Rect { width, height } => {
1318 assert_eq!(*width, 280.0);
1319 assert_eq!(*height, 44.0);
1320 }
1321 _ => panic!("expected Rect"),
1322 }
1323 }
1324
1325 #[test]
1326 fn parse_empty_document() {
1327 let input = "";
1328 let graph = parse_document(input).expect("empty doc should parse");
1329 assert_eq!(graph.children(graph.root).len(), 0);
1330 }
1331
1332 #[test]
1333 fn parse_comments_only() {
1334 let input = "# This is a comment\n# Another comment\n";
1335 let graph = parse_document(input).expect("comments-only should parse");
1336 assert_eq!(graph.children(graph.root).len(), 0);
1337 }
1338
1339 #[test]
1340 fn parse_anonymous_node() {
1341 let input = "rect { w: 50 h: 50 }";
1342 let graph = parse_document(input).expect("anonymous node should parse");
1343 assert_eq!(graph.children(graph.root).len(), 1);
1344 }
1345
1346 #[test]
1347 fn parse_ellipse() {
1348 let input = r#"
1349ellipse @dot {
1350 w: 30 h: 30
1351 fill: #FF5733
1352}
1353"#;
1354 let graph = parse_document(input).expect("ellipse should parse");
1355 let dot = graph.get_by_id(NodeId::intern("dot")).unwrap();
1356 match &dot.kind {
1357 NodeKind::Ellipse { rx, ry } => {
1358 assert_eq!(*rx, 30.0);
1359 assert_eq!(*ry, 30.0);
1360 }
1361 _ => panic!("expected Ellipse"),
1362 }
1363 }
1364
1365 #[test]
1366 fn parse_text_with_content() {
1367 let input = r#"
1368text @greeting "Hello World" {
1369 font: "Inter" 600 24
1370 fill: #1A1A2E
1371}
1372"#;
1373 let graph = parse_document(input).expect("text should parse");
1374 let node = graph.get_by_id(NodeId::intern("greeting")).unwrap();
1375 match &node.kind {
1376 NodeKind::Text { content } => {
1377 assert_eq!(content, "Hello World");
1378 }
1379 _ => panic!("expected Text"),
1380 }
1381 assert!(node.style.font.is_some());
1382 let font = node.style.font.as_ref().unwrap();
1383 assert_eq!(font.family, "Inter");
1384 assert_eq!(font.weight, 600);
1385 assert_eq!(font.size, 24.0);
1386 }
1387
1388 #[test]
1389 fn parse_stroke_property() {
1390 let input = r#"
1391rect @bordered {
1392 w: 100 h: 100
1393 stroke: #DDDDDD 2
1394}
1395"#;
1396 let graph = parse_document(input).expect("stroke should parse");
1397 let node = graph.get_by_id(NodeId::intern("bordered")).unwrap();
1398 assert!(node.style.stroke.is_some());
1399 let stroke = node.style.stroke.as_ref().unwrap();
1400 assert_eq!(stroke.width, 2.0);
1401 }
1402
1403 #[test]
1404 fn parse_multiple_constraints() {
1405 let input = r#"
1406rect @a { w: 100 h: 100 }
1407rect @b { w: 50 h: 50 }
1408@a -> center_in: canvas
1409@a -> absolute: 10, 20
1410"#;
1411 let graph = parse_document(input).expect("multiple constraints should parse");
1412 let node = graph.get_by_id(NodeId::intern("a")).unwrap();
1413 assert_eq!(node.constraints.len(), 2);
1415 }
1416
1417 #[test]
1418 fn parse_comments_between_nodes() {
1419 let input = r#"
1420# First node
1421rect @a { w: 100 h: 100 }
1422# Second node
1423rect @b { w: 200 h: 200 }
1424"#;
1425 let graph = parse_document(input).expect("interleaved comments should parse");
1426 assert_eq!(graph.children(graph.root).len(), 2);
1427 }
1428 #[test]
1429 fn parse_frame() {
1430 let input = r#"
1431frame @card {
1432 w: 400 h: 300
1433 clip: true
1434 fill: #FFFFFF
1435 corner: 16
1436 layout: column gap=12 pad=20
1437}
1438"#;
1439 let graph = parse_document(input).expect("parse failed");
1440 let node = graph
1441 .get_by_id(crate::id::NodeId::intern("card"))
1442 .expect("card not found");
1443 match &node.kind {
1444 NodeKind::Frame {
1445 width,
1446 height,
1447 clip,
1448 layout,
1449 } => {
1450 assert_eq!(*width, 400.0);
1451 assert_eq!(*height, 300.0);
1452 assert!(*clip);
1453 assert!(matches!(layout, LayoutMode::Column { .. }));
1454 }
1455 other => panic!("expected Frame, got {other:?}"),
1456 }
1457 }
1458
1459 #[test]
1460 fn roundtrip_frame() {
1461 let input = r#"
1462frame @panel {
1463 w: 200 h: 150
1464 clip: true
1465 fill: #F0F0F0
1466 layout: row gap=8 pad=10
1467
1468 rect @child {
1469 w: 50 h: 50
1470 fill: #FF0000
1471 }
1472}
1473"#;
1474 let graph = parse_document(input).expect("parse failed");
1475 let emitted = crate::emitter::emit_document(&graph);
1476 let reparsed = parse_document(&emitted).expect("re-parse failed");
1477 let node = reparsed
1478 .get_by_id(crate::id::NodeId::intern("panel"))
1479 .expect("panel not found");
1480 match &node.kind {
1481 NodeKind::Frame {
1482 width,
1483 height,
1484 clip,
1485 layout,
1486 } => {
1487 assert_eq!(*width, 200.0);
1488 assert_eq!(*height, 150.0);
1489 assert!(*clip);
1490 assert!(matches!(layout, LayoutMode::Row { .. }));
1491 }
1492 other => panic!("expected Frame, got {other:?}"),
1493 }
1494 let child = reparsed
1496 .get_by_id(crate::id::NodeId::intern("child"))
1497 .expect("child not found");
1498 assert!(matches!(child.kind, NodeKind::Rect { .. }));
1499 }
1500
1501 #[test]
1502 fn roundtrip_align() {
1503 let src = r#"
1504text @title "Hello" {
1505 fill: #FFFFFF
1506 font: "Inter" 600 24
1507 align: right bottom
1508}
1509"#;
1510 let graph = parse_document(src).unwrap();
1511 let node = graph
1512 .get_by_id(crate::id::NodeId::intern("title"))
1513 .expect("node not found");
1514 assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Right));
1515 assert_eq!(
1516 node.style.text_valign,
1517 Some(crate::model::TextVAlign::Bottom)
1518 );
1519
1520 let emitted = crate::emitter::emit_document(&graph);
1522 assert!(emitted.contains("align: right bottom"));
1523
1524 let reparsed = parse_document(&emitted).unwrap();
1525 let node2 = reparsed
1526 .get_by_id(crate::id::NodeId::intern("title"))
1527 .expect("node not found after roundtrip");
1528 assert_eq!(node2.style.text_align, Some(crate::model::TextAlign::Right));
1529 assert_eq!(
1530 node2.style.text_valign,
1531 Some(crate::model::TextVAlign::Bottom)
1532 );
1533 }
1534
1535 #[test]
1536 fn parse_align_center_only() {
1537 let src = r#"
1538text @heading "Welcome" {
1539 align: center
1540}
1541"#;
1542 let graph = parse_document(src).unwrap();
1543 let node = graph
1544 .get_by_id(crate::id::NodeId::intern("heading"))
1545 .expect("node not found");
1546 assert_eq!(node.style.text_align, Some(crate::model::TextAlign::Center));
1547 assert_eq!(node.style.text_valign, None);
1549 }
1550
1551 #[test]
1552 fn roundtrip_align_in_style_block() {
1553 let src = r#"
1554style heading_style {
1555 fill: #333333
1556 font: "Inter" 700 32
1557 align: left top
1558}
1559
1560text @main_title "Hello" {
1561 use: heading_style
1562}
1563"#;
1564 let graph = parse_document(src).unwrap();
1565
1566 let style = graph
1568 .styles
1569 .get(&crate::id::NodeId::intern("heading_style"))
1570 .expect("style not found");
1571 assert_eq!(style.text_align, Some(crate::model::TextAlign::Left));
1572 assert_eq!(style.text_valign, Some(crate::model::TextVAlign::Top));
1573
1574 let node = graph
1576 .get_by_id(crate::id::NodeId::intern("main_title"))
1577 .expect("node not found");
1578 let resolved = graph.resolve_style(node, &[]);
1579 assert_eq!(resolved.text_align, Some(crate::model::TextAlign::Left));
1580 assert_eq!(resolved.text_valign, Some(crate::model::TextVAlign::Top));
1581
1582 let emitted = crate::emitter::emit_document(&graph);
1584 assert!(emitted.contains("align: left top"));
1585 let reparsed = parse_document(&emitted).unwrap();
1586 let style2 = reparsed
1587 .styles
1588 .get(&crate::id::NodeId::intern("heading_style"))
1589 .expect("style not found after roundtrip");
1590 assert_eq!(style2.text_align, Some(crate::model::TextAlign::Left));
1591 assert_eq!(style2.text_valign, Some(crate::model::TextVAlign::Top));
1592 }
1593
1594 #[test]
1595 fn parse_font_weight_names() {
1596 let src = r#"
1597text @heading "Hello" {
1598 font: "Inter" bold 24
1599}
1600"#;
1601 let graph = parse_document(src).unwrap();
1602 let node = graph
1603 .get_by_id(crate::id::NodeId::intern("heading"))
1604 .unwrap();
1605 let font = node.style.font.as_ref().unwrap();
1606 assert_eq!(font.weight, 700);
1607 assert_eq!(font.size, 24.0);
1608 }
1609
1610 #[test]
1611 fn parse_font_weight_semibold() {
1612 let src = r#"text @t "Hi" { font: "Inter" semibold 16 }"#;
1613 let graph = parse_document(src).unwrap();
1614 let font = graph
1615 .get_by_id(crate::id::NodeId::intern("t"))
1616 .unwrap()
1617 .style
1618 .font
1619 .as_ref()
1620 .unwrap();
1621 assert_eq!(font.weight, 600);
1622 assert_eq!(font.size, 16.0);
1623 }
1624
1625 #[test]
1626 fn parse_named_color() {
1627 let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1628 let graph = parse_document(src).unwrap();
1629 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1630 assert!(
1631 node.style.fill.is_some(),
1632 "fill should be set from named color"
1633 );
1634 }
1635
1636 #[test]
1637 fn parse_named_color_blue() {
1638 let src = r#"rect @box { w: 50 h: 50 fill: blue }"#;
1639 let graph = parse_document(src).unwrap();
1640 let node = graph.get_by_id(crate::id::NodeId::intern("box")).unwrap();
1641 if let Some(crate::model::Paint::Solid(c)) = &node.style.fill {
1642 assert_eq!(c.to_hex(), "#3B82F6");
1643 } else {
1644 panic!("expected solid fill from named color");
1645 }
1646 }
1647
1648 #[test]
1649 fn parse_property_alias_background() {
1650 let src = r#"rect @r { w: 100 h: 50 background: #FF0000 }"#;
1651 let graph = parse_document(src).unwrap();
1652 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1653 assert!(node.style.fill.is_some(), "background: should map to fill");
1654 }
1655
1656 #[test]
1657 fn parse_property_alias_rounded() {
1658 let src = r#"rect @r { w: 100 h: 50 rounded: 12 }"#;
1659 let graph = parse_document(src).unwrap();
1660 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1661 assert_eq!(node.style.corner_radius, Some(12.0));
1662 }
1663
1664 #[test]
1665 fn parse_property_alias_radius() {
1666 let src = r#"rect @r { w: 100 h: 50 radius: 8 }"#;
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(8.0));
1670 }
1671
1672 #[test]
1673 fn parse_dimension_px_suffix() {
1674 let src = r#"rect @r { w: 320px h: 200px }"#;
1675 let graph = parse_document(src).unwrap();
1676 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1677 if let crate::model::NodeKind::Rect { width, height } = &node.kind {
1678 assert_eq!(*width, 320.0);
1679 assert_eq!(*height, 200.0);
1680 } else {
1681 panic!("expected rect");
1682 }
1683 }
1684
1685 #[test]
1686 fn parse_corner_px_suffix() {
1687 let src = r#"rect @r { w: 100 h: 50 corner: 10px }"#;
1688 let graph = parse_document(src).unwrap();
1689 let node = graph.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1690 assert_eq!(node.style.corner_radius, Some(10.0));
1691 }
1692
1693 #[test]
1694 fn roundtrip_font_weight_name() {
1695 let src = r#"text @t "Hello" { font: "Inter" bold 18 }"#;
1696 let graph = parse_document(src).unwrap();
1697 let emitted = crate::emitter::emit_document(&graph);
1698 assert!(
1699 emitted.contains("bold"),
1700 "emitted output should use 'bold' not '700'"
1701 );
1702 let reparsed = parse_document(&emitted).unwrap();
1703 let font = reparsed
1704 .get_by_id(crate::id::NodeId::intern("t"))
1705 .unwrap()
1706 .style
1707 .font
1708 .as_ref()
1709 .unwrap();
1710 assert_eq!(font.weight, 700);
1711 }
1712
1713 #[test]
1714 fn roundtrip_named_color() {
1715 let src = r#"rect @r { w: 100 h: 50 fill: purple }"#;
1716 let graph = parse_document(src).unwrap();
1717 let emitted = crate::emitter::emit_document(&graph);
1718 assert!(emitted.contains("#8B5CF6"), "purple should emit as #8B5CF6");
1720 let reparsed = parse_document(&emitted).unwrap();
1721 assert!(
1722 reparsed
1723 .get_by_id(crate::id::NodeId::intern("r"))
1724 .unwrap()
1725 .style
1726 .fill
1727 .is_some()
1728 );
1729 }
1730
1731 #[test]
1732 fn roundtrip_property_aliases() {
1733 let src = r#"rect @r { w: 200 h: 100 background: #FF0000 rounded: 12 }"#;
1734 let graph = parse_document(src).unwrap();
1735 let emitted = crate::emitter::emit_document(&graph);
1736 assert!(
1738 emitted.contains("fill:"),
1739 "background: should emit as fill:"
1740 );
1741 assert!(
1742 emitted.contains("corner:"),
1743 "rounded: should emit as corner:"
1744 );
1745 let reparsed = parse_document(&emitted).unwrap();
1746 let node = reparsed.get_by_id(crate::id::NodeId::intern("r")).unwrap();
1747 assert!(node.style.fill.is_some());
1748 assert_eq!(node.style.corner_radius, Some(12.0));
1749 }
1750}