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_spec_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 spec: 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 spec: 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.spec = parsed.spec;
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_spec_block(input: &mut &str) -> ModalResult<String> {
336 let _ = alt(("spec", "note")).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_spec_content(raw);
372 Ok(trimmed)
373}
374
375fn dedent_spec_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 spec: 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_spec_block.parse_next(input)?;
583 spec = Some(match spec {
585 Some(existing) => format!("{existing}\n\n{content}"),
586 None => content,
587 });
588 } else if starts_with_child_node(input) && !matches!(kind_str, "text" | "path") {
589 let mut child = parse_node.parse_next(input)?;
590 child.comments = Vec::new(); children.push(child);
594 } else if starts_with_child_node(input) {
595 let _discarded = parse_node.parse_next(input)?;
598 } else if input.starts_with("when") || input.starts_with("anim") {
599 animations.push(parse_anim_block.parse_next(input)?);
600 } else {
601 parse_node_property(
602 input,
603 &mut style,
604 &mut use_styles,
605 &mut constraints,
606 &mut width,
607 &mut height,
608 &mut layout,
609 &mut clip,
610 &mut place,
611 &mut locked,
612 &mut path_commands,
613 &mut image_src,
614 &mut image_fit,
615 )?;
616 }
617 let _inner_comments = collect_leading_comments(input);
619 }
620
621 let _ = '}'.parse_next(input)?;
622
623 let kind = match kind_str {
624 "group" => NodeKind::Group, "frame" => NodeKind::Frame {
626 width: width.unwrap_or(200.0),
627 height: height.unwrap_or(200.0),
628 clip,
629 layout,
630 },
631 "rect" => NodeKind::Rect {
632 width: width.unwrap_or(100.0),
633 height: height.unwrap_or(100.0),
634 },
635 "ellipse" => NodeKind::Ellipse {
636 rx: width.unwrap_or(50.0),
637 ry: height.unwrap_or(50.0),
638 },
639 "text" => NodeKind::Text {
640 content: inline_text.unwrap_or_default(),
641 max_width: width,
642 },
643 "path" => NodeKind::Path {
644 commands: path_commands,
645 },
646 "image" => NodeKind::Image {
647 source: ImageSource::File(image_src.unwrap_or_default()),
648 width: width.unwrap_or(100.0),
649 height: height.unwrap_or(100.0),
650 fit: image_fit,
651 },
652 "generic" => NodeKind::Generic,
653 _ => unreachable!(),
654 };
655
656 Ok(ParsedNode {
657 id,
658 kind,
659 props: style,
660 use_styles,
661 constraints,
662 animations,
663 spec,
664 comments: Vec::new(),
665 children,
666 place,
667 locked,
668 })
669}
670
671fn starts_with_child_node(input: &str) -> bool {
674 if is_generic_node_start(input) {
676 return true;
677 }
678 let keywords = &[
679 ("group", 5),
680 ("frame", 5),
681 ("rect", 4),
682 ("ellipse", 7),
683 ("path", 4),
684 ("image", 5),
685 ("text", 4),
686 ];
687 for &(keyword, len) in keywords {
688 if input.starts_with(keyword) {
689 if keyword == "text" && input.get(len..).is_some_and(|s| s.starts_with('_')) {
690 continue; }
692 if let Some(after) = input.get(len..)
693 && after.starts_with(|c: char| {
694 c == ' ' || c == '\t' || c == '@' || c == '{' || c == '"'
695 })
696 {
697 return true;
698 }
699 }
700 }
701 false
702}
703
704fn named_color_to_hex(name: &str) -> Option<Color> {
706 match name {
707 "red" => Color::from_hex("#EF4444"),
708 "orange" => Color::from_hex("#F97316"),
709 "amber" | "yellow" => Color::from_hex("#F59E0B"),
710 "lime" => Color::from_hex("#84CC16"),
711 "green" => Color::from_hex("#22C55E"),
712 "teal" => Color::from_hex("#14B8A6"),
713 "cyan" => Color::from_hex("#06B6D4"),
714 "blue" => Color::from_hex("#3B82F6"),
715 "indigo" => Color::from_hex("#6366F1"),
716 "purple" | "violet" => Color::from_hex("#8B5CF6"),
717 "pink" => Color::from_hex("#EC4899"),
718 "rose" => Color::from_hex("#F43F5E"),
719 "white" => Color::from_hex("#FFFFFF"),
720 "black" => Color::from_hex("#000000"),
721 "gray" | "grey" => Color::from_hex("#6B7280"),
722 "slate" => Color::from_hex("#64748B"),
723 _ => None,
724 }
725}
726
727fn parse_paint(input: &mut &str) -> ModalResult<Paint> {
729 if input.starts_with("linear(") {
730 let _ = "linear(".parse_next(input)?;
731 let angle = parse_number.parse_next(input)?;
732 let _ = "deg".parse_next(input)?;
733 let stops = parse_gradient_stops(input)?;
734 let _ = ')'.parse_next(input)?;
735 Ok(Paint::LinearGradient { angle, stops })
736 } else if input.starts_with("radial(") {
737 let _ = "radial(".parse_next(input)?;
738 let stops = parse_gradient_stops(input)?;
739 let _ = ')'.parse_next(input)?;
740 Ok(Paint::RadialGradient { stops })
741 } else if input.starts_with('#') {
742 parse_hex_color.map(Paint::Solid).parse_next(input)
743 } else {
744 let saved = *input;
746 if let Ok(name) = parse_identifier.parse_next(input) {
747 if let Some(color) = named_color_to_hex(name) {
748 return Ok(Paint::Solid(color));
749 }
750 *input = saved;
751 }
752 parse_hex_color.map(Paint::Solid).parse_next(input)
753 }
754}
755
756fn parse_gradient_stops(input: &mut &str) -> ModalResult<Vec<GradientStop>> {
761 let mut stops = Vec::new();
762 loop {
763 skip_space(input);
764 if input.starts_with(',') {
766 let _ = ','.parse_next(input)?;
767 skip_space(input);
768 }
769 if input.is_empty() || input.starts_with(')') {
771 break;
772 }
773 let Ok(color) = parse_hex_color.parse_next(input) else {
775 break;
776 };
777 skip_space(input);
778 let offset = parse_number.parse_next(input)?;
779 stops.push(GradientStop { color, offset });
780 }
781 Ok(stops)
782}
783
784#[allow(clippy::too_many_arguments)]
785fn parse_node_property(
786 input: &mut &str,
787 style: &mut Properties,
788 use_styles: &mut Vec<NodeId>,
789 constraints: &mut Vec<Constraint>,
790 width: &mut Option<f32>,
791 height: &mut Option<f32>,
792 layout: &mut LayoutMode,
793 clip: &mut bool,
794 place: &mut Option<(HPlace, VPlace)>,
795 locked: &mut bool,
796 path_commands: &mut Vec<PathCmd>,
797 image_src: &mut Option<String>,
798 image_fit: &mut ImageFit,
799) -> ModalResult<()> {
800 let prop_name = parse_identifier.parse_next(input)?;
801 skip_space(input);
802 let _ = ':'.parse_next(input)?;
803 skip_space(input);
804
805 match prop_name {
806 "x" => {
807 let x_val = parse_number.parse_next(input)?;
808 if let Some(Constraint::Position { x, .. }) = constraints
810 .iter_mut()
811 .find(|c| matches!(c, Constraint::Position { .. }))
812 {
813 *x = x_val;
814 } else {
815 constraints.push(Constraint::Position { x: x_val, y: 0.0 });
816 }
817 }
818 "y" => {
819 let y_val = parse_number.parse_next(input)?;
820 if let Some(Constraint::Position { y, .. }) = constraints
821 .iter_mut()
822 .find(|c| matches!(c, Constraint::Position { .. }))
823 {
824 *y = y_val;
825 } else {
826 constraints.push(Constraint::Position { x: 0.0, y: y_val });
827 }
828 }
829 "w" | "width" => {
830 *width = Some(parse_number.parse_next(input)?);
831 skip_px_suffix(input);
832 skip_space(input);
833 if input.starts_with("h:") || input.starts_with("h :") {
834 let _ = "h".parse_next(input)?;
835 skip_space(input);
836 let _ = ':'.parse_next(input)?;
837 skip_space(input);
838 *height = Some(parse_number.parse_next(input)?);
839 skip_px_suffix(input);
840 }
841 }
842 "h" | "height" => {
843 *height = Some(parse_number.parse_next(input)?);
844 skip_px_suffix(input);
845 }
846 "fill" | "background" | "color" => {
847 style.fill = Some(parse_paint(input)?);
848 }
849 "bg" => {
850 style.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
851 loop {
852 skip_space(input);
853 if input.starts_with("corner=") {
854 let _ = "corner=".parse_next(input)?;
855 style.corner_radius = Some(parse_number.parse_next(input)?);
856 } else if input.starts_with("shadow=(") {
857 let _ = "shadow=(".parse_next(input)?;
858 let ox = parse_number.parse_next(input)?;
859 let _ = ','.parse_next(input)?;
860 let oy = parse_number.parse_next(input)?;
861 let _ = ','.parse_next(input)?;
862 let blur = parse_number.parse_next(input)?;
863 let _ = ','.parse_next(input)?;
864 let color = parse_hex_color.parse_next(input)?;
865 let _ = ')'.parse_next(input)?;
866 style.shadow = Some(Shadow {
867 offset_x: ox,
868 offset_y: oy,
869 blur,
870 color,
871 });
872 } else {
873 break;
874 }
875 }
876 }
877 "stroke" | "border" => {
878 let color = parse_hex_color.parse_next(input)?;
879 let _ = space1.parse_next(input)?;
880 let w = parse_number.parse_next(input)?;
881 style.stroke = Some(Stroke {
882 paint: Paint::Solid(color),
883 width: w,
884 ..Stroke::default()
885 });
886 }
887 "corner" | "rounded" | "radius" => {
888 style.corner_radius = Some(parse_number.parse_next(input)?);
889 skip_px_suffix(input);
890 }
891 "opacity" => {
892 style.opacity = Some(parse_number.parse_next(input)?);
893 }
894 "align" | "text_align" => {
895 parse_align_value(input, style)?;
896 }
897 "place" => {
898 *place = Some(parse_place_value(input)?);
899 }
900 "locked" => {
901 let val = parse_identifier.parse_next(input)?;
903 *locked = val == "true";
904 }
905 "shadow" => {
906 skip_space(input);
908 if input.starts_with('(') {
909 let _ = '('.parse_next(input)?;
910 let ox = parse_number.parse_next(input)?;
911 let _ = ','.parse_next(input)?;
912 let oy = parse_number.parse_next(input)?;
913 let _ = ','.parse_next(input)?;
914 let blur = parse_number.parse_next(input)?;
915 let _ = ','.parse_next(input)?;
916 let color = parse_hex_color.parse_next(input)?;
917 let _ = ')'.parse_next(input)?;
918 style.shadow = Some(Shadow {
919 offset_x: ox,
920 offset_y: oy,
921 blur,
922 color,
923 });
924 }
925 }
926 "label" => {
927 if input.starts_with('"') {
930 let _ = parse_quoted_string.parse_next(input)?;
931 } else {
932 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
933 c == '\n' || c == ';' || c == '}'
934 })
935 .parse_next(input);
936 }
937 }
938 "use" | "apply" => {
939 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
940 }
941 "font" => {
942 parse_font_value(input, style)?;
943 }
944 "layout" => {
945 let mode_str = parse_identifier.parse_next(input)?;
946 skip_space(input);
947 let mut gap = 0.0f32;
948 let mut pad = 0.0f32;
949 loop {
950 skip_space(input);
951 if input.starts_with("gap=") {
952 let _ = "gap=".parse_next(input)?;
953 gap = parse_number.parse_next(input)?;
954 } else if input.starts_with("pad=") {
955 let _ = "pad=".parse_next(input)?;
956 pad = parse_number.parse_next(input)?;
957 } else if input.starts_with("cols=") {
958 let _ = "cols=".parse_next(input)?;
959 let _ = parse_number.parse_next(input)?;
960 } else {
961 break;
962 }
963 }
964 *layout = match mode_str {
965 "column" => LayoutMode::Column { gap, pad },
966 "row" => LayoutMode::Row { gap, pad },
967 "grid" => LayoutMode::Grid { cols: 2, gap, pad },
968 _ => LayoutMode::Free { pad: 0.0 },
969 };
970 }
971 "clip" => {
972 let val = parse_identifier.parse_next(input)?;
973 *clip = val == "true";
974 }
975 "pad" | "padding" => {
976 let val = parse_number.parse_next(input)?;
978 match layout {
979 LayoutMode::Free { pad } => *pad = val,
980 LayoutMode::Column { pad, .. }
981 | LayoutMode::Row { pad, .. }
982 | LayoutMode::Grid { pad, .. } => *pad = val,
983 }
984 }
985 "d" => {
986 loop {
988 skip_space(input);
989 let at_end = input.is_empty()
990 || input.starts_with('\n')
991 || input.starts_with(';')
992 || input.starts_with('}');
993 if at_end {
994 break;
995 }
996 let saved = *input;
997 if let Ok(cmd_char) = take_while::<_, _, ContextError>(1..=1, |c: char| {
998 matches!(c, 'M' | 'L' | 'Q' | 'C' | 'Z')
999 })
1000 .parse_next(input)
1001 {
1002 skip_space(input);
1003 match cmd_char {
1004 "M" => {
1005 let x = parse_number.parse_next(input)?;
1006 skip_space(input);
1007 let y = parse_number.parse_next(input)?;
1008 path_commands.push(PathCmd::MoveTo(x, y));
1009 }
1010 "L" => {
1011 let x = parse_number.parse_next(input)?;
1012 skip_space(input);
1013 let y = parse_number.parse_next(input)?;
1014 path_commands.push(PathCmd::LineTo(x, y));
1015 }
1016 "Q" => {
1017 let cx = parse_number.parse_next(input)?;
1018 skip_space(input);
1019 let cy = parse_number.parse_next(input)?;
1020 skip_space(input);
1021 let ex = parse_number.parse_next(input)?;
1022 skip_space(input);
1023 let ey = parse_number.parse_next(input)?;
1024 path_commands.push(PathCmd::QuadTo(cx, cy, ex, ey));
1025 }
1026 "C" => {
1027 let c1x = parse_number.parse_next(input)?;
1028 skip_space(input);
1029 let c1y = parse_number.parse_next(input)?;
1030 skip_space(input);
1031 let c2x = parse_number.parse_next(input)?;
1032 skip_space(input);
1033 let c2y = parse_number.parse_next(input)?;
1034 skip_space(input);
1035 let ex = parse_number.parse_next(input)?;
1036 skip_space(input);
1037 let ey = parse_number.parse_next(input)?;
1038 path_commands.push(PathCmd::CubicTo(c1x, c1y, c2x, c2y, ex, ey));
1039 }
1040 "Z" => {
1041 path_commands.push(PathCmd::Close);
1042 }
1043 _ => {
1044 *input = saved;
1045 break;
1046 }
1047 }
1048 } else {
1049 *input = saved;
1050 break;
1051 }
1052 }
1053 }
1054 "src" => {
1055 *image_src = Some(
1056 parse_quoted_string
1057 .map(|s| s.to_string())
1058 .parse_next(input)?,
1059 );
1060 }
1061 "fit" => {
1062 let val = parse_identifier.parse_next(input)?;
1063 *image_fit = match val {
1064 "cover" => ImageFit::Cover,
1065 "contain" => ImageFit::Contain,
1066 "fill" => ImageFit::Fill,
1067 "none" => ImageFit::None,
1068 _ => ImageFit::Cover,
1069 };
1070 }
1071 _ => {
1072 let _ =
1073 take_till::<_, _, ContextError>(0.., |c: char| c == '\n' || c == ';' || c == '}')
1074 .parse_next(input);
1075 }
1076 }
1077
1078 skip_opt_separator(input);
1079 Ok(())
1080}
1081
1082fn parse_align_value(input: &mut &str, style: &mut Properties) -> ModalResult<()> {
1085 use crate::model::{TextAlign, TextVAlign};
1086
1087 let first = parse_identifier.parse_next(input)?;
1088 style.text_align = Some(match first {
1089 "left" => TextAlign::Left,
1090 "right" => TextAlign::Right,
1091 _ => TextAlign::Center, });
1093
1094 skip_space(input);
1096 let at_end = input.is_empty()
1097 || input.starts_with('\n')
1098 || input.starts_with(';')
1099 || input.starts_with('}');
1100 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1101 style.text_valign = Some(match second {
1102 "top" => TextVAlign::Top,
1103 "bottom" => TextVAlign::Bottom,
1104 _ => TextVAlign::Middle,
1105 });
1106 }
1107
1108 Ok(())
1109}
1110
1111fn parse_place_value(input: &mut &str) -> ModalResult<(HPlace, VPlace)> {
1114 use crate::model::{HPlace, VPlace};
1115
1116 let first = parse_identifier.parse_next(input)?;
1117
1118 if input.starts_with('-') {
1120 let saved = *input;
1121 *input = &input[1..]; if let Ok(second) = parse_identifier.parse_next(input) {
1123 match (first, second) {
1124 ("top", "left") => return Ok((HPlace::Left, VPlace::Top)),
1125 ("top", "right") => return Ok((HPlace::Right, VPlace::Top)),
1126 ("bottom", "left") => return Ok((HPlace::Left, VPlace::Bottom)),
1127 ("bottom", "right") => return Ok((HPlace::Right, VPlace::Bottom)),
1128 _ => *input = saved, }
1130 } else {
1131 *input = saved;
1132 }
1133 }
1134
1135 match first {
1136 "center" => {
1137 skip_space(input);
1139 let at_end = input.is_empty()
1140 || input.starts_with('\n')
1141 || input.starts_with(';')
1142 || input.starts_with('}');
1143 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1144 let v = match second {
1145 "top" => VPlace::Top,
1146 "bottom" => VPlace::Bottom,
1147 _ => VPlace::Middle,
1148 };
1149 return Ok((HPlace::Center, v));
1150 }
1151 Ok((HPlace::Center, VPlace::Middle))
1152 }
1153 "top" => Ok((HPlace::Center, VPlace::Top)),
1154 "bottom" => Ok((HPlace::Center, VPlace::Bottom)),
1155 _ => {
1156 let h = match first {
1158 "left" => HPlace::Left,
1159 "right" => HPlace::Right,
1160 _ => HPlace::Center,
1161 };
1162
1163 skip_space(input);
1164 let at_end = input.is_empty()
1165 || input.starts_with('\n')
1166 || input.starts_with(';')
1167 || input.starts_with('}');
1168 if !at_end && let Ok(second) = parse_identifier.parse_next(input) {
1169 let v = match second {
1170 "top" => VPlace::Top,
1171 "bottom" => VPlace::Bottom,
1172 _ => VPlace::Middle,
1173 };
1174 return Ok((h, v));
1175 }
1176
1177 Ok((h, VPlace::Middle))
1178 }
1179 }
1180}
1181
1182fn parse_anim_block(input: &mut &str) -> ModalResult<AnimKeyframe> {
1185 let _ = alt(("when", "anim")).parse_next(input)?;
1186 let _ = space1.parse_next(input)?;
1187 let _ = ':'.parse_next(input)?;
1188 let trigger_str = parse_identifier.parse_next(input)?;
1189 let trigger = match trigger_str {
1190 "hover" => AnimTrigger::Hover,
1191 "press" => AnimTrigger::Press,
1192 "enter" => AnimTrigger::Enter,
1193 other => AnimTrigger::Custom(other.to_string()),
1194 };
1195
1196 let default_duration = match &trigger {
1198 AnimTrigger::Hover => 300u32,
1199 AnimTrigger::Press => 150u32,
1200 AnimTrigger::Enter => 500u32,
1201 AnimTrigger::Custom(_) => 300u32,
1202 };
1203
1204 skip_space(input);
1205 let _ = '{'.parse_next(input)?;
1206
1207 let mut props = AnimProperties::default();
1208 let mut duration_ms = default_duration;
1209 let mut easing = Easing::EaseInOut;
1210 let mut delay_ms: Option<u32> = None;
1211
1212 skip_ws_and_comments(input);
1213
1214 while !input.starts_with('}') {
1215 let prop = parse_identifier.parse_next(input)?;
1216 skip_space(input);
1217 let _ = ':'.parse_next(input)?;
1218 skip_space(input);
1219
1220 match prop {
1221 "fill" => {
1222 props.fill = Some(Paint::Solid(parse_hex_color.parse_next(input)?));
1223 }
1224 "opacity" => {
1225 props.opacity = Some(parse_number.parse_next(input)?);
1226 }
1227 "scale" => {
1228 props.scale = Some(parse_number.parse_next(input)?);
1229 }
1230 "rotate" => {
1231 props.rotate = Some(parse_number.parse_next(input)?);
1232 }
1233 "ease" => {
1234 let ease_name = parse_identifier.parse_next(input)?;
1235 easing = match ease_name {
1236 "linear" => Easing::Linear,
1237 "ease_in" | "easeIn" => Easing::EaseIn,
1238 "ease_out" | "easeOut" => Easing::EaseOut,
1239 "ease_in_out" | "easeInOut" => Easing::EaseInOut,
1240 "spring" => Easing::Spring,
1241 _ => Easing::EaseInOut,
1242 };
1243 skip_space(input);
1244 if let Ok(n) = parse_number.parse_next(input) {
1245 duration_ms = n as u32;
1246 if input.starts_with("ms") {
1247 *input = &input[2..];
1248 }
1249 }
1250 }
1251 "delay" => {
1252 let n = parse_number.parse_next(input)?;
1253 delay_ms = Some(n as u32);
1254 if input.starts_with("ms") {
1255 *input = &input[2..];
1256 }
1257 }
1258 _ => {
1259 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1260 c == '\n' || c == ';' || c == '}'
1261 })
1262 .parse_next(input);
1263 }
1264 }
1265
1266 skip_opt_separator(input);
1267 skip_ws_and_comments(input);
1268 }
1269
1270 let _ = '}'.parse_next(input)?;
1271
1272 Ok(AnimKeyframe {
1273 trigger,
1274 duration_ms,
1275 easing,
1276 properties: props,
1277 delay_ms,
1278 })
1279}
1280
1281fn parse_edge_anchor(input: &mut &str) -> ModalResult<EdgeAnchor> {
1285 skip_space(input);
1286 if input.starts_with('@') {
1287 Ok(EdgeAnchor::Node(parse_node_id.parse_next(input)?))
1288 } else {
1289 let x = parse_number.parse_next(input)?;
1290 skip_space(input);
1291 let y = parse_number.parse_next(input)?;
1292 Ok(EdgeAnchor::Point(x, y))
1293 }
1294}
1295
1296fn parse_edge_defaults_block(input: &mut &str) -> ModalResult<EdgeDefaults> {
1299 let _ = "edge_defaults".parse_next(input)?;
1300 skip_space(input);
1301 let _ = '{'.parse_next(input)?;
1302
1303 let mut defaults = EdgeDefaults::default();
1304 skip_ws_and_comments(input);
1305
1306 while !input.starts_with('}') {
1307 let prop = parse_identifier.parse_next(input)?;
1308 skip_space(input);
1309 let _ = ':'.parse_next(input)?;
1310 skip_space(input);
1311
1312 match prop {
1313 "stroke" => {
1314 let color = parse_hex_color.parse_next(input)?;
1315 skip_space(input);
1316 let w = parse_number.parse_next(input).unwrap_or(1.0);
1317 defaults.props.stroke = Some(Stroke {
1318 paint: Paint::Solid(color),
1319 width: w,
1320 ..Stroke::default()
1321 });
1322 }
1323 "arrow" => {
1324 let kind = parse_identifier.parse_next(input)?;
1325 defaults.arrow = Some(match kind {
1326 "none" => ArrowKind::None,
1327 "start" => ArrowKind::Start,
1328 "end" => ArrowKind::End,
1329 "both" => ArrowKind::Both,
1330 _ => ArrowKind::None,
1331 });
1332 }
1333 "curve" => {
1334 let kind = parse_identifier.parse_next(input)?;
1335 defaults.curve = Some(match kind {
1336 "straight" => CurveKind::Straight,
1337 "smooth" => CurveKind::Smooth,
1338 "step" => CurveKind::Step,
1339 _ => CurveKind::Straight,
1340 });
1341 }
1342 "opacity" => {
1343 defaults.props.opacity = Some(parse_number.parse_next(input)?);
1344 }
1345 _ => {
1346 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1347 c == '\n' || c == ';' || c == '}'
1348 })
1349 .parse_next(input);
1350 }
1351 }
1352 skip_opt_separator(input);
1353 skip_ws_and_comments(input);
1354 }
1355
1356 let _ = '}'.parse_next(input)?;
1357 Ok(defaults)
1358}
1359
1360fn parse_edge_block(input: &mut &str) -> ModalResult<(Edge, Option<(NodeId, String)>)> {
1363 let _ = "edge".parse_next(input)?;
1364 let _ = space1.parse_next(input)?;
1365
1366 let id = if input.starts_with('@') {
1367 parse_node_id.parse_next(input)?
1368 } else {
1369 NodeId::anonymous("edge")
1370 };
1371
1372 skip_space(input);
1373 let _ = '{'.parse_next(input)?;
1374
1375 let mut from = None;
1376 let mut to = None;
1377 let mut text_child = None;
1378 let mut text_child_content = None; let mut style = Properties::default();
1380 let mut use_styles = Vec::new();
1381 let mut arrow = ArrowKind::None;
1382 let mut curve = CurveKind::Straight;
1383 let mut spec: Option<String> = None;
1384 let mut animations = Vec::new();
1385 let mut flow = None;
1386 let mut label_offset = None;
1387
1388 skip_ws_and_comments(input);
1389
1390 while !input.starts_with('}') {
1391 if input.starts_with("spec ")
1392 || input.starts_with("spec{")
1393 || input.starts_with("note ")
1394 || input.starts_with("note{")
1395 {
1396 let content = parse_spec_block.parse_next(input)?;
1397 spec = Some(match spec {
1398 Some(existing) => format!("{existing}\n\n{content}"),
1399 None => content,
1400 });
1401 } else if input.starts_with("when") || input.starts_with("anim") {
1402 animations.push(parse_anim_block.parse_next(input)?);
1403 } else if input.starts_with("text ") || input.starts_with("text@") {
1404 let node = parse_node.parse_next(input)?;
1406 if let NodeKind::Text { ref content, .. } = node.kind {
1407 text_child = Some(node.id);
1408 text_child_content = Some((node.id, content.clone()));
1409 }
1410 } else {
1411 let prop = parse_identifier.parse_next(input)?;
1412 skip_space(input);
1413 let _ = ':'.parse_next(input)?;
1414 skip_space(input);
1415
1416 match prop {
1417 "from" => {
1418 from = Some(parse_edge_anchor(input)?);
1419 }
1420 "to" => {
1421 to = Some(parse_edge_anchor(input)?);
1422 }
1423 "label" => {
1424 let s = parse_quoted_string
1426 .map(|s| s.to_string())
1427 .parse_next(input)?;
1428 let label_id = NodeId::intern(&format!("_{}_label", id.as_str()));
1429 text_child = Some(label_id);
1430 text_child_content = Some((label_id, s));
1431 }
1432 "stroke" => {
1433 let color = parse_hex_color.parse_next(input)?;
1434 skip_space(input);
1435 let w = parse_number.parse_next(input).unwrap_or(1.0);
1436 style.stroke = Some(Stroke {
1437 paint: Paint::Solid(color),
1438 width: w,
1439 ..Stroke::default()
1440 });
1441 }
1442 "arrow" => {
1443 let kind = parse_identifier.parse_next(input)?;
1444 arrow = match kind {
1445 "none" => ArrowKind::None,
1446 "start" => ArrowKind::Start,
1447 "end" => ArrowKind::End,
1448 "both" => ArrowKind::Both,
1449 _ => ArrowKind::None,
1450 };
1451 }
1452 "curve" => {
1453 let kind = parse_identifier.parse_next(input)?;
1454 curve = match kind {
1455 "straight" => CurveKind::Straight,
1456 "smooth" => CurveKind::Smooth,
1457 "step" => CurveKind::Step,
1458 _ => CurveKind::Straight,
1459 };
1460 }
1461 "use" => {
1462 use_styles.push(parse_identifier.map(NodeId::intern).parse_next(input)?);
1463 }
1464 "opacity" => {
1465 style.opacity = Some(parse_number.parse_next(input)?);
1466 }
1467 "flow" => {
1468 let kind_str = parse_identifier.parse_next(input)?;
1469 let kind = match kind_str {
1470 "pulse" => FlowKind::Pulse,
1471 "dash" => FlowKind::Dash,
1472 _ => FlowKind::Pulse,
1473 };
1474 skip_space(input);
1475 let dur = parse_number.parse_next(input).unwrap_or(800.0) as u32;
1476 if input.starts_with("ms") {
1477 *input = &input[2..];
1478 }
1479 flow = Some(FlowAnim {
1480 kind,
1481 duration_ms: dur,
1482 });
1483 }
1484 "label_offset" => {
1485 let ox = parse_number.parse_next(input)?;
1486 skip_space(input);
1487 let oy = parse_number.parse_next(input)?;
1488 label_offset = Some((ox, oy));
1489 }
1490 _ => {
1491 let _ = take_till::<_, _, ContextError>(0.., |c: char| {
1492 c == '\n' || c == ';' || c == '}'
1493 })
1494 .parse_next(input);
1495 }
1496 }
1497
1498 skip_opt_separator(input);
1499 }
1500 skip_ws_and_comments(input);
1501 }
1502
1503 let _ = '}'.parse_next(input)?;
1504
1505 if style.stroke.is_none() {
1507 style.stroke = Some(Stroke {
1508 paint: Paint::Solid(Color::rgba(0.42, 0.44, 0.5, 1.0)),
1509 width: 1.5,
1510 ..Stroke::default()
1511 });
1512 }
1513
1514 Ok((
1515 Edge {
1516 id,
1517 from: from.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1518 to: to.unwrap_or(EdgeAnchor::Point(0.0, 0.0)),
1519 text_child,
1520 props: style,
1521 use_styles: use_styles.into(),
1522 arrow,
1523 curve,
1524 spec,
1525 animations: animations.into(),
1526 flow,
1527 label_offset,
1528 },
1529 text_child_content,
1530 ))
1531}
1532
1533fn parse_constraint_line(input: &mut &str) -> ModalResult<(NodeId, Constraint)> {
1536 let node_id = parse_node_id.parse_next(input)?;
1537 skip_space(input);
1538 let _ = "->".parse_next(input)?;
1539 skip_space(input);
1540
1541 let constraint_type = parse_identifier.parse_next(input)?;
1542 skip_space(input);
1543 let _ = ':'.parse_next(input)?;
1544 skip_space(input);
1545
1546 let constraint = match constraint_type {
1547 "center_in" => Constraint::CenterIn(NodeId::intern(parse_identifier.parse_next(input)?)),
1548 "offset" => {
1549 let from = parse_node_id.parse_next(input)?;
1550 let _ = space1.parse_next(input)?;
1551 let dx = parse_number.parse_next(input)?;
1552 skip_space(input);
1553 let _ = ','.parse_next(input)?;
1554 skip_space(input);
1555 let dy = parse_number.parse_next(input)?;
1556 Constraint::Offset { from, dx, dy }
1557 }
1558 "fill_parent" => {
1559 let pad = opt(parse_number).parse_next(input)?.unwrap_or(0.0);
1560 Constraint::FillParent { pad }
1561 }
1562 "absolute" | "position" => {
1563 let x = parse_number.parse_next(input)?;
1564 skip_space(input);
1565 let _ = ','.parse_next(input)?;
1566 skip_space(input);
1567 let y = parse_number.parse_next(input)?;
1568 Constraint::Position { x, y }
1569 }
1570 _ => {
1571 let _ = take_till::<_, _, ContextError>(0.., '\n').parse_next(input);
1572 Constraint::Position { x: 0.0, y: 0.0 }
1573 }
1574 };
1575
1576 if input.starts_with('\n') {
1577 *input = &input[1..];
1578 }
1579 Ok((node_id, constraint))
1580}
1581
1582#[cfg(test)]
1583#[path = "parser_tests.rs"]
1584mod tests;