@@ -131,6 +131,38 @@ struct Parser {
131 pos: usize,
131 pos: usize,
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))
144+ let result = crate::expr::eval(src, &resolve);
146+ Ok(value) => Some(value),
148+ self.diagnostics.push(Diagnostic {
151+ message: format!("invalid expression: {}", message),
152+ severity: Severity::Error,
153+ source_line: Some(src.to_string()),
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())
134/// The standard library (`std.hl`): components and bundles defined in
166/// The standard library (`std.hl`): components and bundles defined in
135/// htmlang itself and available in every file.
167/// htmlang itself and available in every file.
136const PRELUDE: &str = include_str!("std.hl");
168const PRELUDE: &str = include_str!("std.hl");
@@ -629,14 +661,20 @@ impl Parser {
630 if let Some((name, value)) = rest.split_once(' ') {
662 if let Some((name, value)) = rest.split_once(' ') {
631 let value = value.trim();
663 let value = value.trim();
632− // Support @let name = expr syntax (strip leading =)
664+ // `@let name = EXPR` computes its value (see expr.rs); any
665+ // other value is literal text with `$var` interpolation.
633− let value = if let Some(after_eq) = value.strip_prefix("= ") {
666+ if let Some(expression) = value.strip_prefix('=') {
634− after_eq.trim()
667+ let value = ctx
635− } else if value == "=" {
668+ .eval(expression.trim(), line_num)
636− ""
669+ .map(|v| v.to_string())
637− } else {
670+ .unwrap_or_default();
638− value
671+ if name.starts_with("--") {
672+ ctx.css_vars.push((name.to_string(), value.clone()));
674+ ctx.variables.insert(name.to_string(), value);
675+ ctx.let_lines.entry(name.to_string()).or_insert(line_num);
641 // Attribute bundle: @let name [attr1, attr2, ...]
679 // Attribute bundle: @let name [attr1, attr2, ...]
642 if value.starts_with('[') {
680 if value.starts_with('[') {
@@ -648,8 +686,6 @@ impl Parser {
649 // Multi-line @let with triple quotes: @let name """..."""
687 // Multi-line @let with triple quotes: @let name """..."""
650 let value_str;
688 let value_str;
651− let quoted = value.starts_with('"') && value.ends_with('"') && value.len() >= 2
652− || value.starts_with("\"\"\"");
653 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
689 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
654 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
690 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
655 // Single-line triple-quote: @let name """value"""
691 // Single-line triple-quote: @let name """value"""
@@ -690,13 +726,6 @@ impl Parser {
691 track_var_refs(value, &mut ctx.used_variables);
727 track_var_refs(value, &mut ctx.used_variables);
692 let value = substitute_vars(value, &ctx.variables);
728 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 {
698− evaluate_arithmetic(&value)
700 if name.starts_with("--") {
729 if name.starts_with("--") {
701 // CSS custom property
730 // CSS custom property
702 ctx.css_vars.push((name.to_string(), value.clone()));
731 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -889,9 +918,7 @@ impl Parser {
890 if let Some(rest) = content.strip_prefix("@assert ") {
919 if let Some(rest) = content.strip_prefix("@assert ") {
891 let rest = rest.trim();
920 let rest = rest.trim();
892− let condition = substitute_vars(rest, &ctx.variables);
921+ if !ctx.condition(rest, line_num) {
893− track_var_refs(rest, &mut ctx.used_variables);
894− if !evaluate_condition(&condition) {
895 ctx.diagnostics.push(Diagnostic {
922 ctx.diagnostics.push(Diagnostic {
896 line: line_num,
923 line: line_num,
897 column: None,
924 column: None,
@@ -1568,10 +1595,7 @@ impl Parser {
1568 // --- @if / @else ---
1595 // --- @if / @else ---
1570 if let Some(rest) = content.strip_prefix("@if ") {
1597 if let Some(rest) = content.strip_prefix("@if ") {
1571− let rest = rest.trim();
1598+ let result = ctx.condition(rest.trim(), line_num);
1572− track_var_refs(rest, &mut ctx.used_variables);
1573− let condition = substitute_vars(rest, &ctx.variables);
1574− let result = evaluate_condition(&condition);
1576 // Collect then-body lines
1600 // Collect then-body lines
1577 let mut then_lines = Vec::new();
1601 let mut then_lines = Vec::new();
@@ -1592,9 +1616,8 @@ impl Parser {
1592 let trimmed = s.trim();
1616 let trimmed = s.trim();
1593 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1617 if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1594 self.pos += 1; // consume @else if
1618 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);
1598 let mut body = Vec::new();
1621 let mut body = Vec::new();
1599 while self.pos < self.lines.len()
1622 while self.pos < self.lines.len()
1600 && self.lines[self.pos].indent > current_indent
1623 && self.lines[self.pos].indent > current_indent
@@ -3433,54 +3456,31 @@ fn parse_attr_list(
3433 continue;
3456 continue;
3436− // Substitute variables in value
3437 track_var_refs(part, &mut ctx.used_variables);
3459 track_var_refs(part, &mut ctx.used_variables);
3438− let part = substitute_vars(part, &ctx.variables);
3440− // Conditional attribute: `key if condition` or `key value if condition`
3461+ // Conditional attribute: `key if condition` or `key value if condition`.
3441− let (part, is_conditional) = {
3462+ // Conditions are evaluated on the source text, before substitution.
3442− // Check for " if " not inside parentheses
3463+ let part = match split_trailing_if(part) {
3443− let check = part.as_str();
3464+ (attr, Some(condition)) => {
3444− let mut found_if = None;
3465+ if !ctx.condition(condition, line_num) {
3445− let mut depth = 0;
3466+ continue;
3446− for (i, c) in check.char_indices() {
3452− if depth == 0 && check[i..].starts_with(" if ") {
3453− found_if = Some(i);
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());
3462− (attr_part.to_string(), false)
3464− (String::new(), true)
3467− (part.to_string(), false)
3470+ (attr, None) => attr,
3470−
3472+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`.
3471− // Skip this attribute if the condition was false
3473+ let part = choose_if_value(part, ctx, line_num);
3472− if is_conditional {
3474+ let part = substitute_vars(&part, &ctx.variables);
3476 let attr = if let Some((key, value)) = split_html_attribute(&part) {
3476 let attr = if let Some((key, value)) = split_html_attribute(&part) {
3477 Attribute {
3477 Attribute {
3478 key: key.to_string(),
3478 key: key.to_string(),
3479− value: Some(evaluate_if_expr(value)),
3479+ value: Some(value.to_string()),
3480 html: true,
3480 html: true,
3482 } else if let Some((key, value)) = part.split_once(' ') {
3482 } else if let Some((key, value)) = part.split_once(' ') {
3483− let value = evaluate_if_expr(value.trim());
3483+ let value = value.trim().to_string();
3484 Attribute {
3484 Attribute {
3485 key: key.trim().to_string(),
3485 key: key.trim().to_string(),
3486 value: Some(value),
3486 value: Some(value),
@@ -3779,78 +3779,47 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
3782−fn evaluate_condition(condition: &str) -> bool {
3783− if let Some(rest) = condition.trim_start().strip_prefix("not ") {
3784− return !evaluate_condition(rest);
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,
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,
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,
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,
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())
3827− // Truthy check: non-empty, not "false", not "0"
3828− let trimmed = condition.trim();
3829− !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
3834/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
3784/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
3835−fn evaluate_if_expr(input: &str) -> String {
3785+/// Split `key value if condition` into the attribute and its condition
3836− let trimmed = input.trim();
3786+/// (ignoring ` if ` inside parentheses).
3787+fn split_trailing_if(part: &str) -> (&str, Option<&str>) {
3789+ for (i, c) in part.char_indices() {
3837− if !trimmed.starts_with("if(") || !trimmed.ends_with(')') {
3793+ _ if depth == 0 && part[i..].starts_with(" if ") => {
3838− return input.to_string();
3794+ return (&part[..i], Some(part[i + 4..].trim()));
3840− let inner = &trimmed[3..trimmed.len() - 1];
3796+ _ => {}
3841− let parts = split_if_args(inner);
3797+ }
3842− if parts.len() != 3 {
3843− return input.to_string();
3845− let condition = parts[0].trim();
3799+ (part, None)
3846− let true_val = parts[1].trim();
3800+}
3847− let false_val = parts[2].trim();
3849− if evaluate_condition(condition) {
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),
3850− true_val.to_string()
3807+ None => return part.to_string(),
3809+ let value = value.trim();
3810+ let Some(inner) = value.strip_prefix("if(").and_then(|v| v.strip_suffix(')')) else {
3852− false_val.to_string()
3811+ return part.to_string();
3813+ let args = split_if_args(inner);
3814+ if args.len() != 3 {
3815+ return part.to_string();
3817+ let branch = if ctx.condition(args[0].trim(), line) {
3822+ format!("{}{}", head, branch.trim())
3856fn split_if_args(input: &str) -> Vec<&str> {
3825fn split_if_args(input: &str) -> Vec<&str> {
@@ -3872,54 +3841,6 @@ fn split_if_args(input: &str) -> Vec<&str> {
3875−fn evaluate_arithmetic(input: &str) -> String {
3876− let input = input.trim();
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 {
3886− // Strip quotes from string literals
3887− if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
3893− .collect::<Vec<_>>()
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 {
3905− if let (Ok(l), Ok(r)) = (left.parse::<f64>(), right.parse::<f64>()) {
3906− let result = match op_char {
3910− '/' if r != 0.0 => l / r,
3911− _ => return input.to_string(),
3913− if result == result.floor() && result.abs() < i64::MAX as f64 {
3914− return format!("{}", result as i64);
3916− return format!("{}", result);
3923// ---------------------------------------------------------------------------
3844// ---------------------------------------------------------------------------
3924// Post-parse validation (context-dependent warnings)
3845// Post-parse validation (context-dependent warnings)
3925// ---------------------------------------------------------------------------
3846// ---------------------------------------------------------------------------
@@ -5474,7 +5395,7 @@ mod tests {
5475 fn arithmetic_empty_operator_does_not_panic() {
5396 fn arithmetic_empty_operator_does_not_panic() {
5476 // Guards the `op.trim().chars().next().unwrap()` fix.
5397 // 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");
5478 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
5399 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));