diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs index e70ba4b5..94bde7a2 100644 --- a/macros/src/attr/enum.rs +++ b/macros/src/attr/enum.rs @@ -41,10 +41,11 @@ pub enum Tagged<'a> { Untagged, } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq, Eq)] pub enum Repr { Int, Name, + ConstObject, } impl EnumAttr { @@ -228,7 +229,7 @@ impl Attr for EnumAttr { } if let Optional::Optional { .. } = self.optional_fields { - syn_err!("`optional_fields` is not compatible with `as`"); + syn_err!("`optional_fields` is not compatible with `repr`"); } } diff --git a/macros/src/attr/mod.rs b/macros/src/attr/mod.rs index abd42459..1e73dcf3 100644 --- a/macros/src/attr/mod.rs +++ b/macros/src/attr/mod.rs @@ -241,6 +241,7 @@ fn parse_repr(input: ParseStream) -> Result { let ident = content.parse::()?; match ident.to_string().as_str() { "name" => Ok(Repr::Name), - _ => syn_err!(span; "expected `name`"), + "const_object" => Ok(Repr::ConstObject), + _ => syn_err!(span; "expected `name` or `const_object`"), } } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index b54e588e..94d67ee6 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -294,6 +294,36 @@ impl DerivedTS { buffer.trim_end_matches(['|', ' ']).into() }, + Some(Repr::ConstObject) => quote! { + let variants = #inline; + let mut variants = variants.split(',').map(|v| v.trim()).peekable(); + + if variants.peek().is_none() { + return "never".into() + } + + let mut buffer = String::new(); + let mut latest = None::; + + for variant in variants { + if let Some((_name, explicit_val)) = variant.split_once('=') { + let explicit_val = explicit_val.trim(); + + if let Ok(val) = explicit_val.parse::() { + buffer.push_str(&format!("{} | ", val)); + latest = Some(val); + } else { + buffer.push_str(&format!("{} | ", explicit_val)); + } + } else { + let new_val = latest.map(|x| x + 1).unwrap_or(0); + buffer.push_str(&format!("{} | ", new_val)); + latest = Some(new_val); + } + } + + buffer.trim_end_matches(['|', ' ']).into() + }, None => quote!(#inline), }; @@ -316,6 +346,48 @@ impl DerivedTS { let crate_rename = &self.crate_rename; let name = &self.ts_name; + if self.ts_enum == Some(Repr::ConstObject) { + let inline = &self.inline; + return quote! { + fn decl_concrete(cfg: &#crate_rename::Config) -> String { + let variants = #inline; + let mut variants = variants.split(',').map(|v| v.trim()).peekable(); + + if variants.peek().is_none() { + return format!("const {} = {{}} as const;\nexport type {} = never;", #name, #name) + } + + let mut buffer = String::new(); + let mut latest = None::; + + for variant in variants { + if let Some((name, explicit_val)) = variant.split_once('=') { + let name = name.trim(); + let explicit_val = explicit_val.trim(); + + if let Ok(val) = explicit_val.parse::() { + buffer.push_str(&format!("{}: {}, ", name, val)); + latest = Some(val); + } else { + buffer.push_str(&format!("{}: {}, ", name, explicit_val)); + } + } else { + let new_val = latest.map(|x| x + 1).unwrap_or(0); + buffer.push_str(&format!("{}: {}, ", variant, new_val)); + latest = Some(new_val); + } + } + + let obj = format!("{{ {} }}", buffer.trim_end_matches([',', ' '])); + format!("const {} = {} as const;\nexport type {} = (typeof {})[keyof typeof {}];", #name, obj, #name, #name, #name) + } + + fn decl(cfg: &#crate_rename::Config) -> String { + ::decl_concrete(cfg) + } + }; + } + if self.ts_enum.is_some() { let inline = &self.inline; return quote! { diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs index 2366e886..a70b8308 100644 --- a/macros/src/types/enum.rs +++ b/macros/src/types/enum.rs @@ -36,6 +36,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result { let mut formatted_variants = Vec::new(); let mut dependencies = Dependencies::new(crate_rename.clone()); + let is_int_enum = s.variants.iter().any(|v| v.discriminant.is_some()); for variant in &s.variants { format_variant( @@ -43,6 +44,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result { &mut dependencies, &enum_attr, variant, + is_int_enum, )?; } @@ -74,6 +76,7 @@ fn format_variant( dependencies: &mut Dependencies, enum_attr: &EnumAttr, variant: &Variant, + is_int_enum: bool, ) -> syn::Result<()> { let crate_rename = enum_attr.crate_rename(); @@ -101,12 +104,16 @@ fn format_variant( }; if let Some(ref repr) = enum_attr.repr { - let formatted = match (repr, &variant.discriminant) { - (Repr::Int, Some((_, value))) => { + let formatted = match (repr, &variant.discriminant, is_int_enum) { + (Repr::Int, Some((_, value)), _) | (Repr::ConstObject, Some((_, value)), true) => { quote!(format!("\"{}\" = {}", #ts_name, #value)) } - (Repr::Int, None) => quote!(format!("\"{}\"", #ts_name)), - (Repr::Name, _) => quote!(format!("\"{}\" = \"{}\"", #ts_name, #ts_name)), + (Repr::Int, None, _) | (Repr::ConstObject, None, true) => { + quote!(format!("\"{}\"", #ts_name)) + } + (Repr::Name, _, _) | (Repr::ConstObject, _, false) => { + quote!(format!("\"{}\" = \"{}\"", #ts_name, #ts_name)) + } }; formatted_variants.push(formatted); diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 524296ff..807fffb0 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -342,9 +342,12 @@ mod tokio; /// /// - **`#[ts(repr(enum))]`** \ /// Exports the enum as a TypeScript enum instead of type union. \ -/// Discriminants (`= {integer}`) are included in the exported enum's variants +/// Discriminants (`= {integer}`) are included in the exported enum's variants. \ /// If `#[ts(repr(enum = name))]` is used, all variants without a discriminant will be exported -/// as `VariantName = "VariantName"` +/// as `VariantName = "VariantName"`. \ +/// If `#[ts(repr(enum = const_object))]` or `#[ts(repr(enum = "const-object"))]` is used, the enum is exported as a TypeScript `const` object +/// alongside a type alias for its values, allowing it to be used at runtime. All variants of the enum +/// must be unit variants. /// /// ### enum variant attributes /// diff --git a/ts-rs/tests/integration/arrays.rs b/ts-rs/tests/integration/arrays.rs index 9f721cae..a164b51a 100644 --- a/ts-rs/tests/integration/arrays.rs +++ b/ts-rs/tests/integration/arrays.rs @@ -11,7 +11,10 @@ struct Interface { #[test] fn free() { let cfg = Config::from_env(); - assert_eq!(<[String; 4]>::inline(&cfg), "[string, string, string, string]") + assert_eq!( + <[String; 4]>::inline(&cfg), + "[string, string, string, string]" + ) } #[test] diff --git a/ts-rs/tests/integration/bson.rs b/ts-rs/tests/integration/bson.rs index 95005ec6..588e9958 100644 --- a/ts-rs/tests/integration/bson.rs +++ b/ts-rs/tests/integration/bson.rs @@ -13,5 +13,8 @@ struct User { #[test] fn bson() { let cfg = Config::from_env(); - assert_eq!(User::decl(&cfg), "type User = { _id: string, _uuid: string, };") + assert_eq!( + User::decl(&cfg), + "type User = { _id: string, _uuid: string, };" + ) } diff --git a/ts-rs/tests/integration/concrete_generic.rs b/ts-rs/tests/integration/concrete_generic.rs index d06de7ae..2b304da8 100644 --- a/ts-rs/tests/integration/concrete_generic.rs +++ b/ts-rs/tests/integration/concrete_generic.rs @@ -84,7 +84,10 @@ mod simple { #[test] fn simple() { let cfg = Config::from_env(); - assert_eq!(Simple::::decl(&cfg), "type Simple = { t: number, };"); + assert_eq!( + Simple::::decl(&cfg), + "type Simple = { t: number, };" + ); assert_eq!( WithOption::::decl(&cfg), "type WithOption = { opt: number | null, };" diff --git a/ts-rs/tests/integration/enum_variant_annotation.rs b/ts-rs/tests/integration/enum_variant_annotation.rs index 1bd43894..6a302e59 100644 --- a/ts-rs/tests/integration/enum_variant_annotation.rs +++ b/ts-rs/tests/integration/enum_variant_annotation.rs @@ -75,7 +75,10 @@ pub enum C { #[test] fn test_enum_variant_with_tag() { let cfg = Config::from_env(); - assert_eq!(C::inline(&cfg), r#"{ "kind": "SQUARE_THING", name: string, }"#); + assert_eq!( + C::inline(&cfg), + r#"{ "kind": "SQUARE_THING", name: string, }"# + ); } #[cfg(feature = "serde-compat")] diff --git a/ts-rs/tests/integration/generics_flatten.rs b/ts-rs/tests/integration/generics_flatten.rs index 254648e2..818db377 100644 --- a/ts-rs/tests/integration/generics_flatten.rs +++ b/ts-rs/tests/integration/generics_flatten.rs @@ -45,7 +45,10 @@ fn flattened_generic_parameters() { } let cfg = Config::from_env(); - assert_eq!(Item::<()>::decl(&cfg), "type Item = { id: string, } & D;"); + assert_eq!( + Item::<()>::decl(&cfg), + "type Item = { id: string, } & D;" + ); assert_eq!( TwoParameters::<(), ()>::decl(&cfg), "type TwoParameters = { id: string, ab: [A, B], } & A & B;" diff --git a/ts-rs/tests/integration/list.rs b/ts-rs/tests/integration/list.rs index 1ce870c9..65c2b400 100644 --- a/ts-rs/tests/integration/list.rs +++ b/ts-rs/tests/integration/list.rs @@ -10,5 +10,8 @@ struct List { #[test] fn list() { let cfg = Config::from_env(); - assert_eq!(List::decl(&cfg), "type List = { data: Array | null, };"); + assert_eq!( + List::decl(&cfg), + "type List = { data: Array | null, };" + ); } diff --git a/ts-rs/tests/integration/main.rs b/ts-rs/tests/integration/main.rs index d2c85252..dd51453b 100644 --- a/ts-rs/tests/integration/main.rs +++ b/ts-rs/tests/integration/main.rs @@ -51,6 +51,7 @@ mod ranges; mod raw_idents; mod recursion_limit; mod references; +mod repr_const_object; mod repr_enum; mod same_file_export; mod self_referential; diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 0ad7bbe5..569c3525 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -19,7 +19,10 @@ fn in_struct() { let b = "b?: number | null"; let c = "c: number | null"; let cfg = Config::from_env(); - assert_eq!(OptionalInStruct::inline(&cfg), format!("{{ {a}, {b}, {c}, }}")); + assert_eq!( + OptionalInStruct::inline(&cfg), + format!("{{ {a}, {b}, {c}, }}") + ); } #[derive(Serialize, TS)] @@ -105,7 +108,10 @@ fn inline() { let b = "b?: number | null"; let c = "c: number | null"; let cfg = Config::from_env(); - assert_eq!(Inline::inline(&cfg), format!("{{ x: {{ {a}, {b}, {c}, }}, }}")); + assert_eq!( + Inline::inline(&cfg), + format!("{{ x: {{ {a}, {b}, {c}, }}, }}") + ); } type Foo = Option; diff --git a/ts-rs/tests/integration/repr_const_object.rs b/ts-rs/tests/integration/repr_const_object.rs new file mode 100644 index 00000000..c583fa2e --- /dev/null +++ b/ts-rs/tests/integration/repr_const_object.rs @@ -0,0 +1,92 @@ +use ts_rs::{Config, TS}; + +#[derive(TS)] +#[ts(export, export_to = "repr_const_object/", repr(enum = const_object))] +enum Foo { + A = 1, + B = 2, +} + +#[derive(TS)] +#[ts(export, export_to = "repr_const_object/", repr(enum = const_object))] +enum Bar { + A = 1, + B, +} + +#[derive(TS)] +#[ts(export, export_to = "repr_const_object/", repr(enum = const_object))] +enum Baz { + A, + B, +} + +#[derive(TS)] +#[ts(export, export_to = "repr_const_object/", rename_all = "snake_case", repr(enum = const_object))] +enum SnakeCase { + EnumVariantFoo, + EnumVariantBar, +} + +#[derive(TS)] +#[ts(export, export_to = "repr_const_object/", rename_all = "camelCase", repr(enum = const_object))] +enum CamelCase { + EnumVariantFoo, + EnumVariantBar, +} + +#[derive(TS)] +#[ts(export, export_to = "repr_const_object/", rename_all = "kebab-case", repr(enum = const_object))] +enum KebabCase { + EnumVariantFoo, + EnumVariantBar, +} + +#[test] +fn native_ts_enum_repr_const_object() { + let cfg = Config::from_env(); + assert_eq!( + Foo::decl(&cfg), + "const Foo = { \"A\": 1, \"B\": 2 } as const;\nexport type Foo = (typeof Foo)[keyof typeof Foo];" + ); + assert_eq!( + Bar::decl(&cfg), + "const Bar = { \"A\": 1, \"B\": 2 } as const;\nexport type Bar = (typeof Bar)[keyof typeof Bar];" + ); + assert_eq!( + Baz::decl(&cfg), + "const Baz = { \"A\": \"A\", \"B\": \"B\" } as const;\nexport type Baz = (typeof Baz)[keyof typeof Baz];" + ); + assert_eq!( + SnakeCase::decl(&cfg), + "const SnakeCase = { \"enum_variant_foo\": \"enum_variant_foo\", \"enum_variant_bar\": \"enum_variant_bar\" } as const;\nexport type SnakeCase = (typeof SnakeCase)[keyof typeof SnakeCase];" + ); + assert_eq!( + CamelCase::decl(&cfg), + "const CamelCase = { \"enumVariantFoo\": \"enumVariantFoo\", \"enumVariantBar\": \"enumVariantBar\" } as const;\nexport type CamelCase = (typeof CamelCase)[keyof typeof CamelCase];" + ); + assert_eq!( + KebabCase::decl(&cfg), + "const KebabCase = { \"enum-variant-foo\": \"enum-variant-foo\", \"enum-variant-bar\": \"enum-variant-bar\" } as const;\nexport type KebabCase = (typeof KebabCase)[keyof typeof KebabCase];" + ); +} + +#[test] +fn native_ts_enum_repr_const_object_inline() { + let cfg = Config::from_env(); + assert_eq!(Foo::inline(&cfg), "1 | 2"); + assert_eq!(Bar::inline(&cfg), "1 | 2"); + assert_eq!(Baz::inline(&cfg), "\"A\" | \"B\""); + assert_eq!( + SnakeCase::inline(&cfg), + "\"enum_variant_foo\" | \"enum_variant_bar\"" + ); + assert_eq!( + CamelCase::inline(&cfg), + "\"enumVariantFoo\" | \"enumVariantBar\"" + ); + assert_eq!( + KebabCase::inline(&cfg), + "\"enum-variant-foo\" | \"enum-variant-bar\"" + ); +} diff --git a/ts-rs/tests/integration/repr_enum.rs b/ts-rs/tests/integration/repr_enum.rs index 7ba92d48..39c71bba 100644 --- a/ts-rs/tests/integration/repr_enum.rs +++ b/ts-rs/tests/integration/repr_enum.rs @@ -53,22 +53,10 @@ enum KebabCase { #[test] fn native_ts_enum_repr() { let cfg = Config::from_env(); - assert_eq!( - Foo::decl(&cfg), - "enum Foo { \"A\" = 1, \"B\" = 2 }" - ); - assert_eq!( - Bar::decl(&cfg), - "enum Bar { \"A\" = 1, \"B\" }" - ); - assert_eq!( - Baz::decl(&cfg), - "enum Baz { \"A\", \"B\" }" - ); - assert_eq!( - Biz::decl(&cfg), - "enum Biz { \"A\" = \"A\", \"B\" = \"B\" }" - ); + assert_eq!(Foo::decl(&cfg), "enum Foo { \"A\" = 1, \"B\" = 2 }"); + assert_eq!(Bar::decl(&cfg), "enum Bar { \"A\" = 1, \"B\" }"); + assert_eq!(Baz::decl(&cfg), "enum Baz { \"A\", \"B\" }"); + assert_eq!(Biz::decl(&cfg), "enum Biz { \"A\" = \"A\", \"B\" = \"B\" }"); assert_eq!( SnakeCase::decl(&cfg), "enum SnakeCase { \"enum_variant_foo\" = \"enum_variant_foo\", \"enum_variant_bar\" = \"enum_variant_bar\" }" diff --git a/ts-rs/tests/integration/same_file_export.rs b/ts-rs/tests/integration/same_file_export.rs index 3a9eaeec..d431b3c3 100644 --- a/ts-rs/tests/integration/same_file_export.rs +++ b/ts-rs/tests/integration/same_file_export.rs @@ -12,6 +12,14 @@ struct DepB { foo: i32, } +#[derive(TS)] +#[ts(export, export_to = "same_file_export/", repr(enum = const_object))] +enum DepC { + A, + B, + C, +} + #[derive(TS)] #[ts(export, export_to = "same_file_export/types.ts")] struct A { @@ -30,4 +38,11 @@ struct C { foo: DepA, bar: DepB, biz: B, + baz: DepC, +} + +#[derive(TS)] +#[ts(export, export_to = "same_file_export/types.ts", repr(enum = const_object))] +enum ConstEnumB { + B, } diff --git a/ts-rs/tests/integration/serde_skip_with_default.rs b/ts-rs/tests/integration/serde_skip_with_default.rs index 9bffb57b..75ddceae 100644 --- a/ts-rs/tests/integration/serde_skip_with_default.rs +++ b/ts-rs/tests/integration/serde_skip_with_default.rs @@ -22,5 +22,8 @@ pub struct Foobar { #[test] fn serde_skip_with_default() { let cfg = Config::from_env(); - assert_eq!(Foobar::decl(&cfg), "type Foobar = { something_else: number, };"); + assert_eq!( + Foobar::decl(&cfg), + "type Foobar = { something_else: number, };" + ); } diff --git a/ts-rs/tests/integration/struct_rename.rs b/ts-rs/tests/integration/struct_rename.rs index e1f87b04..97a9f91c 100644 --- a/ts-rs/tests/integration/struct_rename.rs +++ b/ts-rs/tests/integration/struct_rename.rs @@ -80,7 +80,10 @@ struct RenameSerdeSpecialChar { #[test] fn serde_rename_special_char() { let cfg = Config::from_env(); - assert_eq!(RenameSerdeSpecialChar::inline(&cfg), r#"{ "a/b": number, }"#); + assert_eq!( + RenameSerdeSpecialChar::inline(&cfg), + r#"{ "a/b": number, }"# + ); } // struct-level renames diff --git a/ts-rs/tests/integration/type_override.rs b/ts-rs/tests/integration/type_override.rs index 23603305..2308aad1 100644 --- a/ts-rs/tests/integration/type_override.rs +++ b/ts-rs/tests/integration/type_override.rs @@ -73,5 +73,8 @@ fn enum_newtype_representations() { // regression test for https://github.com/Aleph-Alpha/ts-rs/issues/126 let cfg = Config::from_env(); assert_eq!(Internal::inline(&cfg), r#"{ "t": "Newtype" } & unknown"#); - assert_eq!(Adjacent::inline(&cfg), r#"{ "t": "Newtype", "c": unknown }"#); + assert_eq!( + Adjacent::inline(&cfg), + r#"{ "t": "Newtype", "c": unknown }"# + ); }