use crate::component_resolver::ComponentResolver;
use crate::script_index::{
PropField, StateMethod, extract_state_methods, method_names, resolve_method_name,
};
use crate::template_ast::{AttrKind, Node, TemplateAttr};
use crate::template_parse::is_all_ws;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq)]
enum TransformMode {
Resolve,
State,
Props,
}
#[allow(dead_code)]
struct VForInfo {
item_name: String,
index_name: String,
expr: String,
has_destructuring: bool,
}
fn parse_v_for(value: &str) -> Option<VForInfo> {
let idx = value.find(" in ")?;
let left = value[..idx].trim();
let expr = value[idx + 4..].trim();
if expr.is_empty() {
return None;
}
let (item_name, index_name, has_destructuring) = if left.starts_with('(') && left.ends_with(')')
{
let inner = &left[1..left.len() - 1];
let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
let item = if parts.is_empty() || parts[0].is_empty() {
"__item".to_string()
} else {
parts[0].to_string()
};
let idx = if parts.len() >= 2 && !parts[1].is_empty() {
parts[1].to_string()
} else {
"__idx".to_string()
};
(item, idx, true)
} else if left.is_empty() {
("__item".to_string(), "__idx".to_string(), false)
} else {
(left.to_string(), "__idx".to_string(), false)
};
Some(VForInfo {
item_name,
index_name,
expr: expr.to_string(),
has_destructuring,
})
}
fn resolve_key_expr(key_val: &str) -> String {
let trimmed = key_val.trim();
let unwrapped = trimmed
.strip_prefix("{{")
.and_then(|rest| rest.strip_suffix("}}"))
.map(str::trim)
.filter(|expr| !expr.is_empty());
unwrapped.unwrap_or(trimmed).to_string()
}
fn resolve_loop_item_expr(expr: &str, for_info: &VForInfo) -> Option<String> {
let expr = expr.trim();
if expr == for_info.index_name {
return Some(for_info.index_name.clone());
}
if expr == for_info.item_name {
return Some(format!(
r#"resolve(&format!("{}[{{}}]", {idx}))"#,
for_info.expr,
idx = for_info.index_name
));
}
if let Some(path) = expr.strip_prefix(&for_info.item_name)
&& let Some(path) = path.strip_prefix('.')
&& is_field_path(path)
{
return Some(format!(
r#"resolve(&format!("{}[{{}}].{path}", {idx}))"#,
for_info.expr,
idx = for_info.index_name
));
}
None
}
fn is_field_path(s: &str) -> bool {
!s.is_empty()
&& s.split('.').all(|seg| {
let mut chars = seg.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(is_ident_char)
})
}
fn resolve_mode_key_value(key_val: &str, for_info: &VForInfo) -> String {
let authored = resolve_key_expr(key_val);
match resolve_loop_item_expr(&authored, for_info) {
Some(indexed) => format!("{indexed}.to_string()"),
None => format!("resolve({}).to_string()", string_lit(&authored)),
}
}
fn resolver_truthiness(read: &str) -> String {
format!(
r#"{{ let __v = {read}; __v == "true" || match __v.trim().parse::<f64>() {{ Ok(__n) => __n != 0.0, Err(_) => !__v.is_empty() && __v != "false" }} }}"#
)
}
fn validate_template(nodes: &[Node]) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
let root_elements: Vec<&Node> = nodes
.iter()
.filter(|n| matches!(n, Node::Element { .. }))
.collect();
if root_elements.len() > 1 {
let tags: Vec<String> = root_elements
.iter()
.filter_map(|n| match n {
Node::Element { tag, .. } => Some(tag.clone()),
_ => None,
})
.collect();
errors.push(format!(
"template has multiple root elements: <{}> — a valid SFC template must have exactly one root element",
tags.join(", ")
));
}
fn check_stray_else(nodes: &[Node], errors: &mut Vec<String>) {
for i in 0..nodes.len() {
if let Node::Element { attrs, tag, .. } = &nodes[i]
&& let Some(dir) = attrs.iter().find(|a| {
matches!(a.kind, AttrKind::Directive)
&& (a.name == "else" || a.name == "else-if" || a.name == "elseif")
})
{
let has_valid_preceding = if i > 0 {
let mut found = false;
for j in (0..i).rev() {
if let Node::Element {
attrs: prev_attrs, ..
} = &nodes[j]
{
found = prev_attrs.iter().any(|a| {
matches!(a.kind, AttrKind::Directive)
&& (a.name == "if" || a.name == "else-if" || a.name == "elseif")
});
break;
}
}
found
} else {
false
};
if !has_valid_preceding {
errors.push(format!(
"stray v-{} on <{}> without a preceding v-if — v-else/v-else-if must follow an element with v-if",
dir.name, tag
));
}
}
if let Node::Element { children, .. } = &nodes[i] {
check_stray_else(children, errors);
}
}
}
check_stray_else(nodes, &mut errors);
errors
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RenderMode {
#[default]
State,
Resolve,
}
pub fn compile_template_to_rs(
template_src: &str,
component_name: &str,
resolver: Option<&mut ComponentResolver>,
) -> Result<String, String> {
compile_template_to_rs_full(template_src, component_name, resolver, None, None)
}
pub fn compile_template_to_rs_full(
template_src: &str,
component_name: &str,
resolver: Option<&mut ComponentResolver>,
script_setup: Option<&str>,
scope_id: Option<&str>,
) -> Result<String, String> {
compile_template_to_rs_full_with_mode(
template_src,
component_name,
resolver,
script_setup,
scope_id,
RenderMode::State,
)
}
pub fn compile_template_to_rs_full_with_mode(
template_src: &str,
_component_name: &str,
mut resolver: Option<&mut ComponentResolver>,
script_setup: Option<&str>,
scope_id: Option<&str>,
mode: RenderMode,
) -> Result<String, String> {
let nodes = crate::template_parse::parse_template_to_ast(template_src)?;
let validation_errors = validate_template(&nodes);
if !validation_errors.is_empty() {
return Err(validation_errors.join("\n"));
}
{
let mut known: Vec<String> = Vec::new();
if let Some(resolver) = &resolver {
known.extend(resolver.component_names());
}
if let Some(script_setup) = script_setup {
let mut scratch = ComponentResolver::new(std::path::PathBuf::from("."));
scratch.parse_imports(script_setup);
for name in scratch.component_names() {
if !known.contains(&name) {
known.push(name);
}
}
}
let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
for warning in
crate::template_parse::unknown_component_warnings(&nodes, &known_refs, template_src)
{
eprintln!("velox: warning: {warning}");
}
}
let mut nodes = nodes;
let methods = script_setup.map(extract_state_methods).unwrap_or_default();
let fields = script_setup
.map(crate::script_index::extract_state_fields)
.unwrap_or_default();
if let Some(resolver) = &mut resolver {
super::component_resolver::transform_components(&mut nodes, resolver);
}
let tree_handlers = if let Some(resolver) = &mut resolver {
collect_component_tree_handlers(&nodes, resolver, &fields)
} else {
Vec::new()
};
if nodes.is_empty() {
return Ok(empty_component_module());
}
let props_fields = script_setup
.map(crate::script_index::extract_props_fields)
.unwrap_or_default();
let props_root = if !props_fields.is_empty() && fields.iter().any(|f| f == "props") {
"state.props"
} else {
"props"
};
let child_props = collect_child_props_fields(&nodes, resolver);
let props = PropsChannel {
fields: &props_fields,
root: props_root,
state_fields: &fields,
children: &child_props,
};
let root = &nodes[0];
let body_with = emit_node_with(root, &fields, &props, scope_id);
let body_with_state = emit_node_with_state(root, &fields, &props, scope_id);
let mut out = format!(
r#"pub fn render() -> velox_dom::VNode {{
render_with(|_| String::new())
}}
pub fn render_with<F>(mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {{
use velox_dom::*;
{body_with}
}}"#,
body_with = body_with
);
out.push_str("\n\n");
out.push_str(&format!(
r#"#[allow(clippy::arc_with_non_send_sync)]
#[allow(unused_variables)]
pub fn render_with_state<F>(state: std::sync::Arc<script_rs::State>, mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {{
use velox_dom::*;
{body_with_state}
}}"#,
body_with_state = body_with_state
));
let resolver_keys = collect_resolver_keys(&nodes, &methods, &fields, mode, &props);
for warning in &resolver_keys.warnings {
eprintln!("velox: warning: {warning}");
}
if !resolver_keys.errors.is_empty() {
return Err(resolver_keys.errors.join("\n"));
}
out.push_str("\n\n");
out.push_str(&generate_make_resolve(
&resolver_keys.keys,
&methods,
&fields,
));
let mut handlers = collect_handlers(&nodes);
for handler in collect_function_prop_handlers(&nodes, &props) {
if !handlers.contains(&handler) {
handlers.push(handler);
}
}
out.push_str("\n\n");
out.push_str(&generate_make_on_event(&handlers, &tree_handlers, &methods));
out.push_str("\n\n");
out.push_str(&generate_render_with_props(
&resolver_keys.keys,
&methods,
&fields,
&props,
root,
scope_id,
script_setup.is_some(),
));
Ok(out)
}
fn collect_handlers(nodes: &[Node]) -> Vec<String> {
let mut set: HashSet<String> = HashSet::new();
fn walk(n: &Node, set: &mut HashSet<String>, loop_vars: &mut Vec<(String, String)>) {
if let Node::Element {
attrs, children, ..
} = n
{
if let Some(info) = v_for_scope(attrs) {
loop_vars.push((info.item_name.clone(), info.index_name.clone()));
}
let (item_name, idx_name) = loop_vars.last().map_or((None, None), |(item, idx)| {
(Some(item.as_str()), Some(idx.as_str()))
});
for a in attrs {
if let AttrKind::On = a.kind
&& let Some(v) = &a.value
{
let (base_event, modifier) = parse_event_modifier(v);
let handler_key = if let Some(m) = modifier {
format!("{}.{}", base_event, m)
} else {
base_event.clone()
};
set.insert(handler_key);
}
if matches!(a.kind, AttrKind::Directive)
&& a.name == "model"
&& let Some(ref expr) = a.value
&& !vmodel_is_loop_rooted(expr, item_name, idx_name)
{
set.insert(vmodel_setter_name(expr));
}
}
for c in children {
walk(c, set, loop_vars);
}
if v_for_scope(attrs).is_some() {
loop_vars.pop();
}
}
}
let mut loop_vars: Vec<(String, String)> = Vec::new();
for n in nodes {
walk(n, &mut set, &mut loop_vars);
}
let mut v: Vec<String> = set.into_iter().collect();
v.sort();
v
}
fn parse_event_modifier(handler: &str) -> (String, Option<String>) {
let parts: Vec<&str> = handler.split('.').collect();
if parts.len() >= 2 {
let modifier = parts[parts.len() - 1].to_string();
let known_modifiers = ["stop", "prevent", "once", "capture", "self"];
if known_modifiers.iter().any(|&m| m == modifier) {
let base: String = parts[..parts.len() - 1].join(".");
(base, Some(modifier))
} else {
(handler.to_string(), None)
}
} else {
(handler.to_string(), None)
}
}
fn collect_component_tree_handlers(
nodes: &[Node],
resolver: &mut ComponentResolver,
fields: &[String],
) -> Vec<crate::script_index::TreeHandler> {
let mut out = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
collect_tree_handlers_inner(nodes, resolver, fields, None, None, &mut out, &mut seen);
out
}
#[allow(clippy::too_many_arguments)]
fn collect_tree_handlers_inner(
nodes: &[Node],
resolver: &mut ComponentResolver,
fields: &[String],
owner: Option<&str>,
owner_methods: Option<&[StateMethod]>,
out: &mut Vec<crate::script_index::TreeHandler>,
seen: &mut HashSet<String>,
) {
for n in nodes {
let Node::Element {
attrs, children, ..
} = n
else {
continue;
};
for a in attrs {
if let AttrKind::On = a.kind
&& let Some(v) = &a.value
{
let takes_payload = owner_methods
.map(|ms| ms.iter().any(|m| m.name == *v && m.takes_payload))
.unwrap_or(false);
out.push(crate::script_index::TreeHandler {
name: v.clone(),
owner: owner.map(String::from),
takes_payload,
});
}
}
if let Some(comp_attr) = attrs
.iter()
.find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
&& let Some(comp_name) = &comp_attr.value
{
let field = comp_name.to_lowercase();
let becomes_owner = owner.is_none() && fields.contains(&field);
let child_owner: Option<String> = if becomes_owner {
Some(field)
} else {
owner.map(String::from)
};
if seen.insert(comp_name.clone())
&& let Ok(sfc) = resolver.load_component(comp_name)
{
let child_script = sfc.script_setup.as_ref().map(|b| b.content.clone());
let child_tpl = sfc.template.as_ref().map(|t| t.content.clone());
let subtree_methods = if becomes_owner {
child_script.as_deref().map(extract_state_methods)
} else {
None
};
let child_methods: Option<&[StateMethod]> = if becomes_owner {
subtree_methods.as_deref()
} else {
owner_methods
};
if let Some(tpl) = child_tpl
&& let Ok(child_nodes) = crate::template_parse::parse_template_to_ast(&tpl)
{
collect_tree_handlers_inner(
&child_nodes,
resolver,
fields,
child_owner.as_deref(),
child_methods,
out,
seen,
);
}
}
}
collect_tree_handlers_inner(children, resolver, fields, owner, owner_methods, out, seen);
}
}
fn collect_child_props_fields(
nodes: &[Node],
resolver: Option<&mut ComponentResolver>,
) -> Vec<(String, Vec<PropField>)> {
let Some(resolver) = resolver else {
return Vec::new();
};
let mut out: Vec<(String, Vec<PropField>)> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
collect_child_props_fields_inner(nodes, resolver, &mut out, &mut seen);
out
}
fn collect_child_props_fields_inner(
nodes: &[Node],
resolver: &mut ComponentResolver,
out: &mut Vec<(String, Vec<PropField>)>,
seen: &mut HashSet<String>,
) {
for node in nodes {
let Node::Element {
attrs, children, ..
} = node
else {
continue;
};
if let Some(comp_attr) = attrs
.iter()
.find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
&& let Some(comp_name) = &comp_attr.value
&& seen.insert(comp_name.clone())
&& let Ok(sfc) = resolver.load_component(comp_name)
{
let script = sfc.script_setup.as_ref().map(|b| b.content.clone());
out.push((
comp_name.clone(),
script
.as_deref()
.map(crate::script_index::extract_props_fields)
.unwrap_or_default(),
));
}
collect_child_props_fields_inner(children, resolver, out, seen);
}
}
fn generate_make_on_event(
handlers: &[String],
extra_handlers: &[crate::script_index::TreeHandler],
methods: &[StateMethod],
) -> String {
let names = method_names(methods);
let mut extra_by_name: HashMap<&str, &crate::script_index::TreeHandler> = HashMap::new();
for eh in extra_handlers {
extra_by_name.entry(&eh.name).or_insert(eh);
}
let mut arms = String::new();
let mut used: HashSet<String> = HashSet::new();
let _modifier_handlers: HashMap<&str, &str> = [
("stop", "e.stop_propagation()"),
("prevent", "e.prevent_default()"),
("once", "// mark as handled once"),
("capture", "// capture phase handling"),
("self", "// only handle if event target is this element"),
]
.iter()
.cloned()
.collect();
for h in handlers {
if h.starts_with("__vmodel_set_") {
arms.push_str(&format!(
" \"{name}\" => {{ if let Some(p) = payload {{ state.{name}(p); }} }},\n",
name = h
));
} else if let Some(eh) = extra_by_name.get(h.as_str())
&& let Some(owner) = eh.owner.as_deref()
{
if eh.takes_payload {
arms.push_str(&format!(
" \"{name}\" => {{ if let Some(p) = payload {{ state.{owner}.{method}(p); }} }},\n",
name = h, owner = owner, method = eh.name
));
} else {
arms.push_str(&format!(
" \"{name}\" => {{ state.{owner}.{method}(); }},\n",
name = h,
owner = owner,
method = eh.name
));
}
} else {
let (base_event, modifier) = parse_event_modifier(h);
let method = resolve_method_name(&names, &base_event);
let takes_payload = methods.iter().any(|m| m.name == method && m.takes_payload);
if takes_payload {
let mod_code = match modifier.as_deref() {
Some("stop") => "e.stop_propagation()",
Some("prevent") => "e.prevent_default()",
Some("once") => "// mark as handled once",
Some("capture") => "// capture phase handling",
Some("self") => "// only handle if event target is this element",
_ => "",
};
arms.push_str(&format!(
" \"{original}\" => {{ if let Some(p) = payload {{ state.{method}(p); {mod_code} }} }},\n",
original = h,
method = method,
mod_code = mod_code
));
} else {
let mod_code = match modifier.as_deref() {
Some("stop") => "e.stop_propagation()",
Some("prevent") => "e.prevent_default()",
Some("once") => "// mark as handled once",
Some("capture") => "// capture phase handling",
Some("self") => "// only handle if event target is this element",
_ => "",
};
arms.push_str(&format!(
" \"{original}\" => {{ state.{method}(); {mod_code} }},\n",
original = h,
method = method,
mod_code = mod_code
));
}
}
used.insert(h.clone());
}
for eh in extra_handlers {
if used.contains(&eh.name) {
continue;
}
let Some(owner) = eh.owner.as_deref() else {
continue;
};
if eh.takes_payload {
arms.push_str(&format!(
" \"{name}\" => {{ if let Some(p) = payload {{ state.{owner}.{method}(p); }} }},\n",
name = eh.name, owner = owner, method = eh.name
));
} else {
arms.push_str(&format!(
" \"{name}\" => {{ state.{owner}.{method}(); }},\n",
name = eh.name,
owner = owner,
method = eh.name
));
}
}
format!(
r#"#[allow(clippy::arc_with_non_send_sync, clippy::single_match)]
pub fn make_on_event(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str, Option<&str>) + 'static {{
move |name: &str, payload: Option<&str>| {{
match name {{
{arms} _ => {{}}
}}
}}
}}"#,
arms = arms
)
}
fn generate_make_resolve(
interp_keys: &[String],
methods: &[StateMethod],
fields: &[String],
) -> String {
let mut arms = String::new();
for key in interp_keys {
let method = resolve_getter_call(methods, fields, key);
arms.push_str(&format!(
" \"{}\" => state.{}.to_string(),\n",
key, method
));
}
format!(
r#"#[allow(clippy::arc_with_non_send_sync)]
pub fn make_resolve(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str) -> String {{
move |name: &str| match name {{
{arms} _ => String::new(),
}}
}}"#,
arms = arms
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Answer {
Getter { root: String, name: String },
MemberChain { root: String, chain: String },
Conventional { name: String },
}
impl Answer {
fn call(&self) -> String {
match self {
Answer::Getter { name, .. } | Answer::Conventional { name } => format!("{name}()"),
Answer::MemberChain { chain, .. } => chain.clone(),
}
}
fn refusal(&self, key: &str, methods: &[StateMethod], fields: &[String]) -> String {
match self {
Answer::MemberChain { root, .. } => root_reason(root, true, methods, fields),
Answer::Conventional { .. } => key_reason(key, false, methods, fields),
Answer::Getter { .. } => key_reason(key, true, methods, fields),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Unrenderable {
key_reason: String,
root_reason: String,
remedy: String,
}
fn answerable(
key: &str,
methods: &[StateMethod],
fields: &[String],
) -> Result<Answer, Unrenderable> {
if let Some((root, _)) = member_path(key) {
let reason = root_reason(root, true, methods, fields);
return match member_path_call(methods, key) {
Some(chain) => Ok(Answer::MemberChain {
root: root.to_string(),
chain,
}),
None => Err(Unrenderable {
key_reason: reason.clone(),
root_reason: reason,
remedy: path_remedy(root),
}),
};
}
if is_bare_identifier(key) {
return match getter_method_name(methods, key) {
Some(name) => Ok(Answer::Getter {
root: key.to_string(),
name: name.to_string(),
}),
None => Err(Unrenderable {
key_reason: key_reason(key, true, methods, fields),
root_reason: root_reason(key, false, methods, fields),
remedy: name_remedy(key),
}),
};
}
Ok(Answer::Conventional {
name: conventional_name(methods, key),
})
}
fn conventional_name(methods: &[StateMethod], key: &str) -> String {
resolve_method_name(&method_names(methods), key)
}
fn key_reason(key: &str, is_bare: bool, methods: &[StateMethod], fields: &[String]) -> String {
let named = methods.iter().find(|m| m.name == key);
match named {
Some(m) if m.takes_payload => {
format!("`{key}` is a payload-taking State method, not a getter")
}
Some(m) if m.return_type.is_none() => {
format!("`{key}` declares no return type, so there is no text to render")
}
Some(m) => format!(
"`{key}` returns `{}`, which cannot be rendered as text",
m.return_type.as_deref().unwrap_or_default()
),
None if fields.iter().any(|f| f == key) => format!(
"`{key}` is a `State` field, not a getter — reading a field in a \
template needs a typed field resolver, which is not implemented yet"
),
None if !is_bare => {
format!("`{key}` is an expression, not a `State` getter name")
}
None => format!("`{key}` is not a zero-argument `State` getter"),
}
}
fn root_reason(root: &str, is_path: bool, methods: &[StateMethod], fields: &[String]) -> String {
let declared = methods.iter().find(|m| m.name == root);
match declared {
Some(m) if m.takes_payload => {
format!("`{root}` is a payload-taking State method, not an accessor")
}
Some(m) if !returns_a_value(m.return_type.as_deref()) => {
format!("`{root}` returns nothing, so there is no value to read from")
}
Some(_) if is_path => {
format!("`{root}` is a State method, but a member path needs an accessor")
}
Some(_) => {
format!("`{root}` is a `State` method, but its return type cannot be rendered as text")
}
None if fields.iter().any(|f| f == root) => format!(
"`{root}` is a `State` field, not an accessor — reading a field in a \
template needs a typed field resolver, which is not implemented yet"
),
None => format!("`{root}` is not a zero-argument `State` method"),
}
}
fn name_remedy(name: &str) -> String {
format!("Declare `pub fn {name}(&self) -> impl std::fmt::Display` on `State`.")
}
fn path_remedy(root: &str) -> String {
format!(
"Declare `pub fn {root}(&self) -> YourType` on `State` and a method for each \
segment after it."
)
}
fn unresolvable_key_reason(key: &str, methods: &[StateMethod], fields: &[String]) -> String {
match answerable(key, methods, fields) {
Err(unrenderable) => unrenderable.key_reason,
Ok(answer) => answer.refusal(key, methods, fields),
}
}
fn condition_unresolvable_warning(
directive: &str,
expr: &str,
key: &str,
methods: &[StateMethod],
fields: &[String],
) -> String {
let consequence = if directive == "show" {
"the condition reads an empty value, so the element renders with `display: none`"
} else {
"the condition reads an empty value, so the element and everything inside it are not rendered"
};
format!(
"{directive}=\"{expr}\" cannot be resolved — {}; {consequence}. Bind a zero-argument \
State getter for `{key}` instead.",
unresolvable_key_reason(key, methods, fields)
)
}
fn vmodel_loop_write_warning(expr: &str) -> String {
format!(
"v-model=\"{expr}\" on a `v-for` item cannot be written, and no template syntax \
reaches it — the read works, so the input shows the item's value, but typing does \
not change it. Both hops of every write path break: the loop body iterates a \
DETACHED COPY, because `Signal::get` returns `T` by value rather than a handle, \
and the event dispatcher is `FnMut(&str, Option<&str>)` — a name and a payload, \
with no index — so a write to \"the third item\" cannot even be NAMED. Two routes \
do work: bind `v-model` on a `State` field instead of on the loop item, where the \
generated `__vmodel_set_<field>` setter is dispatched and writes through the \
signal; or render this component through `render_with_state` and do the write in \
a `State` method the component calls itself, since that is the only place the \
index still exists. This is a limitation of `v-model` on a loop item, not a typo \
to work around: a `v-for` item is read-only by design."
)
}
fn vmodel_resolve_mode_warning(expr: &str) -> String {
format!(
"v-model=\"{expr}\" cannot be written in Resolve mode — the resolver renders values but \
dispatches no events, so only the read side exists here. Render this component through \
`render_with_state`, which reads the value and writes it from the State directly."
)
}
#[derive(Debug, Clone, Copy, Default)]
struct PropsChannel<'a> {
fields: &'a [PropField],
root: &'a str,
state_fields: &'a [String],
children: &'a [(String, Vec<PropField>)],
}
impl<'a> PropsChannel<'a> {
fn declares_vec_field(&self, expr: &str) -> bool {
self.field(expr).is_some_and(|f| is_vec_shaped(&f.ty))
}
fn field(&self, name: &str) -> Option<&'a PropField> {
self.fields.iter().find(|f| f.name == name)
}
fn holds_collection(&self, name: &str) -> bool {
self.declares_vec_field(name) || self.state_fields.iter().any(|f| f == name)
}
fn inventory_is_closed(&self) -> bool {
!self.fields.is_empty()
}
fn child_field(&self, comp_name: &str, name: &str) -> Option<&'a PropField> {
self.children
.iter()
.find(|(tag, _)| tag == comp_name)
.and_then(|(_, fields)| fields.iter().find(|f| f.name == name))
}
fn child_fields(&self, comp_name: &str) -> &'a [PropField] {
self.children
.iter()
.find(|(tag, _)| tag == comp_name)
.map(|(_, fields)| fields.as_slice())
.unwrap_or(&[])
}
fn child_declares_props(&self, comp_name: &str) -> Option<bool> {
self.children
.iter()
.find(|(tag, _)| tag == comp_name)
.map(|(_, fields)| !fields.is_empty())
}
}
fn is_vec_shaped(ty: &str) -> bool {
let ty = ty.trim();
ty.starts_with("Vec<")
|| ty.starts_with("&[")
|| (ty.starts_with('[') && ty.contains(';'))
|| ty.starts_with("std::vec::Vec<")
|| ty.starts_with("std::collections::")
}
fn is_text_shaped(ty: &str) -> bool {
let ty = ty.trim();
matches!(ty, "String" | "str" | "&str" | "bool") || is_numeric_type(ty)
}
fn generate_render_with_props(
interp_keys: &[String],
methods: &[StateMethod],
fields: &[String],
props: &PropsChannel<'_>,
root: &Node,
scope_id: Option<&str>,
has_script: bool,
) -> String {
if props.fields.is_empty() {
return r#"pub fn render_with_props(props: PropsArg) -> velox_dom::VNode {
let resolve_props = |key: &str| -> String {
props.values.get(key).cloned().unwrap_or_default()
};
render_with(resolve_props)
}"#
.to_string();
}
let props_root = props.root;
let mut match_arms = String::new();
for f in props.fields {
if !is_text_shaped(&f.ty) {
continue;
}
match_arms.push_str(&format!(
" \"{}\" => {}.{}.to_string(),\n",
f.name, props_root, f.name
));
}
for key in interp_keys {
if props.fields.iter().any(|f| &f.name == key) {
continue;
}
let method = resolve_getter_call(methods, fields, key);
match_arms.push_str(&format!(
" \"{}\" => state.{}.to_string(),\n",
key, method
));
}
let assign_props = if props_root == "state.props" {
" state.props = props;\n"
} else {
" let _ = props;\n"
};
let state_binding = if has_script {
if props_root == "state.props" {
" let mut state = script_rs::State::new();\n"
} else {
" let state = script_rs::State::new();\n"
}
} else {
""
};
let body = emit_node_with_mode(root, TransformMode::Props, fields, *props, scope_id);
format!(
r#"pub fn render_with_props(props: PropsArg) -> velox_dom::VNode {{
use velox_dom::*;
{state_binding}{assign_props} let resolve = |key: &str| -> String {{
match key {{
{match_arms} _ => String::new(),
}}
}};
{body}
}}"#,
)
}
fn empty_component_module() -> String {
r#"pub fn render() -> velox_dom::VNode {
use velox_dom::*;
text("")
}
pub fn render_with<F>(mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {
use velox_dom::*;
text("")
}
#[allow(clippy::arc_with_non_send_sync)]
pub fn render_with_state<F>(state: std::sync::Arc<script_rs::State>, mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {
use velox_dom::*;
text("")
}
#[allow(clippy::arc_with_non_send_sync)]
pub fn make_resolve(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str) -> String {
move |_name: &str| String::new()
}
pub fn make_on_event(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str, Option<&str>) + 'static {
move |_name: &str, _payload: Option<&str>| {}
}
pub fn render_with_props(props: PropsArg) -> velox_dom::VNode {
let _ = props;
render_with(|_key: &str| String::new())
}"#
.to_string()
}
fn normalize_slot_name(name: &str) -> String {
crate::template_parse::normalize_directive_name(name.trim())
}
fn slot_outlet_name(attrs: &[TemplateAttr]) -> String {
attrs
.iter()
.find(|a| a.name == "name" && matches!(a.kind, AttrKind::Static))
.and_then(|a| a.value.clone())
.map(|raw| normalize_slot_name(&raw))
.unwrap_or_else(|| "default".to_string())
}
fn emit_slot_node(
attrs: &[TemplateAttr],
children: &[Node],
mode: TransformMode,
fields: &[String],
props: PropsChannel<'_>,
scope_id: Option<&str>,
) -> String {
let slot_name = slot_outlet_name(attrs);
let fallback = emit_children_with_mode(children, mode, fields, props, scope_id);
format!(
r#"render_slot({slot_name_lit}, || velox_dom::h("slot", velox_dom::Props::new(), {fallback}))"#,
slot_name_lit = string_lit(&slot_name),
fallback = fallback,
)
}
pub(crate) fn emit_node(n: &Node) -> String {
match n {
Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
Node::Interpolation(expr) => {
let key = string_lit(expr.trim());
format!(r#"text(resolve({}))"#, key)
}
Node::Element {
tag,
attrs,
children,
..
} => {
let elem_props = emit_props(attrs);
let kids = emit_children(children);
format!(r#"h("{}", {elem_props}, {kids})"#, tag)
}
}
}
#[allow(dead_code)]
pub(crate) fn emit_props(attrs: &[TemplateAttr]) -> String {
if attrs.is_empty() {
return "Props::new()".to_string();
}
let mut parts = vec!["Props::new()".to_string()];
for a in attrs {
match a.kind {
AttrKind::Static => {
let v = a.value.clone().unwrap_or_default();
parts.push(format!(r#".set("{}", {})"#, a.name, string_lit(&v)));
}
AttrKind::Bind => {
let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
parts.push(format!(
r#".set("{}", &format!("{{}}", {}))"#,
a.name,
expr.trim()
));
}
AttrKind::Directive => {
}
AttrKind::On => {
let handler = a.value.clone().unwrap_or_default();
parts.push(format!(
r#".set("on:{}", {})"#,
a.name,
string_lit(&handler)
));
}
}
}
parts.join("")
}
#[allow(dead_code)]
pub(crate) fn emit_children(children: &[Node]) -> String {
if children.is_empty() {
return "vec![]".to_string();
}
let items: Vec<String> = children.iter().map(emit_node).collect();
format!("vec![{}]", items.join(", "))
}
fn is_comparison_char(ch: char) -> bool {
matches!(ch, '=' | '!' | '<' | '>')
}
fn member_path_end(chars: &[char], start: usize) -> usize {
let mut i = start;
i += 1;
while i < chars.len() && is_ident_char(chars[i]) {
i += 1;
}
loop {
match chars.get(i) {
Some('.') => match chars.get(i + 1) {
Some(c) if c.is_ascii_alphabetic() || *c == '_' => {
i += 2;
while i < chars.len() && is_ident_char(chars[i]) {
i += 1;
}
}
_ => return i,
},
Some('[') => {
let mut j = i + 1;
while j < chars.len() && chars[j] != ']' {
j += 1;
}
if j >= chars.len() {
return i;
}
i = j + 1;
}
_ => return i,
}
}
}
pub(crate) fn rewrite_if_expr(expr: &str) -> String {
let has_cmp = expr.contains("==")
|| expr.contains("!=")
|| expr.contains(">=")
|| expr.contains("<=")
|| expr.contains('>')
|| expr.contains('<');
let has_negation = expr.contains('!');
let chars: Vec<char> = expr.chars().collect();
let mut out = String::new();
let mut i = 0;
fn flush_token(out: &mut String, token: &str, numeric: bool) {
if token.is_empty() {
return;
}
if matches!(token, "true" | "false" | "resolve" | "state") {
out.push_str(token);
} else if numeric {
out.push_str(&format!(
"resolve({}).parse::<f64>().unwrap_or(0.0)",
string_lit(token)
));
} else {
out.push_str(&resolver_truthiness(&format!(
"resolve({})",
string_lit(token)
)));
}
}
while i < chars.len() {
let ch = chars[i];
if ch.is_ascii_alphabetic() || ch == '_' {
let end = member_path_end(&chars, i);
let token: String = chars[i..end].iter().collect();
i = end;
let mut pending = String::new();
let mut j = i;
let mut next_is_cmp = false;
while j < chars.len() {
if chars[j].is_whitespace() {
pending.push(chars[j]);
j += 1;
} else if is_comparison_char(chars[j]) {
next_is_cmp = true;
break;
} else {
break;
}
}
let prev_is_cmp = out
.trim_end()
.chars()
.last()
.is_some_and(is_comparison_char);
flush_token(&mut out, &token, has_cmp && (prev_is_cmp || next_is_cmp));
out.push_str(&pending);
} else if has_cmp && ch.is_ascii_digit() {
let mut num = String::new();
num.push(ch);
i += 1;
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
num.push(chars[i]);
i += 1;
}
if !num.contains('.') {
num.push_str(".0");
}
out.push_str(&num);
} else if ch == '!' && has_negation {
out.push_str("!(");
i += 1;
let mut neg_ident = String::new();
if chars
.get(i)
.is_some_and(|c| c.is_ascii_alphabetic() || *c == '_')
{
let end = member_path_end(&chars, i);
neg_ident = chars[i..end].iter().collect();
i = end;
}
if neg_ident.is_empty() {
out.pop(); out.push('!');
} else if neg_ident == "true" || neg_ident == "false" {
out.push_str(&neg_ident);
out.push(')');
} else {
out.push_str(&resolver_truthiness(&format!(
"resolve({})",
string_lit(&neg_ident)
)));
out.push(')');
}
} else {
out.push(ch);
i += 1;
}
}
out
}
fn v_for_scope(attrs: &[TemplateAttr]) -> Option<VForInfo> {
attrs
.iter()
.find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
.and_then(|a| a.value.as_deref())
.and_then(parse_v_for)
}
fn vmodel_is_loop_rooted(expr: &str, item_name: Option<&str>, idx_name: Option<&str>) -> bool {
let roots: Vec<&str> = [item_name, idx_name].into_iter().flatten().collect();
!roots.is_empty() && expr_reads_roots(expr, &roots)
}
fn vmodel_setter_name(expr: &str) -> String {
format!("__vmodel_set_{}", expr.replace('.', "_"))
}
fn extract_vmodel(
attrs: &[TemplateAttr],
item_name: Option<&str>,
idx_name: Option<&str>,
) -> (Vec<TemplateAttr>, Option<String>) {
let vmodel_pos = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "model");
if let Some(pos) = vmodel_pos {
let vmodel_attr = &attrs[pos];
let model_expr = vmodel_attr.value.clone().unwrap_or_default();
let mut remaining: Vec<TemplateAttr> = attrs.to_vec();
remaining.remove(pos);
remaining.push(TemplateAttr {
name: "value".to_string(),
value: Some(model_expr.clone()),
kind: AttrKind::Bind,
});
if !vmodel_is_loop_rooted(&model_expr, item_name, idx_name) {
remaining.push(TemplateAttr {
name: "input".to_string(),
value: Some(vmodel_setter_name(&model_expr)),
kind: AttrKind::On,
});
}
(remaining, Some(model_expr))
} else {
(attrs.to_vec(), None)
}
}
fn emit_node_with_mode(
n: &Node,
mode: TransformMode,
fields: &[String],
props: PropsChannel<'_>,
scope_id: Option<&str>,
) -> String {
match n {
Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
Node::Interpolation(expr) => {
let key = string_lit(expr.trim());
format!(r#"text(resolve({}))"#, key)
}
Node::Element {
tag,
attrs,
children,
..
} => {
if tag == "slot" {
return emit_slot_node(attrs, children, mode, fields, props, scope_id);
}
let (attrs2, _v_model_handler) = extract_vmodel(attrs, None, None);
let attrs = &attrs2;
if let Some(pos) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if")
{
let mut attrs2 = attrs.clone();
let dir = attrs2.remove(pos);
let expr = rewrite_if_expr(&dir.value.unwrap_or_default());
let tmp = Node::Element {
tag: tag.clone(),
attrs: attrs2,
children: children.clone(),
self_closing: false,
};
let inner = emit_node_with_mode(&tmp, mode, fields, props, scope_id);
return format!(r#"if {} {{ {} }} else {{ text("") }}"#, expr.trim(), inner);
}
if let Some(pos) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "show")
{
let mut attrs2 = attrs.clone();
let dir = attrs2.remove(pos);
let expr = rewrite_if_expr(&dir.value.unwrap_or_default());
let inner = emit_node_with_mode(
&Node::Element {
tag: tag.clone(),
attrs: attrs2,
children: children.clone(),
self_closing: false,
},
mode,
fields,
props,
scope_id,
);
let has_style = attrs.iter().any(|a| {
a.name == "style" && matches!(a.kind, AttrKind::Static | AttrKind::Bind)
});
if has_style {
return format!(
r#"{{ let mut __node = {inner}; if !({expr}) {{ if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.entry("style".to_string()).and_modify(|s| {{ if s.contains("display:") {{ /* preserve existing display */ }} else {{ s.push_str("; display: none"); }} }}).or_insert_with(|| "display: none".to_string()); }} }} __node }}"#,
inner = inner,
expr = expr.trim()
);
} else {
return format!(
r#"{{ let mut __node = {inner}; if !({expr}) {{ if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert("style".to_string(), "display: none".to_string()); }} }} __node }}"#,
inner = inner,
expr = expr.trim()
);
}
}
let has_v_for = attrs
.iter()
.any(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for");
if !has_v_for
&& let Some(component_attr) = attrs
.iter()
.find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
&& let Some(comp_name) = &component_attr.value
{
let mut clean_attrs = attrs.clone();
clean_attrs.retain(|a| a.name != "data-velox-component");
let has_slot_children = !children.is_empty();
let field = comp_name.to_lowercase();
let has_props_events = clean_attrs
.iter()
.any(|a| matches!(a.kind, AttrKind::Bind | AttrKind::On));
if mode == TransformMode::State
&& !has_props_events
&& !has_slot_children
&& fields.contains(&field)
{
return format!(
r#"{comp_name}::render_with_state(std::sync::Arc::clone(&state.{field}), {comp_name}::make_resolve(std::sync::Arc::clone(&state.{field})))"#,
);
}
let (_has_props, props_expr) =
generate_component_props_expr(&clean_attrs, comp_name, &props);
let callbacks = collect_component_callbacks(&clean_attrs);
let dispatch = if mode == TransformMode::State {
emit_dispatch_registrations(
comp_name,
&component_dispatch_handlers(&clean_attrs, comp_name, &props),
)
} else {
String::new()
};
if callbacks.is_empty() && !has_slot_children {
return format!(
r#"{{ {dispatch}let __props = {props_expr}; {comp_name}::render_with_props(__props) }}"#,
);
}
let slots_expr = if has_slot_children {
slots_map_expr(children, &|c| {
emit_node_with_mode(c, mode, fields, props, scope_id)
})
} else {
"std::collections::HashMap::new()".to_string()
};
if !has_slot_children {
let callback_map = format_callback_map(&callbacks);
let callback_names: Vec<String> = callbacks
.iter()
.map(|(_, handler)| handler.clone())
.collect();
return format!(
r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; {comp_name}::render_with_callbacks(__props, &__callbacks, &[{callback_names}]) }}"#,
callback_names = callback_names
.iter()
.map(|n| format!("\"{}\"", n))
.collect::<Vec<_>>()
.join(", ")
);
}
let callback_map = format_callback_map(&callbacks);
let callback_names: Vec<String> = callbacks
.iter()
.map(|(_, handler)| handler.clone())
.collect();
return format!(
r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; let __slots = {slots_expr}; {comp_name}::render_with_slots(__props, &__callbacks, &[{callback_names}], &__slots) }}"#,
callback_names = callback_names
.iter()
.map(|n| format!("\"{}\"", n))
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(pos_for) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
{
let v_if_pos = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if");
let mut attrs_f = attrs.clone();
let dir = attrs_f.remove(pos_for);
let val = dir.value.unwrap_or_default();
let key_attr_pos = attrs_f
.iter()
.position(|a| a.kind == AttrKind::Bind && a.name == "key");
let key_expr: Option<Option<String>> = if let Some(kp) = key_attr_pos {
let key_val = attrs_f[kp].value.clone();
attrs_f.remove(kp);
Some(key_val)
} else {
None
};
if let Some(for_info) = parse_v_for(&val) {
let tmp_elem = Node::Element {
tag: tag.clone(),
attrs: attrs_f.clone(),
children: children.clone(),
self_closing: false,
};
let mut loop_code = String::new();
loop_code
.push_str("{ let mut __children: Vec<velox_dom::VNode> = Vec::new();\n");
let prop_field =
mode == TransformMode::Props && props.declares_vec_field(&for_info.expr);
let counted = !prop_field && mode != TransformMode::State;
if counted {
loop_code.push_str(&format!(
"let __for_expr = resolve(\"{}\");\n",
for_info.expr
));
loop_code.push_str(
"let __for_count = if let Ok(n) = __for_expr.parse::<usize>() {\n",
);
loop_code.push_str(" n\n");
loop_code.push_str("} else if __for_expr.is_empty() {\n");
loop_code.push_str(" 0\n");
loop_code.push_str("} else {\n");
loop_code.push_str(" __for_expr.split(',').count()\n");
loop_code.push_str("};\n");
loop_code.push_str(&format!(
"for {idx_var} in 0..__for_count {{\n",
idx_var = for_info.index_name
));
} else if prop_field {
loop_code
.push_str(&format!("let __col = &{}.{};\n", props.root, for_info.expr));
loop_code.push_str("if !__col.is_empty() {\n");
loop_code.push_str(&format!(
" for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
idx_var = for_info.index_name,
item_var = for_info.item_name
));
} else {
loop_code
.push_str(&format!("let __col = state.{}.get();\n", for_info.expr));
loop_code.push_str("if !__col.is_empty() {\n");
loop_code.push_str(&format!(
" for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
idx_var = for_info.index_name,
item_var = for_info.item_name
));
}
let inner = if counted {
emit_node_with_ctx_for_loop(&tmp_elem, &for_info, scope_id)
} else {
emit_node_with_ctx_state(
&tmp_elem,
Some(&for_info.item_name),
Some(&for_info.index_name),
mode,
props,
scope_id,
)
};
let inner_with_key = if let Some(Some(key_val)) = &key_expr {
if counted {
format!(
"{{ let mut __node = {}; if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert(\"key\".to_string(), {}); }} __node }}",
inner,
resolve_mode_key_value(key_val, &for_info)
)
} else {
let key_path_str = key_val
.strip_prefix(&for_info.item_name)
.map(|s| s.trim_start_matches('.'))
.unwrap_or("id");
format!(
"{{ let mut __node = {}; if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert(\"key\".to_string(), {item_var}.{key_path}.to_string()); }} __node }}",
inner,
item_var = for_info.item_name,
key_path = key_path_str
)
}
} else {
inner
};
if let Some(if_pos) = v_if_pos {
let dir_if = &attrs[if_pos];
let expr_if = rewrite_if_expr(&dir_if.value.clone().unwrap_or_default());
loop_code.push_str(&format!(
" if {} {{\n __children.push({});\n }}\n",
expr_if.trim(),
inner_with_key
));
} else {
loop_code.push_str(&format!(" __children.push({});\n", inner_with_key));
}
if !counted {
loop_code.push_str(" }\n");
loop_code.push_str("}\n");
}
loop_code.push_str("__children; }");
return loop_code;
}
}
let elem_props = emit_props_with(attrs);
let kids = emit_children_with_mode(children, mode, fields, props, scope_id);
format!(
r#"h("{}", {}, {kids})"#,
tag,
append_scope_attr(&elem_props, scope_id)
)
}
}
}
fn emit_node_with(
n: &Node,
fields: &[String],
props: &PropsChannel<'_>,
scope_id: Option<&str>,
) -> String {
emit_node_with_mode(n, TransformMode::Resolve, fields, *props, scope_id)
}
fn generate_component_props_expr(
clean_attrs: &[TemplateAttr],
comp_name: &str,
props: &PropsChannel<'_>,
) -> (bool, String) {
component_props_arg(clean_attrs, comp_name, props, None, None)
}
fn component_props_arg(
clean_attrs: &[TemplateAttr],
comp_name: &str,
props: &PropsChannel<'_>,
item_name: Option<&str>,
idx_name: Option<&str>,
) -> (bool, String) {
match props.child_declares_props(comp_name) {
Some(false) => {
return (
false,
format!("{comp_name}::PropsArg {{ values: std::collections::HashMap::new() }}"),
);
}
Some(true) => {}
None => {
let mut pairs: Vec<String> = Vec::new();
for a in clean_attrs {
if let AttrKind::Bind = a.kind {
let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
let value = bind_attr_value(&a.name, &expr, item_name, idx_name);
pairs.push(format!("({:?}, {value}.to_string())", a.name));
}
}
return (
!pairs.is_empty(),
format!(
"{comp_name}::PropsArg {{ values: std::collections::HashMap::from([{}]) }}",
pairs.join(", ")
),
);
}
}
let mut fields: Vec<String> = Vec::new();
let mut bound: Vec<String> = Vec::new();
for a in clean_attrs {
if let AttrKind::Bind = a.kind {
let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
let declared = props.child_field(comp_name, &a.name);
let value = match declared {
Some(f) if is_function_prop_type(&f.ty) && !is_handler_name(&expr) => {
format!(
"{name}: compile_error!(\"velox: <{comp_name}> prop `{name}` is a function, and this parent bound it to `{expr}`; a function prop takes the NAME of a method on this component's State, because the handler is invoked through this component's own event dispatcher — bind it as :{name}=\\\"on_confirm\\\"\")",
name = a.name
)
}
Some(f)
if is_function_prop_type(&f.ty) && !is_buildable_function_prop_type(&f.ty) =>
{
let name = a.name.clone();
let ty = f.ty.clone();
format!(
"{name}: compile_error!(\"velox: <{comp_name}> declares prop `{name}` as `{ty}`, and this parent bound a method name to it. The generated handler is a `Fn(&str)` that returns nothing and dispatches by name, so this generator cannot produce that signature. Declare the prop `Option<Box<dyn Fn(&str)>>` (or `Box<dyn Fn(&str)>`) and have the child call it as `f(payload)`\")"
)
}
Some(f) if is_function_prop_type(&f.ty) => {
format!(
"{}: {}",
a.name,
function_prop_value(comp_name, f, expr.trim())
)
}
Some(f) => {
let value = bind_attr_value(&a.name, &expr, item_name, idx_name);
format!("{}: {}", a.name, props_field_value(f, &value))
}
None => format!(
"{}: {}",
a.name,
bind_attr_value(&a.name, &expr, item_name, idx_name)
),
};
bound.push(a.name.clone());
fields.push(value);
}
}
for declared in props.child_fields(comp_name) {
if bound.contains(&declared.name) {
continue;
}
if is_optional_type(&declared.ty) {
fields.push(format!("{}: None", declared.name));
continue;
}
fields.push(format!(
"{}: compile_error!(\"velox: <{comp_name}> declares prop `{}` and this parent did not bind it; bind it on <{comp_name}> or drop the prop from its Props struct\")",
declared.name, declared.name
));
}
(
true,
format!("{comp_name}::PropsArg {{ {} }}", fields.join(", ")),
)
}
fn props_field_value(field: &PropField, value: &str) -> String {
match field.ty.trim() {
"String" | "str" | "&str" => value.to_string(),
"bool" => format!("({value}) == \"true\""),
ty if is_numeric_type(ty) => format!("({value}).parse().unwrap_or_default()"),
_ => value.to_string(),
}
}
fn is_numeric_type(ty: &str) -> bool {
matches!(
ty,
"i8" | "i16"
| "i32"
| "i64"
| "i128"
| "isize"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "usize"
| "f32"
| "f64"
)
}
fn rewrite_ctx_expr(expr: &str, item_name: Option<&str>, idx_name: Option<&str>) -> Option<String> {
let expr = expr.trim();
if let Some(item) = item_name {
if expr == item {
return Some(item.to_string());
}
if expr.starts_with(item) && expr.len() > item.len() && expr.as_bytes()[item.len()] == b'.'
{
return Some(expr.to_string());
}
}
if let Some(idx) = idx_name
&& expr == idx
{
return Some(idx.to_string());
}
None
}
fn generate_component_props_expr_with_ctx(
clean_attrs: &[TemplateAttr],
comp_name: &str,
props: &PropsChannel<'_>,
item_name: Option<&str>,
idx_name: Option<&str>,
) -> (bool, String) {
component_props_arg(clean_attrs, comp_name, props, item_name, idx_name)
}
fn emit_props_with_ctx(
attrs: &[TemplateAttr],
item_name: Option<&str>,
idx_name: Option<&str>,
) -> String {
emit_props_in_loop(attrs, item_name, idx_name, None)
}
fn emit_props_with(attrs: &[TemplateAttr]) -> String {
emit_props_in_loop(attrs, None, None, None)
}
fn emit_props_in_loop(
attrs: &[TemplateAttr],
item_name: Option<&str>,
idx_name: Option<&str>,
resolve_loop: Option<&VForInfo>,
) -> String {
if attrs.is_empty() {
return "Props::new()".to_string();
}
let mut parts = vec!["Props::new()".to_string()];
let static_class = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Static) && a.name == "class");
let bound_class: Vec<usize> = attrs
.iter()
.enumerate()
.filter(|(_, a)| matches!(a.kind, AttrKind::Bind) && a.name == "class")
.map(|(i, _)| i)
.collect();
let merged_class = match (static_class, bound_class.first()) {
(Some(first_static), Some(&first_bound)) => {
let at = first_static.min(first_bound);
let mut additions: Vec<String> =
class_tokens(attrs[first_static].value.as_deref().unwrap_or_default())
.iter()
.map(|name| format!("__classes.push({});", string_lit(name)))
.collect();
for &i in &bound_class {
let expr = attrs[i]
.value
.clone()
.unwrap_or_else(|| attrs[i].name.clone());
let emission = emit_bind_attr("class", &expr, item_name, idx_name, resolve_loop);
match &emission.value {
BindValue::ClassObject { conditions } => {
additions.extend(conditions.iter().cloned());
}
other => additions.push(format!(
"let __src = &{}; for __t in __src.split_whitespace() {{ __classes.push(__t); }}",
other.expr()
)),
}
}
Some((
at,
format!(r#".set("class", {})"#, class_block_value(&additions)),
))
}
_ => None,
};
for (i, a) in attrs.iter().enumerate() {
if let Some((at, entry)) = &merged_class {
if *at == i {
parts.push(entry.clone());
continue;
}
let is_class = a.name == "class" && matches!(a.kind, AttrKind::Static | AttrKind::Bind);
if is_class {
continue;
}
}
match a.kind {
AttrKind::Static => {
let v = a.value.clone().unwrap_or_default();
parts.push(format!(r#".set("{}", {})"#, a.name, string_lit(&v)));
}
AttrKind::Bind => {
let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
let key = if a.name == "click-payload" || a.name == "payload" {
"on:click-payload"
} else {
&a.name
};
parts.push(bind_prop_entry(
key,
&a.name,
&expr,
item_name,
idx_name,
resolve_loop,
));
}
AttrKind::Directive => {
}
AttrKind::On => {
let handler = a.value.clone().unwrap_or_default();
parts.push(format!(
r#".set("on:{}", {})"#,
a.name,
string_lit(&handler)
));
}
}
}
parts.join("")
}
fn append_scope_attr(props: &str, scope_id: Option<&str>) -> String {
if let Some(id) = scope_id {
format!(r#"{}.set("{}", "")"#, props, id)
} else {
props.to_string()
}
}
fn emit_children_with_mode(
children: &[Node],
mode: TransformMode,
fields: &[String],
props: PropsChannel<'_>,
scope_id: Option<&str>,
) -> String {
if children.is_empty() {
return "vec![]".to_string();
}
let mut out = String::new();
out.push_str("{ let mut __children: Vec<velox_dom::VNode> = Vec::new();\n");
let mut i = 0usize;
while i < children.len() {
match &children[i] {
Node::Element {
tag,
attrs,
children: ch,
self_closing,
} => {
if let Some(pos) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if")
{
let mut attrs_if = attrs.clone();
let dir = attrs_if.remove(pos);
let expr_if = rewrite_if_expr(&dir.value.unwrap_or_default());
let tmp_if = Node::Element {
tag: tag.clone(),
attrs: attrs_if,
children: ch.clone(),
self_closing: *self_closing,
};
let inner_if = emit_node_with_mode(&tmp_if, mode, fields, props, scope_id);
let mut chain_parts: Vec<String> = Vec::new();
let mut j = i + 1;
let mut else_part: Option<String> = None;
while j < children.len() {
if let Node::Element {
tag: tag2,
attrs: attrs2,
children: ch2,
self_closing: sc2,
} = &children[j]
{
if let Some(pos2) = attrs2.iter().position(|a| {
matches!(a.kind, AttrKind::Directive)
&& (a.name == "else-if" || a.name == "elseif")
}) {
let mut attrs_ei = attrs2.clone();
let dir_ei = attrs_ei.remove(pos2);
let expr_ei = rewrite_if_expr(&dir_ei.value.unwrap_or_default());
let tmp_ei = Node::Element {
tag: tag2.clone(),
attrs: attrs_ei,
children: ch2.clone(),
self_closing: *sc2,
};
let inner_ei =
emit_node_with_mode(&tmp_ei, mode, fields, props, scope_id);
chain_parts.push(format!(
r#"else if {} {{ {} }}"#,
expr_ei.trim(),
inner_ei
));
j += 1;
continue;
}
if let Some(pos3) = attrs2.iter().position(|a| {
matches!(a.kind, AttrKind::Directive) && a.name == "else"
}) {
let mut attrs_e = attrs2.clone();
attrs_e.remove(pos3);
let tmp_e = Node::Element {
tag: tag2.clone(),
attrs: attrs_e,
children: ch2.clone(),
self_closing: *sc2,
};
let inner_e =
emit_node_with_mode(&tmp_e, mode, fields, props, scope_id);
else_part = Some(format!(r#"else {{ {} }}"#, inner_e));
j += 1;
break;
}
}
if let Node::Text(t) = &children[j]
&& is_all_ws(t)
{
j += 1;
continue;
}
break;
}
let mut cond = String::new();
cond.push_str(&format!(r#"if {} {{ {} }}"#, expr_if.trim(), inner_if));
for part in chain_parts.iter() {
cond.push(' ');
cond.push_str(part);
}
if let Some(e) = else_part {
cond.push(' ');
cond.push_str(&e);
} else {
cond.push_str(r#" else { text("") }"#);
}
out.push_str(&format!("__children.push({});\n", cond));
i = if j > i { j } else { i + 1 };
continue;
}
if let Some(posf) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
{
let v_if_pos = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if");
let mut attrs_f = attrs.clone();
let dir = attrs_f.remove(posf);
let val = dir.value.unwrap_or_default();
let key_attr_pos = attrs_f
.iter()
.position(|a| a.kind == AttrKind::Bind && a.name == "key");
let key_expr = if let Some(kp) = key_attr_pos {
let key_val = attrs_f[kp].value.clone();
attrs_f.remove(kp);
Some(key_val)
} else {
None
};
if let Some(for_info) = parse_v_for(&val) {
let tmp_elem = Node::Element {
tag: tag.clone(),
attrs: attrs_f,
children: ch.clone(),
self_closing: *self_closing,
};
let prop_field = mode == TransformMode::Props
&& props.declares_vec_field(&for_info.expr);
let counted = !prop_field && mode != TransformMode::State;
if counted {
out.push_str(&format!(
"let __for_expr = resolve(\"{}\");\n",
for_info.expr
));
out.push_str(
"let __for_count = if let Ok(n) = __for_expr.parse::<usize>() {\n",
);
out.push_str(" n\n");
out.push_str("} else if __for_expr.is_empty() {\n");
out.push_str(" 0\n");
out.push_str("} else {\n");
out.push_str(" __for_expr.split(',').count()\n");
out.push_str("};\n");
out.push_str(&format!(
"for {idx_var} in 0..__for_count {{\n",
idx_var = for_info.index_name
));
} else if prop_field {
out.push_str(&format!(
"let __col = &{}.{};\n",
if mode == TransformMode::Props {
props.root
} else {
"state.props"
},
for_info.expr
));
out.push_str("if !__col.is_empty() {\n");
out.push_str(&format!(
" for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
idx_var = for_info.index_name,
item_var = for_info.item_name
));
} else {
out.push_str(&format!("let __col = state.{}.get();\n", for_info.expr));
out.push_str("if !__col.is_empty() {\n");
out.push_str(&format!(
" for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
idx_var = for_info.index_name,
item_var = for_info.item_name
));
}
let inner = if counted {
emit_node_with_ctx_for_loop(&tmp_elem, &for_info, scope_id)
} else {
emit_node_with_ctx_state(
&tmp_elem,
Some(&for_info.item_name),
Some(&for_info.index_name),
mode,
props,
scope_id,
)
};
let inner_with_key = if let Some(Some(key_val)) = &key_expr {
if counted {
format!(
"{{ let mut __node = {}; if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert(\"key\".to_string(), {}); }} __node }}",
inner,
resolve_mode_key_value(key_val, &for_info)
)
} else {
let key_path_str = key_val
.strip_prefix(&for_info.item_name)
.map(|s| s.trim_start_matches('.'))
.unwrap_or("id");
format!(
"{{ let mut __node = {}; if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert(\"key\".to_string(), {item_var}.{key_path}.to_string()); }} __node }}",
inner,
item_var = for_info.item_name,
key_path = key_path_str
)
}
} else {
inner
};
if let Some(if_pos) = v_if_pos {
let dir_if = &attrs[if_pos];
let expr_if =
rewrite_if_expr(&dir_if.value.clone().unwrap_or_default());
out.push_str(&format!(
" if {} {{\n __children.push({});\n }}\n",
expr_if.trim(),
inner_with_key
));
} else {
out.push_str(&format!(" __children.push({});\n", inner_with_key));
}
if !counted {
out.push_str(" }\n");
out.push_str("}\n");
}
if counted {
out.push_str("}\n");
}
i += 1;
continue;
}
}
let expr = emit_node_with_mode(&children[i], mode, fields, props, scope_id);
out.push_str(&format!("__children.push({});\n", expr));
i += 1;
}
_ => {
let expr = emit_node_with_mode(&children[i], mode, fields, props, scope_id);
out.push_str(&format!("__children.push({});\n", expr));
i += 1;
}
}
}
out.push_str("__children\n}");
out
}
fn emit_node_with_state(
n: &Node,
fields: &[String],
props: &PropsChannel<'_>,
scope_id: Option<&str>,
) -> String {
emit_node_with_mode(n, TransformMode::State, fields, *props, scope_id)
}
fn emit_node_with_ctx_state(
n: &Node,
item_name: Option<&str>,
idx_name: Option<&str>,
mode: TransformMode,
props: PropsChannel<'_>,
scope_id: Option<&str>,
) -> String {
match n {
Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
Node::Interpolation(expr) => {
let key = expr.trim();
if let Some(item) = item_name {
if key == item {
return format!(r#"text(format!("{{}}", {item}))"#);
}
if key.starts_with(item)
&& key.len() > item.len()
&& key.as_bytes()[item.len()] == b'.'
{
let prop_path = &key[item.len()..];
return format!(
r#"text({{ let __obj = &{item}; __obj{}.to_string() }})"#,
prop_path
);
}
}
if let Some(idx) = idx_name
&& key == idx
{
return format!(r#"text({idx}.to_string())"#);
}
let key_lit = string_lit(key);
format!(r#"text(resolve({}))"#, key_lit)
}
Node::Element {
tag,
attrs,
children,
..
} => {
let (attrs2, _v_model_handler) = extract_vmodel(attrs, item_name, idx_name);
let attrs = &attrs2;
if tag == "slot" {
let slot_name = slot_outlet_name(attrs);
let fallback_children: Vec<String> = children
.iter()
.map(|c| {
emit_node_with_ctx_state(c, item_name, idx_name, mode, props, scope_id)
})
.collect();
let fallback = format!("vec![{}]", fallback_children.join(", "));
return format!(
r#"render_slot({slot_name_lit}, || {{ velox_dom::h(\"slot\", velox_dom::Props::new(), {fallback}) }})"#,
slot_name_lit = string_lit(&slot_name),
fallback = fallback,
);
}
if let Some(pos) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "show")
{
let mut attrs2 = attrs.clone();
let dir = attrs2.remove(pos);
let (expr, _keys) =
condition_expr(&dir.value.unwrap_or_default(), item_name, idx_name, None);
let inner = emit_node_with_ctx_state(
&Node::Element {
tag: tag.clone(),
attrs: attrs2.clone(),
children: children.clone(),
self_closing: false,
},
item_name,
idx_name,
mode,
props,
scope_id,
);
let has_style = attrs.iter().any(|a| {
a.name == "style" && matches!(a.kind, AttrKind::Static | AttrKind::Bind)
});
if has_style {
return format!(
r#"{{ let mut __node = {inner}; if !({expr}) {{ if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.entry("style".to_string()).and_modify(|s| {{ if s.contains("display:") {{ /* preserve existing display */ }} else {{ s.push_str("; display: none"); }} }}).or_insert_with(|| "display: none".to_string()); }} }} __node }}"#,
inner = inner,
expr = expr.trim()
);
}
return format!(
r#"{{ let mut __node = {inner}; if !({expr}) {{ if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert("style".to_string(), "display: none".to_string()); }} }} __node }}"#,
inner = inner,
expr = expr.trim()
);
}
if let Some(pos) = attrs
.iter()
.position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if")
{
let mut attrs2 = attrs.clone();
let dir = attrs2.remove(pos);
let (expr_if, _keys) =
condition_expr(&dir.value.unwrap_or_default(), item_name, idx_name, None);
let tmp = Node::Element {
tag: tag.clone(),
attrs: attrs2,
children: children.clone(),
self_closing: false,
};
let inner =
emit_node_with_ctx_state(&tmp, item_name, idx_name, mode, props, scope_id);
return format!(
r#"if {} {{ {} }} else {{ text("") }}"#,
expr_if.trim(),
inner
);
}
if let Some(component_attr) = attrs
.iter()
.find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
&& let Some(comp_name) = &component_attr.value
{
let mut clean_attrs = attrs.clone();
clean_attrs.retain(|a| a.name != "data-velox-component");
let has_slot_children = !children.is_empty();
let (_has_props, props_expr) = generate_component_props_expr_with_ctx(
&clean_attrs,
comp_name,
&props,
item_name,
idx_name,
);
let callbacks = collect_component_callbacks(&clean_attrs);
let dispatch = if mode == TransformMode::State {
emit_dispatch_registrations(
comp_name,
&component_dispatch_handlers(&clean_attrs, comp_name, &props),
)
} else {
String::new()
};
let slots_expr = if has_slot_children {
slots_map_expr(children, &|c| {
emit_node_with_ctx_state(c, item_name, idx_name, mode, props, scope_id)
})
} else {
"std::collections::HashMap::new()".to_string()
};
if callbacks.is_empty() {
if !has_slot_children {
return format!(
r#"{{ {dispatch}let __props = {props_expr}; {comp_name}::render_with_props(__props) }}"#,
);
}
return format!(
r#"{{ {dispatch}let __props = {props_expr}; let __slots = {slots_expr}; {comp_name}::render_with_slots_only(__props, &__slots) }}"#,
);
}
if !has_slot_children {
let callback_map = format_callback_map(&callbacks);
let callback_names: Vec<String> = callbacks
.iter()
.map(|(_, handler)| handler.clone())
.collect();
return format!(
r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; {comp_name}::render_with_callbacks(__props, &__callbacks, &[{callback_names}]) }}"#,
callback_names = callback_names
.iter()
.map(|n| format!("\"{}\"", n))
.collect::<Vec<_>>()
.join(", ")
);
}
let callback_map = format_callback_map(&callbacks);
let callback_names: Vec<String> = callbacks
.iter()
.map(|(_, handler)| handler.clone())
.collect();
return format!(
r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; let __slots = {slots_expr}; {comp_name}::render_with_slots(__props, &__callbacks, &[{callback_names}], &__slots) }}"#,
callback_names = callback_names
.iter()
.map(|n| format!("\"{}\"", n))
.collect::<Vec<_>>()
.join(", ")
);
}
let elem_props = emit_props_with_ctx(attrs, item_name, idx_name);
let mut k_items: Vec<String> = Vec::new();
for c in children {
k_items.push(emit_node_with_ctx_state(
c, item_name, idx_name, mode, props, scope_id,
));
}
let kids = format!("vec![{}]", k_items.join(", "));
format!(
r#"h("{}", {}, {kids})"#,
tag,
append_scope_attr(&elem_props, scope_id)
)
}
}
}
#[allow(dead_code)]
fn emit_node_with_ctx(n: &Node, loop_var: Option<&str>, scope_id: Option<&str>) -> String {
match n {
Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
Node::Interpolation(expr) => {
let key = expr.trim();
if let Some(var) = loop_var {
if key == var {
return "text(__i.to_string())".to_string();
}
if key.starts_with(var)
&& key.len() > var.len()
&& key.as_bytes()[var.len()] == b'.'
{
let prop_path = &key[var.len()..];
return format!(
r#"text({{ let __obj = __i; __obj{}.to_string() }})"#,
prop_path
);
}
}
let key_lit = string_lit(key);
format!(r#"text(resolve({}))"#, key_lit)
}
Node::Element {
tag,
attrs,
children,
..
} => {
let elem_props = emit_props_with(attrs);
let kids = {
let mut k_items: Vec<String> = Vec::new();
for c in children {
k_items.push(emit_node_with_ctx(c, loop_var, scope_id));
}
format!("vec![{}]", k_items.join(", "))
};
format!(
r#"h("{}", {}, {kids})"#,
tag,
append_scope_attr(&elem_props, scope_id)
)
}
}
}
fn emit_node_with_ctx_for_loop(n: &Node, for_info: &VForInfo, scope_id: Option<&str>) -> String {
match n {
Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
Node::Interpolation(expr) => {
let key = expr.trim();
if key == for_info.item_name {
return format!(
r#"text(resolve(&format!("{}[{{}}]", {})))"#,
for_info.expr, for_info.index_name
);
}
if key.starts_with(&for_info.item_name)
&& key.len() > for_info.item_name.len()
&& key.as_bytes()[for_info.item_name.len()] == b'.'
{
let prop_path = &key[for_info.item_name.len()..];
return format!(
r#"text(resolve(&format!("{expr}[{{}}]{prop_path}", {idx})))"#,
expr = for_info.expr,
idx = for_info.index_name,
);
}
if key == for_info.index_name {
return format!(r#"text({}.to_string())"#, for_info.index_name);
}
let key_lit = string_lit(key);
format!(r#"text(resolve({}))"#, key_lit)
}
Node::Element {
tag,
attrs,
children,
..
} => {
let elem_props = emit_props_in_loop(
attrs,
Some(&for_info.item_name),
Some(&for_info.index_name),
Some(for_info),
);
let kids = {
let mut k_items: Vec<String> = Vec::new();
for c in children {
k_items.push(emit_node_with_ctx_for_loop(c, for_info, scope_id));
}
format!("vec![{}]", k_items.join(", "))
};
format!(
r#"h("{}", {}, {kids})"#,
tag,
append_scope_attr(&elem_props, scope_id)
)
}
}
}
fn collect_component_callbacks(attrs: &[TemplateAttr]) -> Vec<(String, String)> {
attrs
.iter()
.filter(|a| a.kind == AttrKind::On)
.filter_map(|a| {
let handler = a.value.clone().unwrap_or_default();
if handler.is_empty() {
None
} else {
Some((a.name.clone(), handler))
}
})
.collect()
}
fn format_callback_map(callbacks: &[(String, String)]) -> String {
if callbacks.is_empty() {
return "std::collections::HashMap::new()".to_string();
}
let entries: Vec<String> = callbacks
.iter()
.map(|(event, handler)| format!("({}, {})", string_lit(event), string_lit(handler)))
.collect();
format!("std::collections::HashMap::from([{}])", entries.join(", "))
}
const PROP_TYPE_WRAPPERS: [(&str, &str); 9] = [
("std::option::Option<", "Some"),
("Option<", "Some"),
("Box<", "Box::new"),
("std::rc::Rc<", "std::rc::Rc::new"),
("std::sync::Arc<", "std::sync::Arc::new"),
("std::cell::RefCell<", "std::cell::RefCell::new"),
("Rc<", "std::rc::Rc::new"),
("Arc<", "std::sync::Arc::new"),
("RefCell<", "std::cell::RefCell::new"),
];
fn fn_prop_core(ty: &str) -> Option<&str> {
let mut inner = ty.trim();
let mut peeled = false;
loop {
let Some(marker) = PROP_TYPE_WRAPPERS
.iter()
.filter(|(marker, _)| inner.starts_with(marker))
.map(|(marker, _)| *marker)
.max_by_key(|marker| marker.len())
else {
break;
};
inner = inner[marker.len()..].trim_end_matches('>').trim();
peeled = true;
}
if !peeled {
return None;
}
Some(inner.strip_prefix("dyn ").unwrap_or(inner).trim())
}
fn is_function_prop_type(ty: &str) -> bool {
fn_prop_core(ty).is_some_and(|core| core.starts_with("Fn"))
}
fn is_buildable_function_prop_type(ty: &str) -> bool {
fn_prop_core(ty).is_some_and(|core| matches!(core, "Fn(&str)" | "FnMut(&str)" | "FnOnce(&str)"))
}
fn is_optional_type(ty: &str) -> bool {
let ty = ty.trim();
PROP_TYPE_WRAPPERS
.iter()
.filter(|(marker, _)| marker.ends_with("Option<"))
.any(|(marker, _)| ty.starts_with(marker))
}
fn is_handler_name(expr: &str) -> bool {
let expr = expr.trim();
!expr.is_empty()
&& !expr.starts_with(|c: char| c.is_ascii_digit())
&& expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn collect_function_prop_bindings(
attrs: &[TemplateAttr],
comp_name: &str,
props: &PropsChannel<'_>,
) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for a in attrs {
if a.kind != AttrKind::Bind {
continue;
}
let Some(field) = props.child_field(comp_name, &a.name) else {
continue;
};
if !is_function_prop_type(&field.ty) {
continue;
}
let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
if is_handler_name(&expr) {
out.push((a.name.clone(), expr.trim().to_string()));
}
}
out
}
fn function_prop_value(comp_name: &str, field: &PropField, handler: &str) -> String {
let body = format!(
"move |__vx_payload: &str| {{ {comp_name}::dispatch_emit({}, __vx_payload); }}",
string_lit(handler)
);
wrap_by_declared_type(field.ty.trim(), body)
}
fn wrap_by_declared_type(ty: &str, body: String) -> String {
let mut ctors: Vec<&str> = Vec::new();
let mut rest = ty.trim();
loop {
let Some((marker, ctor)) = PROP_TYPE_WRAPPERS
.iter()
.filter(|(marker, _)| rest.starts_with(marker))
.max_by_key(|(marker, _)| marker.len())
else {
break;
};
ctors.push(ctor);
rest = &rest[marker.len()..];
}
ctors
.into_iter()
.rev()
.fold(body, |inner, ctor| format!("{ctor}({inner})"))
}
fn emit_dispatch_registrations(comp_name: &str, handlers: &[String]) -> String {
if handlers.is_empty() {
return String::new();
}
let mut out = String::new();
for (i, handler) in handlers.iter().enumerate() {
out.push_str(&format!(
"let __vx_state_{i} = std::sync::Arc::downgrade(&state); \
{comp_name}::set_emit_dispatch({name}, std::rc::Rc::new(std::cell::RefCell::new(\
move |__vx_payload: &str| {{ \
if let Some(__vx_state) = __vx_state_{i}.upgrade() {{ \
let mut __vx_dispatch = make_on_event(__vx_state); \
__vx_dispatch({name}, Some(__vx_payload)); \
}} \
}}))); ",
name = string_lit(handler),
));
}
out
}
fn component_dispatch_handlers(
clean_attrs: &[TemplateAttr],
comp_name: &str,
props: &PropsChannel<'_>,
) -> Vec<String> {
let mut out: Vec<String> = collect_component_callbacks(clean_attrs)
.into_iter()
.map(|(_event, handler)| handler)
.collect();
for (_prop_name, handler) in collect_function_prop_bindings(clean_attrs, comp_name, props) {
if !out.contains(&handler) {
out.push(handler);
}
}
out
}
fn collect_function_prop_handlers(nodes: &[Node], props: &PropsChannel<'_>) -> Vec<String> {
fn walk(node: &Node, props: &PropsChannel<'_>, out: &mut Vec<String>) {
let Node::Element {
attrs, children, ..
} = node
else {
return;
};
if let Some(comp_name) = attrs
.iter()
.find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
.and_then(|a| a.value.clone())
{
for (_, handler) in collect_function_prop_bindings(attrs, &comp_name, props) {
if !out.contains(&handler) {
out.push(handler);
}
}
}
for c in children {
walk(c, props, out);
}
}
let mut out: Vec<String> = Vec::new();
for n in nodes {
walk(n, props, &mut out);
}
out
}
pub(crate) fn slot_binding(node: &Node) -> Option<String> {
let Node::Element { attrs, .. } = node else {
return None;
};
for a in attrs {
if !matches!(a.kind, AttrKind::Directive) {
continue;
}
if let Some(name) = a.name.strip_prefix("slot:") {
let name = name.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
pub(crate) fn strip_slot_binding(node: &Node) -> Node {
let Node::Element {
tag,
attrs,
children,
self_closing,
} = node
else {
return node.clone();
};
let attrs: Vec<TemplateAttr> = attrs
.iter()
.filter(|a| !(a.kind == AttrKind::Directive && a.name.starts_with("slot:")))
.cloned()
.collect();
Node::Element {
tag: tag.clone(),
attrs,
children: children.clone(),
self_closing: *self_closing,
}
}
pub(crate) fn slot_content(node: &Node) -> Vec<Node> {
match node {
Node::Element { tag, children, .. }
if tag == "template" && slot_binding(node).is_some() =>
{
children.clone()
}
other => vec![strip_slot_binding(other)],
}
}
fn slots_map_expr(children: &[Node], emit: &dyn Fn(&Node) -> String) -> String {
let mut groups: Vec<(String, Vec<Node>)> = Vec::new();
for child in children {
let name = slot_binding(child).unwrap_or_else(|| "default".to_string());
let content = slot_content(child);
match groups.iter_mut().find(|(existing, _)| *existing == name) {
Some((_, nodes)) => nodes.extend(content),
None => groups.push((name, content)),
}
}
let entries: Vec<String> = groups
.iter()
.filter(|(_, nodes)| !nodes.is_empty())
.map(|(name, nodes)| {
let vnodes: Vec<String> = nodes.iter().map(emit).collect();
let vnode = if vnodes.len() == 1 {
vnodes[0].clone()
} else {
format!(
"velox_dom::h(\"slot\", velox_dom::Props::new(), vec![{}])",
vnodes.join(", ")
)
};
format!("({}, {vnode})", string_lit(name))
})
.collect();
if entries.is_empty() {
return "std::collections::HashMap::new()".to_string();
}
format!("std::collections::HashMap::from([{}])", entries.join(", "))
}
fn string_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
out.push('"');
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('"');
out
}
pub fn collect_vmodel_expressions(nodes: &[Node]) -> Vec<(String, String)> {
let mut results = Vec::new();
fn walk(
nodes: &[Node],
out: &mut Vec<(String, String)>,
loop_vars: &mut Vec<(String, String)>,
) {
for node in nodes {
if let Node::Element {
attrs, children, ..
} = node
{
if let Some(info) = v_for_scope(attrs) {
loop_vars.push((info.item_name.clone(), info.index_name.clone()));
}
let (item_name, idx_name) = loop_vars.last().map_or((None, None), |(item, idx)| {
(Some(item.as_str()), Some(idx.as_str()))
});
for attr in attrs {
if matches!(attr.kind, AttrKind::Directive)
&& attr.name == "model"
&& let Some(expr) = &attr.value
&& !vmodel_is_loop_rooted(expr, item_name, idx_name)
{
let handler = vmodel_setter_name(expr);
out.push((expr.clone(), handler));
}
}
walk(children, out, loop_vars);
if v_for_scope(attrs).is_some() {
loop_vars.pop();
}
}
}
}
let mut loop_vars: Vec<(String, String)> = Vec::new();
walk(nodes, &mut results, &mut loop_vars);
results
}
pub fn collect_interpolation_keys(nodes: &[Node]) -> Vec<String> {
let mut keys = Vec::new();
let mut loop_vars: Vec<(String, String)> = Vec::new();
fn walk(nodes: &[Node], out: &mut Vec<String>, loop_vars: &mut Vec<(String, String)>) {
for node in nodes {
match node {
Node::Interpolation(expr) => {
let key = expr.trim().to_string();
if key.is_empty() || out.contains(&key) {
continue;
}
let roots: Vec<&str> = loop_vars
.iter()
.flat_map(|(item, index)| [item.as_str(), index.as_str()])
.collect();
if expr_reads_roots(&key, &roots) {
continue;
}
out.push(key);
}
Node::Element {
attrs, children, ..
} => {
let scope = attrs
.iter()
.find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
.and_then(|a| a.value.as_deref())
.and_then(parse_v_for)
.map(|info| (info.item_name, info.index_name));
if let Some(pair) = &scope {
loop_vars.push(pair.clone());
}
walk(children, out, loop_vars);
if scope.is_some() {
loop_vars.pop();
}
}
_ => {}
}
}
}
walk(nodes, &mut keys, &mut loop_vars);
keys
}
fn is_bare_identifier(expr: &str) -> bool {
let mut chars = expr.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(is_ident_char)
}
fn getter_method_name<'a>(methods: &'a [StateMethod], key: &'a str) -> Option<&'a str> {
find_state_method(methods, key, |m| {
!m.takes_payload && return_type_is_renderable(m.return_type.as_deref())
})
}
fn accessor_method_name<'a>(methods: &'a [StateMethod], key: &'a str) -> Option<&'a str> {
find_state_method(methods, key, |m| {
!m.takes_payload && returns_a_value(m.return_type.as_deref())
})
}
fn returns_a_value(ty: Option<&str>) -> bool {
match ty.map(str::trim) {
Some(ty) => !matches!(strip_borrow(ty), "()" | "Self" | ""),
None => false,
}
}
fn find_state_method<'a>(
methods: &'a [StateMethod],
key: &str,
usable: impl Fn(&StateMethod) -> bool,
) -> Option<&'a str> {
if let Some(m) = methods.iter().find(|m| m.name == key && usable(m)) {
return Some(m.name.as_str());
}
["get_", "is_", "has_"].iter().find_map(|prefix| {
let candidate = format!("{prefix}{key}");
methods
.iter()
.find(|m| m.name == candidate && usable(m))
.map(|m| m.name.as_str())
})
}
fn return_type_is_renderable(ty: Option<&str>) -> bool {
let Some(ty) = ty else {
return false;
};
let base = strip_borrow(ty);
if matches!(
base,
"String"
| "str"
| "bool"
| "char"
| "i8"
| "i16"
| "i32"
| "i64"
| "i128"
| "isize"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "usize"
| "f32"
| "f64"
) {
return true;
}
["Rc<", "Arc<", "Cow<"].iter().any(|wrapper| {
base.strip_prefix(wrapper)
.and_then(|inner| inner.strip_suffix('>'))
.is_some_and(|inner| matches!(strip_borrow(inner.trim()), "String" | "str"))
})
}
fn strip_borrow(ty: &str) -> &str {
let mut ty = ty.trim();
if let Some(rest) = ty.strip_prefix('&') {
ty = rest.trim_start();
}
if let Some(rest) = ty.strip_prefix('\'') {
ty = match rest.strip_prefix("_,") {
Some(after) => after.trim_start(),
None => {
let end = rest.find(|c: char| !is_ident_char(c)).unwrap_or(rest.len());
rest[end..].trim_start()
}
};
}
ty
}
fn has_state_getter(methods: &[StateMethod], key: &str) -> bool {
getter_method_name(methods, key).is_some()
}
fn resolve_getter_call(methods: &[StateMethod], fields: &[String], key: &str) -> String {
match answerable(key, methods, fields) {
Ok(answer) => answer.call(),
Err(_) => format!("{}()", conventional_name(methods, key)),
}
}
fn member_path_call(methods: &[StateMethod], key: &str) -> Option<String> {
let (root, segments) = member_path(key)?;
let mut chain = format!("{}()", accessor_method_name(methods, root)?);
for segment in segments {
if segment.starts_with('[') {
chain.push_str(segment);
} else {
chain.push('.');
chain.push_str(segment);
chain.push_str("()");
}
}
Some(chain)
}
fn member_path(key: &str) -> Option<(&str, Vec<&str>)> {
if key.contains('(') || key.contains(')') {
return None;
}
let split = key
.find(['.', '['])
.filter(|&i| i > 0 && key[..i].chars().all(is_ident_char))?;
let root = &key[..split];
let mut segments = Vec::new();
let rest = key[split..].strip_prefix('.').unwrap_or(&key[split..]);
for segment in rest.split('.') {
let is_index = segment.starts_with('[') && segment.ends_with(']');
let is_name = !segment.is_empty() && segment.chars().all(is_ident_char);
if !is_index && !is_name {
return None;
}
segments.push(segment);
}
if segments.is_empty() {
return None;
}
Some((root, segments))
}
fn is_ident_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
fn key_is_answerable(methods: &[StateMethod], fields: &[String], key: &str) -> bool {
answerable(key, methods, fields).is_ok()
}
fn keep_answerable_interpolation_keys(
methods: &[StateMethod],
fields: &[String],
keys: Vec<String>,
warnings: &mut Vec<String>,
) -> Vec<String> {
let mut registered = Vec::with_capacity(keys.len());
for key in keys {
let Err(unrenderable) = answerable(&key, methods, fields) else {
registered.push(key);
continue;
};
warnings.push(format!(
"interpolation {{{{ {key} }}}} cannot be resolved — {}; the interpolation \
renders empty. {}",
unrenderable.root_reason, unrenderable.remedy
));
}
registered
}
enum BindValue {
Lookup(String),
ClassObject { conditions: Vec<String> },
Direct(String),
}
impl BindValue {
fn expr(&self) -> String {
match self {
BindValue::Lookup(expr) | BindValue::Direct(expr) => expr.clone(),
BindValue::ClassObject { conditions } => class_block_value(conditions),
}
}
}
fn class_block_value(additions: &[String]) -> String {
format!(
"{{ let mut __classes: Vec<&str> = Vec::new(); {} __classes.join(\" \") }}",
additions.join(" ")
)
}
fn class_tokens(value: &str) -> Vec<String> {
value.split_whitespace().map(str::to_string).collect()
}
struct BindEmission {
value: BindValue,
keys: Vec<String>,
authored: String,
}
fn condition_expr(
expr: &str,
item_name: Option<&str>,
idx_name: Option<&str>,
resolve_loop: Option<&VForInfo>,
) -> (String, Vec<String>) {
match rewrite_ctx_expr(expr, item_name, idx_name) {
Some(direct) => {
let read = match resolve_loop {
Some(info) => match resolve_loop_item_expr(&direct, info) {
Some(indexed) if indexed != info.index_name => resolver_truthiness(&indexed),
_ => direct,
},
None => direct,
};
(read, Vec::new())
}
None => (rewrite_if_expr(expr), condition_resolver_keys(expr)),
}
}
fn emit_bind_attr(
attr_name: &str,
expr: &str,
item_name: Option<&str>,
idx_name: Option<&str>,
resolve_loop: Option<&VForInfo>,
) -> BindEmission {
let authored = if attr_name == "key" {
resolve_key_expr(expr)
} else {
expr.trim().to_string()
};
if attr_name == "class" {
let pairs = class_object_pairs(&authored);
if !pairs.is_empty() {
let mut conditions: Vec<String> = Vec::new();
let mut keys: Vec<String> = Vec::new();
for (cls, cond) in pairs {
if cls.is_empty() || cond.is_empty() {
continue;
}
let (value, cond_keys) = condition_expr(&cond, item_name, idx_name, resolve_loop);
for key in cond_keys {
push_unique(&mut keys, key);
}
conditions.push(format!(
"if {} {{ __classes.push({}); }}",
value,
string_lit(&cls)
));
}
if !conditions.is_empty() {
return BindEmission {
value: BindValue::ClassObject { conditions },
keys,
authored,
};
}
}
}
if let Some(direct) = rewrite_ctx_expr(&authored, item_name, idx_name) {
let direct = match resolve_loop {
Some(info) => match resolve_loop_item_expr(&direct, info) {
Some(indexed) => indexed,
None => {
return BindEmission {
value: BindValue::Lookup(format!("resolve({})", string_lit(&authored))),
keys: vec![authored.clone()],
authored,
};
}
},
None => direct,
};
return BindEmission {
value: BindValue::Direct(direct),
keys: Vec::new(),
authored,
};
}
BindEmission {
value: BindValue::Lookup(format!("resolve({})", string_lit(&authored))),
keys: vec![authored.clone()],
authored,
}
}
fn bind_attr_value(
attr_name: &str,
expr: &str,
item_name: Option<&str>,
idx_name: Option<&str>,
) -> String {
let emission = emit_bind_attr(attr_name, expr, item_name, idx_name, None);
match &emission.value {
BindValue::Direct(direct) => format!("format!(\"{{}}\", {direct})"),
_ => format!("{}.clone()", emission.value.expr()),
}
}
fn bind_prop_entry(
key: &str,
attr_name: &str,
expr: &str,
item_name: Option<&str>,
idx_name: Option<&str>,
resolve_loop: Option<&VForInfo>,
) -> String {
let emission = emit_bind_attr(attr_name, expr, item_name, idx_name, resolve_loop);
match &emission.value {
BindValue::Direct(direct) => format!(r#".set("{}", &format!("{{}}", {direct}))"#, key),
BindValue::ClassObject { conditions } => {
format!(r#".set("class", {})"#, class_block_value(conditions))
}
BindValue::Lookup(value) => format!(r#".set("{}", &{value})"#, key),
}
}
fn class_object_pairs(expr: &str) -> Vec<(String, String)> {
let trimmed = expr.trim();
if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
return Vec::new();
}
let inner = &trimmed[1..trimmed.len() - 1];
inner
.split(',')
.filter_map(|pair| pair.split_once(':'))
.map(|(cls, cond)| (strip_class_quotes(cls), cond.trim().to_string()))
.filter(|(cls, cond)| !cls.is_empty() && !cond.is_empty())
.collect()
}
fn strip_class_quotes(cls: &str) -> String {
cls.trim()
.trim_matches(|c| c == '\'' || c == '"')
.trim()
.to_string()
}
fn condition_resolver_keys(expr: &str) -> Vec<String> {
let chars: Vec<char> = expr.chars().collect();
let mut keys: Vec<String> = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_ascii_alphabetic() || c == '_' {
let start = i;
i = member_path_end(&chars, start);
let token: String = chars[start..i].iter().collect();
if !matches!(token.as_str(), "true" | "false" | "resolve" | "state") {
keys.push(token);
}
} else {
i += 1;
}
}
keys
}
struct ResolveLoopFamily {
collection: String,
body_reads_loop: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum IndexEmission {
Direct,
Resolver,
}
#[derive(Clone, Copy, PartialEq)]
enum AttrSite {
LoopElement,
InLoopBody,
}
fn directive_emission(name: &str, site: AttrSite) -> IndexEmission {
match (name, site) {
("if" | "show" | "else-if" | "elseif" | "else", AttrSite::LoopElement) => {
IndexEmission::Resolver
}
(_, AttrSite::InLoopBody) => IndexEmission::Direct,
("model", AttrSite::LoopElement) => IndexEmission::Direct,
(_, AttrSite::LoopElement) => IndexEmission::Resolver,
}
}
fn attrs_read_loop_root(attrs: &[TemplateAttr], item: &str, index: &str, site: AttrSite) -> bool {
attrs.iter().any(|a| match a.kind {
AttrKind::Bind => a
.value
.as_deref()
.map(|value| expr_reads_loop_var(value, item, index, IndexEmission::Direct))
.unwrap_or(false),
AttrKind::Directive => a
.value
.as_deref()
.map(|value| match parse_v_for(value) {
Some(info) => expr_reads_loop_var(&info.expr, item, index, IndexEmission::Resolver),
None => expr_reads_loop_var(value, item, index, directive_emission(&a.name, site)),
})
.unwrap_or(false),
AttrKind::Static | AttrKind::On => false,
})
}
fn reads_loop_root(node: &Node, item: &str, index: &str) -> bool {
match node {
Node::Text(_) => false,
Node::Interpolation(expr) => expr_reads_loop_var(expr, item, index, IndexEmission::Direct),
Node::Element {
attrs, children, ..
} => {
let roots = [item, index];
let shadowed = attrs
.iter()
.find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
.and_then(|a| a.value.as_deref())
.and_then(parse_v_for)
.is_some_and(|info| {
!roots.is_empty()
&& roots
.iter()
.all(|root| *root == info.item_name || *root == info.index_name)
});
(!shadowed && attrs_read_loop_root(attrs, item, index, AttrSite::InLoopBody))
|| children.iter().any(|c| reads_loop_root(c, item, index))
}
}
}
fn expr_reads_loop_var(expr: &str, item: &str, index: &str, emission: IndexEmission) -> bool {
if expr_reads_roots(expr, &[item]) {
return true;
}
expr_reads_roots(expr, &[index])
&& (emission == IndexEmission::Resolver || expr.trim() != index)
}
fn expr_reads_loop_root(expr: &str, item: &str, index: &str) -> bool {
expr_reads_roots(expr, &[item, index])
}
fn expr_reads_roots(expr: &str, roots: &[&str]) -> bool {
let chars: Vec<char> = expr.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_ascii_alphabetic() || chars[i] == '_' {
let start = i;
i += 1;
while i < chars.len() && is_ident_char(chars[i]) {
i += 1;
}
let token: String = chars[start..i].iter().collect();
if roots.contains(&token.as_str()) {
return true;
}
} else {
i += 1;
}
}
false
}
fn resolve_loop_warning(family: &ResolveLoopFamily, methods: &[StateMethod]) -> String {
let collection = &family.collection;
let has_arm = has_state_getter(methods, collection);
let cause = if has_arm {
format!(
"the loop's values are read as indexed resolver keys (`{collection}[0]…`), which \
no resolver arm answers, so every read of the loop ITEM renders empty — only a \
direct read of the loop index, such as `{{{{ index }}}}`, survives. This is also \
reported for a read that the emitter DROPS rather than makes, such as a condition \
on an element inside the body: that read renders nothing to be wrong, but it \
cannot be checked from here, and a loop reported for the wrong reason is better \
than one that is broken for the wrong reason without saying so"
)
} else {
format!(
"`{collection}` is not a registered `State` getter, so the resolver returns an empty \
string for it, the loop counts zero items and its body never runs at all"
)
};
format!(
"`v-for` over `{collection}` cannot render in Resolve mode — {cause}. Render this \
component through `render_with_state`, which reads the collection and the loop item \
directly."
)
}
fn unrenderable_collection_error(collection: &str, props: &PropsChannel<'_>) -> String {
let cause = if let Some(field) = props.field(collection) {
format!(
"it is declared as a `Props` field, but as `{}` rather than a collection type, so \
the generated `for` cannot read it: a `Vec<T>`, a slice or an array is what a \
`v-for` can iterate",
field.ty
)
} else {
match answerable(collection, &[], props.state_fields) {
Err(unrenderable) => unrenderable.remedy.trim_end_matches('.').to_string(),
Ok(_) => "the resolver table is keyed by name, and this collection is an expression \
rather than a name, so no arm can answer it"
.to_string(),
}
};
format!(
"`v-for` over `{collection}` cannot render — {cause}. The resolver is a flat \
`\"<key>\" => state.<getter>().to_string()` table built once, outside every loop, so the \
loop asks it for the collection and it answers an empty string, the count is zero, and \
the body never runs at all. Give the loop something it can read: declare `{collection}` \
in this component's `pub struct Props` as a `Vec<T>`, give it a zero-argument `State` \
getter, or render this component through `render_with_state`, which reads the collection \
and the loop item directly."
)
}
struct ResolverKeys {
keys: Vec<String>,
warnings: Vec<String>,
errors: Vec<String>,
}
fn collect_resolver_keys(
nodes: &[Node],
methods: &[StateMethod],
fields: &[String],
mode: RenderMode,
props: &PropsChannel<'_>,
) -> ResolverKeys {
let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut keys = keep_answerable_interpolation_keys(
methods,
fields,
collect_interpolation_keys(nodes),
&mut warnings,
);
let mut resolve_loops: Vec<ResolveLoopFamily> = Vec::new();
#[derive(Clone, Copy)]
struct LoopScope<'a> {
item_name: Option<&'a str>,
idx_name: Option<&'a str>,
info: Option<&'a VForInfo>,
}
fn walk(
nodes: &[Node],
methods: &[StateMethod],
fields: &[String],
mode: RenderMode,
scope: LoopScope<'_>,
keys: &mut Vec<String>,
warnings: &mut Vec<String>,
resolve_loops: &mut Vec<ResolveLoopFamily>,
) {
for node in nodes {
let Node::Element {
attrs, children, ..
} = node
else {
continue;
};
let loop_scope = attrs
.iter()
.find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
.and_then(|a| a.value.as_deref())
.and_then(parse_v_for);
let scope = match &loop_scope {
Some(info) => LoopScope {
item_name: Some(info.item_name.as_str()),
idx_name: Some(info.index_name.as_str()),
info: Some(info),
},
None => scope,
};
let LoopScope {
item_name,
idx_name,
info: loop_info,
} = scope;
if let (RenderMode::Resolve, Some(info)) = (mode, &loop_scope) {
resolve_loops.push(ResolveLoopFamily {
collection: info.expr.clone(),
body_reads_loop: attrs_read_loop_root(
attrs,
&info.item_name,
&info.index_name,
AttrSite::LoopElement,
) || children
.iter()
.any(|c| reads_loop_root(c, &info.item_name, &info.index_name)),
});
}
for attr in attrs {
if matches!(attr.kind, AttrKind::Directive) && attr.name == "model" {
let Some(expr) = attr.value.as_deref() else {
continue;
};
if vmodel_is_loop_rooted(expr, item_name, idx_name) {
if matches!(mode, RenderMode::State) {
warnings.push(vmodel_loop_write_warning(expr));
}
continue;
}
if key_is_answerable(methods, fields, expr) {
push_unique(keys, expr);
} else {
warnings.push(format!(
"v-model=\"{expr}\" cannot render its value — {}; the input renders \
empty. The write needs every segment of `{expr}` to be a `State` \
field and the last one to be a type `VModel` is implemented for \
(`Signal<T>`, `RefCell<String>` or `Cell<T>`); otherwise the \
generated setter does not compile. Bind a zero-argument `State` \
getter, or a method for each segment of a dotted expression.",
unresolvable_key_reason(expr, methods, fields)
));
}
if matches!(mode, RenderMode::Resolve) {
warnings.push(vmodel_resolve_mode_warning(expr));
}
continue;
}
if matches!(attr.kind, AttrKind::Directive)
&& matches!(attr.name.as_str(), "if" | "show" | "else-if" | "elseif")
{
let Some(expr) = attr.value.as_deref() else {
continue;
};
if expr_reads_loop_root(expr, item_name.unwrap_or(""), idx_name.unwrap_or("")) {
continue;
}
for key in condition_resolver_keys(expr) {
if key_is_answerable(methods, fields, &key) {
push_unique(keys, &key);
} else {
warnings.push(condition_unresolvable_warning(
&attr.name, expr, &key, methods, fields,
));
}
}
continue;
}
if !matches!(attr.kind, AttrKind::Bind) {
continue;
}
let Some(expr) = attr.value.as_deref() else {
continue;
};
let resolve_loop = match mode {
RenderMode::Resolve => loop_info,
RenderMode::State => None,
};
let emission = emit_bind_attr(&attr.name, expr, item_name, idx_name, resolve_loop);
if matches!(emission.value, BindValue::Direct(_)) {
continue;
}
for key in &emission.keys {
if has_state_getter(methods, key) {
push_unique(keys, key);
continue;
}
if matches!(mode, RenderMode::Resolve)
&& loop_info.is_some()
&& expr_reads_loop_root(
expr,
item_name.unwrap_or(""),
idx_name.unwrap_or(""),
)
{
continue;
}
let reason = unresolvable_key_reason(key, methods, fields);
warnings.push(format!(
"bound attribute :{}=\"{}\" cannot be resolved — {reason}; \
the binding renders empty. Bind a zero-argument State getter instead.",
attr.name, emission.authored
));
}
}
walk(
children,
methods,
fields,
mode,
LoopScope {
item_name,
idx_name,
info: loop_info,
},
keys,
warnings,
resolve_loops,
);
}
}
walk(
nodes,
methods,
fields,
mode,
LoopScope {
item_name: None,
idx_name: None,
info: None,
},
&mut keys,
&mut warnings,
&mut resolve_loops,
);
for family in &resolve_loops {
if has_state_getter(methods, &family.collection) {
if family.body_reads_loop {
warnings.push(resolve_loop_warning(family, methods));
}
continue;
}
if props.holds_collection(&family.collection) {
warnings.push(held_collection_loop_warning(&family.collection, props));
} else if props.inventory_is_closed() {
errors.push(unrenderable_collection_error(&family.collection, props));
} else {
warnings.push(resolve_loop_warning(family, methods));
}
}
ResolverKeys {
keys,
warnings,
errors,
}
}
fn held_collection_loop_warning(collection: &str, props: &PropsChannel<'_>) -> String {
let (holding, reads_it) = match props.field(collection) {
Some(field) => (
format!("a `Props` field of type `{}`", field.ty),
"`render_with_props`",
),
None => ("a `State` field".to_string(), "`render_with_state`"),
};
format!(
"`v-for` over `{collection}` is {holding}, so {reads_it} reads it directly and binds the \
loop item — but `render_with` counts the resolver's string for it instead, and there is \
no zero-argument `State` getter behind that string, so `render_with` counts zero items \
and never runs the body. A parent that renders this component through {reads_it} is \
unaffected; a parent that calls `render_with` is not. Give `{collection}` a \
zero-argument `State` getter and both agree."
)
}
fn push_unique(out: &mut Vec<String>, key: impl AsRef<str>) {
let key = key.as_ref();
if !out.iter().any(|k| k == key) {
out.push(key.to_string());
}
}
pub fn generate_vmodel_setters(vmodels: &[(String, String)]) -> String {
if vmodels.is_empty() {
return String::new();
}
let mut out = String::new();
for (expr, handler) in vmodels {
let field_path = format!("self.{}", expr);
out.push_str(&format!(
r#"
pub fn {handler}(&self, payload: &str) {{
velox_core::vmodel::VModel::vmodel_set(&{field_path}, payload);
}}"#,
handler = handler,
field_path = field_path,
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn methods(script: &str) -> Vec<StateMethod> {
extract_state_methods(script)
}
fn props_of(state_fields: &[String]) -> PropsChannel<'_> {
PropsChannel {
fields: &[],
root: "props",
state_fields,
children: &[],
}
}
#[test]
fn unsupported_bound_expression_is_reported_as_a_warning() {
let nodes =
crate::template_parse::parse_template_to_ast(r#"<input :value="draft.trim()" />"#)
.unwrap();
let collected =
collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
assert!(
collected.keys.is_empty(),
"an unresolvable expression must not be registered: {:?}",
collected.keys
);
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
let warning = &collected.warnings[0];
assert!(warning.contains(":value=\"draft.trim()\""), "{warning}");
assert!(warning.contains("`draft.trim()`"), "{warning}");
assert!(warning.contains("renders empty"), "{warning}");
}
#[test]
fn payload_taking_handler_binding_is_reported_as_a_warning() {
let script = "impl State { pub fn on_input(&self, payload: &str) { let _ = payload; } }";
let nodes =
crate::template_parse::parse_template_to_ast(r#"<input :value="on_input" />"#).unwrap();
let collected = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::State,
&props_of(&[]),
);
assert!(collected.keys.is_empty(), "{:?}", collected.keys);
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
assert!(
collected.warnings[0].contains("payload-taking State method"),
"{}",
collected.warnings[0]
);
}
#[test]
fn field_backed_binding_is_reported_as_a_warning() {
let script = "pub struct State { pub count: std::rc::Rc<velox_core::signal::Signal<i32>> }";
let nodes =
crate::template_parse::parse_template_to_ast(r#"<input :value="count" />"#).unwrap();
let collected = collect_resolver_keys(
&nodes,
&methods(script),
&["count".to_string()],
RenderMode::State,
&props_of(&["count".to_string()]),
);
assert!(collected.keys.is_empty(), "{:?}", collected.keys);
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
let warning = &collected.warnings[0];
assert!(warning.contains(":value=\"count\""), "{warning}");
assert!(warning.contains("`count`"), "{warning}");
assert!(warning.contains("renders empty"), "{warning}");
}
#[test]
fn loop_rooted_binding_is_covered_by_the_loop_diagnostic_in_resolve_mode() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<div v-for="(todo, idx) in todos"><p :value="todo.text">x</p></div>"#,
)
.unwrap();
let state =
collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
assert!(state.keys.is_empty(), "{:?}", state.keys);
assert!(
state.warnings.is_empty(),
"State mode reads the field off the loop item: {:?}",
state.warnings
);
let resolve = collect_resolver_keys(
&nodes,
&methods(""),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(resolve.keys.is_empty(), "{:?}", resolve.keys);
assert_eq!(
resolve.warnings.len(),
1,
"one message for the loop, not one for the loop and one for the \
binding inside it: {:?}",
resolve.warnings
);
let warning = &resolve.warnings[0];
assert!(warning.contains("`todos`"), "{warning}");
assert!(
!warning.contains(":value=\"todo.text\""),
"the loop message must not restate the binding: {warning}"
);
}
#[test]
fn compound_loop_rooted_binding_is_covered_by_the_loop_diagnostic() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<div v-for="todo in todos"><p :value="todo.a + todo.b">x</p></div>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(""),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(resolve.keys.is_empty(), "{:?}", resolve.keys);
assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
let warning = &resolve.warnings[0];
assert!(warning.contains("`todos`"), "{warning}");
assert!(
!warning.contains("renders empty"),
"the loop message already covers every read in the body, so this \
binding must not add a second one: {warning}"
);
let state =
collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
assert!(
state.warnings.is_empty(),
"State mode reads the fields off the loop item, so it resolves: {:?}",
state.warnings
);
}
#[test]
fn non_loop_rooted_binding_inside_a_resolve_loop_is_still_reported() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<div v-for="todo in todos"><input :value="draft.trim()" /></div>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(""),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(resolve.warnings.len(), 2, "{:?}", resolve.warnings);
let binding = resolve
.warnings
.iter()
.find(|w| w.contains(":value=\"draft.trim()\""))
.unwrap_or_else(|| panic!("{:?}", resolve.warnings));
assert!(binding.contains("renders empty"), "{binding}");
}
#[test]
fn field_backed_binding_is_reported_in_both_modes() {
let script = "pub struct State { pub count: Rc<Signal<i32>> }";
let nodes =
crate::template_parse::parse_template_to_ast(r#"<input :value="count" />"#).unwrap();
let methods = methods(script);
let fields = ["count".to_string()];
for mode in [RenderMode::State, RenderMode::Resolve] {
let collected =
collect_resolver_keys(&nodes, &methods, &fields, mode, &props_of(&fields));
assert!(collected.keys.is_empty(), "{mode:?}: {:?}", collected.keys);
assert_eq!(
collected.warnings.len(),
1,
"{mode:?}: {:?}",
collected.warnings
);
assert!(
collected.warnings[0].contains(":value=\"count\""),
"{mode:?}: {}",
collected.warnings[0]
);
}
}
#[test]
fn v_for_key_binding_is_covered_by_the_loop_diagnostic_in_resolve_mode() {
for tpl in [
r#"<div v-for="todo in todos" :key="todo.id">x</div>"#,
r#"<div v-for="todo in todos" :key="{{ todo.id }}">x</div>"#,
] {
let nodes = crate::template_parse::parse_template_to_ast(tpl).unwrap();
let state =
collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
assert!(state.keys.is_empty(), "{tpl} {:?}", state.keys);
assert!(
state.warnings.is_empty(),
"{tpl}: State mode reads the key off the loop item: {:?}",
state.warnings
);
let resolve = collect_resolver_keys(
&nodes,
&methods(""),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(resolve.keys.is_empty(), "{tpl} {:?}", resolve.keys);
assert_eq!(resolve.warnings.len(), 1, "{tpl} {:?}", resolve.warnings);
assert!(
!resolve.warnings[0].contains(":key="),
"{tpl}: the key must not be reported separately from its loop: {}",
resolve.warnings[0]
);
}
}
#[test]
fn mustache_key_is_never_quoted_by_a_diagnostic() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<div v-for="todo in todos" :key="{{ todo.id }}">x</div>"#,
)
.unwrap();
for mode in [RenderMode::Resolve, RenderMode::State] {
let collected = collect_resolver_keys(&nodes, &methods(""), &[], mode, &props_of(&[]));
for warning in &collected.warnings {
assert!(
!warning.contains("{{"),
"{mode:?}: a diagnostic quoting the raw mustache text: {warning}"
);
}
}
}
#[test]
fn resolve_mode_v_for_over_an_unregistered_collection_is_diagnosed_as_empty() {
const TPL: &str =
r#"<ul><li v-for="item in items" :key="item.id">{{ item.name }}</li></ul>"#;
let rs = compile_template_to_rs_full_with_mode(
TPL,
"App",
None,
None,
None,
RenderMode::Resolve,
)
.expect("template compiles");
assert!(rs.contains("for __idx in 0..__for_count {"), "{rs}");
assert!(
rs.contains("let __for_count = if let Ok(n) = __for_expr.parse::<usize>()"),
"the loop bound must be derived from the collection read: {rs}"
);
assert!(
rs.contains(r#"let __for_expr = resolve("items");"#),
"the collection is read through the resolver: {rs}"
);
assert!(
!rs.contains(r#""items" =>"#),
"the collection must have no resolver arm: {rs}"
);
assert!(rs.contains("_ => String::new()"), "{rs}");
let nodes = crate::template_parse::parse_template_to_ast(TPL).unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(""),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
let warning = &resolve.warnings[0];
assert!(warning.contains("`items`"), "{warning}");
assert!(
warning.contains("never runs"),
"the diagnosis must say the body never runs: {warning}"
);
let state =
collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
assert!(state.warnings.is_empty(), "{:?}", state.warnings);
}
#[test]
fn compound_class_condition_in_a_resolve_loop_adds_no_second_warning() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<li v-for="todo in todos" :class="{ active: todo.a == todo.b }">x</li>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(""),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
assert!(
resolve.warnings[0].contains("`todos`"),
"{}",
resolve.warnings[0]
);
}
#[test]
fn resolve_loop_over_a_registered_collection_is_not_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
pub fn draft(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<div v-for="item in items"><p :value="draft">x</p></div>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(
resolve.keys,
vec!["draft".to_string()],
"{:?}",
resolve.keys
);
assert!(
resolve.warnings.is_empty(),
"the collection is a registered getter and the body reads no loop \
variable, so the loop renders: {:?}",
resolve.warnings
);
}
#[test]
fn resolve_loop_reading_only_the_index_is_not_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items">{{ i }}<b :value="i" :class="i" :style="i">x</b></li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(
resolve.warnings.is_empty(),
"the collection is a registered getter and the body reads only the loop \
index, directly, which is a real binding: {:?}",
resolve.warnings
);
}
#[test]
fn resolve_loop_with_a_condition_reading_only_the_index_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" v-if="i">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_with_a_show_condition_reading_only_the_index_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<li v-for="(item, i) in items" v-show="i">x</li>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_reports_a_dropped_show_condition_on_a_nested_loop_element() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" v-show="i">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn unrecognised_directives_fail_loud_on_the_loop_element_and_stay_quiet_in_the_body() {
assert!(
directive_emission("if", AttrSite::LoopElement) == IndexEmission::Resolver,
"a condition on the loop element is rewritten by `rewrite_if_expr`"
);
assert!(
directive_emission("show", AttrSite::LoopElement) == IndexEmission::Resolver,
"so is `v-show`, through the same handler"
);
assert!(
directive_emission("text", AttrSite::LoopElement) == IndexEmission::Resolver,
"a directive the emitter does not handle has no direct read to claim, \
so it must not be exempted from the loop diagnostic"
);
assert!(
directive_emission("text", AttrSite::InLoopBody) == IndexEmission::Direct,
"inside the body every directive is dropped, so it emits no read to \
be wrong about and reporting it would flag a loop that renders"
);
assert!(
directive_emission("model", AttrSite::LoopElement) == IndexEmission::Direct,
"`v-model` becomes a `:value` bind, which renders"
);
}
#[test]
fn an_unrecognised_directive_naming_the_index_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" v-when="i">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn the_loop_message_blames_the_item_read_not_every_value() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items">{{ item.name }}</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
let warning = &resolve.warnings[0];
assert!(
warning.contains("every read of the loop ITEM renders empty"),
"the unanswerable read is the loop item's: {warning}"
);
assert!(
!warning.contains("every value in the body renders empty"),
"a direct index read renders, so that claim would be false: {warning}"
);
}
#[test]
fn the_loop_message_says_a_dropped_read_is_not_checkable() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" v-show="i">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
let warning = &resolve.warnings[0];
assert!(
warning.contains("the emitter DROPS rather than makes"),
"the read that triggered this is dropped, so the message must say so: {warning}"
);
}
#[test]
fn resolve_loop_reports_a_condition_beside_a_direct_index_read() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" :value="i" v-if="i">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_condition_on_a_child_in_the_body_is_not_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items"><b v-if="i">x</b></li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(
resolve.warnings.is_empty(),
"a dropped directive emits no read, so nothing in the body renders \
empty: {:?}",
resolve.warnings
);
}
#[test]
fn index_classification_agrees_with_the_emitted_read() {
let cases: [(&str, &str, bool, bool, &str); 10] = [
(
"v-if on a nested loop element",
r#"<ul><li v-for="(item, i) in items" v-if="i">x</li></ul>"#,
true,
true,
"the children emitter's v-for branch rewrites it to resolve(\"i\")",
),
(
"v-if on the root loop element",
r#"<li v-for="(item, i) in items" v-if="i">x</li>"#,
true,
true,
"emit_node_with_mode rewrites it before the loop branch",
),
(
"v-show on the root loop element",
r#"<li v-for="(item, i) in items" v-show="i">x</li>"#,
true,
true,
"the same handler, negated",
),
(
"v-show on a nested loop element",
r#"<ul><li v-for="(item, i) in items" v-show="i">x</li></ul>"#,
false,
true,
"dropped by that branch, and still reported on purpose",
),
(
"a call condition naming the index",
r#"<ul><li v-for="(item, i) in items" v-if="i()">x</li></ul>"#,
true,
true,
"the expression is not the index name, so no exemption",
),
(
"a compound binding",
r#"<ul><li v-for="(item, i) in items" :value="i + 1">x</li></ul>"#,
true,
true,
"emitted as resolve(\"i + 1\")",
),
(
"a direct index bind",
r#"<ul><li v-for="(item, i) in items" :value="i">x</li></ul>"#,
false,
false,
"emitted as format!(\"{}\", i)",
),
(
"a bare index interpolation",
r#"<ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
false,
false,
"emitted as i.to_string()",
),
(
"a condition on a child in the body",
r#"<ul><li v-for="(item, i) in items"><b v-if="i">x</b></li></ul>"#,
false,
false,
"emit_props_in_loop drops the directive, so it reads nothing",
),
(
"v-model on the loop element",
r#"<ul><li v-for="(item, i) in items" v-model="i">x</li></ul>"#,
false,
false,
"no index read is emitted for it",
),
];
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
for (label, tpl, resolves_index, reported, why) in cases {
let rs = crate::compile_template_to_rs_full_with_mode(
tpl,
script,
None,
None,
None,
RenderMode::Resolve,
)
.expect("template compiles");
let resolve_renderer = rs
.split("pub fn render_with_state")
.next()
.expect("the module has a Resolve renderer");
let emitted_resolver_read = resolve_renderer.contains("resolve(\"i\")")
|| resolve_renderer.contains("resolve(\"i + 1\")");
let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(
emitted_resolver_read, resolves_index,
"{label}: the emitted read changed ({why})\n{resolve_renderer}"
);
assert_eq!(
!resolve.warnings.is_empty(),
reported,
"{label}: the decision disagrees with the emitted read ({why}): {:?}",
resolve.warnings
);
}
}
#[test]
fn resolve_loop_reading_a_compound_index_binding_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" :value="i + 1">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_reading_a_compound_index_condition_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" :class="{a: i > 2}">x</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_reading_a_compound_index_interpolation_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items">{{ i + 1 }}</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_reports_a_compound_index_read_beside_direct_ones() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items" :value="i" :title="i + 1">{{ i }} {{ i * 2 }}</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_reading_the_index_as_a_nested_collection_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items"><p v-for="row in i">{{ row }}</p></li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(resolve.warnings.len(), 2, "{:?}", resolve.warnings);
let nested = resolve
.warnings
.iter()
.find(|w| w.contains("`v-for` over `i` cannot render in Resolve mode"))
.unwrap_or_else(|| {
panic!(
"the nested loop over the index must be reported: {:?}",
resolve.warnings
)
});
assert!(
nested.contains("is not a registered `State` getter"),
"`resolve(\"i\")` answers an empty string, so the count is zero: {nested}"
);
assert!(
resolve
.warnings
.iter()
.any(|w| w.contains("`v-for` over `items` cannot render in Resolve mode")),
"the outer loop reads the index, so it reports too: {:?}",
resolve.warnings
);
}
fn assert_loop_reported_for_unanswerable_reads(warnings: &[String]) {
assert_eq!(warnings.len(), 1, "{warnings:?}");
let warning = &warnings[0];
assert!(
warning.contains("`v-for` over `items` cannot render in Resolve mode"),
"{warning}"
);
assert!(
warning.contains("indexed resolver keys"),
"the collection has a getter, so the cause must be the unreadable \
indexed read: {warning}"
);
}
#[test]
fn resolve_loop_reading_only_the_item_is_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items"><b>{{ item.name }}</b></li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
}
#[test]
fn resolve_loop_reading_the_index_and_the_item_is_still_reported() {
let script = r#"
impl State {
pub fn items(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(item, i) in items">{{ i }}{{ item.name }}</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
let warning = &resolve.warnings[0];
assert!(
warning.contains("`v-for` over `items` cannot render in Resolve mode"),
"{warning}"
);
assert!(
warning.contains("indexed resolver keys"),
"the collection has a getter, so the cause must be the unreadable \
indexed read: {warning}"
);
}
#[test]
fn resolve_loop_over_a_collection_with_no_accessor_is_reported() {
let script = r#"
impl State {
pub fn draft(&self) -> String { String::new() }
}"#;
let nodes = crate::template_parse::parse_template_to_ast(
r#"<p :x="items">{{ items }}</p><ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
)
.unwrap();
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(
!resolve.keys.iter().any(|k| k == "items"),
"neither route can register a key `State` has no getter for — the \
binding is gated by `has_state_getter` and the interpolation by \
R-1d's gate — so no key can stand in for the getter: {:?}",
resolve.keys
);
assert!(
resolve
.warnings
.iter()
.any(|w| w.contains("interpolation {{ items }} cannot be resolved")),
"the interpolation reports why it was dropped: {:?}",
resolve.warnings
);
assert_eq!(resolve.warnings.len(), 3, "{:?}", resolve.warnings);
let warning = &resolve.warnings[2];
assert!(
warning.contains("`v-for` over `items` cannot render in Resolve mode"),
"{warning}"
);
assert!(
warning.contains("not a registered `State` getter"),
"no getter backs the key, so the cause must be the empty answer: {warning}"
);
let state = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::State,
&props_of(&[]),
);
assert_eq!(state.keys, resolve.keys, "the key set is mode-independent");
assert_eq!(state.warnings.len(), 2, "{:?}", state.warnings);
assert!(
!state
.warnings
.iter()
.any(|w| w.contains("`v-for` over `items` cannot render")),
"a loop report is Resolve-mode only: {:?}",
state.warnings
);
}
#[test]
fn method_without_return_type_is_reported_as_a_warning() {
let script = "impl State { pub fn reset(&self) {} }";
let nodes =
crate::template_parse::parse_template_to_ast(r#"<input :value="reset" />"#).unwrap();
let collected = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::State,
&props_of(&[]),
);
assert!(collected.keys.is_empty(), "{:?}", collected.keys);
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
let warning = &collected.warnings[0];
assert!(warning.contains(":value=\"reset\""), "{warning}");
assert!(
warning.contains("return type") || warning.contains("returns"),
"the warning must explain the return type: {warning}"
);
}
#[test]
fn unrenderable_return_type_is_reported_as_a_warning() {
let script = "impl State { pub fn items(&self) -> Vec<String> { Vec::new() } }";
let nodes =
crate::template_parse::parse_template_to_ast(r#"<input :value="items" />"#).unwrap();
let collected = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::State,
&props_of(&[]),
);
assert!(collected.keys.is_empty(), "{:?}", collected.keys);
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
assert!(
collected.warnings[0].contains("Vec<String>"),
"the warning must name the return type: {}",
collected.warnings[0]
);
}
#[test]
fn getter_method_name_requires_a_zero_argument_renderable_getter() {
let script = r#"
impl State {
pub fn title(&self) -> String { String::new() }
pub fn get_count(&self) -> i32 { 0 }
pub fn is_open(&self) -> bool { true }
pub fn has_items(&self) -> bool { true }
pub fn on_input(&self, payload: &str) { let _ = payload; }
pub fn pick(&self, index: usize) -> usize { index }
pub fn reset(&self) {}
pub fn rows(&self) -> Vec<String> { Vec::new() }
pub fn result(&self) -> Result<String, String> { Ok(String::new()) }
}
"#;
let methods = methods(script);
assert_eq!(getter_method_name(&methods, "title"), Some("title"));
assert_eq!(getter_method_name(&methods, "count"), Some("get_count"));
assert_eq!(getter_method_name(&methods, "open"), Some("is_open"));
assert_eq!(getter_method_name(&methods, "items"), Some("has_items"));
assert_eq!(getter_method_name(&methods, "on_input"), None);
assert_eq!(getter_method_name(&methods, "pick"), None);
assert_eq!(getter_method_name(&methods, "reset"), None);
assert_eq!(getter_method_name(&methods, "rows"), None);
assert_eq!(getter_method_name(&methods, "result"), None);
assert!(has_state_getter(&methods, "title"));
assert!(!has_state_getter(&methods, "on_input"));
assert!(!has_state_getter(&methods, "input"));
}
#[test]
fn accessor_and_getter_share_one_lookup_and_differ_only_on_renderability() {
let script = r#"
impl State {
pub fn user(&self) -> User { User }
pub fn get_count(&self) -> i32 { 0 }
pub fn title(&self) -> String { String::new() }
pub fn on_input(&self, payload: &str) { let _ = payload; }
pub fn reset(&self) {}
}
"#;
let methods = methods(script);
assert_eq!(accessor_method_name(&methods, "user"), Some("user"));
assert_eq!(getter_method_name(&methods, "user"), None);
assert_eq!(accessor_method_name(&methods, "count"), Some("get_count"));
assert_eq!(getter_method_name(&methods, "count"), Some("get_count"));
assert_eq!(accessor_method_name(&methods, "title"), Some("title"));
assert_eq!(accessor_method_name(&methods, "on_input"), None);
assert_eq!(accessor_method_name(&methods, "reset"), None);
}
#[test]
fn member_path_splits_a_path_and_refuses_everything_else() {
assert_eq!(member_path("user.name"), Some(("user", vec!["name"])));
assert_eq!(member_path("a.b.c"), Some(("a", vec!["b", "c"])));
assert_eq!(
member_path("items[0].name"),
Some(("items", vec!["[0]", "name"]))
);
for not_a_path in [
"title", "user.name()", "user.", ".name", "user..name", "user name", "items[0", "", ] {
assert_eq!(member_path(not_a_path), None, "{not_a_path:?}");
}
}
#[test]
fn a_member_path_key_is_registered_only_when_its_root_has_an_accessor() {
let script = r#"
impl State {
pub fn user(&self) -> User { User }
pub fn title(&self) -> String { String::new() }
}
"#;
let with_accessor =
crate::template_parse::parse_template_to_ast(r#"<p>{{ user.name }} {{ title }}</p>"#)
.unwrap();
let answerable = collect_resolver_keys(
&with_accessor,
&methods(script),
&[],
RenderMode::State,
&props_of(&[]),
);
assert_eq!(
answerable.keys,
vec!["user.name".to_string(), "title".to_string()],
"an answerable member path keeps its key, in order"
);
assert!(answerable.warnings.is_empty(), "{:?}", answerable.warnings);
let without =
crate::template_parse::parse_template_to_ast(r#"<p>{{ user.name }} {{ title }}</p>"#)
.unwrap();
for mode in [RenderMode::State, RenderMode::Resolve] {
let dropped = collect_resolver_keys(&without, &methods(""), &[], mode, &props_of(&[]));
assert_eq!(
dropped.keys,
Vec::<String>::new(),
"an unanswerable member path is not registered, so it cannot emit an arm, and \
neither is the bare name beside it, in {mode:?}"
);
assert_eq!(dropped.warnings.len(), 2, "{:?}", dropped.warnings);
let warning = &dropped.warnings[0];
assert!(warning.contains("{{ user.name }}"), "{warning}");
assert!(
warning.contains("`user` is not a zero-argument `State` method"),
"{warning}"
);
assert!(warning.contains("renders empty"), "{warning}");
}
}
#[test]
fn the_member_path_diagnostic_names_the_root_and_why() {
let tpl = r#"<p>{{ user.name }}</p>"#;
let nodes = crate::template_parse::parse_template_to_ast(tpl).unwrap();
let payload = r#"
impl State { pub fn user(&self, payload: &str) -> String { payload.to_string() } }
"#;
let unit = r#"
impl State { pub fn user(&self) {} }
"#;
let script = r#"
pub struct State { user: String }
impl State { pub fn new() -> Self { Self { user: String::new() } } }
"#;
for (script_text, fields, expected) in [
(
payload,
Vec::new(),
"`user` is a payload-taking State method",
),
(unit, Vec::new(), "`user` returns nothing"),
(
script,
vec!["user".to_string()],
"`user` is a `State` field",
),
] {
let collected = collect_resolver_keys(
&nodes,
&methods(script_text),
&fields,
RenderMode::State,
&props_of(&fields),
);
assert_eq!(collected.keys, Vec::<String>::new(), "{expected}");
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
assert!(
collected.warnings[0].contains(expected),
"expected {expected:?} in {:?}",
collected.warnings
);
}
}
#[test]
fn a_bare_interpolation_key_is_gated_on_a_getter_and_otherwise_untouched() {
let template = r#"<p>{{ title }} {{ user.name() }} {{ count }}</p>"#;
let nodes = crate::template_parse::parse_template_to_ast(template).unwrap();
let collected =
collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
assert_eq!(
collected.keys,
vec!["user.name()".to_string()],
"a bare name with no getter is not registered, so it cannot emit an \
unanswerable arm; a call key is not gated"
);
assert_eq!(collected.warnings.len(), 2, "{:?}", collected.warnings);
for (key, warning) in [
("title", &collected.warnings[0]),
("count", &collected.warnings[1]),
] {
assert!(warning.contains(&format!("{{{{ {key} }}}}")), "{warning}");
assert!(
warning.contains(&format!("`{key}` is not a zero-argument `State` method")),
"{warning}"
);
assert!(warning.contains("Declare `pub fn"), "{warning}");
}
let with_getters = r#"
impl State {
pub fn title(&self) -> String { String::new() }
pub fn count(&self) -> i32 { 0 }
}
"#;
let answered = collect_resolver_keys(
&nodes,
&methods(with_getters),
&[],
RenderMode::State,
&props_of(&[]),
);
assert_eq!(
answered.keys,
vec![
"title".to_string(),
"user.name()".to_string(),
"count".to_string()
],
"a getter-backed bare name is registered exactly as before, in order"
);
assert!(answered.warnings.is_empty(), "{:?}", answered.warnings);
}
#[test]
fn a_bare_name_with_an_unrenderable_return_type_is_not_registered() {
let script = r#"
impl State {
pub fn items(&self) -> Vec<Item> { Vec::new() }
}
"#;
let nodes = crate::template_parse::parse_template_to_ast(r#"<p>{{ items }}</p>"#).unwrap();
let collected = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::State,
&props_of(&[]),
);
assert_eq!(collected.keys, Vec::<String>::new(), "{:?}", collected.keys);
assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
assert!(
collected.warnings[0].contains(
"`items` is a `State` method, but its return type cannot be rendered as text"
),
"{}",
collected.warnings[0]
);
}
#[test]
fn return_type_is_renderable_accepts_only_display_types() {
for ty in [
Some("String"),
Some("&str"),
Some("&'static str"),
Some("str"),
Some("bool"),
Some("char"),
Some("i32"),
Some("usize"),
Some("f64"),
Some("Rc<String>"),
Some("Cow<'_, str>"),
] {
assert!(return_type_is_renderable(ty), "{ty:?} should be renderable");
}
for ty in [
None,
Some("()"),
Some("Self"),
Some("Vec<String>"),
Some("Option<String>"),
Some("Result<String, String>"),
Some("Signal<i32>"),
Some("Todo"),
] {
assert!(!return_type_is_renderable(ty), "{ty:?} is not renderable");
}
}
#[test]
fn condition_resolver_keys_match_rewrite_if_expr_lookups() {
for cond in [
"completed",
"!completed",
"count > 0",
"todo.completed",
"is_empty()",
"true",
"!false",
"filter == \"all\"",
] {
let rewritten = rewrite_if_expr(cond);
let emitted: Vec<&str> = rewritten
.match_indices("resolve(\"")
.map(|(i, _)| {
let rest = &rewritten[i + "resolve(\"".len()..];
&rest[..rest.find('"').expect("closing quote")]
})
.collect();
let collected = condition_resolver_keys(cond);
assert_eq!(
collected,
emitted
.iter()
.map(|k| k.to_string())
.collect::<Vec<String>>(),
"condition `{cond}` emits {emitted:?} but collects {collected:?}"
);
}
}
#[test]
fn a_scanner_that_enters_on_a_first_char_test_advances_even_when_this_is_narrow() {
use std::sync::mpsc;
use std::time::Duration;
let cases = [
("a!", vec!["a"], false),
("_x!", vec!["_x"], false),
("a!b", vec!["a", "b"], false),
("9a!", vec!["a"], false),
("item!", vec!["item"], true),
("item.name", vec!["item.name"], true),
("item[0].name", vec!["item[0].name"], true),
];
for (expr, expected_keys, reads_item) in cases {
let (tx, rx) = mpsc::channel();
let expr_owned = expr.to_string();
let probe = tx.clone();
std::thread::spawn(move || {
let _ = probe.send(condition_resolver_keys(&expr_owned));
});
let (tx2, rx2) = mpsc::channel();
let expr_owned = expr.to_string();
std::thread::spawn(move || {
let _ = tx2.send(expr_reads_roots(&expr_owned, &["item", "i"]));
});
let collected = rx.recv_timeout(Duration::from_secs(10)).unwrap_or_else(|_| {
panic!("condition_resolver_keys did not return on `{expr}` — the scanner is not making progress")
});
let reads = rx2.recv_timeout(Duration::from_secs(10)).unwrap_or_else(|_| {
panic!("expr_reads_roots did not return on `{expr}` — the scanner is not making progress")
});
assert_eq!(collected, expected_keys, "keys collected from `{expr}`");
assert_eq!(reads, reads_item, "root read reported for `{expr}`");
}
}
const R1C_SCRIPT: &str = r#"
pub struct User { name: String }
impl State {
pub fn items(&self) -> String { String::new() }
pub fn title(&self) -> String { String::new() }
pub fn user(&self) -> User { User { name: String::new() } }
}"#;
fn keys_of(tpl: &str, mode: RenderMode) -> Vec<String> {
let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
collect_resolver_keys(&nodes, &methods(R1C_SCRIPT), &[], mode, &props_of(&[])).keys
}
#[test]
fn a_loop_rooted_interpolation_is_not_a_resolver_key() {
for tpl in [
r#"<ul><li v-for="item in items">{{ item.name }}</li></ul>"#,
r#"<ul><li v-for="item in items">{{ item.name }} {{ item.id }}</li></ul>"#,
r#"<ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
r#"<ul><li v-for="item in items"><span><b>{{ item.name }}</b></span></li></ul>"#,
r#"<ul><li v-for="(item, i) in items"><ul><li v-for="c in rows">{{ item.name }} {{ c }}</li></ul></li></ul>"#,
] {
for mode in [RenderMode::State, RenderMode::Resolve] {
assert_eq!(
keys_of(tpl, mode),
Vec::<String>::new(),
"{mode:?}: a read of a loop variable cannot be answered by a \
flat arm over `State` fields: {tpl}"
);
}
}
}
#[test]
fn a_root_level_dotted_interpolation_is_still_a_key() {
for mode in [RenderMode::State, RenderMode::Resolve] {
assert_eq!(
keys_of(
r#"<p>{{ user.name }} {{ items[0].name }} {{ title }}</p>"#,
mode
),
vec!["user.name", "items[0].name", "title"],
"{mode:?}: nothing here is inside a loop, so nothing is dropped"
);
}
}
#[test]
fn loop_rooted_interpolations_are_dropped_and_the_rest_keep_document_order() {
for mode in [RenderMode::State, RenderMode::Resolve] {
assert_eq!(
keys_of(
r#"<header>{{ title }}</header><ul><li v-for="(item, i) in items">{{ item.name }} {{ i }} {{ i + 1 }}</li></ul><footer>{{ user.name }}</footer>"#,
mode
),
vec!["title", "user.name"],
"{mode:?}: the two loop-rooted keys go, the two that surround \
them keep their order"
);
}
}
#[test]
fn the_bare_index_interpolation_is_direct_and_is_still_not_a_key() {
let direct = r#"<ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#;
let compound = r#"<ul><li v-for="(item, i) in items">{{ i + 1 }}</li></ul>"#;
let nodes =
|tpl: &str| crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
let direct_resolve = collect_resolver_keys(
&nodes(direct),
&methods(R1C_SCRIPT),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
let compound_resolve = collect_resolver_keys(
&nodes(compound),
&methods(R1C_SCRIPT),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert_eq!(
direct_resolve.keys,
Vec::<String>::new(),
"`{{ i }}` is loop-rooted, so it is not a top-level key: {:?}",
direct_resolve.keys
);
assert!(
direct_resolve.warnings.is_empty(),
"R-1's exemption: a bare-index interpolation emits `text(i.to_string())`, \
a direct read, so the loop is not reported: {:?}",
direct_resolve.warnings
);
assert_eq!(
compound_resolve.keys,
Vec::<String>::new(),
"the same key-set rule applies to a compound index read: {:?}",
compound_resolve.keys
);
assert_eq!(
compound_resolve.warnings.len(),
1,
"and R-1's N3 rule is untouched: the body emits `text(resolve(\"i + 1\"))`, \
which no arm answers, so the loop IS reported: {:?}",
compound_resolve.warnings
);
}
#[test]
fn a_collection_with_no_accessor_registers_no_key_and_is_still_reported() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<p :x="items">{{ items }}</p><ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
)
.expect("template parses");
let script = r#"
impl State {
pub fn draft(&self) -> String { String::new() }
}"#;
let resolve = collect_resolver_keys(
&nodes,
&methods(script),
&[],
RenderMode::Resolve,
&props_of(&[]),
);
assert!(
!resolve.keys.iter().any(|k| k == "items"),
"a collection with no accessor is registered from neither route: {:?}",
resolve.keys
);
assert_eq!(
resolve.warnings.len(),
3,
"the loop report, the dropped interpolation and the dropped binding, and \
nothing else: {:?}",
resolve.warnings
);
assert!(
!resolve.keys.iter().any(|k| k == "item" || k == "i"),
"but nothing loop-rooted is: {:?}",
resolve.keys
);
}
const R2_SCRIPT: &str = r#"
pub struct User { name: String }
impl User {
pub fn name(&self) -> String { String::new() }
}
impl State {
pub fn items(&self) -> String { String::new() }
pub fn draft(&self) -> String { String::new() }
pub fn user(&self) -> User { User { name: String::new() } }
}"#;
fn r2_keys_and_warnings(
tpl: &str,
script: &str,
fields: &[&str],
mode: RenderMode,
) -> (Vec<String>, Vec<String>) {
let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
let fields: Vec<String> = fields.iter().map(|f| f.to_string()).collect();
let collected =
collect_resolver_keys(&nodes, &methods(script), &fields, mode, &props_of(&fields));
(collected.keys, collected.warnings)
}
#[test]
fn a_v_model_on_an_accessor_is_registered_as_a_resolver_key() {
for mode in [RenderMode::State, RenderMode::Resolve] {
let (keys, warnings) =
r2_keys_and_warnings(r#"<input v-model="draft"/>"#, R2_SCRIPT, &[], mode);
assert_eq!(
keys,
vec!["draft".to_string()],
"a v-model on a `State` accessor must get a resolver arm, in {mode:?}"
);
if matches!(mode, RenderMode::State) {
assert!(warnings.is_empty(), "{:?}", warnings);
}
}
}
#[test]
fn a_dotted_v_model_is_answered_by_the_same_chain_as_an_interpolation() {
let (keys, warnings) = r2_keys_and_warnings(
r#"<input v-model="user.name"/>"#,
R2_SCRIPT,
&[],
RenderMode::State,
);
assert_eq!(keys, vec!["user.name".to_string()], "{:?}", keys);
assert!(warnings.is_empty(), "{:?}", warnings);
let (keys, warnings) = r2_keys_and_warnings(
r#"<input v-model="form.name"/>"#,
R2_SCRIPT,
&["form"],
RenderMode::State,
);
assert_eq!(keys, Vec::<String>::new(), "{:?}", keys);
assert_eq!(warnings.len(), 1, "{:?}", warnings);
assert!(
warnings[0].contains(r#"v-model="form.name""#),
"{}",
warnings[0]
);
assert!(
warnings[0].contains("is a `State` field"),
"{}",
warnings[0]
);
assert!(warnings[0].contains("renders empty"), "{}", warnings[0]);
}
#[test]
fn a_v_model_on_a_loop_item_reads_it_and_writes_nothing() {
for mode in [RenderMode::State, RenderMode::Resolve] {
let (keys, warnings) = r2_keys_and_warnings(
r#"<ul><li v-for="item in items"><input v-model="item.name"/></li></ul>"#,
R2_SCRIPT,
&[],
mode,
);
assert_eq!(
keys,
Vec::<String>::new(),
"a loop-rooted v-model must not register a resolver key, in {mode:?}"
);
if matches!(mode, RenderMode::State) {
assert_eq!(warnings.len(), 1, "{:?}", warnings);
for expected in [
r#"v-model="item.name""#,
"cannot be written",
"DETACHED COPY",
"no index",
"render_with_state",
"__vmodel_set_",
] {
assert!(
warnings[0].contains(expected),
"the warning must mention {expected:?} so the message is actionable, \
but it read:\n{}",
warnings[0]
);
}
} else {
assert_eq!(warnings.len(), 1, "{:?}", warnings);
assert!(
warnings[0].contains("`v-for` over `items` cannot render"),
"{}",
warnings[0]
);
}
}
}
#[test]
fn a_v_model_in_resolve_mode_is_reported() {
let (keys, warnings) = r2_keys_and_warnings(
r#"<input v-model="draft"/>"#,
R2_SCRIPT,
&[],
RenderMode::Resolve,
);
assert_eq!(keys, vec!["draft".to_string()], "the read still resolves");
assert_eq!(warnings.len(), 1, "{:?}", warnings);
assert!(
warnings[0].contains(r#"v-model="draft""#),
"{}",
warnings[0]
);
assert!(
warnings[0].contains("cannot be written in Resolve mode"),
"{}",
warnings[0]
);
assert!(
warnings[0].contains("render_with_state"),
"the report names the renderer that does support it: {}",
warnings[0]
);
}
#[test]
fn the_v_model_collectors_agree_on_one_hardcoded_setter_name() {
for (tpl, expected_expressions, expected_handlers) in [
(
r#"<input v-model="draft"/>"#,
vec!["draft"],
vec!["__vmodel_set_draft"],
),
(
r#"<input v-model="user.name"/>"#,
vec!["user.name"],
vec!["__vmodel_set_user_name"],
),
(
r#"<ul><li v-for="item in items"><input v-model="item.name"/></li></ul>"#,
vec![],
vec![],
),
] {
let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
let collected = collect_vmodel_expressions(&nodes);
let expressions: Vec<String> = collected.iter().map(|(expr, _)| expr.clone()).collect();
assert_eq!(expressions, expected_expressions, "expressions for {tpl}");
let handlers: Vec<String> = collected
.iter()
.map(|(_, handler)| handler.clone())
.collect();
assert_eq!(handlers, expected_handlers, "handlers for {tpl}");
let dispatcher: Vec<String> = collect_handlers(&nodes)
.into_iter()
.filter(|h| h.starts_with("__vmodel_set_"))
.collect();
assert_eq!(
dispatcher, expected_handlers,
"the dispatcher must carry an arm for every non-loop-rooted v-model, under \
the same name, and none for a loop-rooted one, in {tpl}"
);
}
}
const R3_SCRIPT: &str = r#"
pub struct User { name: String }
impl User {
pub fn name(&self) -> String { String::new() }
}
impl State {
pub fn items(&self) -> String { String::new() }
pub fn visible(&self) -> bool { false }
pub fn wide(&self) -> bool { false }
pub fn count(&self) -> i32 { 0 }
pub fn user(&self) -> User { User { name: String::new() } }
}"#;
fn class_set_of(tpl: &str) -> String {
let rs = compile_template_to_rs_full_with_mode(
tpl,
"App",
None,
Some(R3_SCRIPT),
None,
RenderMode::State,
)
.expect("template compiles");
let start = rs
.find(r#".set("class""#)
.unwrap_or_else(|| panic!("no class is set for {tpl}: {rs}"));
let rest = &rs[start..];
let mut depth = 0i32;
for (i, c) in rest.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return rest[..=i].to_string();
}
}
_ => {}
}
}
panic!("unterminated class value for {tpl}: {rest}");
}
fn r3_keys_and_warnings(tpl: &str, mode: RenderMode) -> (Vec<String>, Vec<String>) {
let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
let collected =
collect_resolver_keys(&nodes, &methods(R3_SCRIPT), &[], mode, &props_of(&[]));
(collected.keys, collected.warnings)
}
#[test]
fn a_v_if_condition_is_registered_as_a_resolver_key() {
for tpl in [
r#"<div v-if="visible"/>"#,
r#"<div><p v-if="visible">x</p></div>"#,
r#"<div v-show="wide"/>"#,
r#"<div v-if="user.name"/>"#,
r#"<div><p v-if="count > 0">x</p></div>"#,
] {
for mode in [RenderMode::State, RenderMode::Resolve] {
let (keys, warnings) = r3_keys_and_warnings(tpl, mode);
assert!(
warnings.is_empty(),
"a condition on a `State` accessor needs no warning: {warnings:?} in \
{mode:?} for {tpl}"
);
assert!(
!keys.is_empty(),
"a `v-if`/`v-show` condition is a resolver read and must be \
registered, in {mode:?} for {tpl}"
);
}
}
assert_eq!(
r3_keys_and_warnings(r#"<div><p v-if="user.name">x</p></div>"#, RenderMode::State).0,
vec!["user.name".to_string()],
"a dotted condition is one key, not one per segment: the emission reads it \
as a single path"
);
}
#[test]
fn an_unanswerable_condition_is_reported_with_its_consequence() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<div><p v-if="missing">x</p><b v-show="gone"/></div>"#,
)
.expect("template parses");
let collected = collect_resolver_keys(
&nodes,
&methods(R3_SCRIPT),
&[],
RenderMode::State,
&props_of(&[]),
);
assert!(
collected.keys.is_empty(),
"nothing about the condition is answerable, so nothing is registered: {:?}",
collected.keys
);
assert_eq!(collected.warnings.len(), 2, "{:?}", collected.warnings);
assert!(
collected.warnings[0].contains("if=\"missing\"")
&& collected.warnings[0]
.contains("the element and everything inside it are not rendered"),
"a `v-if` names what a falsy condition costs: {:?}",
collected.warnings[0]
);
assert!(
collected.warnings[1].contains("show=\"gone\"")
&& collected.warnings[1].contains("display: none"),
"a `v-show` names its own consequence, because it is a different one: {:?}",
collected.warnings[1]
);
assert!(
!collected
.warnings
.iter()
.any(|w| w.contains("The write still works")),
"no diagnostic may claim a capability the emitted code lacks: {:?}",
collected.warnings
);
}
#[test]
fn a_loop_rooted_condition_is_not_registered_and_is_not_reported_again() {
let nodes = crate::template_parse::parse_template_to_ast(
r#"<ul><li v-for="(todo, i) in todos" v-if="todo.keep">x</li></ul>"#,
)
.expect("template parses");
let script = r#"
impl State {
pub fn todos(&self) -> String { String::new() }
}"#;
for mode in [RenderMode::State, RenderMode::Resolve] {
let collected =
collect_resolver_keys(&nodes, &methods(script), &[], mode, &props_of(&[]));
assert!(
collected.keys.is_empty(),
"a read of a loop item is not a `State` field: {:?} in {mode:?}",
collected.keys
);
assert!(
!collected.warnings.iter().any(|w| w.contains("todo.keep")),
"the loop report already covers the read, so the condition must not \
be reported as well: {:?}",
collected.warnings
);
assert_eq!(
collected.warnings.len(),
usize::from(matches!(mode, RenderMode::Resolve)),
"in State mode the loop renders, so nothing is reported at all; in \
Resolve mode the loop's own report is the only one: {:?}",
collected.warnings
);
}
}
#[test]
fn a_v_else_if_condition_is_registered_like_a_v_if() {
for tpl in [
r#"<div><p v-if="count > 0">a</p><p v-else-if="visible">b</p></div>"#,
r#"<div><p v-if="count > 0">a</p><p v-elseif="visible">b</p></div>"#,
r#"<div><p v-if="count > 0">a</p><p v-else-if="user.name">b</p></div>"#,
r#"<div><p v-if="count > 0">a</p><p v-else-if="count > 1">b</p></div>"#,
] {
for mode in [RenderMode::State, RenderMode::Resolve] {
let (keys, warnings) = r3_keys_and_warnings(tpl, mode);
assert!(
!keys.is_empty(),
"a `v-else-if` condition is a resolver read and must be \
registered, in {mode:?} for {tpl}"
);
assert!(
warnings.is_empty(),
"a `v-else-if` condition on a `State` accessor needs no \
warning: {warnings:?} in {mode:?} for {tpl}"
);
let rs = compile_template_to_rs_full_with_mode(
tpl,
"App",
None,
Some(R3_SCRIPT),
None,
mode,
)
.expect("template compiles");
for key in &keys {
let looked_up = format!("resolve({})", string_lit(key));
assert!(
rs.contains(&looked_up),
"`{key}` is registered, so the emission must look it up: {rs}"
);
}
}
}
}
#[test]
fn a_compound_condition_reads_only_its_comparison_operands_as_numbers() {
let rs = compile_template_to_rs_full_with_mode(
r#"<div v-if="count > 0 && visible">x</div>"#,
"App",
None,
Some(R3_SCRIPT),
None,
RenderMode::State,
)
.expect("template compiles");
assert!(
rs.contains(&format!("resolve({}).parse::<f64>()", string_lit("count"))),
"`count` is an operand of `>`, so it is read as a number: {rs}"
);
assert!(
!rs.contains(&format!(
"resolve({}).parse::<f64>()",
string_lit("visible")
)),
"`visible` is not an operand of any comparison, so it must be a \
truthiness test and not a number — a number here makes the `&&` an \
`f64`, which does not compile: {rs}"
);
assert!(
rs.contains(&resolver_truthiness(&format!(
"resolve({})",
string_lit("visible")
))),
"the one notion of truthiness is what a non-comparison operand gets: {rs}"
);
}
#[test]
fn each_resolver_answer_is_judged_by_the_one_truthiness_test() {
let rs = compile_template_to_rs_full_with_mode(
r#"<div v-if="visible"/>"#,
"App",
None,
Some(R3_SCRIPT),
None,
RenderMode::State,
)
.expect("template compiles");
let start = rs
.find("if { let __v = resolve(")
.expect("a v-if condition is emitted")
+ 3;
let condition = &rs[start..];
let mut depth = 0i32;
let mut closed = None;
for (i, c) in condition.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
closed = Some(i + 1);
break;
}
}
_ => {}
}
}
let end = closed.unwrap_or_else(|| panic!("the condition is not closed in {rs}"));
let dir = std::env::temp_dir().join("velox-r3-truthiness");
std::fs::create_dir_all(&dir).expect("temp dir");
let src = dir.join("verdict.rs");
std::fs::write(
&src,
format!(
"fn resolve(_key: &str) -> String {{ std::env::args().nth(1).unwrap() }}\n\
fn main() {{ println!(\"{{}}\", {}\n); }}\n",
&condition[..end]
),
)
.expect("write the probe");
let bin = dir.join("verdict");
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
let build = std::process::Command::new(&rustc)
.args(["--edition", "2024", "-o"])
.arg(&bin)
.arg(&src)
.output()
.unwrap_or_else(|e| panic!("`{rustc}` must be runnable: {e}"));
assert!(
build.status.success(),
"the emitted condition must compile on its own:\n{}\n{}",
std::fs::read_to_string(&src).expect("read back the probe"),
String::from_utf8_lossy(&build.stderr)
);
for (answer, want) in [
("", "false"),
("false", "false"),
("0", "false"),
("0.0", "false"),
("-0.0", "false"),
("0.00", "false"),
("true", "true"),
("false ", "true"),
("1", "true"),
("-3.5", "true"),
("abc", "true"),
("a b c", "true"),
] {
let run = std::process::Command::new(&bin)
.arg(answer)
.output()
.expect("run");
assert_eq!(
String::from_utf8_lossy(&run.stdout).trim(),
want,
"the resolver answered {:?} and the condition must read that as {want}",
answer
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_condition_path_gets_the_one_truthiness_test() {
let read = format!("resolve({})", string_lit("visible"));
let want = resolver_truthiness(&read);
for (label, tpl) in [
("a v-if", r#"<div v-if="visible"/>"#),
("a v-show", r#"<div v-show="visible"/>"#),
(
"a v-else-if",
r#"<div><p v-if="count > 0"/><p v-else-if="visible"/></div>"#,
),
(
"an object :class condition",
r#"<div :class="{ active: visible }"/>"#,
),
] {
let rs = compile_template_to_rs_full_with_mode(
tpl,
"App",
None,
Some(R3_SCRIPT),
None,
RenderMode::State,
)
.expect("template compiles");
assert!(
rs.contains(&want),
"{label} must be judged by the one truthiness test, {want:?}: {rs}"
);
}
let rs = compile_template_to_rs_full_with_mode(
r#"<div v-for="item in items"><span :class="{ active: item.keep }"/></div>"#,
"App",
None,
Some(R3_SCRIPT),
None,
RenderMode::Resolve,
)
.expect("template compiles");
let indexed = r#"resolve(&format!("items[{}].keep", __idx))"#;
assert!(
rs.contains(&resolver_truthiness(indexed)),
"a loop-rooted condition in the Resolve renderer must get the one truthiness \
test as well: {rs}"
);
}
#[test]
fn a_static_class_and_an_object_syntax_class_are_emitted_as_one_set() {
let tpl = r#"<div class="card" :class="{ active: visible, 'is-wide': wide }"/>"#;
let set = class_set_of(tpl);
assert_eq!(
set.matches(r#".set("class""#).count(),
1,
"one `.set` for the class, or the second replaces the first: {set}"
);
assert!(
set.contains(r#"__classes.push("card");"#),
"the static class is the first entry whatever the authored order: {set}"
);
assert!(
set.contains(r#"__classes.push("active");"#)
&& set.contains(r#"__classes.push("is-wide");"#),
"both conditions contribute their class: {set}"
);
assert!(
!set.contains('\''),
"an object key written in quotes is a class NAME; the quotes must not \
become part of it, or the cascade's whole-word match misses: {set}"
);
let active = set.find("__classes.push(\"active\")").unwrap();
let wide = set.find("__classes.push(\"is-wide\")").unwrap();
assert!(active < wide, "the authored order is kept: {set}");
}
#[test]
fn a_class_on_its_own_is_emitted_exactly_as_before() {
assert_eq!(
class_set_of(r#"<div class="card"/>"#),
r#".set("class", "card")"#,
"a static class alone is unchanged"
);
assert_eq!(
class_set_of(r#"<div :class="{ active: visible }"/>"#),
format!(
r#".set("class", {{ let mut __classes: Vec<&str> = Vec::new(); if {truthy} {{ __classes.push("active"); }} __classes.join(" ") }})"#,
truthy = resolver_truthiness(&format!("resolve({})", string_lit("visible")))
),
"a `:class` alone is the object block it was before this task, byte for \
byte: the same `Vec<&str>`, the same single push, the same join, and the ONE \
truthiness test rather than a second one of its own"
);
assert_eq!(
class_set_of(r#"<div :class="label"/>"#),
r#".set("class", &resolve("label"))"#,
"a non-object `:class` alone is unchanged too"
);
}
#[test]
fn a_string_syntax_class_is_merged_in_either_authored_order() {
for tpl in [
r#"<div class="card" :class="label"/>"#,
r#"<div :class="label" class="card"/>"#,
] {
let set = class_set_of(tpl);
assert_eq!(
set.matches(r#".set("class""#).count(),
1,
"one `.set` for the class, whichever order they were written in: {set}"
);
assert!(
set.contains(r#"__classes.push("card");"#)
&& set.contains("__src.split_whitespace()"),
"the static class and the bound words both reach the list: {set}"
);
}
}
#[test]
fn the_condition_collector_and_the_condition_rewrite_agree_on_every_shape() {
for cond in [
"visible",
"!visible",
"count > 0",
"count > 0 && visible",
"user.name",
"!user.name",
"items[0].name",
"user.name == 'a'",
"is_empty()",
] {
let keys = condition_resolver_keys(cond);
let emitted = rewrite_if_expr(cond);
for key in &keys {
assert!(
emitted.contains(&format!(r#"resolve("{}")"#, key)),
"`{cond}` is rewritten into a read of {key:?}, so {key:?} must be a \
registered key: {emitted}"
);
}
let looked_up: Vec<String> = emitted
.match_indices(r#"resolve(""#)
.map(|(i, _)| {
emitted[i + 9..]
.find('"')
.map(|e| emitted[i + 9..i + 9 + e].to_string())
.unwrap_or_default()
})
.collect();
assert_eq!(
looked_up, keys,
"`{cond}` is rewritten into exactly the reads that are registered, so \
there is no way for a key to be missing"
);
}
}
fn answerable_fixture() -> (Vec<StateMethod>, Vec<String>) {
let method = |name: &str, takes_payload: bool, return_type: Option<&str>| StateMethod {
name: name.to_string(),
takes_payload,
return_type: return_type.map(|t| t.to_string()),
};
let methods = vec![
method("title", false, Some("String")),
method("get_sub", false, Some("String")),
method("on_input", true, Some("String")),
method("nothing", false, None),
method("unit", false, Some("()")),
method("vecs", false, Some("Vec<String>")),
method("user", false, Some("User")),
method("flag", false, Some("bool")),
];
let fields = vec!["rows".to_string(), "sub".to_string()];
(methods, fields)
}
const ANSWERABLE_KEYS: [&str; 23] = [
"title",
"get_sub",
"on_input",
"nothing",
"unit",
"vecs",
"user",
"flag",
"nope",
"user.name",
"user.missing",
"vecs[0]",
"items[0].name",
"user.name()",
"a.b[0",
"1bad",
"a + b",
"user + other",
"items[0",
"a.b()",
"",
"user.name.deep",
"sub",
];
#[test]
fn answerable_names_each_of_the_three_arms_a_key_can_take() {
let (methods, fields) = answerable_fixture();
assert_eq!(
answerable("title", &methods, &fields),
Ok(Answer::Getter {
root: "title".to_string(),
name: "title".to_string(),
})
);
assert_eq!(
answerable("sub", &methods, &fields),
Ok(Answer::Getter {
root: "sub".to_string(),
name: "get_sub".to_string(),
})
);
assert_eq!(
answerable("user.name", &methods, &fields),
Ok(Answer::MemberChain {
root: "user".to_string(),
chain: "user().name()".to_string(),
})
);
assert_eq!(
answerable("vecs[0]", &methods, &fields),
Ok(Answer::MemberChain {
root: "vecs".to_string(),
chain: "vecs()[0]".to_string(),
})
);
assert_eq!(
answerable("user.name.deep", &methods, &fields),
Ok(Answer::MemberChain {
root: "user".to_string(),
chain: "user().name().deep()".to_string(),
})
);
for key in ["user.name()", "a.b[0", "1bad", "a + b", "items[0", ""] {
assert_eq!(
answerable(key, &methods, &fields),
Ok(Answer::Conventional {
name: key.to_string(),
}),
"`{key}` is not a shape the arm emitter can spell, so it keeps the \\
name-only answer it has always had"
);
}
}
#[test]
fn the_arm_text_of_every_key_is_unchanged() {
let (methods, fields) = answerable_fixture();
let expected = [
("title", "title()"),
("get_sub", "get_sub()"),
("on_input", "on_input()"),
("nothing", "nothing()"),
("unit", "unit()"),
("vecs", "vecs()"),
("user", "user()"),
("flag", "flag()"),
("nope", "nope()"),
("user.name", "user().name()"),
("user.missing", "user().missing()"),
("vecs[0]", "vecs()[0]"),
("items[0].name", "items[0].name()"),
("user.name()", "user.name()()"),
("a.b[0", "a.b[0()"),
("1bad", "1bad()"),
("a + b", "a + b()"),
("user + other", "user + other()"),
("items[0", "items[0()"),
("a.b()", "a.b()()"),
("", "()"),
("user.name.deep", "user().name().deep()"),
("sub", "get_sub()"),
];
for (key, call) in expected {
assert_eq!(
resolve_getter_call(&methods, &fields, key),
call,
"the arm text for `{key}` moved"
);
}
}
#[test]
fn every_refusal_sentence_is_unchanged() {
let (methods, fields) = answerable_fixture();
let expected = [
(
"title",
"`title` returns `String`, which cannot be rendered as text",
),
(
"get_sub",
"`get_sub` returns `String`, which cannot be rendered as text",
),
(
"on_input",
"`on_input` is a payload-taking State method, not a getter",
),
(
"nothing",
"`nothing` declares no return type, so there is no text to render",
),
(
"unit",
"`unit` returns `()`, which cannot be rendered as text",
),
(
"vecs",
"`vecs` returns `Vec<String>`, which cannot be rendered as text",
),
(
"user",
"`user` returns `User`, which cannot be rendered as text",
),
(
"flag",
"`flag` returns `bool`, which cannot be rendered as text",
),
("nope", "`nope` is not a zero-argument `State` getter"),
(
"user.name",
"`user` is a State method, but a member path needs an accessor",
),
(
"user.missing",
"`user` is a State method, but a member path needs an accessor",
),
(
"vecs[0]",
"`vecs` is a State method, but a member path needs an accessor",
),
(
"items[0].name",
"`items` is not a zero-argument `State` method",
),
(
"user.name()",
"`user.name()` is an expression, not a `State` getter name",
),
(
"a.b[0",
"`a.b[0` is an expression, not a `State` getter name",
),
("1bad", "`1bad` is an expression, not a `State` getter name"),
(
"a + b",
"`a + b` is an expression, not a `State` getter name",
),
(
"user + other",
"`user + other` is an expression, not a `State` getter name",
),
(
"items[0",
"`items[0` is an expression, not a `State` getter name",
),
(
"a.b()",
"`a.b()` is an expression, not a `State` getter name",
),
("", "`` is an expression, not a `State` getter name"),
(
"user.name.deep",
"`user` is a State method, but a member path needs an accessor",
),
(
"sub",
"`sub` is a `State` field, not a getter — reading a field in a template needs a typed field resolver, which is not implemented yet",
),
];
for (key, reason) in expected {
assert_eq!(
unresolvable_key_reason(key, &methods, &fields),
reason,
"the key register's sentence for `{key}` moved"
);
}
}
#[test]
fn the_interpolation_gate_registers_and_reports_exactly_what_it_did() {
let (methods, fields) = answerable_fixture();
let mut warnings = Vec::new();
let registered = keep_answerable_interpolation_keys(
&methods,
&fields,
ANSWERABLE_KEYS.iter().map(|k| k.to_string()).collect(),
&mut warnings,
);
assert_eq!(
registered,
[
"title",
"get_sub",
"flag",
"user.name",
"user.missing",
"vecs[0]",
"user.name()",
"a.b[0",
"1bad",
"a + b",
"user + other",
"items[0",
"a.b()",
"",
"user.name.deep",
"sub",
]
.map(String::from)
);
let expected = [
"interpolation {{ on_input }} cannot be resolved — `on_input` is a payload-taking State method, not an accessor; the interpolation renders empty. Declare `pub fn on_input(&self) -> impl std::fmt::Display` on `State`.",
"interpolation {{ nothing }} cannot be resolved — `nothing` returns nothing, so there is no value to read from; the interpolation renders empty. Declare `pub fn nothing(&self) -> impl std::fmt::Display` on `State`.",
"interpolation {{ unit }} cannot be resolved — `unit` returns nothing, so there is no value to read from; the interpolation renders empty. Declare `pub fn unit(&self) -> impl std::fmt::Display` on `State`.",
"interpolation {{ vecs }} cannot be resolved — `vecs` is a `State` method, but its return type cannot be rendered as text; the interpolation renders empty. Declare `pub fn vecs(&self) -> impl std::fmt::Display` on `State`.",
"interpolation {{ user }} cannot be resolved — `user` is a `State` method, but its return type cannot be rendered as text; the interpolation renders empty. Declare `pub fn user(&self) -> impl std::fmt::Display` on `State`.",
"interpolation {{ nope }} cannot be resolved — `nope` is not a zero-argument `State` method; the interpolation renders empty. Declare `pub fn nope(&self) -> impl std::fmt::Display` on `State`.",
"interpolation {{ items[0].name }} cannot be resolved — `items` is not a zero-argument `State` method; the interpolation renders empty. Declare `pub fn items(&self) -> YourType` on `State` and a method for each segment after it.",
];
assert_eq!(warnings, expected);
}
#[test]
fn a_refusal_carries_both_frozen_registers() {
let (methods, fields) = answerable_fixture();
let unrenderable = answerable("on_input", &methods, &fields)
.expect_err("a payload-taking method cannot be read as a value");
assert_eq!(
unrenderable.root_reason,
"`on_input` is a payload-taking State method, not an accessor"
);
assert_eq!(
unrenderable.key_reason,
"`on_input` is a payload-taking State method, not a getter"
);
assert_eq!(
unrenderable.remedy,
"Declare `pub fn on_input(&self) -> impl std::fmt::Display` on `State`."
);
}
#[test]
fn every_registered_key_is_answerable() {
let template = r#"
<p :class="{ on: flag }" :title="sub">{{ title }} {{ user.name() }} {{ sub }}</p>
<input v-model="title" />
<p v-if="flag">{{ rows }}</p>
<span v-show="sub" :data-name="flag">x</span>
<p v-if="nope">CH</p>
<input v-model="nope" />
<p v-if="user.name">CH</p>
"#;
let script = r#"
pub struct User { pub name: String }
impl State {
pub fn title(&self) -> String { String::new() }
pub fn flag(&self) -> bool { true }
pub fn user(&self) -> User { User { name: String::new() } }
pub fn sub(&self) -> String { String::new() }
}
"#;
let nodes = crate::template_parse::parse_template_to_ast(template).unwrap();
let fields = vec!["rows".to_string()];
let collected = collect_resolver_keys(
&nodes,
&methods(script),
&fields,
RenderMode::State,
&props_of(&fields),
);
let registered: std::collections::BTreeSet<&str> =
collected.keys.iter().map(String::as_str).collect();
assert_eq!(
registered,
["flag", "sub", "title", "user.name", "user.name()"]
.into_iter()
.collect::<std::collections::BTreeSet<&str>>(),
"the set of keys a template registers is itself a decision about \
answerability, so it is pinned: a fourth key here means a gate \
stopped asking the one predicate, and a missing key means one \
started refusing what it used to register"
);
assert_eq!(
collected.warnings,
vec![
"interpolation {{ rows }} cannot be resolved — `rows` is a \
`State` field, not an accessor — reading a field in a \
template needs a typed field resolver, which is not \
implemented yet; the interpolation renders empty. Declare \
`pub fn rows(&self) -> impl std::fmt::Display` on `State`."
.to_string(),
"if=\"nope\" cannot be resolved — `nope` is not a \
zero-argument `State` getter; the condition reads an empty \
value, so the element and everything inside it are not \
rendered. Bind a zero-argument State getter for `nope` \
instead."
.to_string(),
"v-model=\"nope\" cannot render its value — `nope` is not a \
zero-argument `State` getter; the input renders empty. The \
write needs every segment of `nope` to be a `State` field and \
the last one to be a type `VModel` is implemented for \
(`Signal<T>`, `RefCell<String>` or `Cell<T>`); otherwise the \
generated setter does not compile. Bind a zero-argument \
`State` getter, or a method for each segment of a dotted \
expression."
.to_string()
],
"one of the names in the template is refused by each of the three \
gates, and each sentence is the register that gate assembles"
);
for key in &collected.keys {
let answered = answerable(key, &methods(script), &fields);
assert!(
answered.is_ok(),
"`{key}` was registered by a path that has not asked the \
predicate, so its arm is the guess and not an answer: {:?}",
answered.err()
);
}
}
}