use alef_core::ir::{EnumDef, FunctionDef, ParamDef, PrimitiveType, TypeDef, TypeRef};
use std::collections::BTreeSet;
use super::helpers::emit_cleaned_kdoc;
use super::shared::{kotlin_field_name, to_lower_camel, to_screaming_snake};
use crate::type_map::KotlinMapper;
use alef_codegen::type_mapper::TypeMapper;
fn primitive_type_name(pt: &PrimitiveType) -> &'static str {
use alef_core::ir::PrimitiveType;
match pt {
PrimitiveType::Bool => "Boolean",
PrimitiveType::I8 => "Byte",
PrimitiveType::I16 => "Short",
PrimitiveType::I32 => "Int",
PrimitiveType::I64 => "Long",
PrimitiveType::U8 => "Byte",
PrimitiveType::U16 => "Short",
PrimitiveType::U32 => "Int",
PrimitiveType::U64 => "Long",
PrimitiveType::F32 => "Float",
PrimitiveType::F64 => "Double",
PrimitiveType::Usize | PrimitiveType::Isize => "Long",
}
}
fn kotlin_zero_value(rendered: &str) -> &'static str {
match rendered.trim_end_matches('?') {
"Boolean" => "false",
"Byte" | "Short" | "Int" => "0",
"Long" => "0L",
"Float" => "0.0f",
"Double" => "0.0",
"String" => "\"\"",
_ => "null",
}
}
const KTFMT_LINE_WIDTH: usize = 100;
fn fits_single_line(indent: &str, prefix: &str, field_strings: &[String], suffix: &str) -> bool {
let fields_inline = field_strings.join(", ");
let total = indent.len() + prefix.len() + 1 + fields_inline.len() + 1 + suffix.len();
total <= KTFMT_LINE_WIDTH
}
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>,
) {
emit_cleaned_kdoc(out, &ty.doc, "");
if ty.fields.is_empty() {
out.push_str(&crate::template_env::render(
"empty_class.jinja",
minijinja::context! {
name => &ty.name,
},
));
return;
}
let field_sealed_annotations: Vec<Option<String>> = ty
.fields
.iter()
.map(|f| sealed_class_field_annotation(&f.ty, sealed_class_names))
.collect();
let has_field_docs = ty.fields.iter().any(|f| !f.doc.is_empty());
let has_field_annotations =
ty.fields.iter().any(|f| f.serde_rename.is_some()) || field_sealed_annotations.iter().any(Option::is_some);
let has_flatten_field = ty.fields.iter().any(|f| f.serde_flatten);
let mut field_strings: Vec<String> = Vec::with_capacity(ty.fields.len());
for (idx, field) in ty.fields.iter().enumerate() {
let ty_str = kotlin_type_with_string_imports(&field.ty, field.optional, imports);
let name = kotlin_field_name(&field.name, idx);
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 prefix = format!("data class {}", ty.name);
let use_single_line = !has_field_docs
&& !has_field_annotations
&& !has_flatten_field
&& 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(&format!("{prefix}({})\n", field_strings.join(", ")));
} else {
out.push_str(&format!("{prefix}(\n"));
for (idx, (field, field_str)) in ty.fields.iter().zip(field_strings.iter()).enumerate() {
emit_cleaned_kdoc(out, &field.doc, " ");
if let Some(rename) = &field.serde_rename {
out.push_str(&format!(
" @com.fasterxml.jackson.annotation.JsonProperty(\"{}\")\n",
escape_kotlin_string(rename)
));
}
if let Some(annotation) = &field_sealed_annotations[idx] {
out.push_str(" ");
out.push_str(annotation);
out.push('\n');
}
let comma = if idx + 1 == ty.fields.len() { "" } else { "," };
out.push_str(&format!(" {field_str}{comma}\n"));
}
out.push_str(")\n");
}
}
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 {
if 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 {
if sealed_class_names.contains(name) {
return Some(format!(
"@field:com.fasterxml.jackson.databind.annotation.JsonSerialize(contentAs = {name}::class)"
));
}
}
None
}
_ => None,
}
}
pub(crate) fn emit_enum(en: &EnumDef, out: &mut String, package: &str) {
emit_cleaned_kdoc(out, &en.doc, "");
let all_unit = en.variants.iter().all(|v| v.fields.is_empty());
if all_unit {
out.push_str(&crate::template_env::render(
"enum_class_header.jinja",
minijinja::context! {
name => &en.name,
},
));
let names: Vec<String> = en.variants.iter().map(|v| to_screaming_snake(&v.name)).collect();
for (idx, name) in names.iter().enumerate() {
emit_cleaned_kdoc(out, &en.variants[idx].doc, " ");
let discriminator = variant_discriminator(&en.variants[idx], en.serde_rename_all.as_deref());
if discriminator != *name {
out.push_str(&format!(
" @com.fasterxml.jackson.annotation.JsonProperty(\"{}\")\n",
escape_kotlin_string(&discriminator)
));
}
let comma = if idx + 1 == names.len() { ";" } else { "," };
out.push_str(&crate::template_env::render(
"enum_variant.jinja",
minijinja::context! {
name => name,
comma => comma,
},
));
}
out.push_str("\n @com.fasterxml.jackson.annotation.JsonValue\n");
out.push_str(" fun toWire(): String = when (this) {\n");
for (idx, name) in names.iter().enumerate() {
let discriminator = variant_discriminator(&en.variants[idx], en.serde_rename_all.as_deref());
out.push_str(&format!(
" {} -> \"{}\"\n",
name,
escape_kotlin_string(&discriminator)
));
}
out.push_str(" }\n");
out.push_str("\n companion object {\n");
out.push_str(" @com.fasterxml.jackson.annotation.JsonCreator\n");
out.push_str(" @JvmStatic\n");
out.push_str(" fun fromWire(value: String): ");
out.push_str(&en.name);
out.push_str(" = when (value) {\n");
for (idx, name) in names.iter().enumerate() {
let discriminator = variant_discriminator(&en.variants[idx], en.serde_rename_all.as_deref());
out.push_str(&format!(
" \"{}\" -> {}\n",
escape_kotlin_string(&discriminator),
name
));
}
out.push_str(" else -> throw IllegalArgumentException(\"Unknown ");
out.push_str(&en.name);
out.push_str(" value: $value\")\n");
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str("}\n");
} else {
let needs_deserializer = en.serde_tag.is_some() || en.serde_untagged;
if needs_deserializer {
out.push_str("@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ");
out.push_str(&en.name);
out.push_str("Deserializer::class)\n");
}
let needs_serializer = en.serde_tag.is_some() || en.serde_untagged;
if needs_serializer {
out.push_str("@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = ");
out.push_str(&en.name);
out.push_str("Serializer::class)\n");
}
out.push_str(&crate::template_env::render(
"sealed_class_header.jinja",
minijinja::context! {
name => &en.name,
},
));
let variant_names: std::collections::HashSet<&str> = en.variants.iter().map(|v| v.name.as_str()).collect();
for variant in &en.variants {
emit_cleaned_kdoc(out, &variant.doc, " ");
if variant.fields.is_empty() {
out.push_str(&crate::template_env::render(
"sealed_object_variant.jinja",
minijinja::context! {
name => &variant.name,
parent_name => &en.name,
},
));
} else {
let is_newtype_variant = variant.fields.len() == 1 && is_tuple_field_name(&variant.fields[0].name);
let emit_reset = !is_newtype_variant;
if needs_deserializer && emit_reset {
out.push_str(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None::class)\n");
}
if needs_serializer && emit_reset {
out.push_str(" @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.JsonSerializer.None::class)\n");
}
let has_annotations = (needs_deserializer || needs_serializer) && emit_reset;
let mut variant_field_strings: Vec<String> = Vec::with_capacity(variant.fields.len());
for (idx, f) in variant.fields.iter().enumerate() {
let ty_str = kotlin_type_disambiguated(&f.ty, f.optional, &variant_names, package);
let field_type_name = match &f.ty {
TypeRef::Named(name) => Some(name.as_str()),
TypeRef::String => Some("String"),
TypeRef::Primitive(p) => Some(primitive_type_name(p)),
_ => None,
};
let name = super::shared::kotlin_field_name_with_type(
&f.name,
idx,
field_type_name,
&variant.name,
variant.fields.len(),
);
variant_field_strings.push(format!("val {name}: {ty_str}"));
}
let variant_prefix = format!("data class {}", variant.name);
let variant_suffix = format!(" : {}()", en.name);
let use_single_line = !has_annotations
&& fits_single_line(" ", &variant_prefix, &variant_field_strings, &variant_suffix);
if use_single_line {
out.push_str(&format!(
" {variant_prefix}({fields}){variant_suffix}\n",
fields = variant_field_strings.join(", ")
));
} else {
out.push_str(&format!(" {variant_prefix}(\n"));
let last_idx = variant_field_strings.len().saturating_sub(1);
for (i, field_str) in variant_field_strings.iter().enumerate() {
let comma = if i < last_idx { "," } else { "" };
out.push_str(&format!(" {field_str}{comma}\n"));
}
out.push_str(&format!(" ){variant_suffix}\n"));
}
}
}
out.push_str("}\n");
if needs_deserializer {
if let Some(tag_field) = &en.serde_tag {
emit_kotlin_tagged_deserializer(out, en, tag_field);
} else if en.serde_untagged {
emit_kotlin_untagged_deserializer(out, en);
}
}
if let Some(tag_field) = &en.serde_tag {
emit_kotlin_tagged_serializer(out, en, tag_field);
} else if en.serde_untagged {
emit_kotlin_untagged_serializer(out, en);
}
}
}
fn variant_discriminator(variant: &alef_core::ir::EnumVariant, rename_all: Option<&str>) -> String {
if let Some(r) = &variant.serde_rename {
return r.clone();
}
match rename_all {
Some("snake_case") => heck::ToSnakeCase::to_snake_case(variant.name.as_str()),
Some("camelCase") => heck::ToLowerCamelCase::to_lower_camel_case(variant.name.as_str()),
Some("PascalCase") => heck::ToPascalCase::to_pascal_case(variant.name.as_str()),
Some("SCREAMING_SNAKE_CASE") => heck::ToSnakeCase::to_snake_case(variant.name.as_str()).to_uppercase(),
Some("kebab-case") => heck::ToKebabCase::to_kebab_case(variant.name.as_str()),
Some("SCREAMING-KEBAB-CASE") => heck::ToKebabCase::to_kebab_case(variant.name.as_str()).to_uppercase(),
Some("lowercase") => variant.name.to_lowercase(),
Some("UPPERCASE") => variant.name.to_uppercase(),
_ => variant.name.clone(),
}
}
fn is_tuple_field_name(name: &str) -> bool {
let stripped = name.trim_start_matches('_');
!stripped.is_empty() && stripped.chars().all(|c| c.is_ascii_digit())
}
fn kotlin_class_name_for_type(ty: &TypeRef) -> String {
match ty {
TypeRef::String => "String".to_string(),
TypeRef::Primitive(p) => {
use alef_core::ir::PrimitiveType;
match p {
PrimitiveType::Bool => "Boolean".to_string(),
PrimitiveType::U8 | PrimitiveType::I8 => "Byte".to_string(),
PrimitiveType::U16 | PrimitiveType::I16 => "Short".to_string(),
PrimitiveType::U32 | PrimitiveType::I32 => "Int".to_string(),
PrimitiveType::U64 | PrimitiveType::I64 | PrimitiveType::Usize | PrimitiveType::Isize => {
"Long".to_string()
}
PrimitiveType::F32 => "Float".to_string(),
PrimitiveType::F64 => "Double".to_string(),
}
}
TypeRef::Named(n) => n.clone(),
TypeRef::Vec(_) => "List".to_string(),
TypeRef::Map(_, _) => "Map".to_string(),
_ => "Any".to_string(),
}
}
fn emit_kotlin_tagged_serializer(out: &mut String, en: &EnumDef, tag_field: &str) {
let name = &en.name;
out.push('\n');
out.push_str("private class ");
out.push_str(name);
out.push_str("Serializer : com.fasterxml.jackson.databind.ser.std.StdSerializer<");
out.push_str(name);
out.push_str(">(");
out.push_str(name);
out.push_str("::class.java) {\n");
out.push_str(" @Suppress(\"LongMethod\")\n");
out.push_str(" override fun serialize(\n");
out.push_str(" value: ");
out.push_str(name);
out.push_str(",\n");
out.push_str(" gen: com.fasterxml.jackson.core.JsonGenerator,\n");
out.push_str(" provider: com.fasterxml.jackson.databind.SerializerProvider,\n");
out.push_str(" ) {\n");
out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n");
out.push_str(" val mapper = (gen.codec as? com.fasterxml.jackson.databind.ObjectMapper) ?: com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()\n");
out.push_str(" val node: com.fasterxml.jackson.databind.node.ObjectNode = when (value) {\n");
for variant in &en.variants {
let discriminator = variant_discriminator(variant, en.serde_rename_all.as_deref());
out.push_str(" is ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(" -> {\n");
if variant.fields.is_empty() {
out.push_str(" val n = mapper.createObjectNode()\n");
out.push_str(" n.put(\"");
out.push_str(tag_field);
out.push_str("\", \"");
out.push_str(&discriminator);
out.push_str("\")\n");
out.push_str(" n\n");
} else if variant.fields.len() == 1 && is_tuple_field_name(&variant.fields[0].name) {
let field = &variant.fields[0];
let field_name = super::shared::kotlin_field_name_with_type(
&field.name,
0,
match &field.ty {
TypeRef::Named(n) => Some(n.as_str()),
TypeRef::String => Some("String"),
TypeRef::Primitive(p) => Some(primitive_type_name(p)),
_ => None,
},
&variant.name,
1,
);
out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n");
out.push_str(
" val n = mapper.valueToTree<com.fasterxml.jackson.databind.node.ObjectNode>(value.",
);
out.push_str(&field_name);
out.push_str(") as com.fasterxml.jackson.databind.node.ObjectNode\n");
out.push_str(" n.put(\"");
out.push_str(tag_field);
out.push_str("\", \"");
out.push_str(&discriminator);
out.push_str("\")\n");
out.push_str(" n\n");
} else {
out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n");
out.push_str(
" val n = mapper.valueToTree<com.fasterxml.jackson.databind.node.ObjectNode>(value as ",
);
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(") as com.fasterxml.jackson.databind.node.ObjectNode\n");
out.push_str(" n.put(\"");
out.push_str(tag_field);
out.push_str("\", \"");
out.push_str(&discriminator);
out.push_str("\")\n");
out.push_str(" n\n");
}
out.push_str(" }\n");
}
out.push_str(" }\n");
out.push_str(" mapper.writeTree(gen, node)\n");
out.push_str(" }\n");
out.push_str("}\n");
}
fn emit_kotlin_tagged_deserializer(out: &mut String, en: &EnumDef, tag_field: &str) {
let name = &en.name;
out.push('\n');
out.push_str("private class ");
out.push_str(name);
out.push_str("Deserializer : com.fasterxml.jackson.databind.deser.std.StdDeserializer<");
out.push_str(name);
out.push_str(">(");
out.push_str(name);
out.push_str("::class.java) {\n");
out.push_str(" @Suppress(\"LongMethod\")\n");
out.push_str(" override fun deserialize(\n");
out.push_str(" parser: com.fasterxml.jackson.core.JsonParser,\n");
out.push_str(" ctx: com.fasterxml.jackson.databind.DeserializationContext,\n");
out.push_str(" ): ");
out.push_str(name);
out.push_str(" {\n");
out.push_str(" val node = parser.codec.readTree<com.fasterxml.jackson.databind.node.ObjectNode>(parser)\n");
out.push_str(" val tag = node.get(\"");
out.push_str(tag_field);
out.push_str("\")?.asText()\n");
out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n");
out.push_str(
" val payload = (node.deepCopy() as com.fasterxml.jackson.databind.node.ObjectNode).apply { remove(\"",
);
out.push_str(tag_field);
out.push_str("\") }\n");
out.push_str(" return when (tag) {\n");
for variant in &en.variants {
let discriminator = variant_discriminator(variant, en.serde_rename_all.as_deref());
out.push_str(" \"");
out.push_str(&discriminator);
out.push_str("\" -> ");
if variant.fields.is_empty() {
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push('\n');
} else if variant.fields.len() == 1 && is_tuple_field_name(&variant.fields[0].name) {
let inner_class = kotlin_class_name_for_type(&variant.fields[0].ty);
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str("(ctx.readTreeAsValue<");
out.push_str(&inner_class);
out.push_str(">(payload, ");
out.push_str(&inner_class);
out.push_str("::class.java))\n");
} else {
out.push_str("ctx.readTreeAsValue<");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(">(payload, ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str("::class.java)\n");
}
}
out.push_str(" else -> throw com.fasterxml.jackson.databind.exc.InvalidFormatException(\n");
out.push_str(" parser, \"Unknown ");
out.push_str(name);
out.push_str(" tag\", tag, ");
out.push_str(name);
out.push_str("::class.java,\n");
out.push_str(" )\n");
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str("}\n");
}
fn emit_kotlin_untagged_deserializer(out: &mut String, en: &EnumDef) {
let name = &en.name;
out.push('\n');
out.push_str("private class ");
out.push_str(name);
out.push_str("Deserializer : com.fasterxml.jackson.databind.deser.std.StdDeserializer<");
out.push_str(name);
out.push_str(">(");
out.push_str(name);
out.push_str("::class.java) {\n");
out.push_str(" @Suppress(\"LongMethod\")\n");
out.push_str(" override fun deserialize(\n");
out.push_str(" parser: com.fasterxml.jackson.core.JsonParser,\n");
out.push_str(" ctx: com.fasterxml.jackson.databind.DeserializationContext,\n");
out.push_str(" ): ");
out.push_str(name);
out.push_str(" {\n");
out.push_str(" val node = parser.codec.readTree<com.fasterxml.jackson.databind.JsonNode>(parser)\n");
for variant in &en.variants {
if variant.fields.is_empty() {
continue;
}
let (condition, inner_expr) = if variant.fields.len() == 1 && is_tuple_field_name(&variant.fields[0].name) {
let ty = &variant.fields[0].ty;
match ty {
TypeRef::String => ("node.isTextual", format!("{name}.{}(node.asText())", variant.name)),
TypeRef::Vec(elem_ty) => {
let elem_class = kotlin_class_name_for_type(elem_ty);
let expr = format!(
"run {{\n val javaType = ctx.typeFactory.constructCollectionType(List::class.java, {elem_class}::class.java)\n @Suppress(\"UNCHECKED_CAST\")\n {name}.{}(ctx.readTreeAsValue<List<{elem_class}>>(node, javaType) as List<{elem_class}>)\n }}",
variant.name,
);
("node.isArray", expr)
}
TypeRef::Primitive(_) => {
let class_name = kotlin_class_name_for_type(ty);
(
"node.isNumber",
format!(
"{name}.{}(ctx.readTreeAsValue(node, {class_name}::class.java))",
variant.name
),
)
}
TypeRef::Named(n) => {
(
"true", format!(
"try {{ {name}.{}(ctx.readTreeAsValue(node, {n}::class.java)) }} catch (_: com.fasterxml.jackson.databind.exc.MismatchedInputException) {{ null as? {name} }} catch (_: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException) {{ null as? {name} }}",
variant.name
),
)
}
_ => {
let class_name = kotlin_class_name_for_type(ty);
(
"node.isObject",
format!(
"{name}.{}(ctx.readTreeAsValue(node, {class_name}::class.java))",
variant.name
),
)
}
}
} else {
let struct_class = format!("{name}.{}", variant.name);
(
"node.isObject",
format!("ctx.readTreeAsValue<{struct_class}>(node, {struct_class}::class.java)"),
)
};
out.push_str(" if (");
out.push_str(condition);
out.push_str(") ");
if condition == "true" && inner_expr.contains("try {") {
out.push_str("{\n");
out.push_str(" val result = ");
out.push_str(&inner_expr);
out.push('\n');
out.push_str(" if (result != null) return result\n");
out.push_str(" }\n");
} else {
out.push_str("return ");
out.push_str(&inner_expr);
out.push('\n');
}
}
out.push_str(" throw com.fasterxml.jackson.databind.exc.InvalidFormatException(\n");
out.push_str(" parser, \"Cannot deserialize ");
out.push_str(name);
out.push_str(": no matching variant for JSON shape\", null, ");
out.push_str(name);
out.push_str("::class.java,\n");
out.push_str(" )\n");
out.push_str(" }\n");
out.push_str("}\n");
}
fn emit_kotlin_untagged_serializer(out: &mut String, en: &EnumDef) {
let name = &en.name;
out.push('\n');
out.push_str("private class ");
out.push_str(name);
out.push_str("Serializer : com.fasterxml.jackson.databind.ser.std.StdSerializer<");
out.push_str(name);
out.push_str(">(");
out.push_str(name);
out.push_str("::class.java) {\n");
out.push_str(" @Suppress(\"LongMethod\")\n");
out.push_str(" override fun serialize(\n");
out.push_str(" value: ");
out.push_str(name);
out.push_str(",\n");
out.push_str(" gen: com.fasterxml.jackson.core.JsonGenerator,\n");
out.push_str(" provider: com.fasterxml.jackson.databind.SerializerProvider,\n");
out.push_str(" ) {\n");
out.push_str(" @Suppress(\"UNCHECKED_CAST\")\n");
out.push_str(" val mapper = (gen.codec as? com.fasterxml.jackson.databind.ObjectMapper) ?: com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()\n");
out.push_str(" when (value) {\n");
for variant in &en.variants {
if variant.fields.is_empty() {
out.push_str(" is ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(" -> gen.writeNull()\n");
} else if variant.fields.len() == 1 && is_tuple_field_name(&variant.fields[0].name) {
let field = &variant.fields[0];
let field_name = super::shared::kotlin_field_name_with_type(
&field.name,
0,
match &field.ty {
TypeRef::Named(n) => Some(n.as_str()),
TypeRef::String => Some("String"),
TypeRef::Primitive(p) => Some(primitive_type_name(p)),
_ => None,
},
&variant.name,
1,
);
if let TypeRef::Vec(inner) = &field.ty {
if let TypeRef::Named(elem_type) = inner.as_ref() {
out.push_str(" is ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(" -> {\n");
out.push_str(" gen.writeStartArray()\n");
out.push_str(&format!(
" val elemSerializer = provider.findValueSerializer({}::class.java)\n",
elem_type
));
out.push_str(&format!(" for (elem in value.{field_name}) {{\n"));
out.push_str(" elemSerializer.serialize(elem, gen, provider)\n");
out.push_str(" }\n");
out.push_str(" gen.writeEndArray()\n");
out.push_str(" }\n");
} else {
out.push_str(" is ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(" -> mapper.writeValue(gen, value.");
out.push_str(&field_name);
out.push_str(")\n");
}
} else {
out.push_str(" is ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(" -> mapper.writeValue(gen, value.");
out.push_str(&field_name);
out.push_str(")\n");
}
} else {
out.push_str(" is ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(" -> mapper.writeValue(gen, value as ");
out.push_str(name);
out.push('.');
out.push_str(&variant.name);
out.push_str(")\n");
}
}
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str("}\n");
}
fn interpolate_error_message_template(template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut remaining = template;
while let Some(open) = remaining.find('{') {
let after_open = &remaining[open + 1..];
if let Some(close) = after_open.find('}') {
let token = &after_open[..close];
if token.chars().all(|c| c.is_ascii_digit()) && !token.is_empty() {
out.push_str(&remaining[..open]);
let after_close = &after_open[close + 1..];
let next_is_ident_cont = after_close
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
if next_is_ident_cont {
out.push_str("${field");
out.push_str(token);
out.push('}');
} else {
out.push_str("$field");
out.push_str(token);
}
remaining = &remaining[open + 1 + close + 1..];
continue;
}
}
out.push_str(&remaining[..open + 1]);
remaining = &remaining[open + 1..];
}
out.push_str(remaining);
out
}
pub(crate) fn emit_error_type_with_imports(
error: &alef_core::ir::ErrorDef,
out: &mut String,
imports: &mut BTreeSet<String>,
) {
emit_cleaned_kdoc(out, &error.doc, "");
out.push_str(&crate::template_env::render(
"error_sealed_class_header.jinja",
minijinja::context! {
name => &error.name,
},
));
for variant in &error.variants {
if variant.is_unit {
let raw_msg = variant.message_template.as_deref().unwrap_or(&variant.name);
let message = interpolate_error_message_template(raw_msg);
out.push_str(&crate::template_env::render(
"error_object_variant.jinja",
minijinja::context! {
name => &variant.name,
parent_name => &error.name,
message => message,
},
));
} else {
let raw_msg = variant.message_template.as_deref().unwrap_or(&variant.name);
let message = interpolate_error_message_template(raw_msg);
let mut err_field_strings: Vec<String> = Vec::with_capacity(variant.fields.len());
for (idx, f) in variant.fields.iter().enumerate() {
let ty_str = kotlin_type_with_string_imports(&f.ty, f.optional, imports);
let name = kotlin_field_name(&f.name, idx);
let modifier = if name == "message" { "override " } else { "" };
err_field_strings.push(format!("{modifier}val {name}: {ty_str}"));
}
let err_prefix = format!("data class {}", variant.name);
let err_suffix = format!(" : {}(\"{message}\")", error.name);
let use_single_line = fits_single_line(" ", &err_prefix, &err_field_strings, &err_suffix);
if use_single_line {
out.push_str(&format!(
" {err_prefix}({fields}){err_suffix}\n",
fields = err_field_strings.join(", ")
));
} else {
out.push_str(&format!(" {err_prefix}(\n"));
let last_idx = err_field_strings.len().saturating_sub(1);
for (i, field_str) in err_field_strings.iter().enumerate() {
let comma = if i < last_idx { "," } else { "" };
out.push_str(&format!(" {field_str}{comma}\n"));
}
out.push_str(&format!(" ){err_suffix}\n"));
}
}
}
for method in error.methods.iter().filter(|m| !m.sanitized) {
let prop_name = to_lower_camel(&method.name);
let ty_str = kotlin_type_with_string_imports(&method.return_type, false, imports);
let default = kotlin_zero_value(&ty_str);
out.push_str(&format!(" open val {prop_name}: {ty_str} = {default}\n"));
}
out.push_str("}\n");
}
pub(crate) fn emit_function(
f: &FunctionDef,
out: &mut String,
imports: &mut BTreeSet<String>,
_java_package: &str,
client_type_names: &std::collections::HashSet<&str>,
) {
emit_cleaned_kdoc(out, &f.doc, " ");
let params: Vec<String> = f.params.iter().map(|p| format_param_with_imports(p, imports)).collect();
let return_ty = kotlin_type_with_string_imports(&f.return_type, false, imports);
let async_kw = if f.is_async { "suspend " } else { "" };
let func_name_camel = to_lower_camel(&f.name);
let call_args = f
.params
.iter()
.map(|p| to_lower_camel(&p.name))
.collect::<Vec<_>>()
.join(", ");
let returns_client_type = match &f.return_type {
TypeRef::Named(n) => client_type_names.contains(n.as_str()),
_ => false,
};
out.push_str(&crate::template_env::render(
"function_signature.jinja",
minijinja::context! {
async_kw => async_kw,
name => func_name_camel,
params => params.join(", "),
return_type => return_ty,
},
));
out.push('\n');
let optional_suffix = if matches!(f.return_type, TypeRef::Optional(_)) && !returns_client_type {
".orElse(null)"
} else {
""
};
if f.is_async {
if returns_client_type {
let wrapper = return_ty.trim_end_matches('?');
out.push_str(&format!(
" return withContext(Dispatchers.IO) {{ {wrapper}(Bridge.{func_name_camel}({call_args})) }}\n"
));
} else {
out.push_str(&format!(
" return withContext(Dispatchers.IO) {{\n Bridge.{func_name_camel}({call_args}){optional_suffix}\n }}\n"
));
}
} else if matches!(f.return_type, TypeRef::Unit) {
out.push_str(&crate::template_env::render(
"bridge_call_unit.jinja",
minijinja::context! {
name => func_name_camel,
args => call_args,
},
));
out.push('\n');
} else if returns_client_type {
let wrapper = return_ty.trim_end_matches('?');
out.push_str(&format!(
" return {wrapper}(Bridge.{func_name_camel}({call_args}))\n"
));
} else {
out.push_str(&format!(
" return Bridge.{func_name_camel}({call_args}){optional_suffix}\n"
));
}
out.push_str(" }\n");
}
pub(crate) fn format_param_with_imports(p: &ParamDef, imports: &mut BTreeSet<String>) -> String {
let ty_str = kotlin_type_with_string_imports(&p.ty, p.optional, imports);
let default = if p.optional { " = null" } else { "" };
format!("{}: {}{}", to_lower_camel(&p.name), ty_str, default)
}
pub(crate) fn kotlin_type_with_string_imports(ty: &TypeRef, optional: bool, imports: &mut BTreeSet<String>) -> String {
let inner = render_type_ref_with_string_imports(ty, imports);
if optional { format!("{inner}?") } else { inner }
}
fn render_type_ref_with_string_imports(ty: &TypeRef, imports: &mut BTreeSet<String>) -> String {
let mapper = KotlinMapper;
match ty {
TypeRef::Path => {
imports.insert("import java.nio.file.Path".to_string());
mapper.map_type(ty)
}
TypeRef::Duration => {
imports.insert("import kotlin.time.Duration".to_string());
mapper.map_type(ty)
}
TypeRef::Optional(inner) => format!("{}?", render_type_ref_with_string_imports(inner, imports)),
TypeRef::Vec(inner) => {
format!("List<{}>", render_type_ref_with_string_imports(inner, imports))
}
TypeRef::Map(k, v) => {
format!(
"Map<{}, {}>",
render_type_ref_with_string_imports(k, imports),
render_type_ref_with_string_imports(v, imports)
)
}
_ => mapper.map_type(ty),
}
}
fn kotlin_field_default(
ty: &TypeRef,
optional: bool,
typed_default: Option<&alef_core::ir::DefaultValue>,
enum_defaults: &std::collections::HashMap<String, String>,
default_constructible_types: &std::collections::HashSet<String>,
) -> String {
if let Some(default) = typed_default {
if optional && matches!(default, alef_core::ir::DefaultValue::Empty) {
return " = null".to_string();
}
if let Some(literal) = render_kotlin_default(ty, default, enum_defaults, default_constructible_types) {
return format!(" = {literal}");
}
}
if optional {
return " = null".to_string();
}
match ty {
TypeRef::Optional(_) => " = null".to_string(),
TypeRef::Vec(_) => " = emptyList()".to_string(),
TypeRef::Map(_, _) => " = emptyMap()".to_string(),
_ => String::new(),
}
}
fn render_kotlin_default(
ty: &TypeRef,
default: &alef_core::ir::DefaultValue,
enum_defaults: &std::collections::HashMap<String, String>,
default_constructible_types: &std::collections::HashSet<String>,
) -> Option<String> {
use alef_core::ir::DefaultValue;
match default {
DefaultValue::BoolLiteral(b) => Some(b.to_string()),
DefaultValue::IntLiteral(n) => {
use alef_core::ir::PrimitiveType;
if matches!(ty, TypeRef::Duration) {
Some(format!("{n}.milliseconds"))
}
else if matches!(ty, TypeRef::Primitive(p) if matches!(p,
PrimitiveType::I64 | PrimitiveType::U64
| PrimitiveType::Usize | PrimitiveType::Isize))
{
Some(format!("{n}L"))
} else {
Some(n.to_string())
}
}
DefaultValue::FloatLiteral(f) => {
use alef_core::ir::PrimitiveType;
if matches!(ty, TypeRef::Primitive(PrimitiveType::F32)) {
Some(format!("{f}f"))
} else {
Some(f.to_string())
}
}
DefaultValue::StringLiteral(s) => {
Some(format!("\"{}\"", escape_kotlin_string(s)))
}
DefaultValue::EnumVariant(variant) => match ty {
TypeRef::Named(name) => {
if enum_defaults.contains_key(name.as_str()) {
Some(format!("{name}.{}", to_screaming_snake(variant)))
} else {
Some(format!("{name}.{}", variant))
}
}
_ => None,
},
DefaultValue::Empty => match ty {
TypeRef::Vec(_) => Some("emptyList()".to_string()),
TypeRef::Map(_, _) => Some("emptyMap()".to_string()),
TypeRef::Optional(_) => Some("null".to_string()),
TypeRef::String => Some("\"\"".to_string()),
TypeRef::Primitive(p) => {
use alef_core::ir::PrimitiveType;
match p {
PrimitiveType::Bool => Some("false".to_string()),
PrimitiveType::F32 => Some("0.0f".to_string()),
PrimitiveType::F64 => Some("0.0".to_string()),
PrimitiveType::I64 | PrimitiveType::U64 | PrimitiveType::Usize | PrimitiveType::Isize => {
Some("0L".to_string())
}
_ => Some("0".to_string()),
}
}
TypeRef::Named(name) => {
if let Some(variant) = enum_defaults.get(name.as_str()) {
let value = variant.as_str();
if value.is_empty() {
None
} else {
Some(format!("{name}.{}", to_screaming_snake(value)))
}
} else if default_constructible_types.contains(name.as_str()) {
Some(format!("{name}()"))
} else {
None
}
}
_ => None,
},
DefaultValue::None => Some("null".to_string()),
}
}
fn escape_kotlin_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
fn kotlin_type_disambiguated(
ty: &TypeRef,
optional: bool,
variant_names: &std::collections::HashSet<&str>,
package: &str,
) -> String {
let inner = render_type_ref_disambiguated(ty, variant_names, package);
if optional { format!("{inner}?") } else { inner }
}
fn render_type_ref_disambiguated(
ty: &TypeRef,
variant_names: &std::collections::HashSet<&str>,
package: &str,
) -> String {
let list_name = if variant_names.contains("List") {
"kotlin.collections.List"
} else {
"List"
};
let map_name = if variant_names.contains("Map") {
"kotlin.collections.Map"
} else {
"Map"
};
match ty {
TypeRef::Named(n) if !package.is_empty() && variant_names.contains(n.as_str()) => {
format!("{package}.{n}")
}
TypeRef::Optional(inner) => {
format!("{}?", render_type_ref_disambiguated(inner, variant_names, package))
}
TypeRef::Vec(inner) => {
format!(
"{list_name}<{}>",
render_type_ref_disambiguated(inner, variant_names, package),
)
}
TypeRef::Map(k, v) => {
format!(
"{map_name}<{}, {}>",
render_type_ref_disambiguated(k, variant_names, package),
render_type_ref_disambiguated(v, variant_names, package),
)
}
_ => {
render_type_ref_with_imports(ty, &mut BTreeSet::new())
}
}
}
fn render_type_ref_with_imports(ty: &TypeRef, imports: &mut BTreeSet<&'static str>) -> String {
let mapper = KotlinMapper;
match ty {
TypeRef::Path => {
imports.insert("import java.nio.file.Path");
mapper.map_type(ty)
}
TypeRef::Duration => {
imports.insert("import kotlin.time.Duration");
mapper.map_type(ty)
}
TypeRef::Optional(inner) => format!("{}?", render_type_ref_with_imports(inner, imports)),
TypeRef::Vec(inner) => {
format!("List<{}>", render_type_ref_with_imports(inner, imports))
}
TypeRef::Map(k, v) => {
format!(
"Map<{}, {}>",
render_type_ref_with_imports(k, imports),
render_type_ref_with_imports(v, imports)
)
}
_ => mapper.map_type(ty),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alef_core::ir::{CoreWrapper, EnumVariant, FieldDef};
fn make_field(name: &str, ty: TypeRef) -> FieldDef {
FieldDef {
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: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}
}
fn make_variant(name: &str, serde_rename: Option<&str>, fields: Vec<FieldDef>) -> EnumVariant {
EnumVariant {
name: name.to_string(),
fields,
doc: String::new(),
is_default: false,
serde_rename: serde_rename.map(str::to_string),
is_tuple: false,
}
}
fn make_enum(
name: &str,
serde_tag: Option<&str>,
serde_untagged: bool,
serde_rename_all: Option<&str>,
variants: Vec<EnumVariant>,
) -> EnumDef {
EnumDef {
name: name.to_string(),
rust_path: format!("crate::{name}"),
original_rust_path: format!("crate::{name}"),
variants,
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: true,
serde_tag: serde_tag.map(str::to_string),
serde_untagged,
serde_rename_all: serde_rename_all.map(str::to_string),
binding_excluded: false,
binding_exclusion_reason: None,
}
}
#[test]
fn emit_enum_tagged_sealed_class_emits_json_deserialize_annotation() {
let en = make_enum(
"Message",
Some("role"),
false,
None,
vec![
make_variant(
"System",
Some("system"),
vec![make_field("_0", TypeRef::Named("SystemMessage".to_string()))],
),
make_variant(
"User",
Some("user"),
vec![make_field("_0", TypeRef::Named("UserMessage".to_string()))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(
"@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = MessageDeserializer::class)"
),
"missing @JsonDeserialize annotation on tagged sealed class; got:\n{out}",
);
assert!(
out.contains("private class MessageDeserializer"),
"missing MessageDeserializer class; got:\n{out}",
);
assert!(
out.contains("node.get(\"role\")"),
"deserializer must read the 'role' tag field; got:\n{out}",
);
assert!(
out.contains("\"system\" ->"),
"deserializer must dispatch on variant 'system'; got:\n{out}",
);
assert!(
out.contains("val tag = node.get(\"role\")?.asText()"),
"tagged deserializer must extract tag into separate variable; got:\n{out}",
);
assert!(
out.contains("val payload = (node.deepCopy() as com.fasterxml.jackson.databind.node.ObjectNode).apply { remove(\"role\") }"),
"tagged deserializer must strip tag field from payload via cast-safe deepCopy; got:\n{out}",
);
assert!(
out.contains("Message.System(ctx.readTreeAsValue<SystemMessage>(payload, SystemMessage::class.java))"),
"tagged deserializer must wrap readTreeAsValue<InnerType>(payload) in variant constructor for newtype; got:\n{out}",
);
}
#[test]
fn emit_enum_untagged_sealed_class_emits_json_deserialize_annotation() {
let en = make_enum(
"EmbeddingInput",
None,
true,
None,
vec![
make_variant("Single", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Multiple",
None,
vec![make_field("_0", TypeRef::Vec(Box::new(TypeRef::String)))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(
"@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = EmbeddingInputDeserializer::class)"
),
"missing @JsonDeserialize annotation on untagged sealed class; got:\n{out}",
);
assert!(
out.contains("private class EmbeddingInputDeserializer"),
"missing EmbeddingInputDeserializer class; got:\n{out}",
);
assert!(
out.contains("node.isTextual"),
"untagged deserializer must check isTextual for String variant; got:\n{out}",
);
assert!(
out.contains("node.isArray"),
"untagged deserializer must check isArray for List variant; got:\n{out}",
);
assert!(
out.contains("ctx.typeFactory.constructCollectionType(List::class.java, String::class.java)"),
"untagged deserializer must use constructCollectionType for List<String> variant; got:\n{out}",
);
assert!(
!out.contains("ctx.readTreeAsValue(node, List::class.java)"),
"untagged deserializer must NOT use raw List::class.java; got:\n{out}",
);
}
#[test]
fn tagged_deserializer_named_field_variant_no_double_wrap() {
let en = make_enum(
"OcrDocument",
Some("type"),
false,
Some("snake_case"),
vec![
make_variant("Url", Some("url"), vec![make_field("url", TypeRef::String)]),
make_variant(
"Base64",
Some("base64"),
vec![
make_field("data", TypeRef::String),
make_field("media_type", TypeRef::Named("MediaType".to_string())),
],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(
"\"base64\" -> ctx.readTreeAsValue<OcrDocument.Base64>(payload, OcrDocument.Base64::class.java)"
),
"tagged deserializer must return readTreeAsValue<T>(payload) directly for named-field variant; got:\n{out}",
);
assert!(
!out.contains("OcrDocument.Base64(ctx.readTreeAsValue"),
"tagged deserializer must NOT wrap readTreeAsValue result in variant constructor; got:\n{out}",
);
}
#[test]
fn tagged_deserializer_newtype_variant_no_double_wrap() {
let en = make_enum(
"ContentPart",
Some("type"),
false,
Some("snake_case"),
vec![make_variant(
"Text",
Some("text"),
vec![make_field("_0", TypeRef::Named("TextContent".to_string()))],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("ContentPart.Text(ctx.readTreeAsValue<TextContent>(payload, TextContent::class.java))"),
"tagged deserializer must wrap readTreeAsValue<InnerType>(payload) in variant constructor for newtype variant; got:\n{out}",
);
}
#[test]
fn untagged_deserializer_list_of_named_type_uses_java_type() {
let en = make_enum(
"UserContent",
None,
true,
None,
vec![
make_variant("Text", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Parts",
None,
vec![make_field(
"_0",
TypeRef::Vec(Box::new(TypeRef::Named("ContentPart".to_string()))),
)],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("ctx.typeFactory.constructCollectionType(List::class.java, ContentPart::class.java)"),
"untagged deserializer must use constructCollectionType for List<ContentPart>; got:\n{out}",
);
assert!(
!out.contains("ctx.readTreeAsValue(node, List::class.java)"),
"untagged deserializer must NOT use raw List::class.java for List<ContentPart>; got:\n{out}",
);
}
#[test]
fn emit_enum_unit_only_does_not_emit_json_deserialize() {
let en = make_enum(
"FinishReason",
None,
false,
None,
vec![make_variant("Stop", None, vec![]), make_variant("Length", None, vec![])],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
!out.contains("@JsonDeserialize") && !out.contains("Deserializer"),
"unit-only enum must not emit a deserializer; got:\n{out}",
);
assert!(
out.contains("enum class FinishReason"),
"must emit enum class; got:\n{out}"
);
}
#[test]
fn tagged_deserializer_strips_tag_field_from_payload() {
let en = make_enum(
"Message",
Some("role"),
false,
None,
vec![make_variant(
"System",
Some("system"),
vec![make_field("_0", TypeRef::Named("SystemMessage".to_string()))],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("val tag = node.get(\"role\")?.asText()"),
"deserializer must extract tag into a local variable; got:\n{out}",
);
assert!(
out.contains(
"val payload = (node.deepCopy() as com.fasterxml.jackson.databind.node.ObjectNode).apply { remove(\"role\") }"
),
"deserializer must create tag-stripped payload via cast-safe deepCopy; got:\n{out}",
);
assert!(
out.contains("return when (tag)"),
"deserializer must dispatch on extracted tag variable; got:\n{out}",
);
assert!(
out.contains("readTreeAsValue<SystemMessage>(payload, SystemMessage::class.java)"),
"deserializer must pass tag-stripped payload to readTreeAsValue; got:\n{out}",
);
assert!(
!out.contains("readTreeAsValue<SystemMessage>(node, SystemMessage::class.java)"),
"deserializer must NOT pass un-stripped node to readTreeAsValue; got:\n{out}",
);
}
#[test]
fn sealed_class_variant_field_type_qualified_when_name_clashes_with_sibling_variant() {
let en = make_enum(
"ContentPart",
Some("type"),
false,
None,
vec![
make_variant("Text", Some("text"), vec![make_field("text", TypeRef::String)]),
make_variant(
"ImageUrl",
Some("image_url"),
vec![make_field("image_url", TypeRef::Named("ImageUrl".to_string()))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "dev.kreuzberg.literllm.android");
assert!(
out.contains("val imageUrl: dev.kreuzberg.literllm.android.ImageUrl"),
"variant field type must be package-qualified when it clashes with a sibling variant name; got:\n{out}",
);
assert!(
out.contains("data class ImageUrl("),
"variant class declaration must still use simple name; got:\n{out}",
);
}
#[test]
fn sealed_class_variant_field_type_unqualified_when_no_clash() {
let en = make_enum(
"ContentPart",
Some("type"),
false,
None,
vec![make_variant(
"Document",
Some("document"),
vec![make_field("document", TypeRef::Named("DocumentContent".to_string()))],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "dev.kreuzberg.literllm.android");
assert!(
out.contains("val document: DocumentContent"),
"non-clashing field type must remain unqualified; got:\n{out}",
);
assert!(
!out.contains("dev.kreuzberg.literllm.android.DocumentContent"),
"non-clashing field type must not be package-qualified; got:\n{out}",
);
}
#[test]
fn sealed_class_variant_data_classes_get_json_deserialize_reset_annotation() {
let en = make_enum(
"OcrDocument",
Some("type"),
false,
Some("snake_case"),
vec![
make_variant("Url", Some("document_url"), vec![make_field("url", TypeRef::String)]),
make_variant(
"Base64",
Some("base64"),
vec![
make_field("data", TypeRef::String),
make_field("mediaType", TypeRef::String),
],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None::class)\n @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.JsonSerializer.None::class)\n data class Url("),
"Url variant must have @JsonDeserialize(using=None) and @JsonSerialize(using=None) reset annotations; got:\n{out}",
);
assert!(
out.contains(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None::class)\n @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.JsonSerializer.None::class)\n data class Base64("),
"Base64 variant must have @JsonDeserialize(using=None) and @JsonSerialize(using=None) reset annotations; got:\n{out}",
);
}
#[test]
fn untagged_sealed_class_vec_variant_serializer_uses_declared_type_serializer() {
let en = make_enum(
"UserContent",
None,
true,
None,
vec![
make_variant("Text", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Parts",
None,
vec![make_field(
"_0",
TypeRef::Vec(Box::new(TypeRef::Named("ContentPart".to_string()))),
)],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
!out.contains(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize\n @com.fasterxml.jackson.databind.annotation.JsonSerialize\n data class Text("),
"Text newtype variant must NOT have reset annotations; got:\n{out}",
);
assert!(
out.contains("provider.findValueSerializer(ContentPart::class.java)"),
"Parts serializer must use provider.findValueSerializer(ContentPart::class.java); got:\n{out}",
);
assert!(
out.contains("is UserContent.Text -> mapper.writeValue(gen, value.value)"),
"Text serializer must use mapper.writeValue; got:\n{out}",
);
}
#[test]
fn untagged_serializer_tuple_variant_uses_payload_derived_field_name() {
let en = make_enum(
"EmbeddingInput",
None,
true,
None,
vec![
make_variant("Single", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Multiple",
None,
vec![make_field("_0", TypeRef::Vec(Box::new(TypeRef::String)))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("-> mapper.writeValue(gen, value.value)"),
"untagged serializer must use payload-derived field name `value`; got:\n{out}",
);
assert!(
!out.contains("value.field0"),
"untagged serializer must NOT use hardcoded `field0`; got:\n{out}",
);
}
#[test]
fn tagged_serializer_named_field_variant_casts_to_concrete_type() {
let en = make_enum(
"OcrDocument",
Some("type"),
false,
Some("snake_case"),
vec![make_variant(
"Url",
Some("document_url"),
vec![make_field("url", TypeRef::String)],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("mapper.valueToTree<com.fasterxml.jackson.databind.node.ObjectNode>(value as OcrDocument.Url) as com.fasterxml.jackson.databind.node.ObjectNode"),
"tagged serializer must cast value to concrete variant type; got:\n{out}",
);
assert!(
!out.contains("mapper.valueToTree<com.fasterxml.jackson.databind.node.ObjectNode>(value) as"),
"tagged serializer must NOT call valueToTree on un-cast parent-type value; got:\n{out}",
);
}
}