///|
/// Draft-07 identifier scopes over declared schema roots. Indexing walks only
/// draft-07 subschema positions: literal keywords, unknown keywords, `$ref`
/// siblings and declared foreign dialects never establish identifiers.
/// The optional input is the caller's own document, named by `input_base`
/// when it has one, and is never registered as a resource.
pub struct SchemaIndex {
priv resources : ResourceSet
priv input : @json.Value?
priv input_base : UriReference?
priv ids : Map[String, Location]
priv names : Map[String, Location]
priv scopes : Map[String, UriReference?]
priv diagnostics : Array[@json.Diagnostic]
priv mut complete : Bool
priv max_findings : Int32
} derive(Debug)
///|
pub extend SchemaIndex with @debug.Debug::{to_repr}
///|
fn SchemaIndex::report(
self : SchemaIndex,
d : @json.Diagnostic,
complete~ : Bool = true,
) -> Unit {
if !complete {
self.complete = false
}
if self.diagnostics.length() < self.max_findings {
self.diagnostics.push(d)
} else {
self.complete = false
}
}
///|
/// Draft-07 core 8.2.3 plain-name fragments.
fn plain_name(text : String) -> Bool {
let first = text.code_unit_at(0)
if !uri_letter(first) {
return false
}
for c in text.code_units() {
if !(uri_letter(c) ||
uri_digit(c) ||
c == 45 ||
c == 46 ||
c == 58 ||
c == 95) {
return false
}
}
true
}
///|
fn SchemaIndex::bind_id(
self : SchemaIndex,
scope : UriReference,
location : Location,
) -> Bool {
let identity = scope.identity()
let owner = match self.ids.get(identity) {
Some(existing) => Some(existing)
None =>
match self.resources.get(scope) {
Some(resource) =>
Some(Location::{
resource: Some(resource.uri),
pointer: { parts: [], },
target: resource.value,
})
None =>
match (self.input, self.input_base) {
(Some(value), Some(base)) if base.identity() == identity =>
Some(Location::{
resource: Some(base),
pointer: { parts: [], },
target: value,
})
_ => None
}
}
}
if owner is Some(existing) && existing.identity() != location.identity() {
self.report(
@json.Diagnostic::new(
@json.ConflictingIdentifier,
"Schema identifier " +
scope.text() +
" already names " +
existing.identity(),
path=[..location.pointer.parts, "$id"],
),
)
return false
}
self.ids.set(identity, location)
true
}
///|
fn SchemaIndex::bind_name(
self : SchemaIndex,
scope_root : String,
name : String,
location : Location,
) -> Unit {
let key = scope_root + "#" + name
match self.names.get(key) {
Some(existing) if existing.identity() != location.identity() =>
self.report(
@json.Diagnostic::new(
@json.ConflictingIdentifier,
"Schema identifier fragment #" +
name +
" already names " +
existing.identity(),
path=[..location.pointer.parts, "$id"],
),
)
_ => self.names.set(key, location)
}
}
///|
/// The scope after applying a draft-07 `$id` at `location`, with the identity
/// of the location that establishes it. Plain names are keyed by that
/// location, so every spelling of its document reaches them.
fn SchemaIndex::apply_id(
self : SchemaIndex,
id : UriReference,
scope : UriReference?,
scope_root : String,
location : Location,
limits : @json.Limits,
) -> (UriReference?, String) {
let at = [..location.pointer.parts, "$id"]
let mut next = scope
let mut root = scope_root
if !same_document(id) {
let resolved = match scope {
Some(base) if base.is_absolute() => id.resolve(base, limits~)
_ => if id.is_absolute() { id.resolve(id, limits~) } else { Ok(id) }
}
match resolved {
Ok(uri) => {
let free = uri.without_fragment()
// Without an absolute base a relative identifier names its location
// only within the unnamed input; nested relative scopes stay unbound.
let bindable = free.is_absolute() || scope is None
if bindable && !self.bind_id(free, location) {
return (scope, scope_root)
}
next = Some(free)
root = location.identity()
}
Err(d) => {
self.report(
@json.Diagnostic::new(d.code(), d.message(), path=at),
complete=d.code() != @json.LimitExceeded,
)
return (scope, scope_root)
}
}
}
match id.fragment {
None => ()
Some(fragment) if fragment.is_empty() => ()
Some(fragment) if plain_name(fragment) =>
self.bind_name(root, fragment, location)
Some(fragment) =>
self.report(
@json.Diagnostic::new(
@json.UnsupportedCheck,
"Draft-07 does not define identifier fragment #" + fragment,
path=at,
),
complete=false,
)
}
(next, root)
}
///|
pub fn SchemaIndex::new(
resources : ResourceSet,
input~ : @json.Value? = None,
input_base~ : UriReference? = None,
input_roots~ : Array[JsonPointer] = [],
limits~ : @json.Limits = @json.Limits::new(),
) -> SchemaIndex {
let index = SchemaIndex::{
resources,
input,
input_base,
ids: Map([]),
names: Map([]),
scopes: Map([]),
diagnostics: [],
complete: true,
max_findings: limits.max_findings(),
}
let stack : Array[
(@json.Value, UriReference?, Array[String], UriReference?, String),
] = []
let roots = []
for resource in resources.resources {
for root in resource.schema_roots {
roots.push((resource.value, Some(resource.uri), root))
}
}
if input is Some(value) {
if input_base is Some(base) {
if retrieval_name(base, "Input base URI") is Err(d) {
index.report(d, complete=false)
} else if resources.get(base) is Some(_) {
index.report(
@json.Diagnostic::new(
@json.ConflictingIdentifier,
"Input base URI " + base.text() + " is a registered resource",
),
)
}
}
for root in input_roots {
roots.push((value, input_base, root))
}
}
// Ancestors first: a nested root reached by walking its ancestors inherits
// their identifier scopes, whatever order the roots were declared in.
roots.sort_by((a, b) => a.2.parts.length() - b.2.parts.length())
for i in 0.. stack.push((value, owner, root.parts, owner, document_root))
Err(d) => index.report(d, complete=d.code() != @json.LimitExceeded)
}
}
let mut nodes = 0
// `$ref` sites found while walking; their targets are schema positions too.
let pending : Array[(@json.Value, UriReference?)] = []
let mut exhausted = false
while !exhausted {
while stack.pop() is Some((value, owner, tokens, scope, scope_root)) {
nodes += 1
if nodes > limits.max_nodes() || tokens.length() > limits.max_depth() {
index.report(
@json.Diagnostic::new(
@json.LimitExceeded,
"Schema identifier index limit exceeded",
path=tokens,
),
complete=false,
)
exhausted = true
break
}
let location = Location::{
resource: owner,
pointer: { parts: tokens, },
target: value,
}
let key = location.identity()
if index.scopes.contains(key) {
continue
}
index.scopes.set(key, scope)
guard value.kind() is @json.Object(fields) else { continue }
if value.get("$schema") is Some(dialect) &&
dialect.text() is Some(text) &&
!@domain.draft07_dialect(text) {
continue
}
if value.get("$ref") is Some(raw) {
pending.push((raw, scope))
continue
}
let mut next = scope
let mut next_root = scope_root
if value.get("$id") is Some(raw) &&
raw.text() is Some(text) &&
UriReference::parse(text, limits~) is Ok(id) {
let (applied, applied_root) = index.apply_id(
id, scope, scope_root, location, limits,
)
next = applied
next_root = applied_root
index.scopes.set(key, next)
}
fields.sort_by((a, b) => a.0.lexical_compare(b.0))
let children = []
for field in fields {
for entry in @domain.draft07_subschemas(field.0, field.1) {
children.push(
(entry.1, owner, [..tokens, field.0, ..entry.0], next, next_root),
)
}
}
for i in 0.. {
let document_root = Location::{
resource: target.resource,
pointer: { parts: [], },
target: target.target,
}.identity()
stack.push(
(
target.target,
target.resource,
target.pointer.parts,
target.resource,
document_root,
),
)
}
_ => ()
}
}
if stack.is_empty() {
break
}
}
index
}
///|
pub fn SchemaIndex::resources(self : SchemaIndex) -> ResourceSet {
self.resources
}
///|
pub fn SchemaIndex::diagnostics(self : SchemaIndex) -> Array[@json.Diagnostic] {
self.diagnostics.copy()
}
///|
/// False when identifiers could not all be indexed: missing bases, undefined
/// fragment forms or exhausted limits.
pub fn SchemaIndex::complete(self : SchemaIndex) -> Bool {
self.complete
}
///|
/// The base URI in effect for the schema at `location`: its nearest `$id`
/// scope when indexed, otherwise its document's retrieval URI. A relative
/// result means the scope is undetermined for lack of an absolute base.
pub fn SchemaIndex::scope(
self : SchemaIndex,
location : Location,
) -> UriReference? {
match self.scopes.get(location.identity()) {
Some(scope) => scope
None => location.resource
}
}
///|
/// The scope recorded for the indexed schema whose `Location::identity` is
/// `identity`, or None when that location was not indexed.
pub fn SchemaIndex::indexed_scope(
self : SchemaIndex,
identity : String,
) -> UriReference?? {
self.scopes.get(identity)
}
///|
fn same_document(uri : UriReference) -> Bool {
uri.scheme is None &&
uri.authority is None &&
uri.path.is_empty() &&
uri.query is None
}
///|
fn SchemaIndex::document(self : SchemaIndex, uri : UriReference) -> Location? {
match self.ids.get(uri.identity()) {
Some(location) => return Some(location)
None => ()
}
match self.resources.get(uri) {
Some(resource) =>
return Some({
resource: Some(resource.uri),
pointer: { parts: [], },
target: resource.value,
})
None => ()
}
match (self.input, self.input_base) {
(Some(value), Some(base)) if base.identity() == uri.identity() =>
Some({ resource: Some(base), pointer: { parts: [], }, target: value, })
_ => None
}
}
///|
/// Resolve a schema reference from a site whose scope is `base`. Without a
/// base, the input's base or else the set's root base applies; a
/// same-document reference in an unnamed input targets that input.
pub fn SchemaIndex::resolve(
self : SchemaIndex,
reference : Reference,
base~ : UriReference? = None,
limits~ : @json.Limits = @json.Limits::new(),
) -> Resolution {
let base = match base {
Some(_) => base
None =>
if self.input is Some(_) {
self.input_base
} else {
self.resources.root_base
}
}
let fail = fn(chain, d : @json.Diagnostic, complete) {
{ target: None, chain, diagnostics: [d], complete, }
}
let absolute = if reference.uri.is_absolute() {
Some(reference.uri.resolve(reference.uri, limits~))
} else {
match base {
Some(base) if base.is_absolute() =>
Some(reference.uri.resolve(base, limits~))
Some(_) => None
None => None
}
}
let absolute = match absolute {
Some(Ok(uri)) => Some(uri)
Some(Err(d)) =>
return fail(
[{ site: None, reference, resolved: None, }],
d,
d.code() != @json.LimitExceeded,
)
None => None
}
let chain = [{ site: None, reference, resolved: absolute, }]
let (document, key) = match absolute {
Some(uri) => {
let free = uri.without_fragment()
(self.document(free), free.identity())
}
None =>
match (base, self.input) {
// A relative scope is known only within the unnamed input.
(Some(relative), _) if same_document(reference.uri) =>
match self.ids.get(relative.without_fragment().identity()) {
Some(location) => (Some(location), relative.text())
None =>
return fail(
chain,
@json.Diagnostic::new(
@json.MissingBaseUri,
"Reference " +
reference.text() +
" is in a scope with no absolute base URI",
),
false,
)
}
(None, Some(value)) if same_document(reference.uri) =>
(
Some(Location::{
resource: None,
pointer: { parts: [], },
target: value,
}),
"",
)
_ =>
return fail(
chain,
@json.Diagnostic::new(
@json.MissingBaseUri,
"Relative reference " +
reference.text() +
" has no absolute base URI",
),
false,
)
}
}
guard document is Some(document) else {
return fail(
chain,
@json.Diagnostic::new(
@json.UnavailableResource,
"Schema resource " + key + " was not supplied",
),
false,
)
}
match reference.fragment {
WholeResource =>
{ target: Some(document), chain, diagnostics: [], complete: true, }
PointerFragment(pointer) => {
let target = match pointer.resolve(document.target, limits~) {
Ok(target) => target
Err(d) => return fail(chain, d, d.code() != @json.LimitExceeded)
}
let combined = match
JsonPointer::from_tokens(
[..document.pointer.parts, ..pointer.parts],
limits~,
) {
Ok(combined) => combined
Err(d) => return fail(chain, d, false)
}
{
target: Some({ resource: document.resource, pointer: combined, target, }),
chain,
diagnostics: [],
complete: true,
}
}
IdentifierFragment(name) =>
match self.names.get(document.identity() + "#" + name) {
Some(location) =>
{ target: Some(location), chain, diagnostics: [], complete: true, }
None =>
fail(
chain,
@json.Diagnostic::new(
@json.UnresolvedReference,
"No schema identifier #" + name + " in " + key,
),
true,
)
}
}
}
///|
/// The scope in effect below a schema whose `$id` is `id`, given the
/// enclosing scope. Without an absolute base, a relative identifier gives a
/// relative scope: fragment-only references resolve within the location the
/// index bound to it, and other references report `MissingBaseUri`.
pub fn schema_scope(
id : UriReference,
scope : UriReference?,
limits~ : @json.Limits = @json.Limits::new(),
) -> UriReference? {
if same_document(id) {
return scope
}
let resolved = match scope {
Some(base) if base.is_absolute() => id.resolve(base, limits~)
_ => if id.is_absolute() { id.resolve(id, limits~) } else { Ok(id) }
}
match resolved {
Ok(uri) => Some(uri.without_fragment())
Err(_) => scope
}
}
///|
/// The physical location at `pointer` in a registered resource, or in the
/// input when `resource` is None or names the input base.
pub fn SchemaIndex::locate(
self : SchemaIndex,
resource : UriReference?,
pointer : JsonPointer,
limits~ : @json.Limits = @json.Limits::new(),
) -> Result[Location, @json.Diagnostic] {
let document : Location? = match resource {
None =>
match self.input {
Some(value) =>
Some({ resource: None, pointer: { parts: [], }, target: value, })
None => None
}
Some(uri) =>
match self.resources.get(uri) {
Some(found) =>
Some({
resource: Some(found.uri),
pointer: { parts: [], },
target: found.value,
})
None =>
match (self.input, self.input_base) {
(Some(value), Some(base)) if base.identity() ==
uri.without_fragment().identity() =>
Some({
resource: Some(base),
pointer: { parts: [], },
target: value,
})
_ => None
}
}
}
guard document is Some(document) else {
return Err(
@json.Diagnostic::new(
@json.UnavailableResource,
"Schema document was not supplied",
),
)
}
match pointer.resolve(document.target, limits~) {
Ok(target) => Ok({ resource: document.resource, pointer, target, })
Err(d) => Err(d)
}
}