Skip to main content

logicaffeine_language/lgx/
deps.rs

1//! The static dependency graph a fine-grained DOM update is compiled from.
2//!
3//! Stage A repaints a component by clearing its mount and rebuilding the subtree — correct, and
4//! O(the whole view) for a change to one field. Stage B emits a PATCHER instead, and a patcher
5//! needs three facts known before the program runs:
6//!
7//!   * every SITE in the view that can change, and which state fields it reads;
8//!   * every ACTION, and which state fields it writes;
9//!   * every DERIVED field, and what it is derived from.
10//!
11//! Then a turn that writes `draft` updates exactly the sites that read `draft`.
12//!
13//! ★ THE READ SET IS CLOSED, which is the whole difficulty and the whole value. The names a site
14//! LITERALLY MENTIONS are usually not state:
15//!
16//!   * `{remaining}` where `remaining` is derived reads whatever the derivation reads — nothing
17//!     ever writes `remaining` itself, so a literal read set would never update it;
18//!   * `{t}` inside `{for each t in todos:}` reads `todos` — `t` is a window onto state, not
19//!     state, and no action writes it;
20//!   * `{doubled}` after `{let doubled be n * 2}` reads `n`.
21//!
22//! Every one of those failures is SILENT: the page simply stops updating, on exactly the
23//! constructs real pages are built from. So resolution happens here, once, and the answer a site
24//! carries is already in terms of the fields an action can write.
25//!
26//! ★ The write set is a static OVER-approximation on purpose. A write inside an `If` counts even
27//! when the branch does not run: repainting a site that did not change is a wasted patch, while
28//! missing one leaves a visitor looking at a stale page. The two errors are not comparable.
29
30use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
31
32use logicaffeine_base::{Interner, Symbol};
33
34use crate::ast::component::{ComponentDef, Trigger};
35use crate::ast::stmt::{Block, ClosureBody, Expr, Pattern, Stmt, StringPart};
36use crate::ast::view::{Attr, ViewNode};
37
38/// Why a component has no dependency graph.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum DepError {
41    /// Derived fields that depend on each other, in the order the walk met them.
42    ///
43    /// `a always be b + 1` beside `b always be a + 1` has no fixed point: recomputing them gives
44    /// an answer that depends on the order, and the turn semantics forbid a second round. Better
45    /// refused where it is written than settled by whichever value happened to land last.
46    DerivedCycle(Vec<String>),
47}
48
49/// What kind of thing a site is — which decides what the patcher emits for it.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum SiteKind {
52    /// A text child: `{draft}`.
53    Text,
54    /// An attribute value: `title={name}`.
55    Attr(Symbol),
56    /// A conditional class: `class:done={done}`.
57    Class(Symbol),
58    /// The condition set of an `{if}` — any of them changing may switch which arm renders.
59    If,
60    /// A loop's collection.
61    Each,
62    /// A two-way binding, which SHOWS its field as well as writing it.
63    Bind(Symbol),
64    /// An `{inspect}` scrutinee.
65    Inspect,
66}
67
68/// One addressable place in a view that can change, and what it depends on.
69#[derive(Debug, Clone)]
70pub struct SiteDeps {
71    /// Dotted child indices from the view root — the patcher's address for the node.
72    pub path: String,
73    pub kind: SiteKind,
74    /// Does this site live under an `{if}`, a `{for each}` or an `{inspect}`?
75    ///
76    /// A site under control flow has no single node: a loop body renders once PER ITEM, and an
77    /// arm renders only while its condition holds. Its static path therefore names a shape rather
78    /// than a node, so an in-place patch cannot address it and the update falls back to
79    /// rebuilding. Marked here, where the walk already knows, rather than re-derived from the
80    /// path string — a path is not a reliable record of how it was produced.
81    pub dynamic: bool,
82    /// State fields, already closed over derived fields and template bindings.
83    pub reads: BTreeSet<Symbol>,
84    /// Props read here. Separate because a prop cannot change without the PARENT re-rendering
85    /// this component, so it is not something this instance's own turns can invalidate.
86    pub prop_reads: BTreeSet<Symbol>,
87}
88
89/// The whole graph for one component.
90#[derive(Debug, Clone)]
91pub struct ComponentDeps {
92    /// Sites in document order.
93    pub sites: Vec<SiteDeps>,
94    /// Each derived field and the base state fields it is computed from.
95    pub derived: BTreeMap<Symbol, BTreeSet<Symbol>>,
96    /// Each turn (an action by name, an event handler by its trigger) and what it writes.
97    pub writes: BTreeMap<String, BTreeSet<Symbol>>,
98}
99
100impl SiteDeps {
101    /// `kind@path` — the site's identity, and the KEY a renderer records its node under.
102    ///
103    /// The dependency walk and the render walk are two walks over one tree, and two walks drift.
104    /// Making them agree on a STRING means the agreement can be asserted directly rather than
105    /// hoped for: a renderer that numbered a child differently records a key the graph never
106    /// names, and the lock says so.
107    pub fn key(&self, interner: &Interner) -> String {
108        let name = |s: &Symbol| interner.resolve(*s).to_string();
109        let kind = match &self.kind {
110            SiteKind::Text => "text".to_string(),
111            SiteKind::Attr(a) => format!("attr:{}", name(a)),
112            SiteKind::Class(c) => format!("class:{}", name(c)),
113            SiteKind::If => "if".to_string(),
114            SiteKind::Each => "each".to_string(),
115            SiteKind::Bind(f) => format!("bind:{}", name(f)),
116            SiteKind::Inspect => "inspect".to_string(),
117        };
118        format!("{kind}@{}", self.path)
119    }
120
121    /// Can a turn update this site IN PLACE, without rebuilding anything?
122    ///
123    /// Only a value-shaped site under no control flow: it names exactly one node that exists for
124    /// as long as the component is mounted. An `{if}` or a `{for each}` MOUNTS and UNMOUNTS
125    /// subtrees, which is a different operation from writing a value into a node that is already
126    /// there.
127    pub fn patchable(&self) -> bool {
128        // ★ ONE PREDICATE. Every walk must agree about which turns patch and which repaint, or
129        // their op-logs diverge on the same page — and the op-log is the contract, not the
130        // rendered tree. So the set is defined here once rather than decided twice.
131        //
132        // Text and a binding are values written into one node. An ATTRIBUTE and a CLASS TOGGLE
133        // are not here yet: a class toggle recomposes the element's whole class list (`class=`
134        // and every `class:x` compose into one attribute, so writing one would clobber its
135        // neighbours), which the bytecode tier builds inline during the paint. Both tiers repaint
136        // for those, which is correct and merely slower; widening this set widens both together.
137        !self.dynamic && matches!(self.kind, SiteKind::Text | SiteKind::Bind(_))
138    }
139}
140
141impl ComponentDeps {
142    /// The sites a write to `field` invalidates.
143    pub fn sites_reading(&self, field: Symbol) -> impl Iterator<Item = &SiteDeps> {
144        self.sites.iter().filter(move |s| s.reads.contains(&field))
145    }
146
147    /// A deterministic line per fact: sites in document order, then the derived graph, then the
148    /// write sets. Emitting from the maps' iteration order would make every downstream lock a
149    /// coin flip and every generated patcher differ between builds of one source.
150    pub fn describe(&self, interner: &Interner) -> Vec<String> {
151        let name = |s: &Symbol| interner.resolve(*s).to_string();
152        let list = |set: &BTreeSet<Symbol>| {
153            let mut v: Vec<String> = set.iter().map(name).collect();
154            v.sort();
155            v
156        };
157        let mut out = Vec::new();
158        for site in &self.sites {
159            let key = site.key(interner);
160            let mut parts: Vec<String> = Vec::new();
161            if !site.reads.is_empty() {
162                parts.push(list(&site.reads).join(", "));
163            }
164            if !site.prop_reads.is_empty() {
165                parts.push(format!("props {}", list(&site.prop_reads).join(", ")));
166            }
167            let reads = if parts.is_empty() {
168                "—".to_string()
169            } else {
170                parts.join(" ")
171            };
172            out.push(format!("{key} reads {reads}"));
173        }
174        for (field, from) in &self.derived {
175            let from = if from.is_empty() {
176                "—".to_string()
177            } else {
178                list(from).join(", ")
179            };
180            out.push(format!("derived {} <- {from}", name(field)));
181        }
182        for (turn, fields) in &self.writes {
183            let f = if fields.is_empty() {
184                "—".to_string()
185            } else {
186                list(fields).join(", ")
187            };
188            out.push(format!("writes {turn} -> {f}"));
189        }
190        out
191    }
192}
193
194/// What a name resolves to once template bindings and derivations are followed through.
195#[derive(Debug, Clone, Default)]
196struct Resolved {
197    state: BTreeSet<Symbol>,
198    props: BTreeSet<Symbol>,
199}
200
201impl Resolved {
202    fn merge(&mut self, other: &Resolved) {
203        self.state.extend(other.state.iter().copied());
204        self.props.extend(other.props.iter().copied());
205    }
206}
207
208/// Build the dependency graph for one component.
209pub fn component_deps(
210    def: &ComponentDef<'_>,
211    interner: &Interner,
212) -> Result<ComponentDeps, DepError> {
213    let plain: HashSet<Symbol> = def
214        .state
215        .iter()
216        .filter(|f| !f.derived)
217        .map(|f| f.name)
218        .collect();
219    let derived_fields: Vec<Symbol> = def
220        .state
221        .iter()
222        .filter(|f| f.derived)
223        .map(|f| f.name)
224        .collect();
225    let derived_set: HashSet<Symbol> = derived_fields.iter().copied().collect();
226    let props: HashSet<Symbol> = def.props.iter().map(|(n, _)| *n).collect();
227
228    // The DIRECT reads of each derivation: the state and derived names its initializer mentions.
229    let mut direct: HashMap<Symbol, Vec<Symbol>> = HashMap::new();
230    for field in def.state.iter().filter(|f| f.derived) {
231        let mut names = Vec::new();
232        collect_names(field.init, &mut names);
233        let deps: Vec<Symbol> = names
234            .into_iter()
235            .filter(|n| plain.contains(n) || derived_set.contains(n))
236            .collect();
237        direct.insert(field.name, deps);
238    }
239
240    // A fixpoint over the derivations, with a cycle refused rather than settled.
241    let mut base: HashMap<Symbol, BTreeSet<Symbol>> = HashMap::new();
242    for field in &derived_fields {
243        let mut stack = Vec::new();
244        let mut visiting = HashSet::new();
245        close_derived(
246            *field,
247            &direct,
248            &plain,
249            &mut base,
250            &mut visiting,
251            &mut stack,
252            interner,
253        )?;
254    }
255
256    let ctx = Ctx {
257        plain,
258        derived: derived_set,
259        props,
260        base,
261    };
262
263    // The view's sites.
264    let mut sites = Vec::new();
265    if let Some(view) = &def.view {
266        let mut env: HashMap<Symbol, Resolved> = HashMap::new();
267        walk_nodes(&view.nodes, "", &ctx, &mut env, &mut sites, false);
268    }
269
270    // The turns.
271    let mut writes: BTreeMap<String, BTreeSet<Symbol>> = BTreeMap::new();
272    for action in &def.actions {
273        let mut shadowed: HashSet<Symbol> = action.params.iter().map(|(n, _)| *n).collect();
274        let mut set = BTreeSet::new();
275        writes_of_block(&action.body, &ctx, &mut shadowed, &mut set);
276        set.retain(|n| ctx.plain.contains(n));
277        writes.insert(interner.resolve(action.name).to_string(), set);
278    }
279    for handler in &def.events {
280        let mut shadowed = HashSet::new();
281        let mut set = BTreeSet::new();
282        writes_of_block(&handler.body, &ctx, &mut shadowed, &mut set);
283        set.retain(|n| ctx.plain.contains(n));
284        writes.insert(trigger_label(&handler.trigger, interner), set);
285    }
286
287    let derived_out: BTreeMap<Symbol, BTreeSet<Symbol>> = derived_fields
288        .iter()
289        .map(|f| (*f, ctx.base.get(f).cloned().unwrap_or_default()))
290        .collect();
291
292    Ok(ComponentDeps {
293        sites,
294        derived: derived_out,
295        writes,
296    })
297}
298
299/// The state fields one block of statements writes — the same walk `component_deps` uses for an
300/// action, exposed so a lowering can ask about a body it is about to emit.
301///
302/// Shared rather than re-derived: a second copy of "what counts as a write" drifts the first time
303/// a verb is added, and the two answers disagreeing means one tier patches while the other
304/// repaints — a divergence on the same page.
305pub fn block_writes(def: &ComponentDef<'_>, block: Block<'_>, _interner: &Interner) -> Vec<Symbol> {
306    let plain: HashSet<Symbol> = def
307        .state
308        .iter()
309        .filter(|f| !f.derived)
310        .map(|f| f.name)
311        .collect();
312    let ctx = Ctx {
313        plain,
314        derived: def
315            .state
316            .iter()
317            .filter(|f| f.derived)
318            .map(|f| f.name)
319            .collect(),
320        props: def.props.iter().map(|(n, _)| *n).collect(),
321        base: HashMap::new(),
322    };
323    let mut shadowed = HashSet::new();
324    let mut out = BTreeSet::new();
325    writes_of_block(block, &ctx, &mut shadowed, &mut out);
326    out.into_iter().filter(|n| ctx.plain.contains(n)).collect()
327}
328
329/// The PROPS a block writes — every one a mistake, since a prop is the parent's value. `bound`
330/// are the names the block's own header binds (an action's parameters, a trigger's values), which
331/// shadow a prop of the same name. The same walk as [`block_writes`], so a verb that writes state
332/// is a verb that writes a prop.
333pub fn block_prop_writes(
334    def: &ComponentDef<'_>,
335    block: Block<'_>,
336    bound: &[Symbol],
337) -> Vec<Symbol> {
338    let ctx = Ctx {
339        plain: def
340            .state
341            .iter()
342            .filter(|f| !f.derived)
343            .map(|f| f.name)
344            .collect(),
345        derived: def
346            .state
347            .iter()
348            .filter(|f| f.derived)
349            .map(|f| f.name)
350            .collect(),
351        props: def.props.iter().map(|(n, _)| *n).collect(),
352        base: HashMap::new(),
353    };
354    let mut shadowed: HashSet<Symbol> = bound.iter().copied().collect();
355    let mut out = BTreeSet::new();
356    writes_of_block(block, &ctx, &mut shadowed, &mut out);
357    out.into_iter().filter(|n| ctx.props.contains(n)).collect()
358}
359
360/// Everything the walks need to decide what a name means.
361struct Ctx {
362    plain: HashSet<Symbol>,
363    derived: HashSet<Symbol>,
364    props: HashSet<Symbol>,
365    base: HashMap<Symbol, BTreeSet<Symbol>>,
366}
367
368impl Ctx {
369    /// A single NAME, resolved to base state fields and props.
370    fn resolve_name(&self, name: Symbol, env: &HashMap<Symbol, Resolved>) -> Resolved {
371        // A template binding shadows everything: `{let n be …}` beside a field `n` means the
372        // binding, exactly as an action's own `Let` shadows the field for a write.
373        if let Some(found) = env.get(&name) {
374            return found.clone();
375        }
376        if self.derived.contains(&name) {
377            let state = self.base.get(&name).cloned().unwrap_or_default();
378            return Resolved {
379                state,
380                props: BTreeSet::new(),
381            };
382        }
383        if self.plain.contains(&name) {
384            return Resolved {
385                state: [name].into_iter().collect(),
386                props: BTreeSet::new(),
387            };
388        }
389        if self.props.contains(&name) {
390            return Resolved {
391                state: BTreeSet::new(),
392                props: [name].into_iter().collect(),
393            };
394        }
395        Resolved::default()
396    }
397
398    /// A whole expression.
399    fn resolve_expr(&self, expr: &Expr<'_>, env: &HashMap<Symbol, Resolved>) -> Resolved {
400        let mut names = Vec::new();
401        collect_names(expr, &mut names);
402        let mut out = Resolved::default();
403        for n in names {
404            let r = self.resolve_name(n, env);
405            out.merge(&r);
406        }
407        out
408    }
409}
410
411/// Close one derived field over the derivations it reads, refusing a cycle by name.
412fn close_derived(
413    field: Symbol,
414    direct: &HashMap<Symbol, Vec<Symbol>>,
415    plain: &HashSet<Symbol>,
416    base: &mut HashMap<Symbol, BTreeSet<Symbol>>,
417    visiting: &mut HashSet<Symbol>,
418    stack: &mut Vec<Symbol>,
419    interner: &Interner,
420) -> Result<BTreeSet<Symbol>, DepError> {
421    if let Some(done) = base.get(&field) {
422        return Ok(done.clone());
423    }
424    if !visiting.insert(field) {
425        // The cycle names every field on it, in the order the walk met them — a message naming
426        // one field would leave the reader looking for the other half.
427        let mut names: Vec<String> = stack
428            .iter()
429            .map(|s| interner.resolve(*s).to_string())
430            .collect();
431        names.push(interner.resolve(field).to_string());
432        return Err(DepError::DerivedCycle(names));
433    }
434    stack.push(field);
435    let mut out = BTreeSet::new();
436    for dep in direct.get(&field).map(|v| v.as_slice()).unwrap_or(&[]) {
437        if plain.contains(dep) {
438            out.insert(*dep);
439        } else {
440            let inner = close_derived(*dep, direct, plain, base, visiting, stack, interner)?;
441            out.extend(inner);
442        }
443    }
444    stack.pop();
445    visiting.remove(&field);
446    base.insert(field, out.clone());
447    Ok(out)
448}
449
450/// Every identifier an expression mentions, including through interpolation and closures.
451///
452/// Deliberately EXHAUSTIVE over `Expr`: a new variant must be considered here, because a variant
453/// the walk silently ignores is a read the patcher never learns about, and the symptom is a page
454/// that stops updating rather than anything that looks like a compiler bug.
455fn collect_names(expr: &Expr<'_>, out: &mut Vec<Symbol>) {
456    match expr {
457        Expr::Identifier(s) => out.push(*s),
458        Expr::Literal(_) | Expr::OptionNone | Expr::Escape { .. } => {}
459        Expr::BinaryOp { left, right, .. }
460        | Expr::Contains {
461            collection: left,
462            value: right,
463        }
464        | Expr::Union { left, right }
465        | Expr::Intersection { left, right }
466        | Expr::Range {
467            start: left,
468            end: right,
469        }
470        | Expr::Index {
471            collection: left,
472            index: right,
473        }
474        | Expr::WithCapacity {
475            value: left,
476            capacity: right,
477        }
478        | Expr::ChunkAt {
479            index: left,
480            zone: right,
481        } => {
482            collect_names(left, out);
483            collect_names(right, out);
484        }
485        Expr::Not { operand }
486        | Expr::Copy { expr: operand }
487        | Expr::Give { value: operand }
488        | Expr::Length {
489            collection: operand,
490        }
491        | Expr::ManifestOf { zone: operand }
492        | Expr::OptionSome { value: operand } => collect_names(operand, out),
493        Expr::Slice {
494            collection,
495            start,
496            end,
497        } => {
498            collect_names(collection, out);
499            collect_names(start, out);
500            collect_names(end, out);
501        }
502        // A function NAME is not a read of state — only its arguments are.
503        Expr::Call { args, .. } => args.iter().for_each(|a| collect_names(a, out)),
504        Expr::CallExpr { callee, args } => {
505            collect_names(callee, out);
506            args.iter().for_each(|a| collect_names(a, out));
507        }
508        Expr::List(items) | Expr::Tuple(items) => items.iter().for_each(|i| collect_names(i, out)),
509        // `t's text` depends on whatever `t` is.
510        Expr::FieldAccess { object, .. } => collect_names(object, out),
511        Expr::New { init_fields, .. } => {
512            init_fields.iter().for_each(|(_, e)| collect_names(e, out))
513        }
514        Expr::NewVariant { fields, .. } => fields.iter().for_each(|(_, e)| collect_names(e, out)),
515        Expr::Closure { body, .. } => match body {
516            ClosureBody::Expression(e) => collect_names(e, out),
517            ClosureBody::Block(b) => b.iter().for_each(|s| collect_names_stmt(s, out)),
518        },
519        Expr::InterpolatedString(parts) => {
520            for part in parts {
521                if let StringPart::Expr { value, .. } = part {
522                    collect_names(value, out);
523                }
524            }
525        }
526        // A nested view's own dependencies belong to that view, not to the expression holding it.
527        Expr::View(_) => {}
528        // A page question reads the DOCUMENT, not this component's state; its selector may still
529        // be computed from state.
530        Expr::Dom(q) => {
531            if let Some(sel) = q.selector() {
532                collect_names(sel, out);
533            }
534        }
535    }
536}
537
538/// The identifiers a statement mentions — used for closure bodies inside expressions.
539fn collect_names_stmt(stmt: &Stmt<'_>, out: &mut Vec<Symbol>) {
540    let mut sink = |e: &Expr<'_>| collect_names(e, out);
541    match stmt {
542        Stmt::Let { value, .. } => sink(value),
543        Stmt::Show { object, recipient } => {
544            sink(object);
545            sink(recipient);
546        }
547        Stmt::Set { target, value } => {
548            out.push(*target);
549            collect_names(value, out);
550        }
551        Stmt::Return { value } => {
552            if let Some(v) = value {
553                collect_names(v, out);
554            }
555        }
556        Stmt::If {
557            cond,
558            then_block,
559            else_block,
560        } => {
561            collect_names(cond, out);
562            then_block.iter().for_each(|s| collect_names_stmt(s, out));
563            if let Some(b) = else_block {
564                b.iter().for_each(|s| collect_names_stmt(s, out));
565            }
566        }
567        Stmt::While { cond, body, .. } => {
568            collect_names(cond, out);
569            body.iter().for_each(|s| collect_names_stmt(s, out));
570        }
571        Stmt::Repeat { iterable, body, .. } => {
572            collect_names(iterable, out);
573            body.iter().for_each(|s| collect_names_stmt(s, out));
574        }
575        _ => {}
576    }
577}
578
579/// The state fields a block writes, honouring shadowing by the block's own `Let`s.
580fn writes_of_block(
581    block: Block<'_>,
582    ctx: &Ctx,
583    shadowed: &mut HashSet<Symbol>,
584    out: &mut BTreeSet<Symbol>,
585) {
586    for stmt in block {
587        writes_of_stmt(stmt, ctx, shadowed, out);
588    }
589}
590
591/// One statement's writes: every name it writes that the block has not bound itself. Callers
592/// keep the kind of name they ask about (state fields, props).
593///
594/// Shadowing decides what a write means: after an action's own `Let mutable n be 5.`, `Set n` is
595/// a write to the LOCAL, not to a field that happens to share the name.
596fn writes_of_stmt(
597    stmt: &Stmt<'_>,
598    ctx: &Ctx,
599    shadowed: &mut HashSet<Symbol>,
600    out: &mut BTreeSet<Symbol>,
601) {
602    let note = |name: Symbol, shadowed: &HashSet<Symbol>, out: &mut BTreeSet<Symbol>| {
603        if !shadowed.contains(&name) {
604            out.insert(name);
605        }
606    };
607    match stmt {
608        Stmt::Let { var, .. } => {
609            shadowed.insert(*var);
610        }
611        Stmt::Set { target, .. } => note(*target, shadowed, out),
612        Stmt::Swap { a, b } => {
613            note(*a, shadowed, out);
614            note(*b, shadowed, out);
615        }
616        // A collection verb writes whatever identifier the collection expression bottoms out in:
617        // `Push v to todos` and `Push v to it's todos` both change `todos`.
618        Stmt::Push { collection, .. }
619        | Stmt::Add { collection, .. }
620        | Stmt::Remove { collection, .. }
621        | Stmt::SetIndex { collection, .. } => {
622            for n in base_identifiers(collection) {
623                note(n, shadowed, out);
624            }
625        }
626        Stmt::Pop { collection, into } => {
627            for n in base_identifiers(collection) {
628                note(n, shadowed, out);
629            }
630            if let Some(v) = into {
631                note(*v, shadowed, out);
632            }
633        }
634        Stmt::SetField { object, .. }
635        | Stmt::IncreaseCrdt { object, .. }
636        | Stmt::DecreaseCrdt { object, .. } => {
637            for n in base_identifiers(object) {
638                note(n, shadowed, out);
639            }
640        }
641        // ★ A UI VERB CAN WRITE STATE TOO — `Toggle done.` moves a Bool field and `Load "k" into
642        // x.` fills one. Both were invisible here until a test asked, which is the exact failure
643        // this analysis exists to prevent: a write the compiler cannot see is a site that never
644        // updates, and the page looks broken while the state is perfectly correct.
645        //
646        // Read through `UiStmt::writes()` rather than re-matched here, so the ONE place the
647        // language decides what a UI verb writes stays the one place — a second copy would drift
648        // the first time a verb is added.
649        Stmt::Ui(ui) => {
650            if let Some(name) = ui.writes() {
651                note(name, shadowed, out);
652            }
653        }
654        // ★ A write inside a branch counts even when the branch does not run. The analysis is a
655        // static over-approximation on purpose: a spare patch is waste, a missed one is a stale
656        // page, and only one of those is visible to whoever is using the app.
657        Stmt::If {
658            then_block,
659            else_block,
660            ..
661        } => {
662            let mut inner = shadowed.clone();
663            writes_of_block(then_block, ctx, &mut inner, out);
664            if let Some(b) = else_block {
665                let mut inner = shadowed.clone();
666                writes_of_block(b, ctx, &mut inner, out);
667            }
668        }
669        Stmt::While { body, .. } => {
670            let mut inner = shadowed.clone();
671            writes_of_block(body, ctx, &mut inner, out);
672        }
673        Stmt::Repeat { pattern, body, .. } => {
674            let mut inner = shadowed.clone();
675            bind_pattern(pattern, &mut inner);
676            writes_of_block(body, ctx, &mut inner, out);
677        }
678        Stmt::Inspect { arms, .. } => {
679            for arm in arms {
680                let mut inner = shadowed.clone();
681                for (_, binding) in &arm.bindings {
682                    inner.insert(*binding);
683                }
684                writes_of_block(arm.body, ctx, &mut inner, out);
685            }
686        }
687        // A desugared statement (`Push v to item 1 of xs`, `Set item i of xs to v`) is inline: its
688        // temporaries are its own `Let`s, and its write-back lands on the collection it names.
689        Stmt::Splice { body } => writes_of_block(body, ctx, shadowed, out),
690        _ => {}
691    }
692}
693
694/// Add every name a destructuring pattern binds to the shadow set.
695fn bind_pattern(pattern: &Pattern, shadowed: &mut HashSet<Symbol>) {
696    match pattern {
697        Pattern::Identifier(s) => {
698            shadowed.insert(*s);
699        }
700        Pattern::Tuple(parts) => {
701            for p in parts {
702                shadowed.insert(*p);
703            }
704        }
705    }
706}
707
708/// The identifiers an lvalue expression bottoms out in.
709fn base_identifiers(expr: &Expr<'_>) -> Vec<Symbol> {
710    let mut out = Vec::new();
711    collect_names(expr, &mut out);
712    out
713}
714
715/// A stable label for a turn started by an event sentence.
716fn trigger_label(trigger: &Trigger<'_>, interner: &Interner) -> String {
717    let text_of = |e: &Expr<'_>| match e {
718        Expr::Literal(crate::ast::stmt::Literal::Text(s)) => interner.resolve(*s).to_string(),
719        _ => "<computed>".to_string(),
720    };
721    match trigger {
722        Trigger::PageLoads => "when:page-loads".to_string(),
723        Trigger::Appears => "when:appears".to_string(),
724        Trigger::Disappears => "when:disappears".to_string(),
725        Trigger::RouteChanges => "when:route-changes".to_string(),
726        Trigger::Online => "when:online".to_string(),
727        Trigger::Offline => "when:offline".to_string(),
728        Trigger::Selector {
729            selector, event, ..
730        } => {
731            format!("when:{}:{}", text_of(selector), interner.resolve(*event))
732        }
733        Trigger::Key { key } => format!("when:key:{}", interner.resolve(*key)),
734        Trigger::FieldChanges { store, field } => match store {
735            Some(s) => format!(
736                "when:{}.{}:changes",
737                interner.resolve(*s),
738                interner.resolve(*field)
739            ),
740            None => format!("when:{}:changes", interner.resolve(*field)),
741        },
742        Trigger::Every { .. } => "when:every".to_string(),
743        Trigger::After { .. } => "when:after".to_string(),
744        Trigger::Message { topic, .. } => format!("when:message:{}", text_of(topic)),
745        Trigger::ActionFails { .. } => "when:action-fails".to_string(),
746        Trigger::FetchFails { .. } => "when:fetch-fails".to_string(),
747        Trigger::WindowResizes => "when:resize".to_string(),
748        Trigger::ScrollsToBottom { .. } => "when:scroll-bottom".to_string(),
749        Trigger::FileDropped { .. } => "when:file-drop".to_string(),
750    }
751}
752
753/// Walk a list of sibling nodes, emitting a site for everything that can change.
754fn walk_nodes(
755    nodes: &[ViewNode<'_>],
756    prefix: &str,
757    ctx: &Ctx,
758    env: &mut HashMap<Symbol, Resolved>,
759    out: &mut Vec<SiteDeps>,
760    dynamic: bool,
761) {
762    for (i, node) in nodes.iter().enumerate() {
763        let path = format!("{prefix}{i}");
764        walk_node(node, &path, ctx, env, out, dynamic);
765    }
766}
767
768fn push_site(
769    out: &mut Vec<SiteDeps>,
770    path: &str,
771    kind: SiteKind,
772    resolved: Resolved,
773    dynamic: bool,
774) {
775    out.push(SiteDeps {
776        path: path.to_string(),
777        kind,
778        dynamic,
779        reads: resolved.state,
780        prop_reads: resolved.props,
781    });
782}
783
784fn walk_node(
785    node: &ViewNode<'_>,
786    path: &str,
787    ctx: &Ctx,
788    env: &mut HashMap<Symbol, Resolved>,
789    out: &mut Vec<SiteDeps>,
790    dynamic: bool,
791) {
792    match node {
793        ViewNode::Element {
794            attrs, children, ..
795        } => {
796            for attr in attrs {
797                match attr {
798                    // A LITERAL attribute never changes, so it is not an update site at all —
799                    // listing it would put a constant in some field's invalidation set and make
800                    // every turn touch it.
801                    Attr::Plain { name, value } => {
802                        if !is_static(value) {
803                            push_site(
804                                out,
805                                path,
806                                SiteKind::Attr(*name),
807                                ctx.resolve_expr(value, env),
808                                dynamic,
809                            );
810                        }
811                    }
812                    Attr::Class { name, cond } => {
813                        push_site(
814                            out,
815                            path,
816                            SiteKind::Class(*name),
817                            ctx.resolve_expr(cond, env),
818                            dynamic,
819                        );
820                    }
821                    Attr::Bind { field } => {
822                        let r = ctx.resolve_name(*field, env);
823                        push_site(out, path, SiteKind::Bind(*field), r, dynamic);
824                    }
825                    Attr::Directive { name, value } => {
826                        push_site(
827                            out,
828                            path,
829                            SiteKind::Attr(*name),
830                            ctx.resolve_expr(value, env),
831                            dynamic,
832                        );
833                    }
834                }
835            }
836            walk_nodes(children, &format!("{path}."), ctx, env, out, dynamic);
837        }
838        ViewNode::Text { text, .. } => {
839            push_site(
840                out,
841                path,
842                SiteKind::Text,
843                ctx.resolve_expr(text, env),
844                dynamic,
845            );
846        }
847        ViewNode::Component {
848            props, children, ..
849        } => {
850            // A prop expression is evaluated HERE, so its reads belong to this component: when
851            // `todos` moves, the child has to be handed a new value.
852            for (name, value) in props {
853                push_site(
854                    out,
855                    path,
856                    SiteKind::Attr(*name),
857                    ctx.resolve_expr(value, env),
858                    dynamic,
859                );
860            }
861            walk_nodes(children, &format!("{path}."), ctx, env, out, dynamic);
862        }
863        ViewNode::Each {
864            pattern,
865            iterable,
866            key,
867            body,
868            empty,
869            ..
870        } => {
871            let over = ctx.resolve_expr(iterable, env);
872            push_site(out, path, SiteKind::Each, over.clone(), dynamic);
873            // ★ The loop variable is a WINDOW onto the collection: everything the body reads
874            // through it depends on the collection, and on nothing the body could name instead.
875            let mut inner = env.clone();
876            bind_pattern_env(pattern, &over, &mut inner);
877            if let Some(k) = key {
878                let _ = ctx.resolve_expr(k, &inner);
879            }
880            walk_nodes(body, &format!("{path}."), ctx, &mut inner, out, true);
881            walk_nodes(empty, &format!("{path}.e"), ctx, env, out, true);
882        }
883        ViewNode::If {
884            arms, otherwise, ..
885        } => {
886            // ONE site for the whole `{if}`: any condition changing may switch which arm renders,
887            // and the patcher's job there is to mount or unmount a subtree, not to edit a value.
888            let mut conds = Resolved::default();
889            for (cond, _) in arms {
890                let r = ctx.resolve_expr(cond, env);
891                conds.merge(&r);
892            }
893            push_site(out, path, SiteKind::If, conds, dynamic);
894            for (k, (_, body)) in arms.iter().enumerate() {
895                let mut inner = env.clone();
896                walk_nodes(body, &format!("{path}.{k}."), ctx, &mut inner, out, true);
897            }
898            if let Some(body) = otherwise {
899                let mut inner = env.clone();
900                walk_nodes(body, &format!("{path}.o"), ctx, &mut inner, out, true);
901            }
902        }
903        ViewNode::Inspect {
904            scrutinee, arms, ..
905        } => {
906            let over = ctx.resolve_expr(scrutinee, env);
907            push_site(out, path, SiteKind::Inspect, over.clone(), dynamic);
908            for (k, arm) in arms.iter().enumerate() {
909                let mut inner = env.clone();
910                // A pattern binding is a window onto the scrutinee, exactly as a loop variable is
911                // onto its collection.
912                for (_, binding) in &arm.bindings {
913                    inner.insert(*binding, over.clone());
914                }
915                walk_nodes(
916                    &arm.body,
917                    &format!("{path}.{k}."),
918                    ctx,
919                    &mut inner,
920                    out,
921                    true,
922                );
923            }
924        }
925        ViewNode::Let { var, value, .. } => {
926            let r = ctx.resolve_expr(value, env);
927            env.insert(*var, r);
928        }
929        ViewNode::Raw { html, .. } => {
930            push_site(
931                out,
932                path,
933                SiteKind::Text,
934                ctx.resolve_expr(html, env),
935                dynamic,
936            );
937        }
938        ViewNode::Block { default, .. } => {
939            if let Some(body) = default {
940                walk_nodes(body, &format!("{path}."), ctx, env, out, dynamic);
941            }
942        }
943        // A handler's body is a TURN, recorded in the write sets rather than as a read site; an
944        // `{extends}` and a `{children}` slot hold nothing that can change.
945        ViewNode::Handler { .. } | ViewNode::Extends { .. } | ViewNode::Children { .. } => {}
946    }
947}
948
949/// Bind a loop pattern's names to what the collection resolves to.
950fn bind_pattern_env(pattern: &Pattern, over: &Resolved, env: &mut HashMap<Symbol, Resolved>) {
951    match pattern {
952        Pattern::Identifier(s) => {
953            env.insert(*s, over.clone());
954        }
955        Pattern::Tuple(parts) => {
956            for p in parts {
957                env.insert(*p, over.clone());
958            }
959        }
960    }
961}
962
963/// Is this expression a compile-time constant — something that can never need an update?
964fn is_static(expr: &Expr<'_>) -> bool {
965    match expr {
966        Expr::Literal(_) => true,
967        Expr::InterpolatedString(parts) => {
968            parts.iter().all(|p| matches!(p, StringPart::Literal(_)))
969        }
970        _ => false,
971    }
972}