use std::sync::Arc;
use ahash::{AHashMap, AHashSet};
use referencing::Resolver;
use serde_json::Value;
use crate::canonical::{
context::CanonicalizationContext,
ir::{BoundCardinality, Schema, SchemaKind},
parse::{self, ParseOutput},
schema::DefinitionMap,
CanonicalizationError, ROOT_DEFINITION_KEY,
};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub(crate) enum Position {
InPlace,
Consuming,
}
pub(crate) fn collect_classified_references<'a>(
schema: &'a Schema,
position: Position,
out: &mut Vec<(&'a Arc<str>, Position)>,
) {
match schema.kind() {
SchemaKind::Reference(uri) => out.push((uri, position)),
SchemaKind::Not(inner) | SchemaKind::TypedGroup { body: inner, .. } => {
collect_classified_references(inner, position, out);
}
SchemaKind::AllOf(branches) | SchemaKind::AnyOf(branches) => {
for branch in branches.as_slice() {
collect_classified_references(branch, position, out);
}
}
SchemaKind::OneOf(branches) => {
for branch in branches {
collect_classified_references(branch, position, out);
}
}
SchemaKind::Array(leaf) => {
let leaf = leaf.get();
for schema in &leaf.prefix {
collect_classified_references(schema, Position::Consuming, out);
}
if let Some(schema) = &leaf.items {
collect_classified_references(schema, Position::Consuming, out);
}
for facet in &leaf.contains {
collect_classified_references(&facet.schema, Position::Consuming, out);
}
}
SchemaKind::Object(leaf) => {
let leaf = leaf.get();
if let Some(schema) = &leaf.property_names {
collect_classified_references(schema, Position::Consuming, out);
}
for schema in leaf.properties.values() {
collect_classified_references(schema, Position::Consuming, out);
}
for schema in leaf.pattern_properties.values() {
collect_classified_references(schema, Position::Consuming, out);
}
if let Some(schema) = &leaf.additional {
collect_classified_references(schema, Position::Consuming, out);
}
}
SchemaKind::MultiType(_)
| SchemaKind::String(_)
| SchemaKind::Integer(_)
| SchemaKind::Number(_)
| SchemaKind::Const(_)
| SchemaKind::Enum(_)
| SchemaKind::True
| SchemaKind::False
| SchemaKind::Raw(_) => {}
}
}
pub(crate) type ReferenceEdges = AHashMap<Arc<str>, Vec<(Arc<str>, Position)>>;
pub(crate) fn reference_edges(root: &Schema, definitions: &DefinitionMap) -> ReferenceEdges {
let mut edges = ReferenceEdges::default();
let mut record = |key: Arc<str>, body: &Schema| {
let mut found = Vec::new();
collect_classified_references(body, Position::InPlace, &mut found);
let mut targets: Vec<(Arc<str>, Position)> = found
.into_iter()
.map(|(uri, position)| (Arc::clone(uri), position))
.collect();
targets.sort();
targets.dedup();
edges.insert(key, targets);
};
record(Arc::from(ROOT_DEFINITION_KEY), root);
for (uri, body) in definitions {
record(Arc::clone(uri), body);
}
edges
}
pub(crate) fn guarded_members(edges: &ReferenceEdges) -> AHashSet<Arc<str>> {
strongly_connected(edges)
.into_iter()
.filter(|component| is_cyclic(component, edges))
.filter_map(|component| {
let internal = restrict(&component, edges, Edges::All);
let unguarded = in_place_cycle_members(&component, &internal);
let guarded: Vec<Arc<str>> = component
.into_iter()
.filter(|key| !unguarded.contains(key))
.collect();
let remaining = restrict(&guarded, &internal, Edges::All);
strongly_connected(&remaining)
.iter()
.any(|inner| is_cyclic(inner, &remaining))
.then_some(guarded)
})
.flatten()
.collect()
}
fn in_place_cycle_members(component: &[Arc<str>], internal: &ReferenceEdges) -> AHashSet<Arc<str>> {
let restricted = restrict(component, internal, Edges::InPlaceOnly);
strongly_connected(&restricted)
.into_iter()
.filter(|inner| is_cyclic(inner, &restricted))
.flatten()
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Edges {
All,
InPlaceOnly,
}
fn restrict(component: &[Arc<str>], edges: &ReferenceEdges, keep: Edges) -> ReferenceEdges {
let members: AHashSet<&Arc<str>> = component.iter().collect();
component
.iter()
.map(|key| {
let targets = edges
.get(key)
.map(|targets| {
targets
.iter()
.filter(|(target, position)| {
members.contains(target)
&& (keep == Edges::All || *position == Position::InPlace)
})
.cloned()
.collect()
})
.unwrap_or_default();
(Arc::clone(key), targets)
})
.collect()
}
fn is_cyclic(component: &[Arc<str>], edges: &ReferenceEdges) -> bool {
match component {
[only] => edges
.get(only)
.is_some_and(|targets| targets.iter().any(|(target, _)| target == only)),
_ => component.len() > 1,
}
}
fn strongly_connected(edges: &ReferenceEdges) -> Vec<Vec<Arc<str>>> {
let mut index_of: AHashMap<Arc<str>, usize> = AHashMap::default();
let mut low_of: AHashMap<Arc<str>, usize> = AHashMap::default();
let mut on_stack: AHashSet<Arc<str>> = AHashSet::default();
let mut stack: Vec<Arc<str>> = Vec::new();
let mut components = Vec::new();
let mut next_index = 0;
let mut roots: Vec<&Arc<str>> = edges.keys().collect();
roots.sort();
for root in roots {
if index_of.contains_key(root) {
continue;
}
let mut frames: Vec<(Arc<str>, usize)> = vec![(Arc::clone(root), 0)];
index_of.insert(Arc::clone(root), next_index);
low_of.insert(Arc::clone(root), next_index);
next_index += 1;
stack.push(Arc::clone(root));
on_stack.insert(Arc::clone(root));
while let Some((node, cursor)) = frames.pop() {
let targets: &[(Arc<str>, Position)] =
edges.get(&node).map_or(&[], |targets| targets.as_slice());
let next = targets
.iter()
.enumerate()
.skip(cursor)
.find(|(_, (target, _))| edges.contains_key(target));
if let Some((offset, (target, _))) = next {
frames.push((Arc::clone(&node), offset + 1));
if let Some(target_index) = index_of.get(target).copied() {
if on_stack.contains(target) {
let low = low_of[&node].min(target_index);
low_of.insert(Arc::clone(&node), low);
}
} else {
index_of.insert(Arc::clone(target), next_index);
low_of.insert(Arc::clone(target), next_index);
next_index += 1;
stack.push(Arc::clone(target));
on_stack.insert(Arc::clone(target));
frames.push((Arc::clone(target), 0));
}
continue;
}
if low_of[&node] == index_of[&node] {
let mut component = Vec::new();
while let Some(member) = stack.pop() {
on_stack.remove(&member);
let done = member == node;
component.push(member);
if done {
break;
}
}
components.push(component);
}
if let Some((parent, _)) = frames.last() {
let low = low_of[parent].min(low_of[&node]);
let parent = Arc::clone(parent);
low_of.insert(parent, low);
}
}
}
components
}
pub(crate) fn fold_empty_definitions<'a>(
mut parsed: ParseOutput,
value: &'a Value,
ctx: &CanonicalizationContext,
resolver: &Resolver<'a>,
) -> Result<ParseOutput, CanonicalizationError> {
let mut empty = AHashSet::default();
loop {
let proven = resolve_empty_definitions(&parsed, value, ctx, resolver, &empty)?;
let mut grew = false;
for uri in proven {
grew |= empty.insert(uri);
}
if !grew {
return Ok(parsed);
}
let Some(refolded) = parse::parse_with_empty(value, ctx, resolver, &empty)? else {
return Ok(parsed);
};
parsed = refolded;
}
}
fn resolve_empty_definitions<'a>(
parsed: &ParseOutput,
value: &'a Value,
ctx: &CanonicalizationContext,
resolver: &Resolver<'a>,
proven: &AHashSet<Arc<str>>,
) -> Result<AHashSet<Arc<str>>, CanonicalizationError> {
if !parsed.has_references {
return Ok(AHashSet::default());
}
let edges = reference_edges(&parsed.root, &parsed.definitions);
let assumed = guarded_members(&edges);
let mut assumed = plausible_assumptions(parsed, assumed);
while !assumed.is_empty() {
let hypothesis: AHashSet<Arc<str>> = proven.union(&assumed).cloned().collect();
let Some(hypothetical) = parse::parse_hypothesis(value, ctx, resolver, &hypothesis)? else {
return Ok(AHashSet::default());
};
let survivors: AHashSet<Arc<str>> = assumed
.iter()
.filter(|key| folds_to_false(key, &hypothetical))
.cloned()
.collect();
if survivors.len() == assumed.len() {
return Ok(assumed);
}
assumed = plausible_assumptions(parsed, survivors);
}
Ok(assumed)
}
fn may_fold(schema: &Schema, assumed: &AHashSet<Arc<str>>) -> bool {
match schema.kind() {
SchemaKind::False => true,
SchemaKind::Reference(uri) => assumed.contains(uri.as_ref()),
SchemaKind::TypedGroup { body, .. } => may_fold(body, assumed),
SchemaKind::AllOf(branches) => branches.as_slice().iter().any(|b| may_fold(b, assumed)),
SchemaKind::AnyOf(branches) => branches.as_slice().iter().all(|b| may_fold(b, assumed)),
SchemaKind::OneOf(branches) => branches.iter().all(|b| may_fold(b, assumed)),
SchemaKind::Array(leaf) => {
let leaf = leaf.get();
let demands = |index: usize| {
leaf.lengths.minimum.as_ref().is_some_and(|minimum| {
u64::try_from(index)
.ok()
.and_then(|index| index.checked_add(1))
.is_some_and(|reach| *minimum >= BoundCardinality::from(reach))
})
};
let demanded_prefix = leaf
.prefix
.iter()
.enumerate()
.any(|(index, schema)| demands(index) && may_fold(schema, assumed));
let demanded_items = leaf
.items
.as_ref()
.is_some_and(|schema| demands(leaf.prefix.len()) && may_fold(schema, assumed));
let demanded_contains = leaf.contains.iter().any(|facet| {
!facet.effective_minimum().is_zero() && may_fold(&facet.schema, assumed)
});
demanded_prefix || demanded_items || demanded_contains
}
SchemaKind::Object(leaf) => {
let leaf = leaf.get();
let demands_a_key = !leaf.required.is_empty()
|| leaf
.sizes
.minimum
.as_ref()
.is_some_and(|minimum| !minimum.is_zero());
let demanded_names = demands_a_key
&& leaf
.property_names
.as_ref()
.is_some_and(|schema| may_fold(schema, assumed));
let demanded_catch_all = demands_a_key
&& leaf.required.is_empty()
&& leaf.properties.is_empty()
&& (leaf
.pattern_properties
.values()
.any(|schema| may_fold(schema, assumed))
|| leaf
.additional
.as_ref()
.is_some_and(|schema| may_fold(schema, assumed)));
let demanded_required =
leaf.required
.iter()
.any(|key| match leaf.properties.get(key) {
Some(schema) => may_fold(schema, assumed),
None => {
leaf.pattern_properties
.values()
.any(|schema| may_fold(schema, assumed))
|| leaf
.additional
.as_ref()
.is_some_and(|schema| may_fold(schema, assumed))
}
});
demanded_names || demanded_catch_all || demanded_required
}
SchemaKind::Not(_)
| SchemaKind::MultiType(_)
| SchemaKind::String(_)
| SchemaKind::Integer(_)
| SchemaKind::Number(_)
| SchemaKind::Const(_)
| SchemaKind::Enum(_)
| SchemaKind::True
| SchemaKind::Raw(_) => false,
}
}
fn foldable_closure(parsed: &ParseOutput, assumed: &AHashSet<Arc<str>>) -> AHashSet<Arc<str>> {
let mut candidates = assumed.clone();
loop {
let mut grew = false;
for (key, body) in &parsed.definitions {
if !candidates.contains(key) && may_fold(body, &candidates) {
candidates.insert(Arc::clone(key));
grew = true;
}
}
if !grew {
return candidates;
}
}
}
fn plausible_assumptions(
parsed: &ParseOutput,
mut assumed: AHashSet<Arc<str>>,
) -> AHashSet<Arc<str>> {
if assumed.is_empty() {
return assumed;
}
loop {
let candidates = foldable_closure(parsed, &assumed);
let kept: AHashSet<Arc<str>> = assumed
.iter()
.filter(|key| body_of(parsed, key).is_some_and(|body| may_fold(body, &candidates)))
.cloned()
.collect();
if kept.len() == assumed.len() {
return kept;
}
assumed = kept;
}
}
fn folds_to_false(key: &Arc<str>, hypothetical: &ParseOutput) -> bool {
body_of(hypothetical, key).is_some_and(|body| matches!(body.kind(), SchemaKind::False))
}
fn body_of<'a>(parsed: &'a ParseOutput, key: &str) -> Option<&'a Schema> {
if key == ROOT_DEFINITION_KEY {
Some(&parsed.root)
} else {
parsed.definitions.get(key)
}
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
use test_case::test_case;
use super::*;
fn parsed_of(schema: &Value) -> ParseOutput {
let registry = referencing::Registry::new()
.add(
"json-schema:///",
referencing::Draft::Draft202012.create_resource_ref(schema),
)
.expect("the fixture is a resource")
.draft(referencing::Draft::Draft202012)
.prepare()
.expect("the registry prepares");
let resolver = registry
.resolver(referencing::uri::from_str("json-schema:///").expect("a valid base URI"));
let ctx = CanonicalizationContext::new(
referencing::Draft::Draft202012,
crate::options::PatternEngineOptions::default(),
false,
);
parse::parse(schema, &ctx, &resolver)
.expect("the fixture parses")
.expect("the fixture is modeled")
}
fn self_reference_position(schema: &Value) -> Position {
let parsed = parsed_of(schema);
let mut found = Vec::new();
collect_classified_references(&parsed.root, Position::InPlace, &mut found);
let positions: Vec<Position> = found
.into_iter()
.filter(|(uri, _)| uri.as_ref() == "#")
.map(|(_, position)| position)
.collect();
assert_eq!(
positions.len(),
1,
"the fixture holds exactly one self-reference, found {positions:?}"
);
positions[0]
}
#[test_case(&json!({"properties": {"a": {"$ref": "#"}}}), Position::Consuming ; "properties")]
#[test_case(&json!({"patternProperties": {"^a": {"$ref": "#"}}}), Position::Consuming ; "pattern_properties")]
#[test_case(&json!({"type": "object", "additionalProperties": {"$ref": "#"}}), Position::Consuming ; "additional_properties")]
#[test_case(&json!({"type": "object", "propertyNames": {"$ref": "#"}}), Position::Consuming ; "property_names")]
#[test_case(&json!({"type": "array", "items": {"$ref": "#"}}), Position::Consuming ; "items")]
#[test_case(&json!({"type": "array", "prefixItems": [{"$ref": "#"}]}), Position::Consuming ; "prefix_items")]
#[test_case(&json!({"contains": {"$ref": "#"}}), Position::Consuming ; "contains")]
#[test_case(&json!({"allOf": [{"$ref": "#"}, {"type": "integer"}]}), Position::InPlace ; "all_of")]
#[test_case(&json!({"anyOf": [{"$ref": "#"}, {"type": "integer"}]}), Position::InPlace ; "any_of")]
#[test_case(&json!({"not": {"$ref": "#"}}), Position::InPlace ; "not")]
fn classifies_reference_position(schema: &Value, expected: Position) {
assert_eq!(self_reference_position(schema), expected);
}
#[test]
fn nesting_below_a_consuming_position_stays_consuming() {
let schema = json!({"properties": {"a": {"anyOf": [{"$ref": "#"}, {"type": "null"}]}}});
assert_eq!(self_reference_position(&schema), Position::Consuming);
}
fn edges_of(schema: &Value) -> ReferenceEdges {
let parsed = parsed_of(schema);
reference_edges(&parsed.root, &parsed.definitions)
}
fn guarded(schema: &Value) -> Vec<String> {
let mut names: Vec<String> = guarded_members(&edges_of(schema))
.iter()
.map(ToString::to_string)
.collect();
names.sort();
names
}
#[test]
fn a_consuming_self_cycle_qualifies() {
let schema = json!({"type": "object", "properties": {"a": {"$ref": "#"}}});
assert_eq!(guarded(&schema), vec!["#".to_string()]);
}
#[test]
fn a_required_consuming_self_cycle_qualifies() {
let schema =
json!({"type": "object", "required": ["a"], "properties": {"a": {"$ref": "#"}}});
assert_eq!(guarded(&schema), vec!["#".to_string()]);
}
#[test]
fn an_in_place_self_cycle_is_excluded() {
let schema = json!({"$ref": "#/$defs/a", "$defs": {"a": {"allOf": [{"$ref": "#/$defs/a"}, {"type": "integer"}]}}});
assert!(guarded(&schema).is_empty());
}
#[test]
fn a_mixed_cycle_qualifies() {
let schema = json!({
"$ref": "#/$defs/a",
"$defs": {
"a": {"allOf": [{"$ref": "#/$defs/b"}, {"type": "object"}]},
"b": {"type": "object", "properties": {"x": {"$ref": "#/$defs/a"}}}
}
});
assert_eq!(
guarded(&schema),
vec!["#/$defs/a".to_string(), "#/$defs/b".to_string()]
);
}
#[test]
fn only_the_ill_founded_member_is_excluded() {
let schema = json!({
"$ref": "#/$defs/a",
"$defs": {
"a": {"type": "object", "properties": {
"x": {"$ref": "#/$defs/a"},
"y": {"$ref": "#/$defs/b"}
}},
"b": {"anyOf": [{"$ref": "#/$defs/b"}, {"$ref": "#/$defs/a"}]}
}
});
assert_eq!(guarded(&schema), vec!["#/$defs/a".to_string()]);
}
#[test]
fn an_acyclic_document_has_no_members() {
let schema = json!({"$ref": "#/$defs/a", "$defs": {"a": {"type": "integer"}}});
assert!(guarded(&schema).is_empty());
}
}