Skip to main content

velox_sfc/
codegen.rs

1use std::path::Path;
2
3use crate::sfc::Sfc;
4
5/// Extremely simple stub codegen that emits a Rust module with raw blocks.
6/// Later, you'll compile `<template>` into VNodes and `<style>` into native styles.
7pub fn to_stub_rs(sfc: &Sfc, component_name: &str) -> String {
8    to_stub_rs_inner(sfc, component_name, None, true)
9}
10
11/// Same as `to_stub_rs` but accepts an optional base path for import resolution.
12/// When provided, relative imports are resolved from this path instead of CWD.
13///
14/// The output is wrapped in `pub mod {name} { ... }`, suitable for `include!()`
15/// in a standalone build script context.
16pub fn to_stub_rs_with_base(sfc: &Sfc, component_name: &str, base_path: Option<&Path>) -> String {
17    to_stub_rs_inner(sfc, component_name, base_path, true)
18}
19
20/// Same as `to_stub_rs_with_base` but emits the module body *without* the
21/// `pub mod {name} { ... }` wrapper. Use this when the caller already manages
22/// module declarations (e.g., via `#[path]` attributes in a component tree).
23pub fn to_stub_rs_unwrapped(sfc: &Sfc, component_name: &str, base_path: Option<&Path>) -> String {
24    to_stub_rs_inner(sfc, component_name, base_path, false)
25}
26
27fn to_stub_rs_inner(
28    sfc: &Sfc,
29    component_name: &str,
30    base_path: Option<&Path>,
31    wrap_module: bool,
32) -> String {
33    let t = sfc
34        .template
35        .as_ref()
36        .map(|b| b.content.as_str())
37        .unwrap_or("");
38    let ss = sfc
39        .script_setup
40        .as_ref()
41        .map(|b| b.content.as_str())
42        .unwrap_or("");
43    let s = sfc
44        .script
45        .as_ref()
46        .map(|b| b.content.as_str())
47        .unwrap_or("");
48    let st = sfc.style.as_ref().map(|b| b.content.as_str()).unwrap_or("");
49
50    let name = sanitize_ident(component_name);
51    let open = "{";
52    let close = "}";
53
54    // Create resolver for component imports using the provided base path.
55    // If no base path is given, fall back to the current working directory.
56    let resolver_base = base_path
57        .map(|p| p.to_path_buf())
58        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
59    let mut resolver = crate::component_resolver::ComponentResolver::new(resolver_base);
60    if let Some(script_setup) = &sfc.script_setup {
61        resolver.parse_imports(&script_setup.content);
62    }
63
64    // NOTE: We intentionally do NOT generate `pub mod {name};` declarations here.
65    // The caller (e.g., compile_component_tree in veloxc) is responsible for
66    // generating `#[path = "..."] pub mod ...;` declarations with correct file paths.
67    // Generating bare `pub mod` here would conflict with the caller's declarations
68    // and cause "file not found for module" errors.
69
70    // A parent component may call `{child}::render_with_callbacks(...)` whenever
71    // it attaches an `@event` to a component tag, so every component must expose
72    // the emit infrastructure (thread-local callback registry) unconditionally.
73    let needs_emit_infra = true;
74
75    // Scoped CSS: when the `<style>` block is declared `scoped`, this component
76    // gets a unique `data-v-<hash>` scope id and every CSS selector is prefixed
77    // with the matching attribute selector (e.g. `.header` becomes
78    // `.header[data-v-abc1ef23]`), confining the rule to this component's
79    // elements. The scope id is also exposed as `SCOPE_ID` so the caller can tag
80    // rendered VNodes with the same attribute. Unscoped styles pass through
81    // unchanged and apply globally.
82    let (processed_style, scope_id) = match sfc.style.as_ref() {
83        Some(style) if is_scoped(style) => {
84            let scope_id = generate_scope_id(component_name);
85            (scope_css(st, &scope_id), Some(scope_id))
86        }
87        _ => (st.to_string(), None),
88    };
89    let processed_template = t.to_string();
90
91    // Indent level: 4 spaces when wrapped in a module, 0 when unwrapped.
92    let indent = if wrap_module { "    " } else { "" };
93    let inner_indent = if wrap_module { "        " } else { "    " };
94
95    // Build output manually to allow embedding script_setup as raw Rust.
96    let mut out = String::new();
97    out.push_str("// @generated by velox-sfc (stub)\n");
98    // Always emit inner #![allow] — works both at file-level and inside pub mod {} blocks.
99    // When the CLI wraps this in pub mod {name} { ... }, the #![allow] applies to the module body.
100    // `unused_variables` covers generated functions whose `state` param is unused
101    // in presentational components and v-for index variables in Resolve mode. The
102    // clippy allows cover common patterns in generated render code (object-class
103    // ternaries, stringified value props, duplicated descendant #[path] modules).
104    out.push_str(
105        "#![allow(clippy::vec_init_then_push, clippy::arc_with_non_send_sync, \
106         clippy::blocks_in_conditions, clippy::duplicate_mod, \
107         clippy::match_single_binding, clippy::needless_borrows_for_generic_args, \
108         clippy::useless_format, unused_variables)]\n",
109    );
110
111    // The component module opens BEFORE the emit infrastructure, so the
112    // infrastructure is part of it rather than a sibling of it. Both halves of
113    // the emit path reach it from inside the module — `script_rs::emit` as
114    // `super::emit`, `render_with_callbacks` as a bare `set_emit_callbacks` —
115    // and neither path resolves when the registry sits one level out. The
116    // helper was written for the unwrapped form (where the file IS the module,
117    // so the two levels are the same one) and the wrapped form never compiled a
118    // component that emitted.
119    if wrap_module {
120        out.push_str(&format!("pub mod {} {}\n", name, open));
121    }
122
123    // If any component in the template uses @event, we need the emit module
124    if needs_emit_infra {
125        out.push_str(&format!("{indent}use std::cell::RefCell;\n"));
126        out.push_str(&format!("{indent}use std::collections::HashMap;\n\n"));
127        // Emit infrastructure is already indented for module-level (4 spaces).
128        // When unwrapped, strip that indentation.
129        let emit_code = generate_emit_infrastructure();
130        if wrap_module {
131            out.push_str(&emit_code);
132        } else {
133            for line in emit_code.lines() {
134                let stripped = line.strip_prefix("    ").unwrap_or(line);
135                out.push_str(stripped);
136                out.push('\n');
137            }
138        }
139        out.push('\n');
140    }
141
142    out.push_str(&format!(
143        "{indent}pub const TEMPLATE: &str = r#\"{}\"#;\n",
144        escape_raw_string(&processed_template)
145    ));
146    out.push_str(&format!(
147        "{indent}pub const SCRIPT_SETUP: &str = r#\"{}\"#;\n",
148        escape_raw_string(ss)
149    ));
150    out.push_str(&format!(
151        "{indent}pub const SCRIPT: &str = r#\"{}\"#;\n",
152        escape_raw_string(s)
153    ));
154    out.push_str(&format!(
155        "{indent}pub const STYLE: &str = r#\"{}\"#;\n",
156        escape_raw_string(&processed_style)
157    ));
158    // Emit the component's scope id (empty for unscoped components) so callers
159    // can tag rendered VNodes with the same `data-v-*` attribute the scoped
160    // selectors rely on.
161    out.push_str(&format!(
162        "{indent}pub const SCOPE_ID: &str = \"{}\";\n",
163        scope_id.unwrap_or_default()
164    ));
165    if !ss.is_empty() {
166        out.push_str(&format!("{indent}pub mod script_rs {{\n{inner_indent}#![allow(unused_variables, unused_imports, unused_mut, unused_assignments, clippy::new_without_default)]\n"));
167        out.push_str(&format!("{inner_indent}use super::*;\n"));
168
169        // If this component itself needs emit infrastructure (parent uses @event on it),
170        // add the callback type and emit helper
171        if ss.contains("define_emits") || ss.contains("emit(") {
172            let helpers = generate_component_emit_helpers();
173            // helpers are indented for module-level; adjust for inner_indent
174            for line in helpers.lines() {
175                // Strip the 8-space prefix that was designed for the wrapped format
176                let stripped = line
177                    .strip_prefix("        ")
178                    .unwrap_or(line.strip_prefix("    ").unwrap_or(line));
179                out.push_str(&format!("{inner_indent}{stripped}\n"));
180            }
181        }
182
183        // Insert user code as-is, BUT skip import lines (e.g., `import X from 'Y.vx'`).
184        // Import lines are a Velox DSL, not valid Rust. They are already stored
185        // in the SCRIPT_SETUP constant above. The caller handles module declarations
186        // for imported components via #[path] attributes.
187        for line in ss.lines() {
188            if is_import_line(line) {
189                continue;
190            }
191            out.push_str(&format!("{inner_indent}{line}\n"));
192        }
193
194        // Generate v-model setter methods on the State struct.
195        // Parse the template to find v-model directives, then emit setter methods
196        // that the event dispatch system can call (e.g., __vmodel_set_counter).
197        if let Some(template_block) = &sfc.template
198            && let Ok(template_nodes) =
199                crate::template_parse::parse_template_to_ast(&template_block.content)
200        {
201            let vmodels = crate::template_codegen::collect_vmodel_expressions(&template_nodes);
202            if !vmodels.is_empty() {
203                // The setters are `&self` methods, so they go in an `impl State`
204                // block — a second one is fine, the script's own `impl` has
205                // already been emitted above. They used to be appended after it,
206                // which made them free functions: `state.__vmodel_set_draft(p)`
207                // in the generated dispatcher then had no method to call (E0599)
208                // and no `v-model` could compile at all.
209                let setters = crate::template_codegen::generate_vmodel_setters(&vmodels);
210                out.push_str(&format!("{inner_indent}impl State {{\n"));
211                for line in setters.lines() {
212                    out.push_str(&format!("{inner_indent}    {line}\n"));
213                }
214                out.push_str(&format!("{inner_indent}}}\n"));
215            }
216        }
217        out.push_str(&format!("{indent}}}\n"));
218    }
219
220    // The name a parent writes this component's props with.
221    //
222    // A component that declares `pub struct Props` gets that struct, so a parent
223    // builds it as a struct literal and `rustc` checks every field it binds: a
224    // missing prop is a compile error rather than a value nobody asked for. A
225    // component that declares no `Props` gets an empty stand-in, so a parent
226    // that binds nothing to it still compiles, and one that binds something
227    // cannot.
228    out.push_str(&generate_props_arg(ss, indent));
229
230    // Always generate render_with_callbacks so a parent can pass event callbacks
231    // regardless of whether this component declares emits.
232    let render_cb = generate_render_with_callbacks_inside_module();
233
234    // The template has 4-space indentation; adjust to indent level
235    for line in render_cb.lines() {
236        let stripped = line.strip_prefix("    ").unwrap_or(line);
237        out.push_str(&format!("{indent}{stripped}\n"));
238    }
239
240    // The slots-only entry point, for a parent that passes slot content and no
241    // `@event`. Emitted unconditionally, and under its own name: Rust has no
242    // overloading, so sharing `render_with_slots` with the four-argument
243    // variant above made the two definitions collide (E0428) in every component
244    // whose template has a `<slot>`, while gating it on `t.contains("<slot")`
245    // left a parent calling it against a child without one with no such
246    // function to call (E0599). One name per arity, always present, is the only
247    // arrangement where the call site can be generated without knowing what the
248    // child's template contains.
249    let render_slots = generate_render_with_slots_only_inside_module();
250    for line in render_slots.lines() {
251        let stripped = line.strip_prefix("    ").unwrap_or(line);
252        out.push_str(&format!("{indent}{stripped}\n"));
253    }
254
255    if wrap_module {
256        out.push_str(close);
257        out.push('\n');
258    }
259    out
260}
261
262/// Check if a line is a Velox component import statement.
263/// Matches patterns like:
264///   import MyButton from './components/Button.vx'
265///   import { Button, Card } from './components.vx'
266fn is_import_line(line: &str) -> bool {
267    let trimmed = line.trim();
268    trimmed.starts_with("import ") && trimmed.contains(" from ")
269}
270
271/// True when the `<style>` block is declared `scoped`, which requires
272/// attribute-scoped selectors and a per-component scope id.
273pub fn is_scoped(style: &crate::sfc::StyleBlock) -> bool {
274    style.attrs.iter().any(|a| a.name == "scoped")
275}
276
277/// Generate a unique, stable scope id for a component (Vue-style
278/// `data-v-<hash>`). The 32-bit FNV-1a hash of the component name yields a
279/// distinct id per component and is deterministic, so generated code is
280/// reproducible and golden-file stable.
281pub fn generate_scope_id(component_name: &str) -> String {
282    let mut hash: u32 = 0x811c9dc5;
283    for b in component_name.bytes() {
284        hash ^= u32::from(b);
285        hash = hash.wrapping_mul(0x01000193);
286    }
287    format!("data-v-{:08x}", hash)
288}
289
290/// Rewrite a CSS stylesheet so every selector compound carries the scope
291/// attribute, preserving combinators between compounds:
292///
293/// ```css
294/// .header h1 { ... }   ->  .header[data-v-x] h1[data-v-x] { ... }
295/// div > .card { ... }  ->  div[data-v-x] > .card[data-v-x] { ... }
296/// h1, .a { ... }       ->  h1[data-v-x], .a[data-v-x] { ... }
297/// ```
298///
299/// Declaration blocks are copied verbatim. `@keyframes` blocks keep their
300/// inner keyframe selectors (`from`/`to`/percent offsets) unscoped, while
301/// `@media` preludes pass through and only their inner style-rule selectors
302/// are scoped. Appending the attribute to every compound confines the rule to
303/// elements that all belong to this component — every element rendered from
304/// this component's template is tagged with the same `data-v-*` attribute, so
305/// a rule like `.header h1[data-v-*]` could otherwise leak across component
306/// boundaries via an un-tagged ancestor.
307///
308/// CSS comments are replaced by a space before anything else happens (see
309/// [`strip_css_comments`]). Without that, a comment between two rules is glued
310/// onto the front of the next rule's prelude, and the per-compound tokenizer
311/// then appends the scope attribute to the comment's own `*/` as though it were
312/// a compound selector: `.dark .app` was emitted as
313/// `[data-v-x] .dark[data-v-x] .app[data-v-x]`, a three-part descendant
314/// selector requiring a scope-tagged ancestor strictly above the `.dark`
315/// carrier. The carrier is the tree root, whose ancestor list is empty, so
316/// `match_prefix` finds no candidate and the rule can never match.
317pub fn scope_css(css: &str, scope_id: &str) -> String {
318    let stripped = strip_css_comments(css);
319    let attr = format!("[{scope_id}]");
320    let mut out = String::with_capacity(stripped.len() + 32);
321    let mut prelude = String::new();
322    // Stack of block types: true = at-rule container (@media, @keyframes), false = style rule.
323    let mut stack: Vec<bool> = Vec::new();
324    // Depth of @keyframes container, if any. Selectors inside it (0%, from, to) must not be scoped.
325    let mut keyframes_depth: Option<usize> = None;
326    for c in stripped.chars() {
327        match c {
328            '{' => {
329                let trimmed = prelude.trim().to_string();
330                let is_at_rule = trimmed.starts_with('@');
331                let is_keyframes = trimmed.starts_with("@keyframes")
332                    || trimmed.starts_with("@-webkit-keyframes")
333                    || trimmed.starts_with("@-moz-keyframes");
334                if is_keyframes {
335                    keyframes_depth = Some(stack.len());
336                }
337                if is_at_rule {
338                    out.push_str(&prelude);
339                } else if keyframes_depth.is_some() {
340                    // Inside @keyframes: keyframe selectors (0%, from, to) are not scoped.
341                    out.push_str(&prelude);
342                } else if trimmed.is_empty() {
343                    out.push_str(&prelude);
344                } else {
345                    let inside_style = stack.last() == Some(&false);
346                    if inside_style {
347                        // Unexpected nested selector inside declarations — pass through.
348                        out.push_str(&prelude);
349                    } else {
350                        out.push_str(&scope_selector_list(&prelude, &attr));
351                    }
352                }
353                prelude.clear();
354                stack.push(is_at_rule);
355                out.push(c);
356            }
357            '}' => {
358                out.push(c);
359                stack.pop();
360                if let Some(kd) = keyframes_depth
361                    && stack.len() <= kd
362                {
363                    keyframes_depth = None;
364                }
365            }
366            _ => {
367                let inside_style = stack.last() == Some(&false);
368                if inside_style {
369                    // Inside a style rule's declaration block — copy verbatim.
370                    out.push(c);
371                } else {
372                    prelude.push(c);
373                }
374            }
375        }
376    }
377    out.push_str(&prelude);
378    out
379}
380
381/// Replace every CSS comment (`/* … */`) with a single space.
382///
383/// [`scope_css`] appends the scope attribute to every whitespace-separated
384/// token of a selector prelude, so comment text must never reach one: a
385/// comment sitting between two rules used to be glued onto the front of the
386/// NEXT rule's prelude and then scoped as if it were a compound selector.
387/// Comments must also not contribute braces — a `}` inside a comment used to
388/// close the declaration block it sat in — so the text is removed here, once,
389/// before any scoping decision is made.
390///
391/// This is a tokenizing scan, not `split("/*")`, because a `/*` inside a
392/// quoted string or inside an unquoted `url(…)` token is NOT a comment opener.
393/// Treating it as one truncates the remainder of the stylesheet: a naive
394/// string-split corrupts `content: "/*"`, `a[href="/*"]` and `url(/*)`. The
395/// scan therefore tracks the two contexts that suppress comment recognition —
396/// a quoted string, and the body of an unquoted `url()` token — and neither
397/// can hide a comment opener from the other.
398///
399/// An unterminated comment runs to end of input (CSS Syntax 3: "consume the
400/// remnants of the bad comment"), so this terminates on any input.
401///
402/// A stylesheet containing no `/*` is returned borrowed and untouched, which is
403/// what keeps [`scope_css`] byte-identical on comment-free CSS.
404fn strip_css_comments(css: &str) -> std::borrow::Cow<'_, str> {
405    use std::borrow::Cow;
406
407    // Only a literal `/*` can open a comment, so a sheet without one cannot
408    // contain one — not even inside a string or a url(), where it would be
409    // inert anyway. Nothing to scan, and the borrow keeps output identical.
410    if !css.contains("/*") {
411        return Cow::Borrowed(css);
412    }
413    let bytes = css.as_bytes();
414    let mut out = String::with_capacity(css.len());
415    // Start of the run of source text not yet copied into `out`. Comments are
416    // collapsed to a single space and everything between runs is preserved
417    // verbatim, so declaration blocks survive byte-for-byte. Every byte offset
418    // this scan stops on is ASCII (`/`, a quote, `)`, `*`) or the end of input,
419    // so it is always a character boundary and the slices never split a
420    // multi-byte character.
421    let mut run_start = 0usize;
422    let mut i = 0usize;
423    while i < bytes.len() {
424        if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
425            out.push_str(&css[run_start..i]);
426            out.push(' ');
427            i = skip_css_comment(bytes, i + 2);
428            run_start = i;
429        } else if bytes[i] == b'"' || bytes[i] == b'\'' {
430            i = skip_css_string(bytes, i);
431        } else if at_url_open(bytes, i) {
432            i = skip_css_url(bytes, i);
433        } else {
434            // Advance by one byte, not one char: the loop above only ever stops
435            // on ASCII, and the only slicing offsets are those stops.
436            i += 1;
437        }
438    }
439    out.push_str(&css[run_start..]);
440    Cow::Owned(out)
441}
442
443/// Byte offset just past the `*/` that closes the comment whose body starts at
444/// `start`, or the end of input when the comment is never closed. Every byte is
445/// legal inside a comment except that `*/` ends it, and CSS does not nest
446/// comments, so a linear scan is exact.
447fn skip_css_comment(bytes: &[u8], start: usize) -> usize {
448    let mut i = start;
449    while i < bytes.len() {
450        if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
451            return i + 2;
452        }
453        i += 1;
454    }
455    i
456}
457
458/// Byte offset just past the string whose opening quote is at `open`.
459///
460/// A `\` escapes the next byte, and an unescaped newline terminates the string
461/// as a bad-string token without consuming the newline, so neither can run the
462/// scan past the stylesheet.
463fn skip_css_string(bytes: &[u8], open: usize) -> usize {
464    let quote = bytes[open];
465    let mut i = open + 1;
466    while i < bytes.len() {
467        match bytes[i] {
468            b'\\' => i = (i + 2).min(bytes.len()),
469            b'\n' | b'\r' | b'\x0C' => return i,
470            b if b == quote => return i + 1,
471            _ => i += 1,
472        }
473    }
474    i
475}
476
477/// True when `i` starts a `url(` token — an unquoted URL, whose body is literal
478/// text rather than a token stream. `url (` with a space is a function token,
479/// and an identifier that merely ends in `url` (`myurl(`) is that identifier
480/// followed by a parenthesis block, so neither is a URL.
481fn at_url_open(bytes: &[u8], i: usize) -> bool {
482    fn ident_byte(b: u8) -> bool {
483        b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'\\' | b'\0')
484    }
485    if i > 0 && ident_byte(bytes[i - 1]) {
486        return false;
487    }
488    matches!(
489        &bytes[i..i.saturating_add(4).min(bytes.len())],
490        [b'u' | b'U', b'r' | b'R', b'l' | b'L', b'(']
491    )
492}
493
494/// Byte offset just past the `)` that closes the unquoted URL token opening at
495/// `i`. Quoted URLs are handled as ordinary strings by the caller; this only
496/// needs to reach the closing paren, and an unterminated one runs to end of
497/// input.
498fn skip_css_url(bytes: &[u8], open: usize) -> usize {
499    let mut i = open + 4;
500    while i < bytes.len() && bytes[i] != b')' {
501        i += if bytes[i] == b'\\' { 2 } else { 1 };
502    }
503    (i + 1).min(bytes.len())
504}
505
506/// Append the scope attribute to each selector in a comma-separated selector
507/// list. Non-selector text between rules is carried through unchanged.
508fn scope_selector_list(prelude: &str, attr: &str) -> String {
509    let mut out = String::with_capacity(prelude.len() + 32);
510    for (i, sel) in prelude.split(',').enumerate() {
511        if i > 0 {
512            out.push(',');
513        }
514        out.push_str(&scope_single_selector(sel, attr));
515    }
516    out
517}
518
519/// Append `[data-v-<scope>]` to every compound in a single complex selector,
520/// preserving the combinators (`>`, `+`, `~`) between compounds.
521///
522/// `div > .card` -> `div[data-v-x] > .card[data-v-x]`
523/// `.header h1`  -> `.header[data-v-x] h1[data-v-x]`
524fn scope_single_selector(sel: &str, attr: &str) -> String {
525    let spaced = pad_child_combinators(sel);
526    let mut out = String::with_capacity(spaced.len() + 16);
527    let mut pending_combinator: Option<&str> = None;
528    let mut wrote_compound = false;
529    for token in spaced.split_whitespace() {
530        if matches!(token, ">" | "+" | "~") {
531            pending_combinator = Some(token);
532            continue;
533        }
534        if wrote_compound {
535            out.push(' ');
536            if let Some(comb) = pending_combinator {
537                out.push_str(comb);
538                out.push(' ');
539            }
540        }
541        pending_combinator = None;
542        out.push_str(token);
543        out.push_str(attr);
544        wrote_compound = true;
545    }
546    if !wrote_compound {
547        return sel.to_string();
548    }
549    out
550}
551
552/// Re-space `>` so the whitespace tokenizer sees it as a standalone token,
553/// which makes `div>.card` behave like `div > .card`. A `>` inside an
554/// attribute selector (e.g. `[data-x="a>b"]`) is left untouched.
555fn pad_child_combinators(raw: &str) -> String {
556    let mut out = String::with_capacity(raw.len() + 4);
557    let mut bracket_depth = 0usize;
558    let mut quote: Option<char> = None;
559    for ch in raw.chars() {
560        if let Some(q) = quote {
561            out.push(ch);
562            if ch == q {
563                quote = None;
564            }
565            continue;
566        }
567        match ch {
568            '[' => {
569                bracket_depth += 1;
570                out.push(ch);
571            }
572            ']' => {
573                bracket_depth = bracket_depth.saturating_sub(1);
574                out.push(ch);
575            }
576            '"' | '\'' if bracket_depth > 0 => {
577                quote = Some(ch);
578                out.push(ch);
579            }
580            '>' if bracket_depth == 0 => out.push_str(" > "),
581            _ => out.push(ch),
582        }
583    }
584    out
585}
586
587/// Generate the global emit infrastructure: Callbacks type, emit function,
588/// set_callback function, and slots thread-local using thread-local storage.
589///
590/// Two registries live here, and they answer different questions about the same
591/// `@event` binding:
592///
593/// - `EMIT_CALLBACKS` maps an EVENT to the handler NAME the parent wrote
594///   (`@confirm="on_confirm"` -> `confirm` -> `on_confirm`). The child reads it
595///   in [`emit`], because the child is the side that knows which event it just
596///   emitted.
597/// - `EMIT_DISPATCH` maps that handler NAME to the CALL that runs it, registered
598///   by the parent through [`set_emit_dispatch`] at the moment it renders the
599///   child. The parent is the side that has a `State` to call the method on, so
600///   this is the only registry that can turn a name into a running handler.
601///
602/// Neither alone is enough, which is why the lookup in `emit` goes through both:
603/// the first says which method the author meant, the second is the only thing
604/// that can reach it. The pair is also what a function-typed prop reuses — see
605/// `dispatch_emit`.
606///
607/// The registries are per generated FILE, not global to the crate. A child
608/// resolves its own emit against its own module, which is why the parent has to
609/// name the child (`Child::set_emit_dispatch(..)`) rather than reach a shared
610/// table: each component module carries its own copy of this infrastructure and
611/// nothing else in the crate can name it.
612fn generate_emit_infrastructure() -> String {
613    r#"    /// Type alias for callback maps passed to child components.
614    pub type Callbacks = std::collections::HashMap<&'static str, &'static str>;
615
616    /// One handler a parent hands a child, as something the child can call.
617    ///
618    /// `Rc<RefCell<dyn FnMut(&str)>>`, and the shape is not decoration — both
619    /// halves of it are load-bearing:
620    ///
621    /// - The `Rc` is what makes a re-entrant call possible. A `Box<dyn FnMut>`
622    ///   is owned by the registry cell, so calling it means calling it while
623    ///   that cell is still borrowed, and a handler that emitted in turn would
624    ///   panic on the second borrow. Cloning the handle out first leaves the
625    ///   registry unborrowed for the whole call. The registry is thread-local,
626    ///   so the handle never has to be `Send`.
627    /// - The inner `RefCell` is what makes the handle callable at all. `Fn`/`FnMut`
628    ///   are implemented for `Box<F>` and for `&F`, but NOT for `Rc<F>`, so
629    ///   `Rc<dyn FnMut(..)>` cannot be called without a `DerefMut` that `Rc` does
630    ///   not implement. The inner cell is where the `&mut` comes from; the
631    ///   registration itself is never removed, so a handler that panics does not
632    ///   take its own binding down with it.
633    pub type EventHandler = std::rc::Rc<std::cell::RefCell<dyn FnMut(&str)>>;
634
635    // Thread-local callback registry for emit system.
636    // Stores event name -> handler function name mappings.
637    thread_local! {
638        static EMIT_CALLBACKS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
639        // Handler name -> the call that runs it. Written by the parent that
640        // rendered this component, read by `emit` and `dispatch_emit`.
641        static EMIT_DISPATCH: RefCell<HashMap<String, EventHandler>> = RefCell::new(HashMap::new());
642        static SLOTS: RefCell<HashMap<String, velox_dom::VNode>> = RefCell::new(HashMap::new());
643    }
644
645    /// Register callbacks for the current render scope.
646    /// Called by `render_with_callbacks` before rendering a child component.
647    pub fn set_emit_callbacks(callbacks: &Callbacks) {
648        EMIT_CALLBACKS.with(|cell| {
649            let mut map = cell.borrow_mut();
650            map.clear();
651            for (k, v) in callbacks {
652                map.insert(k.to_string(), v.to_string());
653            }
654        });
655    }
656
657    /// Store slots for the current render scope so `<slot>` elements can find them.
658    pub fn set_slots(slots: &std::collections::HashMap<&str, velox_dom::VNode>) {
659        SLOTS.with(|cell| {
660            let mut map = cell.borrow_mut();
661            map.clear();
662            for (k, v) in slots {
663                map.insert(k.to_string(), v.clone());
664            }
665        });
666    }
667
668    /// Register the call that runs one handler, keyed by the handler NAME the
669    /// parent wrote on the tag (`@confirm="on_confirm"` registers `on_confirm`).
670    ///
671    /// A parent calls this for every handler it binds, immediately before it
672    /// calls `render_with_callbacks` / `render_with_slots`, and the call it
673    /// passes is its own event dispatcher: so the payload a child emits lands on
674    /// the parent's `State` method, with the arity the method declared already
675    /// decided by the dispatcher.
676    ///
677    /// Registering a name twice replaces the call, so one component rendered
678    /// twice in a pass under different handlers is last-wins rather than both.
679    pub fn set_emit_dispatch(handler: &str, call: EventHandler) {
680        EMIT_DISPATCH.with(|cell| {
681            cell.borrow_mut().insert(handler.to_string(), call);
682        });
683    }
684
685    /// Run the handler registered under `handler` with `payload`.
686    ///
687    /// This is the entry point a FUNCTION-TYPED prop goes through: a parent
688    /// binding `:on_confirm="on_confirm"` hands the child a closure that calls
689    /// this with the name it read off the binding, so a child that prefers a
690    /// callable prop over `emit("..")` reaches exactly the same handler.
691    ///
692    /// Returns whether a handler was registered, so a caller can tell a handler
693    /// that ran and decided to do nothing from one that was never bound.
694    pub fn dispatch_emit(handler: &str, payload: &str) -> bool {
695        // Clone the handle out so the call happens with the registry cell
696        // unborrowed — a handler that emits in turn reaches this function again
697        // while its own call is still on the stack.
698        let call = EMIT_DISPATCH.with(|cell| cell.borrow().get(handler).cloned());
699        let Some(call) = call else { return false };
700        (call.borrow_mut())(payload);
701        true
702    }
703
704    /// Emit an event from a child component to its parent.
705    ///
706    /// Resolves the handler the parent bound for `event` and RUNS it: the
707    /// dispatcher the parent registered is invoked with `payload`, which is what
708    /// reaches the parent's `State` method. Returns the handler name it
709    /// resolved, or `None` when the parent bound no handler for this event —
710    /// the one case where an emit has nowhere to go.
711    pub fn emit(event: &str, payload: &str) -> Option<String> {
712        let handler = EMIT_CALLBACKS.with(|cell| cell.borrow().get(event).cloned())?;
713        dispatch_emit(&handler, payload);
714        Some(handler)
715    }
716
717    /// Look up slot content by name. Returns the slot VNode if found,
718    /// otherwise calls the fallback closure.
719    pub fn render_slot(
720        name: &str,
721        fallback: impl FnOnce() -> velox_dom::VNode,
722    ) -> velox_dom::VNode {
723        SLOTS.with(|cell| {
724            let map = cell.borrow();
725            if let Some(v) = map.get(name) {
726                return v.clone();
727            }
728            drop(map);
729            fallback()
730        })
731    }"#
732    .to_string()
733}
734
735/// Generate emit helpers that go inside the component's script_rs module.
736fn generate_component_emit_helpers() -> String {
737    r#"
738        /// Emit an event to the parent component that rendered this one.
739        ///
740        /// `event` names the binding on the parent's side: the parent wrote
741        /// `@confirm="on_confirm"` on the tag that rendered this component, and
742        /// `emit("confirm", payload)` runs that parent's `on_confirm` with
743        /// `payload`. The parent owns the call — it is the side with the `State`
744        /// the method lives on — so this component never names a handler, only
745        /// the event it wants the parent to hear about.
746        ///
747        /// A parent that bound no handler for this event has nothing to run, and
748        /// that is the one case where the emit goes nowhere: it is not an error,
749        /// because `@event` on a component tag is opt-in per event.
750        pub fn emit(event: &str, payload: &str) {
751            let _handler = super::emit(event, payload);
752        }
753"#
754    .to_string()
755}
756
757/// Generate the name a parent writes this component's props with.
758///
759/// A component that declares `pub struct Props` gets an alias for that struct, so
760/// a parent builds it as a struct literal and `rustc` checks every field it
761/// binds. A component that declares no `Props` gets a stand-in with no fields of
762/// its own: a parent that binds nothing to it still compiles, and one that binds
763/// something cannot. Its `values` map is still readable, because a component
764/// with no `Props` struct and no way to receive an attribute has nothing else to
765/// resolve a read from.
766///
767/// This is the DEFINING half of the name and it lives in the stub half of the
768/// pipeline, while every `render_with_props(props: PropsArg)` that names it is
769/// emitted by the template half (`template_codegen::generate_render_with_props`).
770/// The two halves are only ever correct together, so a caller that assembles a
771/// module by hand — rather than through `to_stub_rs*` plus
772/// `compile_template_to_rs_full*` as `veloxc` does — must emit both or
773/// neither, or the module does not compile (`E0412: cannot find type 'PropsArg'`).
774/// It is `pub` for exactly that caller.
775pub fn generate_props_arg(ss: &str, indent: &str) -> String {
776    if crate::script_index::extract_props_fields(ss).is_empty() {
777        return format!(
778            "{indent}/// This component declares no `Props` struct, so a parent binds nothing to it.\n\
779             {indent}pub struct PropsArg {{\n\
780             {indent}    pub values: std::collections::HashMap<String, String>,\n\
781             {indent}}}\n"
782        );
783    }
784    format!(
785        "{indent}/// The typed channel across this component's boundary.\n\
786         {indent}pub type PropsArg = script_rs::Props;\n"
787    )
788}
789
790/// Generate `render_with_callbacks` function that accepts callbacks from a parent.
791/// Also generates `render_with_slots` overloads for components that accept slots.
792/// This goes INSIDE the module (before the closing brace).
793fn generate_render_with_callbacks_inside_module() -> String {
794    r#"
795    /// Render this component with callbacks from a parent component.
796    /// The callbacks map event names to parent handler method names.
797    pub fn render_with_callbacks(
798        props: PropsArg,
799        callbacks: &std::collections::HashMap<&'static str, &'static str>,
800        _handler_names: &[&'static str],
801    ) -> velox_dom::VNode {
802        // Register callbacks in thread-local storage so emit() can find them.
803        // set_emit_callbacks lives at the same level as this function (both inside
804        // the component module), so no path qualifier is needed.
805        set_emit_callbacks(callbacks);
806        // Render using the standard props path
807        render_with_props(props)
808    }
809
810    /// Render this component with callbacks and slots from a parent component.
811    /// The callbacks map event names to parent handler method names.
812    /// The slots map slot names to pre-rendered VNode trees.
813    pub fn render_with_slots(
814        props: PropsArg,
815        callbacks: &std::collections::HashMap<&'static str, &'static str>,
816        _handler_names: &[&'static str],
817        slots: &std::collections::HashMap<&str, velox_dom::VNode>,
818    ) -> velox_dom::VNode {
819        set_emit_callbacks(callbacks);
820        set_slots(slots);
821        render_with_props(props)
822    }
823"#
824    .to_string()
825}
826
827/// Generate the `render_with_slots_only` function: props and slots from a
828/// parent, with no `@event` bound. Goes INSIDE the module (before the closing
829/// brace).
830fn generate_render_with_slots_only_inside_module() -> String {
831    r#"
832    /// Render this component with slot content from a parent component.
833    /// The slots map maps slot names to pre-rendered VNode trees.
834    ///
835    /// Distinct from `render_with_slots` above rather than another arity of it:
836    /// Rust resolves calls by name, so two functions with one name is a
837    /// duplicate definition however many arguments each takes.
838    pub fn render_with_slots_only(
839        props: PropsArg,
840        slots: &std::collections::HashMap<&str, velox_dom::VNode>,
841    ) -> velox_dom::VNode {
842        set_slots(slots);
843        render_with_props(props)
844    }
845"#
846    .to_string()
847}
848
849/// Turn `raw` into a Rust identifier safe to interpolate into generated code:
850/// lowercased, with anything that cannot appear in an identifier replaced.
851///
852/// PRIVATE, and deliberately so. It was `pub(crate)` on the stated ground that
853/// the template half of codegen names generated locals with it
854/// (`template_codegen::function_prop_value`) and the two halves have to agree on
855/// what a safe name is. That consumer does not exist: the only references to this
856/// function in the crate were its definition and the one call site below. A
857/// justification naming a consumer that is not there is worse than no
858/// justification — it describes an agreement the code has not made, and the next
859/// reader trusts it. If `template_codegen` ever DOES need to name a local from a
860/// prop field, it should call this — and then this should become `pub(crate)`
861/// again, with that caller named in this comment.
862fn sanitize_ident(raw: &str) -> String {
863    let mut out = String::with_capacity(raw.len());
864    for (i, ch) in raw.chars().enumerate() {
865        if (i == 0 && ch.is_ascii_digit()) || !is_ident_char(ch) {
866            out.push('_');
867        } else {
868            out.push(ch.to_ascii_lowercase());
869        }
870    }
871    if out.is_empty() { "_comp".into() } else { out }
872}
873
874fn is_ident_char(ch: char) -> bool {
875    ch.is_ascii_alphanumeric() || ch == '_'
876}
877
878/// Escape content for use in r#"..."# raw string literals.
879/// The sequence "# would terminate the raw string early, so we escape it.
880fn escape_raw_string(s: &str) -> String {
881    s.replace("\"#", "\"\\#")
882}