expr.rs
raw
//! 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<String>, Span),
Unary(UnOp, Box<Expr>, Span),
Binary(BinOp, Box<Expr>, Box<Expr>, Span),
If(Box<Expr>, Box<Expr>, Box<Expr>, Span),
/// `(list a b c)` — a list of element expressions.
List(Vec<Expr>, 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<Expr>, 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<Segment>,
}
#[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<Item = (&str, Span)> {
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<Expr, Diagnostic> {
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<Expr, Diagnostic> {
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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<BinOp> {
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<Template, Diagnostic> {
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;