maplibre-expr
A pure-Rust parser and evaluator for MapLibre GL style expressions that aims to behave exactly like the reference implementation โ not just the same results, but the same compile errors, in the same places.
- ๐ฏ Exhaustive compatibility. Passes the entire upstream conformance
suite โ 563/563 fixtures, zero skipped. Every operator, legacy
stop-function, type coercion, and edge case behaves like
maplibre-gl-js. - ๐งญ Byte-exact errors. Compile- and eval-error messages match MapLibre's
wording character-for-character, and each compile error carries the same
location
key(e.g."[4][0]"). The test harness enforces this, so the parity can't silently regress. - ๐ฆ Pure Rust, tiny surface. No rendering, no I/O, no C deps โ just
serde_jsonand a pure-Rust ICU for locale-aware collation. Works anywhere Rust does. - ๐งฑ Real pipeline.
parseโ optional statictypecheck(the same type-inference/coercion pass MapLibre runs) โevaluateagainst a zoom + feature context. Full coverage:match/step/interpolate,format,collator,within/distancegeometry,number-format, images, and more. - ๐ Extensible. Plug in your own operators as macros, recursive functions, or native Rust closures โ without forking the language.
It turns a MapLibre expression (JSON such as ["*", ["get", "x"], 2]) into a
typed tree with parse, optionally validates it with typecheck, then
evaluates that tree against an EvaluationContext (zoom + feature) with
evaluate.
use ;
use BTreeMap;
let expr = parse.unwrap;
let mut props = new;
props.insert;
let ctx = new.with_feature;
assert_eq!;
Type checking
typecheck(&expr, expected) runs a static pass that mirrors the compile-time
validation MapLibre performs while parsing: it infers each node's result type,
checks operator argument types, and reconciles against an optional expected
type (assert/coerce/subtype). It rejects, for example, comparisons between
incompatible types, malformed match branches, non-interpolatable
interpolate outputs, bad array item-type/length arguments, and misuse of
zoom outside a single top-level curve.
Errors are semantic and match MapLibre's wording. ParseError/EvalError
carry a kind ([ParseErrorKind]/[EvalErrorKind]) you can match on โ
UnknownExpression, WrongArgCount, TypeMismatch, NotComparable,
CannotCompare, NotInterpolatable, UnboundVariable, Zoom, โฆ โ with a
Display "printer" rendering the message. ParseError also carries a key, the
location path of the offending sub-expression (e.g. "[2]" or "[4][0]"),
collected as the error bubbles up. Both the message text and the location key
match the reference implementation byte-for-byte across the conformance
suite, and the harness enforces this (see Conformance testing).
Every intrinsic error has a dedicated variant (CouldNotParse,
ArrayIndexOutOfBounds, InvalidRgba, BranchLabels*, ExpectedEvenArgs,
InterpolationTypeArray, โฆ). The Other(String) kind is reserved for
message-only cases with no fixed category: the user-thrown ["error", msg]
operator, and runtime errors surfaced by compile-time constant folding.
Extensions: macros and functions
Beyond the standard operators, you can plug your own operators in through
Options, passed to parse_with / evaluate_with:
- A macro expands at parse time into a
letbinding its parameters to the call arguments โ zero runtime cost, but it cannot recurse (a recursion-depth limit rejects cyclic macros). - A function stays a call in the tree and runs at evaluation time, so it may recurse; a call-depth limit turns runaway recursion into an error instead of a stack overflow.
- A native function ([
Options::native]) is a Rust closure invoked with the evaluated arguments (and the context), so results can be computed dynamically.OptionsisSend + Sync, so the registry can be shared across threads (native closures must beSend + Sync).
use ;
use json;
let mut opts = new;
opts.macro_def;
opts.function;
let expr = parse_with.unwrap;
let out = evaluate_with.unwrap;
assert_eq!; // sum(6)
A native function is just a Rust fn/closure โ it receives the already
evaluated arguments plus the context, so it can compute anything:
use ;
use json;
let mut opts = new;
opts.native;
let expr = parse_with.unwrap;
let out = evaluate_with.unwrap;
assert_eq!;
These are parser/runtime options, not a new dialect โ a tree without any custom operators parses and evaluates identically with or without them.
Legacy function objects
Before expressions existed, styling was driven by function objects โ
{"type": "exponential", "property": "x", "stops": [...]} and friends.
parse accepts these transparently: hand it either a modern expression or a
legacy function object and it converts the latter to the equivalent modern
expression (interpolate / step / match / case / โฆ) before parsing โ a
port of maplibre-style-spec's convert.ts, so the result matches the reference.
use ;
use json;
// A zoom function โ ["interpolate", ["exponential", 2], ["zoom"], 0, 0, 10, 100].
let expr = parse.unwrap;
let out = evaluate.unwrap;
Conversion is on by default; turn it off with
Options::convert_legacy(false) to reject bare objects instead.
The convert module is also public: call convert::convert_function(params, spec) directly when you have the property's style spec, which unlocks the
spec-dependent cases ({token} expansion, enum/array/color identity
functions, and the exponential-vs-interval default). The transparent path
has no spec, so it relies on the object's own fields.
Implementation notes
distanceuses a brute-force pairwise scan rather than MapLibre's bounding-volume hierarchy. The minimum distance is independent of traversal order, so the result is identical; the trade-off is scalability โ this isO(nยทm)in the vertex counts, where MapLibre's BVH prunes distant pairs. For tile-sized geometry the difference is negligible, and the code is far simpler. (If you need large-geometry performance, this is the place to add a spatial index.)- Feature coordinates are round-tripped through tile coordinates before
distance/within, matching MapLibre's quantization so results agree. collatoruses CLDR collation viaicu_collator(pure Rust), so locale-aware ordering works for any locale โ Intl'ssensitivitymaps to an ICU strength plus case level.
Conformance testing
The crate is validated against a vendored snapshot of the upstream
maplibre-style-spec expression fixtures (tests/fixtures/expression,
see tests/fixtures/ATTRIBUTION.md). The harness in tests/spec.rs turns
each fixture directory into one libtest case (via libtest-mimic), so a
run reads like:
cargo test --test spec
# test result: ok. 563 passed; 0 failed; 0 ignored; ...
For every fixture it compiles the expression (parse + typecheck, with the
expected type taken from the fixture's propertySpec; legacy stop-function
objects are converted first), checking success vs. compile error, then evaluates
it against each input and compares to the expected output, matching
{ "error": ... } outputs against evaluation errors. Numbers are compared with
the same 6-significant-figure stripPrecision rule the upstream suite uses;
colors are compared premultiplied, matching MapLibre's internal Color.
For error fixtures it also asserts error parity: our ParseError/EvalError
message text and (for compile errors) the location key must match the
fixture's expected.compiled.errors[0] / outputs[i].error exactly. Running
the harness with PARITY=1 prints a coverage report of message/key agreement
instead of the pass/fail run.
Scope note: the harness verifies compiled.result (success/error), the
per-input outputs, and error message/key parity. It does not assert the
other static-analysis fields (type, isFeatureConstant, isZoomConstant).
Refresh the vendored snapshot with tests/refresh_fixtures.sh [git-ref].
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. Vendored test fixtures under tests/fixtures/expression are
from maplibre/maplibre-style-spec (BSD-3-Clause); see their ATTRIBUTION.md.