use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use heck::{ToLowerCamelCase, ToUpperCamelCase};
use indexmap::IndexMap;
use serde::Deserialize;
use serde_json::Value;
use crate::error::{Error, Result};
use crate::generator::ExternalModelBackend;
use crate::generator::java::render_java_doc_comment;
use crate::language::Language;
use crate::parser::{ManifestModel, NameManifest, build_name_manifest};
use crate::planning::{PlannedFamily, PlannedJsonType, PlannedSpec};
use crate::spec::{ExternalTypeSpec, ModulePath, RecordSpec};
pub(in crate::generator) const GENERATED_HEADER: &str = concat!(
"// Generated by nexgen v",
env!("CARGO_PKG_VERSION"),
". DO NOT EDIT.\n"
);
#[derive(Debug, Deserialize, Default, Clone)]
pub(in crate::generator) struct Schema {
#[serde(rename = "$ref")]
reference: Option<String>,
#[serde(rename = "type")]
ty: Option<Value>,
title: Option<String>,
description: Option<String>,
deprecated: Option<bool>,
properties: Option<IndexMap<String, Schema>>,
required: Option<Vec<String>>,
#[serde(rename = "additionalProperties")]
additional_properties: Option<Value>,
items: Option<Box<Schema>>,
#[serde(rename = "oneOf")]
one_of: Option<Vec<Schema>>,
default: Option<Value>,
#[serde(rename = "const")]
const_value: Option<Value>,
#[serde(rename = "maxProperties")]
max_properties: Option<usize>,
#[serde(rename = "minProperties")]
min_properties: Option<usize>,
#[serde(rename = "propertyNames")]
property_names: Option<Box<Schema>>,
#[serde(rename = "dependentRequired")]
dependent_required: Option<IndexMap<String, Vec<String>>>,
minimum: Option<serde_json::Number>,
maximum: Option<serde_json::Number>,
#[serde(rename = "exclusiveMinimum")]
exclusive_minimum: Option<serde_json::Number>,
#[serde(rename = "exclusiveMaximum")]
exclusive_maximum: Option<serde_json::Number>,
#[serde(rename = "multipleOf")]
multiple_of: Option<serde_json::Number>,
#[serde(rename = "minLength")]
min_length: Option<u64>,
#[serde(rename = "maxLength")]
max_length: Option<u64>,
pattern: Option<String>,
format: Option<String>,
#[serde(rename = "contentEncoding")]
content_encoding: Option<String>,
#[serde(rename = "minItems")]
min_items: Option<u64>,
#[serde(rename = "maxItems")]
max_items: Option<u64>,
#[serde(rename = "uniqueItems")]
unique_items: Option<bool>,
contains: Option<Box<Schema>>,
#[serde(rename = "minContains")]
min_contains: Option<u64>,
#[serde(rename = "maxContains")]
max_contains: Option<u64>,
#[serde(rename = "enum")]
enum_values: Option<Vec<Value>>,
#[serde(rename = "x-java-name")]
x_java_name: Option<String>,
#[serde(rename = "x-java-const-name")]
x_java_const_name: Option<String>,
#[serde(rename = "x-java-enum-names")]
x_java_enum_names: Option<IndexMap<String, String>>,
}
#[derive(Debug, Clone, Default)]
struct ArrayConstraints {
min_items: Option<u64>,
max_items: Option<u64>,
unique_items: bool,
contains: Option<Box<Schema>>,
min_contains: Option<u64>,
max_contains: Option<u64>,
}
impl ArrayConstraints {
fn from_schema(schema: &Schema) -> Self {
Self {
min_items: schema.min_items,
max_items: schema.max_items,
unique_items: schema.unique_items == Some(true),
contains: schema.contains.clone(),
min_contains: schema.min_contains,
max_contains: schema.max_contains,
}
}
fn is_empty(&self) -> bool {
self.min_items.is_none()
&& self.max_items.is_none()
&& !self.unique_items
&& self.contains.is_none()
}
}
/// A pinned string `format` lowered for Java: the canonical name (for the
/// reason), the pinned regex with the per-target `$`→`\z` rewrite already
/// applied, and the optional total-length guard. See
/// `specs/json-schema/features/format.md`.
#[derive(Debug, Clone)]
struct JavaFormat {
name: &'static str,
pattern: String,
max_code_points: Option<usize>,
}
#[derive(Debug, Clone, Default)]
struct StringLengthConstraints {
min_length: Option<u64>,
max_length: Option<u64>,
/// The loader-normalized `pattern` with the per-target `$`→`\z` rewrite
/// already applied (Java's strict end-of-input anchor). See
/// `specs/json-schema/features/pattern.md`.
pattern: Option<String>,
/// A pinned `format` check (regex + optional length guard) on the same node.
format: Option<JavaFormat>,
}
impl StringLengthConstraints {
fn from_schema(schema: &Schema) -> Self {
Self {
min_length: schema.min_length,
max_length: schema.max_length,
pattern: schema
.pattern
.as_deref()
.map(|pattern| crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z")),
format: schema
.format
.as_deref()
.and_then(crate::json_schema::format::check_for)
.map(|check| JavaFormat {
name: check.name,
pattern: crate::json_schema::pattern::rewrite_end_anchor(&check.pattern, r"\z"),
max_code_points: check.max_code_points,
}),
}
}
/// True when there is no length bound, pattern, or format.
fn is_empty(&self) -> bool {
self.min_length.is_none()
&& self.max_length.is_none()
&& self.pattern.is_none()
&& self.format.is_none()
}
/// True when there is a length bound (needing the `codePointCount` local).
fn has_length(&self) -> bool {
self.min_length.is_some() || self.max_length.is_some()
}
}
#[derive(Debug, Clone, Default)]
struct NumericConstraints {
minimum: Option<serde_json::Number>,
maximum: Option<serde_json::Number>,
exclusive_minimum: Option<serde_json::Number>,
exclusive_maximum: Option<serde_json::Number>,
multiple_of: Option<serde_json::Number>,
}
impl NumericConstraints {
fn from_schema(schema: &Schema) -> Self {
Self {
minimum: schema.minimum.clone(),
maximum: schema.maximum.clone(),
exclusive_minimum: schema.exclusive_minimum.clone(),
exclusive_maximum: schema.exclusive_maximum.clone(),
multiple_of: schema.multiple_of.clone(),
}
}
fn is_empty(&self) -> bool {
self.minimum.is_none()
&& self.maximum.is_none()
&& self.exclusive_minimum.is_none()
&& self.exclusive_maximum.is_none()
&& self.multiple_of.is_none()
}
}
fn java_bound_literal(number: &serde_json::Number, is_integer: bool) -> String {
if is_integer {
if let Some(value) = number.as_f64() {
return format!("{}L", value.trunc() as i64);
}
}
// Number-field bounds render as a `double` literal.
let text = number.to_string();
if text.contains('.') || text.contains('e') || text.contains('E') {
text
} else {
format!("{text}.0")
}
}
/// Emits the numeric-constraint predicates over `value_expr` (a validated
/// `long`/`double` in scope) into the collecting deserializer, appending
/// `Violation`s. `is_integer` selects `long`/`double` divisibility.
fn render_java_numeric_checks(
output: &mut String,
value_expr: &str,
json: &str,
constraints: &NumericConstraints,
is_integer: bool,
indent: &str,
) {
let mut emit = |condition: String, reason: &str| {
output.push_str(&format!(
"{indent}if ({condition}) {{\n{indent} violations.add(new Violation({json}, \"{reason}, got \" + {value_expr}));\n{indent}}}\n"
));
};
if let Some(min) = &constraints.minimum {
let bound = java_bound_literal(min, is_integer);
emit(
format!("{value_expr} < {bound}"),
&format!("must be >= {}", trim_java_bound(&bound)),
);
}
if let Some(max) = &constraints.maximum {
let bound = java_bound_literal(max, is_integer);
emit(
format!("{value_expr} > {bound}"),
&format!("must be <= {}", trim_java_bound(&bound)),
);
}
if let Some(min) = &constraints.exclusive_minimum {
let bound = java_bound_literal(min, is_integer);
emit(
format!("{value_expr} <= {bound}"),
&format!("must be > {}", trim_java_bound(&bound)),
);
}
if let Some(max) = &constraints.exclusive_maximum {
let bound = java_bound_literal(max, is_integer);
emit(
format!("{value_expr} >= {bound}"),
&format!("must be < {}", trim_java_bound(&bound)),
);
}
if let Some(divisor) = &constraints.multiple_of {
let bound = java_bound_literal(divisor, is_integer);
emit(
format!("{value_expr} % {bound} != 0"),
&format!("must be a multiple of {}", trim_java_bound(&bound)),
);
}
}
/// True when an in-memory value contains a JSON Schema `number` position whose
/// Java `double` can carry NaN or infinity. References validate in their own
/// generated serializer; union branches validate through their dispatcher.
fn java_type_needs_finite_check(ty: &JavaType) -> bool {
match ty {
JavaType::Double => true,
JavaType::List(element) => java_type_needs_finite_check(element),
_ => false,
}
}
/// True when an in-memory materialized date/date-time can carry Gregorian year
/// zero even though the shared wire contract starts at year 0001.
fn java_type_needs_calendar_year_check(ty: &JavaType) -> bool {
match ty {
JavaType::Temporal(
crate::json_schema::format::TemporalKind::Date
| crate::json_schema::format::TemporalKind::DateTime,
) => true,
JavaType::List(element) => java_type_needs_calendar_year_check(element),
_ => false,
}
}
fn schema_has_recursive_value_checks(schema: &Schema) -> bool {
schema.const_value.is_some()
|| schema.enum_values.is_some()
|| !NumericConstraints::from_schema(schema).is_empty()
|| !StringLengthConstraints::from_schema(schema).is_empty()
|| !ArrayConstraints::from_schema(schema).is_empty()
|| schema
.items
.as_deref()
.is_some_and(schema_has_recursive_value_checks)
}
fn render_java_recursive_value_checks(
output: &mut String,
value_expr: &str,
path_expr: &str,
schema: &Schema,
ty: &JavaType,
indent: &str,
depth: usize,
) {
match ty {
JavaType::String => {
render_java_inline_string_checks(output, value_expr, path_expr, schema, indent);
}
JavaType::Long | JavaType::Double => {
render_java_numeric_checks(
output,
value_expr,
path_expr,
&NumericConstraints::from_schema(schema),
matches!(ty, JavaType::Long),
indent,
);
render_java_closed_scalar_checks(output, value_expr, path_expr, schema, ty, indent);
}
JavaType::Boolean => {
render_java_closed_scalar_checks(output, value_expr, path_expr, schema, ty, indent);
}
JavaType::Temporal(kind) => {
let wire = format!("nestedWire{depth}");
let expression = java_temporal_format_fn(*kind)
.map(|format_fn| format!("TemporalSupport.{format_fn}({value_expr})"))
.unwrap_or_else(|| value_expr.to_string());
output.push_str(&format!("{indent}String {wire} = {expression};\n"));
render_java_inline_string_checks(output, &wire, path_expr, schema, indent);
}
JavaType::Bytes(encoding) => {
let wire = format!("nestedWire{depth}");
output.push_str(&format!(
"{indent}String {wire} = Base64Support.{}({value_expr});\n",
java_content_encoding_format_fn(*encoding)
));
render_java_inline_string_checks(output, &wire, path_expr, schema, indent);
}
JavaType::List(element_ty) => {
let constraints = ArrayConstraints::from_schema(schema);
if !constraints.is_empty() {
render_java_array_checks(
output,
value_expr,
path_expr,
element_ty,
&constraints,
indent,
);
}
if let Some(item_schema) = schema.items.as_deref()
&& schema_has_recursive_value_checks(item_schema)
{
let index = format!("validationIndex{depth}");
let item = format!("validationValue{depth}");
let item_path = format!("{path_expr} + \"[\" + {index} + \"]\"");
output.push_str(&format!(
"{indent}for (int {index} = 0; {index} < {value_expr}.size(); {index}++) {{\n{indent} {} {item} = {value_expr}.get({index});\n{indent} if ({item} != null) {{\n",
element_ty.boxed_name()
));
render_java_recursive_value_checks(
output,
&item,
&item_path,
item_schema,
element_ty,
&format!("{indent} "),
depth + 1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
}
}
JavaType::Ref { .. } | JavaType::Union { .. } | JavaType::ClosedValue { .. } => {}
}
}
/// Emits the serialize-side finiteness predicate for every `number` reachable
/// through `ty`. Arrays recurse elementwise and extend the violation path at
/// each level, so nested failures surface as `field[1][2]`.
fn render_java_finite_checks(
output: &mut String,
ty: &JavaType,
value_expr: &str,
path_expr: &str,
indent: &str,
depth: usize,
) {
match ty {
JavaType::Double => {
output.push_str(&format!(
"{indent}if (!Double.isFinite({value_expr})) {{\n{indent} violations.add(new Violation({path_expr}, \"must be a finite number, got \" + {value_expr}));\n{indent}}}\n"
));
}
JavaType::List(element) if java_type_needs_finite_check(element) => {
let index = format!("finiteIndex{depth}");
let value = format!("finiteValue{depth}");
let element_path = format!("{path_expr} + \"[\" + {index} + \"]\"");
output.push_str(&format!(
"{indent}for (int {index} = 0; {index} < {value_expr}.size(); {index}++) {{\n"
));
output.push_str(&format!(
"{indent} {} {value} = {value_expr}.get({index});\n",
element.boxed_name()
));
output.push_str(&format!("{indent} if ({value} != null) {{\n"));
render_java_finite_checks(
output,
element,
&value,
&element_path,
&format!("{indent} "),
depth + 1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
}
_ => {}
}
}
/// Emits the serialize-side year floor for materialized native dates and
/// date-times, recursively extending array paths just like number validation.
fn render_java_calendar_year_checks(
output: &mut String,
ty: &JavaType,
value_expr: &str,
path_expr: &str,
indent: &str,
depth: usize,
) {
match ty {
JavaType::Temporal(kind @ crate::json_schema::format::TemporalKind::Date)
| JavaType::Temporal(kind @ crate::json_schema::format::TemporalKind::DateTime) => {
output.push_str(&format!(
"{indent}if ({value_expr}.getYear() < 1) {{\n{indent} violations.add(new Violation({path_expr}, \"must be a valid {}, got \" + {value_expr} + \": year must be >= 0001\"));\n{indent}}}\n",
kind.name()
));
}
JavaType::List(element) if java_type_needs_calendar_year_check(element) => {
let index = format!("calendarIndex{depth}");
let value = format!("calendarValue{depth}");
let element_path = format!("{path_expr} + \"[\" + {index} + \"]\"");
output.push_str(&format!(
"{indent}for (int {index} = 0; {index} < {value_expr}.size(); {index}++) {{\n"
));
output.push_str(&format!(
"{indent} {} {value} = {value_expr}.get({index});\n",
element.boxed_name()
));
output.push_str(&format!("{indent} if ({value} != null) {{\n"));
render_java_calendar_year_checks(
output,
element,
&value,
&element_path,
&format!("{indent} "),
depth + 1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
}
_ => {}
}
}
/// Emits the string-length predicates (`minLength`/`maxLength`) over
/// `value_expr` (a validated `String` in scope) into the collecting
/// deserializer, appending `Violation`s. Length is the Unicode code-point count
/// via `codePointCount(0, length())` (never `length()`, which is the UTF-16
/// code-unit count) — see `specs/json-schema/features/maxLength.md`.
fn render_java_string_checks(
output: &mut String,
value_expr: &str,
json: &str,
field_java_name: &str,
constraints: &StringLengthConstraints,
indent: &str,
) {
if constraints.has_length() {
output.push_str(&format!(
"{indent}int length = {value_expr}.codePointCount(0, {value_expr}.length());\n"
));
if let Some(min) = constraints.min_length {
output.push_str(&format!(
"{indent}if (length < {min}) {{\n{indent} violations.add(new Violation({json}, \"must have length >= {min}, got \" + length));\n{indent}}}\n"
));
}
if let Some(max) = constraints.max_length {
output.push_str(&format!(
"{indent}if (length > {max}) {{\n{indent} violations.add(new Violation({json}, \"must have length <= {max}, got \" + length));\n{indent}}}\n"
));
}
}
// `pattern`: unanchored `Matcher.find()` (never `matches()`, which anchors
// the whole input), default flags (ASCII `\d\w\s`, code-point `.`). The
// compiled `Pattern` is a static field on the class (compiled once).
if let Some(pattern) = &constraints.pattern {
output.push_str(&format!(
"{indent}if (!{field_pattern}.matcher({value_expr}).find()) {{\n{indent} violations.add(new Violation({json}, \"must match pattern \" + {pattern_literal} + \", got \" + {value_expr}));\n{indent}}}\n",
field_pattern = java_pattern_field_name(field_java_name),
pattern_literal = java_string_literal(pattern),
));
}
// `format`: the length guard (if any) short-circuits **before** the pinned
// regex — the email order neutralizes a `java.util.regex` matcher recursion
// that throws `StackOverflowError` on adversarial multi-thousand-char inputs.
if let Some(format) = &constraints.format {
let mut condition = String::new();
if let Some(max) = format.max_code_points {
condition.push_str(&format!(
"{value_expr}.codePointCount(0, {value_expr}.length()) > {max} || "
));
}
condition.push_str(&format!(
"!{field_format}.matcher({value_expr}).find()",
field_format = java_format_field_name(field_java_name),
));
output.push_str(&format!(
"{indent}if ({condition}) {{\n{indent} violations.add(new Violation({json}, \"must be a valid {name}, got \" + {value_expr}));\n{indent}}}\n",
name = format.name,
));
}
}
/// String checks for recursively nested positions that do not have a stable
/// class member name on which to hang a compiled static pattern.
fn render_java_inline_string_checks(
output: &mut String,
value_expr: &str,
path_expr: &str,
schema: &Schema,
indent: &str,
) {
let constraints = StringLengthConstraints::from_schema(schema);
if constraints.has_length() {
output.push_str(&format!(
"{indent}int nestedLength = {value_expr}.codePointCount(0, {value_expr}.length());\n"
));
if let Some(min) = constraints.min_length {
output.push_str(&format!(
"{indent}if (nestedLength < {min}) {{\n{indent} violations.add(new Violation({path_expr}, \"must have length >= {min}, got \" + nestedLength));\n{indent}}}\n"
));
}
if let Some(max) = constraints.max_length {
output.push_str(&format!(
"{indent}if (nestedLength > {max}) {{\n{indent} violations.add(new Violation({path_expr}, \"must have length <= {max}, got \" + nestedLength));\n{indent}}}\n"
));
}
}
if let Some(pattern) = constraints.pattern {
output.push_str(&format!(
"{indent}if (!java.util.regex.Pattern.compile({}).matcher({value_expr}).find()) {{\n{indent} violations.add(new Violation({path_expr}, \"must match pattern \" + {} + \", got \" + {value_expr}));\n{indent}}}\n",
java_string_literal(&pattern),
java_string_literal(&pattern),
));
}
if let Some(format) = constraints.format {
let mut condition = String::new();
if let Some(max) = format.max_code_points {
condition.push_str(&format!(
"{value_expr}.codePointCount(0, {value_expr}.length()) > {max} || "
));
}
condition.push_str(&format!(
"!java.util.regex.Pattern.compile({}).matcher({value_expr}).find()",
java_string_literal(&format.pattern)
));
output.push_str(&format!(
"{indent}if ({condition}) {{\n{indent} violations.add(new Violation({path_expr}, \"must be a valid {}, got \" + {value_expr}));\n{indent}}}\n",
format.name
));
}
render_java_closed_string_checks(output, value_expr, path_expr, schema, indent);
}
/// The static `Pattern` field name for a `pattern` on the Java field
/// `java_name` — unique per class, compiled once at class init.
fn java_pattern_field_name(java_name: &str) -> String {
use heck::ToShoutySnakeCase;
format!("{}_PATTERN", java_name.to_shouty_snake_case())
}
/// The static `Pattern` field name for a `format` on the Java field `java_name`.
fn java_format_field_name(java_name: &str) -> String {
use heck::ToShoutySnakeCase;
format!("{}_FORMAT", java_name.to_shouty_snake_case())
}
/// Emits the object member-count predicates (`minProperties`/`maxProperties`)
/// over `size_expr` (the number of distinct wire member keys, one number over
/// the whole object) into the collecting deserializer. See
/// `specs/json-schema/features/minProperties.md`.
fn render_java_property_count_checks(
output: &mut String,
size_expr: &str,
schema: &Schema,
indent: &str,
) {
if let Some(min) = schema.min_properties {
output.push_str(&format!(
"{indent}if ({size_expr} < {min}) {{\n{indent} violations.add(new Violation(\"\", \"must have at least {min} properties, got \" + {size_expr}));\n{indent}}}\n"
));
}
if let Some(max) = schema.max_properties {
output.push_str(&format!(
"{indent}if ({size_expr} > {max}) {{\n{indent} violations.add(new Violation(\"\", \"must have at most {max} properties, got \" + {size_expr}));\n{indent}}}\n"
));
}
}
/// Emits the `propertyNames` key-shape predicate over the keys of `node_expr`,
/// applying the (string-length) key subschema to each key. `codePointCount`
/// counts Unicode code points — spec-correct. See
/// `specs/json-schema/features/propertyNames.md`.
fn render_java_property_name_checks(
output: &mut String,
node_expr: &str,
subschema: &Schema,
indent: &str,
) {
let constraints = StringLengthConstraints::from_schema(subschema);
if constraints.is_empty() && subschema.const_value.is_none() && subschema.enum_values.is_none()
{
return;
}
output.push_str(&format!(
"{indent}Iterator<String> propertyNameKeys = {node_expr}.fieldNames();\n{indent}while (propertyNameKeys.hasNext()) {{\n"
));
output.push_str(&format!(
"{indent} String pnKey = propertyNameKeys.next();\n"
));
if constraints.has_length() {
output.push_str(&format!(
"{indent} int pnLength = pnKey.codePointCount(0, pnKey.length());\n"
));
if let Some(min) = constraints.min_length {
output.push_str(&format!(
"{indent} if (pnLength < {min}) {{\n{indent} violations.add(new Violation(pnKey, \"invalid property name \\\"\" + pnKey + \"\\\": must have length >= {min}, got \" + pnLength));\n{indent} }}\n"
));
}
if let Some(max) = constraints.max_length {
output.push_str(&format!(
"{indent} if (pnLength > {max}) {{\n{indent} violations.add(new Violation(pnKey, \"invalid property name \\\"\" + pnKey + \"\\\": must have length <= {max}, got \" + pnLength));\n{indent} }}\n"
));
}
}
let mut non_length = constraints.clone();
non_length.min_length = None;
non_length.max_length = None;
render_java_string_checks(
output,
"pnKey",
"pnKey",
PROPERTY_NAME_POSITION,
&non_length,
&format!("{indent} "),
);
render_java_closed_string_checks(
output,
"pnKey",
"pnKey",
subschema,
&format!("{indent} "),
);
output.push_str(&format!("{indent}}}\n"));
}
/// Emits the `dependentRequired` cross-field presence predicate over
/// `node_expr` (the parsed wire tree): for each present trigger key, each
/// dependent key must also be present. See
/// `specs/json-schema/features/dependentRequired.md`.
fn render_java_dependent_required(
output: &mut String,
node_expr: &str,
schema: &Schema,
indent: &str,
) {
let Some(dependent_required) = &schema.dependent_required else {
return;
};
for (trigger, deps) in dependent_required {
output.push_str(&format!(
"{indent}if ({node_expr}.has({})) {{\n",
java_string_literal(trigger)
));
for dep in deps {
let reason = format!(
"property {} is required when {} is present",
quote_for_message(dep),
quote_for_message(trigger)
);
output.push_str(&format!(
"{indent} if (!{node_expr}.has({})) {{\n{indent} violations.add(new Violation({}, {}));\n{indent} }}\n",
java_string_literal(dep),
java_string_literal(dep),
java_string_literal(&reason)
));
}
output.push_str(&format!("{indent}}}\n"));
}
}
/// Renders a Java literal for a scalar matcher value in the element's boxed
/// type.
fn java_scalar_literal(value: &Value, element_ty: &JavaType) -> String {
match value {
Value::String(text) => java_string_literal(text),
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => java_bound_literal(number, matches!(element_ty, JavaType::Long)),
_ => "null".to_string(),
}
}
/// A single `element == literal` / `literal.equals(element)` match term, boxed
/// per the element type (strings compare by value, scalars unbox).
fn java_equals_term(value: &Value, elem: &str, element_ty: &JavaType) -> String {
let literal = java_scalar_literal(value, element_ty);
match element_ty {
JavaType::String => format!("{literal}.equals({elem})"),
_ => format!("{elem} == {literal}"),
}
}
/// Builds the boolean Java match expression for a scalar `contains` matcher over
/// `elem` (a boxed element). A type-only matcher matches every element, so an
/// empty condition set renders as the literal `true`.
fn java_matcher_condition(matcher: &Schema, elem: &str, element_ty: &JavaType) -> String {
let is_integer = matches!(element_ty, JavaType::Long);
let mut parts: Vec<String> = Vec::new();
if let Some(value) = &matcher.const_value {
parts.push(java_equals_term(value, elem, element_ty));
}
if let Some(values) = &matcher.enum_values {
let alternatives = values
.iter()
.map(|value| java_equals_term(value, elem, element_ty))
.collect::<Vec<_>>()
.join(" || ");
if !alternatives.is_empty() {
parts.push(format!("({alternatives})"));
}
}
if let Some(min) = &matcher.minimum {
parts.push(format!("{elem} >= {}", java_bound_literal(min, is_integer)));
}
if let Some(max) = &matcher.maximum {
parts.push(format!("{elem} <= {}", java_bound_literal(max, is_integer)));
}
if let Some(min) = &matcher.exclusive_minimum {
parts.push(format!("{elem} > {}", java_bound_literal(min, is_integer)));
}
if let Some(max) = &matcher.exclusive_maximum {
parts.push(format!("{elem} < {}", java_bound_literal(max, is_integer)));
}
if let Some(divisor) = &matcher.multiple_of {
parts.push(format!(
"{elem} % {} == 0",
java_bound_literal(divisor, is_integer)
));
}
if let Some(min) = matcher.min_length {
parts.push(format!(
"{elem}.codePointCount(0, {elem}.length()) >= {min}"
));
}
if let Some(max) = matcher.max_length {
parts.push(format!(
"{elem}.codePointCount(0, {elem}.length()) <= {max}"
));
}
if let Some(pattern) = &matcher.pattern {
let pattern = crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z");
parts.push(format!(
"java.util.regex.Pattern.compile({}).matcher({elem}).find()",
java_string_literal(&pattern)
));
}
if let Some(format) = matcher
.format
.as_deref()
.and_then(crate::json_schema::format::check_for)
{
let pattern = crate::json_schema::pattern::rewrite_end_anchor(&format.pattern, r"\z");
if let Some(max) = format.max_code_points {
parts.push(format!(
"{elem}.codePointCount(0, {elem}.length()) <= {max}"
));
}
parts.push(format!(
"java.util.regex.Pattern.compile({}).matcher({elem}).find()",
java_string_literal(&pattern)
));
}
if parts.is_empty() {
"true".to_string()
} else {
parts.join(" && ")
}
}
fn matcher_kind<'a>(matcher: &'a Schema, fallback: &'a JavaType) -> &'a str {
matcher
.ty
.as_ref()
.and_then(Value::as_str)
.unwrap_or(match fallback {
JavaType::String => "string",
JavaType::Boolean => "boolean",
JavaType::Long => "integer",
JavaType::Double => "number",
_ => "unsupported",
})
}
fn java_typed_matcher_guard(matcher: &Schema, elem: &str, element_ty: &JavaType) -> String {
match (matcher_kind(matcher, element_ty), element_ty) {
("string", JavaType::String) | ("boolean", JavaType::Boolean) => "true".to_string(),
("integer", JavaType::Long) | ("number", JavaType::Long) => "true".to_string(),
("number", JavaType::Double) => format!("Double.isFinite({elem})"),
("integer", JavaType::Double) => format!(
"Double.isFinite({elem}) && {elem} == Math.rint({elem}) && {elem} >= -(double) SpecNumbers.INTEGER_CAP && {elem} <= (double) SpecNumbers.INTEGER_CAP"
),
_ => "false".to_string(),
}
}
/// Emits the array-constraint predicates over `items` (a built `List<T>` in
/// scope) into the collecting deserializer, appending `Violation`s.
fn render_java_array_checks(
output: &mut String,
list: &str,
json: &str,
element_ty: &JavaType,
constraints: &ArrayConstraints,
indent: &str,
) {
if let Some(min) = constraints.min_items {
output.push_str(&format!(
"{indent}if ({list}.size() < {min}) {{\n{indent} violations.add(new Violation({json}, \"must have at least {min} items, got \" + {list}.size()));\n{indent}}}\n"
));
}
if let Some(max) = constraints.max_items {
output.push_str(&format!(
"{indent}if ({list}.size() > {max}) {{\n{indent} violations.add(new Violation({json}, \"must have at most {max} items, got \" + {list}.size()));\n{indent}}}\n"
));
}
if constraints.unique_items {
let boxed = element_ty.boxed_name();
output.push_str(&format!(
"{indent}java.util.Map<{boxed}, Integer> seen = new java.util.HashMap<>();\n"
));
output.push_str(&format!(
"{indent}for (int index = 0; index < {list}.size(); index++) {{\n"
));
output.push_str(&format!(
"{indent} {boxed} element = {list}.get(index);\n"
));
output.push_str(&format!(
"{indent} Integer priorIndex = seen.get(element);\n"
));
output.push_str(&format!("{indent} if (priorIndex != null) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"duplicate items: element at index \" + index + \" equals index \" + priorIndex));\n"
));
output.push_str(&format!("{indent} }} else {{\n"));
output.push_str(&format!("{indent} seen.put(element, index);\n"));
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
}
if let Some(matcher) = &constraints.contains {
let boxed = element_ty.boxed_name();
let condition = java_matcher_condition(matcher, "element", element_ty);
let guard = java_typed_matcher_guard(matcher, "element", element_ty);
let effective_min = constraints.min_contains.unwrap_or(1);
output.push_str(&format!("{indent}int matchCount = 0;\n"));
output.push_str(&format!("{indent}for ({boxed} element : {list}) {{\n"));
output.push_str(&format!("{indent} if ({guard} && ({condition})) {{\n"));
output.push_str(&format!("{indent} matchCount++;\n"));
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
if effective_min > 0 {
output.push_str(&format!("{indent}if (matchCount < {effective_min}) {{\n"));
if constraints.min_contains.is_some() {
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"too few matching items: at least {effective_min}, got \" + matchCount));\n"
));
} else {
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"no element matches the required schema\"));\n"
));
}
output.push_str(&format!("{indent}}}\n"));
}
if let Some(max) = constraints.max_contains {
output.push_str(&format!("{indent}if (matchCount > {max}) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"too many matching items: at most {max}, got \" + matchCount));\n"
));
output.push_str(&format!("{indent}}}\n"));
}
}
}
/// Emits deserialize-side sibling array checks over the original Jackson
/// array node. Element conversion may omit failed values from the typed list;
/// `minItems`, `maxItems`, `uniqueItems`, and `contains` must not observe that
/// shortened implementation detail.
fn render_java_raw_array_checks(
output: &mut String,
node: &str,
json: &str,
element_ty: &JavaType,
constraints: &ArrayConstraints,
indent: &str,
) {
if let Some(min) = constraints.min_items {
output.push_str(&format!(
"{indent}if ({node}.size() < {min}) {{\n{indent} violations.add(new Violation({json}, \"must have at least {min} items, got \" + {node}.size()));\n{indent}}}\n"
));
}
if let Some(max) = constraints.max_items {
output.push_str(&format!(
"{indent}if ({node}.size() > {max}) {{\n{indent} violations.add(new Violation({json}, \"must have at most {max} items, got \" + {node}.size()));\n{indent}}}\n"
));
}
if constraints.unique_items {
output.push_str(&format!(
"{indent}java.util.Map<Object, Integer> rawSeen = new java.util.HashMap<>();\n{indent}for (int rawIndex = 0; rawIndex < {node}.size(); rawIndex++) {{\n{indent} Object rawKey = SpecNumbers.valueKey({node}.get(rawIndex));\n{indent} Integer priorIndex = rawSeen.get(rawKey);\n{indent} if (priorIndex != null) {{\n{indent} violations.add(new Violation({json}, \"duplicate items: element at index \" + rawIndex + \" equals index \" + priorIndex));\n{indent} }} else {{\n{indent} rawSeen.put(rawKey, rawIndex);\n{indent} }}\n{indent}}}\n"
));
}
if let Some(matcher) = &constraints.contains {
let kind = matcher_kind(matcher, element_ty);
let (guard, value, evaluation_ty) = match kind {
"string" => (
"rawElement.isTextual()".to_string(),
"rawElement.textValue()",
JavaType::String,
),
"boolean" => (
"rawElement.isBoolean()".to_string(),
"rawElement.booleanValue()",
JavaType::Boolean,
),
"integer" => (
"SpecNumbers.isSpecLong(rawElement)".to_string(),
"rawElement.doubleValue()",
JavaType::Double,
),
"number" => (
"rawElement.isNumber() && Double.isFinite(rawElement.doubleValue())".to_string(),
"rawElement.doubleValue()",
JavaType::Double,
),
_ => ("false".to_string(), "rawElement", element_ty.clone()),
};
let condition = java_matcher_condition(matcher, value, &evaluation_ty);
let effective_min = constraints.min_contains.unwrap_or(1);
output.push_str(&format!(
"{indent}int rawMatchCount = 0;\n{indent}for (JsonNode rawElement : {node}) {{\n{indent} if ({guard} && ({condition})) {{\n{indent} rawMatchCount++;\n{indent} }}\n{indent}}}\n"
));
if effective_min > 0 {
output.push_str(&format!(
"{indent}if (rawMatchCount < {effective_min}) {{\n"
));
if constraints.min_contains.is_some() {
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"too few matching items: at least {effective_min}, got \" + rawMatchCount));\n"
));
} else {
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"no element matches the required schema\"));\n"
));
}
output.push_str(&format!("{indent}}}\n"));
}
if let Some(max) = constraints.max_contains {
output.push_str(&format!(
"{indent}if (rawMatchCount > {max}) {{\n{indent} violations.add(new Violation({json}, \"too many matching items: at most {max}, got \" + rawMatchCount));\n{indent}}}\n"
));
}
}
}
/// Strips the Java literal suffix/`.0` for human-readable reason messages so
/// they match the other targets (`10`, not `10L`).
fn trim_java_bound(literal: &str) -> String {
literal
.strip_suffix('L')
.map(str::to_string)
.unwrap_or_else(|| {
literal
.strip_suffix(".0")
.map(str::to_string)
.unwrap_or_else(|| literal.to_string())
})
}
/// Minimal `ExternalModelBackend` implementation for the JSON Java backend.
///
/// The bulk of Java code generation lives in the parent `generator::java`
/// module, which walks the planned tree and renders one file per exported
/// class. This backend exists to satisfy the shared external-model contract and
/// to resolve JSON model references to their Java class identifiers.
#[derive(Debug, Default)]
#[allow(dead_code)]
pub(in crate::generator) struct ModelBackend {
json_models: Vec<PlannedJsonType>,
}
impl ExternalModelBackend<PlannedJsonType> for ModelBackend {
type ModelFragments = ();
type WireConversion = ();
fn prepare(&mut self, api_plan: &PlannedSpec) -> Result<()> {
self.json_models = api_plan
.external_types()
.map(|(_, binding)| binding)
.filter_map(|binding| match &binding.external_type {
ExternalTypeSpec::Json(json_type) => Some(json_type.clone()),
_ => None,
})
.collect();
Ok(())
}
fn render_models(&self) -> Result<()> {
Ok(())
}
fn model_type_annotation(&self, json_type: &PlannedJsonType) -> Option<String> {
Some(json_type.model_name.clone())
}
fn wire_type_identifier(&self, json_type: &PlannedJsonType) -> Option<String> {
Some(json_type.full_name.clone())
}
fn wire_conversion(
&self,
_json_type: &PlannedJsonType,
_planned_record: Option<&RecordSpec<PlannedFamily>>,
) -> Option<()> {
None
}
}
/// Resolution context for a single input module (file). Carries the package
/// root and the module path so `$ref` values can be resolved to a Java
/// `(package, class)` pair.
pub(in crate::generator) struct JavaContext<'a> {
pub(in crate::generator) base_package: &'a str,
pub(in crate::generator) module: &'a ModulePath,
/// Maps a planned model's `full_name` (the canonical string that appears in
/// planned `$ref` values) to its `(package, class)` pair.
pub(in crate::generator) registry: &'a BTreeMap<String, (String, String)>,
/// The `full_name` of every definition that is a `oneOf` union, so a `$ref`
/// at one can be told apart from a `$ref` at a POJO.
pub(in crate::generator) union_defs: &'a BTreeSet<String>,
}
impl JavaContext<'_> {
pub(in crate::generator) fn package_for_module(
base_package: &str,
module: &[String],
) -> String {
if module.is_empty() {
base_package.to_string()
} else {
format!("{base_package}.{}", module.join("."))
}
}
fn current_package(&self) -> String {
Self::package_for_module(self.base_package, &self.module.0)
}
/// True when a `$ref` points at a named `oneOf` union definition.
fn ref_is_union(&self, reference: &str) -> bool {
self.union_defs
.contains(reference.strip_prefix("#/$defs/").unwrap_or(reference))
}
/// Resolve a `$ref` string to the target (package, class) pair.
fn resolve_ref(&self, reference: &str) -> (String, String) {
// Planned `$ref` values take the form `#/$defs/<full_name>`, where
// `<full_name>` is the registry key.
let key = reference.strip_prefix("#/$defs/").unwrap_or(reference);
if let Some(resolved) = self.registry.get(key) {
return resolved.clone();
}
if let Some(resolved) = self.registry.get(reference) {
return resolved.clone();
}
let (path_part, fragment) = match reference.split_once('#') {
Some((path, fragment)) => (path, Some(fragment)),
None => (reference, None),
};
let target_module: Vec<String> = if path_part.is_empty() {
self.module.0.clone()
} else {
// Cross-file reference: resolve relative to the current file's
// directory (the module path without its file-stem leaf).
let mut segments = self.module.0.clone();
segments.pop();
for segment in path_part.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop();
}
other => {
segments.push(crate::parser::strip_json_schema_extension(other).to_string())
}
}
}
segments
};
let class = match fragment {
Some(fragment) if !fragment.is_empty() => {
let name = fragment
.strip_prefix("/$defs/")
.unwrap_or_else(|| fragment.trim_start_matches('/'));
name.to_upper_camel_case()
}
_ => target_module
.last()
.map(|segment| segment.to_upper_camel_case())
.unwrap_or_default(),
};
(
Self::package_for_module(self.base_package, &target_module),
class,
)
}
}
#[derive(Debug, Clone)]
enum JavaType {
Long,
Double,
Boolean,
String,
Ref {
package: String,
class: String,
/// True when the target definition is a `oneOf` union — a sealed
/// interface Jackson cannot instantiate, so a value in this position
/// decodes through the interface's static `fromNode` dispatcher rather
/// than `readTreeAsValue`.
union: bool,
},
List(Box<JavaType>),
/// A `oneOf` sum type — a sealed-by-convention interface. `class` is the
/// (possibly nested) interface name.
Union {
class: String,
},
/// A materialized temporal `format` (native `java.time` construct, or a
/// validated+canonicalized `String` for `time` — no single `java.time` type
/// holds both offset-bearing and offset-less time). See `format.md`.
Temporal(crate::json_schema::format::TemporalKind),
/// A materialized `contentEncoding` (native `byte[]`; the wire is a JSON
/// string carrying the encoded form). See `contentEncoding.md`.
Bytes(crate::json_schema::content_encoding::Encoding),
/// A closed value-set (`const`/`enum`) rendered as a nested value class:
/// a private constructor, one known constant per member, and Jackson
/// `@JsonCreator`/`@JsonValue` over the underlying scalar `wire` type. The
/// class is the boxed type of the owning field; it can only ever hold a
/// known constant, so it is a compile-time closed contract (P13.1). See
/// `specs/json-schema/features/{const,enum}.md`.
ClosedValue {
/// The nested value-class name (`Kind`, `Status`), derived from the
/// owning member and scoped inside the enclosing POJO.
class: String,
/// The underlying scalar the value class wraps (String/Long/Double/
/// Boolean); the wire form and the `getValue()`/`@JsonCreator` type.
wire: Box<JavaType>,
},
}
impl JavaType {
fn boxed_name(&self) -> String {
match self {
JavaType::Long => "Long".to_string(),
JavaType::Double => "Double".to_string(),
JavaType::Boolean => "Boolean".to_string(),
JavaType::String => "String".to_string(),
JavaType::Ref { class, .. } => class.clone(),
JavaType::List(inner) => format!("List<{}>", inner.boxed_name()),
JavaType::Union { class } => class.clone(),
JavaType::Temporal(kind) => java_temporal_type(*kind).to_string(),
JavaType::Bytes(_) => "byte[]".to_string(),
// The value class is the field's boxed type; it is always a
// reference (never a stored primitive), even when required.
JavaType::ClosedValue { class, .. } => class.clone(),
}
}
fn primitive_name(&self) -> Option<&'static str> {
match self {
JavaType::Long => Some("long"),
JavaType::Double => Some("double"),
JavaType::Boolean => Some("boolean"),
_ => None,
}
}
fn collect_refs(&self, refs: &mut BTreeSet<(String, String)>) {
match self {
JavaType::Ref { package, class, .. } => {
refs.insert((package.clone(), class.clone()));
}
JavaType::List(inner) => inner.collect_refs(refs),
// A closed value class is nested in the same file — no import.
_ => {}
}
}
}
/// The (fully-qualified) Java type a materialized temporal `format` maps to.
/// `time` stays a `String` because no single `java.time` type holds both an
/// offset-bearing and an offset-less time-of-day.
fn java_temporal_type(kind: crate::json_schema::format::TemporalKind) -> &'static str {
match kind {
crate::json_schema::format::TemporalKind::DateTime => "OffsetDateTime",
crate::json_schema::format::TemporalKind::Date => "LocalDate",
crate::json_schema::format::TemporalKind::Time => "String",
crate::json_schema::format::TemporalKind::Duration => "Duration",
}
}
/// The `TemporalSupport` static method name that parses this kind from the wire.
fn java_temporal_parse_fn(kind: crate::json_schema::format::TemporalKind) -> &'static str {
match kind {
crate::json_schema::format::TemporalKind::DateTime => "parseDateTime",
crate::json_schema::format::TemporalKind::Date => "parseDate",
crate::json_schema::format::TemporalKind::Time => "parseTime",
crate::json_schema::format::TemporalKind::Duration => "parseDuration",
}
}
/// The `TemporalSupport` static method that serializes this kind (`None` for
/// `time`, which is already a canonical `String` written directly).
fn java_temporal_format_fn(kind: crate::json_schema::format::TemporalKind) -> Option<&'static str> {
match kind {
crate::json_schema::format::TemporalKind::DateTime => Some("formatDateTime"),
crate::json_schema::format::TemporalKind::Date => Some("formatDate"),
crate::json_schema::format::TemporalKind::Time => None,
crate::json_schema::format::TemporalKind::Duration => Some("formatDuration"),
}
}
/// The materialized `TemporalKind` of a schema that is directly a temporal
/// string (the `oneOf[…, null]` wrapper is handled by `java_type_for` recursion).
fn temporal_kind_direct(schema: &Schema) -> Option<crate::json_schema::format::TemporalKind> {
if schema.ty.as_ref().and_then(Value::as_str) != Some("string") {
return None;
}
schema
.format
.as_deref()
.and_then(crate::json_schema::format::TemporalKind::from_name)
}
/// The materialized `contentEncoding` of a schema that is directly a bytes
/// string (the `oneOf[…, null]` wrapper is handled by `java_type_for` recursion).
fn content_encoding_direct(
schema: &Schema,
) -> Option<crate::json_schema::content_encoding::Encoding> {
if schema.ty.as_ref().and_then(Value::as_str) != Some("string") {
return None;
}
schema
.content_encoding
.as_deref()
.and_then(crate::json_schema::content_encoding::Encoding::from_name)
}
/// The `Base64Support` static method that decodes this encoding from the wire.
fn java_content_encoding_parse_fn(
encoding: crate::json_schema::content_encoding::Encoding,
) -> &'static str {
match encoding {
crate::json_schema::content_encoding::Encoding::Base64 => "parseBase64",
crate::json_schema::content_encoding::Encoding::Base64Url => "parseBase64Url",
}
}
/// The `Base64Support` static method that serializes this encoding to the wire.
fn java_content_encoding_format_fn(
encoding: crate::json_schema::content_encoding::Encoding,
) -> &'static str {
match encoding {
crate::json_schema::content_encoding::Encoding::Base64 => "formatBase64",
crate::json_schema::content_encoding::Encoding::Base64Url => "formatBase64Url",
}
}
// ---------------------------------------------------------------------------
// `oneOf` closed sum types (specs/json-schema/features/oneOf.md)
// ---------------------------------------------------------------------------
/// A member of a Java union (a sealed-by-convention interface).
#[derive(Debug, Clone)]
struct JavaUnionVariant {
/// The member/wrapper class implementing the interface (`Circle`, or a
/// nested `IdOrNameString`).
class: String,
is_object: bool,
/// The underlying scalar/array Java type for a synthesized wrapper; `None`
/// for an object `$ref` member (an existing POJO).
underlying: Option<JavaType>,
/// The Jackson `JsonNode` predicate that selects this branch by token.
node_test: &'static str,
/// For an object member of a tagged union: its discriminant `const`.
discriminant_value: Option<Value>,
/// The member's canonical `full_name` (object `$ref` members only), for
/// wiring `implements` on the member POJO.
member_full_name: Option<String>,
/// The member POJO's package (object `$ref` members only), so a union
/// declared in another module still imports the member class.
member_package: Option<String>,
/// True for an **inline** object member: the union declares the wrapper class
/// itself (`<Interface>Object`), because there is no named POJO to implement
/// the interface. Only the free-form object stays inline (every structured
/// shape is named and `$ref`ed by the loader), so `Map<String, JsonNode>`
/// expresses the member in full.
owned_object: bool,
/// True for an array wrapper whose *elements* are nullable ([[items]]).
nullable_items: bool,
label: String,
/// The branch's own schema. A wrapper has no POJO to carry the branch's
/// constraints, so it runs them itself ([[oneOf]] §"Validator mapping").
schema: Schema,
}
#[derive(Debug, Clone)]
struct JavaUnion {
/// The interface name (`Shape`, or a nested `IdOrName`).
interface: String,
/// True for an inline union whose interface + wrappers are declared nested
/// inside the enclosing POJO; false for a named `$def` union (its own file).
nested: bool,
nullable: bool,
discriminant: Option<String>,
variants: Vec<JavaUnionVariant>,
}
impl JavaUnion {
fn admissible(&self) -> String {
self.variants
.iter()
.map(|variant| variant.label.clone())
.collect::<Vec<_>>()
.join(", ")
}
}
fn strip_ref(reference: &str) -> &str {
reference.strip_prefix("#/$defs/").unwrap_or(reference)
}
fn java_discriminator_const(property: &Schema) -> Option<Value> {
if let Some(value) = &property.const_value {
return Some(value.clone());
}
if let Some(values) = &property.enum_values
&& values.len() == 1
{
return Some(values[0].clone());
}
None
}
fn java_branch_discriminator_tags(object: &Schema) -> BTreeMap<String, Value> {
let required: BTreeSet<String> = object.required.iter().flatten().cloned().collect();
let mut tags = BTreeMap::new();
if let Some(properties) = &object.properties {
for (name, property) in properties {
if required.contains(name)
&& let Some(value) = java_discriminator_const(property)
{
tags.insert(name.clone(), value);
}
}
}
tags
}
/// Classifies a `oneOf` schema into a Java union. Object `$ref` members are
/// resolved through `all_models`. Returns `None` for the degenerate nullability
/// pattern (fewer than two non-null branches).
fn classify_java_union(
interface: &str,
nested: bool,
schema: &Schema,
all_models: &BTreeMap<String, PlannedJsonType>,
context: &JavaContext,
) -> Option<JavaUnion> {
let branches = schema.one_of.as_ref()?;
let mut nullable = false;
let mut variants: Vec<JavaUnionVariant> = Vec::new();
let mut object_schemas: Vec<Schema> = Vec::new();
for branch in branches {
let (member_schema, object_class, member_full, member_package) =
if let Some(reference) = &branch.reference {
let full = strip_ref(reference).to_string();
let member_schema = all_models
.get(&full)
.and_then(|model| decode_schema(model).ok())
.unwrap_or_else(|| branch.clone());
let (package, class) = context.resolve_ref(reference);
(member_schema, Some(class), Some(full), Some(package))
} else {
(branch.clone(), None, None, None)
};
let ty = member_schema.ty.as_ref().and_then(Value::as_str);
match ty {
Some("null") => nullable = true,
Some("object") => {
let owned_object = object_class.is_none();
let class = object_class.unwrap_or_else(|| format!("{interface}Object"));
object_schemas.push(member_schema.clone());
variants.push(JavaUnionVariant {
class: class.clone(),
is_object: true,
underlying: None,
node_test: "isObject",
discriminant_value: None,
member_full_name: member_full,
member_package,
owned_object,
nullable_items: false,
label: if owned_object {
"object".to_string()
} else {
class
},
schema: member_schema.clone(),
});
}
Some("string") => variants.push(JavaUnionVariant {
class: format!("{interface}String"),
is_object: false,
underlying: Some(JavaType::String),
node_test: "isTextual",
discriminant_value: None,
member_full_name: None,
member_package: None,
owned_object: false,
nullable_items: false,
label: "string".to_string(),
schema: member_schema.clone(),
}),
Some("integer") => variants.push(JavaUnionVariant {
class: format!("{interface}Integer"),
is_object: false,
underlying: Some(JavaType::Long),
node_test: "isNumber",
discriminant_value: None,
member_full_name: None,
member_package: None,
owned_object: false,
nullable_items: false,
label: "integer".to_string(),
schema: member_schema.clone(),
}),
Some("number") => variants.push(JavaUnionVariant {
class: format!("{interface}Number"),
is_object: false,
underlying: Some(JavaType::Double),
node_test: "isNumber",
discriminant_value: None,
member_full_name: None,
member_package: None,
owned_object: false,
nullable_items: false,
label: "number".to_string(),
schema: member_schema.clone(),
}),
Some("boolean") => variants.push(JavaUnionVariant {
class: format!("{interface}Boolean"),
is_object: false,
underlying: Some(JavaType::Boolean),
node_test: "isBoolean",
discriminant_value: None,
member_full_name: None,
member_package: None,
owned_object: false,
nullable_items: false,
label: "boolean".to_string(),
schema: member_schema.clone(),
}),
Some("array") => {
let item = member_schema
.items
.as_deref()
.and_then(|item| java_type_for(item, context).ok())
.unwrap_or(JavaType::String);
variants.push(JavaUnionVariant {
class: format!("{interface}Array"),
is_object: false,
underlying: Some(JavaType::List(Box::new(item))),
node_test: "isArray",
discriminant_value: None,
member_full_name: None,
member_package: None,
owned_object: false,
nullable_items: member_schema.items.as_deref().is_some_and(allows_null),
label: "array".to_string(),
schema: member_schema.clone(),
});
}
_ => {}
}
}
if variants.len() < 2 {
return None;
}
let mut discriminant = None;
if object_schemas.len() >= 2 {
let mut shared: Option<BTreeMap<String, Value>> = None;
for object in &object_schemas {
let tags = java_branch_discriminator_tags(object);
shared = Some(match shared {
None => tags,
Some(existing) => existing
.into_iter()
.filter(|(name, _)| tags.contains_key(name))
.collect(),
});
}
let shared = shared.unwrap_or_default();
let name = shared
.keys()
.find(|name| {
let values: Vec<Value> = object_schemas
.iter()
.filter_map(|object| java_branch_discriminator_tags(object).get(*name).cloned())
.collect();
values
.iter()
.enumerate()
.all(|(index, value)| !values[..index].iter().any(|existing| existing == value))
})
.cloned();
if let Some(name) = &name {
let mut object_index = 0;
for variant in variants.iter_mut().filter(|variant| variant.is_object) {
variant.discriminant_value =
java_branch_discriminator_tags(&object_schemas[object_index])
.get(name)
.cloned();
object_index += 1;
}
}
discriminant = name;
}
Some(JavaUnion {
interface: interface.to_string(),
nested,
nullable,
discriminant,
variants,
})
}
/// True when a model's schema is a `oneOf` sum type (two or more non-null
/// branches) — a named union def emitted as an interface, not a POJO.
fn is_java_union_schema(schema: &Schema) -> bool {
schema.one_of.as_ref().is_some_and(|branches| {
branches
.iter()
.filter(|branch| branch.ty.as_ref().and_then(Value::as_str) != Some("null"))
.count()
>= 2
})
}
/// The verbatim value-constant overrides (`x-java-const-name` /
/// `x-java-enum-names`) carried by a closed-value (`const`/`enum`) property.
/// Mirrors `go_value_constant_override` in `src/generator/json/go.rs` and
/// `value_constant_override` in `src/parser/json_schema.rs`: a `const` gates on
/// its single-value override, otherwise an enum member is looked up by the wire
/// value's string form. Keep the three lookups identical.
#[derive(Debug, Clone, Default)]
struct ClosedNameOverrides {
is_const: bool,
const_name: Option<String>,
enum_names: Option<IndexMap<String, String>>,
}
impl ClosedNameOverrides {
fn from_property(schema: &Schema) -> Self {
Self {
is_const: schema.const_value.is_some(),
const_name: schema.x_java_const_name.clone(),
enum_names: schema.x_java_enum_names.clone(),
}
}
fn get(&self, value: &Value) -> Option<&str> {
if self.is_const {
self.const_name.as_deref()
} else if let (Some(map), Value::String(key)) = (&self.enum_names, value) {
map.get(key).map(String::as_str)
} else {
None
}
}
}
#[derive(Debug, Clone)]
struct FieldPlan {
json_name: String,
java_name: String,
ty: JavaType,
required: bool,
nullable: bool,
/// The closed value set for a `const` (one value) or `enum` (many); empty
/// otherwise. Enforced by equality/membership in the collecting deserializer.
closed_values: Vec<Value>,
/// The verbatim value-constant overrides for `closed_values`, if any.
closed_overrides: ClosedNameOverrides,
default: Option<Value>,
doc: Option<String>,
deprecated: bool,
numeric: NumericConstraints,
string_length: StringLengthConstraints,
array: ArrayConstraints,
/// The complete authored schema for parse-adapter checks whose wire
/// instance cannot be reconstructed from the Java type alone (notably
/// nested array siblings of `items`).
schema: Schema,
/// True when the array's *elements* are nullable — the `items` subschema is
/// the [[nullability]] `oneOf` pattern. Distinct from `nullable`, which
/// wraps the whole collection ([[items]] §"Element nullability is the
/// element's own concern").
nullable_items: bool,
/// A `oneOf` sum type carried by this field (an inline union or a `$ref` to
/// a named union def).
union: Option<JavaUnion>,
/// A `oneOf` sum type carried by this field's *elements* — a collection whose
/// element type is a union def ([[oneOf]] §"Unions in element positions").
/// Distinct from `union`, which is the field's own type.
element_union: Option<JavaUnion>,
}
impl FieldPlan {
/// A field is stored as a primitive only when it is required, non-nullable,
/// and one of the scalar numeric/boolean kinds.
fn is_primitive(&self) -> bool {
self.required
&& !self.nullable
&& matches!(
self.ty,
JavaType::Long | JavaType::Double | JavaType::Boolean
)
}
fn nullable_annotation(&self) -> bool {
!self.required || self.nullable
}
fn field_type(&self) -> String {
if self.is_primitive() {
self.ty.primitive_name().expect("primitive").to_string()
} else if let JavaType::List(item) = &self.ty
&& self.nullable_items
{
// A TYPE_USE annotation on the element, not the collection: the
// list itself is non-null, its members may be null.
format!("List<{}>", element_declaration(item, true))
} else {
self.ty.boxed_name()
}
}
fn declared_type(&self) -> String {
if self.nullable_annotation() && !self.is_primitive() {
// A `byte[]` takes the array-level TYPE_USE annotation (`byte
// @Nullable []`); `@Nullable byte[]` would (wrongly) annotate the
// primitive element type.
if matches!(self.ty, JavaType::Bytes(_)) {
return "byte @Nullable []".to_string();
}
format!("@Nullable {}", self.field_type())
} else {
self.field_type()
}
}
}
enum ModelKind {
Object {
open: bool,
fields: Vec<FieldPlan>,
typed_additional: Option<TypedAdditionalPlan>,
},
TypedMap {
value: JavaType,
max_properties: Option<usize>,
},
}
#[derive(Debug, Clone)]
struct TypedAdditionalPlan {
ty: JavaType,
schema: Schema,
nullable: bool,
union: Option<JavaUnion>,
}
/// Resolves the emitted Java identifier for every planned model through the
/// shared [`NameManifest`], keyed by `full_name`. This is the single place type
/// names (with any `x-java-name` override applied) are resolved, so the class
/// declaration and every `$ref` target agree. The Java generator is driven by
/// the parent `generator::java` module (which builds the `$ref` registry from
/// the pre-override `model_name`), so the manifest is rebuilt here from the
/// full model set rather than threaded through a backend `prepare`.
fn build_java_name_manifest(
all_models: &BTreeMap<String, PlannedJsonType>,
) -> Result<NameManifest> {
let models: Vec<ManifestModel> = all_models
.values()
.map(|json| {
let module_key = json
.module_path
.as_ref()
.map(ModulePath::as_module_key)
.unwrap_or_default();
let local_name = json
.full_name
.rsplit(['#', '/'])
.next()
.unwrap_or(&json.full_name)
.to_string();
ManifestModel {
full_name: json.full_name.clone(),
local_name,
model_name: json.model_name.clone(),
module_key,
schema: json.schema.clone(),
}
})
.collect();
build_name_manifest(Language::Java, &models, &[])
}
fn decode_schema(model: &PlannedJsonType) -> Result<Schema> {
serde_json::from_value(model.schema.clone()).map_err(|error| Error::InvalidJsonSchema {
path: PathBuf::from("<json-generator>"),
reason: format!(
"failed to read planned JSON schema `{}`: {error}",
model.full_name
),
})
}
fn schema_type_includes(schema: &Schema, ty: &str) -> bool {
match schema.ty.as_ref() {
Some(Value::String(value)) => value == ty,
Some(Value::Array(values)) => values
.iter()
.any(|value| value.as_str().is_some_and(|value| value == ty)),
_ => false,
}
}
fn allows_null(schema: &Schema) -> bool {
schema.const_value.as_ref() == Some(&Value::Null)
|| schema_type_includes(schema, "null")
|| schema
.one_of
.as_ref()
.is_some_and(|branches| branches.iter().any(allows_null))
}
fn nullable_non_null_schema(schema: &Schema) -> Option<&Schema> {
schema.one_of.as_ref()?.iter().find(|branch| {
!schema_type_includes(branch, "null") && branch.const_value.as_ref() != Some(&Value::Null)
})
}
/// The `full_name` of every model that is a named `oneOf` union definition.
fn union_def_names(all_models: &BTreeMap<String, PlannedJsonType>) -> BTreeSet<String> {
all_models
.iter()
.filter(|(_, model)| decode_schema(model).is_ok_and(|schema| is_java_union_schema(&schema)))
.map(|(full_name, _)| full_name.clone())
.collect()
}
fn java_type_for(schema: &Schema, context: &JavaContext) -> Result<JavaType> {
if let Some(reference) = &schema.reference {
let (package, class) = context.resolve_ref(reference);
return Ok(JavaType::Ref {
package,
class,
union: context.ref_is_union(reference),
});
}
if let Some(non_null) = nullable_non_null_schema(schema) {
return java_type_for(non_null, context);
}
if let Some(kind) = temporal_kind_direct(schema) {
return Ok(JavaType::Temporal(kind));
}
if let Some(encoding) = content_encoding_direct(schema) {
return Ok(JavaType::Bytes(encoding));
}
match schema.ty.as_ref().and_then(Value::as_str) {
Some("string") => Ok(JavaType::String),
Some("integer") => Ok(JavaType::Long),
Some("number") => Ok(JavaType::Double),
Some("boolean") => Ok(JavaType::Boolean),
Some("array") => {
let item = schema
.items
.as_deref()
.map(|item| java_type_for(item, context))
.transpose()?
.unwrap_or(JavaType::String);
Ok(JavaType::List(Box::new(item)))
}
// Objects without a name are not produced by the supported subset;
// fall back to a string map value only reached for typed maps handled
// elsewhere.
_ => Ok(JavaType::String),
}
}
fn typed_map_value(schema: &Schema) -> Option<&Value> {
if schema
.properties
.as_ref()
.is_some_and(|properties| !properties.is_empty())
{
return None;
}
match &schema.additional_properties {
Some(value @ Value::Object(_)) => Some(value),
_ => None,
}
}
fn resolve_model_kind(
schema: &Schema,
context: &JavaContext,
all_models: &BTreeMap<String, PlannedJsonType>,
) -> Result<ModelKind> {
if let Some(value) = typed_map_value(schema) {
let value_schema: Schema =
serde_json::from_value(value.clone()).map_err(|error| Error::InvalidJsonSchema {
path: PathBuf::from("<json-generator>"),
reason: format!("failed to read `additionalProperties`: {error}"),
})?;
return Ok(ModelKind::TypedMap {
value: java_type_for(&value_schema, context)?,
max_properties: schema.max_properties,
});
}
let open = schema.additional_properties.as_ref() != Some(&Value::Bool(false));
let typed_additional =
match &schema.additional_properties {
Some(Value::Object(value)) => {
let authored: Schema = serde_json::from_value(Value::Object(value.clone()))
.map_err(|error| Error::InvalidJsonSchema {
path: PathBuf::from("<json-generator>"),
reason: format!("failed to read `additionalProperties`: {error}"),
})?;
let schema = nullable_non_null_schema(&authored)
.cloned()
.unwrap_or_else(|| authored.clone());
Some(TypedAdditionalPlan {
ty: java_type_for(&schema, context)?,
union: ref_union(&schema, all_models, context),
nullable: allows_null(&authored),
schema,
})
}
_ => None,
};
let mut fields = Vec::new();
let required: BTreeSet<String> = schema.required.iter().flatten().cloned().collect();
if let Some(properties) = &schema.properties {
for (json_name, property) in properties {
let closed_values = if let Some(value) = &property.const_value {
vec![value.clone()]
} else if let Some(values) = &property.enum_values {
values.clone()
} else {
Vec::new()
};
let java_name = property
.x_java_name
.clone()
.unwrap_or_else(|| json_name.to_lower_camel_case());
// A union-typed field: an inline `oneOf` (nested interface +
// wrappers) or a `$ref` to a named union def.
let (ty, union) = if property.one_of.is_some() && is_java_union_schema(property) {
let interface = json_name.to_upper_camel_case();
let union = classify_java_union(&interface, true, property, all_models, context);
(
JavaType::Union {
class: interface.clone(),
},
union,
)
} else if let Some(union) = ref_union(property, all_models, context) {
(java_type_for(property, context)?, Some(union))
} else if !closed_values.is_empty() {
// A `const`/`enum` member is a nested value class over its
// underlying scalar (P13.1). The class name follows the member
// (`x-java-name` moves it, per the properties resolved policy).
let wire = java_type_for(property, context)?;
if matches!(wire, JavaType::Temporal(_) | JavaType::Bytes(_)) {
(wire, None)
} else {
(
JavaType::ClosedValue {
class: upper_first(&java_name),
wire: Box::new(wire),
},
None,
)
}
} else {
(java_type_for(property, context)?, None)
};
// A collection whose *element* is a union def: the field carries the
// element's union so the serialize side can route each element through
// its dispatcher ([[oneOf]] §"Unions in element positions").
let element_union = match &property.items {
Some(element) => ref_union(element, all_models, context),
None => None,
};
fields.push(FieldPlan {
java_name,
json_name: json_name.clone(),
ty,
required: required.contains(json_name),
nullable: allows_null(property),
closed_values,
closed_overrides: ClosedNameOverrides::from_property(property),
default: property.default.clone(),
doc: compose_doc(property.title.as_deref(), property.description.as_deref()),
deprecated: property.deprecated == Some(true),
numeric: NumericConstraints::from_schema(property),
string_length: StringLengthConstraints::from_schema(property),
array: ArrayConstraints::from_schema(property),
schema: property.clone(),
nullable_items: property.items.as_deref().is_some_and(allows_null),
union,
element_union,
});
}
}
Ok(ModelKind::Object {
open,
fields,
typed_additional,
})
}
/// Renders the full `.java` source for a single JSON model class.
pub(in crate::generator) fn render_model_file(
model: &PlannedJsonType,
base_package: &str,
module: &ModulePath,
root_package: &str,
registry: &BTreeMap<String, (String, String)>,
all_models: &BTreeMap<String, PlannedJsonType>,
) -> Result<String> {
let schema = decode_schema(model)?;
// Resolve every emitted type name (with any `x-java-name` override applied)
// through the shared manifest. The parent generator builds `registry` from
// the pre-override `model_name`, so patch each entry's class through the
// manifest — this makes a type override land at every `$ref` site, not just
// the class declaration.
let manifest = build_java_name_manifest(all_models)?;
let resolved_registry: BTreeMap<String, (String, String)> = registry
.iter()
.map(|(key, (package, class))| {
let class = manifest
.type_name(key)
.map(str::to_string)
.unwrap_or_else(|| class.clone());
(key.clone(), (package.clone(), class))
})
.collect();
let union_defs = union_def_names(all_models);
let context = JavaContext {
base_package,
module,
registry: &resolved_registry,
union_defs: &union_defs,
};
let package = context.current_package();
let class_name = manifest
.type_name(&model.full_name)
.map(str::to_string)
.unwrap_or_else(|| model.model_name.clone());
let class = class_name.as_str();
// A named `oneOf` union def is emitted as a sealed-by-convention interface.
if is_java_union_schema(&schema)
&& let Some(union) = classify_java_union(class, false, &schema, all_models, &context)
{
let mut body = String::new();
let mut refs = BTreeSet::new();
render_union_interface(&mut body, &schema, &union, &mut refs);
return Ok(assemble_file(&package, root_package, &refs, false, &body));
}
let kind = resolve_model_kind(&schema, &context, all_models)?;
let mut body = String::new();
let mut refs = BTreeSet::new();
// Interfaces this POJO implements: every union with this model as an object
// member — a named union def (`Shape`), or a union written inline on another
// model's property, whose interface is nested in that model (`Showcase.Note`).
let mut implements: Vec<String> = Vec::new();
for other in all_models.values() {
let Ok(other_schema) = decode_schema(other) else {
continue;
};
let other_context = JavaContext {
base_package,
module,
registry: &resolved_registry,
union_defs: &union_defs,
};
let other_class = manifest
.type_name(&other.full_name)
.unwrap_or(&other.model_name);
let holds_this_model = |union: &JavaUnion| {
union.variants.iter().any(|variant| {
variant.member_full_name.as_deref() == Some(model.full_name.as_str())
})
};
if is_java_union_schema(&other_schema) {
if let Some(union) = classify_java_union(
other_class,
false,
&other_schema,
all_models,
&other_context,
) && holds_this_model(&union)
{
implements.push(other_class.to_string());
}
continue;
}
for (json_name, property) in other_schema.properties.iter().flatten() {
if property.one_of.is_none() || !is_java_union_schema(property) {
continue;
}
let interface = json_name.to_upper_camel_case();
if let Some(union) =
classify_java_union(&interface, true, property, all_models, &other_context)
&& holds_this_model(&union)
{
implements.push(format!("{other_class}.{interface}"));
// The nested interface is reached through its declaring class, so
// that class is imported when it lives in another module.
let (other_package, _) =
other_context.resolve_ref(&format!("#/$defs/{}", other.full_name));
refs.insert((other_package, other_class.to_string()));
}
}
}
implements.sort();
implements.dedup();
match &kind {
ModelKind::Object {
open,
fields,
typed_additional,
} => {
render_object_class(
&mut body,
class,
&schema,
*open,
fields,
typed_additional.as_ref(),
&implements,
&mut refs,
);
}
ModelKind::TypedMap {
value,
max_properties,
} => {
render_typed_map_class(
&mut body,
class,
&schema,
value,
*max_properties,
map_member_union(&schema, all_models, &context).as_ref(),
&mut refs,
);
}
}
Ok(assemble_file(&package, root_package, &refs, false, &body))
}
/// Renders a named `oneOf` union def as a sealed-by-convention interface with a
/// static `fromNode` collecting dispatcher.
fn render_union_interface(
output: &mut String,
schema: &Schema,
union: &JavaUnion,
refs: &mut BTreeSet<(String, String)>,
) {
for variant in &union.variants {
if let Some(underlying) = &variant.underlying {
underlying.collect_refs(refs);
}
// An object member is named by `X.class` in the dispatcher; import it
// when the member POJO lives in another module.
if let Some(package) = &variant.member_package {
refs.insert((package.clone(), variant.class.clone()));
}
}
render_java_schema_doc(
output,
"",
schema.title.as_deref(),
schema.description.as_deref(),
schema.deprecated == Some(true),
"type",
);
output.push_str(&format!("public interface {} {{\n", union.interface));
render_union_from_node(output, union, " ");
let mut wrappers = String::new();
render_union_wrapper_classes(&mut wrappers, union, " ");
if !wrappers.is_empty() {
output.push('\n');
output.push_str(wrappers.trim_end());
output.push('\n');
}
output.push_str("}\n");
}
/// Emits the token/discriminant dispatch that reads a `JsonNode` into a union
/// value (returning it) or records a `Violation` and returns `null`.
fn render_union_dispatch_body(
output: &mut String,
union: &JavaUnion,
node: &str,
path: &str,
indent: &str,
) {
let admissible = union.admissible();
let object_variants: Vec<&JavaUnionVariant> = union
.variants
.iter()
.filter(|variant| variant.is_object)
.collect();
if !object_variants.is_empty() {
output.push_str(&format!("{indent}if ({node}.isObject()) {{\n"));
if let Some(discriminant) = &union.discriminant {
let disc_lit = java_string_literal(discriminant);
output.push_str(&format!(
"{indent} JsonNode disc = {node}.get({disc_lit});\n"
));
output.push_str(&format!(
"{indent} if (disc == null || !disc.isTextual()) {{\n{indent} violations.add(new Violation({path}, {}));\n{indent} return null;\n{indent} }}\n",
java_string_literal(&format!("discriminator {discriminant:?} is required"))
));
output.push_str(&format!("{indent} switch (disc.textValue()) {{\n"));
let mut values_display = Vec::new();
for variant in &object_variants {
let Some(value) = &variant.discriminant_value else {
continue;
};
let text = value
.as_str()
.map(str::to_string)
.unwrap_or_else(|| value.to_string());
values_display.push(text.clone());
output.push_str(&format!(
"{indent} case {}:\n",
java_string_literal(&text)
));
render_union_read_object(
output,
variant,
node,
path,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent} default:\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path}, {} + disc.textValue() + {}));\n",
java_string_literal(&format!("unknown discriminator {discriminant} ")),
java_string_literal(&format!(": expected one of [{}]", values_display.join(", ")))
));
output.push_str(&format!("{indent} return null;\n"));
output.push_str(&format!("{indent} }}\n"));
} else {
let variant = object_variants[0];
render_union_read_object(output, variant, node, path, &format!("{indent} "));
}
output.push_str(&format!("{indent}}}\n"));
}
for variant in union.variants.iter().filter(|variant| !variant.is_object) {
output.push_str(&format!("{indent}if ({node}.{}()) {{\n", variant.node_test));
render_union_read_scalar(output, variant, node, path, &format!("{indent} "));
output.push_str(&format!("{indent}}}\n"));
}
if union.nullable {
output.push_str(&format!(
"{indent}if ({node}.isNull()) {{\n{indent} return null;\n{indent}}}\n"
));
}
output.push_str(&format!(
"{indent}violations.add(new Violation({path}, {}));\n{indent}return null;\n",
java_string_literal(&format!("expected one of: {admissible}"))
));
}
/// Reads an object member (delegating to its POJO deserializer) and returns it.
fn render_union_read_object(
output: &mut String,
variant: &JavaUnionVariant,
node: &str,
path: &str,
indent: &str,
) {
// The free-form object member has no POJO: the wrapper carries the wire
// members verbatim, exactly like an open POJO's catch-all (P13).
if variant.owned_object {
output.push_str(&format!(
"{indent}{JAVA_JSON_MAP_TYPE} members = new LinkedHashMap<>();\n"
));
output.push_str(&format!(
"{indent}Iterator<String> memberNames = {node}.fieldNames();\n"
));
output.push_str(&format!(
"{indent}while (memberNames.hasNext()) {{\n{indent} String memberName = memberNames.next();\n{indent} members.put(memberName, {node}.get(memberName));\n{indent}}}\n"
));
output.push_str(&format!("{indent}return new {}(members);\n", variant.class));
return;
}
output.push_str(&format!("{indent}try {{\n"));
output.push_str(&format!(
"{indent} return context.readTreeAsValue({node}, {}.class);\n",
variant.class
));
output.push_str(&format!(
"{indent}}} catch (ValidationException nested) {{\n"
));
output.push_str(&format!(
"{indent} for (Violation violation : nested.getViolations()) {{\n{indent} violations.add(violation.withPathPrefix({path}));\n{indent} }}\n"
));
output.push_str(&format!("{indent} return null;\n"));
output.push_str(&format!("{indent}}} catch (IOException nested) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path}, nested.getMessage()));\n{indent} return null;\n"
));
output.push_str(&format!("{indent}}}\n"));
}
/// Reads a scalar/array wrapper member and returns a new wrapper instance,
/// holding it to the branch's own constraints on the way in.
fn render_union_read_scalar(
output: &mut String,
variant: &JavaUnionVariant,
node: &str,
path: &str,
indent: &str,
) {
// A branch that declares constraints validates the wrapper it just built, so
// a bad value is a `Violation` under the union's own path rather than an
// accepted member.
let checked = java_variant_checks(variant, path, indent).is_some();
let wrap = |output: &mut String, argument: &str| {
if checked {
output.push_str(&format!(
"{indent}{class} wrapped = new {class}({argument});\n{indent}wrapped.validate({path}, violations);\n{indent}return wrapped;\n",
class = variant.class
));
} else {
output.push_str(&format!(
"{indent}return new {}({argument});\n",
variant.class
));
}
};
match variant.underlying.as_ref() {
Some(JavaType::String) => {
wrap(output, &format!("{node}.textValue()"));
}
Some(JavaType::Boolean) => {
wrap(output, &format!("{node}.booleanValue()"));
}
Some(JavaType::Long) => {
output.push_str(&format!(
"{indent}Long parsed = SpecNumbers.specLong({node}, {path}, violations);\n"
));
if checked {
output.push_str(&format!(
"{indent}if (parsed == null) {{\n{indent} return null;\n{indent}}}\n"
));
wrap(output, "parsed");
} else {
output.push_str(&format!(
"{indent}return parsed == null ? null : new {}(parsed);\n",
variant.class
));
}
}
Some(JavaType::Double) => {
output.push_str(&format!(
"{indent}Double parsed = SpecNumbers.specDouble({node}, {path}, violations);\n"
));
output.push_str(&format!(
"{indent}if (parsed == null) {{\n{indent} return null;\n{indent}}}\n"
));
wrap(output, "parsed");
}
Some(JavaType::List(item)) => {
output.push_str(&format!(
"{indent}List<{}> items = new ArrayList<>();\n",
element_declaration(item, variant.nullable_items)
));
output.push_str(&format!(
"{indent}for (int index = 0; index < {node}.size(); index++) {{\n"
));
output.push_str(&format!(
"{indent} JsonNode element = {node}.get(index);\n"
));
output.push_str(&format!(
"{indent} String elementPath = {path} + \"[\" + index + \"]\";\n"
));
render_parse_element(
output,
item,
variant.schema.items.as_deref(),
"items",
"element",
"elementPath",
variant.nullable_items,
0,
&format!("{indent} "),
);
output.push_str(&format!("{indent}}}\n"));
let constraints = ArrayConstraints::from_schema(&variant.schema);
if !constraints.is_empty() {
render_java_raw_array_checks(output, node, path, item, &constraints, indent);
}
// Array-level branch constraints have just run over the original
// node. Re-validating the shortened typed list here would both
// duplicate correct violations and fabricate results after a bad
// element was omitted.
output.push_str(&format!("{indent}return new {}(items);\n", variant.class));
}
_ => {
output.push_str(&format!("{indent}return null;\n"));
}
}
}
/// The Java type carrying a free-form object's members verbatim — the same shape
/// an open POJO's catch-all uses, so unknown members survive a round-trip (P13).
const JAVA_JSON_MAP_TYPE: &str = "Map<String, JsonNode>";
/// Renders a property-level union's interface — carrying the same static
/// `fromNode` dispatcher a named union def does — and its wrapper classes,
/// nested inside the enclosing POJO.
fn render_nested_union(output: &mut String, union: &JavaUnion) {
output.push_str(&format!(" public interface {} {{\n", union.interface));
render_union_from_node(output, union, " ");
output.push_str(" }\n\n");
render_union_wrapper_classes(output, union, " ");
}
/// Emits the `fromNode` collecting dispatcher every union interface carries, so
/// a union parses identically whether it is a named def or written inline on a
/// property.
fn render_union_from_node(output: &mut String, union: &JavaUnion, indent: &str) {
output.push_str(&format!(
"{indent}static @Nullable {} fromNode(JsonNode node, String path, List<Violation> violations, DeserializationContext context) {{\n",
union.interface
));
render_union_dispatch_body(output, union, "node", "path", &format!("{indent} "));
output.push_str(&format!("{indent}}}\n"));
render_union_validate(output, union, indent);
}
/// Emits the union's static `validate` — the serialize-side counterpart of
/// `fromNode`: it dispatches on the member's runtime class and re-runs that
/// branch's own constraints before the value is written (P12). The enclosing
/// POJO's `Serializer` calls it, so a branch violation aggregates with its
/// siblings into the one `ValidationException` (P11).
///
/// Emitted only when some branch declares a constraint; a union of unconstrained
/// kinds needs no dispatcher, because holding a member *is* the whole invariant.
fn render_union_validate(output: &mut String, union: &JavaUnion, indent: &str) {
if !java_union_has_checks(union) {
return;
}
let inner = format!("{indent} ");
output.push_str(&format!(
"\n{indent}static void validate({} value, String path, List<Violation> violations) {{\n",
union.interface
));
for variant in &union.variants {
if java_variant_checks(variant, "path", "").is_none() {
continue;
}
output.push_str(&format!(
"{inner}if (value instanceof {class}) {{\n{inner} (({class}) value).validate(path, violations);\n{inner}}}\n",
class = variant.class
));
}
output.push_str(&format!("{indent}}}\n"));
}
/// The predicates a wrapper runs over the `value` it holds — every constraint the
/// branch declares, through the same emitters (and with the same reasons) a
/// declared field of that type uses. `None` when the branch declares nothing, so
/// an unconstrained wrapper keeps no `validate` at all.
fn java_variant_checks(
variant: &JavaUnionVariant,
path_expr: &str,
indent: &str,
) -> Option<String> {
let ty = variant.underlying.as_ref()?;
let mut body = String::new();
render_java_member_checks(
&mut body,
"value",
path_expr,
&variant.schema,
ty,
"value",
indent,
);
(!body.is_empty()).then_some(body)
}
/// The union a schema `$ref`s, when its target definition is a `oneOf` sum type.
/// Used for a member whose type is a named union and for a collection whose
/// element type is (an inline element union is hoisted into `$defs` by the loader,
/// so it arrives here as a reference too).
fn ref_union(
schema: &Schema,
all_models: &BTreeMap<String, PlannedJsonType>,
context: &JavaContext,
) -> Option<JavaUnion> {
let reference = schema.reference.as_ref()?;
let target = all_models
.get(strip_ref(reference))
.and_then(|model| decode_schema(model).ok())?;
if !is_java_union_schema(&target) {
return None;
}
let (_package, class) = context.resolve_ref(reference);
classify_java_union(&class, false, &target, all_models, context)
}
/// The union interface a field's type carries, if any: an inline union nested in
/// the declaring POJO, a `$ref` to a named union def, or a collection whose
/// element is either.
fn java_union_interface(ty: &JavaType) -> Option<&String> {
match ty {
JavaType::Union { class } => Some(class),
JavaType::Ref {
class, union: true, ..
} => Some(class),
JavaType::List(element) => java_union_interface(element),
_ => None,
}
}
/// True when any of a union's wrapper classes carries a `validate` — i.e. some
/// non-object branch declares a constraint.
fn java_union_has_checks(union: &JavaUnion) -> bool {
union
.variants
.iter()
.any(|variant| java_variant_checks(variant, "path", "").is_some())
}
/// Renders the wrapper class carrying each non-object member (and the free-form
/// object member, which has no POJO of its own). A `$ref` object member is
/// already a POJO and implements the interface directly, so it gets no wrapper.
fn render_union_wrapper_classes(output: &mut String, union: &JavaUnion, indent: &str) {
let inner = format!("{indent} ");
for variant in &union.variants {
let (field_type, getter_type) = match &variant.underlying {
Some(JavaType::Long) => ("long".to_string(), "long".to_string()),
Some(JavaType::Double) => ("double".to_string(), "double".to_string()),
Some(JavaType::Boolean) => ("boolean".to_string(), "boolean".to_string()),
Some(other) => (other.boxed_name(), other.boxed_name()),
// An inline free-form object member: the wrapper holds the wire
// members verbatim, exactly like an open POJO's catch-all (P13).
None if variant.owned_object => (
JAVA_JSON_MAP_TYPE.to_string(),
JAVA_JSON_MAP_TYPE.to_string(),
),
// A `$ref` object member is its own POJO; it implements the
// interface instead of being wrapped.
None => continue,
};
output.push_str(&format!(
"{indent}public static final class {} implements {} {{\n",
variant.class, union.interface
));
// Compiled `pattern`/`format` regexes for the branch, compiled once at
// class init — the wrapper counterpart of a POJO's per-field statics. The
// wrapper's single field is `value`, so the position name is the same one
// a map member uses.
let string_length = StringLengthConstraints::from_schema(&variant.schema);
if let Some(pattern) = &string_length.pattern {
output.push_str(&format!(
"{inner}private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n\n",
java_pattern_field_name(MAP_MEMBER_POSITION),
java_string_literal(pattern),
));
}
if let Some(format) = &string_length.format {
output.push_str(&format!(
"{inner}private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n\n",
java_format_field_name(MAP_MEMBER_POSITION),
java_string_literal(&format.pattern),
));
}
output.push_str(&format!("{inner}private final {field_type} value;\n\n"));
output.push_str(&format!(
"{inner}public {}({field_type} value) {{\n{inner} this.value = value;\n{inner}}}\n\n",
variant.class
));
// `@JsonValue` is what writes the member back to the wire: a union field
// serializes by runtime class, so the wrapper must render as the value it
// holds rather than as a bean around it.
output.push_str(&format!(
"{inner}@JsonValue\n{inner}public {getter_type} getValue() {{\n{inner} return value;\n{inner}}}\n\n"
));
// The branch's own constraints, run on the way in (the dispatcher) and
// again before emit (the enclosing serializer) — P12 over one emitter.
if let Some(checks) = java_variant_checks(variant, "path", &format!("{inner} ")) {
output.push_str(&format!(
"{inner}void validate(String path, List<Violation> violations) {{\n{checks}{inner}}}\n\n"
));
}
// equals/hashCode/toString over the single value.
output.push_str(&format!(
"{inner}@Override\n{inner}public boolean equals(Object other) {{\n"
));
output.push_str(&format!(
"{inner} if (this == other) {{\n{inner} return true;\n{inner} }}\n{inner} if (!(other instanceof {})) {{\n{inner} return false;\n{inner} }}\n",
variant.class
));
output.push_str(&format!(
"{inner} return Objects.equals(value, (({}) other).value);\n{inner}}}\n\n",
variant.class
));
output.push_str(&format!(
"{inner}@Override\n{inner}public int hashCode() {{\n{inner} return Objects.hash(value);\n{inner}}}\n\n"
));
output.push_str(&format!(
"{inner}@Override\n{inner}public String toString() {{\n{inner} return \"{}[\" + value + \"]\";\n{inner}}}\n",
variant.class
));
output.push_str(&format!("{indent}}}\n\n"));
}
}
fn assemble_file(
package: &str,
root_package: &str,
model_refs: &BTreeSet<(String, String)>,
service_imports: bool,
body: &str,
) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str("package ");
output.push_str(package);
output.push_str(";\n\n");
let mut imports: BTreeSet<String> = BTreeSet::new();
if service_imports {
imports.insert("io.nexusrpc.Operation".to_string());
imports.insert("io.nexusrpc.Service".to_string());
} else {
for import in [
"com.fasterxml.jackson.core.JsonGenerator",
"com.fasterxml.jackson.core.JsonParser",
"com.fasterxml.jackson.databind.DeserializationContext",
"com.fasterxml.jackson.databind.JsonNode",
"com.fasterxml.jackson.databind.SerializerProvider",
"com.fasterxml.jackson.databind.annotation.JsonDeserialize",
"com.fasterxml.jackson.databind.annotation.JsonSerialize",
"java.io.IOException",
"java.util.ArrayList",
"java.util.Iterator",
"java.util.LinkedHashMap",
"java.util.List",
"java.util.Map",
"java.util.Objects",
] {
imports.insert(import.to_string());
}
if package != root_package {
imports.insert(format!("{root_package}.SpecNumbers"));
imports.insert(format!("{root_package}.ValidationException"));
imports.insert(format!("{root_package}.Violation"));
if body.contains("TemporalSupport.") {
imports.insert(format!("{root_package}.TemporalSupport"));
}
if body.contains("Base64Support.") {
imports.insert(format!("{root_package}.Base64Support"));
}
}
}
imports.insert("org.jspecify.annotations.Nullable".to_string());
for (ref_package, ref_class) in model_refs {
if ref_package != package {
imports.insert(format!("{ref_package}.{ref_class}"));
}
}
// Materialized-temporal `java.time` field types (imported so the `@Nullable`
// type-use annotation binds to the simple name, not a package qualifier).
for (needle, import) in [
("OffsetDateTime", "java.time.OffsetDateTime"),
("LocalDate", "java.time.LocalDate"),
("Duration", "java.time.Duration"),
] {
if body.contains(needle) {
imports.insert(import.to_string());
}
}
// Closed value-set (`const`/`enum`) value classes carry Jackson
// `@JsonCreator`/`@JsonValue` for the standalone/interop wire mapping.
for (needle, import) in [
(
"@JsonCreator",
"com.fasterxml.jackson.annotation.JsonCreator",
),
("@JsonValue", "com.fasterxml.jackson.annotation.JsonValue"),
] {
if body.contains(needle) {
imports.insert(import.to_string());
}
}
for import in &imports {
output.push_str("import ");
output.push_str(import);
output.push_str(";\n");
}
output.push('\n');
output.push_str(body);
output
}
/// Composes the doc-comment text from a `title` (summary line) and a
/// `description` (body); returns `None` when both are empty. See
/// specs/json-schema/features/{title,description}.md.
fn compose_doc(title: Option<&str>, description: Option<&str>) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(title) = title.map(str::trim).filter(|t| !t.is_empty()) {
parts.push(title.to_string());
}
if let Some(description) = description.map(str::trim).filter(|d| !d.is_empty()) {
parts.push(description.to_string());
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
/// Renders the Javadoc for a schema declaration (title summary + description
/// body, plus an `@deprecated` tag when deprecated) followed by the
/// `@Deprecated` annotation. `kind` is "type" or "field". See
/// specs/json-schema/features/deprecated.md.
fn render_java_schema_doc(
output: &mut String,
indent: &str,
title: Option<&str>,
description: Option<&str>,
deprecated: bool,
kind: &str,
) {
let doc = compose_doc(title, description);
let tags = if deprecated {
vec![(
"@deprecated".to_string(),
format!("This {kind} is deprecated."),
)]
} else {
Vec::new()
};
render_java_doc_comment(output, indent, doc.as_deref(), &tags);
if deprecated {
output.push_str(indent);
output.push_str("@Deprecated\n");
}
}
fn render_javadoc(output: &mut String, indent: &str, doc: Option<&str>) {
render_java_doc_comment(output, indent, doc, &[]);
}
fn java_string_literal(value: &str) -> String {
let mut output = String::with_capacity(value.len() + 2);
output.push('"');
for character in value.chars() {
match character {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
other => output.push(other),
}
}
output.push('"');
output
}
fn render_object_class(
output: &mut String,
class: &str,
schema: &Schema,
open: bool,
fields: &[FieldPlan],
typed_additional: Option<&TypedAdditionalPlan>,
implements: &[String],
refs: &mut BTreeSet<(String, String)>,
) {
for field in fields {
field.ty.collect_refs(refs);
}
if let Some(additional) = typed_additional {
additional.ty.collect_refs(refs);
}
render_java_schema_doc(
output,
"",
schema.title.as_deref(),
schema.description.as_deref(),
schema.deprecated == Some(true),
"type",
);
let implements_clause = if implements.is_empty() {
String::new()
} else {
format!(" implements {}", implements.join(", "))
};
output.push_str(&format!(
"@JsonSerialize(using = {class}.Serializer.class)\n@JsonDeserialize(using = {class}.Deserializer.class)\npublic final class {class}{implements_clause} {{\n"
));
// Nested inline-union declarations (interface + scalar/array wrappers).
for field in fields {
if let Some(union) = &field.union
&& union.nested
{
render_nested_union(output, union);
}
}
// Closed value-set (const/enum) nested value classes.
for field in fields {
if let JavaType::ClosedValue { class, wire } = &field.ty {
render_closed_value_class(
output,
class,
wire,
&field.java_name,
&field.closed_values,
&field.closed_overrides,
);
}
}
let mut wrote_pattern = false;
for (position, candidate) in [
(
ADDITIONAL_PROPERTIES_POSITION,
typed_additional.map(|additional| &additional.schema),
),
(PROPERTY_NAME_POSITION, schema.property_names.as_deref()),
] {
let Some(candidate) = candidate else {
continue;
};
let constraints = StringLengthConstraints::from_schema(candidate);
if let Some(pattern) = &constraints.pattern {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n",
java_pattern_field_name(position),
java_string_literal(pattern),
));
wrote_pattern = true;
}
if let Some(format) = &constraints.format {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n",
java_format_field_name(position),
java_string_literal(&format.pattern),
));
wrote_pattern = true;
}
}
// Compiled `pattern` regexes (compiled once at class init; the load-time
// gate already proved they compile). `find()` (unanchored) at each use site.
for field in fields {
if let Some(pattern) = &field.string_length.pattern {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n",
java_pattern_field_name(&field.java_name),
java_string_literal(pattern),
));
wrote_pattern = true;
}
if let Some(format) = &field.string_length.format {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n",
java_format_field_name(&field.java_name),
java_string_literal(&format.pattern),
));
wrote_pattern = true;
}
}
if wrote_pattern {
output.push('\n');
}
// Fields.
for field in fields {
render_javadoc(output, " ", field.doc.as_deref());
output.push_str(" private final ");
output.push_str(&field.declared_type());
output.push(' ');
output.push_str(&field.java_name);
output.push_str(";\n");
}
if open {
let value_type = typed_additional
.map(|additional| element_declaration(&additional.ty, additional.nullable))
.unwrap_or_else(|| "JsonNode".to_string());
output.push_str(&format!(
" private final Map<String, {value_type}> additionalProperties;\n"
));
}
output.push('\n');
// Constructor.
render_constructor(output, class, fields, open, typed_additional);
// Getters.
for field in fields {
let return_type = field.declared_type();
if field.deprecated {
// Native marker on the public getter: `@Deprecated` annotation plus a
// Javadoc `@deprecated` tag (the rationale lives on the field above).
render_java_doc_comment(
output,
" ",
None,
&[(
"@deprecated".to_string(),
"This field is deprecated.".to_string(),
)],
);
output.push_str(" @Deprecated\n");
}
output.push_str(&format!(
" public {return_type} get{}() {{\n return {};\n }}\n\n",
upper_first(&field.java_name),
field.java_name
));
// Default accessor for scalar defaults.
if let Some(default) = &field.default {
if !field.required {
if let Some(default_expr) = default_expr(field, default) {
let return_type = if default.is_null() {
field.ty.boxed_name()
} else {
field
.ty
.primitive_name()
.map(str::to_string)
.unwrap_or_else(|| field.ty.boxed_name())
};
output.push_str(&format!(
" public {} get{}OrDefault() {{\n return {} != null ? {} : {};\n }}\n\n",
return_type,
upper_first(&field.java_name),
field.java_name,
field.java_name,
default_expr
));
}
}
}
}
if open {
let value_type = typed_additional
.map(|additional| element_declaration(&additional.ty, additional.nullable))
.unwrap_or_else(|| "JsonNode".to_string());
output.push_str(&format!(
" public Map<String, {value_type}> getAdditionalProperties() {{\n return additionalProperties;\n }}\n\n"
));
}
render_equals_hashcode_tostring(output, class, fields, open);
render_object_serializer(output, class, schema, fields, open, typed_additional);
render_object_deserializer(output, class, schema, open, fields, typed_additional);
output.push_str("}\n");
}
fn render_constructor(
output: &mut String,
class: &str,
fields: &[FieldPlan],
open: bool,
typed_additional: Option<&TypedAdditionalPlan>,
) {
output.push_str(" public ");
output.push_str(class);
output.push('(');
let mut params = Vec::new();
for field in fields {
let param_type = field.declared_type();
params.push(format!("{param_type} {}", field.java_name));
}
if open {
let value_type = typed_additional
.map(|additional| element_declaration(&additional.ty, additional.nullable))
.unwrap_or_else(|| "JsonNode".to_string());
params.push(format!("Map<String, {value_type}> additionalProperties"));
}
output.push_str(¶ms.join(", "));
output.push_str(") {\n");
for field in fields {
output.push_str(&format!(
" this.{} = {};\n",
field.java_name, field.java_name
));
}
if open {
output.push_str(" this.additionalProperties = additionalProperties;\n");
}
output.push_str(" }\n\n");
}
fn render_equals_hashcode_tostring(
output: &mut String,
class: &str,
fields: &[FieldPlan],
open: bool,
) {
// equals
output.push_str(" @Override\n public boolean equals(@Nullable Object other) {\n");
output.push_str(" if (this == other) {\n return true;\n }\n");
output.push_str(&format!(
" if (!(other instanceof {class})) {{\n return false;\n }}\n"
));
output.push_str(&format!(" {class} that = ({class}) other;\n"));
let mut comparisons = Vec::new();
for field in fields {
if field.is_primitive() {
comparisons.push(format!("this.{0} == that.{0}", field.java_name));
} else {
comparisons.push(format!(
"Objects.equals(this.{0}, that.{0})",
field.java_name
));
}
}
if open {
comparisons.push(
"Objects.equals(this.additionalProperties, that.additionalProperties)".to_string(),
);
}
if comparisons.is_empty() {
output.push_str(" return true;\n");
} else {
output.push_str(" return ");
output.push_str(&comparisons.join("\n && "));
output.push_str(";\n");
}
output.push_str(" }\n\n");
// hashCode
output.push_str(" @Override\n public int hashCode() {\n return Objects.hash(");
let mut names: Vec<String> = fields.iter().map(|field| field.java_name.clone()).collect();
if open {
names.push("additionalProperties".to_string());
}
output.push_str(&names.join(", "));
output.push_str(");\n }\n\n");
// toString
output.push_str(" @Override\n public String toString() {\n");
output.push_str(&format!(" return \"{class}{{\"\n"));
if names.is_empty() {
output.push_str(" + \"}\";\n");
} else {
for (index, name) in names.iter().enumerate() {
let prefix = if index == 0 {
format!("{name}=")
} else {
format!(", {name}=")
};
output.push_str(&format!(" + \"{prefix}\" + {name}\n"));
}
output.push_str(" + \"}\";\n");
}
output.push_str(" }\n\n");
}
/// True when a field carries a constraint the serialize path must re-check over
/// the in-memory value (P12, both directions). Mirrors the dispatch in
/// `render_java_serialize_field_check`. A closed value (`const`/`enum`) needs no
/// serialize check: its value class can only hold a known constant.
fn field_has_serialize_check(field: &FieldPlan) -> bool {
// A union member (on its own, or as a collection's element) is re-checked
// against the branch it holds, when any branch declares a constraint.
if let Some(union) = field.union.as_ref().or(field.element_union.as_ref())
&& java_union_interface(&field.ty).is_some()
&& java_union_has_checks(union)
{
return true;
}
if java_type_needs_finite_check(&field.ty) {
return true;
}
if java_type_needs_calendar_year_check(&field.ty) {
return true;
}
if !field.closed_values.is_empty()
&& matches!(field.ty, JavaType::Temporal(_) | JavaType::Bytes(_))
{
return true;
}
if matches!(field.ty, JavaType::List(_))
&& field
.schema
.items
.as_deref()
.is_some_and(schema_has_recursive_value_checks)
{
return true;
}
match &field.ty {
JavaType::String => !field.string_length.is_empty(),
JavaType::Temporal(_) | JavaType::Bytes(_) => !field.string_length.is_empty(),
JavaType::Long | JavaType::Double => !field.numeric.is_empty(),
JavaType::List(_) => !field.array.is_empty(),
_ => false,
}
}
/// True when an object POJO's `Serializer` must run collecting validation before
/// writing: any constrained field, or an object-level count/dependency
/// constraint.
fn object_needs_serialize_validation(schema: &Schema, fields: &[FieldPlan], open: bool) -> bool {
schema.min_properties.is_some()
|| schema.max_properties.is_some()
|| schema.dependent_required.is_some()
|| schema.property_names.is_some()
|| open
|| fields.iter().any(field_has_serialize_check)
}
/// Emits the per-field constraint checks over an in-memory field value for the
/// serialize path, reusing the same emitters as the deserializer (numeric /
/// string-length / pattern / format / array). Closed values (`const`/`enum`) are
/// omitted — their value class can only hold a known constant. Boxed values are
/// guarded non-null; the check locals live in their own block so the reused
/// emitters' locals (`length`, `matchCount`, …) never collide across fields.
fn render_java_serialize_field_check(output: &mut String, field: &FieldPlan, indent: &str) {
let json = java_string_literal(&field.json_name);
let accessor = format!("value.{}", field.java_name);
let inner = format!("{indent} ");
let mut body = String::new();
{
render_java_finite_checks(&mut body, &field.ty, &accessor, &json, &inner, 0);
render_java_calendar_year_checks(&mut body, &field.ty, &accessor, &json, &inner, 0);
match &field.ty {
JavaType::String if !field.string_length.is_empty() => render_java_string_checks(
&mut body,
&accessor,
&json,
&field.java_name,
&field.string_length,
&inner,
),
JavaType::Long if !field.numeric.is_empty() => render_java_numeric_checks(
&mut body,
&accessor,
&json,
&field.numeric,
true,
&inner,
),
JavaType::Double if !field.numeric.is_empty() => render_java_numeric_checks(
&mut body,
&accessor,
&json,
&field.numeric,
false,
&inner,
),
JavaType::List(element_ty) => {
if !field.array.is_empty() {
render_java_array_checks(
&mut body,
&accessor,
&json,
element_ty,
&field.array,
&inner,
);
}
if let Some(item_schema) = field.schema.items.as_deref()
&& schema_has_recursive_value_checks(item_schema)
{
let index = "validationIndex0";
let item = "validationValue0";
let item_path = format!("{json} + \"[\" + {index} + \"]\"");
body.push_str(&format!(
"{inner}for (int {index} = 0; {index} < {accessor}.size(); {index}++) {{\n{inner} {} {item} = {accessor}.get({index});\n{inner} if ({item} != null) {{\n",
element_ty.boxed_name()
));
render_java_recursive_value_checks(
&mut body,
item,
&item_path,
item_schema,
element_ty,
&format!("{inner} "),
1,
);
body.push_str(&format!("{inner} }}\n{inner}}}\n"));
}
}
JavaType::Temporal(kind)
if !field.string_length.is_empty() || !field.closed_values.is_empty() =>
{
let wire = format!("{}Wire", field.java_name);
let expression = java_temporal_format_fn(*kind)
.map(|format_fn| format!("TemporalSupport.{format_fn}({accessor})"))
.unwrap_or_else(|| accessor.clone());
body.push_str(&format!("{inner}String {wire} = {expression};\n"));
render_java_string_checks(
&mut body,
&wire,
&json,
&field.java_name,
&field.string_length,
&inner,
);
render_java_closed_string_checks(&mut body, &wire, &json, &field.schema, &inner);
}
JavaType::Bytes(encoding)
if !field.string_length.is_empty() || !field.closed_values.is_empty() =>
{
let wire = format!("{}Wire", field.java_name);
let format_fn = java_content_encoding_format_fn(*encoding);
body.push_str(&format!(
"{inner}String {wire} = Base64Support.{format_fn}({accessor});\n"
));
render_java_string_checks(
&mut body,
&wire,
&json,
&field.java_name,
&field.string_length,
&inner,
);
render_java_closed_string_checks(&mut body, &wire, &json, &field.schema, &inner);
}
_ => {}
}
// A union member is re-checked against the branch it holds before emit,
// through the union's own runtime-class dispatcher. As a collection's
// element, each value is routed under its own index (P11) — the
// serialize-side counterpart of the elementwise parse.
if let Some(union) = &field.union
&& java_union_has_checks(union)
&& let Some(interface) = java_union_interface(&field.ty)
{
body.push_str(&format!(
"{inner}{interface}.validate({accessor}, {json}, violations);\n"
));
}
if let Some(union) = &field.element_union
&& java_union_has_checks(union)
&& let Some(interface) = java_union_interface(&field.ty)
{
body.push_str(&format!(
"{inner}for (int index = 0; index < {accessor}.size(); index++) {{\n{inner} {interface}.validate({accessor}.get(index), {json} + \"[\" + index + \"]\", violations);\n{inner}}}\n"
));
}
}
if body.is_empty() {
return;
}
if field.is_primitive() {
// A stored primitive is always present; a bare block scopes the locals.
output.push_str(&format!("{indent}{{\n{body}{indent}}}\n"));
} else {
output.push_str(&format!(
"{indent}if ({accessor} != null) {{\n{body}{indent}}}\n"
));
}
}
/// The boolean expression that decides whether the wire member `json_key` will
/// be emitted, for the serialize-side member-count and dependency checks.
fn java_field_present_expr(fields: &[FieldPlan], json_key: &str, open: bool) -> String {
if let Some(field) = fields.iter().find(|field| field.json_name == json_key) {
// A stored primitive or a required+nullable member is always written.
if field.is_primitive() || (field.required && field.nullable) {
"true".to_string()
} else {
format!("value.{} != null", field.java_name)
}
} else if open {
format!(
"value.additionalProperties != null && value.additionalProperties.containsKey({})",
java_string_literal(json_key)
)
} else {
"false".to_string()
}
}
/// Emits the object member-count predicate over the to-be-emitted wire key count
/// for the serialize path (P12). The count mirrors `render_field_serialize`'s
/// emit decision per member, plus any catch-all (`additionalProperties`) entries.
fn render_java_serialize_property_count(
output: &mut String,
schema: &Schema,
fields: &[FieldPlan],
open: bool,
indent: &str,
) {
if schema.min_properties.is_none() && schema.max_properties.is_none() {
return;
}
output.push_str(&format!("{indent}int wireKeyCount = 0;\n"));
for field in fields {
if field.is_primitive() || (field.required && field.nullable) {
output.push_str(&format!("{indent}wireKeyCount++;\n"));
} else {
output.push_str(&format!(
"{indent}if (value.{} != null) {{\n{indent} wireKeyCount++;\n{indent}}}\n",
field.java_name
));
}
}
if open {
output.push_str(&format!(
"{indent}if (value.additionalProperties != null) {{\n{indent} wireKeyCount += value.additionalProperties.size();\n{indent}}}\n"
));
}
render_java_property_count_checks(output, "wireKeyCount", schema, indent);
}
/// Emits the `dependentRequired` cross-field presence predicate for the
/// serialize path: for each present trigger member, each dependent member must
/// also be present in the to-be-emitted wire object.
fn render_java_serialize_dependent_required(
output: &mut String,
schema: &Schema,
fields: &[FieldPlan],
open: bool,
indent: &str,
) {
let Some(dependent_required) = &schema.dependent_required else {
return;
};
for (trigger, deps) in dependent_required {
let trigger_present = java_field_present_expr(fields, trigger, open);
output.push_str(&format!("{indent}if ({trigger_present}) {{\n"));
for dep in deps {
let dep_present = java_field_present_expr(fields, dep, open);
let reason = format!(
"property {} is required when {} is present",
quote_for_message(dep),
quote_for_message(trigger)
);
output.push_str(&format!(
"{indent} if (!({dep_present})) {{\n{indent} violations.add(new Violation({}, {}));\n{indent} }}\n",
java_string_literal(dep),
java_string_literal(&reason)
));
}
output.push_str(&format!("{indent}}}\n"));
}
}
/// Emits the `propertyNames` key-shape predicate over the keys of a typed map's
/// in-memory `additionalProperties` for the serialize path. See
/// `specs/json-schema/features/propertyNames.md`.
fn render_java_serialize_property_name_checks(
output: &mut String,
keys_expr: &str,
subschema: &Schema,
indent: &str,
) {
let constraints = StringLengthConstraints::from_schema(subschema);
if constraints.is_empty() && subschema.const_value.is_none() && subschema.enum_values.is_none()
{
return;
}
output.push_str(&format!("{indent}for (String pnKey : {keys_expr}) {{\n"));
if constraints.has_length() {
output.push_str(&format!(
"{indent} int pnLength = pnKey.codePointCount(0, pnKey.length());\n"
));
if let Some(min) = constraints.min_length {
output.push_str(&format!(
"{indent} if (pnLength < {min}) {{\n{indent} violations.add(new Violation(pnKey, \"invalid property name \\\"\" + pnKey + \"\\\": must have length >= {min}, got \" + pnLength));\n{indent} }}\n"
));
}
if let Some(max) = constraints.max_length {
output.push_str(&format!(
"{indent} if (pnLength > {max}) {{\n{indent} violations.add(new Violation(pnKey, \"invalid property name \\\"\" + pnKey + \"\\\": must have length <= {max}, got \" + pnLength));\n{indent} }}\n"
));
}
}
let mut non_length = constraints.clone();
non_length.min_length = None;
non_length.max_length = None;
render_java_string_checks(
output,
"pnKey",
"pnKey",
PROPERTY_NAME_POSITION,
&non_length,
&format!("{indent} "),
);
render_java_closed_string_checks(
output,
"pnKey",
"pnKey",
subschema,
&format!("{indent} "),
);
output.push_str(&format!("{indent}}}\n"));
}
fn render_java_closed_string_checks(
output: &mut String,
value_expr: &str,
path_expr: &str,
schema: &Schema,
indent: &str,
) {
render_java_closed_scalar_checks(
output,
value_expr,
path_expr,
schema,
&JavaType::String,
indent,
);
}
fn render_java_closed_scalar_checks(
output: &mut String,
value_expr: &str,
path_expr: &str,
schema: &Schema,
ty: &JavaType,
indent: &str,
) {
let values = schema
.const_value
.as_ref()
.map(|value| vec![value.clone()])
.or_else(|| schema.enum_values.clone());
let Some(values) = values else {
return;
};
let condition = values
.iter()
.map(|value| format!("!({})", java_equals_term(value, value_expr, ty)))
.collect::<Vec<_>>()
.join(" && ");
let reason = if values.len() == 1 {
format!(
"must equal {}, got ",
serde_json::to_string(&values[0]).unwrap()
)
} else {
format!(
"must be one of [{}], got ",
java_closed_set_display(&values)
)
};
output.push_str(&format!(
"{indent}if ({condition}) {{\n{indent} violations.add(new Violation({path_expr}, {} + {value_expr}));\n{indent}}}\n",
java_string_literal(&reason)
));
}
fn render_object_serializer(
output: &mut String,
class: &str,
schema: &Schema,
fields: &[FieldPlan],
open: bool,
typed_additional: Option<&TypedAdditionalPlan>,
) {
output.push_str(&format!(
" public static final class Serializer extends com.fasterxml.jackson.databind.JsonSerializer<{class}> {{\n"
));
output.push_str(&format!(
" @Override\n public void serialize({class} value, JsonGenerator gen, SerializerProvider serializers) throws IOException {{\n"
));
// Serialize-side (P12): re-run the shared field validation over the
// in-memory model and throw the aggregated `ValidationException` before
// emitting any wire member — matching the deserializer (both directions over
// one set of check emitters).
let needs_validation = object_needs_serialize_validation(schema, fields, open);
if needs_validation {
output.push_str(" List<Violation> violations = new ArrayList<>();\n");
for field in fields {
render_java_serialize_field_check(output, field, " ");
}
if open {
output.push_str(
" java.util.Set<String> wireKeys = new java.util.LinkedHashSet<>();\n",
);
for field in fields {
let key = java_string_literal(&field.json_name);
if field.is_primitive() || (field.required && field.nullable) {
output.push_str(&format!(" wireKeys.add({key});\n"));
} else {
output.push_str(&format!(
" if (value.{} != null) {{\n wireKeys.add({key});\n }}\n",
field.java_name
));
}
}
output.push_str(" if (value.additionalProperties != null) {\n");
output.push_str(
" for (String key : value.additionalProperties.keySet()) {\n",
);
output.push_str(" if (!wireKeys.add(key)) {\n");
output.push_str(" violations.add(new Violation(key, \"declared property key collision\"));\n");
output.push_str(" }\n }\n }\n");
render_java_property_count_checks(output, "wireKeys.size()", schema, " ");
if let Some(subschema) = &schema.property_names {
render_java_serialize_property_name_checks(
output,
"wireKeys",
subschema,
" ",
);
}
if let Some(additional) = typed_additional {
let value_type = element_declaration(&additional.ty, additional.nullable);
output.push_str(&format!(
" if (value.additionalProperties != null) {{\n for (Map.Entry<String, {value_type}> entry : value.additionalProperties.entrySet()) {{\n"
));
if additional.nullable {
output.push_str(" if (entry.getValue() == null) {\n continue;\n }\n");
}
render_java_member_checks(
output,
"entry.getValue()",
"entry.getKey()",
&additional.schema,
&additional.ty,
ADDITIONAL_PROPERTIES_POSITION,
" ",
);
render_java_materialized_wire_checks(
output,
"entry.getValue()",
"entry.getKey()",
&additional.schema,
&additional.ty,
ADDITIONAL_PROPERTIES_POSITION,
" ",
);
if let Some(interface) = java_union_interface(&additional.ty)
&& let Some(union) = &additional.union
&& java_union_has_checks(union)
{
output.push_str(&format!(
" {interface}.validate(entry.getValue(), entry.getKey(), violations);\n"
));
}
output.push_str(" }\n }\n");
}
} else {
render_java_serialize_property_count(output, schema, fields, open, " ");
}
render_java_serialize_dependent_required(output, schema, fields, open, " ");
output.push_str(" if (!violations.isEmpty()) {\n throw new ValidationException(violations);\n }\n");
}
output.push_str(" gen.writeStartObject();\n");
for field in fields {
render_field_serialize(output, field);
}
if open {
output.push_str(" if (value.additionalProperties != null) {\n");
let value_type = typed_additional
.map(|additional| element_declaration(&additional.ty, additional.nullable))
.unwrap_or_else(|| "JsonNode".to_string());
output.push_str(&format!(
" for (Map.Entry<String, {value_type}> entry : value.additionalProperties.entrySet()) {{\n"
));
if let Some(additional) = typed_additional {
if additional.nullable {
output.push_str(" if (entry.getValue() == null) {\n gen.writeNullField(entry.getKey());\n continue;\n }\n");
}
output.push_str(&write_map_value(&additional.ty));
} else {
output.push_str(" gen.writeFieldName(entry.getKey());\n");
output.push_str(" gen.writeTree(entry.getValue());\n");
}
output.push_str(" }\n");
output.push_str(" }\n");
}
output.push_str(" gen.writeEndObject();\n");
output.push_str(" }\n }\n\n");
}
fn render_field_serialize(output: &mut String, field: &FieldPlan) {
let json = java_string_literal(&field.json_name);
let accessor = format!("value.{}", field.java_name);
let write = write_value_statement(&field.ty, &json, &accessor, " ");
if field.is_primitive() {
// Always present.
output.push_str(&format!(
" {}\n",
write_value_statement(&field.ty, &json, &accessor, " ").trim_start()
));
return;
}
if field.required && field.nullable {
// required + nullable: always emit, null as JSON null.
output.push_str(&format!(" if ({accessor} != null) {{\n"));
output.push_str(&write);
output.push_str(&format!(
" }} else {{\n gen.writeNullField({json});\n }}\n"
));
} else {
// optional (nullable or not): omit when null.
output.push_str(&format!(" if ({accessor} != null) {{\n"));
output.push_str(&write);
output.push_str(" }\n");
}
}
fn write_value_statement(ty: &JavaType, json: &str, accessor: &str, indent: &str) -> String {
match ty {
JavaType::Long => format!("{indent}gen.writeNumberField({json}, {accessor});\n"),
JavaType::Double => format!("{indent}gen.writeNumberField({json}, {accessor});\n"),
JavaType::Boolean => format!("{indent}gen.writeBooleanField({json}, {accessor});\n"),
JavaType::String => format!("{indent}gen.writeStringField({json}, {accessor});\n"),
JavaType::Temporal(kind) => match java_temporal_format_fn(*kind) {
// `time` is already a canonical String; write it directly.
None => format!("{indent}gen.writeStringField({json}, {accessor});\n"),
Some(format_fn) => format!(
"{indent}gen.writeStringField({json}, TemporalSupport.{format_fn}({accessor}));\n"
),
},
JavaType::Bytes(encoding) => format!(
"{indent}gen.writeStringField({json}, Base64Support.{}({accessor}));\n",
java_content_encoding_format_fn(*encoding)
),
JavaType::Ref { .. } | JavaType::List(_) | JavaType::Union { .. } => {
format!(
"{indent}gen.writeFieldName({json});\n{indent}serializers.defaultSerializeValue({accessor}, gen);\n"
)
}
// A closed value writes its underlying scalar via `getValue()`; the
// value class can only hold a known constant, so no membership check.
JavaType::ClosedValue { wire, .. } => {
write_value_statement(wire, json, &format!("{accessor}.getValue()"), indent)
}
}
}
fn render_object_deserializer(
output: &mut String,
class: &str,
schema: &Schema,
open: bool,
fields: &[FieldPlan],
typed_additional: Option<&TypedAdditionalPlan>,
) {
output.push_str(&format!(
" public static final class Deserializer extends com.fasterxml.jackson.databind.JsonDeserializer<{class}> {{\n"
));
output.push_str(&format!(
" @Override\n public {class} deserialize(JsonParser parser, DeserializationContext context) throws IOException {{\n"
));
output.push_str(" JsonNode node = parser.readValueAsTree();\n");
output.push_str(" List<Violation> violations = new ArrayList<>();\n");
output.push_str(" if (node == null || !node.isObject()) {\n");
output.push_str(
" violations.add(new Violation(\"\", \"expected object\"));\n throw new ValidationException(violations);\n }\n",
);
let known: BTreeSet<&str> = fields
.iter()
.map(|field| field.json_name.as_str())
.collect();
if open {
let value_type = typed_additional
.map(|additional| element_declaration(&additional.ty, additional.nullable))
.unwrap_or_else(|| "JsonNode".to_string());
output.push_str(&format!(
" Map<String, {value_type}> additionalProperties = new LinkedHashMap<>();\n"
));
output.push_str(" Iterator<String> fieldNames = node.fieldNames();\n");
output.push_str(" while (fieldNames.hasNext()) {\n");
output.push_str(" String key = fieldNames.next();\n");
output.push_str(" switch (key) {\n");
for name in &known {
output.push_str(&format!(
" case {}:\n",
java_string_literal(name)
));
}
if !known.is_empty() {
output.push_str(" break;\n");
}
output.push_str(" default:\n");
if let Some(additional) = typed_additional {
output.push_str(" JsonNode element = node.get(key);\n");
output.push_str(" if (element.isNull()) {\n");
if additional.nullable {
output.push_str(" additionalProperties.put(key, null);\n break;\n");
} else {
output.push_str(" violations.add(new Violation(key, \"explicit null not allowed\"));\n break;\n");
}
output.push_str(" }\n");
render_parse_map_value(
output,
&additional.ty,
"additionalProperties",
"element",
"key",
Some(&additional.schema),
ADDITIONAL_PROPERTIES_POSITION,
" ",
);
} else {
output.push_str(
" additionalProperties.put(key, node.get(key));\n",
);
}
output.push_str(" }\n");
output.push_str(" }\n");
} else {
output.push_str(" Iterator<String> fieldNames = node.fieldNames();\n");
output.push_str(" while (fieldNames.hasNext()) {\n");
output.push_str(" String key = fieldNames.next();\n");
output.push_str(" switch (key) {\n");
for name in &known {
output.push_str(&format!(
" case {}:\n",
java_string_literal(name)
));
}
if !known.is_empty() {
output.push_str(" break;\n");
}
output.push_str(" default:\n");
output.push_str(
" violations.add(new Violation(key, \"unknown field\"));\n",
);
output.push_str(" }\n");
output.push_str(" }\n");
}
// Declare locals and parse.
for field in fields {
render_field_deserialize(output, field);
}
// Object member-count and cross-field constraints over the wire member set
// (`node` is the parsed wire tree; `node.size()` is the distinct key count).
render_java_property_count_checks(output, "node.size()", schema, " ");
render_java_dependent_required(output, "node", schema, " ");
output.push_str(" if (!violations.isEmpty()) {\n throw new ValidationException(violations);\n }\n");
output.push_str(" return new ");
output.push_str(class);
output.push('(');
let mut args: Vec<String> = fields.iter().map(|field| field.java_name.clone()).collect();
if open {
args.push("additionalProperties".to_string());
}
output.push_str(&args.join(", "));
output.push_str(");\n");
output.push_str(" }\n }\n");
}
fn render_field_deserialize(output: &mut String, field: &FieldPlan) {
let json = java_string_literal(&field.json_name);
let name = &field.java_name;
let indent = " ";
// Local declaration with default initial value.
if field.is_primitive() {
let init = match field.ty {
JavaType::Long => "0L",
JavaType::Double => "0.0",
JavaType::Boolean => "false",
_ => "null",
};
output.push_str(&format!(
"{indent}{} {name} = {init};\n",
field.field_type()
));
} else {
output.push_str(&format!("{indent}{} {name} = null;\n", field.field_type()));
}
output.push_str(&format!("{indent}{{\n"));
output.push_str(&format!("{indent} JsonNode field = node.get({json});\n"));
output.push_str(&format!("{indent} if (field == null) {{\n"));
if field.required {
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"required\"));\n"
));
}
output.push_str(&format!("{indent} }} else if (field.isNull()) {{\n"));
if !field.nullable {
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"explicit null not allowed\"));\n"
));
}
output.push_str(&format!("{indent} }} else {{\n"));
if let Some(union) = &field.union {
render_union_field_parse(output, union, name, &json, &format!("{indent} "));
} else {
render_parse_value(
output,
&field.ty,
name,
&json,
&field.java_name,
&field.closed_values,
&field.closed_overrides,
&field.numeric,
&field.string_length,
&field.array,
&field.schema,
field.nullable_items,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
}
/// Assigns a union-typed field from the wire `field` node by delegating to the
/// union interface's `fromNode` — the same call whether the interface is a named
/// def or nested in this POJO.
fn render_union_field_parse(
output: &mut String,
union: &JavaUnion,
target: &str,
json: &str,
indent: &str,
) {
output.push_str(&format!(
"{indent}{target} = {}.fromNode(field, {json}, violations, context);\n",
union.interface
));
}
fn render_parse_value(
output: &mut String,
ty: &JavaType,
target: &str,
json: &str,
field_java_name: &str,
closed_values: &[Value],
closed_overrides: &ClosedNameOverrides,
numeric: &NumericConstraints,
string_length: &StringLengthConstraints,
array: &ArrayConstraints,
schema: &Schema,
nullable_items: bool,
indent: &str,
) {
if let JavaType::ClosedValue { class, wire } = ty {
render_java_closed_parse(
output,
class,
wire,
target,
json,
field_java_name,
closed_values,
closed_overrides,
indent,
);
return;
}
match ty {
JavaType::Temporal(kind) => {
let parse_fn = java_temporal_parse_fn(*kind);
output.push_str(&format!("{indent}if (!field.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if !string_length.is_empty() {
render_java_string_checks(
output,
"field.textValue()",
json,
field_java_name,
string_length,
&format!("{indent} "),
);
}
render_java_closed_string_checks(
output,
"field.textValue()",
json,
schema,
&format!("{indent} "),
);
output.push_str(&format!(
"{indent} {target} = TemporalSupport.{parse_fn}(field.textValue(), {json}, violations);\n"
));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Bytes(encoding) => {
let parse_fn = java_content_encoding_parse_fn(*encoding);
output.push_str(&format!("{indent}if (!field.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if !string_length.is_empty() {
render_java_string_checks(
output,
"field.textValue()",
json,
field_java_name,
string_length,
&format!("{indent} "),
);
}
render_java_closed_string_checks(
output,
"field.textValue()",
json,
schema,
&format!("{indent} "),
);
output.push_str(&format!(
"{indent} {target} = Base64Support.{parse_fn}(field.textValue(), {json}, violations);\n"
));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::String => {
output.push_str(&format!("{indent}if (!field.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!("{indent} {target} = field.textValue();\n"));
if !string_length.is_empty() {
render_java_string_checks(
output,
target,
json,
field_java_name,
string_length,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Long => {
// specLong returns null on failure; guard the assignment so a
// primitive `long` target is never unboxed from null.
output.push_str(&format!(
"{indent}Long numberValue = SpecNumbers.specLong(field, {json}, violations);\n"
));
output.push_str(&format!("{indent}if (numberValue != null) {{\n"));
output.push_str(&format!("{indent} {target} = numberValue;\n"));
if !numeric.is_empty() {
render_java_numeric_checks(
output,
"numberValue",
json,
numeric,
true,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Double => {
output.push_str(&format!(
"{indent}Double numberValue = SpecNumbers.specDouble(field, {json}, violations);\n"
));
output.push_str(&format!("{indent}if (numberValue != null) {{\n"));
output.push_str(&format!("{indent} {target} = numberValue;\n"));
if !numeric.is_empty() {
render_java_numeric_checks(
output,
"numberValue",
json,
numeric,
false,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Boolean => {
output.push_str(&format!("{indent}if (!field.isBoolean()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"expected boolean\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!("{indent} {target} = field.booleanValue();\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Ref { class, .. } => {
output.push_str(&format!("{indent}try {{\n"));
output.push_str(&format!(
"{indent} {target} = context.readTreeAsValue(field, {class}.class);\n"
));
output.push_str(&format!(
"{indent}}} catch (ValidationException nested) {{\n"
));
output.push_str(&format!(
"{indent} for (Violation violation : nested.getViolations()) {{\n"
));
output.push_str(&format!(
"{indent} violations.add(violation.withPathPrefix({json}));\n"
));
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent}}} catch (IOException nested) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, nested.getMessage()));\n"
));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::List(inner) => {
output.push_str(&format!("{indent}if (!field.isArray()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({json}, \"expected array\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!(
"{indent} List<{}> items = new ArrayList<>();\n",
element_declaration(inner, nullable_items)
));
output.push_str(&format!(
"{indent} for (int index = 0; index < field.size(); index++) {{\n"
));
output.push_str(&format!(
"{indent} JsonNode element = field.get(index);\n"
));
output.push_str(&format!(
"{indent} String elementPath = {json} + \"[\" + index + \"]\";\n"
));
render_parse_element(
output,
inner,
schema.items.as_deref(),
"items",
"element",
"elementPath",
nullable_items,
0,
&format!("{indent} "),
);
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent} {target} = items;\n"));
if !array.is_empty() {
render_java_raw_array_checks(
output,
"field",
json,
inner,
array,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Union { .. } => {
// Union fields are dispatched by `render_union_field_parse`; this
// arm is unreachable but keeps the match exhaustive.
output.push_str(&format!(
"{indent}violations.add(new Violation({json}, \"unsupported union\"));\n"
));
}
// Closed values are handled by the early return above.
JavaType::ClosedValue { .. } => unreachable!("closed value handled above"),
}
}
/// An element type as it is written inside `List<…>`: a nullable element takes
/// the TYPE_USE annotation, so the collection stays non-null while its members
/// may be null ([[items]]).
fn element_declaration(ty: &JavaType, nullable: bool) -> String {
if !nullable {
return ty.boxed_name();
}
// A `byte[]` takes the array-level annotation (`byte @Nullable []`);
// `@Nullable byte[]` would (wrongly) annotate the primitive component type,
// as for a field of that type (see `FieldPlan::declared_type`).
if matches!(ty, JavaType::Bytes(_)) {
return "byte @Nullable []".to_string();
}
format!("@Nullable {}", ty.boxed_name())
}
fn render_parse_element(
output: &mut String,
ty: &JavaType,
schema: Option<&Schema>,
list: &str,
element: &str,
path_var: &str,
nullable: bool,
depth: usize,
indent: &str,
) {
// A nullable element ([[nullability]] `oneOf` in `items`) admits a wire
// `null` as a null member; the element type's own parse handles the rest.
if nullable {
output.push_str(&format!("{indent}if ({element}.isNull()) {{\n"));
output.push_str(&format!("{indent} {list}.add(null);\n"));
output.push_str(&format!("{indent} continue;\n"));
output.push_str(&format!("{indent}}}\n"));
}
match ty {
JavaType::String => {
output.push_str(&format!("{indent}if (!{element}.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path_var}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!(
"{indent} String parsed = {element}.textValue();\n"
));
if let Some(schema) = schema {
render_java_inline_string_checks(
output,
"parsed",
path_var,
schema,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent} {list}.add(parsed);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Long => {
output.push_str(&format!(
"{indent}Long parsed = SpecNumbers.specLong({element}, {path_var}, violations);\n"
));
output.push_str(&format!("{indent}if (parsed != null) {{\n"));
if let Some(schema) = schema {
let constraints = NumericConstraints::from_schema(schema);
render_java_numeric_checks(
output,
"parsed",
path_var,
&constraints,
true,
&format!("{indent} "),
);
render_java_closed_scalar_checks(
output,
"parsed",
path_var,
schema,
ty,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent} {list}.add(parsed);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Double => {
output.push_str(&format!(
"{indent}Double parsed = SpecNumbers.specDouble({element}, {path_var}, violations);\n"
));
output.push_str(&format!("{indent}if (parsed != null) {{\n"));
if let Some(schema) = schema {
let constraints = NumericConstraints::from_schema(schema);
render_java_numeric_checks(
output,
"parsed",
path_var,
&constraints,
false,
&format!("{indent} "),
);
render_java_closed_scalar_checks(
output,
"parsed",
path_var,
schema,
ty,
&format!("{indent} "),
);
}
output.push_str(&format!("{indent} {list}.add(parsed);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Boolean => {
output.push_str(&format!("{indent}if (!{element}.isBoolean()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path_var}, \"expected boolean\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if let Some(schema) = schema {
render_java_closed_scalar_checks(
output,
&format!("{element}.booleanValue()"),
path_var,
schema,
ty,
&format!("{indent} "),
);
}
output.push_str(&format!(
"{indent} {list}.add({element}.booleanValue());\n"
));
output.push_str(&format!("{indent}}}\n"));
}
// A union element is a sealed interface: Jackson cannot instantiate it,
// so it decodes through the interface's own token dispatcher, which
// collects its violations under the element path itself.
JavaType::Ref {
class, union: true, ..
} => {
let parsed = format!("parsed{}", upper_first(list));
output.push_str(&format!(
"{indent}{class} {parsed} = {class}.fromNode({element}, {path_var}, violations, context);\n"
));
output.push_str(&format!("{indent}if ({parsed} != null) {{\n"));
output.push_str(&format!("{indent} {list}.add({parsed});\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Ref { class, .. } => {
output.push_str(&format!("{indent}try {{\n"));
output.push_str(&format!(
"{indent} {list}.add(context.readTreeAsValue({element}, {class}.class));\n"
));
output.push_str(&format!(
"{indent}}} catch (ValidationException nested) {{\n"
));
output.push_str(&format!(
"{indent} for (Violation violation : nested.getViolations()) {{\n"
));
output.push_str(&format!(
"{indent} violations.add(violation.withPathPrefix({path_var}));\n"
));
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent}}} catch (IOException nested) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path_var}, nested.getMessage()));\n"
));
output.push_str(&format!("{indent}}}\n"));
}
// A nested array decodes elementwise in turn, one loop per level. The
// loop variables carry the nesting depth so an inner level never shadows
// the element, index, or path of the level above it ([[items]] admits
// `items` at any depth).
JavaType::List(inner) => {
let level = depth + 1;
let nested = format!("items{level}");
output.push_str(&format!("{indent}if (!{element}.isArray()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path_var}, \"expected array\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if let Some(schema) = schema {
render_java_inline_string_checks(
output,
&format!("{element}.textValue()"),
path_var,
schema,
&format!("{indent} "),
);
}
output.push_str(&format!(
"{indent} List<{}> {nested} = new ArrayList<>();\n",
element_declaration(inner, false)
));
output.push_str(&format!(
"{indent} for (int index{level} = 0; index{level} < {element}.size(); index{level}++) {{\n"
));
output.push_str(&format!(
"{indent} JsonNode element{level} = {element}.get(index{level});\n"
));
output.push_str(&format!(
"{indent} String path{level} = {path_var} + \"[\" + index{level} + \"]\";\n"
));
render_parse_element(
output,
inner,
schema.and_then(|schema| schema.items.as_deref()),
&nested,
&format!("element{level}"),
&format!("path{level}"),
false,
level,
&format!("{indent} "),
);
output.push_str(&format!("{indent} }}\n"));
if let Some(schema) = schema {
let constraints = ArrayConstraints::from_schema(schema);
if !constraints.is_empty() {
render_java_raw_array_checks(
output,
element,
path_var,
inner,
&constraints,
&format!("{indent} "),
);
}
}
output.push_str(&format!("{indent} {list}.add({nested});\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Union { .. } => {
output.push_str(&format!(
"{indent}violations.add(new Violation({path_var}, \"unsupported union element\"));\n"
));
}
JavaType::Temporal(kind) => {
let parse_fn = java_temporal_parse_fn(*kind);
let parsed_type = java_temporal_type(*kind);
output.push_str(&format!("{indent}if (!{element}.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path_var}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if let Some(schema) = schema {
render_java_inline_string_checks(
output,
&format!("{element}.textValue()"),
path_var,
schema,
&format!("{indent} "),
);
}
output.push_str(&format!(
"{indent} {parsed_type} parsed = TemporalSupport.{parse_fn}({element}.textValue(), {path_var}, violations);\n"
));
output.push_str(&format!("{indent} if (parsed != null) {{\n{indent} {list}.add(parsed);\n{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Bytes(encoding) => {
let parse_fn = java_content_encoding_parse_fn(*encoding);
output.push_str(&format!("{indent}if (!{element}.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({path_var}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!(
"{indent} byte[] parsed = Base64Support.{parse_fn}({element}.textValue(), {path_var}, violations);\n"
));
output.push_str(&format!("{indent} if (parsed != null) {{\n{indent} {list}.add(parsed);\n{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
}
// Array items are never a closed value (const/enum lives at the
// property level, not on array elements).
JavaType::ClosedValue { .. } => {
unreachable!("closed value cannot be an array element")
}
}
}
fn render_parse_map_value(
output: &mut String,
ty: &JavaType,
map: &str,
element: &str,
key_var: &str,
member: Option<&Schema>,
position: &str,
indent: &str,
) {
// The member's own constraints run over the decoded value, keyed by its key.
let checks = |output: &mut String, value_expr: &str, indent: &str| {
if let Some(member) = member {
render_java_member_checks(output, value_expr, key_var, member, ty, position, indent);
}
};
// A member with nothing to check needs no local for the decoded value.
let has_checks = {
let mut probe = String::new();
checks(&mut probe, "value", "");
!probe.is_empty()
};
match ty {
JavaType::String => {
output.push_str(&format!("{indent}if (!{element}.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({key_var}, \"expected string value\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if has_checks {
output.push_str(&format!(
"{indent} String value = {element}.textValue();\n"
));
checks(output, "value", &format!("{indent} "));
output.push_str(&format!("{indent} {map}.put({key_var}, value);\n"));
} else {
output.push_str(&format!(
"{indent} {map}.put({key_var}, {element}.textValue());\n"
));
}
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Long => {
output.push_str(&format!(
"{indent}Long parsed = SpecNumbers.specLong({element}, {key_var}, violations);\n"
));
output.push_str(&format!("{indent}if (parsed != null) {{\n"));
checks(output, "parsed", &format!("{indent} "));
output.push_str(&format!("{indent} {map}.put({key_var}, parsed);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Double => {
output.push_str(&format!(
"{indent}Double value = SpecNumbers.specDouble({element}, {key_var}, violations);\n"
));
output.push_str(&format!("{indent}if (value != null) {{\n"));
if has_checks {
checks(output, "value", &format!("{indent} "));
}
output.push_str(&format!("{indent} {map}.put({key_var}, value);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Boolean => {
output.push_str(&format!("{indent}if (!{element}.isBoolean()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({key_var}, \"expected boolean value\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!(
"{indent} {map}.put({key_var}, {element}.booleanValue());\n"
));
output.push_str(&format!("{indent}}}\n"));
}
// A union member decodes through the interface's token dispatcher —
// Jackson cannot instantiate a sealed interface.
JavaType::Ref {
class, union: true, ..
} => {
let parsed = format!("parsed{}", upper_first(map));
output.push_str(&format!(
"{indent}{class} {parsed} = {class}.fromNode({element}, {key_var}, violations, context);\n"
));
output.push_str(&format!("{indent}if ({parsed} != null) {{\n"));
output.push_str(&format!("{indent} {map}.put({key_var}, {parsed});\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Ref { class, .. } => {
output.push_str(&format!("{indent}try {{\n"));
output.push_str(&format!(
"{indent} {map}.put({key_var}, context.readTreeAsValue({element}, {class}.class));\n"
));
output.push_str(&format!(
"{indent}}} catch (ValidationException nested) {{\n"
));
output.push_str(&format!(
"{indent} for (Violation violation : nested.getViolations()) {{\n"
));
output.push_str(&format!(
"{indent} violations.add(violation.withPathPrefix({key_var}));\n"
));
output.push_str(&format!("{indent} }}\n"));
output.push_str(&format!("{indent}}} catch (IOException nested) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({key_var}, nested.getMessage()));\n"
));
output.push_str(&format!("{indent}}}\n"));
}
// An array-valued member decodes elementwise, like an array-typed field;
// its own `items` constraints then run over the decoded list, keyed by the
// member's key.
JavaType::List(inner) => {
output.push_str(&format!("{indent}if (!{element}.isArray()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({key_var}, \"expected array value\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!(
"{indent} List<{}> items = new ArrayList<>();\n",
element_declaration(inner, false)
));
output.push_str(&format!(
"{indent} for (int index = 0; index < {element}.size(); index++) {{\n"
));
output.push_str(&format!(
"{indent} JsonNode item = {element}.get(index);\n"
));
output.push_str(&format!(
"{indent} String itemPath = {key_var} + \"[\" + index + \"]\";\n"
));
render_parse_element(
output,
inner,
member.and_then(|schema| schema.items.as_deref()),
"items",
"item",
"itemPath",
false,
0,
&format!("{indent} "),
);
output.push_str(&format!("{indent} }}\n"));
if let Some(member) = member {
let constraints = ArrayConstraints::from_schema(member);
if !constraints.is_empty() {
render_java_raw_array_checks(
output,
element,
key_var,
inner,
&constraints,
&format!("{indent} "),
);
}
}
output.push_str(&format!("{indent} {map}.put({key_var}, items);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Union { .. } => {
output.push_str(&format!(
"{indent}violations.add(new Violation({key_var}, \"unsupported union value\"));\n"
));
}
JavaType::Temporal(kind) => {
let parse_fn = java_temporal_parse_fn(*kind);
let parsed_type = java_temporal_type(*kind);
output.push_str(&format!("{indent}if (!{element}.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({key_var}, \"expected string value\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if let Some(member) = member {
let constraints = StringLengthConstraints::from_schema(member);
if !constraints.is_empty() {
render_java_string_checks(
output,
&format!("{element}.textValue()"),
key_var,
position,
&constraints,
&format!("{indent} "),
);
}
}
output.push_str(&format!(
"{indent} {parsed_type} parsed = TemporalSupport.{parse_fn}({element}.textValue(), {key_var}, violations);\n"
));
output.push_str(&format!("{indent} if (parsed != null) {{\n{indent} {map}.put({key_var}, parsed);\n{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Bytes(encoding) => {
let parse_fn = java_content_encoding_parse_fn(*encoding);
output.push_str(&format!("{indent}if (!{element}.isTextual()) {{\n"));
output.push_str(&format!(
"{indent} violations.add(new Violation({key_var}, \"expected string value\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
if let Some(member) = member {
let constraints = StringLengthConstraints::from_schema(member);
if !constraints.is_empty() {
render_java_string_checks(
output,
&format!("{element}.textValue()"),
key_var,
position,
&constraints,
&format!("{indent} "),
);
}
}
output.push_str(&format!(
"{indent} byte[] parsed = Base64Support.{parse_fn}({element}.textValue(), {key_var}, violations);\n"
));
output.push_str(&format!("{indent} if (parsed != null) {{\n{indent} {map}.put({key_var}, parsed);\n{indent} }}\n"));
output.push_str(&format!("{indent}}}\n"));
}
// Typed-map values are never a closed value (const/enum lives at the
// property level, not on `additionalProperties`).
JavaType::ClosedValue { .. } => {
unreachable!("closed value cannot be a typed-map value")
}
}
}
/// The Java identifier a typed map's member contributes to a synthesized name —
/// the static `Pattern` fields its `pattern`/`format` compile into. Matches the
/// `Value` position name the loader gives an inline member shape.
const MAP_MEMBER_POSITION: &str = "value";
const ADDITIONAL_PROPERTIES_POSITION: &str = "additionalPropertiesValue";
const PROPERTY_NAME_POSITION: &str = "propertyName";
/// Emits the predicates a typed map's member schema declares, over `value_expr`
/// (the decoded member in scope) keyed by `key_expr` (the member's own key, which
/// becomes the violation path) — the same predicates, and the same reasons, a
/// declared field of that type carries. See
/// `specs/json-schema/features/additionalProperties.md` §"Validator mapping"
/// (per-member `T` validation).
fn render_java_member_checks(
output: &mut String,
value_expr: &str,
key_expr: &str,
member: &Schema,
ty: &JavaType,
position: &str,
indent: &str,
) {
render_java_finite_checks(output, ty, value_expr, key_expr, indent, 0);
render_java_calendar_year_checks(output, ty, value_expr, key_expr, indent, 0);
render_java_closed_scalar_checks(output, value_expr, key_expr, member, ty, indent);
match member.ty.as_ref().and_then(Value::as_str) {
Some("string") if matches!(ty, JavaType::String) => {
let constraints = StringLengthConstraints::from_schema(member);
if !constraints.is_empty() {
render_java_string_checks(
output,
value_expr,
key_expr,
position,
&constraints,
indent,
);
}
}
Some(kind @ ("integer" | "number")) => {
let constraints = NumericConstraints::from_schema(member);
if !constraints.is_empty() {
render_java_numeric_checks(
output,
value_expr,
key_expr,
&constraints,
kind == "integer",
indent,
);
}
}
Some("array") => {
let constraints = ArrayConstraints::from_schema(member);
if let JavaType::List(element) = ty {
if !constraints.is_empty() {
render_java_array_checks(
output,
value_expr,
key_expr,
element,
&constraints,
indent,
);
}
if let Some(item_schema) = member.items.as_deref()
&& schema_has_recursive_value_checks(item_schema)
{
let index = "validationIndex0";
let item = "validationValue0";
let item_path = format!("{key_expr} + \"[\" + {index} + \"]\"");
output.push_str(&format!(
"{indent}for (int {index} = 0; {index} < {value_expr}.size(); {index}++) {{\n{indent} {} {item} = {value_expr}.get({index});\n{indent} if ({item} != null) {{\n",
element.boxed_name()
));
render_java_recursive_value_checks(
output,
item,
&item_path,
item_schema,
element,
&format!("{indent} "),
1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
}
}
}
_ => {}
}
}
fn render_java_materialized_wire_checks(
output: &mut String,
value_expr: &str,
key_expr: &str,
member: &Schema,
ty: &JavaType,
position: &str,
indent: &str,
) {
let constraints = StringLengthConstraints::from_schema(member);
if constraints.is_empty() {
return;
}
let wire = match ty {
JavaType::Temporal(kind) => java_temporal_format_fn(*kind)
.map(|format_fn| format!("TemporalSupport.{format_fn}({value_expr})"))
.unwrap_or_else(|| value_expr.to_string()),
JavaType::Bytes(encoding) => format!(
"Base64Support.{}({value_expr})",
java_content_encoding_format_fn(*encoding)
),
_ => return,
};
output.push_str(&format!("{indent}String canonicalWire = {wire};\n"));
render_java_string_checks(
output,
"canonicalWire",
key_expr,
position,
&constraints,
indent,
);
}
/// True when a map-shaped schema admits an explicit `null` member (its
/// `additionalProperties` is the nullability `oneOf` wrapper).
fn map_member_allows_null(schema: &Schema) -> bool {
let Some(Value::Object(members)) = &schema.additional_properties else {
return false;
};
serde_json::from_value::<Schema>(Value::Object(members.clone()))
.map(|member| allows_null(&member))
.unwrap_or(false)
}
/// The union a typed map's member type refers to, when the member is a `oneOf`
/// sum type. The loader hoists an inline member union into `$defs` and rewrites
/// the position to a `$ref`, so a union member is always a reference here.
fn map_member_union(
schema: &Schema,
all_models: &BTreeMap<String, PlannedJsonType>,
context: &JavaContext,
) -> Option<JavaUnion> {
ref_union(&map_member_schema(schema)?, all_models, context)
}
/// A map-shaped schema's declared member schema, with any nullability `oneOf`
/// wrapper looked through so the constraints it carries are the member's own.
fn map_member_schema(schema: &Schema) -> Option<Schema> {
let Some(Value::Object(members)) = &schema.additional_properties else {
return None;
};
let member: Schema = serde_json::from_value(Value::Object(members.clone())).ok()?;
match nullable_non_null_schema(&member) {
Some(non_null) => Some(non_null.clone()),
None => Some(member),
}
}
fn render_typed_map_class(
output: &mut String,
class: &str,
schema: &Schema,
value: &JavaType,
max_properties: Option<usize>,
member_union: Option<&JavaUnion>,
refs: &mut BTreeSet<(String, String)>,
) {
value.collect_refs(refs);
render_java_schema_doc(
output,
"",
schema.title.as_deref(),
schema.description.as_deref(),
schema.deprecated == Some(true),
"type",
);
output.push_str(&format!(
"@JsonSerialize(using = {class}.Serializer.class)\n@JsonDeserialize(using = {class}.Deserializer.class)\npublic final class {class} {{\n"
));
// A nullable member takes the TYPE_USE annotation, so the map stays non-null
// while its members may be null — the rule [[items]] applies to an element.
let nullable_members = map_member_allows_null(schema);
let value_type = element_declaration(value, nullable_members);
let member = map_member_schema(schema);
// Compiled member `pattern`/`format` regexes (compiled once at class init),
// the map counterpart of a POJO's per-field `Pattern` statics.
if let Some(member) = &member {
let constraints = StringLengthConstraints::from_schema(member);
if let Some(pattern) = &constraints.pattern {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n\n",
java_pattern_field_name(MAP_MEMBER_POSITION),
java_string_literal(pattern),
));
}
if let Some(format) = &constraints.format {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n\n",
java_format_field_name(MAP_MEMBER_POSITION),
java_string_literal(&format.pattern),
));
}
}
if let Some(property_names) = &schema.property_names {
let constraints = StringLengthConstraints::from_schema(property_names);
if let Some(pattern) = &constraints.pattern {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n\n",
java_pattern_field_name(PROPERTY_NAME_POSITION),
java_string_literal(pattern),
));
}
if let Some(format) = &constraints.format {
output.push_str(&format!(
" private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n\n",
java_format_field_name(PROPERTY_NAME_POSITION),
java_string_literal(&format.pattern),
));
}
}
// The catch-all is always the named `additionalProperties` member, matching
// the struct-shaped POJOs — see `specs/json-schema/features/additionalProperties.md`.
output.push_str(&format!(
" private final Map<String, {value_type}> additionalProperties;\n\n"
));
output.push_str(&format!(
" public {class}(Map<String, {value_type}> additionalProperties) {{\n this.additionalProperties = additionalProperties;\n }}\n\n"
));
output.push_str(&format!(
" public Map<String, {value_type}> getAdditionalProperties() {{\n return additionalProperties;\n }}\n\n"
));
// equals/hashCode/toString
output.push_str(" @Override\n public boolean equals(@Nullable Object other) {\n");
output.push_str(" if (this == other) {\n return true;\n }\n");
output.push_str(&format!(
" if (!(other instanceof {class})) {{\n return false;\n }}\n"
));
output.push_str(&format!(
" return Objects.equals(this.additionalProperties, (({class}) other).additionalProperties);\n }}\n\n"
));
output.push_str(
" @Override\n public int hashCode() {\n return Objects.hash(additionalProperties);\n }\n\n",
);
output.push_str(&format!(
" @Override\n public String toString() {{\n return \"{class}{{\"\n + \"additionalProperties=\" + additionalProperties\n + \"}}\";\n }}\n\n"
));
// Serializer
output.push_str(&format!(
" public static final class Serializer extends com.fasterxml.jackson.databind.JsonSerializer<{class}> {{\n"
));
output.push_str(&format!(
" @Override\n public void serialize({class} value, JsonGenerator gen, SerializerProvider serializers) throws IOException {{\n"
));
// Serialize-side (P12): member-count and key-shape constraints over the
// in-memory map, plus each member re-checked against `T`, thrown as an
// aggregated `ValidationException` before emitting — matching the
// deserializer.
let mut member_checks = String::new();
if let Some(member) = &member {
render_java_member_checks(
&mut member_checks,
"entry.getValue()",
"entry.getKey()",
member,
value,
MAP_MEMBER_POSITION,
" ",
);
render_java_materialized_wire_checks(
&mut member_checks,
"entry.getValue()",
"entry.getKey()",
member,
value,
MAP_MEMBER_POSITION,
" ",
);
// A union-typed member is re-checked against the branch it holds, through
// the union's own dispatcher (the parse side runs the same checks inside
// `fromNode`).
if let Some(interface) = java_union_interface(value)
&& let Some(union) = member_union
&& java_union_has_checks(union)
{
member_checks.push_str(&format!(
" {interface}.validate(entry.getValue(), entry.getKey(), violations);\n"
));
}
}
let map_needs_validation = schema.min_properties.is_some()
|| schema.max_properties.is_some()
|| schema.property_names.is_some()
|| !member_checks.is_empty();
if map_needs_validation {
output.push_str(" List<Violation> violations = new ArrayList<>();\n");
render_java_property_count_checks(
output,
"value.additionalProperties.size()",
schema,
" ",
);
if let Some(subschema) = &schema.property_names {
render_java_serialize_property_name_checks(
output,
"value.additionalProperties.keySet()",
subschema,
" ",
);
}
if !member_checks.is_empty() {
output.push_str(&format!(
" for (Map.Entry<String, {value_type}> entry : value.additionalProperties.entrySet()) {{\n"
));
if nullable_members {
output.push_str(" if (entry.getValue() == null) {\n");
output.push_str(" continue;\n }\n");
}
output.push_str(&member_checks);
output.push_str(" }\n");
}
output.push_str(" if (!violations.isEmpty()) {\n throw new ValidationException(violations);\n }\n");
}
output.push_str(" gen.writeStartObject();\n");
output.push_str(&format!(
" for (Map.Entry<String, {value_type}> entry : value.additionalProperties.entrySet()) {{\n"
));
if nullable_members {
output.push_str(" if (entry.getValue() == null) {\n");
output.push_str(" gen.writeNullField(entry.getKey());\n");
output.push_str(" continue;\n }\n");
}
output.push_str(&write_map_value(value));
output.push_str(" }\n");
output.push_str(" gen.writeEndObject();\n");
output.push_str(" }\n }\n\n");
// Deserializer
output.push_str(&format!(
" public static final class Deserializer extends com.fasterxml.jackson.databind.JsonDeserializer<{class}> {{\n"
));
output.push_str(&format!(
" @Override\n public {class} deserialize(JsonParser parser, DeserializationContext context) throws IOException {{\n"
));
output.push_str(" JsonNode node = parser.readValueAsTree();\n");
output.push_str(" List<Violation> violations = new ArrayList<>();\n");
output.push_str(" if (node == null || !node.isObject()) {\n");
output.push_str(
" violations.add(new Violation(\"\", \"expected object\"));\n throw new ValidationException(violations);\n }\n",
);
output.push_str(&format!(
" Map<String, {value_type}> additionalProperties = new LinkedHashMap<>();\n"
));
output.push_str(" Iterator<String> fieldNames = node.fieldNames();\n");
output.push_str(" while (fieldNames.hasNext()) {\n");
output.push_str(" String key = fieldNames.next();\n");
output.push_str(" JsonNode element = node.get(key);\n");
output.push_str(" if (element.isNull()) {\n");
if nullable_members {
// A `null` member of a nullable map is kept as a null member, so the key
// survives the round trip ([[nullability]]).
output.push_str(
" additionalProperties.put(key, null);\n continue;\n }\n",
);
} else {
output.push_str(
" violations.add(new Violation(key, \"explicit null not allowed\"));\n continue;\n }\n",
);
}
render_parse_map_value(
output,
value,
"additionalProperties",
"element",
"key",
member.as_ref(),
MAP_MEMBER_POSITION,
" ",
);
output.push_str(" }\n");
let _ = max_properties;
// Object member-count and key-shape constraints over the distinct wire keys
// (`node.size()`), counted as one number.
render_java_property_count_checks(output, "node.size()", schema, " ");
if let Some(subschema) = &schema.property_names {
render_java_property_name_checks(output, "node", subschema, " ");
}
output.push_str(" if (!violations.isEmpty()) {\n throw new ValidationException(violations);\n }\n");
output.push_str(&format!(
" return new {class}(additionalProperties);\n"
));
output.push_str(" }\n }\n}\n");
}
fn write_map_value(value: &JavaType) -> String {
match value {
JavaType::String => {
" gen.writeStringField(entry.getKey(), entry.getValue());\n".to_string()
}
JavaType::Long | JavaType::Double => {
" gen.writeNumberField(entry.getKey(), entry.getValue());\n".to_string()
}
JavaType::Boolean => {
" gen.writeBooleanField(entry.getKey(), entry.getValue());\n".to_string()
}
JavaType::Ref { .. } | JavaType::List(_) | JavaType::Union { .. } => {
" gen.writeFieldName(entry.getKey());\n serializers.defaultSerializeValue(entry.getValue(), gen);\n".to_string()
}
JavaType::Temporal(kind) => match java_temporal_format_fn(*kind) {
None => {
" gen.writeStringField(entry.getKey(), entry.getValue());\n".to_string()
}
Some(format_fn) => format!(
" gen.writeStringField(entry.getKey(), TemporalSupport.{format_fn}(entry.getValue()));\n"
),
},
JavaType::Bytes(encoding) => format!(
" gen.writeStringField(entry.getKey(), Base64Support.{}(entry.getValue()));\n",
java_content_encoding_format_fn(*encoding)
),
// Typed-map values are never a closed value.
JavaType::ClosedValue { .. } => {
unreachable!("closed value cannot be a typed-map value")
}
}
}
/// Renders a nested closed value-set class (`const`/`enum`) inside the enclosing
/// POJO: one known constant per member, a private constructor, a `@JsonCreator`
/// factory (throws on the standalone/interop path), and a `@JsonValue` accessor
/// over the underlying scalar. The private constructor makes the known constants
/// the only obtainable instances — a value outside the set cannot be constructed
/// (P13.1). See `specs/json-schema/features/{const,enum}.md`.
fn render_closed_value_class(
output: &mut String,
class: &str,
wire: &JavaType,
field_java_name: &str,
values: &[Value],
overrides: &ClosedNameOverrides,
) {
let const_type = java_closed_const_type(wire);
output.push_str(&format!(" public static final class {class} {{\n"));
// Known constants — the only obtainable instances.
for value in values {
output.push_str(&format!(
" public static final {class} {} = new {class}({});\n",
java_const_name(overrides, field_java_name, values, value),
java_closed_literal(wire, value),
));
}
output.push('\n');
output.push_str(&format!(" private final {const_type} value;\n\n"));
output.push_str(&format!(
" private {class}({const_type} value) {{\n this.value = value;\n }}\n\n"
));
render_closed_value_creator(output, class, wire, field_java_name, values, overrides);
output.push_str(&format!(
" @JsonValue\n public {const_type} getValue() {{\n return value;\n }}\n\n"
));
render_closed_value_object_methods(output, class, wire);
output.push_str(" }\n\n");
}
/// Emits the value class's `@JsonCreator` factory: a fail-fast lookup used on the
/// standalone/interop decode path (the aggregating per-POJO deserializer uses its
/// own non-throwing lookup instead). Returns the matching constant or throws
/// `IllegalArgumentException` naming the expected set and the offending value.
fn render_closed_value_creator(
output: &mut String,
class: &str,
wire: &JavaType,
field_java_name: &str,
values: &[Value],
overrides: &ClosedNameOverrides,
) {
let is_string = matches!(wire, JavaType::String);
let (factory, param_type) = match wire {
JavaType::Long => ("fromLong", "long"),
JavaType::Double => ("fromDouble", "double"),
JavaType::Boolean => ("fromBoolean", "boolean"),
_ => ("fromString", "String"),
};
// Only the reference-typed (`String`) wire can be null; a primitive factory
// parameter never is, so its return type is non-null.
let return_type = if is_string {
format!("@Nullable {class}")
} else {
class.to_string()
};
output.push_str(&format!(
" @JsonCreator\n public static {return_type} {factory}({param_type} value) {{\n"
));
if is_string {
output.push_str(
" if (value == null) {\n return null;\n }\n",
);
}
for member in values {
let name = java_const_name(overrides, field_java_name, values, member);
let test = if is_string {
format!("{}.equals(value)", java_closed_literal(wire, member))
} else {
format!("value == {}", java_closed_literal(wire, member))
};
output.push_str(&format!(
" if ({test}) {{\n return {name};\n }}\n"
));
}
output.push_str(&format!(
" throw new IllegalArgumentException({});\n }}\n\n",
java_closed_throw_message(values, is_string),
));
}
/// Emits the value class's value-based `equals`/`hashCode`/`toString`.
fn render_closed_value_object_methods(output: &mut String, class: &str, wire: &JavaType) {
output.push_str(" @Override\n public boolean equals(@Nullable Object other) {\n");
output.push_str(
" if (this == other) {\n return true;\n }\n",
);
output.push_str(&format!(
" if (!(other instanceof {class})) {{\n return false;\n }}\n"
));
output.push_str(&format!(" {class} that = ({class}) other;\n"));
if matches!(wire, JavaType::String) {
output.push_str(" return Objects.equals(this.value, that.value);\n");
} else {
output.push_str(" return this.value == that.value;\n");
}
output.push_str(" }\n\n");
output.push_str(
" @Override\n public int hashCode() {\n return Objects.hash(value);\n }\n\n",
);
output.push_str(&format!(
" @Override\n public String toString() {{\n return \"{class}[\" + value + \"]\";\n }}\n"
));
}
/// The Java expression for the `@JsonCreator` throw message: names the expected
/// value (`const`) or set (`enum`) and the offending value, mirroring the
/// deserializer's `Violation` reason (`must equal "user", got "admin"` /
/// `must be one of ["a", "b"], got "c"`).
fn java_closed_throw_message(values: &[Value], is_string: bool) -> String {
let lead = if values.len() == 1 {
let text = match &values[0] {
Value::String(string) => format!("must equal {string:?}"),
Value::Bool(flag) => format!("must equal {flag}"),
Value::Number(number) => format!("must equal {number}"),
_ => "must equal <const>".to_string(),
};
text
} else {
format!("must be one of [{}]", java_closed_set_display(values))
};
if is_string {
format!(
"{} + value + \"\\\"\"",
java_string_literal(&format!("{lead}, got \""))
)
} else {
format!("{} + value", java_string_literal(&format!("{lead}, got ")))
}
}
fn default_expr(field: &FieldPlan, value: &Value) -> Option<String> {
let ty = &field.ty;
match (ty, value) {
(_, Value::Null) if field.nullable => Some("null".to_string()),
(JavaType::Long, Value::Number(number)) => number.as_i64().map(|value| format!("{value}L")),
(JavaType::Double, Value::Number(number)) => {
number.as_f64().map(|value| format!("{value}"))
}
(JavaType::Boolean, Value::Bool(value)) => Some(value.to_string()),
(JavaType::String, Value::String(text)) => Some(java_string_literal(text)),
(JavaType::ClosedValue { class, .. }, _) => Some(format!(
"{class}.{}",
java_const_name(
&field.closed_overrides,
&field.java_name,
&field.closed_values,
value,
)
)),
(JavaType::Temporal(kind), Value::String(text)) => {
let literal = java_string_literal(text);
Some(match kind {
crate::json_schema::format::TemporalKind::DateTime => {
format!("OffsetDateTime.parse({literal})")
}
crate::json_schema::format::TemporalKind::Date => {
format!("LocalDate.parse({literal})")
}
crate::json_schema::format::TemporalKind::Time => literal,
crate::json_schema::format::TemporalKind::Duration => {
format!("Duration.parse({literal})")
}
})
}
(JavaType::Bytes(encoding), Value::String(text)) => {
let decoder = match encoding {
crate::json_schema::content_encoding::Encoding::Base64 => "getDecoder",
crate::json_schema::content_encoding::Encoding::Base64Url => "getUrlDecoder",
};
Some(format!(
"java.util.Base64.{decoder}().decode({})",
java_string_literal(text)
))
}
_ => None,
}
}
fn quote_for_message(value: &str) -> String {
format!("\"{value}\"")
}
/// The Java type used for a closed-value (const/enum) named constant.
fn java_closed_const_type(ty: &JavaType) -> &'static str {
match ty {
JavaType::Long => "long",
JavaType::Double => "double",
JavaType::Boolean => "boolean",
_ => "String",
}
}
/// The Java literal for a scalar closed value in its constant's type.
fn java_closed_literal(ty: &JavaType, value: &Value) -> String {
match ty {
JavaType::Long => format!("{}L", value.as_i64().unwrap_or_default()),
JavaType::Double => value.to_string(),
JavaType::Boolean => value.as_bool().unwrap_or_default().to_string(),
_ => java_string_literal(value.as_str().unwrap_or_default()),
}
}
/// The UPPER_SNAKE token encoding a scalar value for a class-scoped constant
/// name (digits kept, `.` → `_`, a leading sign → `NEG_`, booleans TRUE/FALSE).
fn java_closed_token(value: &Value) -> String {
match value {
Value::String(text) => shouty(text),
Value::Bool(flag) => if *flag { "TRUE" } else { "FALSE" }.to_string(),
Value::Number(number) => number.to_string().replace('-', "NEG_").replace('.', "_"),
_ => String::new(),
}
}
/// The class-scoped constant name for a closed value. A single-value `const`
/// uses the field name (`KIND`); an `enum` member appends the value token
/// (`STATUS_ACTIVE`, `TIER_1`, `SCALE_1_5`), the field name supplying the
/// leading letter so no `V_` digit-guard prefix is needed.
fn java_const_name(
overrides: &ClosedNameOverrides,
field_java_name: &str,
values: &[Value],
value: &Value,
) -> String {
if let Some(name) = overrides.get(value) {
return name.to_string();
}
let field_upper = shouty(field_java_name);
if values.len() == 1 {
field_upper
} else {
format!("{field_upper}_{}", java_closed_token(value))
}
}
/// The comma-joined display of a closed value set for a reason string.
fn java_closed_set_display(values: &[Value]) -> String {
values
.iter()
.map(|value| match value {
Value::String(text) => format!("{text:?}"),
Value::Bool(flag) => flag.to_string(),
Value::Number(number) => number.to_string(),
_ => String::new(),
})
.collect::<Vec<_>>()
.join(", ")
}
/// The Java expression producing the off-value violation reason: a `must equal`
/// literal for `const`, or a `must be one of [...], got ` + value concatenation
/// for `enum`. `target` is the parsed in-scope value.
fn java_closed_reason(values: &[Value], target: &str) -> String {
if values.len() == 1 {
let text = match &values[0] {
Value::String(string) => format!("must equal {string:?}"),
Value::Bool(flag) => format!("must equal {flag}"),
Value::Number(number) => format!("must equal {number}"),
_ => "must equal <const>".to_string(),
};
java_string_literal(&text)
} else {
format!(
"{} + {target}",
java_string_literal(&format!(
"must be one of [{}], got ",
java_closed_set_display(values)
))
)
}
}
/// Emits the collecting-deserializer parse + closed-value lookup (const equality
/// / enum membership) — a non-throwing lookup that maps the wire scalar to the
/// value-class constant, or records a Violation for an off-set value so multiple
/// bad fields aggregate. `target` is the value-class-typed field local; `wire`
/// is the underlying scalar the value class wraps.
fn render_java_closed_parse(
output: &mut String,
class: &str,
wire: &JavaType,
target: &str,
json: &str,
field_java_name: &str,
values: &[Value],
overrides: &ClosedNameOverrides,
indent: &str,
) {
let is_string = matches!(wire, JavaType::String);
let temp = format!("{field_java_name}Value");
let inner = format!("{indent} ");
// The membership lookup assigns the matching constant, else records the
// off-value Violation — the same informative reason in both directions.
let emit_lookup = |o: &mut String, ind: &str| {
for (index, value) in values.iter().enumerate() {
let keyword = if index == 0 { "if" } else { "} else if" };
let literal = java_closed_literal(wire, value);
let test = if is_string {
format!("{literal}.equals({temp})")
} else {
format!("{temp} == {literal}")
};
let name = java_const_name(overrides, field_java_name, values, value);
o.push_str(&format!("{ind}{keyword} ({test}) {{\n"));
o.push_str(&format!("{ind} {target} = {class}.{name};\n"));
}
o.push_str(&format!("{ind}}} else {{\n"));
o.push_str(&format!(
"{ind} violations.add(new Violation({json}, {}));\n",
java_closed_reason(values, &temp)
));
o.push_str(&format!("{ind}}}\n"));
};
match wire {
JavaType::Long => {
output.push_str(&format!(
"{indent}Long {temp} = SpecNumbers.specLong(field, {json}, violations);\n"
));
output.push_str(&format!("{indent}if ({temp} != null) {{\n"));
emit_lookup(output, &inner);
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Double => {
output.push_str(&format!(
"{indent}Double {temp} = SpecNumbers.specDouble(field, {json}, violations);\n"
));
output.push_str(&format!("{indent}if ({temp} != null) {{\n"));
emit_lookup(output, &inner);
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Boolean => {
output.push_str(&format!("{indent}if (!field.isBoolean()) {{\n"));
output.push_str(&format!(
"{inner}violations.add(new Violation({json}, \"expected boolean\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!("{inner}boolean {temp} = field.booleanValue();\n"));
emit_lookup(output, &inner);
output.push_str(&format!("{indent}}}\n"));
}
_ => {
output.push_str(&format!("{indent}if (!field.isTextual()) {{\n"));
output.push_str(&format!(
"{inner}violations.add(new Violation({json}, \"expected string\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!("{inner}String {temp} = field.textValue();\n"));
emit_lookup(output, &inner);
output.push_str(&format!("{indent}}}\n"));
}
}
}
fn shouty(name: &str) -> String {
use heck::ToShoutySnakeCase;
name.to_shouty_snake_case()
}
fn upper_first(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Runtime `Violation.java` contents for the package root.
pub(in crate::generator) fn render_violation_file(package: &str) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str(&format!("package {package};\n\n"));
output.push_str("import org.jspecify.annotations.Nullable;\n\n");
output.push_str(
"/** A single constraint failure: a JSON member path and a human-readable reason. */\n",
);
output.push_str("public final class Violation {\n");
output.push_str(" private final String path;\n private final String reason;\n\n");
output.push_str(" public Violation(String path, String reason) {\n this.path = path;\n this.reason = reason;\n }\n\n");
output.push_str(" public String getPath() {\n return path;\n }\n\n");
output.push_str(" public String getReason() {\n return reason;\n }\n\n");
output.push_str(" public Violation withPathPrefix(String prefix) {\n");
output.push_str(" if (path == null || path.isEmpty()) {\n return new Violation(prefix, reason);\n }\n");
output.push_str(" return new Violation(prefix + \".\" + path, reason);\n }\n\n");
output.push_str(" @Override\n public String toString() {\n");
output.push_str(
" if (path == null || path.isEmpty()) {\n return reason;\n }\n",
);
output.push_str(" return path + \": \" + reason;\n }\n\n");
output.push_str(" @Override\n public boolean equals(@Nullable Object other) {\n");
output.push_str(" if (this == other) {\n return true;\n }\n");
output.push_str(
" if (!(other instanceof Violation)) {\n return false;\n }\n",
);
output.push_str(" Violation that = (Violation) other;\n");
output.push_str(" return java.util.Objects.equals(path, that.path) && java.util.Objects.equals(reason, that.reason);\n }\n\n");
output.push_str(" @Override\n public int hashCode() {\n return java.util.Objects.hash(path, reason);\n }\n");
output.push_str("}\n");
output
}
/// Runtime `ValidationException.java` contents for the package root.
pub(in crate::generator) fn render_validation_exception_file(package: &str) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str(&format!("package {package};\n\n"));
output.push_str("import com.fasterxml.jackson.databind.JsonMappingException;\n");
output.push_str("import java.util.ArrayList;\n");
output.push_str("import java.util.Collections;\n");
output.push_str("import java.util.List;\n\n");
output.push_str(
"/** Aggregates every {@link Violation} found while (de)serializing a value. */\n",
);
output.push_str("public final class ValidationException extends JsonMappingException {\n");
output.push_str(" private final List<Violation> violations;\n\n");
output.push_str(" public ValidationException(List<Violation> violations) {\n");
output.push_str(" super((java.io.Closeable) null, buildMessage(violations));\n");
output.push_str(" this.violations = Collections.unmodifiableList(new ArrayList<>(violations));\n }\n\n");
output.push_str(
" public List<Violation> getViolations() {\n return violations;\n }\n\n",
);
output.push_str(" private static String buildMessage(List<Violation> violations) {\n");
output.push_str(" StringBuilder builder = new StringBuilder();\n");
output.push_str(
" builder.append(violations.size()).append(\" validation error(s): \");\n",
);
output.push_str(" for (int index = 0; index < violations.size(); index++) {\n");
output.push_str(
" if (index > 0) {\n builder.append(\"; \");\n }\n",
);
output.push_str(" builder.append(violations.get(index).toString());\n }\n");
output.push_str(" return builder.toString();\n }\n");
output.push_str("}\n");
output
}
/// Runtime `SpecNumbers.java` contents for the package root.
pub(in crate::generator) fn render_spec_numbers_file(package: &str) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str(&format!("package {package};\n\n"));
output.push_str("import com.fasterxml.jackson.databind.JsonNode;\n");
output.push_str("import java.util.List;\n");
output.push_str("import org.jspecify.annotations.Nullable;\n\n");
output.push_str(
"/** Shared spec-number parsing enforcing the JSON Schema numeric semantics. */\n",
);
output.push_str("public final class SpecNumbers {\n");
output.push_str(" public static final long INTEGER_CAP = (1L << 53) - 1;\n\n");
output.push_str(" private SpecNumbers() {}\n\n");
output.push_str(" /**\n * Parses a JSON number as a spec integer: rejects non-numbers, fractional\n * values, and magnitudes beyond the +/-(2^53-1) cap. Adds a {@link Violation}\n * and returns {@code null} on failure.\n */\n");
output.push_str(" public static @Nullable Long specLong(JsonNode node, String path, List<Violation> violations) {\n");
output.push_str(" if (!node.isNumber()) {\n violations.add(new Violation(path, \"expected integer\"));\n return null;\n }\n");
output.push_str(" double value = node.doubleValue();\n");
output.push_str(" if (Double.isNaN(value) || Double.isInfinite(value) || value != Math.floor(value)) {\n");
output.push_str(" violations.add(new Violation(path, \"not an integer\"));\n return null;\n }\n");
output
.push_str(" if (value < -(double) INTEGER_CAP || value > (double) INTEGER_CAP) {\n");
output.push_str(" violations.add(new Violation(path, \"exceeds \\u00b1(2^53-1) integer cap\"));\n return null;\n }\n");
output.push_str(" return node.longValue();\n }\n");
output.push_str("\n /** Returns whether a node is an accepted spec integer without recording a violation. */\n");
output.push_str(" public static boolean isSpecLong(JsonNode node) {\n");
output.push_str(" if (!node.isNumber()) {\n return false;\n }\n");
output.push_str(" double value = node.doubleValue();\n");
output.push_str(" return Double.isFinite(value) && value == Math.floor(value)\n && value >= -(double) INTEGER_CAP && value <= (double) INTEGER_CAP;\n }\n");
output.push_str("\n /**\n * Parses a JSON number as a finite binary64 value. Adds a\n * {@link Violation} and returns {@code null} on failure.\n */\n");
output.push_str(" public static @Nullable Double specDouble(JsonNode node, String path, List<Violation> violations) {\n");
output.push_str(" if (!node.isNumber()) {\n violations.add(new Violation(path, \"expected number\"));\n return null;\n }\n");
output.push_str(" double value = node.doubleValue();\n");
output.push_str(" if (!Double.isFinite(value)) {\n violations.add(new Violation(path, \"must be a finite number, got \" + value));\n return null;\n }\n");
output.push_str(" return value;\n }\n");
output.push_str("\n /** Returns a JSON-value equality key, normalizing numeric spellings and signed zero. */\n");
output.push_str(" public static Object valueKey(JsonNode node) {\n");
output.push_str(" if (!node.isNumber()) {\n return node;\n }\n");
output.push_str(" double value = node.doubleValue();\n");
output.push_str(
" return value == 0.0d ? Double.valueOf(0.0d) : Double.valueOf(value);\n }\n",
);
output.push_str("}\n");
output
}
/// Recursively walks schema-bearing positions that can materialize a target
/// type. Planned `$defs` are models of their own and are covered by the caller's
/// all-model scan; this handles properties, arrays, nullable/sum branches, and
/// typed-map members within each planned model.
fn schema_uses_feature(schema: &Schema, direct: fn(&Schema) -> bool) -> bool {
if direct(schema) {
return true;
}
if schema.properties.as_ref().is_some_and(|properties| {
properties
.values()
.any(|value| schema_uses_feature(value, direct))
}) {
return true;
}
if schema
.items
.as_deref()
.is_some_and(|items| schema_uses_feature(items, direct))
|| schema.one_of.as_ref().is_some_and(|branches| {
branches
.iter()
.any(|branch| schema_uses_feature(branch, direct))
})
|| schema
.contains
.as_deref()
.is_some_and(|contains| schema_uses_feature(contains, direct))
|| schema
.property_names
.as_deref()
.is_some_and(|names| schema_uses_feature(names, direct))
{
return true;
}
let Some(Value::Object(member)) = &schema.additional_properties else {
return false;
};
serde_json::from_value::<Schema>(Value::Object(member.clone()))
.is_ok_and(|member| schema_uses_feature(&member, direct))
}
/// True when any reachable position of the model materializes a temporal
/// `format`.
pub(in crate::generator) fn model_uses_temporal(model: &PlannedJsonType) -> bool {
let Ok(schema) = decode_schema(model) else {
return false;
};
schema_uses_feature(&schema, |candidate| {
temporal_kind_direct(candidate).is_some()
})
}
/// True when any reachable position of the model materializes a
/// `contentEncoding`.
pub(in crate::generator) fn model_uses_content_encoding(model: &PlannedJsonType) -> bool {
let Ok(schema) = decode_schema(model) else {
return false;
};
schema_uses_feature(&schema, |candidate| {
content_encoding_direct(candidate).is_some()
})
}
/// Runtime `Base64Support.java`: the pinned canonical base64 / base64url regexes
/// (the validity oracle) and the `java.util.Base64` decode / canonical encode
/// adapters. The decoder runs only after the regex passes, so no lenient decoder
/// can diverge (P1). See `specs/json-schema/features/contentEncoding.md`.
pub(in crate::generator) fn render_base64_support_file(package: &str) -> String {
use crate::json_schema::content_encoding::Encoding;
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str(&format!("package {package};\n\n"));
output.push_str("import java.util.Base64;\n");
output.push_str("import java.util.List;\n");
output.push_str("import java.util.regex.Pattern;\n");
output.push_str("import org.jspecify.annotations.Nullable;\n\n");
output.push_str(
"/** Shared materialized-contentEncoding parse/serialize helpers (generator-owned). */\n",
);
output.push_str("public final class Base64Support {\n");
output.push_str(" private Base64Support() {}\n\n");
output.push_str(&format!(
" private static final Pattern BASE64 = Pattern.compile({});\n",
java_string_literal(Encoding::Base64.pattern())
));
output.push_str(&format!(
" private static final Pattern BASE64URL = Pattern.compile({});\n\n",
java_string_literal(Encoding::Base64Url.pattern())
));
output.push_str(BASE64_SUPPORT_BODY);
output.push_str("}\n");
output
}
const BASE64_SUPPORT_BODY: &str = r####" public static byte @Nullable [] parseBase64(String value, String path, List<Violation> violations) {
if (!BASE64.matcher(value).matches()) {
violations.add(new Violation(path, "must be base64-encoded, got \"" + value + "\""));
return null;
}
try {
return Base64.getDecoder().decode(value);
} catch (IllegalArgumentException e) {
violations.add(new Violation(path, "must be base64-encoded, got \"" + value + "\""));
return null;
}
}
public static String formatBase64(byte[] value) {
return Base64.getEncoder().encodeToString(value);
}
public static byte @Nullable [] parseBase64Url(String value, String path, List<Violation> violations) {
if (!BASE64URL.matcher(value).matches()) {
violations.add(new Violation(path, "must be base64url-encoded, got \"" + value + "\""));
return null;
}
try {
return Base64.getUrlDecoder().decode(value);
} catch (IllegalArgumentException e) {
violations.add(new Violation(path, "must be base64url-encoded, got \"" + value + "\""));
return null;
}
}
public static String formatBase64Url(byte[] value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
"####;
/// Runtime `TemporalSupport.java`: the pinned narrowed regexes, the Gregorian
/// calendar predicate, and the parse/serialize adapters for the materialized
/// temporal formats. The serializer is generator-owned (RFC 3339, original
/// offset preserved, +00:00/-00:00 -> Z, trailing fractional zeros trimmed;
/// duration canonicalized time-only) — NOT `Duration.toString()` (for .NET
/// parity) nor Jackson's native temporal serializers. See `format.md`.
pub(in crate::generator) fn render_temporal_support_file(package: &str) -> String {
use crate::json_schema::format::TemporalKind;
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str(&format!("package {package};\n\n"));
output.push_str("import java.time.Duration;\n");
output.push_str("import java.time.LocalDate;\n");
output.push_str("import java.time.LocalTime;\n");
output.push_str("import java.time.OffsetDateTime;\n");
output.push_str("import java.time.OffsetTime;\n");
output.push_str("import java.time.format.DateTimeParseException;\n");
output.push_str("import java.util.List;\n");
output.push_str("import java.util.regex.Pattern;\n");
output.push_str("import org.jspecify.annotations.Nullable;\n\n");
output.push_str(
"/** Shared materialized-temporal parse/serialize helpers (generator-owned). */\n",
);
output.push_str("public final class TemporalSupport {\n");
output.push_str(" private TemporalSupport() {}\n\n");
output.push_str(&format!(
" private static final Pattern DATE_TIME = Pattern.compile({});\n",
java_string_literal(TemporalKind::DateTime.pattern())
));
output.push_str(&format!(
" private static final Pattern DATE = Pattern.compile({});\n",
java_string_literal(TemporalKind::Date.pattern())
));
output.push_str(&format!(
" private static final Pattern TIME = Pattern.compile({});\n",
java_string_literal(TemporalKind::Time.pattern())
));
output.push_str(&format!(
" private static final Pattern DURATION = Pattern.compile({});\n",
java_string_literal(TemporalKind::Duration.pattern())
));
output.push_str(
" private static final long MAX_DURATION_SECONDS = Long.MAX_VALUE / 1_000_000_000L;\n\n",
);
output.push_str(TEMPORAL_SUPPORT_BODY);
output.push_str("}\n");
output
}
const TEMPORAL_SUPPORT_BODY: &str = r####" private static int daysInMonth(int year, int month) {
switch (month) {
case 1: case 3: case 5: case 7: case 8: case 10: case 12:
return 31;
case 4: case 6: case 9: case 11:
return 30;
case 2:
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 ? 29 : 28;
default:
return 0;
}
}
private static boolean validCalendar(String value) {
if (value.length() < 10) {
return false;
}
try {
int year = Integer.parseInt(value.substring(0, 4));
int month = Integer.parseInt(value.substring(5, 7));
int day = Integer.parseInt(value.substring(8, 10));
if (year < 1) {
return false;
}
int max = daysInMonth(year, month);
return max > 0 && day >= 1 && day <= max;
} catch (NumberFormatException e) {
return false;
}
}
private static String frac(int nanos) {
if (nanos == 0) {
return "";
}
String s = String.format("%09d", nanos);
int end = s.length();
while (end > 0 && s.charAt(end - 1) == '0') {
end--;
}
return "." + s.substring(0, end);
}
private static String offset(int secs) {
if (secs == 0) {
return "Z";
}
String sign = secs < 0 ? "-" : "+";
int abs = Math.abs(secs);
return String.format("%s%02d:%02d", sign, abs / 3600, (abs % 3600) / 60);
}
public static @Nullable OffsetDateTime parseDateTime(String value, String path, List<Violation> violations) {
if (!DATE_TIME.matcher(value).matches() || !validCalendar(value)) {
violations.add(new Violation(path, "must be a valid date-time, got \"" + value + "\""));
return null;
}
try {
return OffsetDateTime.parse(value.toUpperCase());
} catch (DateTimeParseException e) {
violations.add(new Violation(path, "must be a valid date-time, got \"" + value + "\""));
return null;
}
}
public static String formatDateTime(OffsetDateTime value) {
return String.format("%04d-%02d-%02dT%02d:%02d:%02d", value.getYear(), value.getMonthValue(),
value.getDayOfMonth(), value.getHour(), value.getMinute(), value.getSecond())
+ frac(value.getNano()) + offset(value.getOffset().getTotalSeconds());
}
public static @Nullable LocalDate parseDate(String value, String path, List<Violation> violations) {
if (!DATE.matcher(value).matches() || !validCalendar(value)) {
violations.add(new Violation(path, "must be a valid date, got \"" + value + "\""));
return null;
}
try {
return LocalDate.parse(value);
} catch (DateTimeParseException e) {
violations.add(new Violation(path, "must be a valid date, got \"" + value + "\""));
return null;
}
}
public static String formatDate(LocalDate value) {
return String.format("%04d-%02d-%02d", value.getYear(), value.getMonthValue(), value.getDayOfMonth());
}
// time -> validated, canonicalized String (no single java.time type holds
// both an offset-bearing and an offset-less time-of-day).
public static @Nullable String parseTime(String value, String path, List<Violation> violations) {
if (!TIME.matcher(value).matches()) {
violations.add(new Violation(path, "must be a valid time, got \"" + value + "\""));
return null;
}
String upper = value.toUpperCase();
try {
OffsetTime t = OffsetTime.parse(upper);
return String.format("%02d:%02d:%02d", t.getHour(), t.getMinute(), t.getSecond())
+ frac(t.getNano()) + offset(t.getOffset().getTotalSeconds());
} catch (DateTimeParseException e) {
LocalTime t = LocalTime.parse(upper);
return String.format("%02d:%02d:%02d", t.getHour(), t.getMinute(), t.getSecond())
+ frac(t.getNano());
}
}
public static @Nullable Duration parseDuration(String value, String path, List<Violation> violations) {
if (!DURATION.matcher(value).matches()) {
violations.add(new Violation(path, "must be a valid duration, got \"" + value + "\""));
return null;
}
long total = 0;
StringBuilder number = new StringBuilder();
try {
for (int i = 2; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isDigit(c)) {
number.append(c);
continue;
}
long magnitude = Long.parseLong(number.toString());
number.setLength(0);
long unit = c == 'H' ? 3600 : c == 'M' ? 60 : 1;
total = Math.addExact(total, Math.multiplyExact(magnitude, unit));
if (total > MAX_DURATION_SECONDS) {
throw new ArithmeticException("overflow");
}
}
} catch (NumberFormatException | ArithmeticException e) {
violations.add(new Violation(path, "must be a valid duration, got \"" + value + "\""));
return null;
}
return Duration.ofSeconds(total);
}
public static String formatDuration(Duration value) {
long total = value.getSeconds();
if (total == 0) {
return "PT0S";
}
long hours = total / 3600;
long minutes = (total % 3600) / 60;
long seconds = total % 60;
StringBuilder out = new StringBuilder("PT");
if (hours != 0) {
out.append(hours).append('H');
}
if (minutes != 0) {
out.append(minutes).append('M');
}
if (seconds != 0) {
out.append(seconds).append('S');
}
return out.toString();
}
"####;
/// `package-info.java` contents marking a package `@NullMarked`.
pub(in crate::generator) fn render_package_info(package: &str) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str("@org.jspecify.annotations.NullMarked\n");
output.push_str(&format!("package {package};\n"));
output
}