validate.rs raw

//! Validation and lowering: component AST → IR.
//!
//! Checks names (duplicates, unknown widgets/properties, unresolved
//! references), enforces the scoping rules (layout properties are static;
//! dotted paths reserved; color-valued expressions built from color
//! literals), and resolves everything the consumers need into plain IR.
//! Deeper type errors (e.g. adding a bool to an int) are left to rustc,
//! which checks the generated code.

use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};

use crate::ast::{Component, Node, TypeKind};
use crate::diagnostics::Diagnostic;
use crate::expr::Expr;
use crate::ir::*;
use crate::registry::{
    self, EventRef, LayoutProp, PropDeclKind, PropTy, Registry, WidgetDescriptor,
};

/// What a name in an expression refers to.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Binding {
    Prop(ValueTy),
    State(ValueTy),
    /// A declared handler (payload types live on the declaration, looked
    /// up by name — they no longer fit a Copy binding).
    Handler,
    Output,
    /// A `for` loop variable: a read-only value of the list's element type.
    Loop(ElemKind),
    /// A `for` `:index` name: a read-only integer.
    LoopIndex,
    /// `event` inside a wire's arguments: a *pseudo*-record, carrying the
    /// shape of the wire it was bound on.
    ///
    /// It has fields and reads like a record, but it is not one — there is no
    /// `(record …)` for it and it never becomes a Rust struct, so it cannot
    /// be an `ElemKind::Record`, which indexes the file's own table.
    Event(EventShape),
}

/// What `event` is, on the wire it was read from.
///
/// The shape rides on the binding rather than there being one flat field list,
/// because the two families genuinely describe different things: a pointer
/// wire has no keystroke and a key wire has no position. One list would have
/// to admit every field on every wire and let the back ends produce a
/// placeholder for the ones that cannot exist — a diagnostic that lies at
/// compile time to avoid failing at run time.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum EventShape {
    /// A pointer wire: where the pointer was, and what the click count is.
    Pointer,
    /// A key wire: which keystroke it was.
    Key,
    /// A scroll wire: where the container is now.
    Scroll,
}

impl EventShape {
    /// The fields this shape offers, and their types.
    ///
    /// Flat, because records are: `event.x`, never `event.pos.x`. Pointer
    /// coordinates are in the widget's *own* space — the frame the wire is
    /// written in, and the one that stays meaningful when the widget moves.
    pub(crate) fn fields(self) -> &'static [(&'static str, TypeKind)] {
        match self {
            Self::Pointer => &[
                ("x", TypeKind::F64),
                ("y", TypeKind::F64),
                ("click-count", TypeKind::I32),
            ],
            // The keystroke's canonical spelling — the same notation an
            // accelerator is declared in, so `"Ctrl+S"` is one string however
            // it is reached.
            Self::Key => &[("key", TypeKind::String)],
            // Named for what they are rather than `x`/`y`: on a pointer wire
            // those mean where the pointer is, and a scroll wire's numbers are
            // a position of a different thing entirely.
            Self::Scroll => &[("offset-x", TypeKind::F64), ("offset-y", TypeKind::F64)],
        }
    }

    fn field(self, name: &str) -> Option<TypeKind> {
        self.fields()
            .iter()
            .find(|(field, _)| *field == name)
            .map(|(_, kind)| *kind)
    }

    /// The field names, for a diagnostic.
    fn field_names(self) -> String {
        self.fields()
            .iter()
            .map(|(f, _)| *f)
            .collect::<Vec<_>>()
            .join(", ")
    }
}

/// The wires that bind `event`, for the diagnostic when one does not.
const EVENT_WIRES: &str = "pointer wires (`:on-click`, `:on-counted-click`, \
                           and the `:on-pointer-…` family) and `:on-key`";

/// Validate and lower one component against `registry`'s widget vocabulary
/// (the builtins, plus whatever the application declared in `.gdw` manifests)
/// and the compiled interfaces of the components it instantiates.
pub fn validate(
    component: &Component,
    components: &BTreeMap<String, Ir>,
    registry: &Registry,
) -> Result<Ir, Vec<Diagnostic>> {
    let mut errors = Vec::new();
    let mut scope: HashMap<String, Binding> = HashMap::new();

    // Records first: types below may reference them.
    let mut records: Vec<IrRecord> = Vec::new();
    for record in &component.records {
        if record.name.name == component.name.name
            || records.iter().any(|r| r.name == record.name.name)
        {
            errors.push(Diagnostic::new(
                format!(
                    "`{}` is already the name of {}",
                    record.name.name,
                    if record.name.name == component.name.name {
                        "this component"
                    } else {
                        "another record"
                    }
                ),
                record.name.span,
            ));
            continue;
        }
        let mut fields: Vec<(String, TypeKind)> = Vec::new();
        for (field, ty) in &record.fields {
            if fields.iter().any(|(name, _)| *name == field.name) {
                errors.push(Diagnostic::new(
                    format!("field `{}` is declared twice", field.name),
                    field.span,
                ));
                continue;
            }
            match (&ty.kind, ty.list) {
                (crate::ast::TyKind::Scalar(kind), false) => {
                    fields.push((field.name.clone(), *kind));
                }
                (crate::ast::TyKind::Named(_), _) => errors.push(Diagnostic::new(
                    "record fields are scalars; nested records are a planned \
                     extension",
                    ty.span,
                )),
                (_, true) => errors.push(Diagnostic::new(
                    "record fields are scalars; list-typed fields are a \
                     planned extension",
                    ty.span,
                )),
            }
        }
        records.push(IrRecord {
            name: record.name.name.clone(),
            fields,
        });
    }

    /// What a declared `Ty` may resolve to at this position.
    enum TyPosition {
        /// Props, outputs, handler payloads: scalars and scalar lists only
        /// (records are file-local and do not cross component boundaries).
        Boundary(&'static str),
        /// State: scalars, scalar lists, and record lists (single-record
        /// state is a planned extension).
        State,
    }
    let resolve_ty = |ty: &crate::ast::Ty,
                      position: TyPosition,
                      records: &[IrRecord],
                      errors: &mut Vec<Diagnostic>|
     -> ValueTy {
        match &ty.kind {
            crate::ast::TyKind::Scalar(kind) => ValueTy {
                kind: ElemKind::Scalar(*kind),
                list: ty.list,
            },
            crate::ast::TyKind::Named(name) => {
                let index = records.iter().position(|r| r.name == *name);
                match (index, &position, ty.list) {
                    (None, _, _) => {
                        errors.push(Diagnostic::new(
                            format!("`{name}` is not a declared record"),
                            ty.span,
                        ));
                        ValueTy::scalar(TypeKind::I64)
                    }
                    (Some(index), TyPosition::State, true) => ValueTy {
                        kind: ElemKind::Record(index as u32),
                        list: true,
                    },
                    (Some(_), TyPosition::State, false) => {
                        errors.push(Diagnostic::new(
                            "single-record state is a planned extension; \
                             records live in `(List …)` state for now",
                            ty.span,
                        ));
                        ValueTy::scalar(TypeKind::I64)
                    }
                    (Some(_), TyPosition::Boundary(what), _) => {
                        errors.push(Diagnostic::new(
                            format!(
                                "records are file-local and cannot cross \
                                 component boundaries; {what} take scalars \
                                 (pass a field, e.g. `todo.id`)"
                            ),
                            ty.span,
                        ));
                        ValueTy::scalar(TypeKind::I64)
                    }
                }
            }
        }
    };

    let mut declare = |name: &crate::ast::Ident, binding: Binding, errors: &mut Vec<Diagnostic>| {
        if name.name == "payload" || name.name == "event" {
            errors.push(Diagnostic::new(
                format!(
                    "`{}` is reserved: it names {} inside event-wire arguments",
                    name.name,
                    if name.name == "payload" {
                        "the delivered value"
                    } else {
                        "the pointer event"
                    }
                ),
                name.span,
            ));
            return;
        }
        if scope.insert(name.name.clone(), binding).is_some() {
            errors.push(Diagnostic::new(
                format!("`{}` is declared more than once", name.name),
                name.span,
            ));
        }
    };

    for prop in &component.props {
        // Instance syntax reserves these spellings (`:class`, `:key`,
        // `:on-…` wires, and the flex-item layout overrides), so a prop by
        // any of these names could never be passed unambiguously.
        if RESERVED_INSTANCE_NAMES.contains(&prop.name.name.as_str())
            || prop.name.name.starts_with("on-")
        {
            errors.push(Diagnostic::new(
                format!(
                    "prop `{}` cannot be passed to an instance: `:class`, \
                     `:enabled`, `:key`, `:on-…`, and the item-layout names \
                     ({}) are reserved spellings; pick another name",
                    prop.name.name,
                    RESERVED_INSTANCE_NAMES.join(", ")
                ),
                prop.name.span,
            ));
        }
        let ty = resolve_ty(
            &prop.ty,
            TyPosition::Boundary("props"),
            &records,
            &mut errors,
        );
        declare(&prop.name, Binding::Prop(ty), &mut errors);
    }
    for state in &component.states {
        let ty = resolve_ty(&state.ty, TyPosition::State, &records, &mut errors);
        declare(&state.name, Binding::State(ty), &mut errors);
    }
    for output in &component.outputs {
        if output.ty.list {
            errors.push(Diagnostic::new(
                "outputs carry scalar payloads; list-typed outputs are a \
                 planned extension",
                output.ty.span,
            ));
        }
        let _ = resolve_ty(
            &output.ty,
            TyPosition::Boundary("outputs"),
            &records,
            &mut errors,
        );
        declare(&output.name, Binding::Output, &mut errors);
    }
    for handler in &component.handlers {
        for payload in &handler.payloads {
            if payload.list {
                errors.push(Diagnostic::new(
                    "handler payloads are scalar; list-typed payloads are a \
                     planned extension",
                    payload.span,
                ));
            }
            let _ = resolve_ty(
                payload,
                TyPosition::Boundary("handler payloads"),
                &records,
                &mut errors,
            );
        }
        if handler.name.name.replace('-', "_") == crate::MOUNT_HOOK_METHOD {
            errors.push(Diagnostic::new(
                format!(
                    "handler `{}` collides with `{}`, the logic method the \
                     framework calls once the component has mounted; rename it",
                    handler.name.name,
                    crate::MOUNT_HOOK_METHOD
                ),
                handler.name.span,
            ));
        }
        declare(&handler.name, Binding::Handler, &mut errors);
    }

    // Prop defaults and state inits must be compile-time constants.
    let mut props = Vec::new();
    for prop in &component.props {
        let default = prop
            .default
            .as_ref()
            .and_then(|expr| literal(expr, "prop default", &mut errors));
        props.push(IrProp {
            name: prop.name.name.clone(),
            ty: resolve_ty(
                &prop.ty,
                TyPosition::Boundary("props"),
                &records,
                &mut Vec::new(),
            ),
            default,
        });
    }
    let mut states = Vec::new();
    for state in &component.states {
        let ty = resolve_ty(&state.ty, TyPosition::State, &records, &mut Vec::new());
        if state.init.is_literal() && !literal_fits(ty, &state.init, &records) {
            errors.push(Diagnostic::new(
                format!(
                    "this `:init` does not fit `{}` (record literals supply \
                     every declared field)",
                    ty.rust_name(&records)
                ),
                state.init.span(),
            ));
        }
        let init = literal(&state.init, "state `:init`", &mut errors).unwrap_or(Literal::Int(0));
        states.push(IrState {
            name: state.name.name.clone(),
            ty,
            init,
        });
    }
    let outputs = component
        .outputs
        .iter()
        .map(|o| IrOutput {
            name: o.name.name.clone(),
            ty: match &o.ty.kind {
                crate::ast::TyKind::Scalar(kind) => *kind,
                // Rejected above; a placeholder keeps lowering total.
                crate::ast::TyKind::Named(_) => TypeKind::I64,
            },
        })
        .collect();

    let mut cx = LowerCx {
        parent_widget: None,
        scope,
        records: &records,
        components,
        registry,
        handlers: &component.handlers,
        nodes: Vec::new(),
        autofocus_seen: None,
        slot_seen: None,
        instantiated: Vec::new(),
        instance_keys: Vec::new(),
        in_dynamic: false,
        errors,
    };
    lower_node(&component.root, &mut cx);
    let LowerCx { nodes, errors, .. } = cx;

    if errors.is_empty() {
        let mut handlers: Vec<IrHandler> = component
            .handlers
            .iter()
            .map(|h| IrHandler {
                name: h.name.name.clone(),
                payloads: h
                    .payloads
                    .iter()
                    .map(|ty| match &ty.kind {
                        crate::ast::TyKind::Scalar(kind) => *kind,
                        crate::ast::TyKind::Named(_) => TypeKind::I64,
                    })
                    .collect(),
                returns: None,
            })
            .collect();
        // A handler wired to a query returns that query's value; its generated
        // method's return type follows from where it is used, so it is stamped
        // here once every node has been lowered.
        for node in &nodes {
            if let IrWidget::Widget(widget) = &node.widget {
                for query in &widget.queries {
                    if let Some(handler) = handlers.iter_mut().find(|h| h.name == query.handler) {
                        handler.returns = Some(query.returns.clone());
                    }
                }
            }
        }
        Ok(Ir {
            name: component.name.name.clone(),
            records,
            props,
            outputs,
            states,
            handlers,
            nodes,
        })
    } else {
        Err(errors)
    }
}

/// Everything the node-lowering walk reads and accumulates.
struct LowerCx<'a> {
    /// Names visible to expressions. Owned: `for` bodies extend it with
    /// their loop variables for the body's duration.
    scope: HashMap<String, Binding>,
    /// The file's record table (field typing for dotted paths, literal
    /// checks).
    records: &'a [IrRecord],
    /// Compiled interfaces of every component this file may instantiate.
    components: &'a BTreeMap<String, Ir>,
    /// The widget vocabulary in force: the builtins, plus the application's
    /// own `.gdw`-declared widgets.
    registry: &'a Registry,
    handlers: &'a [crate::ast::HandlerDecl],
    nodes: Vec<IrNode>,
    /// The builtin kind of the node being lowered into, when there is one.
    /// The enclosing widget's name, for the menu placement rules.
    ///
    /// A `menu-item`'s legal parents are named (`menu`/`context-menu`/
    /// `dropdown`); the name of a user widget matches none of them, which is
    /// exactly the answer the rules want — a `menu-item` belongs inside a
    /// `markdown` no more than inside a `container`.
    parent_widget: Option<String>,
    autofocus_seen: Option<crate::sexpr::Span>,
    slot_seen: Option<crate::sexpr::Span>,
    /// Component types already instantiated (factory-name collision and
    /// duplicate checks run once per type).
    instantiated: Vec<String>,
    /// `(component, key)` pairs already used, for duplicate `:key` checks.
    instance_keys: Vec<(String, String)>,
    /// Whether lowering is inside an `if` branch or `for` body, where
    /// cardinality is unknowable at compile time.
    in_dynamic: bool,
    errors: Vec<Diagnostic>,
}

/// The widget an unresolved or non-widget node stands in as: the placeholder a
/// node holds until lowering decides what it is, and what a `slot`, an `if`, a
/// `for`, or an instance leaves behind (none of which a consumer reads).
fn placeholder_widget() -> IrWidget {
    let descriptor = Registry::default()
        .resolve("container")
        .expect("container is a builtin")
        .clone();
    IrWidget::Widget(widget_node("container", &descriptor))
}

/// A widget node with no properties yet: the name written and the Rust type to
/// construct — both read from the one descriptor, whether it is a builtin's
/// `&'static` row or a manifest's owned entry.
fn widget_node(name: &str, descriptor: &WidgetDescriptor) -> IrWidgetNode {
    IrWidgetNode {
        name: name.to_owned(),
        type_path: descriptor.type_path.to_string(),
        props: Vec::new(),
        events: Vec::new(),
        queries: Vec::new(),
    }
}

fn empty_node() -> IrNode {
    IrNode {
        widget: placeholder_widget(),
        classes: Vec::new(),
        style: StyleIr::default(),
        events: Vec::new(),
        autofocus: false,
        enabled: None,
        focused: None,
        tooltip: None,
        accel: None,
        control: None,
        component_props: Vec::new(),
        component_outputs: Vec::new(),
        component_key: None,
        children: Vec::new(),
    }
}

/// Lower one widget node (and its subtree); returns its index in `nodes`.
fn lower_node(node: &Node, cx: &mut LowerCx<'_>) -> usize {
    let index = cx.nodes.len();
    cx.nodes.push(empty_node()); // placeholder until resolved below

    if let Some(control) = &node.control {
        let ir = lower_control(node, control, index, cx);
        cx.nodes[index] = ir;
        return index;
    }

    if crate::is_component_name(&node.widget.name) {
        let ir = lower_component_instance(node, cx);
        cx.nodes[index] = ir;
        return index;
    }

    if node.widget.name == "slot" {
        let mut ir = empty_node();
        ir.widget = IrWidget::Slot;
        if cx.in_dynamic {
            cx.errors.push(Diagnostic::new(
                "`(slot)` cannot live inside `if` or `for`; projected content \
                 has exactly one home",
                node.widget.span,
            ));
        }
        if index == 0 {
            cx.errors.push(Diagnostic::new(
                "`(slot)` cannot be the root; a component's own widget hosts \
                 the projected content",
                node.widget.span,
            ));
        }
        if let Some(first) = cx.slot_seen {
            cx.errors.push(Diagnostic::new(
                "a component declares at most one `(slot)`",
                node.widget.span,
            ));
            let _ = first;
        } else {
            cx.slot_seen = Some(node.widget.span);
        }
        if !node.props.is_empty() || !node.children.is_empty() {
            cx.errors.push(Diagnostic::new(
                "`(slot)` takes no properties or children; it only marks \
                 where an instance's children project in",
                node.span,
            ));
        }
        cx.nodes[index] = ir;
        return index;
    }

    // One descriptor, whether builtin or declared, resolved by name: the
    // property loop resolves each `:name` to a `PropDecl` through it. The local
    // `registry` copies the `&Registry`, so the descriptor borrows the
    // vocabulary, not `cx` — leaving `cx` free to mutate below.
    let registry: &Registry = cx.registry;
    let descriptor = match registry
        .resolve(&node.widget.name)
        .filter(|descriptor| descriptor.is_writable)
    {
        Some(descriptor) => descriptor,
        None => {
            cx.errors.push(Diagnostic::new(
                format!(
                    "unknown widget `{}`; expected one of: {}, `if`, `for` (or a \
                     capitalized component name)",
                    node.widget.name,
                    registry.writable_names()
                ),
                node.widget.span,
            ));
            registry
                .resolve("container")
                .expect("container is a builtin")
        }
    };

    // Menus are a shape: a row belongs to a menu, a menu to a bar or to
    // another menu. Both consumers rely on it — a menu reads its parent's name
    // to know whether it is a title or a submenu row — so a misplaced one is an
    // error here rather than a puzzle at runtime. Only the framework's own
    // widgets carry a placement rule; a manifest declares none.
    let allowed = &descriptor.required_parents;
    if !allowed.is_empty()
        && !cx
            .parent_widget
            .as_deref()
            .is_some_and(|parent| allowed.iter().any(|a| a == parent))
    {
        cx.errors.push(Diagnostic::new(
            format!(
                "`{}` belongs directly inside {}",
                descriptor.name,
                match allowed.as_ref() {
                    [one] => format!("a `{one}`"),
                    many => format!(
                        "a `{}`",
                        many.iter()
                            .map(Cow::as_ref)
                            .collect::<Vec<_>>()
                            .join("` or a `")
                    ),
                }
            ),
            node.widget.span,
        ));
    }

    let mut ir = empty_node();
    let in_dynamic = cx.in_dynamic;
    let scope = &cx.scope;
    let records = cx.records;
    let handlers = cx.handlers;
    let autofocus_seen = &mut cx.autofocus_seen;
    let errors = &mut cx.errors;

    let mut built = widget_node(&node.widget.name, descriptor);

    for prop in &node.props {
        // `:class` is universal and static: theme rule matching keys on it.
        if prop.name.name == "class" {
            lower_class(&prop.value, &mut ir, errors);
            continue;
        }
        // `:tooltip` is universal and static: it is the tree's to show, and
        // nothing reads it per frame.
        if prop.name.name == "tooltip" {
            match &prop.value {
                Expr::Str(template, _) if template.refs().next().is_none() => {
                    ir.tooltip = Some(
                        template
                            .segments
                            .iter()
                            .filter_map(|s| match s {
                                crate::expr::Segment::Literal(l) => Some(l.as_str()),
                                crate::expr::Segment::Ref(..) => None,
                            })
                            .collect(),
                    );
                }
                other => errors.push(Diagnostic::new(
                    "`:tooltip` takes a literal string",
                    other.span(),
                )),
            }
            continue;
        }
        // `:enabled` is universal too, and may be reactive.
        if prop.name.name == "enabled" {
            lower_enabled(&prop.value, scope, records, &mut ir, errors);
            continue;
        }
        // `:focused` likewise: focus is the tree's, not any widget's, so it
        // is lowered here rather than looked up per widget.
        if prop.name.name == "focused" {
            lower_bool_prop("focused", &prop.value, scope, records, errors, |expr| {
                ir.focused = Some(expr)
            });
            continue;
        }
        let Some(decl) = descriptor.lookup(&prop.name.name) else {
            errors.push(Diagnostic::new(
                format!(
                    "unknown property `:{}` on `{}`; known properties: {}",
                    prop.name.name,
                    descriptor.name,
                    descriptor.known_props().join(", ")
                ),
                prop.name.span,
            ));
            continue;
        };
        match &decl.kind {
            PropDeclKind::Layout(layout) => {
                lower_layout_prop(*layout, &prop.value, &mut ir.style, errors);
            }
            // One lowering for every settable property, builtin or declared,
            // dispatched on the value type: a color from color literals, a
            // graphic from a data form, a scalar coerced to the setter's width.
            PropDeclKind::Setter { method, ty } => {
                lower_prop(
                    &decl.name,
                    method,
                    *ty,
                    &prop.value,
                    scope,
                    records,
                    &mut built.props,
                    errors,
                );
            }
            // A framework event: mapped to a runtime `EventKind` by both back
            // ends. The pointer family delivers `event`, so it is readable in
            // the wire's arguments.
            PropDeclKind::Event(EventRef::Builtin(event)) => {
                match event_wire(*event, &prop.value, scope, records, handlers) {
                    Ok((handler, args)) => ir.events.push(IrEvent {
                        event: *event,
                        handler,
                        args,
                    }),
                    Err(diag) => errors.push(diag),
                }
            }
            // A manifest event: dispatched as `EventKind::User` by name. Never
            // a pointer event — the pointer family is universal and resolves
            // above.
            PropDeclKind::Event(EventRef::User { payload }) => {
                match invocation_wire(&prop.value, *payload, None, scope, records, handlers) {
                    Ok((handler, args)) => built.events.push(IrUserEvent {
                        name: decl.name.to_string(),
                        payload: *payload,
                        handler,
                        args,
                    }),
                    Err(diag) => errors.push(diag),
                }
            }
            // A query hands the handler the value being resolved (its
            // `payload`) and takes an answer back — validated like a payload
            // event; the return type is applied to the handler in a pass once
            // every node is lowered.
            PropDeclKind::Query {
                setter,
                payload,
                returns,
            } => {
                match invocation_wire(&prop.value, Some(*payload), None, scope, records, handlers) {
                    Ok((handler, _args)) => {
                        // A query handler takes exactly the resolved value; it reads
                        // anything else from `cx`. That keeps the dev interpreter
                        // able to install it without evaluating wire arguments, so
                        // the compiled and interpreted paths call it identically.
                        match handler_payloads(&handler, prop.value.span(), scope, handlers) {
                            Ok(payloads) if payloads == [*payload] => {
                                built.queries.push(IrQuery {
                                    name: decl.name.to_string(),
                                    setter: setter.to_string(),
                                    payload: *payload,
                                    returns: returns.to_string(),
                                    handler,
                                });
                            }
                            Ok(_) => errors.push(Diagnostic::new(
                                format!(
                                    "a `:{}` handler takes exactly the resolved value \
                                 ({}) and nothing else — read other state from `cx`",
                                    decl.name,
                                    payload.rust_name(),
                                ),
                                prop.value.span(),
                            )),
                            Err(diag) => errors.push(diag),
                        }
                    }
                    Err(diag) => errors.push(diag),
                }
            }
            PropDeclKind::Autofocus => match &prop.value {
                Expr::Bool(enabled, span) => {
                    if *enabled && in_dynamic {
                        errors.push(Diagnostic::new(
                            "`:autofocus` cannot live inside `if` or `for`; \
                             initial focus must be unconditional",
                            *span,
                        ));
                    } else if *enabled {
                        if let Some(_first) = autofocus_seen {
                            errors.push(Diagnostic::new(
                                "`:autofocus` appears more than once; only one \
                                 widget can take initial focus",
                                *span,
                            ));
                        } else {
                            *autofocus_seen = Some(*span);
                            ir.autofocus = true;
                        }
                    }
                }
                other => errors.push(Diagnostic::new(
                    "`:autofocus` takes a literal `true` or `false`",
                    other.span(),
                )),
            },
            PropDeclKind::Accel { method } => {
                if in_dynamic {
                    errors.push(Diagnostic::new(
                        "`:accel` cannot live inside `if` or `for`: an \
                         accelerator fires while its menu is closed, so it is \
                         registered once at mount, when a row built per-item \
                         does not exist and a loop variable has no value",
                        prop.name.span,
                    ));
                } else {
                    match &prop.value {
                        Expr::Str(template, span) if template.refs().next().is_none() => {
                            let text: String = template
                                .segments
                                .iter()
                                .filter_map(|s| match s {
                                    crate::expr::Segment::Literal(l) => Some(l.as_str()),
                                    crate::expr::Segment::Ref(..) => None,
                                })
                                .collect();
                            match crate::accel::parse_accel(&text, *span) {
                                Ok(accel) => {
                                    // An accelerator is two things: a keystroke
                                    // the tree registers at mount (because it
                                    // must fire while the menu is closed) and a
                                    // string the row renders. The row's half is
                                    // an ordinary property.
                                    built.props.push(IrWidgetProp {
                                        name: decl.name.to_string(),
                                        setter: method.to_string(),
                                        ty: PropTy::Scalar(TypeKind::String),
                                        value: IrPropValue::Static(Literal::Str(accel.display())),
                                    });
                                    ir.accel = Some(accel);
                                }
                                Err(diagnostic) => errors.push(diagnostic),
                            }
                        }
                        other => errors.push(Diagnostic::new(
                            "`:accel` takes a literal string, like `\"Ctrl+S\"`",
                            other.span(),
                        )),
                    }
                }
            }
        }
    }

    // The submenu flag no file writes, because its value is a fact about the
    // file's own structure. It is a setter like any other — that is the point
    // of routing it here rather than teaching each back end a method name — and
    // only a `menu` has it, because only the framework defines the rule that
    // produces it. (Deferred content is not among these: whether a widget
    // builds its children on open is the widget's own runtime answer, not a
    // manifest fact, so it never becomes a setter — the tree hands every
    // widget its content builder and the widget decides.)
    if descriptor.name == "menu" {
        // A menu inside a menu is a submenu row; a menu inside a bar is a
        // title. The structural rule checked above guarantees it is one or the
        // other, so this is a fact rather than a guess — and settling it here
        // is what keeps both back ends from having to sniff for it.
        let submenu = cx.parent_widget.as_deref() == Some("menu");
        built.props.push(IrWidgetProp {
            name: registry::SUBMENU_PROP.to_owned(),
            setter: registry::SUBMENU_METHOD.to_owned(),
            ty: PropTy::Scalar(TypeKind::Bool),
            value: IrPropValue::Static(Literal::Bool(submenu)),
        });
    }
    ir.widget = IrWidget::Widget(built);

    let enclosing = cx.parent_widget.take();
    cx.parent_widget = Some(descriptor.name.to_string());
    for child in &node.children {
        let child_index = lower_node(child, cx);
        ir.children.push(child_index);
    }
    cx.parent_widget = enclosing;
    cx.nodes[index] = ir;
    index
}

/// Lower a settable property — a builtin's or a manifest's — to a constant or
/// a binding, dispatched on the value type. One lowering, so a `:background`
/// and a declared `(prop icon Graphic)` are checked and routed by the same
/// rules: a brush is color literals and `if`, a graphic is a data form, a
/// number coerces an integer to the setter's float width.
fn lower_prop(
    name: &str,
    method: &str,
    ty: PropTy,
    value: &Expr,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
    out: &mut Vec<IrWidgetProp>,
    errors: &mut Vec<Diagnostic>,
) {
    let push = |out: &mut Vec<IrWidgetProp>, ir_value: IrPropValue| {
        out.push(IrWidgetProp {
            name: name.to_owned(),
            setter: method.to_owned(),
            ty,
            value: ir_value,
        });
    };

    // A data form — `(asset …)`, `(path …)`, `(rich …)` — is a finished value
    // that references nothing, so it goes straight to the type check.
    if let Expr::Form(literal, span) = value {
        let fits = match ty {
            PropTy::Graphic => matches!(literal, Literal::Path(_) | Literal::Asset(_)),
            PropTy::RichText => matches!(literal, Literal::Rich(_)),
            _ => false,
        };
        if fits {
            push(out, IrPropValue::Static(literal.clone()));
        } else {
            // Name where the value they wrote belongs, rather than only that it
            // does not fit here.
            errors.push(match literal {
                Literal::Path(_) | Literal::Asset(_) => Diagnostic::new(
                    format!(
                        "`:{name}` does not take a graphic; a graphic belongs on a \
                         graphic property, like an image's `:source`"
                    ),
                    *span,
                ),
                Literal::Rich(_) => Diagnostic::new(
                    format!(
                        "`:{name}` does not take rich text; rich text belongs on a \
                         text property, like a `text`'s `:text`"
                    ),
                    *span,
                ),
                _ => value_does_not_fit(name, ty, *span),
            });
        }
        return;
    }

    // A scroll axis is one of three keywords — bare symbols, so they are read
    // before name resolution (which would take `vertical` for an undefined
    // reference). Static, read at insert to shape the layout node.
    if ty == PropTy::ScrollAxes {
        if let Some(axes) = keyword_of(
            value,
            &[
                ("vertical", ScrollAxesIr::Vertical),
                ("horizontal", ScrollAxesIr::Horizontal),
                ("both", ScrollAxesIr::Both),
            ],
            errors,
        ) {
            push(out, IrPropValue::Static(Literal::ScrollAxes(axes)));
        }
        return;
    }

    // Every referenced name must resolve to a prop, state, or loop var.
    if !resolve_expr_refs(value, scope, records, errors) {
        return;
    }

    // A brush is built from color literals and `if`.
    if ty == PropTy::Brush {
        if !check_color_expr(value, errors) {
            return;
        }
        let ir_value = if value.is_literal() {
            match literal(value, "a widget property", errors) {
                Some(Literal::Str(text)) => match parse_color(&text) {
                    Some(color) => IrPropValue::Static(color),
                    None => return,
                },
                _ => return,
            }
        } else {
            IrPropValue::Binding(value.clone())
        };
        push(out, ir_value);
        return;
    }

    // A graphic's only value is a data form, handled above; a bare expression
    // here is a mistake worth naming.
    if ty == PropTy::Graphic {
        errors.push(Diagnostic::new(
            format!("`:{name}` is a graphic: `(asset \"…\")` or `(path …)`"),
            value.span(),
        ));
        return;
    }

    if value.is_literal() {
        // A number-typed property coerces an integer literal to a float, so the
        // setter (which takes f32/f64) gets a float and `:font-size 14` is not
        // width-typed as an integer.
        if let (PropTy::Scalar(TypeKind::F32 | TypeKind::F64), Expr::Int(v, _)) = (ty, value) {
            push(out, IrPropValue::Static(Literal::Float(*v as f64)));
            return;
        }
        let fits = match ty {
            PropTy::Scalar(kind) => literal_fits(ValueTy::scalar(kind), value, records),
            // A plain string is rich text with no runs, which is the whole
            // reason one setter takes both.
            PropTy::RichText => literal_fits(ValueTy::scalar(TypeKind::String), value, records),
            _ => false,
        };
        if !fits {
            errors.push(value_does_not_fit(name, ty, value.span()));
            return;
        }
        let Some(literal) = literal(value, "a widget property", errors) else {
            return;
        };
        push(out, IrPropValue::Static(literal));
    } else {
        // A binding. `Graphic` — the one non-bindable type that reaches here —
        // was handled above, so a non-literal value is always bindable. What is
        // knowable is checked here; a computed expression's type is rustc's to
        // check against the setter, where the contract is finally enforced.
        if let Some(kind) = path_type(value, scope, records)
            && !prop_type_fits(kind, ty)
        {
            errors.push(value_does_not_fit(name, ty, value.span()));
            return;
        }
        push(out, IrPropValue::Binding(value.clone()));
    }
}

/// A value's type does not match what the property takes.
fn value_does_not_fit(name: &str, ty: PropTy, span: crate::sexpr::Span) -> Diagnostic {
    let wanted = match ty {
        PropTy::Scalar(TypeKind::String) | PropTy::RichText => "a string",
        PropTy::Scalar(TypeKind::Bool) => "a boolean",
        PropTy::Scalar(TypeKind::F32 | TypeKind::F64) => "a number",
        PropTy::Scalar(TypeKind::I32 | TypeKind::I64) => "an integer",
        PropTy::Brush => "a color",
        PropTy::Graphic => "a graphic",
        PropTy::ScrollAxes => "an axis",
    };
    Diagnostic::new(
        format!("`:{name}` takes {wanted}; this value is not one"),
        span,
    )
}

/// Whether a value of a known `.gdc` type fits a declared property.
fn prop_type_fits(have: TypeKind, want: registry::PropTy) -> bool {
    match want {
        registry::PropTy::Scalar(kind) => type_fits(have, kind),
        // Every scalar has a string form, and rich text is a string plus runs.
        registry::PropTy::RichText => have == TypeKind::String,
        registry::PropTy::Brush | registry::PropTy::Graphic | registry::PropTy::ScrollAxes => false,
    }
}

/// Lower a structural `(if …)` / `(for …)` node.
fn lower_control(
    node: &Node,
    control: &crate::ast::Control,
    index: usize,
    cx: &mut LowerCx<'_>,
) -> IrNode {
    let mut ir = empty_node();
    if index == 0 {
        cx.errors.push(Diagnostic::new(
            "a structural form cannot be the component root; wrap it in a \
             container",
            node.span,
        ));
    }
    let was_dynamic = cx.in_dynamic;
    cx.in_dynamic = true;
    match control {
        crate::ast::Control::If { cond } => {
            ir.widget = IrWidget::If;
            match cond {
                Expr::Str(..) | Expr::Int(..) | Expr::Float(..) | Expr::List(..) => {
                    cx.errors.push(Diagnostic::new(
                        "`if` takes a boolean condition, e.g. `(> count 0)`",
                        cond.span(),
                    ));
                }
                _ => {
                    if resolve_expr_refs(cond, &cx.scope, cx.records, &mut cx.errors) {
                        ir.control = Some(ControlIr::If { cond: cond.clone() });
                    }
                }
            }
            for child in &node.children {
                let child_index = lower_node(child, cx);
                ir.children.push(child_index);
            }
        }
        crate::ast::Control::For {
            var,
            index: index_name,
            list,
            key,
        } => {
            ir.widget = IrWidget::For;
            // The list is a `(List T)` prop or state, referenced by name —
            // there are no list-producing operators to iterate over.
            let resolved = match list {
                Expr::Path(segments, span) if segments.len() == 1 => {
                    match cx.scope.get(segments[0].as_str()) {
                        Some(Binding::Prop(ty) | Binding::State(ty)) if ty.list => {
                            Some((segments[0].clone(), ty.kind))
                        }
                        Some(Binding::Prop(ty) | Binding::State(ty)) => {
                            cx.errors.push(Diagnostic::new(
                                format!(
                                    "`{}` is a `{}`, not a list; `for` iterates \
                                     a `(List T)` prop or state",
                                    segments[0],
                                    ty.rust_name(cx.records)
                                ),
                                *span,
                            ));
                            None
                        }
                        _ => {
                            cx.errors.push(Diagnostic::new(
                                format!("`{}` is not a declared prop or state", segments[0]),
                                *span,
                            ));
                            None
                        }
                    }
                }
                other => {
                    cx.errors.push(Diagnostic::new(
                        "`for` iterates a `(List T)` prop or state by name",
                        other.span(),
                    ));
                    None
                }
            };

            // The loop variable (and `:index` name) extend the scope for
            // the body; shadowing an existing name is rejected rather than
            // resolved.
            let mut added = Vec::new();
            let element = resolved.as_ref().map(|(_, kind)| *kind);
            for (ident, binding) in [
                (
                    Some(var),
                    Binding::Loop(element.unwrap_or(ElemKind::Scalar(TypeKind::I64))),
                ),
                (index_name.as_ref(), Binding::LoopIndex),
            ]
            .into_iter()
            .filter_map(|(ident, binding)| ident.map(|i| (i, binding)))
            {
                if ident.name == "payload" {
                    cx.errors.push(Diagnostic::new(
                        "`payload` is reserved: it names the delivered value \
                         inside event-wire arguments",
                        ident.span,
                    ));
                } else if cx.scope.contains_key(&ident.name) {
                    cx.errors.push(Diagnostic::new(
                        format!(
                            "`{}` shadows a declared name; pick another loop \
                             variable",
                            ident.name
                        ),
                        ident.span,
                    ));
                } else {
                    cx.scope.insert(ident.name.clone(), binding);
                    added.push(ident.name.clone());
                }
            }

            if let Some(key) = key {
                let _ = resolve_expr_refs(key, &cx.scope, cx.records, &mut cx.errors);
            }
            if let Some((list_name, element)) = resolved {
                ir.control = Some(ControlIr::For {
                    var: var.name.clone(),
                    index: index_name.as_ref().map(|i| i.name.clone()),
                    list: list_name,
                    element,
                    key: key.clone(),
                });
            }

            let child_index = lower_node(&node.children[0], cx);
            ir.children.push(child_index);
            for name in added {
                cx.scope.remove(&name);
            }
        }
    }
    cx.in_dynamic = was_dynamic;
    ir
}

/// Spellings the instance syntax owns; a component prop cannot bear any of
/// them. The layout entries start at index 3 (`class`, `enabled`, `key`
/// come first).
const RESERVED_INSTANCE_NAMES: &[&str] = &[
    "class",
    "enabled",
    "key",
    "width",
    "height",
    "grow",
    "shrink",
    "basis",
    "align-self",
];

/// Layout properties that shape a component's *interior*, rejected on
/// instances.
const INTERIOR_LAYOUT_NAMES: &[&str] = &[
    "direction",
    "gap",
    "padding",
    "align-items",
    "justify-content",
];

/// The flex-item layout subset an instance accepts.
fn instance_layout_prop(name: &str) -> Option<LayoutProp> {
    Some(match name {
        "width" => LayoutProp::Width,
        "height" => LayoutProp::Height,
        "grow" => LayoutProp::Grow,
        "shrink" => LayoutProp::Shrink,
        "basis" => LayoutProp::Basis,
        "align-self" => LayoutProp::AlignSelf,
        _ => return None,
    })
}

/// A universal boolean property — `:enabled`, `:focused` — taking a literal
/// or reactive expression. One lowering, so the two cannot disagree about
/// what a boolean expression is.
fn lower_bool_prop(
    name: &str,
    value: &Expr,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
    errors: &mut Vec<Diagnostic>,
    store: impl FnOnce(Expr),
) {
    match value {
        Expr::Str(_, span) | Expr::Int(_, span) | Expr::Float(_, span) => {
            errors.push(Diagnostic::new(
                format!("`:{name}` takes a boolean expression, e.g. `true` or `(not busy)`"),
                *span,
            ));
        }
        _ => {
            if resolve_expr_refs(value, scope, records, errors) {
                store(value.clone());
            }
        }
    }
}

/// `:enabled` — a boolean expression driving the widget's enabled flag.
fn lower_enabled(
    value: &Expr,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
    ir: &mut IrNode,
    errors: &mut Vec<Diagnostic>,
) {
    lower_bool_prop("enabled", value, scope, records, errors, |expr| {
        ir.enabled = Some(expr)
    });
}

/// `:class` — a literal string of space-separated class names.
fn lower_class(value: &Expr, ir: &mut IrNode, errors: &mut Vec<Diagnostic>) {
    match value {
        Expr::Str(template, span) if template.refs().next().is_none() => {
            let text: String = template
                .segments
                .iter()
                .filter_map(|s| match s {
                    crate::expr::Segment::Literal(l) => Some(l.as_str()),
                    crate::expr::Segment::Ref(..) => None,
                })
                .collect();
            ir.classes = text.split_whitespace().map(str::to_owned).collect();
            if ir.classes.is_empty() {
                errors.push(Diagnostic::new(
                    "`:class` must name at least one class",
                    *span,
                ));
            }
        }
        other => errors.push(Diagnostic::new(
            "`:class` takes a literal string of space-separated class names",
            other.span(),
        )),
    }
}

/// Lower a component instance node, checking every prop and output wire
/// against the child's compiled interface.
fn lower_component_instance(node: &Node, cx: &mut LowerCx<'_>) -> IrNode {
    let name = &node.widget.name;
    let mut ir = empty_node();
    ir.widget = IrWidget::Component(name.clone());

    let Some(child) = cx.components.get(name) else {
        // compile_with resolves references before validation, so a miss
        // means resolution already failed and reported; stay quiet.
        return ir;
    };

    if !cx.instantiated.iter().any(|n| n == name) {
        cx.instantiated.push(name.clone());
        // The generated logic factory (`fn counter_button(…)`) must not
        // collide with a handler's generated method.
        let method = crate::component_method_name(name);
        if method == crate::MOUNT_HOOK_METHOD {
            cx.errors.push(Diagnostic::new(
                format!(
                    "the logic-factory method `{method}` generated for `{name}` \
                     collides with the method the framework calls once the \
                     component has mounted; rename the component"
                ),
                node.widget.span,
            ));
        }
        if let Some(handler) = cx
            .handlers
            .iter()
            .find(|h| h.name.name.replace('-', "_") == method)
        {
            cx.errors.push(Diagnostic::new(
                format!(
                    "handler `{}` collides with the logic-factory method \
                     `{method}` generated for `{name}`; rename one of them",
                    handler.name.name
                ),
                handler.name.span,
            ));
        }
    }

    for prop in &node.props {
        let prop_name = &prop.name.name;
        // `:class` merges into the instance root widget's class list.
        if prop_name == "class" {
            lower_class(&prop.value, &mut ir, &mut cx.errors);
            continue;
        }
        // `:enabled` gates the instance root, disabling the whole embedded
        // subtree; the expression evaluates in the *parent's* scope.
        if prop_name == "enabled" {
            lower_enabled(&prop.value, &cx.scope, cx.records, &mut ir, &mut cx.errors);
            continue;
        }
        // `:key` names the instance for state matching across hot reloads.
        if prop_name == "key" {
            if cx.in_dynamic {
                cx.errors.push(Diagnostic::new(
                    "instance `:key` cannot live inside `if` or `for` (it \
                     would repeat per row); `for :key` carries row identity",
                    prop.name.span,
                ));
                continue;
            }
            match &prop.value {
                Expr::Str(template, span) if template.refs().next().is_none() => {
                    let text: String = template
                        .segments
                        .iter()
                        .filter_map(|s| match s {
                            crate::expr::Segment::Literal(l) => Some(l.as_str()),
                            crate::expr::Segment::Ref(..) => None,
                        })
                        .collect();
                    if text.is_empty() || text.starts_with('#') {
                        cx.errors.push(Diagnostic::new(
                            "`:key` must be a non-empty string not starting with \
                             `#` (reserved for occurrence indices)",
                            *span,
                        ));
                    } else if cx
                        .instance_keys
                        .iter()
                        .any(|(c, k)| c == name && *k == text)
                    {
                        cx.errors.push(Diagnostic::new(
                            format!("`:key \"{text}\"` is used by another `{name}` instance"),
                            *span,
                        ));
                    } else {
                        cx.instance_keys.push((name.clone(), text.clone()));
                        ir.component_key = Some(text);
                    }
                }
                other => cx.errors.push(Diagnostic::new(
                    "`:key` takes a literal string",
                    other.span(),
                )),
            }
            continue;
        }
        // The flex-item layout subset patches the instance root's style —
        // the parent controls the instance *as an item*; the interior
        // properties stay the child file's business.
        if let Some(layout) = instance_layout_prop(prop_name) {
            lower_layout_prop(layout, &prop.value, &mut ir.style, &mut cx.errors);
            continue;
        }
        if INTERIOR_LAYOUT_NAMES.contains(&prop_name.as_str()) {
            cx.errors.push(Diagnostic::new(
                format!(
                    "`:{prop_name}` lays out a component's interior and belongs \
                     to its own file; instances take only the item-layout \
                     properties ({})",
                    RESERVED_INSTANCE_NAMES[3..].join(", ")
                ),
                prop.name.span,
            ));
            continue;
        }
        if let Some(output_name) = prop_name.strip_prefix("on-") {
            let Some(output) = child.outputs.iter().find(|o| o.name == output_name) else {
                cx.errors.push(Diagnostic::new(
                    format!(
                        "`{name}` has no output `{output_name}`; declared outputs: {}",
                        list_names(child.outputs.iter().map(|o| o.name.as_str()))
                    ),
                    prop.name.span,
                ));
                continue;
            };
            match invocation_wire(
                &prop.value,
                Some(output.ty),
                // An instance's output carries a value, not an input event —
                // there is no pointer or keystroke behind it.
                None,
                &cx.scope,
                cx.records,
                cx.handlers,
            ) {
                Ok((handler, args)) => ir.component_outputs.push(IrOutputWire {
                    output: output_name.to_owned(),
                    handler,
                    args,
                }),
                Err(diag) => cx.errors.push(diag),
            }
            continue;
        }
        let Some(declared) = child.props.iter().find(|p| p.name == *prop_name) else {
            cx.errors.push(Diagnostic::new(
                format!(
                    "`{name}` has no prop `{prop_name}`; declared props: {}",
                    list_names(child.props.iter().map(|p| p.name.as_str()))
                ),
                prop.name.span,
            ));
            continue;
        };
        if ir.component_props.iter().any(|(n, _)| n == prop_name) {
            cx.errors.push(Diagnostic::new(
                format!("prop `{prop_name}` is given more than once"),
                prop.name.span,
            ));
            continue;
        }
        if !resolve_expr_refs(&prop.value, &cx.scope, cx.records, &mut cx.errors) {
            continue;
        }
        if prop.value.is_literal() && !literal_fits(declared.ty, &prop.value, cx.records) {
            cx.errors.push(Diagnostic::new(
                format!(
                    "`{name}` declares `{prop_name}` as {}; this literal does not fit",
                    declared.ty.rust_name(&child.records)
                ),
                prop.value.span(),
            ));
            continue;
        }
        ir.component_props
            .push((prop_name.clone(), prop.value.clone()));
    }

    for declared in &child.props {
        if declared.default.is_none()
            && !ir.component_props.iter().any(|(n, _)| *n == declared.name)
        {
            cx.errors.push(Diagnostic::new(
                format!(
                    "missing required prop `{}` (declared `(prop {} {})` in `{name}`)",
                    declared.name,
                    declared.name,
                    declared.ty.rust_name(&child.records)
                ),
                node.span,
            ));
        }
    }

    // Instance children are slot content — *parent* content: their names
    // resolve here, their events wire to this file's handlers — projected
    // into the child's `(slot)`.
    if !node.children.is_empty() && !has_slot(child) {
        cx.errors.push(Diagnostic::new(
            format!("`{name}` declares no `(slot)`, so instances take no children"),
            node.children[0].span,
        ));
    } else {
        for child_node in &node.children {
            let child_index = lower_node(child_node, cx);
            ir.children.push(child_index);
        }
    }

    // At most one widget takes initial focus across the *composed* tree:
    // this file's own `:autofocus` plus every instantiated component that
    // carries one (each validated file already holds at most one).
    if has_autofocus(child, cx.components) {
        if cx.autofocus_seen.is_some() {
            cx.errors.push(Diagnostic::new(
                format!(
                    "`{name}` takes initial focus via `:autofocus`, but another \
                     widget in the composed tree already does"
                ),
                node.widget.span,
            ));
        } else {
            cx.autofocus_seen = Some(node.widget.span);
        }
    }

    ir
}

/// Whether a component's composed tree (its own nodes plus everything it
/// instantiates) contains an `:autofocus`.
fn has_autofocus(ir: &Ir, components: &BTreeMap<String, Ir>) -> bool {
    ir.nodes.iter().any(|node| {
        node.autofocus
            || match &node.widget {
                IrWidget::Component(name) => components
                    .get(name)
                    .is_some_and(|child| has_autofocus(child, components)),
                IrWidget::Widget(_) | IrWidget::Slot | IrWidget::If | IrWidget::For => false,
            }
    })
}

fn list_names<'a>(names: impl Iterator<Item = &'a str>) -> String {
    let listed: Vec<&str> = names.collect();
    if listed.is_empty() {
        "(none)".to_owned()
    } else {
        listed.join(", ")
    }
}

/// The span of the first handler-invocation form in an expression tree,
/// if any — calls are event-wire syntax, not expression syntax.
fn find_call(value: &Expr) -> Option<crate::sexpr::Span> {
    match value {
        Expr::Call(_, _, span) => Some(*span),
        Expr::Int(..)
        | Expr::Float(..)
        | Expr::Bool(..)
        | Expr::Str(..)
        | Expr::Path(..)
        | Expr::Form(..) => None,
        Expr::Unary(_, inner, _) => find_call(inner),
        Expr::Binary(_, lhs, rhs, _) => find_call(lhs).or_else(|| find_call(rhs)),
        Expr::If(c, t, e, _) => find_call(c)
            .or_else(|| find_call(t))
            .or_else(|| find_call(e)),
        Expr::List(items, _) => items.iter().find_map(find_call),
        Expr::RecordLit(_, fields, _) => fields.iter().find_map(|(_, value)| find_call(value)),
    }
}

/// The span of the first data form in an expression tree, if any.
///
/// `(path …)` and `(asset "…")` are complete values, only ever a whole
/// property value on a graphic-typed property. Inside a computation they are
/// meaningless — there is nothing to add an asset to — and, being reducible
/// to a `Literal` the reader already finished, they would otherwise sail past
/// the type checks and reach codegen.
fn find_form(value: &Expr) -> Option<crate::sexpr::Span> {
    match value {
        Expr::Form(_, span) => Some(*span),
        Expr::Int(..) | Expr::Float(..) | Expr::Bool(..) | Expr::Str(..) | Expr::Path(..) => None,
        Expr::Unary(_, inner, _) => find_form(inner),
        Expr::Binary(_, lhs, rhs, _) => find_form(lhs).or_else(|| find_form(rhs)),
        Expr::If(c, t, e, _) => find_form(c)
            .or_else(|| find_form(t))
            .or_else(|| find_form(e)),
        Expr::List(items, _) => items.iter().find_map(find_form),
        Expr::Call(_, args, _) => args.iter().find_map(find_form),
        Expr::RecordLit(_, fields, _) => fields.iter().find_map(|(_, value)| find_form(value)),
    }
}

/// Whether a literal expression can initialize a value of the given type.
fn literal_fits(ty: ValueTy, value: &Expr, records: &[IrRecord]) -> bool {
    if ty.list {
        return match value {
            Expr::List(items, _) => items.iter().all(|item| {
                literal_fits(
                    ValueTy {
                        kind: ty.kind,
                        list: false,
                    },
                    item,
                    records,
                )
            }),
            _ => false,
        };
    }
    match ty.kind {
        ElemKind::Record(index) => {
            let Some(record) = records.get(index as usize) else {
                return false;
            };
            let Expr::RecordLit(name, fields, _) = value else {
                return false;
            };
            *name == record.name
                && record.fields.len() == fields.len()
                && record.fields.iter().all(|(field, kind)| {
                    fields.iter().any(|(given, value)| {
                        given == field && literal_fits(ValueTy::scalar(*kind), value, records)
                    })
                })
        }
        ElemKind::Scalar(kind) => match value {
            Expr::List(..) | Expr::RecordLit(..) => false,
            Expr::Int(..) => !matches!(kind, TypeKind::Bool | TypeKind::String),
            Expr::Float(..) => matches!(kind, TypeKind::F32 | TypeKind::F64),
            Expr::Bool(..) => kind == TypeKind::Bool,
            Expr::Str(..) => kind == TypeKind::String,
            _ => true,
        },
    }
}

/// Check that every name an expression references resolves to a prop,
/// state, or loop variable — and that dotted names access a real field of
/// a record-typed loop variable; returns whether they all do.
fn resolve_expr_refs(
    value: &Expr,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
    errors: &mut Vec<Diagnostic>,
) -> bool {
    let mut refs = Vec::new();
    value.referenced_names(&mut refs);
    let mut ok = true;
    if let Some(span) = find_call(value) {
        errors.push(Diagnostic::new(
            "handler invocations like `(name …)` only wire events \
             (`:on-click (remove todo.id)`); expressions have no calls",
            span,
        ));
        ok = false;
    }
    if let Some(span) = find_form(value) {
        errors.push(Diagnostic::new(
            "a `(path …)` or `(asset …)` is a graphic, not a value an \
             expression can compute with; it belongs on a property that takes \
             one, like an image's `:source`",
            span,
        ));
        ok = false;
    }
    for (name, span) in &refs {
        let mut segments = name.split('.');
        let head = segments.next().expect("split yields at least one");
        let field = segments.next();
        if segments.next().is_some() {
            errors.push(Diagnostic::new(
                format!("`{name}` reaches too deep; records are flat (one field level)"),
                *span,
            ));
            ok = false;
            continue;
        }
        if let Some(field) = field {
            // Field access: the head must hold a record.
            match scope.get(head) {
                Some(Binding::Loop(ElemKind::Record(index))) => {
                    let record = &records[*index as usize];
                    if !record.fields.iter().any(|(f, _)| f == field) {
                        errors.push(Diagnostic::new(
                            format!(
                                "`{}` has no field `{field}`; it declares {}",
                                record.name,
                                record
                                    .fields
                                    .iter()
                                    .map(|(f, _)| f.as_str())
                                    .collect::<Vec<_>>()
                                    .join(", ")
                            ),
                            *span,
                        ));
                        ok = false;
                    }
                }
                Some(Binding::Event(shape)) => {
                    if shape.field(field).is_none() {
                        errors.push(Diagnostic::new(
                            format!(
                                "`event` has no field `{field}` on this wire; here it \
                                 offers {}",
                                shape.field_names()
                            ),
                            *span,
                        ));
                        ok = false;
                    }
                }
                Some(Binding::Loop(ElemKind::Scalar(kind))) => {
                    errors.push(Diagnostic::new(
                        format!(
                            "`{head}` is a `{}`, not a record; it has no fields",
                            kind.rust_name()
                        ),
                        *span,
                    ));
                    ok = false;
                }
                Some(_) => {
                    errors.push(Diagnostic::new(
                        format!(
                            "`{head}` is not a record; field access works on \
                             record-typed `for` loop variables"
                        ),
                        *span,
                    ));
                    ok = false;
                }
                None => {
                    errors.push(Diagnostic::new(
                        format!("`{head}` is not a declared name"),
                        *span,
                    ));
                    ok = false;
                }
            }
            continue;
        }
        let name = head;
        match scope.get(name) {
            Some(Binding::Prop(_) | Binding::State(_) | Binding::Loop(_) | Binding::LoopIndex) => {}
            // A bare `event` is the whole pseudo-record, which is not a value
            // anything can take — records do not cross wires, and this one has
            // no type to cross as.
            Some(Binding::Event(shape)) => {
                errors.push(Diagnostic::new(
                    format!(
                        "`event` is not a value; read one of its fields ({})",
                        shape
                            .fields()
                            .iter()
                            .map(|(f, _)| format!("event.{f}"))
                            .collect::<Vec<_>>()
                            .join(", ")
                    ),
                    *span,
                ));
                ok = false;
            }
            Some(Binding::Handler) => {
                errors.push(Diagnostic::new(
                    format!("`{name}` is a handler; handlers only wire to `:on-…` events"),
                    *span,
                ));
                ok = false;
            }
            Some(Binding::Output) => {
                errors.push(Diagnostic::new(
                    format!("`{name}` is an output; outputs cannot be read in bindings"),
                    *span,
                ));
                ok = false;
            }
            None => {
                errors.push(Diagnostic::new(
                    format!("`{name}` is not a declared prop or state"),
                    *span,
                ));
                ok = false;
            }
        }
    }
    ok
}

/// Verify a brush-valued expression: string leaves must parse as colors;
/// only literals, `if`, and boolean machinery over props/states are allowed.
fn check_color_expr(value: &Expr, errors: &mut Vec<Diagnostic>) -> bool {
    match value {
        Expr::Str(template, span) => {
            if template.refs().next().is_some() {
                errors.push(Diagnostic::new(
                    "color values cannot be interpolated strings",
                    *span,
                ));
                return false;
            }
            let text: String = template
                .segments
                .iter()
                .map(|s| match s {
                    crate::expr::Segment::Literal(l) => l.as_str(),
                    crate::expr::Segment::Ref(..) => unreachable!(),
                })
                .collect();
            if parse_color(&text).is_none() {
                errors.push(Diagnostic::new(
                    format!("`{text}` is not a color; expected `#rgb`, `#rrggbb`, or `#rrggbbaa`"),
                    *span,
                ));
                return false;
            }
            true
        }
        Expr::If(c, t, e, _) => {
            // The condition is an ordinary boolean expression; branches must
            // be colors.
            let _ = c;
            check_color_expr(t, errors) && check_color_expr(e, errors)
        }
        Expr::Path(_, span) => {
            errors.push(Diagnostic::new(
                "a color expression is built from color literals and `if`; \
                 color-typed props and states are a planned extension",
                *span,
            ));
            false
        }
        other => {
            errors.push(Diagnostic::new(
                "expected a color literal or an `if` choosing between colors",
                other.span(),
            ));
            false
        }
    }
}

/// Resolve an event wire: a bare handler name, or — on events without a
/// widget-supplied payload — an invocation form `(handler arg…)` whose
/// arguments evaluate at dispatch time in the node's scope.
fn event_wire(
    event: registry::EventProp,
    value: &Expr,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
    handlers: &[crate::ast::HandlerDecl],
) -> Result<(String, Vec<Expr>), Diagnostic> {
    let expected = event.payload().map(|p| match p {
        registry::PayloadKind::String => TypeKind::String,
        registry::PayloadKind::Bool => TypeKind::Bool,
        registry::PayloadKind::Number => TypeKind::F64,
    });
    // The pointer family delivers a `PointerEvent` and `:on-key` a keystroke,
    // so `event` is readable in their arguments; a semantic wire
    // (`:on-change`, `:on-select`) has neither behind it.
    let shape = match event {
        registry::EventProp::Click
        | registry::EventProp::CountedClick
        | registry::EventProp::PointerEnter
        | registry::EventProp::PointerLeave
        | registry::EventProp::PointerDown
        | registry::EventProp::PointerUp => Some(EventShape::Pointer),
        registry::EventProp::Key => Some(EventShape::Key),
        registry::EventProp::Scrolled => Some(EventShape::Scroll),
        registry::EventProp::Changed
        | registry::EventProp::Close
        | registry::EventProp::Toggled
        | registry::EventProp::Select
        | registry::EventProp::Link
        | registry::EventProp::FileDrop
        | registry::EventProp::FocusChange
        | registry::EventProp::ValueChanged => None,
    };
    invocation_wire(value, expected, shape, scope, records, handlers)
}

/// Resolve any wire — a builtin event's or a component instance output's:
/// a bare handler name, or an invocation form whose
/// arguments evaluate at dispatch time. On wires that deliver a payload,
/// the reserved name `payload` refers to it inside the arguments, typed as
/// the delivery.
fn invocation_wire(
    value: &Expr,
    expected: Option<TypeKind>,
    event: Option<EventShape>,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
    handlers: &[crate::ast::HandlerDecl],
) -> Result<(String, Vec<Expr>), Diagnostic> {
    if let Expr::Call(name, args, span) = value {
        let payloads = handler_payloads(name, *span, scope, handlers)?;
        if payloads.len() != args.len() {
            return Err(Diagnostic::new(
                format!(
                    "`{name}` is declared with {} payload(s) but is invoked \
                     with {} argument(s)",
                    payloads.len(),
                    args.len()
                ),
                *span,
            ));
        }
        // Two values a wire's arguments may read, each where it exists:
        // `payload` on wires that deliver one, `event` on the wires that have
        // one behind them.
        let mut arg_scope = scope.clone();
        if let Some(kind) = expected {
            arg_scope.insert("payload".to_owned(), Binding::Loop(ElemKind::Scalar(kind)));
        }
        if let Some(shape) = event {
            arg_scope.insert("event".to_owned(), Binding::Event(shape));
        }
        let scope = &arg_scope;
        for (arg, want) in args.iter().zip(payloads.iter()) {
            if expected.is_none()
                && let Some(span) = references_payload(arg)
            {
                return Err(Diagnostic::new(
                    "`payload` is only available on wires that deliver one \
                     (`:on-change`, instance outputs); this event carries \
                     nothing",
                    span,
                ));
            }
            if event.is_none()
                && let Some(span) = references_named(arg, "event")
            {
                return Err(Diagnostic::new(
                    format!(
                        "`event` is only available on {EVENT_WIRES}; this wire \
                         is neither"
                    ),
                    span,
                ));
            }
            let mut errors = Vec::new();
            if !resolve_expr_refs(arg, scope, records, &mut errors)
                && let Some(first) = errors.into_iter().next()
            {
                return Err(first);
            }
            let fits = if arg.is_literal() {
                literal_fits(ValueTy::scalar(*want), arg, &[])
            } else if let Some(kind) = path_type(arg, scope, records) {
                type_fits(kind, *want)
            } else {
                true
            };
            if !fits {
                return Err(Diagnostic::new(
                    format!(
                        "this argument does not fit the declared {} payload",
                        want.rust_name()
                    ),
                    arg.span(),
                ));
            }
        }
        return Ok((name.clone(), args.clone()));
    }
    let name = wired_handler(value, expected, scope, handlers)?;
    Ok((name, Vec::new()))
}

/// The span of the first `payload` reference in an expression, if any.
fn references_payload(value: &Expr) -> Option<crate::sexpr::Span> {
    references_named(value, "payload")
}

/// Where an expression reads a reserved name, whole or by field.
fn references_named(value: &Expr, reserved: &str) -> Option<crate::sexpr::Span> {
    let mut refs = Vec::new();
    value.referenced_names(&mut refs);
    let prefix = format!("{reserved}.");
    refs.into_iter()
        .find(|(name, _)| name == reserved || name.starts_with(&prefix))
        .map(|(_, span)| span)
}

/// Whether a value of a known type can be delivered where another is wanted:
/// exactly, or across the widths that coerce anyway — integer and float
/// widths survive the dyn bridge and inference on the typed path, so
/// rejecting them here would reject code that compiles.
///
/// Both places that know a value's type and want another consult this: an
/// event wire's arguments and a `.gdw`-declared property's binding.
fn type_fits(have: TypeKind, want: TypeKind) -> bool {
    have == want
        || matches!(
            (have, want),
            (TypeKind::I32 | TypeKind::I64, TypeKind::I32 | TypeKind::I64)
                | (TypeKind::F32 | TypeKind::F64, TypeKind::F32 | TypeKind::F64)
        )
}

/// The exact scalar type of a plain name or field-access expression, when
/// knowable from the scope.
fn path_type(
    value: &Expr,
    scope: &HashMap<String, Binding>,
    records: &[IrRecord],
) -> Option<TypeKind> {
    let Expr::Path(segments, _) = value else {
        return None;
    };
    let binding = scope.get(segments[0].as_str())?;
    match (binding, segments.get(1)) {
        (Binding::Prop(ty) | Binding::State(ty), None) if !ty.list => match ty.kind {
            ElemKind::Scalar(kind) => Some(kind),
            ElemKind::Record(_) => None,
        },
        (Binding::Loop(ElemKind::Scalar(kind)), None) => Some(*kind),
        (Binding::LoopIndex, None) => Some(TypeKind::I64),
        (Binding::Loop(ElemKind::Record(index)), Some(field)) => records
            .get(*index as usize)?
            .fields
            .iter()
            .find(|(name, _)| name == field)
            .map(|(_, kind)| *kind),
        (Binding::Event(shape), Some(field)) => shape.field(field),
        _ => None,
    }
}

/// The declared payload types of a handler named in an event wire.
fn handler_payloads(
    name: &str,
    span: crate::sexpr::Span,
    scope: &HashMap<String, Binding>,
    handlers: &[crate::ast::HandlerDecl],
) -> Result<Vec<TypeKind>, Diagnostic> {
    match scope.get(name) {
        Some(Binding::Handler) => Ok(handlers
            .iter()
            .find(|h| h.name.name == name)
            .map(|h| {
                h.payloads
                    .iter()
                    .map(|ty| match &ty.kind {
                        crate::ast::TyKind::Scalar(kind) => *kind,
                        crate::ast::TyKind::Named(_) => TypeKind::I64,
                    })
                    .collect()
            })
            .unwrap_or_default()),
        Some(_) => Err(Diagnostic::new(
            format!("`{name}` is not a handler; `:on-…` events take a declared `(handler …)` name"),
            span,
        )),
        None => Err(Diagnostic::new(
            format!("`{name}` is not a declared handler"),
            span,
        )),
    }
}

/// Resolve a bare wired handler name and check the payload contract — the
/// handler's declared payload must match what the wire delivers (a builtin
/// event's payload, or a child component's output type), in both directions.
fn wired_handler(
    value: &Expr,
    expected: Option<TypeKind>,
    scope: &HashMap<String, Binding>,
    handlers: &[crate::ast::HandlerDecl],
) -> Result<String, Diagnostic> {
    let Expr::Path(segments, span) = value else {
        return Err(Diagnostic::new(
            "events take a bare handler name (or an invocation like \
             `(remove todo.id)` on payload-free events)",
            value.span(),
        ));
    };
    if segments.len() != 1 {
        return Err(Diagnostic::new(
            "events take a bare handler name, e.g. `:on-click increment`",
            *span,
        ));
    }
    let name = &segments[0];
    let payloads = handler_payloads(name, *span, scope, handlers)?;
    let payload = match payloads.as_slice() {
        [] => None,
        [one] => Some(*one),
        _ => {
            return Err(Diagnostic::new(
                format!(
                    "`{name}` takes {} payloads; wire it with an invocation \
                     form supplying its arguments",
                    payloads.len()
                ),
                *span,
            ));
        }
    };
    match (expected, payload) {
        (None, None) => Ok(name.clone()),
        (Some(want), Some(have)) if want == have => Ok(name.clone()),
        (Some(want), Some(have)) => Err(Diagnostic::new(
            format!(
                "this event delivers a {} payload, but `{name}` is declared \
                 `(handler {name} {})`",
                want.rust_name(),
                have.rust_name()
            ),
            *span,
        )),
        (Some(want), None) => Err(Diagnostic::new(
            format!(
                "this event carries a {} payload; declare the handler as \
                 `(handler {name} {})`",
                want.rust_name(),
                want.rust_name()
            ),
            *span,
        )),
        (None, Some(have)) => Err(Diagnostic::new(
            format!(
                "`{name}` is declared with a {} payload, but this event \
                 carries none; invoke it with an argument — `({name} expr)` \
                 — or declare it as `(handler {name})`",
                have.rust_name()
            ),
            *span,
        )),
    }
}

fn lower_layout_prop(
    prop: LayoutProp,
    value: &Expr,
    style: &mut StyleIr,
    errors: &mut Vec<Diagnostic>,
) {
    if !value.is_literal() && !matches!(value, Expr::Path(..)) {
        errors.push(Diagnostic::new(
            "layout properties take literal values; reactive layout comes from \
             the style engine, not from bindings",
            value.span(),
        ));
        return;
    }
    match prop {
        LayoutProp::Width => style.width = dimension(value, errors),
        LayoutProp::Height => style.height = dimension(value, errors),
        LayoutProp::Basis => style.basis = dimension(value, errors),
        LayoutProp::Padding => style.padding = number(value, errors),
        LayoutProp::Gap => style.gap = number(value, errors),
        LayoutProp::Grow => style.grow = number(value, errors),
        LayoutProp::Shrink => style.shrink = number(value, errors),
        LayoutProp::Direction => {
            style.direction = keyword_of(
                value,
                &[("row", DirectionIr::Row), ("column", DirectionIr::Column)],
                errors,
            );
        }
        LayoutProp::AlignItems => style.align_items = alignment(value, errors),
        LayoutProp::JustifyContent => style.justify_content = alignment(value, errors),
        LayoutProp::AlignSelf => style.align_self = alignment(value, errors),
    }
}

fn dimension(value: &Expr, errors: &mut Vec<Diagnostic>) -> Option<DimIr> {
    match value {
        Expr::Int(v, _) => Some(DimIr::Px(*v as f32)),
        Expr::Float(v, _) => Some(DimIr::Px(*v as f32)),
        Expr::Path(segments, _) if segments.len() == 1 && segments[0] == "auto" => {
            Some(DimIr::Auto)
        }
        Expr::Str(template, span) => {
            let text: String = template
                .segments
                .iter()
                .filter_map(|s| match s {
                    crate::expr::Segment::Literal(l) => Some(l.as_str()),
                    crate::expr::Segment::Ref(..) => None,
                })
                .collect();
            let percent = text.strip_suffix('%').and_then(|n| n.parse::<f32>().ok());
            match percent {
                Some(p) => Some(DimIr::Percent(p / 100.0)),
                None => {
                    errors.push(Diagnostic::new(
                        format!(
                            "`{text}` is not a dimension; use a number (px), `\"N%\"`, or `auto`"
                        ),
                        *span,
                    ));
                    None
                }
            }
        }
        other => {
            errors.push(Diagnostic::new(
                "expected a dimension: a number (px), `\"N%\"`, or `auto`",
                other.span(),
            ));
            None
        }
    }
}

fn number(value: &Expr, errors: &mut Vec<Diagnostic>) -> Option<f32> {
    match value {
        Expr::Int(v, _) => Some(*v as f32),
        Expr::Float(v, _) => Some(*v as f32),
        other => {
            errors.push(Diagnostic::new("expected a number", other.span()));
            None
        }
    }
}

fn alignment(value: &Expr, errors: &mut Vec<Diagnostic>) -> Option<AlignIr> {
    keyword_of(
        value,
        &[
            ("start", AlignIr::Start),
            ("flex-start", AlignIr::Start),
            ("end", AlignIr::End),
            ("flex-end", AlignIr::End),
            ("center", AlignIr::Center),
            ("stretch", AlignIr::Stretch),
        ],
        errors,
    )
}

fn keyword_of<T: Copy>(
    value: &Expr,
    options: &[(&str, T)],
    errors: &mut Vec<Diagnostic>,
) -> Option<T> {
    if let Expr::Path(segments, span) = value {
        if segments.len() == 1 {
            for (name, result) in options {
                if segments[0] == *name {
                    return Some(*result);
                }
            }
            errors.push(Diagnostic::new(
                format!(
                    "`{}` is not valid here; expected one of: {}",
                    segments[0],
                    options
                        .iter()
                        .map(|(n, _)| *n)
                        .collect::<Vec<_>>()
                        .join(", ")
                ),
                *span,
            ));
            return None;
        }
    }
    errors.push(Diagnostic::new(
        format!(
            "expected one of: {}",
            options
                .iter()
                .map(|(n, _)| *n)
                .collect::<Vec<_>>()
                .join(", ")
        ),
        value.span(),
    ));
    None
}

fn literal(expr: &Expr, what: &str, errors: &mut Vec<Diagnostic>) -> Option<Literal> {
    match expr {
        Expr::RecordLit(name, fields, _) => {
            let values: Option<Vec<(String, Literal)>> = fields
                .iter()
                .map(|(field, value)| Some((field.clone(), literal(value, what, errors)?)))
                .collect();
            Some(Literal::Record {
                name: name.clone(),
                fields: values?,
            })
        }
        Expr::List(items, _) => {
            let elements: Option<Vec<Literal>> = items
                .iter()
                .map(|item| literal(item, what, errors))
                .collect();
            Some(Literal::List(elements?))
        }
        Expr::Int(v, _) => Some(Literal::Int(*v)),
        Expr::Float(v, _) => Some(Literal::Float(*v)),
        Expr::Bool(v, _) => Some(Literal::Bool(*v)),
        Expr::Str(template, span) => {
            if template.refs().next().is_some() {
                errors.push(Diagnostic::new(
                    format!("{what} cannot use interpolation"),
                    *span,
                ));
                return None;
            }
            Some(Literal::Str(
                template
                    .segments
                    .iter()
                    .filter_map(|s| match s {
                        crate::expr::Segment::Literal(l) => Some(l.as_str()),
                        crate::expr::Segment::Ref(..) => None,
                    })
                    .collect(),
            ))
        }
        other => {
            errors.push(Diagnostic::new(
                format!("{what} must be a literal value"),
                other.span(),
            ));
            None
        }
    }
}