feat: support expressions in #[prop(default=...)] (#611)

This commit is contained in:
Roland Fredenhagen
2023-03-03 01:15:45 +01:00
committed by GitHub
parent cebc824fbe
commit 2ee323135f
8 changed files with 174 additions and 154 deletions

View File

@@ -12,6 +12,7 @@ readme = "../README.md"
proc-macro = true
[dependencies]
attribute-derive = { version = "0.5", features = ["syn-full"] }
cfg-if = "1"
html-escape = "0.2"
itertools = "0.10"
@@ -31,6 +32,7 @@ uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
log = "0.4"
typed-builder = "0.12"
trybuild = "1"
leptos = { path = "../leptos" }
[features]

View File

@@ -1,17 +1,15 @@
use attribute_derive::Attribute as AttributeDerive;
use convert_case::{
Case::{Pascal, Snake},
Casing,
};
use itertools::Itertools;
use proc_macro2::{Ident, TokenStream};
use proc_macro_error::ResultExt;
use quote::{format_ident, ToTokens, TokenStreamExt};
use std::collections::HashSet;
use syn::{
parse::Parse, parse_quote, AngleBracketedGenericArguments, Attribute,
FnArg, GenericArgument, ItemFn, LitStr, Meta, MetaList, MetaNameValue,
NestedMeta, Pat, PatIdent, Path, PathArguments, ReturnType, Type, TypePath,
Visibility,
FnArg, GenericArgument, ItemFn, LitStr, Meta, MetaNameValue, Pat, PatIdent,
Path, PathArguments, ReturnType, Type, TypePath, Visibility,
};
pub struct Model {
@@ -238,7 +236,7 @@ impl Model {
struct Prop {
docs: Docs,
prop_opts: HashSet<PropOpt>,
prop_opts: PropOpt,
name: PatIdent,
ty: Type,
}
@@ -251,53 +249,12 @@ impl Prop {
abort!(arg, "receiver not allowed in `fn`");
};
let prop_opts = typed
.attrs
.iter()
.enumerate()
.filter_map(|(i, attr)| {
PropOpt::from_attribute(attr).map(|opt| (i, opt))
})
.fold(HashSet::new(), |mut acc, cur| {
// Make sure opts aren't repeated
if acc.intersection(&cur.1).next().is_some() {
abort!(
typed.attrs[cur.0],
"`#[prop]` options are repeated"
);
}
acc.extend(cur.1);
acc
let prop_opts =
PropOpt::from_attributes(&typed.attrs).unwrap_or_else(|e| {
// TODO: replace with `.unwrap_or_abort()` once https://gitlab.com/CreepySkeleton/proc-macro-error/-/issues/17 is fixed
abort!(e.span(), e.to_string());
});
// Make sure conflicting options are not present
if prop_opts.contains(&PropOpt::Optional)
&& prop_opts.contains(&PropOpt::OptionalNoStrip)
{
abort!(
typed,
"`optional` and `optional_no_strip` options are mutually \
exclusive"
);
} else if prop_opts.contains(&PropOpt::Optional)
&& prop_opts.contains(&PropOpt::StripOption)
{
abort!(
typed,
"`optional` and `strip_option` options are mutually exclusive"
);
} else if prop_opts.contains(&PropOpt::OptionalNoStrip)
&& prop_opts.contains(&PropOpt::StripOption)
{
abort!(
typed,
"`optional_no_strip` and `strip_option` options are mutually \
exclusive"
);
}
let name = if let Pat::Ident(i) = *typed.pat {
i
} else {
@@ -407,98 +364,34 @@ impl Docs {
}
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
enum PropOpt {
Optional,
OptionalNoStrip,
OptionalWithDefault(syn::Lit),
StripOption,
Into,
}
impl PropOpt {
fn from_attribute(attr: &Attribute) -> Option<HashSet<Self>> {
const ABORT_OPT_MESSAGE: &str =
"only `optional`, `optional_no_strip`, `strip_option`, `default` \
and `into` are allowed as arguments to `#[prop()]`";
if attr.path != parse_quote!(prop) {
return None;
}
if let Meta::List(MetaList { nested, .. }) =
attr.parse_meta().unwrap_or_abort()
{
Some(
nested
.iter()
.map(|opt| match opt {
NestedMeta::Meta(Meta::Path(opt)) => {
if *opt == parse_quote!(optional) {
PropOpt::Optional
} else if *opt == parse_quote!(optional_no_strip) {
PropOpt::OptionalNoStrip
} else if *opt == parse_quote!(strip_option) {
PropOpt::StripOption
} else if *opt == parse_quote!(into) {
PropOpt::Into
} else {
abort!(
opt,
"invalid prop option";
help = ABORT_OPT_MESSAGE
);
}
}
NestedMeta::Meta(Meta::NameValue(MetaNameValue {
path,
eq_token: _,
lit,
})) => {
if *path == parse_quote!(default) {
PropOpt::OptionalWithDefault(lit.to_owned())
} else {
abort!(
opt,
"invalid prop option";
help = ABORT_OPT_MESSAGE
);
}
}
_ => abort!(opt, ABORT_OPT_MESSAGE,),
})
.collect(),
)
} else {
abort!(
attr,
"the syntax for `#[prop]` is incorrect";
help = "try `#[prop(optional)]`";
help = ABORT_OPT_MESSAGE
);
}
}
#[derive(Clone, Debug, AttributeDerive)]
#[attribute(ident = prop)]
struct PropOpt {
#[attribute(conflicts = [optional_no_strip, strip_option])]
optional: bool,
#[attribute(conflicts = [optional, strip_option])]
optional_no_strip: bool,
#[attribute(conflicts = [optional, optional_no_strip])]
strip_option: bool,
#[attribute(example = "5 * 10")]
default: Option<syn::Expr>,
into: bool,
}
struct TypedBuilderOpts {
default: bool,
default_with_value: Option<syn::Lit>,
default_with_value: Option<syn::Expr>,
strip_option: bool,
into: bool,
}
impl TypedBuilderOpts {
fn from_opts(opts: &HashSet<PropOpt>, is_ty_option: bool) -> Self {
fn from_opts(opts: &PropOpt, is_ty_option: bool) -> Self {
Self {
default: opts.contains(&PropOpt::Optional)
|| opts.contains(&PropOpt::OptionalNoStrip),
default_with_value: opts.iter().find_map(|p| match p {
PropOpt::OptionalWithDefault(v) => Some(v.to_owned()),
_ => None,
}),
strip_option: opts.contains(&PropOpt::StripOption)
|| (opts.contains(&PropOpt::Optional) && is_ty_option),
into: opts.contains(&PropOpt::Into),
default: opts.optional || opts.optional_no_strip,
default_with_value: opts.default.clone(),
strip_option: opts.strip_option || opts.optional && is_ty_option,
into: opts.into,
}
}
}
@@ -506,7 +399,8 @@ impl TypedBuilderOpts {
impl ToTokens for TypedBuilderOpts {
fn to_tokens(&self, tokens: &mut TokenStream) {
let default = if let Some(v) = &self.default_with_value {
quote! { default=#v, }
let v = v.to_token_stream().to_string();
quote! { default_code=#v, }
} else if self.default {
quote! { default, }
} else {
@@ -576,8 +470,7 @@ fn generate_component_fn_prop_docs(props: &[Prop]) -> TokenStream {
let required_prop_docs = props
.iter()
.filter(|Prop { prop_opts, .. }| {
!(prop_opts.contains(&PropOpt::Optional)
|| prop_opts.contains(&PropOpt::OptionalNoStrip))
!(prop_opts.optional || prop_opts.optional_no_strip)
})
.map(|p| prop_to_doc(p, PropDocStyle::List))
.collect::<TokenStream>();
@@ -585,8 +478,7 @@ fn generate_component_fn_prop_docs(props: &[Prop]) -> TokenStream {
let optional_prop_docs = props
.iter()
.filter(|Prop { prop_opts, .. }| {
prop_opts.contains(&PropOpt::Optional)
|| prop_opts.contains(&PropOpt::OptionalNoStrip)
prop_opts.optional || prop_opts.optional_no_strip
})
.map(|p| prop_to_doc(p, PropDocStyle::List))
.collect::<TokenStream>();
@@ -647,10 +539,10 @@ fn unwrap_option(ty: &Type) -> Type {
AngleBracketedGenericArguments { args, .. },
) = &first.arguments
{
if let [first] = &args.iter().collect::<Vec<_>>()[..] {
if let GenericArgument::Type(ty) = first {
return ty.clone();
}
if let [GenericArgument::Type(ty)] =
&args.iter().collect::<Vec<_>>()[..]
{
return ty.clone();
}
}
}
@@ -679,9 +571,7 @@ fn prop_to_doc(
}: &Prop,
style: PropDocStyle,
) -> TokenStream {
let ty = if (prop_opts.contains(&PropOpt::Optional)
|| prop_opts.contains(&PropOpt::StripOption))
&& is_option(ty)
let ty = if (prop_opts.optional || prop_opts.strip_option) && is_option(ty)
{
unwrap_option(ty)
} else {
@@ -705,7 +595,7 @@ fn prop_to_doc(
match style {
PropDocStyle::List => {
let arg_ty_doc = LitStr::new(
&if !prop_opts.contains(&PropOpt::Into) {
&if !prop_opts.into {
format!("- **{}**: [`{}`]", quote!(#name), pretty_ty)
} else {
format!(
@@ -726,7 +616,7 @@ fn prop_to_doc(
}
PropDocStyle::Inline => {
let arg_ty_doc = LitStr::new(
&if !prop_opts.contains(&PropOpt::Into) {
&if !prop_opts.into {
format!(
"**{}**: [`{}`]{}",
quote!(#name),

View File

@@ -19,7 +19,7 @@ pub fn impl_params(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
let span = field.span();
quote_spanned! {
span.into() => #ident: <#ty>::into_param(map.get(#field_name_string).map(|n| n.as_str()), #field_name_string)?
span => #ident: <#ty>::into_param(map.get(#field_name_string).map(|n| n.as_str()), #field_name_string)?
}
})
.collect()

View File

@@ -266,8 +266,8 @@ fn attr_to_tokens(
expressions: &mut Vec<TokenStream>,
) {
let name = node.key.to_string();
let name = name.strip_prefix("_").unwrap_or(&name);
let name = name.strip_prefix("attr:").unwrap_or(&name);
let name = name.strip_prefix('_').unwrap_or(&name);
let name = name.strip_prefix("attr:").unwrap_or(name);
let value = match &node.value {
Some(expr) => match expr.as_ref() {
@@ -299,7 +299,7 @@ fn attr_to_tokens(
}
// Properties
else if let Some(name) = name.strip_prefix("prop:") {
let value = attribute_value(&node);
let value = attribute_value(node);
expressions.push(quote_spanned! {
span => leptos_dom::property(#cx, #el_id.unchecked_ref(), #name, #value.into_property(#cx))
@@ -307,7 +307,7 @@ fn attr_to_tokens(
}
// Classes
else if let Some(name) = name.strip_prefix("class:") {
let value = attribute_value(&node);
let value = attribute_value(node);
expressions.push(quote_spanned! {
span => leptos::leptos_dom::class_helper(#el_id.unchecked_ref(), #name.into(), #value.into_class(#cx))
@@ -318,14 +318,14 @@ fn attr_to_tokens(
match value {
AttributeValue::Empty => {
template.push(' ');
template.push_str(&name);
template.push_str(name);
}
// Static attributes (i.e., just a literal given as value, not an expression)
// are just set in the template — again, nothing programmatic
AttributeValue::Static(value) => {
template.push(' ');
template.push_str(&name);
template.push_str(name);
template.push_str("=\"");
template.push_str(&value);
template.push('"');

View File

@@ -0,0 +1,23 @@
use core::num::NonZeroUsize;
use leptos::*;
#[component]
fn Component(
_cx: Scope,
#[prop(optional)] optional: bool,
#[prop(optional_no_strip)] optional_no_strip: Option<String>,
#[prop(strip_option)] strip_option: Option<u8>,
#[prop(default = NonZeroUsize::new(10).unwrap())] default: NonZeroUsize,
#[prop(into)] into: String,
) -> impl IntoView {
}
#[test]
fn component() {
let cp = ComponentProps::builder().into("").strip_option(9).build();
assert_eq!(cp.optional, false);
assert_eq!(cp.optional_no_strip, None);
assert_eq!(cp.strip_option, Some(9));
assert_eq!(cp.default, NonZeroUsize::new(10).unwrap());
assert_eq!(cp.into, "");
}

5
leptos_macro/tests/ui.rs Normal file
View File

@@ -0,0 +1,5 @@
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}

View File

@@ -0,0 +1,47 @@
use leptos::*;
#[component]
fn missing_scope() {}
#[component]
fn missing_return_type(cx: Scope) {}
#[component]
fn unknown_prop_option(cx: Scope, #[prop(hello)] test: bool) -> impl IntoView {}
#[component]
fn optional_and_optional_no_strip(
cx: Scope,
#[prop(optional, optional_no_strip)] conflicting: bool,
) -> impl IntoView {
}
#[component]
fn optional_and_strip_option(
cx: Scope,
#[prop(optional, strip_option)] conflicting: bool,
) -> impl IntoView {
}
#[component]
fn optional_no_strip_and_strip_option(
cx: Scope,
#[prop(optional_no_strip, strip_option)] conflicting: bool,
) -> impl IntoView {
}
#[component]
fn default_without_value(
cx: Scope,
#[prop(default)] default: bool,
) -> impl IntoView {
}
#[component]
fn default_with_invalid_value(
cx: Scope,
#[prop(default= |)] default: bool,
) -> impl IntoView {
}
fn main() {}

View File

@@ -0,0 +1,53 @@
error: this method requires a `Scope` parameter
--> tests/ui/component.rs:4:1
|
4 | fn missing_scope() {}
| ^^^^^^^^^^^^^^^^^^
|
= help: try `fn missing_scope(cx: Scope, /* ... */)`
error: return type is incorrect
--> tests/ui/component.rs:7:1
|
7 | fn missing_return_type(cx: Scope) {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: return signature must be `-> impl IntoView`
error: supported fields are `optional`, `optional_no_strip`, `strip_option`, `default` and `into`
--> tests/ui/component.rs:10:42
|
10 | fn unknown_prop_option(cx: Scope, #[prop(hello)] test: bool) -> impl IntoView {}
| ^^^^^
error: `optional` conflicts with mutually exclusive `optional_no_strip`
--> tests/ui/component.rs:15:12
|
15 | #[prop(optional, optional_no_strip)] conflicting: bool,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: `optional` conflicts with mutually exclusive `strip_option`
--> tests/ui/component.rs:22:12
|
22 | #[prop(optional, strip_option)] conflicting: bool,
| ^^^^^^^^^^^^^^^^^^^^^^
error: `optional_no_strip` conflicts with mutually exclusive `strip_option`
--> tests/ui/component.rs:29:12
|
29 | #[prop(optional_no_strip, strip_option)] conflicting: bool,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: unexpected end of input, expected assignment `=`
--> tests/ui/component.rs:36:19
|
36 | #[prop(default)] default: bool,
| ^
error: unexpected end of input, expected one of: `::`, `<`, `_`, literal, `const`, `ref`, `mut`, `&`, parentheses, square brackets, `..`, `const`
= help: try `#[prop(default=5 * 10)]`
--> tests/ui/component.rs:43:22
|
43 | #[prop(default= |)] default: bool,
| ^