use crate::core::ir::{MethodDef, TypeDef, TypeRef};
use std::collections::BTreeSet;
use super::types::{escape_kotlin_string, fits_single_line, kotlin_field_default, kotlin_type_with_string_imports};
use crate::backends::kotlin::gen_bindings::helpers::emit_cleaned_kdoc;
use crate::backends::kotlin::gen_bindings::shared::{ValueMethodBridge, kotlin_field_name, to_lower_camel};
use crate::core::jni::{bridge_method_name, bridgeable_value_methods, is_functional_ref_mut_value_method};
const VALUE_METHOD_MAPPER: &str = "VALUE_METHOD_MAPPER";
fn kotlin_property_name_defeats_bean_introspection(camel_name: &str) -> bool {
let mut chars = camel_name.chars();
match (chars.next(), chars.next()) {
(Some(first), Some(second)) => first.is_ascii_lowercase() && second.is_ascii_uppercase(),
_ => false,
}
}
pub(crate) fn emit_type_with_imports(
ty: &TypeDef,
out: &mut String,
imports: &mut BTreeSet<String>,
enum_defaults: &std::collections::HashMap<String, String>,
sealed_class_names: &std::collections::HashSet<String>,
default_constructible_types: &std::collections::HashSet<String>,
value_method_bridge: Option<ValueMethodBridge<'_>>,
) {
emit_cleaned_kdoc(out, &ty.doc, "");
if ty.fields.is_empty() {
out.push_str(&crate::backends::kotlin::template_env::render(
"empty_class.jinja",
minijinja::context! {
name => &ty.name,
},
));
return;
}
let visible_fields: Vec<(usize, &crate::core::ir::FieldDef)> = ty
.fields
.iter()
.enumerate()
.filter(|(_, f)| !f.binding_excluded)
.collect();
let field_sealed_annotations: Vec<Option<String>> = visible_fields
.iter()
.map(|(_, f)| sealed_class_field_annotation(&f.ty, sealed_class_names))
.collect();
let has_field_docs = visible_fields.iter().any(|(_, f)| !f.doc.is_empty());
let has_flatten_field = visible_fields.iter().any(|(_, f)| f.serde_flatten);
let mut field_strings: Vec<String> = Vec::with_capacity(visible_fields.len());
let mut field_names: Vec<String> = Vec::with_capacity(visible_fields.len());
for (original_idx, field) in visible_fields.iter() {
let ty_str = kotlin_type_with_string_imports(&field.ty, field.optional, imports);
let name = kotlin_field_name(&field.name, *original_idx);
field_names.push(name.clone());
let (effective_ty_str, default_suffix) = if field.serde_flatten {
let nullable_ty = if ty_str.ends_with('?') {
ty_str.clone()
} else {
format!("{ty_str}?")
};
(nullable_ty, " = null".to_string())
} else {
let default_suffix = kotlin_field_default(
&field.ty,
field.optional,
field.typed_default.as_ref(),
enum_defaults,
default_constructible_types,
);
if default_suffix.contains(".milliseconds") {
imports.insert("import kotlin.time.Duration.Companion.milliseconds".to_string());
}
(ty_str, default_suffix)
};
field_strings.push(format!("val {name}: {effective_ty_str}{default_suffix}"));
}
let has_field_annotations = visible_fields.iter().any(|(_, f)| f.serde_rename.is_some())
|| field_sealed_annotations.iter().any(Option::is_some)
|| field_names
.iter()
.any(|name| kotlin_property_name_defeats_bean_introspection(name));
let bridged_methods: Vec<&MethodDef> = value_method_bridge
.map(|bridge| bridgeable_value_methods(ty, bridge.serde_type_names))
.unwrap_or_default();
let has_instance_methods = !bridged_methods.is_empty();
let prefix = format!("data class {}", ty.name);
let use_single_line = !has_field_docs
&& !has_field_annotations
&& !has_flatten_field
&& !has_instance_methods
&& fits_single_line("", &prefix, &field_strings, "");
if has_flatten_field {
out.push_str("@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)\n");
}
if use_single_line {
out.push_str(&crate::backends::kotlin::template_env::render(
"data_class_inline.jinja",
minijinja::context! {
prefix => prefix,
fields => field_strings.join(", "),
},
));
} else {
out.push_str(&crate::backends::kotlin::template_env::render(
"data_class_header_only.jinja",
minijinja::context! {
prefix => prefix,
},
));
for (idx, (((_, field), field_str), name)) in visible_fields
.iter()
.zip(field_strings.iter())
.zip(field_names.iter())
.enumerate()
{
emit_cleaned_kdoc(out, &field.doc, " ");
let wire_name = field
.serde_rename
.clone()
.or_else(|| kotlin_property_name_defeats_bean_introspection(name).then(|| field.name.clone()));
if let Some(wire_name) = &wire_name {
out.push_str(&crate::backends::kotlin::template_env::render(
"json_property_annotation.jinja",
minijinja::context! {
indent => " ",
value => escape_kotlin_string(wire_name),
},
));
}
if let Some(annotation) = &field_sealed_annotations[idx] {
out.push_str(" ");
out.push_str(annotation);
out.push('\n');
}
out.push_str(&crate::backends::kotlin::template_env::render(
"data_class_field_line.jinja",
minijinja::context! {
indent => " ",
field => field_str,
},
));
}
out.push_str(&crate::backends::kotlin::template_env::render(
"data_class_close.jinja",
minijinja::context! {
indent => "",
suffix => if has_instance_methods { " {" } else { "" },
},
));
}
if let Some(bridge) = value_method_bridge {
for &method in &bridged_methods {
emit_value_method(out, ty, method, imports, bridge);
}
}
if has_instance_methods {
out.push_str("}\n");
emit_value_method_mapper(out);
}
}
fn emit_value_method(
out: &mut String,
ty: &TypeDef,
method: &MethodDef,
imports: &mut BTreeSet<String>,
bridge: ValueMethodBridge<'_>,
) {
let method_name = to_lower_camel(&method.name);
let returns_receiver = is_functional_ref_mut_value_method(method);
let return_type = if returns_receiver {
TypeRef::Named(ty.name.clone())
} else {
method.return_type.clone()
};
let return_type_str = kotlin_type_with_string_imports(&return_type, false, imports);
let params_sig: Vec<String> = method
.params
.iter()
.map(|p| {
let ptype = kotlin_type_with_string_imports(&p.ty, p.optional, imports);
let pname = to_lower_camel(&p.name);
format!("{pname}: {ptype}")
})
.collect();
out.push('\n');
emit_cleaned_kdoc(out, &method.doc, " ");
out.push_str(" fun ");
out.push_str(&method_name);
out.push('(');
out.push_str(¶ms_sig.join(", "));
out.push_str("): ");
out.push_str(&return_type_str);
out.push_str(" {\n");
out.push_str(&format!(
" val selfJson = {VALUE_METHOD_MAPPER}.writeValueAsString(this)\n"
));
let mut call_args = vec!["selfJson".to_string()];
if !method.params.is_empty() {
out.push_str(&format!(
" val requestJson = {VALUE_METHOD_MAPPER}.writeValueAsString(\n mapOf(\n"
));
for param in &method.params {
let key = escape_kotlin_string(¶m.name.replace('-', "_"));
let value = to_lower_camel(¶m.name);
out.push_str(&format!(" \"{key}\" to {value},\n"));
}
out.push_str(" ),\n )\n");
call_args.push("requestJson".to_string());
}
let native_call = format!(
"{}.{}({})",
bridge.bridge_class,
bridge_method_name(&ty.name, &method.name),
call_args.join(", ")
);
match &return_type {
TypeRef::Unit => out.push_str(&format!(" {native_call}\n")),
TypeRef::Primitive(_) | TypeRef::String => out.push_str(&format!(" return {native_call}\n")),
_ => {
out.push_str(&format!(" val resultJson = {native_call}\n"));
out.push_str(&format!(" return {VALUE_METHOD_MAPPER}.readValue(\n"));
out.push_str(" resultJson,\n");
out.push_str(&format!(
" object : com.fasterxml.jackson.core.type.TypeReference<{return_type_str}>() {{}},\n"
));
out.push_str(" )\n");
}
}
out.push_str(" }\n");
}
fn emit_value_method_mapper(out: &mut String) {
let declaration = crate::backends::kotlin::template_env::render(
"value_method_mapper.jinja",
minijinja::context! {
name => VALUE_METHOD_MAPPER,
},
);
if !out.contains(&declaration) {
out.push('\n');
out.push_str(&declaration);
}
}
fn sealed_class_field_annotation(
ty: &TypeRef,
sealed_class_names: &std::collections::HashSet<String>,
) -> Option<String> {
let base = match ty {
TypeRef::Optional(inner) => inner.as_ref(),
other => other,
};
match base {
TypeRef::Named(name) if sealed_class_names.contains(name) => Some(format!(
"@field:com.fasterxml.jackson.databind.annotation.JsonSerialize(`as` = {name}::class)"
)),
TypeRef::Vec(inner) => {
let inner_base = match inner.as_ref() {
TypeRef::Optional(i) => i.as_ref(),
other => other,
};
if let TypeRef::Named(name) = inner_base
&& sealed_class_names.contains(name)
{
return Some(format!(
"@field:com.fasterxml.jackson.databind.annotation.JsonSerialize(contentAs = {name}::class)"
));
}
None
}
TypeRef::Map(_, value) => {
let value_base = match value.as_ref() {
TypeRef::Optional(i) => i.as_ref(),
other => other,
};
if let TypeRef::Named(name) = value_base
&& sealed_class_names.contains(name)
{
return Some(format!(
"@field:com.fasterxml.jackson.databind.annotation.JsonSerialize(contentAs = {name}::class)"
));
}
None
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{CoreWrapper, TypeDef};
fn make_field(name: &str, ty: TypeRef, serde_rename: Option<&str>) -> crate::core::ir::FieldDef {
crate::core::ir::FieldDef {
version: Default::default(),
name: name.to_string(),
ty,
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: serde_rename.map(str::to_string),
serde_flatten: false,
serde_with: None,
serde_skip_serializing_if: false,
serde_skip: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}
}
#[test]
fn json_property_annotation_targets_both_getter_and_constructor_parameter() {
let timeout_field = make_field(
"timeout_ms",
TypeRef::Primitive(crate::core::ir::PrimitiveType::U64),
Some("timeoutMs"),
);
let ty = TypeDef {
name: "ClientConfig".to_string(),
rust_path: "crate::ClientConfig".to_string(),
fields: vec![timeout_field],
has_serde: true,
..Default::default()
};
let mut out = String::new();
let mut imports = std::collections::BTreeSet::new();
emit_type_with_imports(
&ty,
&mut out,
&mut imports,
&std::collections::HashMap::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
None,
);
assert!(
out.contains(
" @get:com.fasterxml.jackson.annotation.JsonProperty(\"timeoutMs\")\n\
\x20\x20\x20\x20@param:com.fasterxml.jackson.annotation.JsonProperty(\"timeoutMs\")\n"
),
"renamed field must carry both an explicit @get: and @param: use-site target, \
adjacent and in that order: {out}"
);
assert!(
!out.contains("\n @com.fasterxml.jackson.annotation.JsonProperty("),
"the annotation must not be emitted with no explicit use-site target: {out}"
);
}
#[test]
fn value_method_mapper_uses_the_non_deprecated_default_property_inclusion_setter() {
let mut out = String::new();
emit_value_method_mapper(&mut out);
assert!(
out.contains(".setDefaultPropertyInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)"),
"value-method mapper must configure inclusion via the non-deprecated setter: {out}"
);
assert!(
!out.contains(".setSerializationInclusion("),
"value-method mapper must not call the deprecated (since Jackson 2.13) setSerializationInclusion: {out}"
);
}
#[test]
fn bean_introspection_predicate_fires_only_on_the_documented_shape() {
assert!(kotlin_property_name_defeats_bean_introspection("nWidgets"));
assert!(kotlin_property_name_defeats_bean_introspection("xOffset"));
assert!(!kotlin_property_name_defeats_bean_introspection("maxDepth"));
assert!(!kotlin_property_name_defeats_bean_introspection("text"));
assert!(!kotlin_property_name_defeats_bean_introspection(""));
assert!(!kotlin_property_name_defeats_bean_introspection("a"));
}
#[test]
fn json_property_is_emitted_for_names_that_defeat_bean_introspection() {
let vulnerable_field = make_field(
"n_widgets",
TypeRef::Primitive(crate::core::ir::PrimitiveType::U64),
None,
);
let ordinary_field = make_field(
"max_depth",
TypeRef::Primitive(crate::core::ir::PrimitiveType::U64),
None,
);
let ty = TypeDef {
name: "WidgetConfig".to_string(),
rust_path: "crate::WidgetConfig".to_string(),
fields: vec![vulnerable_field, ordinary_field],
has_serde: true,
..Default::default()
};
let mut out = String::new();
let mut imports = std::collections::BTreeSet::new();
emit_type_with_imports(
&ty,
&mut out,
&mut imports,
&std::collections::HashMap::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
None,
);
assert!(
out.contains(
" @get:com.fasterxml.jackson.annotation.JsonProperty(\"n_widgets\")\n\
\x20\x20\x20\x20@param:com.fasterxml.jackson.annotation.JsonProperty(\"n_widgets\")\n"
),
"a name shaped like [a-z][A-Z]... must get an explicit @get: (serialization) AND \
@param: (deserialization) wire-name annotation, adjacent and in that order — a \
@param:-only emission passes this field's *read* path while still breaking its \
*write* path, which is exactly the regression this test guards against: {out}"
);
assert!(
!out.contains("\"max_depth\""),
"an ordinary field name must not be annotated: {out}"
);
}
}