use std::path::Path;
use crate::sfc::Sfc;
pub fn to_stub_rs(sfc: &Sfc, component_name: &str) -> String {
to_stub_rs_inner(sfc, component_name, None, true)
}
pub fn to_stub_rs_with_base(sfc: &Sfc, component_name: &str, base_path: Option<&Path>) -> String {
to_stub_rs_inner(sfc, component_name, base_path, true)
}
pub fn to_stub_rs_unwrapped(sfc: &Sfc, component_name: &str, base_path: Option<&Path>) -> String {
to_stub_rs_inner(sfc, component_name, base_path, false)
}
fn to_stub_rs_inner(
sfc: &Sfc,
component_name: &str,
base_path: Option<&Path>,
wrap_module: bool,
) -> String {
let t = sfc
.template
.as_ref()
.map(|b| b.content.as_str())
.unwrap_or("");
let ss = sfc
.script_setup
.as_ref()
.map(|b| b.content.as_str())
.unwrap_or("");
let s = sfc
.script
.as_ref()
.map(|b| b.content.as_str())
.unwrap_or("");
let st = sfc.style.as_ref().map(|b| b.content.as_str()).unwrap_or("");
let name = sanitize_ident(component_name);
let open = "{";
let close = "}";
let resolver_base = base_path
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let mut resolver = crate::component_resolver::ComponentResolver::new(resolver_base);
if let Some(script_setup) = &sfc.script_setup {
resolver.parse_imports(&script_setup.content);
}
let needs_emit_infra = true;
let (processed_style, scope_id) = match sfc.style.as_ref() {
Some(style) if is_scoped(style) => {
let scope_id = generate_scope_id(component_name);
(scope_css(st, &scope_id), Some(scope_id))
}
_ => (st.to_string(), None),
};
let processed_template = t.to_string();
let indent = if wrap_module { " " } else { "" };
let inner_indent = if wrap_module { " " } else { " " };
let mut out = String::new();
out.push_str("// @generated by velox-sfc (stub)\n");
out.push_str(
"#![allow(clippy::vec_init_then_push, clippy::arc_with_non_send_sync, \
clippy::blocks_in_conditions, clippy::duplicate_mod, \
clippy::match_single_binding, clippy::needless_borrows_for_generic_args, \
clippy::useless_format, unused_variables)]\n",
);
if wrap_module {
out.push_str(&format!("pub mod {} {}\n", name, open));
}
if needs_emit_infra {
out.push_str(&format!("{indent}use std::cell::RefCell;\n"));
out.push_str(&format!("{indent}use std::collections::HashMap;\n\n"));
let emit_code = generate_emit_infrastructure();
if wrap_module {
out.push_str(&emit_code);
} else {
for line in emit_code.lines() {
let stripped = line.strip_prefix(" ").unwrap_or(line);
out.push_str(stripped);
out.push('\n');
}
}
out.push('\n');
}
out.push_str(&format!(
"{indent}pub const TEMPLATE: &str = r#\"{}\"#;\n",
escape_raw_string(&processed_template)
));
out.push_str(&format!(
"{indent}pub const SCRIPT_SETUP: &str = r#\"{}\"#;\n",
escape_raw_string(ss)
));
out.push_str(&format!(
"{indent}pub const SCRIPT: &str = r#\"{}\"#;\n",
escape_raw_string(s)
));
out.push_str(&format!(
"{indent}pub const STYLE: &str = r#\"{}\"#;\n",
escape_raw_string(&processed_style)
));
out.push_str(&format!(
"{indent}pub const SCOPE_ID: &str = \"{}\";\n",
scope_id.unwrap_or_default()
));
if !ss.is_empty() {
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"));
out.push_str(&format!("{inner_indent}use super::*;\n"));
if ss.contains("define_emits") || ss.contains("emit(") {
let helpers = generate_component_emit_helpers();
for line in helpers.lines() {
let stripped = line
.strip_prefix(" ")
.unwrap_or(line.strip_prefix(" ").unwrap_or(line));
out.push_str(&format!("{inner_indent}{stripped}\n"));
}
}
for line in ss.lines() {
if is_import_line(line) {
continue;
}
out.push_str(&format!("{inner_indent}{line}\n"));
}
if let Some(template_block) = &sfc.template
&& let Ok(template_nodes) =
crate::template_parse::parse_template_to_ast(&template_block.content)
{
let vmodels = crate::template_codegen::collect_vmodel_expressions(&template_nodes);
if !vmodels.is_empty() {
let setters = crate::template_codegen::generate_vmodel_setters(&vmodels);
out.push_str(&format!("{inner_indent}impl State {{\n"));
for line in setters.lines() {
out.push_str(&format!("{inner_indent} {line}\n"));
}
out.push_str(&format!("{inner_indent}}}\n"));
}
}
out.push_str(&format!("{indent}}}\n"));
}
out.push_str(&generate_props_arg(ss, indent));
let render_cb = generate_render_with_callbacks_inside_module();
for line in render_cb.lines() {
let stripped = line.strip_prefix(" ").unwrap_or(line);
out.push_str(&format!("{indent}{stripped}\n"));
}
let render_slots = generate_render_with_slots_only_inside_module();
for line in render_slots.lines() {
let stripped = line.strip_prefix(" ").unwrap_or(line);
out.push_str(&format!("{indent}{stripped}\n"));
}
if wrap_module {
out.push_str(close);
out.push('\n');
}
out
}
fn is_import_line(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with("import ") && trimmed.contains(" from ")
}
pub fn is_scoped(style: &crate::sfc::StyleBlock) -> bool {
style.attrs.iter().any(|a| a.name == "scoped")
}
pub fn generate_scope_id(component_name: &str) -> String {
let mut hash: u32 = 0x811c9dc5;
for b in component_name.bytes() {
hash ^= u32::from(b);
hash = hash.wrapping_mul(0x01000193);
}
format!("data-v-{:08x}", hash)
}
pub fn scope_css(css: &str, scope_id: &str) -> String {
let stripped = strip_css_comments(css);
let attr = format!("[{scope_id}]");
let mut out = String::with_capacity(stripped.len() + 32);
let mut prelude = String::new();
let mut stack: Vec<bool> = Vec::new();
let mut keyframes_depth: Option<usize> = None;
for c in stripped.chars() {
match c {
'{' => {
let trimmed = prelude.trim().to_string();
let is_at_rule = trimmed.starts_with('@');
let is_keyframes = trimmed.starts_with("@keyframes")
|| trimmed.starts_with("@-webkit-keyframes")
|| trimmed.starts_with("@-moz-keyframes");
if is_keyframes {
keyframes_depth = Some(stack.len());
}
if is_at_rule {
out.push_str(&prelude);
} else if keyframes_depth.is_some() {
out.push_str(&prelude);
} else if trimmed.is_empty() {
out.push_str(&prelude);
} else {
let inside_style = stack.last() == Some(&false);
if inside_style {
out.push_str(&prelude);
} else {
out.push_str(&scope_selector_list(&prelude, &attr));
}
}
prelude.clear();
stack.push(is_at_rule);
out.push(c);
}
'}' => {
out.push(c);
stack.pop();
if let Some(kd) = keyframes_depth
&& stack.len() <= kd
{
keyframes_depth = None;
}
}
_ => {
let inside_style = stack.last() == Some(&false);
if inside_style {
out.push(c);
} else {
prelude.push(c);
}
}
}
}
out.push_str(&prelude);
out
}
fn strip_css_comments(css: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
if !css.contains("/*") {
return Cow::Borrowed(css);
}
let bytes = css.as_bytes();
let mut out = String::with_capacity(css.len());
let mut run_start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
out.push_str(&css[run_start..i]);
out.push(' ');
i = skip_css_comment(bytes, i + 2);
run_start = i;
} else if bytes[i] == b'"' || bytes[i] == b'\'' {
i = skip_css_string(bytes, i);
} else if at_url_open(bytes, i) {
i = skip_css_url(bytes, i);
} else {
i += 1;
}
}
out.push_str(&css[run_start..]);
Cow::Owned(out)
}
fn skip_css_comment(bytes: &[u8], start: usize) -> usize {
let mut i = start;
while i < bytes.len() {
if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
return i + 2;
}
i += 1;
}
i
}
fn skip_css_string(bytes: &[u8], open: usize) -> usize {
let quote = bytes[open];
let mut i = open + 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i = (i + 2).min(bytes.len()),
b'\n' | b'\r' | b'\x0C' => return i,
b if b == quote => return i + 1,
_ => i += 1,
}
}
i
}
fn at_url_open(bytes: &[u8], i: usize) -> bool {
fn ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'\\' | b'\0')
}
if i > 0 && ident_byte(bytes[i - 1]) {
return false;
}
matches!(
&bytes[i..i.saturating_add(4).min(bytes.len())],
[b'u' | b'U', b'r' | b'R', b'l' | b'L', b'(']
)
}
fn skip_css_url(bytes: &[u8], open: usize) -> usize {
let mut i = open + 4;
while i < bytes.len() && bytes[i] != b')' {
i += if bytes[i] == b'\\' { 2 } else { 1 };
}
(i + 1).min(bytes.len())
}
fn scope_selector_list(prelude: &str, attr: &str) -> String {
let mut out = String::with_capacity(prelude.len() + 32);
for (i, sel) in prelude.split(',').enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&scope_single_selector(sel, attr));
}
out
}
fn scope_single_selector(sel: &str, attr: &str) -> String {
let spaced = pad_child_combinators(sel);
let mut out = String::with_capacity(spaced.len() + 16);
let mut pending_combinator: Option<&str> = None;
let mut wrote_compound = false;
for token in spaced.split_whitespace() {
if matches!(token, ">" | "+" | "~") {
pending_combinator = Some(token);
continue;
}
if wrote_compound {
out.push(' ');
if let Some(comb) = pending_combinator {
out.push_str(comb);
out.push(' ');
}
}
pending_combinator = None;
out.push_str(token);
out.push_str(attr);
wrote_compound = true;
}
if !wrote_compound {
return sel.to_string();
}
out
}
fn pad_child_combinators(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 4);
let mut bracket_depth = 0usize;
let mut quote: Option<char> = None;
for ch in raw.chars() {
if let Some(q) = quote {
out.push(ch);
if ch == q {
quote = None;
}
continue;
}
match ch {
'[' => {
bracket_depth += 1;
out.push(ch);
}
']' => {
bracket_depth = bracket_depth.saturating_sub(1);
out.push(ch);
}
'"' | '\'' if bracket_depth > 0 => {
quote = Some(ch);
out.push(ch);
}
'>' if bracket_depth == 0 => out.push_str(" > "),
_ => out.push(ch),
}
}
out
}
fn generate_emit_infrastructure() -> String {
r#" /// Type alias for callback maps passed to child components.
pub type Callbacks = std::collections::HashMap<&'static str, &'static str>;
/// One handler a parent hands a child, as something the child can call.
///
/// `Rc<RefCell<dyn FnMut(&str)>>`, and the shape is not decoration — both
/// halves of it are load-bearing:
///
/// - The `Rc` is what makes a re-entrant call possible. A `Box<dyn FnMut>`
/// is owned by the registry cell, so calling it means calling it while
/// that cell is still borrowed, and a handler that emitted in turn would
/// panic on the second borrow. Cloning the handle out first leaves the
/// registry unborrowed for the whole call. The registry is thread-local,
/// so the handle never has to be `Send`.
/// - The inner `RefCell` is what makes the handle callable at all. `Fn`/`FnMut`
/// are implemented for `Box<F>` and for `&F`, but NOT for `Rc<F>`, so
/// `Rc<dyn FnMut(..)>` cannot be called without a `DerefMut` that `Rc` does
/// not implement. The inner cell is where the `&mut` comes from; the
/// registration itself is never removed, so a handler that panics does not
/// take its own binding down with it.
pub type EventHandler = std::rc::Rc<std::cell::RefCell<dyn FnMut(&str)>>;
// Thread-local callback registry for emit system.
// Stores event name -> handler function name mappings.
thread_local! {
static EMIT_CALLBACKS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
// Handler name -> the call that runs it. Written by the parent that
// rendered this component, read by `emit` and `dispatch_emit`.
static EMIT_DISPATCH: RefCell<HashMap<String, EventHandler>> = RefCell::new(HashMap::new());
static SLOTS: RefCell<HashMap<String, velox_dom::VNode>> = RefCell::new(HashMap::new());
}
/// Register callbacks for the current render scope.
/// Called by `render_with_callbacks` before rendering a child component.
pub fn set_emit_callbacks(callbacks: &Callbacks) {
EMIT_CALLBACKS.with(|cell| {
let mut map = cell.borrow_mut();
map.clear();
for (k, v) in callbacks {
map.insert(k.to_string(), v.to_string());
}
});
}
/// Store slots for the current render scope so `<slot>` elements can find them.
pub fn set_slots(slots: &std::collections::HashMap<&str, velox_dom::VNode>) {
SLOTS.with(|cell| {
let mut map = cell.borrow_mut();
map.clear();
for (k, v) in slots {
map.insert(k.to_string(), v.clone());
}
});
}
/// Register the call that runs one handler, keyed by the handler NAME the
/// parent wrote on the tag (`@confirm="on_confirm"` registers `on_confirm`).
///
/// A parent calls this for every handler it binds, immediately before it
/// calls `render_with_callbacks` / `render_with_slots`, and the call it
/// passes is its own event dispatcher: so the payload a child emits lands on
/// the parent's `State` method, with the arity the method declared already
/// decided by the dispatcher.
///
/// Registering a name twice replaces the call, so one component rendered
/// twice in a pass under different handlers is last-wins rather than both.
pub fn set_emit_dispatch(handler: &str, call: EventHandler) {
EMIT_DISPATCH.with(|cell| {
cell.borrow_mut().insert(handler.to_string(), call);
});
}
/// Run the handler registered under `handler` with `payload`.
///
/// This is the entry point a FUNCTION-TYPED prop goes through: a parent
/// binding `:on_confirm="on_confirm"` hands the child a closure that calls
/// this with the name it read off the binding, so a child that prefers a
/// callable prop over `emit("..")` reaches exactly the same handler.
///
/// Returns whether a handler was registered, so a caller can tell a handler
/// that ran and decided to do nothing from one that was never bound.
pub fn dispatch_emit(handler: &str, payload: &str) -> bool {
// Clone the handle out so the call happens with the registry cell
// unborrowed — a handler that emits in turn reaches this function again
// while its own call is still on the stack.
let call = EMIT_DISPATCH.with(|cell| cell.borrow().get(handler).cloned());
let Some(call) = call else { return false };
(call.borrow_mut())(payload);
true
}
/// Emit an event from a child component to its parent.
///
/// Resolves the handler the parent bound for `event` and RUNS it: the
/// dispatcher the parent registered is invoked with `payload`, which is what
/// reaches the parent's `State` method. Returns the handler name it
/// resolved, or `None` when the parent bound no handler for this event —
/// the one case where an emit has nowhere to go.
pub fn emit(event: &str, payload: &str) -> Option<String> {
let handler = EMIT_CALLBACKS.with(|cell| cell.borrow().get(event).cloned())?;
dispatch_emit(&handler, payload);
Some(handler)
}
/// Look up slot content by name. Returns the slot VNode if found,
/// otherwise calls the fallback closure.
pub fn render_slot(
name: &str,
fallback: impl FnOnce() -> velox_dom::VNode,
) -> velox_dom::VNode {
SLOTS.with(|cell| {
let map = cell.borrow();
if let Some(v) = map.get(name) {
return v.clone();
}
drop(map);
fallback()
})
}"#
.to_string()
}
fn generate_component_emit_helpers() -> String {
r#"
/// Emit an event to the parent component that rendered this one.
///
/// `event` names the binding on the parent's side: the parent wrote
/// `@confirm="on_confirm"` on the tag that rendered this component, and
/// `emit("confirm", payload)` runs that parent's `on_confirm` with
/// `payload`. The parent owns the call — it is the side with the `State`
/// the method lives on — so this component never names a handler, only
/// the event it wants the parent to hear about.
///
/// A parent that bound no handler for this event has nothing to run, and
/// that is the one case where the emit goes nowhere: it is not an error,
/// because `@event` on a component tag is opt-in per event.
pub fn emit(event: &str, payload: &str) {
let _handler = super::emit(event, payload);
}
"#
.to_string()
}
pub fn generate_props_arg(ss: &str, indent: &str) -> String {
if crate::script_index::extract_props_fields(ss).is_empty() {
return format!(
"{indent}/// This component declares no `Props` struct, so a parent binds nothing to it.\n\
{indent}pub struct PropsArg {{\n\
{indent} pub values: std::collections::HashMap<String, String>,\n\
{indent}}}\n"
);
}
format!(
"{indent}/// The typed channel across this component's boundary.\n\
{indent}pub type PropsArg = script_rs::Props;\n"
)
}
fn generate_render_with_callbacks_inside_module() -> String {
r#"
/// Render this component with callbacks from a parent component.
/// The callbacks map event names to parent handler method names.
pub fn render_with_callbacks(
props: PropsArg,
callbacks: &std::collections::HashMap<&'static str, &'static str>,
_handler_names: &[&'static str],
) -> velox_dom::VNode {
// Register callbacks in thread-local storage so emit() can find them.
// set_emit_callbacks lives at the same level as this function (both inside
// the component module), so no path qualifier is needed.
set_emit_callbacks(callbacks);
// Render using the standard props path
render_with_props(props)
}
/// Render this component with callbacks and slots from a parent component.
/// The callbacks map event names to parent handler method names.
/// The slots map slot names to pre-rendered VNode trees.
pub fn render_with_slots(
props: PropsArg,
callbacks: &std::collections::HashMap<&'static str, &'static str>,
_handler_names: &[&'static str],
slots: &std::collections::HashMap<&str, velox_dom::VNode>,
) -> velox_dom::VNode {
set_emit_callbacks(callbacks);
set_slots(slots);
render_with_props(props)
}
"#
.to_string()
}
fn generate_render_with_slots_only_inside_module() -> String {
r#"
/// Render this component with slot content from a parent component.
/// The slots map maps slot names to pre-rendered VNode trees.
///
/// Distinct from `render_with_slots` above rather than another arity of it:
/// Rust resolves calls by name, so two functions with one name is a
/// duplicate definition however many arguments each takes.
pub fn render_with_slots_only(
props: PropsArg,
slots: &std::collections::HashMap<&str, velox_dom::VNode>,
) -> velox_dom::VNode {
set_slots(slots);
render_with_props(props)
}
"#
.to_string()
}
fn sanitize_ident(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for (i, ch) in raw.chars().enumerate() {
if (i == 0 && ch.is_ascii_digit()) || !is_ident_char(ch) {
out.push('_');
} else {
out.push(ch.to_ascii_lowercase());
}
}
if out.is_empty() { "_comp".into() } else { out }
}
fn is_ident_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
fn escape_raw_string(s: &str) -> String {
s.replace("\"#", "\"\\#")
}