on master help sign in
One conditional form in attributes: if()
35b1774iko
DESIGN.md+5−3
@@ -255,12 +255,13 @@ Prefix a style attribute to apply it conditionally:
255
255
256### Conditional attributes
256### Conditional attributes
257
257
258−`key if CONDITION` includes an attribute only when the condition holds;
258+`if(CONDITION, A, B)` picks `A` or `B` by the condition. It works as a value or
259−`if(CONDITION, A, B)` picks a value.
259+as a whole attribute, and an empty choice (the `B` can be left out) leaves the
260+attribute out.
260
261
261```
262```
262@let active true
263@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)]
264 Conditionally styled
265 Conditionally styled
265```
266```
266
267
@@ -489,6 +490,7 @@ removed form with its replacement.
489| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
490| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
490| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
491| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
491| `COND ? A : B` | `if(COND, A, B)` |
492| `COND ? A : B` | `if(COND, A, B)` |
493+| `key if COND` (in attributes) | `if(COND, key)` |
492| `$a ~ " " ~ $b` | `"$a $b"` |
494| `$a ~ " " ~ $b` | `"$a $b"` |
493| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
495| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
494| `...$bundle` | `$bundle` |
496| `...$bundle` | `$bundle` |
crates/htmlang-core/src/parser.rs+86−33
@@ -2766,6 +2766,15 @@ fn parse_attr_list(
2766
2766
2767 for part in split_commas(input) {
2767 for part in split_commas(input) {
2768 let part = part.trim();
2768 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+ };
2769 if part.is_empty() {
2778 if part.is_empty() {
2770 continue;
2779 continue;
2771 }
2780 }
@@ -2781,19 +2790,24 @@ fn parse_attr_list(
2781
2790
2782 track_var_refs(part, &mut ctx.used_variables);
2791 track_var_refs(part, &mut ctx.used_variables);
2783
2792
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) {
2793+ if let Some((attr, condition)) = split_trailing_if(part) {
2787− (attr, Some(condition)) => {
2794+ ctx.diagnostics.push(Diagnostic {
2788− if !ctx.condition(condition, line_num) {
2795+ line: line_num,
2789− continue;
2796+ column: None,
2790− }
2797+ message: format!(
2791− attr
2798+ "`KEY if CONDITION` was removed: write `if({}, {})`",
2799+ condition, attr
2800+ ),
2801+ severity: Severity::Error,
2802+ source_line: None,
2792− }
2803+ });
2793− (attr, None) => attr,
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;
2794 };
2810 };
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);
2797 let part = substitute_vars(&part, &ctx.variables);
2811 let part = substitute_vars(&part, &ctx.variables);
2798
2812
2799 let attr = if let Some((key, value)) = split_html_attribute(&part) {
2813 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> {
3108
3122
3109
3123
3110
3124
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).
3125+/// Find a leftover `key if condition` (ignoring ` if ` inside parentheses
3126+/// or quotes), to point at `if()`.
3114−fn split_trailing_if(part: &str) -> (&str, Option<&str>) {
3127+fn split_trailing_if(part: &str) -> Option<(&str, &str)> {
3115 let mut depth = 0;
3128 let mut depth = 0;
3129+ let mut quote = None;
3116 for (i, c) in part.char_indices() {
3130 for (i, c) in part.char_indices() {
3117 match c {
3131 match c {
3132+ '"' | '\'' if quote == Some(c) => quote = None,
3133+ '"' | '\'' if quote.is_none() => quote = Some(c),
3134+ _ if quote.is_some() => {}
3118 '(' => depth += 1,
3135 '(' => depth += 1,
3119 ')' => depth -= 1,
3136 ')' => depth -= 1,
3120 _ if depth == 0 && part[i..].starts_with(" if ") => {
3137 _ 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()));
3122 }
3139 }
3123 _ => {}
3140 _ => {}
3124 }
3141 }
3125 }
3142 }
3126− (part, None)
3143+ None
3127}
3144}
3128
3145
3129−/// Resolve a value written `if(cond, a, b)`: evaluate `cond` and keep the
3146+/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
3130−/// chosen branch's text. Other attributes are returned unchanged.
3147+/// returning the chosen branch (empty when `cond` fails and there is no `b`).
3131−fn choose_if_value(part: &str, ctx: &mut ParseContext, line: usize) -> String {
3148+fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<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 {
3149+ let inner = text.strip_prefix("if(")?.strip_suffix(')')?;
3138− return part.to_string();
3139− };
3140 let args = split_if_args(inner);
3150 let args = split_if_args(inner);
3141− if args.len() != 3 {
3151+ // `if(a) (b)` isn't one call
3152+ if split_trailing_paren(inner) || !(2..=3).contains(&args.len()) {
3142− return part.to_string();
3153+ return None;
3143 }
3154 }
3144 let branch = if ctx.condition(args[0].trim(), line) {
3155 let branch = if ctx.condition(args[0].trim(), line) {
3145 args[1]
3156 args[1]
3146 } else {
3157 } 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());
3148 };
3192 };
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,
3149− format!("{}{}", head, branch.trim())
3196+ Some(branch) => Some(format!("{}{}", head, branch)),
3197+ None => Some(part.to_string()),
3198+ }
3150}
3199}
3151
3200
3152fn split_if_args(input: &str) -> Vec<&str> {
3201fn split_if_args(input: &str) -> Vec<&str> {
3153 let mut parts = Vec::new();
3202 let mut parts = Vec::new();
3154 let mut start = 0;
3203 let mut start = 0;
3155 let mut depth = 0;
3204 let mut depth = 0;
3205+ let mut quote = None;
3156 for (i, c) in input.char_indices() {
3206 for (i, c) in input.char_indices() {
3157 match c {
3207 match c {
3208+ '"' | '\'' if quote == Some(c) => quote = None,
3209+ '"' | '\'' if quote.is_none() => quote = Some(c),
3210+ _ if quote.is_some() => {}
3158 '(' => depth += 1,
3211 '(' => depth += 1,
3159 ')' => depth -= 1,
3212 ')' => depth -= 1,
3160 ',' if depth == 0 => {
3213 ',' if depth == 0 => {
src/upgrade.rs+40
@@ -1013,6 +1013,27 @@ fn rewrite_attr_regions(
1013}
1013}
1014
1014
1015/// Rewrite one attribute list's contents (without the brackets).
1015/// 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+
1016fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
1037fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
1017 if list.is_empty() {
1038 if list.is_empty() {
1018 return String::new();
1039 return String::new();
@@ -1035,6 +1056,16 @@ fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
1035 out.push_str(ws);
1056 out.push_str(ws);
1036 }
1057 }
1037 first = false;
1058 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+ }
1038 // `...$bundle` spread → `$bundle`
1069 // `...$bundle` spread → `$bundle`
1039 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
1070 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
1040 // Built-in style attributes that became standard-library bundles
1071 // Built-in style attributes that became standard-library bundles
@@ -1549,6 +1580,15 @@ mod tests {
1549 );
1580 );
1550 }
1581 }
1551
1582
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+
1552 #[test]
1592 #[test]
1553 fn layouts_become_functions() {
1593 fn layouts_become_functions() {
1554 assert_eq!(
1594 assert_eq!(
tests/snapshots.rs+31−4
@@ -3833,28 +3833,55 @@ fn for_with_variable_bounds() {
3833
3833
3834#[test]
3834#[test]
3835fn conditional_attr_true() {
3835fn 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");
3837 assert!(html.contains("padding:10px"));
3837 assert!(html.contains("padding:10px"));
3838}
3838}
3839
3839
3840#[test]
3840#[test]
3841fn conditional_attr_false() {
3841fn 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");
3843 assert!(!html.contains("padding:10px"));
3843 assert!(!html.contains("padding:10px"));
3844}
3844}
3845
3845
3846#[test]
3846#[test]
3847fn conditional_attr_boolean_true() {
3847fn 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");
3849 assert!(html.contains("disabled"));
3849 assert!(html.contains("disabled"));
3850}
3850}
3851
3851
3852#[test]
3852#[test]
3853fn conditional_attr_boolean_false() {
3853fn 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");
3855 assert!(!html.contains("disabled"));
3855 assert!(!html.contains("disabled"));
3856}
3856}
3857
3857
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+
3858// -----------------------------------------------------------------------
3885// -----------------------------------------------------------------------
3859// @component tests
3886// @component tests
3860// -----------------------------------------------------------------------
3887// -----------------------------------------------------------------------