1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
use crate::core::ir::{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::kotlin_field_name;
use heck::ToLowerCamelCase;
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::backends::kotlin::template_env::render(
"empty_class.jinja",
minijinja::context! {
name => &ty.name,
},
));
return;
}
// Include all fields, including those marked as binding_excluded.
// binding_excluded fields are generated as nullable with null defaults so JSON
// deserialization tolerates missing fields (Rust carries Default::skip for them).
let visible_fields: Vec<(usize, &crate::core::ir::FieldDef)> = ty.fields.iter().enumerate().collect();
// Pre-compute the per-field JsonSerialize annotation needed when the
// declared field type references a sealed class. Jackson dispatches
// serializers by RUNTIME type, so a `data class Parent(val foo: Sealed)`
// would look up the serializer for the concrete variant (e.g.
// `Sealed.Variant`) — which carries `@JsonSerialize(using =
// JsonSerializer.None::class)` to break the deserializer recursion
// protection — and emit a default POJO instead of routing through the
// parent's custom `SealedSerializer`. `@field:JsonSerialize(\`as\` = ...)`
// forces Jackson to use the DECLARED static type for serializer lookup,
// which carries the custom serializer. For collections we use
// `contentAs` on the element type.
//
// The annotation must be attached to the underlying field (not the
// constructor parameter) because Kotlin defaults annotations on primary
// constructor parameters to the parameter use-site, but Jackson reads
// field-level annotations. Hence the `@field:` site target.
// Only compute for visible fields.
let field_sealed_annotations: Vec<Option<String>> = visible_fields
.iter()
.map(|(_, f)| sealed_class_field_annotation(&f.ty, sealed_class_names))
.collect();
// Pre-build field strings so we can apply the ktfmt single-line heuristic
// before committing to an emission style. Field-level KDoc or @JsonProperty
// annotations force multi-line because they cannot be inlined inside a
// constructor parameter list.
let has_field_docs = visible_fields.iter().any(|(_, f)| !f.doc.is_empty());
let has_field_annotations = visible_fields.iter().any(|(_, f)| f.serde_rename.is_some())
|| field_sealed_annotations.iter().any(Option::is_some);
// Detect `#[serde(flatten)]` fields. In Rust these collect all unknown
// wire fields into a value (often `serde_json::Value` or `HashMap`); Kotlin
// has no native equivalent. As a pragmatic mitigation, treat the flatten
// field as a nullable bag (default null) AND emit
// `@JsonIgnoreProperties(ignoreUnknown = true)` on the class so Jackson
// tolerates the unknown sibling keys that Rust would have absorbed.
// Note: this is lossy — the flatten contents aren't actually captured in
// the Kotlin struct, but the deserialiser no longer fails outright.
let has_flatten_field = visible_fields.iter().any(|(_, f)| f.serde_flatten);
let mut field_strings: 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);
// Append a Kotlin default for fields whose underlying Rust type has a
// natural empty value. Rust serializers commonly skip Default-valued
// collections (`#[serde(skip_serializing_if = "...")]`) or skip a
// field entirely under a feature gate (`#[serde(skip)]`). Without a
// Kotlin-side default the Jackson Kotlin module fails the entire
// deserialization with `MissingKotlinParameterException` whenever the
// wire JSON omits the key — even if the Rust source carries `Default`.
let (effective_ty_str, default_suffix) = if field.binding_excluded {
// binding_excluded fields must be nullable with null default so JSON
// deserialization tolerates missing fields (Rust carries #[serde(skip)]).
let nullable_ty = if ty_str.ends_with('?') {
ty_str.clone()
} else {
format!("{ty_str}?")
};
(nullable_ty, " = null".to_string())
} else if field.serde_flatten {
// Force `T?` + default null for flatten fields (see has_flatten_field above).
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,
);
// A `Duration` default is rendered with the `.milliseconds` extension
// property, which is not in scope without an explicit import.
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}"));
}
// Non-opaque data classes may carry inherent instance methods (e.g. `attributes()`,
// `intoOwned()`). Kotlin requires these to live inside a class body `{ ... }` appended
// after the primary constructor — emitting them after a bare `)` is a syntax error.
// Determine up front whether a body is needed so the constructor close and the single-line
// shortcut can account for it.
use crate::codegen::shared::partition_methods;
let (instance_methods, _) = partition_methods(&ty.methods);
let instance_methods: Vec<_> = instance_methods.into_iter().filter(|m| !m.sanitized).collect();
let has_instance_methods = !instance_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)) in visible_fields.iter().zip(field_strings.iter()).enumerate() {
emit_cleaned_kdoc(out, &field.doc, " ");
// Emit @JsonProperty when the Rust field carries #[serde(rename = "...")]
// so Jackson maps the wire key to the Kotlin camelCase property name.
if let Some(rename) = &field.serde_rename {
out.push_str(&crate::backends::kotlin::template_env::render(
"json_property_annotation.jinja",
minijinja::context! {
indent => " ",
value => escape_kotlin_string(rename),
},
));
}
// Emit @field:JsonSerialize(`as` = …) / (contentAs = …) when the
// field's declared type references a sealed class. See the
// `field_sealed_annotations` precomputation above for the
// rationale.
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 => "",
// Open a class body when inherent instance methods follow, so they are
// emitted inside `data class Foo(...) { ... }` rather than after a bare `)`.
suffix => if has_instance_methods { " {" } else { "" },
},
));
}
// Emit inherent instance methods for non-opaque data classes.
// These are graceful stubs that throw UnsupportedOperationException since
// instance method bridging via JNI has not been implemented yet.
// They live inside the class body opened by the constructor close above.
for method in &instance_methods {
let method_name = heck::AsLowerCamelCase(method.name.as_str()).to_string();
let return_type_str = kotlin_type_with_string_imports(&method.return_type, false, imports);
// Build parameter signature
let params_sig: Vec<String> = method
.params
.iter()
.map(|p| {
let ptype = kotlin_type_with_string_imports(&p.ty, p.optional, imports);
let pname = p.name.to_lower_camel_case();
format!("{pname}: {ptype}")
})
.collect();
out.push_str("\n 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(" throw UnsupportedOperationException(\n");
out.push_str(" \"");
out.push_str(&method_name);
out.push_str(" is not yet bridged via JNI; reconstruct via Builder.\"\n");
out.push_str(" )\n");
out.push_str(" }\n");
}
// Close the class body opened by the constructor `) {` when instance methods were emitted.
if has_instance_methods {
out.push_str("}\n");
}
}
/// Return the `@field:JsonSerialize(...)` annotation source needed for a
/// field whose declared type references a sealed class, or `None` if the
/// type does not reference a sealed class.
///
/// Recognised shapes (Optional layers are unwrapped first):
/// - `Named(sealed)` → `@field:JsonSerialize(\`as\` = sealed::class)`
/// - `Vec<Named(sealed)>` → `@field:JsonSerialize(contentAs = sealed::class)`
/// - `Map<_, Named(sealed)>` → `@field:JsonSerialize(contentAs = sealed::class)`
///
/// Other shapes (nested generics, sealed inside `Map` key, …) are ignored —
/// they don't appear in the current codebase, and `contentAs` cannot express
/// them anyway.
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,
}
}