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        if let Some(embed) = try_parse_embed(line) {
359            nodes.push(Node::Embed(embed));
360            i += 1;
361            continue;
362        }
363
364        // include directive
365        if let Some(mut inc) = try_parse_include(line) {
366            i += 1;
367            let (slot, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
368                let child_indent = lines[i].0;
369                parse_nodes(lines, i, child_indent)
370            } else {
371                (vec![], i)
372            };
373            i = next_i;
374            inc.slot = slot;
375            nodes.push(Node::Include(inc));
376            continue;
377        }
378
379        // $: let declaration
380        if let Some(decl) = try_parse_let_decl(line) {
381            nodes.push(Node::LetDecl(decl));
382            i += 1;
383            continue;
384        }
385
386        // match block
387        if let Some(expr) = try_parse_match(line) {
388            i += 1;
389            let (arms, next_i) = parse_match_arms(lines, i, expected_indent);
390            i = next_i;
391            nodes.push(Node::Match(MatchBlock { expr, arms }));
392            continue;
393        }
394
395        // if block
396        if try_parse_if(line).is_some() {
397            let (node, next_i) = parse_if_node(lines, i, expected_indent);
398            i = next_i;
399            nodes.push(node);
400            continue;
401        }
402
403        i += 1;
404
405        // Children: lines with strictly greater indent
406        let (children, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
407            let child_indent = lines[i].0;
408            parse_nodes(lines, i, child_indent)
409        } else {
410            (vec![], i)
411        };
412        i = next_i;
413
414        if let Some((pattern, iterator)) = try_parse_for(line) {
415            nodes.push(Node::For(ForBlock {
416                pattern,
417                iterator,
418                body: children,
419            }));
420        } else if line.starts_with('"') {
421            let parts = parse_text_template(line);
422            nodes.push(Node::Text(parts));
423        } else if is_raw_expr(line) {
424            // Raw expressions — rendered as evaluated text
425            nodes.push(Node::RawText(line[1..line.len() - 1].trim().to_string()));
426        } else {
427            let element = parse_element_line(line, children);
428            nodes.push(Node::Element(element));
429        }
430    }
431
432    (nodes, i)
433}
434
435fn parse_if_node(lines: &[(usize, String)], i: usize, expected_indent: usize) -> (Node, usize) {
436    let line = &lines[i].1;
437    let condition = try_parse_if(line).unwrap_or_default();
438    let mut i = i + 1;
439
440    let (then_children, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
441        let child_indent = lines[i].0;
442        parse_nodes(lines, i, child_indent)
443    } else {
444        (vec![], i)
445    };
446    i = next_i;
447
448    let else_children = if i < lines.len() && lines[i].0 == expected_indent {
449        let else_line = &lines[i].1;
450        if else_line == "else" {
451            i += 1;
452            if i < lines.len() && lines[i].0 > expected_indent {
453                let else_indent = lines[i].0;
454                let (else_nodes, next_i) = parse_nodes(lines, i, else_indent);
455                i = next_i;
456                Some(else_nodes)
457            } else {
458                Some(vec![])
459            }
460        } else if else_line.starts_with("else if ") {
461            let rewritten = else_line
462                .strip_prefix("else ")
463                .unwrap_or(else_line)
464                .to_string();
465            let mut patched = lines.to_vec();
466            patched[i].1 = rewritten;
467            let (else_if_node, next_i) = parse_if_node(&patched, i, expected_indent);
468            i = next_i;
469            Some(vec![else_if_node])
470        } else {
471            None
472        }
473    } else {
474        None
475    };
476
477    (
478        Node::If(IfBlock {
479            condition,
480            then_children,
481            else_children,
482        }),
483        i,
484    )
485}
486
487fn parse_match_arms(
488    lines: &[(usize, String)],
489    start: usize,
490    expected_indent: usize,
491) -> (Vec<MatchArm>, usize) {
492    let mut arms = Vec::new();
493    let mut i = start;
494
495    while i < lines.len() {
496        let (indent, line) = &lines[i];
497        if *indent < expected_indent {
498            break;
499        }
500        if *indent > expected_indent {
501            i += 1;
502            continue;
503        }
504
505        if let Some(pattern) = try_parse_match_arm(line) {
506            i += 1;
507            let (body, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
508                let body_indent = lines[i].0;
509                parse_nodes(lines, i, body_indent)
510            } else {
511                (vec![], i)
512            };
513            i = next_i;
514            arms.push(MatchArm { pattern, body });
515        } else {
516            break;
517        }
518    }
519
520    (arms, i)
521}
522
523// ── Include parsing ───────────────────────────────────────────────────────────
524
525fn try_parse_include(line: &str) -> Option<IncludeNode> {
526    let rest = line.strip_prefix("include ")?;
527    // First token is the path (no spaces in path), rest are props
528    let (path, props_str) = match rest.find(' ') {
529        Some(pos) => (rest[..pos].trim().to_string(), rest[pos + 1..].trim()),
530        None => (rest.trim().to_string(), ""),
531    };
532    if path.is_empty() {
533        return None;
534    }
535    let props = parse_props(props_str);
536    Some(IncludeNode {
537        path,
538        props,
539        slot: vec![],
540    })
541}
542
543fn try_parse_embed(line: &str) -> Option<EmbedNode> {
544    let rest = line.strip_prefix("embed ")?;
545    let (src, props_str) = match rest.find(' ') {
546        Some(pos) => (rest[..pos].trim().to_string(), rest[pos + 1..].trim()),
547        None => (rest.trim().to_string(), ""),
548    };
549    if src.is_empty() {
550        return None;
551    }
552    let mut props = parse_props(props_str);
553    let adapter = take_literal_prop(&mut props, "adapter");
554    Some(EmbedNode {
555        src,
556        adapter,
557        props,
558    })
559}
560
561fn take_literal_prop(props: &mut Vec<(String, String)>, key: &str) -> Option<String> {
562    let pos = props.iter().position(|(k, _)| k == key)?;
563    let (_, value) = props.remove(pos);
564    Some(unquote_expr_string(&value).unwrap_or(value))
565}
566
567fn unquote_expr_string(value: &str) -> Option<String> {
568    if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
569        Some(value[1..value.len() - 1].replace("\\\"", "\""))
570    } else {
571        None
572    }
573}
574
575fn parse_props(s: &str) -> Vec<(String, String)> {
576    let mut props = Vec::new();
577    let mut remaining = s.trim();
578
579    while !remaining.is_empty() {
580        // Find key= (key is an identifier, no spaces)
581        let eq_pos = match remaining.find('=') {
582            Some(p) => p,
583            None => break,
584        };
585        let key = remaining[..eq_pos].trim().to_string();
586        if key.is_empty() || key.contains(' ') {
587            break;
588        }
589        remaining = remaining[eq_pos + 1..].trim_start();
590
591        // Extract value
592        let (expr_str, rest) = extract_prop_value(remaining);
593        props.push((key, expr_str));
594        remaining = rest.trim_start();
595    }
596
597    props
598}
599
600/// Extract a prop value token from the start of `s`.
601/// Returns `(expr_string, remaining)`.
602/// - `"quoted"` → returns the string content wrapped in quotes for the evaluator
603/// - `{expr}` → returns the inner expr string
604/// - `bare_token` → returns the token as-is (treated as a variable name / literal)
605fn extract_prop_value(s: &str) -> (String, &str) {
606    if s.is_empty() {
607        return (String::new(), s);
608    }
609
610    if s.starts_with('"') || s.starts_with('\'') {
611        let quote = s.as_bytes()[0];
612        let mut i = 1;
613        let mut escaped = false;
614        while i < s.len() {
615            let byte = s.as_bytes()[i];
616            if escaped {
617                escaped = false;
618            } else if byte == b'\\' {
619                escaped = true;
620            } else if byte == quote {
621                let content = &s[1..i];
622                let escaped_content = content.replace('\\', "\\\\").replace('"', "\\\"");
623                let expr = format!("\"{}\"", escaped_content);
624                let rest = if i < s.len() { &s[i + 1..] } else { "" };
625                return (expr, rest);
626            }
627            i += 1;
628        }
629
630        let content = &s[1..];
631        let escaped_content = content.replace('\\', "\\\\").replace('"', "\\\"");
632        let expr = format!("\"{}\"", escaped_content);
633        return (expr, "");
634    }
635
636    if s.starts_with('{') {
637        let mut depth = 0usize;
638        for (i, c) in s.char_indices() {
639            match c {
640                '{' => depth += 1,
641                '}' => {
642                    depth -= 1;
643                    if depth == 0 {
644                        let expr = s[1..i].trim().to_string();
645                        return (expr, &s[i + 1..]);
646                    }
647                }
648                _ => {}
649            }
650        }
651        return (s.to_string(), "");
652    }
653
654    // Bare token: ends at next space
655    let end = s.find(' ').unwrap_or(s.len());
656    (s[..end].to_string(), &s[end..])
657}
658
659// ── Other parsers ─────────────────────────────────────────────────────────────
660
661fn try_parse_if(line: &str) -> Option<String> {
662    let rest = line.strip_prefix("if ")?;
663    Some(extract_braced(rest.trim()).unwrap_or_else(|| rest.trim().to_string()))
664}
665
666fn try_parse_for(line: &str) -> Option<(String, String)> {
667    let rest = line.strip_prefix("for ")?;
668    let in_pos = rest.find(" in ")?;
669    let pattern = rest[..in_pos].trim().to_string();
670    let after_in = rest[in_pos + 4..].trim();
671    let iterator = extract_braced(after_in).unwrap_or_else(|| after_in.to_string());
672    Some((pattern, iterator))
673}
674
675fn try_parse_match(line: &str) -> Option<String> {
676    let rest = line.strip_prefix("match ")?;
677    Some(extract_braced(rest.trim()).unwrap_or_else(|| rest.trim().to_string()))
678}
679
680fn try_parse_match_arm(line: &str) -> Option<String> {
681    let pattern = line.strip_suffix(" =>")?;
682    let pattern = pattern.trim();
683    if pattern.starts_with('{') && pattern.ends_with('}') {
684        Some(pattern[1..pattern.len() - 1].trim().to_string())
685    } else {
686        Some(pattern.to_string())
687    }
688}
689
690fn try_parse_let_decl(line: &str) -> Option<LetDecl> {
691    let (rest, is_default) = if let Some(r) = line.strip_prefix("$: default ") {
692        (r, true)
693    } else if let Some(r) = line.strip_prefix("$: let ") {
694        (r, false)
695    } else {
696        return None;
697    };
698    let eq_pos = rest.find('=')?;
699    let name = rest[..eq_pos].trim().to_string();
700    let expr_str = rest[eq_pos + 1..].trim();
701    let expr = extract_braced(expr_str).unwrap_or_else(|| expr_str.to_string());
702    Some(LetDecl {
703        name,
704        expr,
705        is_default,
706    })
707}
708
709fn is_raw_expr(line: &str) -> bool {
710    line.starts_with('{') && line.ends_with('}') && {
711        let inner = &line[1..line.len() - 1];
712        !inner.contains('"')
713    }
714}
715
716fn extract_braced(s: &str) -> Option<String> {
717    if !s.starts_with('{') {
718        return None;
719    }
720    let mut depth = 0usize;
721    for (i, c) in s.char_indices() {
722        match c {
723            '{' => depth += 1,
724            '}' => {
725                depth -= 1;
726                if depth == 0 {
727                    return Some(s[1..i].trim().to_string());
728                }
729            }
730            _ => {}
731        }
732    }
733    None
734}
735
736fn parse_element_line(line: &str, children: Vec<Node>) -> Element {
737    let tokens = tokenize_line(line);
738    if tokens.is_empty() {
739        return Element {
740            tag: "div".to_string(),
741            id: None,
742            classes: vec![],
743            conditional_classes: vec![],
744            event_handlers: vec![],
745            bindings: vec![],
746            animations: vec![],
747            children,
748        };
749    }
750
751    let tag = tokens[0].clone();
752    let mut children = children;
753    let inline_text = tokens
754        .last()
755        .filter(|token| is_inline_text_token(token))
756        .cloned();
757    let parse_limit = if inline_text.is_some() {
758        tokens.len().saturating_sub(1)
759    } else {
760        tokens.len()
761    };
762    if let Some(text) = inline_text {
763        children.insert(0, Node::Text(parse_text_template(&text)));
764    }
765
766    let mut id = None;
767    let mut classes = Vec::new();
768    let mut conditional_classes = Vec::new();
769    let mut event_handlers = Vec::new();
770    let mut bindings = Vec::new();
771    let mut animations = Vec::new();
772
773    for token in &tokens[1..parse_limit] {
774        if let Some(rest) = token.strip_prefix('@') {
775            if let Some(eq_pos) = rest.find('=') {
776                let event_part = &rest[..eq_pos];
777                let handler = strip_optional_quotes(&rest[eq_pos + 1..]).to_string();
778                let event = event_part.split('|').next().unwrap_or("").to_string();
779                let modifiers: Vec<String> = event_part
780                    .split('|')
781                    .skip(1)
782                    .map(|s| s.to_string())
783                    .collect();
784                event_handlers.push(EventHandler {
785                    event,
786                    modifiers,
787                    handler,
788                });
789            }
790        } else if let Some(rest) = token.strip_prefix("when:") {
791            if let Some((condition, raw_classes)) = parse_when_attribute_suffix(rest) {
792                let classes_src = strip_optional_quotes(raw_classes.trim());
793                for class in classes_src.split_whitespace() {
794                    if class.is_empty() {
795                        continue;
796                    }
797                    conditional_classes.push(ConditionalClass {
798                        class: class.to_string(),
799                        condition: condition.clone(),
800                    });
801                }
802            }
803        } else if let Some(rest) = token.strip_prefix("class:") {
804            if let Some(eq_pos) = rest.find('=') {
805                let class = rest[..eq_pos].to_string();
806                let cond_str = rest[eq_pos + 1..].trim();
807                let condition = if cond_str.starts_with('{') && cond_str.ends_with('}') {
808                    cond_str[1..cond_str.len() - 1].trim().to_string()
809                } else {
810                    cond_str.to_string()
811                };
812                conditional_classes.push(ConditionalClass { class, condition });
813            }
814        } else if let Some(rest) = token.strip_prefix("bind:") {
815            if let Some(eq_pos) = rest.find('=') {
816                let prop = rest[..eq_pos].to_string();
817                let value = rest[eq_pos + 1..]
818                    .trim_matches(|c| c == '{' || c == '}')
819                    .to_string();
820                bindings.push(Binding { prop, value });
821            }
822        } else if let Some(rest) = token.strip_prefix("animate:") {
823            // animate:property={duration easing} or animate:property={duration easing repeat}
824            if let Some(eq_pos) = rest.find('=') {
825                let property = rest[..eq_pos].to_string();
826                let value_str = rest[eq_pos + 1..]
827                    .trim_matches(|c| c == '{' || c == '}')
828                    .trim()
829                    .to_string();
830                let parts: Vec<&str> = value_str.split_whitespace().collect();
831                let duration_expr = parts.first().unwrap_or(&"300ms").to_string();
832                let easing = parts.get(1).unwrap_or(&"linear").to_string();
833                let repeat = parts.get(2).map(|s| *s == "repeat").unwrap_or(false);
834                animations.push(AnimationSpec {
835                    property,
836                    duration_expr,
837                    easing,
838                    repeat,
839                });
840            }
841        } else if let Some(rest) = token.strip_prefix('#') {
842            if !rest.is_empty() {
843                id = Some(rest.to_string());
844            }
845        } else if token.contains('=') {
846            // HTML attribute: class="foo bar", type="button", data-action="x", key={expr}
847            let eq_pos = token.find('=').unwrap();
848            let key = &token[..eq_pos];
849            let valid_key = !key.is_empty()
850                && key
851                    .chars()
852                    .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
853            if valid_key {
854                let raw = token[eq_pos + 1..].trim();
855                let unquoted = if raw.len() >= 2
856                    && ((raw.starts_with('"') && raw.ends_with('"'))
857                        || (raw.starts_with('\'') && raw.ends_with('\'')))
858                {
859                    &raw[1..raw.len() - 1]
860                } else {
861                    raw
862                };
863                if key == "class" {
864                    // class="foo bar" → individual class tokens
865                    for cls in unquoted.split_whitespace() {
866                        classes.push(cls.to_string());
867                    }
868                } else if key == "id" {
869                    id = Some(unquoted.to_string());
870                } else {
871                    let expr = if raw.starts_with('{') && raw.ends_with('}') {
872                        raw[1..raw.len() - 1].trim().to_string()
873                    } else {
874                        format!("\"{}\"", unquoted)
875                    };
876                    bindings.push(Binding {
877                        prop: key.to_string(),
878                        value: expr,
879                    });
880                }
881            } else {
882                classes.push(token.clone());
883            }
884        } else if matches!(
885            token.as_str(),
886            "checked"
887                | "disabled"
888                | "hidden"
889                | "required"
890                | "readonly"
891                | "multiple"
892                | "selected"
893                | "autofocus"
894                | "open"
895        ) {
896            // Boolean HTML attributes
897            bindings.push(Binding {
898                prop: token.clone(),
899                value: "\"\"".to_string(),
900            });
901        } else {
902            classes.push(token.clone());
903        }
904    }
905
906    Element {
907        tag,
908        id,
909        classes,
910        conditional_classes,
911        event_handlers,
912        bindings,
913        animations,
914        children,
915    }
916}
917
918fn is_inline_text_token(token: &str) -> bool {
919    token.len() >= 2 && token.starts_with('"') && token.ends_with('"')
920}
921
922fn strip_optional_quotes(s: &str) -> &str {
923    if s.len() >= 2
924        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
925    {
926        &s[1..s.len() - 1]
927    } else {
928        s
929    }
930}
931
932/// Parses the right-hand side of a `when:` attribute (everything after the `when:` prefix).
933///
934/// Accepts:
935/// - `{expr}=quoted-or-bare-classes` — expression may contain `=` (e.g. `{a == b}="x y"`)
936/// - `ident=classes` — simple condition (variable name)
937///
938/// Returns `(condition_source, raw_value)`; the caller should strip optional surrounding
939/// quotes from `raw_value` (matching the parser’s `when:` value rules) and split on whitespace
940/// for Tailwind tokens.
941pub fn parse_when_attribute_suffix(src: &str) -> Option<(String, String)> {
942    let s = src.trim();
943    if s.is_empty() {
944        return None;
945    }
946    if s.starts_with('{') {
947        let mut depth = 0usize;
948        for (i, c) in s.char_indices() {
949            match c {
950                '{' => depth += 1,
951                '}' => {
952                    depth -= 1;
953                    if depth == 0 {
954                        let cond = s[1..i].trim().to_string();
955                        let mut tail = s[i + 1..].trim_start();
956                        tail = tail.strip_prefix('=')?;
957                        return Some((cond, tail.trim().to_string()));
958                    }
959                }
960                _ => {}
961            }
962        }
963        return None;
964    }
965    let eq_pos = s.find('=')?;
966    let cond = s[..eq_pos].trim().to_string();
967    if cond.is_empty() {
968        return None;
969    }
970    Some((cond, s[eq_pos + 1..].trim().to_string()))
971}
972
973fn tokenize_line(line: &str) -> Vec<String> {
974    let line = normalize_fullwidth_braces(line);
975    let mut tokens = Vec::new();
976    let mut current = String::new();
977    let mut bracket_depth: usize = 0;
978    let mut brace_depth: usize = 0;
979    let mut in_string = false;
980    let mut string_char = ' ';
981
982    for ch in line.chars() {
983        match ch {
984            '[' if !in_string && brace_depth == 0 => {
985                bracket_depth += 1;
986                current.push(ch);
987            }
988            ']' if !in_string && brace_depth == 0 => {
989                bracket_depth = bracket_depth.saturating_sub(1);
990                current.push(ch);
991            }
992            '{' if !in_string && bracket_depth == 0 => {
993                brace_depth += 1;
994                current.push(ch);
995            }
996            '}' if !in_string && bracket_depth == 0 => {
997                brace_depth = brace_depth.saturating_sub(1);
998                current.push(ch);
999            }
1000            '\'' | '"' => {
1001                if in_string && ch == string_char {
1002                    in_string = false;
1003                } else if !in_string {
1004                    in_string = true;
1005                    string_char = ch;
1006                }
1007                current.push(ch);
1008            }
1009            ' ' | '\t' if bracket_depth == 0 && brace_depth == 0 && !in_string => {
1010                if !current.is_empty() {
1011                    tokens.push(current.clone());
1012                    current.clear();
1013                }
1014            }
1015            _ => current.push(ch),
1016        }
1017    }
1018
1019    if !current.is_empty() {
1020        tokens.push(current);
1021    }
1022    tokens
1023}
1024
1025/// Unescape `\n`, `\r`, `\t`, `\\`, `\"`, and `\'` inside a `.crepus` quoted text segment.
1026///
1027/// Unknown escapes keep the backslash (e.g. `\x` → `\x`).
1028pub fn unescape_crepus_text_literal(s: &str) -> String {
1029    let mut out = String::with_capacity(s.len());
1030    let mut chars = s.chars();
1031    while let Some(c) = chars.next() {
1032        if c != '\\' {
1033            out.push(c);
1034            continue;
1035        }
1036        match chars.next() {
1037            Some('n') => out.push('\n'),
1038            Some('r') => out.push('\r'),
1039            Some('t') => out.push('\t'),
1040            Some('\\') => out.push('\\'),
1041            Some('"') => out.push('"'),
1042            Some('\'') => out.push('\''),
1043            Some(other) => {
1044                out.push('\\');
1045                out.push(other);
1046            }
1047            None => out.push('\\'),
1048        }
1049    }
1050    out
1051}
1052
1053fn parse_text_template(line: &str) -> Vec<TextPart> {
1054    let content = if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
1055        &line[1..line.len() - 1]
1056    } else {
1057        line
1058    };
1059
1060    let mut parts = Vec::new();
1061    let mut literal = String::new();
1062    let mut chars = content.chars().peekable();
1063
1064    while let Some(ch) = chars.next() {
1065        if ch == '{' {
1066            if !literal.is_empty() {
1067                parts.push(TextPart::Literal(unescape_crepus_text_literal(&literal)));
1068                literal.clear();
1069            }
1070            let mut expr = String::new();
1071            let mut depth = 1usize;
1072            for ec in chars.by_ref() {
1073                match ec {
1074                    '{' => {
1075                        depth += 1;
1076                        expr.push(ec);
1077                    }
1078                    '}' => {
1079                        depth -= 1;
1080                        if depth == 0 {
1081                            break;
1082                        }
1083                        expr.push(ec);
1084                    }
1085                    _ => expr.push(ec),
1086                }
1087            }
1088            parts.push(TextPart::Expr(expr.trim().to_string()));
1089        } else {
1090            literal.push(ch);
1091        }
1092    }
1093
1094    if !literal.is_empty() {
1095        parts.push(TextPart::Literal(unescape_crepus_text_literal(&literal)));
1096    }
1097
1098    parts
1099}
1100
1101// ── JSX / HTML tag syntax parser ──────────────────────────────────────────────
1102//
1103// Activated automatically when parse_template() detects JSX mode (first content
1104// line starts with `<`). Produces the same Node/Element AST as the indentation
1105// parser, so every backend — GPUI, web, webext — works unchanged.
1106//
1107// Supported syntax:
1108//   <div class="text-white w-full">children</div>   — HTML element
1109//   <div className="text-white">children</div>       — JSX className alias
1110//   <img src={url} />                               — self-closing
1111//   <if condition={score > 50}>...
1112//     <else>...</else>
1113//   </if>                                            — conditional
1114//   <else-if condition={...}>...<else-if>            — else-if chain
1115//   <for let="item" in={list}>...</for>              — loop
1116//   <match on={status}>
1117//     <case pattern="ok"><div>Good</div></case>
1118//   </match>                                         — match / switch
1119//   <include src="file.crepus#Card" title={t} />    — include (self-closing = no slot)
1120//   <include src="file.crepus">...</include>         — include with slot
1121//   <let name="x" value={42} />                     — let declaration
1122//   <let-default name="x" value={42} />             — default let
1123//   $: let x = 42                                   — also works in JSX files
1124
1125// ── Internal attribute types (private to this module) ─────────────────────────
1126
1127struct JsxAttr {
1128    key: String,
1129    value: JsxAttrValue,
1130}
1131
1132enum JsxAttrValue {
1133    Bool(bool),
1134    Str(String),
1135    Expr(String),
1136}
1137
1138impl JsxAttr {
1139    fn as_str(&self) -> Option<&str> {
1140        if let JsxAttrValue::Str(s) = &self.value {
1141            Some(s)
1142        } else {
1143            None
1144        }
1145    }
1146
1147    /// Returns an evaluable expression string for the attribute value.
1148    fn as_expr(&self) -> Option<String> {
1149        match &self.value {
1150            JsxAttrValue::Expr(e) => Some(e.clone()),
1151            JsxAttrValue::Str(s) => Some(format!("\"{}\"", s.replace('"', "\\\""))),
1152            JsxAttrValue::Bool(b) => Some(b.to_string()),
1153        }
1154    }
1155}
1156
1157// ── Entry point ───────────────────────────────────────────────────────────────
1158
1159fn normalize_jsx_mapped(s: &str) -> (String, Vec<usize>) {
1160    let mut norm = String::with_capacity(s.len());
1161    let mut map: Vec<usize> = Vec::with_capacity(s.len());
1162    for (orig_b, ch) in s.char_indices() {
1163        match ch {
1164            '\u{FF5B}' => {
1165                norm.push('{');
1166                map.push(orig_b);
1167            }
1168            '\u{FF5D}' => {
1169                norm.push('}');
1170                map.push(orig_b);
1171            }
1172            c => {
1173                let mut buf = [0u8; 4];
1174                let enc = c.encode_utf8(&mut buf);
1175                for _ in 0..enc.len() {
1176                    map.push(orig_b);
1177                }
1178                norm.push_str(enc);
1179            }
1180        }
1181    }
1182    debug_assert_eq!(norm.len(), map.len());
1183    (norm, map)
1184}
1185
1186fn map_jsx_offset(map: &[usize], off: usize) -> usize {
1187    map.get(off).copied().unwrap_or(off)
1188}
1189
1190#[inline]
1191fn jsx_err(norm_root: &str, at: &str, message: impl Into<String>) -> RawParseError {
1192    RawParseError {
1193        message: message.into(),
1194        byte_offset: Some(subslice_byte_offset(norm_root, at)),
1195    }
1196}
1197
1198fn parse_jsx_template(src: &str) -> Result<Vec<Node>, RawParseError> {
1199    let (norm, map) = normalize_jsx_mapped(src);
1200    let root = norm.as_str();
1201    match parse_jsx_nodes(root, root) {
1202        Ok((nodes, _)) => Ok(nodes),
1203        Err(mut err) => {
1204            if let Some(off) = err.byte_offset.take() {
1205                err.byte_offset = Some(map_jsx_offset(&map, off));
1206            }
1207            Err(err)
1208        }
1209    }
1210}
1211
1212fn parse_jsx_nodes<'a>(
1213    norm_root: &'a str,
1214    src: &'a str,
1215) -> Result<(Vec<Node>, &'a str), RawParseError> {
1216    let mut nodes = Vec::new();
1217    let mut rest = src;
1218
1219    loop {
1220        let t = rest.trim_start();
1221
1222        if t.is_empty() {
1223            rest = t;
1224            break;
1225        }
1226        if t.starts_with("</") || t.starts_with("<else") {
1227            rest = t;
1228            break;
1229        }
1230        if t.starts_with("$:") {
1231            let end = t.find('\n').unwrap_or(t.len());
1232            let line = t[..end].trim();
1233            rest = &t[end..];
1234            if let Some(decl) = try_parse_let_decl(line) {
1235                nodes.push(Node::LetDecl(decl));
1236            }
1237            continue;
1238        }
1239        if t.starts_with('<') {
1240            rest = t;
1241            let (node, next) = parse_jsx_tag(norm_root, rest)?;
1242            nodes.push(node);
1243            rest = next;
1244            continue;
1245        }
1246        if t.starts_with('{') {
1247            rest = t;
1248            let (expr, next) = jsx_brace_expr(norm_root, rest)?;
1249            nodes.push(Node::RawText(expr));
1250            rest = next;
1251            continue;
1252        }
1253        let prev_len = rest.len();
1254        let (node_opt, next) = jsx_text_node(rest);
1255        if let Some(node) = node_opt {
1256            nodes.push(node);
1257        }
1258        rest = next;
1259        if rest.len() == prev_len {
1260            let skip = rest
1261                .char_indices()
1262                .nth(1)
1263                .map(|(i, _)| i)
1264                .unwrap_or(rest.len());
1265            rest = &rest[skip..];
1266        }
1267    }
1268
1269    Ok((nodes, rest))
1270}
1271
1272fn parse_jsx_tag<'a>(norm_root: &'a str, src: &'a str) -> Result<(Node, &'a str), RawParseError> {
1273    let src = src.trim_start();
1274    let after_lt = &src[1..];
1275    let name_end = after_lt
1276        .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1277        .unwrap_or(after_lt.len());
1278    let tag = &after_lt[..name_end];
1279    let rest = after_lt[name_end..].trim_start();
1280
1281    let (attrs, after_gt, self_closing) = jsx_parse_attrs(norm_root, rest)?;
1282
1283    match tag {
1284        "if" => parse_jsx_if(norm_root, attrs, after_gt),
1285        "else" | "else-if" => Err(jsx_err(
1286            norm_root,
1287            src,
1288            format!("<{tag}> encountered outside <if>"),
1289        )),
1290        "for" => parse_jsx_for(norm_root, attrs, after_gt),
1291        "match" => parse_jsx_match(norm_root, attrs, after_gt),
1292        "island" | "crepus-island" if self_closing => Ok((jsx_build_embed(attrs), after_gt)),
1293        "include" if self_closing => Ok((jsx_build_include(attrs, vec![]), after_gt)),
1294        "include" => {
1295            let (slot, rest) = parse_jsx_nodes(norm_root, after_gt)?;
1296            let rest = jsx_close(norm_root, rest, "include")?;
1297            Ok((jsx_build_include(attrs, slot), rest))
1298        }
1299        "let" => Ok((Node::LetDecl(jsx_build_let(attrs, false)), after_gt)),
1300        "let-default" => Ok((Node::LetDecl(jsx_build_let(attrs, true)), after_gt)),
1301        _ if self_closing => Ok((
1302            Node::Element(jsx_build_element(tag, attrs, vec![])),
1303            after_gt,
1304        )),
1305        _ => {
1306            let (children, rest) = parse_jsx_nodes(norm_root, after_gt)?;
1307            let rest = jsx_close(norm_root, rest, tag)?;
1308            Ok((Node::Element(jsx_build_element(tag, attrs, children)), rest))
1309        }
1310    }
1311}
1312
1313fn parse_jsx_if<'a>(
1314    norm_root: &'a str,
1315    attrs: Vec<JsxAttr>,
1316    children_src: &'a str,
1317) -> Result<(Node, &'a str), RawParseError> {
1318    let condition = attrs
1319        .iter()
1320        .find(|a| matches!(a.key.as_str(), "condition" | "test" | "cond"))
1321        .and_then(|a| a.as_expr())
1322        .unwrap_or_default();
1323
1324    let (then_children, rest) = parse_jsx_nodes(norm_root, children_src)?;
1325    let rest = rest.trim_start();
1326
1327    let (else_children, rest) = if rest.starts_with("<else-if") {
1328        let after_name = rest.strip_prefix("<else-if").unwrap_or("").trim_start();
1329        let (ei_attrs, ei_body, _) = jsx_parse_attrs(norm_root, after_name)?;
1330        let (nested, next) = parse_jsx_if(norm_root, ei_attrs, ei_body)?;
1331        (Some(vec![nested]), next)
1332    } else if rest.starts_with("<else") {
1333        let after_name = rest.strip_prefix("<else").unwrap_or("").trim_start();
1334        let (_, else_body, self_closing) = jsx_parse_attrs(norm_root, after_name)?;
1335        if self_closing {
1336            (Some(vec![]), else_body)
1337        } else {
1338            let (else_nodes, after_nodes) = parse_jsx_nodes(norm_root, else_body)?;
1339            let after_close = jsx_close(norm_root, after_nodes, "else")?;
1340            (Some(else_nodes), after_close)
1341        }
1342    } else {
1343        (None, rest)
1344    };
1345
1346    let rest = jsx_close(norm_root, rest, "if")?;
1347    Ok((
1348        Node::If(IfBlock {
1349            condition,
1350            then_children,
1351            else_children,
1352        }),
1353        rest,
1354    ))
1355}
1356
1357fn parse_jsx_for<'a>(
1358    norm_root: &'a str,
1359    attrs: Vec<JsxAttr>,
1360    children_src: &'a str,
1361) -> Result<(Node, &'a str), RawParseError> {
1362    let pattern = attrs
1363        .iter()
1364        .find(|a| matches!(a.key.as_str(), "let" | "var"))
1365        .and_then(|a| a.as_str())
1366        .unwrap_or("")
1367        .to_string();
1368    let iterator = attrs
1369        .iter()
1370        .find(|a| a.key == "in")
1371        .and_then(|a| a.as_expr())
1372        .unwrap_or_default();
1373
1374    let (body, rest) = parse_jsx_nodes(norm_root, children_src)?;
1375    let rest = jsx_close(norm_root, rest, "for")?;
1376    Ok((
1377        Node::For(ForBlock {
1378            pattern,
1379            iterator,
1380            body,
1381        }),
1382        rest,
1383    ))
1384}
1385
1386fn parse_jsx_match<'a>(
1387    norm_root: &'a str,
1388    attrs: Vec<JsxAttr>,
1389    children_src: &'a str,
1390) -> Result<(Node, &'a str), RawParseError> {
1391    let expr = attrs
1392        .iter()
1393        .find(|a| matches!(a.key.as_str(), "on" | "value"))
1394        .and_then(|a| a.as_expr())
1395        .unwrap_or_default();
1396
1397    let mut arms = Vec::new();
1398    let mut rest = children_src.trim_start();
1399
1400    while rest.starts_with("<case") {
1401        let after_name = &rest["<case".len()..].trim_start();
1402        let (case_attrs, case_body, self_closing) = jsx_parse_attrs(norm_root, after_name)?;
1403        let pattern = case_attrs
1404            .iter()
1405            .find(|a| matches!(a.key.as_str(), "pattern" | "match" | "when"))
1406            .and_then(|a| match &a.value {
1407                JsxAttrValue::Str(s) => Some(s.clone()),
1408                JsxAttrValue::Expr(e) => Some(e.clone()),
1409                JsxAttrValue::Bool(_) => None,
1410            })
1411            .unwrap_or_else(|| "_".to_string());
1412        let (body, after_body): (Vec<Node>, &str) = if self_closing {
1413            (vec![], case_body)
1414        } else {
1415            let (b, r) = parse_jsx_nodes(norm_root, case_body)?;
1416            let r = jsx_close(norm_root, r, "case")?;
1417            (b, r)
1418        };
1419        arms.push(MatchArm { pattern, body });
1420        rest = after_body.trim_start();
1421    }
1422
1423    let rest = jsx_close(norm_root, rest, "match")?;
1424    Ok((Node::Match(MatchBlock { expr, arms }), rest))
1425}
1426
1427// ── Node builders ──────────────────────────────────────────────────────────────
1428
1429fn jsx_build_element(tag: &str, attrs: Vec<JsxAttr>, children: Vec<Node>) -> Element {
1430    let mut id = None;
1431    let mut classes = Vec::new();
1432    let mut conditional_classes = Vec::new();
1433    let mut event_handlers = Vec::new();
1434    let mut bindings = Vec::new();
1435    let mut animations = Vec::new();
1436
1437    for attr in attrs {
1438        let key = &attr.key;
1439
1440        // class / className → split into individual class tokens
1441        if key == "class" || key == "className" {
1442            match &attr.value {
1443                JsxAttrValue::Str(s) => {
1444                    classes.extend(s.split_whitespace().map(|c| c.to_string()));
1445                }
1446                JsxAttrValue::Expr(e) => {
1447                    // Dynamic expression — keep as a single {expr} class token
1448                    classes.push(format!("{{{}}}", e));
1449                }
1450                JsxAttrValue::Bool(_) => {}
1451            }
1452            continue;
1453        }
1454
1455        if key == "id" {
1456            if let Some(value) = attr.as_expr() {
1457                let trimmed = value.trim();
1458                if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
1459                    id = Some(trimmed[1..trimmed.len() - 1].to_string());
1460                }
1461            }
1462            continue;
1463        }
1464
1465        // class:name={condition}
1466        if let Some(class_name) = key.strip_prefix("class:") {
1467            conditional_classes.push(ConditionalClass {
1468                class: class_name.to_string(),
1469                condition: attr.as_expr().unwrap_or_default(),
1470            });
1471            continue;
1472        }
1473
1474        // when:{condition}="class1 class2 …"
1475        if let Some(cond_src) = key.strip_prefix("when:") {
1476            let condition = if cond_src.starts_with('{') && cond_src.ends_with('}') {
1477                cond_src[1..cond_src.len() - 1].trim().to_string()
1478            } else {
1479                cond_src.trim().to_string()
1480            };
1481            if condition.is_empty() {
1482                continue;
1483            }
1484            match &attr.value {
1485                JsxAttrValue::Str(s) => {
1486                    for class in s.split_whitespace() {
1487                        if class.is_empty() {
1488                            continue;
1489                        }
1490                        conditional_classes.push(ConditionalClass {
1491                            class: class.to_string(),
1492                            condition: condition.clone(),
1493                        });
1494                    }
1495                }
1496                JsxAttrValue::Expr(_) | JsxAttrValue::Bool(_) => {}
1497            }
1498            continue;
1499        }
1500
1501        // @event={handler}
1502        if let Some(event_part) = key.strip_prefix('@') {
1503            let event = event_part.split('|').next().unwrap_or("").to_string();
1504            let modifiers = event_part
1505                .split('|')
1506                .skip(1)
1507                .map(|s| s.to_string())
1508                .collect();
1509            event_handlers.push(EventHandler {
1510                event,
1511                modifiers,
1512                handler: attr.as_expr().unwrap_or_default(),
1513            });
1514            continue;
1515        }
1516
1517        // onEvent={handler} — React-style camelCase
1518        if key.starts_with("on") && key.len() > 2 {
1519            let rest = &key[2..];
1520            if rest.starts_with(|c: char| c.is_ascii_uppercase()) {
1521                let first = rest.chars().next().unwrap();
1522                let event = format!(
1523                    "{}{}",
1524                    first.to_ascii_lowercase(),
1525                    &rest[first.len_utf8()..]
1526                );
1527                event_handlers.push(EventHandler {
1528                    event,
1529                    modifiers: vec![],
1530                    handler: attr.as_expr().unwrap_or_default(),
1531                });
1532                continue;
1533            }
1534        }
1535
1536        // animate:property={duration easing}
1537        if let Some(prop) = key.strip_prefix("animate:") {
1538            let val = attr.as_expr().unwrap_or_default();
1539            let parts: Vec<&str> = val.split_whitespace().collect();
1540            animations.push(AnimationSpec {
1541                property: prop.to_string(),
1542                duration_expr: parts.first().unwrap_or(&"300ms").to_string(),
1543                easing: parts.get(1).unwrap_or(&"linear").to_string(),
1544                repeat: parts.get(2).map(|s| *s == "repeat").unwrap_or(false),
1545            });
1546            continue;
1547        }
1548
1549        // bind:prop={expr}
1550        if let Some(prop) = key.strip_prefix("bind:") {
1551            bindings.push(Binding {
1552                prop: prop.to_string(),
1553                value: attr.as_expr().unwrap_or_default(),
1554            });
1555            continue;
1556        }
1557
1558        // All other attributes with values → binding
1559        if let Some(value) = attr.as_expr() {
1560            bindings.push(Binding {
1561                prop: key.clone(),
1562                value,
1563            });
1564        }
1565    }
1566
1567    Element {
1568        tag: tag.to_string(),
1569        id,
1570        classes,
1571        conditional_classes,
1572        event_handlers,
1573        bindings,
1574        animations,
1575        children,
1576    }
1577}
1578
1579fn jsx_build_include(attrs: Vec<JsxAttr>, slot: Vec<Node>) -> Node {
1580    let path = attrs
1581        .iter()
1582        .find(|a| matches!(a.key.as_str(), "src" | "path"))
1583        .and_then(|a| a.as_str())
1584        .unwrap_or("")
1585        .to_string();
1586    let props = attrs
1587        .iter()
1588        .filter(|a| !matches!(a.key.as_str(), "src" | "path"))
1589        .filter_map(|a| a.as_expr().map(|v| (a.key.clone(), v)))
1590        .collect();
1591    Node::Include(IncludeNode { path, props, slot })
1592}
1593
1594fn jsx_build_embed(attrs: Vec<JsxAttr>) -> Node {
1595    let src = attrs
1596        .iter()
1597        .find(|a| matches!(a.key.as_str(), "src" | "path"))
1598        .and_then(|a| a.as_str())
1599        .unwrap_or("")
1600        .to_string();
1601    let adapter = attrs
1602        .iter()
1603        .find(|a| a.key == "adapter")
1604        .and_then(|a| a.as_str())
1605        .map(|s| s.to_string());
1606    let props = attrs
1607        .iter()
1608        .filter(|a| !matches!(a.key.as_str(), "src" | "path" | "adapter"))
1609        .filter_map(|a| a.as_expr().map(|v| (a.key.clone(), v)))
1610        .collect();
1611    Node::Embed(EmbedNode {
1612        src,
1613        adapter,
1614        props,
1615    })
1616}
1617
1618fn jsx_build_let(attrs: Vec<JsxAttr>, is_default: bool) -> LetDecl {
1619    let name = attrs
1620        .iter()
1621        .find(|a| a.key == "name")
1622        .and_then(|a| a.as_str())
1623        .unwrap_or("")
1624        .to_string();
1625    let expr = attrs
1626        .iter()
1627        .find(|a| a.key == "value")
1628        .and_then(|a| a.as_expr())
1629        .unwrap_or_default();
1630    LetDecl {
1631        name,
1632        expr,
1633        is_default,
1634    }
1635}
1636
1637// ── Low-level helpers ──────────────────────────────────────────────────────────
1638
1639fn jsx_parse_attrs<'a>(
1640    norm_root: &'a str,
1641    src: &'a str,
1642) -> Result<(Vec<JsxAttr>, &'a str, bool), RawParseError> {
1643    let mut attrs = Vec::new();
1644    let mut rest = src.trim_start();
1645    let mut self_closing = false;
1646
1647    loop {
1648        rest = rest.trim_start();
1649        if rest.is_empty() {
1650            return Err(jsx_err(norm_root, rest, "unclosed JSX tag"));
1651        }
1652        if rest.starts_with("/>") {
1653            self_closing = true;
1654            rest = &rest[2..];
1655            break;
1656        }
1657        if rest.starts_with('>') {
1658            rest = &rest[1..];
1659            break;
1660        }
1661
1662        let key_end = rest
1663            .find(|c: char| c.is_whitespace() || c == '=' || c == '>' || c == '/')
1664            .unwrap_or(rest.len());
1665        if key_end == 0 {
1666            rest = &rest[1..];
1667            continue;
1668        }
1669        let key = rest[..key_end].to_string();
1670        rest = rest[key_end..].trim_start();
1671
1672        if rest.starts_with('=') {
1673            rest = rest[1..].trim_start();
1674            let (value, next) = jsx_attr_value(norm_root, rest)?;
1675            attrs.push(JsxAttr { key, value });
1676            rest = next;
1677        } else {
1678            attrs.push(JsxAttr {
1679                key,
1680                value: JsxAttrValue::Bool(true),
1681            });
1682        }
1683    }
1684
1685    Ok((attrs, rest, self_closing))
1686}
1687
1688fn jsx_attr_value<'a>(
1689    norm_root: &'a str,
1690    src: &'a str,
1691) -> Result<(JsxAttrValue, &'a str), RawParseError> {
1692    if src.starts_with('"') {
1693        let mut i = 1;
1694        let bytes = src.as_bytes();
1695        while i < bytes.len() {
1696            match bytes[i] {
1697                b'\\' => i += 2,
1698                b'"' => {
1699                    let content = src[1..i].replace("\\\"", "\"");
1700                    return Ok((JsxAttrValue::Str(content), &src[i + 1..]));
1701                }
1702                _ => i += 1,
1703            }
1704        }
1705        let inner = src.strip_prefix('"').unwrap_or("");
1706        Ok((JsxAttrValue::Str(inner.replace("\\\"", "\"")), ""))
1707    } else if src.starts_with('\'') {
1708        let inner = src.strip_prefix('\'').unwrap_or(src);
1709        let end = inner.find('\'').unwrap_or(inner.len());
1710        Ok((
1711            JsxAttrValue::Str(inner[..end].to_string()),
1712            &inner[end + 1..],
1713        ))
1714    } else if src.starts_with('{') {
1715        let (expr, rest) = jsx_brace_expr(norm_root, src)?;
1716        Ok((JsxAttrValue::Expr(expr), rest))
1717    } else {
1718        let end = src
1719            .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1720            .unwrap_or(src.len());
1721        let val = &src[..end];
1722        let value = match val {
1723            "true" => JsxAttrValue::Bool(true),
1724            "false" => JsxAttrValue::Bool(false),
1725            other => JsxAttrValue::Str(other.to_string()),
1726        };
1727        Ok((value, &src[end..]))
1728    }
1729}
1730
1731fn jsx_brace_expr<'a>(
1732    norm_root: &'a str,
1733    src: &'a str,
1734) -> Result<(String, &'a str), RawParseError> {
1735    let src = src.trim_start();
1736    if !src.starts_with('{') {
1737        return Err(jsx_err(
1738            norm_root,
1739            src,
1740            format!("expected '{{', got: {}", &src[..src.len().min(10)]),
1741        ));
1742    }
1743    let mut depth = 0usize;
1744    for (i, c) in src.char_indices() {
1745        match c {
1746            '{' => depth += 1,
1747            '}' => {
1748                depth -= 1;
1749                if depth == 0 {
1750                    let expr = src[1..i].trim().to_string();
1751                    return Ok((expr, &src[i + 1..]));
1752                }
1753            }
1754            _ => {}
1755        }
1756    }
1757    Err(jsx_err(norm_root, src, "unclosed '{' in JSX expression"))
1758}
1759
1760/// Consume text content up to the next `<` tag, parsing `{expr}` interpolations.
1761fn jsx_text_node(src: &str) -> (Option<Node>, &str) {
1762    let end = src.find('<').unwrap_or(src.len());
1763    if end == 0 {
1764        return (None, src);
1765    }
1766    let text = &src[..end];
1767    let trimmed = text.trim();
1768    if trimmed.is_empty() {
1769        return (None, &src[end..]);
1770    }
1771    let parts = parse_text_template(trimmed);
1772    (Some(Node::Text(parts)), &src[end..])
1773}
1774
1775fn jsx_close<'a>(norm_root: &str, src: &'a str, tag: &str) -> Result<&'a str, RawParseError> {
1776    let src = src.trim_start();
1777    let prefix = format!("</{}", tag);
1778    if let Some(rest) = src.strip_prefix(&prefix) {
1779        let rest = rest.trim_start();
1780        if let Some(rest) = rest.strip_prefix('>') {
1781            return Ok(rest);
1782        }
1783    }
1784    Err(jsx_err(
1785        norm_root,
1786        src,
1787        format!("expected </{}>, got: {}", tag, &src[..src.len().min(40)]),
1788    ))
1789}
1790
1791fn normalize_fullwidth_braces(s: &str) -> String {
1792    s.replace('\u{FF5B}', "{").replace('\u{FF5D}', "}")
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797    use super::*;
1798
1799    #[test]
1800    fn strip_structural_indent_basic() {
1801        assert_eq!(strip_structural_indent("    hello", 4), "hello");
1802        assert_eq!(strip_structural_indent("    hello", 2), "  hello");
1803        assert_eq!(strip_structural_indent("    hello", 0), "    hello");
1804        assert_eq!(strip_structural_indent("hi", 4), "hi");
1805        assert_eq!(strip_structural_indent("", 4), "");
1806    }
1807
1808    fn text_from_node(node: &Node) -> String {
1809        let Node::Text(parts) = node else {
1810            panic!("expected Text node, got: {node:?}")
1811        };
1812        parts
1813            .iter()
1814            .map(|p| match p {
1815                crate::ast::TextPart::Literal(s) => s.clone(),
1816                crate::ast::TextPart::Expr(e) => format!("{{{e}}}"),
1817            })
1818            .collect()
1819    }
1820
1821    #[test]
1822    fn parse_when_attribute_suffix_braced_condition_with_equals() {
1823        let (c, v) = parse_when_attribute_suffix(r#"{a == b}="x y z""#).unwrap();
1824        assert_eq!(c, "a == b");
1825        assert_eq!(v, r#""x y z""#);
1826    }
1827
1828    #[test]
1829    fn parse_when_attribute_suffix_simple_ident() {
1830        let (c, v) = parse_when_attribute_suffix(r#"active=bg-red-500"#).unwrap();
1831        assert_eq!(c, "active");
1832        assert_eq!(v, "bg-red-500");
1833    }
1834
1835    #[test]
1836    fn when_attribute_indent_expands_to_conditional_classes() {
1837        let nodes = parse_template(
1838            r#"div base when:{flag}="a b" when:{!flag}="c"
1839  "hi""#,
1840        )
1841        .unwrap();
1842        let Node::Element(el) = &nodes[0] else {
1843            panic!("expected element");
1844        };
1845        assert_eq!(el.classes, vec!["base".to_string()]);
1846        assert_eq!(el.conditional_classes.len(), 3);
1847        assert_eq!(el.conditional_classes[0].class, "a");
1848        assert_eq!(el.conditional_classes[0].condition, "flag");
1849        assert_eq!(el.conditional_classes[1].class, "b");
1850        assert_eq!(el.conditional_classes[2].class, "c");
1851        assert_eq!(el.conditional_classes[2].condition, "!flag");
1852    }
1853
1854    #[test]
1855    fn when_attribute_jsx_expands_to_conditional_classes() {
1856        let nodes =
1857            parse_template(r#"<div class="base" when:{active}="font-bold text-white"></div>"#)
1858                .unwrap();
1859        let Node::Element(el) = &nodes[0] else {
1860            panic!("expected element");
1861        };
1862        assert_eq!(el.classes, vec!["base".to_string()]);
1863        assert_eq!(el.conditional_classes.len(), 2);
1864        assert_eq!(el.conditional_classes[0].class, "font-bold");
1865        assert_eq!(el.conditional_classes[0].condition, "active");
1866        assert_eq!(el.conditional_classes[1].class, "text-white");
1867    }
1868
1869    #[test]
1870    fn embed_indent_parses_src_adapter_and_props() {
1871        let nodes =
1872            parse_template(r#"embed ./islands/wave.ts adapter="module" title="Wave" count={n}"#)
1873                .unwrap();
1874        let Node::Embed(embed) = &nodes[0] else {
1875            panic!("expected embed");
1876        };
1877        assert_eq!(embed.src, "./islands/wave.ts");
1878        assert_eq!(embed.adapter.as_deref(), Some("module"));
1879        assert_eq!(embed.props.len(), 2);
1880        assert_eq!(
1881            embed.props[0],
1882            ("title".to_string(), "\"Wave\"".to_string())
1883        );
1884        assert_eq!(embed.props[1], ("count".to_string(), "n".to_string()));
1885    }
1886
1887    #[test]
1888    fn island_jsx_parses_src_adapter_and_props() {
1889        let nodes = parse_template(
1890            r#"<island src="./islands/wave.ts" adapter="module" title="Wave" count={n} />"#,
1891        )
1892        .unwrap();
1893        let Node::Embed(embed) = &nodes[0] else {
1894            panic!("expected embed");
1895        };
1896        assert_eq!(embed.src, "./islands/wave.ts");
1897        assert_eq!(embed.adapter.as_deref(), Some("module"));
1898        assert_eq!(embed.props.len(), 2);
1899        assert_eq!(
1900            embed.props[0],
1901            ("title".to_string(), "\"Wave\"".to_string())
1902        );
1903        assert_eq!(embed.props[1], ("count".to_string(), "n".to_string()));
1904    }
1905
1906    #[test]
1907    fn multiline_string_preserves_inner_indent() {
1908        // `"` line is at indent=4 (4 leading spaces). Continuation "  indented line"
1909        // has 2 spaces → after stripping 4 structural spaces it becomes "indented line"
1910        // (only 4 available, so strip up to the string length → "indented line").
1911        // Actually strip min(4, available) → strips 2 available → "indented line".
1912        let template = "pre\n  \"line one\n  indented line\n    more indented\"";
1913        let nodes = parse_template(template).unwrap();
1914        let Node::Element(el) = &nodes[0] else {
1915            panic!("expected element")
1916        };
1917        let text = text_from_node(&el.children[0]);
1918        assert!(text.contains("indented line"), "got: {text:?}");
1919        // "  indented line" with structural indent 2 → strips 2 → "indented line" (0 extra spaces)
1920        let cont_indent = text
1921            .lines()
1922            .nth(1)
1923            .map(|l| l.len() - l.trim_start().len())
1924            .unwrap_or(0);
1925        assert_eq!(
1926            cont_indent, 0,
1927            "continuation at same level should have 0 leading spaces, got: {text:?}"
1928        );
1929        // "    more indented" with structural indent 2 → strips 2 → "  more indented" (2 extra)
1930        let deep_indent = text
1931            .lines()
1932            .nth(2)
1933            .map(|l| l.len() - l.trim_start().len())
1934            .unwrap_or(0);
1935        assert_eq!(
1936            deep_indent, 2,
1937            "deeper line should have 2 preserved leading spaces, got: {text:?}"
1938        );
1939    }
1940
1941    #[test]
1942    fn multiline_string_preserves_extra_indent() {
1943        // structural indent of the `"` line is 2; continuation "    extra" has 4 spaces.
1944        // After stripping 2 structural spaces: "  extra" (2 extra spaces remain).
1945        let template = "pre\n  \"first\n    extra\"";
1946        let nodes = parse_template(template).unwrap();
1947        let Node::Element(el) = &nodes[0] else {
1948            panic!("expected element")
1949        };
1950        let text = text_from_node(&el.children[0]);
1951        assert!(
1952            text.contains("  extra"),
1953            "extra indent should be preserved, got: {text:?}"
1954        );
1955    }
1956
1957    #[test]
1958    fn quoted_line_unescapes_backslash_sequences() {
1959        let nodes = parse_template("pre\n  \"a\\nb\\tc\\\\d\"").unwrap();
1960        let Node::Element(el) = &nodes[0] else {
1961            panic!("expected element");
1962        };
1963        let text = text_from_node(&el.children[0]);
1964        assert_eq!(text, "a\nb\tc\\d");
1965    }
1966
1967    #[test]
1968    fn unescape_crepus_text_literal_accepts_common_escapes() {
1969        assert_eq!(
1970            unescape_crepus_text_literal(r#"line\n\t\"quote""#),
1971            "line\n\t\"quote\""
1972        );
1973    }
1974}