Skip to main content

crepuscularity_core/
parser.rs

1/// Runtime parser for the crepuscularity template DSL.
2/// Mirrors the compile-time proc-macro parser but operates on strings at runtime.
3use 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// ── Multi-component files ─────────────────────────────────────────────────────
21
22/// Metadata and default prop values for one component parsed from TOML frontmatter.
23#[derive(Debug, Clone, Default)]
24pub struct ComponentMeta {
25    /// Description string (from `description = "..."` in TOML).
26    pub description: Option<String>,
27    /// Default prop values as evaluable expression strings.
28    /// `[Card.defaults]` section: `title = "Untitled"` → `"title" → "\"Untitled\""`
29    pub defaults: HashMap<String, String>,
30}
31
32/// A named component extracted from a multi-component file.
33#[derive(Debug, Clone)]
34pub struct ComponentDef {
35    pub nodes: Vec<Node>,
36    pub meta: ComponentMeta,
37}
38
39/// Parsed multi-component `.crepus` file.
40///
41/// A multi-component file starts with an optional `+++...+++` TOML frontmatter
42/// block, followed by one or more component sections introduced by `--- Name`.
43///
44/// ```text
45/// +++
46/// [Card]
47/// description = "A simple card"
48///
49/// [Card.defaults]
50/// title = "Untitled"
51/// subtitle = ""
52///
53/// [Button]
54/// description = "A clickable button"
55///
56/// [Button.defaults]
57/// label = "Click me"
58/// variant = "primary"
59/// +++
60///
61/// --- Card
62/// div rounded-lg border p-4 mb-2
63///   div font-bold text-lg
64///     {title}
65///   div text-sm text-gray-400
66///     {subtitle}
67///   slot
68///
69/// --- Button
70/// $: default variant = "primary"
71/// button px-4 py-2 rounded
72///   {label}
73/// ```
74///
75/// Components are then included with `include components.crepus#Card title="Hello"`.
76pub struct ComponentFile {
77    pub components: HashMap<String, ComponentDef>,
78}
79
80/// Parse a multi-component `.crepus` file into a [`ComponentFile`].
81#[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(&section_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
208/// Convert a TOML scalar to an expression string usable by the evaluator.
209fn 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
236/// Returns true when the first non-blank, non-comment, non-`$:` line starts with `<`.
237/// This activates the JSX/HTML tag-based input syntax.
238fn 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
249/// Strip exactly `n` leading spaces from `s`, leaving any additional
250/// whitespace intact (it is content, not structural indent).
251fn 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    // Entire string was spaces (≤ n of them).
258    ""
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        // Multi-line strings: a `"` that opens but doesn't close on the same line
277        // is merged with subsequent lines until the closing `"` is found.
278        // Continuation lines have exactly `indent` leading spaces stripped (the
279        // structural indent of the opening `"` line); any additional whitespace
280        // is part of the string content and must be preserved.
281        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    // Normalize indentation so root elements always start at column 0.
298    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
305/// Returns true when `s` is a properly closed double-quoted string
306/// (starts with `"` and has a matching unescaped closing `"`).
307fn 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        // `else` and `else if` belong to the caller's `if`
349        if line == "else" || line.starts_with("else if ") {
350            break;
351        }
352
353        // Match arm terminators
354        if line.ends_with(" =>") || line == "_ =>" {
355            break;
356        }
357
358        // include directive
359        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        // $: let declaration
374        if let Some(decl) = try_parse_let_decl(line) {
375            nodes.push(Node::LetDecl(decl));
376            i += 1;
377            continue;
378        }
379
380        // match block
381        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 block
390        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        // Children: lines with strictly greater indent
400        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            // Raw expressions — rendered as evaluated text
419            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
517// ── Include parsing ───────────────────────────────────────────────────────────
518
519fn try_parse_include(line: &str) -> Option<IncludeNode> {
520    let rest = line.strip_prefix("include ")?;
521    // First token is the path (no spaces in path), rest are props
522    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        // Find key= (key is an identifier, no spaces)
543        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        // Extract value
554        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
562/// Extract a prop value token from the start of `s`.
563/// Returns `(expr_string, remaining)`.
564/// - `"quoted"` → returns the string content wrapped in quotes for the evaluator
565/// - `{expr}` → returns the inner expr string
566/// - `bare_token` → returns the token as-is (treated as a variable name / literal)
567fn 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    // Bare token: ends at next space
617    let end = s.find(' ').unwrap_or(s.len());
618    (s[..end].to_string(), &s[end..])
619}
620
621// ── Other parsers ─────────────────────────────────────────────────────────────
622
623fn 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            // animate:property={duration easing} or animate:property={duration easing repeat}
786            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            // HTML attribute: class="foo bar", type="button", data-action="x", key={expr}
809            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                    // class="foo bar" → individual class tokens
827                    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            // Boolean HTML attributes
859            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
894/// Parses the right-hand side of a `when:` attribute (everything after the `when:` prefix).
895///
896/// Accepts:
897/// - `{expr}=quoted-or-bare-classes` — expression may contain `=` (e.g. `{a == b}="x y"`)
898/// - `ident=classes` — simple condition (variable name)
899///
900/// Returns `(condition_source, raw_value)`; the caller should strip optional surrounding
901/// quotes from `raw_value` (matching the parser’s `when:` value rules) and split on whitespace
902/// for Tailwind tokens.
903pub 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
987/// Unescape `\n`, `\r`, `\t`, `\\`, `\"`, and `\'` inside a `.crepus` quoted text segment.
988///
989/// Unknown escapes keep the backslash (e.g. `\x` → `\x`).
990pub 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
1063// ── JSX / HTML tag syntax parser ──────────────────────────────────────────────
1064//
1065// Activated automatically when parse_template() detects JSX mode (first content
1066// line starts with `<`). Produces the same Node/Element AST as the indentation
1067// parser, so every backend — GPUI, web, webext — works unchanged.
1068//
1069// Supported syntax:
1070//   <div class="text-white w-full">children</div>   — HTML element
1071//   <div className="text-white">children</div>       — JSX className alias
1072//   <img src={url} />                               — self-closing
1073//   <if condition={score > 50}>...
1074//     <else>...</else>
1075//   </if>                                            — conditional
1076//   <else-if condition={...}>...<else-if>            — else-if chain
1077//   <for let="item" in={list}>...</for>              — loop
1078//   <match on={status}>
1079//     <case pattern="ok"><div>Good</div></case>
1080//   </match>                                         — match / switch
1081//   <include src="file.crepus#Card" title={t} />    — include (self-closing = no slot)
1082//   <include src="file.crepus">...</include>         — include with slot
1083//   <let name="x" value={42} />                     — let declaration
1084//   <let-default name="x" value={42} />             — default let
1085//   $: let x = 42                                   — also works in JSX files
1086
1087// ── Internal attribute types (private to this module) ─────────────────────────
1088
1089struct 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    /// Returns an evaluable expression string for the attribute value.
1110    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
1119// ── Entry point ───────────────────────────────────────────────────────────────
1120
1121fn 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
1388// ── Node builders ──────────────────────────────────────────────────────────────
1389
1390fn 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        // class / className → split into individual class tokens
1402        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                    // Dynamic expression — keep as a single {expr} class token
1409                    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        // class:name={condition}
1427        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        // when:{condition}="class1 class2 …"
1436        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        // @event={handler}
1463        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        // onEvent={handler} — React-style camelCase
1479        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        // animate:property={duration easing}
1498        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        // bind:prop={expr}
1511        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        // All other attributes with values → binding
1520        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
1574// ── Low-level helpers ──────────────────────────────────────────────────────────
1575
1576fn 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
1697/// Consume text content up to the next `<` tag, parsing `{expr}` interpolations.
1698fn 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        // `"` line is at indent=4 (4 leading spaces). Continuation "  indented line"
1809        // has 2 spaces → after stripping 4 structural spaces it becomes "indented line"
1810        // (only 4 available, so strip up to the string length → "indented line").
1811        // Actually strip min(4, available) → strips 2 available → "indented line".
1812        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        // "  indented line" with structural indent 2 → strips 2 → "indented line" (0 extra spaces)
1820        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        // "    more indented" with structural indent 2 → strips 2 → "  more indented" (2 extra)
1830        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        // structural indent of the `"` line is 2; continuation "    extra" has 4 spaces.
1844        // After stripping 2 structural spaces: "  extra" (2 extra spaces remain).
1845        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}