1use std::collections::HashSet;
2
3use crate::codegen::traits::file_writer::FileInfo;
4use crate::generators::request_inputs::{
5 RequestInputField, RequestInputFieldKind, RequestInputModel, RequestInputPlan,
6};
7use crate::ir::types::{
8 IrEnum, IrEnumValueType, IrIntersection, IrObject, IrSchema, IrSchemaKind, IrSpec,
9 IrTaggedUnion, IrTypeExpr, IrUnion, TaggingStyle,
10};
11use heck::{ToLowerCamelCase, ToPascalCase};
12use sigil_stitch::lang::java::Java;
13use sigil_stitch::prelude::*;
14
15use super::util::{
16 build_java_getter, escape_java_string, java_boxed_type_str, java_field_name, java_getter_name,
17 java_type_str, type_uses_list, type_uses_map, unique_name,
18};
19
20const RENDER_WIDTH: usize = 100;
21
22pub fn generate_model_files(
23 ir: &IrSpec,
24 package_name: &str,
25 header: &str,
26 request_inputs: &RequestInputPlan,
27) -> Result<Vec<FileInfo>, String> {
28 let mut files = Vec::new();
29 for (_name, schema) in &ir.schemas {
30 let body = emit_model_body(schema, package_name).ok_or_else(|| {
31 format!(
32 "unsupported schema kind for {}: {:?}",
33 schema.name, schema.kind
34 )
35 })?;
36 let class_name = schema.name.to_pascal_case();
37 let filename = format!("{class_name}.java");
38 let mut content = String::with_capacity(header.len() + body.len());
39 content.push_str(header);
40 content.push_str(&body);
41 files.push(FileInfo::model(filename, content));
42 }
43 for model in request_inputs.models() {
44 files.push(request_input_model_file(model, package_name, header));
45 }
46 Ok(files)
47}
48
49fn request_input_model_file(
50 model: &RequestInputModel,
51 package_name: &str,
52 header: &str,
53) -> FileInfo {
54 let class_name = model.name.to_pascal_case();
55 let needs_upload = model.fields.iter().any(RequestInputField::is_upload);
56 let mut content = String::new();
57 content.push_str(header);
58 content.push_str(&format!("package {package_name}.models;\n\n"));
59 if needs_upload {
60 content.push_str(&format!("import {package_name}.runtime.UploadFile;\n\n"));
61 }
62 let needs_list = model
63 .fields
64 .iter()
65 .any(|field| type_uses_list(&field.type_expr));
66 if needs_list {
67 content.push_str("import java.util.List;\n");
68 }
69 let needs_map = model
70 .fields
71 .iter()
72 .any(|field| type_uses_map(&field.type_expr));
73 if needs_map {
74 content.push_str("import java.util.Map;\n");
75 }
76 if needs_list || needs_map {
77 content.push('\n');
78 }
79 content.push_str(&format!("public final class {class_name} {{\n"));
80 for field in &model.fields {
81 content.push_str(&format!(
82 " private final {} {};\n",
83 request_input_java_type(field),
84 java_field_name(&field.wire_name)
85 ));
86 }
87 content.push('\n');
88 content.push_str(&format!(" public {class_name}("));
89 let params = model
90 .fields
91 .iter()
92 .map(|field| {
93 format!(
94 "{} {}",
95 request_input_java_type(field),
96 java_field_name(&field.wire_name)
97 )
98 })
99 .collect::<Vec<_>>()
100 .join(", ");
101 content.push_str(¶ms);
102 content.push_str(") {\n");
103 for field in &model.fields {
104 let name = java_field_name(&field.wire_name);
105 content.push_str(&format!(" this.{name} = {name};\n"));
106 }
107 content.push_str(" }\n\n");
108 for field in &model.fields {
109 let field_name = java_field_name(&field.wire_name);
110 let getter = java_getter_name(&field.wire_name);
111 content.push_str(&format!(
112 " public {} {}() {{\n return {};\n }}\n\n",
113 request_input_java_type(field),
114 getter,
115 field_name
116 ));
117 }
118 content.push_str("}\n");
119
120 FileInfo::model(format!("{class_name}.java"), content)
121}
122
123fn request_input_java_type(field: &RequestInputField) -> String {
124 match field.kind {
125 RequestInputFieldKind::UploadFile { .. } => "UploadFile".to_string(),
126 RequestInputFieldKind::SchemaValue => java_boxed_type_str(&field.type_expr),
127 }
128}
129
130fn emit_model_body(schema: &IrSchema, package_name: &str) -> Option<String> {
131 let file_spec = match &schema.kind {
132 IrSchemaKind::Object(obj) => emit_object(schema, obj, package_name),
133 IrSchemaKind::Enum(en) => emit_enum(schema, en, package_name),
134 IrSchemaKind::Alias(expr) => emit_alias(schema, expr, package_name),
135 IrSchemaKind::Union(u) => emit_union(schema, u, package_name),
136 IrSchemaKind::Intersection(i) => emit_intersection(schema, i, package_name),
137 IrSchemaKind::TaggedUnion(tu) => emit_tagged_union(schema, tu, package_name),
138 }?;
139 file_spec.render(RENDER_WIDTH).ok()
140}
141
142fn package_header(package_name: &str) -> CodeBlock {
143 sigil_quote!(Java {
144 package $L(format!("{package_name}.models"));
145 })
146 .expect("package header builds")
147}
148
149fn emit_object(schema: &IrSchema, obj: &IrObject, package_name: &str) -> Option<FileSpec> {
154 let name = schema.name.to_pascal_case();
155
156 let mut file =
157 FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
158
159 let needs_serialized_name = obj.properties.iter().any(|(json_name, prop)| {
160 let field_name = java_field_name(&prop.name);
161 *json_name != field_name
162 });
163 if needs_serialized_name {
164 file = file.add_import(ImportSpec::named(
165 "com.google.gson.annotations",
166 "SerializedName",
167 ));
168 }
169
170 let needs_list = obj
171 .properties
172 .iter()
173 .any(|(_, prop)| type_uses_list(&prop.type_expr));
174 if needs_list {
175 file = file.add_import(ImportSpec::named("java.util", "List"));
176 }
177
178 let needs_map = obj
179 .properties
180 .iter()
181 .any(|(_, prop)| type_uses_map(&prop.type_expr));
182 if needs_map {
183 file = file.add_import(ImportSpec::named("java.util", "Map"));
184 }
185
186 let mut tb = TypeSpec::builder(&name, TypeKind::Struct).visibility(Visibility::Public);
187 if let Some(doc) = &schema.description {
188 tb = tb.doc(doc);
189 }
190
191 for (json_name, prop) in &obj.properties {
193 let field_name = java_field_name(&prop.name);
194 let required = prop.required && !prop.nullable;
195 let type_str = if required {
196 java_type_str(&prop.type_expr)
197 } else {
198 java_boxed_type_str(&prop.type_expr)
199 };
200
201 let annotation = if *json_name != field_name {
202 Some(format!(
203 "@SerializedName(\"{}\")",
204 escape_java_string(json_name)
205 ))
206 } else {
207 None
208 };
209
210 let mut fb = FieldSpec::builder(&field_name, TypeName::primitive(&type_str))
211 .visibility(Visibility::Private);
212 if let Some(ann) = annotation {
213 fb = fb.annotation(CodeBlock::of(&ann, ()).expect("annotation"));
214 }
215 tb = tb.add_field(fb.build().expect("field"));
216 }
217
218 let mut ctor = FunSpec::builder(&name);
220 ctor = ctor.visibility(Visibility::Public);
221 for (_json_name, prop) in &obj.properties {
222 let field_name = java_field_name(&prop.name);
223 let required = prop.required && !prop.nullable;
224 let type_str = if required {
225 java_type_str(&prop.type_expr)
226 } else {
227 java_boxed_type_str(&prop.type_expr)
228 };
229 ctor = ctor.add_param(
230 ParameterSpec::new(&format!("{type_str} {field_name}"), TypeName::primitive(""))
231 .expect("ctor param"),
232 );
233 }
234 let assignment_fields: Vec<String> = obj
235 .properties
236 .iter()
237 .map(|(_json_name, prop)| java_field_name(&prop.name))
238 .collect();
239 let ctor_body = sigil_quote!(Java {
240 $for(field_name in &assignment_fields) {
241 this.$L(field_name.as_str()) = $L(field_name.as_str());
242 }
243 })
244 .expect("ctor body");
245 ctor = ctor.body(ctor_body);
246 tb = tb.add_method(ctor.build().expect("constructor"));
247
248 for (_json_name, prop) in &obj.properties {
250 let field_name = java_field_name(&prop.name);
251 let required = prop.required && !prop.nullable;
252 let type_str = if required {
253 java_type_str(&prop.type_expr)
254 } else {
255 java_boxed_type_str(&prop.type_expr)
256 };
257 let getter_name = java_getter_name(&prop.name);
258 tb = tb.add_method(build_java_getter(&getter_name, &type_str, &field_name));
259 }
260
261 file = file.add_type(tb.build().ok()?);
262 file.build().ok()
263}
264
265fn emit_enum(schema: &IrSchema, en: &IrEnum, package_name: &str) -> Option<FileSpec> {
270 let name = schema.name.to_pascal_case();
271
272 let mut file =
273 FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
274
275 if en.value_type == IrEnumValueType::Mixed {
276 return emit_comment_class(&name, "Object", schema.description.as_deref(), package_name);
277 }
278
279 file = file.add_import(ImportSpec::named(
280 "com.google.gson.annotations",
281 "SerializedName",
282 ));
283
284 let base_type = match en.value_type {
285 IrEnumValueType::String => "String",
286 IrEnumValueType::Integer => "int",
287 IrEnumValueType::Number => "double",
288 IrEnumValueType::Mixed => unreachable!(),
289 };
290
291 let mut tb = TypeSpec::builder(&name, TypeKind::Enum).visibility(Visibility::Public);
292 if let Some(doc) = &schema.description {
293 tb = tb.doc(doc);
294 }
295
296 for v in &en.values {
297 let (variant_name, literal, raw_value) = match en.value_type {
298 IrEnumValueType::String => {
299 let s = v.value.as_str()?;
300 (
301 enum_variant_name(s),
302 format!("\"{}\"", escape_java_string(s)),
303 s.to_string(),
304 )
305 }
306 IrEnumValueType::Integer | IrEnumValueType::Number => {
307 let n = v.value.as_number()?;
308 let variant = format!("N{}", n.to_string().replace(['-', '.'], "_"));
309 let s = n.to_string();
310 (variant, s.clone(), s)
311 }
312 IrEnumValueType::Mixed => unreachable!(),
313 };
314
315 let variant_name = if variant_name.is_empty() {
316 "UNKNOWN".to_string()
317 } else {
318 variant_name
319 };
320
321 let mut ev = EnumVariantSpec::builder(&variant_name);
322 ev = ev.annotation(
323 CodeBlock::of(
324 &format!("@SerializedName(\"{}\")", escape_java_string(&raw_value)),
325 (),
326 )
327 .expect("annotation"),
328 );
329 ev = ev.value(CodeBlock::of(&literal, ()).expect("literal"));
330 tb = tb.add_variant(ev.build().expect("enum variant"));
331 }
332
333 tb = tb.add_field(
335 FieldSpec::builder("value", TypeName::primitive(base_type))
336 .visibility(Visibility::Private)
337 .is_readonly()
338 .build()
339 .expect("value field"),
340 );
341
342 let mut ctor = FunSpec::builder(&name);
343 ctor = ctor.add_param(
344 ParameterSpec::new("value", TypeName::primitive(base_type)).expect("ctor param"),
345 );
346 let ctor_body = sigil_quote!(Java {
347 this.value = value;
348 })
349 .expect("ctor body");
350 ctor = ctor.body(ctor_body);
351 tb = tb.add_method(ctor.build().expect("enum ctor"));
352
353 tb = tb.add_method(build_java_getter("getValue", base_type, "value"));
354
355 file = file.add_type(tb.build().ok()?);
356 file.build().ok()
357}
358
359fn emit_alias(schema: &IrSchema, expr: &IrTypeExpr, package_name: &str) -> Option<FileSpec> {
364 let name = schema.name.to_pascal_case();
365 let rhs = java_type_str(expr);
366 emit_comment_class(&name, &rhs, schema.description.as_deref(), package_name)
367}
368
369fn emit_union(schema: &IrSchema, _union: &IrUnion, package_name: &str) -> Option<FileSpec> {
374 let name = schema.name.to_pascal_case();
375 emit_comment_class(&name, "Object", schema.description.as_deref(), package_name)
376}
377
378fn emit_intersection(
383 schema: &IrSchema,
384 inter: &IrIntersection,
385 package_name: &str,
386) -> Option<FileSpec> {
387 let name = schema.name.to_pascal_case();
388 let mut file =
389 FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
390
391 let mut tb = TypeSpec::builder(&name, TypeKind::Struct).visibility(Visibility::Public);
392 if let Some(doc) = &schema.description {
393 tb = tb.doc(doc);
394 }
395
396 let mut used_names: HashSet<String> = HashSet::new();
397 let member_bindings: Vec<(String, String)> = inter
398 .members
399 .iter()
400 .map(|member| {
401 let member_type = java_type_str(member);
402 let field_name = unique_name(&member_type.to_lower_camel_case(), &mut used_names);
403 (member_type, field_name)
404 })
405 .collect();
406 if member_bindings
407 .iter()
408 .any(|(member_type, _)| member_type.contains("List<"))
409 {
410 file = file.add_import(ImportSpec::named("java.util", "List"));
411 }
412 if member_bindings
413 .iter()
414 .any(|(member_type, _)| member_type.contains("Map<"))
415 {
416 file = file.add_import(ImportSpec::named("java.util", "Map"));
417 }
418
419 for (member_type, field_name) in &member_bindings {
420 tb = tb.add_field(
421 FieldSpec::builder(field_name, TypeName::primitive(member_type))
422 .visibility(Visibility::Private)
423 .build()
424 .expect("field"),
425 );
426 }
427
428 let mut ctor = FunSpec::builder(&name);
430 ctor = ctor.visibility(Visibility::Public);
431 for (member_type, field_name) in &member_bindings {
432 ctor = ctor.add_param(
433 ParameterSpec::new(
434 &format!("{member_type} {field_name}"),
435 TypeName::primitive(""),
436 )
437 .expect("param"),
438 );
439 }
440 let assignment_fields: Vec<String> = member_bindings
441 .iter()
442 .map(|(_member_type, field_name)| field_name.clone())
443 .collect();
444 let ctor_body = sigil_quote!(Java {
445 $for(field_name in &assignment_fields) {
446 this.$L(field_name.as_str()) = $L(field_name.as_str());
447 }
448 })
449 .expect("ctor body");
450 ctor = ctor.body(ctor_body);
451 tb = tb.add_method(ctor.build().expect("constructor"));
452
453 for (member_type, field_name) in &member_bindings {
455 let getter_name = format!("get{}", field_name.to_pascal_case());
456 tb = tb.add_method(build_java_getter(&getter_name, member_type, field_name));
457 }
458
459 file = file.add_type(tb.build().ok()?);
460 file.build().ok()
461}
462
463fn emit_tagged_union(
468 schema: &IrSchema,
469 tu: &IrTaggedUnion,
470 package_name: &str,
471) -> Option<FileSpec> {
472 let name = schema.name.to_pascal_case();
473
474 if tu.variants.is_empty() {
475 return emit_comment_class(&name, "Object", schema.description.as_deref(), package_name);
476 }
477
478 let hint = match &tu.tagging {
479 TaggingStyle::Internal => {
480 format!("Discriminator: {} (internal).", tu.discriminator_field)
481 }
482 TaggingStyle::Adjacent { content_field } => format!(
483 "Discriminator: {} / content: {} (adjacent).",
484 tu.discriminator_field, content_field
485 ),
486 TaggingStyle::External => "Discriminator: variant key (external).".to_string(),
487 };
488 let doc = match &schema.description {
489 Some(desc) => format!("{desc}\n\n{hint}"),
490 None => hint,
491 };
492
493 emit_comment_class(&name, "Object", Some(&doc), package_name)
494}
495
496fn emit_comment_class(
501 name: &str,
502 underlying_type: &str,
503 doc: Option<&str>,
504 package_name: &str,
505) -> Option<FileSpec> {
506 let mut file =
507 FileSpec::builder_with("model.java", Java::new()).header(package_header(package_name));
508 if underlying_type.contains("List<") {
509 file = file.add_import(ImportSpec::named("java.util", "List"));
510 }
511 if underlying_type.contains("Map<") {
512 file = file.add_import(ImportSpec::named("java.util", "Map"));
513 }
514
515 let mut tb = TypeSpec::builder(name, TypeKind::Struct).visibility(Visibility::Public);
516 if let Some(d) = doc {
517 tb = tb.doc(d);
518 }
519
520 tb = tb.add_field(
522 FieldSpec::builder("value", TypeName::primitive(underlying_type))
523 .visibility(Visibility::Private)
524 .build()
525 .expect("value field"),
526 );
527
528 let mut ctor = FunSpec::builder(name);
530 ctor = ctor.visibility(Visibility::Public);
531 ctor = ctor.add_param(
532 ParameterSpec::new(&format!("{underlying_type} value"), TypeName::primitive(""))
533 .expect("param"),
534 );
535 let body = sigil_quote!(Java {
536 this.value = value;
537 })
538 .expect("ctor body");
539 ctor = ctor.body(body);
540 tb = tb.add_method(ctor.build().expect("constructor"));
541
542 tb = tb.add_method(build_java_getter("getValue", underlying_type, "value"));
544
545 file = file.add_type(tb.build().ok()?);
546 file.build().ok()
547}
548
549fn enum_variant_name(s: &str) -> String {
550 let upper: String = s
551 .chars()
552 .map(|c| {
553 if c.is_alphanumeric() {
554 c.to_ascii_uppercase()
555 } else {
556 '_'
557 }
558 })
559 .collect();
560 if upper.is_empty() {
561 "UNKNOWN".to_string()
562 } else if upper.chars().next().unwrap().is_ascii_digit() {
563 format!("N{upper}")
564 } else {
565 upper
566 }
567}