on master help sign in
Text tests are functions

contains, starts-with and ends-with were the only word operators besides and/or/not. They are functions now, next to uppercase() and length(): contains($tags, rust). Operators are arithmetic, comparison and logic.

commitef93e44fe93236b4acebc88ba8f01bad1191e724 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:57 (2 weeks ago) parenta541580
3 files changed+22−25
DESIGN.md+2−1 crates/htmlang-core/src/expr.rs+17−21 tests/snapshots.rs+3−3
DESIGN.md+2−1
@@ -346,9 +346,10 @@ Conditions and computed values (`@let x = ...`) are expressions:
346346|---|---|
347347| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
348348| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
349−| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains` (in text, or as an item of a list), `starts-with`, `ends-with` |
349+| Comparison | `== != < > <= >=` (numeric when both sides are numbers) |
350350| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
351351| Choice | `if(CONDITION, A, B)` |
352+| Tests | `contains(s, x)` (in text, or as an item of a list), `starts-with(s, x)`, `ends-with(s, x)` |
352353| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)` (a list's items, or a text's characters), `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
353354| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
354355
crates/htmlang-core/src/expr.rs+17−21
@@ -10,8 +10,7 @@
1010//! or := and ("or" and)*
1111//! and := not ("and" not)*
1212//! not := "not" not | compare
13−//! compare := sum (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
14−//! | "starts-with" | "ends-with") sum)?
13+//! compare := sum (("==" | "!=" | "<" | ">" | "<=" | ">=") sum)?
1514//! sum := product (("+" | "-") product)*
1615//! product := unary (("*" | "/" | "%") unary)*
1716//! unary := "-" unary | primary
@@ -22,7 +21,8 @@
2221//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. A variable
2322//! loaded from a JSON array is a list: `length` counts its items and
2423//! `contains` tests membership. Functions:
25−//! `if(cond, a, b)`, text (`uppercase`, `lowercase`, `capitalize`, `trim`,
24+//! `if(cond, a, b)`, tests (`contains(s, x)`, `starts-with(s, x)`,
25+//! `ends-with(s, x)`), text (`uppercase`, `lowercase`, `capitalize`, `trim`,
2626//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
2727//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
2828//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
@@ -255,15 +255,7 @@ impl Parser<'_> {
255255
256256 fn compare(&mut self) -> Result<Value, String> {
257257 let left = self.sum()?;
258− let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
259− op
260− } else if self.eat_word("contains") {
261− "contains"
262− } else if self.eat_word("starts-with") {
263− "starts-with"
264− } else if self.eat_word("ends-with") {
265− "ends-with"
266− } else {
258+ let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) else {
267259 return Ok(left);
268260 };
269261 let right = self.sum()?;
@@ -282,12 +274,6 @@ impl Parser<'_> {
282274 (">", None) => l > r,
283275 ("<=", None) => l <= r,
284276 (">=", None) => l >= r,
285− ("contains", _) => match &left {
286− Value::List(items) => items.contains(&r),
287− _ => l.contains(&r),
288− },
289− ("starts-with", _) => l.starts_with(&r),
290− ("ends-with", _) => l.ends_with(&r),
291277 _ => unreachable!(),
292278 }))
293279 }
@@ -401,6 +387,15 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
401387 let then = args.pop().unwrap();
402388 if args[0].truthy() { then } else { otherwise }
403389 }
390+ "contains" => {
391+ arity(2)?;
392+ Value::Bool(match &args[0] {
393+ Value::List(items) => items.contains(&text(1)),
394+ other => other.to_string().contains(&text(1)),
395+ })
396+ }
397+ "starts-with" => { arity(2)?; Value::Bool(text(0).starts_with(&text(1))) }
398+ "ends-with" => { arity(2)?; Value::Bool(text(0).ends_with(&text(1))) }
404399 "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
405400 "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
406401 "capitalize" => {
@@ -561,7 +556,8 @@ mod tests {
561556 assert!(ev("$theme == dark").truthy());
562557 assert!(ev("$theme != light and not $empty").truthy());
563558 assert!(!ev("$count < 2 or $empty").truthy());
564− assert!(ev("$name contains or").truthy());
559+ assert!(ev("contains($name, or)").truthy());
560+ assert!(ev("starts-with($name, Wo) and ends-with($name, ld)").truthy());
565561 assert!(ev("uppercase($name) == WORLD").truthy());
566562 assert!(ev("#3b82f6 != red").truthy());
567563 }
@@ -595,8 +591,8 @@ mod tests {
595591 #[test]
596592 fn lists() {
597593 assert_eq!(ev("length($tags)").to_string(), "2");
598− assert!(ev("$tags contains \"web dev\"").truthy());
599− assert!(!ev("$tags contains web").truthy());
594+ assert!(ev("contains($tags, \"web dev\")").truthy());
595+ assert!(!ev("contains($tags, web)").truthy());
600596 assert_eq!(ev("$tags").to_string(), "rust, web dev");
601597 }
602598
tests/snapshots.rs+3−3
@@ -955,14 +955,14 @@ fn comparison_operators_lt() {
955955
956956#[test]
957957fn comparison_operators_contains() {
958− let output = compile("@page T\n@let name hello world\n@if $name contains world\n @text found");
958+ let output = compile("@page T\n@let name hello world\n@if contains($name, world)\n @text found");
959959 assert!(output.contains("found"));
960960}
961961
962962#[test]
963963fn comparison_operators_starts_with() {
964964 let output = compile(
965− "@page T\n@let url https://example.com\n@if $url starts-with https\n @text secure",
965+ "@page T\n@let url https://example.com\n@if starts-with($url, https)\n @text secure",
966966 );
967967 assert!(output.contains("secure"));
968968}
@@ -1050,7 +1050,7 @@ fn comparison_gte_lte() {
10501050
10511051#[test]
10521052fn comparison_ends_with() {
1053− let output = compile("@page T\n@let file photo.jpg\n@if $file ends-with .jpg\n @text image");
1053+ let output = compile("@page T\n@let file photo.jpg\n@if ends-with($file, .jpg)\n @text image");
10541054 assert!(output.contains("image"));
10551055}
10561056