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}: style/theme error — expected `style 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_defaults ") || rest.starts_with("edge_defaults{") {
72 let defaults = parse_edge_defaults_block
73 .parse_next(&mut rest)
74 .map_err(|e| format!("line {line}: edge_defaults error — expected `edge_defaults {{ props }}`, got `{ctx}…`: {e}"))?;
75 graph.edge_defaults = Some(defaults);
76 pending_comments.clear();
77 } else if rest.starts_with("edge ") {
78 let (edge, text_child_data) = parse_edge_block
79 .parse_next(&mut rest)
80 .map_err(|e| format!("line {line}: edge error — expected `edge @id {{ from: @a to: @b }}`, got `{ctx}…`: {e}"))?;
81 if let Some((text_id, content)) = text_child_data {
83 let text_node = crate::model::SceneNode {
84 id: text_id,
85 kind: crate::model::NodeKind::Text {
86 content,
87 max_width: None,
88 },
89 props: crate::model::Properties::default(),
90 use_styles: Default::default(),
91 constraints: Default::default(),
92 annotations: Vec::new(),
93 animations: Default::default(),
94 comments: Vec::new(),
95 place: None,
96 };
97 let idx = graph.graph.add_node(text_node);
98 graph.graph.add_edge(graph.root, idx, ());
99 graph.id_index.insert(text_id, idx);
100 }
101 graph.edges.push(edge);
102 pending_comments.clear();
103 } else if starts_with_node_keyword(rest) {
104 let mut node_data = parse_node.parse_next(&mut rest).map_err(|e| {
105 format!(
106 "line {line}: node error — expected `kind @id {{ ... }}`, got `{ctx}…`: {e}"
107 )
108 })?;
109 node_data.comments = std::mem::take(&mut pending_comments);
110 let root = graph.root;
111 insert_node_recursive(&mut graph, root, node_data);
112 } else {
113 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(&mut rest);
115 if rest.starts_with('\n') {
116 rest = &rest[1..];
117 }
118 pending_comments.clear();
119 }
120
121 let more = collect_leading_comments(&mut rest);
124 pending_comments.extend(more);
125 }
126
127 Ok(graph)
128}
129
130fn line_number(full_input: &str, remaining: &str) -> usize {
132 let consumed = full_input.len() - remaining.len();
133 full_input[..consumed].matches('\n').count() + 1
134}
135
136fn starts_with_node_keyword(s: &str) -> bool {
137 s.starts_with("group")
138 || s.starts_with("frame")
139 || s.starts_with("rect")
140 || s.starts_with("ellipse")
141 || s.starts_with("path")
142 || s.starts_with("image")
143 || s.starts_with("text")
144}
145
146fn is_generic_node_start(s: &str) -> bool {
149 let rest = match s.strip_prefix('@') {
150 Some(r) => r,
151 None => return false,
152 };
153 let after_id = rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_');
154 if after_id.len() == rest.len() {
156 return false;
157 }
158 after_id.trim_start().starts_with('{')
159}
160
161#[derive(Debug)]
163struct ParsedNode {
164 id: NodeId,
165 kind: NodeKind,
166 props: Properties,
167 use_styles: Vec<NodeId>,
168 constraints: Vec<Constraint>,
169 animations: Vec<AnimKeyframe>,
170 annotations: Vec<Annotation>,
171 comments: Vec<String>,
173 children: Vec<ParsedNode>,
174 place: Option<(HPlace, VPlace)>,
176}
177
178fn insert_node_recursive(
179 graph: &mut SceneGraph,
180 parent: petgraph::graph::NodeIndex,
181 parsed: ParsedNode,
182) {
183 let mut node = SceneNode::new(parsed.id, parsed.kind);
184 node.props = parsed.props;
185 node.use_styles.extend(parsed.use_styles);
186 node.constraints.extend(parsed.constraints);
187 node.animations.extend(parsed.animations);
188 node.annotations = parsed.annotations;
189 node.comments = parsed.comments;
190 node.place = parsed.place;
191
192 let idx = graph.add_node(parent, node);
193
194 for child in parsed.children {
195 insert_node_recursive(graph, idx, child);
196 }
197}
198
199fn parse_import_line(input: &mut &str) -> ModalResult<Import> {
203 let _ = "import".parse_next(input)?;
204 let _ = space1.parse_next(input)?;
205 let path = parse_quoted_string
206 .map(|s| s.to_string())
207 .parse_next(input)?;
208 let _ = space1.parse_next(input)?;
209 let _ = "as".parse_next(input)?;
210 let _ = space1.parse_next(input)?;
211 let namespace = parse_identifier.map(|s| s.to_string()).parse_next(input)?;
212 skip_opt_separator(input);
213 Ok(Import { path, namespace })
214}
215
216const SECTION_SEPARATORS: &[&str] = &[
221 "─── Styles ───",
222 "─── Themes ───",
223 "─── Layout ───",
224 "─── Constraints ───",
225 "─── Flows ───",
226];
227
228fn is_section_separator(text: &str) -> bool {
230 SECTION_SEPARATORS.iter().any(|sep| text.contains(sep))
231}
232
233fn collect_leading_comments(input: &mut &str) -> Vec<String> {
236 let mut comments = Vec::new();
237 loop {
238 let before = *input;
240 *input = input.trim_start();
241 if input.starts_with('#') {
242 let end = input.find('\n').unwrap_or(input.len());
244 let text = input[1..end].trim().to_string();
245 *input = &input[end.min(input.len())..];
246 if input.starts_with('\n') {
247 *input = &input[1..];
248 }
249 if !text.is_empty() && !is_section_separator(&text) && !text.starts_with("[auto]") {
251 comments.push(text);
252 }
253 continue;
254 }
255 if *input == before {
256 break;
257 }
258 }
259 comments
260}
261
262fn skip_ws_and_comments(input: &mut &str) {
265 let _ = collect_leading_comments(input);
266}
267
268fn skip_space(input: &mut &str) {
270 use winnow::ascii::space0;
271 let _: Result<&str, winnow::error::ErrMode<ContextError>> = space0.parse_next(input);
272}
273
274fn parse_identifier<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
275 take_while(1.., |c: char| c.is_alphanumeric() || c == '_').parse_next(input)
276}
277
278fn parse_node_id(input: &mut &str) -> ModalResult<NodeId> {
279 preceded('@', parse_identifier)
280 .map(NodeId::intern)
281 .parse_next(input)
282}
283
284fn parse_hex_color(input: &mut &str) -> ModalResult<Color> {
285 let _ = '#'.parse_next(input)?;
286 let hex_digits: &str = take_while(1..=8, |c: char| c.is_ascii_hexdigit()).parse_next(input)?;
287 Color::from_hex(hex_digits)
288 .ok_or_else(|| winnow::error::ErrMode::Backtrack(ContextError::new()))
289}
290
291fn parse_number(input: &mut &str) -> ModalResult<f32> {
292 let start = *input;
293 if input.starts_with('-') {
294 *input = &input[1..];
295 }
296 let _ = take_while(1.., |c: char| c.is_ascii_digit()).parse_next(input)?;
297 if input.starts_with('.') {
298 *input = &input[1..];
299 let _ =
300 take_while::<_, _, ContextError>(0.., |c: char| c.is_ascii_digit()).parse_next(input);
301 }
302 let matched = &start[..start.len() - input.len()];
303 matched
304 .parse::<f32>()
305 .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
306}
307
308fn parse_quoted_string<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
309 delimited('"', take_till(0.., '"'), '"').parse_next(input)
310}
311
312fn skip_opt_separator(input: &mut &str) {
313 if input.starts_with(';') || input.starts_with('\n') {
314 *input = &input[1..];
315 }
316}
317
318fn skip_px_suffix(input: &mut &str) {
320 if input.starts_with("px") {
321 *input = &input[2..];
322 }
323}
324
325fn parse_spec_block(input: &mut &str) -> ModalResult<Vec<Annotation>> {
329 let _ = "spec".parse_next(input)?;
330 skip_space(input);
331
332 if input.starts_with('"') {
334 let desc = parse_quoted_string
335 .map(|s| s.to_string())
336 .parse_next(input)?;
337 skip_opt_separator(input);
338 return Ok(vec![Annotation::Description(desc)]);
339 }
340
341 let _ = '{'.parse_next(input)?;
343 let mut annotations = Vec::new();
344 skip_ws_and_comments(input);
345
346 while !input.starts_with('}') {
347 annotations.push(parse_spec_item.parse_next(input)?);
348 skip_ws_and_comments(input);
349 }
350
351 let _ = '}'.parse_next(input)?;
352 Ok(annotations)
353}
354
355fn parse_spec_item(input: &mut &str) -> ModalResult<Annotation> {
364 if input.starts_with('"') {
366 let desc = parse_quoted_string
367 .map(|s| s.to_string())
368 .parse_next(input)?;
369 skip_opt_separator(input);
370 return Ok(Annotation::Description(desc));
371 }
372
373 let keyword = parse_identifier.parse_next(input)?;
375 skip_space(input);
376 let _ = ':'.parse_next(input)?;
377 skip_space(input);
378
379 let value = if input.starts_with('"') {
380 parse_quoted_string
381 .map(|s| s.to_string())
382 .parse_next(input)?
383 } else {
384 let v: &str =
385 take_till(0.., |c: char| c == '\n' || c == ';' || c == '}').parse_next(input)?;
386 v.trim().to_string()
387 };
388
389 let ann = match keyword {
390 "accept" => Annotation::Accept(value),
391 "status" => Annotation::Status(value),
392 "priority" => Annotation::Priority(value),
393 "tag" => Annotation::Tag(value),
394 _ => Annotation::Description(format!("{keyword}: {value}")),
395 };
396
397 skip_opt_separator(input);
398 Ok(ann)
399}
400
401fn parse_style_block(input: &mut &str) -> ModalResult<(NodeId, Properties)> {
404 let _ = alt(("theme", "style")).parse_next(input)?;
405 let _ = space1.parse_next(input)?;
406 let name = parse_identifier.map(NodeId::intern).parse_next(input)?;
407 skip_space(input);
408 let _ = '{'.parse_next(input)?;
409
410 let mut style = Properties::default();
411 skip_ws_and_comments(input);
412
413 while !input.starts_with('}') {
414 parse_style_property(input, &mut style)?;
415 skip_ws_and_comments(input);
416 }
417
418 let _ = '}'.parse_next(input)?;
419 Ok((name, style))
420}
421
422fn parse_style_property(input: &mut &str, style: &mut Properties) -> ModalResult<()> {
423 let prop_name = parse_identifier.parse_next(input)?;
424 skip_space(input);
425 let _ = ':'.parse_next(input)?;
426 skip_space(input);
427
428 match prop_name {
429 "fill" | "background" | "color" => {
430 style.fill = Some(parse_paint(input)?);
431 }
432 "font" => {
433 parse_font_value(input, style)?;
434 }
435 "corner" | "rounded" | "radius" => {
436 style.corner_radius = Some(parse_number.parse_next(input)?);
437 skip_px_suffix(input);
438 }
439 "opacity" => {
440 style.opacity = Some(parse_number.parse_next(input)?);
441 }
442 "align" | "text_align" => {
443 parse_align_value(input, style)?;
444 }
445 _ => {
446 let _ =
447 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
448 .parse_next(input);
449 }
450 }
451
452 skip_opt_separator(input);
453 Ok(())
454}
455
456fn weight_name_to_number(name: &str) -> Option<u16> {
458 match name {
459 "thin" => Some(100),
460 "extralight" | "extra_light" => Some(200),
461 "light" => Some(300),
462 "regular" | "normal" => Some(400),
463 "medium" => Some(500),
464 "semibold" | "semi_bold" => Some(600),
465 "bold" => Some(700),
466 "extrabold" | "extra_bold" => Some(800),
467 "black" | "heavy" => Some(900),
468 _ => None,
469 }
470}
471
472fn parse_font_value(input: &mut &str, style: &mut Properties) -> ModalResult<()> {
473 let mut font = style.font.clone().unwrap_or_default();
474
475 if input.starts_with('"') {
476 let family = parse_quoted_string.parse_next(input)?;
477 font.family = family.to_string();
478 skip_space(input);
479 }
480
481 let saved = *input;
483 if let Ok(name) = parse_identifier.parse_next(input) {
484 if let Some(w) = weight_name_to_number(name) {
485 font.weight = w;
486 skip_space(input);
487 if let Ok(size) = parse_number.parse_next(input) {
488 font.size = size;
489 skip_px_suffix(input);
490 }
491 } else {
492 *input = saved; }
494 }
495
496 if *input == saved
498 && let Ok(n1) = parse_number.parse_next(input)
499 {
500 skip_space(input);
501 if let Ok(n2) = parse_number.parse_next(input) {
502 font.weight = n1 as u16;
503 font.size = n2;
504 skip_px_suffix(input);
505 } else {
506 font.size = n1;
507 skip_px_suffix(input);
508 }
509 }
510
511 style.font = Some(font);
512 Ok(())
513}
514
515fn parse_node(input: &mut &str) -> ModalResult<ParsedNode> {
518 let kind_str = if input.starts_with('@') {
520 "generic"
521 } else {
522 alt((
523 "group".value("group"),
524 "frame".value("frame"),
525 "rect".value("rect"),
526 "ellipse".value("ellipse"),
527 "path".value("path"),
528 "image".value("image"),
529 "text".value("text"),
530 ))
531 .parse_next(input)?
532 };
533
534 skip_space(input);
535
536 let id = if input.starts_with('@') {
537 parse_node_id.parse_next(input)?
538 } else {
539 NodeId::anonymous(kind_str)
540 };
541
542 skip_space(input);
543
544 let inline_text = if kind_str == "text" && input.starts_with('"') {
545 Some(
546 parse_quoted_string
547 .map(|s| s.to_string())
548 .parse_next(input)?,
549 )
550 } else {
551 None
552 };
553
554 skip_space(input);
555 let _ = '{'.parse_next(input)?;
556
557 let mut style = Properties::default();
558 let mut use_styles = Vec::new();
559 let mut constraints = Vec::new();
560 let mut animations = Vec::new();
561 let mut annotations = Vec::new();
562 let mut children = Vec::new();
563 let mut width: Option<f32> = None;
564 let mut height: Option<f32> = None;
565 let mut layout = LayoutMode::Free { pad: 0.0 };
566 let mut clip = false;
567 let mut place: Option<(HPlace, VPlace)> = None;
568 let mut path_commands: Vec<PathCmd> = Vec::new();
569 let mut image_src: Option<String> = None;
570 let mut image_fit = ImageFit::default();
571
572 skip_ws_and_comments(input);
573
574 while !input.starts_with('}') {
575 if input.starts_with("spec ") || input.starts_with("spec{") {
576 annotations.extend(parse_spec_block.parse_next(input)?);
577 } else if starts_with_child_node(input) {
578 let mut child = parse_node.parse_next(input)?;
579 child.comments = Vec::new(); children.push(child);
583 } else if input.starts_with("when") || input.starts_with("anim") {
584 animations.push(parse_anim_block.parse_next(input)?);
585 } else {
586 parse_node_property(
587 input,
588 &mut style,
589 &mut use_styles,
590 &mut constraints,
591 &mut width,
592 &mut height,
593 &mut layout,
594 &mut clip,
595 &mut place,
596 &mut path_commands,
597 &mut image_src,
598 &mut image_fit,
599 )?;
600 }
601 let _inner_comments = collect_leading_comments(input);
603 }
604
605 let _ = '}'.parse_next(input)?;
606
607 let kind = match kind_str {
608 "group" => NodeKind::Group, "frame" => NodeKind::Frame {
610 width: width.unwrap_or(200.0),
611 height: height.unwrap_or(200.0),
612 clip,
613 layout,
614 },
615 "rect" => NodeKind::Rect {
616 width: width.unwrap_or(100.0),
617 height: height.unwrap_or(100.0),
618 },
619 "ellipse" => NodeKind::Ellipse {
620 rx: width.unwrap_or(50.0),
621 ry: height.unwrap_or(50.0),
622 },
623 "text" => NodeKind::Text {
624 content: inline_text.unwrap_or_default(),
625 max_width: width,
626 },
627 "path" => NodeKind::Path {
628 commands: path_commands,
629 },
630 "image" => NodeKind::Image {
631 source: ImageSource::File(image_src.unwrap_or_default()),
632 width: width.unwrap_or(100.0),
633 height: height.unwrap_or(100.0),
634 fit: image_fit,
635 },
636 "generic" => NodeKind::Generic,
637 _ => unreachable!(),
638 };
639
640 Ok(ParsedNode {
641 id,
642 kind,
643 props: style,
644 use_styles,
645 constraints,
646 animations,
647 annotations,
648 comments: Vec::new(),
649 children,
650 place,
651 })
652}
653
654fn starts_with_child_node(input: &str) -> bool {
657 if is_generic_node_start(input) {
659 return true;
660 }
661 let keywords = &[
662 ("group", 5),
663 ("frame", 5),
664 ("rect", 4),
665 ("ellipse", 7),
666 ("path", 4),
667 ("image", 5),
668 ("text", 4),
669 ];
670 for &(keyword, len) in keywords {
671 if input.starts_with(keyword) {
672 if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
673 continue; }
675 if let Some(after) = input.get(len..)
676 && after.starts_with(|c: char| {
677 c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
678 })
679 {
680 return true;
681 }
682 }
683 }
684 false
685}
686
687fn named_color_to_hex(name: &str) -> Option<Color> {
689 match name {
690 "red" => Color::from_hex("#EF4444"),
691 "orange" => Color::from_hex("#F97316"),
692 "amber" | "yellow" => Color::from_hex("#F59E0B"),
693 "lime" => Color::from_hex("#84CC16"),
694 "green" => Color::from_hex("#22C55E"),
695 "teal" => Color::from_hex("#14B8A6"),
696 "cyan" => Color::from_hex("#06B6D4"),
697 "blue" => Color::from_hex("#3B82F6"),
698 "indigo" => Color::from_hex("#6366F1"),
699 "purple" | "violet" => Color::from_hex("#8B5CF6"),
700 "pink" => Color::from_hex("#EC4899"),
701 "rose" => Color::from_hex("#F43F5E"),
702 "white" => Color::from_hex("#FFFFFF"),
703 "black" => Color::from_hex("#000000"),
704 "gray" | "grey" => Color::from_hex("#6B7280"),
705 "slate" => Color::from_hex("#64748B"),
706 _ => None,
707 }
708}
709
710fn parse_paint(input: &mut &str) -> ModalResult<Paint> {
712 if input.starts_with("linear(") {
713 let _ = "linear(".parse_next(input)?;
714 let angle = parse_number.parse_next(input)?;
715 let _ = "deg".parse_next(input)?;
716 let stops = parse_gradient_stops(input)?;
717 let _ = ')'.parse_next(input)?;
718 Ok(Paint::LinearGradient { angle, stops })
719 } else if input.starts_with("radial(") {
720 let _ = "radial(".parse_next(input)?;
721 let stops = parse_gradient_stops(input)?;
722 let _ = ')'.parse_next(input)?;
723 Ok(Paint::RadialGradient { stops })
724 } else if input.starts_with('#') {
725 parse_hex_color.map(Paint::Solid).parse_next(input)
726 } else {
727 let saved = *input;
729 if let Ok(name) = parse_identifier.parse_next(input) {
730 if let Some(color) = named_color_to_hex(name) {
731 return Ok(Paint::Solid(color));
732 }
733 *input = saved;
734 }
735 parse_hex_color.map(Paint::Solid).parse_next(input)
736 }
737}
738
739fn parse_gradient_stops(input: &mut &str) -> ModalResult<Vec<GradientStop>> {
744 let mut stops = Vec::new();
745 loop {
746 skip_space(input);
747 if input.starts_with(',') {
749 let _ = ','.parse_next(input)?;
750 skip_space(input);
751 }
752 if input.is_empty() || input.starts_with(')') {
754 break;
755 }
756 let Ok(color) = parse_hex_color.parse_next(input) else {
758 break;
759 };
760 skip_space(input);
761 let offset = parse_number.parse_next(input)?;
762 stops.push(GradientStop { color, offset });
763 }
764 Ok(stops)
765}
766
767#[allow(clippy::too_many_arguments)]
768fn parse_node_property(
769 input: &mut &str,
770 style: &mut Properties,
771 use_styles: &mut Vec<NodeId>,
772 constraints: &mut Vec<Constraint>,
773 width: &mut Option<f32>,
774 height: &mut Option<f32>,
775 layout: &mut LayoutMode,
776 clip: &mut bool,
777 place: &mut Option<(HPlace, VPlace)>,
778 path_commands: &mut Vec<PathCmd>,
779 image_src: &mut Option<String>,
780 image_fit: &mut ImageFit,
781) -> ModalResult<()> {
782 let prop_name = parse_identifier.parse_next(input)?;
783 skip_space(input);
784 let _ = ':'.parse_next(input)?;
785 skip_space(input);
786
787 match prop_name {
788 "x" => {
789 let x_val = parse_number.parse_next(input)?;
790 if let Some(Constraint::Position { x, .. }) = constraints
792 .iter_mut()
793 .find(|c| matches!(c, Constraint::Position { .. }))
794 {
795 *x = x_val;
796 } else {
797 constraints.push(Constraint::Position { x: x_val, y: 0.0 });
798 }
799 }
800 "y" => {
801 let y_val = parse_number.parse_next(input)?;
802 if let Some(Constraint::Position { y, .. }) = constraints
803 .iter_mut()
804 .find(|c| matches!(c, Constraint::Position { .. }))
805 {
806 *y = y_val;
807 } else {
808 constraints.push(Constraint::Position { x: 0.0, y: y_val });
809 }
810 }
811 "w" | "width" => {
812 *width = Some(parse_number.parse_next(input)?);
813 skip_px_suffix(input);
814 skip_space(input);
815 if input.starts_with("h:") || input.starts_with("h :") {
816 let _ = "h".parse_next(input)?;
817 skip_space(input);
818 let _ = ':'.parse_next(input)?;
819 skip_space(input);
820 *height = Some(parse_number.parse_next(input)?);
821 skip_px_suffix(input);
822 }
823 }
824 "h" | "height" => {
825 *height = Some(parse_number.parse_next(input)?);
826 skip_px_suffix(input);
827 }
828 "fill" | "background" | "color" => {
829 style.fill = Some(parse_paint(input)?);
830 }
831 "bg" => {
832 style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
833 loop {
834 skip_space(input);
835 if input.starts_with("corner=") {
836 let _ = "corner=".parse_next(input)?;
837 style.corner_radius = Some(parse_number.parse_next(input)?);
838 } else if input.starts_with("shadow=(") {
839 let _ = "shadow=(".parse_next(input)?;
840 let ox = parse_number.parse_next(input)?;
841 let _ = ','.parse_next(input)?;
842 let oy = parse_number.parse_next(input)?;
843 let _ = ','.parse_next(input)?;
844 let blur = parse_number.parse_next(input)?;
845 let _ = ','.parse_next(input)?;
846 let color = parse_hex_color.parse_next(input)?;
847 let _ = ')'.parse_next(input)?;
848 style.shadow = Some(Shadow {
849 offset_x: ox,
850 offset_y: oy,
851 blur,
852 color,
853 });
854 } else {
855 break;
856 }
857 }
858 }
859 "stroke" | "border" => {
860 let color = parse_hex_color.parse_next(input)?;
861 let _ = space1.parse_next(input)?;
862 let w = parse_number.parse_next(input)?;
863 style.stroke = Some(Stroke {
864 paint: Paint::Solid(color),
865 width: w,
866 ..Stroke::default()
867 });
868 }
869 "corner" | "rounded" | "radius" => {
870 style.corner_radius = Some(parse_number.parse_next(input)?);
871 skip_px_suffix(input);
872 }
873 "opacity" => {
874 style.opacity = Some(parse_number.parse_next(input)?);
875 }
876 "align" | "text_align" => {
877 parse_align_value(input, style)?;
878 }
879 "place" => {
880 *place = Some(parse_place_value(input)?);
881 }
882 "shadow" => {
883 skip_space(input);
885 if input.starts_with('(') {
886 let _ = '('.parse_next(input)?;
887 let ox = parse_number.parse_next(input)?;
888 let _ = ','.parse_next(input)?;
889 let oy = parse_number.parse_next(input)?;
890 let _ = ','.parse_next(input)?;
891 let blur = parse_number.parse_next(input)?;
892 let _ = ','.parse_next(input)?;
893 let color = parse_hex_color.parse_next(input)?;
894 let _ = ')'.parse_next(input)?;
895 style.shadow = Some(Shadow {
896 offset_x: ox,
897 offset_y: oy,
898 blur,
899 color,
900 });
901 }
902 }
903 "label" => {
904 if input.starts_with('"') {
907 let _ = parse_quoted_string.parse_next(input)?;
908 } else {
909 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
910 c == '\n' || c == ';' || c == '}'
911 })
912 .parse_next(input);
913 }
914 }
915 "use" | "apply" => {
916 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
917 }
918 "font" => {
919 parse_font_value(input, style)?;
920 }
921 "layout" => {
922 let mode_str = parse_identifier.parse_next(input)?;
923 skip_space(input);
924 let mut gap = 0.0f32;
925 let mut pad = 0.0f32;
926 loop {
927 skip_space(input);
928 if input.starts_with("gap=") {
929 let _ = "gap=".parse_next(input)?;
930 gap = parse_number.parse_next(input)?;
931 } else if input.starts_with("pad=") {
932 let _ = "pad=".parse_next(input)?;
933 pad = parse_number.parse_next(input)?;
934 } else if input.starts_with("cols=") {
935 let _ = "cols=".parse_next(input)?;
936 let _ = parse_number.parse_next(input)?;
937 } else {
938 break;
939 }
940 }
941 *layout = match mode_str {
942 "column" => LayoutMode::Column { gap, pad },
943 "row" => LayoutMode::Row { gap, pad },
944 "grid" => LayoutMode::Grid { cols: 2, gap, pad },
945 _ => LayoutMode::Free { pad: 0.0 },
946 };
947 }
948 "clip" => {
949 let val = parse_identifier.parse_next(input)?;
950 *clip = val == "true";
951 }
952 "pad" | "padding" => {
953 let val = parse_number.parse_next(input)?;
955 match layout {
956 LayoutMode::Free { pad } => *pad = val,
957 LayoutMode::Column { pad, .. }
958 | LayoutMode::Row { pad, .. }
959 | LayoutMode::Grid { pad, .. } => *pad = val,
960 }
961 }
962 "d" => {
963 loop {
965 skip_space(input);
966 let at_end = input.is_empty()
967 || input.starts_with('\n')
968 || input.starts_with(';')
969 || input.starts_with('}');
970 if at_end {
971 break;
972 }
973 let saved = *input;
974 if let Ok(cmd_char) = take_while::<_, _, ContextError>(1..=1, |c: char| {
975 matches!(c, 'M' | 'L' | 'Q' | 'C' | 'Z')
976 })
977 .parse_next(input)
978 {
979 skip_space(input);
980 match cmd_char {
981 "M" => {
982 let x = parse_number.parse_next(input)?;
983 skip_space(input);
984 let y = parse_number.parse_next(input)?;
985 path_commands.push(PathCmd::MoveTo(x, y));
986 }
987 "L" => {
988 let x = parse_number.parse_next(input)?;
989 skip_space(input);
990 let y = parse_number.parse_next(input)?;
991 path_commands.push(PathCmd::LineTo(x, y));
992 }
993 "Q" => {
994 let cx = parse_number.parse_next(input)?;
995 skip_space(input);
996 let cy = parse_number.parse_next(input)?;
997 skip_space(input);
998 let ex = parse_number.parse_next(input)?;
999 skip_space(input);
1000 let ey = parse_number.parse_next(input)?;
1001 path_commands.push(PathCmd::QuadTo(cx, cy, ex, ey));
1002 }
1003 "C" => {
1004 let c1x = parse_number.parse_next(input)?;
1005 skip_space(input);
1006 let c1y = parse_number.parse_next(input)?;
1007 skip_space(input);
1008 let c2x = parse_number.parse_next(input)?;
1009 skip_space(input);
1010 let c2y = parse_number.parse_next(input)?;
1011 skip_space(input);
1012 let ex = parse_number.parse_next(input)?;
1013 skip_space(input);
1014 let ey = parse_number.parse_next(input)?;
1015 path_commands.push(PathCmd::CubicTo(c1x, c1y, c2x, c2y, ex, ey));
1016 }
1017 "Z" => {
1018 path_commands.push(PathCmd::Close);
1019 }
1020 _ => {
1021 *input = saved;
1022 break;
1023 }
1024 }
1025 } else {
1026 *input = saved;
1027 break;
1028 }
1029 }
1030 }
1031 "src" => {
1032 *image_src = Some(
1033 parse_quoted_string
1034 .map(|s| s.to_string())
1035 .parse_next(input)?,
1036 );
1037 }
1038 "fit" => {
1039 let val = parse_identifier.parse_next(input)?;
1040 *image_fit = match val {
1041 "cover" => ImageFit::Cover,
1042 "contain" => ImageFit::Contain,
1043 "fill" => ImageFit::Fill,
1044 "none" => ImageFit::None,
1045 _ => ImageFit::Cover,
1046 };
1047 }
1048 _ => {
1049 let _ =
1050 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
1051 .parse_next(input);
1052 }
1053 }
1054
1055 skip_opt_separator(input);
1056 Ok(())
1057}
1058
1059fn parse_align_value(input: &mut &str, style: &mut Properties) -> ModalResult<()> {
1062 use crate::model::{TextAlign, TextVAlign};
1063
1064 let first = parse_identifier.parse_next(input)?;
1065 style.text_align = Some(match first {
1066 "left" => TextAlign::Left,
1067 "right" => TextAlign::Right,
1068 _ => TextAlign::Center, });
1070
1071 skip_space(input);
1073 let at_end = input.is_empty()
1074 || input.starts_with('\n')
1075 || input.starts_with(';')
1076 || input.starts_with('}');
1077 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1078 style.text_valign = Some(match second {
1079 "top" => TextVAlign::Top,
1080 "bottom" => TextVAlign::Bottom,
1081 _ => TextVAlign::Middle,
1082 });
1083 }
1084
1085 Ok(())
1086}
1087
1088fn parse_place_value(input: &mut &str) -> ModalResult<(HPlace, VPlace)> {
1091 use crate::model::{HPlace, VPlace};
1092
1093 let first = parse_identifier.parse_next(input)?;
1094
1095 if input.starts_with('-') {
1097 let saved = *input;
1098 *input = &input[1..]; if let Ok(second) = parse_identifier.parse_next(input) {
1100 match (first, second) {
1101 ("top", "left") => return Ok((HPlace::Left, VPlace::Top)),
1102 ("top", "right") => return Ok((HPlace::Right, VPlace::Top)),
1103 ("bottom", "left") => return Ok((HPlace::Left, VPlace::Bottom)),
1104 ("bottom", "right") => return Ok((HPlace::Right, VPlace::Bottom)),
1105 _ => *input = saved, }
1107 } else {
1108 *input = saved;
1109 }
1110 }
1111
1112 match first {
1113 "center" => {
1114 skip_space(input);
1116 let at_end = input.is_empty()
1117 || input.starts_with('\n')
1118 || input.starts_with(';')
1119 || input.starts_with('}');
1120 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1121 let v = match second {
1122 "top" => VPlace::Top,
1123 "bottom" => VPlace::Bottom,
1124 _ => VPlace::Middle,
1125 };
1126 return Ok((HPlace::Center, v));
1127 }
1128 Ok((HPlace::Center, VPlace::Middle))
1129 }
1130 "top" => Ok((HPlace::Center, VPlace::Top)),
1131 "bottom" => Ok((HPlace::Center, VPlace::Bottom)),
1132 _ => {
1133 let h = match first {
1135 "left" => HPlace::Left,
1136 "right" => HPlace::Right,
1137 _ => HPlace::Center,
1138 };
1139
1140 skip_space(input);
1141 let at_end = input.is_empty()
1142 || input.starts_with('\n')
1143 || input.starts_with(';')
1144 || input.starts_with('}');
1145 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1146 let v = match second {
1147 "top" => VPlace::Top,
1148 "bottom" => VPlace::Bottom,
1149 _ => VPlace::Middle,
1150 };
1151 return Ok((h, v));
1152 }
1153
1154 Ok((h, VPlace::Middle))
1155 }
1156 }
1157}
1158
1159fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
1162 let _ = alt(("when", "anim")).parse_next(input)?;
1163 let _ = space1.parse_next(input)?;
1164 let _ = ':'.parse_next(input)?;
1165 let trigger_str = parse_identifier.parse_next(input)?;
1166 let trigger = match trigger_str {
1167 "hover" => AnimTrigger::Hover,
1168 "press" => AnimTrigger::Press,
1169 "enter" => AnimTrigger::Enter,
1170 other => AnimTrigger::Custom(other.to_string()),
1171 };
1172
1173 skip_space(input);
1174 let _ = '{'.parse_next(input)?;
1175
1176 let mut props = AnimProperties::default();
1177 let mut duration_ms = 300u32;
1178 let mut easing = Easing::EaseInOut;
1179
1180 skip_ws_and_comments(input);
1181
1182 while !input.starts_with('}') {
1183 let prop = parse_identifier.parse_next(input)?;
1184 skip_space(input);
1185 let _ = ':'.parse_next(input)?;
1186 skip_space(input);
1187
1188 match prop {
1189 "fill" => {
1190 props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
1191 }
1192 "opacity" => {
1193 props.opacity = Some(parse_number.parse_next(input)?);
1194 }
1195 "scale" => {
1196 props.scale = Some(parse_number.parse_next(input)?);
1197 }
1198 "rotate" => {
1199 props.rotate = Some(parse_number.parse_next(input)?);
1200 }
1201 "ease" => {
1202 let ease_name = parse_identifier.parse_next(input)?;
1203 easing = match ease_name {
1204 "linear" => Easing::Linear,
1205 "ease_in" | "easeIn" => Easing::EaseIn,
1206 "ease_out" | "easeOut" => Easing::EaseOut,
1207 "ease_in_out" | "easeInOut" => Easing::EaseInOut,
1208 "spring" => Easing::Spring,
1209 _ => Easing::EaseInOut,
1210 };
1211 skip_space(input);
1212 if let Ok(n) = parse_number.parse_next(input) {
1213 duration_ms = n as u32;
1214 if input.starts_with("ms") {
1215 *input = &input[2..];
1216 }
1217 }
1218 }
1219 _ => {
1220 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1221 c == '\n' || c == ';' || c == '}'
1222 })
1223 .parse_next(input);
1224 }
1225 }
1226
1227 skip_opt_separator(input);
1228 skip_ws_and_comments(input);
1229 }
1230
1231 let _ = '}'.parse_next(input)?;
1232
1233 Ok(AnimKeyframe {
1234 trigger,
1235 duration_ms,
1236 easing,
1237 properties: props,
1238 })
1239}
1240
1241fn parse_edge_anchor(input: &mut &str) -> ModalResult<EdgeAnchor> {
1245 skip_space(input);
1246 if input.starts_with('@') {
1247 Ok(EdgeAnchor::Node(parse_node_id.parse_next(input)?))
1248 } else {
1249 let x = parse_number.parse_next(input)?;
1250 skip_space(input);
1251 let y = parse_number.parse_next(input)?;
1252 Ok(EdgeAnchor::Point(x, y))
1253 }
1254}
1255
1256fn parse_edge_defaults_block(input: &mut &str) -> ModalResult<EdgeDefaults> {
1259 let _ = "edge_defaults".parse_next(input)?;
1260 skip_space(input);
1261 let _ = '{'.parse_next(input)?;
1262
1263 let mut defaults = EdgeDefaults::default();
1264 skip_ws_and_comments(input);
1265
1266 while !input.starts_with('}') {
1267 let prop = parse_identifier.parse_next(input)?;
1268 skip_space(input);
1269 let _ = ':'.parse_next(input)?;
1270 skip_space(input);
1271
1272 match prop {
1273 "stroke" => {
1274 let color = parse_hex_color.parse_next(input)?;
1275 skip_space(input);
1276 let w = parse_number.parse_next(input).unwrap_or(1.0);
1277 defaults.props.stroke = Some(Stroke {
1278 paint: Paint::Solid(color),
1279 width: w,
1280 ..Stroke::default()
1281 });
1282 }
1283 "arrow" => {
1284 let kind = parse_identifier.parse_next(input)?;
1285 defaults.arrow = Some(match kind {
1286 "none" => ArrowKind::None,
1287 "start" => ArrowKind::Start,
1288 "end" => ArrowKind::End,
1289 "both" => ArrowKind::Both,
1290 _ => ArrowKind::None,
1291 });
1292 }
1293 "curve" => {
1294 let kind = parse_identifier.parse_next(input)?;
1295 defaults.curve = Some(match kind {
1296 "straight" => CurveKind::Straight,
1297 "smooth" => CurveKind::Smooth,
1298 "step" => CurveKind::Step,
1299 _ => CurveKind::Straight,
1300 });
1301 }
1302 "opacity" => {
1303 defaults.props.opacity = Some(parse_number.parse_next(input)?);
1304 }
1305 _ => {
1306 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1307 c == '\n' || c == ';' || c == '}'
1308 })
1309 .parse_next(input);
1310 }
1311 }
1312 skip_opt_separator(input);
1313 skip_ws_and_comments(input);
1314 }
1315
1316 let _ = '}'.parse_next(input)?;
1317 Ok(defaults)
1318}
1319
1320fn parse_edge_block(input: &mut &str) -> ModalResult<(Edge, Option<(NodeId, String)>)> {
1323 let _ = "edge".parse_next(input)?;
1324 let _ = space1.parse_next(input)?;
1325
1326 let id = if input.starts_with('@') {
1327 parse_node_id.parse_next(input)?
1328 } else {
1329 NodeId::anonymous("edge")
1330 };
1331
1332 skip_space(input);
1333 let _ = '{'.parse_next(input)?;
1334
1335 let mut from = None;
1336 let mut to = None;
1337 let mut text_child = None;
1338 let mut text_child_content = None; let mut style = Properties::default();
1340 let mut use_styles = Vec::new();
1341 let mut arrow = ArrowKind::None;
1342 let mut curve = CurveKind::Straight;
1343 let mut annotations = Vec::new();
1344 let mut animations = Vec::new();
1345 let mut flow = None;
1346 let mut label_offset = None;
1347
1348 skip_ws_and_comments(input);
1349
1350 while !input.starts_with('}') {
1351 if input.starts_with("spec ") || input.starts_with("spec{") {
1352 annotations.extend(parse_spec_block.parse_next(input)?);
1353 } else if input.starts_with("when") || input.starts_with("anim") {
1354 animations.push(parse_anim_block.parse_next(input)?);
1355 } else if input.starts_with("text ") || input.starts_with("text@") {
1356 let node = parse_node.parse_next(input)?;
1358 if let NodeKind::Text { ref content, .. } = node.kind {
1359 text_child = Some(node.id);
1360 text_child_content = Some((node.id, content.clone()));
1361 }
1362 } else {
1363 let prop = parse_identifier.parse_next(input)?;
1364 skip_space(input);
1365 let _ = ':'.parse_next(input)?;
1366 skip_space(input);
1367
1368 match prop {
1369 "from" => {
1370 from = Some(parse_edge_anchor(input)?);
1371 }
1372 "to" => {
1373 to = Some(parse_edge_anchor(input)?);
1374 }
1375 "label" => {
1376 let s = parse_quoted_string
1378 .map(|s| s.to_string())
1379 .parse_next(input)?;
1380 let label_id = NodeId::intern(&format!("_{}_label", id.as_str()));
1381 text_child = Some(label_id);
1382 text_child_content = Some((label_id, s));
1383 }
1384 "stroke" => {
1385 let color = parse_hex_color.parse_next(input)?;
1386 skip_space(input);
1387 let w = parse_number.parse_next(input).unwrap_or(1.0);
1388 style.stroke = Some(Stroke {
1389 paint: Paint::Solid(color),
1390 width: w,
1391 ..Stroke::default()
1392 });
1393 }
1394 "arrow" => {
1395 let kind = parse_identifier.parse_next(input)?;
1396 arrow = match kind {
1397 "none" => ArrowKind::None,
1398 "start" => ArrowKind::Start,
1399 "end" => ArrowKind::End,
1400 "both" => ArrowKind::Both,
1401 _ => ArrowKind::None,
1402 };
1403 }
1404 "curve" => {
1405 let kind = parse_identifier.parse_next(input)?;
1406 curve = match kind {
1407 "straight" => CurveKind::Straight,
1408 "smooth" => CurveKind::Smooth,
1409 "step" => CurveKind::Step,
1410 _ => CurveKind::Straight,
1411 };
1412 }
1413 "use" => {
1414 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1415 }
1416 "opacity" => {
1417 style.opacity = Some(parse_number.parse_next(input)?);
1418 }
1419 "flow" => {
1420 let kind_str = parse_identifier.parse_next(input)?;
1421 let kind = match kind_str {
1422 "pulse" => FlowKind::Pulse,
1423 "dash" => FlowKind::Dash,
1424 _ => FlowKind::Pulse,
1425 };
1426 skip_space(input);
1427 let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1428 if input.starts_with("ms") {
1429 *input = &input[2..];
1430 }
1431 flow = Some(FlowAnim {
1432 kind,
1433 duration_ms: dur,
1434 });
1435 }
1436 "label_offset" => {
1437 let ox = parse_number.parse_next(input)?;
1438 skip_space(input);
1439 let oy = parse_number.parse_next(input)?;
1440 label_offset = Some((ox, oy));
1441 }
1442 _ => {
1443 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1444 c == '\n' || c == ';' || c == '}'
1445 })
1446 .parse_next(input);
1447 }
1448 }
1449
1450 skip_opt_separator(input);
1451 }
1452 skip_ws_and_comments(input);
1453 }
1454
1455 let _ = '}'.parse_next(input)?;
1456
1457 if style.stroke.is_none() {
1459 style.stroke = Some(Stroke {
1460 paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1461 width: 1.5,
1462 ..Stroke::default()
1463 });
1464 }
1465
1466 Ok((
1467 Edge {
1468 id,
1469 from: from.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1470 to: to.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1471 text_child,
1472 props: style,
1473 use_styles: use_styles.into(),
1474 arrow,
1475 curve,
1476 annotations,
1477 animations: animations.into(),
1478 flow,
1479 label_offset,
1480 },
1481 text_child_content,
1482 ))
1483}
1484
1485fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1488 let node_id = parse_node_id.parse_next(input)?;
1489 skip_space(input);
1490 let _ = "->".parse_next(input)?;
1491 skip_space(input);
1492
1493 let constraint_type = parse_identifier.parse_next(input)?;
1494 skip_space(input);
1495 let _ = ':'.parse_next(input)?;
1496 skip_space(input);
1497
1498 let constraint = match constraint_type {
1499 "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1500 "offset" => {
1501 let from = parse_node_id.parse_next(input)?;
1502 let _ = space1.parse_next(input)?;
1503 let dx = parse_number.parse_next(input)?;
1504 skip_space(input);
1505 let _ = ','.parse_next(input)?;
1506 skip_space(input);
1507 let dy = parse_number.parse_next(input)?;
1508 Constraint::Offset { from, dx, dy }
1509 }
1510 "fill_parent" => {
1511 let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1512 Constraint::FillParent { pad }
1513 }
1514 "absolute" | "position" => {
1515 let x = parse_number.parse_next(input)?;
1516 skip_space(input);
1517 let _ = ','.parse_next(input)?;
1518 skip_space(input);
1519 let y = parse_number.parse_next(input)?;
1520 Constraint::Position { x, y }
1521 }
1522 _ => {
1523 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1524 Constraint::Position { x: 0.0, y: 0.0 }
1525 }
1526 };
1527
1528 if input.starts_with('\n') {
1529 *input = &input[1..];
1530 }
1531 Ok((node_id, constraint))
1532}
1533
1534#[cfg(test)]
1535#[path = "parser_tests.rs"]
1536mod tests;