1use pest::Parser;
2
3#[derive(pest_derive::Parser)]
4#[grammar = "parser/fncc.pest"]
5pub struct FnccParser;
6
7use pest::iterators::Pair;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct ComponentImport {
11 pub name: String,
12 pub source: ImportSource,
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub enum ImportSource {
17 FuiPath(String),
19 Gpui,
21 PropsType,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct Document {
27 pub frontmatter: Option<String>,
28 pub state_type: Option<String>,
29 pub imports: Vec<ComponentImport>,
30 pub root: Element,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34pub struct Element {
35 pub name: String,
36 pub attrs: Vec<(String, AttrValue)>,
37 pub children: Vec<Node>,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum AttrValue {
42 String(String),
43 Interpolation(String),
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub enum Node {
48 Element(Element),
49 Text(String),
50 Interpolation(String),
51}
52
53pub fn parse(source: &str) -> Result<Document, String> {
54 let mut pairs = FnccParser::parse(Rule::document, source).map_err(|e| format!("parse error: {e}"))?;
55
56 let pair = pairs.next().expect("document should exist");
57
58 let mut frontmatter = None;
59 let mut state_type = None;
60 let mut imports = Vec::new();
61 let mut root = None;
62
63 for inner in pair.into_inner() {
64 match inner.as_rule() {
65 Rule::frontmatter => {
66 let content = inner.as_str().trim();
67 let content = content
68 .strip_prefix("---")
69 .and_then(|s| s.strip_suffix("---"))
70 .map(|s| s.trim().to_string());
71
72 if let Some(ref raw) = content {
73 let mut clean_lines = Vec::new();
75 let mut component_imports = Vec::new();
76 for line in raw.lines() {
77 let trimmed = line.trim();
78 if let Some(st) = trimmed.strip_prefix("@state ") {
79 state_type = Some(st.trim().to_string());
80 } else if is_component_import_line(trimmed) {
81 if let Some(imports) = parse_component_imports(trimmed) {
82 component_imports.extend(imports);
83 }
84 if !trimmed.starts_with("use gpui::") {
87 continue;
88 }
89 clean_lines.push(line);
90 } else {
91 clean_lines.push(line);
92 }
93 }
94 imports = component_imports;
95 let clean = clean_lines.join("\n");
96 if !clean.trim().is_empty() {
97 frontmatter = Some(clean);
98 }
99 }
100 }
101 Rule::element => {
102 root = Some(parse_element(inner));
103 }
104 _ => {}
105 }
106 }
107
108 Ok(Document {
109 frontmatter,
110 state_type,
111 imports,
112 root: root.expect("document must have a root element"),
113 })
114}
115
116fn parse_element(pair: Pair<Rule>) -> Element {
117 let mut name = String::new();
118 let mut attrs = Vec::new();
119 let mut children = Vec::new();
120 let mut close_name: Option<String> = None;
121
122 for inner in pair.into_inner() {
123 match inner.as_rule() {
124 Rule::open_tag => {
125 for tag_inner in inner.into_inner() {
126 match tag_inner.as_rule() {
127 Rule::tag_name => {
128 name = tag_inner.as_str().to_string();
129 }
130 Rule::attr => {
131 let (aname, avalue) = parse_attr(tag_inner);
132 attrs.push((aname, avalue));
133 }
134 _ => {}
135 }
136 }
137 }
138 Rule::self_closing_tag => {
139 for tag_inner in inner.into_inner() {
140 match tag_inner.as_rule() {
141 Rule::tag_name => {
142 name = tag_inner.as_str().to_string();
143 }
144 Rule::attr => {
145 let (aname, avalue) = parse_attr(tag_inner);
146 attrs.push((aname, avalue));
147 }
148 _ => {}
149 }
150 }
151 }
152 Rule::children => {
153 for child in inner.into_inner() {
154 let actual = child.into_inner().next().expect("node should have one child");
155 match actual.as_rule() {
156 Rule::element => {
157 children.push(Node::Element(parse_element(actual)));
158 }
159 Rule::inner_text => {
160 let text = actual.as_str().trim().to_string();
161 if !text.is_empty() {
162 children.push(Node::Text(text));
163 }
164 }
165 Rule::interpolation => {
166 let expr = actual.as_str().trim();
167 let expr = expr.strip_prefix('{').and_then(|s| s.strip_suffix('}')).unwrap_or(expr);
168 children.push(Node::Interpolation(expr.to_string()));
169 }
170 _ => {}
171 }
172 }
173 }
174 Rule::close_tag => {
175 for tag_inner in inner.into_inner() {
176 if tag_inner.as_rule() == Rule::tag_name {
177 close_name = Some(tag_inner.as_str().to_string());
178 }
179 }
180 }
181 _ => {}
182 }
183 }
184
185 if let Some(ref close) = close_name {
186 assert_eq!(&name, close, "mismatched close tag: </{close}> does not match <{name}>");
187 }
188
189 Element { name, attrs, children }
190}
191
192fn parse_attr(pair: Pair<Rule>) -> (String, AttrValue) {
193 let mut attr_name = String::new();
194 let mut attr_value = AttrValue::String(String::new());
195
196 for inner in pair.into_inner() {
197 match inner.as_rule() {
198 Rule::attr_name => {
199 attr_name = inner.as_str().to_string();
200 }
201 Rule::attr_value => {
202 let val = inner.as_str();
203 let val = val.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(val);
204 let trimmed = val.trim();
205 if trimmed.starts_with('{') && trimmed.ends_with('}') {
206 let expr = &trimmed[1..trimmed.len() - 1].trim();
207 attr_value = AttrValue::Interpolation(expr.to_string());
208 } else {
209 attr_value = AttrValue::String(val.to_string());
210 }
211 }
212 Rule::interpolation => {
213 let expr = inner.as_str().trim();
214 let expr = expr.strip_prefix('{').and_then(|s| s.strip_suffix('}')).unwrap_or(expr);
215 attr_value = AttrValue::Interpolation(expr.to_string());
216 }
217 _ => {}
218 }
219 }
220
221 (attr_name, attr_value)
222}
223
224fn is_component_import_line(line: &str) -> bool {
226 (line.starts_with("use ui::") || line.starts_with("use gpui::") || line.starts_with("use props::"))
227 && line.ends_with(';')
228}
229
230fn parse_component_imports(line: &str) -> Option<Vec<ComponentImport>> {
237 let trimmed = line.strip_prefix("use ")?.strip_suffix(';')?.trim();
238 if trimmed.starts_with("ui::") {
239 parse_fui_imports(trimmed)
240 } else if trimmed.starts_with("gpui::") {
241 parse_gpui_imports(trimmed)
242 } else if trimmed.starts_with("props::") {
243 parse_props_imports(trimmed)
244 } else {
245 None
246 }
247}
248
249fn parse_fui_imports(path: &str) -> Option<Vec<ComponentImport>> {
250 let rest = path.strip_prefix("ui::")?;
252 if let Some(brace_start) = rest.find('{') {
253 let prefix = rest[..brace_start].trim_end_matches("::");
255 let inner = &rest[brace_start + 1..];
256 let inner = inner.split('}').next()?.trim();
257 let names: Vec<String> = inner
258 .split(',')
259 .map(|s| s.trim().to_string())
260 .filter(|s| !s.is_empty())
261 .collect();
262 if names.is_empty() {
263 return None;
264 }
265 Some(
266 names
267 .into_iter()
268 .map(|name| {
269 let source = if prefix.is_empty() {
270 format!("ui::{name}")
271 } else {
272 format!("ui::{prefix}::{name}")
273 };
274 ComponentImport {
275 name: name.clone(),
276 source: ImportSource::FuiPath(source),
277 }
278 })
279 .collect(),
280 )
281 } else {
282 let name = rest.split("::").last()?.to_string();
284 Some(vec![ComponentImport {
285 name: name.clone(),
286 source: ImportSource::FuiPath(format!("ui::{}", rest)),
287 }])
288 }
289}
290
291pub fn uses_props_interpolation(el: &Element) -> bool {
295 for (_, val) in &el.attrs {
296 if let AttrValue::Interpolation(expr) = val
297 && expr.trim().starts_with("props.")
298 {
299 return true;
300 }
301 }
302 for child in &el.children {
303 match child {
304 Node::Interpolation(expr) => {
305 if expr.trim().starts_with("props.") {
306 return true;
307 }
308 }
309 Node::Element(child_el) => {
310 if uses_props_interpolation(child_el) {
311 return true;
312 }
313 }
314 Node::Text(_) => {}
315 }
316 }
317 false
318}
319
320pub fn collect_commands(el: &Element) -> Vec<String> {
322 let mut cmds = Vec::new();
323 for (key, val) in &el.attrs {
324 if key == "onclick"
325 && let AttrValue::String(name) = val
326 && !cmds.contains(name)
327 {
328 cmds.push(name.clone());
329 }
330 }
331 for child in &el.children {
332 if let Node::Element(child_el) = child {
333 cmds.extend(collect_commands(child_el));
334 }
335 }
336 cmds
337}
338
339fn parse_gpui_imports(path: &str) -> Option<Vec<ComponentImport>> {
340 let name = path.strip_prefix("gpui::")?;
341 if name.contains('{') || name.contains('}') || name.contains("::") {
343 return None;
344 }
345 Some(vec![ComponentImport {
346 name: name.to_string(),
347 source: ImportSource::Gpui,
348 }])
349}
350
351fn parse_props_imports(path: &str) -> Option<Vec<ComponentImport>> {
352 let rest = path.strip_prefix("props::")?;
353 if let Some(brace_start) = rest.find('{') {
354 let inner = &rest[brace_start + 1..];
356 let inner = inner.split('}').next()?.trim();
357 let names: Vec<String> = inner
358 .split(',')
359 .map(|s| s.trim().to_string())
360 .filter(|s| !s.is_empty())
361 .collect();
362 if names.is_empty() {
363 return None;
364 }
365 Some(
366 names
367 .into_iter()
368 .map(|name| ComponentImport {
369 name,
370 source: ImportSource::PropsType,
371 })
372 .collect(),
373 )
374 } else if rest.contains("::") {
375 None
376 } else {
377 Some(vec![ComponentImport {
378 name: rest.to_string(),
379 source: ImportSource::PropsType,
380 }])
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
391 fn test_simple_element_parses_correctly() {
392 let doc = parse("<Text size=\"xl\">hello</Text>").unwrap();
393 assert_eq!(doc.root.name, "Text");
394 assert_eq!(doc.root.attrs.len(), 1);
395 assert_eq!(doc.root.attrs[0].0, "size");
396 assert_eq!(doc.root.attrs[0].1, AttrValue::String("xl".into()));
397 assert_eq!(doc.root.children.len(), 1);
398 assert_eq!(doc.root.children[0], Node::Text("hello".into()));
399 }
400
401 #[test]
402 fn test_frontmatter_with_imports() {
403 let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
404 assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
405 assert_eq!(doc.root.name, "App");
406 }
407
408 #[test]
409 fn test_interpolation_in_text() {
410 let doc = parse("<Text>{state.msg}</Text>").unwrap();
411 assert_eq!(doc.root.children.len(), 1);
412 assert_eq!(doc.root.children[0], Node::Interpolation("state.msg".to_string()));
413 }
414
415 #[test]
416 fn test_nested_elements_with_attrs() {
417 let src = "<Stack direction=\"vertical\">\n <Text>oi</Text>\n</Stack>";
418 let doc = parse(src).unwrap();
419 assert_eq!(doc.root.name, "Stack");
420 assert_eq!(doc.root.children.len(), 1);
421 let child = match &doc.root.children[0] {
422 Node::Element(el) => el,
423 _ => panic!("expected element node"),
424 };
425 assert_eq!(child.name, "Text");
426 assert_eq!(child.children.len(), 1);
427 assert_eq!(child.children[0], Node::Text("oi".to_string()));
428 }
429
430 #[test]
431 fn test_frontmatter_with_state_directive() {
432 let doc = parse("---\n@state CounterState\n---\n<App></App>").unwrap();
433 assert_eq!(doc.state_type, Some("CounterState".to_string()));
434 assert_eq!(doc.frontmatter, None);
435 }
436
437 #[test]
438 fn test_frontmatter_with_state_and_imports() {
439 let doc = parse("---\nuse crate::state::MyState;\n@state MyState\n---\n<App></App>").unwrap();
440 assert_eq!(doc.state_type, Some("MyState".to_string()));
441 assert_eq!(doc.frontmatter, Some("use crate::state::MyState;".to_string()));
442 }
443
444 #[test]
445 fn test_self_closing_tag() {
446 let doc = parse("<Button onclick=\"handle_click\" />").unwrap();
447 assert_eq!(doc.root.name, "Button");
448 assert_eq!(doc.root.attrs.len(), 1);
449 assert_eq!(doc.root.attrs[0].0, "onclick");
450 assert!(doc.root.children.is_empty());
451 }
452
453 #[test]
454 fn text_interpolation_in_attribute() {
455 let doc = parse("<Text size=\"{state.size}\">hey</Text>").unwrap();
456 assert_eq!(doc.root.attrs[0].1, AttrValue::Interpolation("state.size".into()));
457 }
458
459 #[test]
460 fn test_multiple_attributes() {
461 let src = "<Stack direction=\"horizontal\" gap=\"16\" id=\"main-stack\"></Stack>";
462 let doc = parse(src).unwrap();
463 assert_eq!(doc.root.attrs.len(), 3);
464 assert_eq!(doc.root.attrs[0].0, "direction");
465 assert_eq!(doc.root.attrs[1].0, "gap");
466 assert_eq!(doc.root.attrs[2].0, "id");
467 }
468
469 #[test]
472 fn test_element_with_no_children() {
473 let doc = parse("<Div></Div>").unwrap();
474 assert_eq!(doc.root.name, "Div");
475 assert!(doc.root.children.is_empty());
476 }
477
478 #[test]
479 fn test_element_with_no_attributes() {
480 let doc = parse("<View></View>").unwrap();
481 assert_eq!(doc.root.name, "View");
482 assert!(doc.root.attrs.is_empty());
483 }
484
485 #[test]
486 fn test_whitespace_only_text_is_ignored() {
487 let doc = parse("<Text> \n </Text>").unwrap();
488 assert!(doc.root.children.is_empty());
489 }
490
491 #[test]
492 fn test_mixed_children_text_and_interpolation() {
493 let src = "<Text>Hello {name} !</Text>";
494 let doc = parse(src).unwrap();
495 assert_eq!(doc.root.children.len(), 3);
496 assert_eq!(doc.root.children[0], Node::Text("Hello".into()));
497 assert_eq!(doc.root.children[1], Node::Interpolation("name".into()));
498 }
499
500 #[test]
501 fn test_deeply_nested_elements() {
502 let src = "<A><B><C><D><E></E></D></C></B></A>";
503 let doc = parse(src).unwrap();
504 assert_eq!(doc.root.name, "A");
505 match &doc.root.children[0] {
506 Node::Element(b) => {
507 assert_eq!(b.name, "B");
508 match &b.children[0] {
509 Node::Element(c) => {
510 assert_eq!(c.name, "C");
511 match &c.children[0] {
512 Node::Element(d) => {
513 assert_eq!(d.name, "D");
514 match &d.children[0] {
515 Node::Element(e) => assert_eq!(e.name, "E"),
516 _ => panic!("expected element E"),
517 }
518 }
519 _ => panic!("expected element D"),
520 }
521 }
522 _ => panic!("expected element C"),
523 }
524 }
525 _ => panic!("expected element B"),
526 }
527 }
528
529 #[test]
530 fn test_tag_name_with_hyphen() {
531 let doc = parse("<my-component></my-component>").unwrap();
532 assert_eq!(doc.root.name, "my-component");
533 }
534
535 #[test]
536 fn test_tag_name_starting_with_underscore() {
537 let doc = parse("<_custom></_custom>").unwrap();
538 assert_eq!(doc.root.name, "_custom");
539 }
540
541 #[test]
542 fn test_frontmatter_only_with_state_and_other_lines() {
543 let src = "---\n@state MyState\nconst X: i32 = 42;\n---\n<Root></Root>";
544 let doc = parse(src).unwrap();
545 assert_eq!(doc.state_type, Some("MyState".into()));
546 assert_eq!(doc.frontmatter, Some("const X: i32 = 42;".into()));
547 }
548
549 #[test]
550 fn test_multiline_interpolation_expression() {
551 let src = "<Text>{ state . count }</Text>";
552 let doc = parse(src).unwrap();
553 match &doc.root.children[0] {
554 Node::Interpolation(expr) => {
555 assert!(expr.contains("state"));
556 assert!(expr.contains("count"));
557 }
558 _ => panic!("expected interpolation"),
559 }
560 }
561
562 #[test]
565 fn test_empty_string_returns_error() {
566 let result = parse("");
567 assert!(result.is_err());
568 }
569
570 #[test]
571 fn test_only_whitespace_returns_error() {
572 let result = parse(" \n \t ");
573 assert!(result.is_err());
574 }
575
576 #[test]
577 fn test_unclosed_tag_returns_error() {
578 let result = parse("<Text>unclosed");
579 assert!(result.is_err());
580 }
581
582 #[test]
583 fn test_unopened_close_tag_returns_error() {
584 let result = parse("</Text>");
585 assert!(result.is_err());
586 }
587
588 #[test]
589 #[should_panic(expected = "mismatched close tag")]
590 fn test_mismatched_close_tag_panics() {
591 parse("<Div></Text>").unwrap();
592 }
593
594 #[test]
595 fn test_invalid_tag_name_returns_error() {
596 let result = parse("<123invalid></123invalid>");
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn test_unclosed_frontmatter_returns_error() {
602 let result = parse("---\n@state Foo\n<Root></Root>");
603 assert!(result.is_err());
604 }
605
606 #[test]
607 fn test_invalid_attribute_syntax_returns_error() {
608 let result = parse("<Text size=>hello</Text>");
609 assert!(result.is_err());
610 }
611
612 #[test]
613 fn test_invalid_attribute_value_no_quotes_returns_error() {
614 let result = parse("<Text size=xl>hello</Text>");
615 assert!(result.is_err());
616 }
617
618 #[test]
619 fn test_self_closing_tag_with_content_before_close_errors() {
620 let result = parse("<Text>hello<Button/></Text>");
623 assert!(result.is_ok());
624 }
625
626 #[test]
629 fn test_document_structure_contract() {
630 let doc = parse("<Root></Root>").unwrap();
631 assert_eq!(doc.root.name, "Root");
632 assert!(doc.frontmatter.is_none());
633 assert!(doc.state_type.is_none());
634 assert!(doc.root.attrs.is_empty());
635 assert!(doc.root.children.is_empty());
636 }
637
638 #[test]
639 fn test_node_variants_contract() {
640 let src = r#"<Container>
641 text content
642 <Inner />
643 {interp}
644 </Container>"#;
645 let doc = parse(src).unwrap();
646 let types: Vec<&str> = doc
647 .root
648 .children
649 .iter()
650 .map(|n| match n {
651 Node::Text(_) => "text",
652 Node::Element(_) => "element",
653 Node::Interpolation(_) => "interpolation",
654 })
655 .collect();
656 assert_eq!(types, ["text", "element", "interpolation"]);
657 }
658
659 #[test]
660 fn test_attr_value_types_contract() {
661 let src = "<Text size=\"lg\" data-value=\"{expr}\" />";
662 let doc = parse(src).unwrap();
663 assert_eq!(doc.root.attrs[0].1, AttrValue::String("lg".into()));
664 assert_eq!(doc.root.attrs[1].1, AttrValue::Interpolation("expr".into()));
665 }
666
667 #[test]
670 fn test_regression_trailing_whitespace_after_tag() {
671 let doc = parse("<Text>hello</Text> ").unwrap();
672 assert_eq!(doc.root.name, "Text");
673 }
674
675 #[test]
676 fn test_regression_leading_whitespace_before_tag() {
677 let doc = parse(" <Text>hello</Text>").unwrap();
678 assert_eq!(doc.root.name, "Text");
679 }
680
681 #[test]
682 fn test_regression_nested_same_component_name() {
683 let src = "<Item><Item><Item></Item></Item></Item>";
684 let doc = parse(src).unwrap();
685 assert_eq!(doc.root.name, "Item");
686 if let Node::Element(child) = &doc.root.children[0] {
687 assert_eq!(child.name, "Item");
688 if let Node::Element(grandchild) = &child.children[0] {
689 assert_eq!(grandchild.name, "Item");
690 } else {
691 panic!("expected inner Item");
692 }
693 } else {
694 panic!("expected child Item");
695 }
696 }
697
698 #[test]
699 fn test_regression_interpolation_with_adjacent_text() {
700 let doc = parse("<Text>Count: {count} items</Text>").unwrap();
701 assert_eq!(doc.root.children.len(), 3);
702 assert_eq!(doc.root.children[0], Node::Text("Count:".into()));
703 assert_eq!(doc.root.children[1], Node::Interpolation("count".into()));
704 assert_eq!(doc.root.children[2], Node::Text("items".into()));
705 }
706
707 #[test]
710 fn test_simple_use_ui_import() {
711 let doc = parse("---\nuse ui::components::Button;\n---\n<Stack></Stack>").unwrap();
712 assert_eq!(doc.imports.len(), 1);
713 assert_eq!(doc.imports[0].name, "Button");
714 assert_eq!(
715 doc.imports[0].source,
716 ImportSource::FuiPath("ui::components::Button".into())
717 );
718 }
719
720 #[test]
721 fn test_use_ui_import_stripped_from_frontmatter() {
722 let doc = parse("---\nuse ui::components::Button;\n---\n<Stack></Stack>").unwrap();
723 assert!(doc.frontmatter.is_none());
724 }
725
726 #[test]
727 fn test_use_crate_import_preserved() {
728 let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
729 assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
730 assert!(doc.imports.is_empty());
731 }
732
733 #[test]
734 fn test_use_ui_and_crate_mixed() {
735 let src = "---\nuse crate::prelude::*;\nuse ui::components::Header;\n---\n<App></App>";
736 let doc = parse(src).unwrap();
737 assert_eq!(doc.imports.len(), 1);
738 assert_eq!(doc.imports[0].name, "Header");
739 assert!(doc.frontmatter.as_ref().unwrap().contains("use crate::prelude::*;"));
740 assert!(!doc.frontmatter.as_ref().unwrap().contains("use ui::"));
741 }
742
743 #[test]
744 fn test_grouped_use_ui_import() {
745 let src = "---\nuse ui::components::{Button, Input, Card};\n---\n<Stack></Stack>";
746 let doc = parse(src).unwrap();
747 assert_eq!(doc.imports.len(), 3);
748 assert_eq!(doc.imports[0].name, "Button");
749 assert_eq!(doc.imports[1].name, "Input");
750 assert_eq!(doc.imports[2].name, "Card");
751 for imp in &doc.imports {
752 match &imp.source {
753 ImportSource::FuiPath(p) => assert!(p.starts_with("ui::components::")),
754 _ => panic!("expected FuiPath"),
755 }
756 }
757 }
758
759 #[test]
760 fn test_use_gpui_import_kept_in_frontmatter() {
761 let doc = parse("---\nuse gpui::TextInput;\n---\n<Stack></Stack>").unwrap();
762 assert_eq!(doc.imports.len(), 1);
763 assert_eq!(doc.imports[0].name, "TextInput");
764 assert_eq!(doc.imports[0].source, ImportSource::Gpui);
765 assert!(doc.frontmatter.as_ref().unwrap().contains("use gpui::TextInput;"));
767 }
768
769 #[test]
770 fn test_use_ui_with_state_and_imports() {
771 let src = "---\n@state AppState\nuse ui::components::Header;\n---\n<Stack></Stack>";
772 let doc = parse(src).unwrap();
773 assert_eq!(doc.state_type, Some("AppState".into()));
774 assert_eq!(doc.imports.len(), 1);
775 assert_eq!(doc.imports[0].name, "Header");
776 }
777
778 #[test]
779 fn test_imports_default_empty() {
780 let doc = parse("<Text>hi</Text>").unwrap();
781 assert!(doc.imports.is_empty());
782 }
783
784 #[test]
785 fn test_use_ui_with_deep_path() {
786 let doc = parse("---\nuse ui::layout::sidebar::SidePanel;\n---\n<Root></Root>").unwrap();
787 assert_eq!(doc.imports.len(), 1);
788 assert_eq!(doc.imports[0].name, "SidePanel");
789 assert_eq!(
790 doc.imports[0].source,
791 ImportSource::FuiPath("ui::layout::sidebar::SidePanel".into())
792 );
793 }
794}