Skip to main content

fncc_core/
parser.rs

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