fix formatting and merging if chains in attributes (#2697)

* fix formatting and merging if chains in attributes

* fix autoformat if attribute chains

* Fix IfAttributeValue ToTokens implementation

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
Evan Almloff 2024-07-25 03:56:21 +02:00 committed by GitHub
parent a6e41e9d68
commit 7f210633eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 301 additions and 145 deletions

View file

@ -85,6 +85,9 @@ fn app() -> Element {
class: "{asd}",
// if statements can be used to conditionally render attributes
class: if formatting.contains("form") { "{asd}" },
// longer if chains also work
class: if formatting.contains("form") { "{asd}" } else if formatting.contains("my other form") { "{asd}" },
class: if formatting.contains("form") { "{asd}" } else if formatting.contains("my other form") { "{asd}" } else { "{asd}" },
div {
class: {
const WORD: &str = "expressions";

View file

@ -226,14 +226,8 @@ impl Writer<'_> {
fn write_attribute_value(&mut self, value: &AttributeValue) -> Result {
match value {
AttributeValue::AttrOptionalExpr { condition, value } => {
write!(
self.out,
"if {condition} {{ ",
condition = unparse_expr(condition),
)?;
self.write_attribute_value(value)?;
write!(self.out, " }}")?;
AttributeValue::IfExpr(if_chain) => {
self.write_attribute_if_chain(if_chain)?;
}
AttributeValue::AttrLiteral(value) => {
write!(self.out, "{value}")?;
@ -258,6 +252,26 @@ impl Writer<'_> {
Ok(())
}
fn write_attribute_if_chain(&mut self, if_chain: &IfAttributeValue) -> Result {
write!(self.out, "if {} {{ ", unparse_expr(&if_chain.condition))?;
self.write_attribute_value(&if_chain.then_value)?;
write!(self.out, " }}")?;
match if_chain.else_value.as_deref() {
Some(AttributeValue::IfExpr(else_if_chain)) => {
write!(self.out, "else ")?;
self.write_attribute_if_chain(else_if_chain)?;
}
Some(other) => {
write!(self.out, "else {{")?;
self.write_attribute_value(other)?;
write!(self.out, " }}")?;
}
None => {}
}
Ok(())
}
fn write_mulitiline_tokens(&mut self, out: String) -> Result {
let mut lines = out.split('\n').peekable();
let first = lines.next().unwrap();

View file

@ -249,10 +249,18 @@ impl<'a> Writer<'a> {
pub(crate) fn attr_value_len(&mut self, value: &ElementAttrValue) -> usize {
match value {
ElementAttrValue::AttrOptionalExpr { condition, value } => {
let condition_len = self.retrieve_formatted_expr(condition).len();
let value_len = self.attr_value_len(value);
condition_len + value_len + 6
ElementAttrValue::IfExpr(if_chain) => {
let condition_len = self.retrieve_formatted_expr(&if_chain.condition).len();
let value_len = self.attr_value_len(&if_chain.then_value);
let if_len = 2;
let brace_len = 2;
let space_len = 2;
let else_len = if_chain
.else_value
.as_ref()
.map(|else_value| self.attr_value_len(else_value) + 1)
.unwrap_or_default();
condition_len + value_len + if_len + brace_len + space_len + else_len
}
ElementAttrValue::AttrLiteral(lit) => lit.to_string().len(),
ElementAttrValue::Shorthand(expr) => expr.span().line_length(),

View file

@ -23,8 +23,9 @@ use std::fmt::Display;
use syn::{
ext::IdentExt,
parse::{Parse, ParseStream},
parse_quote,
spanned::Spanned,
Expr, ExprClosure, ExprIf, Ident, Lit, LitBool, LitFloat, LitInt, LitStr, Token,
Block, Expr, ExprClosure, ExprIf, Ident, Lit, LitBool, LitFloat, LitInt, LitStr, Token,
};
#[cfg(feature = "hot_reload")]
@ -241,7 +242,7 @@ impl Attribute {
AttributeValue::AttrLiteral(_)
| AttributeValue::AttrExpr(_)
| AttributeValue::Shorthand(_)
| AttributeValue::AttrOptionalExpr { .. }
| AttributeValue::IfExpr { .. }
if is_not_event =>
{
let name = &self.name;
@ -439,20 +440,17 @@ pub enum AttributeValue {
/// A series of tokens that represent an event handler
///
/// We use a special type here so we can get autocomplete in the closure using partial expansion.
/// We also do some extra wrapping for improved type hinting since rust sometimes as trouble with
/// We also do some extra wrapping for improved type hinting since rust sometimes has trouble with
/// generics and closures.
EventTokens(PartialClosure),
/// Unterminated expression - full expressions are handled by AttrExpr
/// Conditional expression
///
/// attribute: if bool { "value" }
/// attribute: if bool { "value" } else if bool { "other value" } else { "default value" }
///
/// Currently these don't get hotreloading super powers, but they could, depending on how far
/// we want to go with it
AttrOptionalExpr {
condition: Expr,
value: Box<AttributeValue>,
},
IfExpr(IfAttributeValue),
/// attribute: some_expr
/// attribute: {some_expr} ?
@ -463,45 +461,7 @@ impl Parse for AttributeValue {
fn parse(content: ParseStream) -> syn::Result<Self> {
// Attempt to parse the unterminated if statement
if content.peek(Token![if]) {
let if_expr = content.parse::<ExprIf>()?;
if is_if_chain_terminated(&if_expr) {
return Ok(AttributeValue::AttrExpr(
syn::parse2(if_expr.to_token_stream()).unwrap(),
));
}
let stmts = &if_expr.then_branch.stmts;
if stmts.len() != 1 {
return Err(syn::Error::new(
if_expr.then_branch.span(),
"Expected a single statement in the if block",
));
}
// either an ifmt or an expr in the block
let stmt = &stmts[0];
// Either it's a valid ifmt or an expression
let value = match stmt {
syn::Stmt::Expr(exp, None) => {
// Try parsing the statement as an IfmtInput by passing it through tokens
let value: Result<HotLiteral, syn::Error> = syn::parse2(quote! { #exp });
match value {
Ok(res) => Box::new(AttributeValue::AttrLiteral(res)),
Err(_) => Box::new(AttributeValue::AttrExpr(
syn::parse2(if_expr.to_token_stream()).unwrap(),
)),
}
}
_ => return Err(syn::Error::new(stmt.span(), "Expected an expression")),
};
return Ok(AttributeValue::AttrOptionalExpr {
condition: *if_expr.cond,
value,
});
return Ok(Self::IfExpr(content.parse::<IfAttributeValue>()?));
}
// Use the move and/or bars as an indicator that we have an event handler
@ -534,15 +494,7 @@ impl ToTokens for AttributeValue {
match self {
Self::Shorthand(ident) => ident.to_tokens(tokens),
Self::AttrLiteral(ifmt) => ifmt.to_tokens(tokens),
Self::AttrOptionalExpr { condition, value } => tokens.append_all(quote! {
{
if #condition {
Some(#value)
} else {
None
}
}
}),
Self::IfExpr(if_expr) => if_expr.to_tokens(tokens),
Self::AttrExpr(expr) => expr.to_tokens(tokens),
Self::EventTokens(closure) => closure.to_tokens(tokens),
}
@ -554,13 +506,209 @@ impl AttributeValue {
match self {
Self::Shorthand(ident) => ident.span(),
Self::AttrLiteral(ifmt) => ifmt.span(),
Self::AttrOptionalExpr { value, .. } => value.span(),
Self::IfExpr(if_expr) => if_expr.span(),
Self::AttrExpr(expr) => expr.span(),
Self::EventTokens(closure) => closure.span(),
}
}
}
/// A if else chain attribute value
#[derive(PartialEq, Eq, Clone, Debug, Hash)]
pub struct IfAttributeValue {
pub condition: Expr,
pub then_value: Box<AttributeValue>,
pub else_value: Option<Box<AttributeValue>>,
}
impl IfAttributeValue {
/// Convert the if expression to an expression that returns a string. If the unterminated case is hit, it returns an empty string
pub(crate) fn quote_as_string(&self, diagnostics: &mut Diagnostics) -> Expr {
let mut expression = quote! {};
let mut current_if_value = self;
let mut non_string_diagnostic = |span: proc_macro2::Span| -> Expr {
Element::add_merging_non_string_diagnostic(diagnostics, span);
parse_quote! { ::std::string::String::new() }
};
loop {
let AttributeValue::AttrLiteral(lit) = current_if_value.then_value.as_ref() else {
return non_string_diagnostic(current_if_value.span());
};
let HotLiteralType::Fmted(new) = &lit.value else {
return non_string_diagnostic(current_if_value.span());
};
let condition = &current_if_value.condition;
expression.extend(quote! {
if #condition {
#new.to_string()
} else
});
match current_if_value.else_value.as_deref() {
// If the else value is another if expression, then we need to continue the loop
Some(AttributeValue::IfExpr(else_value)) => {
current_if_value = else_value;
}
// If the else value is a literal, then we need to append it to the expression and break
Some(AttributeValue::AttrLiteral(lit)) => {
if let HotLiteralType::Fmted(new) = &lit.value {
expression.extend(quote! { { #new.to_string() } });
break;
} else {
return non_string_diagnostic(current_if_value.span());
}
}
// If it is the end of the if expression without an else, then we need to append the default value and break
None => {
expression.extend(quote! { { ::std::string::String::new() } });
break;
}
_ => {
return non_string_diagnostic(current_if_value.else_value.span());
}
}
}
parse_quote! {
{
#expression
}
}
}
fn span(&self) -> proc_macro2::Span {
self.then_value.span()
}
fn is_terminated(&self) -> bool {
match &self.else_value {
Some(attribute) => match attribute.as_ref() {
AttributeValue::IfExpr(if_expr) => if_expr.is_terminated(),
_ => true,
},
None => false,
}
}
fn parse_attribute_value_from_block(block: &Block) -> syn::Result<Box<AttributeValue>> {
let stmts = &block.stmts;
if stmts.len() != 1 {
return Err(syn::Error::new(
block.span(),
"Expected a single statement in the if block",
));
}
// either an ifmt or an expr in the block
let stmt = &stmts[0];
// Either it's a valid ifmt or an expression
match stmt {
syn::Stmt::Expr(exp, None) => {
// Try parsing the statement as an IfmtInput by passing it through tokens
let value: Result<HotLiteral, syn::Error> = syn::parse2(quote! { #exp });
Ok(match value {
Ok(res) => Box::new(AttributeValue::AttrLiteral(res)),
Err(_) => Box::new(AttributeValue::AttrExpr(PartialExpr::from_expr(exp))),
})
}
_ => Err(syn::Error::new(stmt.span(), "Expected an expression")),
}
}
fn to_tokens_with_terminated(&self, tokens: &mut TokenStream2, terminated: bool) {
let IfAttributeValue {
condition,
then_value,
else_value,
} = self;
let then_value = if terminated {
quote! { #then_value }
}
// Otherwise we need to return an Option and a None if the else value is None
else {
quote! { Some(#then_value) }
};
let else_value = match else_value.as_deref() {
Some(AttributeValue::IfExpr(else_value)) => {
let mut tokens = TokenStream2::new();
else_value.to_tokens_with_terminated(&mut tokens, terminated);
tokens
}
Some(other) => {
if terminated {
quote! { #other }
} else {
quote! { Some(#other) }
}
}
None => quote! { None },
};
tokens.append_all(quote! {
{
if #condition {
#then_value
} else {
#else_value
}
}
});
}
}
impl Parse for IfAttributeValue {
fn parse(input: ParseStream) -> syn::Result<Self> {
let if_expr = input.parse::<ExprIf>()?;
let stmts = &if_expr.then_branch.stmts;
if stmts.len() != 1 {
return Err(syn::Error::new(
if_expr.then_branch.span(),
"Expected a single statement in the if block",
));
}
// Parse the then branch into a single attribute value
let then_value = Self::parse_attribute_value_from_block(&if_expr.then_branch)?;
// If there's an else branch, parse it as a single attribute value or an if expression
let else_value = match if_expr.else_branch.as_ref() {
Some((_, else_branch)) => {
// The else branch if either a block or another if expression
let attribute_value = match else_branch.as_ref() {
// If it is a block, then the else is terminated
Expr::Block(block) => Self::parse_attribute_value_from_block(&block.block)?,
// Otherwise try to parse it as an if expression
_ => Box::new(syn::parse2(quote! { #else_branch })?),
};
Some(attribute_value)
}
None => None,
};
Ok(Self {
condition: *if_expr.cond,
then_value,
else_value,
})
}
}
impl ToTokens for IfAttributeValue {
fn to_tokens(&self, tokens: &mut TokenStream2) {
// If the if expression is terminated, we can just return the then value
let terminated = self.is_terminated();
self.to_tokens_with_terminated(tokens, terminated)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -581,22 +729,33 @@ mod tests {
let _parsed: Attribute = parse2(quote! { "custom": false, }).unwrap();
let _parsed: Attribute = parse2(quote! { name: false, }).unwrap();
// with expressions
let _parsed: Attribute = parse2(quote! { name: if true { "value" } }).unwrap();
let _parsed: Attribute =
// with if chains
let parsed: Attribute = parse2(quote! { name: if true { "value" } }).unwrap();
assert!(matches!(parsed.value, AttributeValue::IfExpr(_)));
let parsed: Attribute =
parse2(quote! { name: if true { "value" } else { "other" } }).unwrap();
assert!(matches!(parsed.value, AttributeValue::IfExpr(_)));
let parsed: Attribute =
parse2(quote! { name: if true { "value" } else if false { "other" } }).unwrap();
assert!(matches!(parsed.value, AttributeValue::IfExpr(_)));
// with shorthand
let _parsed: Attribute = parse2(quote! { name }).unwrap();
let _parsed: Attribute = parse2(quote! { name, }).unwrap();
// Events - make sure they get partial expansion
let _parsed: Attribute = parse2(quote! { onclick: |e| {} }).unwrap();
let _parsed: Attribute = parse2(quote! { onclick: |e| { "value" } }).unwrap();
let _parsed: Attribute = parse2(quote! { onclick: |e| { value. } }).unwrap();
let _parsed: Attribute = parse2(quote! { onclick: move |e| { value. } }).unwrap();
let _parsed: Attribute = parse2(quote! { onclick: move |e| value }).unwrap();
let _parsed: Attribute = parse2(quote! { onclick: |e| value, }).unwrap();
let parsed: Attribute = parse2(quote! { onclick: |e| {} }).unwrap();
assert!(matches!(parsed.value, AttributeValue::EventTokens(_)));
let parsed: Attribute = parse2(quote! { onclick: |e| { "value" } }).unwrap();
assert!(matches!(parsed.value, AttributeValue::EventTokens(_)));
let parsed: Attribute = parse2(quote! { onclick: |e| { value. } }).unwrap();
assert!(matches!(parsed.value, AttributeValue::EventTokens(_)));
let parsed: Attribute = parse2(quote! { onclick: move |e| { value. } }).unwrap();
assert!(matches!(parsed.value, AttributeValue::EventTokens(_)));
let parsed: Attribute = parse2(quote! { onclick: move |e| value }).unwrap();
assert!(matches!(parsed.value, AttributeValue::EventTokens(_)));
let parsed: Attribute = parse2(quote! { onclick: |e| value, }).unwrap();
assert!(matches!(parsed.value, AttributeValue::EventTokens(_)));
}
#[test]

View file

@ -211,6 +211,12 @@ impl ToTokens for Element {
}
impl Element {
pub(crate) fn add_merging_non_string_diagnostic(diagnostics: &mut Diagnostics, span: Span) {
diagnostics.push(span.error("Cannot merge non-fmt literals").help(
"Only formatted strings can be merged together. If you want to merge literals, you can use a format string.",
));
}
/// Collapses ifmt attributes into a single dynamic attribute using a space or `;` as a delimiter
///
/// ```ignore,
@ -271,22 +277,16 @@ impl Element {
}
}
// Merge `if cond { "abc" }` into the output
if let AttributeValue::AttrOptionalExpr { condition, value } = &matching_attr.value
{
if let AttributeValue::AttrLiteral(lit) = value.as_ref() {
if let HotLiteralType::Fmted(new) = &lit.value {
out.push_condition(condition.clone(), new.clone());
continue;
}
}
// Merge `if cond { "abc" } else if ...` into the output
if let AttributeValue::IfExpr(value) = &matching_attr.value {
out.push_expr(value.quote_as_string(&mut self.diagnostics));
continue;
}
// unwind in case there's a test or two that cares about this weird state
_ = out.segments.pop();
self.diagnostics.push(matching_attr.span().error("Cannot merge non-fmt literals").help(
"Only formatted strings can be merged together. If you want to merge literals, you can use a format string.",
));
Self::add_merging_non_string_diagnostic(
&mut self.diagnostics,
matching_attr.span(),
);
}
let out_lit = HotLiteral {
@ -533,11 +533,13 @@ fn merges_attributes() {
let input = quote::quote! {
div {
class: "hello world",
class: if count > 3 { "abc {def}" }
class: if count > 3 { "abc {def}" },
class: if count < 50 { "small" } else { "big" }
}
};
let parsed: Element = syn::parse2(input).unwrap();
assert_eq!(parsed.diagnostics.len(), 0);
assert_eq!(parsed.merged_attributes.len(), 1);
assert_eq!(
parsed.merged_attributes[0].name.to_string(),

View file

@ -7,7 +7,7 @@ use quote::quote;
use quote::{ToTokens, TokenStreamExt};
use syn::{
parse::{Parse, ParseStream},
Expr, ExprIf, Result, Token,
Expr, Result, Token,
};
use crate::TemplateBody;
@ -138,21 +138,6 @@ impl ToTokens for IfChain {
}
}
pub(crate) fn is_if_chain_terminated(chain: &ExprIf) -> bool {
let mut current = chain;
loop {
if let Some((_, else_block)) = &current.else_branch {
if let Expr::If(else_if) = else_block.as_ref() {
current = else_if;
} else {
return true;
}
} else {
return false;
}
}
}
#[test]
fn parses_if_chain() {
let input = quote! {

View file

@ -20,6 +20,12 @@ pub struct IfmtInput {
pub segments: Vec<Segment>,
}
impl Default for IfmtInput {
fn default() -> Self {
Self::new(Span::call_site())
}
}
impl IfmtInput {
pub fn new(span: Span) -> Self {
Self {
@ -45,22 +51,6 @@ impl IfmtInput {
self.segments.extend(other.segments);
}
pub fn push_condition(&mut self, condition: Expr, contents: IfmtInput) {
let desugared = quote! {
{
let _cond = if #condition { #contents.to_string() } else { String::new() };
_cond
}
};
let parsed = syn::parse2::<Expr>(desugared).unwrap();
self.segments.push(Segment::Formatted(FormattedSegment {
format_args: String::new(),
segment: FormattedSegmentType::Expr(Box::new(parsed)),
}));
}
pub fn push_expr(&mut self, expr: Expr) {
self.segments.push(Segment::Formatted(FormattedSegment {
format_args: String::new(),
@ -481,18 +471,6 @@ mod tests {
assert!(input.is_static());
}
#[test]
fn pushing_conditional() {
let mut input = syn::parse2::<IfmtInput>(quote! { "hello " }).unwrap();
input.push_condition(
parse_quote! { true },
syn::parse2::<IfmtInput>(quote! { "world" }).unwrap(),
);
println!("{}", input.to_token_stream().pretty_unparse());
dbg!(input.segments);
}
#[test]
fn fmt_segments() {
let left = syn::parse2::<IfmtInput>(quote! { "thing {abc}" }).unwrap();

View file

@ -1,6 +1,6 @@
#![cfg(feature = "hot_reload")]
use crate::{Attribute, AttributeValue, BodyNode, HotLiteralType, IfmtInput};
use crate::{Attribute, AttributeValue, BodyNode, HotLiteralType, IfAttributeValue, IfmtInput};
/// Take two nodes and return their similarity score
///
@ -114,17 +114,24 @@ fn score_attr_value(old_attr: &AttributeValue, new_attr: &AttributeValue) -> usi
}
(
AttrOptionalExpr {
IfExpr(IfAttributeValue {
condition: cond_a,
value: value_a,
},
AttrOptionalExpr {
then_value: value_a,
else_value: else_value_a,
}),
IfExpr(IfAttributeValue {
condition: cond_b,
value: value_b,
},
then_value: value_b,
else_value: else_value_b,
}),
) if cond_a == cond_b => {
// If the condition is the same, we can hotreload it
score_attr_value(value_a, value_b)
+ match (else_value_a, else_value_b) {
(Some(a), Some(b)) => score_attr_value(a, b),
(None, None) => 0,
_ => usize::MAX,
}
}
// todo: we should try and score recursively if we can - templates need to propagate up their