// mbel expression runner — evaluates Jexl expressions end to end.
//
// Usage:
// moon run cmd/main -- ""
// moon run cmd/main -- "" ''
// moon run cmd/main -- "$(cat tools/corpus.txt)" # one expr per line
//
// Prints one canonical line per expression (see tools/README.md), or
// {"$error":true} when evaluation fails.
///|
fn main {
let args = @env.args()
if args.length() < 2 {
println("usage: moon run cmd/main -- \"\" ['']")
return
}
let corpus = args[1]
// Optional JSON context (argv[2]); expressions without a context
// arg keep evaluating against an empty object.
let context = if args.length() > 2 && args[2] != "" {
parse_context(args[2])
} else {
@ast.ObjectVal([])
}
let inst = @engine.new()
// argv[3] == "vm" selects the bytecode engine (argv[2] stays the
// optional JSON context).
if args.length() > 3 && args[3] == "vm" {
@engine.Engine::set_engine(inst, Vm)
}
register_demo_transforms(inst)
for line in corpus.split("\n") {
if line == "" {
continue
}
// A line may carry its own context: expr{json}. The context
// column must start with '{' so expressions containing literal
// tabs (whitespace tests) are left intact.
let (expr, line_ctx) = split_line_context(line.to_owned())
let ctx = match line_ctx {
Some(json) => parse_context(json)
None => context
}
let out = canon(@engine.Engine::eval(inst, expr, ctx)) catch {
@engine.EngineErr(_) => "{\"$error\":true}"
}
println(out)
}
}
///|
/// Splits a corpus line into (expression, optional context JSON):
/// the context is the text after the first tab when it starts with '{'.
fn split_line_context(line : String) -> (String, String?) {
let chars = line.to_array()
for i in 0.. @ast.Value {
let json_val = @json.parse(s) catch {
_ => abort("context is not valid JSON: " + s)
}
json_to_value(json_val)
}
///|
/// Converts a parsed Json value into an engine Value. JSON null maps
/// to NullVal, numbers to NumVal (as parsed doubles), objects to
/// ObjectVal in insertion order.
fn json_to_value(j : Json) -> @ast.Value {
match j {
Null => @ast.NullVal
True => @ast.BoolVal(true)
False => @ast.BoolVal(false)
// Integer-exact input (0002 P2a, Decision 4 = (a)): an integer-valued
// JSON number within ±(2^53 - 1) maps to IntVal. `5.0` is indistinguishable
// from `5` at the Json layer (core keeps the raw repr only for
// non-round-trippable numbers) and becomes IntVal too; NaN/±Inf stay
// NumVal. The bound is 2^53 - 1, not 2^53: an integer literal at or
// beyond 2^53 arrives as Number(double, repr~Some(_)) where the double
// already aliases neighbors (2^53 + 1 parses to the same double as 2^53),
// so mapping it to IntVal would claim exactness it does not have — such
// numbers stay NumVal (documented out of P2a scope; exact repr-based
// int64 mapping is future work). Keep in sync with playground/web/web.mbt
// and builtin/helpers.mbt.
Number(d, ..) =>
if d.floor() == d && Double::abs(d) <= 9007199254740991.0 {
@ast.IntVal(d.to_int64())
} else {
@ast.NumVal(d)
}
String(s) => @ast.StrVal(s)
Array(xs) => @ast.ArrayVal(xs.map(json_to_value))
Object(m) => {
let pairs = m
.to_array()
.map(fn(kv) {
let (k, v) = kv
(k, json_to_value(v))
})
@ast.ObjectVal(pairs)
}
}
}
///|
/// Demo transforms shared with the differential harness (see
/// tools/jexl_driver.js) so corpus and interactive expressions behave
/// identically on both sides.
fn register_demo_transforms(inst : @engine.Engine) -> Unit {
@engine.Engine::add_transform(inst, "dbl", fn(
args : Array[@ast.Value],
) -> @ast.Value {
@ast.NumVal(@evaluator.to_number(args[0]) * 2.0)
})
@engine.Engine::add_transform(inst, "first", fn(
args : Array[@ast.Value],
) -> @ast.Value {
match args[0] {
@ast.ArrayVal(xs) => if xs.length() > 0 { xs[0] } else { @ast.UndefVal }
_ => @ast.UndefVal
}
})
@engine.Engine::add_transform(inst, "concatWith", fn(
args : Array[@ast.Value],
) -> @ast.Value {
@evaluator.plus(args[0], args[1])
})
}
///|
/// Canonical serializer shared with tools/jexl_driver.js — identical
/// output on both sides is what makes the corpus diff meaningful.
fn canon(v : @ast.Value) -> String {
match v {
@ast.BoolVal(b) => if b { "true" } else { "false" }
@ast.NumVal(d) => canon_num(d)
@ast.IntVal(i) => i.to_string()
// Bytes serialize like Go's json.Marshal of []byte: base64
// (expr-only value; unreachable on the legacy CLI path).
@ast.BytesVal(b) => canon_str(@builtin.encode64_bytes(b))
@ast.StrVal(s) => canon_str(s)
@ast.NullVal => "null"
@ast.UndefVal => "{\"$undef\":true}"
@ast.ArrayVal(xs) => {
let parts = xs.map(canon)
"[" + parts.join(",") + "]"
}
@ast.ObjectVal(pairs) => {
let parts = pairs.map(fn(kv) {
let (k, val) = kv
canon_str(k) + ":" + canon(val)
})
"{" + parts.join(",") + "}"
}
}
}
///|
fn canon_num(d : Double) -> String {
if d != d {
return "{\"$nan\":true}"
}
if d == 1.0 / 0.0 {
return "{\"$inf\":true}"
}
if d == -1.0 / 0.0 {
return "{\"$ninf\":true}"
}
d.to_string()
}
///|
/// JSON-style string escaping, matching JS JSON.stringify.
fn canon_str(s : String) -> String {
let out = StringBuilder::new()
out.write_char('"')
for c in s {
let code = c.to_int()
match c {
'"' => out.write_string("\\\"")
'\\' => out.write_string("\\\\")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
'\u{8}' => out.write_string("\\b")
'\u{C}' => out.write_string("\\f")
_ =>
if code < 0x20 || code == 0x2028 || code == 0x2029 {
out.write_string("\\u" + hex4(code))
} else {
out.write_char(c)
}
}
}
out.write_char('"')
out.to_string()
}
///|
fn hex4(code : Int32) -> String {
let digits = "0123456789abcdef".to_array()
let out = StringBuilder::new()
for i in 0..<4 {
let shift = (3 - i) * 4
let nibble = (code >> shift) & 0xF
out.write_char(digits[nibble])
}
out.to_string()
}