///|
/// Compile-time reference context. `slots` maps the canonical escaped pointer
/// of every object/boolean schema position to its arena index in `nodes`;
/// slot 0 is always the document root. Positions the keyword walk never
/// visits (unknown extension keywords, annotation payloads) stay unvisited
/// and are compiled on first reference, so unreferenced annotation content
/// is never schema-checked while referenced targets always are. `visited`
/// also marks the active compile chain, so mutually recursive unknown-keyword
/// targets terminate. Resolution descends `root` directly: compilation stays
/// I/O-free and external URIs are refused, never fetched.
priv struct RefIndex {
root : Json
slots : Map[String, Int]
paths : Array[String]
targets : Array[Json]
visited : Array[Bool]
mut reference_depth : Int
nodes : Array[Plan]
}
///|
fn index_walk(
index : RefIndex,
value : Json,
pointer : String,
depth : Int,
limit : Int,
) -> Unit raise CompileError {
if depth > limit {
raise CompileError::DepthLimit(limit)
}
if value is (Object(_) | True | False) {
let slot = index.slots.length()
index.slots[pointer] = slot
index.paths.push(pointer)
index.targets.push(value)
index.visited.push(false)
index.nodes.push(Accept)
}
match value {
Object(fields) =>
for name, child in fields {
index_walk(index, child, pointer_step(pointer, name), depth + 1, limit)
}
Array(values) =>
for position, child in values {
index_walk(
index,
child,
pointer_step(pointer, position.to_string()),
depth + 1,
limit,
)
}
_ => ()
}
}
///|
/// One pre-compilation pass assigns an arena slot to every schema position,
/// including positions that appear textually after the `$ref` keywords that
/// target them, so forward references need no fixup phase.
fn index_document(document : Json, limit : Int) -> RefIndex raise CompileError {
let index : RefIndex = {
root: document,
slots: Map([]),
paths: [],
targets: [],
visited: [],
reference_depth: 0,
nodes: [],
}
index_walk(index, document, "", 0, limit)
index
}
///|
/// Compile an arena slot if no walk has reached it yet. The visited flag is
/// raised on entry to `compile_into`, so it doubles as the active-chain mark
/// and mutually recursive unknown-keyword targets terminate: the frame that
/// owns the slot writes its plan on return.
fn ensure_compiled(
index : RefIndex,
slot : Int,
limit : Int,
) -> Unit raise CompileError {
if !index.visited[slot] {
if index.reference_depth >= limit {
raise CompileError::DepthLimit(limit)
}
index.reference_depth += 1
defer {
index.reference_depth -= 1
}
ignore(
compile_into(index, index.targets[slot], index.paths[slot], 0, limit),
)
}
}
///|
fn slot_at(
index : RefIndex,
pointer : String,
path : String,
) -> Int raise CompileError {
match index.slots.get(pointer) {
Some(slot) => slot
// Unreachable: every object/boolean position receives a slot.
None => {
invalid_keyword(path, "reference does not resolve to a schema")
0
}
}
}
///|
/// RFC 3986 percent-decoding over UTF-8 bytes. Decoding happens before JSON
/// Pointer unescaping (URI layer first). Malformed escapes and non-UTF-8
/// byte sequences fail compilation instead of silently mangling a pointer.
fn percent_decode(text : String, path : String) -> String raise CompileError {
let raw = @utf8.encode(text)
let bytes : Array[Byte] = []
let length = raw.length()
let mut cursor = 0
while cursor < length {
if raw[cursor] is b'%' {
if cursor + 2 >= length {
invalid_keyword(path, "truncated percent-escape in reference")
return ""
}
let high = hex_digit(raw[cursor + 1])
let low = hex_digit(raw[cursor + 2])
if high is None || low is None {
invalid_keyword(path, "invalid percent-escape in reference")
return ""
}
bytes.push((high.unwrap() * 16 + low.unwrap()).to_byte())
cursor += 3
} else {
bytes.push(raw[cursor])
cursor += 1
}
}
@utf8.decode(Bytes::from_array(bytes[:])) catch {
_ => {
invalid_keyword(path, "reference fragment is not valid UTF-8")
""
}
}
}
///|
fn hex_digit(unit : Byte) -> Int? {
let value = unit.to_int()
let digit = value - '0'.to_int()
if digit >= 0 && digit <= 9 {
Some(digit)
} else if value >= 'a'.to_int() && value <= 'f'.to_int() {
Some(value - 'a'.to_int() + 10)
} else if value >= 'A'.to_int() && value <= 'F'.to_int() {
Some(value - 'A'.to_int() + 10)
} else {
None
}
}
///|
/// JSON Pointer unescaping: `~1` becomes `/` and `~0` becomes `~`. A `~`
/// followed by any other unit, or a trailing `~`, is a malformed pointer.
fn pointer_unescape(
segment : StringView,
path : String,
) -> String raise CompileError {
let builder = StringBuilder()
let mut escaped = false
for ch in segment {
if escaped {
match ch {
'0' => {
builder.write_char('~')
escaped = false
}
'1' => {
builder.write_char('/')
escaped = false
}
_ => {
invalid_keyword(path, "invalid JSON Pointer escape in reference")
return ""
}
}
} else if ch is '~' {
escaped = true
} else {
builder.write_char(ch)
}
}
if escaped {
invalid_keyword(path, "invalid JSON Pointer escape in reference")
return ""
}
builder.to_string()
}
///|
/// RFC 6901 array tokens: `"0"` or a canonical decimal without leading zeros.
/// `-` and non-canonical forms never match, so they resolve as dangling.
fn array_token(segment : String) -> Int? {
let length = segment.length()
if length <= 0 {
return None
}
let first = segment[0].to_int() - '0'.to_int()
if first < 0 || first > 9 {
return None
}
if length == 1 {
return Some(first)
}
if first == 0 {
return None
}
let mut value = first
for position in 1.. 9 {
return None
}
if value > 214748364 || (value == 214748364 && digit > 7) {
return None
}
value = value * 10 + digit
}
Some(value)
}
///|
/// Resolve a same-document reference to its arena slot. The part before `#`
/// must be empty (other resources stay unsupported); an empty fragment is the
/// root; a fragment starting with `/` is a JSON Pointer; anything else is an
/// anchor-style name, which requires the unsupported `$anchor` vocabulary.
fn resolve_ref(
index : RefIndex,
reference : String,
path : String,
limit : Int,
) -> Int raise CompileError {
let mut split = -1
let units = reference.length()
for position in 0.. 0 {
raise UnsupportedKeyword(path~, keyword="$ref")
}
let raw = if split >= 0 {
reference[split + 1:].to_owned()
} else {
reference
}
let decoded = percent_decode(raw, path)
if decoded is "" {
let slot = slot_at(index, "", path)
ensure_compiled(index, slot, limit)
return slot
}
if split < 0 {
// A nonempty reference without a fragment names another resource.
raise UnsupportedKeyword(path~, keyword="$ref")
}
if !(decoded.get_char(0) is Some('/')) {
// Plain-name fragments are anchors, which stay unsupported.
raise UnsupportedKeyword(path~, keyword="$anchor")
}
let mut current = index.root
let mut canonical = ""
for segment in decoded.split("/")[1:] {
let key = pointer_unescape(segment, path)
match current {
Object(fields) =>
match fields.get(key) {
Some(child) => {
current = child
canonical = pointer_step(canonical, key)
}
None => {
invalid_keyword(path, "reference does not resolve: missing member")
return 0
}
}
Array(values) =>
match array_token(key) {
Some(position) =>
if position < values.length() {
current = values[position]
canonical = pointer_step(canonical, key)
} else {
invalid_keyword(
path, "reference does not resolve: array index out of range",
)
return 0
}
None => {
invalid_keyword(
path, "reference does not resolve: invalid array index token",
)
return 0
}
}
_ => {
invalid_keyword(path, "reference does not resolve through a scalar")
return 0
}
}
}
guard current is (Object(_) | True | False) else {
invalid_keyword(path, "reference target is not a schema")
return 0
}
let slot = slot_at(index, canonical, path)
ensure_compiled(index, slot, limit)
slot
}