From a1026aceb346d196cf873d8d418fbbeb84cd888f Mon Sep 17 00:00:00 2001 From: Ben Heidemann Date: Sun, 16 Mar 2025 19:46:44 +0000 Subject: [PATCH 1/3] feat: initial implementation for java language support --- cli/src/args.rs | 1 + cli/src/config.rs | 10 + cli/src/main.rs | 10 +- cli/src/parse.rs | 1 + .../can_generate_bare_string_enum/output.java | 8 + .../output.java | 6 + .../can_generate_simple_enum/output.java | 9 + .../output.java | 14 + .../output.java | 6 + .../output.java | 7 + .../can_generate_unit_structs/output.java | 4 + .../output.java | 6 + .../can_handle_serde_rename_all/output.java | 18 + .../output.java | 14 + .../output.java | 18 + .../output.java | 9 + core/data/tests/generate_types/output.java | 17 + .../output.java | 4 + core/data/tests/kebab_case_rename/output.java | 9 + core/data/tests/orders_types/output.java | 23 ++ .../tests/resolves_qualified_type/output.java | 11 + .../data/tests/serialize_field_as/output.java | 8 + .../data/tests/test_generate_char/output.java | 6 + core/data/tests/test_i54_u53_type/output.java | 7 + .../test_serde_default_struct/output.java | 6 + .../data/tests/test_serde_iso8601/output.java | 6 + core/data/tests/test_serde_url/output.java | 6 + .../output.java | 8 + .../output.java | 4 + .../use_correct_integer_types/output.java | 12 + core/src/language/java.rs | 368 ++++++++++++++++++ core/src/language/mod.rs | 7 +- core/src/parser.rs | 4 + core/tests/snapshot_tests.rs | 87 +++-- 34 files changed, 710 insertions(+), 24 deletions(-) create mode 100644 core/data/tests/can_generate_bare_string_enum/output.java create mode 100644 core/data/tests/can_generate_double_option_pattern/output.java create mode 100644 core/data/tests/can_generate_simple_enum/output.java create mode 100644 core/data/tests/can_generate_simple_struct_with_a_comment/output.java create mode 100644 core/data/tests/can_generate_slice_of_user_type/output.java create mode 100644 core/data/tests/can_generate_struct_with_skipped_fields/output.java create mode 100644 core/data/tests/can_generate_unit_structs/output.java create mode 100644 core/data/tests/can_handle_quote_in_serde_rename/output.java create mode 100644 core/data/tests/can_handle_serde_rename_all/output.java create mode 100644 core/data/tests/can_handle_serde_rename_on_top_level/output.java create mode 100644 core/data/tests/can_recognize_types_inside_modules/output.java create mode 100644 core/data/tests/enum_is_properly_named_with_serde_overrides/output.java create mode 100644 core/data/tests/generate_types/output.java create mode 100644 core/data/tests/generates_empty_structs_and_initializers/output.java create mode 100644 core/data/tests/kebab_case_rename/output.java create mode 100644 core/data/tests/orders_types/output.java create mode 100644 core/data/tests/resolves_qualified_type/output.java create mode 100644 core/data/tests/serialize_field_as/output.java create mode 100644 core/data/tests/test_generate_char/output.java create mode 100644 core/data/tests/test_i54_u53_type/output.java create mode 100644 core/data/tests/test_serde_default_struct/output.java create mode 100644 core/data/tests/test_serde_iso8601/output.java create mode 100644 core/data/tests/test_serde_url/output.java create mode 100644 core/data/tests/test_simple_enum_case_name_support/output.java create mode 100644 core/data/tests/use_correct_decoded_variable_name/output.java create mode 100644 core/data/tests/use_correct_integer_types/output.java create mode 100644 core/src/language/java.rs diff --git a/cli/src/args.rs b/cli/src/args.rs index 05b79619..b8cb4a1b 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; #[derive(Debug, Clone, Copy, clap::ValueEnum)] #[non_exhaustive] pub enum AvailableLanguage { + Java, Kotlin, Scala, Swift, diff --git a/cli/src/config.rs b/cli/src/config.rs index 7529d556..b87932fb 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -18,6 +18,15 @@ pub struct PythonParams { pub type_mappings: HashMap, } +#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(default)] +pub struct JavaParams { + pub package: String, + pub module_name: String, + pub prefix: String, + pub type_mappings: HashMap, +} + #[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(default)] pub struct KotlinParams { @@ -69,6 +78,7 @@ pub struct GoParams { pub(crate) struct Config { pub swift: SwiftParams, pub typescript: TypeScriptParams, + pub java: JavaParams, pub kotlin: KotlinParams, pub scala: ScalaParams, #[cfg(feature = "python")] diff --git a/cli/src/main.rs b/cli/src/main.rs index 7a702b4e..144a4207 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,7 +27,7 @@ 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, Java, Kotlin, Language, Scala, SupportedLanguage, Swift, TypeScript}, parser::ParsedData, reconcile::reconcile_aliases, }; @@ -87,6 +87,7 @@ fn generate_types(config_file: Option<&Path>, options: &Args) -> anyhow::Result< let language_type = match options.language { None => panic!("no language specified; `clap` should have guaranteed its presence"), Some(language) => match language { + args::AvailableLanguage::Java => SupportedLanguage::Java, args::AvailableLanguage::Kotlin => SupportedLanguage::Kotlin, args::AvailableLanguage::Scala => SupportedLanguage::Scala, args::AvailableLanguage::Swift => SupportedLanguage::Swift, @@ -195,6 +196,13 @@ fn language( codablevoid_constraints: config.swift.codablevoid_constraints, ..Default::default() }), + SupportedLanguage::Java => Box::new(Java { + package: config.java.package, + module_name: config.java.module_name, + prefix: config.java.prefix, + type_mappings: config.java.type_mappings, + ..Default::default() + }), SupportedLanguage::Kotlin => Box::new(Kotlin { package: config.kotlin.package, module_name: config.kotlin.module_name, diff --git a/cli/src/parse.rs b/cli/src/parse.rs index 2ae38fe2..dc64359f 100644 --- a/cli/src/parse.rs +++ b/cli/src/parse.rs @@ -54,6 +54,7 @@ fn output_file_name(language_type: SupportedLanguage, crate_name: &CrateName) -> match language_type { SupportedLanguage::Go => snake_case(), + SupportedLanguage::Java => snake_case(), SupportedLanguage::Kotlin => snake_case(), SupportedLanguage::Scala => snake_case(), SupportedLanguage::Swift => pascal_case(), diff --git a/core/data/tests/can_generate_bare_string_enum/output.java b/core/data/tests/can_generate_bare_string_enum/output.java new file mode 100644 index 00000000..657fac63 --- /dev/null +++ b/core/data/tests/can_generate_bare_string_enum/output.java @@ -0,0 +1,8 @@ +package com.agilebits.onepassword + +/// This is a comment. +public enum Colors { + Red, + Blue, + Green +} diff --git a/core/data/tests/can_generate_double_option_pattern/output.java b/core/data/tests/can_generate_double_option_pattern/output.java new file mode 100644 index 00000000..e1e8750c --- /dev/null +++ b/core/data/tests/can_generate_double_option_pattern/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +public record SomeStruct( + long field_a +) {} + diff --git a/core/data/tests/can_generate_simple_enum/output.java b/core/data/tests/can_generate_simple_enum/output.java new file mode 100644 index 00000000..d5315ec6 --- /dev/null +++ b/core/data/tests/can_generate_simple_enum/output.java @@ -0,0 +1,9 @@ +package com.agilebits.onepassword + +/// This is a comment. +/// Continued lovingly here +public enum Colors { + Red, + Blue, + Green +} diff --git a/core/data/tests/can_generate_simple_struct_with_a_comment/output.java b/core/data/tests/can_generate_simple_struct_with_a_comment/output.java new file mode 100644 index 00000000..2dc4eb02 --- /dev/null +++ b/core/data/tests/can_generate_simple_struct_with_a_comment/output.java @@ -0,0 +1,14 @@ +package com.agilebits.onepassword + +public record Location() {} + +/// This is a comment. +public record Person( + /// This is another comment + String name, + short age, + String info, + java.util.ArrayList emails, + Location location +) {} + diff --git a/core/data/tests/can_generate_slice_of_user_type/output.java b/core/data/tests/can_generate_slice_of_user_type/output.java new file mode 100644 index 00000000..011de92e --- /dev/null +++ b/core/data/tests/can_generate_slice_of_user_type/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +public record Video( + Tag[] tags +) {} + diff --git a/core/data/tests/can_generate_struct_with_skipped_fields/output.java b/core/data/tests/can_generate_struct_with_skipped_fields/output.java new file mode 100644 index 00000000..2a672093 --- /dev/null +++ b/core/data/tests/can_generate_struct_with_skipped_fields/output.java @@ -0,0 +1,7 @@ +package com.agilebits.onepassword + +public record MyStruct( + int a, + int c +) {} + diff --git a/core/data/tests/can_generate_unit_structs/output.java b/core/data/tests/can_generate_unit_structs/output.java new file mode 100644 index 00000000..ff84efba --- /dev/null +++ b/core/data/tests/can_generate_unit_structs/output.java @@ -0,0 +1,4 @@ +package com.agilebits.onepassword + +public record UnitStruct() {} + diff --git a/core/data/tests/can_handle_quote_in_serde_rename/output.java b/core/data/tests/can_handle_quote_in_serde_rename/output.java new file mode 100644 index 00000000..d453fcfd --- /dev/null +++ b/core/data/tests/can_handle_quote_in_serde_rename/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +/// This is a comment. +public enum Colors { + Green +} diff --git a/core/data/tests/can_handle_serde_rename_all/output.java b/core/data/tests/can_handle_serde_rename_all/output.java new file mode 100644 index 00000000..0bce5baf --- /dev/null +++ b/core/data/tests/can_handle_serde_rename_all/output.java @@ -0,0 +1,18 @@ +package com.agilebits.onepassword + +/// This is a Person struct with camelCase rename +public record Person( + String firstName, + String lastName, + short age, + int extraSpecialField1, + java.util.ArrayList extraSpecialField2 +) {} + +/// This is a Person2 struct with UPPERCASE rename +public record Person2( + String FIRST_NAME, + String LAST_NAME, + short AGE +) {} + diff --git a/core/data/tests/can_handle_serde_rename_on_top_level/output.java b/core/data/tests/can_handle_serde_rename_on_top_level/output.java new file mode 100644 index 00000000..b6774366 --- /dev/null +++ b/core/data/tests/can_handle_serde_rename_on_top_level/output.java @@ -0,0 +1,14 @@ +package com.agilebits.onepassword + +public record OtherType() {} + +/// This is a comment. +public record PersonTwo( + String name, + short age, + int extraSpecialFieldOne, + java.util.ArrayList extraSpecialFieldTwo, + OtherType nonStandardDataType, + java.util.ArrayList nonStandardDataTypeInArray +) {} + diff --git a/core/data/tests/can_recognize_types_inside_modules/output.java b/core/data/tests/can_recognize_types_inside_modules/output.java new file mode 100644 index 00000000..2497247e --- /dev/null +++ b/core/data/tests/can_recognize_types_inside_modules/output.java @@ -0,0 +1,18 @@ +package com.agilebits.onepassword + +public record A( + long field +) {} + +public record AB( + long field +) {} + +public record ABC( + long field +) {} + +public record OutsideOfModules( + long field +) {} + diff --git a/core/data/tests/enum_is_properly_named_with_serde_overrides/output.java b/core/data/tests/enum_is_properly_named_with_serde_overrides/output.java new file mode 100644 index 00000000..b361928c --- /dev/null +++ b/core/data/tests/enum_is_properly_named_with_serde_overrides/output.java @@ -0,0 +1,9 @@ +package com.agilebits.onepassword + +/// This is a comment. +/// Continued lovingly here +public enum Colors { + red, + blue, + green_like +} diff --git a/core/data/tests/generate_types/output.java b/core/data/tests/generate_types/output.java new file mode 100644 index 00000000..26fa67cf --- /dev/null +++ b/core/data/tests/generate_types/output.java @@ -0,0 +1,17 @@ +package com.agilebits.onepassword + +public record CustomType() {} + +public record Types( + String s, + String static_s, + byte int8, + float _float, + double _double, + java.util.ArrayList array, + String[] fixed_length_array, + java.util.HashMap dictionary, + java.util.HashMap optional_dictionary, + CustomType custom_type +) {} + diff --git a/core/data/tests/generates_empty_structs_and_initializers/output.java b/core/data/tests/generates_empty_structs_and_initializers/output.java new file mode 100644 index 00000000..a64d9d83 --- /dev/null +++ b/core/data/tests/generates_empty_structs_and_initializers/output.java @@ -0,0 +1,4 @@ +package com.agilebits.onepassword + +public record MyEmptyStruct() {} + diff --git a/core/data/tests/kebab_case_rename/output.java b/core/data/tests/kebab_case_rename/output.java new file mode 100644 index 00000000..07972b3e --- /dev/null +++ b/core/data/tests/kebab_case_rename/output.java @@ -0,0 +1,9 @@ +package com.agilebits.onepassword + +/// This is a comment. +public record Things( + String bla, + String label, + String label_left +) {} + diff --git a/core/data/tests/orders_types/output.java b/core/data/tests/orders_types/output.java new file mode 100644 index 00000000..2eb54f92 --- /dev/null +++ b/core/data/tests/orders_types/output.java @@ -0,0 +1,23 @@ +package com.agilebits.onepassword + +public record A( + long field +) {} + +public record B( + A dependsOn +) {} + +public record C( + B dependsOn +) {} + +public record E( + D dependsOn +) {} + +public record D( + C dependsOn, + E alsoDependsOn +) {} + diff --git a/core/data/tests/resolves_qualified_type/output.java b/core/data/tests/resolves_qualified_type/output.java new file mode 100644 index 00000000..b34b27f0 --- /dev/null +++ b/core/data/tests/resolves_qualified_type/output.java @@ -0,0 +1,11 @@ +package com.agilebits.onepassword + +public record QualifiedTypes( + String unqualified, + String qualified, + java.util.ArrayList qualified_vec, + java.util.HashMap qualified_hashmap, + String qualified_optional, + java.util.HashMap> qualfied_optional_hashmap_vec +) {} + diff --git a/core/data/tests/serialize_field_as/output.java b/core/data/tests/serialize_field_as/output.java new file mode 100644 index 00000000..3c048c23 --- /dev/null +++ b/core/data/tests/serialize_field_as/output.java @@ -0,0 +1,8 @@ +package com.agilebits.onepassword + +public record EditItemViewModelSaveRequest( + String context, + java.util.ArrayList values, + AutoFillItemActionRequest fill_action +) {} + diff --git a/core/data/tests/test_generate_char/output.java b/core/data/tests/test_generate_char/output.java new file mode 100644 index 00000000..4d225023 --- /dev/null +++ b/core/data/tests/test_generate_char/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +public record MyType( + String field +) {} + diff --git a/core/data/tests/test_i54_u53_type/output.java b/core/data/tests/test_i54_u53_type/output.java new file mode 100644 index 00000000..ce4bf9ed --- /dev/null +++ b/core/data/tests/test_i54_u53_type/output.java @@ -0,0 +1,7 @@ +package com.agilebits.onepassword + +public record Foo( + long a, + java.math.BigInteger b +) {} + diff --git a/core/data/tests/test_serde_default_struct/output.java b/core/data/tests/test_serde_default_struct/output.java new file mode 100644 index 00000000..4f5ab7c8 --- /dev/null +++ b/core/data/tests/test_serde_default_struct/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +public record Foo( + boolean bar +) {} + diff --git a/core/data/tests/test_serde_iso8601/output.java b/core/data/tests/test_serde_iso8601/output.java new file mode 100644 index 00000000..2f103e47 --- /dev/null +++ b/core/data/tests/test_serde_iso8601/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +public record Foo( + String time +) {} + diff --git a/core/data/tests/test_serde_url/output.java b/core/data/tests/test_serde_url/output.java new file mode 100644 index 00000000..c26c1b57 --- /dev/null +++ b/core/data/tests/test_serde_url/output.java @@ -0,0 +1,6 @@ +package com.agilebits.onepassword + +public record Foo( + String url +) {} + diff --git a/core/data/tests/test_simple_enum_case_name_support/output.java b/core/data/tests/test_simple_enum_case_name_support/output.java new file mode 100644 index 00000000..09089a17 --- /dev/null +++ b/core/data/tests/test_simple_enum_case_name_support/output.java @@ -0,0 +1,8 @@ +package com.agilebits.onepassword + +/// This is a comment. +public enum Colors { + red, + blue_ish, + Green +} diff --git a/core/data/tests/use_correct_decoded_variable_name/output.java b/core/data/tests/use_correct_decoded_variable_name/output.java new file mode 100644 index 00000000..a64d9d83 --- /dev/null +++ b/core/data/tests/use_correct_decoded_variable_name/output.java @@ -0,0 +1,4 @@ +package com.agilebits.onepassword + +public record MyEmptyStruct() {} + diff --git a/core/data/tests/use_correct_integer_types/output.java b/core/data/tests/use_correct_integer_types/output.java new file mode 100644 index 00000000..7c94547a --- /dev/null +++ b/core/data/tests/use_correct_integer_types/output.java @@ -0,0 +1,12 @@ +package com.agilebits.onepassword + +/// This is a comment. +public record Foo( + byte a, + short b, + int c, + short e, + int f, + long g +) {} + diff --git a/core/src/language/java.rs b/core/src/language/java.rs new file mode 100644 index 00000000..1dc09c60 --- /dev/null +++ b/core/src/language/java.rs @@ -0,0 +1,368 @@ +// TODO: NotNull annotations? + +use super::{Language, ScopedCrateTypes}; +use crate::language::SupportedLanguage; +use crate::parser::ParsedData; +use crate::rust_types::{RustConst, RustEnum, RustField, RustStruct, RustTypeAlias}; +use crate::rust_types::{RustEnumShared, RustTypeFormatError, SpecialRustType}; +use std::{collections::HashMap, io::Write}; + +/// All information needed for Java type-code +#[derive(Default)] +pub struct Java { + /// Allow multiple classes per file + pub allow_multiple_classes_per_file: bool, + /// Name of the Java package + pub package: String, + /// Name of the Java module + pub module_name: String, + /// The prefix to append to user-defined types + pub prefix: String, + /// Conversions from Rust type names to Java type names. + pub type_mappings: HashMap, + /// 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 Java { + fn type_map(&mut self) -> &HashMap { + &self.type_mappings + } + + fn format_simple_type( + &mut self, + base: &String, + generic_types: &[String], + ) -> Result { + Ok(if let Some(mapped) = self.type_map().get(base) { + mapped.into() + } else if generic_types.contains(base) { + base.into() + } else { + format!("{}{}", self.prefix, base) + }) + } + + fn format_special_type( + &mut self, + special_ty: &SpecialRustType, + generic_types: &[String], + ) -> Result { + Ok(match special_ty { + SpecialRustType::Vec(rtype) => { + format!( + "java.util.ArrayList<{}>", + self.format_type(rtype, generic_types)? + ) + } + SpecialRustType::Array(rtype, _) => { + format!("{}[]", self.format_type(rtype, generic_types)?) + } + SpecialRustType::Slice(rtype) => { + format!("{}[]", self.format_type(rtype, generic_types)?) + } + SpecialRustType::Option(rtype) => self.format_type(rtype, generic_types)?, + SpecialRustType::HashMap(rtype1, rtype2) => { + format!( + "java.util.HashMap<{}, {}>", + self.format_type(rtype1, generic_types)?, + self.format_type(rtype2, generic_types)? + ) + } + SpecialRustType::Unit => "Void".into(), + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-4.html#jls-IntegralType + // Char in Java is 16 bits long, so we need to use String + SpecialRustType::String | SpecialRustType::Char => "String".into(), + SpecialRustType::I8 => "byte".into(), + SpecialRustType::I16 => "short".into(), + SpecialRustType::ISize | SpecialRustType::I32 => "int".into(), + SpecialRustType::I54 | SpecialRustType::I64 => "long".into(), + // byte in Java is signed, so we need to use short to represent all possible values + SpecialRustType::U8 => "short".into(), + // short in Java is signed, so we need to use int to represent all possible values + SpecialRustType::U16 => "int".into(), + // ing in Java is signed, so we need to use long to represent all possible values + SpecialRustType::USize | SpecialRustType::U32 => "long".into(), + // long in Java is signed, so we need to use BigInteger to represent all possible values + SpecialRustType::U53 | SpecialRustType::U64 => "java.math.BigInteger".into(), + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-4.html#jls-PrimitiveType + SpecialRustType::Bool => "boolean".into(), + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-4.html#jls-FloatingPointType + SpecialRustType::F32 => "float".into(), + SpecialRustType::F64 => "double".into(), + // TODO: https://github.com/1Password/typeshare/issues/237 + SpecialRustType::DateTime => { + return Err(RustTypeFormatError::UnsupportedSpecialType( + special_ty.to_string(), + )) + } + }) + } + + fn begin_file(&mut self, w: &mut dyn Write, parsed_data: &ParsedData) -> std::io::Result<()> { + if !self.package.is_empty() { + if !self.no_version_header { + writeln!(w, "/**")?; + writeln!(w, " * Generated by typeshare {}", env!("CARGO_PKG_VERSION"))?; + writeln!(w, " */")?; + writeln!(w)?; + } + if parsed_data.multi_file { + writeln!(w, "package {}.{}", self.package, parsed_data.crate_name)?; + } else { + writeln!(w, "package {}", self.package)?; + } + writeln!(w)?; + } + + Ok(()) + } + + fn write_type_alias(&mut self, _w: &mut dyn Write, _ty: &RustTypeAlias) -> std::io::Result<()> { + todo!() + } + + fn write_const(&mut self, _w: &mut dyn Write, _c: &RustConst) -> std::io::Result<()> { + todo!() + } + + fn write_struct(&mut self, w: &mut dyn Write, rs: &RustStruct) -> std::io::Result<()> { + self.write_comments(w, 0, &rs.comments)?; + + write!( + w, + "public record {}{}{}(", + self.prefix, + rs.id.renamed, + (!rs.generic_types.is_empty()) + .then(|| format!("<{}>", rs.generic_types.join(", "))) + .unwrap_or_default() + )?; + + if let Some((last, elements)) = rs.fields.split_last() { + writeln!(w)?; + for f in elements.iter() { + self.write_element(w, f, rs.generic_types.as_slice())?; + writeln!(w, ",")?; + } + self.write_element(w, last, rs.generic_types.as_slice())?; + writeln!(w)?; + } + + writeln!(w, r") {{}}")?; + + writeln!(w)?; + + Ok(()) + } + + fn write_enum(&mut self, w: &mut dyn Write, e: &RustEnum) -> std::io::Result<()> { + // Generate named types for any anonymous struct variants of this enum + self.write_types_for_anonymous_structs(w, e, &|variant_name| { + format!("{}{}Inner", &e.shared().id.renamed, variant_name) + })?; + + self.write_comments(w, 0, &e.shared().comments)?; + + match e { + RustEnum::Unit(e) => self.write_unit_enum(w, e), + RustEnum::Algebraic { .. } => todo!(), + } + } + + fn write_imports( + &mut self, + w: &mut dyn Write, + imports: ScopedCrateTypes<'_>, + ) -> std::io::Result<()> { + for (path, ty) in imports { + for t in ty { + writeln!(w, "import {}.{path}.{t};", self.package)?; + } + } + writeln!(w) + } + + fn ignored_reference_types(&self) -> Vec<&str> { + self.type_mappings.keys().map(|s| s.as_str()).collect() + } +} + +impl Java { + #[inline] + fn is_java_letter(&self, c: char) -> bool { + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-3.html#jls-JavaLetter + c.is_ascii_alphabetic() || c == '_' || c == '$' + } + + #[inline] + fn is_java_letter_or_number(&self, c: char) -> bool { + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-3.html#jls-JavaLetterOrDigit + self.is_java_letter(c) || c.is_ascii_digit() + } + + #[inline] + fn is_java_reserved_keyword(&self, name: &str) -> bool { + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-3.html#jls-ReservedKeyword + matches!( + name, + "abstract" + | "continue" + | "for" + | "new" + | "switch" + | "assert" + | "default" + | "if" + | "package" + | "synchronized" + | "boolean" + | "do" + | "goto" + | "private" + | "this" + | "break" + | "double" + | "implements" + | "protected" + | "throw" + | "byte" + | "else" + | "import" + | "public" + | "throws" + | "case" + | "enum" + | "instanceof" + | "return" + | "transient" + | "catch" + | "extends" + | "int" + | "short" + | "try" + | "char" + | "final" + | "interface" + | "static" + | "void" + | "class" + | "finally" + | "long" + | "strictfp" + | "volatile" + | "const" + | "float" + | "native" + | "super" + | "while" + | "_" + ) + } + + #[inline] + fn is_java_boolean_literal(&self, name: &str) -> bool { + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-3.html#jls-BooleanLiteral + matches!(name, "true" | "false") + } + + #[inline] + fn is_java_null_literal(&self, name: &str) -> bool { + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-3.html#jls-NullLiteral + matches!(name, "null") + } + + fn santitize_itentifier(&self, name: &str) -> String { + // https://docs.oracle.com/javase/specs/jls/se23/html/jls-3.html#jls-Identifier + let mut chars = name.chars(); + + // Ensure the first character is valid "JavaLetter" + let first_char = chars + .next() + .map(|c| if self.is_java_letter(c) { c } else { '_' }); + + // Ensure each remaining characters is a valid "JavaLetterOrDigit" + let rest: String = chars + .filter_map(|c| match c { + '-' => Some('_'), + c if self.is_java_letter_or_number(c) => Some(c), + _ => None, + }) + .collect(); + + // Combine and return the sanitized identifier + let name: String = first_char.into_iter().chain(rest.chars()).collect(); + + if self.is_java_reserved_keyword(&name) + || self.is_java_boolean_literal(&name) + || self.is_java_null_literal(&name) + { + format!("_{name}") + } else { + name + } + } + + fn write_element( + &mut self, + w: &mut dyn Write, + f: &RustField, + generic_types: &[String], + ) -> std::io::Result<()> { + self.write_comments(w, 1, &f.comments)?; + let ty = match f.type_override(SupportedLanguage::Java) { + Some(type_override) => type_override.to_owned(), + None => self + .format_type(&f.ty, generic_types) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?, + }; + + write!(w, "\t{} {}", ty, self.santitize_itentifier(&f.id.renamed)) + } + + fn write_unit_enum(&mut self, w: &mut dyn Write, e: &RustEnumShared) -> std::io::Result<()> { + writeln!(w, "public enum {}{} {{", self.prefix, &e.id.renamed)?; + + if let Some((last, elements)) = e.variants.split_last() { + for v in elements { + self.write_comments(w, 1, &v.shared().comments)?; + writeln!( + w, + "\t{},", + self.santitize_itentifier(&v.shared().id.renamed), + )?; + } + writeln!( + w, + "\t{}", + self.santitize_itentifier(&last.shared().id.renamed), + )?; + } + + writeln!(w, "}}")?; + + Ok(()) + } + + fn write_comment( + &self, + w: &mut dyn Write, + indent: usize, + comment: &str, + ) -> std::io::Result<()> { + writeln!(w, "{}/// {}", "\t".repeat(indent), comment)?; + Ok(()) + } + + fn write_comments( + &self, + w: &mut dyn Write, + indent: usize, + comments: &[String], + ) -> std::io::Result<()> { + comments + .iter() + .try_for_each(|comment| self.write_comment(w, indent, comment)) + } +} diff --git a/core/src/language/mod.rs b/core/src/language/mod.rs index 4d007411..43423ce5 100644 --- a/core/src/language/mod.rs +++ b/core/src/language/mod.rs @@ -20,6 +20,7 @@ use std::{ }; mod go; +mod java; mod kotlin; mod python; mod scala; @@ -27,6 +28,7 @@ mod swift; mod typescript; pub use go::Go; +pub use java::Java; pub use kotlin::Kotlin; pub use python::Python; pub use scala::Scala; @@ -98,6 +100,7 @@ pub type ScopedCrateTypes<'a> = BTreeMap, SortedTypeNames<' #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] pub enum SupportedLanguage { Go, + Java, Kotlin, Scala, Swift, @@ -109,13 +112,14 @@ 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, Java, Kotlin, Scala, Swift, TypeScript, Python].into_iter() } /// Get the file name extension for the supported language. pub fn language_extension(&self) -> &'static str { match self { SupportedLanguage::Go => "go", + SupportedLanguage::Java => "java", SupportedLanguage::Kotlin => "kt", SupportedLanguage::Scala => "scala", SupportedLanguage::Swift => "swift", @@ -131,6 +135,7 @@ impl FromStr for SupportedLanguage { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "go" => Ok(Self::Go), + "java" => Ok(Self::Java), "kotlin" => Ok(Self::Kotlin), "scala" => Ok(Self::Scala), "swift" => Ok(Self::Swift), diff --git a/core/src/parser.rs b/core/src/parser.rs index 53b9ed5d..37c6bc11 100644 --- a/core/src/parser.rs +++ b/core/src/parser.rs @@ -35,6 +35,8 @@ pub enum DecoratorKind { Swift, /// The typeshare attribute for swift generic constraints "swiftGenericConstraints" SwiftGenericConstraints, + /// The typeshare attribute for java "java" + Java, /// The typeshare attribute for kotlin "kotlin" Kotlin, } @@ -45,6 +47,7 @@ impl DecoratorKind { match self { DecoratorKind::Swift => "swift", DecoratorKind::SwiftGenericConstraints => "swiftGenericConstraints", + DecoratorKind::Java => "java", DecoratorKind::Kotlin => "kotlin", } } @@ -830,6 +833,7 @@ fn get_decorators(attrs: &[syn::Attribute]) -> DecoratorMap { for decorator_kind in [ DecoratorKind::Swift, DecoratorKind::SwiftGenericConstraints, + DecoratorKind::Java, DecoratorKind::Kotlin, ] { for value in get_name_value_meta_items(attrs, decorator_kind.as_str(), TYPESHARE) { diff --git a/core/tests/snapshot_tests.rs b/core/tests/snapshot_tests.rs index 24e72e50..5c3947fb 100644 --- a/core/tests/snapshot_tests.rs +++ b/core/tests/snapshot_tests.rs @@ -136,6 +136,9 @@ fn check( /// Makes a string literal representing the correct output filename for the /// given ident macro_rules! output_file_for_ident { + (java) => { + "output.java" + }; (kotlin) => { "output.kt" }; @@ -188,6 +191,26 @@ macro_rules! output_file_for_ident { /// }); /// ``` macro_rules! language_instance { + // Default java + (java) => { + language_instance!(java { + allow_multiple_classes_per_file: true, + package: "com.agilebits.onepassword".to_string(), + module_name: String::new(), + type_mappings: Default::default(), + }) + }; + + // java with configuration fields forwarded + (java {$($field:ident: $val:expr),* $(,)?}) => { + #[allow(clippy::needless_update)] + Box::new(typeshare_core::language::Java { + no_version_header: true, + $($field: $val,)* + ..Default::default() + }) + }; + // Default kotlin (kotlin) => { language_instance!(kotlin { @@ -402,6 +425,13 @@ macro_rules! tests { )*}; } +static JAVA_MAPPINGS: Lazy> = Lazy::new(|| { + [("Url", "String"), ("DateTime", "String")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +}); + static KOTLIN_MAPPINGS: Lazy> = Lazy::new(|| { [("Url", "String"), ("DateTime", "String")] .iter() @@ -500,7 +530,7 @@ tests! { typescript ]; 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, java, kotlin, scala, typescript, go, python]; can_generate_readonly_fields: [ typescript ]; @@ -508,20 +538,22 @@ tests! { swift { prefix: "TypeShare".to_string(), }, + java, kotlin, scala, typescript, go, python ]; - can_generate_bare_string_enum: [swift, kotlin, scala, typescript, go, python ]; + can_generate_bare_string_enum: [swift, java, kotlin, scala, typescript, go, python ]; can_generate_double_option_pattern: [ + java, typescript ]; can_recognize_types_inside_modules: [ - swift, kotlin, scala, typescript, go, python + swift, java, kotlin, scala, typescript, go, python ]; - test_simple_enum_case_name_support: [swift, kotlin, scala, typescript, go, python ]; + test_simple_enum_case_name_support: [swift, java, kotlin, scala, typescript, go, python ]; test_algebraic_enum_case_name_support: [ swift { prefix: "OP".to_string(), @@ -541,11 +573,11 @@ tests! { 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_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_generate_struct_with_skipped_fields: [swift, java, kotlin, scala, typescript, go, python]; + enum_is_properly_named_with_serde_overrides: [swift, java, kotlin, scala, typescript, go, python]; + can_handle_quote_in_serde_rename: [swift, java, kotlin, scala, typescript, go, python]; can_handle_anonymous_struct: [swift, kotlin, scala, typescript, go, python]; - test_generate_char: [swift, kotlin, scala, typescript, go, python]; + test_generate_char: [swift, java, kotlin, scala, typescript, go, python]; anonymous_struct_with_rename: [ swift { prefix: "Core".to_string(), @@ -559,8 +591,8 @@ tests! { can_override_types: [swift, kotlin, scala, typescript, go]; /// 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: [java, kotlin, swift, typescript, scala, go, python]; + generate_types: [java, kotlin, swift, typescript, scala, go, python]; can_handle_serde_rename: [ swift { prefix: "TypeShareX_".to_string(), @@ -572,16 +604,21 @@ tests! { python ]; // TODO: kotlin and typescript don't appear to support this yet - generates_empty_structs_and_initializers: [swift, kotlin, scala, typescript, go,python]; + generates_empty_structs_and_initializers: [swift, java, kotlin, scala, typescript, go,python]; test_default_decorators: [swift { default_decorators: vec!["Sendable".into(), "Identifiable".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, java, kotlin, scala, typescript, go, python]; + test_serde_default_struct: [swift, java, kotlin, scala, typescript, go, python]; test_serde_iso8601: [ swift { prefix: String::new(), type_mappings: super::SWIFT_MAPPINGS.clone(), }, + java { + package: "com.agilebits.onepassword".to_string(), + module_name: "colorModule".to_string(), + type_mappings: super::JAVA_MAPPINGS.clone() + }, kotlin { package: "com.agilebits.onepassword".to_string(), module_name: "colorModule".to_string(), @@ -607,6 +644,11 @@ tests! { prefix: String::new(), type_mappings: super::SWIFT_MAPPINGS.clone(), }, + java { + package: "com.agilebits.onepassword".to_string(), + module_name: "colorModule".to_string(), + type_mappings: super::KOTLIN_MAPPINGS.clone() + }, kotlin { package: "com.agilebits.onepassword".to_string(), module_name: "colorModule".to_string(), @@ -643,27 +685,27 @@ tests! { }, python ]; - 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_generate_unit_structs: [swift, kotlin, scala, typescript, go, python]; - kebab_case_rename: [swift, kotlin, scala, typescript, go, python]; + can_handle_serde_rename_all: [swift, java, kotlin, scala, typescript, go,python]; + can_handle_serde_rename_on_top_level: [swift { prefix: "OP".to_string(), }, java, kotlin, scala, typescript, go, python]; + can_generate_unit_structs: [swift, java, kotlin, scala, typescript, go, python]; + kebab_case_rename: [swift, java, kotlin, scala, typescript, go, python]; /// Globals get topologically sorted - orders_types: [swift, kotlin, go, python]; + orders_types: [swift, java, kotlin, go, python]; /// Other - use_correct_integer_types: [swift, kotlin, scala, typescript, go, python]; + use_correct_integer_types: [swift, java, kotlin, scala, typescript, go, python]; // Only swift supports generating types with keywords generate_types_with_keywords: [swift]; // 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, java, kotlin, scala, typescript, go, python]; + can_handle_unit_type: [swift { codablevoid_constraints: vec!["Equatable".into()]}, kotlin, scala, typescript, go, python]; //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_field_as: [java, kotlin, swift, typescript, scala, go, python]; serialize_type_alias: [kotlin, swift, typescript, scala, go, python]; serialize_anonymous_field_as: [kotlin, swift, typescript, scala, go, python]; smart_pointers: [kotlin, swift, typescript, scala, go, python]; @@ -679,6 +721,7 @@ tests! { prefix: "Core".into() }, typescript, + java, kotlin, scala, go, From ffbafc207ab328414edb173dc018e717180fab7f Mon Sep 17 00:00:00 2001 From: Ben Heidemann Date: Mon, 17 Mar 2025 11:05:29 +0000 Subject: [PATCH 2/3] feat: hook up command line flags --- cli/src/args.rs | 12 ++++++++++++ cli/src/config.rs | 1 + cli/src/main.rs | 16 ++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/cli/src/args.rs b/cli/src/args.rs index b8cb4a1b..cc4847bf 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -34,6 +34,14 @@ pub struct Args { #[arg(short, long)] pub swift_prefix: Option, + /// Prefix for generated Java types + #[arg(long)] + pub java_allow_multiple_classes_per_file: Option, + + /// Prefix for generated Java types + #[arg(long)] + pub java_prefix: Option, + /// Prefix for generated Kotlin types #[arg(short, long)] pub kotlin_prefix: Option, @@ -42,6 +50,10 @@ pub struct Args { #[arg(short, long)] pub java_package: Option, + /// Java serializer module name + #[arg(long = "java-module-name")] + pub java_module_name: Option, + /// Kotlin serializer module name #[arg(short = 'm', long = "module-name")] pub kotlin_module_name: Option, diff --git a/cli/src/config.rs b/cli/src/config.rs index b87932fb..a0fad81a 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -21,6 +21,7 @@ pub struct PythonParams { #[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(default)] pub struct JavaParams { + pub allow_multiple_classes_per_file: bool, pub package: String, pub module_name: String, pub prefix: String, diff --git a/cli/src/main.rs b/cli/src/main.rs index 144a4207..d9c40a01 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -197,6 +197,7 @@ fn language( ..Default::default() }), SupportedLanguage::Java => Box::new(Java { + allow_multiple_classes_per_file: config.java.allow_multiple_classes_per_file, package: config.java.package, module_name: config.java.module_name, prefix: config.java.prefix, @@ -250,14 +251,29 @@ fn override_configuration(mut config: Config, options: &Args) -> anyhow::Result< config.swift.prefix = swift_prefix.clone(); } + if let Some(java_allow_multiple_classes_per_file) = + options.java_allow_multiple_classes_per_file.as_ref() + { + config.java.allow_multiple_classes_per_file = java_allow_multiple_classes_per_file.clone(); + } + + if let Some(java_prefix) = options.java_prefix.as_ref() { + config.java.prefix = java_prefix.clone(); + } + if let Some(kotlin_prefix) = options.kotlin_prefix.as_ref() { config.kotlin.prefix = kotlin_prefix.clone(); } if let Some(java_package) = options.java_package.as_ref() { + config.java.package = java_package.clone(); config.kotlin.package = java_package.clone(); } + if let Some(module_name) = options.java_module_name.as_ref() { + config.java.module_name = module_name.to_string(); + } + if let Some(module_name) = options.kotlin_module_name.as_ref() { config.kotlin.module_name = module_name.to_string(); } From df792f656c1b44555e6b588f0c837535b40000da Mon Sep 17 00:00:00 2001 From: Ben Heidemann Date: Thu, 27 Mar 2025 14:14:28 +0000 Subject: [PATCH 3/3] use namespace class --- core/Cargo.toml | 2 +- core/src/language/java.rs | 87 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/core/Cargo.toml b/core/Cargo.toml index 3eeb576b..51515505 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,7 +15,7 @@ itertools = "0.12" lazy_format = "2" joinery = "2" topological-sort = { version = "0.2.2"} -convert_case = { version = "0.6.0"} +convert_case = "0.6.0" log.workspace = true flexi_logger.workspace = true diff --git a/core/src/language/java.rs b/core/src/language/java.rs index 1dc09c60..3f447de0 100644 --- a/core/src/language/java.rs +++ b/core/src/language/java.rs @@ -1,10 +1,15 @@ // TODO: NotNull annotations? -use super::{Language, ScopedCrateTypes}; +use convert_case::{Case, Casing as _}; +use itertools::Itertools as _; + +use super::{used_imports, CrateTypes, Language, ScopedCrateTypes}; use crate::language::SupportedLanguage; use crate::parser::ParsedData; -use crate::rust_types::{RustConst, RustEnum, RustField, RustStruct, RustTypeAlias}; +use crate::rust_types::{RustConst, RustEnum, RustField, RustItem, RustStruct, RustTypeAlias}; use crate::rust_types::{RustEnumShared, RustTypeFormatError, SpecialRustType}; +use crate::topsort::topsort; +use std::io::BufWriter; use std::{collections::HashMap, io::Write}; /// All information needed for Java type-code @@ -26,6 +31,66 @@ pub struct Java { } impl Language for Java { + /// Given `data`, generate type-code for this language and write it out to `writable`. + /// Returns whether or not writing was successful. + fn generate_types( + &mut self, + writable: &mut dyn Write, + all_types: &CrateTypes, + data: ParsedData, + ) -> std::io::Result<()> { + self.begin_file(writable, &data)?; + + if data.multi_file { + self.write_imports(writable, used_imports(&data, all_types))?; + } + + let ParsedData { + structs, + enums, + aliases, + consts, + crate_name, + .. + } = data; + + let namespace_class_name = crate_name.as_str().to_case(Case::Pascal); + + writeln!(writable, "public class {namespace_class_name} {{")?; + writeln!(writable)?; + + let mut items = Vec::from_iter( + aliases + .into_iter() + .map(RustItem::Alias) + .chain(structs.into_iter().map(RustItem::Struct)) + .chain(enums.into_iter().map(RustItem::Enum)) + .chain(consts.into_iter().map(RustItem::Const)), + ); + + topsort(&mut items); + + for thing in &items { + let mut thing_writer = BufWriter::new(Vec::new()); + + match thing { + RustItem::Enum(e) => self.write_enum(&mut thing_writer, e)?, + RustItem::Struct(s) => self.write_struct(&mut thing_writer, s)?, + RustItem::Alias(a) => self.write_type_alias(&mut thing_writer, a)?, + RustItem::Const(c) => self.write_const(&mut thing_writer, c)?, + } + + let thing_bytes = thing_writer.into_inner()?; + let thing = self.indent(String::from_utf8(thing_bytes).unwrap(), 1); + + writable.write(thing.as_bytes())?; + } + + writeln!(writable, "}}")?; + + self.end_file(writable) + } + fn type_map(&mut self) -> &HashMap { &self.type_mappings } @@ -109,9 +174,9 @@ impl Language for Java { writeln!(w)?; } if parsed_data.multi_file { - writeln!(w, "package {}.{}", self.package, parsed_data.crate_name)?; + writeln!(w, "package {}.{};", self.package, parsed_data.crate_name)?; } else { - writeln!(w, "package {}", self.package)?; + writeln!(w, "package {};", self.package)?; } writeln!(w)?; } @@ -365,4 +430,18 @@ impl Java { .iter() .try_for_each(|comment| self.write_comment(w, indent, comment)) } + + fn indent(&self, str: impl AsRef, count: usize) -> String { + let indentation = " ".repeat(count); + str.as_ref() + .split('\n') + .map(|line| { + if line.is_empty() { + line.to_string() + } else { + format!("{indentation}{line}") + } + }) + .join("\n") + } }