//! The binding expression language: s-expression data interpreted as a //! small, pure expression grammar, plus string interpolation templates. //! //! Grammar (all forms are sexpr lists, so spans come from the reader): //! literals: `42`, `2.5`, `true`, `false`, `"text"`, `"{name} text"` //! paths: `count`, `form.busy` //! unary: `(not x)`, `(- x)` //! binary: `(+ a b c…)` `- * / %` `< <= > >=` `= !=` `and or` //! (n-ary forms left-fold) //! choice: `(if cond then else)` //! //! No loops, no calls: real logic lives in Rust behind named handlers. use crate::diagnostics::Diagnostic; use crate::ir::Literal; use crate::sexpr::{Sexpr, Span}; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BinOp { Add, Sub, Mul, Div, Rem, Lt, Le, Gt, Ge, Eq, Ne, And, Or, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum UnOp { Not, Neg, } #[derive(Clone, Debug, PartialEq)] pub enum Expr { Int(i64, Span), Float(f64, Span), Bool(bool, Span), /// A string literal, possibly with `{name}` interpolations. Str(Template, Span), /// `count`, or a record field access like `todo.label` (one level: /// records are flat). Path(Vec, Span), Unary(UnOp, Box, Span), Binary(BinOp, Box, Box, Span), If(Box, Box, Box, Span), /// `(list a b c)` — a list of element expressions. List(Vec, Span), /// `(name arg…)` with a non-operator lowercase head: a handler /// invocation. Only valid as an event-wire value; validation rejects /// it anywhere else. Call(String, Vec, Span), /// `(Todo :id 1 :label "hi")` — a record literal (capitalized head, /// keyword fields), mirroring instance syntax. RecordLit(String, Vec<(String, Expr)>, Span), /// A recognized data form — `(path …)`, `(svg-path "…")`, `(asset "…")` — /// already reduced to its literal by the reader. /// /// These forms are *data*, not computation: they are read straight from /// the s-expression by the same parser the theme language uses, which is /// why they arrive here already finished rather than as a `Call` to be /// interpreted later. One parser per form, two surface languages. Form(Literal, Span), } impl Expr { pub fn span(&self) -> Span { match self { Expr::Int(_, s) | Expr::Float(_, s) | Expr::Bool(_, s) | Expr::Str(_, s) | Expr::Path(_, s) | Expr::Unary(_, _, s) | Expr::Binary(_, _, _, s) | Expr::If(_, _, _, s) | Expr::List(_, s) | Expr::Call(_, _, s) | Expr::RecordLit(_, _, s) | Expr::Form(_, s) => *s, } } /// Whether the expression is a compile-time constant (no paths). pub fn is_literal(&self) -> bool { match self { Expr::Int(..) | Expr::Float(..) | Expr::Bool(..) => true, Expr::Str(template, _) => template.refs().next().is_none(), Expr::Path(..) => false, Expr::Unary(_, inner, _) => inner.is_literal(), Expr::Binary(_, lhs, rhs, _) => lhs.is_literal() && rhs.is_literal(), Expr::If(c, t, e, _) => c.is_literal() && t.is_literal() && e.is_literal(), Expr::List(items, _) => items.iter().all(Expr::is_literal), Expr::Call(..) => false, Expr::RecordLit(_, fields, _) => fields.iter().all(|(_, v)| v.is_literal()), // A data form is finished at read time; that is the whole point. Expr::Form(..) => true, } } /// All names referenced by the expression (paths and interpolations). pub fn referenced_names(&self, out: &mut Vec<(String, Span)>) { match self { Expr::Int(..) | Expr::Float(..) | Expr::Bool(..) => {} Expr::Str(template, _) => { for (name, span) in template.refs() { out.push((name.to_owned(), span)); } } Expr::Path(segments, span) => out.push((segments.join("."), *span)), Expr::Unary(_, inner, _) => inner.referenced_names(out), Expr::Binary(_, lhs, rhs, _) => { lhs.referenced_names(out); rhs.referenced_names(out); } Expr::If(c, t, e, _) => { c.referenced_names(out); t.referenced_names(out); e.referenced_names(out); } Expr::List(items, _) => { for item in items { item.referenced_names(out); } } // A data form is closed: an asset path is fixed at build time and // a path's coordinates are numbers, so neither reads a name. Expr::Form(..) => {} // A call's head is a handler, not a value reference; only the // arguments read names. Expr::Call(_, args, _) => { for arg in args { arg.referenced_names(out); } } Expr::RecordLit(_, fields, _) => { for (_, value) in fields { value.referenced_names(out); } } } } } /// A string with `{name}` interpolations. `{{` escapes a literal brace. #[derive(Clone, Debug, PartialEq)] pub struct Template { pub segments: Vec, } #[derive(Clone, Debug, PartialEq)] pub enum Segment { Literal(String), /// An interpolated name, with its span in the source file. Ref(String, Span), } impl Template { pub fn refs(&self) -> impl Iterator { self.segments.iter().filter_map(|s| match s { Segment::Ref(name, span) => Some((name.as_str(), *span)), Segment::Literal(_) => None, }) } } /// Interpret one sexpr value as an expression. pub fn from_sexpr(value: &Sexpr, source: &str) -> Result { match value { Sexpr::Int(v, span) => Ok(Expr::Int(*v, *span)), Sexpr::Float(v, span) => Ok(Expr::Float(*v, *span)), Sexpr::Str(_, span) => Ok(Expr::Str(parse_template(*span, source)?, *span)), Sexpr::Symbol(name, span) => match name.as_str() { "true" => Ok(Expr::Bool(true, *span)), "false" => Ok(Expr::Bool(false, *span)), _ => Ok(Expr::Path( name.split('.').map(str::to_owned).collect(), *span, )), }, Sexpr::Keyword(name, span) => Err(Diagnostic::new( format!("expected an expression, found keyword `:{name}`"), *span, )), Sexpr::List(items, span) => from_list(items, *span, source), } } fn from_list(items: &[Sexpr], span: Span, source: &str) -> Result { let Some(head) = items.first() else { return Err(Diagnostic::new("empty list is not an expression", span)); }; let Some(op) = head.as_symbol() else { return Err(Diagnostic::new( format!( "an expression list must start with an operator symbol, found {}", head.kind_name() ), head.span(), )); }; // Data forms first: `(path …)` and `(asset …)` are values the shared // s-expression parsers read whole, not operators to evaluate. if let Some(result) = crate::path::parse_path_form(items, span) { return result.map(|cmds| Expr::Form(Literal::Path(cmds), span)); } if let Some(result) = crate::asset::parse_asset_form(items, span) { return result.map(|path| Expr::Form(Literal::Asset(path), span)); } if let Some(result) = crate::rich::parse_rich_form(items, span) { return result.map(|runs| Expr::Form(Literal::Rich(runs), span)); } let args = &items[1..]; match op { "if" => { if args.len() != 3 { return Err(Diagnostic::new( format!( "`if` takes exactly 3 arguments (condition, then, else), found {}", args.len() ), span, )); } Ok(Expr::If( Box::new(from_sexpr(&args[0], source)?), Box::new(from_sexpr(&args[1], source)?), Box::new(from_sexpr(&args[2], source)?), span, )) } "list" => { let items = args .iter() .map(|item| from_sexpr(item, source)) .collect::, _>>()?; Ok(Expr::List(items, span)) } "not" => { if args.len() != 1 { return Err(Diagnostic::new( format!("`not` takes exactly 1 argument, found {}", args.len()), span, )); } Ok(Expr::Unary( UnOp::Not, Box::new(from_sexpr(&args[0], source)?), span, )) } "-" if args.len() == 1 => Ok(Expr::Unary( UnOp::Neg, Box::new(from_sexpr(&args[0], source)?), span, )), _ => { let Some(op_kind) = binary_op(op) else { // A capitalized head is a record literal (keyword fields, // mirroring instance syntax); a lowercase one is a handler // invocation. Whether either is legal here — and whether // the names resolve — is validation's decision. if op.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { let mut fields = Vec::new(); let mut rest = args.iter(); while let Some(item) = rest.next() { let Sexpr::Keyword(name, kw_span) = item else { return Err(Diagnostic::new( "record literals take `:field value` pairs", item.span(), )); }; let value = rest.next().ok_or_else(|| { Diagnostic::new(format!("`:{name}` needs a value"), *kw_span) })?; fields.push((name.clone(), from_sexpr(value, source)?)); } return Ok(Expr::RecordLit(op.to_owned(), fields, span)); } let args_exprs = args .iter() .map(|arg| from_sexpr(arg, source)) .collect::, _>>()?; return Ok(Expr::Call(op.to_owned(), args_exprs, span)); }; let op = op_kind; if args.len() < 2 { return Err(Diagnostic::new( "binary operators take at least 2 arguments", span, )); } let mut expr = from_sexpr(&args[0], source)?; for arg in &args[1..] { let rhs = from_sexpr(arg, source)?; expr = Expr::Binary(op, Box::new(expr), Box::new(rhs), span); } Ok(expr) } } } fn binary_op(symbol: &str) -> Option { Some(match symbol { "+" => BinOp::Add, "-" => BinOp::Sub, "*" => BinOp::Mul, "/" => BinOp::Div, "%" => BinOp::Rem, "<" => BinOp::Lt, "<=" => BinOp::Le, ">" => BinOp::Gt, ">=" => BinOp::Ge, "=" => BinOp::Eq, "!=" => BinOp::Ne, "and" => BinOp::And, "or" => BinOp::Or, _ => return None, }) } /// Scan a string literal for `{name}` interpolations. /// /// Works on the raw source slice (the string's span, quotes included) so /// each `Ref` carries a file-accurate span for diagnostics; escapes are /// processed inline, matching the reader's escape set. fn parse_template(span: Span, source: &str) -> Result { let raw = &source[span.start + 1..span.end - 1]; let base = span.start + 1; let bytes = raw.as_bytes(); let mut segments = Vec::new(); let mut literal = String::new(); let mut i = 0; while i < bytes.len() { match bytes[i] { b'\\' => { // The reader validated the escape already. let escaped = match bytes.get(i + 1) { Some(b'n') => '\n', Some(b't') => '\t', Some(b'\\') => '\\', Some(b'"') => '"', Some(b'{') => '{', _ => unreachable!("reader validated escapes"), }; literal.push(escaped); i += 2; } b'{' if bytes.get(i + 1) == Some(&b'{') => { literal.push('{'); i += 2; } b'{' => { let open = i; let close = raw[i..].find('}').map(|off| i + off).ok_or_else(|| { Diagnostic::new( "unclosed `{` in string interpolation (use `{{` or `\\{` for a literal brace)", Span::new(base + open, base + open + 1), ) })?; let name = raw[i + 1..close].trim(); let name_span = Span::new(base + i + 1, base + close); if name.is_empty() { return Err(Diagnostic::new("empty interpolation `{}`", name_span)); } if !name .chars() .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') { return Err(Diagnostic::new( format!( "interpolations hold a name like `count` or `todo.label`, found `{name}`" ), name_span, )); } if !literal.is_empty() { segments.push(Segment::Literal(std::mem::take(&mut literal))); } segments.push(Segment::Ref(name.to_owned(), name_span)); i = close + 1; } b'}' if bytes.get(i + 1) == Some(&b'}') => { literal.push('}'); i += 2; } _ => { let ch = raw[i..].chars().next().expect("in bounds"); literal.push(ch); i += ch.len_utf8(); } } } if !literal.is_empty() { segments.push(Segment::Literal(literal)); } Ok(Template { segments }) } #[cfg(test)] mod tests;