on master help sign in
Evaluate conditions and computed values with one expression model

expr.rs tokenizes and evaluates expressions over typed values (number, string, boolean) with precedence: or, and, not, comparisons, contains / starts-with / ends-with, ~ concatenation, + - * / %, unary minus, parentheses and if(cond, a, b). Variables are resolved during evaluation instead of being pasted into the text first, so a value containing == or spaces no longer changes how a condition parses. - @if, @else if, @assert, `key if cond` and if() conditions use it - computed @let values require `=` (`@let gap = $base * 2`); a bare value is literal text, so `@let area 1 / 3` is no longer divided - arithmetic now handles more than one operator (2 + 3 * 4 = 14) - invalid expressions are reported as errors instead of silently evaluating to true or to the unevaluated text - replaces evaluate_condition, evaluate_arithmetic and evaluate_if_expr - htmlang upgrade adds `=` to @let values that used to be computed

commit5539000eee738db42179bc4f36d621c94fb8a463 authoriko <mail@iko.soy> dateFri 25 Sep 2026, 20:46 (2 weeks ago) parent21e5176
7 files changed+624−186
crates/htmlang-core/src/expr.rs+451 crates/htmlang-core/src/lib.rs+1 crates/htmlang-core/src/parser.rs+99−178 src/upgrade.rs+36 tests/regressions.rs+29 tests/snapshots.rs+4−4 tests/snapshots/arithmetic.hl+4−4
crates/htmlang-core/src/expr.rsnew file+451
@@ -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+}
crates/htmlang-core/src/lib.rs+1
@@ -1,5 +1,6 @@
11pub mod ast;
22pub mod codegen;
3+pub mod expr;
34pub mod parser;
45pub mod vocab;
56
crates/htmlang-core/src/parser.rs+99−178
@@ -131,6 +131,38 @@ struct Parser {
131131 pos: usize,
132132}
133133
134+impl ParseContext {
135+ /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
136+ fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
137+ track_var_refs(src, &mut self.used_variables);
138+ let vars = &self.variables;
139+ let resolve = |reference: &str| {
140+ let name = reference.split('|').next().unwrap_or(reference);
141+ vars.contains_key(name)
142+ .then(|| substitute_vars(&format!("${}", reference), vars))
143+ };
144+ let result = crate::expr::eval(src, &resolve);
145+ match result {
146+ Ok(value) => Some(value),
147+ Err(message) => {
148+ self.diagnostics.push(Diagnostic {
149+ line,
150+ column: None,
151+ message: format!("invalid expression: {}", message),
152+ severity: Severity::Error,
153+ source_line: Some(src.to_string()),
154+ });
155+ None
156+ }
157+ }
158+ }
159+
160+ /// Evaluate a condition; an invalid one is reported and counts as false.
161+ fn condition(&mut self, src: &str, line: usize) -> bool {
162+ self.eval(src, line).is_some_and(|value| value.truthy())
163+ }
164+}
165+
134166/// The standard library (`std.hl`): components and bundles defined in
135167/// htmlang itself and available in every file.
136168const PRELUDE: &str = include_str!("std.hl");
@@ -629,14 +661,20 @@ impl Parser {
629661
630662 if let Some((name, value)) = rest.split_once(' ') {
631663 let value = value.trim();
632− // Support @let name = expr syntax (strip leading =)
633− let value = if let Some(after_eq) = value.strip_prefix("= ") {
634− after_eq.trim()
635− } else if value == "=" {
636− ""
637− } else {
638− value
639− };
664+ // `@let name = EXPR` computes its value (see expr.rs); any
665+ // other value is literal text with `$var` interpolation.
666+ if let Some(expression) = value.strip_prefix('=') {
667+ let value = ctx
668+ .eval(expression.trim(), line_num)
669+ .map(|v| v.to_string())
670+ .unwrap_or_default();
671+ if name.starts_with("--") {
672+ ctx.css_vars.push((name.to_string(), value.clone()));
673+ }
674+ ctx.variables.insert(name.to_string(), value);
675+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
676+ return Ok(None);
677+ }
640678
641679 // Attribute bundle: @let name [attr1, attr2, ...]
642680 if value.starts_with('[') {
@@ -648,8 +686,6 @@ impl Parser {
648686
649687 // Multi-line @let with triple quotes: @let name """..."""
650688 let value_str;
651− let quoted = value.starts_with('"') && value.ends_with('"') && value.len() >= 2
652− || value.starts_with("\"\"\"");
653689 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
654690 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
655691 // Single-line triple-quote: @let name """value"""
@@ -690,13 +726,6 @@ impl Parser {
690726 };
691727 track_var_refs(value, &mut ctx.used_variables);
692728 let value = substitute_vars(value, &ctx.variables);
693− // Quoted values are literal strings; only bare values are
694− // evaluated as arithmetic (so `"1 / -1"` stays as written).
695− let value = if quoted {
696− value
697− } else {
698− evaluate_arithmetic(&value)
699− };
700729 if name.starts_with("--") {
701730 // CSS custom property
702731 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -889,9 +918,7 @@ impl Parser {
889918
890919 if let Some(rest) = content.strip_prefix("@assert ") {
891920 let rest = rest.trim();
892− let condition = substitute_vars(rest, &ctx.variables);
893− track_var_refs(rest, &mut ctx.used_variables);
894− if !evaluate_condition(&condition) {
921+ if !ctx.condition(rest, line_num) {
895922 ctx.diagnostics.push(Diagnostic {
896923 line: line_num,
897924 column: None,
@@ -1568,10 +1595,7 @@ impl Parser {
15681595 // --- @if / @else ---
15691596
15701597 if let Some(rest) = content.strip_prefix("@if ") {
1571− let rest = rest.trim();
1572− track_var_refs(rest, &mut ctx.used_variables);
1573− let condition = substitute_vars(rest, &ctx.variables);
1574− let result = evaluate_condition(&condition);
1598+ let result = ctx.condition(rest.trim(), line_num);
15751599
15761600 // Collect then-body lines
15771601 let mut then_lines = Vec::new();
@@ -1592,9 +1616,8 @@ impl Parser {
15921616 let trimmed = s.trim();
15931617 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
15941618 self.pos += 1; // consume @else if
1595− track_var_refs(else_if_cond, &mut ctx.used_variables);
1596− let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
1597− let cond_result = evaluate_condition(&cond);
1619+ let cond_result =
1620+ ctx.condition(else_if_cond.trim(), self.lines[self.pos - 1].line_num);
15981621 let mut body = Vec::new();
15991622 while self.pos < self.lines.len()
16001623 && self.lines[self.pos].indent > current_indent
@@ -3433,54 +3456,31 @@ fn parse_attr_list(
34333456 continue;
34343457 }
34353458
3436− // Substitute variables in value
34373459 track_var_refs(part, &mut ctx.used_variables);
3438− let part = substitute_vars(part, &ctx.variables);
34393460
3440− // Conditional attribute: `key if condition` or `key value if condition`
3441− let (part, is_conditional) = {
3442− // Check for " if " not inside parentheses
3443− let check = part.as_str();
3444− let mut found_if = None;
3445− let mut depth = 0;
3446− for (i, c) in check.char_indices() {
3447− match c {
3448− '(' => depth += 1,
3449− ')' => depth -= 1,
3450− _ => {}
3451− }
3452− if depth == 0 && check[i..].starts_with(" if ") {
3453− found_if = Some(i);
3454− break;
3455− }
3456− }
3457− if let Some(if_pos) = found_if {
3458− let attr_part = &check[..if_pos];
3459− let condition = &check[if_pos + 4..];
3460− let cond_result = evaluate_condition(condition.trim());
3461− if cond_result {
3462− (attr_part.to_string(), false)
3463− } else {
3464− (String::new(), true)
3461+ // Conditional attribute: `key if condition` or `key value if condition`.
3462+ // Conditions are evaluated on the source text, before substitution.
3463+ let part = match split_trailing_if(part) {
3464+ (attr, Some(condition)) => {
3465+ if !ctx.condition(condition, line_num) {
3466+ continue;
34653467 }
3466− } else {
3467− (part.to_string(), false)
3468+ attr
34683469 }
3470+ (attr, None) => attr,
34693471 };
3470−
3471− // Skip this attribute if the condition was false
3472− if is_conditional {
3473− continue;
3474− }
3472+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`.
3473+ let part = choose_if_value(part, ctx, line_num);
3474+ let part = substitute_vars(&part, &ctx.variables);
34753475
34763476 let attr = if let Some((key, value)) = split_html_attribute(&part) {
34773477 Attribute {
34783478 key: key.to_string(),
3479− value: Some(evaluate_if_expr(value)),
3479+ value: Some(value.to_string()),
34803480 html: true,
34813481 }
34823482 } else if let Some((key, value)) = part.split_once(' ') {
3483− let value = evaluate_if_expr(value.trim());
3483+ let value = value.trim().to_string();
34843484 Attribute {
34853485 key: key.trim().to_string(),
34863486 value: Some(value),
@@ -3779,78 +3779,47 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
37793779 items
37803780}
37813781
3782−fn evaluate_condition(condition: &str) -> bool {
3783− if let Some(rest) = condition.trim_start().strip_prefix("not ") {
3784− return !evaluate_condition(rest);
3785− }
3786−
3787− if let Some((left, right)) = condition.split_once("!=") {
3788− left.trim() != right.trim()
3789− } else if let Some((left, right)) = condition.split_once("==") {
3790− left.trim() == right.trim()
3791− } else if let Some((left, right)) = condition.split_once(">=") {
3792− // Numeric comparison with fallback to string comparison
3793− let l = left.trim();
3794− let r = right.trim();
3795− match (l.parse::<f64>(), r.parse::<f64>()) {
3796− (Ok(ln), Ok(rn)) => ln >= rn,
3797− _ => l >= r,
3798− }
3799− } else if let Some((left, right)) = condition.split_once("<=") {
3800− let l = left.trim();
3801− let r = right.trim();
3802− match (l.parse::<f64>(), r.parse::<f64>()) {
3803− (Ok(ln), Ok(rn)) => ln <= rn,
3804− _ => l <= r,
3805− }
3806− } else if let Some((left, right)) = condition.split_once('>') {
3807− let l = left.trim();
3808− let r = right.trim();
3809− match (l.parse::<f64>(), r.parse::<f64>()) {
3810− (Ok(ln), Ok(rn)) => ln > rn,
3811− _ => l > r,
3812− }
3813− } else if let Some((left, right)) = condition.split_once('<') {
3814− let l = left.trim();
3815− let r = right.trim();
3816− match (l.parse::<f64>(), r.parse::<f64>()) {
3817− (Ok(ln), Ok(rn)) => ln < rn,
3818− _ => l < r,
3819− }
3820− } else if let Some((left, right)) = condition.split_once(" contains ") {
3821− left.trim().contains(right.trim())
3822− } else if let Some((left, right)) = condition.split_once(" starts-with ") {
3823− left.trim().starts_with(right.trim())
3824− } else if let Some((left, right)) = condition.split_once(" ends-with ") {
3825− left.trim().ends_with(right.trim())
3826− } else {
3827− // Truthy check: non-empty, not "false", not "0"
3828− let trimmed = condition.trim();
3829− !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
3830− }
3831−}
38323782
38333783
38343784/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
3835−fn evaluate_if_expr(input: &str) -> String {
3836− let trimmed = input.trim();
3837− if !trimmed.starts_with("if(") || !trimmed.ends_with(')') {
3838− return input.to_string();
3839− }
3840− let inner = &trimmed[3..trimmed.len() - 1];
3841− let parts = split_if_args(inner);
3842− if parts.len() != 3 {
3843− return input.to_string();
3785+/// Split `key value if condition` into the attribute and its condition
3786+/// (ignoring ` if ` inside parentheses).
3787+fn split_trailing_if(part: &str) -> (&str, Option<&str>) {
3788+ let mut depth = 0;
3789+ for (i, c) in part.char_indices() {
3790+ match c {
3791+ '(' => depth += 1,
3792+ ')' => depth -= 1,
3793+ _ if depth == 0 && part[i..].starts_with(" if ") => {
3794+ return (&part[..i], Some(part[i + 4..].trim()));
3795+ }
3796+ _ => {}
3797+ }
38443798 }
3845− let condition = parts[0].trim();
3846− let true_val = parts[1].trim();
3847− let false_val = parts[2].trim();
3799+ (part, None)
3800+}
38483801
3849− if evaluate_condition(condition) {
3850− true_val.to_string()
3851− } else {
3852− false_val.to_string()
3802+/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
3803+/// chosen branch's text. Other attributes are returned unchanged.
3804+fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> String {
3805+ let (head, value) = match part.find(['=', ' ']) {
3806+ Some(pos) => part.split_at(pos + 1),
3807+ None => return part.to_string(),
3808+ };
3809+ let value = value.trim();
3810+ let Some(inner) = value.strip_prefix("if(").and_then(|v| v.strip_suffix(')')) else {
3811+ return part.to_string();
3812+ };
3813+ let args = split_if_args(inner);
3814+ if args.len() != 3 {
3815+ return part.to_string();
38533816 }
3817+ let branch = if ctx.condition(args[0].trim(), line) {
3818+ args[1]
3819+ } else {
3820+ args[2]
3821+ };
3822+ format!("{}{}", head, branch.trim())
38543823}
38553824
38563825fn split_if_args(input: &str) -> Vec<&str> {
@@ -3872,54 +3841,6 @@ fn split_if_args(input: &str) -> Vec<&str> {
38723841 parts
38733842}
38743843
3875−fn evaluate_arithmetic(input: &str) -> String {
3876− let input = input.trim();
3877−
3878− // String concatenation with ~ operator: @let full $first ~ " " ~ $last
3879− if input.contains(" ~ ") {
3880− let parts: Vec<&str> = input.split(" ~ ").collect();
3881− if parts.len() >= 2 {
3882− return parts
3883− .iter()
3884− .map(|p| {
3885− let t = p.trim();
3886− // Strip quotes from string literals
3887− if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
3888− &t[1..t.len() - 1]
3889− } else {
3890− t
3891− }
3892− })
3893− .collect::<Vec<_>>()
3894− .join("");
3895− }
3896− }
3897−
3898− for op in &[" * ", " / ", " + ", " - "] {
3899− if let Some((left, right)) = input.split_once(op) {
3900− let left = left.trim();
3901− let right = right.trim();
3902− let Some(op_char) = op.trim().chars().next() else {
3903− continue;
3904− };
3905− if let (Ok(l), Ok(r)) = (left.parse::<f64>(), right.parse::<f64>()) {
3906− let result = match op_char {
3907− '+' => l + r,
3908− '-' => l - r,
3909− '*' => l * r,
3910− '/' if r != 0.0 => l / r,
3911− _ => return input.to_string(),
3912− };
3913− if result == result.floor() && result.abs() < i64::MAX as f64 {
3914− return format!("{}", result as i64);
3915− }
3916− return format!("{}", result);
3917− }
3918− }
3919− }
3920− input.to_string()
3921−}
3922−
39233844// ---------------------------------------------------------------------------
39243845// Post-parse validation (context-dependent warnings)
39253846// ---------------------------------------------------------------------------
@@ -5474,7 +5395,7 @@ mod tests {
54745395 #[test]
54755396 fn arithmetic_empty_operator_does_not_panic() {
54765397 // Guards the `op.trim().chars().next().unwrap()` fix.
5477− let r = parse("@let x 1 + 2\n");
5398+ let r = parse("@let x = 1 + 2\n");
54785399 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
54795400 }
54805401
src/upgrade.rs+36
@@ -427,6 +427,13 @@ fn rewrite_directive_line(
427427 .collect();
428428 return format!("{pad}@warn {}", shown.join(", "));
429429 }
430+ // `@let x $a + 4` computed its value; computing now needs `=`.
431+ if let Some(rest) = trimmed.strip_prefix("@let ")
432+ && let Some((name, value)) = rest.split_once(' ')
433+ && is_old_arithmetic(value.trim())
434+ {
435+ return format!("{pad}@let {} = {}", name, value.trim());
436+ }
430437 if let Some(rest) = trimmed.strip_prefix("@unless ") {
431438 return format!("{pad}@if not {}", rest);
432439 }
@@ -451,6 +458,26 @@ fn rewrite_directive_line(
451458 line.to_string()
452459}
453460
461+/// Did the old `@let` evaluate this bare value? It did for one arithmetic
462+/// operator between two operands (`$base * 2`) and for `~` concatenation.
463+fn is_old_arithmetic(value: &str) -> bool {
464+ if value.starts_with(['=', '[', '"']) {
465+ return false;
466+ }
467+ if value.contains(" ~ ") {
468+ return true;
469+ }
470+ [" * ", " / ", " + ", " - "].iter().any(|op| {
471+ value.split_once(op).is_some_and(|(l, r)| {
472+ let operand = |s: &str| {
473+ let s = s.trim();
474+ s.parse::<f64>().is_ok() || (s.starts_with('$') && !s.contains(' '))
475+ };
476+ operand(l) && operand(r)
477+ })
478+ })
479+}
480+
454481/// Rename element aliases wherever an element name can appear: at the start
455482/// of a line, after a `>` chain, or at the start of an inline `{@...}`.
456483fn rename_elements(line: &str, user_defined: &[&str]) -> String {
@@ -915,6 +942,15 @@ mod tests {
915942 assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
916943 }
917944
945+ #[test]
946+ fn computed_let_needs_equals() {
947+ assert_eq!(up("@let gap $base + 4"), "@let gap = $base + 4");
948+ assert_eq!(up("@let full $a ~ \" \" ~ $b"), "@let full = $a ~ \" \" ~ $b");
949+ assert_eq!(up("@let x = 1 + 2"), "@let x = 1 + 2");
950+ assert_eq!(up("@let area 1 / span 2"), "@let area 1 / span 2");
951+ assert_eq!(up("@let card $title $tone=primary\n @text $title"), "@let card $title $tone=primary\n @text $title");
952+ }
953+
918954 #[test]
919955 fn verbatim_regions_are_untouched() {
920956 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/regressions.rs+29
@@ -332,3 +332,32 @@ fn space_form_html_attribute_points_to_equals() {
332332 result.diagnostics
333333 );
334334}
335+
336+#[test]
337+fn conditions_do_not_reparse_variable_values() {
338+ // The value contains `==`; the condition compares it as a whole.
339+ let out = compile("@let v \"a == b\"\n@if $v == \"a == b\"\n @text yes");
340+ assert!(out.contains(">yes<"), "{}", out);
341+ let out = compile("@let n 5\n@if $n > 2 and not $missing\n @text big");
342+ assert!(out.contains(">big<"), "{}", out);
343+}
344+
345+#[test]
346+fn computed_values_need_equals_and_have_precedence() {
347+ let out = compile("@let x = 2 + 3 * 4\n@text $x");
348+ assert!(out.contains(">14<"), "{}", out);
349+ // Without `=`, a value is literal text: `1 / 3` stays as written.
350+ let out = compile("@let area 1 / 3\n@el [grid-row $area] x");
351+ assert!(out.contains("grid-row:1 / 3"), "{}", out);
352+}
353+
354+#[test]
355+fn invalid_expressions_are_errors() {
356+ let result = parser::parse("@let x = dark * 2\n@if (1\n @text y");
357+ let errors: Vec<_> = result
358+ .diagnostics
359+ .iter()
360+ .filter(|d| d.severity == Severity::Error && d.message.contains("invalid expression"))
361+ .collect();
362+ assert_eq!(errors.len(), 2, "{:?}", result.diagnostics);
363+}
tests/snapshots.rs+4−4
@@ -1021,7 +1021,7 @@ fn comparison_operators_starts_with() {
10211021#[test]
10221022fn string_concat_operator() {
10231023 let output = compile(
1024− "@page T\n@let first Hello\n@let last World\n@let full $first ~ \" \" ~ $last\n@text $full",
1024+ "@page T\n@let first Hello\n@let last World\n@let full = $first ~ \" \" ~ $last\n@text $full",
10251025 );
10261026 assert!(output.contains("Hello World"), "got: {}", output);
10271027}
@@ -2109,19 +2109,19 @@ fn og_tags_in_output() {
21092109
21102110#[test]
21112111fn let_arithmetic_multiply() {
2112− let output = compile("@page T\n@let x 10\n@let y $x * 2\n@el [width $y]\n @text test");
2112+ let output = compile("@page T\n@let x 10\n@let y = $x * 2\n@el [width $y]\n @text test");
21132113 assert!(output.contains("width:20px"));
21142114}
21152115
21162116#[test]
21172117fn let_arithmetic_add() {
2118− let output = compile("@page T\n@let a 10\n@let b $a + 5\n@el [padding $b]\n @text test");
2118+ let output = compile("@page T\n@let a 10\n@let b = $a + 5\n@el [padding $b]\n @text test");
21192119 assert!(output.contains("padding:15px"));
21202120}
21212121
21222122#[test]
21232123fn let_arithmetic_divide() {
2124− let output = compile("@page T\n@let x 200 / 4\n@el [height $x]\n @text test");
2124+ let output = compile("@page T\n@let x = 200 / 4\n@el [height $x]\n @text test");
21252125 assert!(output.contains("height:50px"));
21262126}
21272127
tests/snapshots/arithmetic.hl+4−4
@@ -1,10 +1,10 @@
11@page Arithmetic
22
33@let base 10
4−@let double $base * 2
5−@let sum $base + 5
6−@let diff 100 - 30
7−@let half 200 / 4
4+@let double = $base * 2
5+@let sum = $base + 5
6+@let diff = 100 - 30
7+@let half = 200 / 4
88
99@el [width $double]
1010 @text double is $double