Repository: lambda-fairy/maud
Pull request #238 (2020-11-07 10:12:19)

Make the `?` suffix for empty attributes optional
# Summary

Maud no longer requires `?` after an empty attribute. For example, this code

```rust
html! {
    input type="checkbox" checked? disabled?[is_disabled];
}
```

can now be written as

```rust
html! {
    input type="checkbox" checked disabled[is_disabled];
}
```

The old syntax is still allowed, but soft-deprecated. It will be removed in some future release (TBD).

# Rationale

The `?` was necessary back when braces were optional, such that a bare identifier (e.g. `title`) could mean either an attribute or an element.

But with #137 making braces mandatory, there's no need for the `?` anymore – it is perfectly unambiguous without it.

lambda-fairy: TODO:

- [ ] Update changelog
- [ ] Update docs

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69fd788..a156f7f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
 
 ## Unreleased
 
+- [Changed] Don't require `?` suffix for empty attributes. The old syntax is kept for backward compatibility.
+  [#238](https://github.com/lambda-fairy/maud/pull/238)
+
 ## [0.22.1] - 2020-11-02
 
 - [Added] Stable support 🎉
diff --git a/docs/content/elements-attributes.md b/docs/content/elements-attributes.md
index a5f0f4f..03b4da1 100644
--- a/docs/content/elements-attributes.md
+++ b/docs/content/elements-attributes.md
@@ -64,20 +64,24 @@ html! {
 }
 ```
 
-## Empty attributes: `checked?`
+## Empty attributes: `checked`
 
-Declare an empty attribute using a `?` suffix: `checked?`.
+Declare an empty attribute by omitting the value.
 
 ```rust
 html! {
     form {
-        input type="checkbox" name="cupcakes" checked?;
+        input type="checkbox" name="cupcakes" checked;
         " "
         label for="cupcakes" { "Do you like cupcakes?" }
     }
 }
 ```
 
+Before version 0.22.2, Maud required a `?` suffix on empty attributes: `checked?`. This is no longer necessary ([#238]), but still supported for backward compatibility.
+
+[#238]: https://github.com/lambda-fairy/maud/pull/238
+
 ## Classes and IDs: `.foo` `#bar`
 
 Add classes and IDs to an element using `.foo` and `#bar` syntax. You can chain multiple classes and IDs together, and mix and match them with other attributes:
diff --git a/docs/content/splices-toggles.md b/docs/content/splices-toggles.md
index 04823d4..ba4942a 100644
--- a/docs/content/splices-toggles.md
+++ b/docs/content/splices-toggles.md
@@ -95,7 +95,7 @@ This works on empty attributes:
 ```rust
 let allow_editing = true;
 html! {
-    p contenteditable?[allow_editing] {
+    p contenteditable[allow_editing] {
         "Edit me, I "
         em { "dare" }
         " you."
diff --git a/maud_macros/src/parse.rs b/maud_macros/src/parse.rs
index 6390969..3a907e0 100644
--- a/maud_macros/src/parse.rs
+++ b/maud_macros/src/parse.rs
@@ -63,11 +63,6 @@ impl Parser {
         self.next();
     }
 
-    /// Overwrites the current parser state with the given parameter.
-    fn commit(&mut self, attempt: Parser) {
-        *self = attempt;
-    }
-
     /// Parses and renders multiple blocks of markup.
     fn markups(&mut self) -> Vec<ast::Markup> {
         let mut result = Vec::new();
@@ -534,60 +529,73 @@ impl Parser {
     fn attrs(&mut self) -> ast::Attrs {
         let mut attrs = Vec::new();
         loop {
-            let mut attempt = self.clone();
-            let maybe_name = attempt.try_namespaced_name();
-            let token_after = attempt.next();
-            match (maybe_name, token_after) {
-                // Non-empty attribute
-                (Some(ref name), Some(TokenTree::Punct(ref punct))) if punct.as_char() == '=' => {
-                    self.commit(attempt);
-                    let value;
-                    {
-                        // Parse a value under an attribute context
-                        let in_attr = mem::replace(&mut self.in_attr, true);
-                        value = self.markup();
-                        self.in_attr = in_attr;
+            if let Some(name) = self.try_namespaced_name() {
+                // Attribute
+                match self.peek() {
+                    // Non-empty attribute
+                    Some(TokenTree::Punct(ref punct)) if punct.as_char() == '=' => {
+                        self.advance();
+                        let value;
+                        {
+                            // Parse a value under an attribute context
+                            let in_attr = mem::replace(&mut self.in_attr, true);
+                            value = self.markup();
+                            self.in_attr = in_attr;
+                        }
+                        attrs.push(ast::Attr::Attribute {
+                            attribute: ast::Attribute {
+                                name,
+                                attr_type: ast::AttrType::Normal { value },
+                            },
+                        });
+                    }
+                    // Empty attribute (legacy syntax)
+                    Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
+                        self.advance();
+                        let toggler = self.attr_toggler();
+                        attrs.push(ast::Attr::Attribute {
+                            attribute: ast::Attribute {
+                                name: name.clone(),
+                                attr_type: ast::AttrType::Empty { toggler },
+                            },
+                        });
+                    }
+                    // Empty attribute (new syntax)
+                    _ => {
+                        let toggler = self.attr_toggler();
+                        attrs.push(ast::Attr::Attribute {
+                            attribute: ast::Attribute {
+                                name: name.clone(),
+                                attr_type: ast::AttrType::Empty { toggler },
+                            },
+                        });
                     }
-                    attrs.push(ast::Attr::Attribute {
-                        attribute: ast::Attribute {
-                            name: name.clone(),
-                            attr_type: ast::AttrType::Normal { value },
-                        },
-                    });
-                }
-                // Empty attribute
-                (Some(ref name), Some(TokenTree::Punct(ref punct))) if punct.as_char() == '?' => {
-                    self.commit(attempt);
-                    let toggler = self.attr_toggler();
-                    attrs.push(ast::Attr::Attribute {
-                        attribute: ast::Attribute {
-                            name: name.clone(),
-                            attr_type: ast::AttrType::Empty { toggler },
-                        },
-                    });
-                }
-                // Class shorthand
-                (None, Some(TokenTree::Punct(ref punct))) if punct.as_char() == '.' => {
-                    self.commit(attempt);
-                    let name = self.class_or_id_name();
-                    let toggler = self.attr_toggler();
-                    attrs.push(ast::Attr::Class {
-                        dot_span: SpanRange::single_span(punct.span()),
-                        name,
-                        toggler,
-                    });
                 }
-                // ID shorthand
-                (None, Some(TokenTree::Punct(ref punct))) if punct.as_char() == '#' => {
-                    self.commit(attempt);
-                    let name = self.class_or_id_name();
-                    attrs.push(ast::Attr::Id {
-                        hash_span: SpanRange::single_span(punct.span()),
-                        name,
-                    });
+            } else {
+                match self.peek() {
+                    // Class shorthand
+                    Some(TokenTree::Punct(ref punct)) if punct.as_char() == '.' => {
+                        self.advance();
+                        let name = self.class_or_id_name();
+                        let toggler = self.attr_toggler();
+                        attrs.push(ast::Attr::Class {
+                            dot_span: SpanRange::single_span(punct.span()),
+                            name,
+                            toggler,
+                        });
+                    }
+                    // ID shorthand
+                    Some(TokenTree::Punct(ref punct)) if punct.as_char() == '#' => {
+                        self.advance();
+                        let name = self.class_or_id_name();
+                        attrs.push(ast::Attr::Id {
+                            hash_span: SpanRange::single_span(punct.span()),
+                            name,
+                        });
+                    }
+                    // If it's not a valid attribute, backtrack and bail out
+                    _ => break,
                 }
-                // If it's not a valid attribute, backtrack and bail out
-                _ => break,
             }
         }
 


Test patch:
diff --git a/maud/tests/basic_syntax.rs b/maud/tests/basic_syntax.rs
index e99c843..e7f73ca 100644
--- a/maud/tests/basic_syntax.rs
+++ b/maud/tests/basic_syntax.rs
@@ -76,7 +76,7 @@ fn simple_attributes() {
 
 #[test]
 fn empty_attributes() {
-    let result = html! { div readonly? { input type="checkbox" checked?; } };
+    let result = html! { div readonly { input type="checkbox" checked; } };
     assert_eq!(
         result.into_string(),
         r#"<div readonly><input type="checkbox" checked></div>"#
@@ -87,10 +87,10 @@ fn empty_attributes() {
 fn toggle_empty_attributes() {
     let rocks = true;
     let result = html! {
-        input checked?[true];
-        input checked?[false];
-        input checked?[rocks];
-        input checked?[!rocks];
+        input checked[true];
+        input checked[false];
+        input checked[rocks];
+        input checked[!rocks];
     };
     assert_eq!(
         result.into_string(),
@@ -108,10 +108,16 @@ fn toggle_empty_attributes_braces() {
     struct Maud {
         rocks: bool,
     }
-    let result = html! { input checked?[Maud { rocks: true }.rocks]; };
+    let result = html! { input checked[Maud { rocks: true }.rocks]; };
     assert_eq!(result.into_string(), r#"<input checked>"#);
 }
 
+#[test]
+fn empty_attributes_question_mark() {
+    let result = html! { input checked? disabled?[true]; };
+    assert_eq!(result.into_string(), "<input checked disabled>");
+}
+
 #[test]
 fn colons_in_names() {
     let result = html! { pon-pon:controls-alpha { a on:click="yay()" { "Yay!" } } };
@@ -133,7 +139,7 @@ fn hyphens_in_element_names() {
 
 #[test]
 fn hyphens_in_attribute_names() {
-    let result = html! { this sentence-is="false" of-course? {} };
+    let result = html! { this sentence-is="false" of-course {} };
     assert_eq!(
         result.into_string(),
         r#"<this sentence-is="false" of-course></this>"#
@@ -281,7 +287,7 @@ fn div_shorthand_id() {
 
 #[test]
 fn div_shorthand_class_with_attrs() {
-    let result = html! { .awesome-class contenteditable? dir="rtl" #unique-id {} };
+    let result = html! { .awesome-class contenteditable dir="rtl" #unique-id {} };
     assert_eq!(
         result.into_string(),
         r#"<div class="awesome-class" id="unique-id" contenteditable dir="rtl"></div>"#
@@ -290,7 +296,7 @@ fn div_shorthand_class_with_attrs() {
 
 #[test]
 fn div_shorthand_id_with_attrs() {
-    let result = html! { #unique-id contenteditable? dir="rtl" .awesome-class {} };
+    let result = html! { #unique-id contenteditable dir="rtl" .awesome-class {} };
     assert_eq!(
         result.into_string(),
         r#"<div class="awesome-class" id="unique-id" contenteditable dir="rtl"></div>"#