From eec36e37f1e90eeea1838861d4763c83accf7f1c Mon Sep 17 00:00:00 2001 From: Tomi Saarinen Date: Mon, 9 Feb 2026 00:55:24 +0200 Subject: [PATCH 1/5] Implement support for ReScript language and add devcontainer to make development easier Implement support for ReScript language rescript support devcontainer lock integer types snapshot decoded variable name snapshot type alias snapshot and fix enum alias snapshot simple enum case serialize as string serialized as serde url serde 8601 serde default struct optional type alias char fix double optional fix type override comment unused code default decorators cleanup Improve ReScript support and add tests Add 2 rescript tests one more test one more test add test new test change Vec conversion remove unnecessary file add namespacing to anonymous structs add mention of ReScript into readme remove devcontainer config --- README.md | 1 + cli/src/args.rs | 2 + cli/src/config.rs | 8 + cli/src/main.rs | 10 +- cli/src/parse.rs | 1 + .../can_generate_algebraic_enum/output.res | 24 + .../output.res | 5 + .../output.res | 20 + .../can_generate_bare_string_enum/output.res | 7 + .../output.res | 4 + .../output.res | 8 + .../can_generate_generic_enum/output.res | 40 ++ .../can_generate_generic_struct/output.res | 18 + .../output.res | 4 + .../tests/can_generate_simple_enum/output.res | 11 + .../output.res | 13 + .../output.res | 4 + .../output.res | 5 + .../can_handle_anonymous_struct/output.res | 41 ++ .../output.res | 5 + .../tests/can_handle_serde_rename/output.res | 13 + .../can_handle_serde_rename_all/output.res | 16 + .../output.res | 13 + .../tests/can_handle_unit_type/output.res | 10 + core/data/tests/can_override_types/input.rs | 18 +- core/data/tests/can_override_types/output.res | 14 + .../output.res | 16 + .../output.res | 11 + .../tests/excluded_by_target_os/output.res | 50 ++ core/data/tests/generate_types/output.res | 16 + .../generate_types_with_keywords/output.res | 14 + .../tests/serialize_type_alias/output.res | 9 + core/data/tests/smart_pointers/output.res | 43 ++ .../output.res | 10 + .../tests/test_default_decorators/output.res | 4 + core/data/tests/test_generate_char/output.res | 4 + core/data/tests/test_i54_u53_type/output.res | 5 + .../tests/test_optional_type_alias/output.res | 9 + .../test_serde_default_struct/output.res | 4 + core/data/tests/test_serde_iso8601/output.res | 4 + core/data/tests/test_serde_url/output.res | 4 + core/data/tests/test_serialized_as/output.res | 5 + .../tests/test_serialized_as_tuple/output.res | 2 + .../output.res | 7 + core/data/tests/test_type_alias/output.res | 6 + .../output.res | 3 + .../use_correct_integer_types/output.res | 10 + core/src/language/mod.rs | 7 +- core/src/language/rescript.rs | 538 ++++++++++++++++++ core/tests/snapshot_tests.rs | 118 ++-- 50 files changed, 1168 insertions(+), 46 deletions(-) create mode 100644 core/data/tests/can_generate_algebraic_enum/output.res create mode 100644 core/data/tests/can_generate_algebraic_enum_with_skipped_variants/output.res create mode 100644 core/data/tests/can_generate_anonymous_struct_with_skipped_fields/output.res create mode 100644 core/data/tests/can_generate_bare_string_enum/output.res create mode 100644 core/data/tests/can_generate_double_option_pattern/output.res create mode 100644 core/data/tests/can_generate_empty_algebraic_enum/output.res create mode 100644 core/data/tests/can_generate_generic_enum/output.res create mode 100644 core/data/tests/can_generate_generic_struct/output.res create mode 100644 core/data/tests/can_generate_generic_type_alias/output.res create mode 100644 core/data/tests/can_generate_simple_enum/output.res create mode 100644 core/data/tests/can_generate_simple_struct_with_a_comment/output.res create mode 100644 core/data/tests/can_generate_slice_of_user_type/output.res create mode 100644 core/data/tests/can_generate_struct_with_skipped_fields/output.res create mode 100644 core/data/tests/can_handle_anonymous_struct/output.res create mode 100644 core/data/tests/can_handle_quote_in_serde_rename/output.res create mode 100644 core/data/tests/can_handle_serde_rename/output.res create mode 100644 core/data/tests/can_handle_serde_rename_all/output.res create mode 100644 core/data/tests/can_handle_serde_rename_on_top_level/output.res create mode 100644 core/data/tests/can_handle_unit_type/output.res create mode 100644 core/data/tests/can_override_types/output.res create mode 100644 core/data/tests/can_recognize_types_inside_modules/output.res create mode 100644 core/data/tests/enum_is_properly_named_with_serde_overrides/output.res create mode 100644 core/data/tests/excluded_by_target_os/output.res create mode 100644 core/data/tests/generate_types/output.res create mode 100644 core/data/tests/generate_types_with_keywords/output.res create mode 100644 core/data/tests/serialize_type_alias/output.res create mode 100644 core/data/tests/smart_pointers/output.res create mode 100644 core/data/tests/test_algebraic_enum_case_name_support/output.res create mode 100644 core/data/tests/test_default_decorators/output.res create mode 100644 core/data/tests/test_generate_char/output.res create mode 100644 core/data/tests/test_i54_u53_type/output.res create mode 100644 core/data/tests/test_optional_type_alias/output.res create mode 100644 core/data/tests/test_serde_default_struct/output.res create mode 100644 core/data/tests/test_serde_iso8601/output.res create mode 100644 core/data/tests/test_serde_url/output.res create mode 100644 core/data/tests/test_serialized_as/output.res create mode 100644 core/data/tests/test_serialized_as_tuple/output.res create mode 100644 core/data/tests/test_simple_enum_case_name_support/output.res create mode 100644 core/data/tests/test_type_alias/output.res create mode 100644 core/data/tests/use_correct_decoded_variable_name/output.res create mode 100644 core/data/tests/use_correct_integer_types/output.res create mode 100644 core/src/language/rescript.rs diff --git a/README.md b/README.md index 327d0976..741f15e0 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Are you getting weird deserialization issues? Did our procedural macro throw a c - Typescript - Go** - Python** (see list of limitations [here](https://github.com/1Password/typeshare/issues/217)) +- ReScript If there is a language that you want Typeshare to generate definitions for, you can either: diff --git a/cli/src/args.rs b/cli/src/args.rs index 05b79619..a165309f 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -12,6 +12,8 @@ pub enum AvailableLanguage { Go, #[cfg(feature = "python")] Python, + #[value(name = "rescript")] + ReScript, } #[derive(clap::Parser)] diff --git a/cli/src/config.rs b/cli/src/config.rs index e221648d..672e417f 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -62,6 +62,13 @@ pub struct GoParams { pub type_mappings: HashMap, } +#[derive(Default, Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(default)] +pub struct ReScriptParams { + pub type_mappings: HashMap, + pub default_decorators: Vec, +} + /// The parameters that are used to configure the behaviour of typeshare /// from the configuration file `typeshare.toml` #[derive(Serialize, Deserialize, Default, Debug, PartialEq)] @@ -75,6 +82,7 @@ pub(crate) struct Config { pub python: PythonParams, #[cfg(feature = "go")] pub go: GoParams, + pub rescript: ReScriptParams, #[serde(skip)] pub target_os: Vec, } diff --git a/cli/src/main.rs b/cli/src/main.rs index 1062ed2b..1de9b11a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,7 +27,9 @@ use typeshare_core::language::Go; use typeshare_core::language::Python; use typeshare_core::{ context::ParseContext, - language::{CrateName, Kotlin, Language, Scala, SupportedLanguage, Swift, TypeScript}, + language::{ + CrateName, Kotlin, Language, ReScript, Scala, SupportedLanguage, Swift, TypeScript, + }, parser::ParsedData, reconcile::reconcile_aliases, }; @@ -95,6 +97,7 @@ fn generate_types(config_file: Option<&Path>, options: &Args) -> anyhow::Result< args::AvailableLanguage::Go => SupportedLanguage::Go, #[cfg(feature = "python")] args::AvailableLanguage::Python => SupportedLanguage::Python, + args::AvailableLanguage::ReScript => SupportedLanguage::ReScript, }, }; @@ -236,6 +239,11 @@ fn language( SupportedLanguage::Python => { panic!("python support is currently experimental and must be enabled as a feature flag for typeshare-cli") } + SupportedLanguage::ReScript => Box::new(ReScript { + type_mappings: config.rescript.type_mappings, + default_decorators: config.rescript.default_decorators, + ..Default::default() + }), } } diff --git a/cli/src/parse.rs b/cli/src/parse.rs index 1a1aed54..87e8dd52 100644 --- a/cli/src/parse.rs +++ b/cli/src/parse.rs @@ -61,6 +61,7 @@ fn output_file_name(language_type: SupportedLanguage, crate_name: &CrateName) -> SupportedLanguage::Swift => pascal_case(), SupportedLanguage::TypeScript => snake_case(), SupportedLanguage::Python => snake_case(), + SupportedLanguage::ReScript => pascal_case(), } } diff --git a/core/data/tests/can_generate_algebraic_enum/output.res b/core/data/tests/can_generate_algebraic_enum/output.res new file mode 100644 index 00000000..4d65b8ad --- /dev/null +++ b/core/data/tests/can_generate_algebraic_enum/output.res @@ -0,0 +1,24 @@ +/** Struct comment */ +type itemDetailsFieldValue = { +} + +/** Enum comment */ +@tag("type") +type advancedColors = + /** This is a case comment */ + | @as("String") String({ content: string }) + | @as("Number") Number({ content: int }) + | @as("UnsignedNumber") UnsignedNumber({ content: int }) + | @as("NumberArray") NumberArray({ content: array }) + /** Comment on the last element */ + | @as("ReallyCoolType") ReallyCoolType({ content: itemDetailsFieldValue }) + +@tag("type") +type advancedColors2 = + /** This is a case comment */ + | @as("string") String({ content: string }) + | @as("number") Number({ content: int }) + | @as("number-array") NumberArray({ content: array }) + /** Comment on the last element */ + | @as("really-cool-type") ReallyCoolType({ content: itemDetailsFieldValue }) + diff --git a/core/data/tests/can_generate_algebraic_enum_with_skipped_variants/output.res b/core/data/tests/can_generate_algebraic_enum_with_skipped_variants/output.res new file mode 100644 index 00000000..7388d47b --- /dev/null +++ b/core/data/tests/can_generate_algebraic_enum_with_skipped_variants/output.res @@ -0,0 +1,5 @@ +@tag("type") +type SomeEnum = + | @as("A") A + | @as("C") C(int) + diff --git a/core/data/tests/can_generate_anonymous_struct_with_skipped_fields/output.res b/core/data/tests/can_generate_anonymous_struct_with_skipped_fields/output.res new file mode 100644 index 00000000..d6b02663 --- /dev/null +++ b/core/data/tests/can_generate_anonymous_struct_with_skipped_fields/output.res @@ -0,0 +1,20 @@ +/** Enum keeping track of who autofilled a field */ +/** This field was autofilled by us */ +type autofilledByUs = { + /** The UUID for the fill */ + uuid: string, +} + +/** Something else autofilled this field */ +type autofilledBySomethingElse = { + /** The UUID for the fill */ + uuid: string, +} + +@tag("type") +type autofilledBy = + /** This field was autofilled by us */ + | @as("Us") Us({ content: autofilledByUs }) + /** Something else autofilled this field */ + | @as("SomethingElse") SomethingElse({ content: autofilledBySomethingElse }) + diff --git a/core/data/tests/can_generate_bare_string_enum/output.res b/core/data/tests/can_generate_bare_string_enum/output.res new file mode 100644 index 00000000..49d3e747 --- /dev/null +++ b/core/data/tests/can_generate_bare_string_enum/output.res @@ -0,0 +1,7 @@ +/** This is a comment. */ +type colors = + | @as("Red") Red + | @as("Blue") Blue + | @as("Green") Green + + diff --git a/core/data/tests/can_generate_double_option_pattern/output.res b/core/data/tests/can_generate_double_option_pattern/output.res new file mode 100644 index 00000000..d45f9666 --- /dev/null +++ b/core/data/tests/can_generate_double_option_pattern/output.res @@ -0,0 +1,4 @@ +type someStruct = { + field_a?: option, +} + diff --git a/core/data/tests/can_generate_empty_algebraic_enum/output.res b/core/data/tests/can_generate_empty_algebraic_enum/output.res new file mode 100644 index 00000000..91727cd7 --- /dev/null +++ b/core/data/tests/can_generate_empty_algebraic_enum/output.res @@ -0,0 +1,8 @@ +type addressDetails = { +} + +@tag("type") +type address = + | @as("FixedAddress") FixedAddress({ content: addressDetails }) + | @as("NoFixedAddress") NoFixedAddress + diff --git a/core/data/tests/can_generate_generic_enum/output.res b/core/data/tests/can_generate_generic_enum/output.res new file mode 100644 index 00000000..f65b8e68 --- /dev/null +++ b/core/data/tests/can_generate_generic_enum/output.res @@ -0,0 +1,40 @@ +@tag("type") +type genericEnum<'a, 'b> = + | @as("VariantA") VariantA({ content: 'a }) + | @as("VariantB") VariantB({ content: 'b }) + +type structUsingGenericEnum = { + enum_field: genericEnum, +} + +@tag("type") +type genericEnumUsingGenericEnum<'t> = + | @as("VariantC") VariantC({ content: genericEnum<'t, 't> }) + | @as("VariantD") VariantD({ content: genericEnum> }) + | @as("VariantE") VariantE({ content: genericEnum }) + +type genericEnumsUsingStructVariantsVariantF<'t, 'u> = { + action: 't, +} + +type genericEnumsUsingStructVariantsVariantG<'t, 'u> = { + action: 't, + response: 'u, +} + +type genericEnumsUsingStructVariantsVariantH<'t, 'u> = { + non_generic: int, +} + +type genericEnumsUsingStructVariantsVariantI<'t, 'u> = { + vec: array<'t>, + action: myType<'t, 'u>, +} + +@tag("type") +type genericEnumsUsingStructVariants<'t, 'u> = + | @as("VariantF") VariantF({ content: genericEnumsUsingStructVariantsVariantF<'t, 'u> }) + | @as("VariantG") VariantG({ content: genericEnumsUsingStructVariantsVariantG<'t, 'u> }) + | @as("VariantH") VariantH({ content: genericEnumsUsingStructVariantsVariantH<'t, 'u> }) + | @as("VariantI") VariantI({ content: genericEnumsUsingStructVariantsVariantI<'t, 'u> }) + diff --git a/core/data/tests/can_generate_generic_struct/output.res b/core/data/tests/can_generate_generic_struct/output.res new file mode 100644 index 00000000..72b22ef4 --- /dev/null +++ b/core/data/tests/can_generate_generic_struct/output.res @@ -0,0 +1,18 @@ +type genericStruct<'a, 'b> = { + field_a: 'a, + field_b: array<'b>, +} + +type genericStructUsingGenericStruct<'t> = { + struct_field: genericStruct, + second_struct_field: genericStruct<'t, string>, + third_struct_field: genericStruct<'t, array<'t>>, +} + +@tag("type") +type enumUsingGenericStruct = + | @as("VariantA") VariantA({ content: genericStruct }) + | @as("VariantB") VariantB({ content: genericStruct }) + | @as("VariantC") VariantC({ content: genericStruct }) + | @as("VariantD") VariantD({ content: genericStructUsingGenericStruct }) + diff --git a/core/data/tests/can_generate_generic_type_alias/output.res b/core/data/tests/can_generate_generic_type_alias/output.res new file mode 100644 index 00000000..624d9978 --- /dev/null +++ b/core/data/tests/can_generate_generic_type_alias/output.res @@ -0,0 +1,4 @@ +type genericTypeAlias<'t> = array<'t> + +type nonGenericAlias = genericTypeAlias + diff --git a/core/data/tests/can_generate_simple_enum/output.res b/core/data/tests/can_generate_simple_enum/output.res new file mode 100644 index 00000000..ed0df6e5 --- /dev/null +++ b/core/data/tests/can_generate_simple_enum/output.res @@ -0,0 +1,11 @@ +/** + * This is a comment. + * Continued lovingly here + */ +type colors = + | @as("Red") Red + | @as("Blue") Blue + /** Green is a cool color */ + | @as("Green") Green + + diff --git a/core/data/tests/can_generate_simple_struct_with_a_comment/output.res b/core/data/tests/can_generate_simple_struct_with_a_comment/output.res new file mode 100644 index 00000000..7bee5c73 --- /dev/null +++ b/core/data/tests/can_generate_simple_struct_with_a_comment/output.res @@ -0,0 +1,13 @@ +type location = { +} + +/** This is a comment. */ +type person = { + /** This is another comment */ + name: string, + age: int, + info?: string, + emails: array, + location: location, +} + diff --git a/core/data/tests/can_generate_slice_of_user_type/output.res b/core/data/tests/can_generate_slice_of_user_type/output.res new file mode 100644 index 00000000..6402e82a --- /dev/null +++ b/core/data/tests/can_generate_slice_of_user_type/output.res @@ -0,0 +1,4 @@ +type video = { + tags: array, +} + diff --git a/core/data/tests/can_generate_struct_with_skipped_fields/output.res b/core/data/tests/can_generate_struct_with_skipped_fields/output.res new file mode 100644 index 00000000..624164af --- /dev/null +++ b/core/data/tests/can_generate_struct_with_skipped_fields/output.res @@ -0,0 +1,5 @@ +type myStruct = { + a: int, + c: int, +} + diff --git a/core/data/tests/can_handle_anonymous_struct/output.res b/core/data/tests/can_handle_anonymous_struct/output.res new file mode 100644 index 00000000..8867e560 --- /dev/null +++ b/core/data/tests/can_handle_anonymous_struct/output.res @@ -0,0 +1,41 @@ +/** Enum keeping track of who autofilled a field */ +/** This field was autofilled by us */ +type autofilledByUs = { + /** The UUID for the fill */ + uuid: string, +} + +/** Something else autofilled this field */ +type autofilledBySomethingElse = { + /** The UUID for the fill */ + uuid: string, + /** Some other thing */ + thing: int, +} + +@tag("type") +type autofilledBy = + /** This field was autofilled by us */ + | @as("Us") Us({ content: autofilledByUs }) + /** Something else autofilled this field */ + | @as("SomethingElse") SomethingElse({ content: autofilledBySomethingElse }) + +/** This is a comment (yareek sameek wuz here) */ +type enumWithManyVariantsAnonVariant = { + uuid: string, +} + +type enumWithManyVariantsAnotherAnonVariant = { + uuid: string, + thing: int, +} + +@tag("type") +type enumWithManyVariants = + | @as("UnitVariant") UnitVariant + | @as("TupleVariantString") TupleVariantString({ content: string }) + | @as("AnonVariant") AnonVariant({ content: enumWithManyVariantsAnonVariant }) + | @as("TupleVariantInt") TupleVariantInt({ content: int }) + | @as("AnotherUnitVariant") AnotherUnitVariant + | @as("AnotherAnonVariant") AnotherAnonVariant({ content: enumWithManyVariantsAnotherAnonVariant }) + diff --git a/core/data/tests/can_handle_quote_in_serde_rename/output.res b/core/data/tests/can_handle_quote_in_serde_rename/output.res new file mode 100644 index 00000000..416b5d02 --- /dev/null +++ b/core/data/tests/can_handle_quote_in_serde_rename/output.res @@ -0,0 +1,5 @@ +/** This is a comment. */ +type colors = + | @as("Green\"") Green + + diff --git a/core/data/tests/can_handle_serde_rename/output.res b/core/data/tests/can_handle_serde_rename/output.res new file mode 100644 index 00000000..5210cb6c --- /dev/null +++ b/core/data/tests/can_handle_serde_rename/output.res @@ -0,0 +1,13 @@ +type otherType = { +} + +/** This is a comment. */ +type person = { + name: string, + age: int, + extraSpecialFieldOne: int, + extraSpecialFieldTwo?: array, + nonStandardDataType: otherType, + nonStandardDataTypeInArray?: array, +} + diff --git a/core/data/tests/can_handle_serde_rename_all/output.res b/core/data/tests/can_handle_serde_rename_all/output.res new file mode 100644 index 00000000..feafa898 --- /dev/null +++ b/core/data/tests/can_handle_serde_rename_all/output.res @@ -0,0 +1,16 @@ +/** This is a Person struct with camelCase rename */ +type person = { + firstName: string, + lastName: string, + age: int, + extraSpecialField1: int, + extraSpecialField2?: array, +} + +/** This is a Person2 struct with UPPERCASE rename */ +type person2 = { + FIRST_NAME: string, + LAST_NAME: string, + AGE: int, +} + diff --git a/core/data/tests/can_handle_serde_rename_on_top_level/output.res b/core/data/tests/can_handle_serde_rename_on_top_level/output.res new file mode 100644 index 00000000..82b1c742 --- /dev/null +++ b/core/data/tests/can_handle_serde_rename_on_top_level/output.res @@ -0,0 +1,13 @@ +type otherType = { +} + +/** This is a comment. */ +type personTwo = { + name: string, + age: int, + extraSpecialFieldOne: int, + extraSpecialFieldTwo?: array, + nonStandardDataType: otherType, + nonStandardDataTypeInArray?: array, +} + diff --git a/core/data/tests/can_handle_unit_type/output.res b/core/data/tests/can_handle_unit_type/output.res new file mode 100644 index 00000000..7bd29223 --- /dev/null +++ b/core/data/tests/can_handle_unit_type/output.res @@ -0,0 +1,10 @@ +/** This struct has a unit field */ +type structHasVoidType = { + thisIsAUnit: unit, +} + +/** This enum has a variant associated with unit data */ +@tag("type") +type enumHasVoidType = + | @as("hasAUnit") HasAUnit({ content: unit }) + diff --git a/core/data/tests/can_override_types/input.rs b/core/data/tests/can_override_types/input.rs index 994cc9aa..1d9c9d36 100644 --- a/core/data/tests/can_override_types/input.rs +++ b/core/data/tests/can_override_types/input.rs @@ -5,8 +5,10 @@ struct OverrideStruct { #[typeshare( swift(type = "Int"), typescript(readonly, type = "any | undefined"), - kotlin(type = "Int"), go(type = "uint"), - scala(type = "Short") + kotlin(type = "Int"), + go(type = "uint"), + scala(type = "Short"), + rescript(type = "int") )] field_to_override: String, } @@ -21,9 +23,11 @@ enum OverrideEnum { #[typeshare( swift(type = "Int"), typescript(readonly, type = "any | undefined"), - kotlin(type = "Int"), go(type = "uint"), - scala(type = "Short") + kotlin(type = "Int"), + go(type = "uint"), + scala(type = "Short"), + rescript(type = "int") )] - field_to_override: String - } -} \ No newline at end of file + field_to_override: String, + }, +} diff --git a/core/data/tests/can_override_types/output.res b/core/data/tests/can_override_types/output.res new file mode 100644 index 00000000..660ea24c --- /dev/null +++ b/core/data/tests/can_override_types/output.res @@ -0,0 +1,14 @@ +type overrideStruct = { + fieldToOverride: int, +} + +type overrideEnumAnonymousStructVariant = { + fieldToOverride: int, +} + +@tag("type") +type overrideEnum = + | @as("UnitVariant") UnitVariant + | @as("TupleVariant") TupleVariant({ content: string }) + | @as("AnonymousStructVariant") AnonymousStructVariant({ content: overrideEnumAnonymousStructVariant }) + diff --git a/core/data/tests/can_recognize_types_inside_modules/output.res b/core/data/tests/can_recognize_types_inside_modules/output.res new file mode 100644 index 00000000..dc479144 --- /dev/null +++ b/core/data/tests/can_recognize_types_inside_modules/output.res @@ -0,0 +1,16 @@ +type a = { + field: int, +} + +type ab = { + field: int, +} + +type abc = { + field: int, +} + +type outsideOfModules = { + field: int, +} + diff --git a/core/data/tests/enum_is_properly_named_with_serde_overrides/output.res b/core/data/tests/enum_is_properly_named_with_serde_overrides/output.res new file mode 100644 index 00000000..deef59bd --- /dev/null +++ b/core/data/tests/enum_is_properly_named_with_serde_overrides/output.res @@ -0,0 +1,11 @@ +/** + * This is a comment. + * Continued lovingly here + */ +type colors = + | @as("red") Red + | @as("blue") Blue + /** Green is a cool color */ + | @as("green-like") Green + + diff --git a/core/data/tests/excluded_by_target_os/output.res b/core/data/tests/excluded_by_target_os/output.res new file mode 100644 index 00000000..f56d4935 --- /dev/null +++ b/core/data/tests/excluded_by_target_os/output.res @@ -0,0 +1,50 @@ +/** + * A struct with no target_os. Should be generated when + * we use --target-os. + */ +type alwaysAccept = { +} + +type definedTwice = { + field1: string, +} + +type excluded = { +} + +type manyStruct = { +} + +type multipleTargets = { +} + +type nestedNotTarget1 = { +} + +type otherExcluded = { +} + +type alwaysAcceptEnum = + | @as("Variant1") Variant1 + | @as("Variant2") Variant2 + + +type someEnum + + +type testEnumVariant7 = { + field1: string, +} + +type testEnumVariant9 = { + field2: string, +} + +@tag("type") +type testEnum = + | @as("Variant1") Variant1 + | @as("Variant5") Variant5 + | @as("Variant7") Variant7({ content: testEnumVariant7 }) + | @as("Variant8") Variant8 + | @as("Variant9") Variant9({ content: testEnumVariant9 }) + diff --git a/core/data/tests/generate_types/output.res b/core/data/tests/generate_types/output.res new file mode 100644 index 00000000..a0b4382c --- /dev/null +++ b/core/data/tests/generate_types/output.res @@ -0,0 +1,16 @@ +type customType = { +} + +type types = { + s: string, + static_s: string, + int8: int, + float: float, + double: float, + array: array, + fixed_length_array: array /* length: 4 */, + dictionary: Dict.t, + optional_dictionary?: Dict.t, + custom_type: customType, +} + diff --git a/core/data/tests/generate_types_with_keywords/output.res b/core/data/tests/generate_types_with_keywords/output.res new file mode 100644 index 00000000..00a47a97 --- /dev/null +++ b/core/data/tests/generate_types_with_keywords/output.res @@ -0,0 +1,14 @@ +type catch = { + default: string, + case: string, +} + +@tag("type") +type \"switch" = + | @as("default") Default({ content: catch }) + +type throws = + | @as("case") Case + | @as("default") Default + + diff --git a/core/data/tests/serialize_type_alias/output.res b/core/data/tests/serialize_type_alias/output.res new file mode 100644 index 00000000..b49d7d59 --- /dev/null +++ b/core/data/tests/serialize_type_alias/output.res @@ -0,0 +1,9 @@ +type uuid = string + +/** Unique identifier for an Account */ +type accountUuid = uuid + +type alsoString = string + +type itemUuid = string + diff --git a/core/data/tests/smart_pointers/output.res b/core/data/tests/smart_pointers/output.res new file mode 100644 index 00000000..7c1ef48b --- /dev/null +++ b/core/data/tests/smart_pointers/output.res @@ -0,0 +1,43 @@ +/** This is a comment. */ +type arcyColors = { + red: int, + blue: string, + green: array, +} + +/** This is a comment. */ +type cellyColors = { + red: string, + blue: array, +} + +/** This is a comment. */ +type cowyColors = { + lifetime: string, +} + +/** This is a comment. */ +type lockyColors = { + red: string, +} + +/** This is a comment. */ +type mutexyColors = { + blue: array, + green: string, +} + +/** This is a comment. */ +type rcyColors = { + red: string, + blue: array, + green: string, +} + +/** This is a comment. */ +@tag("type") +type boxyColors = + | @as("Red") Red + | @as("Blue") Blue + | @as("Green") Green({ content: string }) + diff --git a/core/data/tests/test_algebraic_enum_case_name_support/output.res b/core/data/tests/test_algebraic_enum_case_name_support/output.res new file mode 100644 index 00000000..e49d3486 --- /dev/null +++ b/core/data/tests/test_algebraic_enum_case_name_support/output.res @@ -0,0 +1,10 @@ +type itemDetailsFieldValue = { +} + +@tag("type") +type advancedColors = + | @as("string") String({ content: string }) + | @as("number") Number({ content: int }) + | @as("number-array") NumberArray({ content: array }) + | @as("reallyCoolType") ReallyCoolType({ content: itemDetailsFieldValue }) + diff --git a/core/data/tests/test_default_decorators/output.res b/core/data/tests/test_default_decorators/output.res new file mode 100644 index 00000000..1cab0d29 --- /dev/null +++ b/core/data/tests/test_default_decorators/output.res @@ -0,0 +1,4 @@ +@schema +type emptyType = { +} + diff --git a/core/data/tests/test_generate_char/output.res b/core/data/tests/test_generate_char/output.res new file mode 100644 index 00000000..e9888afb --- /dev/null +++ b/core/data/tests/test_generate_char/output.res @@ -0,0 +1,4 @@ +type myType = { + field: char, +} + diff --git a/core/data/tests/test_i54_u53_type/output.res b/core/data/tests/test_i54_u53_type/output.res new file mode 100644 index 00000000..f7317410 --- /dev/null +++ b/core/data/tests/test_i54_u53_type/output.res @@ -0,0 +1,5 @@ +type foo = { + a: int, + b: int, +} + diff --git a/core/data/tests/test_optional_type_alias/output.res b/core/data/tests/test_optional_type_alias/output.res new file mode 100644 index 00000000..a84c5b4f --- /dev/null +++ b/core/data/tests/test_optional_type_alias/output.res @@ -0,0 +1,9 @@ +type optionalU16 = option + +type optionalU32 = option + +type fooBar = { + foo: optionalU32, + bar: optionalU16, +} + diff --git a/core/data/tests/test_serde_default_struct/output.res b/core/data/tests/test_serde_default_struct/output.res new file mode 100644 index 00000000..08fd4a67 --- /dev/null +++ b/core/data/tests/test_serde_default_struct/output.res @@ -0,0 +1,4 @@ +type foo = { + bar?: bool, +} + diff --git a/core/data/tests/test_serde_iso8601/output.res b/core/data/tests/test_serde_iso8601/output.res new file mode 100644 index 00000000..f3ebeab2 --- /dev/null +++ b/core/data/tests/test_serde_iso8601/output.res @@ -0,0 +1,4 @@ +type foo = { + time: string, +} + diff --git a/core/data/tests/test_serde_url/output.res b/core/data/tests/test_serde_url/output.res new file mode 100644 index 00000000..8094133d --- /dev/null +++ b/core/data/tests/test_serde_url/output.res @@ -0,0 +1,4 @@ +type foo = { + url: string, +} + diff --git a/core/data/tests/test_serialized_as/output.res b/core/data/tests/test_serialized_as/output.res new file mode 100644 index 00000000..3cf73791 --- /dev/null +++ b/core/data/tests/test_serialized_as/output.res @@ -0,0 +1,5 @@ +type itemId = string + +/** Options that you could pick */ +type options = string + diff --git a/core/data/tests/test_serialized_as_tuple/output.res b/core/data/tests/test_serialized_as_tuple/output.res new file mode 100644 index 00000000..9b20a947 --- /dev/null +++ b/core/data/tests/test_serialized_as_tuple/output.res @@ -0,0 +1,2 @@ +type itemId = string + diff --git a/core/data/tests/test_simple_enum_case_name_support/output.res b/core/data/tests/test_simple_enum_case_name_support/output.res new file mode 100644 index 00000000..cb79bc57 --- /dev/null +++ b/core/data/tests/test_simple_enum_case_name_support/output.res @@ -0,0 +1,7 @@ +/** This is a comment. */ +type colors = + | @as("red") Red + | @as("blue-ish") Blue + | @as("Green") Green + + diff --git a/core/data/tests/test_type_alias/output.res b/core/data/tests/test_type_alias/output.res new file mode 100644 index 00000000..464dc320 --- /dev/null +++ b/core/data/tests/test_type_alias/output.res @@ -0,0 +1,6 @@ +type bar = string + +type foo = { + bar: bar, +} + diff --git a/core/data/tests/use_correct_decoded_variable_name/output.res b/core/data/tests/use_correct_decoded_variable_name/output.res new file mode 100644 index 00000000..81bf4ec3 --- /dev/null +++ b/core/data/tests/use_correct_decoded_variable_name/output.res @@ -0,0 +1,3 @@ +type myEmptyStruct = { +} + diff --git a/core/data/tests/use_correct_integer_types/output.res b/core/data/tests/use_correct_integer_types/output.res new file mode 100644 index 00000000..44f140a5 --- /dev/null +++ b/core/data/tests/use_correct_integer_types/output.res @@ -0,0 +1,10 @@ +/** This is a comment. */ +type foo = { + a: int, + b: int, + c: int, + e: int, + f: int, + g: int, +} + diff --git a/core/src/language/mod.rs b/core/src/language/mod.rs index e92dfc05..f1743c58 100644 --- a/core/src/language/mod.rs +++ b/core/src/language/mod.rs @@ -22,6 +22,7 @@ use std::{ mod go; mod kotlin; mod python; +mod rescript; mod scala; mod swift; mod typescript; @@ -29,6 +30,7 @@ mod typescript; pub use go::Go; pub use kotlin::Kotlin; pub use python::Python; +pub use rescript::ReScript; pub use scala::Scala; pub use swift::GenericConstraints; pub use swift::Swift; @@ -103,13 +105,14 @@ pub enum SupportedLanguage { Swift, TypeScript, Python, + ReScript, } impl SupportedLanguage { /// Returns an iterator over all supported language variants. pub fn all_languages() -> impl Iterator { use SupportedLanguage::*; - [Go, Kotlin, Scala, Swift, TypeScript, Python].into_iter() + [Go, Kotlin, Scala, Swift, TypeScript, Python, ReScript].into_iter() } /// Get the file name extension for the supported language. @@ -121,6 +124,7 @@ impl SupportedLanguage { SupportedLanguage::Swift => "swift", SupportedLanguage::TypeScript => "ts", SupportedLanguage::Python => "py", + SupportedLanguage::ReScript => "res", } } } @@ -136,6 +140,7 @@ impl FromStr for SupportedLanguage { "swift" => Ok(Self::Swift), "typescript" => Ok(Self::TypeScript), "python" => Ok(Self::Python), + "rescript" => Ok(Self::ReScript), _ => Err(ParseError::UnsupportedLanguage(s.into())), } } diff --git a/core/src/language/rescript.rs b/core/src/language/rescript.rs new file mode 100644 index 00000000..8ab17b35 --- /dev/null +++ b/core/src/language/rescript.rs @@ -0,0 +1,538 @@ +use crate::rust_types::Id; +use crate::RenameExt; +use crate::{ + language::{Language, SupportedLanguage}, + parser::ParsedData, + rust_types::{ + RustConst, RustConstExpr, RustEnum, RustEnumVariant, RustField, RustStruct, RustType, + RustTypeAlias, RustTypeFormatError, SpecialRustType, + }, +}; +use itertools::Itertools; +use std::{ + borrow::Cow, + collections::HashMap, + io::{self, Write}, +}; + +use super::ScopedCrateTypes; + +// Source: https://rescript-lang.org/docs/manual/reserved-keywords/ +const RESCRIPT_KEYWORDS: &[&str] = &[ + "and", + "as", + "assert", + "constraint", + "else", + "exception", + "external", + "false", + "for", + "if", + "in", + "include", + "lazy", + "let", + "module", + "mutable", + "of", + "open", + "rec", + "switch", + "true", + "try", + "type", + "when", + "while", + "with", +]; + +/// All information needed to generate ReScript type-code +#[derive(Default)] +pub struct ReScript { + /// Mappings from Rust type names to ReScript type names + pub type_mappings: HashMap, + /// Default decorators that will be applied to all typeshared types + pub default_decorators: Vec, + /// Whether or not to exclude the version header that normally appears at the top of generated code. + /// If you aren't generating a snapshot test, this setting can just be left as a default (false) + pub no_version_header: bool, +} + +impl Language for ReScript { + fn type_map(&mut self) -> &HashMap { + &self.type_mappings + } + + fn end_file(&mut self, _w: &mut dyn Write) -> std::io::Result<()> { + Ok(()) + } + + /// Format a simple type with no generic parameters. + /// Note that we still need to take a list of generic types in case the implementors + /// need to differentiate between a user-defined type and a generic type (for example: Swift) + #[allow(clippy::ptr_arg)] + fn format_simple_type( + &mut self, + base: &String, + generic_types: &[String], + ) -> Result { + if let Some(mapped) = self.type_map().get(base) { + Ok(mapped.into()) + } else { + let base_camel = base.to_camel_case(); + // If this is a generic type parameter (i.e., it's in the generic_types list), + // prefix it with an apostrophe as required by ReScript + if generic_types.contains(base) { + Ok(format!("'{}", base_camel)) + } else { + Ok(base_camel) + } + } + } + + fn format_special_type( + &mut self, + special_ty: &SpecialRustType, + generic_types: &[String], + ) -> Result { + if let Some(mapped) = self.type_mappings.get(&special_ty.to_string()) { + // if self.custom_translations(mapped).is_some() { + // self.types_for_custom_json_translation + // .insert(mapped.to_string(), BTreeSet::new()); + // } + return Ok(mapped.to_owned()); + } + match special_ty { + SpecialRustType::Vec(rtype) => Ok(format!( + "array<{}>", + self.format_type(rtype, generic_types)? + )), + SpecialRustType::Array(rtype, len) => { + // ReScript doesn't have a fixed-length array type, so we just use a regular array type and add a comment about the length + let formatted_type = self.format_type(rtype, generic_types)?; + Ok(format!("array<{}> /* length: {} */", formatted_type, len)) + } + SpecialRustType::Slice(rtype) => Ok(format!( + "array<{}>", + self.format_type(rtype, generic_types)? + )), + // We add optionality above the type formatting level + SpecialRustType::Option(rtype) => self.format_type(rtype, generic_types), + SpecialRustType::HashMap(rtype1, rtype2) => { + let _hashtype = match rtype1.as_ref() { + RustType::Simple { id } if generic_types.contains(id) => { + return Err(RustTypeFormatError::GenericKeyForbiddenInTS(id.clone())); + } + _ => self.format_type(rtype1, generic_types)?, + }; + Ok(format!( + "Dict.t<{}>", + self.format_type(rtype2, generic_types)? + )) + } + SpecialRustType::Unit => Ok("unit".into()), + SpecialRustType::DateTime => Ok("Date.t".into()), + SpecialRustType::String => Ok("string".into()), + SpecialRustType::Char => Ok("char".into()), + SpecialRustType::I8 + | SpecialRustType::U8 + | SpecialRustType::I16 + | SpecialRustType::U16 + | SpecialRustType::I32 + | SpecialRustType::U32 + | SpecialRustType::I54 + | SpecialRustType::U53 => Ok("int".into()), + SpecialRustType::F32 | SpecialRustType::F64 => Ok("float".into()), + SpecialRustType::Bool => Ok("bool".into()), + SpecialRustType::U64 + | SpecialRustType::I64 + | SpecialRustType::ISize + | SpecialRustType::USize => { + panic!("64 bit types not allowed in Typeshare") + } + } + } + + fn begin_file(&mut self, w: &mut dyn Write, _parsed_data: &ParsedData) -> io::Result<()> { + if !self.no_version_header { + writeln!(w, "/*")?; + writeln!(w, " Generated by typeshare {}", env!("CARGO_PKG_VERSION"))?; + writeln!(w, "*/")?; + writeln!(w)?; + } + Ok(()) + } + + fn write_type_alias(&mut self, w: &mut dyn Write, ty: &RustTypeAlias) -> io::Result<()> { + self.write_comments(w, 0, &ty.comments)?; + + let r#type = self + .format_type(&ty.r#type, ty.generic_types.as_slice()) + .map_err(io::Error::other)?; + + // Apply default decorators if any + if !self.default_decorators.is_empty() { + writeln!(w, "{}", self.default_decorators.join(" "))?; + } + + writeln!( + w, + "type {}{} = {}\n", + rescript_keyword_aware_rename(ty.id.renamed.to_camel_case()), + if !ty.generic_types.is_empty() { + format!( + "<{}>", + ty.generic_types + .iter() + .map(|s| format!("'{}", s.to_camel_case())) + .join(", ") + ) + } else { + Default::default() + }, + if ty.r#type.is_optional() { + format!("option<{}>", r#type) + } else { + r#type + }, + )?; + + Ok(()) + } + + fn write_const(&mut self, w: &mut dyn Write, c: &RustConst) -> io::Result<()> { + match c.expr { + RustConstExpr::Int(val) => { + let const_type = self + .format_type(&c.r#type, &[]) + .map_err(std::io::Error::other)?; + writeln!( + w, + "export const {}: {} = {};", + c.id.renamed.to_snake_case().to_uppercase(), + const_type, + val + ) + } + } + } + + fn write_struct(&mut self, w: &mut dyn Write, rs: &RustStruct) -> io::Result<()> { + self.write_comments(w, 0, &rs.comments)?; + + // Apply default decorators if any + if !self.default_decorators.is_empty() { + writeln!(w, "{}", self.default_decorators.join(" "))?; + } + + let type_name = rs.id.renamed.to_camel_case(); + let generic_parameters = if !rs.generic_types.is_empty() { + format!( + "<{}>", + rs.generic_types + .iter() + .map(|s| format!("'{}", s.to_camel_case())) + .join(", ") + ) + } else { + Default::default() + }; + + writeln!( + w, + "type {}{} = {{", + rescript_keyword_aware_rename(type_name), + generic_parameters + )?; + // writeln!(w, "type t = {{")?; + + rs.fields + .iter() + .try_for_each(|f| self.write_field(w, f, rs.generic_types.as_slice()))?; + + // writeln!(w, "}}\n")?; + writeln!(w, "}}\n") + } + + fn write_enum(&mut self, w: &mut dyn Write, e: &RustEnum) -> io::Result<()> { + self.write_comments(w, 0, &e.shared().comments)?; + + let generic_parameters = if !e.shared().generic_types.is_empty() { + format!( + "<{}>", + e.shared() + .generic_types + .iter() + .map(|s| format!("'{}", s.to_camel_case())) + .join(", ") + ) + } else { + Default::default() + }; + + // Apply default decorators if any + if !self.default_decorators.is_empty() { + writeln!(w, "{}", self.default_decorators.join(" "))?; + } + + match e { + RustEnum::Unit(shared) => { + if e.shared().variants.len() == 0 { + // Special case: an empty enum + write!( + w, + "type {}{}", + rescript_keyword_aware_rename(shared.id.renamed.to_camel_case()), + generic_parameters + )?; + } else { + write!( + w, + "type {}{} = ", + rescript_keyword_aware_rename(shared.id.renamed.to_camel_case()), + generic_parameters + )?; + + self.write_enum_variants(w, e)?; + } + writeln!(w, "\n\n") + } + RustEnum::Algebraic { + shared, tag_key, .. + } => { + let parent_name = shared.id.renamed.to_camel_case(); + // Write internal structs before the actual type because rescript does not allow nested structs inside enums/variants + shared.variants.iter().try_for_each(|v| { + // writeln!(w)?; + // self.write_comments(w, 1, &v.shared().comments)?; + match v { + RustEnumVariant::Unit(_shared) => { + // Do nothing + } + RustEnumVariant::Tuple { ty: _, shared: _ } => { + // Do nothing + } + RustEnumVariant::AnonymousStruct { fields, shared } => { + if !self.default_decorators.is_empty() { + writeln!(w, "{}", self.default_decorators.join(" "))?; + } + self.write_struct( + w, + &RustStruct { + id: Id { + original: rescript_keyword_aware_rename(format!( + "{}{}", + parent_name, + shared.id.original.to_pascal_case() + )) + .to_string(), + renamed: rescript_keyword_aware_rename(format!( + "{}{}", + parent_name, + shared.id.renamed.to_pascal_case() + )) + .to_string(), + serde_rename: shared.id.serde_rename, + }, + decorators: HashMap::new(), + is_redacted: false, + comments: shared.comments.clone(), + fields: fields.to_vec(), + generic_types: e.shared().generic_types.clone(), + }, + )?; + } + } + io::Result::Ok(()) + })?; + + writeln!(w, "@tag(\"{}\")", tag_key)?; + write!( + w, + "type {}{} = ", + rescript_keyword_aware_rename(shared.id.renamed.to_camel_case()), + generic_parameters + )?; + + self.write_enum_variants(w, e)?; + + writeln!(w)?; + writeln!(w) + } + } + } + + fn write_imports( + &mut self, + _w: &mut dyn Write, + _imports: ScopedCrateTypes<'_>, + ) -> std::io::Result<()> { + // ReScript does not require import statements for the generated types. + Ok(()) + } + + fn ignored_reference_types(&self) -> Vec<&str> { + self.type_mappings.keys().map(|s| s.as_str()).collect() + } +} + +impl ReScript { + fn write_enum_variants(&mut self, w: &mut dyn Write, e: &RustEnum) -> io::Result<()> { + let parent_name = e.shared().id.original.to_camel_case(); + match e { + // Write all the unit variants out (there can only be unit variants in + // this case) + RustEnum::Unit(shared) => shared.variants.iter().try_for_each(|v| match v { + RustEnumVariant::Unit(shared) => { + writeln!(w)?; + self.write_comments(w, 1, &shared.comments)?; + write!( + w, + "\t| @as({:?}) {}", + &shared.id.renamed, + shared.id.original.to_pascal_case() + ) + } + _ => unreachable!(), + }), + + // Write all the algebraic variants out (all three variant types are possible + // here) + RustEnum::Algebraic { + tag_key: _, + content_key, + shared, + } => shared.variants.iter().try_for_each(|v| { + writeln!(w)?; + self.write_comments(w, 1, &v.shared().comments)?; + match v { + RustEnumVariant::Unit(shared) => { + write!( + w, + "\t| @as(\"{}\") {}", + shared.id.renamed, + shared.id.original.to_pascal_case() + ) + } + RustEnumVariant::Tuple { ty, shared } => { + let r#type = self + .format_type(ty, e.shared().generic_types.as_slice()) + .map_err(io::Error::other)?; + write!( + w, + "\t| @as(\"{}\") {}({{ {}{}: {} }})", + shared.id.renamed, + shared.id.original.to_pascal_case(), + content_key, + if ty.is_optional() { + "?" + } else { + Default::default() + }, + r#type + ) + } + RustEnumVariant::AnonymousStruct { fields, shared } => { + let generic_str = if e.shared().generic_types.as_slice().len() > 0 { + format!( + "<{}>", + e.shared() + .generic_types + .as_slice() + .iter() + .map(|s| format!("'{}", s.to_camel_case())) + .join(", ") + ) + } else { + Default::default() + }; + write!( + w, + "\t| @as(\"{}\") {}({{ {}: {}{} }})", + shared.id.renamed, + shared.id.original.to_pascal_case(), + content_key, + format!("{}{}", parent_name, shared.id.original.to_pascal_case()), + generic_str + ) + } + } + }), + } + } + + fn write_field( + &mut self, + w: &mut dyn Write, + field: &RustField, + generic_types: &[String], + ) -> io::Result<()> { + self.write_comments(w, 1, &field.comments)?; + let ts_ty: String = match field.type_override(SupportedLanguage::ReScript) { + Some(type_override) => type_override.to_owned(), + None => self + .format_type(&field.ty, generic_types) + .map_err(io::Error::other)?, + }; + let optional = field.ty.is_optional() || field.has_default; + let double_optional = field.ty.is_double_optional(); + writeln!( + w, + "\t{}{}: {},", + rescript_keyword_aware_rename(&field.id.renamed), + if optional { "?" } else { Default::default() }, + if double_optional { + format!("option<{}>", ts_ty) + } else { + ts_ty + } + )?; + + Ok(()) + } + + fn write_comments( + &mut self, + w: &mut dyn Write, + indent: usize, + comments: &[String], + ) -> io::Result<()> { + // Only attempt to write a comment if there are some, otherwise we're Ok() + if !comments.is_empty() { + let comment: String = { + let tab_indent = "\t".repeat(indent); + // If there's only one comment then keep it on the same line, otherwise we'll make a nice multi-line comment + if comments.len() == 1 { + format!("{}/** {} */", tab_indent, comments.first().unwrap()) + } else { + let joined_comments = comments.join(&format!("\n{tab_indent} * ")); + format!( + "{tab_indent}/** +{tab_indent} * {joined_comments} +{tab_indent} */" + ) + } + }; + writeln!(w, "{comment}")?; + } + Ok(()) + } +} + +fn rescript_keyword_aware_rename<'a, T>(name: T) -> Cow<'a, str> +where + T: Into>, +{ + let name = name.into(); + if RESCRIPT_KEYWORDS.contains(&name.as_ref()) { + Cow::Owned(format!("\\\"{name}\"")) + } else { + // If name contains hyphen + if name.contains('-') { + Cow::Owned(format!("\\\"{name}\"")) + } else { + name + } + } +} diff --git a/core/tests/snapshot_tests.rs b/core/tests/snapshot_tests.rs index 042684c7..1554453f 100644 --- a/core/tests/snapshot_tests.rs +++ b/core/tests/snapshot_tests.rs @@ -166,6 +166,9 @@ macro_rules! output_file_for_ident { (python) => { "output.py" }; + (rescript) => { + "output.res" + }; } /// Simplifies the construction of `Language` instances for each language. @@ -298,6 +301,22 @@ macro_rules! language_instance { ..Default::default() }) }; + + // Default ReScript + (rescript) => { + language_instance!(rescript { }) + }; + + // ReScript with configuration fields forwarded + (rescript {$($field:ident: $val:expr),* $(,)?}) => { + #[allow(clippy::needless_update)] + Box::new(typeshare_core::language::ReScript { + no_version_header: true, + $($field: $val,)* + ..Default::default() + }) + }; + } macro_rules! target_os { @@ -468,6 +487,17 @@ static PYTHON_MAPPINGS: Lazy> = Lazy::new(|| { .collect() }); +static RESCRIPT_MAPPINGS: Lazy> = Lazy::new(|| { + [ + ("Url", "string"), + ("DateTime", "string"), + ("Vec", "Uint8Array.t"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +}); + tests! { /// Enums can_generate_algebraic_enum: [ @@ -484,7 +514,8 @@ tests! { }, typescript, go, - python + python, + rescript ]; can_generate_generic_enum: [ swift { @@ -492,7 +523,8 @@ tests! { }, kotlin, scala, - typescript + typescript, + rescript, ]; can_generate_generic_struct: [ swift { @@ -502,6 +534,7 @@ tests! { kotlin, scala, typescript, + rescript, ]; can_generate_generic_type_alias: [ swift { @@ -509,10 +542,11 @@ tests! { }, kotlin, scala, - typescript + typescript, + rescript ]; can_generate_const: [typescript, go, python]; - can_generate_slice_of_user_type: [swift, kotlin, scala, typescript, go, python]; + can_generate_slice_of_user_type: [swift, kotlin, scala, typescript, go, python, rescript]; can_generate_readonly_fields: [ typescript ]; @@ -524,16 +558,17 @@ tests! { scala, typescript, go, - python + python, + rescript ]; - can_generate_bare_string_enum: [swift, kotlin, scala, typescript, go, python ]; + can_generate_bare_string_enum: [swift, kotlin, scala, typescript, go, python, rescript ]; can_generate_double_option_pattern: [ - typescript + typescript, rescript ]; can_recognize_types_inside_modules: [ - swift, kotlin, scala, typescript, go, python + swift, kotlin, scala, typescript, go, python, rescript ]; - test_simple_enum_case_name_support: [swift, kotlin, scala, typescript, go, python ]; + test_simple_enum_case_name_support: [swift, kotlin, scala, typescript, go, python, rescript]; test_algebraic_enum_case_name_support: [ swift { prefix: "OP".to_string(), @@ -548,16 +583,17 @@ tests! { }, typescript, go, - python + python, + rescript ]; can_apply_prefix_correctly: [ swift { prefix: "OP".to_string(), }, kotlin { prefix: "OP".to_string(), }, scala, typescript, go, python ]; - can_generate_empty_algebraic_enum: [ swift { prefix: "OP".to_string(), }, kotlin { prefix: "OP".to_string(), }, scala, typescript, go, python ]; + can_generate_empty_algebraic_enum: [ swift { prefix: "OP".to_string(), }, kotlin { prefix: "OP".to_string(), }, scala, typescript, go, python, rescript ]; can_generate_algebraic_enum_with_skipped_variants: [swift, kotlin, scala, typescript, go, python]; - can_generate_struct_with_skipped_fields: [swift, kotlin, scala, typescript, go, python]; - enum_is_properly_named_with_serde_overrides: [swift, kotlin, scala, typescript, go, python]; - can_handle_quote_in_serde_rename: [swift, kotlin, scala, typescript, go, python]; - can_handle_anonymous_struct: [swift, kotlin, scala, typescript, go, python]; - test_generate_char: [swift, kotlin, scala, typescript, go, python]; + can_generate_struct_with_skipped_fields: [swift, kotlin, scala, typescript, go, python, rescript]; + enum_is_properly_named_with_serde_overrides: [swift, kotlin, scala, typescript, go, python, rescript]; + can_handle_quote_in_serde_rename: [swift, kotlin, scala, typescript, go, python, rescript]; + can_handle_anonymous_struct: [swift, kotlin, scala, typescript, go, python, rescript]; + test_generate_char: [swift, kotlin, scala, typescript, go, python, rescript]; anonymous_struct_with_rename: [ swift { prefix: "Core".to_string(), @@ -568,11 +604,11 @@ tests! { go, python ]; - can_override_types: [swift, kotlin, scala, typescript, go]; + can_override_types: [swift, kotlin, scala, typescript, go, rescript]; /// Structs - can_generate_simple_struct_with_a_comment: [kotlin, swift, typescript, scala, go, python]; - generate_types: [kotlin, swift, typescript, scala, go, python]; + can_generate_simple_struct_with_a_comment: [kotlin, swift, typescript, scala, go, python, rescript]; + generate_types: [kotlin, swift, typescript, scala, go, python, rescript]; can_handle_serde_rename: [ swift { prefix: "TypeShareX_".to_string(), @@ -581,14 +617,15 @@ tests! { scala, typescript, go, - python + python, + rescript ]; // TODO: kotlin and typescript don't appear to support this yet generates_empty_structs_and_initializers: [swift, kotlin, scala, typescript, go,python]; - test_default_decorators: [swift { default_decorators: vec!["Sendable".into(), "Identifiable".into()]}]; + test_default_decorators: [swift { default_decorators: vec!["Sendable".into(), "Identifiable".into()]}, rescript { default_decorators: vec!["@schema".into()] }]; test_default_generic_constraints: [swift { default_generic_constraints: typeshare_core::language::GenericConstraints::from_config(vec!["Sendable".into(), "Identifiable".into()]) }]; - test_i54_u53_type: [swift, kotlin, scala, typescript, go, python]; - test_serde_default_struct: [swift, kotlin, scala, typescript, go, python]; + test_i54_u53_type: [swift, kotlin, scala, typescript, go, python, rescript]; + test_serde_default_struct: [swift, kotlin, scala, typescript, go, python, rescript]; test_serde_iso8601: [ swift { prefix: String::new(), @@ -612,6 +649,9 @@ tests! { }, python { type_mappings: super::PYTHON_MAPPINGS.clone(), + }, + rescript { + type_mappings: super::RESCRIPT_MAPPINGS.clone(), } ]; test_serde_url: [ @@ -638,11 +678,14 @@ tests! { }, python{ type_mappings: super::PYTHON_MAPPINGS.clone() + }, + rescript { + type_mappings: super::RESCRIPT_MAPPINGS.clone(), } ]; - test_type_alias: [ swift { prefix: "OP".to_string(), }, kotlin, scala, typescript, go, python ]; - test_optional_type_alias: [swift, kotlin, scala, typescript, go, python]; - test_serialized_as: [ swift { prefix: "OP".to_string(), }, kotlin, scala, typescript, go, python ]; + test_type_alias: [ swift { prefix: "OP".to_string(), }, kotlin, scala, typescript, go, python, rescript ]; + test_optional_type_alias: [swift, kotlin, scala, typescript, go, python, rescript]; + test_serialized_as: [ swift { prefix: "OP".to_string(), }, kotlin, scala, typescript, go, python, rescript ]; test_serialized_as_tuple: [ swift { prefix: "OP".to_string(), @@ -653,10 +696,11 @@ tests! { go { uppercase_acronyms: vec!["ID".to_string()], }, - python + python, + rescript ]; - can_handle_serde_rename_all: [swift, kotlin, scala, typescript, go,python]; - can_handle_serde_rename_on_top_level: [swift { prefix: "OP".to_string(), }, kotlin, scala, typescript, go, python]; + can_handle_serde_rename_all: [swift, kotlin, scala, typescript, go, python, rescript]; + can_handle_serde_rename_on_top_level: [swift { prefix: "OP".to_string(), }, kotlin, scala, typescript, go, python, rescript]; can_generate_unit_structs: [swift, kotlin, scala, typescript, go, python]; kebab_case_rename: [swift, kotlin, scala, typescript, go, python]; @@ -664,21 +708,21 @@ tests! { orders_types: [swift, kotlin, go, python]; /// Other - use_correct_integer_types: [swift, kotlin, scala, typescript, go, python]; + use_correct_integer_types: [swift, kotlin, scala, typescript, go, python, rescript]; // Only swift supports generating types with keywords - generate_types_with_keywords: [swift]; + generate_types_with_keywords: [swift, rescript]; // TODO: how is this different from generates_empty_structs_and_initializers? - use_correct_decoded_variable_name: [swift, kotlin, scala, typescript, go, python]; - can_handle_unit_type: [swift { codablevoid_constraints: vec!["Equatable".into()]} , kotlin, scala, typescript, go, python]; + use_correct_decoded_variable_name: [swift, kotlin, scala, typescript, go, python, rescript]; + can_handle_unit_type: [swift { codablevoid_constraints: vec!["Equatable".into()]} , kotlin, scala, typescript, go, python, rescript]; //3 tests for adding decorators to enums and structs const_enum_decorator: [ swift{ prefix: "OP".to_string(), } ]; algebraic_enum_decorator: [ swift{ prefix: "OP".to_string(), } ]; struct_decorator: [ kotlin, swift{ prefix: "OP".to_string(), } ]; serialize_field_as: [kotlin, swift, typescript, scala, go, python]; - serialize_type_alias: [kotlin, swift, typescript, scala, go, python]; + serialize_type_alias: [kotlin, swift, typescript, scala, go, python, rescript]; serialize_anonymous_field_as: [kotlin, swift, typescript, scala, go, python]; - smart_pointers: [kotlin, swift, typescript, scala, go, python]; + smart_pointers: [kotlin, swift, typescript, scala, go, python, rescript]; recursive_enum_decorator: [kotlin, swift, typescript, scala, go, python]; uppercase_go_acronyms: [ @@ -696,9 +740,9 @@ tests! { go, python ]; - can_generate_anonymous_struct_with_skipped_fields: [swift, kotlin, scala, typescript, go, python]; + can_generate_anonymous_struct_with_skipped_fields: [swift, kotlin, scala, typescript, go, python, rescript]; generic_struct_with_constraints_and_decorators: [swift { codablevoid_constraints: vec!["Equatable".into()] }]; - excluded_by_target_os: [ swift, kotlin, scala, typescript, go,python ] target_os: ["android", "macos"]; + excluded_by_target_os: [ swift, kotlin, scala, typescript, go, python, rescript ] target_os: ["android", "macos"]; // excluded_by_target_os_full_module: [swift] target_os: "ios"; serde_rename_references: [ swift, kotlin, scala, typescript, go ]; test_custom_serialize_deserialize_functions: [ go From 324fd25b3ce1d6b60544f3ad2934ebdbf39595c2 Mon Sep 17 00:00:00 2001 From: Tomi Saarinen Date: Wed, 2 Sep 2026 13:38:21 +0300 Subject: [PATCH 2/5] decorator support --- core/src/language/rescript.rs | 24 +++++++++++++++++++++--- core/src/parser.rs | 2 ++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/core/src/language/rescript.rs b/core/src/language/rescript.rs index 8ab17b35..8ace35f7 100644 --- a/core/src/language/rescript.rs +++ b/core/src/language/rescript.rs @@ -2,7 +2,7 @@ use crate::rust_types::Id; use crate::RenameExt; use crate::{ language::{Language, SupportedLanguage}, - parser::ParsedData, + parser::{DecoratorKind, ParsedData}, rust_types::{ RustConst, RustConstExpr, RustEnum, RustEnumVariant, RustField, RustStruct, RustType, RustTypeAlias, RustTypeFormatError, SpecialRustType, @@ -271,9 +271,27 @@ impl Language for ReScript { Default::default() }; + // If there are no decorators found for this struct, still write `Codable` and default decorators for structs + // Check if this struct's decorators contains swift in the hashmap + let decs = if let Some(rescript_decs) = e.shared().decorators.get(&DecoratorKind::ReScript) + { + // For reach item in the received decorators in the typeshared struct add it to the original vector + // this avoids duplicated of `Codable` without needing to `.sort()` then `.dedup()` + // Note: the list received from `rs.decorators` is already deduped + let combined_decorators: Vec = self + .default_decorators + .iter() + .chain(rescript_decs.iter()) + .cloned() + .collect(); + combined_decorators.join(" ") + } else { + self.default_decorators.join(" ") + }; + // Apply default decorators if any - if !self.default_decorators.is_empty() { - writeln!(w, "{}", self.default_decorators.join(" "))?; + if !decs.is_empty() { + writeln!(w, "{}", decs)?; } match e { diff --git a/core/src/parser.rs b/core/src/parser.rs index a87f1414..4e83fd15 100644 --- a/core/src/parser.rs +++ b/core/src/parser.rs @@ -36,6 +36,7 @@ pub enum DecoratorKind { SwiftGenericConstraints, /// The typeshare attribute for kotlin "kotlin" Kotlin, + ReScript, } impl DecoratorKind { @@ -45,6 +46,7 @@ impl DecoratorKind { DecoratorKind::Swift => "swift", DecoratorKind::SwiftGenericConstraints => "swiftGenericConstraints", DecoratorKind::Kotlin => "kotlin", + DecoratorKind::ReScript => "rescript", } } } From a3aceab3bfc3e2c6d6801ddd2ef2a1c157aed932 Mon Sep 17 00:00:00 2001 From: Tomi Saarinen Date: Wed, 2 Sep 2026 15:16:55 +0300 Subject: [PATCH 3/5] support for decorators --- core/data/tests/const_enum_decorator/input.rs | 5 ++++ .../tests/const_enum_decorator/output.res | 25 +++++++++++++++++++ .../tests/const_enum_decorator/output.swift | 4 +++ core/src/parser.rs | 1 + core/tests/snapshot_tests.rs | 2 +- 5 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 core/data/tests/const_enum_decorator/output.res diff --git a/core/data/tests/const_enum_decorator/input.rs b/core/data/tests/const_enum_decorator/input.rs index 2248020d..c1289777 100644 --- a/core/data/tests/const_enum_decorator/input.rs +++ b/core/data/tests/const_enum_decorator/input.rs @@ -21,3 +21,8 @@ pub enum BestHockeyTeams3 { pub enum BestHockeyTeams4 { PittsburghPenguins, } + +#[typeshare(rescript = "@unboxed")] +pub enum BestHockeyTeams5 { + PittsburghPenguins, +} diff --git a/core/data/tests/const_enum_decorator/output.res b/core/data/tests/const_enum_decorator/output.res new file mode 100644 index 00000000..77d02281 --- /dev/null +++ b/core/data/tests/const_enum_decorator/output.res @@ -0,0 +1,25 @@ +type bestHockeyTeams = + | @as("PittsburghPenguins") PittsburghPenguins + + +type bestHockeyTeams1 = + | @as("PittsburghPenguins") PittsburghPenguins + + +type bestHockeyTeams2 = + | @as("PittsburghPenguins") PittsburghPenguins + + +type bestHockeyTeams3 = + | @as("PittsburghPenguins") PittsburghPenguins + + +type bestHockeyTeams4 = + | @as("PittsburghPenguins") PittsburghPenguins + + +@unboxed +type bestHockeyTeams5 = + | @as("PittsburghPenguins") PittsburghPenguins + + diff --git a/core/data/tests/const_enum_decorator/output.swift b/core/data/tests/const_enum_decorator/output.swift index fbf15cb8..3fb4f04c 100644 --- a/core/data/tests/const_enum_decorator/output.swift +++ b/core/data/tests/const_enum_decorator/output.swift @@ -19,3 +19,7 @@ public enum OPBestHockeyTeams3: String, Codable { public enum OPBestHockeyTeams4: String, Codable, Equatable, Hashable { case pittsburghPenguins = "PittsburghPenguins" } + +public enum OPBestHockeyTeams5: String, Codable { + case pittsburghPenguins = "PittsburghPenguins" +} diff --git a/core/src/parser.rs b/core/src/parser.rs index 4e83fd15..b7276c10 100644 --- a/core/src/parser.rs +++ b/core/src/parser.rs @@ -877,6 +877,7 @@ fn get_decorators(attrs: &[syn::Attribute]) -> DecoratorMap { DecoratorKind::Swift, DecoratorKind::SwiftGenericConstraints, DecoratorKind::Kotlin, + DecoratorKind::ReScript, ]; for (decorator_kind, value) in decorator_kinds.into_iter().flat_map(|decorator_kind| { diff --git a/core/tests/snapshot_tests.rs b/core/tests/snapshot_tests.rs index 1554453f..0b22dac7 100644 --- a/core/tests/snapshot_tests.rs +++ b/core/tests/snapshot_tests.rs @@ -716,7 +716,7 @@ tests! { can_handle_unit_type: [swift { codablevoid_constraints: vec!["Equatable".into()]} , kotlin, scala, typescript, go, python, rescript]; //3 tests for adding decorators to enums and structs - const_enum_decorator: [ swift{ prefix: "OP".to_string(), } ]; + const_enum_decorator: [ swift{ prefix: "OP".to_string(), }, rescript ]; algebraic_enum_decorator: [ swift{ prefix: "OP".to_string(), } ]; struct_decorator: [ kotlin, swift{ prefix: "OP".to_string(), } ]; serialize_field_as: [kotlin, swift, typescript, scala, go, python]; From 1843a4323cf6f70deaeea50948908c11b7e5f253 Mon Sep 17 00:00:00 2001 From: Tomi Saarinen Date: Thu, 24 Sep 2026 20:43:57 +0300 Subject: [PATCH 4/5] Support fully qualified decorators --- .../tests/fully_qualified_decorator/input.rs | 4 +++ .../fully_qualified_decorator/output.res | 3 ++ core/src/language/rescript.rs | 2 +- core/src/parser.rs | 29 +++++++++++++++++-- 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 core/data/tests/fully_qualified_decorator/input.rs create mode 100644 core/data/tests/fully_qualified_decorator/output.res diff --git a/core/data/tests/fully_qualified_decorator/input.rs b/core/data/tests/fully_qualified_decorator/input.rs new file mode 100644 index 00000000..b587f324 --- /dev/null +++ b/core/data/tests/fully_qualified_decorator/input.rs @@ -0,0 +1,4 @@ +#[typeshare::typeshare(rescript = "@unboxed")] +pub enum TestEnum { + Variant1, +} \ No newline at end of file diff --git a/core/data/tests/fully_qualified_decorator/output.res b/core/data/tests/fully_qualified_decorator/output.res new file mode 100644 index 00000000..d72ecf1b --- /dev/null +++ b/core/data/tests/fully_qualified_decorator/output.res @@ -0,0 +1,3 @@ +@unboxed +type testEnum = + | @as("Variant1") Variant1 \ No newline at end of file diff --git a/core/src/language/rescript.rs b/core/src/language/rescript.rs index 8ace35f7..6aeae682 100644 --- a/core/src/language/rescript.rs +++ b/core/src/language/rescript.rs @@ -451,7 +451,7 @@ impl ReScript { r#type ) } - RustEnumVariant::AnonymousStruct { fields, shared } => { + RustEnumVariant::AnonymousStruct { fields: _, shared } => { let generic_str = if e.shared().generic_types.as_slice().len() > 0 { format!( "<{}>", diff --git a/core/src/parser.rs b/core/src/parser.rs index b7276c10..ee341aca 100644 --- a/core/src/parser.rs +++ b/core/src/parser.rs @@ -641,8 +641,15 @@ fn get_name_value_meta_items<'a>( /// Returns all arguments passed into `#[{ident}(...)]` where `{ident}` can be `serde` or `typeshare` attributes #[inline(always)] pub(crate) fn get_meta_items(attr: &syn::Attribute, ident: &str) -> impl Iterator { - attr.path() - .is_ident(ident) + // Check if the attribute path directly matches the ident, or if any of its segments match + let matches = attr.path().is_ident(ident) + || attr + .path() + .segments + .iter() + .any(|segment| segment.ident == ident); + + matches .then(|| { attr.parse_args_with(Punctuated::::parse_terminated) .into_iter() @@ -917,7 +924,7 @@ mod test { rust_types::RustItem, }; use std::collections::BTreeSet; - use syn::{Attribute, ItemStruct}; + use syn::{Attribute, ItemEnum, ItemStruct}; #[test] fn test_rename_all_to_case() { @@ -1042,4 +1049,20 @@ mod test { assert!(has_typeshare_annotation(&item_struct.attrs)); } + + #[test] + fn test_fully_qualified_decorator() { + let item_enum: ItemEnum = syn::parse_quote! { + #[typeshare::typeshare(rescript = "@unboxed")] + pub enum TestEnum { + Variant1, + } + }; + + let decorators = get_decorators(&item_enum.attrs); + // Should find the rescript decorator + assert!(decorators.contains_key(&DecoratorKind::ReScript)); + let rescript_decorators = decorators.get(&DecoratorKind::ReScript).unwrap(); + assert!(rescript_decorators.contains(&"@unboxed".to_string())); + } } From a2326c55c0b0b659f935d74f5d9b8ae2aab3926e Mon Sep 17 00:00:00 2001 From: Tomi Saarinen Date: Thu, 24 Sep 2026 21:28:55 +0300 Subject: [PATCH 5/5] support file header override in rescript --- cli/data/tests/default_config.toml | 4 +++- cli/data/tests/rescript_header_config.toml | 2 ++ cli/src/config.rs | 1 + cli/src/main.rs | 1 + core/src/language/rescript.rs | 7 ++++++- 5 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 cli/data/tests/rescript_header_config.toml diff --git a/cli/data/tests/default_config.toml b/cli/data/tests/default_config.toml index 1fe92952..2125a146 100644 --- a/cli/data/tests/default_config.toml +++ b/cli/data/tests/default_config.toml @@ -19,4 +19,6 @@ package = '' [kotlin.type_mappings] -[scala.type_mappings] \ No newline at end of file +[scala.type_mappings] + +[rescript] \ No newline at end of file diff --git a/cli/data/tests/rescript_header_config.toml b/cli/data/tests/rescript_header_config.toml new file mode 100644 index 00000000..84aa1e0e --- /dev/null +++ b/cli/data/tests/rescript_header_config.toml @@ -0,0 +1,2 @@ +[rescript] +header = "/* Custom ReScript header */" \ No newline at end of file diff --git a/cli/src/config.rs b/cli/src/config.rs index 672e417f..60a75899 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -67,6 +67,7 @@ pub struct GoParams { pub struct ReScriptParams { pub type_mappings: HashMap, pub default_decorators: Vec, + pub header: Option, } /// The parameters that are used to configure the behaviour of typeshare diff --git a/cli/src/main.rs b/cli/src/main.rs index 1de9b11a..6692ad25 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -242,6 +242,7 @@ fn language( SupportedLanguage::ReScript => Box::new(ReScript { type_mappings: config.rescript.type_mappings, default_decorators: config.rescript.default_decorators, + header: config.rescript.header, ..Default::default() }), } diff --git a/core/src/language/rescript.rs b/core/src/language/rescript.rs index 6aeae682..b8651954 100644 --- a/core/src/language/rescript.rs +++ b/core/src/language/rescript.rs @@ -57,6 +57,8 @@ pub struct ReScript { /// Whether or not to exclude the version header that normally appears at the top of generated code. /// If you aren't generating a snapshot test, this setting can just be left as a default (false) pub no_version_header: bool, + /// Custom header content to include at the beginning of generated files + pub header: Option, } impl Language for ReScript { @@ -155,7 +157,10 @@ impl Language for ReScript { } fn begin_file(&mut self, w: &mut dyn Write, _parsed_data: &ParsedData) -> io::Result<()> { - if !self.no_version_header { + if let Some(header) = &self.header { + writeln!(w, "{}", header)?; + writeln!(w)?; + } else if !self.no_version_header { writeln!(w, "/*")?; writeln!(w, " Generated by typeshare {}", env!("CARGO_PKG_VERSION"))?; writeln!(w, "*/")?;