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 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
240fn 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
246fn 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 let rest = path.strip_prefix("ui::")?;
268 if let Some(brace_start) = rest.find('{') {
269 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 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
307pub 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
322pub 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
351pub 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
370pub fn get_each_attr(el: &Element) -> Option<&str> {
372 el.attrs.iter().find(|(n, _)| n == "each").map(|(_, v)| v.as_str())
373}
374
375pub fn get_let_attr(el: &Element) -> Option<&str> {
377 el.attrs.iter().find(|(n, _)| n == "let").map(|(_, v)| v.as_str())
378}
379
380pub fn get_index_attr(el: &Element) -> Option<&str> {
382 el.attrs.iter().find(|(n, _)| n == "index").map(|(_, v)| v.as_str())
383}
384
385pub 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 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 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 #[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 #[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 #[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 let result = parse("<Text>hello<Button/></Text>");
685 assert!(result.is_ok());
686 }
687
688 #[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 #[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 #[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 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 #[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 #[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}