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 theme: Option<String>,
30    pub imports: Vec<ComponentImport>,
31    pub styles: Option<String>,
32    pub root: Element,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub struct Element {
37    pub name: String,
38    pub attrs: Vec<(String, AttrValue)>,
39    pub children: Vec<Node>,
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub enum AttrValue {
44    String(String),
45    Interpolation(String),
46}
47
48#[derive(Debug, Clone, PartialEq)]
49pub enum Node {
50    Element(Element),
51    Text(String),
52    Interpolation(String),
53}
54
55pub fn parse(source: &str) -> Result<Document, String> {
56    let mut pairs = FnccParser::parse(Rule::document, source).map_err(|e| format!("parse error: {e}"))?;
57
58    let pair = pairs.next().expect("document should exist");
59
60    let mut frontmatter = None;
61    let mut state_type = None;
62    let mut theme = None;
63    let mut imports = Vec::new();
64    let mut styles = None;
65    let mut root = None;
66
67    for inner in pair.into_inner() {
68        match inner.as_rule() {
69            Rule::frontmatter => {
70                let content = inner.as_str().trim();
71                let content = content
72                    .strip_prefix("---")
73                    .and_then(|s| s.strip_suffix("---"))
74                    .map(|s| s.trim().to_string());
75
76                if let Some(ref raw) = content {
77                    let mut clean_lines = Vec::new();
78                    let mut component_imports = Vec::new();
79                    for line in raw.lines() {
80                        let trimmed = line.trim();
81                        if let Some(st) = trimmed.strip_prefix("@state ") {
82                            state_type = Some(st.trim().to_string());
83                        } else if let Some(t) = trimmed.strip_prefix("@theme ") {
84                            theme = Some(t.trim().to_string());
85                        } else if is_component_import_line(trimmed) {
86                            if let Some(imports) = parse_component_imports(trimmed) {
87                                component_imports.extend(imports);
88                            }
89                            if !trimmed.starts_with("use gpui::") {
90                                continue;
91                            }
92                            clean_lines.push(line);
93                        } else {
94                            clean_lines.push(line);
95                        }
96                    }
97                    imports = component_imports;
98                    let clean = clean_lines.join("\n");
99                    if !clean.trim().is_empty() {
100                        frontmatter = Some(clean);
101                    }
102                }
103            }
104            Rule::styles_block => {
105                let raw = inner.as_str().trim();
106                let content = raw
107                    .strip_prefix("<Styles>")
108                    .and_then(|s| s.strip_suffix("</Styles>"))
109                    .map(|s| s.trim().to_string())
110                    .filter(|s| !s.is_empty());
111                styles = content;
112            }
113            Rule::element => {
114                root = Some(parse_element(inner)?);
115            }
116            _ => {}
117        }
118    }
119
120    Ok(Document {
121        frontmatter,
122        state_type,
123        theme,
124        imports,
125        styles,
126        root: root.expect("document must have a root element"),
127    })
128}
129
130fn parse_element(pair: Pair<Rule>) -> Result<Element, String> {
131    let mut name = String::new();
132    let mut attrs = Vec::new();
133    let mut children = Vec::new();
134    let mut close_name: Option<String> = None;
135
136    for inner in pair.into_inner() {
137        match inner.as_rule() {
138            Rule::open_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::self_closing_tag => {
153                for tag_inner in inner.into_inner() {
154                    match tag_inner.as_rule() {
155                        Rule::tag_name => {
156                            name = tag_inner.as_str().to_string();
157                        }
158                        Rule::attr => {
159                            let (aname, avalue) = parse_attr(tag_inner);
160                            attrs.push((aname, avalue));
161                        }
162                        _ => {}
163                    }
164                }
165            }
166            Rule::children => {
167                for child in inner.into_inner() {
168                    let actual = child.into_inner().next().expect("node should have one child");
169                    match actual.as_rule() {
170                        Rule::element => {
171                            children.push(Node::Element(parse_element(actual)?));
172                        }
173                        Rule::inner_text => {
174                            let text = actual.as_str().trim().to_string();
175                            if !text.is_empty() {
176                                children.push(Node::Text(text));
177                            }
178                        }
179                        Rule::interpolation => {
180                            let expr = actual.as_str().trim();
181                            let expr = expr.strip_prefix('{').and_then(|s| s.strip_suffix('}')).unwrap_or(expr);
182                            children.push(Node::Interpolation(expr.to_string()));
183                        }
184                        _ => {}
185                    }
186                }
187            }
188            Rule::close_tag => {
189                for tag_inner in inner.into_inner() {
190                    if tag_inner.as_rule() == Rule::tag_name {
191                        close_name = Some(tag_inner.as_str().to_string());
192                    }
193                }
194            }
195            _ => {}
196        }
197    }
198
199    if let Some(ref close) = close_name
200        && &name != close
201    {
202        return Err(format!("mismatched close tag: </{close}> does not match <{name}>"));
203    }
204
205    Ok(Element { name, attrs, children })
206}
207
208fn parse_attr(pair: Pair<Rule>) -> (String, AttrValue) {
209    let mut attr_name = String::new();
210    let mut attr_value = AttrValue::String(String::new());
211
212    for inner in pair.into_inner() {
213        match inner.as_rule() {
214            Rule::attr_name => {
215                attr_name = inner.as_str().to_string();
216            }
217            Rule::attr_value => {
218                let val = inner.as_str();
219                let val = val.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(val);
220                let trimmed = val.trim();
221                if trimmed.starts_with('{') && trimmed.ends_with('}') {
222                    let expr = &trimmed[1..trimmed.len() - 1].trim();
223                    attr_value = AttrValue::Interpolation(expr.to_string());
224                } else {
225                    attr_value = AttrValue::String(val.to_string());
226                }
227            }
228            Rule::interpolation => {
229                let expr = inner.as_str().trim();
230                let expr = expr.strip_prefix('{').and_then(|s| s.strip_suffix('}')).unwrap_or(expr);
231                attr_value = AttrValue::Interpolation(expr.to_string());
232            }
233            _ => {}
234        }
235    }
236
237    (attr_name, attr_value)
238}
239
240/// Check if a trimmed line is a component import (`use ui::...`, `use gpui::...`, or `use props::...`)
241fn is_component_import_line(line: &str) -> bool {
242    (line.starts_with("use ui::") || line.starts_with("use gpui::") || line.starts_with("use props::"))
243        && line.ends_with(';')
244}
245
246/// Parse component imports from a `use` line.
247/// Supports:
248/// - `use ui::components::Button;`
249/// - `use ui::components::{Button, Input};`
250/// - `use gpui::TextInput;`
251/// - `use props::HeaderProps;`
252fn parse_component_imports(line: &str) -> Option<Vec<ComponentImport>> {
253    let trimmed = line.strip_prefix("use ")?.strip_suffix(';')?.trim();
254    if trimmed.starts_with("ui::") {
255        parse_fui_imports(trimmed)
256    } else if trimmed.starts_with("gpui::") {
257        parse_gpui_imports(trimmed)
258    } else if trimmed.starts_with("props::") {
259        parse_props_imports(trimmed)
260    } else {
261        None
262    }
263}
264
265fn parse_fui_imports(path: &str) -> Option<Vec<ComponentImport>> {
266    // Remove leading "ui::"
267    let rest = path.strip_prefix("ui::")?;
268    if let Some(brace_start) = rest.find('{') {
269        // Grouped: "components::{Button, Input}"
270        let prefix = rest[..brace_start].trim_end_matches("::");
271        let inner = &rest[brace_start + 1..];
272        let inner = inner.split('}').next()?.trim();
273        let names: Vec<String> = inner
274            .split(',')
275            .map(|s| s.trim().to_string())
276            .filter(|s| !s.is_empty())
277            .collect();
278        if names.is_empty() {
279            return None;
280        }
281        Some(
282            names
283                .into_iter()
284                .map(|name| {
285                    let source = if prefix.is_empty() {
286                        format!("ui::{name}")
287                    } else {
288                        format!("ui::{prefix}::{name}")
289                    };
290                    ComponentImport {
291                        name: name.clone(),
292                        source: ImportSource::FuiPath(source),
293                    }
294                })
295                .collect(),
296        )
297    } else {
298        // Simple: "components::Button"
299        let name = rest.split("::").last()?.to_string();
300        Some(vec![ComponentImport {
301            name: name.clone(),
302            source: ImportSource::FuiPath(format!("ui::{}", rest)),
303        }])
304    }
305}
306
307/// Check if an element tree contains a `<Slot>` element.
308pub fn has_slot(el: &Element) -> bool {
309    if el.name == "Slot" || el.name == "RouterOutlet" {
310        return true;
311    }
312    for child in &el.children {
313        if let Node::Element(child_el) = child
314            && has_slot(child_el)
315        {
316            return true;
317        }
318    }
319    false
320}
321
322/// Check whether an element tree contains `{props.field}` interpolation.
323/// Used to determine if a component actually receives props (vs merely referencing
324/// a PropsType import for a child component).
325pub fn uses_props_interpolation(el: &Element) -> bool {
326    for (_, val) in &el.attrs {
327        if let AttrValue::Interpolation(expr) = val
328            && expr.trim().starts_with("props.")
329        {
330            return true;
331        }
332    }
333    for child in &el.children {
334        match child {
335            Node::Interpolation(expr) => {
336                if expr.trim().starts_with("props.") {
337                    return true;
338                }
339            }
340            Node::Element(child_el) => {
341                if uses_props_interpolation(child_el) {
342                    return true;
343                }
344            }
345            Node::Text(_) => {}
346        }
347    }
348    false
349}
350
351/// Collect all command names referenced via `onclick` attributes in an element tree.
352pub fn collect_commands(el: &Element) -> Vec<String> {
353    let mut cmds = Vec::new();
354    for (key, val) in &el.attrs {
355        if key == "onclick"
356            && let AttrValue::String(name) = val
357            && !cmds.contains(name)
358        {
359            cmds.push(name.clone());
360        }
361    }
362    for child in &el.children {
363        if let Node::Element(child_el) = child {
364            cmds.extend(collect_commands(child_el));
365        }
366    }
367    cmds
368}
369
370/// Get the `each` attribute value from a `<For>` element.
371pub fn get_each_attr(el: &Element) -> Option<&str> {
372    el.attrs.iter().find(|(n, _)| n == "each").map(|(_, v)| v.as_str())
373}
374
375/// Get the `let` attribute value from a `<For>` element.
376pub fn get_let_attr(el: &Element) -> Option<&str> {
377    el.attrs.iter().find(|(n, _)| n == "let").map(|(_, v)| v.as_str())
378}
379
380/// Get the `index` attribute value from a `<For>` element.
381pub fn get_index_attr(el: &Element) -> Option<&str> {
382    el.attrs.iter().find(|(n, _)| n == "index").map(|(_, v)| v.as_str())
383}
384
385/// Get the `condition` attribute value from an `<If>` or `<ElseIf>` element.
386pub fn get_condition_attr(el: &Element) -> Option<&str> {
387    el.attrs.iter().find(|(n, _)| n == "condition").map(|(_, v)| v.as_str())
388}
389
390impl AttrValue {
391    pub(crate) fn as_str(&self) -> &str {
392        match self {
393            AttrValue::String(s) => s,
394            AttrValue::Interpolation(s) => s,
395        }
396    }
397}
398
399fn parse_gpui_imports(path: &str) -> Option<Vec<ComponentImport>> {
400    let name = path.strip_prefix("gpui::")?;
401    // No grouped imports for gpui (for now)
402    if name.contains('{') || name.contains('}') || name.contains("::") {
403        return None;
404    }
405    Some(vec![ComponentImport {
406        name: name.to_string(),
407        source: ImportSource::Gpui,
408    }])
409}
410
411fn parse_props_imports(path: &str) -> Option<Vec<ComponentImport>> {
412    let rest = path.strip_prefix("props::")?;
413    if let Some(brace_start) = rest.find('{') {
414        // Grouped: "props::{HeaderProps, FooterProps}"
415        let inner = &rest[brace_start + 1..];
416        let inner = inner.split('}').next()?.trim();
417        let names: Vec<String> = inner
418            .split(',')
419            .map(|s| s.trim().to_string())
420            .filter(|s| !s.is_empty())
421            .collect();
422        if names.is_empty() {
423            return None;
424        }
425        Some(
426            names
427                .into_iter()
428                .map(|name| ComponentImport {
429                    name,
430                    source: ImportSource::PropsType,
431                })
432                .collect(),
433        )
434    } else if rest.contains("::") {
435        None
436    } else {
437        Some(vec![ComponentImport {
438            name: rest.to_string(),
439            source: ImportSource::PropsType,
440        }])
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    // --- Happy path ---
449
450    #[test]
451    fn test_simple_element_parses_correctly() {
452        let doc = parse("<Text size=\"xl\">hello</Text>").unwrap();
453        assert_eq!(doc.root.name, "Text");
454        assert_eq!(doc.root.attrs.len(), 1);
455        assert_eq!(doc.root.attrs[0].0, "size");
456        assert_eq!(doc.root.attrs[0].1, AttrValue::String("xl".into()));
457        assert_eq!(doc.root.children.len(), 1);
458        assert_eq!(doc.root.children[0], Node::Text("hello".into()));
459    }
460
461    #[test]
462    fn test_frontmatter_with_imports() {
463        let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
464        assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
465        assert_eq!(doc.root.name, "App");
466    }
467
468    #[test]
469    fn test_interpolation_in_text() {
470        let doc = parse("<Text>{state.msg}</Text>").unwrap();
471        assert_eq!(doc.root.children.len(), 1);
472        assert_eq!(doc.root.children[0], Node::Interpolation("state.msg".to_string()));
473    }
474
475    #[test]
476    fn test_nested_elements_with_attrs() {
477        let src = "<Stack direction=\"vertical\">\n  <Text>oi</Text>\n</Stack>";
478        let doc = parse(src).unwrap();
479        assert_eq!(doc.root.name, "Stack");
480        assert_eq!(doc.root.children.len(), 1);
481        let child = match &doc.root.children[0] {
482            Node::Element(el) => el,
483            _ => panic!("expected element node"),
484        };
485        assert_eq!(child.name, "Text");
486        assert_eq!(child.children.len(), 1);
487        assert_eq!(child.children[0], Node::Text("oi".to_string()));
488    }
489
490    #[test]
491    fn test_frontmatter_with_state_directive() {
492        let doc = parse("---\n@state CounterState\n---\n<App></App>").unwrap();
493        assert_eq!(doc.state_type, Some("CounterState".to_string()));
494        assert_eq!(doc.frontmatter, None);
495    }
496
497    #[test]
498    fn test_frontmatter_with_state_and_imports() {
499        let doc = parse("---\nuse crate::state::MyState;\n@state MyState\n---\n<App></App>").unwrap();
500        assert_eq!(doc.state_type, Some("MyState".to_string()));
501        assert_eq!(doc.frontmatter, Some("use crate::state::MyState;".to_string()));
502    }
503
504    #[test]
505    fn test_self_closing_tag() {
506        let doc = parse("<Button onclick=\"handle_click\" />").unwrap();
507        assert_eq!(doc.root.name, "Button");
508        assert_eq!(doc.root.attrs.len(), 1);
509        assert_eq!(doc.root.attrs[0].0, "onclick");
510        assert!(doc.root.children.is_empty());
511    }
512
513    #[test]
514    fn text_interpolation_in_attribute() {
515        let doc = parse("<Text size=\"{state.size}\">hey</Text>").unwrap();
516        assert_eq!(doc.root.attrs[0].1, AttrValue::Interpolation("state.size".into()));
517    }
518
519    #[test]
520    fn test_multiple_attributes() {
521        let src = "<Stack direction=\"horizontal\" gap=\"16\" id=\"main-stack\"></Stack>";
522        let doc = parse(src).unwrap();
523        assert_eq!(doc.root.attrs.len(), 3);
524        assert_eq!(doc.root.attrs[0].0, "direction");
525        assert_eq!(doc.root.attrs[1].0, "gap");
526        assert_eq!(doc.root.attrs[2].0, "id");
527    }
528
529    // --- Edge cases ---
530
531    #[test]
532    fn test_element_with_no_children() {
533        let doc = parse("<Div></Div>").unwrap();
534        assert_eq!(doc.root.name, "Div");
535        assert!(doc.root.children.is_empty());
536    }
537
538    #[test]
539    fn test_element_with_no_attributes() {
540        let doc = parse("<View></View>").unwrap();
541        assert_eq!(doc.root.name, "View");
542        assert!(doc.root.attrs.is_empty());
543    }
544
545    #[test]
546    fn test_whitespace_only_text_is_ignored() {
547        let doc = parse("<Text>   \n  </Text>").unwrap();
548        assert!(doc.root.children.is_empty());
549    }
550
551    #[test]
552    fn test_mixed_children_text_and_interpolation() {
553        let src = "<Text>Hello {name} !</Text>";
554        let doc = parse(src).unwrap();
555        assert_eq!(doc.root.children.len(), 3);
556        assert_eq!(doc.root.children[0], Node::Text("Hello".into()));
557        assert_eq!(doc.root.children[1], Node::Interpolation("name".into()));
558    }
559
560    #[test]
561    fn test_deeply_nested_elements() {
562        let src = "<A><B><C><D><E></E></D></C></B></A>";
563        let doc = parse(src).unwrap();
564        assert_eq!(doc.root.name, "A");
565        match &doc.root.children[0] {
566            Node::Element(b) => {
567                assert_eq!(b.name, "B");
568                match &b.children[0] {
569                    Node::Element(c) => {
570                        assert_eq!(c.name, "C");
571                        match &c.children[0] {
572                            Node::Element(d) => {
573                                assert_eq!(d.name, "D");
574                                match &d.children[0] {
575                                    Node::Element(e) => assert_eq!(e.name, "E"),
576                                    _ => panic!("expected element E"),
577                                }
578                            }
579                            _ => panic!("expected element D"),
580                        }
581                    }
582                    _ => panic!("expected element C"),
583                }
584            }
585            _ => panic!("expected element B"),
586        }
587    }
588
589    #[test]
590    fn test_tag_name_with_hyphen() {
591        let doc = parse("<my-component></my-component>").unwrap();
592        assert_eq!(doc.root.name, "my-component");
593    }
594
595    #[test]
596    fn test_tag_name_starting_with_underscore() {
597        let doc = parse("<_custom></_custom>").unwrap();
598        assert_eq!(doc.root.name, "_custom");
599    }
600
601    #[test]
602    fn test_frontmatter_only_with_state_and_other_lines() {
603        let src = "---\n@state MyState\nconst X: i32 = 42;\n---\n<Root></Root>";
604        let doc = parse(src).unwrap();
605        assert_eq!(doc.state_type, Some("MyState".into()));
606        assert_eq!(doc.frontmatter, Some("const X: i32 = 42;".into()));
607    }
608
609    #[test]
610    fn test_multiline_interpolation_expression() {
611        let src = "<Text>{ state . count }</Text>";
612        let doc = parse(src).unwrap();
613        match &doc.root.children[0] {
614            Node::Interpolation(expr) => {
615                assert!(expr.contains("state"));
616                assert!(expr.contains("count"));
617            }
618            _ => panic!("expected interpolation"),
619        }
620    }
621
622    // --- Invalid inputs / Error handling ---
623
624    #[test]
625    fn test_empty_string_returns_error() {
626        let result = parse("");
627        assert!(result.is_err());
628    }
629
630    #[test]
631    fn test_only_whitespace_returns_error() {
632        let result = parse("   \n  \t  ");
633        assert!(result.is_err());
634    }
635
636    #[test]
637    fn test_unclosed_tag_returns_error() {
638        let result = parse("<Text>unclosed");
639        assert!(result.is_err());
640    }
641
642    #[test]
643    fn test_unopened_close_tag_returns_error() {
644        let result = parse("</Text>");
645        assert!(result.is_err());
646    }
647
648    #[test]
649    fn test_mismatched_close_tag_returns_error() {
650        let result = parse("<Div></Text>");
651        assert!(result.is_err());
652        let err = result.unwrap_err().to_string();
653        assert!(err.contains("mismatched close tag"), "error: {err}");
654    }
655
656    #[test]
657    fn test_invalid_tag_name_returns_error() {
658        let result = parse("<123invalid></123invalid>");
659        assert!(result.is_err());
660    }
661
662    #[test]
663    fn test_unclosed_frontmatter_returns_error() {
664        let result = parse("---\n@state Foo\n<Root></Root>");
665        assert!(result.is_err());
666    }
667
668    #[test]
669    fn test_invalid_attribute_syntax_returns_error() {
670        let result = parse("<Text size=>hello</Text>");
671        assert!(result.is_err());
672    }
673
674    #[test]
675    fn test_invalid_attribute_value_no_quotes_returns_error() {
676        let result = parse("<Text size=xl>hello</Text>");
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn test_self_closing_tag_with_content_before_close_errors() {
682        // This should parse as: <Text> with children "hello" and then <Button /> after
683        // Actually, it's valid because <Text>hello<Button/ ></Text> is well-formed
684        let result = parse("<Text>hello<Button/></Text>");
685        assert!(result.is_ok());
686    }
687
688    // --- Contract / Structural tests ---
689
690    #[test]
691    fn test_document_structure_contract() {
692        let doc = parse("<Root></Root>").unwrap();
693        assert_eq!(doc.root.name, "Root");
694        assert!(doc.frontmatter.is_none());
695        assert!(doc.state_type.is_none());
696        assert!(doc.root.attrs.is_empty());
697        assert!(doc.root.children.is_empty());
698    }
699
700    #[test]
701    fn test_node_variants_contract() {
702        let src = r#"<Container>
703            text content
704            <Inner />
705            {interp}
706        </Container>"#;
707        let doc = parse(src).unwrap();
708        let types: Vec<&str> = doc
709            .root
710            .children
711            .iter()
712            .map(|n| match n {
713                Node::Text(_) => "text",
714                Node::Element(_) => "element",
715                Node::Interpolation(_) => "interpolation",
716            })
717            .collect();
718        assert_eq!(types, ["text", "element", "interpolation"]);
719    }
720
721    #[test]
722    fn test_attr_value_types_contract() {
723        let src = "<Text size=\"lg\" data-value=\"{expr}\" />";
724        let doc = parse(src).unwrap();
725        assert_eq!(doc.root.attrs[0].1, AttrValue::String("lg".into()));
726        assert_eq!(doc.root.attrs[1].1, AttrValue::Interpolation("expr".into()));
727    }
728
729    // --- Regression tests ---
730
731    #[test]
732    fn test_regression_trailing_whitespace_after_tag() {
733        let doc = parse("<Text>hello</Text>  ").unwrap();
734        assert_eq!(doc.root.name, "Text");
735    }
736
737    #[test]
738    fn test_regression_leading_whitespace_before_tag() {
739        let doc = parse("  <Text>hello</Text>").unwrap();
740        assert_eq!(doc.root.name, "Text");
741    }
742
743    #[test]
744    fn test_regression_nested_same_component_name() {
745        let src = "<Item><Item><Item></Item></Item></Item>";
746        let doc = parse(src).unwrap();
747        assert_eq!(doc.root.name, "Item");
748        if let Node::Element(child) = &doc.root.children[0] {
749            assert_eq!(child.name, "Item");
750            if let Node::Element(grandchild) = &child.children[0] {
751                assert_eq!(grandchild.name, "Item");
752            } else {
753                panic!("expected inner Item");
754            }
755        } else {
756            panic!("expected child Item");
757        }
758    }
759
760    #[test]
761    fn test_regression_interpolation_with_adjacent_text() {
762        let doc = parse("<Text>Count: {count} items</Text>").unwrap();
763        assert_eq!(doc.root.children.len(), 3);
764        assert_eq!(doc.root.children[0], Node::Text("Count:".into()));
765        assert_eq!(doc.root.children[1], Node::Interpolation("count".into()));
766        assert_eq!(doc.root.children[2], Node::Text("items".into()));
767    }
768
769    // --- Component imports ---
770
771    #[test]
772    fn test_simple_use_ui_import() {
773        let doc = parse("---\nuse ui::components::Button;\n---\n<Stack></Stack>").unwrap();
774        assert_eq!(doc.imports.len(), 1);
775        assert_eq!(doc.imports[0].name, "Button");
776        assert_eq!(
777            doc.imports[0].source,
778            ImportSource::FuiPath("ui::components::Button".into())
779        );
780    }
781
782    #[test]
783    fn test_use_ui_import_stripped_from_frontmatter() {
784        let doc = parse("---\nuse ui::components::Button;\n---\n<Stack></Stack>").unwrap();
785        assert!(doc.frontmatter.is_none());
786    }
787
788    #[test]
789    fn test_use_crate_import_preserved() {
790        let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
791        assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
792        assert!(doc.imports.is_empty());
793    }
794
795    #[test]
796    fn test_use_ui_and_crate_mixed() {
797        let src = "---\nuse crate::prelude::*;\nuse ui::components::Header;\n---\n<App></App>";
798        let doc = parse(src).unwrap();
799        assert_eq!(doc.imports.len(), 1);
800        assert_eq!(doc.imports[0].name, "Header");
801        assert!(doc.frontmatter.as_ref().unwrap().contains("use crate::prelude::*;"));
802        assert!(!doc.frontmatter.as_ref().unwrap().contains("use ui::"));
803    }
804
805    #[test]
806    fn test_grouped_use_ui_import() {
807        let src = "---\nuse ui::components::{Button, Input, Card};\n---\n<Stack></Stack>";
808        let doc = parse(src).unwrap();
809        assert_eq!(doc.imports.len(), 3);
810        assert_eq!(doc.imports[0].name, "Button");
811        assert_eq!(doc.imports[1].name, "Input");
812        assert_eq!(doc.imports[2].name, "Card");
813        for imp in &doc.imports {
814            match &imp.source {
815                ImportSource::FuiPath(p) => assert!(p.starts_with("ui::components::")),
816                _ => panic!("expected FuiPath"),
817            }
818        }
819    }
820
821    #[test]
822    fn test_use_gpui_import_kept_in_frontmatter() {
823        let doc = parse("---\nuse gpui::TextInput;\n---\n<Stack></Stack>").unwrap();
824        assert_eq!(doc.imports.len(), 1);
825        assert_eq!(doc.imports[0].name, "TextInput");
826        assert_eq!(doc.imports[0].source, ImportSource::Gpui);
827        // gpui imports are real Rust — stay in frontmatter
828        assert!(doc.frontmatter.as_ref().unwrap().contains("use gpui::TextInput;"));
829    }
830
831    #[test]
832    fn test_use_ui_with_state_and_imports() {
833        let src = "---\n@state AppState\nuse ui::components::Header;\n---\n<Stack></Stack>";
834        let doc = parse(src).unwrap();
835        assert_eq!(doc.state_type, Some("AppState".into()));
836        assert_eq!(doc.imports.len(), 1);
837        assert_eq!(doc.imports[0].name, "Header");
838    }
839
840    #[test]
841    fn test_imports_default_empty() {
842        let doc = parse("<Text>hi</Text>").unwrap();
843        assert!(doc.imports.is_empty());
844    }
845
846    #[test]
847    fn test_use_ui_with_deep_path() {
848        let doc = parse("---\nuse ui::layout::sidebar::SidePanel;\n---\n<Root></Root>").unwrap();
849        assert_eq!(doc.imports.len(), 1);
850        assert_eq!(doc.imports[0].name, "SidePanel");
851        assert_eq!(
852            doc.imports[0].source,
853            ImportSource::FuiPath("ui::layout::sidebar::SidePanel".into())
854        );
855    }
856
857    // --- Styles block tests ---
858
859    #[test]
860    fn test_parse_styles_block() {
861        let src = "<Styles>\n.btn { color: red; }\n</Styles>\n<Text>hi</Text>";
862        let doc = parse(src).unwrap();
863        assert_eq!(doc.styles, Some(".btn { color: red; }".into()));
864    }
865
866    #[test]
867    fn test_parse_styles_block_empty() {
868        let src = "<Styles>\n\n</Styles>\n<Text>hi</Text>";
869        let doc = parse(src).unwrap();
870        assert_eq!(doc.styles, None);
871    }
872
873    #[test]
874    fn test_parse_styles_block_absent() {
875        let doc = parse("<Text>hi</Text>").unwrap();
876        assert_eq!(doc.styles, None);
877    }
878
879    #[test]
880    fn test_parse_styles_with_frontmatter() {
881        let src = "---\n@state AppState\n---\n<Styles>\n.btn { color: red; }\n</Styles>\n<Stack></Stack>";
882        let doc = parse(src).unwrap();
883        assert_eq!(doc.styles, Some(".btn { color: red; }".into()));
884        assert_eq!(doc.state_type, Some("AppState".into()));
885    }
886
887    // --- @theme directive tests ---
888
889    #[test]
890    fn test_theme_directive() {
891        let src = "---\n@theme dark\n---\n<Text>hi</Text>";
892        let doc = parse(src).unwrap();
893        assert_eq!(doc.theme, Some("dark".into()));
894    }
895
896    #[test]
897    fn test_theme_default_none() {
898        let doc = parse("<Text>hi</Text>").unwrap();
899        assert_eq!(doc.theme, None);
900    }
901
902    #[test]
903    fn test_theme_with_state() {
904        let src = "---\n@state AppState\n@theme dark\n---\n<Stack></Stack>";
905        let doc = parse(src).unwrap();
906        assert_eq!(doc.state_type, Some("AppState".into()));
907        assert_eq!(doc.theme, Some("dark".into()));
908    }
909}