// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Where something went wrong in a document that arrived as text.
///
/// The offset is in UTF-16 code units from the start, which is what a
/// [`StringView`] indexes by; line and column are one-based, because that is
/// how every editor counts and an error a person cannot find in their file is
/// half an error.
pub(all) struct At {
/// The offset in UTF-16 code units from the start of the document, which is
/// what a [`StringView`] indexes by.
at : Int32
/// The line the offset is on, counting from one.
line : Int32
/// The column the offset is at, counting from one.
column : Int32
} derive(Eq, Debug)
///|
pub extend At with Eq::{equal, not_equal}
///|
pub extend At with Debug::{to_repr}
///|
/// Why a document was refused, and where.
///
/// One error type for the whole family — reading text, reading bytes, walking
/// a pointer, applying a patch, writing a value a format cannot carry — so that
/// a caller has one thing to catch and one place to look. A variant that has a
/// position carries the [`At`] it was found at; a variant about a pointer or a
/// patch names the pointer itself, because a `move` and a `copy` each carry two
/// and an error that did not say which one would be half an error.
pub(all) suberror Refused {
// Reading text.
/// A character that cannot start or continue what is being read.
Unexpected(At, Char)
/// The document ends inside a value.
Truncated(At)
/// An escape sequence that is not one, or a lone half of a surrogate pair.
BadEscape(At)
/// A number the grammar does not admit — a leading zero, a bare minus, an
/// exponent with no digits.
BadNumber(At)
/// A second value after the first one ended.
Trailing(At)
/// Nesting past the depth limit. Without this, a document of ten thousand
/// open brackets would take the stack down with it.
TooDeep(At)
/// Bytes that are not UTF-8, when reading a document that arrived as bytes.
BadUtf8(At)
/// A member name that appears twice, when the dialect refuses that.
Repeated(At, String)
// Binary formats, where there is no text and so no line to name: `at` is the
// byte offset from the start of the document.
/// The document ended inside a value.
Short(at~ : Int32)
/// Something after the value ended. A document is one value, and a reader
/// that stops at the first turns a corrupt file into a plausible one.
Extra(at~ : Int32)
/// A byte that cannot be read this way: a reserved additional-information
/// value, a length or an index that does not fit, a string that is not UTF-8,
/// a nesting that went too deep.
Bad(at~ : Int32, why~ : String)
// Writing.
/// A value the format has no way to write: a number that is not finite into
/// JSON, a byte string into a text dialect, a key that is not a string into
/// one whose objects have only string keys.
Unwritable(why~ : String)
// Pointers (RFC 6901).
/// The pointer is not one: it does not start with a slash, or a `~` is
/// followed by anything but `0` or `1`. `at` is the offset within `path`.
Syntax(path~ : String, at~ : Int32)
/// What the pointer names is not there: a name no member has, or an index no
/// array reaches.
Missing(path~ : String, token~ : String)
/// The pointer walked into something that is neither an object nor an array.
NotContainer(path~ : String, token~ : String)
/// An array index that is not one — not decimal digits without a leading
/// zero — or `-` where it does not mean append.
BadIndex(path~ : String, token~ : String)
// Patches (RFC 6902).
/// The document is JSON but not a patch: not an array of operations.
Document(why~ : String)
/// The operation at `at` is not one: no `op` member, an `op` that is not one
/// of the six, or a member it requires missing or of the wrong type.
Operation(at~ : Int32, why~ : String)
/// A `test` that did not hold.
TestFailed(path~ : String)
/// A move into one of the moved value's own children (RFC 6902 §4.4).
IntoChild(from~ : String, path~ : String)
/// The operation names the root, which has no parent to take it from.
Root(path~ : String)
} derive(Eq, Debug)
///|
pub extend Refused with Eq::{equal, not_equal}
///|
pub extend Refused with Debug::{to_repr}
///|
/// Where the failure happened, when it happened in text.
///
/// `None` is not a failure to say: a pointer or a patch that was refused has a
/// path rather than a line, and a line number invented for it would be worse
/// than no line number at all.
pub fn Refused::at(self : Refused) -> At? {
match self {
Unexpected(spot, _) => Some(spot)
Truncated(spot)
| BadEscape(spot)
| BadNumber(spot)
| Trailing(spot)
| TooDeep(spot)
| BadUtf8(spot) => Some(spot)
Repeated(spot, _) => Some(spot)
Short(_)
| Extra(_)
| Bad(..)
| Unwritable(_)
| Syntax(..)
| Missing(..)
| NotContainer(..)
| BadIndex(..)
| Document(..)
| Operation(..)
| TestFailed(..)
| IntoChild(..)
| Root(..) => None
}
}
///|
/// The failure as one sentence a person can read: what went wrong, and where.
///
/// A text dialect names the line and column, counting from one, because that is
/// how an editor counts; a binary format names the byte offset, because that is
/// all a file without lines has to offer. The sentence is lower case and carries
/// no full stop, so a caller can put it after its own word — `"refused: " +
/// error.message()` — without the punctuation fighting.
pub fn Refused::message(self : Refused) -> String {
match self {
Unexpected(spot, c) => spot_text(spot) + "unexpected " + char_text(c)
Truncated(spot) => spot_text(spot) + "the document ends inside a value"
BadEscape(spot) => spot_text(spot) + "not an escape sequence"
BadNumber(spot) => spot_text(spot) + "not a number"
Trailing(spot) =>
spot_text(spot) +
"a second value follows the first, and a document is one value"
TooDeep(spot) => spot_text(spot) + "the nesting is too deep"
BadUtf8(spot) => spot_text(spot) + "these bytes are not UTF-8"
Repeated(spot, name) =>
spot_text(spot) + "the member " + quoted(name) + " appears twice"
Short(at~) =>
"byte " + at.to_string() + ": the document ends inside a value"
Extra(at~) => "byte " + at.to_string() + ": something follows the value"
Bad(at~, why~) => "byte " + at.to_string() + ": " + why
Unwritable(why~) => "this value cannot be written: " + why
Syntax(path~, at~) =>
"the pointer " +
quoted(path) +
" is not a pointer, at offset " +
at.to_string()
Missing(path~, token~) =>
"the pointer " + quoted(path) + " names no " + quoted(token)
NotContainer(path~, token~) =>
quoted(token) +
" is neither an object nor an array, on the way to " +
quoted(path)
BadIndex(path~, token~) =>
quoted(token) + " is not an index, on the way to " + quoted(path)
Document(why~) => "not a patch: " + why
Operation(at~, why~) => "operation " + at.to_string() + ": " + why
TestFailed(path~) => "the test at " + quoted(path) + " did not hold"
IntoChild(from~, path~) =>
"cannot move " + quoted(from) + " into its own child " + quoted(path)
Root(path~) => quoted(path) + " is the root, and has no parent"
}
}
///|
/// `line 3, column 8: ` — the prefix every textual message opens with.
fn spot_text(spot : At) -> String {
"line " + spot.line.to_string() + ", column " + spot.column.to_string() + ": "
}
///|
/// A character as it would be written in a document, so a message can say
/// `unexpected ']'` rather than `unexpected U+005D`.
fn char_text(c : Char) -> String {
let code = c.to_int()
if code == 0x27 {
"'\\''"
} else if code == 0x5C {
"'\\\\'"
} else if code >= 0x20 && code < 0x7F {
"'" + c.to_string() + "'"
} else if code == 0x0A {
"'\\n'"
} else if code == 0x0D {
"'\\r'"
} else if code == 0x09 {
"'\\t'"
} else {
let digits = "0123456789ABCDEF"
let mut text = "U+"
let mut shift = 12
while shift >= 0 {
text = text + digits[(code >> shift) & 0xF].unsafe_to_char().to_string()
shift -= 4
}
text
}
}
///|
/// A name or a path, in the quotes a document would give it, with the two
/// characters that would end those quotes escaped.
fn quoted(text : String) -> String {
let out = StringBuilder()
out.write_char('"')
let view = text[:]
for i = 0; i < view.length(); i = i + 1 {
let code = view.at(i).to_int()
if code == 0x22 || code == 0x5C {
out.write_char('\\')
}
out.write_char(code.unsafe_to_char())
}
out.write_char('"')
out.to_string()
}