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 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 #[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 #[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 #[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 let result = parse("<Text>hello<Button/></Text>");
432 assert!(result.is_ok());
433 }
434
435 #[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 #[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}