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}