on master help sign in
One conditional form in attributes: if()

`key if cond` is gone. if(cond, a, b) now also works as a whole attribute (`if($loading, disabled)`), and an empty choice, including a left-out else branch, drops the attribute. htmlang upgrade rewrites `key if cond` to `if(cond, key)`.

commit35b1774a5037fa1648d4acea3c56fb166f89aaa7 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:41 (2 weeks ago) parent17f5762
4 files changed+162−40
DESIGN.md+5−3 crates/htmlang-core/src/parser.rs+86−33 src/upgrade.rs+40 tests/snapshots.rs+31−4
DESIGN.md+5−3
@@ -255,12 +255,13 @@ Prefix a style attribute to apply it conditionally:
255255
256256### Conditional attributes
257257
258−`key if CONDITION` includes an attribute only when the condition holds;
259−`if(CONDITION, A, B)` picks a value.
258+`if(CONDITION, A, B)` picks `A` or `B` by the condition. It works as a value or
259+as a whole attribute, and an empty choice (the `B` can be left out) leaves the
260+attribute out.
260261
261262```
262263@let active true
263−@el [background if($active, blue, gray), bold if $active]
264+@el [background if($active, blue, gray), if($active, bold), padding if($active, 12)]
264265 Conditionally styled
265266```
266267
@@ -489,6 +490,7 @@ removed form with its replacement.
489490| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
490491| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
491492| `COND ? A : B` | `if(COND, A, B)` |
493+| `key if COND` (in attributes) | `if(COND, key)` |
492494| `$a ~ " " ~ $b` | `"$a $b"` |
493495| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
494496| `...$bundle` | `$bundle` |
crates/htmlang-core/src/parser.rs+86−33
@@ -2766,6 +2766,15 @@ fn parse_attr_list(
27662766
27672767 for part in split_commas(input) {
27682768 let part = part.trim();
2769+ // A whole attribute `if(cond, a, b)` is the chosen branch's text.
2770+ let chosen;
2771+ let part = match choose_if(part, ctx, line_num) {
2772+ Some(branch) => {
2773+ chosen = branch;
2774+ chosen.as_str()
2775+ }
2776+ None => part,
2777+ };
27692778 if part.is_empty() {
27702779 continue;
27712780 }
@@ -2781,19 +2790,24 @@ fn parse_attr_list(
27812790
27822791 track_var_refs(part, &mut ctx.used_variables);
27832792
2784− // Conditional attribute: `key if condition` or `key value if condition`.
2785− // Conditions are evaluated on the source text, before substitution.
2786− let part = match split_trailing_if(part) {
2787− (attr, Some(condition)) => {
2788− if !ctx.condition(condition, line_num) {
2789− continue;
2790− }
2791− attr
2792− }
2793− (attr, None) => attr,
2793+ if let Some((attr, condition)) = split_trailing_if(part) {
2794+ ctx.diagnostics.push(Diagnostic {
2795+ line: line_num,
2796+ column: None,
2797+ message: format!(
2798+ "`KEY if CONDITION` was removed: write `if({}, {})`",
2799+ condition, attr
2800+ ),
2801+ severity: Severity::Error,
2802+ source_line: None,
2803+ });
2804+ continue;
2805+ }
2806+ // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`;
2807+ // an empty choice leaves the attribute out.
2808+ let Some(part) = choose_if_value(part, ctx, line_num) else {
2809+ continue;
27942810 };
2795− // A value `if(cond, a, b)` picks `a` or `b` (free text) by `cond`.
2796− let part = choose_if_value(part, ctx, line_num);
27972811 let part = substitute_vars(&part, &ctx.variables);
27982812
27992813 let attr = if let Some((key, value)) = split_html_attribute(&part) {
@@ -3108,53 +3122,92 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
31083122
31093123
31103124
3111−/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
3112−/// Split `key value if condition` into the attribute and its condition
3113−/// (ignoring ` if ` inside parentheses).
3114−fn split_trailing_if(part: &str) -> (&str, Option<&str>) {
3125+/// Find a leftover `key if condition` (ignoring ` if ` inside parentheses
3126+/// or quotes), to point at `if()`.
3127+fn split_trailing_if(part: &str) -> Option<(&str, &str)> {
31153128 let mut depth = 0;
3129+ let mut quote = None;
31163130 for (i, c) in part.char_indices() {
31173131 match c {
3132+ '"' | '\'' if quote == Some(c) => quote = None,
3133+ '"' | '\'' if quote.is_none() => quote = Some(c),
3134+ _ if quote.is_some() => {}
31183135 '(' => depth += 1,
31193136 ')' => depth -= 1,
31203137 _ if depth == 0 && part[i..].starts_with(" if ") => {
3121− return (&part[..i], Some(part[i + 4..].trim()));
3138+ return Some((&part[..i], part[i + 4..].trim()));
31223139 }
31233140 _ => {}
31243141 }
31253142 }
3126− (part, None)
3143+ None
31273144}
31283145
3129−/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
3130−/// chosen branch's text. Other attributes are returned unchanged.
3131−fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> String {
3132− let (head, value) = match part.find(['=', ' ']) {
3133− Some(pos) => part.split_at(pos + 1),
3134− None => return part.to_string(),
3135− };
3136− let value = value.trim();
3137− let Some(inner) = value.strip_prefix("if(").and_then(|v| v.strip_suffix(')')) else {
3138− return part.to_string();
3139− };
3146+/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
3147+/// returning the chosen branch (empty when `cond` fails and there is no `b`).
3148+fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
3149+ let inner = text.strip_prefix("if(")?.strip_suffix(')')?;
31403150 let args = split_if_args(inner);
3141− if args.len() != 3 {
3142− return part.to_string();
3151+ // `if(a) (b)` isn't one call
3152+ if split_trailing_paren(inner) || !(2..=3).contains(&args.len()) {
3153+ return None;
31433154 }
31443155 let branch = if ctx.condition(args[0].trim(), line) {
31453156 args[1]
31463157 } else {
3147− args[2]
3158+ args.get(2).copied().unwrap_or("")
3159+ };
3160+ Some(branch.trim().to_string())
3161+}
3162+
3163+/// Whether the parentheses in `inner` close before its end, as in the
3164+/// inside of `if(a)(b)`.
3165+fn split_trailing_paren(inner: &str) -> bool {
3166+ let mut depth = 0i32;
3167+ let mut quote = None;
3168+ for c in inner.chars() {
3169+ match c {
3170+ '"' | '\'' if quote == Some(c) => quote = None,
3171+ '"' | '\'' if quote.is_none() => quote = Some(c),
3172+ _ if quote.is_some() => {}
3173+ '(' => depth += 1,
3174+ ')' => {
3175+ depth -= 1;
3176+ if depth < 0 {
3177+ return true;
3178+ }
3179+ }
3180+ _ => {}
3181+ }
3182+ }
3183+ false
3184+}
3185+
3186+/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
3187+/// chosen branch's text, or `None` when the choice is empty. Other
3188+/// attributes are returned unchanged.
3189+fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
3190+ let Some(pos) = part.find(['=', ' ']) else {
3191+ return Some(part.to_string());
31483192 };
3149− format!("{}{}", head, branch.trim())
3193+ let (head, value) = part.split_at(pos + 1);
3194+ match choose_if(value.trim(), ctx, line) {
3195+ Some(branch) if branch.is_empty() => None,
3196+ Some(branch) => Some(format!("{}{}", head, branch)),
3197+ None => Some(part.to_string()),
3198+ }
31503199}
31513200
31523201fn split_if_args(input: &str) -> Vec<&str> {
31533202 let mut parts = Vec::new();
31543203 let mut start = 0;
31553204 let mut depth = 0;
3205+ let mut quote = None;
31563206 for (i, c) in input.char_indices() {
31573207 match c {
3208+ '"' | '\'' if quote == Some(c) => quote = None,
3209+ '"' | '\'' if quote.is_none() => quote = Some(c),
3210+ _ if quote.is_some() => {}
31583211 '(' => depth += 1,
31593212 ')' => depth -= 1,
31603213 ',' if depth == 0 => {
src/upgrade.rs+40
@@ -1013,6 +1013,27 @@ fn rewrite_attr_regions(
10131013}
10141014
10151015/// Rewrite one attribute list's contents (without the brackets).
1016+/// Split `key value if condition` at its ` if ` (outside parentheses and
1017+/// quotes).
1018+fn split_trailing_if(attr: &str) -> Option<(&str, &str)> {
1019+ let mut depth = 0;
1020+ let mut quote = None;
1021+ for (i, c) in attr.char_indices() {
1022+ match c {
1023+ '"' | '\'' if quote == Some(c) => quote = None,
1024+ '"' | '\'' if quote.is_none() => quote = Some(c),
1025+ _ if quote.is_some() => {}
1026+ '(' => depth += 1,
1027+ ')' => depth -= 1,
1028+ _ if depth == 0 && attr[i..].starts_with(" if ") => {
1029+ return Some((&attr[..i], attr[i + 4..].trim()));
1030+ }
1031+ _ => {}
1032+ }
1033+ }
1034+ None
1035+}
1036+
10161037fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
10171038 if list.is_empty() {
10181039 return String::new();
@@ -1035,6 +1056,16 @@ fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
10351056 out.push_str(ws);
10361057 }
10371058 first = false;
1059+ // `key if cond` → `if(cond, key)`
1060+ if let Some((attr, condition)) = split_trailing_if(body.trim_end()) {
1061+ out.push_str(&format!(
1062+ "if({}, {})",
1063+ condition,
1064+ rewrite_attr_list(attr, element_attrs)
1065+ ));
1066+ out.push_str(&body[body.trim_end().len()..]);
1067+ continue;
1068+ }
10381069 // `...$bundle` spread → `$bundle`
10391070 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
10401071 // Built-in style attributes that became standard-library bundles
@@ -1549,6 +1580,15 @@ mod tests {
15491580 );
15501581 }
15511582
1583+ #[test]
1584+ fn conditional_attributes_become_if() {
1585+ assert_eq!(
1586+ up("@button [padding 10 if $big, disabled if $x == \"a b\", color if($on, red, blue)] Go\n"),
1587+ "@button [if($big, padding 10), if($x == \"a b\", disabled), color if($on, red, blue)] Go\n"
1588+ );
1589+ assert_eq!(up("@el [shadow if $on]\n"), "@el [if($on, box-shadow)]\n");
1590+ }
1591+
15521592 #[test]
15531593 fn layouts_become_functions() {
15541594 assert_eq!(
tests/snapshots.rs+31−4
@@ -3833,28 +3833,55 @@ fn for_with_variable_bounds() {
38333833
38343834#[test]
38353835fn conditional_attr_true() {
3836− let html = compile("@let show true\n@el [padding 10 if $show]\n test\n");
3836+ let html = compile("@let show true\n@el [if($show, padding 10)]\n test\n");
38373837 assert!(html.contains("padding:10px"));
38383838}
38393839
38403840#[test]
38413841fn conditional_attr_false() {
3842− let html = compile("@let show false\n@el [padding 10 if $show]\n test\n");
3842+ let html = compile("@let show false\n@el [if($show, padding 10)]\n test\n");
38433843 assert!(!html.contains("padding:10px"));
38443844}
38453845
38463846#[test]
38473847fn conditional_attr_boolean_true() {
3848− let html = compile("@let loading true\n@button [disabled if $loading] Click\n");
3848+ let html = compile("@let loading true\n@button [if($loading, disabled)] Click\n");
38493849 assert!(html.contains("disabled"));
38503850}
38513851
38523852#[test]
38533853fn conditional_attr_boolean_false() {
3854− let html = compile("@let loading false\n@button [disabled if $loading] Click\n");
3854+ let html = compile("@let loading false\n@button [if($loading, disabled)] Click\n");
38553855 assert!(!html.contains("disabled"));
38563856}
38573857
3858+#[test]
3859+fn conditional_value_without_else_drops_the_attribute() {
3860+ let html = compile("@let on false\n@el [padding if($on, 10), margin if($on, 4, 8)]\n test\n");
3861+ assert!(!html.contains("padding"), "{}", html);
3862+ assert!(html.contains("margin:8px"), "{}", html);
3863+}
3864+
3865+#[test]
3866+fn conditional_attribute_can_pick_a_bundle() {
3867+ let html = compile("@let on false\n@el [if($on, bold, $truncate)]\n test\n");
3868+ assert!(html.contains("text-overflow:ellipsis"), "{}", html);
3869+ assert!(!html.contains("bold"), "{}", html);
3870+}
3871+
3872+#[test]
3873+fn key_if_condition_was_removed() {
3874+ let result = htmlang::parser::parse("@let on true\n@el [bold if $on]\n test\n");
3875+ assert!(
3876+ result
3877+ .diagnostics
3878+ .iter()
3879+ .any(|d| d.message.contains("was removed") && d.message.contains("if($on, bold)")),
3880+ "{:?}",
3881+ result.diagnostics
3882+ );
3883+}
3884+
38583885// -----------------------------------------------------------------------
38593886// @component tests
38603887// -----------------------------------------------------------------------