From 8e8fc6475905a3ca4f6d32b052aebc50234a5b5a Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sat, 9 Nov 2024 17:15:15 -0300 Subject: [PATCH 01/21] Fix clippy warning --- ts-rs/src/export.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs index 7b0d08fd..3640c96e 100644 --- a/ts-rs/src/export.rs +++ b/ts-rs/src/export.rs @@ -181,9 +181,9 @@ fn export_and_merge( Ok(()) } -const HEADER_ERROR_MESSAGE: &'static str = "The generated strings must have their NOTE and imports separated from their type declarations by a new line"; +const HEADER_ERROR_MESSAGE: &str = "The generated strings must have their NOTE and imports separated from their type declarations by a new line"; -const DECLARATION_START: &'static str = "export type "; +const DECLARATION_START: &str = "export type "; /// Inserts the imports and declaration from the newly generated type /// into the contents of the file, removimg duplicate imports and organazing From 6692151c7f9a632b2b143b3980f6d556b3eb539a Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sat, 9 Nov 2024 17:15:21 -0300 Subject: [PATCH 02/21] Add test --- ts-rs/tests/integration/optional_field.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index c64cbacf..e556ad7d 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -87,3 +87,21 @@ fn inline() { let c = "c: number | null"; assert_eq!(Inline::inline(), format!("{{ x: {{ {a}, {b}, {c}, }}, }}")); } + +#[derive(TS)] +#[ts(export, export_to = "optional_field/", optional)] +struct OptionalStruct { + a: Option, + b: Option, + + #[ts(optional = nullable)] + c: Option, +} + +#[test] +fn struct_optional() { + assert_eq!( + OptionalStruct::inline(), + format!("{{ a?: number, b?: number, c?: number | null, }}") + ) +} From 2b7a95a2f71bc1e5895ed8950cb032f7faffb6ab Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sat, 9 Nov 2024 17:15:34 -0300 Subject: [PATCH 03/21] Parse #[ts(optional)] on structs --- macros/src/attr/field.rs | 29 ++--------------------------- macros/src/attr/mod.rs | 29 ++++++++++++++++++++++++++++- macros/src/attr/struct.rs | 10 ++++++++-- macros/src/types/named.rs | 35 +++++++++++++++++++++++++++-------- 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs index c7726efc..bf255757 100644 --- a/macros/src/attr/field.rs +++ b/macros/src/attr/field.rs @@ -4,7 +4,7 @@ use syn::{ TypeSlice, TypeTuple, }; -use super::{parse_assign_from_str, parse_assign_str, Attr, Serde}; +use super::{parse_assign_from_str, parse_assign_str, parse_optional, Attr, Optional, Serde}; use crate::utils::{parse_attrs, parse_docs}; #[derive(Default)] @@ -21,15 +21,6 @@ pub struct FieldAttr { pub using_serde_with: bool, } -/// Indicates whether the field is marked with `#[ts(optional)]`. -/// `#[ts(optional)]` turns an `t: Option` into `t?: T`, while -/// `#[ts(optional = nullable)]` turns it into `t?: T | null`. -#[derive(Default)] -pub struct Optional { - pub optional: bool, - pub nullable: bool, -} - impl FieldAttr { pub fn from_attrs(attrs: &[Attribute]) -> Result { let mut result = parse_attrs::(attrs)?; @@ -175,23 +166,7 @@ impl_parse! { "rename" => out.rename = Some(parse_assign_str(input)?), "inline" => out.inline = true, "skip" => out.skip = true, - "optional" => { - use syn::{Token, Error}; - let nullable = if input.peek(Token![=]) { - input.parse::()?; - let span = input.span(); - match Ident::parse(input)?.to_string().as_str() { - "nullable" => true, - _ => Err(Error::new(span, "expected 'nullable'"))? - } - } else { - false - }; - out.optional = Optional { - optional: true, - nullable, - } - }, + "optional" => out.optional = parse_optional(input)?, "flatten" => out.flatten = true, } } diff --git a/macros/src/attr/mod.rs b/macros/src/attr/mod.rs index 27d12eb9..de2d4f08 100644 --- a/macros/src/attr/mod.rs +++ b/macros/src/attr/mod.rs @@ -6,7 +6,7 @@ pub use r#struct::*; use syn::{ parse::{Parse, ParseStream}, punctuated::Punctuated, - Error, Lit, Path, Result, Token, WherePredicate, + Error, Ident, Lit, Path, Result, Token, WherePredicate, }; pub use variant::*; @@ -15,6 +15,15 @@ mod field; mod r#struct; mod variant; +/// Indicates whether the field is marked with `#[ts(optional)]`. +/// `#[ts(optional)]` turns an `t: Option` into `t?: T`, while +/// `#[ts(optional = nullable)]` turns it into `t?: T | null`. +#[derive(Default, Clone, Copy)] +pub struct Optional { + pub optional: bool, + pub nullable: bool, +} + #[derive(Copy, Clone, Debug)] pub enum Inflection { Lower, @@ -180,3 +189,21 @@ fn parse_bound(input: ParseStream) -> Result> { other => Err(Error::new(other.span(), "expected string")), } } + +fn parse_optional(input: ParseStream) -> Result { + let nullable = if input.peek(Token![=]) { + input.parse::()?; + let span = input.span(); + match Ident::parse(input)?.to_string().as_str() { + "nullable" => true, + _ => Err(Error::new(span, "expected 'nullable'"))?, + } + } else { + false + }; + + Ok(Optional { + optional: true, + nullable, + }) +} diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs index fc4d4cbc..5a693574 100644 --- a/macros/src/attr/struct.rs +++ b/macros/src/attr/struct.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use syn::{parse_quote, Attribute, Fields, Ident, Path, Result, Type, WherePredicate}; use super::{ - parse_assign_from_str, parse_assign_inflection, parse_bound, parse_concrete, Attr, - ContainerAttr, Serde, Tagged, + parse_assign_from_str, parse_assign_inflection, parse_bound, parse_concrete, parse_optional, + Attr, ContainerAttr, Optional, Serde, Tagged, }; use crate::{ attr::{parse_assign_str, EnumAttr, Inflection, VariantAttr}, @@ -24,6 +24,7 @@ pub struct StructAttr { pub docs: String, pub concrete: HashMap, pub bound: Option>, + pub optional: Optional, } impl StructAttr { @@ -90,6 +91,10 @@ impl Attr for StructAttr { (Some(bound), None) | (None, Some(bound)) => Some(bound), (None, None) => None, }, + optional: Optional { + optional: self.optional.optional || other.optional.optional, + nullable: self.optional.nullable || other.optional.nullable, + }, } } @@ -152,6 +157,7 @@ impl_parse! { "export_to" => out.export_to = Some(parse_assign_str(input)?), "concrete" => out.concrete = parse_concrete(input)?, "bound" => out.bound = Some(parse_bound(input)?), + "optional" => out.optional = parse_optional(input)?, } } diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 95a658ea..8035478e 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -33,6 +33,7 @@ pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Resu &mut dependencies, field, &attr.rename_all, + attr.optional, )?; } @@ -93,6 +94,7 @@ fn format_field( dependencies: &mut Dependencies, field: &Field, rename_all: &Option, + struct_optional: Optional, ) -> Result<()> { let field_attr = FieldAttr::from_attrs(&field.attrs)?; @@ -104,20 +106,37 @@ fn format_field( let parsed_ty = field_attr.type_as(&field.ty); - let (ty, optional_annotation) = match field_attr.optional { - Optional { - optional: true, - nullable, - } => { + let (ty, optional_annotation) = match (struct_optional, field_attr.optional) { + ( + Optional { + optional: true, + nullable, + }, + Optional { + optional: false, .. + }, + ) + | ( + _, + Optional { + optional: true, + nullable, + }, + ) => { let inner_type = extract_option_argument(&parsed_ty)?; // inner type of the optional match nullable { true => (&parsed_ty, "?"), // if it's nullable, we keep the original type false => (inner_type, "?"), // if not, we use the Option's inner type } } - Optional { - optional: false, .. - } => (&parsed_ty, ""), + ( + Optional { + optional: false, .. + }, + Optional { + optional: false, .. + }, + ) => (&parsed_ty, ""), }; if field_attr.flatten { From 1a13390b676ce170d69cc6bd20f7d67ce22fdef1 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 18:26:04 -0300 Subject: [PATCH 04/21] Add non Option field to test --- ts-rs/tests/integration/optional_field.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index e556ad7d..f43d3445 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -96,12 +96,14 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, + + d: i32, } #[test] fn struct_optional() { assert_eq!( OptionalStruct::inline(), - format!("{{ a?: number, b?: number, c?: number | null, }}") + format!("{{ a?: number, b?: number, c?: number | null, d: number, }}") ) } From 0aa63ea9b865eed23cf69d7c063ad35c162b4d28 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 18:35:01 -0300 Subject: [PATCH 05/21] Fix struct optional non-optional field --- macros/src/types/named.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 8035478e..3c1fa3d0 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -115,8 +115,17 @@ fn format_field( Optional { optional: false, .. }, - ) - | ( + ) => match extract_option_argument(&parsed_ty) { + Ok(inner_type) => { + if nullable { + (&parsed_ty, "?") + } else { + (inner_type, "?") + } + } + Err(_) => (&parsed_ty, ""), + }, + ( _, Optional { optional: true, From 69e9987761bbe7f5e13141eef4c88884a5df0fc2 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 19:31:09 -0300 Subject: [PATCH 06/21] Add runtime validation for `#[ts(optional)]` --- macros/src/types/named.rs | 116 ++++++++++------------ ts-rs/src/lib.rs | 22 ++++ ts-rs/tests/integration/optional_field.rs | 11 +- 3 files changed, 84 insertions(+), 65 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 3c1fa3d0..e6267144 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -1,8 +1,6 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::{ - spanned::Spanned, Field, FieldsNamed, GenericArgument, Path, PathArguments, Result, Type, -}; +use syn::{Field, FieldsNamed, Path, Result}; use crate::{ attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, @@ -104,53 +102,47 @@ fn format_field( return Ok(()); } - let parsed_ty = field_attr.type_as(&field.ty); + let ty = field_attr.type_as(&field.ty); - let (ty, optional_annotation) = match (struct_optional, field_attr.optional) { + let opt = match (struct_optional, field_attr.optional) { ( - Optional { - optional: true, - nullable, - }, + opt @ Optional { optional: true, .. }, Optional { optional: false, .. }, - ) => match extract_option_argument(&parsed_ty) { - Ok(inner_type) => { - if nullable { - (&parsed_ty, "?") - } else { - (inner_type, "?") - } - } - Err(_) => (&parsed_ty, ""), - }, + ) => opt, + (_, opt @ Optional { optional: true, .. }) => opt, ( - _, - Optional { - optional: true, - nullable, - }, - ) => { - let inner_type = extract_option_argument(&parsed_ty)?; // inner type of the optional - match nullable { - true => (&parsed_ty, "?"), // if it's nullable, we keep the original type - false => (inner_type, "?"), // if not, we use the Option's inner type - } - } - ( - Optional { + opt @ Optional { optional: false, .. }, Optional { optional: false, .. }, - ) => (&parsed_ty, ""), + ) => opt, + }; + + let optional_annotation = if opt.optional { + quote! { if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } } + } else { + quote! { "" } + }; + + let optional_annotation = if field_attr.optional.optional { + quote! { + if <#ty as #crate_rename::TS>::IS_OPTION { + #optional_annotation + } else { + panic!("`#[ts(optional)]` can only be used with the Option type") + } + } + } else { + optional_annotation }; if field_attr.flatten { flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); - dependencies.append_from(ty); + dependencies.append_from(&ty); return Ok(()); } @@ -159,11 +151,32 @@ fn format_field( .map(|t| quote!(#t)) .unwrap_or_else(|| { if field_attr.inline { - dependencies.append_from(ty); - quote!(<#ty as #crate_rename::TS>::inline()) + dependencies.append_from(&ty); + + if opt.optional && !opt.nullable { + quote! { + if <#ty as #crate_rename::TS>::IS_OPTION { + <#ty as #crate_rename::TS>::option_inner_inline().unwrap() + } else { + <#ty as #crate_rename::TS>::inline() + } + } + } else { + quote!(<#ty as #crate_rename::TS>::inline()) + } } else { - dependencies.push(ty); - quote!(<#ty as #crate_rename::TS>::name()) + dependencies.push(&ty); + if opt.optional && !opt.nullable { + quote! { + if <#ty as #crate_rename::TS>::IS_OPTION { + <#ty as #crate_rename::TS>::option_inner_name().unwrap() + } else { + <#ty as #crate_rename::TS>::name() + } + } + } else { + quote!(<#ty as #crate_rename::TS>::name()) + } } }); @@ -187,28 +200,3 @@ fn format_field( Ok(()) } - -fn extract_option_argument(ty: &Type) -> Result<&Type> { - match ty { - Type::Path(type_path) - if type_path.qself.is_none() - && type_path.path.leading_colon.is_none() - && type_path.path.segments.len() == 1 - && type_path.path.segments[0].ident == "Option" => - { - let segment = &type_path.path.segments[0]; - match &segment.arguments { - PathArguments::AngleBracketed(args) if args.args.len() == 1 => { - match &args.args[0] { - GenericArgument::Type(inner_ty) => Ok(inner_ty), - other => syn_err!(other.span(); "`Option` argument must be a type"), - } - } - other => { - syn_err!(other.span(); "`Option` type must have a single generic argument") - } - } - } - other => syn_err!(other.span(); "`optional` can only be used on an Option type"), - } -} diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 3d0ad6cd..09534781 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -394,6 +394,19 @@ pub trait TS { /// automatically read from your doc comments or `#[doc = ".."]` attributes const DOCS: Option<&'static str> = None; + #[doc(hidden)] + const IS_OPTION: bool = false; + + #[doc(hidden)] + fn option_inner_name() -> Option { + None + } + + #[doc(hidden)] + fn option_inner_inline() -> Option { + None + } + /// Identifier of this type, excluding generic parameters. fn ident() -> String { // by default, fall back to `TS::name()`. @@ -722,6 +735,7 @@ macro_rules! impl_shadow { impl TS for Option { type WithoutGenerics = Self; + const IS_OPTION: bool = true; fn name() -> String { format!("{} | null", T::name()) @@ -731,6 +745,14 @@ impl TS for Option { format!("{} | null", T::inline()) } + fn option_inner_name() -> Option { + Some(T::name()) + } + + fn option_inner_inline() -> Option { + Some(T::inline()) + } + fn visit_dependencies(v: &mut impl TypeVisitor) where Self: 'static, diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index f43d3445..00aaa307 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -88,6 +88,9 @@ fn inline() { assert_eq!(Inline::inline(), format!("{{ x: {{ {a}, {b}, {c}, }}, }}")); } +type Foo = Option; +type Bar = Option; + #[derive(TS)] #[ts(export, export_to = "optional_field/", optional)] struct OptionalStruct { @@ -97,13 +100,19 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, + #[ts(optional = nullable)] d: i32, + + e: Foo, + f: Bar, } #[test] fn struct_optional() { assert_eq!( OptionalStruct::inline(), - format!("{{ a?: number, b?: number, c?: number | null, d: number, }}") + format!( + "{{ a?: number, b?: number, c?: number | null, d: number, e?: number, f?: number, }}" + ) ) } From a562561286f26012e228e52e503bb40f83cba843 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 19:41:55 -0300 Subject: [PATCH 07/21] Turn Optional into an enum --- macros/src/attr/field.rs | 9 +++------ macros/src/attr/mod.rs | 30 +++++++++++++++++++++++------- macros/src/attr/struct.rs | 5 +---- macros/src/types/named.rs | 26 +++++++------------------- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs index bf255757..8994bd66 100644 --- a/macros/src/attr/field.rs +++ b/macros/src/attr/field.rs @@ -55,10 +55,7 @@ impl Attr for FieldAttr { rename: self.rename.or(other.rename), inline: self.inline || other.inline, skip: self.skip || other.skip, - optional: Optional { - optional: self.optional.optional || other.optional.optional, - nullable: self.optional.nullable || other.optional.nullable, - }, + optional: self.optional.or(other.optional), flatten: self.flatten || other.flatten, using_serde_with: self.using_serde_with || other.using_serde_with, @@ -124,7 +121,7 @@ impl Attr for FieldAttr { ); } - if self.optional.optional { + if let Optional::Optional { .. } = self.optional { syn_err_spanned!( field; "`optional` is not compatible with `flatten`" @@ -147,7 +144,7 @@ impl Attr for FieldAttr { ); } - if self.optional.optional { + if let Optional::Optional { .. } = self.optional { syn_err_spanned!( field; "`optional` cannot with tuple struct fields" diff --git a/macros/src/attr/mod.rs b/macros/src/attr/mod.rs index de2d4f08..61debed4 100644 --- a/macros/src/attr/mod.rs +++ b/macros/src/attr/mod.rs @@ -19,9 +19,28 @@ mod variant; /// `#[ts(optional)]` turns an `t: Option` into `t?: T`, while /// `#[ts(optional = nullable)]` turns it into `t?: T | null`. #[derive(Default, Clone, Copy)] -pub struct Optional { - pub optional: bool, - pub nullable: bool, +pub enum Optional { + Optional { + nullable: bool, + }, + + #[default] + NotOptional, +} + +impl Optional { + pub fn or(self, other: Optional) -> Self { + match (self, other) { + (Self::NotOptional, Self::NotOptional) => Self::NotOptional, + + (Self::Optional { nullable }, Self::NotOptional) + | (Self::NotOptional, Self::Optional { nullable }) => Self::Optional { nullable }, + + (Self::Optional { nullable: a }, Self::Optional { nullable: b }) => { + Self::Optional { nullable: a || b } + } + } + } } #[derive(Copy, Clone, Debug)] @@ -202,8 +221,5 @@ fn parse_optional(input: ParseStream) -> Result { false }; - Ok(Optional { - optional: true, - nullable, - }) + Ok(Optional::Optional { nullable }) } diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs index 5a693574..9cd74054 100644 --- a/macros/src/attr/struct.rs +++ b/macros/src/attr/struct.rs @@ -91,10 +91,7 @@ impl Attr for StructAttr { (Some(bound), None) | (None, Some(bound)) => Some(bound), (None, None) => None, }, - optional: Optional { - optional: self.optional.optional || other.optional.optional, - nullable: self.optional.nullable || other.optional.nullable, - }, + optional: self.optional.or(other.optional), } } diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index e6267144..737baf85 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -105,30 +105,18 @@ fn format_field( let ty = field_attr.type_as(&field.ty); let opt = match (struct_optional, field_attr.optional) { - ( - opt @ Optional { optional: true, .. }, - Optional { - optional: false, .. - }, - ) => opt, - (_, opt @ Optional { optional: true, .. }) => opt, - ( - opt @ Optional { - optional: false, .. - }, - Optional { - optional: false, .. - }, - ) => opt, + (opt @ Optional::Optional { .. }, Optional::NotOptional) => opt, + (_, opt @ Optional::Optional { .. }) => opt, + (opt @ Optional::NotOptional, Optional::NotOptional) => opt, }; - let optional_annotation = if opt.optional { + let optional_annotation = if let Optional::Optional { .. } = opt { quote! { if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } } } else { quote! { "" } }; - let optional_annotation = if field_attr.optional.optional { + let optional_annotation = if let Optional::Optional { .. } = field_attr.optional { quote! { if <#ty as #crate_rename::TS>::IS_OPTION { #optional_annotation @@ -153,7 +141,7 @@ fn format_field( if field_attr.inline { dependencies.append_from(&ty); - if opt.optional && !opt.nullable { + if let Optional::Optional { nullable: false } = opt { quote! { if <#ty as #crate_rename::TS>::IS_OPTION { <#ty as #crate_rename::TS>::option_inner_inline().unwrap() @@ -166,7 +154,7 @@ fn format_field( } } else { dependencies.push(&ty); - if opt.optional && !opt.nullable { + if let Optional::Optional { nullable: false } = opt { quote! { if <#ty as #crate_rename::TS>::IS_OPTION { <#ty as #crate_rename::TS>::option_inner_name().unwrap() From 6f064f5e6d593b1a4968259be335c7d1ede107b7 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 19:42:30 -0300 Subject: [PATCH 08/21] Remove invalid optional attribute from test --- ts-rs/tests/integration/optional_field.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 00aaa307..fd57e17e 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -100,7 +100,6 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, - #[ts(optional = nullable)] d: i32, e: Foo, From ad4f02459a7367d171b711578afa7d254d43d925 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 19:51:46 -0300 Subject: [PATCH 09/21] Remove extra methods for option inner type --- macros/src/types/named.rs | 4 ++-- ts-rs/src/lib.rs | 18 ------------------ 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 737baf85..1afe43a8 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -144,7 +144,7 @@ fn format_field( if let Optional::Optional { nullable: false } = opt { quote! { if <#ty as #crate_rename::TS>::IS_OPTION { - <#ty as #crate_rename::TS>::option_inner_inline().unwrap() + <#ty as #crate_rename::TS>::name().trim_end_matches(" | null").to_owned() } else { <#ty as #crate_rename::TS>::inline() } @@ -157,7 +157,7 @@ fn format_field( if let Optional::Optional { nullable: false } = opt { quote! { if <#ty as #crate_rename::TS>::IS_OPTION { - <#ty as #crate_rename::TS>::option_inner_name().unwrap() + <#ty as #crate_rename::TS>::name().trim_end_matches(" | null").to_owned() } else { <#ty as #crate_rename::TS>::name() } diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 09534781..f63ab821 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -397,16 +397,6 @@ pub trait TS { #[doc(hidden)] const IS_OPTION: bool = false; - #[doc(hidden)] - fn option_inner_name() -> Option { - None - } - - #[doc(hidden)] - fn option_inner_inline() -> Option { - None - } - /// Identifier of this type, excluding generic parameters. fn ident() -> String { // by default, fall back to `TS::name()`. @@ -745,14 +735,6 @@ impl TS for Option { format!("{} | null", T::inline()) } - fn option_inner_name() -> Option { - Some(T::name()) - } - - fn option_inner_inline() -> Option { - Some(T::inline()) - } - fn visit_dependencies(v: &mut impl TypeVisitor) where Self: 'static, From ade6a2f33ef1df044667ea5fb10e5e1aa59f0ffc Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 20:07:22 -0300 Subject: [PATCH 10/21] Remove panic --- macros/src/types/named.rs | 12 ------------ ts-rs/tests/integration/optional_field.rs | 2 ++ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 1afe43a8..0a2bab2f 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -116,18 +116,6 @@ fn format_field( quote! { "" } }; - let optional_annotation = if let Optional::Optional { .. } = field_attr.optional { - quote! { - if <#ty as #crate_rename::TS>::IS_OPTION { - #optional_annotation - } else { - panic!("`#[ts(optional)]` can only be used with the Option type") - } - } - } else { - optional_annotation - }; - if field_attr.flatten { flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); dependencies.append_from(&ty); diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index fd57e17e..63fc6eae 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -100,6 +100,8 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, + // `#[ts(optional)]` on a type that isn't `Option` does nothing + #[ts(optional = nullable)] d: i32, e: Foo, From dcc7a461e6680a0e15e9b91da3cb0dc97b450a57 Mon Sep 17 00:00:00 2001 From: NyxCode Date: Mon, 11 Nov 2024 02:19:59 +0100 Subject: [PATCH 11/21] compile-time checked `#[ts(optional)]` (#367) --- macros/src/lib.rs | 2 + macros/src/types/enum.rs | 2 +- macros/src/types/named.rs | 352 +++++++++++++++++++------------------- ts-rs/src/chrono.rs | 6 + ts-rs/src/lib.rs | 28 +++ 5 files changed, 211 insertions(+), 179 deletions(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4be1ce53..a1ade3ab 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -80,6 +80,7 @@ impl DerivedTS { quote! { #impl_start { #assoc_type + type OptionInnerType = Self; fn ident() -> String { #ident.to_owned() @@ -156,6 +157,7 @@ impl DerivedTS { } impl #crate_rename::TS for #generics { type WithoutGenerics = #generics; + type OptionInnerType = Self; fn name() -> String { stringify!(#generics).to_owned() } fn inline() -> String { panic!("{} cannot be inlined", #name) } fn inline_flattened() -> String { stringify!(#generics).to_owned() } diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs index 95cd38d9..1f43c3d0 100644 --- a/macros/src/types/enum.rs +++ b/macros/src/types/enum.rs @@ -24,7 +24,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result { if let Some(attr_type_override) = &enum_attr.type_override { return type_override::type_override_enum(&enum_attr, &name, attr_type_override); } - + if let Some(attr_type_as) = &enum_attr.type_as { return type_as::type_as_enum(&enum_attr, &name, attr_type_as); } diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 0a2bab2f..8792fbbf 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -1,178 +1,174 @@ -use proc_macro2::TokenStream; -use quote::quote; -use syn::{Field, FieldsNamed, Path, Result}; - -use crate::{ - attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, - deps::Dependencies, - utils::{raw_name_to_ts_field, to_ts_ident}, - DerivedTS, -}; - -pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Result { - let crate_rename = attr.crate_rename(); - - let mut formatted_fields = Vec::new(); - let mut flattened_fields = Vec::new(); - let mut dependencies = Dependencies::new(crate_rename.clone()); - - if let Some(tag) = &attr.tag { - let formatted = format!("\"{}\": \"{}\",", tag, name); - formatted_fields.push(quote! { - #formatted.to_string() - }); - } - - for field in &fields.named { - format_field( - &crate_rename, - &mut formatted_fields, - &mut flattened_fields, - &mut dependencies, - field, - &attr.rename_all, - attr.optional, - )?; - } - - let fields = quote!(<[String]>::join(&[#(#formatted_fields),*], " ")); - let flattened = quote!(<[String]>::join(&[#(#flattened_fields),*], " & ")); - - let inline = match (formatted_fields.len(), flattened_fields.len()) { - (0, 0) => quote!("{ }".to_owned()), - (_, 0) => quote!(format!("{{ {} }}", #fields)), - (0, 1) => quote! {{ - if #flattened.starts_with('(') && #flattened.ends_with(')') { - #flattened[1..#flattened.len() - 1].trim().to_owned() - } else { - #flattened.trim().to_owned() - } - }}, - (0, _) => quote!(#flattened), - (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), - }; - - let inline_flattened = match (formatted_fields.len(), flattened_fields.len()) { - (0, 0) => quote!("{ }".to_owned()), - (_, 0) => quote!(format!("{{ {} }}", #fields)), - (0, _) => quote!(#flattened), - (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), - }; - - Ok(DerivedTS { - crate_rename, - // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it - // results in simpler type definitions. - inline: quote!(#inline.replace(" } & { ", " ")), - inline_flattened: Some(quote!(#inline_flattened.replace(" } & { ", " "))), - docs: attr.docs.clone(), - dependencies, - export: attr.export, - export_to: attr.export_to.clone(), - ts_name: name.to_owned(), - concrete: attr.concrete.clone(), - bound: attr.bound.clone(), - }) -} - -// build an expression which expands to a string, representing a single field of a struct. -// -// formatted_fields will contain all the fields that do not contain the flatten -// attribute, in the format -// key: type, -// -// flattened_fields will contain all the fields that contain the flatten attribute -// in their respective formats, which for a named struct is the same as formatted_fields, -// but for enums is -// ({ /* variant data */ } | { /* variant data */ }) -fn format_field( - crate_rename: &Path, - formatted_fields: &mut Vec, - flattened_fields: &mut Vec, - dependencies: &mut Dependencies, - field: &Field, - rename_all: &Option, - struct_optional: Optional, -) -> Result<()> { - let field_attr = FieldAttr::from_attrs(&field.attrs)?; - - field_attr.assert_validity(field)?; - - if field_attr.skip { - return Ok(()); - } - - let ty = field_attr.type_as(&field.ty); - - let opt = match (struct_optional, field_attr.optional) { - (opt @ Optional::Optional { .. }, Optional::NotOptional) => opt, - (_, opt @ Optional::Optional { .. }) => opt, - (opt @ Optional::NotOptional, Optional::NotOptional) => opt, - }; - - let optional_annotation = if let Optional::Optional { .. } = opt { - quote! { if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } } - } else { - quote! { "" } - }; - - if field_attr.flatten { - flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); - dependencies.append_from(&ty); - return Ok(()); - } - - let formatted_ty = field_attr - .type_override - .map(|t| quote!(#t)) - .unwrap_or_else(|| { - if field_attr.inline { - dependencies.append_from(&ty); - - if let Optional::Optional { nullable: false } = opt { - quote! { - if <#ty as #crate_rename::TS>::IS_OPTION { - <#ty as #crate_rename::TS>::name().trim_end_matches(" | null").to_owned() - } else { - <#ty as #crate_rename::TS>::inline() - } - } - } else { - quote!(<#ty as #crate_rename::TS>::inline()) - } - } else { - dependencies.push(&ty); - if let Optional::Optional { nullable: false } = opt { - quote! { - if <#ty as #crate_rename::TS>::IS_OPTION { - <#ty as #crate_rename::TS>::name().trim_end_matches(" | null").to_owned() - } else { - <#ty as #crate_rename::TS>::name() - } - } - } else { - quote!(<#ty as #crate_rename::TS>::name()) - } - } - }); - - let field_name = to_ts_ident(field.ident.as_ref().unwrap()); - let name = match (field_attr.rename, rename_all) { - (Some(rn), _) => rn, - (None, Some(rn)) => rn.apply(&field_name), - (None, None) => field_name, - }; - let valid_name = raw_name_to_ts_field(name); - - // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode - let docs = match field_attr.docs.is_empty() { - true => "".to_string(), - false => format!("\n{}", &field_attr.docs), - }; - - formatted_fields.push(quote! { - format!("{}{}{}: {},", #docs, #valid_name, #optional_annotation, #formatted_ty) - }); - - Ok(()) -} +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::{parse_quote, Field, FieldsNamed, Path, Result}; +use syn::spanned::Spanned; +use crate::{ + attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, + deps::Dependencies, + utils::{raw_name_to_ts_field, to_ts_ident}, + DerivedTS, +}; + +pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Result { + let crate_rename = attr.crate_rename(); + + let mut formatted_fields = Vec::new(); + let mut flattened_fields = Vec::new(); + let mut dependencies = Dependencies::new(crate_rename.clone()); + + if let Some(tag) = &attr.tag { + let formatted = format!("\"{}\": \"{}\",", tag, name); + formatted_fields.push(quote! { + #formatted.to_string() + }); + } + + for field in &fields.named { + format_field( + &crate_rename, + &mut formatted_fields, + &mut flattened_fields, + &mut dependencies, + field, + &attr.rename_all, + attr.optional, + )?; + } + + let fields = quote!(<[String]>::join(&[#(#formatted_fields),*], " ")); + let flattened = quote!(<[String]>::join(&[#(#flattened_fields),*], " & ")); + + let inline = match (formatted_fields.len(), flattened_fields.len()) { + (0, 0) => quote!("{ }".to_owned()), + (_, 0) => quote!(format!("{{ {} }}", #fields)), + (0, 1) => quote! {{ + if #flattened.starts_with('(') && #flattened.ends_with(')') { + #flattened[1..#flattened.len() - 1].trim().to_owned() + } else { + #flattened.trim().to_owned() + } + }}, + (0, _) => quote!(#flattened), + (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), + }; + + let inline_flattened = match (formatted_fields.len(), flattened_fields.len()) { + (0, 0) => quote!("{ }".to_owned()), + (_, 0) => quote!(format!("{{ {} }}", #fields)), + (0, _) => quote!(#flattened), + (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), + }; + + Ok(DerivedTS { + crate_rename, + // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it + // results in simpler type definitions. + inline: quote!(#inline.replace(" } & { ", " ")), + inline_flattened: Some(quote!(#inline_flattened.replace(" } & { ", " "))), + docs: attr.docs.clone(), + dependencies, + export: attr.export, + export_to: attr.export_to.clone(), + ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), + }) +} + +// build an expression which expands to a string, representing a single field of a struct. +// +// formatted_fields will contain all the fields that do not contain the flatten +// attribute, in the format +// key: type, +// +// flattened_fields will contain all the fields that contain the flatten attribute +// in their respective formats, which for a named struct is the same as formatted_fields, +// but for enums is +// ({ /* variant data */ } | { /* variant data */ }) +fn format_field( + crate_rename: &Path, + formatted_fields: &mut Vec, + flattened_fields: &mut Vec, + dependencies: &mut Dependencies, + field: &Field, + rename_all: &Option, + struct_optional: Optional, +) -> Result<()> { + let field_attr = FieldAttr::from_attrs(&field.attrs)?; + + field_attr.assert_validity(field)?; + + if field_attr.skip { + return Ok(()); + } + + let ty = field_attr.type_as(&field.ty); + + let (optional_annotation, nullable) = match (struct_optional, field_attr.optional) { + // `#[ts(optional)]` on field takes precedence, and is enforced **AT COMPILE TIME** + (_, Optional::Optional { nullable }) => ( + // expression that evaluates to the string "?", but fails to compile if `ty` is not an `Option`. + quote_spanned! { field.span() => { + fn check_that_field_is_option(_: std::marker::PhantomData) {} + let x: std::marker::PhantomData<#ty> = std::marker::PhantomData; + check_that_field_is_option(x); + "?" + }}, + nullable, + ), + // `#[ts(optional)]` on the struct acts as `#[ts(optional)]` on a field, but does not error on non-`Option` + // fields. Instead, it is a no-op. + (Optional::Optional { nullable }, _) => ( + quote! { + if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } + }, + nullable, + ), + _ => (quote!(""), true), + }; + + let ty = if nullable { + ty + } else { + parse_quote! {<#ty as #crate_rename::TS>::OptionInnerType} + }; + + if field_attr.flatten { + flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); + dependencies.append_from(&ty); + return Ok(()); + } + + let formatted_ty = field_attr + .type_override + .map(|t| quote!(#t)) + .unwrap_or_else(|| { + if field_attr.inline { + dependencies.append_from(&ty); + quote!(<#ty as #crate_rename::TS>::inline()) + } else { + dependencies.push(&ty); + quote!(<#ty as #crate_rename::TS>::name()) + } + }); + + let field_name = to_ts_ident(field.ident.as_ref().unwrap()); + let name = match (field_attr.rename, rename_all) { + (Some(rn), _) => rn, + (None, Some(rn)) => rn.apply(&field_name), + (None, None) => field_name, + }; + let valid_name = raw_name_to_ts_field(name); + + // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode + let docs = match field_attr.docs.is_empty() { + true => "".to_string(), + false => format!("\n{}", &field_attr.docs), + }; + + formatted_fields.push(quote! { + format!("{}{}{}: {},", #docs, #valid_name, #optional_annotation, #formatted_ty) + }); + + Ok(()) +} diff --git a/ts-rs/src/chrono.rs b/ts-rs/src/chrono.rs index 281ec430..ab57d025 100644 --- a/ts-rs/src/chrono.rs +++ b/ts-rs/src/chrono.rs @@ -12,6 +12,8 @@ macro_rules! impl_dummy { ($($t:ty),*) => {$( impl TS for $t { type WithoutGenerics = $t; + type OptionInnerType = Self; + fn name() -> String { String::new() } fn inline() -> String { String::new() } fn inline_flattened() -> String { panic!("{} cannot be flattened", Self::name()) } @@ -26,6 +28,8 @@ impl_dummy!(Utc, Local, FixedOffset); impl TS for DateTime { type WithoutGenerics = Self; + type OptionInnerType = Self; + fn ident() -> String { "string".to_owned() } @@ -48,6 +52,8 @@ impl TS for DateTime { impl TS for Date { type WithoutGenerics = Self; + type OptionInnerType = Self; + fn ident() -> String { "string".to_owned() } diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index f63ab821..1e657c2c 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -390,6 +390,10 @@ pub trait TS { /// ``` type WithoutGenerics: TS + ?Sized; + /// If the implementing type is `std::option::Option`, then this associated type is set to `T`. + /// All other implementations of `TS` should set this type to `Self` instead. + type OptionInnerType: ?Sized; + /// JSDoc comment to describe this type in TypeScript - when `TS` is derived, docs are /// automatically read from your doc comments or `#[doc = ".."]` attributes const DOCS: Option<&'static str> = None; @@ -622,11 +626,22 @@ impl Dependency { } } +#[doc(hidden)] +#[diagnostic::on_unimplemented( + message = "`#[ts(optional)]` can only be used on fields of type `Option`", + note = "`#[ts(optional)]` was used on a field of type {Self}, which is not permitted", + label = "" +)] +pub trait IsOption {} + +impl IsOption for Option {} + // generate impls for primitive types macro_rules! impl_primitives { ($($($ty:ty),* => $l:literal),*) => { $($( impl TS for $ty { type WithoutGenerics = Self; + type OptionInnerType = Self; fn name() -> String { $l.to_owned() } fn inline() -> String { ::name() } fn inline_flattened() -> String { panic!("{} cannot be flattened", ::name()) } @@ -640,6 +655,7 @@ macro_rules! impl_tuples { ( impl $($i:ident),* ) => { impl<$($i: TS),*> TS for ($($i,)*) { type WithoutGenerics = (Dummy, ); + type OptionInnerType = Self; fn name() -> String { format!("[{}]", [$(<$i as $crate::TS>::name()),*].join(", ")) } @@ -672,6 +688,7 @@ macro_rules! impl_wrapper { ($($t:tt)*) => { $($t)* { type WithoutGenerics = Self; + type OptionInnerType = Self; fn name() -> String { T::name() } fn inline() -> String { T::inline() } fn inline_flattened() -> String { T::inline_flattened() } @@ -700,6 +717,7 @@ macro_rules! impl_shadow { (as $s:ty: $($impl:tt)*) => { $($impl)* { type WithoutGenerics = <$s as $crate::TS>::WithoutGenerics; + type OptionInnerType = <$s as $crate::TS>::OptionInnerType; fn ident() -> String { <$s as $crate::TS>::ident() } fn name() -> String { <$s as $crate::TS>::name() } fn inline() -> String { <$s as $crate::TS>::inline() } @@ -725,6 +743,7 @@ macro_rules! impl_shadow { impl TS for Option { type WithoutGenerics = Self; + type OptionInnerType = T; const IS_OPTION: bool = true; fn name() -> String { @@ -765,6 +784,7 @@ impl TS for Option { impl TS for Result { type WithoutGenerics = Result; + type OptionInnerType = Self; fn name() -> String { format!("{{ Ok : {} }} | {{ Err : {} }}", T::name(), E::name()) @@ -807,6 +827,7 @@ impl TS for Result { impl TS for Vec { type WithoutGenerics = Vec; + type OptionInnerType = Self; fn ident() -> String { "Array".to_owned() @@ -852,6 +873,8 @@ impl TS for Vec { const ARRAY_TUPLE_LIMIT: usize = 64; impl TS for [T; N] { type WithoutGenerics = [Dummy; N]; + type OptionInnerType = Self; + fn name() -> String { if N > ARRAY_TUPLE_LIMIT { return Vec::::name(); @@ -904,6 +927,7 @@ impl TS for [T; N] { impl TS for HashMap { type WithoutGenerics = HashMap; + type OptionInnerType = Self; fn ident() -> String { panic!() @@ -950,6 +974,8 @@ impl TS for HashMap { impl TS for Range { type WithoutGenerics = Range; + type OptionInnerType = Self; + fn name() -> String { format!("{{ start: {}, end: {}, }}", I::name(), I::name()) } @@ -1082,6 +1108,8 @@ impl std::fmt::Display for Dummy { impl TS for Dummy { type WithoutGenerics = Self; + type OptionInnerType = Self; + fn name() -> String { "Dummy".to_owned() } From 95e04015876d98a8d88549509ddea16403aaef60 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 22:21:31 -0300 Subject: [PATCH 12/21] Remove invalid optional from test --- ts-rs/tests/integration/optional_field.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 63fc6eae..fd57e17e 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -100,8 +100,6 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, - // `#[ts(optional)]` on a type that isn't `Option` does nothing - #[ts(optional = nullable)] d: i32, e: Foo, From 4d1a6d42e656546de1d9e079624f782f36ec2cd7 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 22:22:52 -0300 Subject: [PATCH 13/21] Fix doc comment --- ts-rs/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 1e657c2c..1453ecd3 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -380,6 +380,7 @@ pub trait TS { /// struct GenericType(A, B); /// impl TS for GenericType { /// type WithoutGenerics = GenericType; + /// type OptionInnerType = Self; /// // ... /// # fn decl() -> String { todo!() } /// # fn decl_concrete() -> String { todo!() } From 0d51986b997c51cc4c5589cded86e4eb2a06cf08 Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 22:26:05 -0300 Subject: [PATCH 14/21] Show error message on the compiler's ascii graphic too --- ts-rs/src/lib.rs | 2 +- ts-rs/tests/integration/optional_field.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 1453ecd3..bbecbb47 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -631,7 +631,7 @@ impl Dependency { #[diagnostic::on_unimplemented( message = "`#[ts(optional)]` can only be used on fields of type `Option`", note = "`#[ts(optional)]` was used on a field of type {Self}, which is not permitted", - label = "" + label = "`#[ts(optional)]` is not allowed on field of type {Self}" )] pub trait IsOption {} diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index fd57e17e..b9109a7b 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -100,6 +100,7 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, + #[ts(optional)] d: i32, e: Foo, From c7d2c4af33a8b644983887f6b59dc07a6b39d2af Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 22:34:39 -0300 Subject: [PATCH 15/21] Remove invalid optional from test - again, oops --- ts-rs/tests/integration/optional_field.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index b9109a7b..fd57e17e 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -100,7 +100,6 @@ struct OptionalStruct { #[ts(optional = nullable)] c: Option, - #[ts(optional)] d: i32, e: Foo, From 033d0962a3ae51de8f4b776ca3a3404e2bca80ce Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 22:36:16 -0300 Subject: [PATCH 16/21] Rename struct level attribute --- macros/src/attr/struct.rs | 6 +- macros/src/types/named.rs | 348 +++++++++++----------- ts-rs/tests/integration/optional_field.rs | 2 +- 3 files changed, 178 insertions(+), 178 deletions(-) diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs index 9cd74054..2244f808 100644 --- a/macros/src/attr/struct.rs +++ b/macros/src/attr/struct.rs @@ -24,7 +24,7 @@ pub struct StructAttr { pub docs: String, pub concrete: HashMap, pub bound: Option>, - pub optional: Optional, + pub optional_fields: Optional, } impl StructAttr { @@ -91,7 +91,7 @@ impl Attr for StructAttr { (Some(bound), None) | (None, Some(bound)) => Some(bound), (None, None) => None, }, - optional: self.optional.or(other.optional), + optional_fields: self.optional_fields.or(other.optional_fields), } } @@ -154,7 +154,7 @@ impl_parse! { "export_to" => out.export_to = Some(parse_assign_str(input)?), "concrete" => out.concrete = parse_concrete(input)?, "bound" => out.bound = Some(parse_bound(input)?), - "optional" => out.optional = parse_optional(input)?, + "optional_fields" => out.optional_fields = parse_optional(input)?, } } diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 8792fbbf..ba167be5 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -1,174 +1,174 @@ -use proc_macro2::TokenStream; -use quote::{quote, quote_spanned}; -use syn::{parse_quote, Field, FieldsNamed, Path, Result}; -use syn::spanned::Spanned; -use crate::{ - attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, - deps::Dependencies, - utils::{raw_name_to_ts_field, to_ts_ident}, - DerivedTS, -}; - -pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Result { - let crate_rename = attr.crate_rename(); - - let mut formatted_fields = Vec::new(); - let mut flattened_fields = Vec::new(); - let mut dependencies = Dependencies::new(crate_rename.clone()); - - if let Some(tag) = &attr.tag { - let formatted = format!("\"{}\": \"{}\",", tag, name); - formatted_fields.push(quote! { - #formatted.to_string() - }); - } - - for field in &fields.named { - format_field( - &crate_rename, - &mut formatted_fields, - &mut flattened_fields, - &mut dependencies, - field, - &attr.rename_all, - attr.optional, - )?; - } - - let fields = quote!(<[String]>::join(&[#(#formatted_fields),*], " ")); - let flattened = quote!(<[String]>::join(&[#(#flattened_fields),*], " & ")); - - let inline = match (formatted_fields.len(), flattened_fields.len()) { - (0, 0) => quote!("{ }".to_owned()), - (_, 0) => quote!(format!("{{ {} }}", #fields)), - (0, 1) => quote! {{ - if #flattened.starts_with('(') && #flattened.ends_with(')') { - #flattened[1..#flattened.len() - 1].trim().to_owned() - } else { - #flattened.trim().to_owned() - } - }}, - (0, _) => quote!(#flattened), - (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), - }; - - let inline_flattened = match (formatted_fields.len(), flattened_fields.len()) { - (0, 0) => quote!("{ }".to_owned()), - (_, 0) => quote!(format!("{{ {} }}", #fields)), - (0, _) => quote!(#flattened), - (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), - }; - - Ok(DerivedTS { - crate_rename, - // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it - // results in simpler type definitions. - inline: quote!(#inline.replace(" } & { ", " ")), - inline_flattened: Some(quote!(#inline_flattened.replace(" } & { ", " "))), - docs: attr.docs.clone(), - dependencies, - export: attr.export, - export_to: attr.export_to.clone(), - ts_name: name.to_owned(), - concrete: attr.concrete.clone(), - bound: attr.bound.clone(), - }) -} - -// build an expression which expands to a string, representing a single field of a struct. -// -// formatted_fields will contain all the fields that do not contain the flatten -// attribute, in the format -// key: type, -// -// flattened_fields will contain all the fields that contain the flatten attribute -// in their respective formats, which for a named struct is the same as formatted_fields, -// but for enums is -// ({ /* variant data */ } | { /* variant data */ }) -fn format_field( - crate_rename: &Path, - formatted_fields: &mut Vec, - flattened_fields: &mut Vec, - dependencies: &mut Dependencies, - field: &Field, - rename_all: &Option, - struct_optional: Optional, -) -> Result<()> { - let field_attr = FieldAttr::from_attrs(&field.attrs)?; - - field_attr.assert_validity(field)?; - - if field_attr.skip { - return Ok(()); - } - - let ty = field_attr.type_as(&field.ty); - - let (optional_annotation, nullable) = match (struct_optional, field_attr.optional) { - // `#[ts(optional)]` on field takes precedence, and is enforced **AT COMPILE TIME** - (_, Optional::Optional { nullable }) => ( - // expression that evaluates to the string "?", but fails to compile if `ty` is not an `Option`. - quote_spanned! { field.span() => { - fn check_that_field_is_option(_: std::marker::PhantomData) {} - let x: std::marker::PhantomData<#ty> = std::marker::PhantomData; - check_that_field_is_option(x); - "?" - }}, - nullable, - ), - // `#[ts(optional)]` on the struct acts as `#[ts(optional)]` on a field, but does not error on non-`Option` - // fields. Instead, it is a no-op. - (Optional::Optional { nullable }, _) => ( - quote! { - if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } - }, - nullable, - ), - _ => (quote!(""), true), - }; - - let ty = if nullable { - ty - } else { - parse_quote! {<#ty as #crate_rename::TS>::OptionInnerType} - }; - - if field_attr.flatten { - flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); - dependencies.append_from(&ty); - return Ok(()); - } - - let formatted_ty = field_attr - .type_override - .map(|t| quote!(#t)) - .unwrap_or_else(|| { - if field_attr.inline { - dependencies.append_from(&ty); - quote!(<#ty as #crate_rename::TS>::inline()) - } else { - dependencies.push(&ty); - quote!(<#ty as #crate_rename::TS>::name()) - } - }); - - let field_name = to_ts_ident(field.ident.as_ref().unwrap()); - let name = match (field_attr.rename, rename_all) { - (Some(rn), _) => rn, - (None, Some(rn)) => rn.apply(&field_name), - (None, None) => field_name, - }; - let valid_name = raw_name_to_ts_field(name); - - // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode - let docs = match field_attr.docs.is_empty() { - true => "".to_string(), - false => format!("\n{}", &field_attr.docs), - }; - - formatted_fields.push(quote! { - format!("{}{}{}: {},", #docs, #valid_name, #optional_annotation, #formatted_ty) - }); - - Ok(()) -} +use crate::{ + attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, + deps::Dependencies, + utils::{raw_name_to_ts_field, to_ts_ident}, + DerivedTS, +}; +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{parse_quote, Field, FieldsNamed, Path, Result}; + +pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Result { + let crate_rename = attr.crate_rename(); + + let mut formatted_fields = Vec::new(); + let mut flattened_fields = Vec::new(); + let mut dependencies = Dependencies::new(crate_rename.clone()); + + if let Some(tag) = &attr.tag { + let formatted = format!("\"{}\": \"{}\",", tag, name); + formatted_fields.push(quote! { + #formatted.to_string() + }); + } + + for field in &fields.named { + format_field( + &crate_rename, + &mut formatted_fields, + &mut flattened_fields, + &mut dependencies, + field, + &attr.rename_all, + attr.optional_fields, + )?; + } + + let fields = quote!(<[String]>::join(&[#(#formatted_fields),*], " ")); + let flattened = quote!(<[String]>::join(&[#(#flattened_fields),*], " & ")); + + let inline = match (formatted_fields.len(), flattened_fields.len()) { + (0, 0) => quote!("{ }".to_owned()), + (_, 0) => quote!(format!("{{ {} }}", #fields)), + (0, 1) => quote! {{ + if #flattened.starts_with('(') && #flattened.ends_with(')') { + #flattened[1..#flattened.len() - 1].trim().to_owned() + } else { + #flattened.trim().to_owned() + } + }}, + (0, _) => quote!(#flattened), + (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), + }; + + let inline_flattened = match (formatted_fields.len(), flattened_fields.len()) { + (0, 0) => quote!("{ }".to_owned()), + (_, 0) => quote!(format!("{{ {} }}", #fields)), + (0, _) => quote!(#flattened), + (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)), + }; + + Ok(DerivedTS { + crate_rename, + // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it + // results in simpler type definitions. + inline: quote!(#inline.replace(" } & { ", " ")), + inline_flattened: Some(quote!(#inline_flattened.replace(" } & { ", " "))), + docs: attr.docs.clone(), + dependencies, + export: attr.export, + export_to: attr.export_to.clone(), + ts_name: name.to_owned(), + concrete: attr.concrete.clone(), + bound: attr.bound.clone(), + }) +} + +// build an expression which expands to a string, representing a single field of a struct. +// +// formatted_fields will contain all the fields that do not contain the flatten +// attribute, in the format +// key: type, +// +// flattened_fields will contain all the fields that contain the flatten attribute +// in their respective formats, which for a named struct is the same as formatted_fields, +// but for enums is +// ({ /* variant data */ } | { /* variant data */ }) +fn format_field( + crate_rename: &Path, + formatted_fields: &mut Vec, + flattened_fields: &mut Vec, + dependencies: &mut Dependencies, + field: &Field, + rename_all: &Option, + struct_optional: Optional, +) -> Result<()> { + let field_attr = FieldAttr::from_attrs(&field.attrs)?; + + field_attr.assert_validity(field)?; + + if field_attr.skip { + return Ok(()); + } + + let ty = field_attr.type_as(&field.ty); + + let (optional_annotation, nullable) = match (struct_optional, field_attr.optional) { + // `#[ts(optional)]` on field takes precedence, and is enforced **AT COMPILE TIME** + (_, Optional::Optional { nullable }) => ( + // expression that evaluates to the string "?", but fails to compile if `ty` is not an `Option`. + quote_spanned! { field.span() => { + fn check_that_field_is_option(_: std::marker::PhantomData) {} + let x: std::marker::PhantomData<#ty> = std::marker::PhantomData; + check_that_field_is_option(x); + "?" + }}, + nullable, + ), + // `#[ts(optional)]` on the struct acts as `#[ts(optional)]` on a field, but does not error on non-`Option` + // fields. Instead, it is a no-op. + (Optional::Optional { nullable }, _) => ( + quote! { + if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } + }, + nullable, + ), + _ => (quote!(""), true), + }; + + let ty = if nullable { + ty + } else { + parse_quote! {<#ty as #crate_rename::TS>::OptionInnerType} + }; + + if field_attr.flatten { + flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); + dependencies.append_from(&ty); + return Ok(()); + } + + let formatted_ty = field_attr + .type_override + .map(|t| quote!(#t)) + .unwrap_or_else(|| { + if field_attr.inline { + dependencies.append_from(&ty); + quote!(<#ty as #crate_rename::TS>::inline()) + } else { + dependencies.push(&ty); + quote!(<#ty as #crate_rename::TS>::name()) + } + }); + + let field_name = to_ts_ident(field.ident.as_ref().unwrap()); + let name = match (field_attr.rename, rename_all) { + (Some(rn), _) => rn, + (None, Some(rn)) => rn.apply(&field_name), + (None, None) => field_name, + }; + let valid_name = raw_name_to_ts_field(name); + + // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode + let docs = match field_attr.docs.is_empty() { + true => "".to_string(), + false => format!("\n{}", &field_attr.docs), + }; + + formatted_fields.push(quote! { + format!("{}{}{}: {},", #docs, #valid_name, #optional_annotation, #formatted_ty) + }); + + Ok(()) +} diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index fd57e17e..6db671b7 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -92,7 +92,7 @@ type Foo = Option; type Bar = Option; #[derive(TS)] -#[ts(export, export_to = "optional_field/", optional)] +#[ts(export, export_to = "optional_field/", optional_fields)] struct OptionalStruct { a: Option, b: Option, From f72238fc65686bf1564515fcde4717bfc68da12f Mon Sep 17 00:00:00 2001 From: Gustavo Shigueo Date: Sun, 10 Nov 2024 23:04:25 -0300 Subject: [PATCH 17/21] Validate `optional_fields` compatibility with other attributes --- macros/src/attr/struct.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs index 2244f808..80d1e12f 100644 --- a/macros/src/attr/struct.rs +++ b/macros/src/attr/struct.rs @@ -108,6 +108,10 @@ impl Attr for StructAttr { if self.tag.is_some() { syn_err!("`tag` is not compatible with `type`"); } + + if let Optional::Optional { .. } = self.optional_fields { + syn_err!("`optional_fields` is not compatible with `type`"); + } } if self.type_as.is_some() { @@ -118,6 +122,10 @@ impl Attr for StructAttr { if self.rename_all.is_some() { syn_err!("`rename_all` is not compatible with `as`"); } + + if let Optional::Optional { .. } = self.optional_fields { + syn_err!("`optional_fields` is not compatible with `as`"); + } } if !matches!(item, Fields::Named(_)) { @@ -128,6 +136,10 @@ impl Attr for StructAttr { if self.rename_all.is_some() { syn_err!("`rename_all` cannot be used with unit or tuple structs"); } + + if let Optional::Optional { .. } = self.optional_fields { + syn_err!("`optional_fields` cannot be used with unit or tuple structs"); + } } Ok(()) From 21d03f504dd73c3ffa020eeb04b8c0cb04f737f3 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo Date: Sun, 1 Dec 2024 20:50:06 -0300 Subject: [PATCH 18/21] Add extra test for `optional_fields = nullable` --- ts-rs/tests/integration/optional_field.rs | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 6db671b7..46957d33 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -115,3 +115,28 @@ fn struct_optional() { ) ) } + +#[derive(TS)] +#[ts(export, export_to = "optional_field/", optional_fields = nullable)] +struct NullableStruct { + a: Option, + b: Option, + + #[ts(optional = nullable)] + c: Option, + + d: i32, + + e: Foo, + f: Bar, +} + +#[test] +fn struct_nullable() { + assert_eq!( + NullableStruct::inline(), + format!( + "{{ a?: number | null, b?: number | null, c?: number | null, d: number, e?: number | null, f?: number | null, }}" + ) + ) +} From faa431fe0b43b7f99e98ff1e49033f705a06fd0d Mon Sep 17 00:00:00 2001 From: gustavo-shigueo Date: Sun, 1 Dec 2024 20:50:10 -0300 Subject: [PATCH 19/21] Add docs --- ts-rs/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index bbecbb47..6f87586d 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -286,6 +286,12 @@ mod tokio; /// Include the structs name (or value of `#[ts(rename = "..")]`) as a field with the given key. ///

/// +/// - **`#[ts(optional_fields)]`** +/// Makes all `Option` fields in a struct optional. +/// If `#[ts(optional_fields)]` is present, `t?: T` is generated for every `Option` field of the struct. +/// If `#[ts(optional_fields = nullable)]` is present, `t?: T | null` is generated for every `Option` field of the struct. +///

+/// /// ### struct field attributes /// /// - **`#[ts(type = "..")]`** From 53941b5fc1ac5128bd403cd07dcce9cd88a67908 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo Date: Mon, 2 Dec 2024 08:54:08 -0300 Subject: [PATCH 20/21] #[ts(type)] overrides #[ts(optional_fields)] --- macros/src/types/named.rs | 41 ++++++++++++++++------- ts-rs/tests/integration/optional_field.rs | 16 +++++++-- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index ba167be5..6b9a2de3 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -102,6 +102,28 @@ fn format_field( return Ok(()); } + if let Some(ref type_override) = field_attr.type_override { + let field_name = to_ts_ident(field.ident.as_ref().unwrap()); + let name = match (field_attr.rename.as_ref(), rename_all) { + (Some(rn), _) => rn.to_owned(), + (None, Some(rn)) => rn.apply(&field_name), + (None, None) => field_name, + }; + let valid_name = raw_name_to_ts_field(name); + + // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode + let docs = match field_attr.docs.is_empty() { + true => "".to_string(), + false => format!("\n{}", &field_attr.docs), + }; + + formatted_fields.push(quote! { + format!("{}{}: {},", #docs, #valid_name, #type_override) + }); + + return Ok(()); + } + let ty = field_attr.type_as(&field.ty); let (optional_annotation, nullable) = match (struct_optional, field_attr.optional) { @@ -139,18 +161,13 @@ fn format_field( return Ok(()); } - let formatted_ty = field_attr - .type_override - .map(|t| quote!(#t)) - .unwrap_or_else(|| { - if field_attr.inline { - dependencies.append_from(&ty); - quote!(<#ty as #crate_rename::TS>::inline()) - } else { - dependencies.push(&ty); - quote!(<#ty as #crate_rename::TS>::name()) - } - }); + let formatted_ty = if field_attr.inline { + dependencies.append_from(&ty); + quote!(<#ty as #crate_rename::TS>::inline()) + } else { + dependencies.push(&ty); + quote!(<#ty as #crate_rename::TS>::name()) + }; let field_name = to_ts_ident(field.ident.as_ref().unwrap()); let name = match (field_attr.rename, rename_all) { diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 46957d33..2bd7762f 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -104,6 +104,12 @@ struct OptionalStruct { e: Foo, f: Bar, + + #[ts(type = "string")] + g: Option, + + #[ts(as = "String")] + h: Option, } #[test] @@ -111,7 +117,7 @@ fn struct_optional() { assert_eq!( OptionalStruct::inline(), format!( - "{{ a?: number, b?: number, c?: number | null, d: number, e?: number, f?: number, }}" + "{{ a?: number, b?: number, c?: number | null, d: number, e?: number, f?: number, g: string, h: string, }}" ) ) } @@ -129,6 +135,12 @@ struct NullableStruct { e: Foo, f: Bar, + + #[ts(type = "string")] + g: Option, + + #[ts(as = "String")] + h: Option, } #[test] @@ -136,7 +148,7 @@ fn struct_nullable() { assert_eq!( NullableStruct::inline(), format!( - "{{ a?: number | null, b?: number | null, c?: number | null, d: number, e?: number | null, f?: number | null, }}" + "{{ a?: number | null, b?: number | null, c?: number | null, d: number, e?: number | null, f?: number | null, g: string, h: string, }}" ) ) } From 3d0a0487ce4c906b53b72e91787a46383045348a Mon Sep 17 00:00:00 2001 From: gustavo-shigueo Date: Mon, 2 Dec 2024 08:56:07 -0300 Subject: [PATCH 21/21] Make #[ts(type)] and #[ts(optional)] incompatible --- macros/src/attr/field.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs index 8994bd66..441ae4ad 100644 --- a/macros/src/attr/field.rs +++ b/macros/src/attr/field.rs @@ -97,6 +97,13 @@ impl Attr for FieldAttr { "`type` is not compatible with `flatten`" ); } + + if let Optional::Optional { .. } = self.optional { + syn_err_spanned!( + field; + "`type` is not compatible with `optional`" + ); + } } if self.flatten {