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::generator::json_schema::{bare_ref_target, violation_member_segment};
use crate::language::Language;
use crate::parser::{ManifestModel, NameManifest, build_name_manifest};
use crate::planning::{PlannedFamily, PlannedJsonType, PlannedSpec};
use crate::spec::{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()
}
}
/// Renders a numeric literal for an `integer` (`long`) or `number` (`double`)
/// position.
///
/// A **fractional** bound over an integer position keeps its `double` spelling —
/// `>= 1.5` rejects `1` and accepts `2`, which is what the bound means, where
/// the truncated `>= 1L` would accept `1`. Java widens the `long` operand for
/// the comparison, so the emitted predicate stays a single expression. An
/// integral spelling of an integer literal (`1.0` for `const: 1.0`) still
/// renders as `1L`, never `0L`.
fn java_bound_literal(number: &serde_json::Number, is_integer: bool) -> String {
if is_integer
&& let Some(value) = number.as_f64()
&& value.fract() == 0.0
{
return format!("{}L", value 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")
}
}
/// Renders an authored JSON Schema count for use in Java source. Counts above
/// `Integer.MAX_VALUE` need the `L` suffix even when compared with an `int`-
/// valued collection size; the loader permits the full portable 2^53-1 range.
fn java_count_literal(count: u64) -> String {
if count > i32::MAX as u64 {
format!("{count}L")
} else {
count.to_string()
}
}
/// 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 `integer` position is reachable through `ty`. A Java
/// `long` spans ±(2^63−1) while the shared wire contract caps integers at
/// ±(2^53−1), so serialize has to hold the value to the same cap the parser
/// (`SpecNumbers.specLong`) enforces — otherwise the emitter writes a number its
/// own reader rejects.
fn java_type_needs_integer_cap_check(ty: &JavaType) -> bool {
match ty {
JavaType::Long => true,
JavaType::List(element) => java_type_needs_integer_cap_check(element),
_ => false,
}
}
/// Emits the serialize-side ±(2^53−1) integer cap for every `integer` reachable
/// through `ty`, recursively extending array paths like number validation.
fn render_java_integer_cap_checks(
output: &mut String,
ty: &JavaType,
value_expr: &str,
path_expr: &str,
indent: &str,
depth: usize,
) {
match ty {
JavaType::Long => {
output.push_str(&format!(
"{indent}if ({value_expr} < -SpecNumbers.INTEGER_CAP || {value_expr} > SpecNumbers.INTEGER_CAP) {{\n{indent} violations.add(new Violation({path_expr}, \"exceeds \\u00b1(2^53-1) integer cap\"));\n{indent}}}\n"
));
}
JavaType::List(element) if java_type_needs_integer_cap_check(element) => {
let index = format!("integerCapIndex{depth}");
let value = format!("integerCapValue{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_integer_cap_checks(
output,
element,
&value,
&element_path,
&format!("{indent} "),
depth + 1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
}
_ => {}
}
}
/// True when an in-memory materialized temporal can hold a value the wire
/// grammar cannot spell. **Every** kind can: a `LocalDate`/`OffsetDateTime`
/// carries Gregorian year zero and five-digit years, an `OffsetDateTime`/
/// `OffsetTime` carries a sub-minute UTC offset the wire form truncates, and a
/// `Duration` is signed, nanosecond-resolution and unbounded while the pinned
/// `PTnHnMnS` grammar is none of those. See
/// `specs/json-schema/features/format.md` and Python's `_check_date_time` /
/// `_check_time` / `_check_duration`.
fn java_type_needs_temporal_check(ty: &JavaType) -> bool {
match ty {
JavaType::Temporal(_) => true,
JavaType::List(element) => java_type_needs_temporal_check(element),
_ => false,
}
}
/// The `TemporalSupport` static method asserting a value is writable as this
/// kind's wire form (the serialize-side counterpart of `parse*`).
fn java_temporal_check_fn(kind: crate::json_schema::format::TemporalKind) -> &'static str {
match kind {
crate::json_schema::format::TemporalKind::DateTime => "checkDateTime",
crate::json_schema::format::TemporalKind::Date => "checkDate",
crate::json_schema::format::TemporalKind::Time => "checkTime",
crate::json_schema::format::TemporalKind::Duration => "checkDuration",
}
}
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()
|| element_shape(schema).is_some()
}
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 let Some(item_schema) = element_shape(schema) {
let item_nullable = schema.items.as_deref().is_some_and(allows_null);
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()
));
if item_nullable {
output.push_str(&format!("{indent} continue;\n"));
} else {
output.push_str(&format!(
"{indent} violations.add(new Violation({item_path}, \"explicit null not allowed\"));\n"
));
}
output.push_str(&format!("{indent} }} else {{\n"));
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"));
}
if !constraints.is_empty() {
render_java_array_checks(
output,
value_expr,
path_expr,
element_ty,
&constraints,
None,
indent,
);
}
}
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 representability predicate for every materialized
/// temporal reachable through `ty` (P12), recursively extending array paths just
/// like number validation. The predicate itself lives in `TemporalSupport` so it
/// is written once per kind rather than inlined per field.
fn render_java_temporal_checks(
output: &mut String,
ty: &JavaType,
value_expr: &str,
path_expr: &str,
indent: &str,
depth: usize,
) {
match ty {
JavaType::Temporal(kind) => {
output.push_str(&format!(
"{indent}TemporalSupport.{}({value_expr}, {path_expr}, violations);\n",
java_temporal_check_fn(*kind)
));
}
JavaType::List(element) if java_type_needs_temporal_check(element) => {
let index = format!("temporalIndex{depth}");
let value = format!("temporalValue{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_temporal_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 {
let min_literal = java_count_literal(min);
output.push_str(&format!(
"{indent}if (length < {min_literal}) {{\n{indent} violations.add(new Violation({json}, \"must have length >= {min}, got \" + length));\n{indent}}}\n"
));
}
if let Some(max) = constraints.max_length {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent}if (length > {max_literal}) {{\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 {
let min_literal = java_count_literal(min);
output.push_str(&format!(
"{indent}if (nestedLength < {min_literal}) {{\n{indent} violations.add(new Violation({path_expr}, \"must have length >= {min}, got \" + nestedLength));\n{indent}}}\n"
));
}
if let Some(max) = constraints.max_length {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent}if (nestedLength > {max_literal}) {{\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.
pub(crate) 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`.
pub(crate) fn java_format_field_name(java_name: &str) -> String {
use heck::ToShoutySnakeCase;
format!("{}_FORMAT", java_name.to_shouty_snake_case())
}
/// A pinned generator-owned regex (a `format` / `contentEncoding` oracle baked
/// into a runtime support class) with Java's strict end-of-input anchor applied.
///
/// `java.util.regex`'s `$` matches before a final line terminator, so a raw `$`
/// would let `"aGk=\n"` / `"2021-06-15T12:30:45Z\n"` through any `find()`-style
/// use. Every other Java regex the generator emits already routes through
/// `rewrite_end_anchor`; the support classes must not be the exception.
fn java_pinned_pattern(pattern: &str) -> String {
crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z")
}
/// The static `Pattern` field name for a `contains` matcher's `pattern` at the
/// scope `java_name` (a field, a map member, or a union wrapper).
pub(crate) fn java_contains_pattern_field_name(java_name: &str) -> String {
use heck::ToShoutySnakeCase;
format!("{}_CONTAINS_PATTERN", java_name.to_shouty_snake_case())
}
/// The static `Pattern` field name for a `contains` matcher's `format`.
pub(crate) fn java_contains_format_field_name(java_name: &str) -> String {
use heck::ToShoutySnakeCase;
format!("{}_CONTAINS_FORMAT", java_name.to_shouty_snake_case())
}
/// Emits the compiled-once statics a `contains` matcher needs at `scope`.
/// Returns true when anything was written.
fn render_contains_pattern_statics(
output: &mut String,
constraints: &ArrayConstraints,
scope: &str,
indent: &str,
) -> bool {
let Some(matcher) = constraints.contains.as_deref() else {
return false;
};
let mut wrote = false;
if let Some(pattern) = &matcher.pattern {
let pattern = crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z");
output.push_str(&format!(
"{indent}private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n",
java_contains_pattern_field_name(scope),
java_string_literal(&pattern),
));
wrote = true;
}
if let Some(check) = matcher
.format
.as_deref()
.and_then(crate::json_schema::format::check_for)
{
let pattern = crate::json_schema::pattern::rewrite_end_anchor(&check.pattern, r"\z");
output.push_str(&format!(
"{indent}private static final java.util.regex.Pattern {} = java.util.regex.Pattern.compile({});\n",
java_contains_format_field_name(scope),
java_string_literal(&pattern),
));
wrote = true;
}
wrote
}
/// 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 {
let min_literal = java_count_literal(min as u64);
output.push_str(&format!(
"{indent}if ({size_expr} < {min_literal}) {{\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 {
let max_literal = java_count_literal(max as u64);
output.push_str(&format!(
"{indent}if ({size_expr} > {max_literal}) {{\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 {
let min_literal = java_count_literal(min);
output.push_str(&format!(
"{indent} if (pnLength < {min_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \\\"\" + pnKey + \"\\\": must have length >= {min}, got \" + pnLength));\n{indent} }}\n"
));
}
if let Some(max) = constraints.max_length {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent} if (pnLength > {max_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(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",
"Violation.memberPath(pnKey)",
PROPERTY_NAME_POSITION,
&non_length,
&format!("{indent} "),
);
render_java_closed_string_checks(
output,
"pnKey",
"Violation.memberPath(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_violation_path_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,
scope: Option<&str>,
) -> 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 {
let min_literal = java_count_literal(min);
parts.push(format!(
"{elem}.codePointCount(0, {elem}.length()) >= {min_literal}"
));
}
if let Some(max) = matcher.max_length {
let max_literal = java_count_literal(max);
parts.push(format!(
"{elem}.codePointCount(0, {elem}.length()) <= {max_literal}"
));
}
if let Some(pattern) = &matcher.pattern {
let pattern = crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z");
// Compile-once: the matcher runs inside a per-element loop, so an
// inline `Pattern.compile` would recompile per element on every
// (de)serialize ([[pattern]] §"Why compile-once"). A scoped position
// hangs the compiled pattern on a static class member instead.
parts.push(match scope {
Some(scope) => format!(
"{}.matcher({elem}).find()",
java_contains_pattern_field_name(scope)
),
None => 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(match scope {
Some(scope) => format!(
"{}.matcher({elem}).find()",
java_contains_format_field_name(scope)
),
None => 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(),
}
}
/// True when an in-memory element needs a derived `uniqueItems` equality key:
/// a materialized value (temporal / `contentEncoding`) whose Java type compares
/// by reference or by an offset-sensitive natural equality, or a `number` whose
/// `-0.0` must fold onto `0.0`.
fn java_needs_unique_key_mapping(ty: &JavaType) -> bool {
match ty {
JavaType::Double | JavaType::Temporal(_) | JavaType::Bytes(_) => true,
JavaType::List(inner) => java_needs_unique_key_mapping(inner),
_ => false,
}
}
/// The serialize-side `uniqueItems` equality key for one in-memory element.
///
/// Per decision D10 a materialized value compares on its **canonical wire
/// string** — the same side the deserialize path compares (`SpecNumbers.valueKey`
/// over the wire node) — never on the native value and never by reference.
/// `byte[]` has no value equality at all, and `OffsetDateTime` compares
/// offset-sensitively while the wire string is what the contract is written
/// over. A `number` folds `-0.0` onto `0.0` so serialize agrees with
/// deserialize (P1). Everything else already has value equality.
fn java_unique_key_expr(ty: &JavaType, elem: &str, depth: usize) -> String {
match ty {
JavaType::Double => format!("SpecNumbers.numberKey({elem})"),
JavaType::Temporal(kind) => match java_temporal_format_fn(*kind) {
Some(format_fn) => {
format!("({elem} == null ? null : TemporalSupport.{format_fn}({elem}))")
}
None => elem.to_string(),
},
JavaType::Bytes(encoding) => format!(
"({elem} == null ? null : Base64Support.{}({elem}))",
java_content_encoding_format_fn(*encoding)
),
JavaType::List(inner) if java_needs_unique_key_mapping(inner) => {
let param = format!("uniqueKeyElement{depth}");
format!(
"({elem} == null ? null : {elem}.stream().map({param} -> ({})).collect(java.util.stream.Collectors.toList()))",
java_unique_key_expr(inner, ¶m, depth + 1)
)
}
_ => elem.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,
scope: Option<&str>,
indent: &str,
) {
if let Some(min) = constraints.min_items {
let min_literal = java_count_literal(min);
output.push_str(&format!(
"{indent}if ({list}.size() < {min_literal}) {{\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 {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent}if ({list}.size() > {max_literal}) {{\n{indent} violations.add(new Violation({json}, \"must have at most {max} items, got \" + {list}.size()));\n{indent}}}\n"
));
}
if constraints.unique_items {
output.push_str(&format!(
"{indent}java.util.Map<Object, 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} Object element = {};\n",
java_unique_key_expr(element_ty, &format!("{list}.get(index)"), 0)
));
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 (matcher_value, matcher_ty) =
if matches!(element_ty, JavaType::Temporal(_) | JavaType::Bytes(_)) {
(
java_unique_key_expr(element_ty, "element", 0),
JavaType::String,
)
} else {
("element".to_string(), element_ty.clone())
};
let condition = java_matcher_condition(matcher, &matcher_value, &matcher_ty, scope);
let guard = java_typed_matcher_guard(matcher, &matcher_value, &matcher_ty);
let effective_min = constraints.min_contains.unwrap_or(1);
let effective_min_literal = java_count_literal(effective_min);
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 (element != null && ({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_literal}) {{\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 {
let max_literal = java_count_literal(max);
output.push_str(&format!("{indent}if (matchCount > {max_literal}) {{\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,
scope: Option<&str>,
indent: &str,
) {
if let Some(min) = constraints.min_items {
let min_literal = java_count_literal(min);
output.push_str(&format!(
"{indent}if ({node}.size() < {min_literal}) {{\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 {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent}if ({node}.size() > {max_literal}) {{\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, scope);
let effective_min = constraints.min_contains.unwrap_or(1);
let effective_min_literal = java_count_literal(effective_min);
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_literal}) {{\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 {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent}if (rawMatchCount > {max_literal}) {{\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| binding.json_model().cloned())
.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,
}
}
/// Writes a nullable TYPE_USE annotation on this carrier. Collections
/// deliberately do not recurse here: collection presence and element
/// nullability are independent schema axes.
fn nullable_declaration(&self, declaration: String) -> String {
match self {
JavaType::Bytes(_) => "byte @Nullable []".to_string(),
_ => format!("@Nullable {declaration}"),
}
}
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 Java type a materialized temporal `format` maps to. `date-time` uses the
/// idiomatic `OffsetDateTime`; the shared materialized offset domain is exactly
/// the range its `ZoneOffset` can carry. `time` stays a `String` because no
/// single `java.time` type holds both offset-bearing and offset-less time-of-day
/// values.
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",
}
}
#[derive(Debug, Clone, Copy, Default)]
struct JavaFileFeatures {
temporal: bool,
content_encoding: bool,
date_time: bool,
date: bool,
duration: bool,
}
impl JavaFileFeatures {
fn from_schema(schema: &Schema) -> Self {
use crate::json_schema::format::TemporalKind;
Self {
temporal: schema_uses_feature(schema, |candidate| {
temporal_kind_direct(candidate).is_some()
}),
content_encoding: schema_uses_feature(schema, |candidate| {
content_encoding_direct(candidate).is_some()
}),
date_time: schema_uses_feature(schema, |candidate| {
temporal_kind_direct(candidate) == Some(TemporalKind::DateTime)
}),
date: schema_uses_feature(schema, |candidate| {
temporal_kind_direct(candidate) == Some(TemporalKind::Date)
}),
duration: schema_uses_feature(schema, |candidate| {
temporal_kind_direct(candidate) == Some(TemporalKind::Duration)
}),
}
}
}
/// Whether rendering `schema` as a Java model file binds `OffsetDateTime` by
/// bare import. The emitted-name pass uses this exact backend predicate so its
/// schema-dependent P15 reservation cannot drift from [`assemble_file`].
pub(crate) fn schema_imports_offset_date_time(schema: &Value) -> bool {
serde_json::from_value::<Schema>(schema.clone())
.is_ok_and(|schema| JavaFileFeatures::from_schema(&schema).date_time)
}
/// 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.
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 => Some("formatTime"),
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(),
}
}
/// The verbatim constant identifier this member declares, if any.
///
/// `x-java-enum-names` is keyed by the member's canonical **wire spelling**
/// (`"active"`, `"1"`, `"1.5"`, `"true"`). Matching only `Value::String`
/// meant a numeric or boolean member could never be renamed, so P15's one
/// escape hatch for a value-constant collision did not exist for exactly the
/// values most likely to collide — `enum: [1, 1.0]` folds both onto the same
/// token and nothing could separate them (`11#11`).
fn get(&self, value: &Value) -> Option<&str> {
if self.is_const {
return self.const_name.as_deref();
}
let map = self.enum_names.as_ref()?;
map.get(&enum_names_lookup_key(value)?).map(String::as_str)
}
}
/// The `x-java-enum-names` map key for one closed value: its canonical JSON
/// spelling.
///
/// Mirrors the loader's `enum_names_lookup_key` (`src/parser/json_schema.rs`),
/// which validates the same map, and `enum_names_lookup_key` in
/// `src/generator/json_schema/go.rs`. The loader's copy is `pub(crate)` inside a
/// private module, so sharing it would widen `src/parser/mod.rs`; the three must
/// derive the identical key either way, or the P15 collision pass and emission
/// disagree about which member a rename applies to.
fn enum_names_lookup_key(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Bool(flag) => Some(flag.to_string()),
Value::Number(number) => Some(crate::json_schema::scalar::value_token_decimal(number)),
_ => 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() {
self.ty.nullable_declaration(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
),
})
}
/// Resolves an exact bare-ref model alias to the declaration that owns the
/// Java class. Java intentionally emits no alias class, so both member and
/// operation references use this final target directly.
pub(in crate::generator) fn resolve_bare_ref_alias<'a>(
model: &'a PlannedJsonType,
all_models: &'a BTreeMap<String, PlannedJsonType>,
) -> &'a PlannedJsonType {
let mut current = model;
for _ in 0..=all_models.len() {
let Some(reference) = bare_ref_target(current) else {
break;
};
let key = reference.strip_prefix("#/$defs/").unwrap_or(reference);
let Some(target) = all_models.get(key) else {
break;
};
current = target;
}
current
}
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 schema describing an array's **element shape**, with the nullability
/// `oneOf` wrapper stripped.
///
/// `items: {oneOf: [T, null]}` is the element-level spelling of a nullable
/// member: the wrapper carries no `type` and no keywords, so reading
/// `schema.items` directly drops everything the non-null branch declares — the
/// same defect the field planner had at the property level ([[nullability]],
/// finding `13#2`). Every site that reads an element's constraints goes through
/// this; the element's *nullability* is read from the wrapper separately
/// (`allows_null`), which is why the two cannot be collapsed.
fn element_shape(schema: &Schema) -> Option<&Schema> {
let items = schema.items.as_deref()?;
Some(nullable_non_null_schema(items).unwrap_or(items))
}
/// 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 {
// A nullable property is authored as `oneOf: [T, null]`. The
// wrapper carries no `type` and no keywords, so every read of the
// member's *shape* (its closed value set, its constraints, its
// `items`) must go through the non-null branch — otherwise the
// whole constraint set is silently dropped and Java accepts wire
// values the other targets reject (P1). Presence/annotation
// keywords (`default`, `title`, `description`, `deprecated`,
// `x-java-name`) stay on the authored property node.
let shape = nullable_non_null_schema(property).unwrap_or(property);
let closed_values = if let Some(value) = &shape.const_value {
vec![value.clone()]
} else if let Some(values) = &shape.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 = upper_first(&java_name);
let union = classify_java_union(&interface, true, property, all_models, context);
(
JavaType::Union {
class: interface.clone(),
},
union,
)
} else if let Some(union) = ref_union(shape, 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 &shape.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(shape),
default: property.default.clone(),
doc: compose_doc(property.title.as_deref(), property.description.as_deref()),
deprecated: property.deprecated == Some(true),
numeric: NumericConstraints::from_schema(shape),
string_length: StringLengthConstraints::from_schema(shape),
array: ArrayConstraints::from_schema(shape),
schema: shape.clone(),
nullable_items: shape.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)?;
let features = JavaFileFeatures::from_schema(&schema);
// 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 manifest_key = all_models
.get(key)
.map(|model| resolve_bare_ref_alias(model, all_models).full_name.as_str())
.unwrap_or(key);
let class = manifest
.type_name(manifest_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,
features,
&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 java_name = property
.x_java_name
.clone()
.unwrap_or_else(|| json_name.to_lower_camel_case());
let interface = upper_first(&java_name);
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(),
&implements,
&mut refs,
);
}
}
Ok(assemble_file(
&package,
root_package,
&refs,
false,
features,
&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");
}
/// The Java predicate testing a `JsonNode` for JSON-value equality with a
/// scalar literal. Numbers compare **mathematically** (so `1`, `1.0` and `1e0`
/// are one value), strings by content, booleans by identity.
fn java_node_equals_literal(node_expr: &str, value: &Value) -> String {
match value {
Value::String(text) => format!(
"{node_expr}.isTextual() && {}.equals({node_expr}.textValue())",
java_string_literal(text)
),
Value::Bool(flag) => {
format!("{node_expr}.isBoolean() && {node_expr}.booleanValue() == {flag}")
}
Value::Number(number) => format!(
"{node_expr}.isNumber() && {node_expr}.doubleValue() == {}",
java_bound_literal(number, false)
),
Value::Null => format!("{node_expr}.isNull()"),
_ => "false".to_string(),
}
}
/// 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"
));
// The discriminant is any scalar the loader admits (`const: 1`,
// `const: true`, …), not only a string — so the branch is selected
// by **JSON value equality** against each member's `const`, never
// by the tag's text. A number tag matches whatever spelling arrives
// (`1`, `1.0`, `1e0`), which is what the other targets do.
output.push_str(&format!(
"{indent} if (disc == null || disc.isNull() || !disc.isValueNode()) {{\n{indent} violations.add(new Violation({path}, {}));\n{indent} return null;\n{indent} }}\n",
java_string_literal(&format!("discriminator {discriminant:?} is required"))
));
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} if ({}) {{\n",
java_node_equals_literal("disc", value)
));
render_union_read_object(output, variant, node, path, &format!("{indent} "));
output.push_str(&format!("{indent} }}\n"));
}
output.push_str(&format!(
"{indent} violations.add(new Violation({path}, {} + disc.asText() + {}));\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"));
} 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}"))
));
}
fn render_payload_validation_failure(output: &mut String, indent: &str) {
output.push_str(&format!(
"{indent}// TODO: Use PayloadValidationException.newPayloadValidationException once it is available in an SDK release.\n"
));
output.push_str(&format!(
"{indent}throw ApplicationFailure.newNonRetryableFailure(\"Payload validation failed\", \"PayloadValidationError\", violations);\n"
));
}
fn render_nested_violation_cast(
output: &mut String,
indent: &str,
failure: &str,
violations: &str,
) {
output.push_str(&format!(
"{indent}if (!\"PayloadValidationError\".equals({failure}.getType()) || {failure}.getDetails().getSize() == 0) {{\n{indent} throw {failure};\n{indent}}}\n"
));
output.push_str(&format!(
"{indent}// The locally-created failure retains the original list as its first detail.\n"
));
output.push_str(&format!(
"{indent}// This unchecked cast is cheap and performs no serialization.\n"
));
output.push_str(&format!("{indent}@SuppressWarnings(\"unchecked\")\n"));
output.push_str(&format!(
"{indent}List<Violation> {violations} = (List<Violation>) {failure}.getDetails().get(0, List.class);\n"
));
}
/// 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 (ApplicationFailure nested) {{\n"
));
render_nested_violation_cast(
output,
&format!("{indent} "),
"nested",
"nestedViolations",
);
output.push_str(&format!(
"{indent} for (Violation violation : nestedViolations) {{\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,
element_shape(&variant.schema),
"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,
Some(MAP_MEMBER_POSITION),
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 payload-validation failure (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),
));
}
if render_contains_pattern_statics(
output,
&ArrayConstraints::from_schema(&variant.schema),
MAP_MEMBER_POSITION,
&inner,
) {
output.push('\n');
}
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,
features: JavaFileFeatures,
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",
"io.temporal.failure.ApplicationFailure",
"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}.Violation"));
if features.temporal {
imports.insert(format!("{root_package}.TemporalSupport"));
}
if features.content_encoding {
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).
if features.date_time {
imports.insert("java.time.OffsetDateTime".to_string());
}
if features.date {
imports.insert("java.time.LocalDate".to_string());
}
if features.duration {
imports.insert("java.time.Duration".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 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("\\\\"),
'\u{0008}' => output.push_str("\\b"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
'\u{000c}' => output.push_str("\\f"),
other if other <= '\u{001f}' || ('\u{007f}'..='\u{009f}').contains(&other) => {
use std::fmt::Write as _;
write!(output, "\\u{:04x}", u32::from(other)).unwrap();
}
'\u{2028}' => output.push_str("\\u2028"),
'\u{2029}' => output.push_str("\\u2029"),
other => output.push(other),
}
}
output.push('"');
output
}
fn java_violation_path_literal(key: &str) -> String {
java_string_literal(&violation_member_segment(key))
}
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.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;
}
wrote_pattern |= render_contains_pattern_statics(
output,
&ArrayConstraints::from_schema(candidate),
position,
" ",
);
}
// 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;
}
// A `contains` matcher's `pattern`/`format` is compiled once here too —
// it runs inside a per-element loop in both directions.
wrote_pattern |=
render_contains_pattern_statics(output, &field.array, &field.java_name, " ");
}
if wrote_pattern {
output.push('\n');
}
// Fields. The authored prose rides on the **getter** (below), not here:
// `javadoc` skips private members at its default visibility, so a comment on
// the field would never reach published docs (`description.md`: "Javadoc
// above the class/getter/method").
for field in fields {
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();
// One Javadoc block on the public getter: the authored summary/body,
// then the generated `@deprecated` tag — the tag is a trailer of the
// same block, never an orphan comment above a doc-less member.
let tags: Vec<(String, String)> = if field.deprecated {
vec![(
"@deprecated".to_string(),
"This field is deprecated.".to_string(),
)]
} else {
Vec::new()
};
render_java_doc_comment(output, " ", field.doc.as_deref(), &tags);
if field.deprecated {
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");
}
/// The `equals` comparison, `hashCode` argument and `toString` expression for
/// one member.
///
/// A materialized `contentEncoding` member is a `byte[]`, which in Java has
/// **no** value semantics: `Objects.equals` compares references (so two models
/// parsed from the same payload are unequal), `Objects.hash` hashes the
/// identity, and `toString` prints `[B@1b6d…`. Those members route through
/// `java.util.Arrays` — and a `List<byte[]>`, whose `List.equals` would compare
/// its elements by reference, through the generated `Base64Support` list
/// helpers. See `specs/json-schema/features/contentEncoding.md`.
fn java_member_equality(ty: &JavaType, is_primitive: bool, name: &str) -> (String, String, String) {
match ty {
JavaType::Bytes(_) => (
format!("java.util.Arrays.equals(this.{name}, that.{name})"),
format!("java.util.Arrays.hashCode({name})"),
format!("java.util.Arrays.toString({name})"),
),
JavaType::List(inner) if matches!(**inner, JavaType::Bytes(_)) => (
format!("Base64Support.listEquals(this.{name}, that.{name})"),
format!("Base64Support.listHashCode({name})"),
format!("Base64Support.listToString({name})"),
),
_ if is_primitive => (
format!("this.{name} == that.{name}"),
name.to_string(),
name.to_string(),
),
_ => (
format!("Objects.equals(this.{name}, that.{name})"),
name.to_string(),
name.to_string(),
),
}
}
fn render_equals_hashcode_tostring(
output: &mut String,
class: &str,
fields: &[FieldPlan],
open: bool,
) {
let mut members: Vec<(String, String, String, String)> = fields
.iter()
.map(|field| {
let (equals, hash, display) =
java_member_equality(&field.ty, field.is_primitive(), &field.java_name);
(field.java_name.clone(), equals, hash, display)
})
.collect();
if open {
members.push((
"additionalProperties".to_string(),
"Objects.equals(this.additionalProperties, that.additionalProperties)".to_string(),
"additionalProperties".to_string(),
"additionalProperties".to_string(),
));
}
// 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"));
if members.is_empty() {
output.push_str(" return true;\n");
} else {
output.push_str(" return ");
output.push_str(
&members
.iter()
.map(|(_, equals, _, _)| equals.clone())
.collect::<Vec<_>>()
.join("\n && "),
);
output.push_str(";\n");
}
output.push_str(" }\n\n");
// hashCode
output.push_str(" @Override\n public int hashCode() {\n return Objects.hash(");
output.push_str(
&members
.iter()
.map(|(_, _, hash, _)| hash.clone())
.collect::<Vec<_>>()
.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 members.is_empty() {
output.push_str(" + \"}\";\n");
} else {
for (index, (name, _, _, display)) in members.iter().enumerate() {
let prefix = if index == 0 {
format!("{name}=")
} else {
format!(", {name}=")
};
output.push_str(&format!(" + \"{prefix}\" + {display}\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 {
// Java reference types can be constructed with null despite @NullMarked.
// A required, non-nullable reference therefore needs an explicit outbound
// presence check even when it carries no other schema constraint.
if field.required && !field.nullable && !field.is_primitive() {
return true;
}
// 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_temporal_check(&field.ty) {
return true;
}
if java_type_needs_integer_cap_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(_)) && element_shape(&field.schema).is_some() {
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_violation_path_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_integer_cap_checks(&mut body, &field.ty, &accessor, &json, &inner, 0);
render_java_temporal_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 let Some(item_schema) = element_shape(&field.schema) {
let item_nullable = field.schema.items.as_deref().is_some_and(allows_null);
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()
));
if item_nullable {
body.push_str(&format!("{inner} continue;\n"));
} else {
body.push_str(&format!(
"{inner} violations.add(new Violation({item_path}, \"explicit null not allowed\"));\n"
));
}
body.push_str(&format!("{inner} }} else {{\n"));
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"));
}
if !field.array.is_empty() {
render_java_array_checks(
&mut body,
&accessor,
&json,
element_ty,
&field.array,
Some(&field.java_name),
&inner,
);
}
}
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"
));
}
}
let requires_non_null = field.required && !field.nullable && !field.is_primitive();
if body.is_empty() && !requires_non_null {
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 if requires_non_null {
output.push_str(&format!(
"{indent}if ({accessor} == null) {{\n{indent} violations.add(new Violation({json}, \"required\"));\n{indent}}}"
));
if !body.is_empty() {
output.push_str(&format!(" else {{\n{body}{indent}}}\n"));
} else {
output.push('\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 {
if deps.is_empty() {
continue;
}
let trigger_present = if open {
format!("wireKeys.contains({})", java_string_literal(trigger))
} else {
java_field_present_expr(fields, trigger, false)
};
output.push_str(&format!("{indent}if ({trigger_present}) {{\n"));
for dep in deps {
let dep_present = if open {
format!("wireKeys.contains({})", java_string_literal(dep))
} else {
java_field_present_expr(fields, dep, false)
};
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_violation_path_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 {
let min_literal = java_count_literal(min);
output.push_str(&format!(
"{indent} if (pnLength < {min_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(pnKey), \"invalid property name \\\"\" + pnKey + \"\\\": must have length >= {min}, got \" + pnLength));\n{indent} }}\n"
));
}
if let Some(max) = constraints.max_length {
let max_literal = java_count_literal(max);
output.push_str(&format!(
"{indent} if (pnLength > {max_literal}) {{\n{indent} violations.add(new Violation(Violation.memberPath(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",
"Violation.memberPath(pnKey)",
PROPERTY_NAME_POSITION,
&non_length,
&format!("{indent} "),
);
render_java_closed_string_checks(
output,
"pnKey",
"Violation.memberPath(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"
));
// Buffer the complete object. Nested validating serializers can fail after
// they have started writing; buffering keeps the caller's generator
// untouched until every sibling has been checked and the aggregate is
// known to be empty (P11/P12).
output.push_str(" JsonGenerator target = gen;\n");
output.push_str(" com.fasterxml.jackson.databind.util.TokenBuffer pending = new com.fasterxml.jackson.databind.util.TokenBuffer(gen.getCodec(), false);\n");
output.push_str(" gen = pending;\n");
// A member whose value is itself a validating model reports its failures
// through its own payload-validation failure; those have to be re-pathed under
// this member and merged into *this* object's list, so the throw moves to
// after the write (P11: one aggregated failure per payload).
let captures_nested = fields
.iter()
.any(|field| field_serialize_may_nest(&field.ty))
|| typed_additional.is_some_and(|additional| field_serialize_may_nest(&additional.ty));
// Serialize-side (P12): re-run the shared field validation over the
// in-memory model and throw the aggregated payload-validation failure 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 && captures_nested {
output.push_str(" List<Violation> violations = new ArrayList<>();\n");
}
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(Violation.memberPath(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");
} else {
output.push_str(" if (entry.getValue() == null) {\n violations.add(new Violation(Violation.memberPath(entry.getKey()), \"explicit null not allowed\"));\n continue;\n }\n");
}
render_java_member_checks(
output,
"entry.getValue()",
"Violation.memberPath(entry.getKey())",
&additional.schema,
&additional.ty,
ADDITIONAL_PROPERTIES_POSITION,
" ",
);
render_java_materialized_wire_checks(
output,
"entry.getValue()",
"Violation.memberPath(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(), Violation.memberPath(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, " ");
if !captures_nested {
output.push_str(" if (!violations.isEmpty()) {\n");
render_payload_validation_failure(output, " ");
output.push_str(" }\n");
}
}
output.push_str(" gen.writeStartObject();\n");
for field in fields {
render_field_serialize(output, field, captures_nested);
}
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, captures_nested));
} 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");
if captures_nested {
output.push_str(" if (!violations.isEmpty()) {\n");
render_payload_validation_failure(output, " ");
output.push_str(" }\n");
}
output.push_str(" pending.serialize(target);\n");
output.push_str(" }\n }\n\n");
}
/// True when writing a value of this type runs another model's `Serializer`,
/// which may throw its own payload-validation failure with paths rooted at *its*
/// members (`zip`, not `address.zip`).
fn field_serialize_may_nest(ty: &JavaType) -> bool {
match ty {
JavaType::Ref { .. } | JavaType::Union { .. } => true,
JavaType::List(inner) => field_serialize_may_nest(inner),
_ => false,
}
}
/// Writes a value that may itself be a validating model, capturing its
/// payload-validation failure and re-pathing every violation under the parent's
/// member path — `address.zip`, `addresses[1].zip` — into the parent's own
/// list, so one payload still produces one aggregated failure (P11). The
/// deserialize side does the same at `readTreeAsValue`.
fn render_capturing_value_write(
output: &mut String,
ty: &JavaType,
path_expr: &str,
accessor: &str,
indent: &str,
depth: usize,
) {
match ty {
JavaType::List(inner) if field_serialize_may_nest(inner) => {
let index = format!("nestedIndex{depth}");
let element = format!("nestedElement{depth}");
output.push_str(&format!("{indent}gen.writeStartArray();\n"));
output.push_str(&format!(
"{indent}for (int {index} = 0; {index} < {accessor}.size(); {index}++) {{\n"
));
output.push_str(&format!(
"{indent} {} {element} = {accessor}.get({index});\n",
inner.boxed_name()
));
output.push_str(&format!(
"{indent} if ({element} == null) {{\n{indent} gen.writeNull();\n{indent} }} else {{\n"
));
render_capturing_value_write(
output,
inner,
&format!("{path_expr} + \"[\" + {index} + \"]\""),
&element,
&format!("{indent} "),
depth + 1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
output.push_str(&format!("{indent}gen.writeEndArray();\n"));
}
_ => {
let buffer = format!("nestedBuffer{depth}");
output.push_str(&format!(
"{indent}com.fasterxml.jackson.databind.util.TokenBuffer {buffer} = new com.fasterxml.jackson.databind.util.TokenBuffer(gen.getCodec(), false);\n"
));
output.push_str(&format!("{indent}try {{\n"));
output.push_str(&format!(
"{indent} serializers.defaultSerializeValue({accessor}, {buffer});\n{indent} {buffer}.serialize(gen);\n"
));
output.push_str(&format!(
"{indent}}} catch (ApplicationFailure nested{depth}) {{\n"
));
render_nested_violation_cast(
output,
&format!("{indent} "),
&format!("nested{depth}"),
&format!("nestedViolations{depth}"),
);
output.push_str(&format!(
"{indent} for (Violation nestedViolation{depth} : nestedViolations{depth}) {{\n{indent} violations.add(nestedViolation{depth}.withPathPrefix({path_expr}));\n{indent} }}\n"
));
// Keep the buffered parent structurally writable so validation can
// continue through every sibling. The parent buffer is discarded
// when the final aggregate is thrown, so this placeholder is never
// observable on the wire.
output.push_str(&format!("{indent} gen.writeNull();\n"));
output.push_str(&format!("{indent}}}\n"));
}
}
}
fn render_field_serialize(output: &mut String, field: &FieldPlan, capture_nested: bool) {
let json = java_string_literal(&field.json_name);
let path = java_violation_path_literal(&field.json_name);
let accessor = format!("value.{}", field.java_name);
if capture_nested && field_serialize_may_nest(&field.ty) {
// A nesting member is never a stored primitive.
output.push_str(&format!(" if ({accessor} != null) {{\n"));
output.push_str(&format!(" gen.writeFieldName({json});\n"));
render_capturing_value_write(output, &field.ty, &path, &accessor, " ", 0);
if field.required && field.nullable {
output.push_str(&format!(
" }} else {{\n gen.writeNullField({json});\n }}\n"
));
} else {
output.push_str(" }\n");
}
return;
}
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)
),
// A collection of materialized values is written by the generator, not
// by Jackson: see `java_list_needs_owned_write`.
JavaType::List(_) if java_list_needs_owned_write(ty) => {
let mut output = format!("{indent}gen.writeFieldName({json});\n");
render_owned_value_write(&mut output, ty, accessor, indent, 0);
output
}
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)
}
}
}
/// True when a collection holds materialized values that **Jackson cannot write
/// correctly on its own**, so the generator has to write the array elementwise.
///
/// A scalar `format`/`contentEncoding` member is written through
/// `TemporalSupport.format*` / `Base64Support.format*`, but the same value
/// inside a `List` used to reach `serializers.defaultSerializeValue`, i.e.
/// Jackson's defaults. A stock `ObjectMapper` then **throws**
/// `InvalidDefinitionException: Java 8 date/time type ... not supported by
/// default: add Module "jackson-datatype-jsr310"` — the generated code is not
/// self-sufficient, which P3/P4 forbid (a stock converter, no user wiring).
/// `byte[]` is worse than a throw: Jackson silently writes its own base64
/// variant, which is not the canonical `base64url` form the parser accepts.
fn java_list_needs_owned_write(ty: &JavaType) -> bool {
match ty {
JavaType::Temporal(_) | JavaType::Bytes(_) => true,
JavaType::List(inner) => java_list_needs_owned_write(inner),
JavaType::ClosedValue { wire, .. } => java_list_needs_owned_write(wire),
_ => false,
}
}
/// Writes one value (no field name) with the generator-owned encoder, recursing
/// through collections. Only reached for the types
/// `java_list_needs_owned_write` selects.
fn render_owned_value_write(
output: &mut String,
ty: &JavaType,
accessor: &str,
indent: &str,
depth: usize,
) {
match ty {
JavaType::List(inner) => {
let index = format!("wireIndex{depth}");
let element = format!("wireElement{depth}");
output.push_str(&format!("{indent}gen.writeStartArray();\n"));
output.push_str(&format!(
"{indent}for (int {index} = 0; {index} < {accessor}.size(); {index}++) {{\n"
));
output.push_str(&format!(
"{indent} {} {element} = {accessor}.get({index});\n",
element_declaration(inner, true)
));
output.push_str(&format!(
"{indent} if ({element} == null) {{\n{indent} gen.writeNull();\n{indent} }} else {{\n"
));
render_owned_value_write(
output,
inner,
&element,
&format!("{indent} "),
depth + 1,
);
output.push_str(&format!("{indent} }}\n{indent}}}\n"));
output.push_str(&format!("{indent}gen.writeEndArray();\n"));
}
JavaType::Temporal(kind) => match java_temporal_format_fn(*kind) {
// `time` is already a canonical String.
None => output.push_str(&format!("{indent}gen.writeString({accessor});\n")),
Some(format_fn) => output.push_str(&format!(
"{indent}gen.writeString(TemporalSupport.{format_fn}({accessor}));\n"
)),
},
JavaType::Bytes(encoding) => output.push_str(&format!(
"{indent}gen.writeString(Base64Support.{}({accessor}));\n",
java_content_encoding_format_fn(*encoding)
)),
JavaType::ClosedValue { wire, .. } => render_owned_value_write(
output,
wire,
&format!("{accessor}.getValue()"),
indent,
depth,
),
_ => output.push_str(&format!(
"{indent}serializers.defaultSerializeValue({accessor}, gen);\n"
)),
}
}
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 = SpecNumbers.readExactTree(parser);\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");
render_payload_validation_failure(output, " ");
output.push_str(" }\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(" String path = Violation.memberPath(key);\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(path, \"explicit null not allowed\"));\n break;\n");
}
output.push_str(" }\n");
render_parse_map_value(
output,
&additional.ty,
"additionalProperties",
"element",
"key",
"path",
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(" String path = Violation.memberPath(key);\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(path, \"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");
render_payload_validation_failure(output, " ");
output.push_str(" }\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 path = java_violation_path_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({path}, \"required\"));\n"
));
}
output.push_str(&format!("{indent} }} else if (field.isNull()) {{\n"));
if !field.nullable {
output.push_str(&format!(
"{indent} violations.add(new Violation({path}, \"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, &path, &format!("{indent} "));
} else {
render_parse_value(
output,
&field.ty,
name,
&path,
&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 (ApplicationFailure nested) {{\n"
));
render_nested_violation_cast(
output,
&format!("{indent} "),
"nested",
"nestedViolations",
);
output.push_str(&format!(
"{indent} for (Violation violation : nestedViolations) {{\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,
element_shape(schema),
"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,
Some(field_java_name),
&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();
}
ty.nullable_declaration(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 (ApplicationFailure nested) {{\n"
));
render_nested_violation_cast(
output,
&format!("{indent} "),
"nested",
"nestedViolations",
);
output.push_str(&format!(
"{indent} for (Violation violation : nestedViolations) {{\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(element_shape),
&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,
None,
&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,
map_key_var: &str,
path_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, path_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({path_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({map_key_var}, value);\n"));
} else {
output.push_str(&format!(
"{indent} {map}.put({map_key_var}, {element}.textValue());\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"));
checks(output, "parsed", &format!("{indent} "));
output.push_str(&format!("{indent} {map}.put({map_key_var}, parsed);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Double => {
output.push_str(&format!(
"{indent}Double value = SpecNumbers.specDouble({element}, {path_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({map_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({path_var}, \"expected boolean value\"));\n"
));
output.push_str(&format!("{indent}}} else {{\n"));
output.push_str(&format!(
"{indent} {map}.put({map_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}, {path_var}, violations, context);\n"
));
output.push_str(&format!("{indent}if ({parsed} != null) {{\n"));
output.push_str(&format!(
"{indent} {map}.put({map_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({map_key_var}, context.readTreeAsValue({element}, {class}.class));\n"
));
output.push_str(&format!(
"{indent}}} catch (ApplicationFailure nested) {{\n"
));
render_nested_violation_cast(
output,
&format!("{indent} "),
"nested",
"nestedViolations",
);
output.push_str(&format!(
"{indent} for (Violation violation : nestedViolations) {{\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"));
}
// 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({path_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 = {path_var} + \"[\" + index + \"]\";\n"
));
render_parse_element(
output,
inner,
member.and_then(element_shape),
"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,
path_var,
inner,
&constraints,
Some(position),
&format!("{indent} "),
);
}
}
output.push_str(&format!("{indent} {map}.put({map_key_var}, items);\n"));
output.push_str(&format!("{indent}}}\n"));
}
JavaType::Union { .. } => {
output.push_str(&format!(
"{indent}violations.add(new Violation({path_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({path_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()"),
path_var,
position,
&constraints,
&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} {map}.put({map_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({path_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()"),
path_var,
position,
&constraints,
&format!("{indent} "),
);
}
}
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} {map}.put({map_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_integer_cap_checks(output, ty, value_expr, key_expr, indent, 0);
render_java_temporal_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 let Some(item_schema) = element_shape(member)
&& 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"));
}
if !constraints.is_empty() {
render_java_array_checks(
output,
value_expr,
key_expr,
element,
&constraints,
Some(position),
indent,
);
}
}
}
_ => {}
}
}
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>,
implements: &[String],
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",
);
// A typed map can be a `oneOf` member like any other object model, and then
// it has to carry the interface: the union's dispatcher reads the branch with
// `readTreeAsValue(node, <Branch>.class)`, whose `T` is pinned to the branch
// by the class literal and bounded above by the interface — without the
// clause that is an unsatisfiable inference constraint, not a cast error, so
// the package does not compile.
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"
));
// 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 render_contains_pattern_statics(
output,
&ArrayConstraints::from_schema(member),
MAP_MEMBER_POSITION,
" ",
) {
output.push('\n');
}
}
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"
));
// Buffer the complete map for the same reason as struct-shaped objects:
// a nested validating serializer can fail after it has started writing.
output.push_str(" JsonGenerator target = gen;\n");
output.push_str(" com.fasterxml.jackson.databind.util.TokenBuffer pending = new com.fasterxml.jackson.databind.util.TokenBuffer(gen.getCodec(), false);\n");
output.push_str(" gen = pending;\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 payload-validation failure 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()",
"Violation.memberPath(entry.getKey())",
member,
value,
MAP_MEMBER_POSITION,
" ",
);
render_java_materialized_wire_checks(
&mut member_checks,
"entry.getValue()",
"Violation.memberPath(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(), Violation.memberPath(entry.getKey()), violations);\n"
));
}
}
// A model-valued member reports through its own payload-validation failure;
// re-path and merge it here rather than letting it escape unrooted (P11).
let captures_nested = field_serialize_may_nest(value);
output.push_str(" List<Violation> violations = new ArrayList<>();\n");
output.push_str(" if (value.additionalProperties == null) {\n");
output.push_str(" violations.add(new Violation(\"\", \"expected object\"));\n");
output.push_str(" } else {\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.is_some() {
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");
} else {
output.push_str(" if (entry.getValue() == null) {\n");
output.push_str(" violations.add(new Violation(Violation.memberPath(entry.getKey()), \"explicit null not allowed\"));\n");
output.push_str(" continue;\n }\n");
}
output.push_str(&member_checks);
output.push_str(" }\n");
}
output.push_str(" }\n");
output.push_str(" if (!violations.isEmpty()) {\n");
render_payload_validation_failure(output, " ");
output.push_str(" }\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, captures_nested));
output.push_str(" }\n");
output.push_str(" gen.writeEndObject();\n");
if captures_nested {
output.push_str(" if (!violations.isEmpty()) {\n");
render_payload_validation_failure(output, " ");
output.push_str(" }\n");
}
output.push_str(" pending.serialize(target);\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 = SpecNumbers.readExactTree(parser);\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");
render_payload_validation_failure(output, " ");
output.push_str(" }\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(" String path = Violation.memberPath(key);\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(path, \"explicit null not allowed\"));\n continue;\n }\n",
);
}
render_parse_map_value(
output,
value,
"additionalProperties",
"element",
"key",
"path",
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");
render_payload_validation_failure(output, " ");
output.push_str(" }\n");
output.push_str(&format!(
" return new {class}(additionalProperties);\n"
));
output.push_str(" }\n }\n}\n");
}
fn write_map_value(value: &JavaType, capture_nested: bool) -> String {
if capture_nested && field_serialize_may_nest(value) {
let mut output = String::from(" gen.writeFieldName(entry.getKey());\n");
render_capturing_value_write(
&mut output,
value,
"Violation.memberPath(entry.getKey())",
"entry.getValue()",
" ",
0,
);
return output;
}
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::List(_) if java_list_needs_owned_write(value) => {
let mut output =
" gen.writeFieldName(entry.getKey());\n".to_string();
render_owned_value_write(&mut output, value, "entry.getValue()", " ", 0);
output
}
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,
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, 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, 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,
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, 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()),
// `1.0` is a legal spelling of an `integer` default; `as_i64()` returns
// `None` for it, so the shared literal renderer decides the spelling.
(JavaType::Long, Value::Number(number)) => Some(java_bound_literal(number, true)),
(JavaType::Double, Value::Number(number)) => Some(java_bound_literal(number, false)),
(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, value)
)),
(JavaType::Temporal(kind), Value::String(text)) => {
// `java.time`'s `parse` is case-sensitive on the `T`/`Z`/`PT`
// designators while the pinned wire grammar accepts either case.
// Date-time also accepts fractions wider than OffsetDateTime's
// nanosecond capacity, so its generator-owned literal parser applies
// the same normalization and truncation as wire deserialization.
let literal = java_string_literal(&text.to_ascii_uppercase());
Some(match kind {
crate::json_schema::format::TemporalKind::DateTime => {
format!("TemporalSupport.parseDateTimeLiteral({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. An
/// integer written with an integral fraction (`const: 1.0`, which JSON Schema
/// admits for `type: integer`) renders as `1L` — `as_i64()` alone returns
/// `None` for it and would silently emit `0L`.
fn java_closed_literal(ty: &JavaType, value: &Value) -> String {
match (ty, value) {
(JavaType::Long, Value::Number(number)) => java_bound_literal(number, true),
(JavaType::Long, _) => "0L".to_string(),
(JavaType::Double, Value::Number(number)) => java_bound_literal(number, false),
(JavaType::Double, _) => "0.0".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) => crate::json_schema::scalar::value_token_decimal(number)
.replace('-', "NEG_")
.replace('.', "_"),
_ => String::new(),
}
}
/// The class-scoped constant name for a closed value: purely the value's
/// encoded token, with **no member-derived component** (`Kind.SHOWCASE`,
/// `Status.ACTIVE`, `Tier.V_1`). The value class already carries the member in
/// its own name, so re-stating it would only stutter — but the reason the
/// constant may not be member-derived is behavioral, not cosmetic. P15 gives the
/// two axes separate escape hatches: `x-java-name` moves the synthesized *type*
/// and `x-java-const-name` the *value constant*. A member-derived constant would
/// move under `x-java-name` too, so a constant collision would have two remedies
/// while the fix-it names one — and a `const` whose value token is empty (`"-"`)
/// would silently acquire a legal name from its member instead of hitting the
/// load reject the loader raises for it.
///
/// Java constants are class-scoped with no type prefix, so unlike Go there is no
/// `{Type}` to supply a leading letter: a token that does not begin with an ASCII
/// letter takes the `V_` guard. That is every numeric — including the ones whose
/// token happens to lead with the `NEG_` sign word, which the guard applies to by
/// *kind* rather than by first character so `-3` and a string `"neg3"` stay
/// distinct (`V_NEG_3` vs `NEG3`).
fn java_const_name(overrides: &ClosedNameOverrides, value: &Value) -> String {
if let Some(name) = overrides.get(value) {
return name.to_string();
}
let token = java_closed_token(value);
let needs_guard =
matches!(value, Value::Number(_)) || !token.starts_with(|c: char| c.is_ascii_alphabetic());
if needs_guard {
format!("V_{token}")
} else {
token
}
}
/// 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, 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 static String memberPath(String key) {\n");
output.push_str(" if (key.matches(\"[A-Za-z_][A-Za-z0-9_]*\")) {\n return key;\n }\n");
output.push_str(" return \"[\\\"\" + key.replace(\"\\\\\", \"\\\\\\\\\").replace(\"\\\"\", \"\\\\\\\"\") + \"\\\"]\";\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(path.startsWith(\"[\") ? prefix + path : 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 `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.core.JsonParser;\n");
output.push_str("import com.fasterxml.jackson.core.JsonToken;\n");
output.push_str("import com.fasterxml.jackson.databind.JsonNode;\n");
output.push_str("import com.fasterxml.jackson.databind.node.ArrayNode;\n");
output.push_str("import com.fasterxml.jackson.databind.node.DecimalNode;\n");
output.push_str("import com.fasterxml.jackson.databind.node.JsonNodeFactory;\n");
output.push_str("import com.fasterxml.jackson.databind.node.ObjectNode;\n");
output.push_str("import java.io.IOException;\n");
output.push_str("import java.math.BigDecimal;\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 static final BigDecimal INTEGER_CAP_DECIMAL = BigDecimal.valueOf(INTEGER_CAP);\n\n",
);
output.push_str(" private SpecNumbers() {}\n\n");
// Integral classification runs on the token's **exact** decimal value
// (`JsonNode.decimalValue()`), never on `doubleValue()`: above 2^52 a
// fractional literal such as `4503599627370496.5` has already rounded to an
// integral `double`, so `value == Math.floor(value)` would accept it where
// the shared contract rejects. See `specs/json-schema/features/type.md`.
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. Classification is exact\n * (the token's {@code BigDecimal}), never the rounded {@code double}. Adds a\n * {@link Violation} 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(" if (!isFiniteNode(node)) {\n violations.add(new Violation(path, \"not an integer\"));\n return null;\n }\n");
output.push_str(" BigDecimal decimal = node.decimalValue();\n");
output.push_str(" if (decimal.stripTrailingZeros().scale() > 0) {\n");
output.push_str(" violations.add(new Violation(path, \"not an integer\"));\n return null;\n }\n");
output.push_str(" if (decimal.abs().compareTo(INTEGER_CAP_DECIMAL) > 0) {\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 decimal.longValueExact();\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() || !isFiniteNode(node)) {\n return false;\n }\n");
output.push_str(" BigDecimal decimal = node.decimalValue();\n");
output.push_str(" return decimal.stripTrailingZeros().scale() <= 0\n && decimal.abs().compareTo(INTEGER_CAP_DECIMAL) <= 0;\n }\n");
output.push_str("\n /** True when a numeric node has an exact decimal value ({@code decimalValue()} throws on NaN/Infinity). */\n");
output.push_str(" private static boolean isFiniteNode(JsonNode node) {\n");
output.push_str(" return !(node.isDouble() || node.isFloat()) || Double.isFinite(node.doubleValue());\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(SPEC_NUMBERS_EXACT_TREE_BODY);
output.push_str("\n /** Returns an in-memory {@code number} equality key, folding {@code -0.0} onto {@code 0.0}. */\n");
output.push_str(" public static @Nullable Double numberKey(@Nullable Double value) {\n");
output.push_str(" if (value == null) {\n return null;\n }\n");
output.push_str(
" return value.doubleValue() == 0.0d ? Double.valueOf(0.0d) : 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
}
/// The `SpecNumbers.readExactTree` token walk. Jackson's default tree builder
/// folds **every** floating token into a `double`, so a fractional literal at or
/// above 2^52 (`4503599627370496.5`) has already rounded to an integral value
/// before any check can see it — and an `integer` field would accept it. This
/// walk keeps the token's exact decimal **only when the `double` is lossy**;
/// otherwise it produces the very node Jackson would have, so signed zero, the
/// re-emitted lexeme of a pass-through extra, and every other observable stay
/// exactly as before. See `specs/json-schema/features/type.md`.
const SPEC_NUMBERS_EXACT_TREE_BODY: &str = r####"
/**
* Reads the whole value under {@code parser} as a tree whose numbers keep
* their exact decimal value when {@code double} cannot hold it. Used in
* place of {@code parser.readValueAsTree()} by every generated
* deserializer.
*/
public static @Nullable JsonNode readExactTree(JsonParser parser) throws IOException {
JsonToken token = parser.currentToken();
if (token == null) {
token = parser.nextToken();
}
if (token == null) {
return null;
}
JsonNodeFactory factory = JsonNodeFactory.instance;
switch (token) {
case START_OBJECT: {
ObjectNode object = factory.objectNode();
while (parser.nextToken() != JsonToken.END_OBJECT) {
String name = parser.currentName();
parser.nextToken();
object.set(name, readExactTree(parser));
}
return object;
}
case START_ARRAY: {
ArrayNode array = factory.arrayNode();
while (parser.nextToken() != JsonToken.END_ARRAY) {
array.add(readExactTree(parser));
}
return array;
}
case VALUE_STRING:
return factory.textNode(parser.getText());
case VALUE_NUMBER_INT:
switch (parser.getNumberType()) {
case INT:
return factory.numberNode(parser.getIntValue());
case LONG:
return factory.numberNode(parser.getLongValue());
default:
return factory.numberNode(parser.getBigIntegerValue());
}
case VALUE_NUMBER_FLOAT: {
double approximate = parser.getDoubleValue();
if (!Double.isFinite(approximate)) {
return factory.numberNode(approximate);
}
BigDecimal exact = parser.getDecimalValue();
return BigDecimal.valueOf(approximate).compareTo(exact) == 0
? factory.numberNode(approximate)
: DecimalNode.valueOf(exact);
}
case VALUE_TRUE:
return factory.booleanNode(true);
case VALUE_FALSE:
return factory.booleanNode(false);
case VALUE_NULL:
return factory.nullNode();
default:
return factory.pojoNode(parser.getEmbeddedObject());
}
}
"####;
/// 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");
// The pinned regex carries the per-target end anchor, exactly as the
// value-level `pattern`/`format` path does: `java.util.regex`'s `$` matches
// *before* a final line terminator, so a trailing `\n` must be excluded by
// `\z` rather than by the anchoring mode of whichever matcher call happens
// to be at the use site. See `specs/json-schema/features/pattern.md`.
output.push_str(&format!(
" private static final Pattern BASE64 = Pattern.compile({});\n",
java_string_literal(&java_pinned_pattern(Encoding::Base64.pattern()))
));
output.push_str(&format!(
" private static final Pattern BASE64URL = Pattern.compile({});\n\n",
java_string_literal(&java_pinned_pattern(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);
}
// A `byte[]` has no value equality in Java, so a `List<byte[]>` member
// cannot use `List.equals` / `List.hashCode` / `List.toString` (they compare
// and print element identities). These three give it the value semantics the
// rest of the model has.
public static boolean listEquals(@Nullable List<byte[]> left, @Nullable List<byte[]> right) {
if (left == right) {
return true;
}
if (left == null || right == null || left.size() != right.size()) {
return false;
}
for (int index = 0; index < left.size(); index++) {
if (!java.util.Arrays.equals(left.get(index), right.get(index))) {
return false;
}
}
return true;
}
public static int listHashCode(@Nullable List<byte[]> values) {
if (values == null) {
return 0;
}
int result = 1;
for (byte[] value : values) {
result = 31 * result + java.util.Arrays.hashCode(value);
}
return result;
}
public static String listToString(@Nullable List<byte[]> values) {
if (values == null) {
return "null";
}
StringBuilder out = new StringBuilder("[");
for (int index = 0; index < values.size(); index++) {
if (index > 0) {
out.append(", ");
}
out.append(java.util.Arrays.toString(values.get(index)));
}
return out.append("]").toString();
}
"####;
/// 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.OffsetDateTime;\n");
output.push_str("import java.time.ZoneOffset;\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");
// As `Base64Support`: the pinned regex carries the `\z` end anchor so a
// trailing line terminator can never slip past, whatever matcher call the
// use site makes.
output.push_str(&format!(
" private static final Pattern DATE_TIME = Pattern.compile({});\n",
java_string_literal(&java_pinned_pattern(TemporalKind::DateTime.pattern()))
));
output.push_str(&format!(
" private static final Pattern DATE = Pattern.compile({});\n",
java_string_literal(&java_pinned_pattern(TemporalKind::Date.pattern()))
));
output.push_str(&format!(
" private static final Pattern TIME = Pattern.compile({});\n",
java_string_literal(&java_pinned_pattern(TemporalKind::Time.pattern()))
));
output.push_str(&format!(
" private static final Pattern DURATION = Pattern.compile({});\n",
java_string_literal(&java_pinned_pattern(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);
}
/**
* Truncates a fractional-seconds run to nanosecond resolution.
*
* The pinned grammar admits a fraction of any width and every target keeps
* what its own type can hold, dropping the rest: Go, TypeScript and Java at
* nanoseconds, Python at microseconds. That is P1's exception (b) — loss
* only at the target type's genuine capacity limit — and it is what
* `samples/python/tests/test_temporal.py` already pins. `java.time` stops at
* nanoseconds and its ISO parser *throws* past nine digits, so the extra
* digits are dropped here; rejecting instead would split the accept set,
* which exception (b) does not cover.
*/
private static String truncateFraction(String value) {
int dot = value.indexOf('.');
if (dot < 0) {
return value;
}
int end = dot + 1;
while (end < value.length() && value.charAt(end) >= '0' && value.charAt(end) <= '9') {
end++;
}
if (end - dot - 1 <= 9) {
return value;
}
return value.substring(0, dot + 10) + value.substring(end);
}
public static OffsetDateTime parseDateTimeLiteral(String value) {
return OffsetDateTime.parse(truncateFraction(value).toUpperCase());
}
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 parseDateTimeLiteral(value);
} 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());
}
private static String canonicalTime(String value) {
String upper = value.toUpperCase();
int dot = upper.indexOf('.');
if (dot >= 0) {
int fractionEnd = dot + 1;
while (fractionEnd < upper.length() && Character.isDigit(upper.charAt(fractionEnd))) {
fractionEnd++;
}
int trimmedEnd = fractionEnd;
while (trimmedEnd > dot + 1 && upper.charAt(trimmedEnd - 1) == '0') {
trimmedEnd--;
}
String fraction = trimmedEnd == dot + 1 ? "" : upper.substring(dot, trimmedEnd);
upper = upper.substring(0, dot) + fraction + upper.substring(fractionEnd);
}
if (upper.endsWith("+00:00") || upper.endsWith("-00:00")) {
upper = upper.substring(0, upper.length() - 6) + "Z";
}
return upper;
}
// 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;
}
return canonicalTime(value);
}
public static String formatTime(String value) {
return canonicalTime(value);
}
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);
}
// ---- Serialize-side representability (P12) -------------------------
// A POJO is constructed unchecked, so a value the pinned wire grammar
// cannot spell reaches the Serializer. Each check below is the exact
// counterpart of the matching `parse*` above: without it the emitted wire
// is a string this module's own parser rejects.
private static void checkOffset(String name, Object value, ZoneOffset offset, String path, List<Violation> violations) {
int offsetSeconds = offset.getTotalSeconds();
if (offsetSeconds % 60 != 0) {
violations.add(new Violation(path, "must be a valid " + name + ", got " + value
+ ": the UTC offset " + offset + " is not a whole number of minutes"));
} else if (offsetSeconds < -18 * 60 * 60 || offsetSeconds > 18 * 60 * 60) {
violations.add(new Violation(path, "must be a valid " + name + ", got " + value
+ ": the UTC offset is outside -18:00 through +18:00"));
}
}
private static void checkYear(String name, Object value, int year, String path, List<Violation> violations) {
if (year < 1) {
violations.add(new Violation(path, "must be a valid " + name + ", got " + value + ": year must be >= 0001"));
} else if (year > 9999) {
violations.add(new Violation(path, "must be a valid " + name + ", got " + value + ": year must be <= 9999"));
}
}
public static void checkDateTime(OffsetDateTime value, String path, List<Violation> violations) {
checkYear("date-time", value, value.getYear(), path, violations);
checkOffset("date-time", value, value.getOffset(), path, violations);
}
public static void checkDate(LocalDate value, String path, List<Violation> violations) {
checkYear("date", value, value.getYear(), path, violations);
}
// `time` materializes as the canonical wire string; a hand-constructed POJO
// can still hold anything, so the pinned grammar is re-asserted.
public static void checkTime(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;
}
}
public static void checkDuration(Duration value, String path, List<Violation> violations) {
String reason;
if (value.isNegative()) {
reason = "a duration cannot be negative";
} else if (value.getNano() != 0) {
reason = "a duration cannot carry a fraction of a second";
} else if (value.getSeconds() > MAX_DURATION_SECONDS) {
reason = "a duration cannot exceed " + MAX_DURATION_SECONDS + " seconds";
} else {
return;
}
violations.add(new Violation(path, "must be a valid duration, got " + value + ": " + reason));
}
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
}