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 Document {
11    pub frontmatter: Option<String>,
12    pub state_type: Option<String>,
13    pub root: Element,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct Element {
18    pub name: String,
19    pub attrs: Vec<(String, AttrValue)>,
20    pub children: Vec<Node>,
21}
22
23#[derive(Debug, Clone, PartialEq)]
24pub enum AttrValue {
25    String(String),
26    Interpolation(String),
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub enum Node {
31    Element(Element),
32    Text(String),
33    Interpolation(String),
34}
35
36pub fn parse(source: &str) -> Result<Document, String> {
37    let mut pairs = FnccParser::parse(Rule::document, source).map_err(|e| format!("parse error: {e}"))?;
38
39    let pair = pairs.next().expect("document should exist");
40
41    let mut frontmatter = None;
42    let mut state_type = None;
43    let mut root = None;
44
45    for inner in pair.into_inner() {
46        match inner.as_rule() {
47            Rule::frontmatter => {
48                let content = inner.as_str().trim();
49                let content = content
50                    .strip_prefix("---")
51                    .and_then(|s| s.strip_suffix("---"))
52                    .map(|s| s.trim().to_string());
53
54                if let Some(ref raw) = content {
55                    // extract @state directive
56                    let mut clean_lines = Vec::new();
57                    for line in raw.lines() {
58                        let trimmed = line.trim();
59                        if let Some(st) = trimmed.strip_prefix("@state ") {
60                            state_type = Some(st.trim().to_string());
61                        } else {
62                            clean_lines.push(line);
63                        }
64                    }
65                    let clean = clean_lines.join("\n");
66                    if !clean.trim().is_empty() {
67                        frontmatter = Some(clean);
68                    }
69                }
70            }
71            Rule::element => {
72                root = Some(parse_element(inner));
73            }
74            _ => {}
75        }
76    }
77
78    Ok(Document {
79        frontmatter,
80        state_type,
81        root: root.expect("document must have a root element"),
82    })
83}
84
85fn parse_element(pair: Pair<Rule>) -> Element {
86    let mut name = String::new();
87    let mut attrs = Vec::new();
88    let mut children = Vec::new();
89    let mut close_name: Option<String> = None;
90
91    for inner in pair.into_inner() {
92        match inner.as_rule() {
93            Rule::open_tag => {
94                for tag_inner in inner.into_inner() {
95                    match tag_inner.as_rule() {
96                        Rule::tag_name => {
97                            name = tag_inner.as_str().to_string();
98                        }
99                        Rule::attr => {
100                            let (aname, avalue) = parse_attr(tag_inner);
101                            attrs.push((aname, avalue));
102                        }
103                        _ => {}
104                    }
105                }
106            }
107            Rule::self_closing_tag => {
108                for tag_inner in inner.into_inner() {
109                    match tag_inner.as_rule() {
110                        Rule::tag_name => {
111                            name = tag_inner.as_str().to_string();
112                        }
113                        Rule::attr => {
114                            let (aname, avalue) = parse_attr(tag_inner);
115                            attrs.push((aname, avalue));
116                        }
117                        _ => {}
118                    }
119                }
120            }
121            Rule::children => {
122                for child in inner.into_inner() {
123                    let actual = child.into_inner().next().expect("node should have one child");
124                    match actual.as_rule() {
125                        Rule::element => {
126                            children.push(Node::Element(parse_element(actual)));
127                        }
128                        Rule::inner_text => {
129                            let text = actual.as_str().trim().to_string();
130                            if !text.is_empty() {
131                                children.push(Node::Text(text));
132                            }
133                        }
134                        Rule::interpolation => {
135                            let expr = actual.as_str().trim();
136                            let expr = expr.strip_prefix('{').and_then(|s| s.strip_suffix('}')).unwrap_or(expr);
137                            children.push(Node::Interpolation(expr.to_string()));
138                        }
139                        _ => {}
140                    }
141                }
142            }
143            Rule::close_tag => {
144                for tag_inner in inner.into_inner() {
145                    if tag_inner.as_rule() == Rule::tag_name {
146                        close_name = Some(tag_inner.as_str().to_string());
147                    }
148                }
149            }
150            _ => {}
151        }
152    }
153
154    if let Some(ref close) = close_name {
155        assert_eq!(&name, close, "mismatched close tag: </{close}> does not match <{name}>");
156    }
157
158    Element { name, attrs, children }
159}
160
161fn parse_attr(pair: Pair<Rule>) -> (String, AttrValue) {
162    let mut attr_name = String::new();
163    let mut attr_value = AttrValue::String(String::new());
164
165    for inner in pair.into_inner() {
166        match inner.as_rule() {
167            Rule::attr_name => {
168                attr_name = inner.as_str().to_string();
169            }
170            Rule::attr_value => {
171                let val = inner.as_str();
172                let val = val.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(val);
173                let trimmed = val.trim();
174                if trimmed.starts_with('{') && trimmed.ends_with('}') {
175                    let expr = &trimmed[1..trimmed.len() - 1].trim();
176                    attr_value = AttrValue::Interpolation(expr.to_string());
177                } else {
178                    attr_value = AttrValue::String(val.to_string());
179                }
180            }
181            Rule::interpolation => {
182                let expr = inner.as_str().trim();
183                let expr = expr.strip_prefix('{').and_then(|s| s.strip_suffix('}')).unwrap_or(expr);
184                attr_value = AttrValue::Interpolation(expr.to_string());
185            }
186            _ => {}
187        }
188    }
189
190    (attr_name, attr_value)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    // --- Happy path ---
198
199    #[test]
200    fn test_simple_element_parses_correctly() {
201        let doc = parse("<Text size=\"xl\">hello</Text>").unwrap();
202        assert_eq!(doc.root.name, "Text");
203        assert_eq!(doc.root.attrs.len(), 1);
204        assert_eq!(doc.root.attrs[0].0, "size");
205        assert_eq!(doc.root.attrs[0].1, AttrValue::String("xl".into()));
206        assert_eq!(doc.root.children.len(), 1);
207        assert_eq!(doc.root.children[0], Node::Text("hello".into()));
208    }
209
210    #[test]
211    fn test_frontmatter_with_imports() {
212        let doc = parse("---\nuse crate::lib::State;\n---\n<App></App>").unwrap();
213        assert_eq!(doc.frontmatter, Some("use crate::lib::State;".to_string()));
214        assert_eq!(doc.root.name, "App");
215    }
216
217    #[test]
218    fn test_interpolation_in_text() {
219        let doc = parse("<Text>{state.msg}</Text>").unwrap();
220        assert_eq!(doc.root.children.len(), 1);
221        assert_eq!(doc.root.children[0], Node::Interpolation("state.msg".to_string()));
222    }
223
224    #[test]
225    fn test_nested_elements_with_attrs() {
226        let src = "<Stack direction=\"vertical\">\n  <Text>oi</Text>\n</Stack>";
227        let doc = parse(src).unwrap();
228        assert_eq!(doc.root.name, "Stack");
229        assert_eq!(doc.root.children.len(), 1);
230        let child = match &doc.root.children[0] {
231            Node::Element(el) => el,
232            _ => panic!("expected element node"),
233        };
234        assert_eq!(child.name, "Text");
235        assert_eq!(child.children.len(), 1);
236        assert_eq!(child.children[0], Node::Text("oi".to_string()));
237    }
238
239    #[test]
240    fn test_frontmatter_with_state_directive() {
241        let doc = parse("---\n@state CounterState\n---\n<App></App>").unwrap();
242        assert_eq!(doc.state_type, Some("CounterState".to_string()));
243        assert_eq!(doc.frontmatter, None);
244    }
245
246    #[test]
247    fn test_frontmatter_with_state_and_imports() {
248        let doc = parse("---\nuse crate::state::MyState;\n@state MyState\n---\n<App></App>").unwrap();
249        assert_eq!(doc.state_type, Some("MyState".to_string()));
250        assert_eq!(doc.frontmatter, Some("use crate::state::MyState;".to_string()));
251    }
252
253    #[test]
254    fn test_self_closing_tag() {
255        let doc = parse("<Button onclick=\"handle_click\" />").unwrap();
256        assert_eq!(doc.root.name, "Button");
257        assert_eq!(doc.root.attrs.len(), 1);
258        assert_eq!(doc.root.attrs[0].0, "onclick");
259        assert!(doc.root.children.is_empty());
260    }
261
262    #[test]
263    fn text_interpolation_in_attribute() {
264        let doc = parse("<Text size=\"{state.size}\">hey</Text>").unwrap();
265        assert_eq!(doc.root.attrs[0].1, AttrValue::Interpolation("state.size".into()));
266    }
267
268    #[test]
269    fn test_multiple_attributes() {
270        let src = "<Stack direction=\"horizontal\" gap=\"16\" id=\"main-stack\"></Stack>";
271        let doc = parse(src).unwrap();
272        assert_eq!(doc.root.attrs.len(), 3);
273        assert_eq!(doc.root.attrs[0].0, "direction");
274        assert_eq!(doc.root.attrs[1].0, "gap");
275        assert_eq!(doc.root.attrs[2].0, "id");
276    }
277
278    // --- Edge cases ---
279
280    #[test]
281    fn test_element_with_no_children() {
282        let doc = parse("<Div></Div>").unwrap();
283        assert_eq!(doc.root.name, "Div");
284        assert!(doc.root.children.is_empty());
285    }
286
287    #[test]
288    fn test_element_with_no_attributes() {
289        let doc = parse("<View></View>").unwrap();
290        assert_eq!(doc.root.name, "View");
291        assert!(doc.root.attrs.is_empty());
292    }
293
294    #[test]
295    fn test_whitespace_only_text_is_ignored() {
296        let doc = parse("<Text>   \n  </Text>").unwrap();
297        assert!(doc.root.children.is_empty());
298    }
299
300    #[test]
301    fn test_mixed_children_text_and_interpolation() {
302        let src = "<Text>Hello {name} !</Text>";
303        let doc = parse(src).unwrap();
304        assert_eq!(doc.root.children.len(), 3);
305        assert_eq!(doc.root.children[0], Node::Text("Hello".into()));
306        assert_eq!(doc.root.children[1], Node::Interpolation("name".into()));
307    }
308
309    #[test]
310    fn test_deeply_nested_elements() {
311        let src = "<A><B><C><D><E></E></D></C></B></A>";
312        let doc = parse(src).unwrap();
313        assert_eq!(doc.root.name, "A");
314        match &doc.root.children[0] {
315            Node::Element(b) => {
316                assert_eq!(b.name, "B");
317                match &b.children[0] {
318                    Node::Element(c) => {
319                        assert_eq!(c.name, "C");
320                        match &c.children[0] {
321                            Node::Element(d) => {
322                                assert_eq!(d.name, "D");
323                                match &d.children[0] {
324                                    Node::Element(e) => assert_eq!(e.name, "E"),
325                                    _ => panic!("expected element E"),
326                                }
327                            }
328                            _ => panic!("expected element D"),
329                        }
330                    }
331                    _ => panic!("expected element C"),
332                }
333            }
334            _ => panic!("expected element B"),
335        }
336    }
337
338    #[test]
339    fn test_tag_name_with_hyphen() {
340        let doc = parse("<my-component></my-component>").unwrap();
341        assert_eq!(doc.root.name, "my-component");
342    }
343
344    #[test]
345    fn test_tag_name_starting_with_underscore() {
346        let doc = parse("<_custom></_custom>").unwrap();
347        assert_eq!(doc.root.name, "_custom");
348    }
349
350    #[test]
351    fn test_frontmatter_only_with_state_and_other_lines() {
352        let src = "---\n@state MyState\nconst X: i32 = 42;\n---\n<Root></Root>";
353        let doc = parse(src).unwrap();
354        assert_eq!(doc.state_type, Some("MyState".into()));
355        assert_eq!(doc.frontmatter, Some("const X: i32 = 42;".into()));
356    }
357
358    #[test]
359    fn test_multiline_interpolation_expression() {
360        let src = "<Text>{ state . count }</Text>";
361        let doc = parse(src).unwrap();
362        match &doc.root.children[0] {
363            Node::Interpolation(expr) => {
364                assert!(expr.contains("state"));
365                assert!(expr.contains("count"));
366            }
367            _ => panic!("expected interpolation"),
368        }
369    }
370
371    // --- Invalid inputs / Error handling ---
372
373    #[test]
374    fn test_empty_string_returns_error() {
375        let result = parse("");
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn test_only_whitespace_returns_error() {
381        let result = parse("   \n  \t  ");
382        assert!(result.is_err());
383    }
384
385    #[test]
386    fn test_unclosed_tag_returns_error() {
387        let result = parse("<Text>unclosed");
388        assert!(result.is_err());
389    }
390
391    #[test]
392    fn test_unopened_close_tag_returns_error() {
393        let result = parse("</Text>");
394        assert!(result.is_err());
395    }
396
397    #[test]
398    #[should_panic(expected = "mismatched close tag")]
399    fn test_mismatched_close_tag_panics() {
400        parse("<Div></Text>").unwrap();
401    }
402
403    #[test]
404    fn test_invalid_tag_name_returns_error() {
405        let result = parse("<123invalid></123invalid>");
406        assert!(result.is_err());
407    }
408
409    #[test]
410    fn test_unclosed_frontmatter_returns_error() {
411        let result = parse("---\n@state Foo\n<Root></Root>");
412        assert!(result.is_err());
413    }
414
415    #[test]
416    fn test_invalid_attribute_syntax_returns_error() {
417        let result = parse("<Text size=>hello</Text>");
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn test_invalid_attribute_value_no_quotes_returns_error() {
423        let result = parse("<Text size=xl>hello</Text>");
424        assert!(result.is_err());
425    }
426
427    #[test]
428    fn test_self_closing_tag_with_content_before_close_errors() {
429        // This should parse as: <Text> with children "hello" and then <Button /> after
430        // Actually, it's valid because <Text>hello<Button/ ></Text> is well-formed
431        let result = parse("<Text>hello<Button/></Text>");
432        assert!(result.is_ok());
433    }
434
435    // --- Contract / Structural tests ---
436
437    #[test]
438    fn test_document_structure_contract() {
439        let doc = parse("<Root></Root>").unwrap();
440        assert_eq!(doc.root.name, "Root");
441        assert!(doc.frontmatter.is_none());
442        assert!(doc.state_type.is_none());
443        assert!(doc.root.attrs.is_empty());
444        assert!(doc.root.children.is_empty());
445    }
446
447    #[test]
448    fn test_node_variants_contract() {
449        let src = r#"<Container>
450            text content
451            <Inner />
452            {interp}
453        </Container>"#;
454        let doc = parse(src).unwrap();
455        let types: Vec<&str> = doc
456            .root
457            .children
458            .iter()
459            .map(|n| match n {
460                Node::Text(_) => "text",
461                Node::Element(_) => "element",
462                Node::Interpolation(_) => "interpolation",
463            })
464            .collect();
465        assert_eq!(types, ["text", "element", "interpolation"]);
466    }
467
468    #[test]
469    fn test_attr_value_types_contract() {
470        let src = "<Text size=\"lg\" data-value=\"{expr}\" />";
471        let doc = parse(src).unwrap();
472        assert_eq!(doc.root.attrs[0].1, AttrValue::String("lg".into()));
473        assert_eq!(doc.root.attrs[1].1, AttrValue::Interpolation("expr".into()));
474    }
475
476    // --- Regression tests ---
477
478    #[test]
479    fn test_regression_trailing_whitespace_after_tag() {
480        let doc = parse("<Text>hello</Text>  ").unwrap();
481        assert_eq!(doc.root.name, "Text");
482    }
483
484    #[test]
485    fn test_regression_leading_whitespace_before_tag() {
486        let doc = parse("  <Text>hello</Text>").unwrap();
487        assert_eq!(doc.root.name, "Text");
488    }
489
490    #[test]
491    fn test_regression_nested_same_component_name() {
492        let src = "<Item><Item><Item></Item></Item></Item>";
493        let doc = parse(src).unwrap();
494        assert_eq!(doc.root.name, "Item");
495        if let Node::Element(child) = &doc.root.children[0] {
496            assert_eq!(child.name, "Item");
497            if let Node::Element(grandchild) = &child.children[0] {
498                assert_eq!(grandchild.name, "Item");
499            } else {
500                panic!("expected inner Item");
501            }
502        } else {
503            panic!("expected child Item");
504        }
505    }
506
507    #[test]
508    fn test_regression_interpolation_with_adjacent_text() {
509        let doc = parse("<Text>Count: {count} items</Text>").unwrap();
510        assert_eq!(doc.root.children.len(), 3);
511        assert_eq!(doc.root.children[0], Node::Text("Count:".into()));
512        assert_eq!(doc.root.children[1], Node::Interpolation("count".into()));
513        assert_eq!(doc.root.children[2], Node::Text("items".into()));
514    }
515}