// SPDX-License-Identifier: Apache-2.0
// mbel playground — structured error envelope (proposal 0003, P3).
//
// Every JSON export reports a failure as
//
// {"$error": "", "$code": "", "$pos": {"line": L, "col": C}}
//
// The message text is byte-identical to the pre-0.5.0 envelope, so hosts
// that match on prose keep working; `$code` is the stable thing to branch
// on. `$pos` is present only where the source text carries a position
// (parse errors and Compile-mode checker errors) — the engine-level
// "unknown name x" walks the lowered legacy AST and has none.
//
// Codes are assigned from the contract-locked message shapes (AGENTS.md
// fixes the wording) plus the boundary stage that produced the error.
// Renaming a code or changing a message without updating this table is a
// bug: tools/check_abi.mjs asserts one case per family.
///|
/// The full envelope for an engine-produced message.
fn error_out(msg : String) -> String {
code_out(msg, code_of(msg))
}
///|
/// The envelope for a boundary stage that already knows its code
/// (host input, functions file, session handle, limits).
fn code_out(msg : String, code : String) -> String {
code_out_at(msg, code, pos_of(msg))
}
///|
/// Envelope builder; `pos` is omitted from the JSON when None.
fn code_out_at(msg : String, code : String, pos : (Int32, Int32)?) -> String {
let out = StringBuilder::new()
out.write_string("{\"$error\":")
out.write_string(canon_str(msg))
out.write_string(",\"$code\":")
out.write_string(canon_str(code))
match pos {
Some((line, col)) => {
out.write_string(",\"$pos\":{\"line\":")
out.write_string(line.to_string())
out.write_string(",\"col\":")
out.write_string(col.to_string())
out.write_string("}")
}
None => ()
}
out.write_string("}")
out.to_string()
}
///|
/// Maps an engine message to its stable code. Order matters: the parse
/// family is tested before the generic budget shapes because a parse
/// budget failure is reported *inside* a "parse error at L:C:" prefix.
fn code_of(msg : String) -> String {
if msg.contains("parse error at ") {
if msg.contains("too large") {
"PARSE_TOO_LARGE"
} else if msg.contains("too deeply nested") {
"PARSE_TOO_DEEP"
} else {
"PARSE_SYNTAX"
}
} else if msg == "Cannot evaluate an empty expression" {
"PARSE_EMPTY"
} else if msg.contains("expression is too large") {
"PARSE_TOO_LARGE"
} else if msg.contains("expression is too deeply nested") {
"PARSE_TOO_DEEP"
} else if msg.contains("memory budget exceeded") ||
msg.contains("expression is too deep") ||
msg.contains("max depth reached") ||
msg.contains("value nesting too deep") {
"RUNTIME_BUDGET"
} else if msg.contains("function call depth exceeded") {
"RUNTIME_RECURSION"
} else if msg.contains("unknown name ") {
"CHECK_UNKNOWN_NAME"
} else if msg.contains("unknown function ") {
"CHECK_UNKNOWN_FUNCTION"
} else if msg.contains("invalid number of arguments") {
"CHECK_ARITY"
} else if msg.contains("Jexl Function ") && msg.contains("is not defined") {
"RUNTIME_UNKNOWN_CALL"
} else if msg.contains("(mismatched type") {
// Compile mode spells the operand types: "(mismatched types int and
// string)"; Eval mode spells the operation: "int + string".
"CHECK_TYPE"
} else if msg.contains("invalid operation") {
"RUNTIME_TYPE"
} else if msg.has_prefix("function \"") {
// A body failure that matched no more specific shape.
"CHECK_FUNC_BODY"
} else {
"INTERNAL"
}
}
///|
/// Functions-file validation errors carry two distinct remedies: a
/// malformed document is `FUNCS_JSON`, an unsupported `version` is
/// `FUNCS_VERSION`. Semantic registration failures (bad name, duplicate,
/// invalid parameter) are `FUNCS_INVALID` and are mapped by the caller.
fn functions_file_code(msg : String) -> String {
if msg.contains("unsupported version") || msg.contains("missing \"version\"") {
"FUNCS_VERSION"
} else {
"FUNCS_JSON"
}
}
///|
/// Registration failures: keep a specific checker code when the message
/// carries one, otherwise the definition itself is invalid.
fn registration_code(msg : String) -> String {
let code = code_of(msg)
if code == "INTERNAL" {
"FUNCS_INVALID"
} else {
code
}
}
///|
/// Extracts a source position from a message, when one is embedded.
/// Two shapes exist: "parse error at L:C: ..." (parse stage) and expr's
/// FileError layout "msg (L:C)\n | source\n | ..^" (Compile mode).
fn pos_of(msg : String) -> (Int32, Int32)? {
let chars = msg.to_array()
let parse_prefix = "parse error at "
if msg.has_prefix(parse_prefix) {
return pair_at(chars, parse_prefix.length())
}
let mut i = 0
while i < chars.length() {
if chars[i] == '(' {
match pair_at(chars, i + 1) {
Some(p) => return Some(p)
None => ()
}
}
i = i + 1
}
None
}
///|
/// Reads `L:C` at `start`, requiring a `)` or `:` right after the column
/// so unrelated parenthesised numbers are not mistaken for positions.
fn pair_at(chars : Array[Char], start : Int32) -> (Int32, Int32)? {
match read_digits(chars, start) {
None => None
Some((line, after_line)) =>
if after_line < chars.length() && chars[after_line] == ':' {
match read_digits(chars, after_line + 1) {
None => None
Some((col, after_col)) =>
if after_col < chars.length() &&
(chars[after_col] == ')' || chars[after_col] == ':') {
Some((line, col))
} else {
None
}
}
} else {
None
}
}
}
///|
/// Reads a run of ASCII digits; returns the value and the index after it.
fn read_digits(chars : Array[Char], start : Int32) -> (Int32, Int32)? {
if start < 0 || start >= chars.length() {
return None
}
let mut i = start
let mut value = 0
while i < chars.length() && chars[i] >= '0' && chars[i] <= '9' {
value = value * 10 + (chars[i].to_int() - 48)
i = i + 1
}
if i == start {
None
} else {
Some((value, i))
}
}