1use std::collections::HashMap;
4
5use crate::ast::*;
6
7#[derive(Debug, Clone)]
8pub(crate) struct RawParseError {
9 pub message: String,
10 pub byte_offset: Option<usize>,
11}
12
13fn subslice_byte_offset(full: &str, tail: &str) -> usize {
14 let fp = full.as_ptr() as usize;
15 let tp = tail.as_ptr() as usize;
16 debug_assert!(tp >= fp && tp <= fp.saturating_add(full.len()));
17 tp.saturating_sub(fp)
18}
19
20#[derive(Debug, Clone, Default)]
24pub struct ComponentMeta {
25 pub description: Option<String>,
27 pub defaults: HashMap<String, String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct ComponentDef {
35 pub nodes: Vec<Node>,
36 pub meta: ComponentMeta,
37}
38
39pub struct ComponentFile {
77 pub components: HashMap<String, ComponentDef>,
78}
79
80#[tracing::instrument(skip(content), fields(len = content.len()))]
82pub fn parse_component_file(content: &str) -> Result<ComponentFile, String> {
83 parse_component_file_inner(content).map_err(|e| e.message)
84}
85
86pub(crate) fn parse_component_file_inner(content: &str) -> Result<ComponentFile, RawParseError> {
87 let (frontmatter_str, body, body_byte_in_file) = split_frontmatter_parts(content);
88
89 let mut meta_map: HashMap<String, ComponentMeta> = HashMap::new();
90 if let Some(toml_src) = frontmatter_str {
91 let toml_block_start = subslice_byte_offset(content, toml_src);
92 let value: toml::Value = toml_src
93 .parse()
94 .map_err(|e: toml::de::Error| RawParseError {
95 message: format!("TOML parse error in frontmatter: {e}"),
96 byte_offset: e.span().map(|r| toml_block_start + r.start),
97 })?;
98
99 if let toml::Value::Table(table) = value {
100 for (comp_name, comp_val) in &table {
101 if let toml::Value::Table(comp_table) = comp_val {
102 let mut meta = ComponentMeta::default();
103
104 if let Some(toml::Value::String(desc)) = comp_table.get("description") {
105 meta.description = Some(desc.clone());
106 }
107
108 if let Some(toml::Value::Table(defs)) = comp_table.get("defaults") {
109 for (k, v) in defs {
110 meta.defaults.insert(k.clone(), toml_value_to_expr(v));
111 }
112 }
113
114 meta_map.insert(comp_name.clone(), meta);
115 }
116 }
117 }
118 }
119
120 let sections = split_component_body_sections(body);
121 let mut components = HashMap::new();
122
123 for (name, section_content, sec_start_in_body) in sections {
124 let nodes = parse_template_raw(§ion_content).map_err(|mut e| {
125 if let Some(off) = e.byte_offset {
126 e.byte_offset = Some(body_byte_in_file + sec_start_in_body + off);
127 }
128 if e.byte_offset.is_some() {
129 e.message = format!("component {name:?}: {}", e.message);
130 }
131 e
132 })?;
133 let meta = meta_map.remove(&name).unwrap_or_default();
134 components.insert(name, ComponentDef { nodes, meta });
135 }
136
137 Ok(ComponentFile { components })
138}
139
140pub(crate) fn split_frontmatter_parts(content: &str) -> (Option<&str>, &str, usize) {
141 let trimmed = content.trim_start();
142 if !trimmed.starts_with("+++") {
143 return (None, content, 0);
144 }
145 let after_open = &trimmed[3..];
146 if let Some(close_pos) = after_open.find("\n+++") {
147 let raw_toml = &after_open[..close_pos];
148 let rest = &after_open[close_pos + 4..];
149 let body_start = subslice_byte_offset(content, rest);
150 (Some(raw_toml), rest, body_start)
151 } else {
152 (None, content, 0)
153 }
154}
155
156pub(crate) fn split_component_body_sections(body: &str) -> Vec<(String, String, usize)> {
157 let mut sections: Vec<(String, String, usize)> = Vec::new();
158 let mut current_name: Option<String> = None;
159 let mut current_lines: Vec<&str> = Vec::new();
160 let mut content_start_byte: Option<usize> = None;
161 let mut pending_content_start = 0usize;
162
163 let mut byte_pos = 0usize;
164 while byte_pos <= body.len() {
165 let line_start = byte_pos;
166 if line_start >= body.len() {
167 break;
168 }
169 let nl = body[line_start..].find('\n');
170 let line_end = nl.map(|n| line_start + n).unwrap_or(body.len());
171 let raw_line = &body[line_start..line_end];
172 byte_pos = if nl.is_some() {
173 line_end + 1
174 } else {
175 body.len()
176 };
177
178 let line_content = raw_line.strip_suffix('\r').unwrap_or(raw_line);
179 let trimmed = line_content.trim();
180
181 if let Some(name) = trimmed.strip_prefix("--- ") {
182 if let Some(prev) = current_name.take() {
183 let joined = current_lines.join("\n");
184 let start = content_start_byte.unwrap_or(pending_content_start);
185 sections.push((prev, joined, start));
186 current_lines.clear();
187 }
188 current_name = Some(name.trim().to_string());
189 content_start_byte = None;
190 pending_content_start = byte_pos;
191 } else if current_name.is_some() {
192 if content_start_byte.is_none() {
193 content_start_byte = Some(line_start);
194 }
195 current_lines.push(line_content);
196 }
197 }
198
199 if let Some(name) = current_name {
200 let joined = current_lines.join("\n");
201 let start = content_start_byte.unwrap_or(pending_content_start);
202 sections.push((name, joined, start));
203 }
204
205 sections
206}
207
208fn toml_value_to_expr(v: &toml::Value) -> String {
210 match v {
211 toml::Value::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
212 toml::Value::Integer(i) => i.to_string(),
213 toml::Value::Float(f) => f.to_string(),
214 toml::Value::Boolean(b) => b.to_string(),
215 _ => "\"\"".to_string(),
216 }
217}
218
219#[tracing::instrument(skip(template), fields(len = template.len()))]
220pub fn parse_template(template: &str) -> Result<Vec<Node>, String> {
221 parse_template_raw(template).map_err(|e| e.message)
222}
223
224pub(crate) fn parse_template_raw(template: &str) -> Result<Vec<Node>, RawParseError> {
225 if is_jsx_mode(template) {
226 parse_jsx_template(template)
227 } else {
228 let dec = crate::preprocess::strip_indent_decorators(template);
229 let lines = collect_lines(&dec.body);
230 let (mut nodes, _) = parse_nodes(&lines, 0, 0);
231 crate::preprocess::expand_class_aliases_in_nodes(&mut nodes, &dec.class_aliases);
232 Ok(nodes)
233 }
234}
235
236fn is_jsx_mode(template: &str) -> bool {
239 for line in template.lines() {
240 let t = line.trim();
241 if t.is_empty() || t.starts_with('#') || t.starts_with("$:") {
242 continue;
243 }
244 return t.starts_with('<');
245 }
246 false
247}
248
249fn strip_structural_indent(s: &str, n: usize) -> &str {
252 for (count, (byte_pos, ch)) in s.char_indices().enumerate() {
253 if count >= n || ch != ' ' {
254 return &s[byte_pos..];
255 }
256 }
257 ""
259}
260
261fn collect_lines(template: &str) -> Vec<(usize, String)> {
262 let source_lines: Vec<&str> = template.lines().collect();
263 let mut raw: Vec<(usize, String)> = Vec::new();
264 let mut i = 0;
265
266 while i < source_lines.len() {
267 let line = source_lines[i];
268 let trimmed = line.trim_start();
269 let indent = line.len() - trimmed.len();
270 i += 1;
271
272 if trimmed.is_empty() || trimmed.starts_with('#') {
273 continue;
274 }
275
276 if trimmed.starts_with('"') && !string_is_closed(trimmed) {
282 let mut combined = trimmed.to_string();
283 while i < source_lines.len() {
284 combined.push('\n');
285 combined.push_str(strip_structural_indent(source_lines[i], indent));
286 i += 1;
287 if string_is_closed(&combined) {
288 break;
289 }
290 }
291 raw.push((indent, combined));
292 } else {
293 raw.push((indent, trimmed.to_string()));
294 }
295 }
296
297 let min_indent = raw.iter().map(|(i, _)| *i).min().unwrap_or(0);
299 if min_indent == 0 {
300 return raw;
301 }
302 raw.into_iter().map(|(i, l)| (i - min_indent, l)).collect()
303}
304
305fn string_is_closed(s: &str) -> bool {
308 if !s.starts_with('"') {
309 return false;
310 }
311 let mut escaped = false;
312 let mut closes = 0usize;
313 for ch in s.chars().skip(1) {
314 if escaped {
315 escaped = false;
316 continue;
317 }
318 if ch == '\\' {
319 escaped = true;
320 continue;
321 }
322 if ch == '"' {
323 closes += 1;
324 }
325 }
326 closes > 0
327}
328
329fn parse_nodes(
330 lines: &[(usize, String)],
331 start: usize,
332 expected_indent: usize,
333) -> (Vec<Node>, usize) {
334 let mut nodes = Vec::new();
335 let mut i = start;
336
337 while i < lines.len() {
338 let (indent, line) = &lines[i];
339
340 if *indent < expected_indent {
341 break;
342 }
343 if *indent > expected_indent {
344 i += 1;
345 continue;
346 }
347
348 if line == "else" || line.starts_with("else if ") {
350 break;
351 }
352
353 if line.ends_with(" =>") || line == "_ =>" {
355 break;
356 }
357
358 if let Some(mut inc) = try_parse_include(line) {
360 i += 1;
361 let (slot, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
362 let child_indent = lines[i].0;
363 parse_nodes(lines, i, child_indent)
364 } else {
365 (vec![], i)
366 };
367 i = next_i;
368 inc.slot = slot;
369 nodes.push(Node::Include(inc));
370 continue;
371 }
372
373 if let Some(decl) = try_parse_let_decl(line) {
375 nodes.push(Node::LetDecl(decl));
376 i += 1;
377 continue;
378 }
379
380 if let Some(expr) = try_parse_match(line) {
382 i += 1;
383 let (arms, next_i) = parse_match_arms(lines, i, expected_indent);
384 i = next_i;
385 nodes.push(Node::Match(MatchBlock { expr, arms }));
386 continue;
387 }
388
389 if try_parse_if(line).is_some() {
391 let (node, next_i) = parse_if_node(lines, i, expected_indent);
392 i = next_i;
393 nodes.push(node);
394 continue;
395 }
396
397 i += 1;
398
399 let (children, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
401 let child_indent = lines[i].0;
402 parse_nodes(lines, i, child_indent)
403 } else {
404 (vec![], i)
405 };
406 i = next_i;
407
408 if let Some((pattern, iterator)) = try_parse_for(line) {
409 nodes.push(Node::For(ForBlock {
410 pattern,
411 iterator,
412 body: children,
413 }));
414 } else if line.starts_with('"') {
415 let parts = parse_text_template(line);
416 nodes.push(Node::Text(parts));
417 } else if is_raw_expr(line) {
418 nodes.push(Node::RawText(line[1..line.len() - 1].trim().to_string()));
420 } else {
421 let element = parse_element_line(line, children);
422 nodes.push(Node::Element(element));
423 }
424 }
425
426 (nodes, i)
427}
428
429fn parse_if_node(lines: &[(usize, String)], i: usize, expected_indent: usize) -> (Node, usize) {
430 let line = &lines[i].1;
431 let condition = try_parse_if(line).unwrap_or_default();
432 let mut i = i + 1;
433
434 let (then_children, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
435 let child_indent = lines[i].0;
436 parse_nodes(lines, i, child_indent)
437 } else {
438 (vec![], i)
439 };
440 i = next_i;
441
442 let else_children = if i < lines.len() && lines[i].0 == expected_indent {
443 let else_line = &lines[i].1;
444 if else_line == "else" {
445 i += 1;
446 if i < lines.len() && lines[i].0 > expected_indent {
447 let else_indent = lines[i].0;
448 let (else_nodes, next_i) = parse_nodes(lines, i, else_indent);
449 i = next_i;
450 Some(else_nodes)
451 } else {
452 Some(vec![])
453 }
454 } else if else_line.starts_with("else if ") {
455 let rewritten = else_line
456 .strip_prefix("else ")
457 .unwrap_or(else_line)
458 .to_string();
459 let mut patched = lines.to_vec();
460 patched[i].1 = rewritten;
461 let (else_if_node, next_i) = parse_if_node(&patched, i, expected_indent);
462 i = next_i;
463 Some(vec![else_if_node])
464 } else {
465 None
466 }
467 } else {
468 None
469 };
470
471 (
472 Node::If(IfBlock {
473 condition,
474 then_children,
475 else_children,
476 }),
477 i,
478 )
479}
480
481fn parse_match_arms(
482 lines: &[(usize, String)],
483 start: usize,
484 expected_indent: usize,
485) -> (Vec<MatchArm>, usize) {
486 let mut arms = Vec::new();
487 let mut i = start;
488
489 while i < lines.len() {
490 let (indent, line) = &lines[i];
491 if *indent < expected_indent {
492 break;
493 }
494 if *indent > expected_indent {
495 i += 1;
496 continue;
497 }
498
499 if let Some(pattern) = try_parse_match_arm(line) {
500 i += 1;
501 let (body, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
502 let body_indent = lines[i].0;
503 parse_nodes(lines, i, body_indent)
504 } else {
505 (vec![], i)
506 };
507 i = next_i;
508 arms.push(MatchArm { pattern, body });
509 } else {
510 break;
511 }
512 }
513
514 (arms, i)
515}
516
517fn try_parse_include(line: &str) -> Option<IncludeNode> {
520 let rest = line.strip_prefix("include ")?;
521 let (path, props_str) = match rest.find(' ') {
523 Some(pos) => (rest[..pos].trim().to_string(), rest[pos + 1..].trim()),
524 None => (rest.trim().to_string(), ""),
525 };
526 if path.is_empty() {
527 return None;
528 }
529 let props = parse_props(props_str);
530 Some(IncludeNode {
531 path,
532 props,
533 slot: vec![],
534 })
535}
536
537fn parse_props(s: &str) -> Vec<(String, String)> {
538 let mut props = Vec::new();
539 let mut remaining = s.trim();
540
541 while !remaining.is_empty() {
542 let eq_pos = match remaining.find('=') {
544 Some(p) => p,
545 None => break,
546 };
547 let key = remaining[..eq_pos].trim().to_string();
548 if key.is_empty() || key.contains(' ') {
549 break;
550 }
551 remaining = remaining[eq_pos + 1..].trim_start();
552
553 let (expr_str, rest) = extract_prop_value(remaining);
555 props.push((key, expr_str));
556 remaining = rest.trim_start();
557 }
558
559 props
560}
561
562fn extract_prop_value(s: &str) -> (String, &str) {
568 if s.is_empty() {
569 return (String::new(), s);
570 }
571
572 if s.starts_with('"') || s.starts_with('\'') {
573 let quote = s.as_bytes()[0];
574 let mut i = 1;
575 let mut escaped = false;
576 while i < s.len() {
577 let byte = s.as_bytes()[i];
578 if escaped {
579 escaped = false;
580 } else if byte == b'\\' {
581 escaped = true;
582 } else if byte == quote {
583 let content = &s[1..i];
584 let escaped_content = content.replace('\\', "\\\\").replace('"', "\\\"");
585 let expr = format!("\"{}\"", escaped_content);
586 let rest = if i < s.len() { &s[i + 1..] } else { "" };
587 return (expr, rest);
588 }
589 i += 1;
590 }
591
592 let content = &s[1..];
593 let escaped_content = content.replace('\\', "\\\\").replace('"', "\\\"");
594 let expr = format!("\"{}\"", escaped_content);
595 return (expr, "");
596 }
597
598 if s.starts_with('{') {
599 let mut depth = 0usize;
600 for (i, c) in s.char_indices() {
601 match c {
602 '{' => depth += 1,
603 '}' => {
604 depth -= 1;
605 if depth == 0 {
606 let expr = s[1..i].trim().to_string();
607 return (expr, &s[i + 1..]);
608 }
609 }
610 _ => {}
611 }
612 }
613 return (s.to_string(), "");
614 }
615
616 let end = s.find(' ').unwrap_or(s.len());
618 (s[..end].to_string(), &s[end..])
619}
620
621fn try_parse_if(line: &str) -> Option<String> {
624 let rest = line.strip_prefix("if ")?;
625 Some(extract_braced(rest.trim()).unwrap_or_else(|| rest.trim().to_string()))
626}
627
628fn try_parse_for(line: &str) -> Option<(String, String)> {
629 let rest = line.strip_prefix("for ")?;
630 let in_pos = rest.find(" in ")?;
631 let pattern = rest[..in_pos].trim().to_string();
632 let after_in = rest[in_pos + 4..].trim();
633 let iterator = extract_braced(after_in).unwrap_or_else(|| after_in.to_string());
634 Some((pattern, iterator))
635}
636
637fn try_parse_match(line: &str) -> Option<String> {
638 let rest = line.strip_prefix("match ")?;
639 Some(extract_braced(rest.trim()).unwrap_or_else(|| rest.trim().to_string()))
640}
641
642fn try_parse_match_arm(line: &str) -> Option<String> {
643 let pattern = line.strip_suffix(" =>")?;
644 let pattern = pattern.trim();
645 if pattern.starts_with('{') && pattern.ends_with('}') {
646 Some(pattern[1..pattern.len() - 1].trim().to_string())
647 } else {
648 Some(pattern.to_string())
649 }
650}
651
652fn try_parse_let_decl(line: &str) -> Option<LetDecl> {
653 let (rest, is_default) = if let Some(r) = line.strip_prefix("$: default ") {
654 (r, true)
655 } else if let Some(r) = line.strip_prefix("$: let ") {
656 (r, false)
657 } else {
658 return None;
659 };
660 let eq_pos = rest.find('=')?;
661 let name = rest[..eq_pos].trim().to_string();
662 let expr_str = rest[eq_pos + 1..].trim();
663 let expr = extract_braced(expr_str).unwrap_or_else(|| expr_str.to_string());
664 Some(LetDecl {
665 name,
666 expr,
667 is_default,
668 })
669}
670
671fn is_raw_expr(line: &str) -> bool {
672 line.starts_with('{') && line.ends_with('}') && {
673 let inner = &line[1..line.len() - 1];
674 !inner.contains('"')
675 }
676}
677
678fn extract_braced(s: &str) -> Option<String> {
679 if !s.starts_with('{') {
680 return None;
681 }
682 let mut depth = 0usize;
683 for (i, c) in s.char_indices() {
684 match c {
685 '{' => depth += 1,
686 '}' => {
687 depth -= 1;
688 if depth == 0 {
689 return Some(s[1..i].trim().to_string());
690 }
691 }
692 _ => {}
693 }
694 }
695 None
696}
697
698fn parse_element_line(line: &str, children: Vec<Node>) -> Element {
699 let tokens = tokenize_line(line);
700 if tokens.is_empty() {
701 return Element {
702 tag: "div".to_string(),
703 id: None,
704 classes: vec![],
705 conditional_classes: vec![],
706 event_handlers: vec![],
707 bindings: vec![],
708 animations: vec![],
709 children,
710 };
711 }
712
713 let tag = tokens[0].clone();
714 let mut children = children;
715 let inline_text = tokens
716 .last()
717 .filter(|token| is_inline_text_token(token))
718 .cloned();
719 let parse_limit = if inline_text.is_some() {
720 tokens.len().saturating_sub(1)
721 } else {
722 tokens.len()
723 };
724 if let Some(text) = inline_text {
725 children.insert(0, Node::Text(parse_text_template(&text)));
726 }
727
728 let mut id = None;
729 let mut classes = Vec::new();
730 let mut conditional_classes = Vec::new();
731 let mut event_handlers = Vec::new();
732 let mut bindings = Vec::new();
733 let mut animations = Vec::new();
734
735 for token in &tokens[1..parse_limit] {
736 if let Some(rest) = token.strip_prefix('@') {
737 if let Some(eq_pos) = rest.find('=') {
738 let event_part = &rest[..eq_pos];
739 let handler = strip_optional_quotes(&rest[eq_pos + 1..]).to_string();
740 let event = event_part.split('|').next().unwrap_or("").to_string();
741 let modifiers: Vec<String> = event_part
742 .split('|')
743 .skip(1)
744 .map(|s| s.to_string())
745 .collect();
746 event_handlers.push(EventHandler {
747 event,
748 modifiers,
749 handler,
750 });
751 }
752 } else if let Some(rest) = token.strip_prefix("when:") {
753 if let Some((condition, raw_classes)) = parse_when_attribute_suffix(rest) {
754 let classes_src = strip_optional_quotes(raw_classes.trim());
755 for class in classes_src.split_whitespace() {
756 if class.is_empty() {
757 continue;
758 }
759 conditional_classes.push(ConditionalClass {
760 class: class.to_string(),
761 condition: condition.clone(),
762 });
763 }
764 }
765 } else if let Some(rest) = token.strip_prefix("class:") {
766 if let Some(eq_pos) = rest.find('=') {
767 let class = rest[..eq_pos].to_string();
768 let cond_str = rest[eq_pos + 1..].trim();
769 let condition = if cond_str.starts_with('{') && cond_str.ends_with('}') {
770 cond_str[1..cond_str.len() - 1].trim().to_string()
771 } else {
772 cond_str.to_string()
773 };
774 conditional_classes.push(ConditionalClass { class, condition });
775 }
776 } else if let Some(rest) = token.strip_prefix("bind:") {
777 if let Some(eq_pos) = rest.find('=') {
778 let prop = rest[..eq_pos].to_string();
779 let value = rest[eq_pos + 1..]
780 .trim_matches(|c| c == '{' || c == '}')
781 .to_string();
782 bindings.push(Binding { prop, value });
783 }
784 } else if let Some(rest) = token.strip_prefix("animate:") {
785 if let Some(eq_pos) = rest.find('=') {
787 let property = rest[..eq_pos].to_string();
788 let value_str = rest[eq_pos + 1..]
789 .trim_matches(|c| c == '{' || c == '}')
790 .trim()
791 .to_string();
792 let parts: Vec<&str> = value_str.split_whitespace().collect();
793 let duration_expr = parts.first().unwrap_or(&"300ms").to_string();
794 let easing = parts.get(1).unwrap_or(&"linear").to_string();
795 let repeat = parts.get(2).map(|s| *s == "repeat").unwrap_or(false);
796 animations.push(AnimationSpec {
797 property,
798 duration_expr,
799 easing,
800 repeat,
801 });
802 }
803 } else if let Some(rest) = token.strip_prefix('#') {
804 if !rest.is_empty() {
805 id = Some(rest.to_string());
806 }
807 } else if token.contains('=') {
808 let eq_pos = token.find('=').unwrap();
810 let key = &token[..eq_pos];
811 let valid_key = !key.is_empty()
812 && key
813 .chars()
814 .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
815 if valid_key {
816 let raw = token[eq_pos + 1..].trim();
817 let unquoted = if raw.len() >= 2
818 && ((raw.starts_with('"') && raw.ends_with('"'))
819 || (raw.starts_with('\'') && raw.ends_with('\'')))
820 {
821 &raw[1..raw.len() - 1]
822 } else {
823 raw
824 };
825 if key == "class" {
826 for cls in unquoted.split_whitespace() {
828 classes.push(cls.to_string());
829 }
830 } else if key == "id" {
831 id = Some(unquoted.to_string());
832 } else {
833 let expr = if raw.starts_with('{') && raw.ends_with('}') {
834 raw[1..raw.len() - 1].trim().to_string()
835 } else {
836 format!("\"{}\"", unquoted)
837 };
838 bindings.push(Binding {
839 prop: key.to_string(),
840 value: expr,
841 });
842 }
843 } else {
844 classes.push(token.clone());
845 }
846 } else if matches!(
847 token.as_str(),
848 "checked"
849 | "disabled"
850 | "hidden"
851 | "required"
852 | "readonly"
853 | "multiple"
854 | "selected"
855 | "autofocus"
856 | "open"
857 ) {
858 bindings.push(Binding {
860 prop: token.clone(),
861 value: "\"\"".to_string(),
862 });
863 } else {
864 classes.push(token.clone());
865 }
866 }
867
868 Element {
869 tag,
870 id,
871 classes,
872 conditional_classes,
873 event_handlers,
874 bindings,
875 animations,
876 children,
877 }
878}
879
880fn is_inline_text_token(token: &str) -> bool {
881 token.len() >= 2 && token.starts_with('"') && token.ends_with('"')
882}
883
884fn strip_optional_quotes(s: &str) -> &str {
885 if s.len() >= 2
886 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
887 {
888 &s[1..s.len() - 1]
889 } else {
890 s
891 }
892}
893
894pub fn parse_when_attribute_suffix(src: &str) -> Option<(String, String)> {
904 let s = src.trim();
905 if s.is_empty() {
906 return None;
907 }
908 if s.starts_with('{') {
909 let mut depth = 0usize;
910 for (i, c) in s.char_indices() {
911 match c {
912 '{' => depth += 1,
913 '}' => {
914 depth -= 1;
915 if depth == 0 {
916 let cond = s[1..i].trim().to_string();
917 let mut tail = s[i + 1..].trim_start();
918 tail = tail.strip_prefix('=')?;
919 return Some((cond, tail.trim().to_string()));
920 }
921 }
922 _ => {}
923 }
924 }
925 return None;
926 }
927 let eq_pos = s.find('=')?;
928 let cond = s[..eq_pos].trim().to_string();
929 if cond.is_empty() {
930 return None;
931 }
932 Some((cond, s[eq_pos + 1..].trim().to_string()))
933}
934
935fn tokenize_line(line: &str) -> Vec<String> {
936 let line = normalize_fullwidth_braces(line);
937 let mut tokens = Vec::new();
938 let mut current = String::new();
939 let mut bracket_depth: usize = 0;
940 let mut brace_depth: usize = 0;
941 let mut in_string = false;
942 let mut string_char = ' ';
943
944 for ch in line.chars() {
945 match ch {
946 '[' if !in_string && brace_depth == 0 => {
947 bracket_depth += 1;
948 current.push(ch);
949 }
950 ']' if !in_string && brace_depth == 0 => {
951 bracket_depth = bracket_depth.saturating_sub(1);
952 current.push(ch);
953 }
954 '{' if !in_string && bracket_depth == 0 => {
955 brace_depth += 1;
956 current.push(ch);
957 }
958 '}' if !in_string && bracket_depth == 0 => {
959 brace_depth = brace_depth.saturating_sub(1);
960 current.push(ch);
961 }
962 '\'' | '"' => {
963 if in_string && ch == string_char {
964 in_string = false;
965 } else if !in_string {
966 in_string = true;
967 string_char = ch;
968 }
969 current.push(ch);
970 }
971 ' ' | '\t' if bracket_depth == 0 && brace_depth == 0 && !in_string => {
972 if !current.is_empty() {
973 tokens.push(current.clone());
974 current.clear();
975 }
976 }
977 _ => current.push(ch),
978 }
979 }
980
981 if !current.is_empty() {
982 tokens.push(current);
983 }
984 tokens
985}
986
987pub fn unescape_crepus_text_literal(s: &str) -> String {
991 let mut out = String::with_capacity(s.len());
992 let mut chars = s.chars();
993 while let Some(c) = chars.next() {
994 if c != '\\' {
995 out.push(c);
996 continue;
997 }
998 match chars.next() {
999 Some('n') => out.push('\n'),
1000 Some('r') => out.push('\r'),
1001 Some('t') => out.push('\t'),
1002 Some('\\') => out.push('\\'),
1003 Some('"') => out.push('"'),
1004 Some('\'') => out.push('\''),
1005 Some(other) => {
1006 out.push('\\');
1007 out.push(other);
1008 }
1009 None => out.push('\\'),
1010 }
1011 }
1012 out
1013}
1014
1015fn parse_text_template(line: &str) -> Vec<TextPart> {
1016 let content = if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
1017 &line[1..line.len() - 1]
1018 } else {
1019 line
1020 };
1021
1022 let mut parts = Vec::new();
1023 let mut literal = String::new();
1024 let mut chars = content.chars().peekable();
1025
1026 while let Some(ch) = chars.next() {
1027 if ch == '{' {
1028 if !literal.is_empty() {
1029 parts.push(TextPart::Literal(unescape_crepus_text_literal(&literal)));
1030 literal.clear();
1031 }
1032 let mut expr = String::new();
1033 let mut depth = 1usize;
1034 for ec in chars.by_ref() {
1035 match ec {
1036 '{' => {
1037 depth += 1;
1038 expr.push(ec);
1039 }
1040 '}' => {
1041 depth -= 1;
1042 if depth == 0 {
1043 break;
1044 }
1045 expr.push(ec);
1046 }
1047 _ => expr.push(ec),
1048 }
1049 }
1050 parts.push(TextPart::Expr(expr.trim().to_string()));
1051 } else {
1052 literal.push(ch);
1053 }
1054 }
1055
1056 if !literal.is_empty() {
1057 parts.push(TextPart::Literal(unescape_crepus_text_literal(&literal)));
1058 }
1059
1060 parts
1061}
1062
1063struct JsxAttr {
1090 key: String,
1091 value: JsxAttrValue,
1092}
1093
1094enum JsxAttrValue {
1095 Bool(bool),
1096 Str(String),
1097 Expr(String),
1098}
1099
1100impl JsxAttr {
1101 fn as_str(&self) -> Option<&str> {
1102 if let JsxAttrValue::Str(s) = &self.value {
1103 Some(s)
1104 } else {
1105 None
1106 }
1107 }
1108
1109 fn as_expr(&self) -> Option<String> {
1111 match &self.value {
1112 JsxAttrValue::Expr(e) => Some(e.clone()),
1113 JsxAttrValue::Str(s) => Some(format!("\"{}\"", s.replace('"', "\\\""))),
1114 JsxAttrValue::Bool(b) => Some(b.to_string()),
1115 }
1116 }
1117}
1118
1119fn normalize_jsx_mapped(s: &str) -> (String, Vec<usize>) {
1122 let mut norm = String::with_capacity(s.len());
1123 let mut map: Vec<usize> = Vec::with_capacity(s.len());
1124 for (orig_b, ch) in s.char_indices() {
1125 match ch {
1126 '\u{FF5B}' => {
1127 norm.push('{');
1128 map.push(orig_b);
1129 }
1130 '\u{FF5D}' => {
1131 norm.push('}');
1132 map.push(orig_b);
1133 }
1134 c => {
1135 let mut buf = [0u8; 4];
1136 let enc = c.encode_utf8(&mut buf);
1137 for _ in 0..enc.len() {
1138 map.push(orig_b);
1139 }
1140 norm.push_str(enc);
1141 }
1142 }
1143 }
1144 debug_assert_eq!(norm.len(), map.len());
1145 (norm, map)
1146}
1147
1148fn map_jsx_offset(map: &[usize], off: usize) -> usize {
1149 map.get(off).copied().unwrap_or(off)
1150}
1151
1152#[inline]
1153fn jsx_err(norm_root: &str, at: &str, message: impl Into<String>) -> RawParseError {
1154 RawParseError {
1155 message: message.into(),
1156 byte_offset: Some(subslice_byte_offset(norm_root, at)),
1157 }
1158}
1159
1160fn parse_jsx_template(src: &str) -> Result<Vec<Node>, RawParseError> {
1161 let (norm, map) = normalize_jsx_mapped(src);
1162 let root = norm.as_str();
1163 match parse_jsx_nodes(root, root) {
1164 Ok((nodes, _)) => Ok(nodes),
1165 Err(mut err) => {
1166 if let Some(off) = err.byte_offset.take() {
1167 err.byte_offset = Some(map_jsx_offset(&map, off));
1168 }
1169 Err(err)
1170 }
1171 }
1172}
1173
1174fn parse_jsx_nodes<'a>(
1175 norm_root: &'a str,
1176 src: &'a str,
1177) -> Result<(Vec<Node>, &'a str), RawParseError> {
1178 let mut nodes = Vec::new();
1179 let mut rest = src;
1180
1181 loop {
1182 let t = rest.trim_start();
1183
1184 if t.is_empty() {
1185 rest = t;
1186 break;
1187 }
1188 if t.starts_with("</") || t.starts_with("<else") {
1189 rest = t;
1190 break;
1191 }
1192 if t.starts_with("$:") {
1193 let end = t.find('\n').unwrap_or(t.len());
1194 let line = t[..end].trim();
1195 rest = &t[end..];
1196 if let Some(decl) = try_parse_let_decl(line) {
1197 nodes.push(Node::LetDecl(decl));
1198 }
1199 continue;
1200 }
1201 if t.starts_with('<') {
1202 rest = t;
1203 let (node, next) = parse_jsx_tag(norm_root, rest)?;
1204 nodes.push(node);
1205 rest = next;
1206 continue;
1207 }
1208 if t.starts_with('{') {
1209 rest = t;
1210 let (expr, next) = jsx_brace_expr(norm_root, rest)?;
1211 nodes.push(Node::RawText(expr));
1212 rest = next;
1213 continue;
1214 }
1215 let prev_len = rest.len();
1216 let (node_opt, next) = jsx_text_node(rest);
1217 if let Some(node) = node_opt {
1218 nodes.push(node);
1219 }
1220 rest = next;
1221 if rest.len() == prev_len {
1222 let skip = rest
1223 .char_indices()
1224 .nth(1)
1225 .map(|(i, _)| i)
1226 .unwrap_or(rest.len());
1227 rest = &rest[skip..];
1228 }
1229 }
1230
1231 Ok((nodes, rest))
1232}
1233
1234fn parse_jsx_tag<'a>(norm_root: &'a str, src: &'a str) -> Result<(Node, &'a str), RawParseError> {
1235 let src = src.trim_start();
1236 let after_lt = &src[1..];
1237 let name_end = after_lt
1238 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1239 .unwrap_or(after_lt.len());
1240 let tag = &after_lt[..name_end];
1241 let rest = after_lt[name_end..].trim_start();
1242
1243 let (attrs, after_gt, self_closing) = jsx_parse_attrs(norm_root, rest)?;
1244
1245 match tag {
1246 "if" => parse_jsx_if(norm_root, attrs, after_gt),
1247 "else" | "else-if" => Err(jsx_err(
1248 norm_root,
1249 src,
1250 format!("<{tag}> encountered outside <if>"),
1251 )),
1252 "for" => parse_jsx_for(norm_root, attrs, after_gt),
1253 "match" => parse_jsx_match(norm_root, attrs, after_gt),
1254 "include" if self_closing => Ok((jsx_build_include(attrs, vec![]), after_gt)),
1255 "include" => {
1256 let (slot, rest) = parse_jsx_nodes(norm_root, after_gt)?;
1257 let rest = jsx_close(norm_root, rest, "include")?;
1258 Ok((jsx_build_include(attrs, slot), rest))
1259 }
1260 "let" => Ok((Node::LetDecl(jsx_build_let(attrs, false)), after_gt)),
1261 "let-default" => Ok((Node::LetDecl(jsx_build_let(attrs, true)), after_gt)),
1262 _ if self_closing => Ok((
1263 Node::Element(jsx_build_element(tag, attrs, vec![])),
1264 after_gt,
1265 )),
1266 _ => {
1267 let (children, rest) = parse_jsx_nodes(norm_root, after_gt)?;
1268 let rest = jsx_close(norm_root, rest, tag)?;
1269 Ok((Node::Element(jsx_build_element(tag, attrs, children)), rest))
1270 }
1271 }
1272}
1273
1274fn parse_jsx_if<'a>(
1275 norm_root: &'a str,
1276 attrs: Vec<JsxAttr>,
1277 children_src: &'a str,
1278) -> Result<(Node, &'a str), RawParseError> {
1279 let condition = attrs
1280 .iter()
1281 .find(|a| matches!(a.key.as_str(), "condition" | "test" | "cond"))
1282 .and_then(|a| a.as_expr())
1283 .unwrap_or_default();
1284
1285 let (then_children, rest) = parse_jsx_nodes(norm_root, children_src)?;
1286 let rest = rest.trim_start();
1287
1288 let (else_children, rest) = if rest.starts_with("<else-if") {
1289 let after_name = rest.strip_prefix("<else-if").unwrap_or("").trim_start();
1290 let (ei_attrs, ei_body, _) = jsx_parse_attrs(norm_root, after_name)?;
1291 let (nested, next) = parse_jsx_if(norm_root, ei_attrs, ei_body)?;
1292 (Some(vec![nested]), next)
1293 } else if rest.starts_with("<else") {
1294 let after_name = rest.strip_prefix("<else").unwrap_or("").trim_start();
1295 let (_, else_body, self_closing) = jsx_parse_attrs(norm_root, after_name)?;
1296 if self_closing {
1297 (Some(vec![]), else_body)
1298 } else {
1299 let (else_nodes, after_nodes) = parse_jsx_nodes(norm_root, else_body)?;
1300 let after_close = jsx_close(norm_root, after_nodes, "else")?;
1301 (Some(else_nodes), after_close)
1302 }
1303 } else {
1304 (None, rest)
1305 };
1306
1307 let rest = jsx_close(norm_root, rest, "if")?;
1308 Ok((
1309 Node::If(IfBlock {
1310 condition,
1311 then_children,
1312 else_children,
1313 }),
1314 rest,
1315 ))
1316}
1317
1318fn parse_jsx_for<'a>(
1319 norm_root: &'a str,
1320 attrs: Vec<JsxAttr>,
1321 children_src: &'a str,
1322) -> Result<(Node, &'a str), RawParseError> {
1323 let pattern = attrs
1324 .iter()
1325 .find(|a| matches!(a.key.as_str(), "let" | "var"))
1326 .and_then(|a| a.as_str())
1327 .unwrap_or("")
1328 .to_string();
1329 let iterator = attrs
1330 .iter()
1331 .find(|a| a.key == "in")
1332 .and_then(|a| a.as_expr())
1333 .unwrap_or_default();
1334
1335 let (body, rest) = parse_jsx_nodes(norm_root, children_src)?;
1336 let rest = jsx_close(norm_root, rest, "for")?;
1337 Ok((
1338 Node::For(ForBlock {
1339 pattern,
1340 iterator,
1341 body,
1342 }),
1343 rest,
1344 ))
1345}
1346
1347fn parse_jsx_match<'a>(
1348 norm_root: &'a str,
1349 attrs: Vec<JsxAttr>,
1350 children_src: &'a str,
1351) -> Result<(Node, &'a str), RawParseError> {
1352 let expr = attrs
1353 .iter()
1354 .find(|a| matches!(a.key.as_str(), "on" | "value"))
1355 .and_then(|a| a.as_expr())
1356 .unwrap_or_default();
1357
1358 let mut arms = Vec::new();
1359 let mut rest = children_src.trim_start();
1360
1361 while rest.starts_with("<case") {
1362 let after_name = &rest["<case".len()..].trim_start();
1363 let (case_attrs, case_body, self_closing) = jsx_parse_attrs(norm_root, after_name)?;
1364 let pattern = case_attrs
1365 .iter()
1366 .find(|a| matches!(a.key.as_str(), "pattern" | "match" | "when"))
1367 .and_then(|a| match &a.value {
1368 JsxAttrValue::Str(s) => Some(s.clone()),
1369 JsxAttrValue::Expr(e) => Some(e.clone()),
1370 JsxAttrValue::Bool(_) => None,
1371 })
1372 .unwrap_or_else(|| "_".to_string());
1373 let (body, after_body): (Vec<Node>, &str) = if self_closing {
1374 (vec![], case_body)
1375 } else {
1376 let (b, r) = parse_jsx_nodes(norm_root, case_body)?;
1377 let r = jsx_close(norm_root, r, "case")?;
1378 (b, r)
1379 };
1380 arms.push(MatchArm { pattern, body });
1381 rest = after_body.trim_start();
1382 }
1383
1384 let rest = jsx_close(norm_root, rest, "match")?;
1385 Ok((Node::Match(MatchBlock { expr, arms }), rest))
1386}
1387
1388fn jsx_build_element(tag: &str, attrs: Vec<JsxAttr>, children: Vec<Node>) -> Element {
1391 let mut id = None;
1392 let mut classes = Vec::new();
1393 let mut conditional_classes = Vec::new();
1394 let mut event_handlers = Vec::new();
1395 let mut bindings = Vec::new();
1396 let mut animations = Vec::new();
1397
1398 for attr in attrs {
1399 let key = &attr.key;
1400
1401 if key == "class" || key == "className" {
1403 match &attr.value {
1404 JsxAttrValue::Str(s) => {
1405 classes.extend(s.split_whitespace().map(|c| c.to_string()));
1406 }
1407 JsxAttrValue::Expr(e) => {
1408 classes.push(format!("{{{}}}", e));
1410 }
1411 JsxAttrValue::Bool(_) => {}
1412 }
1413 continue;
1414 }
1415
1416 if key == "id" {
1417 if let Some(value) = attr.as_expr() {
1418 let trimmed = value.trim();
1419 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
1420 id = Some(trimmed[1..trimmed.len() - 1].to_string());
1421 }
1422 }
1423 continue;
1424 }
1425
1426 if let Some(class_name) = key.strip_prefix("class:") {
1428 conditional_classes.push(ConditionalClass {
1429 class: class_name.to_string(),
1430 condition: attr.as_expr().unwrap_or_default(),
1431 });
1432 continue;
1433 }
1434
1435 if let Some(cond_src) = key.strip_prefix("when:") {
1437 let condition = if cond_src.starts_with('{') && cond_src.ends_with('}') {
1438 cond_src[1..cond_src.len() - 1].trim().to_string()
1439 } else {
1440 cond_src.trim().to_string()
1441 };
1442 if condition.is_empty() {
1443 continue;
1444 }
1445 match &attr.value {
1446 JsxAttrValue::Str(s) => {
1447 for class in s.split_whitespace() {
1448 if class.is_empty() {
1449 continue;
1450 }
1451 conditional_classes.push(ConditionalClass {
1452 class: class.to_string(),
1453 condition: condition.clone(),
1454 });
1455 }
1456 }
1457 JsxAttrValue::Expr(_) | JsxAttrValue::Bool(_) => {}
1458 }
1459 continue;
1460 }
1461
1462 if let Some(event_part) = key.strip_prefix('@') {
1464 let event = event_part.split('|').next().unwrap_or("").to_string();
1465 let modifiers = event_part
1466 .split('|')
1467 .skip(1)
1468 .map(|s| s.to_string())
1469 .collect();
1470 event_handlers.push(EventHandler {
1471 event,
1472 modifiers,
1473 handler: attr.as_expr().unwrap_or_default(),
1474 });
1475 continue;
1476 }
1477
1478 if key.starts_with("on") && key.len() > 2 {
1480 let rest = &key[2..];
1481 if rest.starts_with(|c: char| c.is_ascii_uppercase()) {
1482 let first = rest.chars().next().unwrap();
1483 let event = format!(
1484 "{}{}",
1485 first.to_ascii_lowercase(),
1486 &rest[first.len_utf8()..]
1487 );
1488 event_handlers.push(EventHandler {
1489 event,
1490 modifiers: vec![],
1491 handler: attr.as_expr().unwrap_or_default(),
1492 });
1493 continue;
1494 }
1495 }
1496
1497 if let Some(prop) = key.strip_prefix("animate:") {
1499 let val = attr.as_expr().unwrap_or_default();
1500 let parts: Vec<&str> = val.split_whitespace().collect();
1501 animations.push(AnimationSpec {
1502 property: prop.to_string(),
1503 duration_expr: parts.first().unwrap_or(&"300ms").to_string(),
1504 easing: parts.get(1).unwrap_or(&"linear").to_string(),
1505 repeat: parts.get(2).map(|s| *s == "repeat").unwrap_or(false),
1506 });
1507 continue;
1508 }
1509
1510 if let Some(prop) = key.strip_prefix("bind:") {
1512 bindings.push(Binding {
1513 prop: prop.to_string(),
1514 value: attr.as_expr().unwrap_or_default(),
1515 });
1516 continue;
1517 }
1518
1519 if let Some(value) = attr.as_expr() {
1521 bindings.push(Binding {
1522 prop: key.clone(),
1523 value,
1524 });
1525 }
1526 }
1527
1528 Element {
1529 tag: tag.to_string(),
1530 id,
1531 classes,
1532 conditional_classes,
1533 event_handlers,
1534 bindings,
1535 animations,
1536 children,
1537 }
1538}
1539
1540fn jsx_build_include(attrs: Vec<JsxAttr>, slot: Vec<Node>) -> Node {
1541 let path = attrs
1542 .iter()
1543 .find(|a| matches!(a.key.as_str(), "src" | "path"))
1544 .and_then(|a| a.as_str())
1545 .unwrap_or("")
1546 .to_string();
1547 let props = attrs
1548 .iter()
1549 .filter(|a| !matches!(a.key.as_str(), "src" | "path"))
1550 .filter_map(|a| a.as_expr().map(|v| (a.key.clone(), v)))
1551 .collect();
1552 Node::Include(IncludeNode { path, props, slot })
1553}
1554
1555fn jsx_build_let(attrs: Vec<JsxAttr>, is_default: bool) -> LetDecl {
1556 let name = attrs
1557 .iter()
1558 .find(|a| a.key == "name")
1559 .and_then(|a| a.as_str())
1560 .unwrap_or("")
1561 .to_string();
1562 let expr = attrs
1563 .iter()
1564 .find(|a| a.key == "value")
1565 .and_then(|a| a.as_expr())
1566 .unwrap_or_default();
1567 LetDecl {
1568 name,
1569 expr,
1570 is_default,
1571 }
1572}
1573
1574fn jsx_parse_attrs<'a>(
1577 norm_root: &'a str,
1578 src: &'a str,
1579) -> Result<(Vec<JsxAttr>, &'a str, bool), RawParseError> {
1580 let mut attrs = Vec::new();
1581 let mut rest = src.trim_start();
1582 let mut self_closing = false;
1583
1584 loop {
1585 rest = rest.trim_start();
1586 if rest.is_empty() {
1587 return Err(jsx_err(norm_root, rest, "unclosed JSX tag"));
1588 }
1589 if rest.starts_with("/>") {
1590 self_closing = true;
1591 rest = &rest[2..];
1592 break;
1593 }
1594 if rest.starts_with('>') {
1595 rest = &rest[1..];
1596 break;
1597 }
1598
1599 let key_end = rest
1600 .find(|c: char| c.is_whitespace() || c == '=' || c == '>' || c == '/')
1601 .unwrap_or(rest.len());
1602 if key_end == 0 {
1603 rest = &rest[1..];
1604 continue;
1605 }
1606 let key = rest[..key_end].to_string();
1607 rest = rest[key_end..].trim_start();
1608
1609 if rest.starts_with('=') {
1610 rest = rest[1..].trim_start();
1611 let (value, next) = jsx_attr_value(norm_root, rest)?;
1612 attrs.push(JsxAttr { key, value });
1613 rest = next;
1614 } else {
1615 attrs.push(JsxAttr {
1616 key,
1617 value: JsxAttrValue::Bool(true),
1618 });
1619 }
1620 }
1621
1622 Ok((attrs, rest, self_closing))
1623}
1624
1625fn jsx_attr_value<'a>(
1626 norm_root: &'a str,
1627 src: &'a str,
1628) -> Result<(JsxAttrValue, &'a str), RawParseError> {
1629 if src.starts_with('"') {
1630 let mut i = 1;
1631 let bytes = src.as_bytes();
1632 while i < bytes.len() {
1633 match bytes[i] {
1634 b'\\' => i += 2,
1635 b'"' => {
1636 let content = src[1..i].replace("\\\"", "\"");
1637 return Ok((JsxAttrValue::Str(content), &src[i + 1..]));
1638 }
1639 _ => i += 1,
1640 }
1641 }
1642 let inner = src.strip_prefix('"').unwrap_or("");
1643 Ok((JsxAttrValue::Str(inner.replace("\\\"", "\"")), ""))
1644 } else if src.starts_with('\'') {
1645 let inner = src.strip_prefix('\'').unwrap_or(src);
1646 let end = inner.find('\'').unwrap_or(inner.len());
1647 Ok((
1648 JsxAttrValue::Str(inner[..end].to_string()),
1649 &inner[end + 1..],
1650 ))
1651 } else if src.starts_with('{') {
1652 let (expr, rest) = jsx_brace_expr(norm_root, src)?;
1653 Ok((JsxAttrValue::Expr(expr), rest))
1654 } else {
1655 let end = src
1656 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1657 .unwrap_or(src.len());
1658 let val = &src[..end];
1659 let value = match val {
1660 "true" => JsxAttrValue::Bool(true),
1661 "false" => JsxAttrValue::Bool(false),
1662 other => JsxAttrValue::Str(other.to_string()),
1663 };
1664 Ok((value, &src[end..]))
1665 }
1666}
1667
1668fn jsx_brace_expr<'a>(
1669 norm_root: &'a str,
1670 src: &'a str,
1671) -> Result<(String, &'a str), RawParseError> {
1672 let src = src.trim_start();
1673 if !src.starts_with('{') {
1674 return Err(jsx_err(
1675 norm_root,
1676 src,
1677 format!("expected '{{', got: {}", &src[..src.len().min(10)]),
1678 ));
1679 }
1680 let mut depth = 0usize;
1681 for (i, c) in src.char_indices() {
1682 match c {
1683 '{' => depth += 1,
1684 '}' => {
1685 depth -= 1;
1686 if depth == 0 {
1687 let expr = src[1..i].trim().to_string();
1688 return Ok((expr, &src[i + 1..]));
1689 }
1690 }
1691 _ => {}
1692 }
1693 }
1694 Err(jsx_err(norm_root, src, "unclosed '{' in JSX expression"))
1695}
1696
1697fn jsx_text_node(src: &str) -> (Option<Node>, &str) {
1699 let end = src.find('<').unwrap_or(src.len());
1700 if end == 0 {
1701 return (None, src);
1702 }
1703 let text = &src[..end];
1704 let trimmed = text.trim();
1705 if trimmed.is_empty() {
1706 return (None, &src[end..]);
1707 }
1708 let parts = parse_text_template(trimmed);
1709 (Some(Node::Text(parts)), &src[end..])
1710}
1711
1712fn jsx_close<'a>(norm_root: &str, src: &'a str, tag: &str) -> Result<&'a str, RawParseError> {
1713 let src = src.trim_start();
1714 let prefix = format!("</{}", tag);
1715 if let Some(rest) = src.strip_prefix(&prefix) {
1716 let rest = rest.trim_start();
1717 if let Some(rest) = rest.strip_prefix('>') {
1718 return Ok(rest);
1719 }
1720 }
1721 Err(jsx_err(
1722 norm_root,
1723 src,
1724 format!("expected </{}>, got: {}", tag, &src[..src.len().min(40)]),
1725 ))
1726}
1727
1728fn normalize_fullwidth_braces(s: &str) -> String {
1729 s.replace('\u{FF5B}', "{").replace('\u{FF5D}', "}")
1730}
1731
1732#[cfg(test)]
1733mod tests {
1734 use super::*;
1735
1736 #[test]
1737 fn strip_structural_indent_basic() {
1738 assert_eq!(strip_structural_indent(" hello", 4), "hello");
1739 assert_eq!(strip_structural_indent(" hello", 2), " hello");
1740 assert_eq!(strip_structural_indent(" hello", 0), " hello");
1741 assert_eq!(strip_structural_indent("hi", 4), "hi");
1742 assert_eq!(strip_structural_indent("", 4), "");
1743 }
1744
1745 fn text_from_node(node: &Node) -> String {
1746 let Node::Text(parts) = node else {
1747 panic!("expected Text node, got: {node:?}")
1748 };
1749 parts
1750 .iter()
1751 .map(|p| match p {
1752 crate::ast::TextPart::Literal(s) => s.clone(),
1753 crate::ast::TextPart::Expr(e) => format!("{{{e}}}"),
1754 })
1755 .collect()
1756 }
1757
1758 #[test]
1759 fn parse_when_attribute_suffix_braced_condition_with_equals() {
1760 let (c, v) = parse_when_attribute_suffix(r#"{a == b}="x y z""#).unwrap();
1761 assert_eq!(c, "a == b");
1762 assert_eq!(v, r#""x y z""#);
1763 }
1764
1765 #[test]
1766 fn parse_when_attribute_suffix_simple_ident() {
1767 let (c, v) = parse_when_attribute_suffix(r#"active=bg-red-500"#).unwrap();
1768 assert_eq!(c, "active");
1769 assert_eq!(v, "bg-red-500");
1770 }
1771
1772 #[test]
1773 fn when_attribute_indent_expands_to_conditional_classes() {
1774 let nodes = parse_template(
1775 r#"div base when:{flag}="a b" when:{!flag}="c"
1776 "hi""#,
1777 )
1778 .unwrap();
1779 let Node::Element(el) = &nodes[0] else {
1780 panic!("expected element");
1781 };
1782 assert_eq!(el.classes, vec!["base".to_string()]);
1783 assert_eq!(el.conditional_classes.len(), 3);
1784 assert_eq!(el.conditional_classes[0].class, "a");
1785 assert_eq!(el.conditional_classes[0].condition, "flag");
1786 assert_eq!(el.conditional_classes[1].class, "b");
1787 assert_eq!(el.conditional_classes[2].class, "c");
1788 assert_eq!(el.conditional_classes[2].condition, "!flag");
1789 }
1790
1791 #[test]
1792 fn when_attribute_jsx_expands_to_conditional_classes() {
1793 let nodes =
1794 parse_template(r#"<div class="base" when:{active}="font-bold text-white"></div>"#)
1795 .unwrap();
1796 let Node::Element(el) = &nodes[0] else {
1797 panic!("expected element");
1798 };
1799 assert_eq!(el.classes, vec!["base".to_string()]);
1800 assert_eq!(el.conditional_classes.len(), 2);
1801 assert_eq!(el.conditional_classes[0].class, "font-bold");
1802 assert_eq!(el.conditional_classes[0].condition, "active");
1803 assert_eq!(el.conditional_classes[1].class, "text-white");
1804 }
1805
1806 #[test]
1807 fn multiline_string_preserves_inner_indent() {
1808 let template = "pre\n \"line one\n indented line\n more indented\"";
1813 let nodes = parse_template(template).unwrap();
1814 let Node::Element(el) = &nodes[0] else {
1815 panic!("expected element")
1816 };
1817 let text = text_from_node(&el.children[0]);
1818 assert!(text.contains("indented line"), "got: {text:?}");
1819 let cont_indent = text
1821 .lines()
1822 .nth(1)
1823 .map(|l| l.len() - l.trim_start().len())
1824 .unwrap_or(0);
1825 assert_eq!(
1826 cont_indent, 0,
1827 "continuation at same level should have 0 leading spaces, got: {text:?}"
1828 );
1829 let deep_indent = text
1831 .lines()
1832 .nth(2)
1833 .map(|l| l.len() - l.trim_start().len())
1834 .unwrap_or(0);
1835 assert_eq!(
1836 deep_indent, 2,
1837 "deeper line should have 2 preserved leading spaces, got: {text:?}"
1838 );
1839 }
1840
1841 #[test]
1842 fn multiline_string_preserves_extra_indent() {
1843 let template = "pre\n \"first\n extra\"";
1846 let nodes = parse_template(template).unwrap();
1847 let Node::Element(el) = &nodes[0] else {
1848 panic!("expected element")
1849 };
1850 let text = text_from_node(&el.children[0]);
1851 assert!(
1852 text.contains(" extra"),
1853 "extra indent should be preserved, got: {text:?}"
1854 );
1855 }
1856
1857 #[test]
1858 fn quoted_line_unescapes_backslash_sequences() {
1859 let nodes = parse_template("pre\n \"a\\nb\\tc\\\\d\"").unwrap();
1860 let Node::Element(el) = &nodes[0] else {
1861 panic!("expected element");
1862 };
1863 let text = text_from_node(&el.children[0]);
1864 assert_eq!(text, "a\nb\tc\\d");
1865 }
1866
1867 #[test]
1868 fn unescape_crepus_text_literal_accepts_common_escapes() {
1869 assert_eq!(
1870 unescape_crepus_text_literal(r#"line\n\t\"quote""#),
1871 "line\n\t\"quote\""
1872 );
1873 }
1874}