1use std::collections::HashSet;
2
3use crate::codegen::traits::file_writer::FileInfo;
4use crate::ir::types::{
5 IrEnum, IrEnumValueType, IrIntersection, IrObject, IrSchema, IrSchemaKind, IrSpec,
6 IrTaggedUnion, IrTypeExpr, IrUnion, TaggingStyle,
7};
8use heck::{ToLowerCamelCase, ToPascalCase};
9use sigil_stitch::lang::java_lang::JavaLang;
10use sigil_stitch::prelude::*;
11
12use super::util::{
13 build_java_getter, escape_java_string, java_boxed_type_str, java_field_name, java_type_str,
14 type_uses_list, type_uses_map, unique_name,
15};
16
17const RENDER_WIDTH: usize = 100;
18
19pub fn generate_model_files(
20 ir: &IrSpec,
21 package_name: &str,
22 header: &str,
23) -> Result<Vec<FileInfo>, String> {
24 let mut files = Vec::new();
25 for (_name, schema) in &ir.schemas {
26 let body = emit_model_body(schema, package_name).ok_or_else(|| {
27 format!(
28 "unsupported schema kind for {}: {:?}",
29 schema.name, schema.kind
30 )
31 })?;
32 let class_name = schema.name.to_pascal_case();
33 let filename = format!("{class_name}.java");
34 let mut content = String::with_capacity(header.len() + body.len());
35 content.push_str(header);
36 content.push_str(&body);
37 files.push(FileInfo::model(filename, content));
38 }
39 Ok(files)
40}
41
42fn emit_model_body(schema: &IrSchema, package_name: &str) -> Option<String> {
43 let file_spec = match &schema.kind {
44 IrSchemaKind::Object(obj) => emit_object(schema, obj, package_name),
45 IrSchemaKind::Enum(en) => emit_enum(schema, en, package_name),
46 IrSchemaKind::Alias(expr) => emit_alias(schema, expr, package_name),
47 IrSchemaKind::Union(u) => emit_union(schema, u, package_name),
48 IrSchemaKind::Intersection(i) => emit_intersection(schema, i, package_name),
49 IrSchemaKind::TaggedUnion(tu) => emit_tagged_union(schema, tu, package_name),
50 }?;
51 file_spec.render(RENDER_WIDTH).ok()
52}
53
54fn package_header(package_name: &str) -> CodeBlock {
55 sigil_quote!(JavaLang {
56 package $L(format!("{package_name}.models"));
57 })
58 .expect("package header builds")
59}
60
61fn emit_object(schema: &IrSchema, obj: &IrObject, package_name: &str) -> Option<FileSpec> {
66 let name = schema.name.to_pascal_case();
67
68 let mut file =
69 FileSpec::builder_with("model.java", JavaLang::new()).header(package_header(package_name));
70
71 let needs_serialized_name = obj.properties.iter().any(|(json_name, prop)| {
72 let field_name = java_field_name(&prop.name);
73 *json_name != field_name
74 });
75 if needs_serialized_name {
76 file = file.add_import(ImportSpec::named(
77 "com.google.gson.annotations",
78 "SerializedName",
79 ));
80 }
81
82 let needs_list = obj
83 .properties
84 .iter()
85 .any(|(_, prop)| type_uses_list(&prop.type_expr));
86 if needs_list {
87 file = file.add_import(ImportSpec::named("java.util", "List"));
88 }
89
90 let needs_map = obj
91 .properties
92 .iter()
93 .any(|(_, prop)| type_uses_map(&prop.type_expr));
94 if needs_map {
95 file = file.add_import(ImportSpec::named("java.util", "Map"));
96 }
97
98 let mut tb = TypeSpec::builder(&name, TypeKind::Struct).visibility(Visibility::Public);
99 if let Some(doc) = &schema.description {
100 tb = tb.doc(doc);
101 }
102
103 for (json_name, prop) in &obj.properties {
105 let field_name = java_field_name(&prop.name);
106 let required = prop.required && !prop.nullable;
107 let type_str = if required {
108 java_type_str(&prop.type_expr)
109 } else {
110 java_boxed_type_str(&prop.type_expr)
111 };
112
113 let annotation = if *json_name != field_name {
114 Some(format!(
115 "@SerializedName(\"{}\")",
116 escape_java_string(json_name)
117 ))
118 } else {
119 None
120 };
121
122 let mut fb = FieldSpec::builder(&field_name, TypeName::primitive(&type_str))
123 .visibility(Visibility::Private);
124 if let Some(ann) = annotation {
125 fb = fb.annotation(CodeBlock::of(&ann, ()).expect("annotation"));
126 }
127 tb = tb.add_field(fb.build().expect("field"));
128 }
129
130 let mut ctor = FunSpec::builder(&name);
132 ctor = ctor.visibility(Visibility::Public);
133 for (_json_name, prop) in &obj.properties {
134 let field_name = java_field_name(&prop.name);
135 let required = prop.required && !prop.nullable;
136 let type_str = if required {
137 java_type_str(&prop.type_expr)
138 } else {
139 java_boxed_type_str(&prop.type_expr)
140 };
141 ctor = ctor.add_param(
142 ParameterSpec::new(&format!("{type_str} {field_name}"), TypeName::primitive(""))
143 .expect("ctor param"),
144 );
145 }
146 let assignments: Vec<CodeBlock> = obj
147 .properties
148 .iter()
149 .map(|(_json_name, prop)| {
150 let field_name = java_field_name(&prop.name);
151 sigil_quote!(JavaLang {
152 this.$L(field_name.as_str()) = $L(field_name.as_str());
153 })
154 .expect("assignment")
155 })
156 .collect();
157 let ctor_body = sigil_quote!(JavaLang {
158 $C_each(assignments);
159 })
160 .expect("ctor body");
161 ctor = ctor.body(ctor_body);
162 tb = tb.add_method(ctor.build().expect("constructor"));
163
164 for (_json_name, prop) in &obj.properties {
166 let field_name = java_field_name(&prop.name);
167 let required = prop.required && !prop.nullable;
168 let type_str = if required {
169 java_type_str(&prop.type_expr)
170 } else {
171 java_boxed_type_str(&prop.type_expr)
172 };
173 let getter_name = format!("get{}", prop.name.to_pascal_case());
174 tb = tb.add_method(build_java_getter(&getter_name, &type_str, &field_name));
175 }
176
177 file = file.add_type(tb.build().ok()?);
178 file.build().ok()
179}
180
181fn emit_enum(schema: &IrSchema, en: &IrEnum, package_name: &str) -> Option<FileSpec> {
186 let name = schema.name.to_pascal_case();
187
188 let mut file =
189 FileSpec::builder_with("model.java", JavaLang::new()).header(package_header(package_name));
190
191 if en.value_type == IrEnumValueType::Mixed {
192 return emit_comment_class(&name, "Object", schema.description.as_deref(), package_name);
193 }
194
195 file = file.add_import(ImportSpec::named(
196 "com.google.gson.annotations",
197 "SerializedName",
198 ));
199
200 let base_type = match en.value_type {
201 IrEnumValueType::String => "String",
202 IrEnumValueType::Integer => "int",
203 IrEnumValueType::Number => "double",
204 IrEnumValueType::Mixed => unreachable!(),
205 };
206
207 let mut tb = TypeSpec::builder(&name, TypeKind::Enum).visibility(Visibility::Public);
208 if let Some(doc) = &schema.description {
209 tb = tb.doc(doc);
210 }
211
212 for v in &en.values {
213 let (variant_name, literal, raw_value) = match en.value_type {
214 IrEnumValueType::String => {
215 let s = v.value.as_str()?;
216 (
217 enum_variant_name(s),
218 format!("\"{}\"", escape_java_string(s)),
219 s.to_string(),
220 )
221 }
222 IrEnumValueType::Integer | IrEnumValueType::Number => {
223 let n = v.value.as_number()?;
224 let variant = format!("N{}", n.to_string().replace(['-', '.'], "_"));
225 let s = n.to_string();
226 (variant, s.clone(), s)
227 }
228 IrEnumValueType::Mixed => unreachable!(),
229 };
230
231 let variant_name = if variant_name.is_empty() {
232 "UNKNOWN".to_string()
233 } else {
234 variant_name
235 };
236
237 let mut ev = EnumVariantSpec::builder(&variant_name);
238 ev = ev.annotation(
239 CodeBlock::of(
240 &format!("@SerializedName(\"{}\")", escape_java_string(&raw_value)),
241 (),
242 )
243 .expect("annotation"),
244 );
245 ev = ev.value(CodeBlock::of(&literal, ()).expect("literal"));
246 tb = tb.add_variant(ev.build().expect("enum variant"));
247 }
248
249 tb = tb.add_field(
251 FieldSpec::builder("value", TypeName::primitive(base_type))
252 .visibility(Visibility::Private)
253 .is_readonly()
254 .build()
255 .expect("value field"),
256 );
257
258 let mut ctor = FunSpec::builder(&name);
259 ctor = ctor.add_param(
260 ParameterSpec::new("value", TypeName::primitive(base_type)).expect("ctor param"),
261 );
262 let ctor_body = sigil_quote!(JavaLang {
263 this.value = value;
264 })
265 .expect("ctor body");
266 ctor = ctor.body(ctor_body);
267 tb = tb.add_method(ctor.build().expect("enum ctor"));
268
269 tb = tb.add_method(build_java_getter("getValue", base_type, "value"));
270
271 file = file.add_type(tb.build().ok()?);
272 file.build().ok()
273}
274
275fn emit_alias(schema: &IrSchema, expr: &IrTypeExpr, package_name: &str) -> Option<FileSpec> {
280 let name = schema.name.to_pascal_case();
281 let rhs = java_type_str(expr);
282 emit_comment_class(&name, &rhs, schema.description.as_deref(), package_name)
283}
284
285fn emit_union(schema: &IrSchema, _union: &IrUnion, package_name: &str) -> Option<FileSpec> {
290 let name = schema.name.to_pascal_case();
291 emit_comment_class(&name, "Object", schema.description.as_deref(), package_name)
292}
293
294fn emit_intersection(
299 schema: &IrSchema,
300 inter: &IrIntersection,
301 package_name: &str,
302) -> Option<FileSpec> {
303 let name = schema.name.to_pascal_case();
304 let mut file =
305 FileSpec::builder_with("model.java", JavaLang::new()).header(package_header(package_name));
306
307 let mut tb = TypeSpec::builder(&name, TypeKind::Struct).visibility(Visibility::Public);
308 if let Some(doc) = &schema.description {
309 tb = tb.doc(doc);
310 }
311
312 let mut used_names: HashSet<String> = HashSet::new();
313 let member_bindings: Vec<(String, String)> = inter
314 .members
315 .iter()
316 .map(|member| {
317 let member_type = java_type_str(member);
318 let field_name = unique_name(&member_type.to_lower_camel_case(), &mut used_names);
319 (member_type, field_name)
320 })
321 .collect();
322
323 for (member_type, field_name) in &member_bindings {
324 tb = tb.add_field(
325 FieldSpec::builder(field_name, TypeName::primitive(member_type))
326 .visibility(Visibility::Private)
327 .build()
328 .expect("field"),
329 );
330 }
331
332 let mut ctor = FunSpec::builder(&name);
334 ctor = ctor.visibility(Visibility::Public);
335 for (member_type, field_name) in &member_bindings {
336 ctor = ctor.add_param(
337 ParameterSpec::new(
338 &format!("{member_type} {field_name}"),
339 TypeName::primitive(""),
340 )
341 .expect("param"),
342 );
343 }
344 let assignments: Vec<CodeBlock> = member_bindings
345 .iter()
346 .map(|(_member_type, field_name)| {
347 sigil_quote!(JavaLang {
348 this.$L(field_name.as_str()) = $L(field_name.as_str());
349 })
350 .expect("assignment")
351 })
352 .collect();
353 let ctor_body = sigil_quote!(JavaLang {
354 $C_each(assignments);
355 })
356 .expect("ctor body");
357 ctor = ctor.body(ctor_body);
358 tb = tb.add_method(ctor.build().expect("constructor"));
359
360 for (member_type, field_name) in &member_bindings {
362 let getter_name = format!("get{}", field_name.to_pascal_case());
363 tb = tb.add_method(build_java_getter(&getter_name, member_type, field_name));
364 }
365
366 file = file.add_type(tb.build().ok()?);
367 file.build().ok()
368}
369
370fn emit_tagged_union(
375 schema: &IrSchema,
376 tu: &IrTaggedUnion,
377 package_name: &str,
378) -> Option<FileSpec> {
379 let name = schema.name.to_pascal_case();
380
381 if tu.variants.is_empty() {
382 return emit_comment_class(&name, "Object", schema.description.as_deref(), package_name);
383 }
384
385 let hint = match &tu.tagging {
386 TaggingStyle::Internal => {
387 format!("Discriminator: {} (internal).", tu.discriminator_field)
388 }
389 TaggingStyle::Adjacent { content_field } => format!(
390 "Discriminator: {} / content: {} (adjacent).",
391 tu.discriminator_field, content_field
392 ),
393 TaggingStyle::External => "Discriminator: variant key (external).".to_string(),
394 };
395 let doc = match &schema.description {
396 Some(desc) => format!("{desc}\n\n{hint}"),
397 None => hint,
398 };
399
400 emit_comment_class(&name, "Object", Some(&doc), package_name)
401}
402
403fn emit_comment_class(
408 name: &str,
409 underlying_type: &str,
410 doc: Option<&str>,
411 package_name: &str,
412) -> Option<FileSpec> {
413 let mut file =
414 FileSpec::builder_with("model.java", JavaLang::new()).header(package_header(package_name));
415
416 let mut tb = TypeSpec::builder(name, TypeKind::Struct).visibility(Visibility::Public);
417 if let Some(d) = doc {
418 tb = tb.doc(d);
419 }
420
421 tb = tb.add_field(
423 FieldSpec::builder("value", TypeName::primitive(underlying_type))
424 .visibility(Visibility::Private)
425 .build()
426 .expect("value field"),
427 );
428
429 let mut ctor = FunSpec::builder(name);
431 ctor = ctor.visibility(Visibility::Public);
432 ctor = ctor.add_param(
433 ParameterSpec::new(&format!("{underlying_type} value"), TypeName::primitive(""))
434 .expect("param"),
435 );
436 let body = sigil_quote!(JavaLang {
437 this.value = value;
438 })
439 .expect("ctor body");
440 ctor = ctor.body(body);
441 tb = tb.add_method(ctor.build().expect("constructor"));
442
443 tb = tb.add_method(build_java_getter("getValue", underlying_type, "value"));
445
446 file = file.add_type(tb.build().ok()?);
447 file.build().ok()
448}
449
450fn enum_variant_name(s: &str) -> String {
451 let upper: String = s
452 .chars()
453 .map(|c| {
454 if c.is_alphanumeric() {
455 c.to_ascii_uppercase()
456 } else {
457 '_'
458 }
459 })
460 .collect();
461 if upper.is_empty() {
462 "UNKNOWN".to_string()
463 } else if upper.chars().next().unwrap().is_ascii_digit() {
464 format!("N{upper}")
465 } else {
466 upper
467 }
468}