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    /// A props type, e.g. `use props::HeaderProps;`
22    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                    // extract @state directive and component imports
74                    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                            // Only strip from emitted Rust if it's a .fui import,
85                            // not a gpui import (those are real Rust)
86                            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
224/// Check if a trimmed line is a component import (`use ui::...`, `use gpui::...`, or `use props::...`)
225fn 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
230/// Parse component imports from a `use` line.
231/// Supports:
232/// - `use ui::components::Button;`
233/// - `use ui::components::{Button, Input};`
234/// - `use gpui::TextInput;`
235/// - `use props::HeaderProps;`
236fn 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    // Remove leading "ui::"
251    let rest = path.strip_prefix("ui::")?;
252    if let Some(brace_start) = rest.find('{') {
253        // Grouped: "components::{Button, Input}"
254        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        // Simple: "components::Button"
283        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
291/// Check if an element tree contains a `<Slot>` element.
292pub fn has_slot(el: &Element) -> bool {
293    if el.name == "Slot" {
294        return true;
295    }
296    for child in &el.children {
297        if let Node::Element(child_el) = child
298            && has_slot(child_el)
299        {
300            return true;
301        }
302    }
303    false
304}
305
306/// Check whether an element tree contains `{props.field}` interpolation.
307/// Used to determine if a component actually receives props (vs merely referencing
308/// a PropsType import for a child component).
309pub fn uses_props_interpolation(el: &Element) -> bool {
310    for (_, val) in &el.attrs {
311        if let AttrValue::Interpolation(expr) = val
312            && expr.trim().starts_with("props.")
313        {
314            return true;
315        }
316    }
317    for child in &el.children {
318        match child {
319            Node::Interpolation(expr) => {
320                if expr.trim().starts_with("props.") {
321                    return true;
322                }
323            }
324            Node::Element(child_el) => {
325                if uses_props_interpolation(child_el) {
326                    return true;
327                }
328            }
329            Node::Text(_) => {}
330        }
331    }
332    false
333}
334
335/// Collect all command names referenced via `onclick` attributes in an element tree.
336pub fn collect_commands(el: &Element) -> Vec<String> {
337    let mut cmds = Vec::new();
338    for (key, val) in &el.attrs {
339        if key == "onclick"
340            && let AttrValue::String(name) = val
341            && !cmds.contains(name)
342        {
343            cmds.push(name.clone());
344        }
345    }
346    for child in &el.children {
347        if let Node::Element(child_el) = child {
348            cmds.extend(collect_commands(child_el));
349        }
350    }
351    cmds
352}
353
354/// Get the `each` attribute value from a `<For>` element.
355pub fn get_each_attr(el: &Element) -> Option<&str> {
356    el.attrs.iter().find(|(n, _)| n == "each").map(|(_, v)| v.as_str())
357}
358
359/// Get the `let` attribute value from a `<For>` element.
360pub fn get_let_attr(el: &Element) -> Option<&str> {
361    el.attrs.iter().find(|(n, _)| n == "let").map(|(_, v)| v.as_str())
362}
363
364/// Get the `index` attribute value from a `<For>` element.
365pub fn get_index_attr(el: &Element) -> Option<&str> {
366    el.attrs.iter().find(|(n, _)| n == "index").map(|(_, v)| v.as_str())
367}
368
369/// Get the `condition` attribute value from an `<If>` or `<ElseIf>` element.
370pub fn get_condition_attr(el: &Element) -> Option<&str> {
371    el.attrs.iter().find(|(n, _)| n == "condition").map(|(_, v)| v.as_str())
372}
373
374impl AttrValue {
375    pub(crate) fn as_str(&self) -> &str {
376        match self {
377            AttrValue::String(s) => s,
378            AttrValue::Interpolation(s) => s,
379        }
380    }
381}
382
383fn parse_gpui_imports(path: &str) -> Option<Vec<ComponentImport>> {
384    let name = path.strip_prefix("gpui::")?;
385    // No grouped imports for gpui (for now)
386    if name.contains('{') || name.contains('}') || name.contains("::") {
387        return None;
388    }
389    Some(vec![ComponentImport {
390        name: name.to_string(),
391        source: ImportSource::Gpui,
392    }])
393}
394
395fn parse_props_imports(path: &str) -> Option<Vec<ComponentImport>> {
396    let rest = path.strip_prefix("props::")?;
397    if let Some(brace_start) = rest.find('{') {
398        // Grouped: "props::{HeaderProps, FooterProps}"
399        let inner = &rest[brace_start + 1..];
400        let inner = inner.split('}').next()?.trim();
401        let names: Vec<String> = inner
402            .split(',')
403            .map(|s| s.trim().to_string())
404            .filter(|s| !s.is_empty())
405            .collect();
406        if names.is_empty() {
407            return None;
408        }
409        Some(
410            names
411                .into_iter()
412                .map(|name| ComponentImport {
413                    name,
414                    source: ImportSource::PropsType,
415                })
416                .collect(),
417        )
418    } else if rest.contains("::") {
419        None
420    } else {
421        Some(vec![ComponentImport {
422            name: rest.to_string(),
423            source: ImportSource::PropsType,
424        }])
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    // --- Happy path ---
433
434    #[test]
435    fn test_simple_element_parses_correctly() {
436        let doc = parse("<Text size=\"xl\">hello</Text>").unwrap();
437        assert_eq!(doc.root.name, "Text");
438        assert_eq!(doc.root.attrs.len(), 1);
439        assert_eq!(doc.root.attrs[0].0, "size");
440        assert_eq!(doc.root.attrs[0].1, AttrValue::String("xl".into()));
441        assert_eq!(doc.root.children.len(), 1);
442        assert_eq!(doc.root.children[0], Node::Text("hello".into()));
443    }
444
445    #[test]
446    fn test_frontmatter_with_imports() {
447        let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
448        assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
449        assert_eq!(doc.root.name, "App");
450    }
451
452    #[test]
453    fn test_interpolation_in_text() {
454        let doc = parse("<Text>{state.msg}</Text>").unwrap();
455        assert_eq!(doc.root.children.len(), 1);
456        assert_eq!(doc.root.children[0], Node::Interpolation("state.msg".to_string()));
457    }
458
459    #[test]
460    fn test_nested_elements_with_attrs() {
461        let src = "<Stack direction=\"vertical\">\n  <Text>oi</Text>\n</Stack>";
462        let doc = parse(src).unwrap();
463        assert_eq!(doc.root.name, "Stack");
464        assert_eq!(doc.root.children.len(), 1);
465        let child = match &doc.root.children[0] {
466            Node::Element(el) => el,
467            _ => panic!("expected element node"),
468        };
469        assert_eq!(child.name, "Text");
470        assert_eq!(child.children.len(), 1);
471        assert_eq!(child.children[0], Node::Text("oi".to_string()));
472    }
473
474    #[test]
475    fn test_frontmatter_with_state_directive() {
476        let doc = parse("---\n@state CounterState\n---\n<App></App>").unwrap();
477        assert_eq!(doc.state_type, Some("CounterState".to_string()));
478        assert_eq!(doc.frontmatter, None);
479    }
480
481    #[test]
482    fn test_frontmatter_with_state_and_imports() {
483        let doc = parse("---\nuse crate::state::MyState;\n@state MyState\n---\n<App></App>").unwrap();
484        assert_eq!(doc.state_type, Some("MyState".to_string()));
485        assert_eq!(doc.frontmatter, Some("use crate::state::MyState;".to_string()));
486    }
487
488    #[test]
489    fn test_self_closing_tag() {
490        let doc = parse("<Button onclick=\"handle_click\" />").unwrap();
491        assert_eq!(doc.root.name, "Button");
492        assert_eq!(doc.root.attrs.len(), 1);
493        assert_eq!(doc.root.attrs[0].0, "onclick");
494        assert!(doc.root.children.is_empty());
495    }
496
497    #[test]
498    fn text_interpolation_in_attribute() {
499        let doc = parse("<Text size=\"{state.size}\">hey</Text>").unwrap();
500        assert_eq!(doc.root.attrs[0].1, AttrValue::Interpolation("state.size".into()));
501    }
502
503    #[test]
504    fn test_multiple_attributes() {
505        let src = "<Stack direction=\"horizontal\" gap=\"16\" id=\"main-stack\"></Stack>";
506        let doc = parse(src).unwrap();
507        assert_eq!(doc.root.attrs.len(), 3);
508        assert_eq!(doc.root.attrs[0].0, "direction");
509        assert_eq!(doc.root.attrs[1].0, "gap");
510        assert_eq!(doc.root.attrs[2].0, "id");
511    }
512
513    // --- Edge cases ---
514
515    #[test]
516    fn test_element_with_no_children() {
517        let doc = parse("<Div></Div>").unwrap();
518        assert_eq!(doc.root.name, "Div");
519        assert!(doc.root.children.is_empty());
520    }
521
522    #[test]
523    fn test_element_with_no_attributes() {
524        let doc = parse("<View></View>").unwrap();
525        assert_eq!(doc.root.name, "View");
526        assert!(doc.root.attrs.is_empty());
527    }
528
529    #[test]
530    fn test_whitespace_only_text_is_ignored() {
531        let doc = parse("<Text>   \n  </Text>").unwrap();
532        assert!(doc.root.children.is_empty());
533    }
534
535    #[test]
536    fn test_mixed_children_text_and_interpolation() {
537        let src = "<Text>Hello {name} !</Text>";
538        let doc = parse(src).unwrap();
539        assert_eq!(doc.root.children.len(), 3);
540        assert_eq!(doc.root.children[0], Node::Text("Hello".into()));
541        assert_eq!(doc.root.children[1], Node::Interpolation("name".into()));
542    }
543
544    #[test]
545    fn test_deeply_nested_elements() {
546        let src = "<A><B><C><D><E></E></D></C></B></A>";
547        let doc = parse(src).unwrap();
548        assert_eq!(doc.root.name, "A");
549        match &doc.root.children[0] {
550            Node::Element(b) => {
551                assert_eq!(b.name, "B");
552                match &b.children[0] {
553                    Node::Element(c) => {
554                        assert_eq!(c.name, "C");
555                        match &c.children[0] {
556                            Node::Element(d) => {
557                                assert_eq!(d.name, "D");
558                                match &d.children[0] {
559                                    Node::Element(e) => assert_eq!(e.name, "E"),
560                                    _ => panic!("expected element E"),
561                                }
562                            }
563                            _ => panic!("expected element D"),
564                        }
565                    }
566                    _ => panic!("expected element C"),
567                }
568            }
569            _ => panic!("expected element B"),
570        }
571    }
572
573    #[test]
574    fn test_tag_name_with_hyphen() {
575        let doc = parse("<my-component></my-component>").unwrap();
576        assert_eq!(doc.root.name, "my-component");
577    }
578
579    #[test]
580    fn test_tag_name_starting_with_underscore() {
581        let doc = parse("<_custom></_custom>").unwrap();
582        assert_eq!(doc.root.name, "_custom");
583    }
584
585    #[test]
586    fn test_frontmatter_only_with_state_and_other_lines() {
587        let src = "---\n@state MyState\nconst X: i32 = 42;\n---\n<Root></Root>";
588        let doc = parse(src).unwrap();
589        assert_eq!(doc.state_type, Some("MyState".into()));
590        assert_eq!(doc.frontmatter, Some("const X: i32 = 42;".into()));
591    }
592
593    #[test]
594    fn test_multiline_interpolation_expression() {
595        let src = "<Text>{ state . count }</Text>";
596        let doc = parse(src).unwrap();
597        match &doc.root.children[0] {
598            Node::Interpolation(expr) => {
599                assert!(expr.contains("state"));
600                assert!(expr.contains("count"));
601            }
602            _ => panic!("expected interpolation"),
603        }
604    }
605
606    // --- Invalid inputs / Error handling ---
607
608    #[test]
609    fn test_empty_string_returns_error() {
610        let result = parse("");
611        assert!(result.is_err());
612    }
613
614    #[test]
615    fn test_only_whitespace_returns_error() {
616        let result = parse("   \n  \t  ");
617        assert!(result.is_err());
618    }
619
620    #[test]
621    fn test_unclosed_tag_returns_error() {
622        let result = parse("<Text>unclosed");
623        assert!(result.is_err());
624    }
625
626    #[test]
627    fn test_unopened_close_tag_returns_error() {
628        let result = parse("</Text>");
629        assert!(result.is_err());
630    }
631
632    #[test]
633    #[should_panic(expected = "mismatched close tag")]
634    fn test_mismatched_close_tag_panics() {
635        parse("<Div></Text>").unwrap();
636    }
637
638    #[test]
639    fn test_invalid_tag_name_returns_error() {
640        let result = parse("<123invalid></123invalid>");
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_unclosed_frontmatter_returns_error() {
646        let result = parse("---\n@state Foo\n<Root></Root>");
647        assert!(result.is_err());
648    }
649
650    #[test]
651    fn test_invalid_attribute_syntax_returns_error() {
652        let result = parse("<Text size=>hello</Text>");
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_invalid_attribute_value_no_quotes_returns_error() {
658        let result = parse("<Text size=xl>hello</Text>");
659        assert!(result.is_err());
660    }
661
662    #[test]
663    fn test_self_closing_tag_with_content_before_close_errors() {
664        // This should parse as: <Text> with children "hello" and then <Button /> after
665        // Actually, it's valid because <Text>hello<Button/ ></Text> is well-formed
666        let result = parse("<Text>hello<Button/></Text>");
667        assert!(result.is_ok());
668    }
669
670    // --- Contract / Structural tests ---
671
672    #[test]
673    fn test_document_structure_contract() {
674        let doc = parse("<Root></Root>").unwrap();
675        assert_eq!(doc.root.name, "Root");
676        assert!(doc.frontmatter.is_none());
677        assert!(doc.state_type.is_none());
678        assert!(doc.root.attrs.is_empty());
679        assert!(doc.root.children.is_empty());
680    }
681
682    #[test]
683    fn test_node_variants_contract() {
684        let src = r#"<Container>
685            text content
686            <Inner />
687            {interp}
688        </Container>"#;
689        let doc = parse(src).unwrap();
690        let types: Vec<&str> = doc
691            .root
692            .children
693            .iter()
694            .map(|n| match n {
695                Node::Text(_) => "text",
696                Node::Element(_) => "element",
697                Node::Interpolation(_) => "interpolation",
698            })
699            .collect();
700        assert_eq!(types, ["text", "element", "interpolation"]);
701    }
702
703    #[test]
704    fn test_attr_value_types_contract() {
705        let src = "<Text size=\"lg\" data-value=\"{expr}\" />";
706        let doc = parse(src).unwrap();
707        assert_eq!(doc.root.attrs[0].1, AttrValue::String("lg".into()));
708        assert_eq!(doc.root.attrs[1].1, AttrValue::Interpolation("expr".into()));
709    }
710
711    // --- Regression tests ---
712
713    #[test]
714    fn test_regression_trailing_whitespace_after_tag() {
715        let doc = parse("<Text>hello</Text>  ").unwrap();
716        assert_eq!(doc.root.name, "Text");
717    }
718
719    #[test]
720    fn test_regression_leading_whitespace_before_tag() {
721        let doc = parse("  <Text>hello</Text>").unwrap();
722        assert_eq!(doc.root.name, "Text");
723    }
724
725    #[test]
726    fn test_regression_nested_same_component_name() {
727        let src = "<Item><Item><Item></Item></Item></Item>";
728        let doc = parse(src).unwrap();
729        assert_eq!(doc.root.name, "Item");
730        if let Node::Element(child) = &doc.root.children[0] {
731            assert_eq!(child.name, "Item");
732            if let Node::Element(grandchild) = &child.children[0] {
733                assert_eq!(grandchild.name, "Item");
734            } else {
735                panic!("expected inner Item");
736            }
737        } else {
738            panic!("expected child Item");
739        }
740    }
741
742    #[test]
743    fn test_regression_interpolation_with_adjacent_text() {
744        let doc = parse("<Text>Count: {count} items</Text>").unwrap();
745        assert_eq!(doc.root.children.len(), 3);
746        assert_eq!(doc.root.children[0], Node::Text("Count:".into()));
747        assert_eq!(doc.root.children[1], Node::Interpolation("count".into()));
748        assert_eq!(doc.root.children[2], Node::Text("items".into()));
749    }
750
751    // --- Component imports ---
752
753    #[test]
754    fn test_simple_use_ui_import() {
755        let doc = parse("---\nuse ui::components::Button;\n---\n<Stack></Stack>").unwrap();
756        assert_eq!(doc.imports.len(), 1);
757        assert_eq!(doc.imports[0].name, "Button");
758        assert_eq!(
759            doc.imports[0].source,
760            ImportSource::FuiPath("ui::components::Button".into())
761        );
762    }
763
764    #[test]
765    fn test_use_ui_import_stripped_from_frontmatter() {
766        let doc = parse("---\nuse ui::components::Button;\n---\n<Stack></Stack>").unwrap();
767        assert!(doc.frontmatter.is_none());
768    }
769
770    #[test]
771    fn test_use_crate_import_preserved() {
772        let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
773        assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
774        assert!(doc.imports.is_empty());
775    }
776
777    #[test]
778    fn test_use_ui_and_crate_mixed() {
779        let src = "---\nuse crate::prelude::*;\nuse ui::components::Header;\n---\n<App></App>";
780        let doc = parse(src).unwrap();
781        assert_eq!(doc.imports.len(), 1);
782        assert_eq!(doc.imports[0].name, "Header");
783        assert!(doc.frontmatter.as_ref().unwrap().contains("use crate::prelude::*;"));
784        assert!(!doc.frontmatter.as_ref().unwrap().contains("use ui::"));
785    }
786
787    #[test]
788    fn test_grouped_use_ui_import() {
789        let src = "---\nuse ui::components::{Button, Input, Card};\n---\n<Stack></Stack>";
790        let doc = parse(src).unwrap();
791        assert_eq!(doc.imports.len(), 3);
792        assert_eq!(doc.imports[0].name, "Button");
793        assert_eq!(doc.imports[1].name, "Input");
794        assert_eq!(doc.imports[2].name, "Card");
795        for imp in &doc.imports {
796            match &imp.source {
797                ImportSource::FuiPath(p) => assert!(p.starts_with("ui::components::")),
798                _ => panic!("expected FuiPath"),
799            }
800        }
801    }
802
803    #[test]
804    fn test_use_gpui_import_kept_in_frontmatter() {
805        let doc = parse("---\nuse gpui::TextInput;\n---\n<Stack></Stack>").unwrap();
806        assert_eq!(doc.imports.len(), 1);
807        assert_eq!(doc.imports[0].name, "TextInput");
808        assert_eq!(doc.imports[0].source, ImportSource::Gpui);
809        // gpui imports are real Rust — stay in frontmatter
810        assert!(doc.frontmatter.as_ref().unwrap().contains("use gpui::TextInput;"));
811    }
812
813    #[test]
814    fn test_use_ui_with_state_and_imports() {
815        let src = "---\n@state AppState\nuse ui::components::Header;\n---\n<Stack></Stack>";
816        let doc = parse(src).unwrap();
817        assert_eq!(doc.state_type, Some("AppState".into()));
818        assert_eq!(doc.imports.len(), 1);
819        assert_eq!(doc.imports[0].name, "Header");
820    }
821
822    #[test]
823    fn test_imports_default_empty() {
824        let doc = parse("<Text>hi</Text>").unwrap();
825        assert!(doc.imports.is_empty());
826    }
827
828    #[test]
829    fn test_use_ui_with_deep_path() {
830        let doc = parse("---\nuse ui::layout::sidebar::SidePanel;\n---\n<Root></Root>").unwrap();
831        assert_eq!(doc.imports.len(), 1);
832        assert_eq!(doc.imports[0].name, "SidePanel");
833        assert_eq!(
834            doc.imports[0].source,
835            ImportSource::FuiPath("ui::layout::sidebar::SidePanel".into())
836        );
837    }
838}