on master help sign in
Drop the ~ operator: interpolated strings join values
2995912iko
DESIGN.md+1−1
@@ -343,7 +343,6 @@ Conditions and computed values (`@let x = ...`) are expressions:
343|---|---|
343|---|---|
344| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
344| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
345| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
345| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
346−| Text | `~` joins two values: `$first ~ " " ~ $last` |
347| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
346| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
348| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
347| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
349| Choice | `if(CONDITION, A, B)` |
348| Choice | `if(CONDITION, A, B)` |
@@ -488,6 +487,7 @@ removed form with its replacement.
488| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
487| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
489| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
488| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
490| `COND ? A : B` | `if(COND, A, B)` |
489| `COND ? A : B` | `if(COND, A, B)` |
490+| `$a ~ " " ~ $b` | `"$a $b"` |
491| `...$bundle` | `$bundle` |
491| `...$bundle` | `$bundle` |
492| `animate`, `inset-area` | `animation`, `position-area` |
492| `animate`, `inset-area` | `animation`, `position-area` |
493| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
493| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
crates/htmlang-core/src/expr.rs+5−16
@@ -10,9 +10,8 @@
10//! or := and ("or" and)*
10//! or := and ("or" and)*
11//! and := not ("and" not)*
11//! and := not ("and" not)*
12//! not := "not" not | compare
12//! not := "not" not | compare
13−//! compare := concat (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
13+//! compare := sum (("==" | "!=" | "<" | ">" | "<=" | ">=" | "contains"
14−//! | "starts-with" | "ends-with") concat)?
14+//! | "starts-with" | "ends-with") sum)?
15−//! concat := sum ("~" sum)*
16//! sum := product (("+" | "-") product)*
15//! sum := product (("+" | "-") product)*
17//! product := unary (("*" | "/" | "%") unary)*
16//! product := unary (("*" | "/" | "%") unary)*
18//! unary := "-" unary | primary
17//! unary := "-" unary | primary
@@ -112,7 +111,7 @@ impl fmt::Display for Token {
112 }
111 }
113}
112}
114
113
115−const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%", "~"];
114+const OPERATORS: &[&str] = &["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%"];
116
115
117fn tokenize(src: &str) -> Result<Vec<Token>, String> {
116fn tokenize(src: &str) -> Result<Vec<Token>, String> {
118 let chars: Vec<char> = src.chars().collect();
117 let chars: Vec<char> = src.chars().collect();
@@ -249,7 +248,7 @@ impl Parser<'_> {
249 }
248 }
250
249
251 fn compare(&mut self) -> Result<Value, String> {
250 fn compare(&mut self) -> Result<Value, String> {
252− let left = self.concat()?;
251+ let left = self.sum()?;
253 let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
252 let op = if let Some(op) = self.eat_op(&["==", "!=", "<=", ">=", "<", ">"]) {
254 op
253 op
255 } else if self.eat_word("contains") {
254 } else if self.eat_word("contains") {
@@ -261,7 +260,7 @@ impl Parser<'_> {
261 } else {
260 } else {
262 return Ok(left);
261 return Ok(left);
263 };
262 };
264− let right = self.concat()?;
263+ let right = self.sum()?;
265 let numbers = left.as_num().zip(right.as_num());
264 let numbers = left.as_num().zip(right.as_num());
266 let (l, r) = (left.to_string(), right.to_string());
265 let (l, r) = (left.to_string(), right.to_string());
267 Ok(Value::Bool(match (op, numbers) {
266 Ok(Value::Bool(match (op, numbers) {
@@ -284,15 +283,6 @@ impl Parser<'_> {
284 }))
283 }))
285 }
284 }
286
285
287− fn concat(&mut self) -> Result<Value, String> {
288− let mut left = self.sum()?;
289− while self.eat_op(&["~"]).is_some() {
290− let right = self.sum()?;
291− left = Value::Str(format!("{}{}", left, right));
292− }
293− Ok(left)
294− }
295−
296 fn sum(&mut self) -> Result<Value, String> {
286 fn sum(&mut self) -> Result<Value, String> {
297 let mut left = self.product()?;
287 let mut left = self.product()?;
298 while let Some(op) = self.eat_op(&["+", "-"]) {
288 while let Some(op) = self.eat_op(&["+", "-"]) {
@@ -566,7 +556,6 @@ mod tests {
566
556
567 #[test]
557 #[test]
568 fn strings_and_if() {
558 fn strings_and_if() {
569− assert_eq!(ev("\"Hello \" ~ $name").to_string(), "Hello World");
570 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
559 assert_eq!(ev("\"Hi $name!\"").to_string(), "Hi World!");
571 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
560 assert_eq!(ev("if($count > 2, big, small)").to_string(), "big");
572 assert_eq!(ev("$missing").to_string(), "");
561 assert_eq!(ev("$missing").to_string(), "");
src/upgrade.rs+39−1
@@ -579,6 +579,43 @@ fn rewrite_directive_line(
579 .collect();
579 .collect();
580 return format!("{pad}@warn {}", shown.join(", "));
580 return format!("{pad}@warn {}", shown.join(", "));
581 }
581 }
582+ // `@let x = $a ~ " " ~ $b` → `@let x "$a $b"` (`~` was removed:
583+ // interpolated strings do the same)
584+ if let Some(rest) = trimmed.strip_prefix("@let ")
585+ && let Some((name, value)) = rest.split_once(' ')
586+ && value.contains(" ~ ")
587+ {
588+ let expression = value.trim();
589+ let expression = expression.strip_prefix('=').unwrap_or(expression);
590+ let is_var_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
591+ let pieces: Vec<String> = expression
592+ .split(" ~ ")
593+ .map(|part| {
594+ let part = part.trim();
595+ if let Some(text) = part.strip_prefix('"').and_then(|p| p.strip_suffix('"')) {
596+ text.to_string()
597+ } else if !part.contains(' ') && !part.contains('(') {
598+ part.to_string()
599+ } else {
600+ format!("${{{}}}", part)
601+ }
602+ })
603+ .collect();
604+ let mut joined = String::new();
605+ for (k, piece) in pieces.iter().enumerate() {
606+ // `$n` directly followed by text would read as a longer name.
607+ let next_continues_name = pieces
608+ .get(k + 1)
609+ .and_then(|next| next.chars().next())
610+ .is_some_and(is_var_char);
611+ if piece.starts_with('$') && !piece.starts_with("${") && next_continues_name {
612+ joined.push_str(&format!("${{{}}}", piece));
613+ } else {
614+ joined.push_str(piece);
615+ }
616+ }
617+ return format!("{pad}@let {} \"{}\"", name, joined);
618+ }
582 // `@let x $a + 4` computed its value; computing now needs `=`.
619 // `@let x $a + 4` computed its value; computing now needs `=`.
583 if let Some(rest) = trimmed.strip_prefix("@let ")
620 if let Some(rest) = trimmed.strip_prefix("@let ")
584 && let Some((name, value)) = rest.split_once(' ')
621 && let Some((name, value)) = rest.split_once(' ')
@@ -1312,7 +1349,8 @@ mod tests {
1312 #[test]
1349 #[test]
1313 fn computed_let_needs_equals() {
1350 fn computed_let_needs_equals() {
1314 assert_eq!(up("@let gap $base + 4"), "@let gap = $base + 4");
1351 assert_eq!(up("@let gap $base + 4"), "@let gap = $base + 4");
1315− assert_eq!(up("@let full $a ~ \" \" ~ $b"), "@let full = $a ~ \" \" ~ $b");
1352+ assert_eq!(up("@let full $a ~ \" \" ~ $b"), "@let full \"$a $b\"");
1353+ assert_eq!(up("@let x = $n ~ \"px\""), "@let x \"${$n}px\"");
1316 assert_eq!(up("@let x = 1 + 2"), "@let x = 1 + 2");
1354 assert_eq!(up("@let x = 1 + 2"), "@let x = 1 + 2");
1317 assert_eq!(up("@let area 1 / span 2"), "@let area 1 / span 2");
1355 assert_eq!(up("@let area 1 / span 2"), "@let area 1 / span 2");
1318 assert_eq!(up("@let card $title $tone=primary\n @text $title"), "@let card $title $tone=primary\n @text $title");
1356 assert_eq!(up("@let card $title $tone=primary\n @text $title"), "@let card $title $tone=primary\n @text $title");
tests/snapshots.rs+2−2
@@ -1020,9 +1020,9 @@ fn comparison_operators_starts_with() {
1020}
1020}
1021
1021
1022#[test]
1022#[test]
1023−fn string_concat_operator() {
1023+fn string_interpolation_joins_values() {
1024 let output = compile(
1024 let output = compile(
1025− "@page T\n@let first Hello\n@let last World\n@let full = $first ~ \" \" ~ $last\n@text $full",
1025+ "@page T\n@let first Hello\n@let last World\n@let full \"$first $last\"\n@text $full",
1026 );
1026 );
1027 assert!(output.contains("Hello World"), "got: {}", output);
1027 assert!(output.contains("Hello World"), "got: {}", output);
1028}
1028}