Allow splices in attribute names

Closes 
This commit is contained in:
Bad Manners 2024-09-28 11:00:42 -03:00
parent 0254fe1f81
commit 2c60c64181
5 changed files with 126 additions and 52 deletions
docs/content
maud/tests
maud_macros/src

View file

@ -90,6 +90,21 @@ html! {
# ;
```
### Splices in attribute name
You can also use splices in the attribute name:
```rust
let tuple = ("hx-get", "/pony");
# let _ = maud::
html! {
button (tuple.0)=(tuple.1) {
"Get a pony!"
}
}
# ;
```
### What can be spliced?
You can splice any value that implements [`Render`][Render].
@ -145,7 +160,7 @@ html! {
### Optional attributes with values: `title=[Some("value")]`
Add optional attributes to an element using `attr=[value]` syntax, with *square* brackets.
Add optional attributes to an element using `attr=[value]` syntax, with _square_ brackets.
These are only rendered if the value is `Some<T>`, and entirely omitted if the value is `None`.
```rust

View file

@ -73,6 +73,16 @@ fn locals() {
assert_eq!(result.into_string(), "Pinkie Pie");
}
#[test]
fn attribute_name() {
let tuple = ("hx-get", "/pony");
let result = html! { button (tuple.0)=(tuple.1) { "Get a pony!" } };
assert_eq!(
result.into_string(),
r#"<button hx-get="/pony">Get a pony!</button>"#
);
}
/// An example struct, for testing purposes only
struct Creature {
name: &'static str,

View file

@ -153,13 +153,13 @@ impl Special {
#[derive(Debug)]
pub struct NamedAttr {
pub name: TokenStream,
pub name: AttrName,
pub attr_type: AttrType,
}
impl NamedAttr {
fn span(&self) -> SpanRange {
let name_span = span_tokens(self.name.clone());
let name_span = span_tokens(self.name.tokens());
if let Some(attr_type_span) = self.attr_type.span() {
name_span.join_range(attr_type_span)
} else {
@ -168,6 +168,31 @@ impl NamedAttr {
}
}
#[derive(Debug, Clone)]
pub enum AttrName {
Fixed { value: TokenStream },
Splice { expr: TokenStream },
}
impl AttrName {
pub fn tokens(&self) -> TokenStream {
match self {
AttrName::Fixed { value } => value.clone(),
AttrName::Splice { expr, .. } => expr.clone(),
}
}
}
impl std::fmt::Display for AttrName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AttrName::Fixed { value } => f.write_str(&value.to_string())?,
AttrName::Splice { expr, .. } => f.write_str(&expr.to_string())?,
};
Ok(())
}
}
#[derive(Debug)]
pub enum AttrType {
Normal { value: Markup },

View file

@ -123,12 +123,19 @@ impl Generator {
build.push_escaped(&name_to_string(name));
}
fn attr_name(&self, name: AttrName, build: &mut Builder) {
match name {
AttrName::Fixed { value } => self.name(value, build),
AttrName::Splice { expr, .. } => self.splice(expr, build),
}
}
fn attrs(&self, attrs: Vec<Attr>, build: &mut Builder) {
for NamedAttr { name, attr_type } in desugar_attrs(attrs) {
match attr_type {
AttrType::Normal { value } => {
build.push_str(" ");
self.name(name, build);
self.attr_name(name, build);
build.push_str("=\"");
self.markup(value, build);
build.push_str("\"");
@ -140,7 +147,7 @@ impl Generator {
let body = {
let mut build = self.builder();
build.push_str(" ");
self.name(name, &mut build);
self.attr_name(name, &mut build);
build.push_str("=\"");
self.splice(inner_value.clone(), &mut build);
build.push_str("\"");
@ -150,7 +157,7 @@ impl Generator {
}
AttrType::Empty { toggler: None } => {
build.push_str(" ");
self.name(name, build);
self.attr_name(name, build);
}
AttrType::Empty {
toggler: Some(Toggler { cond, .. }),
@ -158,7 +165,7 @@ impl Generator {
let body = {
let mut build = self.builder();
build.push_str(" ");
self.name(name, &mut build);
self.attr_name(name, &mut build);
build.finish()
};
build.push_tokens(quote!(if (#cond) { #body }));
@ -224,7 +231,9 @@ fn desugar_classes_or_ids(
});
}
Some(NamedAttr {
name: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
name: AttrName::Fixed {
value: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
},
attr_type: AttrType::Normal {
value: Markup::Block(Block {
markups,

View file

@ -13,7 +13,7 @@ pub fn parse(input: TokenStream) -> Vec<ast::Markup> {
#[derive(Clone)]
struct Parser {
/// If we're inside an attribute, then this contains the attribute name.
current_attr: Option<String>,
current_attr: Option<ast::AttrName>,
input: <TokenStream as IntoIterator>::IntoIter,
}
@ -580,48 +580,7 @@ impl Parser {
let mut attrs = Vec::new();
loop {
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();
// Parse a value under an attribute context
assert!(self.current_attr.is_none());
self.current_attr = Some(ast::name_to_string(name.clone()));
let attr_type = match self.attr_toggler() {
Some(toggler) => ast::AttrType::Optional { toggler },
None => {
let value = self.markup();
ast::AttrType::Normal { value }
}
};
self.current_attr = None;
attrs.push(ast::Attr::Named {
named_attr: ast::NamedAttr { name, attr_type },
});
}
// Empty attribute (legacy syntax)
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
self.advance();
let toggler = self.attr_toggler();
attrs.push(ast::Attr::Named {
named_attr: ast::NamedAttr {
name: name.clone(),
attr_type: ast::AttrType::Empty { toggler },
},
});
}
// Empty attribute (new syntax)
_ => {
let toggler = self.attr_toggler();
attrs.push(ast::Attr::Named {
named_attr: ast::NamedAttr {
name: name.clone(),
attr_type: ast::AttrType::Empty { toggler },
},
});
}
}
attrs.push(self.attr(ast::AttrName::Fixed { value: name }));
} else {
match self.peek() {
// Class shorthand
@ -644,6 +603,18 @@ impl Parser {
name,
});
}
// Spliced attribute name
Some(TokenTree::Group(ref group))
if group.delimiter() == Delimiter::Parenthesis =>
{
match self.markup() {
ast::Markup::Splice { expr, .. } => {
attrs.push(self.attr(ast::AttrName::Splice { expr }));
}
// If it's not a splice, backtrack and bail out
_ => break,
}
}
// If it's not a valid attribute, backtrack and bail out
_ => break,
}
@ -665,7 +636,7 @@ impl Parser {
ast::Attr::Id { .. } => "id".to_string(),
ast::Attr::Named { named_attr } => named_attr
.name
.clone()
.tokens()
.into_iter()
.map(|token| token.to_string())
.collect(),
@ -685,6 +656,50 @@ impl Parser {
attrs
}
fn attr(&mut self, name: ast::AttrName) -> ast::Attr {
match self.peek() {
// Non-empty attribute
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '=' => {
self.advance();
// Parse a value under an attribute context
assert!(self.current_attr.is_none());
self.current_attr = Some(name.clone());
let attr_type = match self.attr_toggler() {
Some(toggler) => ast::AttrType::Optional { toggler },
None => {
let value = self.markup();
ast::AttrType::Normal { value }
}
};
self.current_attr = None;
ast::Attr::Named {
named_attr: ast::NamedAttr { name, attr_type },
}
}
// Empty attribute (legacy syntax)
Some(TokenTree::Punct(ref punct)) if punct.as_char() == '?' => {
self.advance();
let toggler = self.attr_toggler();
ast::Attr::Named {
named_attr: ast::NamedAttr {
name,
attr_type: ast::AttrType::Empty { toggler },
},
}
}
// Empty attribute (new syntax)
_ => {
let toggler = self.attr_toggler();
ast::Attr::Named {
named_attr: ast::NamedAttr {
name,
attr_type: ast::AttrType::Empty { toggler },
},
}
}
}
}
/// Parses the name of a class or ID.
fn class_or_id_name(&mut self) -> ast::Markup {
if let Some(symbol) = self.try_name() {