From dcc7a461e6680a0e15e9b91da3cb0dc97b450a57 Mon Sep 17 00:00:00 2001 From: NyxCode Date: Mon, 11 Nov 2024 02:19:59 +0100 Subject: [PATCH] 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() }