velox_sfc/template_codegen.rs
1use crate::component_resolver::ComponentResolver;
2use crate::script_index::{
3 PropField, StateMethod, extract_state_methods, method_names, resolve_method_name,
4};
5use crate::template_ast::{AttrKind, Node, TemplateAttr};
6use crate::template_parse::is_all_ws;
7use std::collections::{HashMap, HashSet};
8
9/// Determines how identifiers are resolved in generated code.
10#[derive(Debug, Clone, Copy, PartialEq)]
11enum TransformMode {
12 /// Wrap identifiers in `resolve()` calls — used by `render_with()`.
13 Resolve,
14 /// Reference `state.field.get()` directly — used by `render_with_state()`.
15 State,
16 /// Reference the component's own `Props` struct directly — used by
17 /// `render_with_props()`, the entry point a parent calls to hand props to a
18 /// child that has no persistent `State` field of its own.
19 ///
20 /// It differs from [`TransformMode::Resolve`] at exactly one place, the
21 /// `v-for` collection: a collection that names a declared `Props` field is
22 /// read off that field and the loop item becomes a real Rust binding, so the
23 /// body reads the item directly instead of asking the resolver for a
24 /// runtime-formatted key. Every other read is a `resolve(...)` lookup, the
25 /// same one `render_with` makes, because `render_with_props` hands this
26 /// renderer's body a closure that answers from the props first and the
27 /// child's `State` getters second.
28 Props,
29}
30
31/// Parsed v-for directive information.
32#[allow(dead_code)]
33struct VForInfo {
34 /// The item variable name (e.g., "item" from `item in items` or `(item, index) in items`)
35 item_name: String,
36 /// The index variable name (e.g., "index" from `(item, index) in items`), defaults to "__idx"
37 index_name: String,
38 /// The collection expression (e.g., "items" from `item in items`)
39 expr: String,
40 /// Whether destructuring was used: `(item, index) in items`
41 has_destructuring: bool,
42}
43
44/// Parse a v-for directive value like `item in items` or `(item, index) in items`.
45fn parse_v_for(value: &str) -> Option<VForInfo> {
46 let idx = value.find(" in ")?;
47 let left = value[..idx].trim();
48 let expr = value[idx + 4..].trim();
49
50 if expr.is_empty() {
51 return None;
52 }
53
54 let (item_name, index_name, has_destructuring) = if left.starts_with('(') && left.ends_with(')')
55 {
56 let inner = &left[1..left.len() - 1];
57 let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
58 let item = if parts.is_empty() || parts[0].is_empty() {
59 "__item".to_string()
60 } else {
61 parts[0].to_string()
62 };
63 let idx = if parts.len() >= 2 && !parts[1].is_empty() {
64 parts[1].to_string()
65 } else {
66 "__idx".to_string()
67 };
68 (item, idx, true)
69 } else if left.is_empty() {
70 ("__item".to_string(), "__idx".to_string(), false)
71 } else {
72 (left.to_string(), "__idx".to_string(), false)
73 };
74
75 Some(VForInfo {
76 item_name,
77 index_name,
78 expr: expr.to_string(),
79 has_destructuring,
80 })
81}
82
83/// The resolver key a `:key` attribute contributes in the Resolve-mode `v-for`
84/// body.
85///
86/// Nothing has rewritten the attribute value before it reaches the emit site, so
87/// it is normalized here exactly once: surrounding whitespace is dropped and the
88/// documented `{{ … }}` spelling is unwrapped to the bare expression it contains.
89/// The result is emitted as a single `resolve("…")` lookup — the same shape the
90/// sibling `v-for` branch uses for its collection (`let __for_expr =
91/// resolve("items");`) — so the expression is interpolated exactly once and the
92/// generated code compiles.
93fn resolve_key_expr(key_val: &str) -> String {
94 let trimmed = key_val.trim();
95 let unwrapped = trimmed
96 .strip_prefix("{{")
97 .and_then(|rest| rest.strip_suffix("}}"))
98 .map(str::trim)
99 .filter(|expr| !expr.is_empty());
100 unwrapped.unwrap_or(trimmed).to_string()
101}
102
103/// The Resolve-mode read of an expression rooted at a `v-for` loop variable.
104///
105/// The Resolve-mode loop body iterates an index over the collection string, so
106/// the loop item is *not* a Rust binding there: it is read the same way the same
107/// body already reads an interpolated `{ todo.text }` — an indexed resolver
108/// lookup. The loop index is a real binding and is read directly.
109///
110/// Returns `None` for anything this cannot read (a compound expression such as
111/// `todo.a + todo.b`), so the caller keeps the plain resolver lookup and the
112/// diagnostic still reports the binding rather than leaving it silently empty.
113fn resolve_loop_item_expr(expr: &str, for_info: &VForInfo) -> Option<String> {
114 let expr = expr.trim();
115 if expr == for_info.index_name {
116 // The loop index *is* a real binding in this body.
117 return Some(for_info.index_name.clone());
118 }
119 if expr == for_info.item_name {
120 return Some(format!(
121 r#"resolve(&format!("{}[{{}}]", {idx}))"#,
122 for_info.expr,
123 idx = for_info.index_name
124 ));
125 }
126 if let Some(path) = expr.strip_prefix(&for_info.item_name)
127 && let Some(path) = path.strip_prefix('.')
128 && is_field_path(path)
129 {
130 return Some(format!(
131 r#"resolve(&format!("{}[{{}}].{path}", {idx}))"#,
132 for_info.expr,
133 idx = for_info.index_name
134 ));
135 }
136 None
137}
138
139/// Whether `s` is a plain field path — dot-separated Rust identifiers, and
140/// nothing else. It keeps a compound expression such as `todo.a + todo.b` out of
141/// the indexed-resolver rewrite, where only the field path is rewritable.
142fn is_field_path(s: &str) -> bool {
143 !s.is_empty()
144 && s.split('.').all(|seg| {
145 let mut chars = seg.chars();
146 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
147 && chars.all(is_ident_char)
148 })
149}
150
151/// The `String` value a Resolve-mode `v-for` element's `:key` is set from.
152///
153/// `make_resolve` is built once, outside every loop, so a key rooted at the loop
154/// variable has no arm to answer it there. It is read the way the same loop body
155/// reads an interpolated loop value instead; a key the loop cannot read that way
156/// keeps the resolver lookup, which the diagnostic reports.
157fn resolve_mode_key_value(key_val: &str, for_info: &VForInfo) -> String {
158 let authored = resolve_key_expr(key_val);
159 match resolve_loop_item_expr(&authored, for_info) {
160 Some(indexed) => format!("{indexed}.to_string()"),
161 None => format!("resolve({}).to_string()", string_lit(&authored)),
162 }
163}
164
165/// The bool a resolver-backed condition evaluates to, using the same truthiness
166/// a non-loop condition gets from `rewrite_if_expr`. The ONE notion of it, for
167/// both call sites.
168///
169/// A loop item read in the Resolve renderer is a `String` from the resolver, not
170/// a `bool`, so it needs this test in an `if` position exactly as a resolver-backed
171/// condition does.
172///
173/// A resolver answer is a formatted value, so an operand that is a NUMBER arrives
174/// as its digits: `0` and `0.0` are not empty and are not `"false"`, so a test on
175/// emptiness alone would read them as TRUTHY — silently, and in the same way an
176/// empty string would have been silently falsy before an operand was registered at
177/// all. A value that parses as a number is therefore compared numerically, and only
178/// a value that is not a number is judged as text: `"true"`, and anything else that
179/// is not empty and not `"false"`. So `"abc"` is truthy, and so is a name.
180fn resolver_truthiness(read: &str) -> String {
181 format!(
182 r#"{{ let __v = {read}; __v == "true" || match __v.trim().parse::<f64>() {{ Ok(__n) => __n != 0.0, Err(_) => !__v.is_empty() && __v != "false" }} }}"#
183 )
184}
185
186/// Validate the parsed template AST and collect structural errors.
187/// Returns a list of error/warning messages (empty if the template is valid).
188fn validate_template(nodes: &[Node]) -> Vec<String> {
189 let mut errors: Vec<String> = Vec::new();
190
191 // 1. Multiple root template nodes
192 // A well-formed SFC template should have exactly one root element.
193 // (Whitespace-only text nodes are already trimmed by the parser, so
194 // any remaining root nodes are real elements.)
195 let root_elements: Vec<&Node> = nodes
196 .iter()
197 .filter(|n| matches!(n, Node::Element { .. }))
198 .collect();
199 if root_elements.len() > 1 {
200 let tags: Vec<String> = root_elements
201 .iter()
202 .filter_map(|n| match n {
203 Node::Element { tag, .. } => Some(tag.clone()),
204 _ => None,
205 })
206 .collect();
207 errors.push(format!(
208 "template has multiple root elements: <{}> — a valid SFC template must have exactly one root element",
209 tags.join(", ")
210 ));
211 }
212
213 // 2. Stray v-else / v-else-if without preceding v-if
214 // Walk the tree checking that every v-else or v-else-if is immediately
215 // preceded by a sibling with v-if or v-else-if (part of a valid chain).
216 fn check_stray_else(nodes: &[Node], errors: &mut Vec<String>) {
217 for i in 0..nodes.len() {
218 if let Node::Element { attrs, tag, .. } = &nodes[i]
219 && let Some(dir) = attrs.iter().find(|a| {
220 matches!(a.kind, AttrKind::Directive)
221 && (a.name == "else" || a.name == "else-if" || a.name == "elseif")
222 })
223 {
224 // Check that the nearest preceding element sibling has
225 // v-if or v-else-if (i.e., is part of a conditional chain).
226 let has_valid_preceding = if i > 0 {
227 // Walk backwards to find the nearest element sibling
228 // (text nodes between v-if and v-else are valid in Vue)
229 let mut found = false;
230 for j in (0..i).rev() {
231 if let Node::Element {
232 attrs: prev_attrs, ..
233 } = &nodes[j]
234 {
235 found = prev_attrs.iter().any(|a| {
236 matches!(a.kind, AttrKind::Directive)
237 && (a.name == "if" || a.name == "else-if" || a.name == "elseif")
238 });
239 break;
240 }
241 }
242 found
243 } else {
244 false
245 };
246
247 if !has_valid_preceding {
248 errors.push(format!(
249 "stray v-{} on <{}> without a preceding v-if — v-else/v-else-if must follow an element with v-if",
250 dir.name, tag
251 ));
252 }
253 }
254 // Recurse into children
255 if let Node::Element { children, .. } = &nodes[i] {
256 check_stray_else(children, errors);
257 }
258 }
259 }
260 check_stray_else(nodes, &mut errors);
261
262 errors
263}
264
265/// Which renderer a compiled component is expected to be rendered through.
266///
267/// A generated module always exposes both entry points, so this is not a
268/// codegen switch: it records which one the consumer calls, which decides
269/// whether an unresolvable template binding is worth reporting.
270#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
271pub enum RenderMode {
272 /// The component is rendered through `render_with_state`, which reads
273 /// `Signal`/`Ref` fields and `v-for` loop items directly from the persistent
274 /// `State`. This is what `veloxc` and every generated `main.rs` use.
275 #[default]
276 State,
277 /// The component is rendered through `render_with`/`render_with_props`,
278 /// which can only see strings a resolver closure supplies. A binding rooted
279 /// at a `v-for` loop item cannot resolve there, so it is reported.
280 Resolve,
281}
282
283/// Compile a `<template>` with no script indexing or cross-component handlers.
284/// Convenience wrapper used by tests and single-file compilation.
285pub fn compile_template_to_rs(
286 template_src: &str,
287 component_name: &str,
288 resolver: Option<&mut ComponentResolver>,
289) -> Result<String, String> {
290 compile_template_to_rs_full(template_src, component_name, resolver, None, None)
291}
292
293/// Full public API: compile `<template>` string to a Rust module body with `render()`.
294///
295/// The component is assumed to be rendered in [`RenderMode::State`]; use
296/// [`compile_template_to_rs_full_with_mode`] for a resolver-only consumer.
297///
298/// `script_setup` is the raw `<script setup>` block (if any); it is indexed so
299/// template keys can be resolved to the actual State method names and component
300/// tags can be matched to persistent child-state fields.
301///
302/// `scope_id` is the optional CSS scope attribute (e.g. `"data-v-abc123"`) that
303/// should be added to every rendered element so scoped CSS selectors match.
304///
305/// When a `resolver` is provided, handlers are collected across the component
306/// tree so the root dispatcher can route child-component events to the owning
307/// persistent State field.
308pub fn compile_template_to_rs_full(
309 template_src: &str,
310 component_name: &str,
311 resolver: Option<&mut ComponentResolver>,
312 script_setup: Option<&str>,
313 scope_id: Option<&str>,
314) -> Result<String, String> {
315 compile_template_to_rs_full_with_mode(
316 template_src,
317 component_name,
318 resolver,
319 script_setup,
320 scope_id,
321 RenderMode::State,
322 )
323}
324
325/// [`compile_template_to_rs_full`] with the renderer's mode, which decides
326/// whether a binding the resolver cannot satisfy is reported: a `v-for`
327/// loop-rooted binding renders correctly in [`RenderMode::State`] and is only
328/// reported for [`RenderMode::Resolve`]. The generated module is identical in
329/// both modes — only the diagnostics differ.
330pub fn compile_template_to_rs_full_with_mode(
331 template_src: &str,
332 _component_name: &str,
333 mut resolver: Option<&mut ComponentResolver>,
334 script_setup: Option<&str>,
335 scope_id: Option<&str>,
336 mode: RenderMode,
337) -> Result<String, String> {
338 let nodes = crate::template_parse::parse_template_to_ast(template_src)?;
339
340 // Validate template structure before codegen.
341 let validation_errors = validate_template(&nodes);
342 if !validation_errors.is_empty() {
343 return Err(validation_errors.join("\n"));
344 }
345
346 // Surface `unknown component` warnings here: pure template parsing is
347 // component-agnostic, but this is the point where the SFC's registered
348 // component names are known — via the resolver (imports parsed from
349 // `<script setup>` by the caller) and/or the raw `<script setup>` block.
350 // Must run before `transform_components` renames known component tags.
351 {
352 let mut known: Vec<String> = Vec::new();
353 if let Some(resolver) = &resolver {
354 known.extend(resolver.component_names());
355 }
356 if let Some(script_setup) = script_setup {
357 let mut scratch = ComponentResolver::new(std::path::PathBuf::from("."));
358 scratch.parse_imports(script_setup);
359 for name in scratch.component_names() {
360 if !known.contains(&name) {
361 known.push(name);
362 }
363 }
364 }
365 let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
366 for warning in
367 crate::template_parse::unknown_component_warnings(&nodes, &known_refs, template_src)
368 {
369 eprintln!("velox: warning: {warning}");
370 }
371 }
372
373 // For MVP, assume a single root node.
374 let mut nodes = nodes;
375
376 // Index the script block so codegen can resolve template keys to the real
377 // State method names and match component tags to persistent state fields.
378 let methods = script_setup.map(extract_state_methods).unwrap_or_default();
379 let fields = script_setup
380 .map(crate::script_index::extract_state_fields)
381 .unwrap_or_default();
382
383 // Transform components if resolver is provided.
384 if let Some(resolver) = &mut resolver {
385 super::component_resolver::transform_components(&mut nodes, resolver);
386 }
387
388 // Collect handlers from the whole component tree so the dispatcher can route
389 // child-component events to the owning persistent State field.
390 let tree_handlers = if let Some(resolver) = &mut resolver {
391 collect_component_tree_handlers(&nodes, resolver, &fields)
392 } else {
393 Vec::new()
394 };
395
396 if nodes.is_empty() {
397 return Ok(empty_component_module());
398 }
399
400 // The typed channel across the component boundary. The component's own
401 // `Props` struct is what a parent now builds, so its declared fields — with
402 // the types it declared them with — are what codegen needs to know.
403 let props_fields = script_setup
404 .map(crate::script_index::extract_props_fields)
405 .unwrap_or_default();
406 // A `State` that carries a `props` field owns the props it is handed, so
407 // assigning is what makes `self.props.x` mean something inside the child's
408 // own methods. Without that field there is nothing to assign to, and the
409 // props are read straight off the parameter.
410 let props_root = if !props_fields.is_empty() && fields.iter().any(|f| f == "props") {
411 "state.props"
412 } else {
413 "props"
414 };
415 let child_props = collect_child_props_fields(&nodes, resolver);
416 let props = PropsChannel {
417 fields: &props_fields,
418 root: props_root,
419 state_fields: &fields,
420 children: &child_props,
421 };
422
423 // For MVP, assume a single root node.
424 let root = &nodes[0];
425 let body_with = emit_node_with(root, &fields, &props, scope_id);
426 let body_with_state = emit_node_with_state(root, &fields, &props, scope_id);
427
428 let mut out = format!(
429 r#"pub fn render() -> velox_dom::VNode {{
430 render_with(|_| String::new())
431}}
432
433pub fn render_with<F>(mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {{
434 use velox_dom::*;
435 {body_with}
436}}"#,
437 body_with = body_with
438 );
439
440 // render_with_state accepts a `state: Arc<script_rs::State>`; the param is
441 // named `state` so v-for/component codegen can read `state.{field}`.
442 out.push_str("\n\n");
443 out.push_str(&format!(
444 r#"#[allow(clippy::arc_with_non_send_sync)]
445#[allow(unused_variables)]
446pub fn render_with_state<F>(state: std::sync::Arc<script_rs::State>, mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {{
447 use velox_dom::*;
448 {body_with_state}
449}}"#,
450 body_with_state = body_with_state
451 ));
452
453 // make_resolve: maps every key the render path looks up through `resolve()`
454 // (interpolations and bound-attribute expressions) to State getters so
455 // main.rs does not hand-write a resolve closure. Supports both bare
456 // (`title()`) and prefixed (`get_title()`) getter conventions.
457 let resolver_keys = collect_resolver_keys(&nodes, &methods, &fields, mode, &props);
458 for warning in &resolver_keys.warnings {
459 eprintln!("velox: warning: {warning}");
460 }
461 // A `v-for` whose collection nothing can supply renders as a loop that
462 // never runs, which is the one silent defect here with no visible trace in
463 // the output — so it refuses rather than reports. Every refusal is listed,
464 // not just the first: they are independent mistakes in independent
465 // templates-as-authored, and an author fixing one loop and hitting the
466 // next one one build at a time is a worse experience than a longer message.
467 // `join` is the same shape [`validate_template`] failures take a few lines
468 // above, so this is the crate's existing fatal convention, not a new one.
469 if !resolver_keys.errors.is_empty() {
470 return Err(resolver_keys.errors.join("\n"));
471 }
472 out.push_str("\n\n");
473 out.push_str(&generate_make_resolve(
474 &resolver_keys.keys,
475 &methods,
476 &fields,
477 ));
478
479 // make_on_event: dispatch every template handler (plus, for the root, every
480 // handler anywhere in the component tree) to the owning State method.
481 //
482 // A function-typed prop contributes a handler here too. The child calls
483 // through a closure rather than through `emit`, but the method the closure
484 // re-enters is still this component's, so the arm has to exist — and the
485 // arity it dispatches with is read off the method declaration here rather
486 // than assumed at the call site, which is what lets one binding serve both
487 // a zero-argument handler and a payload handler.
488 let mut handlers = collect_handlers(&nodes);
489 for handler in collect_function_prop_handlers(&nodes, &props) {
490 if !handlers.contains(&handler) {
491 handlers.push(handler);
492 }
493 }
494 out.push_str("\n\n");
495 out.push_str(&generate_make_on_event(&handlers, &tree_handlers, &methods));
496
497 // render_with_props: for presentational child components. Props take
498 // priority; interpolations fall back to State getters.
499 out.push_str("\n\n");
500 out.push_str(&generate_render_with_props(
501 &resolver_keys.keys,
502 &methods,
503 &fields,
504 &props,
505 root,
506 scope_id,
507 script_setup.is_some(),
508 ));
509
510 Ok(out)
511}
512
513fn collect_handlers(nodes: &[Node]) -> Vec<String> {
514 let mut set: HashSet<String> = HashSet::new();
515 // The `v-for` loop variables in scope, outermost first. A `v-model` rooted
516 // at one of them has no setter to dispatch to — see
517 // `vmodel_is_loop_rooted` — and `extract_vmodel` emits no handler for it, so
518 // neither can the dispatcher.
519 fn walk(n: &Node, set: &mut HashSet<String>, loop_vars: &mut Vec<(String, String)>) {
520 if let Node::Element {
521 attrs, children, ..
522 } = n
523 {
524 if let Some(info) = v_for_scope(attrs) {
525 loop_vars.push((info.item_name.clone(), info.index_name.clone()));
526 }
527 let (item_name, idx_name) = loop_vars.last().map_or((None, None), |(item, idx)| {
528 (Some(item.as_str()), Some(idx.as_str()))
529 });
530 for a in attrs {
531 if let AttrKind::On = a.kind
532 && let Some(v) = &a.value
533 {
534 // Parse event modifiers: @click.stop, @click.prevent, etc.
535 let (base_event, modifier) = parse_event_modifier(v);
536 let handler_key = if let Some(m) = modifier {
537 format!("{}.{}", base_event, m)
538 } else {
539 base_event.clone()
540 };
541 set.insert(handler_key);
542 }
543 // Collect v-model handlers: v-model="field" → __vmodel_set_field
544 if matches!(a.kind, AttrKind::Directive)
545 && a.name == "model"
546 && let Some(ref expr) = a.value
547 && !vmodel_is_loop_rooted(expr, item_name, idx_name)
548 {
549 set.insert(vmodel_setter_name(expr));
550 }
551 }
552 for c in children {
553 walk(c, set, loop_vars);
554 }
555 if v_for_scope(attrs).is_some() {
556 loop_vars.pop();
557 }
558 }
559 }
560 let mut loop_vars: Vec<(String, String)> = Vec::new();
561 for n in nodes {
562 walk(n, &mut set, &mut loop_vars);
563 }
564 let mut v: Vec<String> = set.into_iter().collect();
565 v.sort();
566 v
567}
568
569/// Parse an event handler string into (base_event, modifier).
570/// e.g., "click.stop" → ("click", Some("stop"))
571/// e.g., "click" → ("click", None)
572fn parse_event_modifier(handler: &str) -> (String, Option<String>) {
573 let parts: Vec<&str> = handler.split('.').collect();
574 if parts.len() >= 2 {
575 // Check if the last part is a known modifier
576 let modifier = parts[parts.len() - 1].to_string();
577 let known_modifiers = ["stop", "prevent", "once", "capture", "self"];
578 if known_modifiers.iter().any(|&m| m == modifier) {
579 let base: String = parts[..parts.len() - 1].join(".");
580 (base, Some(modifier))
581 } else {
582 // Not a known modifier, treat as regular handler
583 (handler.to_string(), None)
584 }
585 } else {
586 (handler.to_string(), None)
587 }
588}
589
590/// Collect every `@event` handler across the component tree, attributing each to
591/// the nearest persistent State field (the field on this component's State that
592/// matches the lowercased component tag). Used to build the root dispatcher so
593/// child-component events route to `state.{owner}.{method}`.
594fn collect_component_tree_handlers(
595 nodes: &[Node],
596 resolver: &mut ComponentResolver,
597 fields: &[String],
598) -> Vec<crate::script_index::TreeHandler> {
599 let mut out = Vec::new();
600 let mut seen: HashSet<String> = HashSet::new();
601 collect_tree_handlers_inner(nodes, resolver, fields, None, None, &mut out, &mut seen);
602 out
603}
604
605#[allow(clippy::too_many_arguments)]
606fn collect_tree_handlers_inner(
607 nodes: &[Node],
608 resolver: &mut ComponentResolver,
609 fields: &[String],
610 owner: Option<&str>,
611 owner_methods: Option<&[StateMethod]>,
612 out: &mut Vec<crate::script_index::TreeHandler>,
613 seen: &mut HashSet<String>,
614) {
615 for n in nodes {
616 let Node::Element {
617 attrs, children, ..
618 } = n
619 else {
620 continue;
621 };
622
623 // Local @event handlers on this element.
624 for a in attrs {
625 if let AttrKind::On = a.kind
626 && let Some(v) = &a.value
627 {
628 let takes_payload = owner_methods
629 .map(|ms| ms.iter().any(|m| m.name == *v && m.takes_payload))
630 .unwrap_or(false);
631 out.push(crate::script_index::TreeHandler {
632 name: v.clone(),
633 owner: owner.map(String::from),
634 takes_payload,
635 });
636 }
637 }
638
639 // Component element: descend into its template.
640 if let Some(comp_attr) = attrs
641 .iter()
642 .find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
643 && let Some(comp_name) = &comp_attr.value
644 {
645 let field = comp_name.to_lowercase();
646 let becomes_owner = owner.is_none() && fields.contains(&field);
647 let child_owner: Option<String> = if becomes_owner {
648 Some(field)
649 } else {
650 owner.map(String::from)
651 };
652
653 if seen.insert(comp_name.clone())
654 && let Ok(sfc) = resolver.load_component(comp_name)
655 {
656 // Clone content so `sfc`'s borrow ends before recursing.
657 let child_script = sfc.script_setup.as_ref().map(|b| b.content.clone());
658 let child_tpl = sfc.template.as_ref().map(|t| t.content.clone());
659
660 // When this component becomes the owner, its State methods tell us
661 // which handlers take a payload. Otherwise inherit the owner's.
662 let subtree_methods = if becomes_owner {
663 child_script.as_deref().map(extract_state_methods)
664 } else {
665 None
666 };
667 let child_methods: Option<&[StateMethod]> = if becomes_owner {
668 subtree_methods.as_deref()
669 } else {
670 owner_methods
671 };
672
673 if let Some(tpl) = child_tpl
674 && let Ok(child_nodes) = crate::template_parse::parse_template_to_ast(&tpl)
675 {
676 collect_tree_handlers_inner(
677 &child_nodes,
678 resolver,
679 fields,
680 child_owner.as_deref(),
681 child_methods,
682 out,
683 seen,
684 );
685 }
686 }
687 }
688
689 // Recurse into plain element children.
690 collect_tree_handlers_inner(children, resolver, fields, owner, owner_methods, out, seen);
691 }
692}
693
694/// The `Props` fields every child component used in this template declares.
695///
696/// Keyed by the tag's local name, which is what the emitted struct literal is
697/// qualified with (`{Comp}::PropsArg { .. }`). A child that declares no `Props`
698/// struct contributes an empty list, and the literal a parent builds for it is
699/// therefore the empty one — so binding anything to a child that declared no
700/// props is a compile error, not a silently dropped attribute.
701fn collect_child_props_fields(
702 nodes: &[Node],
703 resolver: Option<&mut ComponentResolver>,
704) -> Vec<(String, Vec<PropField>)> {
705 let Some(resolver) = resolver else {
706 return Vec::new();
707 };
708 let mut out: Vec<(String, Vec<PropField>)> = Vec::new();
709 let mut seen: HashSet<String> = HashSet::new();
710 collect_child_props_fields_inner(nodes, resolver, &mut out, &mut seen);
711 out
712}
713
714fn collect_child_props_fields_inner(
715 nodes: &[Node],
716 resolver: &mut ComponentResolver,
717 out: &mut Vec<(String, Vec<PropField>)>,
718 seen: &mut HashSet<String>,
719) {
720 for node in nodes {
721 let Node::Element {
722 attrs, children, ..
723 } = node
724 else {
725 continue;
726 };
727
728 if let Some(comp_attr) = attrs
729 .iter()
730 .find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
731 && let Some(comp_name) = &comp_attr.value
732 && seen.insert(comp_name.clone())
733 && let Ok(sfc) = resolver.load_component(comp_name)
734 {
735 let script = sfc.script_setup.as_ref().map(|b| b.content.clone());
736 out.push((
737 comp_name.clone(),
738 script
739 .as_deref()
740 .map(crate::script_index::extract_props_fields)
741 .unwrap_or_default(),
742 ));
743 }
744
745 collect_child_props_fields_inner(children, resolver, out, seen);
746 }
747}
748
749fn generate_make_on_event(
750 handlers: &[String],
751 extra_handlers: &[crate::script_index::TreeHandler],
752 methods: &[StateMethod],
753) -> String {
754 // Generate a dispatch helper that routes every template event to a State
755 // method. v-model handlers (`__vmodel_set_*`) and methods declared with a
756 // payload parameter receive the event payload; other handlers are zero-arg.
757 // Handlers owned by a persistent child component state (`extra_handlers`)
758 // route to `state.{owner}.{method}` instead of this component's State.
759 // Event modifiers (`.stop`, `.prevent`, `.once`, `.capture`, `.self`) are
760 // parsed from the handler name and the appropriate DOM method is called on
761 // the payload event.
762 let names = method_names(methods);
763 let mut extra_by_name: HashMap<&str, &crate::script_index::TreeHandler> = HashMap::new();
764 for eh in extra_handlers {
765 extra_by_name.entry(&eh.name).or_insert(eh);
766 }
767
768 let mut arms = String::new();
769 let mut used: HashSet<String> = HashSet::new();
770
771 // Known event modifiers — reserved for future modifier codegen (currently inlined)
772 let _modifier_handlers: HashMap<&str, &str> = [
773 ("stop", "e.stop_propagation()"),
774 ("prevent", "e.prevent_default()"),
775 ("once", "// mark as handled once"),
776 ("capture", "// capture phase handling"),
777 ("self", "// only handle if event target is this element"),
778 ]
779 .iter()
780 .cloned()
781 .collect();
782
783 for h in handlers {
784 if h.starts_with("__vmodel_set_") {
785 arms.push_str(&format!(
786 " \"{name}\" => {{ if let Some(p) = payload {{ state.{name}(p); }} }},\n",
787 name = h
788 ));
789 } else if let Some(eh) = extra_by_name.get(h.as_str())
790 && let Some(owner) = eh.owner.as_deref()
791 {
792 // Handler owned by a persistent child component state field.
793 if eh.takes_payload {
794 arms.push_str(&format!(
795 " \"{name}\" => {{ if let Some(p) = payload {{ state.{owner}.{method}(p); }} }},\n",
796 name = h, owner = owner, method = eh.name
797 ));
798 } else {
799 arms.push_str(&format!(
800 " \"{name}\" => {{ state.{owner}.{method}(); }},\n",
801 name = h,
802 owner = owner,
803 method = eh.name
804 ));
805 }
806 } else {
807 // Local handler on this component's State.
808 // Parse event modifiers from the handler name (e.g., "click.stop")
809 let (base_event, modifier) = parse_event_modifier(h);
810 let method = resolve_method_name(&names, &base_event);
811 let takes_payload = methods.iter().any(|m| m.name == method && m.takes_payload);
812
813 if takes_payload {
814 // Generate arm with modifier handling
815 let mod_code = match modifier.as_deref() {
816 Some("stop") => "e.stop_propagation()",
817 Some("prevent") => "e.prevent_default()",
818 Some("once") => "// mark as handled once",
819 Some("capture") => "// capture phase handling",
820 Some("self") => "// only handle if event target is this element",
821 _ => "",
822 };
823 arms.push_str(&format!(
824 " \"{original}\" => {{ if let Some(p) = payload {{ state.{method}(p); {mod_code} }} }},\n",
825 original = h,
826 method = method,
827 mod_code = mod_code
828 ));
829 } else {
830 // Zero-arg handler with modifier
831 let mod_code = match modifier.as_deref() {
832 Some("stop") => "e.stop_propagation()",
833 Some("prevent") => "e.prevent_default()",
834 Some("once") => "// mark as handled once",
835 Some("capture") => "// capture phase handling",
836 Some("self") => "// only handle if event target is this element",
837 _ => "",
838 };
839 arms.push_str(&format!(
840 " \"{original}\" => {{ state.{method}(); {mod_code} }},\n",
841 original = h,
842 method = method,
843 mod_code = mod_code
844 ));
845 }
846 }
847 used.insert(h.clone());
848 }
849
850 // Extra handlers not already covered by a local template event still need
851 // an arm — they may be defined only inside a child component's template.
852 // Handlers without an owner are local to this component (already covered);
853 // only owner-attributed ones route through a persistent State field.
854 for eh in extra_handlers {
855 if used.contains(&eh.name) {
856 continue;
857 }
858 let Some(owner) = eh.owner.as_deref() else {
859 continue;
860 };
861 if eh.takes_payload {
862 arms.push_str(&format!(
863 " \"{name}\" => {{ if let Some(p) = payload {{ state.{owner}.{method}(p); }} }},\n",
864 name = eh.name, owner = owner, method = eh.name
865 ));
866 } else {
867 arms.push_str(&format!(
868 " \"{name}\" => {{ state.{owner}.{method}(); }},\n",
869 name = eh.name,
870 owner = owner,
871 method = eh.name
872 ));
873 }
874 }
875
876 // The allow below is emitted onto the generated dispatch function itself, and
877 // deliberately not at module level: a component's hand-written `<script>`
878 // lands in the same generated module, so a module-level allow would also mask a
879 // genuine `single_match` in the user's own code. Scoping it to this one shim
880 // silences only the dispatch shape we generate. The lint fires because a
881 // component with exactly one handler produces a one-armed `match` whose
882 // wildcard arm does nothing. Rewriting that as an `if name == "..."` would
883 // silence it without an allow, but the shape of this dispatch is the contract
884 // the generated-code tests read back (see `vmodel_dispatch_arms` in
885 // velox-sfc/tests/vmodel_tests.rs), so changing it is a codegen decision and
886 // not a lint fix.
887 format!(
888 r#"#[allow(clippy::arc_with_non_send_sync, clippy::single_match)]
889pub fn make_on_event(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str, Option<&str>) + 'static {{
890 move |name: &str, payload: Option<&str>| {{
891 match name {{
892{arms} _ => {{}}
893 }}
894 }}
895}}"#,
896 arms = arms
897 )
898}
899
900/// Generate a `make_resolve(state)` helper that maps template interpolation keys
901/// to State getters. `main.rs` uses it instead of hand-writing a resolve closure.
902fn generate_make_resolve(
903 interp_keys: &[String],
904 methods: &[StateMethod],
905 fields: &[String],
906) -> String {
907 let mut arms = String::new();
908 for key in interp_keys {
909 let method = resolve_getter_call(methods, fields, key);
910 arms.push_str(&format!(
911 " \"{}\" => state.{}.to_string(),\n",
912 key, method
913 ));
914 }
915 format!(
916 r#"#[allow(clippy::arc_with_non_send_sync)]
917pub fn make_resolve(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str) -> String {{
918 move |name: &str| match name {{
919{arms} _ => String::new(),
920 }}
921}}"#,
922 arms = arms
923 )
924}
925
926/// How a resolver key is answered, when it is: one variant per shape an arm
927/// body can take.
928#[derive(Debug, Clone, PartialEq, Eq)]
929enum Answer {
930 /// A bare name, answered by the one zero-argument `State` method whose
931 /// return type renders as text: `{{ title }}` → `title()`.
932 Getter { root: String, name: String },
933 /// A member path, answered by a chain of calls on the root accessor:
934 /// `{{ user.name }}` → `user().name()`.
935 MemberChain { root: String, chain: String },
936 /// A key that is neither a bare name nor a member path — a call expression
937 /// (`user.name()`), an unterminated index (`a.b[0`), anything whose
938 /// segments are not names — answered by the name-only spelling, which is the
939 /// only thing the emitter has ever been able to write down for one.
940 ///
941 /// It is `Ok` because refusing it is a behaviour change, not a fix: a
942 /// `{{ user.name() }}` interpolation compiles today only as
943 /// `state.user.name()()`, and that arm text is pinned. Nothing in this crate
944 /// can say whether such a key means anything — codegen cannot type it — so
945 /// gating it needs a typed resolver and is a separate task. See the report.
946 Conventional { name: String },
947}
948
949impl Answer {
950 /// The call expression the arm body makes on `state`, parentheses included.
951 fn call(&self) -> String {
952 match self {
953 Answer::Getter { name, .. } | Answer::Conventional { name } => format!("{name}()"),
954 Answer::MemberChain { chain, .. } => chain.clone(),
955 }
956 }
957
958 /// The sentence [`unresolvable_key_reason`] shows for a key this function
959 /// ANSWERS.
960 ///
961 /// One caller can be handed an answerable key: the bound-attribute path
962 /// gates on [`has_state_getter`], a narrower question than `answerable`
963 /// asks — a member chain and a `State` field's name both answer here and not
964 /// there — so a key it refuses still needs naming. The register is the
965 /// caller's, and it is this shape's own, so nothing here re-tests the key.
966 fn refusal(&self, key: &str, methods: &[StateMethod], fields: &[String]) -> String {
967 match self {
968 // The root is a provable accessor, so the only sentence that can
969 // describe a provable accessor refused for a chain is the root
970 // register's.
971 Answer::MemberChain { root, .. } => root_reason(root, true, methods, fields),
972 // A key nothing is known about names no method at all.
973 Answer::Conventional { .. } => key_reason(key, false, methods, fields),
974 // A bare name, and the register is its own. Unreachable from every
975 // call site: `Ok(Getter)` asserts a zero-argument renderable method
976 // named `key` exists, which is exactly what the one caller that can
977 // reach an answerable key has already tested. Total so this stays a
978 // reader of [`answerable`] rather than a second decision.
979 Answer::Getter { .. } => key_reason(key, true, methods, fields),
980 }
981 }
982}
983
984/// A key nothing answers, and the words a refusal is reported in.
985///
986/// The wording is two registers because it has always been two and both are
987/// frozen: one quotes the key as the author wrote it ([`key_reason`], read by
988/// [`unresolvable_key_reason`] for the bound-attribute, `v-model` and condition
989/// paths) and one quotes the key's first segment together with the fix
990/// ([`root_reason`] and [`remedy`], read by [`keep_answerable_interpolation_keys`]
991/// for the interpolation gate). The DECISION is [`answerable`]'s alone and is
992/// made exactly once; only the sentence carrying it is per-caller, because
993/// R-1/R-1c/R-1d/R-3's reviews pinned both registers and an author shown new
994/// wording for an old mistake is being told the mistake is new.
995#[derive(Debug, Clone, PartialEq, Eq)]
996struct Unrenderable {
997 /// Names the key as authored.
998 key_reason: String,
999 /// Names the key's first segment.
1000 root_reason: String,
1001 /// The fix the interpolation gate offers, in its own words.
1002 remedy: String,
1003}
1004
1005/// Can a resolver key be read at all, and if not, what to tell the author.
1006///
1007/// This is the whole of answerability. Everything that has to decide asks here:
1008/// the arm emitter (which call expression the arm body makes), the registration
1009/// gate (whether a key is registered at all) and every diagnostic that refuses
1010/// one. Those are one question asked at three layers, and while each had its own
1011/// copy the copies disagreed — a key could end up registered but unanswerable, or
1012/// answerable but unregistered, and which of the two happened depended on which
1013/// path the template happened to take. `Result` is the contract that closes that:
1014/// `Ok` is an answer the emitter can write down, `Err` is a refusal that already
1015/// carries its sentence, and a key is registered only when it is `Ok`. A later
1016/// task adds an arm here for the loop collection `State` cannot hand over; the
1017/// boundary it will cross is this `Result`, not a copy of this question.
1018///
1019/// The tests below run in the order of the shapes an arm can take, and each one
1020/// is asked by the answer rather than guessed from the key.
1021fn answerable(
1022 key: &str,
1023 methods: &[StateMethod],
1024 fields: &[String],
1025) -> Result<Answer, Unrenderable> {
1026 // A member path is answered by a chain on its root accessor, so what is
1027 // wrong with the key is whatever is wrong with the root. Asked first because
1028 // `member_path` is the stricter of the two shapes: it refuses a key with a
1029 // call in it, which a bare name cannot be.
1030 if let Some((root, _)) = member_path(key) {
1031 let reason = root_reason(root, true, methods, fields);
1032 return match member_path_call(methods, key) {
1033 Some(chain) => Ok(Answer::MemberChain {
1034 root: root.to_string(),
1035 chain,
1036 }),
1037 None => Err(Unrenderable {
1038 key_reason: reason.clone(),
1039 root_reason: reason,
1040 remedy: path_remedy(root),
1041 }),
1042 };
1043 }
1044 if is_bare_identifier(key) {
1045 return match getter_method_name(methods, key) {
1046 Some(name) => Ok(Answer::Getter {
1047 root: key.to_string(),
1048 name: name.to_string(),
1049 }),
1050 None => Err(Unrenderable {
1051 key_reason: key_reason(key, true, methods, fields),
1052 root_reason: root_reason(key, false, methods, fields),
1053 remedy: name_remedy(key),
1054 }),
1055 };
1056 }
1057 // Neither shape. `member_path` refuses it and no `State` method can be named
1058 // after it, so the two tests above have nothing to say about it — see
1059 // `Answer::Conventional` for why the answer is still `Ok`.
1060 Ok(Answer::Conventional {
1061 name: conventional_name(methods, key),
1062 })
1063}
1064
1065/// The method name a key is called by when the naming convention is all there is
1066/// to go on: the exact name if `State` has it, else the `get_`/`is_`/`has_`
1067/// variant, else the key itself. One helper for [`answerable`]'s ungated shape
1068/// and for [`resolve_getter_call`]'s dead arm, so a guessed name is spelled once.
1069fn conventional_name(methods: &[StateMethod], key: &str) -> String {
1070 resolve_method_name(&method_names(methods), key)
1071}
1072
1073/// Why `key` as authored cannot be read — the register the bound-attribute,
1074/// `v-model` and condition diagnostics quote in.
1075///
1076/// `is_bare` is the CALLER's knowledge of the key's shape, never a re-test of
1077/// it: [`answerable`] has already decided, and a caller holding an [`Answer`]
1078/// knows which shape it is. Nothing here decides anything; the arms are frozen
1079/// wording, and this is the whole of the key register R-1/R-1c/R-1d/R-3 left.
1080fn key_reason(key: &str, is_bare: bool, methods: &[StateMethod], fields: &[String]) -> String {
1081 // The exact declared name only, with no `get_`/`is_`/`has_` fallback: this
1082 // register names the method the author wrote, so naming a different one here
1083 // would be a second answer to the same question.
1084 let named = methods.iter().find(|m| m.name == key);
1085 match named {
1086 Some(m) if m.takes_payload => {
1087 format!("`{key}` is a payload-taking State method, not a getter")
1088 }
1089 Some(m) if m.return_type.is_none() => {
1090 format!("`{key}` declares no return type, so there is no text to render")
1091 }
1092 Some(m) => format!(
1093 "`{key}` returns `{}`, which cannot be rendered as text",
1094 m.return_type.as_deref().unwrap_or_default()
1095 ),
1096 None if fields.iter().any(|f| f == key) => format!(
1097 "`{key}` is a `State` field, not a getter — reading a field in a \
1098 template needs a typed field resolver, which is not implemented yet"
1099 ),
1100 None if !is_bare => {
1101 format!("`{key}` is an expression, not a `State` getter name")
1102 }
1103 None => format!("`{key}` is not a zero-argument `State` getter"),
1104 }
1105}
1106
1107/// Why the first segment of a key cannot be read — the register the
1108/// interpolation gate quotes in, so `{{ form.name }}` and `v-model="form.name"`
1109/// name the same cause in the same words.
1110///
1111/// `is_path` is the caller's knowledge of the key's shape, as in [`key_reason`].
1112/// The arms are frozen wording, reproduced as R-1/R-1c/R-1d/R-3 left them.
1113fn root_reason(root: &str, is_path: bool, methods: &[StateMethod], fields: &[String]) -> String {
1114 let declared = methods.iter().find(|m| m.name == root);
1115 match declared {
1116 Some(m) if m.takes_payload => {
1117 format!("`{root}` is a payload-taking State method, not an accessor")
1118 }
1119 Some(m) if !returns_a_value(m.return_type.as_deref()) => {
1120 format!("`{root}` returns nothing, so there is no value to read from")
1121 }
1122 Some(_) if is_path => {
1123 format!("`{root}` is a State method, but a member path needs an accessor")
1124 }
1125 Some(_) => {
1126 format!("`{root}` is a `State` method, but its return type cannot be rendered as text")
1127 }
1128 None if fields.iter().any(|f| f == root) => format!(
1129 "`{root}` is a `State` field, not an accessor — reading a field in a \
1130 template needs a typed field resolver, which is not implemented yet"
1131 ),
1132 None => format!("`{root}` is not a zero-argument `State` method"),
1133 }
1134}
1135
1136/// The fix the interpolation gate offers for a bare name.
1137fn name_remedy(name: &str) -> String {
1138 format!("Declare `pub fn {name}(&self) -> impl std::fmt::Display` on `State`.")
1139}
1140
1141/// The fix the interpolation gate offers for a member path, which needs an
1142/// accessor for the root and a method for every segment after it.
1143fn path_remedy(root: &str) -> String {
1144 format!(
1145 "Declare `pub fn {root}(&self) -> YourType` on `State` and a method for each \
1146 segment after it."
1147 )
1148}
1149
1150/// Why a key cannot be answered by a resolver arm, in the register the
1151/// bound-attribute, `v-model` and condition diagnostics quote it in.
1152///
1153/// A reader of [`answerable`], not a second question. The one caller that can be
1154/// handed an answerable key is the bound-attribute path, which gates on
1155/// [`has_state_getter`], and [`Answer::refusal`] covers it. One predicate for
1156/// every diagnostic that asks "can this key be looked up?", so the
1157/// bound-attribute path and the `v-model` value path cannot drift into
1158/// describing the same mistake differently.
1159fn unresolvable_key_reason(key: &str, methods: &[StateMethod], fields: &[String]) -> String {
1160 match answerable(key, methods, fields) {
1161 Err(unrenderable) => unrenderable.key_reason,
1162 Ok(answer) => answer.refusal(key, methods, fields),
1163 }
1164}
1165
1166/// The `v-for` write a `v-model` on a loop item cannot make, reported in State
1167/// mode.
1168///
1169/// The read is real — the input shows the loop item's own field — but the write
1170/// has no route: `on:input` carries a handler *name*, and `make_on_event`
1171/// resolves it against `State`, which has no handle on the loop item. So no
1172/// setter is generated and no dispatcher arm is emitted; an arm naming one would
1173/// be `state.__vmodel_set_todo_text(p)` against a method that cannot exist, and
1174/// the setter body would be `vmodel_set(&self.todo.text, …)` on a field the
1175/// `State` does not have. Both are E0609, not a working binding. The same
1176/// family and the same remedy sentence as [`resolve_loop_warning`].
1177/// Why a `v-if` / `v-show` condition cannot be read, in the terms of the
1178/// directive that reads it.
1179///
1180/// Both directives ask the same question of the same key through the same gate
1181/// ([`key_is_answerable`]), so they say the same thing the same way — and each
1182/// names the consequence ITS OWN emission has rather than a capability the
1183/// emitted code lacks: an unregistered key answers `""`, which is falsy, so a
1184/// `v-if` leaves the element and its subtree out of the tree and a `v-show`
1185/// leaves the element hidden.
1186///
1187/// This is a DISTINCT condition from the Resolve-mode loop diagnostic
1188/// ([`resolve_loop_warning`]): that one is about a loop that cannot render and
1189/// reads no key set, and this one is about a key that has no getter. Neither is
1190/// derived from the other.
1191fn condition_unresolvable_warning(
1192 directive: &str,
1193 expr: &str,
1194 key: &str,
1195 methods: &[StateMethod],
1196 fields: &[String],
1197) -> String {
1198 let consequence = if directive == "show" {
1199 "the condition reads an empty value, so the element renders with `display: none`"
1200 } else {
1201 "the condition reads an empty value, so the element and everything inside it are not rendered"
1202 };
1203 format!(
1204 "{directive}=\"{expr}\" cannot be resolved — {}; {consequence}. Bind a zero-argument \
1205 State getter for `{key}` instead.",
1206 unresolvable_key_reason(key, methods, fields)
1207 )
1208}
1209
1210fn vmodel_loop_write_warning(expr: &str) -> String {
1211 format!(
1212 "v-model=\"{expr}\" on a `v-for` item cannot be written, and no template syntax \
1213 reaches it — the read works, so the input shows the item's value, but typing does \
1214 not change it. Both hops of every write path break: the loop body iterates a \
1215 DETACHED COPY, because `Signal::get` returns `T` by value rather than a handle, \
1216 and the event dispatcher is `FnMut(&str, Option<&str>)` — a name and a payload, \
1217 with no index — so a write to \"the third item\" cannot even be NAMED. Two routes \
1218 do work: bind `v-model` on a `State` field instead of on the loop item, where the \
1219 generated `__vmodel_set_<field>` setter is dispatched and writes through the \
1220 signal; or render this component through `render_with_state` and do the write in \
1221 a `State` method the component calls itself, since that is the only place the \
1222 index still exists. This is a limitation of `v-model` on a loop item, not a typo \
1223 to work around: a `v-for` item is read-only by design."
1224 )
1225}
1226
1227/// The write half a `v-model` does not have in Resolve mode.
1228///
1229/// Resolve mode renders through `render_with(resolve)`, which has no `State` at
1230/// all: the generated `make_on_event` takes one, so nothing dispatches the write
1231/// there. R-1's ruling is that Resolve mode reports rather than grows a write
1232/// path, and this is that report — the same family and remedy as
1233/// [`resolve_loop_warning`], and deliberately a separate function from it,
1234/// because a `v-model` is a different condition from a loop that cannot render.
1235fn vmodel_resolve_mode_warning(expr: &str) -> String {
1236 format!(
1237 "v-model=\"{expr}\" cannot be written in Resolve mode — the resolver renders values but \
1238 dispatches no events, so only the read side exists here. Render this component through \
1239 `render_with_state`, which reads the value and writes it from the State directly."
1240 )
1241}
1242
1243/// The typed props channel on both sides of a parent→child call.
1244///
1245/// A child declares `pub struct Props { … }`; codegen exposes it as
1246/// `pub type PropsArg = script_rs::Props`, and a parent builds it with a struct
1247/// literal. The boundary is therefore checked: `rustc` rejects a parent that
1248/// leaves a field out, names a field the child never declared, or supplies a
1249/// value of the wrong type — at compile time, not as a silently absent prop at
1250/// run time.
1251#[derive(Debug, Clone, Copy, Default)]
1252struct PropsChannel<'a> {
1253 /// This component's own declared `Props` fields, in declaration order.
1254 fields: &'a [PropField],
1255 /// The expression that reads a value of one of those fields, e.g.
1256 /// `state.props`. `props` when the child's `State` has nowhere to keep the
1257 /// value, `state.props` when it does.
1258 root: &'a str,
1259 /// This component's own `State` field names. Not a second copy of
1260 /// `collect_resolver_keys`'s `fields`: it is the same slice, carried so a
1261 /// diagnostic that has to explain WHY a collection is unreachable can ask
1262 /// the one predicate that owns the answer, `answerable`, the question it
1263 /// already knows how to answer.
1264 state_fields: &'a [String],
1265 /// What each child component declares, keyed by its tag name, so a parent
1266 /// builds that child's `PropsArg` with the types that child asked for.
1267 children: &'a [(String, Vec<PropField>)],
1268}
1269
1270impl<'a> PropsChannel<'a> {
1271 /// Does a `v-for` over `expr` have a real collection to read?
1272 ///
1273 /// A `Vec<T>`-shaped declared field is the only kind this can bind an item
1274 /// from, and the shape is what the generated loop actually needs — `item` is
1275 /// a `&T`, so the body reads `{item}.{path}` as a real field read.
1276 fn declares_vec_field(&self, expr: &str) -> bool {
1277 self.field(expr).is_some_and(|f| is_vec_shaped(&f.ty))
1278 }
1279
1280 /// This component's own `Props` field named `name`, whatever its declared
1281 /// type.
1282 ///
1283 /// The ONE place "did the author declare this?" is asked, so the two things
1284 /// that follow from the answer cannot disagree. [`Self::declares_vec_field`]
1285 /// narrows this to the shape a `v-for` can bind an item from and so cannot
1286 /// answer "declared at all" — a field declared `String` is declared and
1287 /// still uniterable, and a diagnostic that asked the shape test for the
1288 /// plain question would call it undeclared and name the wrong mistake.
1289 fn field(&self, name: &str) -> Option<&'a PropField> {
1290 self.fields.iter().find(|f| f.name == name)
1291 }
1292
1293 /// Does this component already HOLD `name` as a collection, in a way some
1294 /// renderer reads directly?
1295 ///
1296 /// The two that hold one are a declared `Props` field of a `Vec`-shaped type
1297 /// ([`Self::declares_vec_field`]) and a `State` field a generated renderer
1298 /// reads off `state` — `state.items.get()`, which is how a `State`-mode
1299 /// `v-for` has always worked. Both answer `true` for a component that is NOT
1300 /// broken, which is exactly what separates them from a name nothing holds:
1301 /// [`unrenderable_collection_error`] only ever describes the latter.
1302 ///
1303 /// The `State` side is deliberately the plain name test, not a shape test.
1304 /// The field's TYPE is not something codegen can read a collection out of —
1305 /// `Rc<Signal<Vec<T>>>` is the shape, and the shape is the renderer's
1306 /// business — and a `State` field that were somehow not a collection would
1307 /// fail to type-check in the generated `state.{name}.get()` read, which is
1308 /// a louder and more precise failure than anything said here.
1309 fn holds_collection(&self, name: &str) -> bool {
1310 self.declares_vec_field(name) || self.state_fields.iter().any(|f| f == name)
1311 }
1312
1313 /// Is this component's inventory of names it can be SUPPLIED closed, so that
1314 /// a name outside it is provably unanswerable and refusing to compile is
1315 /// sound?
1316 ///
1317 /// A component that declares a `Props` interface has one. Only its declared
1318 /// fields can be handed to it, so a `v-for` over a name that is not one of
1319 /// them can never be answered by anything, and
1320 /// [`unrenderable_collection_error`] is the right response.
1321 ///
1322 /// A component with NO declared `Props` does not. Its generated
1323 /// `render_with` entry point takes a caller closure `|name| String`, and
1324 /// that closure can answer any name at all — the inventory is open, and the
1325 /// caller's closure is a legitimate supplier. This is not a loophole: it is
1326 /// the documented shape of a `render_with` template, and `velox init`
1327 /// scaffolds exactly this (a root component that has a `<script setup>` and
1328 /// is rendered by the caller). Treating presence-of-script as "the
1329 /// inventory is closed" refused those working templates, which is the exact
1330 /// mirror of the silent-hole defect this gate exists to close: instead of
1331 /// shipping a hole it made a working template unbuildable.
1332 ///
1333 /// So the question is never whether a `<script setup>` is present but whether
1334 /// this component's own declaration bounds what can be supplied to it.
1335 fn inventory_is_closed(&self) -> bool {
1336 !self.fields.is_empty()
1337 }
1338
1339 /// The field `name` of the child component tagged `comp_name`, if that
1340 /// child is known to this component and declares such a field.
1341 fn child_field(&self, comp_name: &str, name: &str) -> Option<&'a PropField> {
1342 self.children
1343 .iter()
1344 .find(|(tag, _)| tag == comp_name)
1345 .and_then(|(_, fields)| fields.iter().find(|f| f.name == name))
1346 }
1347
1348 /// Every field `comp_name` declares, in declaration order, or an empty
1349 /// slice when this component cannot see that child.
1350 fn child_fields(&self, comp_name: &str) -> &'a [PropField] {
1351 self.children
1352 .iter()
1353 .find(|(tag, _)| tag == comp_name)
1354 .map(|(_, fields)| fields.as_slice())
1355 .unwrap_or(&[])
1356 }
1357
1358 /// Does the child component tagged `comp_name` declare a `Props` struct at
1359 /// all?
1360 ///
1361 /// It decides the SHAPE of the literal a parent builds for that child: a
1362 /// child with declared fields takes them by name, and a child with none
1363 /// takes nothing at all. So a bind to a child that declares no props is
1364 /// written into a field that child does not have, and does not compile.
1365 ///
1366 /// `None` when the template names a child this component cannot see — a tag
1367 /// no import or resolver entry matches. There is no declaration to check
1368 /// against, so the caller falls back to the name/value form.
1369 fn child_declares_props(&self, comp_name: &str) -> Option<bool> {
1370 self.children
1371 .iter()
1372 .find(|(tag, _)| tag == comp_name)
1373 .map(|(_, fields)| !fields.is_empty())
1374 }
1375}
1376
1377/// Is `ty` a `Vec`/`&[T]`/`[T; N]` — something with `.iter()` and `.is_empty()`?
1378///
1379/// Textual, not a parsed type: a `.vx` script is emitted verbatim and may name
1380/// types from anywhere, so the check is on the shape the generated loop needs
1381/// (`item` becomes a `&T`) and nothing more. `String` and `Rc<Signal<T>>` are
1382/// not: a `Signal` holds a value, not a borrow, so an item binding could not be
1383/// a `&T` through it.
1384fn is_vec_shaped(ty: &str) -> bool {
1385 let ty = ty.trim();
1386 ty.starts_with("Vec<")
1387 || ty.starts_with("&[")
1388 || (ty.starts_with('[') && ty.contains(';'))
1389 || ty.starts_with("std::vec::Vec<")
1390 || ty.starts_with("std::collections::")
1391}
1392
1393/// Is `ty` one a `{{ }}` interpolation can read — something with a `to_string()`
1394/// that means something in a template?
1395///
1396/// The same question `props_field_value` asks when it converts a parent's
1397/// binding, asked of the other side of the same declaration.
1398fn is_text_shaped(ty: &str) -> bool {
1399 let ty = ty.trim();
1400 matches!(ty, "String" | "str" | "&str" | "bool") || is_numeric_type(ty)
1401}
1402
1403/// Generate `render_with_props` — the entry point a parent calls to hand props to
1404/// a child that has no persistent `State` field of its own.
1405///
1406/// It answers every read the same way `render_with` does, through one `resolve`
1407/// closure: the child's own declared `Props` fields first, then its `State`
1408/// getters, then nothing. The difference is that the props value is the child's
1409/// own `Props` type, so the arms over it are a `match` on struct fields rather
1410/// than a `HashMap` lookup that can miss, and `rustc` checks the parent's struct
1411/// literal against the child's declaration.
1412///
1413/// The third body is not `render_with`'s. It is emitted in
1414/// [`TransformMode::Props`] so a `v-for` collection that names a declared `Props`
1415/// field is read off that field and its item bound directly, rather than
1416/// resolved as a runtime-formatted string key.
1417///
1418/// A component that declares no `Props` struct gets no third body: there is no
1419/// typed field for the difference to be about, and the extra copy of the tree
1420/// would be a second place for the generated render to change.
1421fn generate_render_with_props(
1422 interp_keys: &[String],
1423 methods: &[StateMethod],
1424 fields: &[String],
1425 props: &PropsChannel<'_>,
1426 root: &Node,
1427 scope_id: Option<&str>,
1428 has_script: bool,
1429) -> String {
1430 if props.fields.is_empty() {
1431 return r#"pub fn render_with_props(props: PropsArg) -> velox_dom::VNode {
1432 let resolve_props = |key: &str| -> String {
1433 props.values.get(key).cloned().unwrap_or_default()
1434 };
1435 render_with(resolve_props)
1436}"#
1437 .to_string();
1438 }
1439 let props_root = props.root;
1440
1441 let mut match_arms = String::new();
1442 for f in props.fields {
1443 // A `Vec<T>` collection, a struct, anything without a text form gets no
1444 // arm: an interpolation resolves to a `String`, and `.to_string()` on a
1445 // type that is not `Display` does not compile. A collection is read by
1446 // the loop, not by an interpolation, so it loses nothing by being absent.
1447 if !is_text_shaped(&f.ty) {
1448 continue;
1449 }
1450 match_arms.push_str(&format!(
1451 " \"{}\" => {}.{}.to_string(),\n",
1452 f.name, props_root, f.name
1453 ));
1454 }
1455 for key in interp_keys {
1456 // A Props field already answers for this key above; a second arm with the
1457 // same literal would be an unreachable pattern, not a fallback.
1458 if props.fields.iter().any(|f| &f.name == key) {
1459 continue;
1460 }
1461 let method = resolve_getter_call(methods, fields, key);
1462 match_arms.push_str(&format!(
1463 " \"{}\" => state.{}.to_string(),\n",
1464 key, method
1465 ));
1466 }
1467 // `props` is owned here and has been moved into `state.props` when the child's
1468 // `State` has a `props` field, so the arms read it back off the state. A
1469 // component that declares no `Props` struct gets the empty stand-in, which
1470 // nothing can read, so it is dropped by name.
1471 let assign_props = if props_root == "state.props" {
1472 " state.props = props;\n"
1473 } else {
1474 " let _ = props;\n"
1475 };
1476 let state_binding = if has_script {
1477 if props_root == "state.props" {
1478 " let mut state = script_rs::State::new();\n"
1479 } else {
1480 " let state = script_rs::State::new();\n"
1481 }
1482 } else {
1483 ""
1484 };
1485 let body = emit_node_with_mode(root, TransformMode::Props, fields, *props, scope_id);
1486 format!(
1487 r#"pub fn render_with_props(props: PropsArg) -> velox_dom::VNode {{
1488 use velox_dom::*;
1489{state_binding}{assign_props} let resolve = |key: &str| -> String {{
1490 match key {{
1491{match_arms} _ => String::new(),
1492 }}
1493 }};
1494 {body}
1495}}"#,
1496 )
1497}
1498
1499/// Minimal module emitted when a component has no template.
1500fn empty_component_module() -> String {
1501 r#"pub fn render() -> velox_dom::VNode {
1502 use velox_dom::*;
1503 text("")
1504}
1505
1506pub fn render_with<F>(mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {
1507 use velox_dom::*;
1508 text("")
1509}
1510
1511#[allow(clippy::arc_with_non_send_sync)]
1512pub fn render_with_state<F>(state: std::sync::Arc<script_rs::State>, mut resolve: F) -> velox_dom::VNode where F: FnMut(&str) -> String {
1513 use velox_dom::*;
1514 text("")
1515}
1516
1517#[allow(clippy::arc_with_non_send_sync)]
1518pub fn make_resolve(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str) -> String {
1519 move |_name: &str| String::new()
1520}
1521
1522pub fn make_on_event(state: std::sync::Arc<script_rs::State>) -> impl FnMut(&str, Option<&str>) + 'static {
1523 move |_name: &str, _payload: Option<&str>| {}
1524}
1525
1526pub fn render_with_props(props: PropsArg) -> velox_dom::VNode {
1527 let _ = props;
1528 render_with(|_key: &str| String::new())
1529}"#
1530 .to_string()
1531}
1532
1533/// The KEY a child looks up for the slot outlet its attrs describe — the
1534/// `name` attribute of a `<slot>`, folded the way the parent folded the name it
1535/// stored.
1536///
1537/// # Why the fold is not optional
1538///
1539/// A slot name is written twice, on opposite sides of a component boundary, and
1540/// the two spellings have to meet. The parent half is a directive, so the parser
1541/// folds it: `v-slot:footerBar` arrives as `slot:footer-bar` and `#footerBar` as
1542/// the same string, and `slots_map_expr` uses that as the map key. The child half
1543/// is a plain static attribute, so nothing folded it — the lookup used the raw
1544/// `footerBar`, the map held `footer-bar`, and `render_slot` found nothing.
1545///
1546/// The symptom is the worst kind: the outlet rendered its FALLBACK, so the page
1547/// looked like a component that had chosen to ignore its caller, with no
1548/// diagnostic anywhere. Folding the child's name through the same function the
1549/// parent's went through is what makes `v-slot:footerBar` and `name="footerBar"`
1550/// the same slot, and `name="footer-bar"` the same slot too — so a project that
1551/// kebab-cases on one side and camel-cases on the other still works.
1552///
1553/// # An unmatched name is not an error
1554///
1555/// The fallback is the feature: a `<slot name="footer">` on a component that is
1556/// sometimes used without a footer is supposed to render its own children. There
1557/// is no way to tell "no caller passed this" from "the caller passed a different
1558/// name" at this level — the map is built by the CALLER, in a different file, and
1559/// an empty result is equally correct for both — so this stays a lookup with a
1560/// fallback. A mismatch is a naming mistake, not a broken component.
1561fn normalize_slot_name(name: &str) -> String {
1562 crate::template_parse::normalize_directive_name(name.trim())
1563}
1564
1565/// The `name` a `<slot>` element looks itself up under, from its static `name`
1566/// attribute; `"default"` when it names none.
1567fn slot_outlet_name(attrs: &[TemplateAttr]) -> String {
1568 attrs
1569 .iter()
1570 .find(|a| a.name == "name" && matches!(a.kind, AttrKind::Static))
1571 .and_then(|a| a.value.clone())
1572 .map(|raw| normalize_slot_name(&raw))
1573 .unwrap_or_else(|| "default".to_string())
1574}
1575
1576/// Emit code for a `<slot>` element.
1577///
1578/// `<slot>` elements appear inside component templates and act as outlets for
1579/// slot content passed from the parent component. The slot's name is determined
1580/// by the `name` attribute (defaulting to "default"), folded by
1581/// [`slot_outlet_name`] so it meets the key the parent stored.
1582///
1583/// Slot content is passed from the parent as a `HashMap<&str, VNode>` under
1584/// the key "slot:{name}". At render time, `render_slot` looks up the slot name
1585/// and returns the slot VNode, falling back to the slot's own children
1586/// (fallback content) if no slot was provided.
1587fn emit_slot_node(
1588 attrs: &[TemplateAttr],
1589 children: &[Node],
1590 mode: TransformMode,
1591 fields: &[String],
1592 props: PropsChannel<'_>,
1593 scope_id: Option<&str>,
1594) -> String {
1595 let slot_name = slot_outlet_name(attrs);
1596
1597 // The fallback is the slot's own children, and `emit_children_with_mode`
1598 // returns a BLOCK that evaluates to a `Vec<VNode>`. `render_slot` takes
1599 // `FnOnce() -> VNode`, so the children are handed to `h("slot", …)` exactly
1600 // as `slots_map_expr` hands a multi-node entry to it — the fallback has to
1601 // be ONE node, and the wrapper is what makes several children into one. The
1602 // earlier `|| { { …__children } }` passed the `Vec` straight through and did
1603 // not compile (E0308, expected `VNode`, found `Vec<VNode>`) for every slot
1604 // that had fallback content at all.
1605 let fallback = emit_children_with_mode(children, mode, fields, props, scope_id);
1606
1607 format!(
1608 r#"render_slot({slot_name_lit}, || velox_dom::h("slot", velox_dom::Props::new(), {fallback}))"#,
1609 slot_name_lit = string_lit(&slot_name),
1610 fallback = fallback,
1611 )
1612}
1613
1614pub(crate) fn emit_node(n: &Node) -> String {
1615 match n {
1616 Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
1617 Node::Interpolation(expr) => {
1618 let key = string_lit(expr.trim());
1619 format!(r#"text(resolve({}))"#, key)
1620 }
1621 Node::Element {
1622 tag,
1623 attrs,
1624 children,
1625 ..
1626 } => {
1627 let elem_props = emit_props(attrs);
1628 let kids = emit_children(children);
1629 format!(r#"h("{}", {elem_props}, {kids})"#, tag)
1630 }
1631 }
1632}
1633
1634#[allow(dead_code)]
1635pub(crate) fn emit_props(attrs: &[TemplateAttr]) -> String {
1636 if attrs.is_empty() {
1637 return "Props::new()".to_string();
1638 }
1639 let mut parts = vec!["Props::new()".to_string()];
1640 for a in attrs {
1641 match a.kind {
1642 AttrKind::Static => {
1643 let v = a.value.clone().unwrap_or_default();
1644 parts.push(format!(r#".set("{}", {})"#, a.name, string_lit(&v)));
1645 }
1646 AttrKind::Bind => {
1647 let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
1648 parts.push(format!(
1649 r#".set("{}", &format!("{{}}", {}))"#,
1650 a.name,
1651 expr.trim()
1652 ));
1653 }
1654 AttrKind::Directive => {
1655 // directives are not emitted as props
1656 }
1657 AttrKind::On => {
1658 // Store as a string for now; renderer will wire this later
1659 let handler = a.value.clone().unwrap_or_default();
1660 parts.push(format!(
1661 r#".set("on:{}", {})"#,
1662 a.name,
1663 string_lit(&handler)
1664 ));
1665 }
1666 }
1667 }
1668 parts.join("")
1669}
1670
1671#[allow(dead_code)]
1672pub(crate) fn emit_children(children: &[Node]) -> String {
1673 if children.is_empty() {
1674 return "vec![]".to_string();
1675 }
1676 let items: Vec<String> = children.iter().map(emit_node).collect();
1677 format!("vec![{}]", items.join(", "))
1678}
1679
1680/// Is this character part of a comparison operator? `==`, `!=`, `>=` and `<=`
1681/// all END in one of these, so the last character of what has been emitted is
1682/// enough to tell whether a token sits next to one.
1683fn is_comparison_char(ch: char) -> bool {
1684 matches!(ch, '=' | '!' | '<' | '>')
1685}
1686
1687/// The end of the member path starting at `start`, where `chars[start]` is the
1688/// first character of an identifier the caller has already accepted.
1689///
1690/// `user.name` is ONE key, not two identifiers, and `items[0].name` is one too:
1691/// the arm that answers such a key is the method chain `state.user().name()`, so
1692/// splitting the path would ask the resolver for `user` and `name` — keys no
1693/// getter is named after — and leave a field access on a `String` in the middle
1694/// of the emitted expression. [`condition_resolver_keys`] collects the same
1695/// tokens through this same function, which is what keeps the registered keys
1696/// and the emitted lookups one set; `condition_resolver_keys_match_rewrite_if_expr_lookups`
1697/// pins their agreement.
1698///
1699/// A segment is `.` followed by an identifier, or an index `[…]` copied verbatim
1700/// (brackets included, so [`member_path`] splits it back the same way). Anything
1701/// else ends the path, leaving the caller looking at the character that stopped
1702/// it.
1703fn member_path_end(chars: &[char], start: usize) -> usize {
1704 let mut i = start;
1705 // The advance past the entry character is UNCONDITIONAL — see the contract on
1706 // `is_ident_char`, and the note in `condition_resolver_keys` that this is why
1707 // no scanner here can spin.
1708 i += 1;
1709 while i < chars.len() && is_ident_char(chars[i]) {
1710 i += 1;
1711 }
1712 loop {
1713 match chars.get(i) {
1714 Some('.') => match chars.get(i + 1) {
1715 Some(c) if c.is_ascii_alphabetic() || *c == '_' => {
1716 i += 2;
1717 while i < chars.len() && is_ident_char(chars[i]) {
1718 i += 1;
1719 }
1720 }
1721 _ => return i,
1722 },
1723 Some('[') => {
1724 let mut j = i + 1;
1725 while j < chars.len() && chars[j] != ']' {
1726 j += 1;
1727 }
1728 if j >= chars.len() {
1729 return i;
1730 }
1731 i = j + 1;
1732 }
1733 _ => return i,
1734 }
1735 }
1736}
1737
1738pub(crate) fn rewrite_if_expr(expr: &str) -> String {
1739 let has_cmp = expr.contains("==")
1740 || expr.contains("!=")
1741 || expr.contains(">=")
1742 || expr.contains("<=")
1743 || expr.contains('>')
1744 || expr.contains('<');
1745 let has_negation = expr.contains('!');
1746
1747 let chars: Vec<char> = expr.chars().collect();
1748 let mut out = String::new();
1749 let mut i = 0;
1750
1751 /// Emit one looked-up token. A token in `if` position is a CONDITION, so it
1752 /// gets the string truthiness [`resolver_truthiness`] defines; a token next
1753 /// to a comparison operator is an OPERAND of that comparison, which is a
1754 /// number in every expression this has ever accepted.
1755 fn flush_token(out: &mut String, token: &str, numeric: bool) {
1756 if token.is_empty() {
1757 return;
1758 }
1759 // Literals and emitted code are values already, not lookups.
1760 if matches!(token, "true" | "false" | "resolve" | "state") {
1761 out.push_str(token);
1762 } else if numeric {
1763 out.push_str(&format!(
1764 "resolve({}).parse::<f64>().unwrap_or(0.0)",
1765 string_lit(token)
1766 ));
1767 } else {
1768 out.push_str(&resolver_truthiness(&format!(
1769 "resolve({})",
1770 string_lit(token)
1771 )));
1772 }
1773 }
1774
1775 while i < chars.len() {
1776 let ch = chars[i];
1777 if ch.is_ascii_alphabetic() || ch == '_' {
1778 let end = member_path_end(&chars, i);
1779 let token: String = chars[i..end].iter().collect();
1780 i = end;
1781 // Whitespace after the token is held back, not dropped, so a token
1782 // followed by a comparison operator keeps the spacing it was
1783 // authored with.
1784 let mut pending = String::new();
1785 let mut j = i;
1786 let mut next_is_cmp = false;
1787 while j < chars.len() {
1788 if chars[j].is_whitespace() {
1789 pending.push(chars[j]);
1790 j += 1;
1791 } else if is_comparison_char(chars[j]) {
1792 next_is_cmp = true;
1793 break;
1794 } else {
1795 break;
1796 }
1797 }
1798 // The last character emitted is what decides what came before the
1799 // token: `&`/`|`/`(`/`)`, a digit or `}` are all "not a comparison".
1800 let prev_is_cmp = out
1801 .trim_end()
1802 .chars()
1803 .last()
1804 .is_some_and(is_comparison_char);
1805 flush_token(&mut out, &token, has_cmp && (prev_is_cmp || next_is_cmp));
1806 out.push_str(&pending);
1807 } else if has_cmp && ch.is_ascii_digit() {
1808 let mut num = String::new();
1809 num.push(ch);
1810 i += 1;
1811 while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
1812 num.push(chars[i]);
1813 i += 1;
1814 }
1815 if !num.contains('.') {
1816 num.push_str(".0");
1817 }
1818 out.push_str(&num);
1819 } else if ch == '!' && has_negation {
1820 // `!` operator: if followed by an identifier, negate the truthy
1821 // check, e.g. `!is_positive` -> !(resolve("is_positive") == "true"
1822 // || ...). The identifier after `!` is consumed here because the
1823 // closing paren has to follow it and the ident branch cannot know
1824 // that.
1825 out.push_str("!(");
1826 i += 1;
1827 // A negated condition is a PATH, not a bare name: `!user.name` reads
1828 // `user.name` as one value, and scanning only the identifier would
1829 // leave the `.` and the second segment to the main loop, which turns
1830 // them into a field access on a `String`. The entry test is the one
1831 // the main scanner uses, so `!=` — which also contains `!` — is left
1832 // for it.
1833 let mut neg_ident = String::new();
1834 if chars
1835 .get(i)
1836 .is_some_and(|c| c.is_ascii_alphabetic() || *c == '_')
1837 {
1838 let end = member_path_end(&chars, i);
1839 neg_ident = chars[i..end].iter().collect();
1840 i = end;
1841 }
1842 if neg_ident.is_empty() {
1843 // standalone !, just emit it
1844 out.pop(); // remove the "(" we just pushed
1845 out.push('!');
1846 } else if neg_ident == "true" || neg_ident == "false" {
1847 out.push_str(&neg_ident);
1848 out.push(')');
1849 } else {
1850 // A negated condition sits in `if` position, never in
1851 // comparison-operand position, so it gets the same truthiness
1852 // as a bare one.
1853 out.push_str(&resolver_truthiness(&format!(
1854 "resolve({})",
1855 string_lit(&neg_ident)
1856 )));
1857 out.push(')');
1858 }
1859 } else {
1860 out.push(ch);
1861 i += 1;
1862 }
1863 }
1864 out
1865}
1866
1867/// Extract v-model directive from attrs and convert to :value + @input.
1868/// Returns (remaining_attrs, optional_vmodel_handler_name).
1869/// The handler name follows the convention `__vmodel_set_{field}` so that
1870/// `make_on_event` can dispatch to it and the codegen can emit the setter.
1871/// The `v-for` binding an element declares, or `None`.
1872///
1873/// The scope a `v-model` expression is resolved against: the two loop variables
1874/// in scope when the element is inside a `v-for` body.
1875fn v_for_scope(attrs: &[TemplateAttr]) -> Option<VForInfo> {
1876 attrs
1877 .iter()
1878 .find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
1879 .and_then(|a| a.value.as_deref())
1880 .and_then(parse_v_for)
1881}
1882
1883/// Whether a `v-model` expression is rooted at a `v-for` loop variable.
1884///
1885/// A loop-rooted `v-model` is a read the loop item can answer — `todo.text` is a
1886/// real field read in State mode — but its write has nowhere to go. This is
1887/// READ-ONLY BY DESIGN, and the two structural facts below are why. Neither is
1888/// a gap a smarter template could close:
1889///
1890/// 1. `Signal::get` returns `T` BY VALUE (`velox-core/src/signal.rs`), so the
1891/// loop body iterates a DETACHED CLONE. `item` is a value, not a handle, and
1892/// there is nothing to write through.
1893/// 2. The event dispatcher is `FnMut(&str, Option<&str>)` — a static name plus an
1894/// optional payload, with no index — so a write to "the third item" cannot be
1895/// named, let alone routed.
1896///
1897/// Lifting this would take handle semantics for `Signal`, which is a redesign of
1898/// the reactivity core and out of scope for this programme. Do not read this as a
1899/// small codegen gap to close later; a future attempt should start at
1900/// `velox-core/src/signal.rs`.
1901///
1902/// `extract_vmodel` therefore emits the read and no handler, and
1903/// `collect_resolver_keys` reports the missing write ([`vmodel_loop_write_warning`])
1904/// instead of emitting an arm that cannot compile. The predicate is
1905/// [`expr_reads_root`] over the loop variables in scope — the same test the read
1906/// path uses to decide it can answer the expression.
1907fn vmodel_is_loop_rooted(expr: &str, item_name: Option<&str>, idx_name: Option<&str>) -> bool {
1908 let roots: Vec<&str> = [item_name, idx_name].into_iter().flatten().collect();
1909 !roots.is_empty() && expr_reads_roots(expr, &roots)
1910}
1911
1912/// The generated setter name for a `v-model` expression.
1913///
1914/// ONE spelling, produced by every side of the write path so the dispatcher arm
1915/// and the `State` method it calls can never disagree: the template emitter
1916/// (`extract_vmodel`) and the handler collector (`collect_handlers`) both build
1917/// it here, and the setter generator is fed by [`collect_vmodel_expressions`],
1918/// which uses the same rule. A `.` becomes `_` because the name has to be a Rust
1919/// method name, so `v-model="form.name"` maps to `__vmodel_set_form_name` and the
1920/// setter it pairs with writes `self.form.name`.
1921fn vmodel_setter_name(expr: &str) -> String {
1922 format!("__vmodel_set_{}", expr.replace('.', "_"))
1923}
1924
1925/// Desugar `v-model="expr"` into a `:value` bind plus an `@input` handler, and
1926/// report the expression.
1927///
1928/// `item_name`/`idx_name` are the `v-for` loop variables in scope. A loop-rooted
1929/// expression gets the value bind only: its read is a direct field read of the
1930/// loop item — the shape State mode already emits for a loop-rooted binding — and
1931/// there is no handler to name, because a write to a loop item cannot be routed
1932/// through the `State`-rooted dispatcher. This is deliberate, not a missing arm:
1933/// a loop item is read-only by design, and [`vmodel_is_loop_rooted`] records the
1934/// two structural facts (value-semantics `Signal::get`, an index-less dispatcher)
1935/// that make it so, plus what lifting it would cost.
1936/// `collect_resolver_keys` reports the case as
1937/// [`vmodel_loop_write_warning`].
1938fn extract_vmodel(
1939 attrs: &[TemplateAttr],
1940 item_name: Option<&str>,
1941 idx_name: Option<&str>,
1942) -> (Vec<TemplateAttr>, Option<String>) {
1943 let vmodel_pos = attrs
1944 .iter()
1945 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "model");
1946
1947 if let Some(pos) = vmodel_pos {
1948 let vmodel_attr = &attrs[pos];
1949 let model_expr = vmodel_attr.value.clone().unwrap_or_default();
1950
1951 // Build remaining attrs without v-model
1952 let mut remaining: Vec<TemplateAttr> = attrs.to_vec();
1953 remaining.remove(pos);
1954
1955 // Add :value binding for v-model
1956 remaining.push(TemplateAttr {
1957 name: "value".to_string(),
1958 value: Some(model_expr.clone()),
1959 kind: AttrKind::Bind,
1960 });
1961
1962 // Generate @input handler that calls state.__vmodel_set_{field}(payload).
1963 // A loop-rooted expression has no such setter: its write target is the
1964 // loop item, which the dispatcher cannot reach, so the input is emitted
1965 // as a read-only value and the missing write is reported instead.
1966 if !vmodel_is_loop_rooted(&model_expr, item_name, idx_name) {
1967 remaining.push(TemplateAttr {
1968 name: "input".to_string(),
1969 value: Some(vmodel_setter_name(&model_expr)),
1970 kind: AttrKind::On,
1971 });
1972 }
1973
1974 (remaining, Some(model_expr))
1975 } else {
1976 (attrs.to_vec(), None)
1977 }
1978}
1979
1980fn emit_node_with_mode(
1981 n: &Node,
1982 mode: TransformMode,
1983 fields: &[String],
1984 props: PropsChannel<'_>,
1985 scope_id: Option<&str>,
1986) -> String {
1987 match n {
1988 Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
1989 Node::Interpolation(expr) => {
1990 let key = string_lit(expr.trim());
1991 format!(r#"text(resolve({}))"#, key)
1992 }
1993 Node::Element {
1994 tag,
1995 attrs,
1996 children,
1997 ..
1998 } => {
1999 // Handle <slot> elements: render slot content passed as props from parent.
2000 // Slot content is stored as a serialized VNode tree in props under
2001 // "slot:{name}" (or "slot:default" for unnamed slots).
2002 if tag == "slot" {
2003 return emit_slot_node(attrs, children, mode, fields, props, scope_id);
2004 }
2005
2006 // Handle v-model directive: convert to :value + @input
2007 let (attrs2, _v_model_handler) = extract_vmodel(attrs, None, None);
2008 let attrs = &attrs2;
2009
2010 // handle directive `v-if`
2011 if let Some(pos) = attrs
2012 .iter()
2013 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if")
2014 {
2015 let mut attrs2 = attrs.clone();
2016 let dir = attrs2.remove(pos);
2017 let expr = rewrite_if_expr(&dir.value.unwrap_or_default());
2018 let tmp = Node::Element {
2019 tag: tag.clone(),
2020 attrs: attrs2,
2021 children: children.clone(),
2022 self_closing: false,
2023 };
2024 let inner = emit_node_with_mode(&tmp, mode, fields, props, scope_id);
2025 return format!(r#"if {} {{ {} }} else {{ text("") }}"#, expr.trim(), inner);
2026 }
2027
2028 // handle directive `v-show`
2029 // v-show always renders the element but toggles CSS `display: none`
2030 // based on the expression, preserving it in the DOM (unlike v-if).
2031 if let Some(pos) = attrs
2032 .iter()
2033 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "show")
2034 {
2035 let mut attrs2 = attrs.clone();
2036 let dir = attrs2.remove(pos);
2037 let expr = rewrite_if_expr(&dir.value.unwrap_or_default());
2038
2039 // Build the element without the v-show directive, then conditionally
2040 // set display style based on the expression value.
2041 let inner = emit_node_with_mode(
2042 &Node::Element {
2043 tag: tag.clone(),
2044 attrs: attrs2,
2045 children: children.clone(),
2046 self_closing: false,
2047 },
2048 mode,
2049 fields,
2050 props,
2051 scope_id,
2052 );
2053
2054 // If the element already has a `style` attribute, we merge the
2055 // display value. Otherwise, we add a style attribute.
2056 let has_style = attrs.iter().any(|a| {
2057 a.name == "style" && matches!(a.kind, AttrKind::Static | AttrKind::Bind)
2058 });
2059
2060 if has_style {
2061 // The style attr is already emitted by emit_node_with_mode.
2062 // We wrap the result: if expression is false, override display.
2063 return format!(
2064 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 }}"#,
2065 inner = inner,
2066 expr = expr.trim()
2067 );
2068 } else {
2069 return format!(
2070 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 }}"#,
2071 inner = inner,
2072 expr = expr.trim()
2073 );
2074 }
2075 }
2076
2077 // Check if this is a component (has data-velox-component marker).
2078 // A component with v-for falls through to the v-for branch below so
2079 // it iterates instead of rendering once.
2080 let has_v_for = attrs
2081 .iter()
2082 .any(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for");
2083 if !has_v_for
2084 && let Some(component_attr) = attrs
2085 .iter()
2086 .find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
2087 && let Some(comp_name) = &component_attr.value
2088 {
2089 let mut clean_attrs = attrs.clone();
2090 clean_attrs.retain(|a| a.name != "data-velox-component");
2091
2092 // Separate slot children from props/events. Every child is slot
2093 // content; the slot it belongs to is the one it names
2094 // (`v-slot:footer`, `#footer`), or the default slot when it names
2095 // none. `slots_map_expr` does the grouping — see its doc comment
2096 // for why the content it emits carries this caller's scope id.
2097 let has_slot_children = !children.is_empty();
2098
2099 // Persistent child state: when the parent State declares a field
2100 // matching the lowercased component tag and the instance is
2101 // prop-less, render with the child's persistent State so it can
2102 // own mutable state across renders (state survives because it is
2103 // created once in the parent's State::new()).
2104 let field = comp_name.to_lowercase();
2105 let has_props_events = clean_attrs
2106 .iter()
2107 .any(|a| matches!(a.kind, AttrKind::Bind | AttrKind::On));
2108 if mode == TransformMode::State
2109 && !has_props_events
2110 && !has_slot_children
2111 && fields.contains(&field)
2112 {
2113 return format!(
2114 r#"{comp_name}::render_with_state(std::sync::Arc::clone(&state.{field}), {comp_name}::make_resolve(std::sync::Arc::clone(&state.{field})))"#,
2115 );
2116 }
2117
2118 let (_has_props, props_expr) =
2119 generate_component_props_expr(&clean_attrs, comp_name, &props);
2120 let callbacks = collect_component_callbacks(&clean_attrs);
2121
2122 // Make every handler this tag binds callable by the child, before
2123 // any of the render entry points below. State mode only — see
2124 // `emit_dispatch_registrations`.
2125 let dispatch = if mode == TransformMode::State {
2126 emit_dispatch_registrations(
2127 comp_name,
2128 &component_dispatch_handlers(&clean_attrs, comp_name, &props),
2129 )
2130 } else {
2131 String::new()
2132 };
2133
2134 if callbacks.is_empty() && !has_slot_children {
2135 return format!(
2136 r#"{{ {dispatch}let __props = {props_expr}; {comp_name}::render_with_props(__props) }}"#,
2137 );
2138 }
2139
2140 // One map entry per slot the children name; see `slots_map_expr`.
2141 let slots_expr = if has_slot_children {
2142 slots_map_expr(children, &|c| {
2143 emit_node_with_mode(c, mode, fields, props, scope_id)
2144 })
2145 } else {
2146 "std::collections::HashMap::new()".to_string()
2147 };
2148
2149 // No slot children but has callbacks: use render_with_callbacks
2150 if !has_slot_children {
2151 let callback_map = format_callback_map(&callbacks);
2152 let callback_names: Vec<String> = callbacks
2153 .iter()
2154 .map(|(_, handler)| handler.clone())
2155 .collect();
2156
2157 return format!(
2158 r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; {comp_name}::render_with_callbacks(__props, &__callbacks, &[{callback_names}]) }}"#,
2159 callback_names = callback_names
2160 .iter()
2161 .map(|n| format!("\"{}\"", n))
2162 .collect::<Vec<_>>()
2163 .join(", ")
2164 );
2165 }
2166
2167 // Has both callbacks and slot children: use render_with_slots
2168 let callback_map = format_callback_map(&callbacks);
2169 let callback_names: Vec<String> = callbacks
2170 .iter()
2171 .map(|(_, handler)| handler.clone())
2172 .collect();
2173
2174 return format!(
2175 r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; let __slots = {slots_expr}; {comp_name}::render_with_slots(__props, &__callbacks, &[{callback_names}], &__slots) }}"#,
2176 callback_names = callback_names
2177 .iter()
2178 .map(|n| format!("\"{}\"", n))
2179 .collect::<Vec<_>>()
2180 .join(", ")
2181 );
2182 }
2183
2184 // handle v-for directive
2185 if let Some(pos_for) = attrs
2186 .iter()
2187 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
2188 {
2189 let v_if_pos = attrs
2190 .iter()
2191 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if");
2192
2193 let mut attrs_f = attrs.clone();
2194 let dir = attrs_f.remove(pos_for);
2195 let val = dir.value.unwrap_or_default();
2196
2197 let key_attr_pos = attrs_f
2198 .iter()
2199 .position(|a| a.kind == AttrKind::Bind && a.name == "key");
2200 let key_expr: Option<Option<String>> = if let Some(kp) = key_attr_pos {
2201 let key_val = attrs_f[kp].value.clone();
2202 attrs_f.remove(kp);
2203 Some(key_val)
2204 } else {
2205 None
2206 };
2207
2208 if let Some(for_info) = parse_v_for(&val) {
2209 let tmp_elem = Node::Element {
2210 tag: tag.clone(),
2211 attrs: attrs_f.clone(),
2212 children: children.clone(),
2213 self_closing: false,
2214 };
2215
2216 let mut loop_code = String::new();
2217 loop_code
2218 .push_str("{ let mut __children: Vec<velox_dom::VNode> = Vec::new();\n");
2219
2220 // Where the collection comes from, and therefore what the loop
2221 // item is.
2222 //
2223 // `State` reads a State field. `PropField` reads a declared
2224 // `Props` field off the typed struct, so the item is a real
2225 // `&T` and the body reads it as one. `Counted` is the
2226 // fallback: a comma-counted string the resolver handed over,
2227 // which has no item to bind, so the body keeps asking the
2228 // resolver for a runtime-formatted key — the behaviour that
2229 // makes an unanswerable collection render nothing at all.
2230 let prop_field =
2231 mode == TransformMode::Props && props.declares_vec_field(&for_info.expr);
2232 let counted = !prop_field && mode != TransformMode::State;
2233
2234 if counted {
2235 // String-based iteration via resolve()
2236 loop_code.push_str(&format!(
2237 "let __for_expr = resolve(\"{}\");\n",
2238 for_info.expr
2239 ));
2240 loop_code.push_str(
2241 "let __for_count = if let Ok(n) = __for_expr.parse::<usize>() {\n",
2242 );
2243 loop_code.push_str(" n\n");
2244 loop_code.push_str("} else if __for_expr.is_empty() {\n");
2245 loop_code.push_str(" 0\n");
2246 loop_code.push_str("} else {\n");
2247 loop_code.push_str(" __for_expr.split(',').count()\n");
2248 loop_code.push_str("};\n");
2249 loop_code.push_str(&format!(
2250 "for {idx_var} in 0..__for_count {{\n",
2251 idx_var = for_info.index_name
2252 ));
2253 } else if prop_field {
2254 // `props.root` is `state.props` when the child's `State`
2255 // has a `props` field, because `render_with_props` moved
2256 // the value into that State before the body runs. The
2257 // borrow is what makes `item` a `&T` instead of a
2258 // `String` the resolver formatted. Only the props body
2259 // can read it: the other two bodies have no `props`
2260 // argument at all, which is why the gate above admits
2261 // this form in `TransformMode::Props` and nowhere else.
2262 loop_code
2263 .push_str(&format!("let __col = &{}.{};\n", props.root, for_info.expr));
2264 loop_code.push_str("if !__col.is_empty() {\n");
2265 loop_code.push_str(&format!(
2266 " for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
2267 idx_var = for_info.index_name,
2268 item_var = for_info.item_name
2269 ));
2270 } else {
2271 // Collection-based iteration via state.{expr}.get()
2272 loop_code
2273 .push_str(&format!("let __col = state.{}.get();\n", for_info.expr));
2274 loop_code.push_str("if !__col.is_empty() {\n");
2275 loop_code.push_str(&format!(
2276 " for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
2277 idx_var = for_info.index_name,
2278 item_var = for_info.item_name
2279 ));
2280 }
2281
2282 let inner = if counted {
2283 emit_node_with_ctx_for_loop(&tmp_elem, &for_info, scope_id)
2284 } else {
2285 emit_node_with_ctx_state(
2286 &tmp_elem,
2287 Some(&for_info.item_name),
2288 Some(&for_info.index_name),
2289 mode,
2290 props,
2291 scope_id,
2292 )
2293 };
2294
2295 let inner_with_key = if let Some(Some(key_val)) = &key_expr {
2296 if counted {
2297 format!(
2298 "{{ let mut __node = {}; if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert(\"key\".to_string(), {}); }} __node }}",
2299 inner,
2300 resolve_mode_key_value(key_val, &for_info)
2301 )
2302 } else {
2303 let key_path_str = key_val
2304 .strip_prefix(&for_info.item_name)
2305 .map(|s| s.trim_start_matches('.'))
2306 .unwrap_or("id");
2307 format!(
2308 "{{ 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 }}",
2309 inner,
2310 item_var = for_info.item_name,
2311 key_path = key_path_str
2312 )
2313 }
2314 } else {
2315 inner
2316 };
2317
2318 if let Some(if_pos) = v_if_pos {
2319 let dir_if = &attrs[if_pos];
2320 let expr_if = rewrite_if_expr(&dir_if.value.clone().unwrap_or_default());
2321 loop_code.push_str(&format!(
2322 " if {} {{\n __children.push({});\n }}\n",
2323 expr_if.trim(),
2324 inner_with_key
2325 ));
2326 } else {
2327 loop_code.push_str(&format!(" __children.push({});\n", inner_with_key));
2328 }
2329
2330 if !counted {
2331 loop_code.push_str(" }\n");
2332 loop_code.push_str("}\n");
2333 }
2334
2335 loop_code.push_str("__children; }");
2336 return loop_code;
2337 }
2338 }
2339
2340 let elem_props = emit_props_with(attrs);
2341 let kids = emit_children_with_mode(children, mode, fields, props, scope_id);
2342 format!(
2343 r#"h("{}", {}, {kids})"#,
2344 tag,
2345 append_scope_attr(&elem_props, scope_id)
2346 )
2347 }
2348 }
2349}
2350
2351fn emit_node_with(
2352 n: &Node,
2353 fields: &[String],
2354 props: &PropsChannel<'_>,
2355 scope_id: Option<&str>,
2356) -> String {
2357 emit_node_with_mode(n, TransformMode::Resolve, fields, *props, scope_id)
2358}
2359
2360/// Generate code that builds a `HashMap<&str, String>` of component props
2361/// from `:attr` (Bind) and `@event` (On) attributes.
2362/// Returns a tuple of (has_props, props_expr) where has_props indicates if
2363/// any bind/on attrs were found, and props_expr is the generated code.
2364fn generate_component_props_expr(
2365 clean_attrs: &[TemplateAttr],
2366 comp_name: &str,
2367 props: &PropsChannel<'_>,
2368) -> (bool, String) {
2369 component_props_arg(clean_attrs, comp_name, props, None, None)
2370}
2371
2372/// Build the `PropsArg` a parent hands to `comp_name`.
2373///
2374/// A struct literal, not a `HashMap`: the child's `Props` fields are named and
2375/// typed on both sides, so `rustc` checks this call. A field the child never
2376/// declared is a compile error, and a field the child declared but the parent
2377/// left out is a compile error naming the field — the silent default that
2378/// produced the props render whose value no script could read is not available
2379/// here. `@event` attributes are not fields: the handler names travel through
2380/// the separate callbacks map that `render_with_callbacks` registers, and the
2381/// `on:*` props keys they used to occupy were read by nothing.
2382fn component_props_arg(
2383 clean_attrs: &[TemplateAttr],
2384 comp_name: &str,
2385 props: &PropsChannel<'_>,
2386 item_name: Option<&str>,
2387 idx_name: Option<&str>,
2388) -> (bool, String) {
2389 match props.child_declares_props(comp_name) {
2390 // A child that declares no `Props` struct has no named fields to bind,
2391 // so the only literal that names anything is one it does not have. What
2392 // it DOES have is a `values` map (the struct `generate_props_arg` emits
2393 // on that branch), and a struct literal is exhaustive: leaving `values`
2394 // out is E0063. An empty map is the honest reading of "this parent
2395 // binds nothing" and still leaves the child able to accept a binding —
2396 // the field is the boundary, not an accident.
2397 Some(false) => {
2398 return (
2399 false,
2400 format!("{comp_name}::PropsArg {{ values: std::collections::HashMap::new() }}"),
2401 );
2402 }
2403 Some(true) => {}
2404 // A child this component cannot see has no declaration to check
2405 // against, so its attributes are passed as the name/value form every
2406 // resolver lookup produces. A real build cannot reach this: the child's
2407 // module has to exist for the parent to compile at all.
2408 None => {
2409 let mut pairs: Vec<String> = Vec::new();
2410 for a in clean_attrs {
2411 if let AttrKind::Bind = a.kind {
2412 let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
2413 let value = bind_attr_value(&a.name, &expr, item_name, idx_name);
2414 pairs.push(format!("({:?}, {value}.to_string())", a.name));
2415 }
2416 }
2417 return (
2418 !pairs.is_empty(),
2419 format!(
2420 "{comp_name}::PropsArg {{ values: std::collections::HashMap::from([{}]) }}",
2421 pairs.join(", ")
2422 ),
2423 );
2424 }
2425 }
2426 let mut fields: Vec<String> = Vec::new();
2427 let mut bound: Vec<String> = Vec::new();
2428 for a in clean_attrs {
2429 if let AttrKind::Bind = a.kind {
2430 let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
2431 let declared = props.child_field(comp_name, &a.name);
2432 let value = match declared {
2433 // A function-typed prop is handed over, not converted. Every
2434 // other type below is reached through the resolver and arrives
2435 // as a `String`, and `format!("{}", closure)` is a type error at
2436 // best — so the string path is never taken for one.
2437 Some(f) if is_function_prop_type(&f.ty) && !is_handler_name(&expr) => {
2438 format!(
2439 "{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\\\"\")",
2440 name = a.name
2441 )
2442 }
2443 // A callback the builder cannot construct. `function_prop_value`
2444 // emits one closure and one signature — `Fn(&str)`, no return
2445 // value — so a child declaring `Option<Box<dyn Fn(String) ->
2446 // bool>>` cannot be handed anything at all. Without this arm it
2447 // went down the conversion path below and came out as a quoted
2448 // string, which failed as a type error inside a props literal
2449 // the author never wrote. Refusing here names the prop, the type
2450 // they declared, and the signature that works.
2451 Some(f)
2452 if is_function_prop_type(&f.ty) && !is_buildable_function_prop_type(&f.ty) =>
2453 {
2454 let name = a.name.clone();
2455 let ty = f.ty.clone();
2456 format!(
2457 "{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)`\")"
2458 )
2459 }
2460 Some(f) if is_function_prop_type(&f.ty) => {
2461 // The name is prefixed HERE, as every other arm does, rather
2462 // than inside `function_prop_value` — the value builder is
2463 // about the closure's shape, not about struct-literal
2464 // punctuation, and an unprefixed value in this list is a
2465 // literal with a field name missing (E0063), which is what
2466 // the other arms guard against by construction.
2467 format!(
2468 "{}: {}",
2469 a.name,
2470 function_prop_value(comp_name, f, expr.trim())
2471 )
2472 }
2473 Some(f) => {
2474 let value = bind_attr_value(&a.name, &expr, item_name, idx_name);
2475 format!("{}: {}", a.name, props_field_value(f, &value))
2476 }
2477 // The child is not visible from here, so there is no declared
2478 // type to convert to. A `String` is what every resolver lookup
2479 // produces, and a child that wanted anything else will not
2480 // compile — which is the honest outcome, not a missing prop.
2481 None => format!(
2482 "{}: {}",
2483 a.name,
2484 bind_attr_value(&a.name, &expr, item_name, idx_name)
2485 ),
2486 };
2487 bound.push(a.name.clone());
2488 fields.push(value);
2489 }
2490 }
2491 // A field the child declares but the parent never bound is a hole in a
2492 // struct literal, and the generator cannot tell an intentional omission
2493 // from a mistake: `PropField` is `{ name, ty }` with no `optional` and no
2494 // `default` (`script_index::PropField`), and `generate_props_arg` has only
2495 // the empty / non-empty branches, so there is no third state to read. A
2496 // fill would have to guess, and a wrong guess is a prop that means
2497 // something else at run time with nothing to say so — which is how this
2498 // shipped once. So the literal refuses instead: `compile_error!` names the
2499 // component and the prop, where the bare E0063 that omitting the field
2500 // would leave behind points into `OUT_DIR/.../todos.rs` at code the author
2501 // never wrote.
2502 //
2503 // The `compile_error!` is the field's VALUE rather than a bare omission so
2504 // the literal stays exhaustive: rustc then reports exactly these errors,
2505 // one per prop, and no E0063 cascade on top.
2506 //
2507 // An `Option` prop is the one declaration that says what the omission
2508 // MEANS, so there is nothing to guess: `None` is the value the author asked
2509 // for by declaring it optional, and filling it is reading the type rather
2510 // than inventing a default. That is what makes a callback a prop at all —
2511 // `<Modal :on_cancel="close">` and `<Modal>` are both legal parents, and
2512 // without this the second could not compile.
2513 for declared in props.child_fields(comp_name) {
2514 if bound.contains(&declared.name) {
2515 continue;
2516 }
2517 if is_optional_type(&declared.ty) {
2518 fields.push(format!("{}: None", declared.name));
2519 continue;
2520 }
2521 fields.push(format!(
2522 "{}: 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\")",
2523 declared.name, declared.name
2524 ));
2525 }
2526 (
2527 true,
2528 format!("{comp_name}::PropsArg {{ {} }}", fields.join(", ")),
2529 )
2530}
2531
2532/// Convert a resolver-produced value to the type the child declared for it.
2533///
2534/// The boundary answer is always a `String`, so a child that declares `bool` or
2535/// a number gets a conversion rather than a type error. A type with no
2536/// conversion here is left as the `String` and does not compile: better a
2537/// compiler message naming the field and its type than a prop that silently
2538/// means something else at run time.
2539fn props_field_value(field: &PropField, value: &str) -> String {
2540 match field.ty.trim() {
2541 "String" | "str" | "&str" => value.to_string(),
2542 "bool" => format!("({value}) == \"true\""),
2543 ty if is_numeric_type(ty) => format!("({value}).parse().unwrap_or_default()"),
2544 _ => value.to_string(),
2545 }
2546}
2547
2548/// Is `ty` one of the primitive integer or float types `str::parse` can build?
2549fn is_numeric_type(ty: &str) -> bool {
2550 matches!(
2551 ty,
2552 "i8" | "i16"
2553 | "i32"
2554 | "i64"
2555 | "i128"
2556 | "isize"
2557 | "u8"
2558 | "u16"
2559 | "u32"
2560 | "u64"
2561 | "u128"
2562 | "usize"
2563 | "f32"
2564 | "f64"
2565 )
2566}
2567
2568/// If `expr` references a v-for loop variable, return the direct Rust expression
2569/// to read it (e.g. `todo` → `todo`, `todo.text` → `todo.text`, `idx` → `idx`).
2570/// Returns `None` when the expression is not a loop-variable reference (codegen
2571/// falls back to `resolve(...)`).
2572fn rewrite_ctx_expr(expr: &str, item_name: Option<&str>, idx_name: Option<&str>) -> Option<String> {
2573 let expr = expr.trim();
2574 if let Some(item) = item_name {
2575 if expr == item {
2576 return Some(item.to_string());
2577 }
2578 if expr.starts_with(item) && expr.len() > item.len() && expr.as_bytes()[item.len()] == b'.'
2579 {
2580 return Some(expr.to_string());
2581 }
2582 }
2583 if let Some(idx) = idx_name
2584 && expr == idx
2585 {
2586 return Some(idx.to_string());
2587 }
2588 None
2589}
2590
2591/// Like [`generate_component_props_expr`] but resolves bind values that reference
2592/// v-for loop variables directly instead of through `resolve()`.
2593fn generate_component_props_expr_with_ctx(
2594 clean_attrs: &[TemplateAttr],
2595 comp_name: &str,
2596 props: &PropsChannel<'_>,
2597 item_name: Option<&str>,
2598 idx_name: Option<&str>,
2599) -> (bool, String) {
2600 component_props_arg(clean_attrs, comp_name, props, item_name, idx_name)
2601}
2602
2603/// Like [`emit_props_with`] but resolves loop-variable bindings directly.
2604fn emit_props_with_ctx(
2605 attrs: &[TemplateAttr],
2606 item_name: Option<&str>,
2607 idx_name: Option<&str>,
2608) -> String {
2609 emit_props_in_loop(attrs, item_name, idx_name, None)
2610}
2611
2612/// The props chain for an element that is not inside a `v-for` body.
2613fn emit_props_with(attrs: &[TemplateAttr]) -> String {
2614 emit_props_in_loop(attrs, None, None, None)
2615}
2616
2617/// Build the props chain for an element.
2618///
2619/// `resolve_loop` is the `v-for` body the element sits in, and is set only when
2620/// the Resolve renderer is being generated: there the loop item is an indexed
2621/// resolver lookup, not a field read, so a loop-rooted `:bind` is normalized
2622/// accordingly. It is `None` for the State renderer, where the loop item is a
2623/// real binding and is read directly.
2624fn emit_props_in_loop(
2625 attrs: &[TemplateAttr],
2626 item_name: Option<&str>,
2627 idx_name: Option<&str>,
2628 resolve_loop: Option<&VForInfo>,
2629) -> String {
2630 if attrs.is_empty() {
2631 return "Props::new()".to_string();
2632 }
2633 let mut parts = vec!["Props::new()".to_string()];
2634 // Vue MERGES a static `class` with a dynamic `:class` on the same element:
2635 // the element's class list is both, in any order. `Props::set` replaces, so
2636 // emitting them as two entries would let whichever came last win — which is
2637 // order-dependent, and the opposite of what a browser does. One merged entry
2638 // is emitted instead, in the position of the first class-related attribute.
2639 //
2640 // Only an element that HAS both gets a merged entry. A static class alone, a
2641 // `:class` alone, and a static class on an element with no `:class` keep the
2642 // exact bytes they had, so this cannot move a golden that has no `:class`
2643 // next to a static one.
2644 let static_class = attrs
2645 .iter()
2646 .position(|a| matches!(a.kind, AttrKind::Static) && a.name == "class");
2647 let bound_class: Vec<usize> = attrs
2648 .iter()
2649 .enumerate()
2650 .filter(|(_, a)| matches!(a.kind, AttrKind::Bind) && a.name == "class")
2651 .map(|(i, _)| i)
2652 .collect();
2653 let merged_class = match (static_class, bound_class.first()) {
2654 (Some(first_static), Some(&first_bound)) => {
2655 let at = first_static.min(first_bound);
2656 let mut additions: Vec<String> =
2657 class_tokens(attrs[first_static].value.as_deref().unwrap_or_default())
2658 .iter()
2659 .map(|name| format!("__classes.push({});", string_lit(name)))
2660 .collect();
2661 for &i in &bound_class {
2662 let expr = attrs[i]
2663 .value
2664 .clone()
2665 .unwrap_or_else(|| attrs[i].name.clone());
2666 let emission = emit_bind_attr("class", &expr, item_name, idx_name, resolve_loop);
2667 match &emission.value {
2668 BindValue::ClassObject { conditions } => {
2669 additions.extend(conditions.iter().cloned());
2670 }
2671 // A `:class` that is not object syntax is a string of class
2672 // names — a resolver lookup, or a loop item's field in a loop
2673 // body — and it joins the list the same way, by name.
2674 // The value is bound first: a `for` head cannot hold a
2675 // borrow of a temporary, and the class list outlives this
2676 // statement, so the borrow has to have a name.
2677 other => additions.push(format!(
2678 "let __src = &{}; for __t in __src.split_whitespace() {{ __classes.push(__t); }}",
2679 other.expr()
2680 )),
2681 }
2682 }
2683 Some((
2684 at,
2685 format!(r#".set("class", {})"#, class_block_value(&additions)),
2686 ))
2687 }
2688 _ => None,
2689 };
2690 for (i, a) in attrs.iter().enumerate() {
2691 if let Some((at, entry)) = &merged_class {
2692 if *at == i {
2693 parts.push(entry.clone());
2694 continue;
2695 }
2696 let is_class = a.name == "class" && matches!(a.kind, AttrKind::Static | AttrKind::Bind);
2697 if is_class {
2698 continue;
2699 }
2700 }
2701 match a.kind {
2702 AttrKind::Static => {
2703 let v = a.value.clone().unwrap_or_default();
2704 parts.push(format!(r#".set("{}", {})"#, a.name, string_lit(&v)));
2705 }
2706 AttrKind::Bind => {
2707 let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
2708 let key = if a.name == "click-payload" || a.name == "payload" {
2709 "on:click-payload"
2710 } else {
2711 &a.name
2712 };
2713 parts.push(bind_prop_entry(
2714 key,
2715 &a.name,
2716 &expr,
2717 item_name,
2718 idx_name,
2719 resolve_loop,
2720 ));
2721 }
2722 AttrKind::Directive => {
2723 // directives are not emitted as props
2724 }
2725 AttrKind::On => {
2726 let handler = a.value.clone().unwrap_or_default();
2727 parts.push(format!(
2728 r#".set("on:{}", {})"#,
2729 a.name,
2730 string_lit(&handler)
2731 ));
2732 }
2733 }
2734 }
2735 parts.join("")
2736}
2737
2738/// Append the CSS scope attribute (e.g. `data-v-abc123`) to a props chain string.
2739/// When `scope_id` is `Some("data-v-xxx")`, appends `.set("data-v-xxx", "")` to the
2740/// Props builder so scoped CSS selectors match this element.
2741fn append_scope_attr(props: &str, scope_id: Option<&str>) -> String {
2742 if let Some(id) = scope_id {
2743 format!(r#"{}.set("{}", "")"#, props, id)
2744 } else {
2745 props.to_string()
2746 }
2747}
2748
2749fn emit_children_with_mode(
2750 children: &[Node],
2751 mode: TransformMode,
2752 fields: &[String],
2753 props: PropsChannel<'_>,
2754 scope_id: Option<&str>,
2755) -> String {
2756 if children.is_empty() {
2757 return "vec![]".to_string();
2758 }
2759 let mut out = String::new();
2760 out.push_str("{ let mut __children: Vec<velox_dom::VNode> = Vec::new();\n");
2761 let mut i = 0usize;
2762 while i < children.len() {
2763 match &children[i] {
2764 Node::Element {
2765 tag,
2766 attrs,
2767 children: ch,
2768 self_closing,
2769 } => {
2770 // v-if chain handling
2771 if let Some(pos) = attrs
2772 .iter()
2773 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if")
2774 {
2775 let mut attrs_if = attrs.clone();
2776 let dir = attrs_if.remove(pos);
2777 let expr_if = rewrite_if_expr(&dir.value.unwrap_or_default());
2778 let tmp_if = Node::Element {
2779 tag: tag.clone(),
2780 attrs: attrs_if,
2781 children: ch.clone(),
2782 self_closing: *self_closing,
2783 };
2784 let inner_if = emit_node_with_mode(&tmp_if, mode, fields, props, scope_id);
2785
2786 let mut chain_parts: Vec<String> = Vec::new();
2787 let mut j = i + 1;
2788 let mut else_part: Option<String> = None;
2789 while j < children.len() {
2790 if let Node::Element {
2791 tag: tag2,
2792 attrs: attrs2,
2793 children: ch2,
2794 self_closing: sc2,
2795 } = &children[j]
2796 {
2797 if let Some(pos2) = attrs2.iter().position(|a| {
2798 matches!(a.kind, AttrKind::Directive)
2799 && (a.name == "else-if" || a.name == "elseif")
2800 }) {
2801 let mut attrs_ei = attrs2.clone();
2802 let dir_ei = attrs_ei.remove(pos2);
2803 let expr_ei = rewrite_if_expr(&dir_ei.value.unwrap_or_default());
2804 let tmp_ei = Node::Element {
2805 tag: tag2.clone(),
2806 attrs: attrs_ei,
2807 children: ch2.clone(),
2808 self_closing: *sc2,
2809 };
2810 let inner_ei =
2811 emit_node_with_mode(&tmp_ei, mode, fields, props, scope_id);
2812 chain_parts.push(format!(
2813 r#"else if {} {{ {} }}"#,
2814 expr_ei.trim(),
2815 inner_ei
2816 ));
2817 j += 1;
2818 continue;
2819 }
2820 if let Some(pos3) = attrs2.iter().position(|a| {
2821 matches!(a.kind, AttrKind::Directive) && a.name == "else"
2822 }) {
2823 let mut attrs_e = attrs2.clone();
2824 attrs_e.remove(pos3);
2825 let tmp_e = Node::Element {
2826 tag: tag2.clone(),
2827 attrs: attrs_e,
2828 children: ch2.clone(),
2829 self_closing: *sc2,
2830 };
2831 let inner_e =
2832 emit_node_with_mode(&tmp_e, mode, fields, props, scope_id);
2833 else_part = Some(format!(r#"else {{ {} }}"#, inner_e));
2834 j += 1;
2835 break;
2836 }
2837 }
2838 if let Node::Text(t) = &children[j]
2839 && is_all_ws(t)
2840 {
2841 j += 1;
2842 continue;
2843 }
2844 break;
2845 }
2846
2847 let mut cond = String::new();
2848 // No outer `{ … }` around the whole conditional.
2849 //
2850 // The parens that used to wrap it were a real bug (a
2851 // conditional cannot be pushed as a parenthesized value), and
2852 // the block that replaced them was an over-correction: an
2853 // expression does not need braces, and wrapping one in them
2854 // emits `unused_braces` at every site whose body rustc can see
2855 // on one line. The braces still needed are the INNER ones —
2856 // the `if` arm's own block, which holds the component render's
2857 // `let __props` / `let __callbacks` bindings and therefore
2858 // cannot be an expression. Four such warnings ship in every
2859 // scaffolded app's generated `app.rs`; see
2860 // `codegen_unit_tests::v_if_else_emits_block_push`, which pins
2861 // both the shape and the per-render-fn count.
2862 cond.push_str(&format!(r#"if {} {{ {} }}"#, expr_if.trim(), inner_if));
2863 for part in chain_parts.iter() {
2864 cond.push(' ');
2865 cond.push_str(part);
2866 }
2867 if let Some(e) = else_part {
2868 cond.push(' ');
2869 cond.push_str(&e);
2870 } else {
2871 cond.push_str(r#" else { text("") }"#);
2872 }
2873 out.push_str(&format!("__children.push({});\n", cond));
2874 i = if j > i { j } else { i + 1 };
2875 continue;
2876 }
2877
2878 // v-for handling
2879 if let Some(posf) = attrs
2880 .iter()
2881 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
2882 {
2883 let v_if_pos = attrs
2884 .iter()
2885 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if");
2886
2887 let mut attrs_f = attrs.clone();
2888 let dir = attrs_f.remove(posf);
2889 let val = dir.value.unwrap_or_default();
2890
2891 let key_attr_pos = attrs_f
2892 .iter()
2893 .position(|a| a.kind == AttrKind::Bind && a.name == "key");
2894 let key_expr = if let Some(kp) = key_attr_pos {
2895 let key_val = attrs_f[kp].value.clone();
2896 attrs_f.remove(kp);
2897 Some(key_val)
2898 } else {
2899 None
2900 };
2901
2902 if let Some(for_info) = parse_v_for(&val) {
2903 let tmp_elem = Node::Element {
2904 tag: tag.clone(),
2905 attrs: attrs_f,
2906 children: ch.clone(),
2907 self_closing: *self_closing,
2908 };
2909
2910 // Where the collection comes from, and therefore what the
2911 // loop item is. See the same three-way choice in
2912 // `emit_node_with_mode`.
2913 let prop_field = mode == TransformMode::Props
2914 && props.declares_vec_field(&for_info.expr);
2915 let counted = !prop_field && mode != TransformMode::State;
2916
2917 if counted {
2918 // String-based iteration via resolve()
2919 out.push_str(&format!(
2920 "let __for_expr = resolve(\"{}\");\n",
2921 for_info.expr
2922 ));
2923 out.push_str(
2924 "let __for_count = if let Ok(n) = __for_expr.parse::<usize>() {\n",
2925 );
2926 out.push_str(" n\n");
2927 out.push_str("} else if __for_expr.is_empty() {\n");
2928 out.push_str(" 0\n");
2929 out.push_str("} else {\n");
2930 out.push_str(" __for_expr.split(',').count()\n");
2931 out.push_str("};\n");
2932 out.push_str(&format!(
2933 "for {idx_var} in 0..__for_count {{\n",
2934 idx_var = for_info.index_name
2935 ));
2936 } else if prop_field {
2937 out.push_str(&format!(
2938 "let __col = &{}.{};\n",
2939 // `render_with_props` is the only body with a
2940 // `props` local; the `render_with_state` body of
2941 // the same component reaches the same field
2942 // through `state.props`, which is where the props
2943 // body put it.
2944 if mode == TransformMode::Props {
2945 props.root
2946 } else {
2947 "state.props"
2948 },
2949 for_info.expr
2950 ));
2951 out.push_str("if !__col.is_empty() {\n");
2952 out.push_str(&format!(
2953 " for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
2954 idx_var = for_info.index_name,
2955 item_var = for_info.item_name
2956 ));
2957 } else {
2958 out.push_str(&format!("let __col = state.{}.get();\n", for_info.expr));
2959 out.push_str("if !__col.is_empty() {\n");
2960 out.push_str(&format!(
2961 " for ({idx_var}, {item_var}) in __col.iter().enumerate() {{\n",
2962 idx_var = for_info.index_name,
2963 item_var = for_info.item_name
2964 ));
2965 }
2966
2967 let inner = if counted {
2968 emit_node_with_ctx_for_loop(&tmp_elem, &for_info, scope_id)
2969 } else {
2970 emit_node_with_ctx_state(
2971 &tmp_elem,
2972 Some(&for_info.item_name),
2973 Some(&for_info.index_name),
2974 mode,
2975 props,
2976 scope_id,
2977 )
2978 };
2979
2980 let inner_with_key = if let Some(Some(key_val)) = &key_expr {
2981 if counted {
2982 format!(
2983 "{{ let mut __node = {}; if let velox_dom::VNode::Element {{ ref mut props, .. }} = __node {{ props.attrs.insert(\"key\".to_string(), {}); }} __node }}",
2984 inner,
2985 resolve_mode_key_value(key_val, &for_info)
2986 )
2987 } else {
2988 let key_path_str = key_val
2989 .strip_prefix(&for_info.item_name)
2990 .map(|s| s.trim_start_matches('.'))
2991 .unwrap_or("id");
2992 format!(
2993 "{{ 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 }}",
2994 inner,
2995 item_var = for_info.item_name,
2996 key_path = key_path_str
2997 )
2998 }
2999 } else {
3000 inner
3001 };
3002
3003 if let Some(if_pos) = v_if_pos {
3004 let dir_if = &attrs[if_pos];
3005 let expr_if =
3006 rewrite_if_expr(&dir_if.value.clone().unwrap_or_default());
3007 out.push_str(&format!(
3008 " if {} {{\n __children.push({});\n }}\n",
3009 expr_if.trim(),
3010 inner_with_key
3011 ));
3012 } else {
3013 out.push_str(&format!(" __children.push({});\n", inner_with_key));
3014 }
3015
3016 if !counted {
3017 out.push_str(" }\n");
3018 out.push_str("}\n");
3019 }
3020
3021 // A counted collection opens `for {idx} in 0..count {` but
3022 // does not close it inline; a real one already closed its for
3023 // and if above.
3024 if counted {
3025 out.push_str("}\n");
3026 }
3027 i += 1;
3028 continue;
3029 }
3030 }
3031
3032 // default element
3033 let expr = emit_node_with_mode(&children[i], mode, fields, props, scope_id);
3034 out.push_str(&format!("__children.push({});\n", expr));
3035 i += 1;
3036 }
3037 _ => {
3038 let expr = emit_node_with_mode(&children[i], mode, fields, props, scope_id);
3039 out.push_str(&format!("__children.push({});\n", expr));
3040 i += 1;
3041 }
3042 }
3043 }
3044 out.push_str("__children\n}");
3045 out
3046}
3047
3048fn emit_node_with_state(
3049 n: &Node,
3050 fields: &[String],
3051 props: &PropsChannel<'_>,
3052 scope_id: Option<&str>,
3053) -> String {
3054 emit_node_with_mode(n, TransformMode::State, fields, *props, scope_id)
3055}
3056
3057fn emit_node_with_ctx_state(
3058 n: &Node,
3059 item_name: Option<&str>,
3060 idx_name: Option<&str>,
3061 mode: TransformMode,
3062 props: PropsChannel<'_>,
3063 scope_id: Option<&str>,
3064) -> String {
3065 match n {
3066 Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
3067 Node::Interpolation(expr) => {
3068 let key = expr.trim();
3069 if let Some(item) = item_name {
3070 if key == item {
3071 return format!(r#"text(format!("{{}}", {item}))"#);
3072 }
3073 // Handle dot notation: item.property or item.nested.property
3074 if key.starts_with(item)
3075 && key.len() > item.len()
3076 && key.as_bytes()[item.len()] == b'.'
3077 {
3078 let prop_path = &key[item.len()..];
3079 return format!(
3080 r#"text({{ let __obj = &{item}; __obj{}.to_string() }})"#,
3081 prop_path
3082 );
3083 }
3084 }
3085 if let Some(idx) = idx_name
3086 && key == idx
3087 {
3088 return format!(r#"text({idx}.to_string())"#);
3089 }
3090 let key_lit = string_lit(key);
3091 format!(r#"text(resolve({}))"#, key_lit)
3092 }
3093 Node::Element {
3094 tag,
3095 attrs,
3096 children,
3097 ..
3098 } => {
3099 // Desugar `v-model` here, where the loop variables are in scope:
3100 // this is the only loop-body emitter on the State-mode path, and
3101 // `emit_props_with_ctx` drops every directive, so a `v-model` that
3102 // reached it would lose both its value bind and its handler. The
3103 // read is a direct field read of the loop item, the shape State mode
3104 // already emits for a loop-rooted binding; a loop-rooted expression
3105 // gets no handler, and `collect_resolver_keys` reports the write it
3106 // cannot route.
3107 let (attrs2, _v_model_handler) = extract_vmodel(attrs, item_name, idx_name);
3108 let attrs = &attrs2;
3109 // Handle <slot> inside v-for context
3110 if tag == "slot" {
3111 // Folded the same way as the parent's key — see
3112 // `normalize_slot_name`.
3113 let slot_name = slot_outlet_name(attrs);
3114
3115 // Fallback children rendered with ctx state
3116 let fallback_children: Vec<String> = children
3117 .iter()
3118 .map(|c| {
3119 emit_node_with_ctx_state(c, item_name, idx_name, mode, props, scope_id)
3120 })
3121 .collect();
3122 let fallback = format!("vec![{}]", fallback_children.join(", "));
3123
3124 return format!(
3125 r#"render_slot({slot_name_lit}, || {{ velox_dom::h(\"slot\", velox_dom::Props::new(), {fallback}) }})"#,
3126 slot_name_lit = string_lit(&slot_name),
3127 fallback = fallback,
3128 );
3129 }
3130
3131 // handle directive `v-show` inside the loop body
3132 // v-show always renders the element but toggles CSS `display: none`
3133 // based on the expression, preserving it in the DOM (unlike v-if).
3134 if let Some(pos) = attrs
3135 .iter()
3136 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "show")
3137 {
3138 let mut attrs2 = attrs.clone();
3139 let dir = attrs2.remove(pos);
3140 // A condition rooted at the loop variable is a real field read
3141 // here, so it goes through the one function that knows the loop
3142 // scope — the same one `:class` conditions use. `resolve_loop` is
3143 // `None` because this is the State renderer, where the loop item
3144 // IS a binding.
3145 let (expr, _keys) =
3146 condition_expr(&dir.value.unwrap_or_default(), item_name, idx_name, None);
3147
3148 // Build the element without the v-show directive, then conditionally
3149 // set display style based on the expression value.
3150 let inner = emit_node_with_ctx_state(
3151 &Node::Element {
3152 tag: tag.clone(),
3153 attrs: attrs2.clone(),
3154 children: children.clone(),
3155 self_closing: false,
3156 },
3157 item_name,
3158 idx_name,
3159 mode,
3160 props,
3161 scope_id,
3162 );
3163
3164 // If the element already has a `style` attribute, we merge the
3165 // display value. Otherwise, we add a style attribute.
3166 let has_style = attrs.iter().any(|a| {
3167 a.name == "style" && matches!(a.kind, AttrKind::Static | AttrKind::Bind)
3168 });
3169
3170 if has_style {
3171 // The style attr is already emitted by emit_node_with_ctx_state.
3172 // We wrap the result: if expression is false, override display.
3173 return format!(
3174 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 }}"#,
3175 inner = inner,
3176 expr = expr.trim()
3177 );
3178 }
3179 return format!(
3180 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 }}"#,
3181 inner = inner,
3182 expr = expr.trim()
3183 );
3184 }
3185
3186 // v-if inside the loop body
3187 if let Some(pos) = attrs
3188 .iter()
3189 .position(|a| matches!(a.kind, AttrKind::Directive) && a.name == "if")
3190 {
3191 let mut attrs2 = attrs.clone();
3192 let dir = attrs2.remove(pos);
3193 // See the `v-show` branch above: a loop-rooted condition is a field
3194 // read in this renderer, and a condition on a `State` getter is a
3195 // resolver lookup. One function emits both, so the element appears
3196 // for the same reason a root `v-if` does.
3197 let (expr_if, _keys) =
3198 condition_expr(&dir.value.unwrap_or_default(), item_name, idx_name, None);
3199 let tmp = Node::Element {
3200 tag: tag.clone(),
3201 attrs: attrs2,
3202 children: children.clone(),
3203 self_closing: false,
3204 };
3205 let inner =
3206 emit_node_with_ctx_state(&tmp, item_name, idx_name, mode, props, scope_id);
3207 return format!(
3208 r#"if {} {{ {} }} else {{ text("") }}"#,
3209 expr_if.trim(),
3210 inner
3211 );
3212 }
3213
3214 // Component inside the loop body: props must be built from the loop
3215 // variables directly (resolve() returns "" for loop vars).
3216 if let Some(component_attr) = attrs
3217 .iter()
3218 .find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
3219 && let Some(comp_name) = &component_attr.value
3220 {
3221 let mut clean_attrs = attrs.clone();
3222 clean_attrs.retain(|a| a.name != "data-velox-component");
3223
3224 let has_slot_children = !children.is_empty();
3225
3226 let (_has_props, props_expr) = generate_component_props_expr_with_ctx(
3227 &clean_attrs,
3228 comp_name,
3229 &props,
3230 item_name,
3231 idx_name,
3232 );
3233 let callbacks = collect_component_callbacks(&clean_attrs);
3234
3235 // A component in a loop body is reached from the same render
3236 // path as one outside it, so the handlers it binds are
3237 // registered the same way — with the loop's own `item` still in
3238 // scope for the dispatcher to close over.
3239 //
3240 // State mode only, exactly as outside the loop: the registration
3241 // closes over `state` (`emit_dispatch_registrations` emits
3242 // `Arc::downgrade(&state)`), and only the State path binds
3243 // `state` as the `Arc<State>` that call needs. This function is
3244 // reached from Props mode too — a `v-for` over a declared `Vec`
3245 // field lands here — and there `state` is either absent
3246 // (E0425) or a plain `script_rs::State` (E0308). Resolve mode
3247 // has no `State` to dispatch through at all.
3248 let dispatch = if mode == TransformMode::State {
3249 emit_dispatch_registrations(
3250 comp_name,
3251 &component_dispatch_handlers(&clean_attrs, comp_name, &props),
3252 )
3253 } else {
3254 String::new()
3255 };
3256
3257 // One map entry per slot the children name; see `slots_map_expr`.
3258 let slots_expr = if has_slot_children {
3259 slots_map_expr(children, &|c| {
3260 emit_node_with_ctx_state(c, item_name, idx_name, mode, props, scope_id)
3261 })
3262 } else {
3263 "std::collections::HashMap::new()".to_string()
3264 };
3265
3266 if callbacks.is_empty() {
3267 if !has_slot_children {
3268 return format!(
3269 r#"{{ {dispatch}let __props = {props_expr}; {comp_name}::render_with_props(__props) }}"#,
3270 );
3271 }
3272 return format!(
3273 r#"{{ {dispatch}let __props = {props_expr}; let __slots = {slots_expr}; {comp_name}::render_with_slots_only(__props, &__slots) }}"#,
3274 );
3275 }
3276
3277 // No slot children but has callbacks: use render_with_callbacks
3278 if !has_slot_children {
3279 let callback_map = format_callback_map(&callbacks);
3280 let callback_names: Vec<String> = callbacks
3281 .iter()
3282 .map(|(_, handler)| handler.clone())
3283 .collect();
3284
3285 return format!(
3286 r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; {comp_name}::render_with_callbacks(__props, &__callbacks, &[{callback_names}]) }}"#,
3287 callback_names = callback_names
3288 .iter()
3289 .map(|n| format!("\"{}\"", n))
3290 .collect::<Vec<_>>()
3291 .join(", ")
3292 );
3293 }
3294
3295 // Has both callbacks and slot children: use render_with_slots
3296 let callback_map = format_callback_map(&callbacks);
3297 let callback_names: Vec<String> = callbacks
3298 .iter()
3299 .map(|(_, handler)| handler.clone())
3300 .collect();
3301
3302 return format!(
3303 r#"{{ {dispatch}let __props = {props_expr}; let __callbacks = {callback_map}; let __slots = {slots_expr}; {comp_name}::render_with_slots(__props, &__callbacks, &[{callback_names}], &__slots) }}"#,
3304 callback_names = callback_names
3305 .iter()
3306 .map(|n| format!("\"{}\"", n))
3307 .collect::<Vec<_>>()
3308 .join(", ")
3309 );
3310 }
3311
3312 // Plain element inside the loop body.
3313 let elem_props = emit_props_with_ctx(attrs, item_name, idx_name);
3314 let mut k_items: Vec<String> = Vec::new();
3315 for c in children {
3316 k_items.push(emit_node_with_ctx_state(
3317 c, item_name, idx_name, mode, props, scope_id,
3318 ));
3319 }
3320 let kids = format!("vec![{}]", k_items.join(", "));
3321 format!(
3322 r#"h("{}", {}, {kids})"#,
3323 tag,
3324 append_scope_attr(&elem_props, scope_id)
3325 )
3326 }
3327 }
3328}
3329
3330#[allow(dead_code)]
3331fn emit_node_with_ctx(n: &Node, loop_var: Option<&str>, scope_id: Option<&str>) -> String {
3332 match n {
3333 Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
3334 Node::Interpolation(expr) => {
3335 let key = expr.trim();
3336 if let Some(var) = loop_var {
3337 if key == var {
3338 return "text(__i.to_string())".to_string();
3339 }
3340 // Handle dot notation: item.property or item.nested.property
3341 if key.starts_with(var)
3342 && key.len() > var.len()
3343 && key.as_bytes()[var.len()] == b'.'
3344 {
3345 let prop_path = &key[var.len()..];
3346 return format!(
3347 r#"text({{ let __obj = __i; __obj{}.to_string() }})"#,
3348 prop_path
3349 );
3350 }
3351 }
3352 let key_lit = string_lit(key);
3353 format!(r#"text(resolve({}))"#, key_lit)
3354 }
3355 Node::Element {
3356 tag,
3357 attrs,
3358 children,
3359 ..
3360 } => {
3361 let elem_props = emit_props_with(attrs);
3362 let kids = {
3363 let mut k_items: Vec<String> = Vec::new();
3364 for c in children {
3365 k_items.push(emit_node_with_ctx(c, loop_var, scope_id));
3366 }
3367 format!("vec![{}]", k_items.join(", "))
3368 };
3369 format!(
3370 r#"h("{}", {}, {kids})"#,
3371 tag,
3372 append_scope_attr(&elem_props, scope_id)
3373 )
3374 }
3375 }
3376}
3377
3378/// Emit a node with context for the render() path inside a v-for loop.
3379/// This version uses resolve() with indexed access for collection items.
3380/// For `item in items`, it generates code like `resolve("items[__i].property")`
3381/// or `resolve("items[__i]")` for the item itself.
3382fn emit_node_with_ctx_for_loop(n: &Node, for_info: &VForInfo, scope_id: Option<&str>) -> String {
3383 match n {
3384 Node::Text(t) => format!(r#"text({})"#, string_lit(t)),
3385 Node::Interpolation(expr) => {
3386 let key = expr.trim();
3387 if key == for_info.item_name {
3388 // Direct item reference: use indexed access via resolve
3389 return format!(
3390 r#"text(resolve(&format!("{}[{{}}]", {})))"#,
3391 for_info.expr, for_info.index_name
3392 );
3393 }
3394 // Handle dot notation: item.property -> items[__i].property
3395 if key.starts_with(&for_info.item_name)
3396 && key.len() > for_info.item_name.len()
3397 && key.as_bytes()[for_info.item_name.len()] == b'.'
3398 {
3399 let prop_path = &key[for_info.item_name.len()..];
3400 return format!(
3401 r#"text(resolve(&format!("{expr}[{{}}]{prop_path}", {idx})))"#,
3402 expr = for_info.expr,
3403 idx = for_info.index_name,
3404 );
3405 }
3406 if key == for_info.index_name {
3407 return format!(r#"text({}.to_string())"#, for_info.index_name);
3408 }
3409 let key_lit = string_lit(key);
3410 format!(r#"text(resolve({}))"#, key_lit)
3411 }
3412 Node::Element {
3413 tag,
3414 attrs,
3415 children,
3416 ..
3417 } => {
3418 // Inside a Resolve-mode loop body the loop item is an indexed
3419 // resolver read, so a loop-rooted binding is normalized as such.
3420 let elem_props = emit_props_in_loop(
3421 attrs,
3422 Some(&for_info.item_name),
3423 Some(&for_info.index_name),
3424 Some(for_info),
3425 );
3426 let kids = {
3427 let mut k_items: Vec<String> = Vec::new();
3428 for c in children {
3429 k_items.push(emit_node_with_ctx_for_loop(c, for_info, scope_id));
3430 }
3431 format!("vec![{}]", k_items.join(", "))
3432 };
3433 format!(
3434 r#"h("{}", {}, {kids})"#,
3435 tag,
3436 append_scope_attr(&elem_props, scope_id)
3437 )
3438 }
3439 }
3440}
3441
3442/// Collect `@event="handler"` attrs from component attributes.
3443/// Returns a list of `(event_name, handler_name)` pairs.
3444fn collect_component_callbacks(attrs: &[TemplateAttr]) -> Vec<(String, String)> {
3445 attrs
3446 .iter()
3447 .filter(|a| a.kind == AttrKind::On)
3448 .filter_map(|a| {
3449 let handler = a.value.clone().unwrap_or_default();
3450 if handler.is_empty() {
3451 None
3452 } else {
3453 Some((a.name.clone(), handler))
3454 }
3455 })
3456 .collect()
3457}
3458
3459/// Generate a Rust expression for a HashMap of callbacks.
3460/// Input: `[("change", "on_change"), ("submit", "on_submit")]`
3461/// Output: `std::collections::HashMap::from([("change", "on_change"), ("submit", "on_submit")])`
3462fn format_callback_map(callbacks: &[(String, String)]) -> String {
3463 if callbacks.is_empty() {
3464 return "std::collections::HashMap::new()".to_string();
3465 }
3466 let entries: Vec<String> = callbacks
3467 .iter()
3468 .map(|(event, handler)| format!("({}, {})", string_lit(event), string_lit(handler)))
3469 .collect();
3470 format!("std::collections::HashMap::from([{}])", entries.join(", "))
3471}
3472
3473/// --- Function-typed props -------------------------------------------------
3474///
3475/// A prop whose declared type is a closure is the one binding that cannot go
3476/// through the resolver. Every other prop arrives at the boundary as a
3477/// `String` — that is the shape `resolve()` returns and the shape the literal
3478/// converts from — and a closure has no string that can be called back. So a
3479/// function prop is passed THROUGH: the parent's own dispatcher goes into the
3480/// props literal as a closure, and the child calls it.
3481///
3482/// Passing it through the same registry `@event` uses is what keeps the two
3483/// syntaxes to one mechanism. The parent's `make_on_event` already owns an arm
3484/// per handler with the arity the method declared, so the closure re-enters the
3485/// dispatcher by NAME rather than calling the method itself: the child states
3486/// which handler it wants, and the parent — the only side that knows what
3487/// `on_confirm` means — decides what running it looks like. A zero-argument
3488/// handler and a payload handler therefore both work through the same emitted
3489/// closure, and the child never carries a `State` reference around to make the
3490/// call itself.
3491///
3492/// The contract, in the terms a component author writes against:
3493///
3494/// - Declare `on_confirm: Option<Box<dyn Fn(&str)>>` in the child's `Props`.
3495/// - The parent binds `:on_confirm="on_confirm"`, where `on_confirm` is the
3496/// name of a zero- or one-argument method on the PARENT's `State`.
3497/// - The child calls it as `props.on_confirm.as_ref().map(|f| f("payload"))`,
3498/// or through the generated `script_rs::emit`-equivalent path.
3499/// - `Option` is what makes the binding optional: a parent that leaves an
3500/// optional function prop unbound compiles, and a parent that leaves a
3501/// required one unbound gets the same `compile_error!` that names it as every
3502/// other unbound prop does.
3503///
3504/// The containers a `Props` struct conventionally puts around a callback, and
3505/// the constructor that rebuilds each one.
3506///
3507/// ONE list, read by the two functions that must not disagree: the predicate
3508/// that recognises a function prop ([`fn_prop_core`]) and the builder that wraps
3509/// the emitted closure ([`wrap_by_declared_type`]). A wrapper one of them
3510/// recognises and the other does not is a prop that is correctly identified and
3511/// then filled with a value of the wrong shape — a type error inside generated
3512/// code, which is the failure mode this module is written to avoid.
3513///
3514/// The fully-qualified spellings come before the bare ones because the peel is a
3515/// prefix strip rather than a token match: `std::rc::Rc<` has to be tried
3516/// before `Rc<`, or the bare marker would match and leave `::` behind.
3517const PROP_TYPE_WRAPPERS: [(&str, &str); 9] = [
3518 ("std::option::Option<", "Some"),
3519 ("Option<", "Some"),
3520 ("Box<", "Box::new"),
3521 ("std::rc::Rc<", "std::rc::Rc::new"),
3522 ("std::sync::Arc<", "std::sync::Arc::new"),
3523 ("std::cell::RefCell<", "std::cell::RefCell::new"),
3524 ("Rc<", "std::rc::Rc::new"),
3525 ("Arc<", "std::sync::Arc::new"),
3526 ("RefCell<", "std::cell::RefCell::new"),
3527];
3528
3529/// The closure type a declared `Props` type wraps down to, with the containers
3530/// peeled off and a `dyn ` dropped — `Option<Box<dyn Fn(&str)>>` gives
3531/// `Fn(&str)`, the bare `dyn Fn(&str)` gives `Fn(&str)`.
3532fn fn_prop_core(ty: &str) -> Option<&str> {
3533 let mut inner = ty.trim();
3534 let mut peeled = false;
3535 loop {
3536 // The LONGEST matching marker wins, so `std::rc::Rc<` beats `Rc<`.
3537 let Some(marker) = PROP_TYPE_WRAPPERS
3538 .iter()
3539 .filter(|(marker, _)| inner.starts_with(marker))
3540 .map(|(marker, _)| *marker)
3541 .max_by_key(|marker| marker.len())
3542 else {
3543 break;
3544 };
3545 inner = inner[marker.len()..].trim_end_matches('>').trim();
3546 peeled = true;
3547 }
3548 if !peeled {
3549 return None;
3550 }
3551 Some(inner.strip_prefix("dyn ").unwrap_or(inner).trim())
3552}
3553
3554/// Is `ty` a callback the parent has to hand OVER rather than convert?
3555///
3556/// This is a RECOGNITION question and is deliberately broad: a `Props` field
3557/// whose type names a closure is a callback, whatever the argument list says, and
3558/// recognising it is what routes it away from the `String`-conversion path at
3559/// all. How much of that the builder can actually produce is a separate question,
3560/// asked by [`is_buildable_function_prop_type`].
3561fn is_function_prop_type(ty: &str) -> bool {
3562 // Deliberately a prefix test rather than a parse: the generator has no type
3563 // information for the argument list, and a `Props` field whose type starts
3564 // with `Fn` is a callback far more often than anything else.
3565 fn_prop_core(ty).is_some_and(|core| core.starts_with("Fn"))
3566}
3567
3568/// Can `function_prop_value` build a value for a prop declared `ty`?
3569///
3570/// It emits exactly one closure —
3571/// `move |__vx_payload: &str| { Comp::dispatch_emit(name, __vx_payload); }` — so
3572/// exactly one signature is buildable: one borrowed payload in, nothing out.
3573/// `FnMut` and `FnOnce` are accepted alongside `Fn` because a closure that
3574/// implements `Fn` satisfies all three, which is what makes
3575/// `RefCell<Box<dyn FnMut(&str)>>` a shape the wrappers can reach.
3576///
3577/// This exists so the generator can REFUSE what it cannot build.
3578/// `Option<Box<dyn Fn(String) -> bool>>` passes [`is_function_prop_type`], and
3579/// without this question the author got a type error deep inside a props literal
3580/// they never wrote, pointing into `OUT_DIR`. With it they get a
3581/// `compile_error!` naming the prop, the type they declared, and the signature
3582/// that works — the same deliberate refusal the call-binding case in
3583/// `generate_component_props_expr` already emits.
3584fn is_buildable_function_prop_type(ty: &str) -> bool {
3585 fn_prop_core(ty).is_some_and(|core| matches!(core, "Fn(&str)" | "FnMut(&str)" | "FnOnce(&str)"))
3586}
3587
3588/// Is `ty` an `Option<…>`, i.e. a field a parent may legitimately leave unbound?
3589///
3590/// Both spellings count. `std::option::Option<String>` is what an author gets
3591/// from writing the path out in full, and it names the same type as `Option<`;
3592/// treating it as a REQUIRED prop would emit the `compile_error!` for a field
3593/// the author explicitly declared optional.
3594fn is_optional_type(ty: &str) -> bool {
3595 let ty = ty.trim();
3596 PROP_TYPE_WRAPPERS
3597 .iter()
3598 .filter(|(marker, _)| marker.ends_with("Option<"))
3599 .any(|(marker, _)| ty.starts_with(marker))
3600}
3601
3602/// Is `expr` a name the parent can HAND OVER — one identifier, and nothing else?
3603///
3604/// A function prop is a different question from the one `is_bare_identifier`
3605/// asks, and it is asked of different syntax. That one answers whether a
3606/// resolver KEY can be rendered as text, and the guard in
3607/// `tests/answerability_decides_in_one_place.rs` holds it to a single predicate
3608/// on purpose. This one answers whether the generator can BUILD A CLOSURE that
3609/// re-enters the parent's dispatcher by this name: it has to name the handler,
3610/// and a call or a member path is an expression it would have to re-evaluate
3611/// inside the closure, whose own captures would not survive the move. Filing
3612/// that under `is_bare_identifier` would put a props-path decision inside a
3613/// decision named for answerability, and the guard's whole point is that
3614/// nothing else gets to do that.
3615///
3616/// The two agree on every expression the generator can see today — the
3617/// difference is only which question the answer is filed under.
3618fn is_handler_name(expr: &str) -> bool {
3619 let expr = expr.trim();
3620 !expr.is_empty()
3621 && !expr.starts_with(|c: char| c.is_ascii_digit())
3622 && expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
3623}
3624
3625/// The function-typed prop bindings on one component tag, as
3626/// `(prop field name, handler name)` pairs.
3627///
3628/// The binding is a bare name, the restriction [`is_handler_name`] states: the
3629/// closure the parent hands over is built from the parent's own `State`, so the
3630/// generator has to name the handler to dispatch it. A bind that is a function
3631/// prop and not a bare name is reported at the call site by a `compile_error!`
3632/// naming the constraint, rather than failing as a type error inside a
3633/// stringified prop.
3634fn collect_function_prop_bindings(
3635 attrs: &[TemplateAttr],
3636 comp_name: &str,
3637 props: &PropsChannel<'_>,
3638) -> Vec<(String, String)> {
3639 let mut out: Vec<(String, String)> = Vec::new();
3640 for a in attrs {
3641 if a.kind != AttrKind::Bind {
3642 continue;
3643 }
3644 let Some(field) = props.child_field(comp_name, &a.name) else {
3645 continue;
3646 };
3647 if !is_function_prop_type(&field.ty) {
3648 continue;
3649 }
3650 let expr = a.value.clone().unwrap_or_else(|| a.name.clone());
3651 if is_handler_name(&expr) {
3652 out.push((a.name.clone(), expr.trim().to_string()));
3653 }
3654 }
3655 out
3656}
3657
3658/// The Rust value a function-typed prop binding takes in the parent's props
3659/// literal: a closure that hands the payload to the child's handler registry,
3660/// by handler name.
3661///
3662/// The closure calls `{comp_name}::dispatch_emit(name, payload)` — the child's
3663/// OWN static entry point — rather than a dispatcher local to this render. Two
3664/// consequences, both wanted:
3665///
3666/// * It needs no `State` in scope, so a function prop is expressible on every
3667/// render path, including `resolve`, which has none.
3668/// * The `State` the handler finally runs against is the one the STATE render
3669/// registered (see `emit_dispatch_registrations`), not a throwaway built for
3670/// the render that happened to build this literal. Dispatching through a local
3671/// dispatcher would have handed the child a handler over a `State` that no
3672/// longer exists by the time the child fires.
3673///
3674/// So the value is the closure and the wrapper the child's DECLARED type asks
3675/// for — read off the type, never assumed, because the parent is filling in
3676/// somebody else's struct field: `Option<Box<dyn Fn(&str)>>` is the shape a
3677/// Confirm should declare, and it is a `Box` around a `Fn`, NOT the
3678/// `Rc<RefCell<…>>` the registry itself stores. Those are two different
3679/// contracts and conflating them produces a literal that does not coerce.
3680fn function_prop_value(comp_name: &str, field: &PropField, handler: &str) -> String {
3681 let body = format!(
3682 "move |__vx_payload: &str| {{ {comp_name}::dispatch_emit({}, __vx_payload); }}",
3683 string_lit(handler)
3684 );
3685 wrap_by_declared_type(field.ty.trim(), body)
3686}
3687
3688/// Apply exactly the wrappers the declared type names, outermost first.
3689///
3690/// Each wrapper is peeled off the front of the type and its constructor pushed
3691/// onto a list, which is then applied in reverse — so `Option<Box<dyn Fn(&str)>>`
3692/// peels as `Option` then `Box` and rebuilds as `Some(Box::new(closure))`. An
3693/// unknown wrapper (a named type alias, a `Signal`, a tuple) peels nothing and
3694/// leaves the bare closure, which is the right answer for `dyn Fn(&str)`, and a
3695/// type error the author can see for anything else.
3696fn wrap_by_declared_type(ty: &str, body: String) -> String {
3697 let mut ctors: Vec<&str> = Vec::new();
3698 let mut rest = ty.trim();
3699 loop {
3700 // The LONGEST matching marker wins, so `std::rc::Rc<` is peeled as
3701 // itself rather than as the `Rc<` that its tail also starts with.
3702 let Some((marker, ctor)) = PROP_TYPE_WRAPPERS
3703 .iter()
3704 .filter(|(marker, _)| rest.starts_with(marker))
3705 .max_by_key(|(marker, _)| marker.len())
3706 else {
3707 break;
3708 };
3709 ctors.push(ctor);
3710 rest = &rest[marker.len()..];
3711 }
3712 ctors
3713 .into_iter()
3714 .rev()
3715 .fold(body, |inner, ctor| format!("{ctor}({inner})"))
3716}
3717
3718/// The statements a parent emits immediately before it renders a child, so
3719/// every handler the parent bound on that tag is callable by the child.
3720///
3721/// One dispatcher per handler, not one per child: the dispatcher borrows its
3722/// `State`, so each closure needs its own instance to `move`, and the
3723/// alternative — a shared `&mut` behind an `Rc` — would mean the child can only
3724/// ever hold one handler.
3725///
3726/// `handlers` are the handler NAMES the parent bound on this tag, and the name is
3727/// both halves of what gets emitted: it is the registry key the child's `emit`
3728/// resolves to, and it is what the parent's own `make_on_event` is asked for.
3729/// That is the same name twice because `make_on_event` matches on handler names
3730/// — it is the dispatcher every `@click="fire"` in this component's template goes
3731/// through, so its arms are keyed by method name and nothing else. Asking it for
3732/// the EVENT instead (`@confirm="on_confirm"` -> `"confirm"`) reaches no arm at
3733/// all: the closure runs, the `match` falls through to its wildcard, and the
3734/// payload is dropped with nothing to show for it.
3735///
3736/// # The registry holds a WEAK state, and that is load-bearing
3737///
3738/// The child fires long after this render returned — from its own event handler,
3739/// from a click — so the registry cannot borrow the `State` and has to own
3740/// something. Owning an `Arc` clone means the LAST reference to a component's
3741/// `State` lives in a `thread_local!`, so the `State` is not destroyed when the
3742/// app drops it but when the THREAD exits, inside that thread-local's own
3743/// destructor. A component `State` holds `Signal`s, and a live `Signal` holds an
3744/// `EffectHandle` whose `Drop` calls `mark_stopped`, which reads a `thread_local!`
3745/// in `velox-core` — by then already destroyed. `thread_local!` destructors run in
3746/// reverse construction order across crates, so the ordering is not ours to
3747/// control, and `mark_stopped`'s `LocalKey::with` panics with `AccessError`. A
3748/// panic in a destructor aborts the process, so this shows up as the whole test
3749/// binary dying at exit rather than as a failed assertion.
3750///
3751/// A `Weak` inverts the ownership: the registry no longer extends the `State`'s
3752/// life, so the `State` is destroyed when the app drops it — while every
3753/// thread-local is still alive, which is the only ordering in which a `Signal`'s
3754/// `Drop` is allowed to touch one. The cost is that a handler fired after the app
3755/// dropped the `State` is a no-op rather than a resurrection of freed state,
3756/// which is the correct way round: a component whose state is gone has no method
3757/// left to run.
3758///
3759/// So the dispatcher is built per CALL, from an upgraded `Weak`, rather than once
3760/// per render: `make_on_event` takes an `Arc` by value, and there is no `Arc` to
3761/// give it at registration time. That is one `Arc` upgrade and one small closure
3762/// per dispatched event, against a re-render per keystroke.
3763///
3764/// Only the State render path emits this. The `resolve` path has no `State` to
3765/// dispatch through at all, and the props path has only just built a `State`
3766/// that belongs to this render and dies with it — registering into it would
3767/// hand the child a handler whose `State` is stale by the time it fires.
3768fn emit_dispatch_registrations(comp_name: &str, handlers: &[String]) -> String {
3769 if handlers.is_empty() {
3770 return String::new();
3771 }
3772 let mut out = String::new();
3773 for (i, handler) in handlers.iter().enumerate() {
3774 out.push_str(&format!(
3775 "let __vx_state_{i} = std::sync::Arc::downgrade(&state); \
3776 {comp_name}::set_emit_dispatch({name}, std::rc::Rc::new(std::cell::RefCell::new(\
3777 move |__vx_payload: &str| {{ \
3778 if let Some(__vx_state) = __vx_state_{i}.upgrade() {{ \
3779 let mut __vx_dispatch = make_on_event(__vx_state); \
3780 __vx_dispatch({name}, Some(__vx_payload)); \
3781 }} \
3782 }}))); ",
3783 name = string_lit(handler),
3784 ));
3785 }
3786 out
3787}
3788
3789/// Every handler name a child on this tag can be reached by: the `@event`
3790/// bindings plus the function-typed prop bindings, deduplicated so one handler
3791/// bound twice registers once.
3792///
3793/// For `@confirm="on_confirm"` that is `on_confirm`, and for a function prop
3794/// `:on_confirm="on_confirm"` it is also `on_confirm` — but for the opposite
3795/// reason, and the difference is the whole reason the function-prop case keys on
3796/// the handler and not on the prop field: `function_prop_value` builds the
3797/// closure the child receives as `dispatch_emit(<bound name>)`, so the bound name
3798/// is what `dispatch_emit` will look the dispatcher up under. Registering the
3799/// prop field name instead finds nothing when the two differ
3800/// (`:on_confirm="handle_ok"`), and the child's call is dropped at run time.
3801fn component_dispatch_handlers(
3802 clean_attrs: &[TemplateAttr],
3803 comp_name: &str,
3804 props: &PropsChannel<'_>,
3805) -> Vec<String> {
3806 let mut out: Vec<String> = collect_component_callbacks(clean_attrs)
3807 .into_iter()
3808 .map(|(_event, handler)| handler)
3809 .collect();
3810 for (_prop_name, handler) in collect_function_prop_bindings(clean_attrs, comp_name, props) {
3811 if !out.contains(&handler) {
3812 out.push(handler);
3813 }
3814 }
3815 out
3816}
3817
3818/// Handler names a function-typed prop binding introduces, so the parent's
3819/// dispatcher emits an arm for each.
3820///
3821/// `:on_confirm="on_confirm"` needs the arm `@confirm="on_confirm"` gets: the
3822/// method the child calls still lives on the parent's `State`, so the parent
3823/// has to own it. Without this the closure would dispatch to a name with no arm
3824/// and the child's call would be dropped at run time — the same silent no-op
3825/// `emit` had before the dispatch registry existed, one layer down.
3826fn collect_function_prop_handlers(nodes: &[Node], props: &PropsChannel<'_>) -> Vec<String> {
3827 fn walk(node: &Node, props: &PropsChannel<'_>, out: &mut Vec<String>) {
3828 let Node::Element {
3829 attrs, children, ..
3830 } = node
3831 else {
3832 return;
3833 };
3834 if let Some(comp_name) = attrs
3835 .iter()
3836 .find(|a| a.name == "data-velox-component" && a.kind == AttrKind::Static)
3837 .and_then(|a| a.value.clone())
3838 {
3839 for (_, handler) in collect_function_prop_bindings(attrs, &comp_name, props) {
3840 if !out.contains(&handler) {
3841 out.push(handler);
3842 }
3843 }
3844 }
3845 for c in children {
3846 walk(c, props, out);
3847 }
3848 }
3849 let mut out: Vec<String> = Vec::new();
3850 for n in nodes {
3851 walk(n, props, &mut out);
3852 }
3853 out
3854}
3855
3856// --- Named slots ----------------------------------------------------------
3857
3858/// The slot NAME a child element is bound to, from `v-slot:foo` or the `#foo`
3859/// shorthand. `None` for a child that names no slot, which is the default slot.
3860///
3861/// `pub(crate)` for the crate's unit tests: this and the two functions below are
3862/// pure AST transforms, and the only way to say what they do to a node is to hand
3863/// them one. Asserting it through the generated Rust does not work — the props
3864/// builder ignores `AttrKind::Directive`, so whether the `slot:` attribute was
3865/// stripped is invisible in the output and the assertion would hold either way.
3866pub(crate) fn slot_binding(node: &Node) -> Option<String> {
3867 let Node::Element { attrs, .. } = node else {
3868 return None;
3869 };
3870 for a in attrs {
3871 if !matches!(a.kind, AttrKind::Directive) {
3872 continue;
3873 }
3874 // The parser folds both spellings into one name: `v-slot:foo` arrives as
3875 // `slot:foo` (the `v-` prefix is stripped and the name is kebab-cased by
3876 // `normalize_directive_name`), and `#foo` is mapped onto the same
3877 // spelling so the two cannot drift apart downstream. The name is
3878 // therefore kebab-case, which is also what `<slot name="foo-bar">` on the
3879 // child side has to say to match `v-slot:fooBar`.
3880 if let Some(name) = a.name.strip_prefix("slot:") {
3881 let name = name.trim();
3882 if !name.is_empty() {
3883 return Some(name.to_string());
3884 }
3885 }
3886 }
3887 None
3888}
3889
3890/// The element with its slot binding removed, so `v-slot:footer` is not also
3891/// emitted as an unknown directive on the slot content.
3892///
3893/// The binding names the slot; it is not an attribute of the content. Leaving it
3894/// on is a latent defect rather than a present one: `emit_props_in_loop` emits
3895/// `AttrKind::Static` and `AttrKind::Bind` and ignores `AttrKind::Directive`
3896/// entirely, so today the binding would be dropped anyway. It is removed HERE so
3897/// that the content node is correct on its own terms, and so the guarantee does
3898/// not silently stop holding the moment a directive becomes something the props
3899/// builder emits — at which point every `<p v-slot:footer>` would start writing
3900/// a `slot:footer` attribute into the caller's DOM.
3901pub(crate) fn strip_slot_binding(node: &Node) -> Node {
3902 let Node::Element {
3903 tag,
3904 attrs,
3905 children,
3906 self_closing,
3907 } = node
3908 else {
3909 return node.clone();
3910 };
3911 let attrs: Vec<TemplateAttr> = attrs
3912 .iter()
3913 .filter(|a| !(a.kind == AttrKind::Directive && a.name.starts_with("slot:")))
3914 .cloned()
3915 .collect();
3916 Node::Element {
3917 tag: tag.clone(),
3918 attrs,
3919 children: children.clone(),
3920 self_closing: *self_closing,
3921 }
3922}
3923
3924/// What a slot-bound child element contributes to its slot, with the binding
3925/// itself removed.
3926///
3927/// A `<template v-slot:footer>` wrapper contributes its CHILDREN, not itself:
3928/// `template` is not an element the renderer knows, so a fragment bound through
3929/// one has to be flattened or it renders as an inert `<template>` node with the
3930/// caller's content inside it. A plain element (`<h1 v-slot:header>`) is its own
3931/// content. Either way the `v-slot:` / `#` attribute comes off: it named the
3932/// slot, it is not an attribute of the content, and leaving it on would emit a
3933/// `slot:footer` directive the emitter has no handler for.
3934pub(crate) fn slot_content(node: &Node) -> Vec<Node> {
3935 match node {
3936 Node::Element { tag, children, .. }
3937 if tag == "template" && slot_binding(node).is_some() =>
3938 {
3939 children.clone()
3940 }
3941 other => vec![strip_slot_binding(other)],
3942 }
3943}
3944
3945/// Build the `HashMap` a parent hands a child as its slots: slot name to the
3946/// already-rendered `VNode` the child reads back out of `render_slot`.
3947///
3948/// Children are grouped by the slot they name. Several children may name the
3949/// same slot (`<p v-slot:footer>a</p><p v-slot:footer>b</p>`) and land in one
3950/// entry as a `<slot>` wrapper element, which is the same shape a multi-child
3951/// default slot already has. A child that names no slot is the default slot,
3952/// and the default slot is a real entry in the same map — there is no separate
3953/// path for it.
3954///
3955/// # Slotted content carries the CALLER's scope id, so a child's `scoped` CSS
3956/// cannot style it
3957///
3958/// `emit` is called with THIS component's `scope_id`, and every node it produces
3959/// is tagged with it. Slot content is produced by the CALLER's `emit`, with the
3960/// caller's `scope_id` — so a node a caller passes into a slot carries the
3961/// caller's `data-v-*`, never this component's. A `scoped` style block here
3962/// cannot reach it: its selectors are suffixed with this component's scope
3963/// attribute, and no slotted node has it. The caller's own `scoped` CSS can
3964/// reach it, because the nodes are the caller's own.
3965///
3966/// This is a consequence of WHERE slot content is evaluated, not a defect to fix
3967/// here, and it is not fixable by a different data structure: the content is a
3968/// fragment of the caller's template, built from the caller's bindings, and any
3969/// arrangement that let a child reach into it would have to hand the child the
3970/// caller's bindings — which is a scoped-slots channel this framework does not
3971/// have. The consequence for component authors is a rule, not a caveat: **a core
3972/// component whose slotted regions need styling must ship an UNSCOPED `<style>`
3973/// block and be styled by class name.** A Modal with a header, a body and a
3974/// footer is exactly that case. The alternative — unscoped-by-default for
3975/// components that take slots — would leak the child's styles back out to every
3976/// element of the same tag in the app.
3977fn slots_map_expr(children: &[Node], emit: &dyn Fn(&Node) -> String) -> String {
3978 let mut groups: Vec<(String, Vec<Node>)> = Vec::new();
3979 for child in children {
3980 let name = slot_binding(child).unwrap_or_else(|| "default".to_string());
3981 let content = slot_content(child);
3982 match groups.iter_mut().find(|(existing, _)| *existing == name) {
3983 Some((_, nodes)) => nodes.extend(content),
3984 None => groups.push((name, content)),
3985 }
3986 }
3987 let entries: Vec<String> = groups
3988 .iter()
3989 .filter(|(_, nodes)| !nodes.is_empty())
3990 .map(|(name, nodes)| {
3991 let vnodes: Vec<String> = nodes.iter().map(emit).collect();
3992 let vnode = if vnodes.len() == 1 {
3993 vnodes[0].clone()
3994 } else {
3995 format!(
3996 "velox_dom::h(\"slot\", velox_dom::Props::new(), vec![{}])",
3997 vnodes.join(", ")
3998 )
3999 };
4000 format!("({}, {vnode})", string_lit(name))
4001 })
4002 .collect();
4003 if entries.is_empty() {
4004 // No entry at all: an empty array literal leaves the map's key and value
4005 // types to inference from a call that may not constrain them, and the
4006 // child's fallback is what an empty slot should render anyway.
4007 return "std::collections::HashMap::new()".to_string();
4008 }
4009 format!("std::collections::HashMap::from([{}])", entries.join(", "))
4010}
4011
4012fn string_lit(s: &str) -> String {
4013 // Basic escape for quotes and backslashes; good enough for tests
4014 let mut out = String::with_capacity(s.len() + 8);
4015 out.push('"');
4016 for ch in s.chars() {
4017 match ch {
4018 '\\' => out.push_str("\\\\"),
4019 '"' => out.push_str("\\\""),
4020 '\n' => out.push_str("\\n"),
4021 '\r' => out.push_str("\\r"),
4022 '\t' => out.push_str("\\t"),
4023 _ => out.push(ch),
4024 }
4025 }
4026 out.push('"');
4027 out
4028}
4029
4030/// Collect all v-model expressions from a template AST.
4031/// Returns a list of (model_expr, handler_name) pairs.
4032/// For `v-model="counter"`, returns `("counter", "__vmodel_set_counter")`.
4033pub fn collect_vmodel_expressions(nodes: &[Node]) -> Vec<(String, String)> {
4034 let mut results = Vec::new();
4035 // The `v-for` loop variables in scope, outermost first: a loop-rooted
4036 // `v-model` gets no setter, because the dispatcher cannot reach a loop item
4037 // (see `vmodel_is_loop_rooted`).
4038 fn walk(
4039 nodes: &[Node],
4040 out: &mut Vec<(String, String)>,
4041 loop_vars: &mut Vec<(String, String)>,
4042 ) {
4043 for node in nodes {
4044 if let Node::Element {
4045 attrs, children, ..
4046 } = node
4047 {
4048 if let Some(info) = v_for_scope(attrs) {
4049 loop_vars.push((info.item_name.clone(), info.index_name.clone()));
4050 }
4051 let (item_name, idx_name) = loop_vars.last().map_or((None, None), |(item, idx)| {
4052 (Some(item.as_str()), Some(idx.as_str()))
4053 });
4054 for attr in attrs {
4055 if matches!(attr.kind, AttrKind::Directive)
4056 && attr.name == "model"
4057 && let Some(expr) = &attr.value
4058 && !vmodel_is_loop_rooted(expr, item_name, idx_name)
4059 {
4060 let handler = vmodel_setter_name(expr);
4061 out.push((expr.clone(), handler));
4062 }
4063 }
4064 walk(children, out, loop_vars);
4065 if v_for_scope(attrs).is_some() {
4066 loop_vars.pop();
4067 }
4068 }
4069 }
4070 }
4071 let mut loop_vars: Vec<(String, String)> = Vec::new();
4072 walk(nodes, &mut results, &mut loop_vars);
4073 results
4074}
4075
4076/// Collect all interpolation key names from the template AST.
4077/// Returns unique keys like `["text", "completed", "counter"]` that are used
4078/// in `{{ key }}` expressions. These correspond to method names on the
4079/// component's State struct.
4080///
4081/// An interpolation inside a `v-for` body that names a loop variable is NOT a
4082/// key: both renderers read the loop item and index directly, from the binding
4083/// the loop itself introduces, so it is a real read rather than a
4084/// `resolve(...)` lookup. Registering one would emit an arm against the
4085/// `State` — `"item.name" => state.item.name().to_string()` — and the loop
4086/// item is not a `State` field, so that arm does not compile (E0609). This is
4087/// the same rule the bind path applies, reached there through `emit_bind_attr`.
4088pub fn collect_interpolation_keys(nodes: &[Node]) -> Vec<String> {
4089 let mut keys = Vec::new();
4090 // Every loop variable in scope, outermost first. A nested `v-for` that
4091 // rebinds the same names shadows them, but either way an expression naming
4092 // one of them is read by the loop, so every enclosing scope is consulted.
4093 let mut loop_vars: Vec<(String, String)> = Vec::new();
4094 fn walk(nodes: &[Node], out: &mut Vec<String>, loop_vars: &mut Vec<(String, String)>) {
4095 for node in nodes {
4096 match node {
4097 Node::Interpolation(expr) => {
4098 let key = expr.trim().to_string();
4099 if key.is_empty() || out.contains(&key) {
4100 continue;
4101 }
4102 let roots: Vec<&str> = loop_vars
4103 .iter()
4104 .flat_map(|(item, index)| [item.as_str(), index.as_str()])
4105 .collect();
4106 if expr_reads_roots(&key, &roots) {
4107 continue;
4108 }
4109 out.push(key);
4110 }
4111 Node::Element {
4112 attrs, children, ..
4113 } => {
4114 let scope = attrs
4115 .iter()
4116 .find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
4117 .and_then(|a| a.value.as_deref())
4118 .and_then(parse_v_for)
4119 .map(|info| (info.item_name, info.index_name));
4120 if let Some(pair) = &scope {
4121 loop_vars.push(pair.clone());
4122 }
4123 walk(children, out, loop_vars);
4124 if scope.is_some() {
4125 loop_vars.pop();
4126 }
4127 }
4128 _ => {}
4129 }
4130 }
4131 }
4132 walk(nodes, &mut keys, &mut loop_vars);
4133 keys
4134}
4135
4136/// Is `expr` a bare identifier (no field access, call, or literal)?
4137fn is_bare_identifier(expr: &str) -> bool {
4138 let mut chars = expr.chars();
4139 match chars.next() {
4140 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
4141 _ => return false,
4142 }
4143 chars.all(is_ident_char)
4144}
4145/// The `State` method that provides the value for `key`, when that method is a
4146/// genuine zero-argument getter whose return type can be rendered as text.
4147/// Payload-taking methods are event handlers, not getters: `state.on_input()`
4148/// does not compile, so their names must never be registered as resolver keys.
4149/// Neither can a method that returns nothing (`state.reset()` has no `Display`
4150/// form), so the return type is checked as well.
4151fn getter_method_name<'a>(methods: &'a [StateMethod], key: &'a str) -> Option<&'a str> {
4152 find_state_method(methods, key, |m| {
4153 !m.takes_payload && return_type_is_renderable(m.return_type.as_deref())
4154 })
4155}
4156
4157/// A zero-argument `State` method that yields a value, whatever its type.
4158///
4159/// [`getter_method_name`] additionally requires that value to be renderable as
4160/// text, which is right when the arm renders the value itself
4161/// (`"title" => state.title().to_string()`) and wrong for the ROOT of a member
4162/// path: `{{ user.name }}` needs `state.user()` to return the user's own type,
4163/// and the `String` that renders is the last segment's. This is the same lookup
4164/// with the renderability test lifted, not a second notion of "answerable" —
4165/// both call sites go through [`find_state_method`], so the `get_`/`is_`/`has_`
4166/// convention and the payload test cannot drift between them.
4167fn accessor_method_name<'a>(methods: &'a [StateMethod], key: &'a str) -> Option<&'a str> {
4168 find_state_method(methods, key, |m| {
4169 !m.takes_payload && returns_a_value(m.return_type.as_deref())
4170 })
4171}
4172
4173/// Does the method yield something a template can go on to read?
4174///
4175/// Deliberately permissive: a user struct is a perfectly good start of a member
4176/// path even though it has no `Display` form of its own, and the inner segments
4177/// decide what the chain ends up producing. A unit return is the one case that
4178/// cannot start anything.
4179fn returns_a_value(ty: Option<&str>) -> bool {
4180 match ty.map(str::trim) {
4181 Some(ty) => !matches!(strip_borrow(ty), "()" | "Self" | ""),
4182 None => false,
4183 }
4184}
4185
4186/// The `State` method `key` names: the exact name first, then the `get_`/`is_`/
4187/// `has_` variants, keeping the first that satisfies `usable`.
4188fn find_state_method<'a>(
4189 methods: &'a [StateMethod],
4190 key: &str,
4191 usable: impl Fn(&StateMethod) -> bool,
4192) -> Option<&'a str> {
4193 if let Some(m) = methods.iter().find(|m| m.name == key && usable(m)) {
4194 return Some(m.name.as_str());
4195 }
4196 ["get_", "is_", "has_"].iter().find_map(|prefix| {
4197 let candidate = format!("{prefix}{key}");
4198 methods
4199 .iter()
4200 .find(|m| m.name == candidate && usable(m))
4201 .map(|m| m.name.as_str())
4202 })
4203}
4204
4205/// Can a method's declared return type be rendered as text?
4206///
4207/// A resolver arm calls `state.<getter>().to_string()`, so only types that
4208/// implement `Display` qualify: the string types, `bool`, `char`, the integer
4209/// and float primitives (templates already render `:class="{ done: completed }"`
4210/// from a `bool` getter, and the truthy check matches the resulting `"true"`),
4211/// and string wrappers. An absent return type means `()`, and collections,
4212/// `Result`/`Option` and framework handles have no `Display` form, so they are
4213/// rejected rather than guessed at.
4214fn return_type_is_renderable(ty: Option<&str>) -> bool {
4215 let Some(ty) = ty else {
4216 return false;
4217 };
4218 let base = strip_borrow(ty);
4219 if matches!(
4220 base,
4221 "String"
4222 | "str"
4223 | "bool"
4224 | "char"
4225 | "i8"
4226 | "i16"
4227 | "i32"
4228 | "i64"
4229 | "i128"
4230 | "isize"
4231 | "u8"
4232 | "u16"
4233 | "u32"
4234 | "u64"
4235 | "u128"
4236 | "usize"
4237 | "f32"
4238 | "f64"
4239 ) {
4240 return true;
4241 }
4242 ["Rc<", "Arc<", "Cow<"].iter().any(|wrapper| {
4243 base.strip_prefix(wrapper)
4244 .and_then(|inner| inner.strip_suffix('>'))
4245 .is_some_and(|inner| matches!(strip_borrow(inner.trim()), "String" | "str"))
4246 })
4247}
4248
4249/// Strip a leading borrow and lifetime from a type, e.g. `&'static str` → `str`.
4250fn strip_borrow(ty: &str) -> &str {
4251 let mut ty = ty.trim();
4252 if let Some(rest) = ty.strip_prefix('&') {
4253 ty = rest.trim_start();
4254 }
4255 if let Some(rest) = ty.strip_prefix('\'') {
4256 // A lifetime: `'static`, `'a`, or the anonymous `'_` of `Cow<'_, str>`.
4257 ty = match rest.strip_prefix("_,") {
4258 Some(after) => after.trim_start(),
4259 None => {
4260 let end = rest.find(|c: char| !is_ident_char(c)).unwrap_or(rest.len());
4261 rest[end..].trim_start()
4262 }
4263 };
4264 }
4265 ty
4266}
4267
4268/// Does `State` expose a zero-argument getter for `key`? Only expressions backed
4269/// by such a getter are registered as resolver keys, so a binding over a plain
4270/// `Signal`/`Ref` field (which has no getter to call) keeps generating
4271/// compiling code.
4272fn has_state_getter(methods: &[StateMethod], key: &str) -> bool {
4273 getter_method_name(methods, key).is_some()
4274}
4275
4276/// The call expression a resolver arm should make on `state` for `key`,
4277/// parentheses included: `title()`, `is_open()`, `user().name()`.
4278///
4279/// A reader of [`answerable`], which is where the choice between a getter, a
4280/// member chain and the name-only spelling is made. Nothing decides here, so the
4281/// arm an emitter writes and the arm a gate believed it was writing cannot be
4282/// two different arms.
4283///
4284/// Every key that reaches an arm was registered by a path that asked
4285/// [`answerable`] — `keep_answerable_interpolation_keys` registers an `Ok` and
4286/// nothing else, and the bound-attribute, `v-model` and condition paths all gate
4287/// on a question `answerable` answers — so the `Err` arm is dead, and
4288/// `every_registered_key_is_answerable` is what keeps it dead.
4289///
4290/// The guess is what the arm used to be in that case, kept byte for byte rather
4291/// than turned into a panic. An `Err` key cannot be answered by anything, so the
4292/// guess is a name `State` does not expose, and the arm it produces is a compile
4293/// error in the generated crate — not a silent wrong value. Codegen must not
4294/// turn a compile error into a process exit, and R-1e-1's second commit is where
4295/// the guess can go away for good, because by then the loop's `items` ask has
4296/// somewhere else to land.
4297fn resolve_getter_call(methods: &[StateMethod], fields: &[String], key: &str) -> String {
4298 match answerable(key, methods, fields) {
4299 Ok(answer) => answer.call(),
4300 Err(_) => format!("{}()", conventional_name(methods, key)),
4301 }
4302}
4303
4304/// The call chain a member path resolves to, or `None` when `key` is not one.
4305///
4306/// `user.name` → `user().name`, `a.b.c` → `a().b().c`, `items[0].name` →
4307/// `items()[0].name`: the root is the zero-argument accessor the arm already
4308/// looks up, and every later segment is a method call on what came before. An
4309/// index segment is a place rather than a method, so it is emitted as written
4310/// and the segments after it stay calls.
4311///
4312/// The whole chain is rewritten uniformly. Rewriting only the segments codegen
4313/// can vouch for would leave code whose legality depends on types it cannot
4314/// see, which is the silent-wrongness this exists to remove.
4315fn member_path_call(methods: &[StateMethod], key: &str) -> Option<String> {
4316 let (root, segments) = member_path(key)?;
4317 let mut chain = format!("{}()", accessor_method_name(methods, root)?);
4318 for segment in segments {
4319 if segment.starts_with('[') {
4320 chain.push_str(segment);
4321 } else {
4322 chain.push('.');
4323 chain.push_str(segment);
4324 chain.push_str("()");
4325 }
4326 }
4327 Some(chain)
4328}
4329
4330/// Split a member path into its root and the segments after it: `user.name` →
4331/// `("user", ["name"])`, `items[0].name` → `("items", ["[0]", "name"])`.
4332///
4333/// `None` for anything that is not a member path, so every caller keeps its
4334/// existing behaviour: a bare name has nothing to chain, and a segment that is
4335/// neither a name nor an index is not a path this can speak about.
4336///
4337/// A key with a call in it (`user.name()`) is `None` too, and **nothing in this
4338/// crate emits `state.user(state.name())`** — such a key falls through to the
4339/// name-only fallback, which is [`keep_answerable_interpolation_keys`]'s caller
4340/// and is deliberately not gated. That is pre-existing behaviour and out of
4341/// scope here; see the report.
4342fn member_path(key: &str) -> Option<(&str, Vec<&str>)> {
4343 if key.contains('(') || key.contains(')') {
4344 return None;
4345 }
4346 let split = key
4347 .find(['.', '['])
4348 .filter(|&i| i > 0 && key[..i].chars().all(is_ident_char))?;
4349 let root = &key[..split];
4350 let mut segments = Vec::new();
4351 // The separator that ended the root is not itself a segment: `user.name`
4352 // continues with `name`, and `items[0]` continues with the index.
4353 let rest = key[split..].strip_prefix('.').unwrap_or(&key[split..]);
4354 for segment in rest.split('.') {
4355 let is_index = segment.starts_with('[') && segment.ends_with(']');
4356 let is_name = !segment.is_empty() && segment.chars().all(is_ident_char);
4357 if !is_index && !is_name {
4358 return None;
4359 }
4360 segments.push(segment);
4361 }
4362 if segments.is_empty() {
4363 return None;
4364 }
4365 Some((root, segments))
4366}
4367
4368/// The shared identifier predicate: one definition for every site that asks
4369/// "is this character part of an identifier?".
4370///
4371/// ITS CONTRACT, for a caller: a scanner that enters on a *first-character* test
4372/// and then extends with this predicate must advance the cursor on the entry
4373/// character before extending. Otherwise the two predicates are coupled by an
4374/// invariant nobody wrote down — this one being wider than the entry test — and
4375/// narrowing it to exclude a character the entry test accepts leaves the cursor
4376/// where it is and the scanner spins forever. `condition_resolver_keys` and
4377/// `expr_reads_roots` both advance unconditionally for exactly that reason, and
4378/// `a_scanner_that_enters_on_a_first_char_test_advances_even_when_this_is_narrow`
4379/// pins it.
4380///
4381/// Narrowing it is otherwise a behaviour change at every call site, including the
4382/// two scanners' tokenisation, and must be measured rather than assumed.
4383fn is_ident_char(ch: char) -> bool {
4384 ch.is_ascii_alphanumeric() || ch == '_'
4385}
4386
4387/// The interpolation keys that have a getter, or a root accessor, to answer
4388/// them — with a warning for each one that has not.
4389///
4390/// A **bare name** (`{{ title }}`) is registered only when a zero-argument,
4391/// renderable `State` method answers it, because the arm is
4392/// `"title" => state.title().to_string()` and a `State` without `title` cannot
4393/// compile that. A **member path** (`{{ user.name }}`) is registered only when
4394/// its ROOT has a zero-argument accessor, because the arm is
4395/// `state.user().name().to_string()` and without a `user()` there is nothing to
4396/// call. Both decisions ask the same lookup the emitter asks, so a key that is
4397/// kept is a key whose arm has something to call.
4398///
4399/// A key that is neither a bare name nor a member path — `user.name()`, `a.b[0`
4400/// — is **not gated** and keeps the long-standing fallback. Those keys are
4401/// refused by [`member_path`] and are not a name a `State` method can have, so
4402/// the two rules above have nothing to say about them; gating them is a
4403/// separate task, and the report discloses them rather than claiming this
4404/// function closes them.
4405///
4406/// This is the same family diagnostic the bound-attribute path already reports,
4407/// asked of one predicate: the name the emitter will call, which is the ROOT of
4408/// a member path and the whole key otherwise. The decision reads
4409/// [`StateMethod`]s only — never the loop families, and never the `v-for`
4410/// collection — so it cannot silence a loop report.
4411/// Whether `key` has a resolver arm. This is the one question a key's origin must
4412/// not change: a bare name needs a getter of that name, and a member path needs
4413/// an accessor for its root plus a method for every segment after it. A key that
4414/// is neither — a call expression, say — is left alone, exactly as
4415/// `keep_answerable_interpolation_keys` leaves it, because an arm for it is the
4416/// fallback's business.
4417///
4418/// `keep_answerable_interpolation_keys` asks this for interpolated text and the
4419/// `v-model` path asks it for a two-way binding, so `{{ form.name }}` and
4420/// `v-model="form.name"` are answered or dropped together instead of one of them
4421/// quietly rendering empty.
4422fn key_is_answerable(methods: &[StateMethod], fields: &[String], key: &str) -> bool {
4423 answerable(key, methods, fields).is_ok()
4424}
4425
4426fn keep_answerable_interpolation_keys(
4427 methods: &[StateMethod],
4428 fields: &[String],
4429 keys: Vec<String>,
4430 warnings: &mut Vec<String>,
4431) -> Vec<String> {
4432 let mut registered = Vec::with_capacity(keys.len());
4433 for key in keys {
4434 // One predicate for the gate, so a key is registered exactly when it can
4435 // be read and the reason for a refusal is the same sentence the other
4436 // three call sites would print for it.
4437 let Err(unrenderable) = answerable(&key, methods, fields) else {
4438 registered.push(key);
4439 continue;
4440 };
4441 warnings.push(format!(
4442 "interpolation {{{{ {key} }}}} cannot be resolved — {}; the interpolation \
4443 renders empty. {}",
4444 unrenderable.root_reason, unrenderable.remedy
4445 ));
4446 }
4447 registered
4448}
4449
4450/// The Rust value one `:attr="expr"` binding emits, together with the resolver
4451/// keys that value looks up.
4452enum BindValue {
4453 /// `resolve("<key>")` — the authored expression is looked up verbatim.
4454 Lookup(String),
4455 /// Object-syntax `:class="{ cls: cond, ... }"` — the statements that add a
4456 /// class name to the element's class list when its condition is truthy, in
4457 /// authored order. They are kept apart from the block that joins them so a
4458 /// static `class` on the same element can be merged in; see
4459 /// [`class_block_value`].
4460 ClassObject { conditions: Vec<String> },
4461 /// A v-for loop variable or one of its fields, read directly.
4462 Direct(String),
4463}
4464
4465impl BindValue {
4466 /// The emitted Rust expression, ready to be interpolated into generated
4467 /// code (`resolve("key")`, a direct loop-variable read, or a block that
4468 /// joins class names).
4469 fn expr(&self) -> String {
4470 match self {
4471 BindValue::Lookup(expr) | BindValue::Direct(expr) => expr.clone(),
4472 BindValue::ClassObject { conditions } => class_block_value(conditions),
4473 }
4474 }
4475}
4476
4477/// The `class` prop value: the class names the element ends up with, joined by a
4478/// space. Each entry in `additions` adds a name — unconditionally for a static
4479/// `class`, and behind its condition for an object-syntax `:class` — so a
4480/// merged list is the same block with more entries, and the single-source form
4481/// is this block with one.
4482fn class_block_value(additions: &[String]) -> String {
4483 format!(
4484 "{{ let mut __classes: Vec<&str> = Vec::new(); {} __classes.join(\" \") }}",
4485 additions.join(" ")
4486 )
4487}
4488
4489/// The class names a static `class="a b"` attribute carries. A browser puts
4490/// every whitespace-separated name in the list, and so does the cascade that
4491/// reads it, so the split is here rather than in the stylesheet.
4492fn class_tokens(value: &str) -> Vec<String> {
4493 value.split_whitespace().map(str::to_string).collect()
4494}
4495
4496/// One normalized bound attribute.
4497struct BindEmission {
4498 value: BindValue,
4499 /// The resolver keys `value` looks up, in lookup order.
4500 keys: Vec<String>,
4501 /// The authored expression, kept for diagnostics.
4502 authored: String,
4503}
4504
4505/// Normalize a bound attribute for emission.
4506///
4507/// Every `resolve(...)` codegen emits for a `:attr="expr"` binding comes from
4508/// this function, and [`collect_resolver_keys`] reads the `keys` it reports, so
4509/// the lookups codegen emits and the keys the resolver registers cannot drift
4510/// apart.
4511///
4512/// `resolve_loop` is the `v-for` body the element sits in, set only when the
4513/// Resolve renderer is being generated. It is `None` for the State renderer,
4514/// where the loop item is a real binding and is read directly.
4515/// The Rust expression for one condition, and the resolver keys it looks up.
4516///
4517/// ONE function decides both, because the emitter and the key collector have to
4518/// agree. A condition whose keys are never registered is permanently falsy —
4519/// `resolve` answers an unregistered key with `""`, so `v-if` drops the element
4520/// and `:class` drops the class — and that is exactly how a root `v-if` and a
4521/// root `:class` parsed, compiled and then did nothing.
4522///
4523/// A condition rooted at the loop variable is a direct field read in the State
4524/// renderer and the loop's indexed resolver read in the Resolve one, so it
4525/// registers no key: the whole loop is reported as a family instead. Every other
4526/// condition is rewritten by [`rewrite_if_expr`] — this is the no-loop-scope half
4527/// of it, and the root `v-if`/`v-show`/`v-else` emit sites call that directly
4528/// because they have no loop scope to consult — and its keys come from
4529/// [`condition_resolver_keys`], which tokenises the same way.
4530fn condition_expr(
4531 expr: &str,
4532 item_name: Option<&str>,
4533 idx_name: Option<&str>,
4534 resolve_loop: Option<&VForInfo>,
4535) -> (String, Vec<String>) {
4536 match rewrite_ctx_expr(expr, item_name, idx_name) {
4537 Some(direct) => {
4538 let read = match resolve_loop {
4539 Some(info) => match resolve_loop_item_expr(&direct, info) {
4540 // The loop index is a number already. Any other loop read is a
4541 // resolver-backed `String` in the Resolve renderer, so it needs
4542 // the string truthiness to sit in an `if` position.
4543 Some(indexed) if indexed != info.index_name => resolver_truthiness(&indexed),
4544 _ => direct,
4545 },
4546 None => direct,
4547 };
4548 (read, Vec::new())
4549 }
4550 None => (rewrite_if_expr(expr), condition_resolver_keys(expr)),
4551 }
4552}
4553
4554fn emit_bind_attr(
4555 attr_name: &str,
4556 expr: &str,
4557 item_name: Option<&str>,
4558 idx_name: Option<&str>,
4559 resolve_loop: Option<&VForInfo>,
4560) -> BindEmission {
4561 let authored = if attr_name == "key" {
4562 // A `:key` value is normalized by the same helper the `v-for` key emit
4563 // sites use, so this path analyzes exactly the expression codegen emits
4564 // instead of the raw `{{ … }}` spelling. The `v-for` branches strip `:key`
4565 // out before emitting, so the only way a `:key` reaches here as a prop is
4566 // a non-`v-for` element — which is normalized consistently too.
4567 resolve_key_expr(expr)
4568 } else {
4569 expr.trim().to_string()
4570 };
4571
4572 if attr_name == "class" {
4573 let pairs = class_object_pairs(&authored);
4574 if !pairs.is_empty() {
4575 let mut conditions: Vec<String> = Vec::new();
4576 let mut keys: Vec<String> = Vec::new();
4577 for (cls, cond) in pairs {
4578 if cls.is_empty() || cond.is_empty() {
4579 continue;
4580 }
4581
4582 // A loop-rooted condition is a direct field read in the State
4583 // renderer, and a bare loop index is left as the direct read, which
4584 // is KNOWN-BROKEN in both renderers: a condition of `:class="{even:
4585 // i}"` becomes `if i`, a `usize` in `if` position (E0308). It is
4586 // left alone because no emitted form of it compiles without
4587 // inventing a `bool` the template never wrote.
4588 let (value, cond_keys) = condition_expr(&cond, item_name, idx_name, resolve_loop);
4589 for key in cond_keys {
4590 push_unique(&mut keys, key);
4591 }
4592 conditions.push(format!(
4593 "if {} {{ __classes.push({}); }}",
4594 value,
4595 string_lit(&cls)
4596 ));
4597 }
4598 if !conditions.is_empty() {
4599 return BindEmission {
4600 value: BindValue::ClassObject { conditions },
4601 keys,
4602 authored,
4603 };
4604 }
4605 }
4606 }
4607
4608 if let Some(direct) = rewrite_ctx_expr(&authored, item_name, idx_name) {
4609 // In the Resolve renderer the loop item is not a Rust binding, so a
4610 // loop-rooted binding is normalized to the indexed resolver read the
4611 // same loop body already uses for an interpolated `{ todo.text }`.
4612 // A root the loop cannot read that way (a compound expression such as
4613 // `todo.a + todo.b`) keeps the resolver lookup, so the diagnostic
4614 // reports it rather than leaving it silently empty.
4615 let direct = match resolve_loop {
4616 Some(info) => match resolve_loop_item_expr(&direct, info) {
4617 Some(indexed) => indexed,
4618 None => {
4619 return BindEmission {
4620 value: BindValue::Lookup(format!("resolve({})", string_lit(&authored))),
4621 keys: vec![authored.clone()],
4622 authored,
4623 };
4624 }
4625 },
4626 None => direct,
4627 };
4628 return BindEmission {
4629 value: BindValue::Direct(direct),
4630 keys: Vec::new(),
4631 authored,
4632 };
4633 }
4634
4635 BindEmission {
4636 value: BindValue::Lookup(format!("resolve({})", string_lit(&authored))),
4637 keys: vec![authored.clone()],
4638 authored,
4639 }
4640}
4641
4642/// The component-prop form of a normalized binding value: both are `String`.
4643fn bind_attr_value(
4644 attr_name: &str,
4645 expr: &str,
4646 item_name: Option<&str>,
4647 idx_name: Option<&str>,
4648) -> String {
4649 let emission = emit_bind_attr(attr_name, expr, item_name, idx_name, None);
4650 match &emission.value {
4651 BindValue::Direct(direct) => format!("format!(\"{{}}\", {direct})"),
4652 _ => format!("{}.clone()", emission.value.expr()),
4653 }
4654}
4655
4656/// The element-prop form of a normalized binding value (`Props::set` entry).
4657fn bind_prop_entry(
4658 key: &str,
4659 attr_name: &str,
4660 expr: &str,
4661 item_name: Option<&str>,
4662 idx_name: Option<&str>,
4663 resolve_loop: Option<&VForInfo>,
4664) -> String {
4665 let emission = emit_bind_attr(attr_name, expr, item_name, idx_name, resolve_loop);
4666 match &emission.value {
4667 BindValue::Direct(direct) => format!(r#".set("{}", &format!("{{}}", {direct}))"#, key),
4668 BindValue::ClassObject { conditions } => {
4669 format!(r#".set("class", {})"#, class_block_value(conditions))
4670 }
4671 BindValue::Lookup(value) => format!(r#".set("{}", &{value})"#, key),
4672 }
4673}
4674
4675/// The `class: condition` pairs of an object-syntax `:class="{ ... }"` binding.
4676fn class_object_pairs(expr: &str) -> Vec<(String, String)> {
4677 let trimmed = expr.trim();
4678 if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
4679 return Vec::new();
4680 }
4681 let inner = &trimmed[1..trimmed.len() - 1];
4682 inner
4683 .split(',')
4684 .filter_map(|pair| pair.split_once(':'))
4685 .map(|(cls, cond)| (strip_class_quotes(cls), cond.trim().to_string()))
4686 .filter(|(cls, cond)| !cls.is_empty() && !cond.is_empty())
4687 .collect()
4688}
4689
4690/// The class name an object-syntax `:class` key names, without the quotes an
4691/// author may write around it. `{'is-wide': cond}` names `is-wide`; keeping the
4692/// quotes would look for a class literally called `'is-wide'`, which no
4693/// stylesheet has.
4694fn strip_class_quotes(cls: &str) -> String {
4695 cls.trim()
4696 .trim_matches(|c| c == '\'' || c == '"')
4697 .trim()
4698 .to_string()
4699}
4700
4701/// The resolver keys a `v-if`/`:class` condition expression looks up.
4702///
4703/// Mirrors the tokens `rewrite_if_expr` turns into `resolve("<key>")` lookups —
4704/// through the same [`member_path_end`], so a member path is one key in both —
4705/// so a condition's operands are registered as keys — `:class` conditions
4706/// included — whether they are bare (`completed`), a member path
4707/// (`user.active`), negated (`!completed`) or part of a comparison
4708/// (`count > 0`). A key the loop emitter reads directly registers nothing, and
4709/// the loop that declares it is reported as a whole instead.
4710fn condition_resolver_keys(expr: &str) -> Vec<String> {
4711 let chars: Vec<char> = expr.chars().collect();
4712 let mut keys: Vec<String> = Vec::new();
4713 let mut i = 0;
4714
4715 while i < chars.len() {
4716 let c = chars[i];
4717 if c.is_ascii_alphabetic() || c == '_' {
4718 let start = i;
4719 // The advance past the entry character is UNCONDITIONAL. This scanner
4720 // enters on `[A-Za-z_]` and extends on `is_ident_char`, and a
4721 // scanner that enters on one predicate while extending on another
4722 // must advance on the entry character: if `is_ident_char` is ever
4723 // narrowed to exclude a character this test accepts, the old
4724 // `while is_ident_char(chars[i])` loop would not move the cursor and
4725 // this would spin forever. Every token is therefore at least one
4726 // character wide, which is what the old loop produced too, because
4727 // `is_ident_char` accepts every character the entry test accepts.
4728 // `member_path_end` advances on the entry character and then
4729 // extends over any `.segment` / `[index]` continuation, so the token
4730 // is the whole path — the same token `rewrite_if_expr` emits a
4731 // lookup for.
4732 i = member_path_end(&chars, start);
4733 let token: String = chars[start..i].iter().collect();
4734 // Literals and emitted code are not lookups.
4735 if !matches!(token.as_str(), "true" | "false" | "resolve" | "state") {
4736 keys.push(token);
4737 }
4738 } else {
4739 i += 1;
4740 }
4741 }
4742 keys
4743}
4744
4745/// A `v-for` in the Resolve renderer, and whether anything in its body reads the
4746/// loop in a way the renderer cannot answer.
4747struct ResolveLoopFamily {
4748 /// The collection expression, the one the renderer hands to `resolve(...)`.
4749 collection: String,
4750 /// Whether a binding, condition, `:key` or interpolation in the body reads
4751 /// the loop in a form the Resolve renderer cannot answer.
4752 ///
4753 /// THE DECISION RULE, in full:
4754 ///
4755 /// * A read of the loop ITEM always counts. Every item form is emitted as
4756 /// the indexed resolver read `items[0]…`, which no flat arm answers.
4757 /// * A read of the loop INDEX counts UNLESS the site emits the direct
4758 /// binding read. The two index forms that render are a bare-index binding
4759 /// — a `:bind`, including `:class` and `:style`, and the `:key` the loop
4760 /// branch reads itself (`format!("{}", index)`) — and a bare-index
4761 /// interpolation (`text(index.to_string())`): in each case exactly the
4762 /// expression that is the index name and nothing else. Any other
4763 /// expression naming the index, such as `i + 1` or `{a: i > 2}`, is
4764 /// emitted as the flat `resolve("…")` lookup, which answers `"""`, and so
4765 /// is a condition: `rewrite_if_expr` rewrites every bare identifier it is
4766 /// given into `resolve("<ident>")`, which is why `v-if="i"` is not a
4767 /// direct read however bare it is. The exemption is therefore per
4768 /// expression, never per loop: a direct index read does not launder a
4769 /// compound sibling on the same element.
4770 body_reads_loop: bool,
4771}
4772
4773/// How a site emits a read of the loop index.
4774#[derive(Clone, Copy, PartialEq)]
4775enum IndexEmission {
4776 /// The site emits the index itself — a binding or an interpolation — so a
4777 /// bare index name is the direct binding read.
4778 Direct,
4779 /// The site hands the expression to the flat resolver, so even a bare index
4780 /// name is an empty answer. A `v-for` collection emits
4781 /// `let __for_expr = resolve("<expr>")`, and a condition on a loop element
4782 /// is rewritten by `rewrite_if_expr` into `resolve("<ident>")`; see
4783 /// [`directive_emission`].
4784 Resolver,
4785}
4786
4787/// Where an attribute sits relative to the `v-for` it belongs to.
4788///
4789/// The two positions are emitted by different paths, and that difference — not
4790/// the attribute's name — decides how a read of the loop index is emitted. A
4791/// directive on the loop element is handled by `emit_node_with_mode` before the
4792/// loop branch, and its value goes through `rewrite_if_expr`; a directive on any
4793/// element inside the body is dropped by `emit_props_in_loop` and emits nothing.
4794#[derive(Clone, Copy, PartialEq)]
4795enum AttrSite {
4796 /// The element that carries the `v-for`.
4797 LoopElement,
4798 /// An element inside that loop's body, emitted by `emit_props_in_loop`.
4799 InLoopBody,
4800}
4801
4802/// How a directive emits a read of the loop index.
4803///
4804/// The classification follows the EMIT PATH, read off `emit_node_with_mode` and
4805/// `emit_props_in_loop`, not the directive's name. `rewrite_if_expr` rewrites
4806/// every bare identifier into `resolve("<ident>")`, so a condition never emits
4807/// the direct binding read — `v-if="i"` is `resolve("i")`, an empty answer.
4808///
4809/// The two fallback arms are deliberately set at opposite defaults, and the
4810/// asymmetry is the point:
4811///
4812/// - Inside the body, an unrecognised directive is `Direct`. Every directive
4813/// there is dropped by `emit_props_in_loop` (`:1600-1602`), so it emits no
4814/// read at all and there is nothing to be wrong about; `Resolver` there
4815/// would report loops that render perfectly.
4816/// - On the loop element, an unrecognised directive is `Resolver`. Today the
4817/// emitter handles only `v-for`, `v-if`/`v-else-if`/`v-else`, `v-show` and
4818/// `v-model`, and `v-model` is listed explicitly below, so nothing reaches
4819/// this arm in the current tree. A directive added later is the case this
4820/// rule has to be right about, and if it is routed through
4821/// `rewrite_if_expr` like every other condition then `Resolver` is the
4822/// answer — while `Direct` would silently exempt it. A read wrongly
4823/// exempted is the silent failure this rule exists to prevent (N3, N6), so
4824/// the unknown case is reported rather than dismissed; an extra sentence in
4825/// a diagnostic is the cheap direction to be wrong in.
4826fn directive_emission(name: &str, site: AttrSite) -> IndexEmission {
4827 match (name, site) {
4828 // `v-for` is not classified here: its value is `(item, i) in expr`, not
4829 // a read, so the caller has already parsed out the collection and passes
4830 // that to `expr_reads_loop_var` itself — see `attrs_read_loop_root`.
4831 //
4832 // Conditions on the loop element: `v-if` (which owns the whole
4833 // `v-if`/`v-else-if`/`v-else` chain) and `v-show` are both rewritten by
4834 // `rewrite_if_expr` in `emit_node_with_mode`, before the loop branch,
4835 // so the read is a resolver lookup. `v-else-if` on the same element as
4836 // the `v-for` is rejected by the parser, but it is listed with its
4837 // chain rather than left to the fallback.
4838 ("if" | "show" | "else-if" | "elseif" | "else", AttrSite::LoopElement) => {
4839 IndexEmission::Resolver
4840 }
4841 // Every directive inside the body is dropped by `emit_props_in_loop`,
4842 // so it emits no read at all and the direct index exemption is right.
4843 (_, AttrSite::InLoopBody) => IndexEmission::Direct,
4844 // `v-model` becomes a `:value` bind plus an `@input` handler in
4845 // `extract_vmodel` before the loop branch, so its value read is a
4846 // bind's — the same path as `:value`, which renders. This is the only
4847 // loop-element directive that is not a condition, and naming it keeps
4848 // the fallback below free to fail loud.
4849 ("model", AttrSite::LoopElement) => IndexEmission::Direct,
4850 // Any directive the emitter does not handle today. Treated as a
4851 // resolver read: see the note on the two fallbacks above.
4852 (_, AttrSite::LoopElement) => IndexEmission::Resolver,
4853 }
4854}
4855
4856/// Whether `attrs` read either loop variable in a form the Resolve renderer
4857/// cannot answer.
4858fn attrs_read_loop_root(attrs: &[TemplateAttr], item: &str, index: &str, site: AttrSite) -> bool {
4859 attrs.iter().any(|a| match a.kind {
4860 AttrKind::Bind => a
4861 .value
4862 .as_deref()
4863 .map(|value| expr_reads_loop_var(value, item, index, IndexEmission::Direct))
4864 .unwrap_or(false),
4865 AttrKind::Directive => a
4866 .value
4867 .as_deref()
4868 // A `v-for` introduces its loop variables rather than reading them,
4869 // so only its collection expression can read an enclosing loop.
4870 .map(|value| match parse_v_for(value) {
4871 Some(info) => expr_reads_loop_var(&info.expr, item, index, IndexEmission::Resolver),
4872 None => expr_reads_loop_var(value, item, index, directive_emission(&a.name, site)),
4873 })
4874 .unwrap_or(false),
4875 AttrKind::Static | AttrKind::On => false,
4876 })
4877}
4878
4879/// Whether anything in `node`'s subtree reads either loop variable in a form
4880/// the Resolve renderer cannot answer.
4881///
4882/// A nested `v-for` rebinding those same names shadows them for its own
4883/// attributes. It does NOT shadow them for its children: those children see the
4884/// NESTED binding, not the outer one, so a read there is attributed to the
4885/// outer loop as well. That over-attributes — a nested loop body cannot really
4886/// be reading the outer item — and the rule deliberately errs that way, because
4887/// a false positive here is one extra sentence in a diagnostic while a false
4888/// negative is a loop that renders empty with nothing said about it.
4889fn reads_loop_root(node: &Node, item: &str, index: &str) -> bool {
4890 match node {
4891 Node::Text(_) => false,
4892 Node::Interpolation(expr) => expr_reads_loop_var(expr, item, index, IndexEmission::Direct),
4893 Node::Element {
4894 attrs, children, ..
4895 } => {
4896 let roots = [item, index];
4897 let shadowed = attrs
4898 .iter()
4899 .find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
4900 .and_then(|a| a.value.as_deref())
4901 .and_then(parse_v_for)
4902 .is_some_and(|info| {
4903 !roots.is_empty()
4904 && roots
4905 .iter()
4906 .all(|root| *root == info.item_name || *root == info.index_name)
4907 });
4908 (!shadowed && attrs_read_loop_root(attrs, item, index, AttrSite::InLoopBody))
4909 || children.iter().any(|c| reads_loop_root(c, item, index))
4910 }
4911 }
4912}
4913
4914/// Whether an authored expression reads either loop variable — as the variable
4915/// itself, as a field path rooted at it, or anywhere inside a compound
4916/// expression such as `todo.a == todo.b` — in a form the resolver cannot
4917/// answer, per the rule on [`ResolveLoopFamily::body_reads_loop`].
4918fn expr_reads_loop_var(expr: &str, item: &str, index: &str, emission: IndexEmission) -> bool {
4919 if expr_reads_roots(expr, &[item]) {
4920 return true;
4921 }
4922 expr_reads_roots(expr, &[index])
4923 && (emission == IndexEmission::Resolver || expr.trim() != index)
4924}
4925
4926/// Whether an authored expression reads either loop variable at all, with no
4927/// exemption for the direct index read.
4928///
4929/// This is the per-BINDING skip's test, not the family's: a binding rooted at
4930/// the loop — including a compound one — is left to the loop's own message,
4931/// which says the whole body cannot render. Whether that body is a false
4932/// positive is decided by the family, above, not here.
4933fn expr_reads_loop_root(expr: &str, item: &str, index: &str) -> bool {
4934 expr_reads_roots(expr, &[item, index])
4935}
4936
4937/// Whether an authored expression names `root` as an identifier token.
4938fn expr_reads_roots(expr: &str, roots: &[&str]) -> bool {
4939 let chars: Vec<char> = expr.chars().collect();
4940 let mut i = 0;
4941
4942 while i < chars.len() {
4943 if chars[i].is_ascii_alphabetic() || chars[i] == '_' {
4944 let start = i;
4945 // The advance past the entry character is UNCONDITIONAL, for the same
4946 // reason as in `condition_resolver_keys`: entering on `[A-Za-z_]` and
4947 // extending on `is_ident_char` means the cursor must move on the entry
4948 // character, or a narrowing of `is_ident_char` that excludes a
4949 // character this test accepts leaves the cursor where it is and this
4950 // loop never terminates. Every token stays at least one character wide,
4951 // which is what the old `while is_ident_char` produced too.
4952 i += 1;
4953 while i < chars.len() && is_ident_char(chars[i]) {
4954 i += 1;
4955 }
4956 let token: String = chars[start..i].iter().collect();
4957 if roots.contains(&token.as_str()) {
4958 return true;
4959 }
4960 } else {
4961 i += 1;
4962 }
4963 }
4964 false
4965}
4966
4967/// The diagnostic for one `v-for` in the Resolve renderer.
4968///
4969/// That renderer's resolver is a flat `&str -> String` table built once, outside
4970/// every loop. An arm is only useful to a loop when it is backed by a real
4971/// zero-argument `State` getter: a key collected from an interpolation elsewhere
4972/// in the template is emitted as `"<key>" => state.<key>().to_string()` against a
4973/// struct that has no such field, so it does not answer the collection either.
4974/// With no arm the loop counts zero items and never runs its body at all; with an
4975/// arm the loop item is still read as `items[0].name`, which no arm answers, so
4976/// every read of the ITEM is empty while a direct read of the index still renders.
4977/// One message says so for the whole body rather than one contradictory message
4978/// per binding, and it also names the case it cannot check — a read the emitter
4979/// drops — rather than asserting that every value in the body is empty.
4980fn resolve_loop_warning(family: &ResolveLoopFamily, methods: &[StateMethod]) -> String {
4981 let collection = &family.collection;
4982 let has_arm = has_state_getter(methods, collection);
4983 let cause = if has_arm {
4984 format!(
4985 "the loop's values are read as indexed resolver keys (`{collection}[0]…`), which \
4986 no resolver arm answers, so every read of the loop ITEM renders empty — only a \
4987 direct read of the loop index, such as `{{{{ index }}}}`, survives. This is also \
4988 reported for a read that the emitter DROPS rather than makes, such as a condition \
4989 on an element inside the body: that read renders nothing to be wrong, but it \
4990 cannot be checked from here, and a loop reported for the wrong reason is better \
4991 than one that is broken for the wrong reason without saying so"
4992 )
4993 } else {
4994 format!(
4995 "`{collection}` is not a registered `State` getter, so the resolver returns an empty \
4996 string for it, the loop counts zero items and its body never runs at all"
4997 )
4998 };
4999 format!(
5000 "`v-for` over `{collection}` cannot render in Resolve mode — {cause}. Render this \
5001 component through `render_with_state`, which reads the collection and the loop item \
5002 directly."
5003 )
5004}
5005
5006/// The fatal diagnostic for a `v-for` whose collection nothing can supply.
5007///
5008/// A loop whose collection answers `""` counts zero items and never runs its
5009/// body, so the component renders with the loop simply absent — no empty
5010/// string, no placeholder, no clue that anything was asked for. That is the one
5011/// failure a build should not carry silently, so it fails the build instead.
5012/// The other Resolve-mode loop defect, where an arm DOES back the collection
5013/// and only the loop ITEM's reads are empty, stays a warning: there the loop
5014/// runs, and the warning says which quantity is wrong.
5015fn unrenderable_collection_error(collection: &str, props: &PropsChannel<'_>) -> String {
5016 // Each branch ends in a full stop, because the `format!` below adds one and
5017 // a remedy that already carries its own would read `..` here.
5018 let cause = if let Some(field) = props.field(collection) {
5019 format!(
5020 "it is declared as a `Props` field, but as `{}` rather than a collection type, so \
5021 the generated `for` cannot read it: a `Vec<T>`, a slice or an array is what a \
5022 `v-for` can iterate",
5023 field.ty
5024 )
5025 } else {
5026 match answerable(collection, &[], props.state_fields) {
5027 // `answerable` has already named the mistake and the fix; the fix
5028 // sentence is lifted whole, so this reports the same defect the same
5029 // way every other gate does.
5030 Err(unrenderable) => unrenderable.remedy.trim_end_matches('.').to_string(),
5031 // Reached only for a collection that is an EXPRESSION rather than a
5032 // name — `items()`, `a + b` — which is neither a member path nor a
5033 // bare identifier, so no `State` method can be named after it and
5034 // the resolver table, which is keyed by name, has no key for it.
5035 Ok(_) => "the resolver table is keyed by name, and this collection is an expression \
5036 rather than a name, so no arm can answer it"
5037 .to_string(),
5038 }
5039 };
5040 format!(
5041 "`v-for` over `{collection}` cannot render — {cause}. The resolver is a flat \
5042 `\"<key>\" => state.<getter>().to_string()` table built once, outside every loop, so the \
5043 loop asks it for the collection and it answers an empty string, the count is zero, and \
5044 the body never runs at all. Give the loop something it can read: declare `{collection}` \
5045 in this component's `pub struct Props` as a `Vec<T>`, give it a zero-argument `State` \
5046 getter, or render this component through `render_with_state`, which reads the collection \
5047 and the loop item directly."
5048 )
5049}
5050
5051/// The resolver keys to register, plus diagnostics for bound expressions that
5052/// cannot be resolved.
5053///
5054/// `errors` is the one family that is fatal rather than reported: a `v-for`
5055/// whose collection nothing can supply. Everything else a key cannot be read
5056/// for degrades to an empty string, which is visible in the output; a loop that
5057/// never runs is not.
5058struct ResolverKeys {
5059 keys: Vec<String>,
5060 warnings: Vec<String>,
5061 errors: Vec<String>,
5062}
5063
5064/// Collect every key the generated render path looks up through `resolve(...)`.
5065///
5066/// Interpolation keys are the historical source; bound attributes are emitted
5067/// as `resolve("..."")` just the same (`:value="draft"`, `:placeholder="hint"`,
5068/// `:click-payload="index"`), as are the conditions of an object-syntax
5069/// `:class`. Both sides are derived from [`emit_bind_attr`], so a key codegen
5070/// emits is always a key this registers or a diagnostic it reports.
5071///
5072/// The generated module carries two renderers: `render_with` (Resolve mode,
5073/// which only has the resolver's strings) and `render_with_state` (State mode,
5074/// which reads loop items and fields directly). A binding rooted at a loop
5075/// variable is a real read in both: a field read in State mode, an indexed
5076/// resolver lookup in Resolve mode. Neither registers a key, so `mode` does not
5077/// change which bound expressions are reportable. Every diagnostic is therefore
5078/// mode-independent: it describes a key the resolver cannot satisfy in either
5079/// renderer — a loop-rooted expression the loop cannot read, such as
5080/// `todo.a + todo.b`, among them.
5081fn collect_resolver_keys(
5082 nodes: &[Node],
5083 methods: &[StateMethod],
5084 fields: &[String],
5085 mode: RenderMode,
5086 props: &PropsChannel<'_>,
5087) -> ResolverKeys {
5088 let mut warnings: Vec<String> = Vec::new();
5089 let mut errors: Vec<String> = Vec::new();
5090 // An interpolation key is registered only when the arm for it can be
5091 // answered; a member path whose root has no accessor is reported instead.
5092 let mut keys = keep_answerable_interpolation_keys(
5093 methods,
5094 fields,
5095 collect_interpolation_keys(nodes),
5096 &mut warnings,
5097 );
5098 // Resolve-mode `v-for` bodies, reported once the key set is final: whether a
5099 // collection has a resolver arm depends on every key collected in the tree.
5100 let mut resolve_loops: Vec<ResolveLoopFamily> = Vec::new();
5101
5102 // The `v-for` bindings in scope for a node: the two loop variables the
5103 // emitted bindings read directly, and the parse they came from, which
5104 // Resolve mode needs to route a loop-rooted bind through the indexed
5105 // resolver. They are one value because they are always in scope together —
5106 // a node either declares a `v-for` or inherits its parent's — so carrying
5107 // them as three parallel `Option`s invited exactly the mixed-scope bugs the
5108 // tuple form already made impossible.
5109 #[derive(Clone, Copy)]
5110 struct LoopScope<'a> {
5111 item_name: Option<&'a str>,
5112 idx_name: Option<&'a str>,
5113 info: Option<&'a VForInfo>,
5114 }
5115
5116 fn walk(
5117 nodes: &[Node],
5118 methods: &[StateMethod],
5119 fields: &[String],
5120 mode: RenderMode,
5121 scope: LoopScope<'_>,
5122 keys: &mut Vec<String>,
5123 warnings: &mut Vec<String>,
5124 resolve_loops: &mut Vec<ResolveLoopFamily>,
5125 ) {
5126 for node in nodes {
5127 let Node::Element {
5128 attrs, children, ..
5129 } = node
5130 else {
5131 continue;
5132 };
5133 // A `v-for` element is emitted — props and children alike — with its
5134 // own loop variables in scope, which the bindings read directly.
5135 let loop_scope = attrs
5136 .iter()
5137 .find(|a| matches!(a.kind, AttrKind::Directive) && a.name == "for")
5138 .and_then(|a| a.value.as_deref())
5139 .and_then(parse_v_for);
5140 let scope = match &loop_scope {
5141 Some(info) => LoopScope {
5142 item_name: Some(info.item_name.as_str()),
5143 idx_name: Some(info.index_name.as_str()),
5144 info: Some(info),
5145 },
5146 None => scope,
5147 };
5148 let LoopScope {
5149 item_name,
5150 idx_name,
5151 info: loop_info,
5152 } = scope;
5153 // A `v-for` is one whole family: the collection it counts and every
5154 // value its body reads. It is reported once, below, from that
5155 // collection's getter alone — not from the collected key set, which
5156 // counts interpolation keys too and would silence a loop whose
5157 // collection has no arm.
5158 //
5159 if let (RenderMode::Resolve, Some(info)) = (mode, &loop_scope) {
5160 resolve_loops.push(ResolveLoopFamily {
5161 collection: info.expr.clone(),
5162 body_reads_loop: attrs_read_loop_root(
5163 attrs,
5164 &info.item_name,
5165 &info.index_name,
5166 AttrSite::LoopElement,
5167 ) || children
5168 .iter()
5169 .any(|c| reads_loop_root(c, &info.item_name, &info.index_name)),
5170 });
5171 }
5172 for attr in attrs {
5173 // A `v-model` desugars to a `:value` bind at emit time, so its
5174 // key is not in the AST this walks. It is collected here
5175 // instead, through the same gate every other key goes through,
5176 // so a `v-model` value reads a real getter or reports why it
5177 // cannot. The write half is reported on its own below.
5178 if matches!(attr.kind, AttrKind::Directive) && attr.name == "model" {
5179 let Some(expr) = attr.value.as_deref() else {
5180 continue;
5181 };
5182 if vmodel_is_loop_rooted(expr, item_name, idx_name) {
5183 // The read is a direct field read of the loop item in
5184 // State mode and an indexed resolver lookup in Resolve
5185 // mode, so it registers no key. The write is the part
5186 // that cannot be routed, and in Resolve mode the write
5187 // half does not exist at all.
5188 if matches!(mode, RenderMode::State) {
5189 warnings.push(vmodel_loop_write_warning(expr));
5190 }
5191 continue;
5192 }
5193 // The same question the interpolation gate asks, so a dotted
5194 // `v-model` is answered by the same method chain `{{ form.name }}`
5195 // reads, and an unanswerable one is reported in the same terms.
5196
5197 if key_is_answerable(methods, fields, expr) {
5198 push_unique(keys, expr);
5199 } else {
5200 // The write is named, not claimed: the generated setter is
5201 // `VModel::vmodel_set(&self.{expr}, payload)`, so it
5202 // compiles only when every segment of `{expr}` is a field
5203 // of `State` and the last one is a type `VModel` is
5204 // implemented for. Saying the write works when the
5205 // expression names no such field would promise a
5206 // capability the emitted code does not have.
5207 warnings.push(format!(
5208 "v-model=\"{expr}\" cannot render its value — {}; the input renders \
5209 empty. The write needs every segment of `{expr}` to be a `State` \
5210 field and the last one to be a type `VModel` is implemented for \
5211 (`Signal<T>`, `RefCell<String>` or `Cell<T>`); otherwise the \
5212 generated setter does not compile. Bind a zero-argument `State` \
5213 getter, or a method for each segment of a dotted expression.",
5214 unresolvable_key_reason(expr, methods, fields)
5215 ));
5216 }
5217
5218 if matches!(mode, RenderMode::Resolve) {
5219 warnings.push(vmodel_resolve_mode_warning(expr));
5220 }
5221 continue;
5222 }
5223 // A `v-if` / `v-show` condition is a resolver read like any other:
5224 // `rewrite_if_expr` emits `resolve("<key>")` for each operand, and
5225 // an unregistered key answers `""` — which is falsy — so the element
5226 // never appears and a `v-show` element is hidden forever. The keys
5227 // are collected here, through the same gate every other key goes
5228 // through, which is what makes the condition live. `v-show` is on
5229 // this path deliberately: it reads its condition the same way `v-if`
5230 // does, so it is registered the same way or not at all. `v-else-if`
5231 // and its `v-elseif` spelling are on it too, for the same reason and
5232 // with the same consequence: the emitter rewrites the chain's
5233 // condition through `rewrite_if_expr` exactly as it does a `v-if`'s,
5234 // so an unregistered operand there leaves a `resolve("")` that is
5235 // always falsy and the `v-else-if` element never renders. A `v-else`
5236 // is absent because it has no condition of its own.
5237 if matches!(attr.kind, AttrKind::Directive)
5238 && matches!(attr.name.as_str(), "if" | "show" | "else-if" | "elseif")
5239 {
5240 let Some(expr) = attr.value.as_deref() else {
5241 continue;
5242 };
5243 // A condition rooted at the loop variable is a field read, not a
5244 // lookup, so it registers nothing; the loop that declares it is
5245 // reported as a whole by the family diagnostic below.
5246 if expr_reads_loop_root(expr, item_name.unwrap_or(""), idx_name.unwrap_or("")) {
5247 continue;
5248 }
5249 for key in condition_resolver_keys(expr) {
5250 if key_is_answerable(methods, fields, &key) {
5251 push_unique(keys, &key);
5252 } else {
5253 warnings.push(condition_unresolvable_warning(
5254 &attr.name, expr, &key, methods, fields,
5255 ));
5256 }
5257 }
5258 continue;
5259 }
5260 if !matches!(attr.kind, AttrKind::Bind) {
5261 continue;
5262 }
5263 let Some(expr) = attr.value.as_deref() else {
5264 continue;
5265 };
5266 // The Resolve renderer reads a loop-rooted binding through the
5267 // indexed resolver the loop body itself uses; the State renderer
5268 // reads the loop item directly. Either way it registers no key,
5269 // and the whole loop is reported once, below.
5270 let resolve_loop = match mode {
5271 RenderMode::Resolve => loop_info,
5272 RenderMode::State => None,
5273 };
5274 let emission = emit_bind_attr(&attr.name, expr, item_name, idx_name, resolve_loop);
5275 if matches!(emission.value, BindValue::Direct(_)) {
5276 continue;
5277 }
5278
5279 for key in &emission.keys {
5280 if has_state_getter(methods, key) {
5281 push_unique(keys, key);
5282 continue;
5283 }
5284 // A loop-rooted binding inside a Resolve-mode `v-for` is
5285 // already covered, as a whole, by the loop's own diagnostic
5286 // below: that one says the loop cannot render and why. A
5287 // second message about the same binding would only restate
5288 // it. A binding that is not rooted at the loop is an ordinary
5289 // one and is still reported.
5290 if matches!(mode, RenderMode::Resolve)
5291 && loop_info.is_some()
5292 && expr_reads_loop_root(
5293 expr,
5294 item_name.unwrap_or(""),
5295 idx_name.unwrap_or(""),
5296 )
5297 {
5298 continue;
5299 }
5300 // Only bare, zero-argument State getters with a renderable
5301 // return type can be looked up. Everything else is a mistake
5302 // worth saying out loud rather than rendering as an empty
5303 // string.
5304 let reason = unresolvable_key_reason(key, methods, fields);
5305 warnings.push(format!(
5306 "bound attribute :{}=\"{}\" cannot be resolved — {reason}; \
5307 the binding renders empty. Bind a zero-argument State getter instead.",
5308 attr.name, emission.authored
5309 ));
5310 }
5311 }
5312 walk(
5313 children,
5314 methods,
5315 fields,
5316 mode,
5317 LoopScope {
5318 item_name,
5319 idx_name,
5320 info: loop_info,
5321 },
5322 keys,
5323 warnings,
5324 resolve_loops,
5325 );
5326 }
5327 }
5328
5329 walk(
5330 nodes,
5331 methods,
5332 fields,
5333 mode,
5334 LoopScope {
5335 item_name: None,
5336 idx_name: None,
5337 info: None,
5338 },
5339 &mut keys,
5340 &mut warnings,
5341 &mut resolve_loops,
5342 );
5343 // Loop families arrive only from Resolve mode, so every report below
5344 // describes the Resolve renderer and none of them needs a mode test.
5345 //
5346 // That is not a limitation of where the family is COLLECTED but where the
5347 // defect is: in State mode an unsupplied collection generates
5348 // `state.<name>.get()`, and `rustc` rejects that outright, so a hole there
5349 // is already loud at compile time. In Resolve mode the same hole
5350 // compiles and renders nothing, which is why it is a report and, below, a
5351 // refusal. Only the silent mode needs to be loud about it.
5352 for family in &resolve_loops {
5353 // Only a real `State` getter backs an arm that can answer the
5354 // collection; a key that merely appears as an interpolation elsewhere in
5355 // the template does not, and treating it as one would leave this loop
5356 // silent and unrenderable.
5357 if has_state_getter(methods, &family.collection) {
5358 if family.body_reads_loop {
5359 warnings.push(resolve_loop_warning(family, methods));
5360 }
5361 continue;
5362 }
5363 // No getter behind the arm. Three things can hand a loop a collection,
5364 // and this is the whole list: a declared `Props` field, a `State` field
5365 // a renderer reads directly, and a `State` getter. The first two are
5366 // collections this component already HOLDS, so the renderer that reads
5367 // one of them binds the item and the loop runs — those are reported
5368 // (the Resolve renderer counts a string they do not back) and not
5369 // refused, because the component is not broken.
5370 //
5371 // What is left is a name the component's own declaration does not
5372 // contain. Whether that is a mistake or the caller's business turns on
5373 // whether the component's inventory of supplyable names is CLOSED, and
5374 // that is what decides fatal from reported.
5375 if props.holds_collection(&family.collection) {
5376 warnings.push(held_collection_loop_warning(&family.collection, props));
5377 } else if props.inventory_is_closed() {
5378 // Closed inventory: only this component's declared `Props` fields
5379 // can be supplied, and this name is not one of them, so no arm can
5380 // ever answer it. The loop counts zero items and its body never
5381 // runs — a list-shaped hole with nothing in the output to show for
5382 // it. A warning on stderr does not stop that from shipping; the
5383 // `Err` does, so this is where refusing is sound.
5384 errors.push(unrenderable_collection_error(&family.collection, props));
5385 } else {
5386 // Open inventory: the generated `render_with` entry point takes a
5387 // caller closure `|name| String` that can supply ANY name, so a
5388 // name outside this component's declaration is not a typo — it is
5389 // the caller's, bound at run time through that closure. Refusing
5390 // here would make a working template unbuildable, which is the
5391 // exact mirror of the defect this gate exists to close, so the
5392 // pre-existing report stands unchanged: the loop may still go
5393 // unanswered, and this is the one place that says so.
5394 warnings.push(resolve_loop_warning(family, methods));
5395 }
5396 }
5397 ResolverKeys {
5398 keys,
5399 warnings,
5400 errors,
5401 }
5402}
5403
5404/// The warning for a `v-for` over a collection this component already holds, for
5405/// the renderers that count the collection's string instead of reading the
5406/// field it is held in.
5407///
5408/// Not fatal, and the difference matters: the collection IS here, so the
5409/// renderer that reads it renders the loop. What is missing is a getter behind
5410/// the string the OTHER renderer counts, so the message names which renderer
5411/// reads the field and which one is left with nothing.
5412fn held_collection_loop_warning(collection: &str, props: &PropsChannel<'_>) -> String {
5413 let (holding, reads_it) = match props.field(collection) {
5414 Some(field) => (
5415 format!("a `Props` field of type `{}`", field.ty),
5416 "`render_with_props`",
5417 ),
5418 None => ("a `State` field".to_string(), "`render_with_state`"),
5419 };
5420 format!(
5421 "`v-for` over `{collection}` is {holding}, so {reads_it} reads it directly and binds the \
5422 loop item — but `render_with` counts the resolver's string for it instead, and there is \
5423 no zero-argument `State` getter behind that string, so `render_with` counts zero items \
5424 and never runs the body. A parent that renders this component through {reads_it} is \
5425 unaffected; a parent that calls `render_with` is not. Give `{collection}` a \
5426 zero-argument `State` getter and both agree."
5427 )
5428}
5429
5430fn push_unique(out: &mut Vec<String>, key: impl AsRef<str>) {
5431 let key = key.as_ref();
5432 if !out.iter().any(|k| k == key) {
5433 out.push(key.to_string());
5434 }
5435}
5436
5437/// Generate setter methods on the State struct for v-model fields.
5438/// For `v-model="counter"` with State containing `counter: Cell<i32>`,
5439/// generates:
5440/// ```ignore
5441/// pub fn __vmodel_set_counter(&self, payload: &str) {
5442/// velox_core::vmodel::VModel::vmodel_set(&self.counter, payload);
5443/// }
5444/// ```
5445pub fn generate_vmodel_setters(vmodels: &[(String, String)]) -> String {
5446 if vmodels.is_empty() {
5447 return String::new();
5448 }
5449 let mut out = String::new();
5450 for (expr, handler) in vmodels {
5451 let field_path = format!("self.{}", expr);
5452 out.push_str(&format!(
5453 r#"
5454 pub fn {handler}(&self, payload: &str) {{
5455 velox_core::vmodel::VModel::vmodel_set(&{field_path}, payload);
5456 }}"#,
5457 handler = handler,
5458 field_path = field_path,
5459 ));
5460 }
5461 out
5462}
5463
5464#[cfg(test)]
5465mod tests {
5466 use super::*;
5467
5468 fn methods(script: &str) -> Vec<StateMethod> {
5469 extract_state_methods(script)
5470 }
5471
5472 /// The `PropsChannel` for a test that compiles a component declaring no
5473 /// `Props` of its own: no declared fields, and the `State` field list the
5474 /// caller already passes as its third argument — that list IS the `State`
5475 /// fields, so a test asking whether a collection is a declared prop and
5476 /// whether it is a `State` field is answered from the same names the real
5477 /// codegen pass sees, rather than from a fixture that answers both `no`.
5478 fn props_of(state_fields: &[String]) -> PropsChannel<'_> {
5479 PropsChannel {
5480 fields: &[],
5481 root: "props",
5482 state_fields,
5483 children: &[],
5484 }
5485 }
5486
5487 /// A bound expression that cannot become a resolver lookup has to be
5488 /// reported, not silently rendered as an empty string.
5489 #[test]
5490 fn unsupported_bound_expression_is_reported_as_a_warning() {
5491 let nodes =
5492 crate::template_parse::parse_template_to_ast(r#"<input :value="draft.trim()" />"#)
5493 .unwrap();
5494 let collected =
5495 collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
5496
5497 assert!(
5498 collected.keys.is_empty(),
5499 "an unresolvable expression must not be registered: {:?}",
5500 collected.keys
5501 );
5502 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
5503 let warning = &collected.warnings[0];
5504 assert!(warning.contains(":value=\"draft.trim()\""), "{warning}");
5505 assert!(warning.contains("`draft.trim()`"), "{warning}");
5506 assert!(warning.contains("renders empty"), "{warning}");
5507 }
5508
5509 /// A bound name that is a payload-taking method is reported the same way:
5510 /// registering it would emit `state.on_input().to_string()`.
5511 #[test]
5512 fn payload_taking_handler_binding_is_reported_as_a_warning() {
5513 let script = "impl State { pub fn on_input(&self, payload: &str) { let _ = payload; } }";
5514 let nodes =
5515 crate::template_parse::parse_template_to_ast(r#"<input :value="on_input" />"#).unwrap();
5516 let collected = collect_resolver_keys(
5517 &nodes,
5518 &methods(script),
5519 &[],
5520 RenderMode::State,
5521 &props_of(&[]),
5522 );
5523
5524 assert!(collected.keys.is_empty(), "{:?}", collected.keys);
5525 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
5526 assert!(
5527 collected.warnings[0].contains("payload-taking State method"),
5528 "{}",
5529 collected.warnings[0]
5530 );
5531 }
5532
5533 /// A binding over a `Signal`/`Ref` field has no getter, so it cannot be
5534 /// looked up: the author is told instead of getting an empty render.
5535 #[test]
5536 fn field_backed_binding_is_reported_as_a_warning() {
5537 let script = "pub struct State { pub count: std::rc::Rc<velox_core::signal::Signal<i32>> }";
5538 let nodes =
5539 crate::template_parse::parse_template_to_ast(r#"<input :value="count" />"#).unwrap();
5540 let collected = collect_resolver_keys(
5541 &nodes,
5542 &methods(script),
5543 &["count".to_string()],
5544 RenderMode::State,
5545 &props_of(&["count".to_string()]),
5546 );
5547
5548 assert!(collected.keys.is_empty(), "{:?}", collected.keys);
5549 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
5550 let warning = &collected.warnings[0];
5551 assert!(warning.contains(":value=\"count\""), "{warning}");
5552 assert!(warning.contains("`count`"), "{warning}");
5553 assert!(warning.contains("renders empty"), "{warning}");
5554 }
5555
5556 /// A loop-rooted binding is a real read in State mode — a field read off the
5557 /// loop item — so State mode has no key to register and nothing to report.
5558 ///
5559 /// In Resolve mode the loop as a whole cannot render, so it is reported once,
5560 /// for the loop, and not once per binding inside it: the loop message already
5561 /// says every one of those reads comes back empty.
5562 #[test]
5563 fn loop_rooted_binding_is_covered_by_the_loop_diagnostic_in_resolve_mode() {
5564 let nodes = crate::template_parse::parse_template_to_ast(
5565 r#"<div v-for="(todo, idx) in todos"><p :value="todo.text">x</p></div>"#,
5566 )
5567 .unwrap();
5568
5569 let state =
5570 collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
5571 assert!(state.keys.is_empty(), "{:?}", state.keys);
5572 assert!(
5573 state.warnings.is_empty(),
5574 "State mode reads the field off the loop item: {:?}",
5575 state.warnings
5576 );
5577
5578 let resolve = collect_resolver_keys(
5579 &nodes,
5580 &methods(""),
5581 &[],
5582 RenderMode::Resolve,
5583 &props_of(&[]),
5584 );
5585 assert!(resolve.keys.is_empty(), "{:?}", resolve.keys);
5586 assert_eq!(
5587 resolve.warnings.len(),
5588 1,
5589 "one message for the loop, not one for the loop and one for the \
5590 binding inside it: {:?}",
5591 resolve.warnings
5592 );
5593 let warning = &resolve.warnings[0];
5594 assert!(warning.contains("`todos`"), "{warning}");
5595 assert!(
5596 !warning.contains(":value=\"todo.text\""),
5597 "the loop message must not restate the binding: {warning}"
5598 );
5599 }
5600
5601 /// A loop body is reported as a whole, so an expression the loop itself
5602 /// cannot reduce is not reported a second time on its own. State mode reads
5603 /// the same expression straight off the loop item, so it resolves there and
5604 /// is not reported.
5605 #[test]
5606 fn compound_loop_rooted_binding_is_covered_by_the_loop_diagnostic() {
5607 let nodes = crate::template_parse::parse_template_to_ast(
5608 r#"<div v-for="todo in todos"><p :value="todo.a + todo.b">x</p></div>"#,
5609 )
5610 .unwrap();
5611
5612 let resolve = collect_resolver_keys(
5613 &nodes,
5614 &methods(""),
5615 &[],
5616 RenderMode::Resolve,
5617 &props_of(&[]),
5618 );
5619 assert!(resolve.keys.is_empty(), "{:?}", resolve.keys);
5620 assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
5621 let warning = &resolve.warnings[0];
5622 assert!(warning.contains("`todos`"), "{warning}");
5623 assert!(
5624 !warning.contains("renders empty"),
5625 "the loop message already covers every read in the body, so this \
5626 binding must not add a second one: {warning}"
5627 );
5628
5629 let state =
5630 collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
5631 assert!(
5632 state.warnings.is_empty(),
5633 "State mode reads the fields off the loop item, so it resolves: {:?}",
5634 state.warnings
5635 );
5636 }
5637
5638 /// A binding inside a Resolve-mode loop that is *not* rooted at the loop is
5639 /// an ordinary one, and the loop diagnostic must not swallow its own.
5640 #[test]
5641 fn non_loop_rooted_binding_inside_a_resolve_loop_is_still_reported() {
5642 let nodes = crate::template_parse::parse_template_to_ast(
5643 r#"<div v-for="todo in todos"><input :value="draft.trim()" /></div>"#,
5644 )
5645 .unwrap();
5646
5647 let resolve = collect_resolver_keys(
5648 &nodes,
5649 &methods(""),
5650 &[],
5651 RenderMode::Resolve,
5652 &props_of(&[]),
5653 );
5654 assert_eq!(resolve.warnings.len(), 2, "{:?}", resolve.warnings);
5655 let binding = resolve
5656 .warnings
5657 .iter()
5658 .find(|w| w.contains(":value=\"draft.trim()\""))
5659 .unwrap_or_else(|| panic!("{:?}", resolve.warnings));
5660 assert!(binding.contains("renders empty"), "{binding}");
5661 }
5662
5663 /// A `State` field resolves through no renderer, so its diagnostic is
5664 /// mode-independent.
5665 #[test]
5666 fn field_backed_binding_is_reported_in_both_modes() {
5667 let script = "pub struct State { pub count: Rc<Signal<i32>> }";
5668 let nodes =
5669 crate::template_parse::parse_template_to_ast(r#"<input :value="count" />"#).unwrap();
5670 let methods = methods(script);
5671 let fields = ["count".to_string()];
5672
5673 for mode in [RenderMode::State, RenderMode::Resolve] {
5674 let collected =
5675 collect_resolver_keys(&nodes, &methods, &fields, mode, &props_of(&fields));
5676 assert!(collected.keys.is_empty(), "{mode:?}: {:?}", collected.keys);
5677 assert_eq!(
5678 collected.warnings.len(),
5679 1,
5680 "{mode:?}: {:?}",
5681 collected.warnings
5682 );
5683 assert!(
5684 collected.warnings[0].contains(":value=\"count\""),
5685 "{mode:?}: {}",
5686 collected.warnings[0]
5687 );
5688 }
5689 }
5690
5691 /// `:key` on a `v-for` element is the same case, and both spellings of the
5692 /// key expression normalize to the same read. State mode reads the loop item
5693 /// directly and reports nothing; Resolve mode reports the loop, once, because
5694 /// the loop cannot render — never the key on its own.
5695 #[test]
5696 fn v_for_key_binding_is_covered_by_the_loop_diagnostic_in_resolve_mode() {
5697 // Both spellings of the same key expression: the collector must analyze
5698 // the normalized expression codegen emits, not the raw `{{ … }}` text.
5699 for tpl in [
5700 r#"<div v-for="todo in todos" :key="todo.id">x</div>"#,
5701 r#"<div v-for="todo in todos" :key="{{ todo.id }}">x</div>"#,
5702 ] {
5703 let nodes = crate::template_parse::parse_template_to_ast(tpl).unwrap();
5704
5705 let state =
5706 collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
5707 assert!(state.keys.is_empty(), "{tpl} {:?}", state.keys);
5708 assert!(
5709 state.warnings.is_empty(),
5710 "{tpl}: State mode reads the key off the loop item: {:?}",
5711 state.warnings
5712 );
5713
5714 let resolve = collect_resolver_keys(
5715 &nodes,
5716 &methods(""),
5717 &[],
5718 RenderMode::Resolve,
5719 &props_of(&[]),
5720 );
5721 assert!(resolve.keys.is_empty(), "{tpl} {:?}", resolve.keys);
5722 assert_eq!(resolve.warnings.len(), 1, "{tpl} {:?}", resolve.warnings);
5723 assert!(
5724 !resolve.warnings[0].contains(":key="),
5725 "{tpl}: the key must not be reported separately from its loop: {}",
5726 resolve.warnings[0]
5727 );
5728 }
5729 }
5730
5731 /// The `{{ … }}` spelling of a `v-for` `:key` is a loop-rooted binding whose
5732 /// key codegen normalizes before emitting, so the raw `{{ … }}` text is never
5733 /// an expression codegen produces. No diagnostic may quote it: it would
5734 /// describe a defect that is not there.
5735 #[test]
5736 fn mustache_key_is_never_quoted_by_a_diagnostic() {
5737 let nodes = crate::template_parse::parse_template_to_ast(
5738 r#"<div v-for="todo in todos" :key="{{ todo.id }}">x</div>"#,
5739 )
5740 .unwrap();
5741
5742 for mode in [RenderMode::Resolve, RenderMode::State] {
5743 let collected = collect_resolver_keys(&nodes, &methods(""), &[], mode, &props_of(&[]));
5744 for warning in &collected.warnings {
5745 assert!(
5746 !warning.contains("{{"),
5747 "{mode:?}: a diagnostic quoting the raw mustache text: {warning}"
5748 );
5749 }
5750 }
5751 }
5752
5753 /// What R-1 delivers, stated as an outcome rather than as a shape: a
5754 /// Resolve-mode `v-for` over a collection the resolver cannot answer runs
5755 /// its body zero times, so every read inside it is worth nothing — and the
5756 /// case is reported instead of passing silently.
5757 ///
5758 /// Nothing in this workspace compiles a generated module and runs it, so a
5759 /// runtime value assertion is not available here. The chain asserted below is
5760 /// the strongest honest form of one, and each link is the generated text a
5761 /// reader can check: the loop bound is a count, the count is computed from
5762 /// one resolver answer, the resolver has no arm for that answer, and the
5763 /// catch-all is the empty string.
5764 #[test]
5765 fn resolve_mode_v_for_over_an_unregistered_collection_is_diagnosed_as_empty() {
5766 const TPL: &str =
5767 r#"<ul><li v-for="item in items" :key="item.id">{{ item.name }}</li></ul>"#;
5768 let rs = compile_template_to_rs_full_with_mode(
5769 TPL,
5770 "App",
5771 None,
5772 None,
5773 None,
5774 RenderMode::Resolve,
5775 )
5776 .expect("template compiles");
5777
5778 // The bound of the loop is a count, and the count is one resolver answer.
5779 assert!(rs.contains("for __idx in 0..__for_count {"), "{rs}");
5780 assert!(
5781 rs.contains("let __for_count = if let Ok(n) = __for_expr.parse::<usize>()"),
5782 "the loop bound must be derived from the collection read: {rs}"
5783 );
5784 assert!(
5785 rs.contains(r#"let __for_expr = resolve("items");"#),
5786 "the collection is read through the resolver: {rs}"
5787 );
5788 // That answer is the empty string: no arm, and the catch-all.
5789 assert!(
5790 !rs.contains(r#""items" =>"#),
5791 "the collection must have no resolver arm: {rs}"
5792 );
5793 assert!(rs.contains("_ => String::new()"), "{rs}");
5794 // An empty answer takes the `is_empty()` branch, so the count is zero and
5795 // the body never runs. Reported, not silent.
5796 let nodes = crate::template_parse::parse_template_to_ast(TPL).unwrap();
5797 let resolve = collect_resolver_keys(
5798 &nodes,
5799 &methods(""),
5800 &[],
5801 RenderMode::Resolve,
5802 &props_of(&[]),
5803 );
5804 assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
5805 let warning = &resolve.warnings[0];
5806 assert!(warning.contains("`items`"), "{warning}");
5807 assert!(
5808 warning.contains("never runs"),
5809 "the diagnosis must say the body never runs: {warning}"
5810 );
5811
5812 // State mode reads the collection and the item straight off the state, so
5813 // the same template renders and is not reported there.
5814 let state =
5815 collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
5816 assert!(state.warnings.is_empty(), "{:?}", state.warnings);
5817 }
5818
5819 /// An object `:class` condition the loop cannot reduce is covered by the
5820 /// loop's own diagnostic, like any other binding in the body. The generated
5821 /// condition is still wrong in Resolve mode — it names the loop item, which
5822 /// that renderer does not bind — and that is left for the collection work;
5823 /// what this pins is that it is not left *silent*, and not reported twice.
5824 #[test]
5825 fn compound_class_condition_in_a_resolve_loop_adds_no_second_warning() {
5826 let nodes = crate::template_parse::parse_template_to_ast(
5827 r#"<li v-for="todo in todos" :class="{ active: todo.a == todo.b }">x</li>"#,
5828 )
5829 .unwrap();
5830
5831 let resolve = collect_resolver_keys(
5832 &nodes,
5833 &methods(""),
5834 &[],
5835 RenderMode::Resolve,
5836 &props_of(&[]),
5837 );
5838 assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
5839 assert!(
5840 resolve.warnings[0].contains("`todos`"),
5841 "{}",
5842 resolve.warnings[0]
5843 );
5844 }
5845
5846 /// The loop diagnostic must name the loop only when the loop really cannot
5847 /// render. A collection the resolver can answer, over a body that reads no
5848 /// loop variable, renders: reporting it would train the reader to ignore it.
5849 #[test]
5850 fn resolve_loop_over_a_registered_collection_is_not_reported() {
5851 let script = r#"
5852 impl State {
5853 pub fn items(&self) -> String { String::new() }
5854 pub fn draft(&self) -> String { String::new() }
5855 }"#;
5856 let nodes = crate::template_parse::parse_template_to_ast(
5857 r#"<div v-for="item in items"><p :value="draft">x</p></div>"#,
5858 )
5859 .unwrap();
5860
5861 let resolve = collect_resolver_keys(
5862 &nodes,
5863 &methods(script),
5864 &[],
5865 RenderMode::Resolve,
5866 &props_of(&[]),
5867 );
5868 assert_eq!(
5869 resolve.keys,
5870 vec!["draft".to_string()],
5871 "{:?}",
5872 resolve.keys
5873 );
5874 assert!(
5875 resolve.warnings.is_empty(),
5876 "the collection is a registered getter and the body reads no loop \
5877 variable, so the loop renders: {:?}",
5878 resolve.warnings
5879 );
5880 }
5881
5882 /// The loop index is a real binding in the loop body — an interpolated
5883 /// index becomes `text(i.to_string())` and a bare-index bind, directive or
5884 /// `:class` becomes `format!("{}", index)` — so a loop whose body reads
5885 /// ONLY the index, directly, renders, and reporting it would be a false
5886 /// positive. Every index form here is the direct read: the value is the
5887 /// index name and nothing else.
5888 #[test]
5889 fn resolve_loop_reading_only_the_index_is_not_reported() {
5890 let script = r#"
5891 impl State {
5892 pub fn items(&self) -> String { String::new() }
5893 }"#;
5894 let nodes = crate::template_parse::parse_template_to_ast(
5895 r#"<ul><li v-for="(item, i) in items">{{ i }}<b :value="i" :class="i" :style="i">x</b></li></ul>"#,
5896 )
5897 .unwrap();
5898
5899 let resolve = collect_resolver_keys(
5900 &nodes,
5901 &methods(script),
5902 &[],
5903 RenderMode::Resolve,
5904 &props_of(&[]),
5905 );
5906 assert!(
5907 resolve.warnings.is_empty(),
5908 "the collection is a registered getter and the body reads only the loop \
5909 index, directly, which is a real binding: {:?}",
5910 resolve.warnings
5911 );
5912 }
5913
5914 /// A CONDITION on the loop element is not a direct read, however bare the
5915 /// expression is. `emit_node_with_mode` routes `v-if` through
5916 /// `rewrite_if_expr`, which rewrites every bare identifier into
5917 /// `resolve("<ident>")`, so `v-if="i"` is emitted as
5918 /// `{ let __v = resolve("i"); … }` around the whole loop: the flat resolver
5919 /// answers `""`, the condition is falsy and the loop renders nothing.
5920 #[test]
5921 fn resolve_loop_with_a_condition_reading_only_the_index_is_reported() {
5922 let script = r#"
5923 impl State {
5924 pub fn items(&self) -> String { String::new() }
5925 }"#;
5926 let nodes = crate::template_parse::parse_template_to_ast(
5927 r#"<ul><li v-for="(item, i) in items" v-if="i">x</li></ul>"#,
5928 )
5929 .unwrap();
5930
5931 let resolve = collect_resolver_keys(
5932 &nodes,
5933 &methods(script),
5934 &[],
5935 RenderMode::Resolve,
5936 &props_of(&[]),
5937 );
5938 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
5939 }
5940
5941 /// The same through `v-show` on the template's ROOT loop element, the other
5942 /// directive the emitter rewrites with `rewrite_if_expr`:
5943 /// `if !({ let __v = resolve("i"); … })`, so the element is hidden whatever
5944 /// the loop renders.
5945 #[test]
5946 fn resolve_loop_with_a_show_condition_reading_only_the_index_is_reported() {
5947 let script = r#"
5948 impl State {
5949 pub fn items(&self) -> String { String::new() }
5950 }"#;
5951 let nodes = crate::template_parse::parse_template_to_ast(
5952 r#"<li v-for="(item, i) in items" v-show="i">x</li>"#,
5953 )
5954 .unwrap();
5955
5956 let resolve = collect_resolver_keys(
5957 &nodes,
5958 &methods(script),
5959 &[],
5960 RenderMode::Resolve,
5961 &props_of(&[]),
5962 );
5963 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
5964 }
5965
5966 /// A `v-show` on a loop element that is NOT the template root is dropped
5967 /// outright: only `v-if` is honoured by the children emitter's `v-for`
5968 /// branch, so no read is emitted and the condition is silently ignored.
5969 ///
5970 /// It is reported anyway, deliberately. Erring toward reporting is this
5971 /// diagnostic's standing direction — a false positive is one extra sentence,
5972 /// a false negative is silence — and the classification also stays correct
5973 /// if the dropped directive is ever fixed, whereas exempting it now would
5974 /// become a fresh silence the day that happens.
5975 #[test]
5976 fn resolve_loop_reports_a_dropped_show_condition_on_a_nested_loop_element() {
5977 let script = r#"
5978 impl State {
5979 pub fn items(&self) -> String { String::new() }
5980 }"#;
5981 let nodes = crate::template_parse::parse_template_to_ast(
5982 r#"<ul><li v-for="(item, i) in items" v-show="i">x</li></ul>"#,
5983 )
5984 .unwrap();
5985
5986 let resolve = collect_resolver_keys(
5987 &nodes,
5988 &methods(script),
5989 &[],
5990 RenderMode::Resolve,
5991 &props_of(&[]),
5992 );
5993 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
5994 }
5995
5996 /// F1: the two fallback arms are set at opposite defaults, and each is
5997 /// pinned here with its reason.
5998 ///
5999 /// On the loop element an unrecognised directive is `Resolver`: it is the
6000 /// one classification with no emit path to read off, it is the case a future
6001 /// condition directive arrives as, and `Direct` there would silently exempt
6002 /// a read the resolver cannot answer — the N3/N6 failure. Inside the body it
6003 /// is `Direct`, because every directive there is dropped by
6004 /// `emit_props_in_loop` and emits no read at all, so `Resolver` would report
6005 /// loops that render perfectly. `v-model` is the one loop-element directive
6006 /// that is not a condition; `extract_vmodel` re-emits it as a `:value` bind,
6007 /// so it must stay exempt.
6008 #[test]
6009 fn unrecognised_directives_fail_loud_on_the_loop_element_and_stay_quiet_in_the_body() {
6010 assert!(
6011 directive_emission("if", AttrSite::LoopElement) == IndexEmission::Resolver,
6012 "a condition on the loop element is rewritten by `rewrite_if_expr`"
6013 );
6014 assert!(
6015 directive_emission("show", AttrSite::LoopElement) == IndexEmission::Resolver,
6016 "so is `v-show`, through the same handler"
6017 );
6018 assert!(
6019 directive_emission("text", AttrSite::LoopElement) == IndexEmission::Resolver,
6020 "a directive the emitter does not handle has no direct read to claim, \
6021 so it must not be exempted from the loop diagnostic"
6022 );
6023 assert!(
6024 directive_emission("text", AttrSite::InLoopBody) == IndexEmission::Direct,
6025 "inside the body every directive is dropped, so it emits no read to \
6026 be wrong about and reporting it would flag a loop that renders"
6027 );
6028 assert!(
6029 directive_emission("model", AttrSite::LoopElement) == IndexEmission::Direct,
6030 "`v-model` becomes a `:value` bind, which renders"
6031 );
6032 }
6033
6034 /// F1, at the decision: an unrecognised directive naming the index on the
6035 /// loop element must not be exempt, so the loop is reported.
6036 #[test]
6037 fn an_unrecognised_directive_naming_the_index_is_reported() {
6038 let script = r#"
6039 impl State {
6040 pub fn items(&self) -> String { String::new() }
6041 }"#;
6042 let nodes = crate::template_parse::parse_template_to_ast(
6043 r#"<ul><li v-for="(item, i) in items" v-when="i">x</li></ul>"#,
6044 )
6045 .unwrap();
6046
6047 let resolve = collect_resolver_keys(
6048 &nodes,
6049 &methods(script),
6050 &[],
6051 RenderMode::Resolve,
6052 &props_of(&[]),
6053 );
6054 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6055 }
6056
6057 /// F2: the cause for a collection WITH an arm must not claim that every
6058 /// value in the body renders empty — a direct index read renders. It names
6059 /// the loop item instead.
6060 #[test]
6061 fn the_loop_message_blames_the_item_read_not_every_value() {
6062 let script = r#"
6063 impl State {
6064 pub fn items(&self) -> String { String::new() }
6065 }"#;
6066 let nodes = crate::template_parse::parse_template_to_ast(
6067 r#"<ul><li v-for="(item, i) in items">{{ item.name }}</li></ul>"#,
6068 )
6069 .unwrap();
6070
6071 let resolve = collect_resolver_keys(
6072 &nodes,
6073 &methods(script),
6074 &[],
6075 RenderMode::Resolve,
6076 &props_of(&[]),
6077 );
6078 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6079 let warning = &resolve.warnings[0];
6080 assert!(
6081 warning.contains("every read of the loop ITEM renders empty"),
6082 "the unanswerable read is the loop item's: {warning}"
6083 );
6084 assert!(
6085 !warning.contains("every value in the body renders empty"),
6086 "a direct index read renders, so that claim would be false: {warning}"
6087 );
6088 }
6089
6090 /// F2: when the only read that triggered the report is one the emitter
6091 /// DROPS, the message must say so instead of claiming the body renders
6092 /// empty. A diagnostic that fires for a right reason with a wrong
6093 /// explanation teaches its reader to ignore the explanation.
6094 ///
6095 /// The `v-show` here is on a loop element nested in a parent, and
6096 /// `emit_children_with_mode`'s `v-for` branch has no `v-show` branch (only
6097 /// `emit_node_with_mode`'s does), so the read is dropped — see the
6098 /// `resolves_index: false` case in `index_classification_agrees_with_the_
6099 /// emitted_read`. The loop is reported anyway, which is the safe direction.
6100 #[test]
6101 fn the_loop_message_says_a_dropped_read_is_not_checkable() {
6102 let script = r#"
6103 impl State {
6104 pub fn items(&self) -> String { String::new() }
6105 }"#;
6106 let nodes = crate::template_parse::parse_template_to_ast(
6107 r#"<ul><li v-for="(item, i) in items" v-show="i">x</li></ul>"#,
6108 )
6109 .unwrap();
6110
6111 let resolve = collect_resolver_keys(
6112 &nodes,
6113 &methods(script),
6114 &[],
6115 RenderMode::Resolve,
6116 &props_of(&[]),
6117 );
6118 // Reported on purpose — the direction that is safe to be wrong in.
6119 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6120 let warning = &resolve.warnings[0];
6121 assert!(
6122 warning.contains("the emitter DROPS rather than makes"),
6123 "the read that triggered this is dropped, so the message must say so: {warning}"
6124 );
6125 }
6126
6127 /// The boundary across attribute KINDS: one element carrying both a
6128 /// genuinely-rendering direct index bind and a condition the resolver
6129 /// answers `""` for is still reported. Without this, reclassifying
6130 /// conditions could have been done by suppressing the loop instead.
6131 #[test]
6132 fn resolve_loop_reports_a_condition_beside_a_direct_index_read() {
6133 let script = r#"
6134 impl State {
6135 pub fn items(&self) -> String { String::new() }
6136 }"#;
6137 let nodes = crate::template_parse::parse_template_to_ast(
6138 r#"<ul><li v-for="(item, i) in items" :value="i" v-if="i">x</li></ul>"#,
6139 )
6140 .unwrap();
6141
6142 let resolve = collect_resolver_keys(
6143 &nodes,
6144 &methods(script),
6145 &[],
6146 RenderMode::Resolve,
6147 &props_of(&[]),
6148 );
6149 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6150 }
6151
6152 /// A directive on an element INSIDE the body is dropped by
6153 /// `emit_props_in_loop`, so it emits no read and cannot leave the body
6154 /// empty; the loop is not reported for it. (That the condition is silently
6155 /// ignored is a separate pre-existing defect, not this rule.)
6156 #[test]
6157 fn resolve_loop_condition_on_a_child_in_the_body_is_not_reported() {
6158 let script = r#"
6159 impl State {
6160 pub fn items(&self) -> String { String::new() }
6161 }"#;
6162 let nodes = crate::template_parse::parse_template_to_ast(
6163 r#"<ul><li v-for="(item, i) in items"><b v-if="i">x</b></li></ul>"#,
6164 )
6165 .unwrap();
6166
6167 let resolve = collect_resolver_keys(
6168 &nodes,
6169 &methods(script),
6170 &[],
6171 RenderMode::Resolve,
6172 &props_of(&[]),
6173 );
6174 assert!(
6175 resolve.warnings.is_empty(),
6176 "a dropped directive emits no read, so nothing in the body renders \
6177 empty: {:?}",
6178 resolve.warnings
6179 );
6180 }
6181
6182 /// The classification table, checked against BOTH facts at once: what the
6183 /// Resolve renderer emits for that read of the index, and what the family
6184 /// diagnostic then says about the loop. Neither column is computed from the
6185 /// other — the emitted form comes from codegen and the decision from the
6186 /// collector — so a classification that drifts from the emit path fails here
6187 /// instead of silently exempting an unanswerable read.
6188 ///
6189 /// Every expectation below was read off the emitted module, not assumed from
6190 /// the attribute's name. Only the first `pub fn render_with_state` onwards is
6191 /// examined: the module also carries the State renderer, whose loop body
6192 /// reads the loop variables differently.
6193 #[test]
6194 fn index_classification_agrees_with_the_emitted_read() {
6195 // (label, template, emits a resolver read of the index, reported, why)
6196 let cases: [(&str, &str, bool, bool, &str); 10] = [
6197 (
6198 "v-if on a nested loop element",
6199 r#"<ul><li v-for="(item, i) in items" v-if="i">x</li></ul>"#,
6200 true,
6201 true,
6202 "the children emitter's v-for branch rewrites it to resolve(\"i\")",
6203 ),
6204 (
6205 "v-if on the root loop element",
6206 r#"<li v-for="(item, i) in items" v-if="i">x</li>"#,
6207 true,
6208 true,
6209 "emit_node_with_mode rewrites it before the loop branch",
6210 ),
6211 (
6212 "v-show on the root loop element",
6213 r#"<li v-for="(item, i) in items" v-show="i">x</li>"#,
6214 true,
6215 true,
6216 "the same handler, negated",
6217 ),
6218 (
6219 "v-show on a nested loop element",
6220 r#"<ul><li v-for="(item, i) in items" v-show="i">x</li></ul>"#,
6221 false,
6222 true,
6223 "dropped by that branch, and still reported on purpose",
6224 ),
6225 (
6226 "a call condition naming the index",
6227 r#"<ul><li v-for="(item, i) in items" v-if="i()">x</li></ul>"#,
6228 true,
6229 true,
6230 "the expression is not the index name, so no exemption",
6231 ),
6232 (
6233 "a compound binding",
6234 r#"<ul><li v-for="(item, i) in items" :value="i + 1">x</li></ul>"#,
6235 true,
6236 true,
6237 "emitted as resolve(\"i + 1\")",
6238 ),
6239 (
6240 "a direct index bind",
6241 r#"<ul><li v-for="(item, i) in items" :value="i">x</li></ul>"#,
6242 false,
6243 false,
6244 "emitted as format!(\"{}\", i)",
6245 ),
6246 (
6247 "a bare index interpolation",
6248 r#"<ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
6249 false,
6250 false,
6251 "emitted as i.to_string()",
6252 ),
6253 (
6254 "a condition on a child in the body",
6255 r#"<ul><li v-for="(item, i) in items"><b v-if="i">x</b></li></ul>"#,
6256 false,
6257 false,
6258 "emit_props_in_loop drops the directive, so it reads nothing",
6259 ),
6260 (
6261 "v-model on the loop element",
6262 r#"<ul><li v-for="(item, i) in items" v-model="i">x</li></ul>"#,
6263 false,
6264 false,
6265 "no index read is emitted for it",
6266 ),
6267 ];
6268 let script = r#"
6269 impl State {
6270 pub fn items(&self) -> String { String::new() }
6271 }"#;
6272 for (label, tpl, resolves_index, reported, why) in cases {
6273 let rs = crate::compile_template_to_rs_full_with_mode(
6274 tpl,
6275 script,
6276 None,
6277 None,
6278 None,
6279 RenderMode::Resolve,
6280 )
6281 .expect("template compiles");
6282 let resolve_renderer = rs
6283 .split("pub fn render_with_state")
6284 .next()
6285 .expect("the module has a Resolve renderer");
6286 let emitted_resolver_read = resolve_renderer.contains("resolve(\"i\")")
6287 || resolve_renderer.contains("resolve(\"i + 1\")");
6288 let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
6289 let resolve = collect_resolver_keys(
6290 &nodes,
6291 &methods(script),
6292 &[],
6293 RenderMode::Resolve,
6294 &props_of(&[]),
6295 );
6296 assert_eq!(
6297 emitted_resolver_read, resolves_index,
6298 "{label}: the emitted read changed ({why})\n{resolve_renderer}"
6299 );
6300 assert_eq!(
6301 !resolve.warnings.is_empty(),
6302 reported,
6303 "{label}: the decision disagrees with the emitted read ({why}): {:?}",
6304 resolve.warnings
6305 );
6306 }
6307 }
6308
6309 /// A COMPOUND expression rooted at the index is not the direct binding read.
6310 /// `rewrite_ctx_expr` reduces only a bare index or an `item.`-prefixed path,
6311 /// so `i + 1` falls through to the flat resolver and is emitted as
6312 /// `resolve("i + 1")`, which the one-arm-per-key table answers `""`. The
6313 /// loop renders an empty attribute, so it must be reported.
6314 #[test]
6315 fn resolve_loop_reading_a_compound_index_binding_is_reported() {
6316 let script = r#"
6317 impl State {
6318 pub fn items(&self) -> String { String::new() }
6319 }"#;
6320 let nodes = crate::template_parse::parse_template_to_ast(
6321 r#"<ul><li v-for="(item, i) in items" :value="i + 1">x</li></ul>"#,
6322 )
6323 .unwrap();
6324
6325 let resolve = collect_resolver_keys(
6326 &nodes,
6327 &methods(script),
6328 &[],
6329 RenderMode::Resolve,
6330 &props_of(&[]),
6331 );
6332 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6333 }
6334
6335 /// The same compound-index hole through a `:class` condition, which also
6336 /// happens to emit a non-compiling comparison in both renderers — the
6337 /// closed compound-`:class` limitation, reached here through the index.
6338 #[test]
6339 fn resolve_loop_reading_a_compound_index_condition_is_reported() {
6340 let script = r#"
6341 impl State {
6342 pub fn items(&self) -> String { String::new() }
6343 }"#;
6344 let nodes = crate::template_parse::parse_template_to_ast(
6345 r#"<ul><li v-for="(item, i) in items" :class="{a: i > 2}">x</li></ul>"#,
6346 )
6347 .unwrap();
6348
6349 let resolve = collect_resolver_keys(
6350 &nodes,
6351 &methods(script),
6352 &[],
6353 RenderMode::Resolve,
6354 &props_of(&[]),
6355 );
6356 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6357 }
6358
6359 /// The same compound-index hole through an interpolation, which is emitted
6360 /// as `text(resolve("i + 1"))` and answers `""`.
6361 #[test]
6362 fn resolve_loop_reading_a_compound_index_interpolation_is_reported() {
6363 let script = r#"
6364 impl State {
6365 pub fn items(&self) -> String { String::new() }
6366 }"#;
6367 let nodes = crate::template_parse::parse_template_to_ast(
6368 r#"<ul><li v-for="(item, i) in items">{{ i + 1 }}</li></ul>"#,
6369 )
6370 .unwrap();
6371
6372 let resolve = collect_resolver_keys(
6373 &nodes,
6374 &methods(script),
6375 &[],
6376 RenderMode::Resolve,
6377 &props_of(&[]),
6378 );
6379 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6380 }
6381
6382 /// The exemption is per EXPRESSION, not per loop: one element carrying both
6383 /// a direct index read and a compound one is still reported, so the direct
6384 /// forms cannot launder a compound sibling.
6385 #[test]
6386 fn resolve_loop_reports_a_compound_index_read_beside_direct_ones() {
6387 let script = r#"
6388 impl State {
6389 pub fn items(&self) -> String { String::new() }
6390 }"#;
6391 let nodes = crate::template_parse::parse_template_to_ast(
6392 r#"<ul><li v-for="(item, i) in items" :value="i" :title="i + 1">{{ i }} {{ i * 2 }}</li></ul>"#,
6393 )
6394 .unwrap();
6395
6396 let resolve = collect_resolver_keys(
6397 &nodes,
6398 &methods(script),
6399 &[],
6400 RenderMode::Resolve,
6401 &props_of(&[]),
6402 );
6403 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6404 }
6405
6406 /// A nested `v-for` whose COLLECTION is the outer index is not a direct
6407 /// read: the collection is emitted as `let __for_expr = resolve("i")`, which
6408 /// answers `""`, so the nested loop counts zero items and its body never
6409 /// runs. The direct-index exemption must not reach that site, and the outer
6410 /// loop — whose body contains that unreadable read — must not be masked by
6411 /// it either.
6412 #[test]
6413 fn resolve_loop_reading_the_index_as_a_nested_collection_is_reported() {
6414 let script = r#"
6415 impl State {
6416 pub fn items(&self) -> String { String::new() }
6417 }"#;
6418 let nodes = crate::template_parse::parse_template_to_ast(
6419 r#"<ul><li v-for="(item, i) in items"><p v-for="row in i">{{ row }}</p></li></ul>"#,
6420 )
6421 .unwrap();
6422
6423 let resolve = collect_resolver_keys(
6424 &nodes,
6425 &methods(script),
6426 &[],
6427 RenderMode::Resolve,
6428 &props_of(&[]),
6429 );
6430 assert_eq!(resolve.warnings.len(), 2, "{:?}", resolve.warnings);
6431 let nested = resolve
6432 .warnings
6433 .iter()
6434 .find(|w| w.contains("`v-for` over `i` cannot render in Resolve mode"))
6435 .unwrap_or_else(|| {
6436 panic!(
6437 "the nested loop over the index must be reported: {:?}",
6438 resolve.warnings
6439 )
6440 });
6441 assert!(
6442 nested.contains("is not a registered `State` getter"),
6443 "`resolve(\"i\")` answers an empty string, so the count is zero: {nested}"
6444 );
6445 assert!(
6446 resolve
6447 .warnings
6448 .iter()
6449 .any(|w| w.contains("`v-for` over `items` cannot render in Resolve mode")),
6450 "the outer loop reads the index, so it reports too: {:?}",
6451 resolve.warnings
6452 );
6453 }
6454
6455 /// Exactly one message for the loop, naming the loop and blaming the
6456 /// unreadable indexed read — the collection here IS a registered getter, so
6457 /// "not a registered `State` getter" would be the wrong cause.
6458 fn assert_loop_reported_for_unanswerable_reads(warnings: &[String]) {
6459 assert_eq!(warnings.len(), 1, "{warnings:?}");
6460 let warning = &warnings[0];
6461 assert!(
6462 warning.contains("`v-for` over `items` cannot render in Resolve mode"),
6463 "{warning}"
6464 );
6465 assert!(
6466 warning.contains("indexed resolver keys"),
6467 "the collection has a getter, so the cause must be the unreadable \
6468 indexed read: {warning}"
6469 );
6470 }
6471
6472 /// A body that reads the ITEM and nothing else still reports: the direct
6473 /// index exemption is about the index, never a blanket suppression of the
6474 /// loop. Every item form — an indexed resolver read, a bound field, a
6475 /// `{{ … }}` — is unanswerable.
6476 #[test]
6477 fn resolve_loop_reading_only_the_item_is_reported() {
6478 let script = r#"
6479 impl State {
6480 pub fn items(&self) -> String { String::new() }
6481 }"#;
6482 let nodes = crate::template_parse::parse_template_to_ast(
6483 r#"<ul><li v-for="(item, i) in items"><b>{{ item.name }}</b></li></ul>"#,
6484 )
6485 .unwrap();
6486
6487 let resolve = collect_resolver_keys(
6488 &nodes,
6489 &methods(script),
6490 &[],
6491 RenderMode::Resolve,
6492 &props_of(&[]),
6493 );
6494 assert_loop_reported_for_unanswerable_reads(&resolve.warnings);
6495 }
6496
6497 /// The converse of the index-only case: reading the ITEM as well still
6498 /// cannot be answered by any resolver arm, so it stays reported. Without
6499 /// this the N1 fix would be a blanket suppression.
6500 #[test]
6501 fn resolve_loop_reading_the_index_and_the_item_is_still_reported() {
6502 let script = r#"
6503 impl State {
6504 pub fn items(&self) -> String { String::new() }
6505 }"#;
6506 let nodes = crate::template_parse::parse_template_to_ast(
6507 r#"<ul><li v-for="(item, i) in items">{{ i }}{{ item.name }}</li></ul>"#,
6508 )
6509 .unwrap();
6510
6511 let resolve = collect_resolver_keys(
6512 &nodes,
6513 &methods(script),
6514 &[],
6515 RenderMode::Resolve,
6516 &props_of(&[]),
6517 );
6518 assert_eq!(resolve.warnings.len(), 1, "{:?}", resolve.warnings);
6519 let warning = &resolve.warnings[0];
6520 assert!(
6521 warning.contains("`v-for` over `items` cannot render in Resolve mode"),
6522 "{warning}"
6523 );
6524 assert!(
6525 warning.contains("indexed resolver keys"),
6526 "the collection has a getter, so the cause must be the unreadable \
6527 indexed read: {warning}"
6528 );
6529 }
6530
6531 /// A collection `State` has no getter for cannot be answered by an arm, so a
6532 /// loop over it counts zero items and must be reported.
6533 ///
6534 /// R-1 found this while a collection name could still reach the key set
6535 /// without a getter, which is why `has_arm` consults the getter alone: a key
6536 /// is not an arm. R-1d's gate closes that route for interpolation keys, and
6537 /// the binding route was already gated by `has_state_getter`, so the hazard
6538 /// is now unreachable from the template — asserted below rather than
6539 /// assumed, because a test that no longer holds its premise is how a
6540 /// narrowing like this hides.
6541 #[test]
6542 fn resolve_loop_over_a_collection_with_no_accessor_is_reported() {
6543 let script = r#"
6544 impl State {
6545 pub fn draft(&self) -> String { String::new() }
6546 }"#;
6547 let nodes = crate::template_parse::parse_template_to_ast(
6548 r#"<p :x="items">{{ items }}</p><ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
6549 )
6550 .unwrap();
6551
6552 let resolve = collect_resolver_keys(
6553 &nodes,
6554 &methods(script),
6555 &[],
6556 RenderMode::Resolve,
6557 &props_of(&[]),
6558 );
6559 assert!(
6560 !resolve.keys.iter().any(|k| k == "items"),
6561 "neither route can register a key `State` has no getter for — the \
6562 binding is gated by `has_state_getter` and the interpolation by \
6563 R-1d's gate — so no key can stand in for the getter: {:?}",
6564 resolve.keys
6565 );
6566 assert!(
6567 resolve
6568 .warnings
6569 .iter()
6570 .any(|w| w.contains("interpolation {{ items }} cannot be resolved")),
6571 "the interpolation reports why it was dropped: {:?}",
6572 resolve.warnings
6573 );
6574 // Three warnings, all of them about `items`, in this order: the dropped
6575 // interpolation (this round's gate), the dropped binding (the bind path's
6576 // own gate) and the loop itself.
6577 assert_eq!(resolve.warnings.len(), 3, "{:?}", resolve.warnings);
6578 let warning = &resolve.warnings[2];
6579 assert!(
6580 warning.contains("`v-for` over `items` cannot render in Resolve mode"),
6581 "{warning}"
6582 );
6583 assert!(
6584 warning.contains("not a registered `State` getter"),
6585 "no getter backs the key, so the cause must be the empty answer: {warning}"
6586 );
6587
6588 // The State renderer reads the collection and the item directly, so the
6589 // loop is not reported there — the family is pushed only under
6590 // `if let (RenderMode::Resolve, Some(info))`, and this is the pin: the two
6591 // key-gate warnings are mode-independent, the loop report is not.
6592 let state = collect_resolver_keys(
6593 &nodes,
6594 &methods(script),
6595 &[],
6596 RenderMode::State,
6597 &props_of(&[]),
6598 );
6599 assert_eq!(state.keys, resolve.keys, "the key set is mode-independent");
6600 assert_eq!(state.warnings.len(), 2, "{:?}", state.warnings);
6601 assert!(
6602 !state
6603 .warnings
6604 .iter()
6605 .any(|w| w.contains("`v-for` over `items` cannot render")),
6606 "a loop report is Resolve-mode only: {:?}",
6607 state.warnings
6608 );
6609 }
6610
6611 /// A zero-argument method that returns nothing is not a getter: the arm would
6612 /// be `state.reset().to_string()`, which does not compile.
6613 #[test]
6614 fn method_without_return_type_is_reported_as_a_warning() {
6615 let script = "impl State { pub fn reset(&self) {} }";
6616 let nodes =
6617 crate::template_parse::parse_template_to_ast(r#"<input :value="reset" />"#).unwrap();
6618 let collected = collect_resolver_keys(
6619 &nodes,
6620 &methods(script),
6621 &[],
6622 RenderMode::State,
6623 &props_of(&[]),
6624 );
6625
6626 assert!(collected.keys.is_empty(), "{:?}", collected.keys);
6627 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
6628 let warning = &collected.warnings[0];
6629 assert!(warning.contains(":value=\"reset\""), "{warning}");
6630 assert!(
6631 warning.contains("return type") || warning.contains("returns"),
6632 "the warning must explain the return type: {warning}"
6633 );
6634 }
6635
6636 /// A zero-argument method with an unrenderable return type is not a getter.
6637 #[test]
6638 fn unrenderable_return_type_is_reported_as_a_warning() {
6639 let script = "impl State { pub fn items(&self) -> Vec<String> { Vec::new() } }";
6640 let nodes =
6641 crate::template_parse::parse_template_to_ast(r#"<input :value="items" />"#).unwrap();
6642 let collected = collect_resolver_keys(
6643 &nodes,
6644 &methods(script),
6645 &[],
6646 RenderMode::State,
6647 &props_of(&[]),
6648 );
6649
6650 assert!(collected.keys.is_empty(), "{:?}", collected.keys);
6651 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
6652 assert!(
6653 collected.warnings[0].contains("Vec<String>"),
6654 "the warning must name the return type: {}",
6655 collected.warnings[0]
6656 );
6657 }
6658
6659 /// Only zero-argument methods whose return type renders as text are getters.
6660 #[test]
6661 fn getter_method_name_requires_a_zero_argument_renderable_getter() {
6662 let script = r#"
6663impl State {
6664 pub fn title(&self) -> String { String::new() }
6665 pub fn get_count(&self) -> i32 { 0 }
6666 pub fn is_open(&self) -> bool { true }
6667 pub fn has_items(&self) -> bool { true }
6668 pub fn on_input(&self, payload: &str) { let _ = payload; }
6669 pub fn pick(&self, index: usize) -> usize { index }
6670 pub fn reset(&self) {}
6671 pub fn rows(&self) -> Vec<String> { Vec::new() }
6672 pub fn result(&self) -> Result<String, String> { Ok(String::new()) }
6673}
6674"#;
6675 let methods = methods(script);
6676
6677 assert_eq!(getter_method_name(&methods, "title"), Some("title"));
6678 assert_eq!(getter_method_name(&methods, "count"), Some("get_count"));
6679 assert_eq!(getter_method_name(&methods, "open"), Some("is_open"));
6680 // The prefixed convention still resolves a key whose exact-name method
6681 // is not a getter, as long as the prefixed one is.
6682 assert_eq!(getter_method_name(&methods, "items"), Some("has_items"));
6683 assert_eq!(getter_method_name(&methods, "on_input"), None);
6684 assert_eq!(getter_method_name(&methods, "pick"), None);
6685 assert_eq!(getter_method_name(&methods, "reset"), None);
6686 assert_eq!(getter_method_name(&methods, "rows"), None);
6687 assert_eq!(getter_method_name(&methods, "result"), None);
6688 assert!(has_state_getter(&methods, "title"));
6689 assert!(!has_state_getter(&methods, "on_input"));
6690 // A prefixed payload-taking method is a handler, not a getter either.
6691 assert!(!has_state_getter(&methods, "input"));
6692 }
6693
6694 /// The two lookups differ in exactly one test, and both go through the same
6695 /// search — a user struct is a fine start of a member path even though it
6696 /// has no `Display` form, while the same type as the value itself is not
6697 /// renderable. Proving both call sites here is what keeps them one notion.
6698 #[test]
6699 fn accessor_and_getter_share_one_lookup_and_differ_only_on_renderability() {
6700 let script = r#"
6701impl State {
6702 pub fn user(&self) -> User { User }
6703 pub fn get_count(&self) -> i32 { 0 }
6704 pub fn title(&self) -> String { String::new() }
6705 pub fn on_input(&self, payload: &str) { let _ = payload; }
6706 pub fn reset(&self) {}
6707}
6708"#;
6709 let methods = methods(script);
6710
6711 // A user struct: an accessor (it starts a chain), not a getter (it does
6712 // not render).
6713 assert_eq!(accessor_method_name(&methods, "user"), Some("user"));
6714 assert_eq!(getter_method_name(&methods, "user"), None);
6715 // The `get_`/`is_`/`has_` convention is shared, not re-invented.
6716 assert_eq!(accessor_method_name(&methods, "count"), Some("get_count"));
6717 assert_eq!(getter_method_name(&methods, "count"), Some("get_count"));
6718 assert_eq!(accessor_method_name(&methods, "title"), Some("title"));
6719 // A payload-taking method and a unit-returning one are neither.
6720 assert_eq!(accessor_method_name(&methods, "on_input"), None);
6721 assert_eq!(accessor_method_name(&methods, "reset"), None);
6722 }
6723
6724 /// A member path is split into a root and the segments after it, and
6725 /// anything that is not one is refused so every caller keeps its own
6726 /// behaviour.
6727 #[test]
6728 fn member_path_splits_a_path_and_refuses_everything_else() {
6729 assert_eq!(member_path("user.name"), Some(("user", vec!["name"])));
6730 assert_eq!(member_path("a.b.c"), Some(("a", vec!["b", "c"])));
6731 assert_eq!(
6732 member_path("items[0].name"),
6733 Some(("items", vec!["[0]", "name"]))
6734 );
6735 for not_a_path in [
6736 "title", // a bare name has nothing to chain
6737 "user.name()", // a call is not a path; it keeps the fallback, ungated
6738 "user.", // a trailing separator names no segment
6739 ".name", // no root
6740 "user..name", // an empty segment
6741 "user name", // not an identifier
6742 "items[0", // an unterminated index
6743 "", // nothing at all
6744 ] {
6745 assert_eq!(member_path(not_a_path), None, "{not_a_path:?}");
6746 }
6747 }
6748
6749 /// The invariant, on the key set itself: a member path is registered only
6750 /// when its root has an accessor, and dropped with a diagnostic otherwise.
6751 #[test]
6752 fn a_member_path_key_is_registered_only_when_its_root_has_an_accessor() {
6753 let script = r#"
6754impl State {
6755 pub fn user(&self) -> User { User }
6756 pub fn title(&self) -> String { String::new() }
6757}
6758"#;
6759 let with_accessor =
6760 crate::template_parse::parse_template_to_ast(r#"<p>{{ user.name }} {{ title }}</p>"#)
6761 .unwrap();
6762 let answerable = collect_resolver_keys(
6763 &with_accessor,
6764 &methods(script),
6765 &[],
6766 RenderMode::State,
6767 &props_of(&[]),
6768 );
6769 assert_eq!(
6770 answerable.keys,
6771 vec!["user.name".to_string(), "title".to_string()],
6772 "an answerable member path keeps its key, in order"
6773 );
6774 assert!(answerable.warnings.is_empty(), "{:?}", answerable.warnings);
6775
6776 let without =
6777 crate::template_parse::parse_template_to_ast(r#"<p>{{ user.name }} {{ title }}</p>"#)
6778 .unwrap();
6779 for mode in [RenderMode::State, RenderMode::Resolve] {
6780 let dropped = collect_resolver_keys(&without, &methods(""), &[], mode, &props_of(&[]));
6781 assert_eq!(
6782 dropped.keys,
6783 Vec::<String>::new(),
6784 "an unanswerable member path is not registered, so it cannot emit an arm, and \
6785 neither is the bare name beside it, in {mode:?}"
6786 );
6787 assert_eq!(dropped.warnings.len(), 2, "{:?}", dropped.warnings);
6788 let warning = &dropped.warnings[0];
6789 assert!(warning.contains("{{ user.name }}"), "{warning}");
6790 assert!(
6791 warning.contains("`user` is not a zero-argument `State` method"),
6792 "{warning}"
6793 );
6794 assert!(warning.contains("renders empty"), "{warning}");
6795 }
6796 }
6797
6798 /// The diagnostic says which part of the path is the problem and why, using
6799 /// the same reasons the bound-attribute path reports.
6800 #[test]
6801 fn the_member_path_diagnostic_names_the_root_and_why() {
6802 let tpl = r#"<p>{{ user.name }}</p>"#;
6803 let nodes = crate::template_parse::parse_template_to_ast(tpl).unwrap();
6804
6805 let payload = r#"
6806impl State { pub fn user(&self, payload: &str) -> String { payload.to_string() } }
6807"#;
6808 let unit = r#"
6809impl State { pub fn user(&self) {} }
6810"#;
6811 let script = r#"
6812pub struct State { user: String }
6813impl State { pub fn new() -> Self { Self { user: String::new() } } }
6814"#;
6815 for (script_text, fields, expected) in [
6816 (
6817 payload,
6818 Vec::new(),
6819 "`user` is a payload-taking State method",
6820 ),
6821 (unit, Vec::new(), "`user` returns nothing"),
6822 (
6823 script,
6824 vec!["user".to_string()],
6825 "`user` is a `State` field",
6826 ),
6827 ] {
6828 let collected = collect_resolver_keys(
6829 &nodes,
6830 &methods(script_text),
6831 &fields,
6832 RenderMode::State,
6833 &props_of(&fields),
6834 );
6835 assert_eq!(collected.keys, Vec::<String>::new(), "{expected}");
6836 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
6837 assert!(
6838 collected.warnings[0].contains(expected),
6839 "expected {expected:?} in {:?}",
6840 collected.warnings
6841 );
6842 }
6843 }
6844
6845 /// A bare name IS gated, and only on a getter — the arm it produces is
6846 /// `state.title().to_string()`, which a `State` without `title` cannot
6847 /// compile. With a getter it is registered unchanged, which is the case the
6848 /// examples and the goldens rest on.
6849 ///
6850 /// `{{ user.name() }}` is NOT gated: a key with a call in it is not a name a
6851 /// `State` method can have and not a member path, so it keeps the
6852 /// long-standing fallback. That is pre-existing and disclosed, not claimed
6853 /// closed.
6854 #[test]
6855 fn a_bare_interpolation_key_is_gated_on_a_getter_and_otherwise_untouched() {
6856 let template = r#"<p>{{ title }} {{ user.name() }} {{ count }}</p>"#;
6857 let nodes = crate::template_parse::parse_template_to_ast(template).unwrap();
6858
6859 // No getters at all: every bare name is dropped, and the paren key — which
6860 // no rule here can speak about — is the only one that survives.
6861 let collected =
6862 collect_resolver_keys(&nodes, &methods(""), &[], RenderMode::State, &props_of(&[]));
6863 assert_eq!(
6864 collected.keys,
6865 vec!["user.name()".to_string()],
6866 "a bare name with no getter is not registered, so it cannot emit an \
6867 unanswerable arm; a call key is not gated"
6868 );
6869 assert_eq!(collected.warnings.len(), 2, "{:?}", collected.warnings);
6870 for (key, warning) in [
6871 ("title", &collected.warnings[0]),
6872 ("count", &collected.warnings[1]),
6873 ] {
6874 assert!(warning.contains(&format!("{{{{ {key} }}}}")), "{warning}");
6875 assert!(
6876 warning.contains(&format!("`{key}` is not a zero-argument `State` method")),
6877 "{warning}"
6878 );
6879 assert!(warning.contains("Declare `pub fn"), "{warning}");
6880 }
6881
6882 // A getter answers the bare name, and the key keeps its place in the set.
6883 let with_getters = r#"
6884impl State {
6885 pub fn title(&self) -> String { String::new() }
6886 pub fn count(&self) -> i32 { 0 }
6887}
6888"#;
6889 let answered = collect_resolver_keys(
6890 &nodes,
6891 &methods(with_getters),
6892 &[],
6893 RenderMode::State,
6894 &props_of(&[]),
6895 );
6896 assert_eq!(
6897 answered.keys,
6898 vec![
6899 "title".to_string(),
6900 "user.name()".to_string(),
6901 "count".to_string()
6902 ],
6903 "a getter-backed bare name is registered exactly as before, in order"
6904 );
6905 assert!(answered.warnings.is_empty(), "{:?}", answered.warnings);
6906 }
6907
6908 /// A bare name whose accessor returns something with no `Display` form is
6909 /// dropped too: `state.items().to_string()` on a `Vec<Item>` is the same
6910 /// compile break as a missing method.
6911 #[test]
6912 fn a_bare_name_with_an_unrenderable_return_type_is_not_registered() {
6913 let script = r#"
6914impl State {
6915 pub fn items(&self) -> Vec<Item> { Vec::new() }
6916}
6917"#;
6918 let nodes = crate::template_parse::parse_template_to_ast(r#"<p>{{ items }}</p>"#).unwrap();
6919 let collected = collect_resolver_keys(
6920 &nodes,
6921 &methods(script),
6922 &[],
6923 RenderMode::State,
6924 &props_of(&[]),
6925 );
6926 assert_eq!(collected.keys, Vec::<String>::new(), "{:?}", collected.keys);
6927 assert_eq!(collected.warnings.len(), 1, "{:?}", collected.warnings);
6928 assert!(
6929 collected.warnings[0].contains(
6930 "`items` is a `State` method, but its return type cannot be rendered as text"
6931 ),
6932 "{}",
6933 collected.warnings[0]
6934 );
6935 }
6936
6937 /// The renderability gate accepts what `.to_string()` accepts for the scalar
6938 /// and string types a template can show, and nothing else.
6939 #[test]
6940 fn return_type_is_renderable_accepts_only_display_types() {
6941 for ty in [
6942 Some("String"),
6943 Some("&str"),
6944 Some("&'static str"),
6945 Some("str"),
6946 Some("bool"),
6947 Some("char"),
6948 Some("i32"),
6949 Some("usize"),
6950 Some("f64"),
6951 Some("Rc<String>"),
6952 Some("Cow<'_, str>"),
6953 ] {
6954 assert!(return_type_is_renderable(ty), "{ty:?} should be renderable");
6955 }
6956 for ty in [
6957 None,
6958 Some("()"),
6959 Some("Self"),
6960 Some("Vec<String>"),
6961 Some("Option<String>"),
6962 Some("Result<String, String>"),
6963 Some("Signal<i32>"),
6964 Some("Todo"),
6965 ] {
6966 assert!(!return_type_is_renderable(ty), "{ty:?} is not renderable");
6967 }
6968 }
6969
6970 /// The condition keys collected must match the lookups
6971 /// `rewrite_if_expr` actually emits, for every supported condition shape.
6972 #[test]
6973 fn condition_resolver_keys_match_rewrite_if_expr_lookups() {
6974 for cond in [
6975 "completed",
6976 "!completed",
6977 "count > 0",
6978 "todo.completed",
6979 "is_empty()",
6980 "true",
6981 "!false",
6982 "filter == \"all\"",
6983 ] {
6984 let rewritten = rewrite_if_expr(cond);
6985 let emitted: Vec<&str> = rewritten
6986 .match_indices("resolve(\"")
6987 .map(|(i, _)| {
6988 let rest = &rewritten[i + "resolve(\"".len()..];
6989 &rest[..rest.find('"').expect("closing quote")]
6990 })
6991 .collect();
6992 let collected = condition_resolver_keys(cond);
6993 assert_eq!(
6994 collected,
6995 emitted
6996 .iter()
6997 .map(|k| k.to_string())
6998 .collect::<Vec<String>>(),
6999 "condition `{cond}` emits {emitted:?} but collects {collected:?}"
7000 );
7001 }
7002 }
7003
7004 /// Both identifier scanners must RETURN on input whose continuation stops at
7005 /// once, and they must do it observably.
7006 ///
7007 /// Each scanner enters on `[A-Za-z_]` and extends with the shared
7008 /// `is_ident_char`, then advances. That is two predicates, so the cursor is
7009 /// moved on the entry character unconditionally — see the comment at each
7010 /// scanner. If `is_ident_char` is ever narrowed to exclude a character the
7011 /// entry test accepts, a scanner that only advanced inside the `while` would
7012 /// leave the cursor where it is and spin forever.
7013 ///
7014 /// WHY A THREAD AND A TIMEOUT: with today's `is_ident_char` the two forms are
7015 /// extensionally identical, so no input can tell them apart and **this test
7016 /// passes with or without the unconditional advance — it is a GUARD, not
7017 /// evidence for the advance.** What it buys is that the landmine is
7018 /// observable: without the thread a narrowed predicate would hang the whole
7019 /// suite with no output at all, and here the spin arrives as a failed
7020 /// assertion on this test instead. The evidence for the advance itself is the
7021 /// narrowing experiment in the report, not this test.
7022 ///
7023 /// The inputs are the ones a continuation-only `while` stops on: a letter or
7024 /// underscore followed by a non-identifier character, and the digit-leading
7025 /// form that the entry test declines (so it must be skipped, not consumed).
7026 #[test]
7027 fn a_scanner_that_enters_on_a_first_char_test_advances_even_when_this_is_narrow() {
7028 use std::sync::mpsc;
7029 use std::time::Duration;
7030
7031 // (expression, keys the condition scanner must collect, whether the
7032 // root scanner must report a read of `item`)
7033 let cases = [
7034 ("a!", vec!["a"], false),
7035 ("_x!", vec!["_x"], false),
7036 ("a!b", vec!["a", "b"], false),
7037 ("9a!", vec!["a"], false),
7038 ("item!", vec!["item"], true),
7039 // A member path is ONE token, since the arm that answers it is the
7040 // method chain `state.item().name()`. R-3 changed that tokenisation
7041 // deliberately — it is what stops a `v-if="item.name"` from emitting
7042 // a field access on a `String` — and the advance this test guards is
7043 // still unconditional, inside `member_path_end`.
7044 ("item.name", vec!["item.name"], true),
7045 ("item[0].name", vec!["item[0].name"], true),
7046 ];
7047
7048 for (expr, expected_keys, reads_item) in cases {
7049 let (tx, rx) = mpsc::channel();
7050 let expr_owned = expr.to_string();
7051 let probe = tx.clone();
7052 std::thread::spawn(move || {
7053 let _ = probe.send(condition_resolver_keys(&expr_owned));
7054 });
7055 let (tx2, rx2) = mpsc::channel();
7056 let expr_owned = expr.to_string();
7057 std::thread::spawn(move || {
7058 let _ = tx2.send(expr_reads_roots(&expr_owned, &["item", "i"]));
7059 });
7060
7061 let collected = rx.recv_timeout(Duration::from_secs(10)).unwrap_or_else(|_| {
7062 panic!("condition_resolver_keys did not return on `{expr}` — the scanner is not making progress")
7063 });
7064 let reads = rx2.recv_timeout(Duration::from_secs(10)).unwrap_or_else(|_| {
7065 panic!("expr_reads_roots did not return on `{expr}` — the scanner is not making progress")
7066 });
7067
7068 assert_eq!(collected, expected_keys, "keys collected from `{expr}`");
7069 assert_eq!(reads, reads_item, "root read reported for `{expr}`");
7070 }
7071 }
7072
7073 // ---- R-1c: a loop item is not a `State` field, so an interpolation that
7074 // reads one must not become a resolver key. Before this, `{{ item.name }}`
7075 // inside `v-for="item in items"` collected the top-level key `item.name`
7076 // and `generate_make_resolve` emitted `"item.name" => state.item.name()`,
7077 // which is E0609: the field is `items` and there is no `item()` getter.
7078 // The key set is asserted WHOLE, not by absence of one name, so a key
7079 // reappearing under another name fails here too.
7080
7081 /// R-1c's script, plus the accessor a member path needs: R-1d gates a
7082 /// member-path key on its ROOT having a zero-argument accessor, so a script
7083 /// without one would make these tests fail for a reason that has nothing to
7084 /// do with what they are about (loop-rooted keys, and document order).
7085 const R1C_SCRIPT: &str = r#"
7086 pub struct User { name: String }
7087 impl State {
7088 pub fn items(&self) -> String { String::new() }
7089 pub fn title(&self) -> String { String::new() }
7090 pub fn user(&self) -> User { User { name: String::new() } }
7091 }"#;
7092
7093 fn keys_of(tpl: &str, mode: RenderMode) -> Vec<String> {
7094 let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
7095 collect_resolver_keys(&nodes, &methods(R1C_SCRIPT), &[], mode, &props_of(&[])).keys
7096 }
7097
7098 #[test]
7099 fn a_loop_rooted_interpolation_is_not_a_resolver_key() {
7100 for tpl in [
7101 // the canonical idiom, the one the golden used to encode as E0609
7102 r#"<ul><li v-for="item in items">{{ item.name }}</li></ul>"#,
7103 // an explicitly indexed loop, so the item name is `__idx` by default
7104 r#"<ul><li v-for="item in items">{{ item.name }} {{ item.id }}</li></ul>"#,
7105 // the index, and the collection itself
7106 r#"<ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
7107 // an interpolation nested deeper in the body
7108 r#"<ul><li v-for="item in items"><span><b>{{ item.name }}</b></span></li></ul>"#,
7109 // the outer item read inside a nested loop, which the inner `v-for`
7110 // does not shadow
7111 r#"<ul><li v-for="(item, i) in items"><ul><li v-for="c in rows">{{ item.name }} {{ c }}</li></ul></li></ul>"#,
7112 ] {
7113 for mode in [RenderMode::State, RenderMode::Resolve] {
7114 assert_eq!(
7115 keys_of(tpl, mode),
7116 Vec::<String>::new(),
7117 "{mode:?}: a read of a loop variable cannot be answered by a \
7118 flat arm over `State` fields: {tpl}"
7119 );
7120 }
7121 }
7122 }
7123
7124 /// The fix is scoped to loop-rooted keys. A dotted interpolation that
7125 /// appears at the top level of the component is a lookup of a state field
7126 /// path and is collected exactly as before — the whole set, in order.
7127 #[test]
7128 fn a_root_level_dotted_interpolation_is_still_a_key() {
7129 for mode in [RenderMode::State, RenderMode::Resolve] {
7130 assert_eq!(
7131 keys_of(
7132 r#"<p>{{ user.name }} {{ items[0].name }} {{ title }}</p>"#,
7133 mode
7134 ),
7135 vec!["user.name", "items[0].name", "title"],
7136 "{mode:?}: nothing here is inside a loop, so nothing is dropped"
7137 );
7138 }
7139 }
7140
7141 /// Dropping a loop-rooted key must not disturb the ones that stay: the
7142 /// surrounding keys keep their document order, and a key read inside a
7143 /// loop body does not leak out to the top level either.
7144 #[test]
7145 fn loop_rooted_interpolations_are_dropped_and_the_rest_keep_document_order() {
7146 for mode in [RenderMode::State, RenderMode::Resolve] {
7147 assert_eq!(
7148 keys_of(
7149 r#"<header>{{ title }}</header><ul><li v-for="(item, i) in items">{{ item.name }} {{ i }} {{ i + 1 }}</li></ul><footer>{{ user.name }}</footer>"#,
7150 mode
7151 ),
7152 vec!["title", "user.name"],
7153 "{mode:?}: the two loop-rooted keys go, the two that surround \
7154 them keep their order"
7155 );
7156 }
7157 }
7158
7159 /// The open question R-1 left behind, pinned from both sides at once.
7160 ///
7161 /// R-1 treats a bare-index interpolation (`{{ i }}`) as one of only two
7162 /// forms that emit a DIRECT read of the loop variable and therefore
7163 /// render, so its diagnostic does not report a loop whose body reads
7164 /// nothing but the index. That is a decision about whether to REPORT a
7165 /// loop; this task removes `{{ i }}` from the KEY set, which is a decision
7166 /// about whether to EMIT an arm. The two are independent, and this test
7167 /// asserts both facts on the same two templates: `{{ i }}` is not a key
7168 /// and is not reported, while `{{ i + 1 }}` is also not a key but IS
7169 /// reported (it is emitted as the flat `resolve("i + 1")`, which the arm
7170 /// table cannot answer). Neither decision can move without this test
7171 /// showing the other.
7172 #[test]
7173 fn the_bare_index_interpolation_is_direct_and_is_still_not_a_key() {
7174 let direct = r#"<ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#;
7175 let compound = r#"<ul><li v-for="(item, i) in items">{{ i + 1 }}</li></ul>"#;
7176 let nodes =
7177 |tpl: &str| crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
7178 let direct_resolve = collect_resolver_keys(
7179 &nodes(direct),
7180 &methods(R1C_SCRIPT),
7181 &[],
7182 RenderMode::Resolve,
7183 &props_of(&[]),
7184 );
7185 let compound_resolve = collect_resolver_keys(
7186 &nodes(compound),
7187 &methods(R1C_SCRIPT),
7188 &[],
7189 RenderMode::Resolve,
7190 &props_of(&[]),
7191 );
7192
7193 assert_eq!(
7194 direct_resolve.keys,
7195 Vec::<String>::new(),
7196 "`{{ i }}` is loop-rooted, so it is not a top-level key: {:?}",
7197 direct_resolve.keys
7198 );
7199 assert!(
7200 direct_resolve.warnings.is_empty(),
7201 "R-1's exemption: a bare-index interpolation emits `text(i.to_string())`, \
7202 a direct read, so the loop is not reported: {:?}",
7203 direct_resolve.warnings
7204 );
7205 assert_eq!(
7206 compound_resolve.keys,
7207 Vec::<String>::new(),
7208 "the same key-set rule applies to a compound index read: {:?}",
7209 compound_resolve.keys
7210 );
7211 assert_eq!(
7212 compound_resolve.warnings.len(),
7213 1,
7214 "and R-1's N3 rule is untouched: the body emits `text(resolve(\"i + 1\"))`, \
7215 which no arm answers, so the loop IS reported: {:?}",
7216 compound_resolve.warnings
7217 );
7218 }
7219
7220 /// The same closed state, asserted from the key-set side: a `v-for`
7221 /// collection with no accessor contributes no key from either route, and the
7222 /// loop is still reported (R-1's N2 finding, re-pinned because this task
7223 /// edits the key set its premise was about).
7224 #[test]
7225 fn a_collection_with_no_accessor_registers_no_key_and_is_still_reported() {
7226 let nodes = crate::template_parse::parse_template_to_ast(
7227 r#"<p :x="items">{{ items }}</p><ul><li v-for="(item, i) in items">{{ i }}</li></ul>"#,
7228 )
7229 .expect("template parses");
7230 let script = r#"
7231 impl State {
7232 pub fn draft(&self) -> String { String::new() }
7233 }"#;
7234
7235 let resolve = collect_resolver_keys(
7236 &nodes,
7237 &methods(script),
7238 &[],
7239 RenderMode::Resolve,
7240 &props_of(&[]),
7241 );
7242 assert!(
7243 !resolve.keys.iter().any(|k| k == "items"),
7244 "a collection with no accessor is registered from neither route: {:?}",
7245 resolve.keys
7246 );
7247 assert_eq!(
7248 resolve.warnings.len(),
7249 3,
7250 "the loop report, the dropped interpolation and the dropped binding, and \
7251 nothing else: {:?}",
7252 resolve.warnings
7253 );
7254 assert!(
7255 !resolve.keys.iter().any(|k| k == "item" || k == "i"),
7256 "but nothing loop-rooted is: {:?}",
7257 resolve.keys
7258 );
7259 }
7260 // =========================================================================
7261 // R-2. `v-model`: the read side is a resolver key like any other, and the two
7262 // conditions codegen cannot honour are reported in R-1's family.
7263 // =========================================================================
7264
7265 const R2_SCRIPT: &str = r#"
7266 pub struct User { name: String }
7267 impl User {
7268 pub fn name(&self) -> String { String::new() }
7269 }
7270 impl State {
7271 pub fn items(&self) -> String { String::new() }
7272 pub fn draft(&self) -> String { String::new() }
7273 pub fn user(&self) -> User { User { name: String::new() } }
7274 }"#;
7275
7276 fn r2_keys_and_warnings(
7277 tpl: &str,
7278 script: &str,
7279 fields: &[&str],
7280 mode: RenderMode,
7281 ) -> (Vec<String>, Vec<String>) {
7282 let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
7283 let fields: Vec<String> = fields.iter().map(|f| f.to_string()).collect();
7284 let collected =
7285 collect_resolver_keys(&nodes, &methods(script), &fields, mode, &props_of(&fields));
7286 (collected.keys, collected.warnings)
7287 }
7288
7289 /// The live read. The synthetic `:value` bind is created at emit time, so before
7290 /// this it was invisible to key collection and every `v-model` read resolved to
7291 /// the empty string.
7292 #[test]
7293 fn a_v_model_on_an_accessor_is_registered_as_a_resolver_key() {
7294 for mode in [RenderMode::State, RenderMode::Resolve] {
7295 let (keys, warnings) =
7296 r2_keys_and_warnings(r#"<input v-model="draft"/>"#, R2_SCRIPT, &[], mode);
7297 assert_eq!(
7298 keys,
7299 vec!["draft".to_string()],
7300 "a v-model on a `State` accessor must get a resolver arm, in {mode:?}"
7301 );
7302 if matches!(mode, RenderMode::State) {
7303 assert!(warnings.is_empty(), "{:?}", warnings);
7304 }
7305 }
7306 }
7307
7308 /// A dotted `v-model` is answered by the same chain an interpolation of the same
7309 /// path reads, and dropped under the same conditions.
7310 #[test]
7311 fn a_dotted_v_model_is_answered_by_the_same_chain_as_an_interpolation() {
7312 let (keys, warnings) = r2_keys_and_warnings(
7313 r#"<input v-model="user.name"/>"#,
7314 R2_SCRIPT,
7315 &[],
7316 RenderMode::State,
7317 );
7318 assert_eq!(keys, vec!["user.name".to_string()], "{:?}", keys);
7319 assert!(warnings.is_empty(), "{:?}", warnings);
7320
7321 // Without the accessor the read cannot be answered, so it is reported rather
7322 // than left to the empty-string fallback.
7323 let (keys, warnings) = r2_keys_and_warnings(
7324 r#"<input v-model="form.name"/>"#,
7325 R2_SCRIPT,
7326 &["form"],
7327 RenderMode::State,
7328 );
7329 assert_eq!(keys, Vec::<String>::new(), "{:?}", keys);
7330 assert_eq!(warnings.len(), 1, "{:?}", warnings);
7331 assert!(
7332 warnings[0].contains(r#"v-model="form.name""#),
7333 "{}",
7334 warnings[0]
7335 );
7336 assert!(
7337 warnings[0].contains("is a `State` field"),
7338 "{}",
7339 warnings[0]
7340 );
7341 assert!(warnings[0].contains("renders empty"), "{}", warnings[0]);
7342 }
7343
7344 /// E-1. A `v-model` on a loop item reads the item's value and writes nothing,
7345 /// because the dispatcher resolves `on:input` against `State` and `State` has no
7346 /// handle on the loop item.
7347 #[test]
7348 fn a_v_model_on_a_loop_item_reads_it_and_writes_nothing() {
7349 for mode in [RenderMode::State, RenderMode::Resolve] {
7350 let (keys, warnings) = r2_keys_and_warnings(
7351 r#"<ul><li v-for="item in items"><input v-model="item.name"/></li></ul>"#,
7352 R2_SCRIPT,
7353 &[],
7354 mode,
7355 );
7356 assert_eq!(
7357 keys,
7358 Vec::<String>::new(),
7359 "a loop-rooted v-model must not register a resolver key, in {mode:?}"
7360 );
7361 // State reports the write, because the loop renders there and the write
7362 // is the part that cannot happen. Resolve mode reports the loop instead,
7363 // which is the stronger fact: nothing in the body renders at all, so a
7364 // second warning about its write would only repeat it.
7365 if matches!(mode, RenderMode::State) {
7366 assert_eq!(warnings.len(), 1, "{:?}", warnings);
7367 // Pinned on the two routes that actually WORK, not only on the
7368 // fact that the write fails. A warning that names no remedy is
7369 // half the message, and the whole point of R-2 is that a user
7370 // can act on it, so the actionable half is what is asserted.
7371 for expected in [
7372 r#"v-model="item.name""#,
7373 "cannot be written",
7374 // the two structural facts that make it unfixable here
7375 "DETACHED COPY",
7376 "no index",
7377 // the two routes that work
7378 "render_with_state",
7379 "__vmodel_set_",
7380 ] {
7381 assert!(
7382 warnings[0].contains(expected),
7383 "the warning must mention {expected:?} so the message is actionable, \
7384 but it read:\n{}",
7385 warnings[0]
7386 );
7387 }
7388 } else {
7389 assert_eq!(warnings.len(), 1, "{:?}", warnings);
7390 assert!(
7391 warnings[0].contains("`v-for` over `items` cannot render"),
7392 "{}",
7393 warnings[0]
7394 );
7395 }
7396 }
7397 }
7398
7399 /// Ruling 1: Resolve mode grows no write path, so it reports instead. The
7400 /// diagnostic is in R-1's family and says which renderer does support it.
7401 #[test]
7402 fn a_v_model_in_resolve_mode_is_reported() {
7403 let (keys, warnings) = r2_keys_and_warnings(
7404 r#"<input v-model="draft"/>"#,
7405 R2_SCRIPT,
7406 &[],
7407 RenderMode::Resolve,
7408 );
7409 assert_eq!(keys, vec!["draft".to_string()], "the read still resolves");
7410 assert_eq!(warnings.len(), 1, "{:?}", warnings);
7411 assert!(
7412 warnings[0].contains(r#"v-model="draft""#),
7413 "{}",
7414 warnings[0]
7415 );
7416 assert!(
7417 warnings[0].contains("cannot be written in Resolve mode"),
7418 "{}",
7419 warnings[0]
7420 );
7421 assert!(
7422 warnings[0].contains("render_with_state"),
7423 "the report names the renderer that does support it: {}",
7424 warnings[0]
7425 );
7426 }
7427
7428 /// One name, from one place: the dotted translation is a hardcoded literal here,
7429 /// not a call into the function under test, and the loop-rooted case is skipped by
7430 /// both collectors.
7431 #[test]
7432 fn the_v_model_collectors_agree_on_one_hardcoded_setter_name() {
7433 for (tpl, expected_expressions, expected_handlers) in [
7434 (
7435 r#"<input v-model="draft"/>"#,
7436 vec!["draft"],
7437 vec!["__vmodel_set_draft"],
7438 ),
7439 (
7440 r#"<input v-model="user.name"/>"#,
7441 vec!["user.name"],
7442 vec!["__vmodel_set_user_name"],
7443 ),
7444 (
7445 r#"<ul><li v-for="item in items"><input v-model="item.name"/></li></ul>"#,
7446 vec![],
7447 vec![],
7448 ),
7449 ] {
7450 let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
7451 let collected = collect_vmodel_expressions(&nodes);
7452 let expressions: Vec<String> = collected.iter().map(|(expr, _)| expr.clone()).collect();
7453 assert_eq!(expressions, expected_expressions, "expressions for {tpl}");
7454
7455 // The handler the setter is generated under is the one the dispatcher
7456 // carries, and the dotted translation lives in one place.
7457 let handlers: Vec<String> = collected
7458 .iter()
7459 .map(|(_, handler)| handler.clone())
7460 .collect();
7461 assert_eq!(handlers, expected_handlers, "handlers for {tpl}");
7462
7463 let dispatcher: Vec<String> = collect_handlers(&nodes)
7464 .into_iter()
7465 .filter(|h| h.starts_with("__vmodel_set_"))
7466 .collect();
7467 assert_eq!(
7468 dispatcher, expected_handlers,
7469 "the dispatcher must carry an arm for every non-loop-rooted v-model, under \
7470 the same name, and none for a loop-rooted one, in {tpl}"
7471 );
7472 }
7473 }
7474 // =========================================================================
7475 // R-3. `v-if` / `v-show` conditions are resolver reads like any other, and a
7476 // static `class` merges with a dynamic one.
7477 // =========================================================================
7478
7479 const R3_SCRIPT: &str = r#"
7480 pub struct User { name: String }
7481 impl User {
7482 pub fn name(&self) -> String { String::new() }
7483 }
7484 impl State {
7485 pub fn items(&self) -> String { String::new() }
7486 pub fn visible(&self) -> bool { false }
7487 pub fn wide(&self) -> bool { false }
7488 pub fn count(&self) -> i32 { 0 }
7489 pub fn user(&self) -> User { User { name: String::new() } }
7490 }"#;
7491
7492 /// The `class` props expression the template compiles to, with the surrounding
7493 /// `Props::new()` noise left in so a second `.set` for the same attribute is
7494 /// visible.
7495 fn class_set_of(tpl: &str) -> String {
7496 let rs = compile_template_to_rs_full_with_mode(
7497 tpl,
7498 "App",
7499 None,
7500 Some(R3_SCRIPT),
7501 None,
7502 RenderMode::State,
7503 )
7504 .expect("template compiles");
7505 let start = rs
7506 .find(r#".set("class""#)
7507 .unwrap_or_else(|| panic!("no class is set for {tpl}: {rs}"));
7508 let rest = &rs[start..];
7509 // a `.set` value ends at the matching close paren of the call
7510 let mut depth = 0i32;
7511 for (i, c) in rest.char_indices() {
7512 match c {
7513 '(' => depth += 1,
7514 ')' => {
7515 depth -= 1;
7516 if depth == 0 {
7517 return rest[..=i].to_string();
7518 }
7519 }
7520 _ => {}
7521 }
7522 }
7523 panic!("unterminated class value for {tpl}: {rest}");
7524 }
7525
7526 fn r3_keys_and_warnings(tpl: &str, mode: RenderMode) -> (Vec<String>, Vec<String>) {
7527 let nodes = crate::template_parse::parse_template_to_ast(tpl).expect("template parses");
7528 let collected =
7529 collect_resolver_keys(&nodes, &methods(R3_SCRIPT), &[], mode, &props_of(&[]));
7530 (collected.keys, collected.warnings)
7531 }
7532
7533 /// The live read, and the reason the audit called the directive inert: the
7534 /// condition became a `resolve("…")` call, and nothing ever registered the
7535 /// key, so every condition was permanently falsy.
7536 #[test]
7537 fn a_v_if_condition_is_registered_as_a_resolver_key() {
7538 for tpl in [
7539 r#"<div v-if="visible"/>"#,
7540 r#"<div><p v-if="visible">x</p></div>"#,
7541 r#"<div v-show="wide"/>"#,
7542 r#"<div v-if="user.name"/>"#,
7543 r#"<div><p v-if="count > 0">x</p></div>"#,
7544 ] {
7545 for mode in [RenderMode::State, RenderMode::Resolve] {
7546 let (keys, warnings) = r3_keys_and_warnings(tpl, mode);
7547 assert!(
7548 warnings.is_empty(),
7549 "a condition on a `State` accessor needs no warning: {warnings:?} in \
7550 {mode:?} for {tpl}"
7551 );
7552 assert!(
7553 !keys.is_empty(),
7554 "a `v-if`/`v-show` condition is a resolver read and must be \
7555 registered, in {mode:?} for {tpl}"
7556 );
7557 }
7558 }
7559 // and the keys are the ones the emission actually looks up
7560 assert_eq!(
7561 r3_keys_and_warnings(r#"<div><p v-if="user.name">x</p></div>"#, RenderMode::State).0,
7562 vec!["user.name".to_string()],
7563 "a dotted condition is one key, not one per segment: the emission reads it \
7564 as a single path"
7565 );
7566 }
7567
7568 /// The diagnostic is a DISTINCT condition from R-1's loop report, in the same
7569 /// style: the requirement and the consequence, never a claim about a
7570 /// capability the emitted code does not have.
7571 #[test]
7572 fn an_unanswerable_condition_is_reported_with_its_consequence() {
7573 let nodes = crate::template_parse::parse_template_to_ast(
7574 r#"<div><p v-if="missing">x</p><b v-show="gone"/></div>"#,
7575 )
7576 .expect("template parses");
7577 let collected = collect_resolver_keys(
7578 &nodes,
7579 &methods(R3_SCRIPT),
7580 &[],
7581 RenderMode::State,
7582 &props_of(&[]),
7583 );
7584 assert!(
7585 collected.keys.is_empty(),
7586 "nothing about the condition is answerable, so nothing is registered: {:?}",
7587 collected.keys
7588 );
7589 assert_eq!(collected.warnings.len(), 2, "{:?}", collected.warnings);
7590 assert!(
7591 collected.warnings[0].contains("if=\"missing\"")
7592 && collected.warnings[0]
7593 .contains("the element and everything inside it are not rendered"),
7594 "a `v-if` names what a falsy condition costs: {:?}",
7595 collected.warnings[0]
7596 );
7597 assert!(
7598 collected.warnings[1].contains("show=\"gone\"")
7599 && collected.warnings[1].contains("display: none"),
7600 "a `v-show` names its own consequence, because it is a different one: {:?}",
7601 collected.warnings[1]
7602 );
7603 assert!(
7604 !collected
7605 .warnings
7606 .iter()
7607 .any(|w| w.contains("The write still works")),
7608 "no diagnostic may claim a capability the emitted code lacks: {:?}",
7609 collected.warnings
7610 );
7611 }
7612
7613 /// R-1c's rule, applied to conditions: a read of a loop variable is answered
7614 /// by the loop body, not by an arm over `State` fields, so it is not
7615 /// registered — and the loop itself is already reported by R-1's family, so
7616 /// adding a second report for the same loop would be noise.
7617 #[test]
7618 fn a_loop_rooted_condition_is_not_registered_and_is_not_reported_again() {
7619 let nodes = crate::template_parse::parse_template_to_ast(
7620 r#"<ul><li v-for="(todo, i) in todos" v-if="todo.keep">x</li></ul>"#,
7621 )
7622 .expect("template parses");
7623 let script = r#"
7624 impl State {
7625 pub fn todos(&self) -> String { String::new() }
7626 }"#;
7627 for mode in [RenderMode::State, RenderMode::Resolve] {
7628 let collected =
7629 collect_resolver_keys(&nodes, &methods(script), &[], mode, &props_of(&[]));
7630 assert!(
7631 collected.keys.is_empty(),
7632 "a read of a loop item is not a `State` field: {:?} in {mode:?}",
7633 collected.keys
7634 );
7635 assert!(
7636 !collected.warnings.iter().any(|w| w.contains("todo.keep")),
7637 "the loop report already covers the read, so the condition must not \
7638 be reported as well: {:?}",
7639 collected.warnings
7640 );
7641 assert_eq!(
7642 collected.warnings.len(),
7643 usize::from(matches!(mode, RenderMode::Resolve)),
7644 "in State mode the loop renders, so nothing is reported at all; in \
7645 Resolve mode the loop's own report is the only one: {:?}",
7646 collected.warnings
7647 );
7648 }
7649 }
7650
7651 /// `v-else-if` is on the `v-if` rewrite path — the chain's condition goes
7652 /// through `rewrite_if_expr` exactly as a `v-if`'s does — so its operands are
7653 /// resolver reads too, and an unregistered one answers `""` and is always
7654 /// falsy: the `v-else-if` element then never renders, and nothing says so.
7655 /// Both spellings the validator accepts are here.
7656 #[test]
7657 fn a_v_else_if_condition_is_registered_like_a_v_if() {
7658 for tpl in [
7659 r#"<div><p v-if="count > 0">a</p><p v-else-if="visible">b</p></div>"#,
7660 r#"<div><p v-if="count > 0">a</p><p v-elseif="visible">b</p></div>"#,
7661 r#"<div><p v-if="count > 0">a</p><p v-else-if="user.name">b</p></div>"#,
7662 r#"<div><p v-if="count > 0">a</p><p v-else-if="count > 1">b</p></div>"#,
7663 ] {
7664 for mode in [RenderMode::State, RenderMode::Resolve] {
7665 let (keys, warnings) = r3_keys_and_warnings(tpl, mode);
7666 assert!(
7667 !keys.is_empty(),
7668 "a `v-else-if` condition is a resolver read and must be \
7669 registered, in {mode:?} for {tpl}"
7670 );
7671 assert!(
7672 warnings.is_empty(),
7673 "a `v-else-if` condition on a `State` accessor needs no \
7674 warning: {warnings:?} in {mode:?} for {tpl}"
7675 );
7676 let rs = compile_template_to_rs_full_with_mode(
7677 tpl,
7678 "App",
7679 None,
7680 Some(R3_SCRIPT),
7681 None,
7682 mode,
7683 )
7684 .expect("template compiles");
7685 for key in &keys {
7686 let looked_up = format!("resolve({})", string_lit(key));
7687 assert!(
7688 rs.contains(&looked_up),
7689 "`{key}` is registered, so the emission must look it up: {rs}"
7690 );
7691 }
7692 }
7693 }
7694 }
7695
7696 /// The R-1c round-2 review's companion finding, pinned on the shape it must
7697 /// keep: in a compound condition only the operands of a comparison are read
7698 /// as numbers. `count > 0 && visible` makes `count` numeric because it sits
7699 /// next to `>`, and `visible` a truthiness test because it does not — which
7700 /// is what makes the expression compile. Reading BOTH as numbers is an
7701 /// `f64 && f64`, and rustc reports that as
7702 /// `error[E0308]: mismatched types … expected bool, found f64`, so the
7703 /// earlier whole-expression `has_cmp` decision was a compile error, not a
7704 /// silent wrong render.
7705 ///
7706 /// This pins EMISSION SHAPE, so it is a guard, not behavioural evidence: the
7707 /// pixels are in
7708 /// `root_vif_class_behaviour::a_compound_condition_re_evaluates_both_of_its_operands`.
7709 #[test]
7710 fn a_compound_condition_reads_only_its_comparison_operands_as_numbers() {
7711 let rs = compile_template_to_rs_full_with_mode(
7712 r#"<div v-if="count > 0 && visible">x</div>"#,
7713 "App",
7714 None,
7715 Some(R3_SCRIPT),
7716 None,
7717 RenderMode::State,
7718 )
7719 .expect("template compiles");
7720 assert!(
7721 rs.contains(&format!("resolve({}).parse::<f64>()", string_lit("count"))),
7722 "`count` is an operand of `>`, so it is read as a number: {rs}"
7723 );
7724 assert!(
7725 !rs.contains(&format!(
7726 "resolve({}).parse::<f64>()",
7727 string_lit("visible")
7728 )),
7729 "`visible` is not an operand of any comparison, so it must be a \
7730 truthiness test and not a number — a number here makes the `&&` an \
7731 `f64`, which does not compile: {rs}"
7732 );
7733 assert!(
7734 rs.contains(&resolver_truthiness(&format!(
7735 "resolve({})",
7736 string_lit("visible")
7737 ))),
7738 "the one notion of truthiness is what a non-comparison operand gets: {rs}"
7739 );
7740 }
7741
7742 /// The six answers a resolver can give, judged by the ONE truthiness test, by
7743 /// COMPILING AND RUNNING the condition the emitter produces.
7744 ///
7745 /// A string-shape assertion cannot decide whether `"0"` is truthy — that is
7746 /// the whole defect — so this compiles the emitted condition with a stub
7747 /// `resolve`, runs it once per answer, and reads the `bool` back. Nothing
7748 /// here re-derives the rule: the expression under test is the codegen output.
7749 #[test]
7750 fn each_resolver_answer_is_judged_by_the_one_truthiness_test() {
7751 let rs = compile_template_to_rs_full_with_mode(
7752 r#"<div v-if="visible"/>"#,
7753 "App",
7754 None,
7755 Some(R3_SCRIPT),
7756 None,
7757 RenderMode::State,
7758 )
7759 .expect("template compiles");
7760 let start = rs
7761 .find("if { let __v = resolve(")
7762 .expect("a v-if condition is emitted")
7763 + 3;
7764 let condition = &rs[start..];
7765 let mut depth = 0i32;
7766 let mut closed = None;
7767 for (i, c) in condition.char_indices() {
7768 match c {
7769 '{' => depth += 1,
7770 '}' => {
7771 depth -= 1;
7772 if depth == 0 {
7773 closed = Some(i + 1);
7774 break;
7775 }
7776 }
7777 _ => {}
7778 }
7779 }
7780 let end = closed.unwrap_or_else(|| panic!("the condition is not closed in {rs}"));
7781
7782 let dir = std::env::temp_dir().join("velox-r3-truthiness");
7783 std::fs::create_dir_all(&dir).expect("temp dir");
7784 let src = dir.join("verdict.rs");
7785 std::fs::write(
7786 &src,
7787 format!(
7788 "fn resolve(_key: &str) -> String {{ std::env::args().nth(1).unwrap() }}\n\
7789 fn main() {{ println!(\"{{}}\", {}\n); }}\n",
7790 &condition[..end]
7791 ),
7792 )
7793 .expect("write the probe");
7794 let bin = dir.join("verdict");
7795 let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
7796 let build = std::process::Command::new(&rustc)
7797 .args(["--edition", "2024", "-o"])
7798 .arg(&bin)
7799 .arg(&src)
7800 .output()
7801 .unwrap_or_else(|e| panic!("`{rustc}` must be runnable: {e}"));
7802 assert!(
7803 build.status.success(),
7804 "the emitted condition must compile on its own:\n{}\n{}",
7805 std::fs::read_to_string(&src).expect("read back the probe"),
7806 String::from_utf8_lossy(&build.stderr)
7807 );
7808
7809 for (answer, want) in [
7810 ("", "false"),
7811 ("false", "false"),
7812 ("0", "false"),
7813 ("0.0", "false"),
7814 ("-0.0", "false"),
7815 ("0.00", "false"),
7816 ("true", "true"),
7817 ("false ", "true"),
7818 ("1", "true"),
7819 ("-3.5", "true"),
7820 ("abc", "true"),
7821 ("a b c", "true"),
7822 ] {
7823 let run = std::process::Command::new(&bin)
7824 .arg(answer)
7825 .output()
7826 .expect("run");
7827 assert_eq!(
7828 String::from_utf8_lossy(&run.stdout).trim(),
7829 want,
7830 "the resolver answered {:?} and the condition must read that as {want}",
7831 answer
7832 );
7833 }
7834 let _ = std::fs::remove_dir_all(&dir);
7835 }
7836
7837 /// The ONE truthiness test reaches every path a condition can take. This is
7838 /// the shape of the expression, not its verdicts — the verdicts are
7839 /// `each_resolver_answer_is_judged_by_the_one_truthiness_test`, which runs the
7840 /// emitted code. What this pins is that no path grew a second test of its own.
7841 #[test]
7842 fn every_condition_path_gets_the_one_truthiness_test() {
7843 let read = format!("resolve({})", string_lit("visible"));
7844 let want = resolver_truthiness(&read);
7845 for (label, tpl) in [
7846 ("a v-if", r#"<div v-if="visible"/>"#),
7847 ("a v-show", r#"<div v-show="visible"/>"#),
7848 (
7849 "a v-else-if",
7850 r#"<div><p v-if="count > 0"/><p v-else-if="visible"/></div>"#,
7851 ),
7852 (
7853 "an object :class condition",
7854 r#"<div :class="{ active: visible }"/>"#,
7855 ),
7856 ] {
7857 let rs = compile_template_to_rs_full_with_mode(
7858 tpl,
7859 "App",
7860 None,
7861 Some(R3_SCRIPT),
7862 None,
7863 RenderMode::State,
7864 )
7865 .expect("template compiles");
7866 assert!(
7867 rs.contains(&want),
7868 "{label} must be judged by the one truthiness test, {want:?}: {rs}"
7869 );
7870 }
7871 // The second call site: a `:class` condition on an element inside a v-for
7872 // body is read through the resolver in the Resolve renderer, where it is
7873 // a `String` in an `if` position, and it is indexed by the loop counter.
7874 let rs = compile_template_to_rs_full_with_mode(
7875 r#"<div v-for="item in items"><span :class="{ active: item.keep }"/></div>"#,
7876 "App",
7877 None,
7878 Some(R3_SCRIPT),
7879 None,
7880 RenderMode::Resolve,
7881 )
7882 .expect("template compiles");
7883 let indexed = r#"resolve(&format!("items[{}].keep", __idx))"#;
7884 assert!(
7885 rs.contains(&resolver_truthiness(indexed)),
7886 "a loop-rooted condition in the Resolve renderer must get the one truthiness \
7887 test as well: {rs}"
7888 );
7889 }
7890
7891 /// Vue semantics: a static `class` and a dynamic `:class` on the same
7892 /// element MERGE, so the class list is `card active` when the condition holds
7893 /// and `card` when it does not. `Props::set` replaces, so the merge has to be
7894 /// built before it is set.
7895 #[test]
7896 fn a_static_class_and_an_object_syntax_class_are_emitted_as_one_set() {
7897 let tpl = r#"<div class="card" :class="{ active: visible, 'is-wide': wide }"/>"#;
7898 let set = class_set_of(tpl);
7899 assert_eq!(
7900 set.matches(r#".set("class""#).count(),
7901 1,
7902 "one `.set` for the class, or the second replaces the first: {set}"
7903 );
7904 assert!(
7905 set.contains(r#"__classes.push("card");"#),
7906 "the static class is the first entry whatever the authored order: {set}"
7907 );
7908 assert!(
7909 set.contains(r#"__classes.push("active");"#)
7910 && set.contains(r#"__classes.push("is-wide");"#),
7911 "both conditions contribute their class: {set}"
7912 );
7913 assert!(
7914 !set.contains('\''),
7915 "an object key written in quotes is a class NAME; the quotes must not \
7916 become part of it, or the cascade's whole-word match misses: {set}"
7917 );
7918 // order of the conditions is the authored order
7919 let active = set.find("__classes.push(\"active\")").unwrap();
7920 let wide = set.find("__classes.push(\"is-wide\")").unwrap();
7921 assert!(active < wide, "the authored order is kept: {set}");
7922 }
7923
7924 /// The merge only engages when both are present. An element with a static
7925 /// class alone, or a `:class` alone, keeps the bytes it had before this task,
7926 /// which is what keeps State-mode output identical where the task does not
7927 /// target it.
7928 #[test]
7929 fn a_class_on_its_own_is_emitted_exactly_as_before() {
7930 assert_eq!(
7931 class_set_of(r#"<div class="card"/>"#),
7932 r#".set("class", "card")"#,
7933 "a static class alone is unchanged"
7934 );
7935 assert_eq!(
7936 class_set_of(r#"<div :class="{ active: visible }"/>"#),
7937 format!(
7938 r#".set("class", {{ let mut __classes: Vec<&str> = Vec::new(); if {truthy} {{ __classes.push("active"); }} __classes.join(" ") }})"#,
7939 truthy = resolver_truthiness(&format!("resolve({})", string_lit("visible")))
7940 ),
7941 "a `:class` alone is the object block it was before this task, byte for \
7942 byte: the same `Vec<&str>`, the same single push, the same join, and the ONE \
7943 truthiness test rather than a second one of its own"
7944 );
7945 assert_eq!(
7946 class_set_of(r#"<div :class="label"/>"#),
7947 r#".set("class", &resolve("label"))"#,
7948 "a non-object `:class` alone is unchanged too"
7949 );
7950 }
7951
7952 /// The string form appends its words, in both authored orders. Vue merges
7953 /// whatever each binding contributes; it does not depend on which was written
7954 /// first.
7955 #[test]
7956 fn a_string_syntax_class_is_merged_in_either_authored_order() {
7957 for tpl in [
7958 r#"<div class="card" :class="label"/>"#,
7959 r#"<div :class="label" class="card"/>"#,
7960 ] {
7961 let set = class_set_of(tpl);
7962 assert_eq!(
7963 set.matches(r#".set("class""#).count(),
7964 1,
7965 "one `.set` for the class, whichever order they were written in: {set}"
7966 );
7967 assert!(
7968 set.contains(r#"__classes.push("card");"#)
7969 && set.contains("__src.split_whitespace()"),
7970 "the static class and the bound words both reach the list: {set}"
7971 );
7972 }
7973 }
7974
7975 /// The one notion of truthiness, and the one notion of an identifier: a
7976 /// condition is rewritten into the same `resolve` reads the collector
7977 /// registers, so a key can never be missing for a reason the emission did not
7978 /// have.
7979 #[test]
7980 fn the_condition_collector_and_the_condition_rewrite_agree_on_every_shape() {
7981 for cond in [
7982 "visible",
7983 "!visible",
7984 "count > 0",
7985 "count > 0 && visible",
7986 "user.name",
7987 "!user.name",
7988 "items[0].name",
7989 "user.name == 'a'",
7990 "is_empty()",
7991 ] {
7992 let keys = condition_resolver_keys(cond);
7993 let emitted = rewrite_if_expr(cond);
7994 for key in &keys {
7995 assert!(
7996 emitted.contains(&format!(r#"resolve("{}")"#, key)),
7997 "`{cond}` is rewritten into a read of {key:?}, so {key:?} must be a \
7998 registered key: {emitted}"
7999 );
8000 }
8001 let looked_up: Vec<String> = emitted
8002 .match_indices(r#"resolve(""#)
8003 .map(|(i, _)| {
8004 emitted[i + 9..]
8005 .find('"')
8006 .map(|e| emitted[i + 9..i + 9 + e].to_string())
8007 .unwrap_or_default()
8008 })
8009 .collect();
8010 assert_eq!(
8011 looked_up, keys,
8012 "`{cond}` is rewritten into exactly the reads that are registered, so \
8013 there is no way for a key to be missing"
8014 );
8015 }
8016 }
8017
8018 /// A `State` with one method of every kind the answerability rules care
8019 /// about: a renderable getter, a prefixed getter, a payload-taking method, a
8020 /// method with no return type, a `()` method, a collection, a struct-valued
8021 /// accessor and a `bool`. Two `State` fields are in scope as well, so the
8022 /// "a field is not an accessor" arm is reachable.
8023 ///
8024 /// This is the fixture the R-1e differential was measured against: every
8025 /// expectation below was read off the code as it stood BEFORE the predicate
8026 /// existed, so a table entry that drifts is a behaviour change, not a
8027 /// restatement of what the new code does.
8028 fn answerable_fixture() -> (Vec<StateMethod>, Vec<String>) {
8029 let method = |name: &str, takes_payload: bool, return_type: Option<&str>| StateMethod {
8030 name: name.to_string(),
8031 takes_payload,
8032 return_type: return_type.map(|t| t.to_string()),
8033 };
8034 let methods = vec![
8035 method("title", false, Some("String")),
8036 method("get_sub", false, Some("String")),
8037 method("on_input", true, Some("String")),
8038 method("nothing", false, None),
8039 method("unit", false, Some("()")),
8040 method("vecs", false, Some("Vec<String>")),
8041 method("user", false, Some("User")),
8042 method("flag", false, Some("bool")),
8043 ];
8044 let fields = vec!["rows".to_string(), "sub".to_string()];
8045 (methods, fields)
8046 }
8047
8048 /// The keys the differential was measured over: a member path, an index, a
8049 /// call, a compound expression, a key no `State` member can be named after,
8050 /// and the empty key.
8051 const ANSWERABLE_KEYS: [&str; 23] = [
8052 "title",
8053 "get_sub",
8054 "on_input",
8055 "nothing",
8056 "unit",
8057 "vecs",
8058 "user",
8059 "flag",
8060 "nope",
8061 "user.name",
8062 "user.missing",
8063 "vecs[0]",
8064 "items[0].name",
8065 "user.name()",
8066 "a.b[0",
8067 "1bad",
8068 "a + b",
8069 "user + other",
8070 "items[0",
8071 "a.b()",
8072 "",
8073 "user.name.deep",
8074 "sub",
8075 ];
8076
8077 /// Every shape an arm body can take is an `Ok` answer, and each one emits the
8078 /// call expression it was emitting before the predicate existed.
8079 ///
8080 /// The three variants are the point: a getter, a member chain and a
8081 /// name-only spelling are different answers to the same question, and a
8082 /// single one of them used to be inferred at the arm site from whichever
8083 /// helper happened to be tried first.
8084 #[test]
8085 fn answerable_names_each_of_the_three_arms_a_key_can_take() {
8086 let (methods, fields) = answerable_fixture();
8087
8088 // A bare name with a renderable getter, whether declared under the
8089 // template's name or under the `get_` convention.
8090 assert_eq!(
8091 answerable("title", &methods, &fields),
8092 Ok(Answer::Getter {
8093 root: "title".to_string(),
8094 name: "title".to_string(),
8095 })
8096 );
8097 assert_eq!(
8098 answerable("sub", &methods, &fields),
8099 Ok(Answer::Getter {
8100 root: "sub".to_string(),
8101 name: "get_sub".to_string(),
8102 })
8103 );
8104
8105 // A member path is answered by a chain on the root accessor, and every
8106 // later segment is a call — including the ones no `State` member names,
8107 // because a chain's legality is the type's business, not codegen's.
8108 assert_eq!(
8109 answerable("user.name", &methods, &fields),
8110 Ok(Answer::MemberChain {
8111 root: "user".to_string(),
8112 chain: "user().name()".to_string(),
8113 })
8114 );
8115 assert_eq!(
8116 answerable("vecs[0]", &methods, &fields),
8117 Ok(Answer::MemberChain {
8118 root: "vecs".to_string(),
8119 chain: "vecs()[0]".to_string(),
8120 })
8121 );
8122 assert_eq!(
8123 answerable("user.name.deep", &methods, &fields),
8124 Ok(Answer::MemberChain {
8125 root: "user".to_string(),
8126 chain: "user().name().deep()".to_string(),
8127 })
8128 );
8129
8130 // A key that is neither a bare name nor a member path keeps the
8131 // name-only spelling it has always been answered with.
8132 for key in ["user.name()", "a.b[0", "1bad", "a + b", "items[0", ""] {
8133 assert_eq!(
8134 answerable(key, &methods, &fields),
8135 Ok(Answer::Conventional {
8136 name: key.to_string(),
8137 }),
8138 "`{key}` is not a shape the arm emitter can spell, so it keeps the \\
8139 name-only answer it has always had"
8140 );
8141 }
8142 }
8143
8144 /// The call expression each answer emits is byte-identical to the one the
8145 /// arm emitter produced before the predicate existed, for every key the
8146 /// differential covered — including the keys nothing answers, whose arm text
8147 /// the three old fallbacks all agreed on by accident.
8148 #[test]
8149 fn the_arm_text_of_every_key_is_unchanged() {
8150 let (methods, fields) = answerable_fixture();
8151 // (key, the `state.…` expression the arm body used to make)
8152 let expected = [
8153 ("title", "title()"),
8154 ("get_sub", "get_sub()"),
8155 ("on_input", "on_input()"),
8156 ("nothing", "nothing()"),
8157 ("unit", "unit()"),
8158 ("vecs", "vecs()"),
8159 ("user", "user()"),
8160 ("flag", "flag()"),
8161 ("nope", "nope()"),
8162 ("user.name", "user().name()"),
8163 ("user.missing", "user().missing()"),
8164 ("vecs[0]", "vecs()[0]"),
8165 ("items[0].name", "items[0].name()"),
8166 ("user.name()", "user.name()()"),
8167 ("a.b[0", "a.b[0()"),
8168 ("1bad", "1bad()"),
8169 ("a + b", "a + b()"),
8170 ("user + other", "user + other()"),
8171 ("items[0", "items[0()"),
8172 ("a.b()", "a.b()()"),
8173 ("", "()"),
8174 ("user.name.deep", "user().name().deep()"),
8175 ("sub", "get_sub()"),
8176 ];
8177 for (key, call) in expected {
8178 assert_eq!(
8179 resolve_getter_call(&methods, &fields, key),
8180 call,
8181 "the arm text for `{key}` moved"
8182 );
8183 }
8184 }
8185
8186 /// Every sentence a refusal is reported in, byte for byte, in the register
8187 /// the caller quotes it in.
8188 ///
8189 /// These are user-facing strings, they were frozen by the reviews of R-1,
8190 /// R-1c, R-1d and R-3, and they were read off that code rather than
8191 /// re-derived from it. Two registers exist and both are pinned: the
8192 /// key-as-authored one the bound-attribute, `v-model` and condition paths
8193 /// print, and the root one the interpolation gate prints. They disagree for
8194 /// a bare key — "not a zero-argument `State` getter" against "not a
8195 /// zero-argument `State` method" — and that disagreement is pre-existing and
8196 /// frozen, so it is pinned rather than tidied.
8197 #[test]
8198 fn every_refusal_sentence_is_unchanged() {
8199 let (methods, fields) = answerable_fixture();
8200 let expected = [
8201 (
8202 "title",
8203 "`title` returns `String`, which cannot be rendered as text",
8204 ),
8205 (
8206 "get_sub",
8207 "`get_sub` returns `String`, which cannot be rendered as text",
8208 ),
8209 (
8210 "on_input",
8211 "`on_input` is a payload-taking State method, not a getter",
8212 ),
8213 (
8214 "nothing",
8215 "`nothing` declares no return type, so there is no text to render",
8216 ),
8217 (
8218 "unit",
8219 "`unit` returns `()`, which cannot be rendered as text",
8220 ),
8221 (
8222 "vecs",
8223 "`vecs` returns `Vec<String>`, which cannot be rendered as text",
8224 ),
8225 (
8226 "user",
8227 "`user` returns `User`, which cannot be rendered as text",
8228 ),
8229 (
8230 "flag",
8231 "`flag` returns `bool`, which cannot be rendered as text",
8232 ),
8233 ("nope", "`nope` is not a zero-argument `State` getter"),
8234 (
8235 "user.name",
8236 "`user` is a State method, but a member path needs an accessor",
8237 ),
8238 (
8239 "user.missing",
8240 "`user` is a State method, but a member path needs an accessor",
8241 ),
8242 (
8243 "vecs[0]",
8244 "`vecs` is a State method, but a member path needs an accessor",
8245 ),
8246 (
8247 "items[0].name",
8248 "`items` is not a zero-argument `State` method",
8249 ),
8250 (
8251 "user.name()",
8252 "`user.name()` is an expression, not a `State` getter name",
8253 ),
8254 (
8255 "a.b[0",
8256 "`a.b[0` is an expression, not a `State` getter name",
8257 ),
8258 ("1bad", "`1bad` is an expression, not a `State` getter name"),
8259 (
8260 "a + b",
8261 "`a + b` is an expression, not a `State` getter name",
8262 ),
8263 (
8264 "user + other",
8265 "`user + other` is an expression, not a `State` getter name",
8266 ),
8267 (
8268 "items[0",
8269 "`items[0` is an expression, not a `State` getter name",
8270 ),
8271 (
8272 "a.b()",
8273 "`a.b()` is an expression, not a `State` getter name",
8274 ),
8275 ("", "`` is an expression, not a `State` getter name"),
8276 (
8277 "user.name.deep",
8278 "`user` is a State method, but a member path needs an accessor",
8279 ),
8280 (
8281 "sub",
8282 "`sub` is a `State` field, not a getter — reading a field in a template needs a typed field resolver, which is not implemented yet",
8283 ),
8284 ];
8285 for (key, reason) in expected {
8286 assert_eq!(
8287 unresolvable_key_reason(key, &methods, &fields),
8288 reason,
8289 "the key register's sentence for `{key}` moved"
8290 );
8291 }
8292 }
8293
8294 /// The interpolation gate registers exactly the keys it used to, and reports
8295 /// exactly the sentences it used to — one whole decision with one whole set
8296 /// of words, so the two cannot be right about the key and wrong about the
8297 /// sentence.
8298 #[test]
8299 fn the_interpolation_gate_registers_and_reports_exactly_what_it_did() {
8300 let (methods, fields) = answerable_fixture();
8301 let mut warnings = Vec::new();
8302 let registered = keep_answerable_interpolation_keys(
8303 &methods,
8304 &fields,
8305 ANSWERABLE_KEYS.iter().map(|k| k.to_string()).collect(),
8306 &mut warnings,
8307 );
8308 // `sub` stays registered: `get_sub` answers it, and a key that is also a
8309 // `State` field is answered by the getter that exists.
8310 assert_eq!(
8311 registered,
8312 [
8313 "title",
8314 "get_sub",
8315 "flag",
8316 "user.name",
8317 "user.missing",
8318 "vecs[0]",
8319 "user.name()",
8320 "a.b[0",
8321 "1bad",
8322 "a + b",
8323 "user + other",
8324 "items[0",
8325 "a.b()",
8326 "",
8327 "user.name.deep",
8328 "sub",
8329 ]
8330 .map(String::from)
8331 );
8332 let expected = [
8333 "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`.",
8334 "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`.",
8335 "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`.",
8336 "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`.",
8337 "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`.",
8338 "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`.",
8339 "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.",
8340 ];
8341 assert_eq!(warnings, expected);
8342 }
8343
8344 /// A refusal carries the sentence the interpolation gate prints AND the one
8345 /// the bound-attribute path prints, because the gate and the diagnostic
8346 /// disagree about a bare key's wording and both are frozen. The two are
8347 /// produced from the same answer, so neither can be right about the key
8348 /// while the other is wrong about it.
8349 #[test]
8350 fn a_refusal_carries_both_frozen_registers() {
8351 let (methods, fields) = answerable_fixture();
8352 let unrenderable = answerable("on_input", &methods, &fields)
8353 .expect_err("a payload-taking method cannot be read as a value");
8354 assert_eq!(
8355 unrenderable.root_reason,
8356 "`on_input` is a payload-taking State method, not an accessor"
8357 );
8358 assert_eq!(
8359 unrenderable.key_reason,
8360 "`on_input` is a payload-taking State method, not a getter"
8361 );
8362 assert_eq!(
8363 unrenderable.remedy,
8364 "Declare `pub fn on_input(&self) -> impl std::fmt::Display` on `State`."
8365 );
8366 }
8367
8368 /// The invariant [`resolve_getter_call`] relies on: a key only ever reaches
8369 /// an arm emitter because a registration path asked [`answerable`] and took
8370 /// its `Ok` branch, so `Err` there would mean a registration path stopped
8371 /// consulting the predicate. [`resolve_getter_call`] keeps answering `Err`
8372 /// with the guess it always produced rather than panicking, which means this
8373 /// test — not an assertion inside the codegen pass — is what makes the dead
8374 /// arm dead, and it fails the moment a gate grows a second notion.
8375 ///
8376 /// The template names all four registration paths, and the expected set is
8377 /// read off it: `flag` from the `:class` object condition, from `v-if` and
8378 /// from `:data-name`; `sub` from `:title`, from the interpolation and from
8379 /// `v-show`; `title` from the interpolation and from `v-model`; and
8380 /// `user.name()` from the ungated call shape. `{{ rows }}` is the one
8381 /// refusal: `rows` is a `State` field, and a field is not a getter. `user`
8382 /// is not registered even though it is a getter, because `User` is not a
8383 /// renderable return type — which is why the bound attributes bind `sub`
8384 /// and `flag` and not `user`.
8385 ///
8386 /// The three refusals are one per gate, each pinned to the sentence that
8387 /// gate assembles: `{{ rows }}` is the interpolation register, and `rows`
8388 /// is a `State` field, which is not a getter; `v-if="nope"` and
8389 /// `v-model="nope"` are the condition and `v-model` gates refusing a name
8390 /// `State` does not declare, through the reason register a bare key is
8391 /// answered with. Without those two this invariant would be measured over
8392 /// the interpolation gate alone, and a condition or `v-model` gate that
8393 /// stopped asking the predicate would not show up.
8394 ///
8395 /// The last `v-if="user.name"` is there for the other half: a member path
8396 /// whose root IS a real accessor is answerable even though that accessor
8397 /// returns something unrenderable, so it registers — and `User` not being
8398 /// renderable is not what lets it.
8399 #[test]
8400 fn every_registered_key_is_answerable() {
8401 let template = r#"
8402<p :class="{ on: flag }" :title="sub">{{ title }} {{ user.name() }} {{ sub }}</p>
8403<input v-model="title" />
8404<p v-if="flag">{{ rows }}</p>
8405<span v-show="sub" :data-name="flag">x</span>
8406<p v-if="nope">CH</p>
8407<input v-model="nope" />
8408<p v-if="user.name">CH</p>
8409"#;
8410 let script = r#"
8411pub struct User { pub name: String }
8412impl State {
8413 pub fn title(&self) -> String { String::new() }
8414 pub fn flag(&self) -> bool { true }
8415 pub fn user(&self) -> User { User { name: String::new() } }
8416 pub fn sub(&self) -> String { String::new() }
8417}
8418"#;
8419 let nodes = crate::template_parse::parse_template_to_ast(template).unwrap();
8420 let fields = vec!["rows".to_string()];
8421 let collected = collect_resolver_keys(
8422 &nodes,
8423 &methods(script),
8424 &fields,
8425 RenderMode::State,
8426 &props_of(&fields),
8427 );
8428
8429 let registered: std::collections::BTreeSet<&str> =
8430 collected.keys.iter().map(String::as_str).collect();
8431 assert_eq!(
8432 registered,
8433 ["flag", "sub", "title", "user.name", "user.name()"]
8434 .into_iter()
8435 .collect::<std::collections::BTreeSet<&str>>(),
8436 "the set of keys a template registers is itself a decision about \
8437 answerability, so it is pinned: a fourth key here means a gate \
8438 stopped asking the one predicate, and a missing key means one \
8439 started refusing what it used to register"
8440 );
8441
8442 assert_eq!(
8443 collected.warnings,
8444 vec![
8445 "interpolation {{ rows }} cannot be resolved — `rows` is a \
8446 `State` field, not an accessor — reading a field in a \
8447 template needs a typed field resolver, which is not \
8448 implemented yet; the interpolation renders empty. Declare \
8449 `pub fn rows(&self) -> impl std::fmt::Display` on `State`."
8450 .to_string(),
8451 "if=\"nope\" cannot be resolved — `nope` is not a \
8452 zero-argument `State` getter; the condition reads an empty \
8453 value, so the element and everything inside it are not \
8454 rendered. Bind a zero-argument State getter for `nope` \
8455 instead."
8456 .to_string(),
8457 "v-model=\"nope\" cannot render its value — `nope` is not a \
8458 zero-argument `State` getter; the input renders empty. The \
8459 write needs every segment of `nope` to be a `State` field and \
8460 the last one to be a type `VModel` is implemented for \
8461 (`Signal<T>`, `RefCell<String>` or `Cell<T>`); otherwise the \
8462 generated setter does not compile. Bind a zero-argument \
8463 `State` getter, or a method for each segment of a dotted \
8464 expression."
8465 .to_string()
8466 ],
8467 "one of the names in the template is refused by each of the three \
8468 gates, and each sentence is the register that gate assembles"
8469 );
8470
8471 for key in &collected.keys {
8472 let answered = answerable(key, &methods(script), &fields);
8473 assert!(
8474 answered.is_ok(),
8475 "`{key}` was registered by a path that has not asked the \
8476 predicate, so its arm is the guess and not an answer: {:?}",
8477 answered.err()
8478 );
8479 }
8480 }
8481}