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