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