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 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
306pub 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
335pub 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
354pub fn get_each_attr(el: &Element) -> Option<&str> {
356 el.attrs.iter().find(|(n, _)| n == "each").map(|(_, v)| v.as_str())
357}
358
359pub fn get_let_attr(el: &Element) -> Option<&str> {
361 el.attrs.iter().find(|(n, _)| n == "let").map(|(_, v)| v.as_str())
362}
363
364pub fn get_index_attr(el: &Element) -> Option<&str> {
366 el.attrs.iter().find(|(n, _)| n == "index").map(|(_, v)| v.as_str())
367}
368
369pub 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 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 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 #[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 #[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 #[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 let result = parse("<Text>hello<Button/></Text>");
667 assert!(result.is_ok());
668 }
669
670 #[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 #[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 #[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 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}