use std::collections::BTreeMap;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use libtest_mimic::{Arguments, Failed, Trial};
use maplibre_expr::convert::convert_function;
use maplibre_expr::{evaluate, parse, typecheck, EvaluationContext, Feature, Type, Value};
use serde_json::Value as Json;
fn property_spec_type(spec: &Json) -> Option<Type> {
let scalar = |t: &str| match t {
"color" => Some(Type::Color),
"number" => Some(Type::Number),
"string" | "enum" => Some(Type::String),
"boolean" => Some(Type::Boolean),
"formatted" => Some(Type::Formatted),
"resolvedImage" => Some(Type::ResolvedImage),
"padding" => Some(Type::Padding),
"numberArray" => Some(Type::NumberArray),
"colorArray" => Some(Type::ColorArray),
"projectionDefinition" => Some(Type::ProjectionDefinition),
"variableAnchorOffsetCollection" => Some(Type::VariableAnchorOffsetCollection),
_ => None,
};
match spec.get("type").and_then(Json::as_str)? {
"array" => {
let item = spec
.get("value")
.and_then(Json::as_str)
.and_then(scalar)
.unwrap_or(Type::Value);
let n = spec
.get("length")
.and_then(Json::as_u64)
.map(|v| v as usize);
Some(Type::array(item, n))
}
other => scalar(other),
}
}
fn main() {
let args = Arguments::from_args();
let root = fixtures_root();
let known = load_known_failures();
let mut fixtures = Vec::new();
collect(&root, &root, &mut fixtures);
fixtures.sort();
if std::env::var_os("PARITY").is_some() {
run_parity(&fixtures);
return;
}
let trials = fixtures
.into_iter()
.map(|(name, path)| {
let ignored = known.contains(&name);
let mut trial = Trial::test(name, move || run_fixture(&path));
if ignored {
trial = trial.with_ignored_flag(true);
}
trial
})
.collect();
libtest_mimic::run(&args, trials).exit();
}
fn run_parity(fixtures: &[(String, PathBuf)]) {
let mut ce_total = 0usize; let mut ce_raised = 0usize; let mut ce_msg_exact = 0usize; let mut ce_key_present = 0usize; let mut ce_key_exact = 0usize; let mut msg_mismatches: Vec<(String, String, String)> = Vec::new();
let mut key_mismatches: Vec<(String, String, String)> = Vec::new();
let mut ee_total = 0usize;
let mut ee_raised = 0usize;
let mut ee_msg_exact = 0usize;
let mut ee_mismatches: Vec<(String, String, String)> = Vec::new();
for (name, path) in fixtures {
let Ok(raw) = fs::read_to_string(path) else {
continue;
};
let Ok(doc) = serde_json::from_str::<Json>(&raw) else {
continue;
};
let Some(expression) = doc.get("expression") else {
continue;
};
let expected = doc.get("expected").cloned().unwrap_or(Json::Null);
let compiled_result = expected
.get("compiled")
.and_then(|c| c.get("result"))
.and_then(Json::as_str)
.unwrap_or("success");
let converted;
let expression = if expression.is_object() {
match doc.get("propertySpec") {
Some(spec) => {
converted = convert_function(expression, spec);
&converted
}
None => expression,
}
} else {
expression
};
let expected_type = doc.get("propertySpec").and_then(property_spec_type);
let coerce_top_string = doc
.get("propertySpec")
.and_then(|s| s.get("type"))
.and_then(Json::as_str)
== Some("string");
let compiled = parse(expression)
.and_then(|e| typecheck(&e, expected_type.as_ref(), coerce_top_string));
if compiled_result == "error" {
ce_total += 1;
let want = expected
.get("compiled")
.and_then(|c| c.get("errors"))
.and_then(Json::as_array)
.and_then(|a| a.first());
let want_msg = want
.and_then(|e| e.get("error"))
.and_then(Json::as_str)
.unwrap_or("");
let want_key = want
.and_then(|e| e.get("key"))
.and_then(Json::as_str)
.unwrap_or("");
if let Err(e) = &compiled {
ce_raised += 1;
let got_msg = e.to_string();
if got_msg == want_msg {
ce_msg_exact += 1;
} else {
msg_mismatches.push((name.clone(), want_msg.to_string(), got_msg));
}
if !want_key.is_empty() {
ce_key_present += 1;
if e.key == want_key {
ce_key_exact += 1;
} else {
key_mismatches.push((name.clone(), want_key.to_string(), e.key.clone()));
}
}
}
continue;
}
let Ok(expr) = compiled else { continue };
let empty = Vec::new();
let inputs = doc.get("inputs").and_then(Json::as_array).unwrap_or(&empty);
let outputs = expected
.get("outputs")
.and_then(Json::as_array)
.cloned()
.unwrap_or_default();
let global_state: BTreeMap<String, Value> = doc
.get("globalState")
.and_then(Json::as_object)
.map(|o| {
o.iter()
.map(|(k, v)| (k.clone(), Value::from_json(v)))
.collect()
})
.unwrap_or_default();
for (i, input) in inputs.iter().enumerate() {
let Some(want_msg) = outputs
.get(i)
.and_then(|o| o.get("error"))
.and_then(Json::as_str)
else {
continue;
};
ee_total += 1;
let Ok(ctx) = build_context(input) else {
continue;
};
let ctx = ctx.with_global_state(global_state.clone());
if let Err(e) = evaluate(&expr, &ctx) {
ee_raised += 1;
let got = e.to_string();
if got == want_msg {
ee_msg_exact += 1;
} else {
ee_mismatches.push((name.clone(), want_msg.to_string(), got));
}
}
}
}
let pct = |n: usize, d: usize| {
if d == 0 {
100.0
} else {
100.0 * n as f64 / d as f64
}
};
println!("\n=== Error-parity assessment ===\n");
println!("Compile errors (Tier B = message, Tier C = key):");
println!(" fixtures expecting a compile error : {ce_total}");
println!(
" error raised by us : {ce_raised} ({:.1}%)",
pct(ce_raised, ce_total)
);
println!(
" message byte-identical : {ce_msg_exact} / {ce_total} ({:.1}%)",
pct(ce_msg_exact, ce_total)
);
println!(
" location key matches : {ce_key_exact} / {ce_key_present} non-empty keys ({:.1}%)",
pct(ce_key_exact, ce_key_present)
);
println!("\nEval errors:");
println!(" outputs expecting an error : {ee_total}");
println!(
" error raised by us : {ee_raised} ({:.1}%)",
pct(ee_raised, ee_total)
);
println!(
" message byte-identical : {ee_msg_exact} / {ee_total} ({:.1}%)",
pct(ee_msg_exact, ee_total)
);
let show = |title: &str, v: &[(String, String, String)], limit: usize| {
println!("\n--- {title} ({} total) ---", v.len());
for (name, want, got) in v.iter().take(limit) {
println!(" [{name}]\n want: {want}\n got : {got}");
}
if v.len() > limit {
println!(" ... and {} more", v.len() - limit);
}
};
show("compile message mismatches", &msg_mismatches, 60);
show("location key mismatches", &key_mismatches, 40);
show("eval message mismatches", &ee_mismatches, 40);
}
fn fixtures_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("expression")
}
fn load_known_failures() -> HashSet<String> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("known_failures.txt");
let Ok(contents) = fs::read_to_string(path) else {
return HashSet::new();
};
contents
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(String::from)
.collect()
}
fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(root, &path, out);
} else if path.file_name().and_then(|n| n.to_str()) == Some("test.json") {
let name = path
.parent()
.unwrap()
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
out.push((name, path));
}
}
}
fn run_fixture(path: &Path) -> Result<(), Failed> {
let raw = fs::read_to_string(path).map_err(|e| format!("cannot read fixture: {e}"))?;
let doc: Json = serde_json::from_str(&raw).map_err(|e| format!("invalid fixture json: {e}"))?;
let expression = doc
.get("expression")
.ok_or("fixture missing \"expression\"")?;
let expected = doc.get("expected").ok_or("fixture missing \"expected\"")?;
let compiled_result = expected
.get("compiled")
.and_then(|c| c.get("result"))
.and_then(Json::as_str)
.unwrap_or("success");
let converted;
let expression = if expression.is_object() {
match doc.get("propertySpec") {
Some(spec) => {
converted = convert_function(expression, spec);
&converted
}
None => expression,
}
} else {
expression
};
let expected_type = doc.get("propertySpec").and_then(property_spec_type);
let coerce_top_string = doc
.get("propertySpec")
.and_then(|s| s.get("type"))
.and_then(Json::as_str)
== Some("string");
let compiled = parse(expression)
.and_then(|expr| typecheck(&expr, expected_type.as_ref(), coerce_top_string));
if compiled_result == "error" {
return match compiled {
Err(e) => {
let want = expected
.get("compiled")
.and_then(|c| c.get("errors"))
.and_then(Json::as_array)
.and_then(|a| a.first());
if let Some(wmsg) = want.and_then(|w| w.get("error")).and_then(Json::as_str) {
if e.to_string() != wmsg {
return Err(format!(
"error message mismatch:\n want: {wmsg}\n got: {e}"
)
.into());
}
}
if let Some(wkey) = want.and_then(|w| w.get("key")).and_then(Json::as_str) {
if e.key != wkey {
return Err(
format!("error key mismatch: want {wkey:?}, got {:?}", e.key).into(),
);
}
}
Ok(())
}
Ok(_) => {
Err("expected a compile error, but the expression compiled successfully".into())
}
};
}
let expr = compiled.map_err(|e| format!("expected successful compile, but failed: {e}"))?;
let empty = Vec::new();
let inputs = doc.get("inputs").and_then(Json::as_array).unwrap_or(&empty);
let outputs = expected
.get("outputs")
.and_then(Json::as_array)
.cloned()
.unwrap_or_default();
let global_state: BTreeMap<String, Value> = doc
.get("globalState")
.and_then(Json::as_object)
.map(|o| {
o.iter()
.map(|(k, v)| (k.clone(), Value::from_json(v)))
.collect()
})
.unwrap_or_default();
for (i, input) in inputs.iter().enumerate() {
let ctx = build_context(input)?.with_global_state(global_state.clone());
let expected_output = outputs
.get(i)
.ok_or_else(|| format!("input #{i} has no expected output"))?;
match evaluate(&expr, &ctx) {
Ok(value) => {
if let Some(err_obj) = expected_output.get("error") {
return Err(format!(
"input #{i}: expected evaluation error ({err_obj}), got value {:?}",
value
)
.into());
}
let actual = value_to_json(&value);
if !json_close(&actual, expected_output) {
return Err(
format!("input #{i}: expected {expected_output}, got {actual}").into(),
);
}
}
Err(e) => match expected_output.get("error").and_then(Json::as_str) {
None => {
return Err(format!(
"input #{i}: expected {expected_output}, got evaluation error: {e}"
)
.into());
}
Some(want) if e.to_string() != want => {
return Err(format!(
"input #{i}: error message mismatch:\n want: {want}\n got: {e}"
)
.into());
}
Some(_) => {}
},
}
}
Ok(())
}
fn build_context(input: &Json) -> Result<EvaluationContext, Failed> {
let items = input
.as_array()
.ok_or("each input must be a [globals, feature] array")?;
let mut ctx = EvaluationContext::new();
if let Some(zoom) = items
.first()
.and_then(|g| g.get("zoom"))
.and_then(Json::as_f64)
{
ctx.zoom = Some(zoom);
}
if let Some(images) = items
.first()
.and_then(|g| g.get("availableImages"))
.and_then(Json::as_array)
{
ctx.available_images = images
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
if let Some(c) = items.first().and_then(|g| g.get("canonicalID")) {
let n = |k| c.get(k).and_then(Json::as_u64).map(|v| v as u32);
if let (Some(z), Some(x), Some(y)) = (n("z"), n("x"), n("y")) {
ctx.canonical = Some((z, x, y));
}
}
let global_num = |k| items.first().and_then(|g| g.get(k)).and_then(Json::as_f64);
ctx.heatmap_density = global_num("heatmapDensity");
ctx.elevation = global_num("elevation");
ctx.line_progress = global_num("lineProgress");
if let Some(feature_json) = items.get(1) {
ctx.feature = build_feature(feature_json);
}
Ok(ctx)
}
fn build_feature(json: &Json) -> Feature {
let mut feature = Feature::default();
if let Some(props) = json.get("properties").and_then(Json::as_object) {
feature.properties = props
.iter()
.map(|(k, v)| (k.clone(), Value::from_json(v)))
.collect::<BTreeMap<_, _>>();
}
if let Some(id) = json.get("id") {
if !id.is_null() {
feature.id = Some(Value::from_json(id));
}
}
if let Some(state) = json.get("featureState").and_then(Json::as_object) {
feature.state = state
.iter()
.map(|(k, v)| (k.clone(), Value::from_json(v)))
.collect::<BTreeMap<_, _>>();
}
feature.geometry_type = geometry_type(json);
if let Some(geom) = json.get("geometry") {
feature.geometry = extract_geometry(geom);
}
feature
}
fn geometry_type(json: &Json) -> Option<String> {
if let Some(t) = json
.get("geometry")
.and_then(|g| g.get("type"))
.and_then(Json::as_str)
{
return Some(
match t {
"Point" | "MultiPoint" => "Point",
"LineString" | "MultiLineString" => "LineString",
"Polygon" | "MultiPolygon" => "Polygon",
other => other,
}
.to_string(),
);
}
match json.get("type") {
Some(Json::String(s)) => Some(s.clone()),
Some(Json::Number(n)) => match n.as_u64() {
Some(1) => Some("Point".into()),
Some(2) => Some("LineString".into()),
Some(3) => Some("Polygon".into()),
_ => None,
},
_ => None,
}
}
fn extract_geometry(geom: &Json) -> Vec<Vec<(f64, f64)>> {
let pt = |c: &Json| -> Option<(f64, f64)> {
let a = c.as_array()?;
Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
};
let line = |c: &Json| -> Vec<(f64, f64)> {
c.as_array()
.map(|a| a.iter().filter_map(pt).collect())
.unwrap_or_default()
};
let coords = geom.get("coordinates");
match geom.get("type").and_then(Json::as_str) {
Some("Point") => coords
.and_then(pt)
.map(|p| vec![vec![p]])
.unwrap_or_default(),
Some("MultiPoint") => coords
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(pt).map(|p| vec![p]).collect())
.unwrap_or_default(),
Some("LineString") => coords.map(|c| vec![line(c)]).unwrap_or_default(),
Some("MultiLineString") | Some("Polygon") => coords
.and_then(Json::as_array)
.map(|a| a.iter().map(line).collect())
.unwrap_or_default(),
Some("MultiPolygon") => coords
.and_then(Json::as_array)
.map(|polys| {
polys
.iter()
.filter_map(Json::as_array)
.flatten()
.map(line)
.collect()
})
.unwrap_or_default(),
_ => Vec::new(),
}
}
fn value_to_json(value: &Value) -> Json {
match value {
Value::Null => Json::Null,
Value::Bool(b) => Json::Bool(*b),
Value::Number(n) => serde_json::json!(n),
Value::String(s) => Json::String(s.clone()),
Value::Color(c) => Json::Array(
c.to_rgba_unit()
.iter()
.map(|n| serde_json::json!(n))
.collect(),
),
Value::Array(a) => Json::Array(a.iter().map(value_to_json).collect()),
Value::Object(o) => Json::Object(
o.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect(),
),
Value::Image { name, available } => {
serde_json::json!({ "name": name, "available": available })
}
Value::NumberArray(v) => serde_json::json!({ "values": v }),
Value::Padding(v) => serde_json::json!({ "values": v }),
Value::ColorArray(v) => {
let colors: Vec<Json> = v
.iter()
.map(|c| serde_json::json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a}))
.collect();
serde_json::json!({ "values": colors })
}
Value::Projection(p) => match p {
maplibre_expr::Projection::Named(s) => Json::String(s.clone()),
maplibre_expr::Projection::Transition {
from,
to,
transition,
} => serde_json::json!({ "from": from, "to": to, "transition": transition }),
},
Value::Collator { .. } => Json::Null,
Value::Formatted(sections) => {
let secs: Vec<Json> = sections
.iter()
.map(|s| {
serde_json::json!({
"text": s.text,
"image": s.image.as_ref().map(|(n, a)| serde_json::json!({"name": n, "available": a})),
"scale": s.scale,
"fontStack": s.font_stack,
"textColor": s.text_color.map(|c| serde_json::json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a})),
"verticalAlign": s.vertical_align,
})
})
.collect();
serde_json::json!({ "sections": secs })
}
}
}
fn json_close(a: &Json, b: &Json) -> bool {
match (a, b) {
(Json::Number(x), Json::Number(y)) => {
let (x, y) = (
x.as_f64().unwrap_or(f64::NAN),
y.as_f64().unwrap_or(f64::NAN),
);
if x.is_nan() || y.is_nan() {
return x.is_nan() && y.is_nan();
}
let (sx, sy) = (strip_precision(x, 6), strip_precision(y, 6));
(sx - sy).abs() <= 1e-9 * sx.abs().max(1.0)
}
(Json::Array(x), Json::Array(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(a, b)| json_close(a, b))
}
(Json::Object(x), Json::Object(y)) => {
x.len() == y.len()
&& x.iter()
.all(|(k, v)| y.get(k).is_some_and(|w| json_close(v, w)))
}
_ => a == b,
}
}
fn strip_precision(x: f64, sig: i32) -> f64 {
if x == 0.0 {
return 0.0;
}
let multiplier = 10f64.powf((sig as f64 - x.abs().log10().ceil()).max(0.0));
let first = (x * multiplier).floor() / multiplier;
(first * multiplier).floor() / multiplier
}