logicaffeine_language/parser/lgx_lower/gate.rs
1use super::json::JsonShapes;
2use super::*;
3use crate::ast::component::{ComponentDef, ComponentKind, RouteBody, Trigger};
4use crate::ast::stmt::{Expr, Literal, Stmt, TypeExpr};
5use crate::ast::view::{Attr, ViewNode};
6use crate::lgx::html::{Behaviour, EventModifier};
7use crate::token::Span;
8use logicaffeine_base::{Interner, Symbol};
9
10/// Whether a store's state can be given a record type here. A derived field or a field whose
11/// type cannot be named from its annotation or its initializer means it cannot.
12/// The storage AREA a store keeps its state in between page loads, or `None` for one that keeps
13/// nothing there. The area is the lifetime the author asked for, spelled the way the browser
14/// spells it — the one decision every tier reads, so every tier puts a persisted store in the same
15/// place.
16pub fn store_area(def: &ComponentDef<'_>) -> Option<&'static str> {
17 match &def.kind {
18 ComponentKind::Store {
19 mode: crate::ast::component::StoreMode::Persisted { .. },
20 } => Some("local"),
21 ComponentKind::Store {
22 mode: crate::ast::component::StoreMode::Preserved,
23 } => Some("session"),
24 ComponentKind::Store {
25 mode: crate::ast::component::StoreMode::Shared { .. },
26 } => Some(SHARED_AREA),
27 _ => None,
28 }
29}
30
31/// Whether a field of this type has a faithful TEXT form, which is what storage carries.
32///
33/// Refusing is the point: a record quietly stored as its display form and read back as the TEXT
34/// of that display is a page that loses its state and says nothing about it.
35pub(crate) fn storable_type(interner: &Interner, ty: Symbol) -> bool {
36 matches!(
37 interner.resolve(ty),
38 "Int" | "Text" | "Bool" | "Float" | "Real"
39 )
40}
41
42/// The element `Seq of <element>` names, from EITHER an explicit annotation (`Let x: Seq of Text
43/// be …`) or the constructor an UNANNOTATED field was given (`Let x be a new Seq of Text.`, which
44/// is how every one of this campaign's own fixtures writes one) — the one shape a store's own
45/// record, and `Save`/`Load`, thread a list through. `None` for anything else, including a `Seq`
46/// of more than one type parameter or of something other than a plain name.
47pub(crate) fn seq_field_element<'a>(
48 ty: Option<&TypeExpr<'a>>,
49 init: &Expr<'a>,
50 interner: &Interner,
51) -> Option<Symbol> {
52 let is_seq = |base: Symbol| interner.resolve(base).eq_ignore_ascii_case("Seq");
53 let elem_of = |params: &[TypeExpr<'a>]| -> Option<Symbol> {
54 if params.len() != 1 {
55 return None;
56 }
57 match ¶ms[0] {
58 TypeExpr::Primitive(e) | TypeExpr::Named(e) => Some(*e),
59 _ => None,
60 }
61 };
62 match ty {
63 Some(TypeExpr::Generic { base, params }) if is_seq(*base) => elem_of(params),
64 Some(_) => None,
65 None => match init {
66 Expr::New {
67 type_name,
68 type_args,
69 ..
70 } if is_seq(*type_name) => elem_of(type_args),
71 _ => None,
72 },
73 }
74}
75
76/// Why a store cannot be lowered, or `None` when it can.
77///
78/// TWO DIFFERENT PROBLEMS with two different fixes, and they must not borrow each other's words:
79/// a field whose TYPE this tier cannot name is a record-typing problem, while a field with no
80/// faithful TEXT form in a store that keeps its state between page loads is a persistence problem
81/// — and the second is fixed either by changing the field or by dropping `persisted as`. A
82/// refusal that names the wrong cause costs the reader the time it takes to rule out the thing
83/// you told them, which is worse than naming no cause at all.
84///
85/// Asked with the same codec questions ([`JsonShapes`]) the store is emitted behind
86/// ([`store_lowerable`]), so a store is never lowered by the one and refused by the other.
87pub(crate) fn store_not_lowerable(
88 def: &ComponentDef<'_>,
89 shapes: &JsonShapes<'_>,
90) -> Option<&'static str> {
91 if !store_lowerable(def, shapes) {
92 // A store this tier cannot type at all — the record-shape problem.
93 if !store_shape_lowerable(def, shapes.interner()) {
94 return Some("it uses a store whose state this tier cannot give a record type");
95 }
96 // It types fine; what it cannot do is CARRY those fields through text.
97 return Some(
98 "it uses a store that keeps its state between page loads, and one of its fields holds \
99 something text cannot carry. A stored field can be a Text, an Int, a Real, a Bool, a \
100 Seq of one of those, or a record this file can fully encode and decode as JSON",
101 );
102 }
103 None
104}
105
106/// The record-shape half: a store whose fields this tier can give a named type, regardless of
107/// whether it also has to survive a page load.
108///
109/// A `Seq of <anything named>` is accepted UNCONDITIONALLY here, scalar or record element alike —
110/// sound because a store that does not persist needs no serialization at all: its `Seq` field
111/// becomes an ordinary `FieldType::Generic` in the type registry, resolved by the exact same
112/// per-field boundary inference every plain `## A Foo has: … Seq of X.` record's field already
113/// goes through. Only a store that KEEPS its state between page loads needs the codec, and that
114/// is [`store_lowerable`]'s own, separate, stricter question.
115pub(crate) fn store_shape_lowerable(def: &ComponentDef<'_>, interner: &Interner) -> bool {
116 matches!(
117 def.kind,
118 ComponentKind::Store {
119 mode: crate::ast::component::StoreMode::Local
120 | crate::ast::component::StoreMode::Persisted { .. }
121 | crate::ast::component::StoreMode::Preserved
122 | crate::ast::component::StoreMode::Shared { .. }
123 }
124 ) && !def.state.iter().any(|f| f.derived)
125 && def.state.iter().all(|f| {
126 shared_convergent_field(def, f, interner)
127 || matches!(f.ty, Some(TypeExpr::Primitive(_) | TypeExpr::Named(_)))
128 || seq_field_element(f.ty, f.init, interner).is_some()
129 || matches!(
130 f.init,
131 Expr::Literal(
132 Literal::Number(_)
133 | Literal::Text(_)
134 | Literal::Boolean(_)
135 | Literal::Float(_)
136 )
137 )
138 })
139}
140
141/// Whether `field` of `def` is a convergent field of a `shared on` store — a `Tally`, a
142/// `SharedSet of T`, … — whose record type is the plain value it converges to and whose topic form
143/// is its CRDT's frame (`lgx_lower::shared`).
144fn shared_convergent_field(
145 def: &ComponentDef<'_>,
146 field: &crate::ast::component::StateField<'_>,
147 interner: &Interner,
148) -> bool {
149 matches!(
150 def.kind,
151 ComponentKind::Store {
152 mode: crate::ast::component::StoreMode::Shared { .. }
153 }
154 ) && field
155 .ty
156 .and_then(|ty| super::shared::shared_field_kind(ty, interner))
157 .is_some_and(|kind| kind.is_ok())
158}
159
160/// Whether a store fully lowers — its shape ([`store_shape_lowerable`]) AND, when it keeps its
161/// state between page loads, every field's own text form: a scalar's fixed tag, or a record this
162/// file can fully encode AND decode as JSON ([`JsonShapes::record_round_trips`]).
163pub(crate) fn store_lowerable(def: &ComponentDef<'_>, shapes: &JsonShapes<'_>) -> bool {
164 let interner = shapes.interner();
165 let record_round_trips = |ty: Symbol| shapes.record_round_trips(ty);
166 // A SHARED store is a module record like any other, its fields carried to and from the relay
167 // topic in their TEXT form (`lgx_lower::shared`) exactly as a persisted store's are carried to
168 // and from storage — so it takes the same field rule below.
169 let persisted_field_ok = |f: &crate::ast::component::StateField<'_>| -> bool {
170 if shared_convergent_field(def, f, interner) {
171 return true;
172 }
173 if let Some(elem) = seq_field_element(f.ty, f.init, interner) {
174 return storable_type(interner, elem) || record_round_trips(elem);
175 }
176 match f.ty {
177 Some(TypeExpr::Primitive(t) | TypeExpr::Named(t)) => {
178 storable_type(interner, *t) || record_round_trips(*t)
179 }
180 Some(_) => false,
181 None => matches!(
182 f.init,
183 Expr::Literal(
184 Literal::Number(_) | Literal::Text(_) | Literal::Boolean(_) | Literal::Float(_)
185 )
186 ),
187 }
188 };
189 matches!(
190 def.kind,
191 ComponentKind::Store {
192 mode: crate::ast::component::StoreMode::Local
193 | crate::ast::component::StoreMode::Persisted { .. }
194 | crate::ast::component::StoreMode::Preserved
195 | crate::ast::component::StoreMode::Shared { .. }
196 }
197 )
198 && !def.state.iter().any(|f| f.derived)
199 // A store that keeps its state between page loads carries every field through TEXT (a
200 // scalar's own fixed tag, or the JSON codec's `j:` tag), so a field with neither a
201 // faithful text form nor a verified JSON round-trip is refused HERE rather than lowered
202 // into a page that silently forgets it.
203 && (store_area(def).is_none() || def.state.iter().all(persisted_field_ok))
204 && store_shape_lowerable(def, interner)
205}
206
207/// Why a component cannot be lowered — the message the VM refuses a `Mount` with, and WHERE.
208pub struct NotLowerable {
209 pub message: String,
210 /// The node, handler or definition that could not be lowered.
211 pub span: Span,
212}
213
214impl NotLowerable {
215 /// A refusal, from a literal or from a message built around the author's own words, AT the
216 /// place that could not be lowered.
217 ///
218 /// ★ IT CARRIES A `String` SO IT CAN NAME WHAT IT REFUSED. Every refusal here used to be a
219 /// `&'static str`, which quietly forced each one to describe a CLASS ("one of its `Actions:`
220 /// sentences waits for something this tier cannot deliver yet") rather than the sentence the
221 /// author wrote. That is the anonymous-refusal shape this campaign has now fixed five times:
222 /// the reader is told a page failed and left to find which line did it.
223 ///
224 /// ★ AND IT CARRIES A `Span`, WHICH IS THE OTHER HALF OF THAT SAME FIX. Naming the construct
225 /// tells the reader WHAT; only a span tells them WHERE, and every one of these rendered at
226 /// `Span::default()` — the top of the file — because the walk destructured each node with
227 /// `..` and threw the location away. A message that says "its template uses a directive" and
228 /// points at line 1 leaves the author scanning a forty-node view by hand.
229 ///
230 /// The span is a REQUIRED argument rather than an `Option` or a defaulted field, so a new
231 /// refusal cannot be added without saying where it happened. That is the only version of this
232 /// that does not rot: the previous shape made "no span" the path of least resistance, and all
233 /// twenty-one sites took it.
234 pub(crate) fn at(span: Span, text: impl Into<String>) -> Self {
235 NotLowerable {
236 message: text.into(),
237 span,
238 }
239 }
240}
241
242/// Whether a component's state has to survive between renders — because an action writes it,
243/// a sentence answers an event, or an input is bound to it. Such a component keeps its state in
244/// module-level bindings; one without any of these reads its initializers each render.
245
246/// Every component a view PLACES, at any depth — the edges of the component tree, which is what
247/// the server renderer's demand rule closes over.
248/// Every component this view PLACES, at any depth.
249///
250/// ★ PUBLIC BECAUSE TWO RENDERERS NEED THE SAME ANSWER. The server hoists stylesheets from the
251/// root and the browser hoists them at the mount point — both walk this exact graph, and two
252/// copies of it would be two chances to disagree about which sheets a page carries.
253/// The components a view places INSIDE a `{for each}` — the ones that share a mount point.
254///
255/// ★ SHARING A MOUNT POINT IS WHAT MAKES A REPAINT UNSAFE, and a loop is the second way to get
256/// there. `repaint_def` re-renders into `__M_<C>[key]` and CLEARS it first; for a row drawn by
257/// `{for each t in tiles:}<Tile …/>{end}` that recorded mount point is the enclosing `<ul>`,
258/// which every row shares. So one row's unpatchable turn cleared the list and redrew itself
259/// alone — two rows in, click the first, and the second is gone from the document.
260///
261/// A placed WRAPPER already had this exact problem for the same reason and is already redirected;
262/// this is the same fact about a different shape, which is why it joins that predicate rather than
263/// growing a second one.
264/// The components an instance of which can ever LEAVE the page.
265///
266/// ★ MACHINERY FOR SOMETHING THAT CANNOT HAPPEN IS STILL SHIPPED TO THE READER. The unmount sweep
267/// answers "which instances were not drawn in the last paint", and an instance can only fail to be
268/// drawn if something chose not to draw it — a `{if}` arm, a `{for each}` whose list shrank, an
269/// `{inspect}` branch. A page whose components are all placed unconditionally can never sweep
270/// anything, and it was carrying the sweep anyway: measured at 1,052 bytes of wasm on a page with
271/// ONE state field, which is a third of what such a page costs at all.
272///
273/// A component under a conditional node is unmountable, and so is everything it places, however
274/// unconditionally it places them — when the `{if}` closes, the whole subtree goes with it.
275pub(crate) fn unmountable_components(all: &[ComponentDef<'_>]) -> Vec<Symbol> {
276 fn walk(nodes: &[ViewNode<'_>], conditional: bool, out: &mut Vec<Symbol>) {
277 for node in nodes {
278 match node {
279 ViewNode::Component { name, children, .. } => {
280 if conditional && !out.contains(name) {
281 out.push(*name);
282 }
283 walk(children, conditional, out);
284 }
285 ViewNode::Element { children, .. } => walk(children, conditional, out),
286 // The three ways a view chooses. Every arm is conditional, including the `otherwise`
287 // — it is not drawn when another arm is.
288 ViewNode::If {
289 arms, otherwise, ..
290 } => {
291 for (_, body) in arms {
292 walk(body, true, out);
293 }
294 if let Some(o) = otherwise {
295 walk(o, true, out);
296 }
297 }
298 ViewNode::Each { body, empty, .. } => {
299 walk(body, true, out);
300 walk(empty, true, out);
301 }
302 ViewNode::Inspect { arms, .. } => {
303 for arm in arms {
304 walk(&arm.body, true, out);
305 }
306 }
307 ViewNode::Block {
308 default: Some(body),
309 ..
310 } => walk(body, conditional, out),
311 _ => {}
312 }
313 }
314 }
315 let mut out: Vec<Symbol> = Vec::new();
316 for def in all {
317 if let Some(view) = def.view.as_ref() {
318 walk(&view.nodes, false, &mut out);
319 }
320 }
321 // Everything an unmountable component places goes with it, to any depth.
322 let mut grew = true;
323 while grew {
324 grew = false;
325 for name in out.clone() {
326 let Some(def) = all.iter().find(|d| d.name == name) else {
327 continue;
328 };
329 let Some(view) = def.view.as_ref() else {
330 continue;
331 };
332 for placed in placed_components(&view.nodes) {
333 if !out.contains(&placed) {
334 out.push(placed);
335 grew = true;
336 }
337 }
338 }
339 }
340 out
341}
342
343pub fn loop_placed_components(nodes: &[ViewNode<'_>]) -> Vec<Symbol> {
344 let mut out = Vec::new();
345 fn walk(nodes: &[ViewNode<'_>], inside: bool, out: &mut Vec<Symbol>) {
346 for node in nodes {
347 match node {
348 ViewNode::Component { name, children, .. } => {
349 if inside {
350 out.push(*name);
351 }
352 walk(children, inside, out);
353 }
354 ViewNode::Element { children, .. } => walk(children, inside, out),
355 ViewNode::If {
356 arms, otherwise, ..
357 } => {
358 for (_, body) in arms {
359 walk(body, inside, out);
360 }
361 if let Some(o) = otherwise {
362 walk(o, inside, out);
363 }
364 }
365 // The loop's BODY is inside; its `{empty}` markup draws once and is not.
366 ViewNode::Each { body, empty, .. } => {
367 walk(body, true, out);
368 walk(empty, inside, out);
369 }
370 ViewNode::Inspect { arms, .. } => {
371 for arm in arms {
372 walk(&arm.body, inside, out);
373 }
374 }
375 ViewNode::Block {
376 default: Some(body),
377 ..
378 } => walk(body, inside, out),
379 _ => {}
380 }
381 }
382 }
383 walk(nodes, false, &mut out);
384 out
385}
386
387/// The lowerable components with a `When this component appears:` handler, in declaration order —
388/// the numbering the appears queue (`__LGX_APPEAR_WHO`) and its drain (`__lgx_appear`) share.
389pub(crate) fn appearing_components(
390 all: &[ComponentDef<'_>],
391 types: Option<&crate::analysis::registry::TypeRegistry>,
392 interner: &Interner,
393) -> Vec<Symbol> {
394 all.iter()
395 .filter(|d| {
396 matches!(
397 d.kind,
398 ComponentKind::Component | ComponentKind::Page { .. }
399 )
400 })
401 .filter(|d| {
402 d.events
403 .iter()
404 .any(|h| matches!(h.trigger, Trigger::Appears))
405 })
406 .filter(|d| lowerable(d, all, types, interner).is_ok())
407 .map(|d| d.name)
408 .collect()
409}
410
411/// Whether `def` can be drawn inside itself: its view places `def` again, directly or through the
412/// components it places. Such a component has a finite rendering only if something in its view
413/// stops the nesting, so its render counts how deep it is (`__LGX_RENDER_DEPTH`).
414pub(crate) fn on_placement_cycle(def: &ComponentDef<'_>, all: &[ComponentDef<'_>]) -> bool {
415 let mut seen: Vec<Symbol> = Vec::new();
416 let mut pending: Vec<Symbol> = def
417 .view
418 .as_ref()
419 .map(|v| placed_components(&v.nodes))
420 .unwrap_or_default();
421 while let Some(name) = pending.pop() {
422 if name == def.name {
423 return true;
424 }
425 if seen.contains(&name) {
426 continue;
427 }
428 seen.push(name);
429 if let Some(view) = all
430 .iter()
431 .find(|d| d.name == name)
432 .and_then(|d| d.view.as_ref())
433 {
434 pending.extend(placed_components(&view.nodes));
435 }
436 }
437 false
438}
439
440pub fn placed_components(nodes: &[ViewNode<'_>]) -> Vec<Symbol> {
441 let mut out = Vec::new();
442 fn walk(nodes: &[ViewNode<'_>], out: &mut Vec<Symbol>) {
443 for node in nodes {
444 match node {
445 ViewNode::Component { name, children, .. } => {
446 out.push(*name);
447 walk(children, out);
448 }
449 ViewNode::Element { children, .. } => walk(children, out),
450 ViewNode::If {
451 arms, otherwise, ..
452 } => {
453 for (_, body) in arms {
454 walk(body, out);
455 }
456 if let Some(o) = otherwise {
457 walk(o, out);
458 }
459 }
460 ViewNode::Each { body, empty, .. } => {
461 walk(body, out);
462 walk(empty, out);
463 }
464 ViewNode::Inspect { arms, .. } => {
465 for arm in arms {
466 walk(&arm.body, out);
467 }
468 }
469 // ★ A LAYOUT IS A COMPONENT THIS VIEW DEPENDS ON, and it fell into the catch-all
470 // below. Every reader of this walk asks the same question — which components does
471 // this page reach — and `{extends Site}` reaches `Site` as surely as `<Site/>`
472 // would. Dropping it meant the LAYOUT'S STYLESHEET WAS NEVER EMITTED: its elements
473 // were stamped with its scope class, the scoped rule would have matched, and the
474 // rule was not in the document. A page that renders with its styling silently
475 // missing, which is the worst shape a styling bug can take — nothing to see, and
476 // nothing said.
477 ViewNode::Extends { layout, .. } => out.push(*layout),
478 // And a block's DEFAULT body places components too; it is the markup used whenever
479 // the page does not fill the block, so a component there is as real as any other.
480 ViewNode::Block {
481 default: Some(body),
482 ..
483 } => walk(body, out),
484 _ => {}
485 }
486 }
487 }
488 walk(nodes, &mut out);
489 out
490}
491
492/// Whether these statements call a `<Store>`'s action — asked of an action body BEFORE the store
493/// rewrite runs over it, because that rewrite replaces the sentence with its lowered form.
494pub(crate) fn touches_store(stmts: &[Stmt<'_>]) -> bool {
495 stmts.iter().any(|s| match s {
496 Stmt::Ui(crate::ast::stmt::UiStmt::CallStore { .. }) => true,
497 Stmt::If {
498 then_block,
499 else_block,
500 ..
501 } => touches_store(then_block) || else_block.is_some_and(touches_store),
502 Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
503 touches_store(body)
504 }
505 Stmt::Inspect { arms, .. } => arms.iter().any(|a| touches_store(a.body)),
506 _ => false,
507 })
508}
509
510/// Whether `def` has a `When <field> changes:` handler on one of its own plain fields — the handlers
511/// a turn of `def` runs after its action commits.
512pub(crate) fn watches_own_fields(def: &ComponentDef<'_>) -> bool {
513 def.events.iter().any(|h| match h.trigger {
514 Trigger::FieldChanges { store: None, field } => {
515 def.state.iter().any(|f| f.name == field && !f.derived)
516 }
517 _ => false,
518 })
519}
520
521/// The components with such handlers, in declaration order; a component's watch id (what
522/// `__LGX_WATCHING` holds while its handlers run) is its position here plus one.
523pub(crate) fn watching_components(all: &[ComponentDef<'_>]) -> Vec<Symbol> {
524 all.iter()
525 .filter(|d| watches_own_fields(d))
526 .map(|d| d.name)
527 .collect()
528}
529
530/// Whether these statements, at any depth, move the location with `Navigate to` — which ends the
531/// turn by delivering the move to the page's route listeners.
532pub(crate) fn navigates(stmts: &[Stmt<'_>]) -> bool {
533 stmts.iter().any(|s| match s {
534 Stmt::Ui(crate::ast::stmt::UiStmt::Navigate { .. }) => true,
535 Stmt::If {
536 then_block,
537 else_block,
538 ..
539 } => navigates(then_block) || else_block.is_some_and(navigates),
540 Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
541 navigates(body)
542 }
543 Stmt::Inspect { arms, .. } => arms.iter().any(|a| navigates(a.body)),
544 _ => false,
545 })
546}
547
548/// Whether any action or handler anywhere in `all` moves the location — the question the route
549/// fan-out's emission asks, so a turn's epilogue never calls a fan-out that was not emitted.
550pub(crate) fn any_navigates(all: &[ComponentDef<'_>]) -> bool {
551 all.iter().any(|d| {
552 d.actions.iter().any(|a| navigates(a.body)) || d.events.iter().any(|h| navigates(h.body))
553 })
554}
555
556/// Whether `stmt` itself asks for an element event delivered after its turn commits — the
557/// statements that lower through `Parser::queue_element_event`: a `Publish` (its page-local
558/// delivery) and a `Hover` (the `mouseover` a browser raises for it). `Focus`/`Blur` are not
559/// here: the host raises their events itself, and only on a real move of focus.
560pub(crate) fn queues_element_event(stmt: &Stmt<'_>) -> bool {
561 use crate::ast::stmt::UiStmt;
562 matches!(
563 stmt,
564 Stmt::Ui(UiStmt::Publish { .. } | UiStmt::Hover { .. })
565 )
566}
567
568/// Whether any statement in `stmts`, at any depth, queues an element event.
569pub(crate) fn body_queues_element_event(stmts: &[Stmt<'_>]) -> bool {
570 stmts.iter().any(|s| {
571 queues_element_event(s)
572 || match s {
573 Stmt::If {
574 then_block,
575 else_block,
576 ..
577 } => {
578 body_queues_element_event(then_block)
579 || else_block.is_some_and(body_queues_element_event)
580 }
581 Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
582 body_queues_element_event(body)
583 }
584 Stmt::Inspect { arms, .. } => {
585 arms.iter().any(|a| body_queues_element_event(a.body))
586 }
587 _ => false,
588 }
589 })
590}
591
592/// Whether running `store`'s action `action` queues an element event — in its own body, or in any
593/// store action it calls in turn.
594pub(crate) fn store_action_queues(all: &[ComponentDef<'_>], store: Symbol, action: Symbol) -> bool {
595 fn go(
596 all: &[ComponentDef<'_>],
597 store: Symbol,
598 action: Symbol,
599 seen: &mut Vec<(Symbol, Symbol)>,
600 ) -> bool {
601 if seen.contains(&(store, action)) {
602 return false;
603 }
604 seen.push((store, action));
605 let Some(body) = all
606 .iter()
607 .find(|d| d.name == store)
608 .and_then(|d| d.actions.iter().find(|a| a.name == action))
609 .map(|a| a.body)
610 else {
611 return false;
612 };
613 if body_queues_element_event(body) {
614 return true;
615 }
616 let mut calls = Vec::new();
617 store_calls_in(body, &mut calls);
618 calls.into_iter().any(|(s, a)| go(all, s, a, seen))
619 }
620 go(all, store, action, &mut Vec::new())
621}
622
623/// Every `Call <Store>'s <action>` in `stmts`, at any depth.
624fn store_calls_in(stmts: &[Stmt<'_>], out: &mut Vec<(Symbol, Symbol)>) {
625 for s in stmts {
626 match s {
627 Stmt::Ui(crate::ast::stmt::UiStmt::CallStore { store, action, .. }) => {
628 out.push((*store, *action))
629 }
630 Stmt::If {
631 then_block,
632 else_block,
633 ..
634 } => {
635 store_calls_in(then_block, out);
636 if let Some(e) = else_block {
637 store_calls_in(e, out);
638 }
639 }
640 Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
641 store_calls_in(body, out)
642 }
643 Stmt::Inspect { arms, .. } => {
644 for a in arms {
645 store_calls_in(a.body, out);
646 }
647 }
648 _ => {}
649 }
650 }
651}
652
653/// Whether any action or handler anywhere in `all` queues an element event — the one question the
654/// outbox's declaration and the dispatcher's flush arm both ask, so neither exists without the other.
655pub(crate) fn uses_outbox(all: &[ComponentDef<'_>]) -> bool {
656 all.iter().any(|d| {
657 d.actions.iter().any(|a| body_queues_element_event(a.body))
658 || d.events.iter().any(|h| body_queues_element_event(h.body))
659 })
660}
661
662/// [`touches_store`], seeing through `Call <sibling>.` into every action of `def` those statements
663/// reach — a sibling runs nested in the caller's turn, so a store it moves is a store the caller's
664/// turn moved.
665pub(crate) fn touches_store_through(def: &ComponentDef<'_>, stmts: &[Stmt<'_>]) -> bool {
666 if touches_store(stmts) {
667 return true;
668 }
669 let mut reached = Vec::new();
670 own_action_calls(stmts, def, &mut reached);
671 let mut next = 0;
672 while next < reached.len() {
673 let name = reached[next];
674 next += 1;
675 let Some(action) = def.actions.iter().find(|a| a.name == name) else {
676 continue;
677 };
678 if touches_store(action.body) {
679 return true;
680 }
681 own_action_calls(action.body, def, &mut reached);
682 }
683 false
684}
685
686/// Whether an `on-<event>.once` handler sits anywhere in `nodes`.
687pub(crate) fn nodes_have_once_handler(nodes: &[ViewNode<'_>]) -> bool {
688 nodes.iter().any(|node| {
689 matches!(node, ViewNode::Handler { modifiers, .. }
690 if modifiers.contains(&EventModifier::Behaviour(Behaviour::Once)))
691 || view_subtrees(node).into_iter().any(nodes_have_once_handler)
692 })
693}
694
695/// Whether `def`'s own markup carries an `on-<event>.once` handler — the component whose instances
696/// own that listener's spent record.
697pub(crate) fn has_once_handler(def: &ComponentDef<'_>) -> bool {
698 def.view
699 .as_ref()
700 .is_some_and(|v| nodes_have_once_handler(&v.nodes))
701}
702
703/// Whether any component in the program declares a `.once` handler — the one question the spent map,
704/// the listener-to-identity map and every reader of them ask, so none exists without the others.
705pub(crate) fn uses_once(all: &[ComponentDef<'_>]) -> bool {
706 all.iter().any(has_once_handler)
707}
708
709/// Whether this component keeps a live-instance registry (`__KEYS_<C>` / `__SEEN_<C>`).
710///
711/// ★ ONE PREDICATE, EVERY READER. Four places have to agree about this — the registry
712/// declarations, the per-paint STAMP that records an instance as drawn, the unmount SWEEP that
713/// walks for instances that were not, and the pass counter both of those read. They were three
714/// separate conditions, and the moment one of them said yes while the stamp said no, the sweep
715/// saw every LIVE instance as gone and cleared its state on the next paint. A predicate they all
716/// ask cannot drift that way.
717///
718/// State is on the list because an instance that leaves must take its state with it: without a
719/// registry nothing notices it vanished, `__S_<C>_<field>` kept the entry, and a remount under the
720/// same key resurrected the old value.
721pub fn keeps_instance_registry(def: &ComponentDef<'_>) -> bool {
722 // ★ ONLY SOMETHING THAT RENDERS CAN BE SWEPT. A registry answers "was this instance drawn in
723 // the last paint", and the sweep treats "not drawn" as GONE. A `## Store` has state and no
724 // view, so it is never drawn and never stamped — including it here made the sweep wipe every
725 // store's state on the first paint. Twenty-one tests said so at once, which is the useful
726 // shape of that mistake: a predicate that is nearly right is worse than one that is obviously
727 // wrong, because it fails only where the two ideas it conflates come apart.
728 if !matches!(
729 def.kind,
730 ComponentKind::Component | ComponentKind::Page { .. }
731 ) {
732 return false;
733 }
734 // A `.once` listener's spent record belongs to its instance: the sweep forgets it when the
735 // instance leaves, so a remount starts with the listener armed again.
736 def.state.iter().any(|f| !f.derived)
737 || has_once_handler(def)
738 || def.events.iter().any(|h| {
739 // ★ `Appears` IS HERE FOR THE SAME REASON `Disappears` IS. Both ask a question only
740 // a registry can answer — "is this the first paint this instance was drawn in" — and
741 // the answer is read from `__SEEN_<C>` at opposite ends of an instance's life. Listing
742 // only one of them gave a component with `When this component appears:` and no state
743 // no presence map at all, so the first-render guard had nothing to consult and the
744 // handler never ran.
745 // A route change and a published message are delivered to every LIVE instance, which
746 // is the registry's question too. `When the page loads:` asks the first-render
747 // question too: a placed instance loads the first time it is drawn.
748 matches!(
749 h.trigger,
750 Trigger::Appears
751 | Trigger::PageLoads
752 | Trigger::Disappears
753 | Trigger::FieldChanges { store: Some(_), .. }
754 | Trigger::Every { .. }
755 | Trigger::After { .. }
756 | Trigger::RouteChanges
757 | Trigger::Message { .. }
758 )
759 })
760}
761
762/// A `{for each …:}` with NO `keyed by` whose body reaches a component that owns `State:`.
763///
764/// ★ SILENT STATE CORRUPTION, IN THE MOST ORDINARY SHAPE A PAGE HAS. Without a key an instance is
765/// identified by its POSITION in the list, so the moment the list is reordered or an item is
766/// removed every survivor inherits its neighbour's state. Measured on a list of three counters
767/// with the second bumped twice and the third once: dropping the FIRST item left the page reading
768/// `b:0 c:2` — b took a's count and c took b's — while the keyed spelling of the same page
769/// correctly read `b:2 c:1`. Nothing was reported on either tier.
770///
771/// The framework cannot tell an append-only list (where positional identity is harmless) from one
772/// that reorders, so it refuses and names the clause that settles it. That is the same judgement
773/// this file makes everywhere else: refused LOUDLY beats rendered wrongly, and a page whose rows
774/// quietly swap their contents is as wrong as a page gets.
775///
776/// TRANSITIVE, because the damage is not limited to the component named in the loop: a stateless
777/// `<Panel>` placed unkeyed still carries a stateful `<Tile>` inside it, and the `<Tile>` is the
778/// one that loses its identity. It answers the same question `placed_components` answers, one
779/// reachability step further out.
780#[allow(dead_code)]
781pub fn unkeyed_stateful_loop<'a>(
782 def: &ComponentDef<'a>,
783 all: &[ComponentDef<'a>],
784) -> Option<(Symbol, Span)> {
785 fn stateful(name: Symbol, all: &[ComponentDef<'_>], seen: &mut Vec<Symbol>) -> Option<Symbol> {
786 if seen.contains(&name) {
787 return None;
788 }
789 seen.push(name);
790 let def = all.iter().find(|d| d.name == name)?;
791 if def.state.iter().any(|f| !f.derived) {
792 return Some(name);
793 }
794 let view = def.view.as_ref()?;
795 placed_components(&view.nodes)
796 .into_iter()
797 .find_map(|c| stateful(c, all, seen))
798 }
799 fn walk<'a>(nodes: &[ViewNode<'a>], all: &[ComponentDef<'a>]) -> Option<(Symbol, Span)> {
800 for node in nodes {
801 let found = match node {
802 ViewNode::Each {
803 key: None,
804 body,
805 empty,
806 span,
807 ..
808 } => placed_components(body)
809 .into_iter()
810 .find_map(|c| stateful(c, all, &mut Vec::new()))
811 .map(|c| (c, *span))
812 .or_else(|| walk(body, all))
813 .or_else(|| walk(empty, all)),
814 ViewNode::Each { body, empty, .. } => walk(body, all).or_else(|| walk(empty, all)),
815 ViewNode::Element { children, .. } => walk(children, all),
816 ViewNode::Component { children, .. } => walk(children, all),
817 ViewNode::If {
818 arms, otherwise, ..
819 } => arms
820 .iter()
821 .find_map(|(_, b)| walk(b, all))
822 .or_else(|| otherwise.as_ref().and_then(|o| walk(o, all))),
823 ViewNode::Inspect { arms, .. } => arms.iter().find_map(|a| walk(&a.body, all)),
824 ViewNode::Block {
825 default: Some(b), ..
826 } => walk(b, all),
827 _ => None,
828 };
829 if found.is_some() {
830 return found;
831 }
832 }
833 None
834 }
835 walk(&def.view.as_ref()?.nodes, all)
836}
837
838/// Which view nodes the server-side HTML walk covers.
839///
840/// A page rendered short is worse than one refused: the reader sees a plausible document and no
841/// error, and the missing piece is whatever the walk did not understand.
842pub fn view_html_lowerable(
843 nodes: &[ViewNode<'_>],
844 interner: &Interner,
845) -> Result<(), NotLowerable> {
846 view_html_lowerable_with(nodes, &[], interner)
847}
848
849/// [`view_html_lowerable`] with the component list, so a CHILD COMPONENT can be checked: a
850/// `<TodoRow todo={t}/>` is server-renderable exactly when the child's own view is.
851///
852/// Without the list a child is refused, which is the honest answer for a caller that cannot see
853/// the other components (a `-> View` route body checked on its own).
854pub fn view_html_lowerable_with(
855 nodes: &[ViewNode<'_>],
856 all: &[ComponentDef<'_>],
857 interner: &Interner,
858) -> Result<(), NotLowerable> {
859 view_html_lowerable_seen(nodes, all, interner, &mut Vec::new())
860}
861
862pub(crate) fn view_html_lowerable_seen(
863 nodes: &[ViewNode<'_>],
864 all: &[ComponentDef<'_>],
865 interner: &Interner,
866 seen: &mut Vec<Symbol>,
867) -> Result<(), NotLowerable> {
868 for node in nodes {
869 let at = node.span();
870 match node {
871 ViewNode::Element {
872 tag,
873 attrs,
874 children,
875 ..
876 } => {
877 for a in attrs {
878 match a {
879 Attr::Plain { .. } => {}
880 // A `bind` renders the field's current value as the attribute the element
881 // reads it from. Which attribute depends on the element's type, the same
882 // question the browser tier asks; a computed type leaves it undecidable
883 // and is refused.
884 Attr::Bind { .. } => {
885 if let Some(why) =
886 crate::ast::view::bound_input_kind(*tag, attrs, interner).refusal()
887 {
888 return Err(NotLowerable::at(at, why));
889 }
890 }
891 // `class:active={on}` is a class the element carries when `on` holds —
892 // which is a rendering, not a behaviour.
893 Attr::Class { .. } => {}
894 Attr::Directive { .. } => {
895 return Err(NotLowerable::at(
896 at,
897 "its template uses a directive, which is browser behaviour and \
898 has no server-rendered form",
899 ));
900 }
901 }
902 }
903 view_html_lowerable_seen(children, all, interner, seen)?;
904 }
905 ViewNode::Text { .. } => {}
906 // Control flow renders on a server exactly as it does in a browser: the same tree,
907 // a different destination.
908 ViewNode::If {
909 arms, otherwise, ..
910 } => {
911 for (_, body) in arms {
912 view_html_lowerable_seen(body, all, interner, seen)?;
913 }
914 if let Some(o) = otherwise {
915 view_html_lowerable_seen(o, all, interner, seen)?;
916 }
917 }
918 ViewNode::Each { body, empty, .. } => {
919 view_html_lowerable_seen(body, all, interner, seen)?;
920 view_html_lowerable_seen(empty, all, interner, seen)?;
921 }
922 ViewNode::Let { .. } => {}
923 // Served markup is inert until hydration attaches the listeners, so a handler
924 // contributes nothing to the string.
925 ViewNode::Handler { .. } => {}
926 // `{inspect}` is `{if}` with a shape to match on.
927 ViewNode::Inspect { arms, .. } => {
928 for arm in arms {
929 view_html_lowerable_seen(&arm.body, all, interner, seen)?;
930 }
931 }
932 // A placed child lowers to a call to its own `__html_<Name>`, as the browser tier
933 // calls its `__render_<Name>`. A wrapper's children belong to the caller: they are
934 // walked in the caller's context here and rendered in the caller's scope.
935 ViewNode::Children { .. } => {}
936 ViewNode::Component { name, children, .. } => {
937 view_html_lowerable_seen(children, all, interner, seen)?;
938 let Some(child) = all.iter().find(|d| d.name == *name) else {
939 return Err(NotLowerable::at(
940 at,
941 format!(
942 "its template places `<{}/>`, which this renderer cannot see, so whether \
943 it can be server-rendered is unknown",
944 interner.resolve(*name)
945 ),
946 ));
947 };
948 // A component may place itself (a tree, a thread, a nested menu). A component
949 // already on the path answers "can it be server-rendered" the same way the second
950 // time, so it is not walked again and the walk terminates on any cycle.
951 let repeat = seen.contains(name);
952 if !repeat {
953 seen.push(*name);
954 }
955 match child.view.as_ref() {
956 Some(_) if repeat => {}
957 Some(v) => view_html_lowerable_seen(&v.nodes, all, interner, seen)?,
958 None => {
959 return Err(NotLowerable::at(
960 at,
961 format!(
962 "its template places `<{}/>`, which has no `View:` to render",
963 interner.resolve(*name)
964 ),
965 ))
966 }
967 }
968 }
969 other => {
970 return Err(NotLowerable::at(
971 at,
972 format!(
973 "its template uses {}, which the server-side renderer does not cover yet \
974 (elements, text, holes, `{{if}}`, `{{inspect}}`, `{{for each}}`, `{{let}}`, a \
975 placed component, `bind`, a `class:` toggle and an event handler all render \
976 today)",
977 other.construct()
978 ),
979 ))
980 }
981 }
982 }
983 Ok(())
984}
985
986/// Does this view have a `{children}` hole anywhere in it — is this component a WRAPPER?
987pub(crate) fn view_takes_children(nodes: &[ViewNode<'_>]) -> bool {
988 // A slot can sit inside another placement — `View: <Box>{children}</Box>` passes this
989 // component's own children straight through to the one it wraps — which `view_subtrees`
990 // walks like any other nesting.
991 nodes.iter().any(|n| {
992 matches!(n, ViewNode::Children { .. })
993 || view_subtrees(n).into_iter().any(view_takes_children)
994 })
995}
996
997/// Why a `## Route` cannot be lowered to a plain function yet.
998///
999/// A route body IS a function body, so the ones this understands need nothing new from any tier.
1000/// The rest are refused BY NAME rather than lowered into something that half-works: a route that
1001/// answers the wrong thing is worse than one that will not start.
1002/// The types whose LOGOS display IS valid JSON with no quoting — a number, a boolean.
1003///
1004/// ONE definition, read by the record derive and by the router. A route answering an `Int` and a
1005/// record FIELD holding one have to agree about what reaches the wire, and two lists drift the
1006/// first time a numeric type is added.
1007pub fn json_bare_scalar_name(n: &str) -> bool {
1008 n.eq_ignore_ascii_case("Int")
1009 || n.eq_ignore_ascii_case("Nat")
1010 || n.eq_ignore_ascii_case("Bool")
1011 || n.eq_ignore_ascii_case("Real")
1012 || n.eq_ignore_ascii_case("Float")
1013}
1014
1015/// A route's own heading, as its author wrote it — the one place a route names itself in a
1016/// refusal, called by both the tree-walker and the VM compiler so the wording cannot diverge.
1017pub fn route_heading(method: Symbol, path: Symbol, interner: &Interner) -> String {
1018 format!(
1019 "## Route {} {}",
1020 interner.resolve(method),
1021 interner.resolve(path)
1022 )
1023}
1024
1025/// How an LGX construct is named in a refusal: by the header its author wrote.
1026///
1027/// A page's component is interned under a lowered name (`__page__about`) that appears nowhere in
1028/// the file, so a page is named by its `## Page` header, an error page by its status, and a
1029/// component by its declaration — called by both the tree-walker and the VM compiler so the
1030/// wording cannot diverge between them.
1031pub fn construct_heading(def: &ComponentDef<'_>, interner: &Interner) -> String {
1032 match &def.kind {
1033 ComponentKind::Page { path, .. } => format!("## Page {}", interner.resolve(*path)),
1034 ComponentKind::ErrorPage { status } => format!("## Error page {status}"),
1035 ComponentKind::Store { .. } => format!("## Store {}", interner.resolve(def.name)),
1036 ComponentKind::Component => format!("## Component {}", interner.resolve(def.name)),
1037 }
1038}
1039
1040/// A `-> View` function whose body is a template — `## To badge (label: Text) -> View:` with markup
1041/// under it — as its name, its parameters and its view. The one reading of that shape: the served
1042/// twin (`lower_view_function_twins`), the server roots a template places, and the browser tier's own
1043/// lowering of a view function all ask it here.
1044pub(crate) fn view_template<'s, 'a>(
1045 stmt: &'s Stmt<'a>,
1046) -> Option<(
1047 Symbol,
1048 &'s [(Symbol, &'a TypeExpr<'a>)],
1049 &'a crate::ast::view::ViewDef<'a>,
1050)> {
1051 let Stmt::FunctionDef {
1052 name, params, body, ..
1053 } = stmt
1054 else {
1055 return None;
1056 };
1057 let body: &'a [Stmt<'a>] = body;
1058 match body {
1059 [Stmt::Return {
1060 value: Some(Expr::View(view)),
1061 }] => Some((*name, params.as_slice(), &**view)),
1062 _ => None,
1063 }
1064}
1065
1066/// Every `## Route` this program cannot lower, and why — computed ONCE where the whole program is
1067/// visible, for the tiers whose per-statement walk cannot see it.
1068///
1069/// A route's lowerability depends on the COMPONENTS beside it (a `-> View` template may place
1070/// one), so it is not a property of the `Stmt::RouteDef` alone. The VM's statement walk and the
1071/// AOT emitter each reach a route with no program in hand; asking there with an empty component
1072/// list refuses every route that places a component, which is a loud wrong answer where the parser
1073/// already knew the right one. Both read this instead.
1074pub fn route_refusals(
1075 stmts: &[Stmt<'_>],
1076 interner: &Interner,
1077 is_record: &dyn Fn(Symbol) -> bool,
1078) -> Vec<(Symbol, Symbol, NotLowerable)> {
1079 let defs: Vec<ComponentDef<'_>> = stmts
1080 .iter()
1081 .filter_map(|s| match s {
1082 Stmt::ComponentDef(d) => Some((**d).clone()),
1083 _ => None,
1084 })
1085 .collect();
1086 let mut out = Vec::new();
1087 for s in stmts {
1088 let Stmt::RouteDef {
1089 method,
1090 path,
1091 params,
1092 return_type,
1093 body,
1094 span,
1095 } = s
1096 else {
1097 continue;
1098 };
1099 if let Err(why) = route_lowerable(
1100 params,
1101 *return_type,
1102 body,
1103 interner,
1104 is_record,
1105 &defs,
1106 *span,
1107 ) {
1108 out.push((*method, *path, why));
1109 }
1110 }
1111 out
1112}
1113
1114pub fn route_lowerable(
1115 params: &[(Symbol, &TypeExpr<'_>)],
1116 return_type: Option<&TypeExpr<'_>>,
1117 body: &RouteBody<'_>,
1118 interner: &Interner,
1119 is_record: &dyn Fn(Symbol) -> bool,
1120 components: &[ComponentDef<'_>],
1121 // Where the route was declared. A route is not a node, so there is nothing to walk for a
1122 // location — the caller holds the only one, and passing it is what keeps these two refusals
1123 // from being the pair that still reports at the top of the file.
1124 at: Span,
1125) -> Result<(), NotLowerable> {
1126 if let RouteBody::View(view) = body {
1127 // A template body renders to HTML TEXT rather than to DOM nodes — the same tree, a
1128 // different destination. Only the node kinds this walk covers may lower; the rest are
1129 // refused by name rather than rendered as a page with a piece missing.
1130 return view_html_lowerable_with(&view.nodes, components, interner);
1131 }
1132 for (_, ty) in params {
1133 // ★ `Request` IS NOT PARSED FROM ANYWHERE — it is the request itself, handed over whole.
1134 // The other two are read out of the path, the query or the body by name.
1135 let ok = matches!(ty, TypeExpr::Primitive(s) | TypeExpr::Named(s)
1136 if interner.resolve(*s).eq_ignore_ascii_case("Text")
1137 || interner.resolve(*s).eq_ignore_ascii_case("Int")
1138 || interner.resolve(*s).eq_ignore_ascii_case("Request"));
1139 if !ok {
1140 return Err(NotLowerable::at(
1141 at,
1142 "one of its parameters is a type the path matcher cannot bind yet (Text, Int and \
1143 Request bind today)",
1144 ));
1145 }
1146 }
1147 // Text answers verbatim; a RECORD answers as JSON through its derived encoder; a `Seq of
1148 // <record>` answers as a JSON ARRAY through the list encoder emitted beside it — which is what
1149 // a collection endpoint always is, and the plan's own headline example. Anything else — an
1150 // Option, an enum — has no encoder yet, and answering something plausible would be worse than
1151 // saying so.
1152 match return_type {
1153 Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym))
1154 if interner.resolve(*sym).eq_ignore_ascii_case("Text") =>
1155 {
1156 Ok(())
1157 }
1158 // A view answers as the markup it renders (`lower_view_function_twins`).
1159 Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym))
1160 if interner.resolve(*sym) == "View" =>
1161 {
1162 Ok(())
1163 }
1164 Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym)) if is_record(*sym) => Ok(()),
1165 // A number or a boolean IS valid JSON printed bare — `42`, `true` — so a count endpoint,
1166 // a health check or an id lookup answers without a record to wrap it in.
1167 Some(TypeExpr::Primitive(sym) | TypeExpr::Named(sym))
1168 if json_bare_scalar_name(interner.resolve(*sym)) =>
1169 {
1170 Ok(())
1171 }
1172 Some(TypeExpr::Generic { base, params })
1173 if interner.resolve(*base).eq_ignore_ascii_case("Seq")
1174 && params.len() == 1
1175 && matches!(¶ms[0], TypeExpr::Primitive(s) | TypeExpr::Named(s) if is_record(*s)) =>
1176 {
1177 Ok(())
1178 }
1179 _ => Err(NotLowerable::at(
1180 at,
1181 "it answers with a type the derived JSON codecs do not cover yet (Text, a View, a \
1182 record, and a Seq of records answer today)",
1183 )),
1184 }
1185}
1186
1187/// Whether some `## Store` has an action — something that can commit a store while a turn runs,
1188/// which is what `__LGX_STORES_DIRTY` tracks.
1189pub(crate) fn commits_stores(all: &[ComponentDef<'_>]) -> bool {
1190 all.iter()
1191 .any(|d| matches!(d.kind, ComponentKind::Store { .. }) && !d.actions.is_empty())
1192}
1193
1194pub fn stateful(def: &ComponentDef<'_>) -> bool {
1195 !def.actions.is_empty()
1196 || !def.events.is_empty()
1197 || def.view.as_ref().is_some_and(|v| has_bind(&v.nodes))
1198}
1199
1200pub(crate) fn has_bind(nodes: &[ViewNode<'_>]) -> bool {
1201 nodes.iter().any(|node| {
1202 matches!(node, ViewNode::Element { attrs, .. } if attrs.iter().any(|a| matches!(a, Attr::Bind { .. })))
1203 || view_subtrees(node).into_iter().any(has_bind)
1204 })
1205}
1206
1207/// Whether this view registers ANY listener (`register_listener_with`'s own gate, read
1208/// backwards): a `bind={field}` (`ListenerSite::Bind`/`DefaultToggle`) or a real `ViewNode::
1209/// Handler` (`on-click={…}`/`on click:` — `ListenerSite::Action`, already its own view-tree node
1210/// by the time this runs, per the parser's own desugaring). A component with neither pays nothing
1211/// for `__LISTENER_SITES_<C>`'s ledger or the retire/write-back blocks `lower_component_render`
1212/// would otherwise emit into every render unconditionally — the bundle-size cost
1213/// `bundle_size_lock` measures on a page that places components which never listen for anything.
1214pub(crate) fn has_listener_site(nodes: &[ViewNode<'_>]) -> bool {
1215 nodes.iter().any(|node| {
1216 let here = match node {
1217 ViewNode::Element { attrs, .. } => attrs.iter().any(|a| matches!(a, Attr::Bind { .. })),
1218 ViewNode::Handler { .. } => true,
1219 _ => false,
1220 };
1221 here || view_subtrees(node).into_iter().any(has_listener_site)
1222 })
1223}
1224
1225/// Every node list nested directly under `node` that is drawn in the SAME render function as
1226/// `node` itself — an element's children, each arm of an `{if}`/`{inspect}`, a loop's body and its
1227/// empty branch, a block's default, and a placement's own children (which `slot_argument` renders
1228/// inline, in the placing component's frame). Exhaustive over `ViewNode`, so a walker built on it
1229/// cannot miss a nesting construct the way an enumerated `_ => false` does.
1230pub(crate) fn view_subtrees<'n, 'a>(node: &'n ViewNode<'a>) -> Vec<&'n [ViewNode<'a>]> {
1231 match node {
1232 ViewNode::Element { children, .. } | ViewNode::Component { children, .. } => vec![children],
1233 ViewNode::Each { body, empty, .. } => vec![body, empty],
1234 ViewNode::If {
1235 arms, otherwise, ..
1236 } => {
1237 let mut out: Vec<&'n [ViewNode<'a>]> = arms.iter().map(|(_, b)| &b[..]).collect();
1238 if let Some(o) = otherwise {
1239 out.push(o);
1240 }
1241 out
1242 }
1243 ViewNode::Inspect { arms, .. } => arms.iter().map(|a| &a.body[..]).collect(),
1244 ViewNode::Block { default, .. } => default.iter().map(|d| &d[..]).collect(),
1245 ViewNode::Text { .. }
1246 | ViewNode::Handler { .. }
1247 | ViewNode::Raw { .. }
1248 | ViewNode::Let { .. }
1249 | ViewNode::Extends { .. }
1250 | ViewNode::Children { .. } => Vec::new(),
1251 }
1252}
1253
1254/// The nodes `def`'s own render function draws: its view. A layout it `{extends}` is an instance
1255/// drawn by its own render function, as a placed child is.
1256pub(crate) fn drawn_view<'n, 'a>(def: &'n ComponentDef<'a>) -> &'n [ViewNode<'a>] {
1257 def.view.as_ref().map_or(&[][..], |v| &v.nodes[..])
1258}
1259
1260/// The `{block <name>}` slots `def`'s view declares, in document order, once each — the block
1261/// parameters its render function takes. A view that `{extends}` a layout declares none: its
1262/// `{block name:}` bodies FILL the layout's slots.
1263pub(crate) fn slot_blocks(def: &ComponentDef<'_>) -> Vec<Symbol> {
1264 fn walk(nodes: &[ViewNode<'_>], out: &mut Vec<Symbol>) {
1265 for node in nodes {
1266 if let ViewNode::Block { name, .. } = node {
1267 if !out.contains(name) {
1268 out.push(*name);
1269 }
1270 }
1271 for sub in view_subtrees(node) {
1272 walk(sub, out);
1273 }
1274 }
1275 }
1276 let Some(view) = &def.view else {
1277 return Vec::new();
1278 };
1279 if super::render::extended_layout(&view.nodes).is_some() {
1280 return Vec::new();
1281 }
1282 let mut out = Vec::new();
1283 walk(&view.nodes, &mut out);
1284 out
1285}
1286
1287/// Whether this component registers ANY listener at all — `has_listener_site` over its view, OR a
1288/// component-level `When … :` handler (`def.events`: a keyed selector, a window event, a message
1289/// subscription, a key press). The latter registers through the exact same `ListenerSite::Action`
1290/// path but OUTSIDE the view tree entirely (there is no `ViewNode` for it), so a component whose
1291/// only listener is one of these was missed by `has_listener_site` alone — the ledger it feeds was
1292/// gated off while the registration it was built to retire still ran, an undeclared-local wasm trap
1293/// waiting to happen the day such a component also got no view-level listener.
1294pub(crate) fn has_any_listener_site(def: &ComponentDef<'_>) -> bool {
1295 has_listener_site(drawn_view(def)) || !def.events.is_empty()
1296}
1297
1298/// Every OWN prop this component's view calls directly as an event handler
1299/// (`on-click={onGo}`, `docs/frontend.md`'s "Passing actions down" pattern) — the shape whose
1300/// TARGET varies by placement and needs its own per-instance identity record, since a closure
1301/// cannot survive in a map the way its identity's two scalar parts can
1302/// (`events.rs::ListenerSite::CallbackProp`'s own doc). Deduplicated: two elements calling the
1303/// same prop share one set of bindings (`events.rs::callback_prop_declarations` declares one).
1304pub(crate) fn callback_prop_targets(def: &ComponentDef<'_>) -> Vec<Symbol> {
1305 let mut out = Vec::new();
1306 if let Some(view) = &def.view {
1307 walk_callback_prop_targets(&view.nodes, def, &mut out);
1308 }
1309 out
1310}
1311
1312fn walk_callback_prop_targets(
1313 nodes: &[ViewNode<'_>],
1314 def: &ComponentDef<'_>,
1315 out: &mut Vec<Symbol>,
1316) {
1317 // A callback can also arrive through a placement's own children, which `slot_argument` lowers
1318 // inline into THIS render — `view_subtrees` walks them with every other nesting.
1319 for node in nodes {
1320 if let ViewNode::Handler { body, .. } = node {
1321 if let Some(Stmt::Call { function, .. }) = body.first() {
1322 if def.props.iter().any(|(p, _)| p == function) && !out.contains(function) {
1323 out.push(*function);
1324 }
1325 }
1326 }
1327 for sub in view_subtrees(node) {
1328 walk_callback_prop_targets(sub, def, out);
1329 }
1330 }
1331}
1332
1333/// Whether a prop's declared type makes it a CALLBACK: `fn(…) -> …`, or the `Action` spelling the
1334/// resolver accepts for the same shape (`lgx_resolve`'s `named_shape`).
1335pub(crate) fn is_callback_type(ty: &TypeExpr<'_>, interner: &Interner) -> bool {
1336 match ty {
1337 TypeExpr::Function { .. } => true,
1338 TypeExpr::Named(s) | TypeExpr::Primitive(s) => interner.resolve(*s) == "Action",
1339 _ => false,
1340 }
1341}
1342
1343/// Every callback-typed prop `def` declares, in declaration order.
1344pub(crate) fn callback_props<'a>(
1345 def: &ComponentDef<'a>,
1346 interner: &Interner,
1347) -> Vec<(Symbol, &'a TypeExpr<'a>)> {
1348 def.props
1349 .iter()
1350 .copied()
1351 .filter(|(_, t)| is_callback_type(t, interner))
1352 .collect()
1353}
1354
1355/// Every `<Child p={v}/>` in `nodes`, however deeply nested — a placement's own children, a loop's
1356/// body and its `{else:}`, every `{if}` arm, every `{inspect}` arm, a block's default body — with
1357/// the name it places, the props it passes, and where each prop was written.
1358pub(crate) fn for_each_placement<'v, 'a>(
1359 nodes: &'v [ViewNode<'a>],
1360 f: &mut dyn FnMut(Symbol, &'v [(Symbol, &'a Expr<'a>)], &'v [Span], Span),
1361) {
1362 for node in nodes {
1363 match node {
1364 ViewNode::Component {
1365 name,
1366 props,
1367 prop_spans,
1368 children,
1369 span,
1370 } => {
1371 f(*name, props, prop_spans, *span);
1372 for_each_placement(children, f);
1373 }
1374 ViewNode::Element { children, .. } => for_each_placement(children, f),
1375 ViewNode::Each { body, empty, .. } => {
1376 for_each_placement(body, f);
1377 for_each_placement(empty, f);
1378 }
1379 ViewNode::If {
1380 arms, otherwise, ..
1381 } => {
1382 for (_, b) in arms {
1383 for_each_placement(b, f);
1384 }
1385 if let Some(o) = otherwise {
1386 for_each_placement(o, f);
1387 }
1388 }
1389 ViewNode::Inspect { arms, .. } => {
1390 for a in arms {
1391 for_each_placement(&a.body, f);
1392 }
1393 }
1394 ViewNode::Block {
1395 default: Some(d), ..
1396 } => for_each_placement(d, f),
1397 _ => {}
1398 }
1399 }
1400}
1401
1402/// What a callback prop value `value`, written in `placer`'s view, hands down: one of `placer`'s own
1403/// actions, or one of `placer`'s own callback props forwarded unchanged. Anything else carries no
1404/// identity the lowered tier can resolve.
1405pub(crate) enum CallbackSource<'a> {
1406 Action(&'a crate::ast::component::ActionDef<'a>),
1407 Forwarded(Symbol),
1408}
1409
1410pub(crate) fn callback_source<'d, 'a>(
1411 value: &Expr<'a>,
1412 placer: &'d ComponentDef<'a>,
1413 interner: &Interner,
1414) -> Option<CallbackSource<'d>>
1415where
1416 'a: 'd,
1417{
1418 let Expr::Identifier(sym) = value else {
1419 return None;
1420 };
1421 if let Some(action) = placer.actions.iter().find(|a| a.name == *sym) {
1422 return Some(CallbackSource::Action(action));
1423 }
1424 if placer
1425 .props
1426 .iter()
1427 .any(|(p, t)| p == sym && is_callback_type(t, interner))
1428 {
1429 return Some(CallbackSource::Forwarded(*sym));
1430 }
1431 None
1432}
1433
1434/// The parameter types `component`'s callback prop `prop` is called with.
1435///
1436/// A `fn(I…) -> O` prop answers its own `I…`. An `Action` prop names no parameters, so its
1437/// signature is read from what the program hands it: the parameters of the action bound at a
1438/// placement, or — for a value forwarded from the placer's own callback prop — that prop's
1439/// signature. `None` only for an `Action` prop no placement binds to anything.
1440pub(crate) fn callback_signature<'a>(
1441 all: &[ComponentDef<'a>],
1442 component: Symbol,
1443 prop: Symbol,
1444 interner: &Interner,
1445) -> Option<Vec<&'a TypeExpr<'a>>> {
1446 fn go<'a>(
1447 all: &[ComponentDef<'a>],
1448 component: Symbol,
1449 prop: Symbol,
1450 interner: &Interner,
1451 visiting: &mut Vec<(Symbol, Symbol)>,
1452 ) -> Option<Vec<&'a TypeExpr<'a>>> {
1453 let def = all.iter().find(|d| d.name == component)?;
1454 let (_, ty) = def.props.iter().find(|(p, _)| *p == prop)?;
1455 if let TypeExpr::Function { inputs, .. } = ty {
1456 return Some(inputs.iter().collect());
1457 }
1458 if visiting.contains(&(component, prop)) {
1459 return None;
1460 }
1461 visiting.push((component, prop));
1462 for placer in all {
1463 let Some(view) = &placer.view else { continue };
1464 let mut bound: Vec<CallbackSourceOwned> = Vec::new();
1465 for_each_placement(&view.nodes, &mut |name, props, _, _| {
1466 if name != component {
1467 return;
1468 }
1469 let Some((_, value)) = props.iter().find(|(p, _)| *p == prop) else {
1470 return;
1471 };
1472 match callback_source(value, placer, interner) {
1473 Some(CallbackSource::Action(a)) => {
1474 bound.push(CallbackSourceOwned::Action(a.name))
1475 }
1476 Some(CallbackSource::Forwarded(p)) => {
1477 bound.push(CallbackSourceOwned::Forwarded(p))
1478 }
1479 None => {}
1480 }
1481 });
1482 for source in bound {
1483 let found = match source {
1484 CallbackSourceOwned::Action(a) => placer
1485 .actions
1486 .iter()
1487 .find(|x| x.name == a)
1488 .map(|x| x.params.iter().map(|(_, t)| *t).collect()),
1489 CallbackSourceOwned::Forwarded(p) => {
1490 go(all, placer.name, p, interner, visiting)
1491 }
1492 };
1493 if found.is_some() {
1494 return found;
1495 }
1496 }
1497 }
1498 None
1499 }
1500 enum CallbackSourceOwned {
1501 Action(Symbol),
1502 Forwarded(Symbol),
1503 }
1504 go(all, component, prop, interner, &mut Vec::new())
1505}
1506
1507/// Whether two parameter types are the same shape — the question the candidate table asks of every
1508/// action against a callback's signature.
1509pub(crate) fn param_shapes_match(a: &TypeExpr<'_>, b: &TypeExpr<'_>) -> bool {
1510 match (a, b) {
1511 (
1512 TypeExpr::Primitive(x) | TypeExpr::Named(x),
1513 TypeExpr::Primitive(y) | TypeExpr::Named(y),
1514 ) => x == y,
1515 (
1516 TypeExpr::Generic {
1517 base: b1,
1518 params: p1,
1519 },
1520 TypeExpr::Generic {
1521 base: b2,
1522 params: p2,
1523 },
1524 ) => {
1525 b1 == b2
1526 && p1.len() == p2.len()
1527 && p1
1528 .iter()
1529 .zip(p2.iter())
1530 .all(|(x, y)| param_shapes_match(x, y))
1531 }
1532 (TypeExpr::Tuple(a1), TypeExpr::Tuple(a2)) => {
1533 a1.len() == a2.len()
1534 && a1
1535 .iter()
1536 .zip(a2.iter())
1537 .all(|(x, y)| param_shapes_match(x, y))
1538 }
1539 (
1540 TypeExpr::Function {
1541 inputs: i1,
1542 output: o1,
1543 },
1544 TypeExpr::Function {
1545 inputs: i2,
1546 output: o2,
1547 },
1548 ) => {
1549 i1.len() == i2.len()
1550 && i1
1551 .iter()
1552 .zip(i2.iter())
1553 .all(|(x, y)| param_shapes_match(x, y))
1554 && param_shapes_match(o1, o2)
1555 }
1556 (TypeExpr::Mutable { inner: x }, y) | (y, TypeExpr::Mutable { inner: x }) => {
1557 param_shapes_match(x, y)
1558 }
1559 _ => false,
1560 }
1561}
1562
1563/// Why `def`'s view hands a callback prop something the lowered tier cannot resolve, or `None`.
1564///
1565/// A browser calls the closure a render attached; a driving verb, a repaint and a turn cannot, so
1566/// the lowering records which action a callback means — an identity — at the placement. That
1567/// identity exists for exactly two values: one of the placer's own actions, whose parameters match
1568/// the callback's signature, or one of its own callback props forwarded unchanged. Any other value
1569/// is refused here rather than lowered into a page whose handler answers nothing.
1570pub(crate) fn callback_binding_refusal(
1571 def: &ComponentDef<'_>,
1572 all: &[ComponentDef<'_>],
1573 interner: &Interner,
1574) -> Option<NotLowerable> {
1575 let view = def.view.as_ref()?;
1576 let mut refusal: Option<NotLowerable> = None;
1577 for_each_placement(&view.nodes, &mut |name, props, spans, span| {
1578 if refusal.is_some() {
1579 return;
1580 }
1581 let Some(child) = all.iter().find(|d| d.name == name) else {
1582 return;
1583 };
1584 for (i, (prop, value)) in props.iter().enumerate() {
1585 let Some((_, ty)) = child.props.iter().find(|(p, _)| p == prop) else {
1586 continue;
1587 };
1588 if !is_callback_type(ty, interner) {
1589 continue;
1590 }
1591 let at = spans.get(i).copied().unwrap_or(span);
1592 let tag = interner.resolve(name);
1593 let p = interner.resolve(*prop);
1594 match callback_source(value, def, interner) {
1595 None => {
1596 refusal = Some(NotLowerable::at(
1597 at,
1598 format!(
1599 "`{p}` of `<{tag}>` is a callback, and this value is neither one of this \
1600 component's actions nor a callback prop it was handed — pass one by name: \
1601 `{p}={{actionName}}`"
1602 ),
1603 ));
1604 return;
1605 }
1606 Some(CallbackSource::Action(action)) => {
1607 let Some(sig) = callback_signature(all, name, *prop, interner) else {
1608 continue;
1609 };
1610 let fits = sig.len() == action.params.len()
1611 && sig
1612 .iter()
1613 .zip(action.params.iter())
1614 .all(|(s, (_, t))| param_shapes_match(s, t));
1615 if !fits {
1616 refusal = Some(NotLowerable::at(at, format!(
1617 "`{p}` of `<{tag}>` is called with one parameter shape, and `{a}` takes \
1618 another; every action handed to one callback must take the same \
1619 parameters",
1620 a = interner.resolve(action.name)
1621 )));
1622 return;
1623 }
1624 }
1625 Some(CallbackSource::Forwarded(_)) => {}
1626 }
1627 }
1628 });
1629 refusal
1630}
1631
1632/// Every action of `def` that `stmts` call by bare name (`Call bump.`), at any depth.
1633pub(crate) fn own_action_calls(stmts: &[Stmt<'_>], def: &ComponentDef<'_>, out: &mut Vec<Symbol>) {
1634 for stmt in stmts {
1635 match stmt {
1636 Stmt::Call { function, .. } => {
1637 if def.actions.iter().any(|a| a.name == *function) && !out.contains(function) {
1638 out.push(*function);
1639 }
1640 }
1641 Stmt::If {
1642 then_block,
1643 else_block,
1644 ..
1645 } => {
1646 own_action_calls(then_block, def, out);
1647 if let Some(e) = else_block {
1648 own_action_calls(e, def, out);
1649 }
1650 }
1651 Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Splice { body } => {
1652 own_action_calls(body, def, out)
1653 }
1654 Stmt::Inspect { arms, .. } => {
1655 for arm in arms {
1656 own_action_calls(arm.body, def, out);
1657 }
1658 }
1659 _ => {}
1660 }
1661 }
1662}
1663
1664/// An action of `def` that reaches ITSELF through bare `Call <sibling>.` statements, or `None`.
1665///
1666/// The lowered tier runs a sibling call nested in the caller's turn by inlining the sibling's body
1667/// (`Parser::inline_own_action_call`), and a circle of siblings has no finite inlining, so the
1668/// component is refused by name.
1669pub(crate) fn own_action_cycle<'d>(
1670 def: &'d ComponentDef<'_>,
1671) -> Option<&'d crate::ast::component::ActionDef<'d>> {
1672 fn reaches(
1673 def: &ComponentDef<'_>,
1674 from: Symbol,
1675 target: Symbol,
1676 seen: &mut Vec<Symbol>,
1677 ) -> bool {
1678 let Some(action) = def.actions.iter().find(|a| a.name == from) else {
1679 return false;
1680 };
1681 let mut callees = Vec::new();
1682 own_action_calls(action.body, def, &mut callees);
1683 for callee in callees {
1684 if callee == target {
1685 return true;
1686 }
1687 if !seen.contains(&callee) {
1688 seen.push(callee);
1689 if reaches(def, callee, target, seen) {
1690 return true;
1691 }
1692 }
1693 }
1694 false
1695 }
1696 def.actions
1697 .iter()
1698 .find(|a| reaches(def, a.name, a.name, &mut Vec::new()))
1699}
1700
1701thread_local! {
1702 /// The components whose lowerability is being decided right now — see [`lowerable`].
1703 static CHECKING: std::cell::RefCell<Vec<Symbol>> = const { std::cell::RefCell::new(Vec::new()) };
1704}
1705
1706/// Leaves the in-progress set however [`lowerable`] returns, `?` included.
1707pub(crate) struct CheckingGuard;
1708
1709impl Drop for CheckingGuard {
1710 fn drop(&mut self) {
1711 CHECKING.with(|c| {
1712 c.borrow_mut().pop();
1713 });
1714 }
1715}
1716
1717/// Whether this component's view is expressible as plain DOM calls.
1718///
1719/// `types` is the file's type registry: whether a persisted store's record field, or a typed
1720/// request's `as <T>`, goes through the derived JSON codec is the codec's own question
1721/// (`JsonShapes`), asked here of the registry the lowering emits the codec from.
1722pub fn lowerable<'a>(
1723 def: &ComponentDef<'a>,
1724 all: &[ComponentDef<'a>],
1725 types: Option<&crate::analysis::registry::TypeRegistry>,
1726 interner: &Interner,
1727) -> Result<(), NotLowerable> {
1728 // A component may place itself (`nodes_lowerable` meets `<Tree/>` and asks `lowerable`
1729 // again). "Can this component be lowered" has one answer per component, so a component
1730 // already on the path contributes nothing the second time; every other check runs before the
1731 // descent, so every other reason to refuse is still reached.
1732 if CHECKING.with(|c| c.borrow().contains(&def.name)) {
1733 return Ok(());
1734 }
1735 CHECKING.with(|c| c.borrow_mut().push(def.name));
1736 let _guard = CheckingGuard;
1737 if !matches!(
1738 def.kind,
1739 ComponentKind::Component | ComponentKind::Page { .. }
1740 ) {
1741 return Err(NotLowerable::at(
1742 def.span,
1743 "a store or error page is not a mountable view",
1744 ));
1745 }
1746 let shapes = JsonShapes::new(types, interner);
1747 for used in &def.uses {
1748 match all.iter().find(|d| d.name == *used) {
1749 Some(store) => {
1750 if let Some(why) = store_not_lowerable(store, &shapes) {
1751 return Err(NotLowerable::at(store.span, why));
1752 }
1753 }
1754 None => {
1755 return Err(NotLowerable::at(
1756 def.span,
1757 format!(
1758 "it uses `{0}`, and this file declares no `## Store {0}`",
1759 interner.resolve(*used)
1760 ),
1761 ))
1762 }
1763 }
1764 }
1765 // Invalid CSS is a refusal, pointed at the character that broke it: the render lowering
1766 // emits the `<style>` element only when the stylesheet scopes, so lowering past an invalid
1767 // one would render the page unstyled.
1768 if let Some(style) = &def.style {
1769 let class = scope_class(interner.resolve(def.name));
1770 let css = interner.resolve(style.css);
1771 if let Err(e) = crate::lgx::css::scope(css, &class) {
1772 let at = style.file_offset(css, e.at);
1773 return Err(NotLowerable::at(
1774 Span::new(at, at + 1),
1775 format!(
1776 "its `Style:` block is not valid CSS, so the stylesheet would be dropped and the \
1777 page would render unstyled. {}",
1778 e.message()
1779 ),
1780 ));
1781 }
1782 }
1783 // A stateful component's triggers must each have a browser delivery. The allowlist below is
1784 // every trigger that does, with the host surface it uses; a trigger added to the language
1785 // without a delivery fails closed here, refused by its written form (`Trigger::form`).
1786 if stateful(def) {
1787 for handler in &def.events {
1788 if !matches!(
1789 handler.trigger,
1790 Trigger::Selector { .. }
1791 | Trigger::PageLoads
1792 | Trigger::Appears
1793 // A turn knows what it wrote, so the watched field is compared before and
1794 // after (`turn_body`).
1795 | Trigger::FieldChanges { .. }
1796 // `onRoute(handler)`, a popstate listener in `runtime.js`.
1797 | Trigger::RouteChanges
1798 // Registered on the page (`lower_component_render`) through `body`,
1799 // `addEventListener` and `eventKey`.
1800 | Trigger::Key { .. }
1801 // A typed fetch's failure paths call the handler with the url and the reason.
1802 | Trigger::FetchFails { .. }
1803 // A paint stamps every instance it draws with the pass number; the sweep reads
1804 // which ones it did not draw.
1805 | Trigger::Disappears
1806 // One import (`dom_on_window_event`) for all three; `Raise "offline".` causes
1807 // one where there is no window.
1808 | Trigger::Online
1809 | Trigger::Offline
1810 | Trigger::WindowResizes
1811 // One geometry read (`dom_scroll_gap`); the threshold is compiled LOGOS.
1812 | Trigger::ScrollsToBottom { .. }
1813 // One import (`dom_set_interval`) for the page's clock; which timer is due is
1814 // compiled LOGOS, so `Advance time by …` runs the same arithmetic.
1815 | Trigger::Every { .. }
1816 | Trigger::After { .. }
1817 // A trap unwinds to the host; the module recorded whose turn it was.
1818 | Trigger::ActionFails { .. }
1819 // One read (`dom_event_file_name`); the handler receives `filedropped`.
1820 | Trigger::FileDropped { .. }
1821 // One import (`dom_on_message`) subscribing through `logicaffeine_system::relay`;
1822 // the topic decision is compiled LOGOS.
1823 | Trigger::Message { .. }
1824 ) {
1825 return Err(NotLowerable::at(
1826 handler.span,
1827 format!(
1828 "its `{}` sentence waits for something this tier cannot deliver yet, so a \
1829 browser would be missing that handler",
1830 handler.trigger.form()
1831 ),
1832 ));
1833 }
1834 }
1835 }
1836 // A typed request whose answer the codec cannot read is refused by its type, rather than
1837 // lowered into a page whose load never happens.
1838 let bodies = def
1839 .actions
1840 .iter()
1841 .map(|a| (a.body, a.span))
1842 .chain(def.events.iter().map(|h| (h.body, h.span)));
1843 for (body, span) in bodies {
1844 if let Some(why) = unreadable_fetch(body, &shapes) {
1845 return Err(NotLowerable::at(span, why));
1846 }
1847 }
1848 let Some(view) = &def.view else {
1849 return Err(NotLowerable::at(def.span, "it has no `View:` to render"));
1850 };
1851 if let Some(why) = callback_binding_refusal(def, all, interner) {
1852 return Err(why);
1853 }
1854 if let Some(action) = own_action_cycle(def) {
1855 return Err(NotLowerable::at(action.span, format!(
1856 "its action `{}` reaches itself through `Call <action>.` statements; a sibling call runs \
1857 nested in the caller's turn, and a circle of them has no end this tier can compile",
1858 interner.resolve(action.name)
1859 )));
1860 }
1861 nodes_lowerable(&view.nodes, all, types, interner)
1862}
1863
1864/// The refusal for the first `Get … as <T>` in `stmts`, at any depth, whose answer the codec
1865/// cannot read ([`JsonShapes::fetch_read`]).
1866fn unreadable_fetch(stmts: &[Stmt<'_>], shapes: &JsonShapes<'_>) -> Option<String> {
1867 let mut refusal = None;
1868 super::json::typed_requests(stmts, &mut |ty| {
1869 if refusal.is_none() {
1870 refusal = shapes.fetch_read(ty).err();
1871 }
1872 });
1873 refusal
1874}
1875
1876pub(crate) fn nodes_lowerable<'a>(
1877 nodes: &[ViewNode<'a>],
1878 all: &[ComponentDef<'a>],
1879 types: Option<&crate::analysis::registry::TypeRegistry>,
1880 interner: &Interner,
1881) -> Result<(), NotLowerable> {
1882 for node in nodes {
1883 let at = node.span();
1884 match node {
1885 ViewNode::Element {
1886 tag,
1887 attrs,
1888 children,
1889 ..
1890 } => {
1891 for attr in attrs {
1892 match attr {
1893 Attr::Plain { .. } => {}
1894 // A `bind` writes `value` or `checked` depending on the element's TYPE,
1895 // and a computed type leaves that undecidable — refused rather than
1896 // guessed, since guessing `value` renders a checkbox the browser draws
1897 // unchecked while the page believes it is checked.
1898 Attr::Bind { .. } => {
1899 // Asked through `BoundInput::refusal`, which is total over the kinds.
1900 if let Some(why) =
1901 crate::ast::view::bound_input_kind(*tag, attrs, interner).refusal()
1902 {
1903 return Err(NotLowerable::at(at, why));
1904 }
1905 }
1906 // A directive runs as a function call against its element; a `When removed:`
1907 // block runs from the sweep that follows the repaint no longer drawing it.
1908 Attr::Class { .. } | Attr::Directive { .. } => {}
1909 }
1910 }
1911 nodes_lowerable(children, all, types, interner)?;
1912 }
1913 ViewNode::Text { .. } => {}
1914 // `{raw h}` drops trusted markup into the containing element: one `setHtml`.
1915 ViewNode::Raw { .. } => {}
1916 ViewNode::If {
1917 arms, otherwise, ..
1918 } => {
1919 for (_, body) in arms {
1920 nodes_lowerable(body, all, types, interner)?;
1921 }
1922 if let Some(o) = otherwise {
1923 nodes_lowerable(o, all, types, interner)?;
1924 }
1925 }
1926 ViewNode::Each { body, empty, .. } => {
1927 nodes_lowerable(body, all, types, interner)?;
1928 nodes_lowerable(empty, all, types, interner)?;
1929 }
1930 ViewNode::Let { .. } => {}
1931 ViewNode::Inspect { arms, .. } => {
1932 for arm in arms {
1933 nodes_lowerable(&arm.body, all, types, interner)?;
1934 }
1935 }
1936 // `{extends L}` draws the layout as an instance of its own — its state, actions and
1937 // registry, keyed like a placement — around the blocks this view draws in its own
1938 // scope, so the layout lowers exactly when a placement of it would. `{extends}` gives
1939 // it no props, so a layout that declares one has nothing to read.
1940 ViewNode::Extends { layout, .. } => match all.iter().find(|d| d.name == *layout) {
1941 Some(l) if !l.props.is_empty() => {
1942 return Err(NotLowerable::at(
1943 at,
1944 format!(
1945 "the layout it extends, `{}`, declares props, and `{{extends}}` has no way \
1946 to give them",
1947 interner.resolve(*layout)
1948 ),
1949 ))
1950 }
1951 Some(l) => lowerable(l, all, types, interner)?,
1952 None => {
1953 return Err(NotLowerable::at(
1954 at,
1955 format!(
1956 "it extends `{0}`, and this file declares no `## Component {0}`",
1957 interner.resolve(*layout)
1958 ),
1959 ))
1960 }
1961 },
1962 ViewNode::Block { default, .. } => {
1963 if let Some(d) = default {
1964 nodes_lowerable(d, all, types, interner)?;
1965 }
1966 }
1967 // Every qualifier arrives classified by the event-modifier catalogue, and every one
1968 // lowers: a key travels in the dispatch's value slot from a driving verb and through
1969 // `eventKey` from a browser; `.once` is compiled LOGOS (a spent record both the
1970 // dispatcher and the browser's listener consult); `.prevent`, `.stop`, `.capture` and
1971 // `.passive` are declared at the registration for the host to dispatch by. System keys
1972 // never reach here: the template parse refuses them.
1973 ViewNode::Handler { .. } => {}
1974 // A child is only renderable here if IT is: a parent cannot lower past a child that
1975 // needs the runtime, or the page would come out missing a piece with nothing said.
1976 ViewNode::Component { name, children, .. } => {
1977 let Some(child) = all.iter().find(|d| d.name == *name) else {
1978 return Err(NotLowerable::at(
1979 at,
1980 format!(
1981 "it places `<{0}/>`, and this file declares no `## Component {0}`",
1982 interner.resolve(*name)
1983 ),
1984 ));
1985 };
1986 lowerable(child, all, types, interner)?;
1987 // A wrapper's children are checked here, in the caller: they are drawn here and
1988 // read the caller's scope. The wrapper itself is an instance with its own frame.
1989 nodes_lowerable(children, all, types, interner)?;
1990 }
1991 ViewNode::Children { .. } => {}
1992 }
1993 }
1994 Ok(())
1995}