Skip to main content

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}