mirror of
https://github.com/chenasraf/leptos.git
synced 2026-05-17 17:48:10 +00:00
feat: support expressions in #[prop(default=...)] (#611)
This commit is contained in:
committed by
GitHub
parent
cebc824fbe
commit
2ee323135f
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('"');
|
||||
|
||||
23
leptos_macro/tests/component.rs
Normal file
23
leptos_macro/tests/component.rs
Normal 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
5
leptos_macro/tests/ui.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[test]
|
||||
fn ui() {
|
||||
let t = trybuild::TestCases::new();
|
||||
t.compile_fail("tests/ui/*.rs");
|
||||
}
|
||||
47
leptos_macro/tests/ui/component.rs
Normal file
47
leptos_macro/tests/ui/component.rs
Normal 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() {}
|
||||
53
leptos_macro/tests/ui/component.stderr
Normal file
53
leptos_macro/tests/ui/component.stderr
Normal 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,
|
||||
| ^
|
||||
Reference in New Issue
Block a user