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
}
}
}