Skip to main content

openapi_nexus/generators/java/okhttp/
sigil_emit.rs

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
61// ---------------------------------------------------------------------------
62// Object -> class with private fields, constructor, getters
63// ---------------------------------------------------------------------------
64
65fn 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    // Fields
104    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    // Constructor
131    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    // Getters
165    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
181// ---------------------------------------------------------------------------
182// Enum -> enum with value field
183// ---------------------------------------------------------------------------
184
185fn 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    // Field, constructor, and getter (rendered after variants in Java)
250    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
275// ---------------------------------------------------------------------------
276// Alias -> wrapper class (Java has no typealias)
277// ---------------------------------------------------------------------------
278
279fn 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
285// ---------------------------------------------------------------------------
286// Union -> Object wrapper class
287// ---------------------------------------------------------------------------
288
289fn 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
294// ---------------------------------------------------------------------------
295// Intersection -> class with all merged properties
296// ---------------------------------------------------------------------------
297
298fn 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    // Constructor
333    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    // Getters
361    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
370// ---------------------------------------------------------------------------
371// TaggedUnion -> wrapper class (sealed interfaces not well-supported by sigil-stitch)
372// ---------------------------------------------------------------------------
373
374fn 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
403// ---------------------------------------------------------------------------
404// Helpers
405// ---------------------------------------------------------------------------
406
407fn 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    // Single field wrapping the underlying type
422    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    // Constructor
430    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    // Getter
444    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}