///|
/// Optional static checker for expressions and rule sets.
///
/// The checker mirrors runtime semantics exactly: it only reports what the
/// evaluator would reject. Fact paths absent from an optional caller-supplied
/// schema are "unknown" and defer to runtime, so a schema-less check still
/// catches structural errors (unknown functions, malformed rules) without
/// claiming full type inference.
priv struct CheckState {
schema : Map[String, String]
errors : Array[String]
}
///|
fn type_name(value : Value) -> String {
match value {
Null => "null"
Bool(_) => "boolean"
Number(_) => "number"
String(_) => "string"
Array(_) => "array"
Object(_) => "object"
}
}
///|
let known_types : Array[String] = [
"boolean", "number", "string", "null", "array", "object",
]
///|
fn CheckState::fail(self : CheckState, message : String) -> Unit {
self.errors.push(message)
}
///|
fn CheckState::require(
self : CheckState,
actual : String,
expected : String,
context : String,
) -> Bool {
if actual == "unknown" || actual == expected {
true
} else {
self.fail(context + " expects " + expected + " but inferred " + actual)
false
}
}
///|
fn CheckState::infer(self : CheckState, expr : Expression) -> String {
match expr {
Literal(value) => type_name(value)
Variable(path) =>
match self.schema.get(path) {
Some(kind) => kind
None => "unknown"
}
Unary(op, operand) => {
let inner = self.infer(operand)
match op {
"!" => {
ignore(self.require(inner, "boolean", "operator !"))
"boolean"
}
"-" => {
ignore(self.require(inner, "number", "unary -"))
"number"
}
_ => {
self.fail("unsupported unary operator: " + op)
"unknown"
}
}
}
Binary(op, left, right) => {
let lt = self.infer(left)
let rt = self.infer(right)
match op {
"&&" | "||" => {
ignore(self.require(lt, "boolean", "logical operand"))
ignore(self.require(rt, "boolean", "logical operand"))
"boolean"
}
"==" | "!=" => "boolean"
">" | "<" | ">=" | "<=" =>
if lt == "unknown" || rt == "unknown" {
"boolean"
} else if lt == rt && (lt == "number" || lt == "string") {
"boolean"
} else {
self.fail(
"comparison expects two numbers or two strings but inferred " +
lt +
" and " +
rt,
)
"unknown"
}
"in" => {
ignore(self.require(rt, "array", "right operand of in"))
"boolean"
}
"+" =>
if lt == "string" {
if self.require(rt, "string", "string concatenation") {
"string"
} else {
"unknown"
}
} else if lt == "number" {
if self.require(rt, "number", "arithmetic +") {
"number"
} else {
"unknown"
}
} else if lt == "unknown" {
"unknown"
} else {
self.fail("operator + expects number or string but inferred " + lt)
"unknown"
}
"-" | "*" | "/" => {
ignore(self.require(lt, "number", "arithmetic " + op))
ignore(self.require(rt, "number", "arithmetic " + op))
"number"
}
_ => {
self.fail("unknown operator: " + op)
"unknown"
}
}
}
Call(name, args) => {
let kinds = args.map(fn(arg) { self.infer(arg) })
match (name, args.length()) {
("exists", 1) =>
if args[0] is Variable(_) {
"boolean"
} else {
self.fail("exists requires a fact path argument")
"unknown"
}
("len", 1) => {
let kind = kinds[0]
if kind == "unknown" ||
kind == "string" ||
kind == "array" ||
kind == "object" {
"number"
} else {
self.fail(
"len expects string, array or object but inferred " + kind,
)
"unknown"
}
}
("contains", 2) => {
let kind = kinds[0]
if kind == "unknown" {
"boolean"
} else if kind == "string" {
if self.require(kinds[1], "string", "contains on a string") {
"boolean"
} else {
"unknown"
}
} else if kind == "array" {
"boolean"
} else {
self.fail("contains expects string or array but inferred " + kind)
"unknown"
}
}
("lower", 1) => {
ignore(self.require(kinds[0], "string", "lower"))
"string"
}
("upper", 1) => {
ignore(self.require(kinds[0], "string", "upper"))
"string"
}
("trim", 1) => {
ignore(self.require(kinds[0], "string", "trim"))
"string"
}
("starts_with", 2) => {
ignore(self.require(kinds[0], "string", "starts_with"))
ignore(self.require(kinds[1], "string", "starts_with"))
"boolean"
}
("ends_with", 2) => {
ignore(self.require(kinds[0], "string", "ends_with"))
ignore(self.require(kinds[1], "string", "ends_with"))
"boolean"
}
("replace", 3) => {
ignore(self.require(kinds[0], "string", "replace"))
ignore(self.require(kinds[1], "string", "replace"))
ignore(self.require(kinds[2], "string", "replace"))
"string"
}
("substring", 3) => {
ignore(self.require(kinds[0], "string", "substring"))
ignore(self.require(kinds[1], "number", "substring"))
ignore(self.require(kinds[2], "number", "substring"))
"string"
}
("abs", 1) => {
ignore(self.require(kinds[0], "number", "abs"))
"number"
}
("floor", 1) => {
ignore(self.require(kinds[0], "number", "floor"))
"number"
}
("ceil", 1) => {
ignore(self.require(kinds[0], "number", "ceil"))
"number"
}
("round", 1) => {
ignore(self.require(kinds[0], "number", "round"))
"number"
}
("sum", 1) => {
ignore(self.require(kinds[0], "array", "sum"))
"number"
}
("concat", 1) => {
ignore(self.require(kinds[0], "array", "concat"))
"string"
}
("count_matches", 3) => {
ignore(self.require(kinds[0], "array", "count_matches"))
ignore(self.require(kinds[1], "string", "count_matches"))
ignore(self.require(kinds[2], "number", "count_matches"))
"number"
}
("any_match", 1) | ("all_match", 1) => {
ignore(self.require(kinds[0], "array", name))
"boolean"
}
("min", 2) | ("max", 2) => {
ignore(self.require(kinds[0], "number", name))
ignore(self.require(kinds[1], "number", name))
"number"
}
(_, _) => {
self.fail("unknown function or wrong arity: " + name)
"unknown"
}
}
}
}
}
///|
fn compile_checked(
state : CheckState,
source : String,
role : String,
) -> Expression? {
let parsed : Expression? = Some(compile(source)) catch {
error => {
state.fail(role + " does not compile: " + error.to_string())
None
}
}
parsed
}
///|
fn check_request(request : Value) -> Value raise {
let schema : Map[String, String] = Map([])
match get(request, "schema") {
Null => ()
Object(fields) =>
for key, value in fields {
let kind = str(value)
if !known_types.contains(kind) {
raise input_error(
"E3008",
"unknown schema type for " + key + ": " + kind,
)
}
schema[key] = kind
}
_ => raise input_error("E3009", "schema must be an object")
}
match get(request, "expression") {
Null => ()
source => {
let state : CheckState = { schema, errors: [], }
let inferred = match compile_checked(state, str(source), "expression") {
Some(expr) => state.infer(expr)
None => "unknown"
}
return record([
("ok", Bool(state.errors.is_empty())),
("type", String(inferred)),
("errors", strings(state.errors)),
])
}
}
let report : Array[Value] = []
let ids : Map[String, Bool] = Map([])
let mut ok = true
for item in arr(get(request, "rules")) {
let errors : Array[String] = []
let state : CheckState = { schema, errors, }
let id = match get(item, "id") {
String(name) => name
_ => {
state.fail("missing or invalid rule ID")
""
}
}
if id != "" {
if ids.contains(id) {
state.fail("duplicate rule ID: " + id)
}
ids[id] = true
}
match get(item, "priority") {
Null => ()
Number(n) =>
if n.is_nan() || n.is_inf() {
state.fail("priority must be a finite number")
}
_ => state.fail("priority must be a number")
}
let mut condition_type = "unknown"
match get(item, "when") {
Null => state.fail("missing condition")
String(source) =>
match compile_checked(state, source, "condition") {
Some(expr) => {
condition_type = state.infer(expr)
if condition_type != "boolean" && condition_type != "unknown" {
state.fail(
"condition must be boolean but inferred " + condition_type,
)
}
}
None => ()
}
_ => state.fail("condition must be a string")
}
match get(item, "compute") {
Null =>
if get(item, "output") is Null {
state.fail("rule needs an output value or a compute expression")
}
String(source) => ignore(compile_checked(state, source, "compute"))
_ => state.fail("compute must be a string")
}
if errors.is_empty() {
ok = ok && true
} else {
ok = false
}
report.push(
record([
("id", String(id)),
("valid", Bool(errors.is_empty())),
("condition_type", String(condition_type)),
("errors", strings(errors)),
]),
)
}
record([("ok", Bool(ok)), ("rules", Array(report))])
}