Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add #[ts(optional)] to struct #366

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 5 additions & 33 deletions macros/src/attr/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<T>` 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<Self> {
let mut result = parse_attrs::<Self>(attrs)?;
Expand Down Expand Up @@ -64,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,
Expand Down Expand Up @@ -133,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`"
Expand All @@ -156,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"
Expand All @@ -175,23 +163,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::<Token![=]>()?;
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,
}
}
Expand Down
45 changes: 44 additions & 1 deletion macros/src/attr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand All @@ -15,6 +15,34 @@ mod field;
mod r#struct;
mod variant;

/// Indicates whether the field is marked with `#[ts(optional)]`.
/// `#[ts(optional)]` turns an `t: Option<T>` into `t?: T`, while
/// `#[ts(optional = nullable)]` turns it into `t?: T | null`.
#[derive(Default, Clone, Copy)]
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)]
pub enum Inflection {
Lower,
Expand Down Expand Up @@ -180,3 +208,18 @@ fn parse_bound(input: ParseStream) -> Result<Vec<WherePredicate>> {
other => Err(Error::new(other.span(), "expected string")),
}
}

fn parse_optional(input: ParseStream) -> Result<Optional> {
let nullable = if input.peek(Token![=]) {
input.parse::<Token![=]>()?;
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 { nullable })
}
7 changes: 5 additions & 2 deletions macros/src/attr/struct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -24,6 +24,7 @@ pub struct StructAttr {
pub docs: String,
pub concrete: HashMap<Ident, Type>,
pub bound: Option<Vec<WherePredicate>>,
pub optional: Optional,
}

impl StructAttr {
Expand Down Expand Up @@ -90,6 +91,7 @@ impl Attr for StructAttr {
(Some(bound), None) | (None, Some(bound)) => Some(bound),
(None, None) => None,
},
optional: self.optional.or(other.optional),
}
}

Expand Down Expand Up @@ -152,6 +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)?,
}
}

Expand Down
98 changes: 51 additions & 47 deletions macros/src/types/named.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -33,6 +31,7 @@ pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Resu
&mut dependencies,
field,
&attr.rename_all,
attr.optional,
)?;
}

Expand Down Expand Up @@ -93,6 +92,7 @@ fn format_field(
dependencies: &mut Dependencies,
field: &Field,
rename_all: &Option<Inflection>,
struct_optional: Optional,
) -> Result<()> {
let field_attr = FieldAttr::from_attrs(&field.attrs)?;

Expand All @@ -102,27 +102,35 @@ fn format_field(
return Ok(());
}

let parsed_ty = field_attr.type_as(&field.ty);

let (ty, optional_annotation) = match field_attr.optional {
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
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! { "" }
};

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")
}
}
Optional {
optional: false, ..
} => (&parsed_ty, ""),
} else {
optional_annotation
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option is just remove this whole thing, this way there'll be no panic (still, no compiler error). What will happen is that #[ts(optional)] will just do nothing when not using an Option type

};

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(());
}

Expand All @@ -131,11 +139,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 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);
quote!(<#ty as #crate_rename::TS>::name())
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())
}
}
});

Expand All @@ -159,28 +188,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<T> type"),
}
}
4 changes: 2 additions & 2 deletions ts-rs/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions ts-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ 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;

/// Identifier of this type, excluding generic parameters.
fn ident() -> String {
// by default, fall back to `TS::name()`.
Expand Down Expand Up @@ -722,6 +725,7 @@ macro_rules! impl_shadow {

impl<T: TS> TS for Option<T> {
type WithoutGenerics = Self;
const IS_OPTION: bool = true;

fn name() -> String {
format!("{} | null", T::name())
Expand Down
28 changes: 28 additions & 0 deletions ts-rs/tests/integration/optional_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,31 @@ fn inline() {
let c = "c: number | null";
assert_eq!(Inline::inline(), format!("{{ x: {{ {a}, {b}, {c}, }}, }}"));
}

type Foo = Option<i32>;
type Bar<T> = Option<T>;

#[derive(TS)]
#[ts(export, export_to = "optional_field/", optional)]
struct OptionalStruct {
a: Option<i32>,
b: Option<i32>,

#[ts(optional = nullable)]
c: Option<i32>,

d: i32,

e: Foo,
f: Bar<i32>,
}

#[test]
fn struct_optional() {
assert_eq!(
OptionalStruct::inline(),
format!(
"{{ a?: number, b?: number, c?: number | null, d: number, e?: number, f?: number, }}"
)
)
}