//! 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 { 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::>() .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, registry: &Registry, ) -> Result> { let mut errors = Vec::new(); let mut scope: HashMap = HashMap::new(); // Records first: types below may reference them. let mut records: Vec = 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| -> 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| { 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 = 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, /// 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, /// 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, /// 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, autofocus_seen: Option, slot_seen: Option, /// Component types already instantiated (factory-name collision and /// duplicate checks run once per type). instantiated: Vec, /// `(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, } /// 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::>() .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, records: &[IrRecord], out: &mut Vec, errors: &mut Vec, ) { let push = |out: &mut Vec, 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 { 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, records: &[IrRecord], errors: &mut Vec, 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, records: &[IrRecord], ir: &mut IrNode, errors: &mut Vec, ) { 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) { 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) -> 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) -> 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 { 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 { 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, records: &[IrRecord], errors: &mut Vec, ) -> 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::>() .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::>() .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) -> 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, records: &[IrRecord], handlers: &[crate::ast::HandlerDecl], ) -> Result<(String, Vec), 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, event: Option, scope: &HashMap, records: &[IrRecord], handlers: &[crate::ast::HandlerDecl], ) -> Result<(String, Vec), 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 { references_named(value, "payload") } /// Where an expression reads a reserved name, whole or by field. fn references_named(value: &Expr, reserved: &str) -> Option { 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, records: &[IrRecord], ) -> Option { 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, handlers: &[crate::ast::HandlerDecl], ) -> Result, 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, scope: &HashMap, handlers: &[crate::ast::HandlerDecl], ) -> Result { 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, ) { 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) -> Option { 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::().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) -> Option { 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) -> Option { 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( value: &Expr, options: &[(&str, T)], errors: &mut Vec, ) -> Option { 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::>() .join(", ") ), *span, )); return None; } } errors.push(Diagnostic::new( format!( "expected one of: {}", options .iter() .map(|(n, _)| *n) .collect::>() .join(", ") ), value.span(), )); None } fn literal(expr: &Expr, what: &str, errors: &mut Vec) -> Option { match expr { Expr::RecordLit(name, fields, _) => { let values: Option> = 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> = 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 } } }