@@ -0,0 +1,451 @@
1+//! Expressions: conditions (`@if $count > 2 and not $hidden`), computed
2+//! values (`@let gap = $base * 2`) and `if(cond, a, b)`.
3+//!
4+//! Expressions are tokenized and evaluated as a whole. Variables are looked
5+//! up while evaluating, never pasted into the text first, so a value that
6+//! happens to contain `==` or spaces can't change how an expression parses.
7+//!
8+//! ```text
9+//! expr := or
10+//! or := and ("or" and)*
11+//! and := not ("and" not)*
12+//! not := "not" not | compare
13+//! compare := concat (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
14+//! | "starts-with" | "ends-with") concat)?
15+//! concat := sum ("~" sum)*
16+//! sum := product (("+" | "-") product)*
17+//! product := unary (("*" | "/" | "%") unary)*
18+//! unary := "-" unary | primary
19+//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
20+//! | if(expr, expr, expr) | "(" expr ")"
21+//! ```
22+//!
23+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string.
24+
25+use std::fmt;
26+
27+#[derive(Debug, Clone, PartialEq)]
28+pub enum Value {
29+ Num(f64),
30+ Str(String),
31+ Bool(bool),
32+}
33+
34+impl Value {
35+ /// Empty strings, `false`, `0` and `"false"` / `"0"` are false.
36+ pub fn truthy(&self) -> bool {
37+ match self {
38+ Value::Bool(b) => *b,
39+ Value::Num(n) => *n != 0.0,
40+ Value::Str(s) => !s.is_empty() && s != "false" && s != "0",
41+ }
42+ }
43+
44+ fn as_num(&self) -> Option<f64> {
45+ match self {
46+ Value::Num(n) => Some(*n),
47+ Value::Str(s) => s.trim().parse().ok(),
48+ Value::Bool(_) => None,
49+ }
50+ }
51+}
52+
53+impl fmt::Display for Value {
54+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55+ match self {
56+ Value::Num(n) if n.fract() == 0.0 && n.abs() < 1e15 => write!(f, "{}", *n as i64),
57+ Value::Num(n) => write!(f, "{}", n),
58+ Value::Str(s) => f.write_str(s),
59+ Value::Bool(b) => write!(f, "{}", b),
60+ }
61+ }
62+}
63+
64+/// Resolves a variable reference: the text after `$`, including any
65+/// filters (`name|uppercase`). Returns `None` for undefined variables.
66+pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
67+
68+/// Evaluate `src`. Undefined variables are empty strings.
69+pub fn eval(src: &str, resolve: Resolver) -> Result<Value, String> {
70+ let tokens = tokenize(src)?;
71+ let mut parser = Parser {
72+ tokens: &tokens,
73+ pos: 0,
74+ resolve,
75+ };
76+ let value = parser.expr()?;
77+ match parser.tokens.get(parser.pos) {
78+ None => Ok(value),
79+ Some(tok) => Err(format!("unexpected {} in `{}`", tok, src.trim())),
80+ }
81+}
82+
83+#[derive(Debug, Clone, PartialEq)]
84+enum Token {
85+ Num(f64),
86+ Str(String),
87+ Var(String),
88+ Word(String),
89+ Op(&'static str),
90+ LParen,
91+ RParen,
92+ Comma,
93+}
94+
95+impl fmt::Display for Token {
96+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97+ match self {
98+ Token::Num(n) => write!(f, "`{}`", n),
99+ Token::Str(s) => write!(f, "`\"{}\"`", s),
100+ Token::Var(v) => write!(f, "`${}`", v),
101+ Token::Word(w) => write!(f, "`{}`", w),
102+ Token::Op(op) => write!(f, "`{}`", op),
103+ Token::LParen => f.write_str("`(`"),
104+ Token::RParen => f.write_str("`)`"),
105+ Token::Comma => f.write_str("`,`"),
106+ }
107+ }
108+}
109+
110+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "~"];
111+
112+fn tokenize(src: &str) -> Result<Vec<Token>, String> {
113+ let chars: Vec<char> = src.chars().collect();
114+ let mut tokens = Vec::new();
115+ let mut i = 0;
116+ let is_word_char = |c: char| c.is_alphanumeric() || matches!(c, '#' | '.' | '_' | '-' | '%');
117+ while i < chars.len() {
118+ let c = chars[i];
119+ if c.is_whitespace() {
120+ i += 1;
121+ } else if c == '(' {
122+ tokens.push(Token::LParen);
123+ i += 1;
124+ } else if c == ')' {
125+ tokens.push(Token::RParen);
126+ i += 1;
127+ } else if c == ',' {
128+ tokens.push(Token::Comma);
129+ i += 1;
130+ } else if c == '"' {
131+ let start = i + 1;
132+ let end = chars[start..]
133+ .iter()
134+ .position(|&c| c == '"')
135+ .map(|p| start + p)
136+ .ok_or_else(|| format!("unclosed string in `{}`", src.trim()))?;
137+ tokens.push(Token::Str(chars[start..end].iter().collect()));
138+ i = end + 1;
139+ } else if c == '$' {
140+ // $name, with optional |filter:args chains
141+ let start = i + 1;
142+ let mut end = start;
143+ while end < chars.len()
144+ && !chars[end].is_whitespace()
145+ && !matches!(chars[end], '(' | ')' | ',' | '"')
146+ {
147+ end += 1;
148+ }
149+ if end == start {
150+ return Err(format!("`$` without a variable name in `{}`", src.trim()));
151+ }
152+ tokens.push(Token::Var(chars[start..end].iter().collect()));
153+ i = end;
154+ } else if let Some(op) = OPERATORS
155+ .iter()
156+ .find(|op| chars[i..].iter().take(op.len()).copied().eq(op.chars()))
157+ // A `-` followed by a letter starts a word (`-webkit-box`);
158+ // inside words (`ease-in`) it is consumed by the word itself.
159+ .filter(|op| **op != "-" || !chars.get(i + 1).is_some_and(|c| c.is_alphabetic()))
160+ {
161+ tokens.push(Token::Op(op));
162+ i += op.len();
163+ } else if is_word_char(c) {
164+ let start = i;
165+ while i < chars.len() && is_word_char(chars[i]) {
166+ i += 1;
167+ }
168+ let word: String = chars[start..i].iter().collect();
169+ tokens.push(match word.parse::<f64>() {
170+ Ok(n) if !word.ends_with('.') => Token::Num(n),
171+ _ => Token::Word(word),
172+ });
173+ } else {
174+ return Err(format!("unexpected `{}` in `{}`", c, src.trim()));
175+ }
176+ }
177+ Ok(tokens)
178+}
179+
180+struct Parser<'a> {
181+ tokens: &'a [Token],
182+ pos: usize,
183+ resolve: Resolver<'a>,
184+}
185+
186+impl Parser<'_> {
187+ fn peek(&self) -> Option<&Token> {
188+ self.tokens.get(self.pos)
189+ }
190+
191+ fn eat_word(&mut self, word: &str) -> bool {
192+ if matches!(self.peek(), Some(Token::Word(w)) if w == word) {
193+ self.pos += 1;
194+ true
195+ } else {
196+ false
197+ }
198+ }
199+
200+ fn eat_op(&mut self, ops: &[&'static str]) -> Option<&'static str> {
201+ match self.peek() {
202+ Some(Token::Op(op)) if ops.contains(op) => {
203+ let op = *op;
204+ self.pos += 1;
205+ Some(op)
206+ }
207+ _ => None,
208+ }
209+ }
210+
211+ fn expect(&mut self, token: Token) -> Result<(), String> {
212+ match self.peek() {
213+ Some(t) if *t == token => {
214+ self.pos += 1;
215+ Ok(())
216+ }
217+ Some(t) => Err(format!("expected {} but found {}", token, t)),
218+ None => Err(format!("expected {} at the end", token)),
219+ }
220+ }
221+
222+ fn expr(&mut self) -> Result<Value, String> {
223+ let mut left = self.and()?;
224+ while self.eat_word("or") {
225+ let right = self.and()?;
226+ left = Value::Bool(left.truthy() || right.truthy());
227+ }
228+ Ok(left)
229+ }
230+
231+ fn and(&mut self) -> Result<Value, String> {
232+ let mut left = self.not()?;
233+ while self.eat_word("and") {
234+ let right = self.not()?;
235+ left = Value::Bool(left.truthy() && right.truthy());
236+ }
237+ Ok(left)
238+ }
239+
240+ fn not(&mut self) -> Result<Value, String> {
241+ if self.eat_word("not") {
242+ return Ok(Value::Bool(!self.not()?.truthy()));
243+ }
244+ self.compare()
245+ }
246+
247+ fn compare(&mut self) -> Result<Value, String> {
248+ let left = self.concat()?;
249+ let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
250+ op
251+ } else if self.eat_word("contains") {
252+ "contains"
253+ } else if self.eat_word("starts-with") {
254+ "starts-with"
255+ } else if self.eat_word("ends-with") {
256+ "ends-with"
257+ } else {
258+ return Ok(left);
259+ };
260+ let right = self.concat()?;
261+ let numbers = left.as_num().zip(right.as_num());
262+ let (l, r) = (left.to_string(), right.to_string());
263+ Ok(Value::Bool(match (op, numbers) {
264+ ("==", Some((a, b))) => a == b,
265+ ("!=", Some((a, b))) => a != b,
266+ ("<", Some((a, b))) => a < b,
267+ (">", Some((a, b))) => a > b,
268+ ("<=", Some((a, b))) => a <= b,
269+ (">=", Some((a, b))) => a >= b,
270+ ("==", None) => l == r,
271+ ("!=", None) => l != r,
272+ ("<", None) => l < r,
273+ (">", None) => l > r,
274+ ("<=", None) => l <= r,
275+ (">=", None) => l >= r,
276+ ("contains", _) => l.contains(&r),
277+ ("starts-with", _) => l.starts_with(&r),
278+ ("ends-with", _) => l.ends_with(&r),
279+ _ => unreachable!(),
280+ }))
281+ }
282+
283+ fn concat(&mut self) -> Result<Value, String> {
284+ let mut left = self.sum()?;
285+ while self.eat_op(&["~"]).is_some() {
286+ let right = self.sum()?;
287+ left = Value::Str(format!("{}{}", left, right));
288+ }
289+ Ok(left)
290+ }
291+
292+ fn sum(&mut self) -> Result<Value, String> {
293+ let mut left = self.product()?;
294+ while let Some(op) = self.eat_op(&["+", "-"]) {
295+ let right = self.product()?;
296+ left = arithmetic(op, &left, &right)?;
297+ }
298+ Ok(left)
299+ }
300+
301+ fn product(&mut self) -> Result<Value, String> {
302+ let mut left = self.unary()?;
303+ while let Some(op) = self.eat_op(&["*", "/", "%"]) {
304+ let right = self.unary()?;
305+ left = arithmetic(op, &left, &right)?;
306+ }
307+ Ok(left)
308+ }
309+
310+ fn unary(&mut self) -> Result<Value, String> {
311+ if self.eat_op(&["-"]).is_some() {
312+ let value = self.unary()?;
313+ return value
314+ .as_num()
315+ .map(|n| Value::Num(-n))
316+ .ok_or_else(|| format!("cannot negate `{}`", value));
317+ }
318+ self.primary()
319+ }
320+
321+ fn primary(&mut self) -> Result<Value, String> {
322+ let token = self
323+ .peek()
324+ .cloned()
325+ .ok_or_else(|| "expected a value at the end".to_string())?;
326+ self.pos += 1;
327+ match token {
328+ Token::Num(n) => Ok(Value::Num(n)),
329+ Token::Str(s) => Ok(Value::Str(interpolate(&s, self.resolve))),
330+ Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
331+ Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
332+ Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
333+ Token::Word(w) if w == "if" && self.peek() == Some(&Token::LParen) => {
334+ self.pos += 1;
335+ let condition = self.expr()?;
336+ self.expect(Token::Comma)?;
337+ let then = self.expr()?;
338+ self.expect(Token::Comma)?;
339+ let otherwise = self.expr()?;
340+ self.expect(Token::RParen)?;
341+ Ok(if condition.truthy() { then } else { otherwise })
342+ }
343+ Token::Word(w) => Ok(Value::Str(w)),
344+ Token::LParen => {
345+ let value = self.expr()?;
346+ self.expect(Token::RParen)?;
347+ Ok(value)
348+ }
349+ other => Err(format!("unexpected {}", other)),
350+ }
351+ }
352+}
353+
354+fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
355+ let (Some(a), Some(b)) = (left.as_num(), right.as_num()) else {
356+ return Err(format!("`{} {} {}` needs numbers", left, op, right));
357+ };
358+ Ok(Value::Num(match op {
359+ "+" => a + b,
360+ "-" => a - b,
361+ "*" => a * b,
362+ "/" if b == 0.0 => return Err("division by zero".to_string()),
363+ "/" => a / b,
364+ "%" if b == 0.0 => return Err("division by zero".to_string()),
365+ "%" => a % b,
366+ _ => unreachable!(),
367+ }))
368+}
369+
370+/// Replace `$name` references inside a string literal.
371+fn interpolate(s: &str, resolve: Resolver) -> String {
372+ let mut out = String::with_capacity(s.len());
373+ let mut rest = s;
374+ while let Some(pos) = rest.find('$') {
375+ out.push_str(&rest[..pos]);
376+ let after = &rest[pos + 1..];
377+ let end = after
378+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '|' | ':')))
379+ .unwrap_or(after.len());
380+ if end == 0 {
381+ out.push('$');
382+ } else {
383+ out.push_str(&resolve(&after[..end]).unwrap_or_default());
384+ }
385+ rest = &after[end..];
386+ }
387+ out.push_str(rest);
388+ out
389+}
390+
391+#[cfg(test)]
392+mod tests {
393+ use super::*;
394+
395+ fn ev(src: &str) -> Value {
396+ let resolve = |name: &str| match name {
397+ "count" => Some("3".to_string()),
398+ "name" => Some("World".to_string()),
399+ "theme" => Some("dark".to_string()),
400+ "tricky" => Some("a == b".to_string()),
401+ "empty" => Some(String::new()),
402+ "name|uppercase" => Some("WORLD".to_string()),
403+ _ => None,
404+ };
405+ eval(src, &resolve).unwrap()
406+ }
407+
408+ #[test]
409+ fn arithmetic_has_precedence() {
410+ assert_eq!(ev("2 + 3 * 4").to_string(), "14");
411+ assert_eq!(ev("(2 + 3) * 4").to_string(), "20");
412+ assert_eq!(ev("$count * 2 - 1").to_string(), "5");
413+ assert_eq!(ev("7 / 2").to_string(), "3.5");
414+ assert_eq!(ev("-$count").to_string(), "-3");
415+ }
416+
417+ #[test]
418+ fn comparisons_and_logic() {
419+ assert!(ev("$count > 2").truthy());
420+ assert!(ev("$theme == dark").truthy());
421+ assert!(ev("$theme != light and not $empty").truthy());
422+ assert!(!ev("$count < 2 or $empty").truthy());
423+ assert!(ev("$name contains or").truthy());
424+ assert!(ev("$name|uppercase == WORLD").truthy());
425+ assert!(ev("#3b82f6 != red").truthy());
426+ }
427+
428+ #[test]
429+ fn variable_values_are_never_reparsed() {
430+ // The value contains `==`, but it's just a string.
431+ assert!(ev("$tricky == \"a == b\"").truthy());
432+ assert!(ev("$tricky").truthy());
433+ }
434+
435+ #[test]
436+ fn strings_and_if() {
437+ assert_eq!(ev("\"Hello \" ~ $name").to_string(), "Hello World");
438+ assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
439+ assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
440+ assert_eq!(ev("$missing").to_string(), "");
441+ }
442+
443+ #[test]
444+ fn errors_are_reported() {
445+ let none = |_: &str| None;
446+ assert!(eval("dark * 2", &none).is_err());
447+ assert!(eval("1 +", &none).is_err());
448+ assert!(eval("(1", &none).is_err());
449+ assert!(eval("1 / 0", &none).is_err());
450+ }
451+}