packages feed

jsonlogic-hs (empty) → 0.1.0.0

raw patch · 100 files changed

+17231/−0 lines, 100 filesdep +QuickCheckdep +aesondep +base

Dependencies added: QuickCheck, aeson, base, bytestring, containers, data-fix, directory, hspec, jsonlogic-hs, process, scientific, tasty-bench, text, vector

Files

+ CHANGELOG.md view
@@ -0,0 +1,90 @@+# Changelog for jsonlogic-hs++## 0.1.0.0 -- unreleased++First release.++### Reading and evaluating rules++* Every builtin operator of the classic JsonLogic specification, with+  JavaScript's semantics as json-logic-js 2.0.5 implements them: coercion,+  `NaN`, the infinities, `undefined`, negative zero, UTF-16 strings, and+  JavaScript-identical number parsing and printing.+* `apply`, `applyWith` and `applyWithConfig`; `compile` to read, check and+  build a rule once, and `run` to evaluate it any number of times. Custom+  operators are resolved when the rule is compiled.+* Strict arity by default (a precise `ArityMismatch`); `arityMode = Lenient`+  behaves exactly like the reference, including evaluating surplus operands+  and throwing on an empty `*` only if it is evaluated.+* Configurable resource limits, on by default: rule depth, evaluation steps,+  string length and array length, so a rule from an untrusted source cannot+  exhaust time or memory (`docs/LIMITS.md`). Exceeding one is a typed+  `LimitExceeded` error.+* Every error carries the `Location` of the node that failed, a JSON Pointer+  (RFC 6901): `ParseFailure Location ParseError` and+  `EvalFailure Location EvalError`; `renderError` starts with it.+* Custom operators: `operatorsFromList` refuses a builtin name or a name given+  twice, `Operators` combine with `<>`, and an operator's result is held to+  the depth and step limits.+* `dataUse`: which paths of the data a rule can read, found without running+  it, scope-aware (an iteration body reads the element, not the data), with a+  tested guarantee.++### How it is tested++* All 278 cases of the classic shared suite; the runner fails if its fixture+  is missing, truncated or changed.+* 26,980 outlier cases with the answers json-logic-js 2.0.5 gave, replayed+  offline by `cabal test` in both arity modes, with a 140,000-case tier+  nightly (`differential/outliers.js`).+* The community suite of json-logic/compat-tables (1,138 cases), scored as the+  compatibility tables score implementations and pinned per file: 782 in+  lenient mode, the same as json-logic-js file for file, and 839 in strict+  mode. Every one of its rules is also an outlier case.+* A differential test against the reference with a fresh seed per CI run,+  failing-case shrinking and replay.+* Unit and property tests (278 examples): boundary triples for every limit,+  amplification rules, laws, error locations that always resolve to the+  operator at fault, every fast numeric algorithm against an exact one, and+  `dataUse`'s guarantee.+* Every example in `docs/TUTORIAL.md` is checked by a test suite.+* `scripts/check-harness.sh` feeds every runner missing, empty, truncated and+  tampered fixtures and demands a failure.+* CI: GHC 9.6 to 9.14 on Linux, plus macOS and Windows; `-Werror` on the+  primary compiler; hlint, fourmolu, weeder, shellcheck and actionlint;+  `cabal check`; the source distribution built, tested and documented on its+  own; a downstream project using only `JsonLogic`.++### Deliberate differences from the reference++Listed with reasons and tests in `docs/COMPATIBILITY.md`: strict arity in the+default mode, arrays and objects never equal under `==` / `===`, own+properties only (no prototype chain), data never run as rules by `missing`,+a slice through a surrogate pair gives U+FFFD, `log` does not print, exact+custom operator names, and the resource limits.++### Changes made after the external audit (2026-10-22)++These describe how the release candidate changed; nothing here was ever+released in another form.++* Fixed: lenient mode rejected an empty `*` while reading the rule, so+  `{"if": [true, 1, {"*": []}]}` failed where the reference gives `1`.+* Fixed: the licence text (placeholder copyright line, a typo in the warranty+  clause) and the README, which named the wrong licence. The licence is MIT.+* Fixed: `cabal check` failed on the benchmark's `-O2`; the weeder CI job never+  found its interface files, and is now blocking and clean.+* `compile` takes the custom operators and builds the evaluator once; `run`+  takes only the compiled rule.+* Errors carry locations (`docs/adr/0006`).+* One table of builtin operators (`JsonLogic.Core.Operator`) and one statement+  of each operator's operands (`shapeOf`), replacing four hand-kept lists.+* `recursion-schemes`, `deriving-compat` and Template Haskell are no longer+  dependencies; `vector` is (it was already a dependency of aeson).+* Faster: arrays are vectors (an index is constant time and, as documented,+  costs no extra steps), numbers are read with Clinger's fast path and written+  with Ryū, literal `var` paths are split once, and a step allocates once.+  `map` over 10,000 elements runs in about 2.5 ms instead of 3.3 ms, printing+  1,000 doubles in 0.3 ms instead of 3.1 ms.+* `var`'s strict arity is reported as `Between 0 2`, which is what it always+  accepted.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Sabrinathan Nair++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,243 @@+# jsonlogic-hs++A native Haskell implementation of [JsonLogic](https://jsonlogic.com), the+small rule language that is itself JSON, so rules can be stored, shared+between systems and evaluated against data without running arbitrary code.++A substantial amount of the code and documentation was written with AI+assistance.++It is held to the JavaScript reference implementation,+[json-logic-js](https://github.com/jwadhams/json-logic-js) 2.0.5. On our own+26,980-case catalogue of reference answers, the next closest implementation we+tested scores 84.8% (see [How it compares](#how-it-compares)).++Author: Sabrinathan Nair. Licence: MIT LICENSE.+++| | |+| --- | --- |+| Classic shared suite (jsonlogic.com/tests.json) | 278 / 278 |+| Outlier catalogue, answers recorded from json-logic-js | 26,980 / 26,980 (lenient mode) |+| Random differential test against json-logic-js | every CI run, fresh seed |+| Community suite (json-logic/compat-tables) | 782 / 1138 lenient, 839 / 1138 strict |+| Haddock coverage | 100% of every module |++The community suite (json-logic/compat-tables) is mostly written for the newer+JSON Logic 2.0 dialect, which this library does not implement. We run it for+reference, not as a target. In lenient mode the library scores exactly what+json-logic-js scores on it, file for file (782 / 1138), which is what you would+expect from a faithful copy of that reference. Libraries that implement the 2.0+dialect score much higher on it; see [How it compares](#how-it-compares).++## Quick start++```haskell+{-# LANGUAGE OverloadedStrings #-}++import Data.Aeson (decode)+import JsonLogic (apply)++main :: IO ()+main = print (apply <$> decode "{\"==\": [{\"var\": \"a\"}, 1]}" <*> decode "{\"a\": 1}")+-- Just (Right (Bool True))+```++`apply rule data` takes both as aeson `Value`s and returns+`Either Error Value`. [`docs/TUTORIAL.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/TUTORIAL.md) walks through+everything else: compiling once, configuring limits, custom operators,+reading errors, and finding out what a rule reads.++## The API in one screen++```haskell+apply           :: Value -> Value -> Either Error Value+applyWith       :: Operators -> Value -> Value -> Either Error Value+applyWithConfig :: Config -> Operators -> Value -> Value -> Either Error Value++compile :: Config -> Operators -> Value -> Either Error Rule   -- read, check, build once+run     :: Rule -> Value -> Either Error Value                 -- run many times+dataUse :: Rule -> DataUse                                     -- what it can read++operatorsFromList :: [(OperatorName, CustomOperator)] -> Either OperatorsError Operators+type CustomOperator = Value -> [Value] -> Either Text Value++data Error = ParseFailure Location ParseError | EvalFailure Location EvalError+renderLocation :: Location -> Text                             -- an RFC 6901 JSON Pointer+```++Everything is pure: no `IO`, no global state. The supported API is the+`JsonLogic` module; `JsonLogic.Core.*` is exposed for advanced use and may+change before 1.0 ([`docs/STABILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/STABILITY.md)).++## What you can rely on++- **The reference's answers.** Mixed-type behaviour is JavaScript's:+  `"1" == 1`, `[] == 0`, `"10" < "9"`, `NaN`, the infinities, negative zero,+  `undefined` (what an empty `and` yields, which is not `null`), UTF-16 string+  length, indexing and ordering, ECMAScript whitespace in numeric strings, and+  numbers printed digit for digit as JavaScript prints them. Every place it+  deliberately differs is listed with a reason and a test in+  [`docs/COMPATIBILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPATIBILITY.md).+- **Bounded work.** The default `Config` limits rule depth (64), evaluation+  steps (1,000,000), string length (1 MiB in UTF-16 code units) and array+  length (100,000), so a hostile rule cannot hang the process or exhaust its+  memory. Each limit is exact at both edges ([`docs/LIMITS.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/LIMITS.md)).+- **Errors you can act on.** A closed set of constructors to match on, each+  with the JSON Pointer of the node that failed:+  `renderError` gives `at /if/1: unknown operator "nope"`.+- **Strict or lenient arity.** By default a wrong argument count is a precise+  `ArityMismatch` (the reference silently pads and ignores). With+  `arityMode = Lenient` the library behaves exactly like the reference.+- **Custom operators** supplied by the caller, refused if they would shadow a+  builtin, resolved once when the rule is compiled, and held to the same+  limits.+- **Speed.** Interpretation is in the same range as json-logic-js running in+  V8 on the same machine (`map` over 10,000 elements: about 2.5 ms here,+  2.1 ms for the reference), with `compile` once and `run` many.++## How it is checked++Every claim above has a mechanical check behind it, and the checks are+checked: [`scripts/check-harness.sh`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/scripts/check-harness.sh) feeds every+runner missing, empty, truncated and tampered fixtures and demands that each+fails.++- **The classic suite**, pinned at exactly 278 cases.+- **The outlier catalogue** (`test/outliers.jsonl`): rules and data chosen to+  be awkward (type coercion, every whitespace code point, 700-digit numbers,+  shortest-digit ties, signed zero, surrogate pairs, odd arities of every+  operator, deep and wide rules, every community rule), with the answers+  json-logic-js gave. Generated deterministically by+  `differential/outliers.js`; CI fails if the committed file is stale. Every+  case runs in both arity modes. A 140,000-case tier runs nightly.+- **A differential test** that generates random rules, compares each answer+  with json-logic-js running in Node, and shrinks any disagreement to a+  minimal reproduction.+- **The community suite**, scored per file and pinned in+  `test/community/expected.json`, so the score only changes on purpose.+- **Unit and property tests**: boundary triples for every limit,+  amplification rules that must stop quickly, algebraic laws, error locations+  that always resolve to the operator at fault, the fast numeric algorithms+  against exact ones on hundreds of thousands of doubles, and `dataUse`'s+  guarantee.+- **Hygiene, blocking in CI**: `-Werror` on the primary GHC, a matrix of+  GHC 9.6 to 9.14 plus macOS and Windows, hlint, fourmolu, shellcheck, actionlint, `cabal check`, a build of the source+  distribution on its own, and a downstream project that uses only the public+  module. Dead code is checked with weeder, which is advisory for now.++Implementing JsonLogic in another language? The fixtures are plain JSON and+JSONL with a documented format; [`docs/CONFORMANCE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/CONFORMANCE.md)+explains how to run them against your implementation.++## How it compares++We ran seven other JsonLogic implementations against the same fixtures on+2026-10-23. The full report is in [`docs/COMPARISON.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPARISON.md).+The runners and raw results are in [`harness/`](https://github.com/Bombay-Boyz/jsonlogic-hs/tree/main/harness/).++**This is a fidelity test, not a neutral correctness benchmark.** The outlier+catalogue records what json-logic-js 2.0.5 answered, so json-logic-js scores+100% by design. The catalogue was generated by this project's author.+Implementations that follow the newer JSON Logic 2.0 dialect are scored+against behaviour they do not aim for.++### Results++| Implementation | Classic suite (278) | Outlier catalogue (26,980) | Community suite (1,138) |+| --- | --- | --- | --- |+| **jsonlogic-hs** (measured by this repo's own test runner, see note below) | 278 (100%) | 26,980 (100%, lenient mode) | 782 lenient / 839 strict |+| json-logic-js 2.0.5 (the reference) | 278 (100%) | 26,980 (100%) | 782 (68.7%) |+| jsonlogic 0.5.1 (Rust) | 270 (97.1%) | 22,881 (84.8%) | 761 (66.9%) |+| panzi-json-logic 1.0.1 (Python) | 277 (99.6%) | 21,361 (79.2%) | 790 (69.4%) |+| json-logic-engine 5.0.7 (JS, interpreted) | 278 (100%) | 16,475 (61.1%) | 1,119 (98.3%) |+| json-logic-engine 5.0.7 (JS, compiled) | 278 (100%) | 16,400 (60.8%) | 1,119 (98.3%) |+| json-logic-qubit 0.9.1 (Python) | 277 (99.6%) | 15,117 (56.0%) | 759 (66.7%) |+| datalogic-rs 5.4.0 (Rust, default features) | 278 (100%) | 14,989 (55.6%) | 1,060 (93.1%) |+| datalogic-rs 5.4.0 (Rust, all extensions) | 278 (100%) | 14,955 (55.4%) | 1,105 (97.1%) |+| jsonlogic-rs 0.5.0 (Rust) | 276 (99.3%) | 13,801 (51.2%) | 782 (68.7%) |++**Note on the jsonlogic-hs row.** Its figures come from this repository's own+test runners (`cabal test all`), which use the same fixtures and the judging+rules in [`docs/CONFORMANCE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/CONFORMANCE.md). The other rows were+produced by the comparison harness in [`harness/`](https://github.com/Bombay-Boyz/jsonlogic-hs/tree/main/harness/). jsonlogic-hs+itself has not been run through that harness, so its row did not go through+exactly the same judge as the others.++### What this shows++- On the outlier catalogue, the closest other implementation we tested scores+  84.8%.+- That implementation, the `jsonlogic` Rust crate, panics on 108 cases and+  cannot read 73 rules that contain numbers such as `1e309`.+- The next closest scores 79.2% (panzi-json-logic).+- On the community suite, json-logic-engine and datalogic-rs score much higher+  than we do (93-98%).+- They score higher because they implement the JSON Logic 2.0 dialect, and+  jsonlogic-hs does not.+- The two columns pull in opposite directions: following the 2.0 dialect+  lowers the outlier score.++### What this does not show++- jsonlogic-hs was not run in the comparison harness, because the machine had+  no suitable GHC. Its row uses the results of this repository's own test+  runners, not the harness judge the other rows went through.+- The older Haskell `jsonlogic` package (0.1.0.0) was not tested.+- Speed was not compared.+- datalogic-rs was tested at 5.4.0, not the latest release (5.9.0), because+  newer versions need a newer Rust than was available.+- Two published scores could not be reproduced exactly: the `jsonlogic` crate+  (761 here, 798 published) and datalogic-rs (1,105 here, 1,138 published).+- json-logic-engine has configurable limits on depth and size. We did not test+  whether they hold, so bounded evaluation is not unique to this library.++## What it is not++- **Not the JSON Logic 2.0 dialect** (`val`, `??`, `exists`, `try`/`throw`,+  `NaN` as an error). That dialect is what most of the community suite tests,+  so a low score there reflects a different target, not a bug. It is not+  implemented.+- **Not a sandbox for custom operators.** The library bounds its own work and+  a custom operator's result, not the operator's own computation.+- **Not a wall-clock timeout.** Limits bound work; add+  `System.Timeout.timeout` if seconds matter.++## Building and testing++```+cabal build+cabal test all            # unit/property, classic suite, outliers, community score+```++The repository's `cabal.project` turns on what released builds do not:+`-Werror`, the differential tester (needs Node.js; see+[`differential/README.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/differential/README.md)) and the benchmarks.++| Command | What it checks |+| --- | --- |+| `bash scripts/lint.sh` | hlint, fourmolu, fixture hashes, shellcheck |+| `bash scripts/weeder.sh` | dead code |+| `bash scripts/check-harness.sh` | the test runners fail when they should; the outlier fixture is current |+| `bash scripts/verify-sdist.sh` | the package as Hackage would receive it |+| `bash scripts/consumer-smoke.sh` | a separate project using only `JsonLogic` |+| `bash scripts/bench.sh` | benchmarks, with their environment ([`bench/README.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/bench/README.md)) |++## Further reading++- [`docs/TUTORIAL.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/TUTORIAL.md): using the library, step by step.+- [`docs/ARCHITECTURE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/ARCHITECTURE.md): how it is built, and why.+- [`docs/COMPATIBILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPATIBILITY.md): every difference from the reference.+- [`docs/COMPARISON.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPARISON.md): how other JsonLogic implementations score on these fixtures, and the limits of that comparison.+- [`docs/LIMITS.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/LIMITS.md): resource limits and what a step is.+- [`docs/CONFORMANCE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/CONFORMANCE.md): using these fixtures to test any implementation.+- [`docs/STABILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/STABILITY.md): what is covered by the versioning policy.+- [`docs/AUDIT-2026-10.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/AUDIT-2026-10.md): the external audit's findings and how each was resolved.+- [`docs/adr/`](https://github.com/Bombay-Boyz/jsonlogic-hs/tree/main/docs/adr/): recorded decisions and deviations from the engineering standard.+- [`VISION.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/VISION.md) and [`ENGINEERING_STANDARD.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/ENGINEERING_STANDARD.md): the design and the rules this codebase follows.++## Licence++MIT. See [`LICENSE`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/LICENSE). The vendored test suites keep their own+licences: `test/community/` is Apache-2.0 (its `LICENSE` is included);+`test/tests.json` is the shared classic suite from jsonlogic.com.
+ bench/Bench.hs view
@@ -0,0 +1,131 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Benchmarks (Standard 5.6): statistically sound timing and allocation of+-- the engine, by tasty-bench.+--+-- Four groups, so each cost is read on its own:+--+-- * @one-shot@: 'apply', which reads the rule and evaluates it, the cost of+--   a rule used once.+-- * @compile only@: reading a rule, with no evaluation.+-- * @precompiled run@: 'run' of a rule read beforehand, the cost of a rule+--   used many times. One-shot minus this is the reading cost.+-- * @limits@: a rule that stops at a resource limit.+--+-- Large inputs live in the /data/, built once, and every rule or datum+-- depends on the argument tasty-bench passes in, so GHC cannot lift a+-- benchmark's work out of the timing loop and run it only once.+--+-- > cabal run -O2 jsonlogic-hs-bench -- --csv results.csv+-- > cabal run -O2 jsonlogic-hs-bench -- --baseline old.csv --fail-if-slower 15+--+-- 'scripts/bench.sh' and 'scripts/bench-compare.sh' wrap both, and record+-- the environment next to the numbers. Timings are comparable only between+-- runs on one machine; see bench/README.md.+module Main (main) where++import Data.Aeson (Value (..), encode, object, toJSON, (.=))+import qualified Data.Aeson.Key as Key+import qualified Data.ByteString.Lazy as LBS+import Data.Either (fromRight)+import qualified Data.Text as Text+import Test.Tasty.Bench (Benchmark, bench, bgroup, defaultMain, whnf)++import JsonLogic++-- | The size of the encoded answer: forces the whole result, and gives the+-- benchmark an 'Int' to hold on to.+size :: Either Error Value -> Int+size = fromIntegral . LBS.length . encode . fromRight Null++num :: Int -> Value+num = toJSON++stringOf :: Int -> Value+stringOf = String . Text.pack . show++var :: Text.Text -> Value+var path = object ["var" .= path]++call :: Text.Text -> [Value] -> Value+call operator operands = object [Key.fromText operator .= operands]++-- | 10,000 numbers, the size the iteration benchmarks run over.+bigNumbers :: Value+bigNumbers = toJSON [1 .. 10000 :: Int]++-- | The data every workload is evaluated against. Parts a rule does not+-- touch are never converted, so a small rule pays nothing for the big array.+dataFor :: Int -> Value+dataFor n =+  object+    [ "x" .= n+    , "xs" .= bigNumbers+    , "a" .= object ["b" .= object ["c" .= object ["d" .= n]]]+    ]++-- | One custom operator, to price the extension point.+customOps :: Operators+customOps = fromRight noOperators (operatorsFromList [(OperatorName "double", doubling)])+  where+    doubling _ [Number k] = Right (Number (k * 2))+    doubling _ _ = Left "expected one number"++-- ---------------------------------------------------------------------+-- Workloads: each is a rule built from the benchmark's argument+-- ---------------------------------------------------------------------++workloads :: [(String, Int -> Value)]+workloads =+  [ ("arithmetic: x + n", \n -> call "+" [var "x", num n])+  , ("short-circuit: and, false first, 1,000 operands", \n -> call "and" (Bool False : num n : replicate 1000 (var "x")))+  , ("nested if, depth 60", \n -> foldr (\_ inner -> call "if" [Bool True, inner, num 0]) (num n) [1 .. 60 :: Int])+  , ("var path a.b.c.d", \_ -> var "a.b.c.d")+  , ("var 50 lookups to the end of a 10,000-element array", \n -> call "+" (num n : replicate 50 (var "xs.9999")))+  , ("map over 10,000 elements", \n -> call "map" [var "xs", call "*" [var "", num n]])+  , ("filter over 10,000 elements", \n -> call "filter" [var "xs", call ">" [var "", num (n + 5000)]])+  , ("reduce (sum) over 10,000 elements", \n -> call "reduce" [var "xs", call "+" [var "current", var "accumulator"], num n])+  , ("cat of 1,000 short strings", call "cat" . replicate 1000 . stringOf)+  , ("number printing via cat, 1,000 doubles", \n -> call "cat" (map (\i -> toJSON (fromIntegral (i + n) / 7 :: Double)) [1 .. 1000 :: Int]))+  , ("string to number, 200-digit literal", \n -> call "+" [String (Text.pack (show n) <> Text.replicate 200 "7")])+  , ("custom operator, composed twice", \n -> call "double" [call "double" [num n]])+  ]++-- ---------------------------------------------------------------------+-- The four groups+-- ---------------------------------------------------------------------++oneShot :: (String, Int -> Value) -> Benchmark+oneShot (name, rule) =+  bench name (whnf (\n -> size (applyWith customOps (rule n) (dataFor n))) 1)++verdict :: Either Error Rule -> Int+verdict = either (const 0) (const 1)++compileOnly :: (String, Int -> Value) -> Benchmark+compileOnly (name, rule) =+  bench name (whnf (verdict . compile defaultConfig customOps . rule) 1)++-- | The rule is read once, outside the timing loop; only 'run' is timed.+precompiledRun :: (String, Int -> Value) -> Benchmark+precompiledRun (name, rule) =+  bench name (whnf (\n -> maybe 0 (\r -> size (run r (dataFor n))) compiled) 1)+  where+    compiled = either (const Nothing) Just (compile defaultConfig customOps (rule 0))++-- | 110,000 operands against a limit of 100,000 steps: the cost of being+-- refused.+limitHit :: Benchmark+limitHit =+  bench "step limit reached, 110,000 operands" $+    whnf (\n -> size (applyWithConfig defaultConfig{maxSteps = 100000} customOps (call "+" (num n : replicate 110000 (num 1))) Null)) 1++main :: IO ()+main =+  defaultMain+    [ bgroup "one-shot" (map oneShot workloads)+    , bgroup "compile only" (map compileOnly workloads)+    , bgroup "precompiled run" (map precompiledRun workloads)+    , bgroup "limits" [limitHit]+    ]
+ community/Community.hs view
@@ -0,0 +1,210 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Scores jsonlogic-hs on the community test suite (@test/community/@, from+-- json-logic/compat-tables) and holds the score to @expected.json@.+--+-- Each case is scored the way the compatibility tables score an+-- implementation (compat-tables, @js-tests/engines/TestRunner.js@):+--+--   * an expected @null@ accepts any falsy result (@null@, @false@, @0@,+--     @""@, @[]@);+--   * numbers are equal within 1e-10;+--   * arrays and objects are compared element by element and key by key;+--   * an expected error passes only if evaluation fails with an error of+--     that type. Errors are named as the suite names them: an unknown+--     operator is @"Unknown Operator"@, a wrong argument count or an operand+--     the operator cannot use is @"Invalid Arguments"@. This library has no+--     error for @NaN@, which the suite's dialect raises and the classic one+--     does not.+--+-- Every case runs in both arity modes. The run fails if any file's case+-- count or pass counts differ from @test/community/expected.json@, in either+-- direction, so the score changes only in a commit that says so. It also+-- fails if a suite file is missing, has no cases, or is not listed in+-- @expected.json@, so it cannot pass by running nothing.+--+-- > cabal test jsonlogic-hs-community --test-show-details=direct+-- > cabal run jsonlogic-hs-community -- --write     # record a new score+module Main (main) where++import Control.Monad (forM, unless, when)+import Data.Aeson (Value (..), eitherDecodeFileStrict, encode, object, (.=))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Foldable (toList)+import Data.List (intercalate)+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Vector as Vector+import System.Directory (doesFileExist)+import System.Environment (getArgs)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)+import Text.Printf (printf)++import JsonLogic++suiteDirectory :: FilePath+suiteDirectory = "test/community/"++expectedPath :: FilePath+expectedPath = suiteDirectory ++ "expected.json"++-- | One case: rule, data, and what the suite expects.+data Case = Case Value Value Expectation++data Expectation = ExpectResult Value | ExpectError Text++-- | A file's case count, and how many pass in strict and lenient mode.+data Score = Score {scoreCases :: Int, scoreStrict :: Int, scoreLenient :: Int}+  deriving (Eq, Show)++main :: IO ()+main = do+  args <- getArgs+  files <- readJson (suiteDirectory ++ "index.json") >>= stringList "index.json"+  when (null files) $ abort "index.json lists no files"+  scores <- forM files $ \file -> do+    cases <- readJson (suiteDirectory ++ Text.unpack file) >>= suiteCases file+    when (null cases) $ abort (Text.unpack file ++ " has no cases")+    pure (file, score cases)+  report scores+  case args of+    ["--write"] -> do+      writeFile expectedPath (renderExpected scores)+      putStrLn ("wrote " ++ expectedPath)+    [] -> compareWithExpected scores+    _ -> abort "usage: jsonlogic-hs-community [--write]"++-- ---------------------------------------------------------------------+-- Reading the suite+-- ---------------------------------------------------------------------++readJson :: FilePath -> IO Value+readJson path = do+  exists <- doesFileExist path+  unless exists $ abort ("required fixture " ++ path ++ " not found (run from the package root)")+  decoded <- eitherDecodeFileStrict path+  either (\reason -> abort ("cannot read " ++ path ++ ": " ++ reason)) pure decoded++stringList :: String -> Value -> IO [Text]+stringList what (Array items) = forM (toList items) $ \case+  String s -> pure s+  _ -> abort (what ++ ": expected a list of file names")+stringList what _ = abort (what ++ ": expected a list of file names")++-- | The cases of a suite file. Strings are section comments; anything else+-- that is not a case object is an error.+suiteCases :: Text -> Value -> IO [Case]+suiteCases file (Array items) = concat <$> mapM entry (toList items)+  where+    entry (String _) = pure []+    entry (Object fields) = case (KeyMap.lookup "rule" fields, KeyMap.lookup "result" fields, KeyMap.lookup "error" fields) of+      (Just rule, Just result, Nothing) -> pure [Case rule (dataOf fields) (ExpectResult result)]+      (Just rule, Nothing, Just (Object e)) | Just (String kind) <- KeyMap.lookup "type" e -> pure [Case rule (dataOf fields) (ExpectError kind)]+      _ -> abort (Text.unpack file ++ ": a case needs a rule and exactly one of result and error")+    entry _ = abort (Text.unpack file ++ ": an entry is neither a case nor a comment")+    dataOf fields = fromMaybe Null (KeyMap.lookup "data" fields)+suiteCases file _ = abort (Text.unpack file ++ " is not a JSON array")++-- ---------------------------------------------------------------------+-- Scoring+-- ---------------------------------------------------------------------++score :: [Case] -> Score+score cases =+  Score+    (length cases)+    (length (filter (passes defaultConfig) cases))+    (length (filter (passes defaultConfig{arityMode = Lenient}) cases))++passes :: Config -> Case -> Bool+passes config (Case rule input expectation) =+  case (applyWithConfig config noOperators rule input, expectation) of+    (Right got, ExpectResult wanted) -> sameValue got wanted+    (Left failure, ExpectError kind) -> Text.toLower kind `Text.isInfixOf` Text.toLower (errorType failure)+    _ -> False++-- | The suite's name for a kind of failure.+errorType :: Error -> Text+errorType failure = case failure of+  ParseFailure _ (ArityMismatch{}) -> "Invalid Arguments"+  ParseFailure _ (DepthExceeded _) -> "Limit Exceeded"+  EvalFailure _ (UnknownOperator _) -> "Unknown Operator"+  EvalFailure _ (InvalidOperand _) -> "Invalid Arguments"+  EvalFailure _ (CustomOperatorFailed _ reason) -> reason+  EvalFailure _ (LimitExceeded _) -> "Limit Exceeded"++-- | compat-tables' @compareValues@.+sameValue :: Value -> Value -> Bool+sameValue got Null = falsy got+sameValue (Number a) (Number b) = abs (realToFrac a - realToFrac b :: Double) < 1e-10+sameValue (Array a) (Array b) = Vector.length a == Vector.length b && and (Vector.zipWith sameValue a b)+sameValue (Object a) (Object b) =+  KeyMap.size a == KeyMap.size b+    && and [maybe False (sameValue v) (KeyMap.lookup k b) | (k, v) <- KeyMap.toList a]+sameValue got wanted = got == wanted++falsy :: Value -> Bool+falsy Null = True+falsy (Bool False) = True+falsy (Number 0) = True+falsy (String "") = True+falsy (Array items) = Vector.null items+falsy _ = False++-- ---------------------------------------------------------------------+-- Reporting and pinning+-- ---------------------------------------------------------------------++report :: [(Text, Score)] -> IO ()+report scores = do+  printf "%-36s %6s %8s %8s\n" ("suite" :: String) ("cases" :: String) ("strict" :: String) ("lenient" :: String)+  mapM_ (\(file, Score n s l) -> printf "%-36s %6d %8d %8d\n" (Text.unpack file) n s l) scores+  let Score n s l = foldr (\(_, Score a b c) (Score x y z) -> Score (a + x) (b + y) (c + z)) (Score 0 0 0) scores+  printf "%-36s %6d %8d %8d\n" ("total" :: String) n s l++-- | @expected.json@: one line per file, in the suite's order, so a change+-- to the score is a readable one-line diff.+renderExpected :: [(Text, Score)] -> String+renderExpected scores = "{\n" ++ intercalate ",\n" (map line scores) ++ "\n}\n"+  where+    line (file, Score n s l) =+      "  " ++ Char8.unpack (encode (String file)) ++ ": " ++ Char8.unpack (encode (object ["cases" .= n, "strict" .= s, "lenient" .= l]))++compareWithExpected :: [(Text, Score)] -> IO ()+compareWithExpected scores = do+  expected <- readJson expectedPath+  pinned <- case expected of+    Object fields -> forM (KeyMap.toList fields) $ \(k, v) -> do+      pin <- parseScore (Key.toText k) v+      pure (Key.toText k, pin)+    _ -> abort (expectedPath ++ " is not an object")+  let differences =+        [ Text.unpack file ++ ": expected " ++ show want ++ ", got " ++ maybe "nothing (not in the suite)" show got+        | (file, want) <- pinned+        , let got = lookup file scores+        , got /= Just want+        ]+          ++ [ Text.unpack file ++ ": not in " ++ expectedPath+             | (file, _) <- scores+             , file `notElem` map fst pinned+             ]+  unless (null differences) $ do+    mapM_ (hPutStrLn stderr . ("community: " ++)) differences+    abort "the score differs from expected.json; if the change is intended, run with --write and commit the result"+  putStrLn "community: the score matches expected.json"+  where+    parseScore _ (Object o)+      | Just (Number n) <- KeyMap.lookup "cases" o+      , Just (Number s) <- KeyMap.lookup "strict" o+      , Just (Number l) <- KeyMap.lookup "lenient" o =+          pure (Score (round n) (round s) (round l))+    parseScore file _ = abort (expectedPath ++ ": bad entry for " ++ Text.unpack file)++abort :: String -> IO a+abort message = hPutStrLn stderr ("community: " ++ message) >> exitFailure
+ conformance/Conformance.hs view
@@ -0,0 +1,107 @@+-- |+-- Conformance runner for the classic JsonLogic test suite+-- (@test/tests.json@, from jsonlogic.com/tests.json).+--+-- The suite is a JSON array. Each case is a three-element array+-- @[rule, data, expected]@; bare strings are section comments. Every+-- case is run through 'apply' and its result compared with @expected@.+--+-- This runner cannot pass by running nothing. It exits non-zero if:+--+--   * the fixture is missing, unreadable, or not a JSON array;+--   * any entry is neither a three-element case nor a string comment;+--   * the number of cases is not exactly 'pinnedCases'; or+--   * any case fails.+--+-- The classic suite passes in full today. Should a change make a case+-- fail, it is either a bug to fix or a deliberate deviation to record+-- in docs/COMPATIBILITY.md: a human call, not a threshold. If the suite is+-- ever updated, change 'pinnedCases' in the same commit.+module Main (main) where++import Control.Monad (unless)+import Data.Aeson (Value (..), eitherDecodeFileStrict, encode)+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Foldable (for_, toList)+import System.Directory (doesFileExist)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++import JsonLogic (apply)++suitePath :: FilePath+suitePath = "test/tests.json"++-- | The exact number of cases in the vendored suite.+pinnedCases :: Int+pinnedCases = 278++main :: IO ()+main = do+  exists <- doesFileExist suitePath+  unless exists $+    abort+      ( "required fixture "+          ++ suitePath+          ++ " not found (run from the package root).\n"+          ++ "  The classic suite is vendored in the repository; restore it with:\n"+          ++ "  git checkout -- "+          ++ suitePath+      )+  decoded <- eitherDecodeFileStrict suitePath+  case decoded of+    Left reason -> abort ("cannot read " ++ suitePath ++ ": " ++ reason)+    Right (Array entries) -> report (toList entries)+    Right _ -> abort (suitePath ++ " is not a JSON array")++abort :: String -> IO a+abort message = hPutStrLn stderr ("conformance: " ++ message) >> exitFailure++-- | A case, a comment, or neither.+data Entry = Case Value Value Value | Comment | Malformed Value++classify :: Value -> Entry+classify (Array parts) = case toList parts of+  [rule, input, expected] -> Case rule input expected+  _ -> Malformed (Array parts)+classify (String _) = Comment+classify other = Malformed other++report :: [Value] -> IO ()+report entries = do+  let classified = map classify entries+      cases = [(rule, input, expected) | Case rule input expected <- classified]+      malformed = [v | Malformed v <- classified]+      failures =+        [ ((rule, input, expected), outcome)+        | (rule, input, expected) <- cases+        , let outcome = apply rule input+        , outcome /= Right expected+        ]+  for_ malformed $ \v ->+    hPutStrLn stderr ("conformance: entry is neither a case nor a comment: " ++ render v)+  for_ failures $ \((rule, input, expected), outcome) -> do+    putStrLn ("FAIL  " ++ render rule ++ "  on  " ++ render input)+    putStrLn ("      expected " ++ render expected)+    putStrLn ("      got      " ++ either show render outcome)+  putStrLn+    ( show (length cases - length failures)+        ++ " of "+        ++ show (length cases)+        ++ " cases pass; "+        ++ show (length entries - length cases - length malformed)+        ++ " comments skipped."+    )+  unless (length cases == pinnedCases) $+    abort+      ( "expected exactly "+          ++ show pinnedCases+          ++ " cases but found "+          ++ show (length cases)+          ++ ": the fixture is truncated or changed"+      )+  unless (null malformed) exitFailure+  unless (null failures) exitFailure++render :: Value -> String+render = Char8.unpack . encode
+ differential/Differential.hs view
@@ -0,0 +1,642 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Differential test: run randomly generated rules through this library and+-- through the real JavaScript implementation (json-logic-js, via Node), and+-- report every disagreement.+--+-- The classic suite checks the reference's own examples. This checks+-- everything else: it generates thousands of rules over every builtin+-- operator, fed deliberately awkward values (@"0x10"@, @" 12 "@, @"1e3"@,+-- @null@, empty strings), and compares the two answers.+--+-- Usage:+--+-- > cabal run jsonlogic-hs-differential -- COUNT SEED+-- > cabal run jsonlogic-hs-differential -- --replay FILE+--+-- The seed is always printed first, so any run can be repeated. A failing+-- case is shrunk against the reference to a smaller rule that still+-- disagrees, and written to a file that @--replay@ re-checks.+--+-- Cases are compared in lenient arity mode, which is the mode that models+-- the reference (docs/COMPATIBILITY.md); in strict mode the library must+-- give the same answer unless it rejects the arity. Equality between two+-- values that are arrays or objects is avoided in generated rules: the+-- reference compares them by reference identity, which parsed JSON does+-- not have.+--+-- An error is compared with an error only when both are of the same+-- class (unknown operator, invalid operand, empty @*@). A reference error+-- of any other kind fails the run, so a new kind of failure is noticed+-- rather than silently accepted.+module Main (main) where++import Control.Monad (unless, when, (>=>))+import Data.Aeson (Value (..), decode, encode, object, toJSON, (.=))+import Data.Aeson.Key (Key)+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.List (isInfixOf)+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Scientific (Scientific)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (createDirectoryIfMissing)+import System.Environment (getArgs)+import System.Exit (exitFailure)+import System.IO (hSetEncoding, stderr, stdout, utf8)+import System.Process (callProcess)+import Test.QuickCheck (Gen, choose, elements, frequency, vectorOf)+import Test.QuickCheck.Gen (unGen)+import Test.QuickCheck.Random (mkQCGen)++import JsonLogic++-- ---------------------------------------------------------------------+-- Generators+-- ---------------------------------------------------------------------++-- | Where a rule sits: at the top, inside a @map@-style operator (where+-- @{"var": ""}@ is the current element), or inside a @reduce@ (where+-- @current@ and @accumulator@ exist).+data Ctx = Top | Iter | Fold++trickyStrings :: [Text]+trickyStrings =+  [ ""+  , "0"+  , "1"+  , "2"+  , "a"+  , "b"+  , "abc"+  , "jsonlogic"+  , " 12 "+  , "1e3"+  , "0x10"+  , "-3"+  , ".5"+  , "5."+  , "Infinity"+  , "-Infinity"+  , "NaN"+  , "10"+  , "9"+  , "true"+  , "null"+  , "1,2"+  , "[object Object]"+  , "a.b"+  , "\x00a0\&7"+  , "\xfeff\&3"+  , "\x2028 4 \x2029"+  , "\x0085\&5"+  , "\x200b\&6"+  , "0x1F"+  , "0b101"+  , "1e1000"+  , "\x1F600"+  , "a\x1F600\&b"+  , "\xE000"+  , "e\x0301"+  , "\x65E5\x672C"+  , "9007199254740993"+  ]++-- | Characters either side of the UTF-16 surrogate range, where ordering+-- by code unit and by code point part ways.+unicodeEdges :: [Text]+unicodeEdges = ["\x1F600", "\xE000", "\xFFFF", "\x10000", "\xD7FF", "a", "", "\x1F600\&a", "\x10FFFF"]++-- | Each ECMAScript whitespace character, and some that look like it,+-- followed by a digit.+whitespaceEdges :: [Text]+whitespaceEdges =+  [ Text.pack [c, '5']+  | c <-+      [ '\t'+      , '\n'+      , '\v'+      , '\f'+      , '\r'+      , ' '+      , '\xA0'+      , '\x1680'+      , '\x2000'+      , '\x2005'+      , '\x200A'+      , '\x2028'+      , '\x2029'+      , '\x202F'+      , '\x205F'+      , '\x3000'+      , '\xFEFF'+      , '\x85'+      , '\x180E'+      , '\x200B'+      ]+  ]++scalarPaths :: [Text]+scalarPaths =+  ["a", "b", "c", "o.x", "o.y", "l.0", "l.1", "l.2", "l.length", "z", "a.b", "o.z"]++-- | A scalar chosen to provoke JavaScript's coercion rules.+genScalar :: Gen Value+genScalar =+  frequency+    [ (1, pure Null)+    , (2, Bool <$> elements [True, False])+    , (4, toJSON <$> (choose (-6, 12) :: Gen Int))+    , (2, toJSON <$> elements decimals)+    , (5, String <$> elements trickyStrings)+    , (1, elements extremes)+    ]+  where+    decimals :: [Double]+    decimals = [0.5, 1.5, -2.25, 100, 1e-7, 1e21]+    extremes =+      [ Number 5e-324+      , Number 1.7976931348623157e308+      , Number 2.2250738585072014e-308+      , Number 9007199254740993+      , Number 1e309+      , Number 123456789012345678901234567890+      ]++genArrayOf :: Gen Value -> Gen Value+genArrayOf element = do+  n <- choose (0, 3)+  toJSON <$> vectorOf n element++call :: Key -> [Value] -> Value+call op args = object [op .= args]++genPath :: Ctx -> Gen Value+genPath ctx =+  frequency+    ( [ (6, String <$> elements scalarPaths)+      , (1, toJSON <$> (choose (0, 2) :: Gen Int))+      ]+        ++ extra+    )+  where+    extra = case ctx of+      Top -> []+      Iter -> [(8, pure (String ""))]+      Fold -> [(6, pure (String "current")), (6, pure (String "accumulator"))]++genVar :: Ctx -> Gen Value+genVar ctx = do+  path <- genPath ctx+  withFallback <- elements [True, False, False]+  if withFallback+    then do+      fallback <- genScalar+      pure (call "var" [path, fallback])+    else pure (call "var" [path])++genLeaf :: Ctx -> Gen Value+genLeaf ctx = frequency [(4, genScalar), (4, genVar ctx), (1, genArrayOf genScalar), (1, elements computed)]++-- | Values JSON cannot write (-0, NaN, the infinities, undefined), produced+-- by a rule instead. They belong in rules only: put in the data they would+-- be objects, which the reference compares by identity.+computed :: [Value]+computed =+  [ object ["*" .= [toJSON (-1 :: Int), toJSON (0 :: Int)]]+  , object ["+" .= [String "a"]]+  , object ["/" .= [toJSON (1 :: Int), toJSON (0 :: Int)]]+  , object ["/" .= [toJSON (-1 :: Int), toJSON (0 :: Int)]]+  , object ["and" .= ([] :: [Int])]+  ]++genRule :: Ctx -> Int -> Gen Value+genRule ctx depth+  | depth <= 0 = genLeaf ctx+  | otherwise =+      frequency+        [ (3, genLeaf ctx)+        , (2, genLogic ctx depth)+        , (3, genCompare ctx depth)+        , (3, genArith ctx depth)+        , (2, genText ctx depth)+        , (2, genScoped ctx depth)+        , (1, genReduce ctx depth)+        , (1, genMerge ctx depth)+        , (1, genMissing)+        , (2, genIf ctx depth)+        , (1, genArrayRule ctx depth)+        , (2, genStringFocus)+        ]++-- | Between @low@ and @high@ sub-rules.+genArgs :: Ctx -> Int -> Int -> Int -> Gen [Value]+genArgs ctx depth low high = do+  n <- choose (low, high)+  vectorOf n (genRule ctx (depth - 1))++-- | Operators applied straight to awkward strings, which is where+-- JavaScript's coercion and ordering rules bite hardest.+genStringFocus :: Gen Value+genStringFocus = do+  pool <- elements [trickyStrings, unicodeEdges, whitespaceEdges]+  a <- String <$> elements pool+  b <- String <$> elements pool+  frequency+    [ (3, (\op -> call op [a, b]) <$> elements ["<", "<=", ">", ">=", "==", "!=", "===", "in", "cat", "max", "min"])+    , (2, (\op -> call op [a]) <$> elements ["+", "-", "!", "!!", "max", "min", "*"])+    , (2, (\op -> call op [a, toJSON (1 :: Int)]) <$> elements ["+", "*", "/", "%", "-", "<", "=="])+    ]++genLogic :: Ctx -> Int -> Gen Value+genLogic ctx depth =+  frequency+    [ (3, call <$> elements ["and", "or"] <*> genArgs ctx depth 0 3)+    , (2, genUnary ctx depth)+    ]++-- | @!@ or @!!@, written either with an argument list or, when the+-- argument is not itself an array, in the bare-value shorthand.+genUnary :: Ctx -> Int -> Gen Value+genUnary ctx depth = do+  op <- elements ["!", "!!"]+  x <- genRule ctx (depth - 1)+  shorthand <- elements [True, False]+  pure $ case x of+    Array _ -> call op [x]+    _+      | shorthand -> object [op .= x]+      | otherwise -> call op [x]++genCompare :: Ctx -> Int -> Gen Value+genCompare ctx depth =+  frequency+    [ (4, call <$> elements ["==", "===", "!=", "!=="] <*> genArgs ctx depth 2 2)+    , (3, call <$> elements ["<", "<="] <*> genArgs ctx depth 2 3)+    , (3, call <$> elements [">", ">="] <*> genArgs ctx depth 2 2)+    ]++genArith :: Ctx -> Int -> Gen Value+genArith ctx depth =+  frequency+    [ (3, call "+" <$> genArgs ctx depth 0 3)+    , -- zero operands too: the reference throws on an empty @*@ only when+      -- it is evaluated, so one in an untaken branch must not fail the rule+      (2, call "*" <$> genArgs ctx depth 0 3)+    , (2, call "-" <$> genArgs ctx depth 1 2)+    , (2, call <$> elements ["/", "%"] <*> genArgs ctx depth 2 2)+    , (2, call <$> elements ["max", "min"] <*> genArgs ctx depth 0 3)+    ]++genText :: Ctx -> Int -> Gen Value+genText ctx depth =+  frequency+    [ (3, call "cat" <$> genArgs ctx depth 0 3)+    , (3, genSubstr ctx depth)+    , (2, genIn ctx depth)+    ]++genIndex :: Gen Value+genIndex =+  frequency+    [ (4, toJSON <$> (choose (-7, 7) :: Gen Int))+    , (1, String <$> elements ["1", "-2", "x"])+    , (1, pure Null)+    ]++genSubstr :: Ctx -> Int -> Gen Value+genSubstr ctx depth = do+  source <- frequency [(2, String <$> elements trickyStrings), (1, genRule ctx (depth - 1))]+  start <- genIndex+  withLength <- elements [True, False]+  if withLength+    then do+      len <- genIndex+      pure (call "substr" [source, start, len])+    else pure (call "substr" [source, start])++genIn :: Ctx -> Int -> Gen Value+genIn ctx depth = do+  needle <- genRule ctx (depth - 1)+  haystack <-+    frequency+      [ (3, String <$> elements trickyStrings)+      , (3, genArrayOf genScalar)+      , (2, pure (call "var" [String "l"]))+      , (1, genRule ctx (depth - 1))+      ]+  pure (call "in" [needle, haystack])++genCollectionSource :: Ctx -> Gen Value+genCollectionSource ctx =+  frequency+    [ (4, pure (call "var" [String "l"]))+    , (3, genArrayOf genScalar)+    , (1, pure (call "var" [String "o"]))+    , (1, genVar ctx)+    ]++genScoped :: Ctx -> Int -> Gen Value+genScoped ctx depth = do+  op <- elements ["map", "filter", "all", "none", "some"]+  collection <- genCollectionSource ctx+  logic <- genRule Iter (depth - 1)+  pure (call op [collection, logic])++genReduce :: Ctx -> Int -> Gen Value+genReduce ctx depth = do+  collection <- genCollectionSource ctx+  logic <- genRule Fold (depth - 1)+  initial <- genScalar+  pure (call "reduce" [collection, logic, initial])++genMerge :: Ctx -> Int -> Gen Value+genMerge ctx depth = do+  n <- choose (0, 3)+  parts <- vectorOf n (frequency [(3, genArrayOf genScalar), (2, genRule ctx (depth - 1))])+  pure (call "merge" parts)++genMissing :: Gen Value+genMissing = do+  n <- choose (0, 3)+  keys <- vectorOf n (String <$> elements scalarPaths)+  need <- choose (0, 3) :: Gen Int+  elements [call "missing" keys, call "missing_some" [toJSON need, toJSON keys]]++genIf :: Ctx -> Int -> Gen Value+genIf ctx depth = do+  op <- elements ["if", "if", "?:"]+  call op <$> genArgs ctx depth 0 5++-- | An array literal whose elements are themselves rules.+genArrayRule :: Ctx -> Int -> Gen Value+genArrayRule ctx depth = do+  n <- choose (0, 3)+  toJSON <$> vectorOf n (genRule ctx (depth - 1))++genObjectData :: Gen Value+genObjectData = do+  a <- genScalar+  b <- genScalar+  c <- genScalar+  n <- choose (0, 4)+  items <- vectorOf n genScalar+  x <- genScalar+  y <- genScalar+  present <- vectorOf 5 (elements [True, True, True, True, False])+  let values = [a, b, c, toJSON items, object ["x" .= x, "y" .= y]]+      fields = [(key, value) | (key, value, True) <- zip3 ["a", "b", "c", "l", "o"] values present]+  pure (object [key .= value | (key, value) <- fields])++genData :: Gen Value+genData =+  frequency+    [ (14, genObjectData)+    , (1, pure Null)+    , (1, genScalar)+    , (1, genArrayOf genScalar)+    ]++genCase :: Gen (Value, Value)+genCase = do+  depth <- choose (1, 4)+  rule <- genRule Top depth+  input <- genData+  pure (rule, input)++-- ---------------------------------------------------------------------+-- Comparison+-- ---------------------------------------------------------------------++-- | What the reference answered.+data Reference = ReferenceValue Value | ReferenceError Text++reference :: Value -> Reference+reference (Object fields) =+  case (KeyMap.lookup "ok" fields, KeyMap.lookup "error" fields) of+    (Just value, _) -> ReferenceValue value+    (_, Just (String message)) -> ReferenceError message+    _ -> ReferenceError "malformed oracle line"+reference _ = ReferenceError "malformed oracle line"++-- | The library's answer in both arity modes.+data Ours = Ours+  { lenientAnswer :: Either Error Value+  , strictAnswer :: Either Error Value+  }++answers :: (Value, Value) -> Ours+answers (rule, input) =+  Ours+    (applyWithConfig defaultConfig{arityMode = Lenient} noOperators rule input)+    (applyWithConfig defaultConfig noOperators rule input)++-- | Why the answers disagree, if they do.+judge :: Reference -> Ours -> Maybe String+judge expected ours = case (judgeOne expected (lenientAnswer ours), strictAnswer ours) of+  (Just why, _) -> Just ("lenient: " ++ why)+  (Nothing, Left (ParseFailure _ ArityMismatch{})) -> Nothing+  (Nothing, strictResult) -> ("strict: " ++) <$> judgeOne expected strictResult++judgeOne :: Reference -> Either Error Value -> Maybe String+judgeOne (ReferenceValue theirs) (Right ours)+  | ours == theirs = Nothing+  | otherwise = Just "different value"+judgeOne (ReferenceValue _) (Left e) = Just ("the library failed: " ++ Text.unpack (renderError e))+judgeOne (ReferenceError _) (Right _) = Just "the reference throws but the library answered"+judgeOne (ReferenceError message) (Left e) = case errorClass e of+  Just wanted+    | any (`isInfixOf` Text.unpack message) wanted -> Nothing+    | otherwise -> Just ("error of a different class: " ++ Text.unpack (renderError e))+  Nothing -> Just ("the library failed in a way the reference does not: " ++ Text.unpack (renderError e))++-- | The messages the reference gives for each class of library error.+errorClass :: Error -> Maybe [String]+errorClass (EvalFailure _ (UnknownOperator _)) = Just ["Unrecognized operation", "apply is not a function"]+errorClass (EvalFailure _ (InvalidOperand (OperatorName "*"))) = Just ["Reduce of empty array"]+errorClass (EvalFailure _ (InvalidOperand _)) = Just ["Cannot read properties"]+errorClass _ = Nothing++json :: Value -> String+json = Char8.unpack . encode++describe :: Reference -> String+describe (ReferenceValue value) = json value+describe (ReferenceError message) = "error: " ++ Text.unpack message++describeOurs :: Either Error Value -> String+describeOurs = either (\e -> "error: " ++ Text.unpack (renderError e)) json++-- ---------------------------------------------------------------------+-- Asking the reference, and shrinking+-- ---------------------------------------------------------------------++-- | Ask the reference about a batch of cases with one Node process.+askReference :: FilePath -> [(Value, Value)] -> IO [Reference]+askReference stem cases = do+  let casesFile = stem ++ ".cases.jsonl"+      referenceFile = stem ++ ".reference.jsonl"+  LBS.writeFile+    casesFile+    (Char8.unlines [encode (object ["rule" .= rule, "data" .= input]) | (rule, input) <- cases])+  callProcess "node" ["differential/oracle.js", casesFile, referenceFile]+  answered <- Char8.lines <$> LBS.readFile referenceFile+  unless (length answered == length cases) $ do+    putStrLn+      ( "differential: the reference gave "+          ++ show (length answered)+          ++ " answers for "+          ++ show (length cases)+          ++ " cases"+      )+    exitFailure+  pure (map (maybe (ReferenceError "malformed oracle line") reference . decode) answered)++-- | Simpler variants of a case: a rule replaced by one of its operands, an+-- operand dropped, a literal made simpler, the data emptied.+shrinks :: (Value, Value) -> [(Value, Value)]+shrinks (rule, input) =+  [(r, input) | r <- shrinkValue rule]+    ++ [(rule, d) | d <- shrinkData input]++shrinkData :: Value -> [Value]+shrinkData Null = []+shrinkData (Object fields) =+  Null : [Object (KeyMap.delete k fields) | k <- KeyMap.keys fields]+shrinkData _ = [Null]++shrinkValue :: Value -> [Value]+shrinkValue value = case value of+  Object fields -> case KeyMap.toList fields of+    [(key, args)] ->+      let operands = case args of+            Array items -> foldr (:) [] items+            other -> [other]+       in operands+            ++ [object [key .= toJSON (dropAt i operands)] | i <- [0 .. length operands - 1]]+            ++ [object [key .= toJSON (replaceAt i smaller operands)] | (i, o) <- zip [0 ..] operands, smaller <- shrinkValue o]+    _ -> [Null]+  Array items ->+    let elems = foldr (:) [] items+     in elems ++ [toJSON (dropAt i elems) | i <- [0 .. length elems - 1]]+  String t | not (Text.null t) -> [String "", String (Text.take 1 t)]+  Number n | n /= 0 -> [Number 0, Number 1] ++ [Number (n / 2) | isSmall n]+  Bool True -> [Bool False]+  Null -> []+  _ -> [Null]+  where+    isSmall :: Scientific -> Bool+    isSmall n = abs n < 1e15++dropAt :: Int -> [a] -> [a]+dropAt i xs = take i xs ++ drop (i + 1) xs++replaceAt :: Int -> a -> [a] -> [a]+replaceAt i x xs = take i xs ++ [x] ++ drop (i + 1) xs++-- | Greedily replace a failing case by a simpler one that still fails.+shrinkCase :: FilePath -> (Value, Value) -> IO (Value, Value)+shrinkCase stem = go (60 :: Int)+  where+    go 0 current = pure current+    go fuel current = do+      let candidates = take 400 (shrinks current)+      if null candidates+        then pure current+        else do+          expected <- askReference stem candidates+          let failing = [c | (c, e) <- zip candidates expected, Just _ <- [judge e (answers c)]]+          case failing of+            [] -> pure current+            c : _ -> go (fuel - 1) c++-- ---------------------------------------------------------------------+-- Running+-- ---------------------------------------------------------------------++readInt :: Int -> String -> Int+readInt fallback text = case reads text of+  [(n, [])] -> n+  _ -> fallback++outDir :: FilePath+outDir = "differential/out"++main :: IO ()+main = do+  hSetEncoding stdout utf8+  hSetEncoding stderr utf8+  args <- getArgs+  createDirectoryIfMissing True outDir+  case args of+    ["--replay", file] -> replay file+    [c, s] -> randomRun (readInt 2000 c) (readInt 1 s)+    [c] -> randomRun (readInt 2000 c) 1+    _ -> randomRun 2000 1++-- | Re-check the cases in a file, one @{"rule":..,"data":..}@ per line.+replay :: FilePath -> IO ()+replay file = do+  contents <- Char8.lines <$> LBS.readFile file+  let cases = mapMaybe (decode >=> toCase) contents+  when (null cases) $ putStrLn ("differential: no cases in " ++ file) >> exitFailure+  expected <- askReference (outDir ++ "/replay") cases+  let bad = [(c, e, judge e (answers c)) | (c, e) <- zip cases expected]+      failures = [(c, e, why) | (c, e, Just why) <- bad]+  mapM_ (reportFailure Nothing) (zip [1 ..] failures)+  putStrLn (show (length cases - length failures) ++ " of " ++ show (length cases) ++ " replayed cases agree")+  unless (null failures) exitFailure+  where+    toCase (Object o) = (,) <$> KeyMap.lookup "rule" o <*> pure (fromMaybe Null (KeyMap.lookup "data" o))+    toCase _ = Nothing++randomRun :: Int -> Int -> IO ()+randomRun count seed = do+  putStrLn ("differential: seed " ++ show seed ++ ", " ++ show count ++ " cases")+  let cases = [unGen genCase (mkQCGen (seed * 1000003 + i)) 30 | i <- [1 .. count]]+  expected <- askReference (outDir ++ "/run") cases+  let bad = [(i, c, e, why) | (i, c, e) <- zip3 [1 :: Int ..] cases expected, Just why <- [judge e (answers c)]]+  putStrLn+    ( show (count - length bad)+        ++ " of "+        ++ show count+        ++ " random cases agree with the reference (seed "+        ++ show seed+        ++ ")"+    )+  unless (null bad) $ do+    -- shrink and report the first few; list every index+    mapM_+      ( \(i, c, _, _) -> do+          small <- shrinkCase (outDir ++ "/shrink") c+          [e] <- askReference (outDir ++ "/shrink1") [small]+          let why = fromMaybe "no longer disagrees after shrinking" (judge e (answers small))+          putStrLn ("  original  " ++ json (fst c) ++ "  on  " ++ json (snd c))+          reportFailure (Just (seed, i)) (i, (small, e, why))+      )+      (take 5 [(i, c, e, w) | (i, c, e, w) <- bad])+    writeFile+      (outDir ++ "/mismatches.txt")+      (unlines ["seed " ++ show seed ++ " index " ++ show i ++ ": " ++ why | (i, _, _, why) <- bad])+    putStrLn (show (length bad) ++ " mismatches; indices are in " ++ outDir ++ "/mismatches.txt")+    exitFailure++reportFailure :: Maybe (Int, Int) -> (Int, ((Value, Value), Reference, String)) -> IO ()+reportFailure origin (n, ((rule, input), expected, why)) = do+  let reproFile = outDir ++ "/repro-" ++ show n ++ ".jsonl"+  LBS.writeFile reproFile (encode (object ["rule" .= rule, "data" .= input]) <> "\n")+  let ours = answers (rule, input)+  mapM_+    putStrLn+    [ "MISMATCH #" ++ show n ++ maybe "" (\(s, i) -> " (seed " ++ show s ++ ", case " ++ show i ++ ", shrunk)") origin+    , "  why       " ++ why+    , "  rule      " ++ json rule+    , "  data      " ++ json input+    , "  lenient   " ++ describeOurs (lenientAnswer ours)+    , "  strict    " ++ describeOurs (strictAnswer ours)+    , "  reference " ++ describe expected+    , "  repeat    cabal run jsonlogic-hs-differential -- --replay " ++ reproFile+    ]
+ differential/README.md view
@@ -0,0 +1,106 @@+# Differential testing against the reference++Two tools, both holding this library to the real JavaScript implementation+([json-logic-js](https://github.com/jwadhams/json-logic-js) **2.0.5**, run+through Node):++- **`outliers.js`** writes the outlier catalogue: a large, fixed set of+  deliberately awkward rules and inputs, with the answers the reference+  gave. The result is committed as `test/outliers.jsonl` and replayed+  *offline* by `cabal test` (`jsonlogic-hs-outliers`).+- **`Differential.hs`** generates random rules and compares each answer with+  the reference live, shrinking any disagreement.++## Setup (once)++```+cd differential && npm ci+```++Needs Node.js 18 or newer. `npm ci` installs exactly the locked oracle+(`2.0.5`); `oracle.js` and `outliers.js` refuse to run against any other+version, because a different oracle changes what "correct" means. Neither is+part of `cabal test`, which must work without Node.++## The outlier catalogue++```+node outliers.js ../test/outliers.jsonl              # write the committed sample (about 27,000 cases)+OUTLIER_TIER=full node outliers.js /tmp/full.jsonl   # the whole catalogue (about 140,000 cases)+node outliers.js --check ../test/outliers.jsonl      # fail if the committed file is stale+cabal run jsonlogic-hs-outliers -- /tmp/full.jsonl   # replay any tier+```++There is no randomness: the same script always writes the same bytes, and+the sample is a stable hash-based thinning of the big cross-product families,+so it only changes when the catalogue does. Regenerate the committed file+whenever `outliers.js` changes; CI fails if you forget.++Families (see the top of `outliers.js`): every operator at argument counts 0+to 4 (`arity`); every operator over a rich pool of awkward values (`unary`,+`pair`, `triple`); string-to-number forms through fifteen contexts+(`numstr`); number printing (`numfmt`) and shortest-digit ties (`tie`);+signed zero, NaN and infinity through every operator and observer+(`signedzero`); UTF-16 length, indexing, `substr`, ordering (`unicode`);+variable paths on awkward data (`var`); iteration operators over awkward+collections and bodies (`collection`); control flow and short-circuiting+(`flow`, including an empty `*` in branches that are and are not taken); a+full loose/strict equality and ordering matrix over mixed types+(`coercion`); literals, deep nesting and very wide rules; and every rule of+the community suite in `test/community/` (`community`). The format is+documented for implementers in other languages in `docs/CONFORMANCE.md`.++Each line is one case: `{"id","fam","op","n","rule","data","expect"}`, where+`expect` is `{"ok":value}` or `{"error":message}`. The first line is a+`meta` line stating how many cases follow, so truncation is detected. A case+that exercises a documented deviation carries `"dev"`:++- `"inherited"`: the rule looks up a name like `constructor` that JavaScript+  finds on the prototype chain. `expect_own` is the reference's answer for a+  path that is certainly absent, which is what this library must return.+- `"reeval"`: `missing` / `missing_some` was given a key that came from the+  data and looks like a rule. Counted, not compared.++Both deviations are explained in `docs/COMPATIBILITY.md`.++## The random differential test++```+cabal run jsonlogic-hs-differential -- COUNT SEED+cabal run jsonlogic-hs-differential -- --replay FILE+```++Defaults: 2000 cases, seed 1. The seed is printed first; the same count and+seed always generate the same cases. CI uses a fresh seed on every run (the+run number) and a larger count nightly.++A disagreement is shrunk, by asking the reference about simpler variants, to+a small rule that still disagrees. The shrunk case, the original, both+answers (lenient and strict) and the reference's are printed, and the shrunk+case is written to `differential/out/repro-N.jsonl`, which `--replay`+re-checks. Every mismatch index is listed in `differential/out/mismatches.txt`.++Cases are compared in lenient arity mode, which models the reference, and+strict mode must give the same answer unless it rejects the arity. The+generator produces every operator at every operand count it accepts,+including an empty `*`, which the reference throws on only when it evaluates+it. Errors are compared by class (unknown operator, invalid operand, empty+`*`); a+reference error of any other kind fails the run, so a new kind of failure+cannot pass unnoticed.++## Reading a mismatch++Generated rules avoid the documented deviations (docs/COMPATIBILITY.md): no+array or object is put in the data where the reference would compare it by+identity, and values JSON cannot write (negative zero, NaN, infinities,+`undefined`) appear only in rules, as computed values. A mismatch is+therefore either a bug here or a new deviation to decide on and record.++## Checking the checkers++`bash scripts/check-harness.sh` feeds the runners missing, empty, truncated+and tampered fixtures and demands a failure each time, and checks the+committed fixture is current. Planting a bug in string ordering or in+whitespace handling must make both the outlier suite and the differential+test fail; when changing the harness, do that once by hand.
+ differential/oracle.js view
@@ -0,0 +1,61 @@+#!/usr/bin/env node+// The reference implementation as an oracle.+//+// Usage: node oracle.js CASES RESULTS+//+// CASES has one JSON object per line: {"rule": ..., "data": ...}.+// RESULTS gets one line per case, in order: {"ok": value} or+// {"error": message}. JavaScript `undefined` is written as null, and NaN+// and the infinities become null through JSON.stringify, which is exactly+// how jsonlogic-hs renders them.++const fs = require("fs");+const jsonLogic = require("json-logic-js");+console.log = () => {}; // the "log" operator would otherwise write to stdout++const [casesFile, resultsFile] = process.argv.slice(2);+if (!casesFile || !resultsFile) {+  console.error("usage: node oracle.js CASES RESULTS");+  process.exit(2);+}++// The reference is pinned. Anything else is not the oracle this library is+// held to, so fail instead of comparing against it.+const REQUIRED = "2.0.5";+const version = require("json-logic-js/package.json").version;+if (version !== REQUIRED) {+  console.error("oracle: json-logic-js " + version + " is installed, but " + REQUIRED + " is required (run `npm ci` in differential/)");+  process.exit(3);+}+console.error("oracle: json-logic-js " + version + ", node " + process.version);++// Lone surrogates cannot be held in a Haskell Text (nor decoded by aeson), so+// the reference's answer is normalised the way String.prototype.toWellFormed+// would: each becomes U+FFFD. This is the one documented string deviation.+function wellFormed(x) {+  if (typeof x === "string") return typeof x.toWellFormed === "function" ? x.toWellFormed() : x;+  if (Array.isArray(x)) return x.map(wellFormed);+  if (x !== null && typeof x === "object") {+    const o = {};+    for (const k of Object.keys(x)) {+      Object.defineProperty(o, wellFormed(k), { value: wellFormed(x[k]), enumerable: true, writable: true, configurable: true });+    }+    return o;+  }+  return x;+}++const answers = [];+for (const line of fs.readFileSync(casesFile, "utf8").split("\n")) {+  if (line.trim() === "") continue;+  const { rule, data } = JSON.parse(line);+  let answer;+  try {+    const result = jsonLogic.apply(rule, data);+    answer = { ok: wellFormed(result === undefined ? null : result) };+  } catch (error) {+    answer = { error: String(error && error.message ? error.message : error) };+  }+  answers.push(JSON.stringify(answer));+}+fs.writeFileSync(resultsFile, answers.join("\n") + "\n");
+ differential/outliers.js view
@@ -0,0 +1,663 @@+#!/usr/bin/env node+// Outlier fixture generator.+//+// Builds a large, fully deterministic catalogue of awkward rules and data+// (no randomness: the same script always writes the same bytes), runs every+// case through the reference implementation (json-logic-js, pinned in+// package.json), and records the reference's answer.+//+// Usage:  node outliers.js OUTPUT.jsonl        (writes the fixture)+//         node outliers.js --check FIXTURE     (fails if FIXTURE is stale)+//+// The Haskell test-suite `jsonlogic-hs-outliers` replays the fixture+// offline, so running it needs neither Node nor network access. Regenerate+// the fixture only when this file changes; CI runs --check so the committed+// fixture can never drift from the generator.+//+// The first line is {"meta":{"cases":N,"oracle":"2.0.5","tier":"core"}}, so a+// truncated fixture is detected. Every other line is one case:+//   {"id":"...","fam":"...","op":"...","n":2,"rule":R,"data":D,"expect":E}+// where E is {"ok":value} or {"error":"message"}. JavaScript `undefined`,+// NaN and the infinities are written as null (JSON.stringify), and lone+// surrogates in output are normalised to U+FFFD (toWellFormed), because a+// Haskell Text cannot hold them. `n` is the number of operands written, so+// the runner can tell strict-arity rejections from real disagreements.++"use strict";+const fs = require("fs");+const jsonLogic = require("json-logic-js");++const REQUIRED_ORACLE = "2.0.5";+const version = require("json-logic-js/package.json").version;+if (version !== REQUIRED_ORACLE) {+  console.error(`oracle is json-logic-js ${version}, need ${REQUIRED_ORACLE}`);+  process.exit(2);+}++// ---------------------------------------------------------------------+// Serialisation that can carry raw JSON tokens (1e309, -0 ...) which+// JSON.stringify would destroy.+// ---------------------------------------------------------------------+const raw = (text) => ({ __raw: text });+function ser(x) {+  if (x !== null && typeof x === "object" && "__raw" in x) return x.__raw;+  if (Array.isArray(x)) return "[" + x.map(ser).join(",") + "]";+  if (x !== null && typeof x === "object") {+    return "{" + Object.keys(x).map((k) => JSON.stringify(k) + ":" + ser(x[k])).join(",") + "}";+  }+  return JSON.stringify(x);+}+const op = (name, ...args) => ({ [name]: args });+const opRaw = (name, args) => ({ [name]: args });++// ---------------------------------------------------------------------+// Value pools+// ---------------------------------------------------------------------+const NEG_ZERO = op("*", -1, 0);+const POS_INF = op("/", 1, 0);+const NEG_INF = op("/", -1, 0);+const NOT_A_NUMBER = op("+", "a");+const UNDEF = op("and");++const NUMBERS = [+  0, 1, -1, 2, 3, 7, 0.5, -0.5, 2.5, -2.5, 3.5, 10, 100, 1e21, 1e-7, 1e-6,+  123456789012345680000, 5e-324, 2.2250738585072014e-308, 1.7976931348623157e308,+  raw("1e309"), raw("-1e309"), raw("1e-400"), raw("-1e-400"), raw("1E5"), raw("1.0"), raw("-0.0"),+  raw("0e0"), raw("100e-2"), raw("9007199254740991"), raw("9007199254740992"),+  raw("9007199254740993"), raw("9007199254740995"), raw("18014398509481984"),+  0.1, 0.2, 0.30000000000000004, 4294967296, 4294967295, 2147483648, -2147483649, 1e300,+  raw("123456789012345678901234567890"), raw("0.000001"), raw("0.0000001"),+  raw("1e21"), raw("1e22"), raw("999999999999999900000"), raw("1.7976931348623158e308"),+  raw("2.2250738585072011e-308"), raw("4.9406564584124654e-324"), raw("2e-324"), raw("3e-324"),+];+const COMPUTED_NUMBERS = [NEG_ZERO, POS_INF, NEG_INF, NOT_A_NUMBER, op("-", 0)];++// Strings that stress ToNumber / parseFloat.+const WS = [+  "\u0009", "\u000a", "\u000b", "\u000c", "\u000d", "\u0020", "\u00a0", "\u1680",+  "\u2000", "\u2001", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007",+  "\u2008", "\u2009", "\u200a", "\u2028", "\u2029", "\u202f", "\u205f", "\u3000", "\ufeff",+];+const NOT_WS = ["\u0085", "\u180e", "\u200b", "\u001c", "\u001f", "\u0000", "\u200c", "\u2060", "\ufffe"];+const NUMERIC_STRINGS = [+  "", " ", "0", "1", "-1", "+1", "00", "01", "-0", "+0", "0.0", "0x10", "0X1F", "0xg", "0x",+  "0b101", "0B11", "0b2", "0b", "0o17", "0O7", "0o8", "0o", "-0x10", "+0x10", "0x-1", "0x1.8",+  "1_000", "1e3", "1E3", "1e", "1e+", "1e-", "1e+3", "1e-3", "1e3.5", ".5", "5.", ".", "-.",+  "+.5e-3", "-.5", ".e1", "1..2", "1.2.3", "1,5", "Infinity", "-Infinity", "+Infinity",+  "infinity", "INFINITY", "Infinityx", "Infinit", "NaN", "nan", "-NaN", " 12 ", "\t12\n",+  "12px", "12 13", "1 2", "١٢٣", "123", "²", "①", "१२३", "a", "abc", "true", "false", "null",+  "undefined", "[object Object]", "[1]", "1,2", "1e1000", "-1e1000", "1e-1000", "0e5", "0.0e5",+  "9007199254740993", "9007199254740992.5", "0.1", "0.30000000000000004",+  "1.7976931348623157e308", "1.7976931348623159e308", "1.797693134862315807e308",+  "4.9e-324", "2e-324", "2.4703282292062327e-324", "2.4703282292062328e-324", "5e-324",+  "2.2250738585072014e-308", "2.225073858507201e-308", "123456789012345678901234567890",+  "0.000001", "0.0000001", "1e21", "1e-7", "-1e-7", "+-1", "--1", "- 1", "+ 1", "1 ", " 1",+  "\u00001", "1\u0000", "0.1e1", "00.5", "0001e3", "1e0003", "1e-0003", ".0e0", "-.0",+  "1".repeat(20), "1".repeat(400), "1".repeat(767), "1".repeat(768), "1".repeat(769),+  "9".repeat(309), "9".repeat(310), "0." + "0".repeat(323) + "1", "0." + "0".repeat(400) + "1",+  "1" + "0".repeat(308), "1" + "0".repeat(309),+  "0." + "0".repeat(20) + "9".repeat(30), "2.4703282292062327" + "0".repeat(300) + "1e-324",+  "5e-325", "2.5e-324", "1.5e-323",+];+for (const w of WS) {+  NUMERIC_STRINGS.push(w + "12", "12" + w, w + "12" + w, w, w + "0x10", w + "-5" + w);+}+for (const w of NOT_WS) {+  NUMERIC_STRINGS.push(w + "12", "12" + w);+}++// Strings that stress length / indexing / ordering / substr.+const UNICODE_STRINGS = [+  "", "a", "ab", "abc", "z", "A", "é", "e\u0301", "\u0000", "\u0000a", "a\u0000b", "😀", "a😀b",+  "😀😀", "👨‍👩‍👧", "🇮🇳", "🇮🇳🇺🇸", "👍🏽", "\ue000", "\uffff", "\ufffd", "\ud7ff", "\uf8ff",+  "\u{10000}", "\u{10ffff}", "\u{1d11e}", "𝒳", "שלום", "ab\u202ec", "\ud83d\ude00", "dž", "ß", "İ", "日本語", "\u{20bb7}",+];++const CONTAINERS = [+  [], [[]], [0], [1], [null], [[1]], [1, 2], [1, 2, 3], ["a"], [[], []], {}, { a: 1 },+  { a: {} }, [{}], [true], ["1"], [false], [""], [0, 0], [[], 0], ["a", "b"], [1, "1"],+  { a: [1, 2] }, [null, null],+];+const MISC = [null, true, false, UNDEF];++// A rich pool for unary experiments and a smaller one for pairs / triples.+const POOL_A = [+  ...NUMBERS, ...COMPUTED_NUMBERS, ...MISC, ...CONTAINERS,+  ...[+    "", " ", "0", "1", "-1", "a", "abc", "0x10", "1e3", " 12 ", "Infinity", "-Infinity", "NaN",+    ".5", "5.", "true", "null", "undefined", "1,2", "[object Object]", "\ufeff1", "\u2028 1",+    "\u00a01", "\u00851", "\u180e1", "\u200b1", "😀", "e\u0301", "\u0000", "١٢٣",+  ],+];+const POOL_B = [+  0, 1, -1, 0.5, 1e21, 1e-7, 5e-324, 1.7976931348623157e308, raw("9007199254740993"),+  NEG_ZERO, POS_INF, NEG_INF, NOT_A_NUMBER, null, true, false, UNDEF,+  "", "0", "1", "a", "10", "9", "0x10", " 12 ", "Infinity", "NaN", "😀", "\ue000",+  [], [0], [1], [1, 2], ["a"], {}, { a: 1 },+];+const POOL_C = [0, 1, "a", "", null, UNDEF, [1, 2], NEG_ZERO];++// ---------------------------------------------------------------------+// Case collection+// ---------------------------------------------------------------------+const cases = [];+const seen = new Set();+let counter = 0;++function add(fam, opName, n, rule, data) {+  const ruleText = ser(rule);+  const dataText = ser(data === undefined ? null : data);+  const key = ruleText + "\u0001" + dataText;+  if (seen.has(key)) return;+  seen.add(key);+  counter += 1;+  cases.push({ id: fam + "-" + String(counter).padStart(6, "0"), fam, op: opName, n, ruleText, dataText });+}++const UNARY_OPS = [+  "var", "missing", "!", "!!", "-", "+", "*", "max", "min", "cat", "log", "merge", "if", "?:",+  "and", "or", "==", "===", "!=", "!==", "<", "<=", ">", ">=", "/", "%", "in", "substr", "map",+  "filter", "reduce", "all", "none", "some", "missing_some",+];+const PAIR_OPS = [+  "==", "===", "!=", "!==", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "max", "min", "cat",+  "in", "merge", "substr", "var", "missing", "missing_some", "and", "or", "if", "map", "filter",+  "all", "none", "some", "reduce",+];+const TRIPLE_OPS = ["<", "<=", "substr", "if", "and", "or", "reduce", "cat", "+", "max", "*", "var", "-"];+// Every builtin operator. It must be the set in src/JsonLogic/Core/Operator.hs:+// the Haskell runner fails if any builtin there has too few cases here.+const ALL_OPS = [+  "var", "missing", "missing_some", "if", "?:", "and", "or", "!", "!!", "==", "===", "!=", "!==",+  "<", "<=", ">", ">=", "max", "min", "+", "-", "*", "/", "%", "map", "filter", "reduce", "all",+  "none", "some", "merge", "in", "cat", "substr", "log",+];++// 1. Every operator at arities 0..4 with simple operands.+const ARITY_OPERANDS = [1, "a", [1, 2], null];+for (const name of ALL_OPS) {+  for (let n = 0; n <= 4; n++) {+    add("arity", name, n, opRaw(name, ARITY_OPERANDS.slice(0, n)), { a: 5 });+    add("arity", name, n, opRaw(name, [{ var: "a" }, { var: "a" }, { var: "a" }, { var: "a" }].slice(0, n)), { a: 5 });+    add("arity", name, n, opRaw(name, [[1, 2, 3], { "+": [{ var: "" }, 1] }, 0, "x"].slice(0, n)), { a: 5 });+  }+}+// bare (non-array) operand shorthand+for (const name of ALL_OPS) for (const v of [1, "a", null, true, { var: "a" }]) {+  add("arity", name, 1, { [name]: v }, { a: 5 });+}++// 2. Unary experiments over the rich pool.+for (const name of UNARY_OPS) for (const v of POOL_A) add("unary", name, 1, opRaw(name, [v]), null);++// 3. Pair experiments.+for (const name of PAIR_OPS) {+  for (const a of POOL_B) for (const b of POOL_B) add("pair", name, 2, opRaw(name, [a, b]), { a: 1, b: [1, 2] });+}++// 4. Triple experiments.+for (const name of TRIPLE_OPS) {+  for (const a of POOL_C) for (const b of POOL_C) for (const c of POOL_C) add("triple", name, 3, opRaw(name, [a, b, c]), null);+}++// 5. Number-string conversion through many operators (also via data).+const NUM_CONTEXTS = [+  (t) => op("+", t), (t) => op("*", t, 1), (t) => op("-", t), (t) => op("-", t, 0),+  (t) => op("<", t, 1), (t) => op("==", t, 0), (t) => op("/", t, 1), (t) => op("%", t, 7),+  (t) => op("max", t), (t) => op("min", t, 5), (t) => op("cat", op("+", t)),+  (t) => op("+", { var: "s" }, t), (t) => op("===", op("*", t, 1), 0),+  (t) => op("!!", t), (t) => op(">", t, -1),+];+for (const s of NUMERIC_STRINGS) {+  NUM_CONTEXTS.forEach((ctx, i) => add("numstr", "numeric", 1, ctx(s), { s }));+}++// 6. Number printing through cat and through results.+const PRINT_NUMBERS = [...NUMBERS, ...COMPUTED_NUMBERS];+for (const v of PRINT_NUMBERS) {+  add("numfmt", "cat", 1, op("cat", v), null);+  add("numfmt", "+", 1, op("+", v), null);+  add("numfmt", "*", 2, op("*", v, 1), null);+  add("numfmt", "-", 2, op("-", v, 0), null);+  add("numfmt", "*", 2, op("*", v, 3), null);+  add("numfmt", "/", 2, op("/", v, 3), null);+  add("numfmt", "/", 2, op("/", 3, v), null);+  add("numfmt", "cat", 2, op("cat", "n=", v), null);+  add("numfmt", "in", 2, op("in", v, [v]), null);+}+for (const [a, b] of [[0.1, 0.2], [0.1, 0.7], [1e21, 1], [9007199254740992, 1], [9007199254740993, 2],+  [raw("1e308"), raw("1e308")], [5e-324, 5e-324], [5e-324, 0.5], [0.3, -0.1], [1e16, 1], [1.5, 1.5]]) {+  for (const o of ["+", "-", "*", "/", "%", "max", "min"]) add("numfmt", o, 2, op(o, a, b), null);+}++// 6b. Shortest-digit ties: doubles exactly halfway between two decimals of+// the shortest length (ECMAScript picks the even one).+for (let k = 0n; k < 60n; k++) {+  const big = String(9007199254740992n + k);+  for (const divisor of [10, 100, 1000, 8, 3, 7]) add("tie", "/", 2, op("/", raw(big), divisor), null);+  add("tie", "*", 2, op("*", raw(big), 1.1), null);+  add("tie", "+", 2, op("+", raw(big), 0.5), null);+  add("tie", "-", 2, op("-", raw(String(4503599627370496n + k)), 0.5), null);+  add("tie", "cat", 1, op("cat", op("/", raw(String(18014398509481984n + k * 3n)), 100)), null);+}+for (const x of [0.5, 1.5, 2.5, 4503599627370495.5, 4503599627370496.5, 1125899906842624.25, 562949953421312.125]) {+  for (const d of [1, 10, 100, 3]) add("tie", "/", 2, op("/", x, d), null);+  add("tie", "cat", 1, op("cat", x), null);+}++// 7. Signed zero, NaN, infinity: every combination, then observed.+const SZ = [0, NEG_ZERO, 1, -1, 2, -2, POS_INF, NEG_INF, NOT_A_NUMBER];+const OBSERVERS = [+  (x) => x,+  (x) => op("/", 1, x),+  (x) => op("/", -1, x),+  (x) => op(">", 0, op("/", 1, x)),+  (x) => op("<", 0, op("/", 1, x)),+  (x) => op("===", op("/", 1, x), op("/", 1, 0)),+  (x) => op("===", op("/", 1, x), op("/", -1, 0)),+  (x) => op("cat", x),+  (x) => op("!", x),+  (x) => op("+", x),+];+for (const name of ["max", "min", "%", "*", "/", "+", "-"]) {+  for (const a of SZ) for (const b of SZ) {+    for (const [i, obs] of OBSERVERS.entries()) add("signedzero", name, 2, obs(op(name, a, b)), null);+  }+}+for (const a of SZ) for (const b of SZ) for (const c of SZ) {+  add("signedzero", "max", 3, op("/", 1, op("max", a, b, c)), null);+  add("signedzero", "min", 3, op("/", 1, op("min", a, b, c)), null);+}+// literal -0 in JSON text is read by JSON.parse as -0; aeson reads it as 0.+// That input difference is recorded as a deviation, not tested here.++// 8. Unicode: length, indexing, substr, ordering, in, cat.+const STARTS = [-100, -3, -2, -1, 0, 1, 2, 3, 5, 100, 0.5, 1.9, -1.9, raw("1e21"), NOT_A_NUMBER, null, "1", ""];+const LENS = [undefined, 0, 1, 2, 3, -1, -2, -3, 100, 0.5, 1.9, -0.5, NOT_A_NUMBER, null, "2", POS_INF, NEG_INF];+for (const s of UNICODE_STRINGS) {+  add("unicode", "var", 1, { var: "s.length" }, { s });+  add("unicode", "var", 1, { var: "s" }, { s });+  for (let i = 0; i < 7; i++) add("unicode", "var", 1, { var: "s." + i }, { s });+  add("unicode", "var", 1, { var: "s.7" }, { s });+  add("unicode", "cat", 2, op("cat", s, s), null);+  add("unicode", "in", 2, op("in", s, "x" + s + "y"), null);+  add("unicode", "in", 2, op("in", "", s), null);+  add("unicode", "!", 1, op("!", s), null);+  for (const st of STARTS) {+    add("unicode", "substr", 2, op("substr", s, st), null);+    for (const ln of LENS) {+      if (ln === undefined) continue;+      add("unicode", "substr", 3, op("substr", s, st, ln), null);+    }+  }+}+for (const a of UNICODE_STRINGS) for (const b of UNICODE_STRINGS) {+  for (const o of ["<", "<=", ">", ">=", "==", "===", "!=", "!==", "in", "max", "min"]) add("unicode", o, 2, op(o, a, b), null);+  add("unicode", "<", 3, op("<", a, b, "\uffff"), null);+}+for (const s of UNICODE_STRINGS) {+  add("unicode", "map", 2, op("map", [s], { var: "" }), null);+  add("unicode", "merge", 2, op("merge", [s], s), null);+}++// 9. Variable paths on awkward data.+const PATHS = [+  "", null, "a", "a.b", "a.0", "a.1", "a.length", ".", "a.", ".a", "..", "a..b", "constructor",+  "__proto__", "toString", "valueOf", "hasOwnProperty", "length", "0", "1", "2", "01", "-1", "1e1",+  "4294967296", "4294967295", "4294967294", "1.5", "a.constructor", "a.__proto__", "a.toString",+  "length.length", "isPrototypeOf", "toLocaleString", "map", "concat", "indexOf", "charAt", "trim", "0.0", "0.length", " ", "a b", "A", "α", "😀", "a.😀", "[0]", "a[0]", "a.b.c.d",+  "0.a", raw("0"), raw("1"), raw("1.5"), true, false, [], {}, [0], ["a"], ["a", "b"], 0, 1, -1, 2, raw("1e21"),+];+const VAR_DATA = [+  null, {}, [], "", "str", "😀", "ab😀cd", 0, 5, true, false, [1, 2, 3], [[1], [2]],+  { a: 1 }, { a: null }, { a: "" }, { a: [] }, { a: {} }, { a: [1, { b: 2 }] }, { a: { b: { c: { d: 4 } } } },+  { "": 1 }, { a: { "": 5 } }, { 1: "x", 0: "y" }, { length: 5 }, { constructor: 1 }, { toString: 2 },+  { hasOwnProperty: 3 }, { a: "xyz" }, { a: [10, 20] }, { "a.b": 7 }, { ".": 8 }, { "a": { "b": null } },+  { 0: "z" }, { "01": "z" }, { "-1": "m" }, { "4294967296": "k" }, { "0.0": 1 },+  { a: 1, b: 2 }, { "😀": 1 }, { "α": 2 },+];+VAR_DATA.push(raw('{"__proto__":{"x":1}}'), raw('{"__proto__":1}'));+for (const d of VAR_DATA) for (const p of PATHS) {+  add("var", "var", 1, op("var", p), d);+  add("var", "var", 2, op("var", p, "dflt"), d);+  add("var", "var", 2, op("var", p, null), d);+  add("var", "var", 2, op("var", p, UNDEF), d);+  add("var", "missing", 1, op("missing", p), d);+  add("var", "missing", 1, opRaw("missing", [[p, "a"]]), d);+  add("var", "missing_some", 2, op("missing_some", 1, [p, "a"]), d);+}+for (const d of VAR_DATA) {+  add("var", "missing", 0, op("missing"), d);+  add("var", "missing", 2, op("missing", "a", "b"), d);+  add("var", "missing", 3, op("missing", "a", "b", "c"), d);+  add("var", "missing_some", 2, op("missing_some", 0, ["a", "b"]), d);+  add("var", "missing_some", 2, op("missing_some", 1, ["a", "b"]), d);+  add("var", "missing_some", 2, op("missing_some", 2, ["a", "b"]), d);+  add("var", "missing_some", 2, op("missing_some", 3, ["a", "b"]), d);+  add("var", "missing_some", 2, op("missing_some", "1", ["a", "b"]), d);+  add("var", "missing_some", 2, op("missing_some", null, ["a", "b"]), d);+  add("var", "missing_some", 2, op("missing_some", 1, []), d);+  add("var", "missing_some", 2, op("missing_some", 1, "ab"), d);+  add("var", "missing_some", 2, op("missing_some", 1, null), d);+  add("var", "missing_some", 2, op("missing_some", 1, { a: 1 }), d);+  add("var", "missing", 1, op("missing", op("var", "a")), d);+  add("var", "missing", 1, op("missing", { merge: ["a", ["b"]] }), d);+}++// 10. Collections: every iterating operator over awkward collections and bodies.+const COLLECTIONS = [+  [], [0], [1], [1, 2, 3], [0, 0], [null], [[1], [2]], ["a", "b"], [1, "1", true, null], [{}], [{ a: 1 }, { a: 2 }],+  [[]], "str", "", null, 0, 5, true, {}, { a: 1 }, UNDEF, NOT_A_NUMBER, [UNDEF], [NEG_ZERO, 0],+  { var: "xs" }, { merge: [[1], [2, 3]] }, { var: "o" },+];+const BODIES = [+  { var: "" }, true, false, null, 0, 1, "s", { "+": [{ var: "" }, 1] }, { "!": { var: "" } },+  { ">": [{ var: "" }, 1] }, { var: "a" }, { var: "current" }, { var: "accumulator" },+  { "+": [{ var: "current" }, { var: "accumulator" }] },+  { cat: [{ var: "accumulator" }, { var: "current" }] },+  { merge: [{ var: "accumulator" }, [{ var: "current" }]] },+  { if: [{ var: "" }, 1, 2] }, { nonexistent: [] }, { "==": [{ var: "" }, 1] },+  { var: ["", "dflt"] }, { and: [] }, { missing: ["a"] }, { in: [{ var: "" }, [1, 2]] },+  { "*": [{ var: "current" }, 2] }, { max: [{ var: "current" }, { var: "accumulator" }] },+  { "/": [1, { var: "" }] }, { "%": [{ var: "" }, 2] }, { var: "length" },+];+const COLL_DATA = { xs: [1, 2, 3], o: { a: 1 }, a: 9 };+for (const name of ["map", "filter", "all", "none", "some"]) {+  for (const c of COLLECTIONS) for (const b of BODIES) add("collection", name, 2, op(name, c, b), COLL_DATA);+}+for (const c of COLLECTIONS) for (const b of BODIES) for (const init of [undefined, 0, "", [], null, UNDEF, 1, "x"]) {+  if (init === undefined) add("collection", "reduce", 2, op("reduce", c, b), COLL_DATA);+  else add("collection", "reduce", 3, op("reduce", c, b, init), COLL_DATA);+}+for (const a of COLLECTIONS) for (const b of COLLECTIONS) {+  add("collection", "merge", 2, op("merge", a, b), COLL_DATA);+  add("collection", "in", 2, op("in", a, b), COLL_DATA);+  add("collection", "cat", 2, op("cat", a, b), COLL_DATA);+}++// 11. Control flow, short-circuit, unknown operators in untaken branches.+const BAD = { nonexistent: [] };+// The reference throws on an empty * only when it is evaluated+// ("Reduce of empty array with no initial value"), so in an untaken branch it+// is harmless.+const EMPTY_MUL = { "*": [] };+const FLOW = [+  op("if", true, 1, BAD), op("if", false, BAD, 2), op("if", false, BAD), op("if", true, 1, BAD, BAD),+  op("if", false, 1, true, 2, BAD), op("if", false, 1, false, 2, 3), op("if", false, 1, false, 2),+  op("if", 1), op("if", BAD), op("if", true, BAD), op("if", false, BAD), op("if", [], 1, 2), op("if", [0], 1, 2),+  op("if", "0", 1, 2), op("if", "", 1, 2), op("if", NEG_ZERO, 1, 2), op("if", NOT_A_NUMBER, 1, 2),+  op("if", {}, 1, 2), op("if", UNDEF, 1, 2), op("?:", true, 1, 2), op("?:", false, 1, 2), op("?:", 0, 1),+  op("and", false, BAD), op("and", true, BAD), op("or", true, BAD), op("or", false, BAD),+  op("and", 1, 2, 3), op("and", 1, 0, BAD), op("and", 0), op("and", [], 1), op("and", "", 1),+  op("or", 0, "", [], null), op("or", 0, "", [], 7), op("or", [], {}), op("and", {}, []), op("or"),+  op("and", NEG_ZERO), op("or", NOT_A_NUMBER, 0), op("and", 1, UNDEF), op("or", 0, UNDEF),+  op("!", []), op("!", [[]]), op("!", {}), op("!", "0"), op("!", "false"), op("!!", []), op("!!", [0]),+  op("!!", NOT_A_NUMBER), op("!!", NEG_ZERO), op("!", UNDEF), op("!!", UNDEF),+  { "": [] }, { "": 1 }, { "a.b": [] }, { ".a": [] }, { "a.": [] }, { a: [], b: [] }, { constructor: [] },+  { hasOwnProperty: [] }, { toString: [] }, raw('{"__proto__":[]}'), { "var.x": [] }, { "var.length": ["a"] },+  { "map.x": [] }, { "+.x": [] }, { "a.constructor": [] }, { "constructor.name": [] },+  { "hasOwnProperty.call": [] }, { "toString.call": [] }, { "": "" }, { " ": 1 }, { "😀": 1 },+  { "==": 1 }, { "==": [] }, { "==": null },+  raw('{"+":[1],"-":[2]}'),+  op("log", 1), op("log", "a"), op("log", [1]), op("log", NEG_ZERO),+];+for (const f of FLOW) add("flow", "mixed", 0, f, { a: 1 });+for (const f of FLOW) add("flow", "mixed", 0, [f, f], { a: 1 });+for (const f of FLOW) add("flow", "mixed", 0, { "cat": [f, "x"] }, { a: 1 });+// literal arrays/objects as operands and results+for (const v of [[], [1, [2]], [{ var: "a" }], [[{ var: "a" }]], { a: { var: "a" } }, { a: 1, b: 2 }, { a: [] },+  [{ "+": [1, 2] }, { "+": [3, 4] }], "str", 5, null, true, [null, UNDEF], [NEG_ZERO], [NOT_A_NUMBER, POS_INF]]) {+  add("literal", "lit", 0, v, { a: 7 });+  add("literal", "lit", 1, op("merge", v), { a: 7 });+  add("literal", "lit", 1, op("cat", v), { a: 7 });+  add("literal", "lit", 1, op("max", v), { a: 7 });+  add("literal", "lit", 1, op("if", true, v, 0), { a: 7 });+}++// 12. Deep nesting (up to the library's documented limit) and wide rules.+for (const depth of [1, 2, 5, 10, 20, 30, 50, 60, 62]) {+  let r = 1;+  for (let i = 0; i < depth; i++) r = op("+", r, 1);+  add("deep", "+", 2, r, null);+  let q = { var: "a" };+  for (let i = 0; i < depth; i++) q = op("if", true, q, 0);+  add("deep", "if", 3, q, { a: 3 });+  let l = [1];+  for (let i = 0; i < depth; i++) l = [l];+  add("deep", "lit", 0, l, null);+  add("deep", "merge", 1, op("merge", l), null);+}+for (const width of [1, 10, 100, 1000, 10000]) {+  add("wide", "+", width, opRaw("+", Array.from({ length: width }, (_, i) => i)), null);+  add("wide", "cat", width, opRaw("cat", Array.from({ length: width }, (_, i) => i % 10)), null);+  add("wide", "max", width, opRaw("max", Array.from({ length: width }, (_, i) => (i * 7919) % 1000)), null);+  add("wide", "and", width, opRaw("and", Array.from({ length: width }, () => 1)), null);+  add("wide", "merge", width, opRaw("merge", Array.from({ length: width }, (_, i) => [i])), null);+  add("wide", "lit", 0, Array.from({ length: width }, (_, i) => i), null);+  add("wide", "map", 2, op("map", Array.from({ length: width }, (_, i) => i), { "+": [{ var: "" }, 1] }), null);+  add("wide", "reduce", 3, op("reduce", Array.from({ length: width }, (_, i) => i),+    { "+": [{ var: "current" }, { var: "accumulator" }] }, 0), null);+}+add("wide", "max", 1000, opRaw("max", Array.from({ length: 1000 }, (_, i) => (i % 2 ? NEG_ZERO : 0))), null);++// 13. Coercion matrix: loose and strict equality, ordering, over typed values.+const TYPED = [+  null, UNDEF, true, false, 0, 1, -1, NEG_ZERO, NOT_A_NUMBER, POS_INF, NEG_INF, "", "0", "1", "-1", "a",+  " ", "true", "false", "null", "NaN", "Infinity", "0x0", [], [0], [1], ["a"], [[]], [null], [[0]], {}, { a: 1 },+];+for (const a of TYPED) for (const b of TYPED) {+  for (const o of ["==", "===", "!=", "!==", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "in", "cat", "max", "min", "substr"]) {+    add("coercion", o, 2, op(o, a, b), null);+  }+}++// 14. Control flow over an operand the reference throws on only when it+// evaluates it (an empty *), in branches that are and are not taken. Last, so+// that adding it left the ids of every earlier case unchanged.+const LAZY_THROW = [+  op("if", true, 1, EMPTY_MUL), op("if", false, EMPTY_MUL, 2), op("if", EMPTY_MUL, 1, 2),+  op("or", 1, EMPTY_MUL), op("or", 0, EMPTY_MUL), op("and", 0, EMPTY_MUL), op("and", 1, EMPTY_MUL),+  op("?:", true, 1, EMPTY_MUL), op("map", [], EMPTY_MUL), op("map", [1], EMPTY_MUL),+  op("filter", [], EMPTY_MUL), op("all", [], EMPTY_MUL), op("some", [], EMPTY_MUL), op("none", [], EMPTY_MUL),+  op("reduce", [], EMPTY_MUL, 0), op("reduce", [1], EMPTY_MUL, 0), op("+", 1, EMPTY_MUL), op("==", EMPTY_MUL, 1),+];+for (const f of LAZY_THROW) add("flow", "mixed", 0, f, { a: 1 });+for (const f of LAZY_THROW) add("flow", "mixed", 0, [f, f], { a: 1 });+for (const f of LAZY_THROW) add("flow", "mixed", 0, { "cat": [f, "x"] }, { a: 1 });++// 15. Every rule of the community test suite (test/community, from+// json-logic/compat-tables), with the reference's own answers. Most of the+// suite is written for the JSON Logic 2.0 dialect; what matters here is only+// that lenient mode answers each rule as json-logic-js does.+{+  const path = require("path");+  const dir = path.join(__dirname, "..", "test", "community");+  for (const file of JSON.parse(fs.readFileSync(path.join(dir, "index.json"), "utf8"))) {+    for (const entry of JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"))) {+      if (entry === null || typeof entry !== "object") continue;+      const rule = entry.rule;+      const name = rule !== null && typeof rule === "object" && !Array.isArray(rule) && Object.keys(rule).length === 1+        ? Object.keys(rule)[0] : "lit";+      add("community", name, 0, rule, entry.data === undefined ? null : entry.data);+    }+  }+}++// ---------------------------------------------------------------------+// Inherited-property deviation (docs/COMPATIBILITY.md, DEV-INHERITED).+//+// In JavaScript, {"var":"constructor"} on {} finds Object's constructor, and+// {"var":"__proto__"} finds Object.prototype, because property lookup walks+// the prototype chain. JSON data has no prototype chain in any other+// language, so jsonlogic-hs looks at own properties only. For every case+// whose variable paths use such a name (and whose data does not itself have+// a key of that name) the fixture records, besides the reference's answer,+// `expect_own`: the reference's answer when the name is replaced by a key+// that is certainly absent. The Haskell runner holds the library to+// `expect_own` for those cases.+// ---------------------------------------------------------------------+const INHERITED_NAMES = new Set([+  "constructor", "__proto__", "toString", "valueOf", "hasOwnProperty", "toLocaleString", "isPrototypeOf",+  "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__",+  "map", "concat", "indexOf", "charAt", "trim", "filter", "slice", "join",+]);+const ABSENT = "\u0001absent:";++let currentDataText = "";+function renamePath(path, used) {+  if (typeof path !== "string") return path;+  return path.split(".").map((seg) => {+    if (!INHERITED_NAMES.has(seg) || currentDataText.includes(JSON.stringify(seg))) return seg;+    used.add(seg);+    return ABSENT + seg;+  }).join(".");+}+function renameRule(rule, used) {+  if (Array.isArray(rule)) return rule.map((r) => renameRule(r, used));+  if (rule === null || typeof rule !== "object") return rule;+  const keys = Object.keys(rule);+  if (keys.length !== 1) return rule;+  const name = keys[0];+  let args = rule[name];+  const wasArray = Array.isArray(args);+  if (!wasArray) args = [args];+  let out;+  if (name === "var") {+    out = args.map((a, i) => (i === 0 ? renamePath(renameRule(a, used), used) : renameRule(a, used)));+  } else if (name === "missing") {+    out = args.map((a) => (Array.isArray(a) ? a.map((k) => renamePath(renameRule(k, used), used)) : renamePath(renameRule(a, used), used)));+  } else if (name === "missing_some") {+    out = args.map((a, i) => (i === 1 && Array.isArray(a) ? a.map((k) => renamePath(renameRule(k, used), used)) : renameRule(a, used)));+  } else {+    out = args.map((a) => renameRule(a, used));+  }+  return { [name]: wasArray ? out : out[0] };+}++// ---------------------------------------------------------------------+// Run the oracle and write the fixture.+// ---------------------------------------------------------------------+function wf(x) {+  if (typeof x === "string") return typeof x.toWellFormed === "function" ? x.toWellFormed() : x;+  if (Array.isArray(x)) return x.map(wf);+  if (x !== null && typeof x === "object") {+    const o = {};+    for (const k of Object.keys(x)) {+      // defineProperty, not assignment: a key named __proto__ must stay an own property+      Object.defineProperty(o, wf(k), { value: wf(x[k]), enumerable: true, writable: true, configurable: true });+    }+    return o;+  }+  return x;+}+function canon(x) {+  // JSON text of a reference result; undefined (and functions) are null.+  const t = JSON.stringify(wf(x === undefined ? null : x));+  return t === undefined ? "null" : t;+}+function run(rule, data) {+  try {+    return '{"ok":' + canon(jsonLogic.apply(rule, data)) + "}";+  } catch (error) {+    const message = String(error && error.message ? error.message : error);+    return '{"error":' + JSON.stringify(wf(message)) + "}";+  }+}++// jsonLogic.apply calls console.log for the "log" operator; keep stdout clean.+console.log = () => {};++function fnv(text) {+  let h = 0x811c9dc5;+  for (let i = 0; i < text.length; i++) {+    h ^= text.charCodeAt(i);+    h = Math.imul(h, 0x01000193) >>> 0;+  }+  return h >>> 0;+}++// Tiers. `core` is committed and replayed by `cabal test`; `full` is the+// whole catalogue, replayed by the nightly job. Small, hand-picked families+// are always kept whole; the big cross-product families are thinned by a+// stable hash of the case text, so the sample never changes unless the+// catalogue does.+const THIN = { pair: 8, triple: 6, unicode: 6, coercion: 8, var: 6, collection: 8, signedzero: 3, numstr: 2, unary: 2 };+const tier = (process.env.OUTLIER_TIER || "core");+function kept(c) {+  if (tier === "full") return true;+  const k = THIN[c.fam];+  return !k || fnv(c.ruleText + "\u0001" + c.dataText) % k === 0;+}++const proto = (c, rule) => {+  if (!/"(?:[^"\\]*\.)?(?:constructor|__proto__|toString|valueOf|hasOwnProperty|toLocaleString|isPrototypeOf|propertyIsEnumerable|map|concat|indexOf|charAt|trim|filter|slice|join)(?:\.[^"\\]*)?"/.test(c.ruleText)) return null;+  currentDataText = c.dataText;+  const used = new Set();+  const renamed = renameRule(rule, used);+  if (used.size === 0) return null;+  let text = run(renamed, JSON.parse(c.dataText));+  for (const name of used) text = text.split(JSON.stringify(ABSENT + name).slice(1, -1)).join(name);+  return text;+};++// DEV-REEVAL: the reference implements missing / missing_some by calling+// apply({"var": key}) on keys that were already evaluated, so a key that is+// itself a one-key object (data!) is run as a rule. jsonlogic-hs never runs+// data as rules. Cases where that happens are tagged and not compared.+function hasLogicDeep(x) {+  if (jsonLogic.is_logic(x)) return true;+  return Array.isArray(x) && x.some(hasLogicDeep);+}+function reevaluates(rule, data) {+  if (rule === null || typeof rule !== "object" || Array.isArray(rule)) return false;+  const keys = Object.keys(rule);+  if (keys.length !== 1) return false;+  const name = keys[0];+  if (name !== "missing" && name !== "missing_some") return false;+  let args = Array.isArray(rule[name]) ? rule[name] : [rule[name]];+  try {+    const values = args.map((a) => jsonLogic.apply(a, data));+    if (name === "missing") return values.some(hasLogicDeep);+    return values.length > 1 && hasLogicDeep(values[1]);+  } catch (e) {+    return false;+  }+}++const lines = [];+for (const c of cases) {+  if (!kept(c)) continue;+  const rule = JSON.parse(c.ruleText);+  const data = JSON.parse(c.dataText);+  const expect = run(rule, data);+  const own = proto(c, rule);+  let extra = "";+  if (reevaluates(rule, data)) extra = ',"dev":"reeval"';+  else if (own !== null && own !== expect) extra = ',"dev":"inherited","expect_own":' + own;+  lines.push(+    '{"id":' + JSON.stringify(c.id) + ',"fam":' + JSON.stringify(c.fam) + ',"op":' + JSON.stringify(c.op) ++    ',"n":' + c.n + ',"rule":' + c.ruleText + ',"data":' + c.dataText + ',"expect":' + expect + extra + "}"+  );+}+const meta = '{"meta":{"cases":' + lines.length + ',"oracle":' + JSON.stringify(REQUIRED_ORACLE) + ',"tier":' + JSON.stringify(tier) + "}}";+const output = [meta, ...lines].join("\n") + "\n";++const args = process.argv.slice(2);+if (args[0] === "--cases") {+  // Raw rule/data pairs, one per line, for feeding other implementations.+  fs.writeFileSync(args[1], cases.filter(kept).map((c) => '{"rule":' + c.ruleText + ',"data":' + c.dataText + "}").join("\n") + "\n");+  process.stderr.write("wrote " + lines.length + " raw cases to " + args[1] + "\n");+} else if (args[0] === "--check") {+  const current = fs.readFileSync(args[1], "utf8");+  if (current !== output) {+    process.stderr.write("outlier fixture " + args[1] + " is stale: regenerate with `node outliers.js " + args[1] + "`\n");+    process.exit(1);+  }+  process.stderr.write("outlier fixture is up to date (" + lines.length + " cases, tier " + tier + ")\n");+} else if (args.length === 1) {+  fs.writeFileSync(args[0], output);+  process.stderr.write("wrote " + lines.length + " cases to " + args[0] + " (" + output.length + " bytes, tier " + tier + ")\n");+} else {+  process.stderr.write("usage: [OUTLIER_TIER=core|full] node outliers.js OUTPUT.jsonl | --check FIXTURE | --cases FILE\n");+  process.exit(2);+}
+ differential/package-lock.json view
@@ -0,0 +1,21 @@+{+  "name": "jsonlogic-hs-differential-oracle",+  "version": "1.0.0",+  "lockfileVersion": 3,+  "requires": true,+  "packages": {+    "": {+      "name": "jsonlogic-hs-differential-oracle",+      "version": "1.0.0",+      "dependencies": {+        "json-logic-js": "2.0.5"+      }+    },+    "node_modules/json-logic-js": {+      "version": "2.0.5",+      "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz",+      "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==",+      "license": "MIT"+    }+  }+}
+ differential/package.json view
@@ -0,0 +1,9 @@+{+  "name": "jsonlogic-hs-differential-oracle",+  "version": "1.0.0",+  "private": true,+  "description": "The reference implementation, wrapped for differential testing against jsonlogic-hs.",+  "dependencies": {+    "json-logic-js": "2.0.5"+  }+}
+ docs/ARCHITECTURE.md view
@@ -0,0 +1,139 @@+# Architecture++jsonlogic-hs reads a JSON rule into a closed tree, compiles the tree once into+a function of the data, and runs that function in a small pure monad that+counts work. This page explains each stage, the modules that implement it, and+why it is built this way.++```+            parse (unfold)               compile (fold)                 run+  Value ───────────────────▶ Rule ────────────────────▶ Program ──────────────────▶ Value+  JSON      depth, arity,    tree of Node,  literals,    Val -> Run Val   step budget,+            locations        each with its  custom ops,                   limits, errors+                             JSON Pointer   var paths                      with locations+```++## Modules++The dependency graph is acyclic and points inward; nothing depends on+`JsonLogic`, the public facade.++| Module | Responsibility |+| --- | --- |+| `JsonLogic` | The supported API: `apply`, `compile`, `run`, `dataUse`, configuration, errors, locations. |+| `JsonLogic.Core.Operator` | The closed set of builtin operators and their names. The only list of them. |+| `JsonLogic.Core.Location` | JSON Pointers (RFC 6901) into a rule. |+| `JsonLogic.Core.Errors` | The closed error vocabulary, one type per stage, and their rendering. |+| `JsonLogic.Core.Config` | Limits and arity mode. |+| `JsonLogic.Core.Rule` | `RuleF`, one layer of the rule tree; `Node`, a layer with its location; `Rule = Fix Node`. |+| `JsonLogic.Core.Parse` | JSON to `Rule`: the unfold, the depth bound, and each operator's operand shape (`shapeOf`), which drives both arity modes. |+| `JsonLogic.Core.Value` | `Val`, JSON plus `NaN`, the infinities, `-0` and `undefined`; JavaScript truthiness, conversions, equality and ordering. |+| `JsonLogic.Core.Number` | Reading and writing JavaScript numbers exactly; `%`, `Math.max`, `Math.min`. |+| `JsonLogic.Core.Ryu` | Ryū, the shortest round-tripping decimal of a double. |+| `JsonLogic.Core.Utf16` | JavaScript's UTF-16 string length, indexing, slicing and ordering over `Text`. |+| `JsonLogic.Core.Custom` | The registry of caller-supplied operators. |+| `JsonLogic.Core.Eval` | The fold that compiles a `Rule` to a `Program`, the `Run` monad, and every operator's semantics. |+| `JsonLogic.Core.Analysis` | `dataUse`: which data paths a rule can read. |++## Reading: an unfold++`parseRuleWith` is `unfoldFixM step seed`. A seed is a piece of JSON still to+read, how deeply it is nested, and its location. `step` produces one `Node`+whose children are further seeds:++- an object with exactly one key is an operator call; its operands are the+  list under the key (a bare value is a list of one), at locations+  `/key/0`, `/key/1`, ... (or `/key` for the bare value);+- an array is a node whose elements are rules, at `/0`, `/1`, ...;+- anything else is a literal, and its own nesting counts towards the depth+  limit, so a deeply nested object cannot be smuggled through as data.++The depth bound is checked on every seed before any of its children exist,+so a hostile rule is refused after reading at most the limit's depth of it.++Each builtin's operands are described once, in `shapeOf`: the counts strict+mode accepts, how many leading operands the reference reads by position, and+whether it evaluates operands past the last one it uses. Strict mode checks+the count against the shape. Lenient mode repairs it as the reference would:+missing operands become `undefined` (an empty `and`), or `null` for+`reduce`'s initial value; surplus operands are dropped, or kept in a `Discard`+node when the reference evaluates them for their side effects (that is, for+whether they fail). A builtin's constructor is shaped so that a wrong count+cannot be built at all: `Lt a a (Maybe a)`, `Sub a (Maybe a)`.++## Compiling: a fold++`compileRule` is `foldFix compileNode`. Its carrier is not a value but a+function from the datum to a computation (`Val -> Run Val`). That is what lets+`map`, `filter`, `reduce`, `all`, `some` and `none` run a sub-rule once per+element against a different datum, and what makes `if`, `and` and `or`+short-circuit for free: a branch not taken is a function never called.++Everything that does not depend on the data happens in the fold, once:+literals are converted to `Val`, custom operators are looked up in the+registry, and a `var` whose path is a literal has the path split at dots.+Children are compiled lazily and shared, so a branch no run reaches is never+compiled.++Operand order follows the reference: every operand is evaluated, left to+right, before its operator runs, except for `if`, `and`, `or` and the+iterating operators' bodies.++## Running: a counted, pure computation++`Run a` is `Int -> Result a`, where the `Int` is the remaining step budget and+`Result` is either a value with the budget left or a failure with its+location. It is a state monad and an error monad in one, written out by hand+so that a step costs one allocation; there is no `IO` and no global state.++Every node charges one step before its own work. Work proportional to the+size of something is charged in proportion: converting an array to a string+or number, building a string with `cat` or an array with `merge`, walking a+string to index or measure it, and taking in a custom operator's result. So+total work is bounded by `maxSteps` times a constant, whatever the rule+(`docs/LIMITS.md`).++## Values: JavaScript, written out plainly++`Val` is JSON plus what a JavaScript evaluation can produce and JSON cannot+say: `NaN`, the infinities, negative zero and `undefined`. Arrays are+`Vector`s (constant-time index and length), objects are aeson's `KeyMap`.++The coercion rules (`==`, `<`, `ToNumber`, `parseFloat`, `String()`) are an+arbitrary table fixed by JavaScript, so they are written as one plain clause+per case of the ECMAScript algorithms, not abstracted. Numbers are read with+Clinger's exact fast path or, failing that, one correctly rounded integer+division; they are written with Ryū, whose shortest-digits answer is the one+ECMAScript specifies. Both are checked against slow exact implementations on+hundreds of thousands of doubles, and the printing against V8 directly.++## Errors++One closed type per stage: `ParseError` (depth, arity) and `EvalError`+(unknown operator, a custom operator's failure, an operand the reference+throws on, a limit). `Error` pairs either with the `Location` of the node that+failed. JavaScript semantics make most odd inputs produce `null`, `false` or+`NaN` rather than fail, so evaluation errors are rare by design.++## Extension: one open point++The builtin set is closed: `RuleF` is a sum type matched exhaustively, so an+operator cannot be half-implemented. Operators supplied by the caller are the+one open extension point, `CustomOp`, resolved against an `Operators` registry+at compile time. Their contract (eager, evaluated operands, JSON in and out,+results held to the limits) matches how the reference treats added+operations.++## Testing as part of the design++Each stage has its own tests (`ParseSpec`, `ValueSpec`, `NumberSpec`,+`EvalSpec`, `LimitsSpec`, `LocationSpec`, `AnalysisSpec`), and the whole+pipeline is held to the reference by four independent bodies of evidence:+the classic suite, the outlier catalogue, the differential test and the+community suite (`docs/CONFORMANCE.md`). The runners are themselves tested+(`scripts/check-harness.sh`).++## Decisions++The reasons behind choices that depart from the engineering standard, or+that a reader might question, are recorded in `docs/adr/`.
+ docs/COMPATIBILITY.md view
@@ -0,0 +1,172 @@+# Compatibility with the reference implementation++jsonlogic-hs is held to [json-logic-js](https://github.com/jwadhams/json-logic-js)+**2.0.5**, the reference implementation. This page lists every place it+deliberately or unavoidably differs, with the reason and the test that+pins it. Anything not listed here is a bug: please report it.++How it is held to the reference:++| Check | What it covers | Where |+| --- | --- | --- |+| Classic shared suite | The 278 cases of jsonlogic.com/tests.json | `jsonlogic-hs-conformance` |+| Outlier fixture | 26,980 awkward cases (including every community rule), answers recorded from the reference | `jsonlogic-hs-outliers` (`test/outliers.jsonl`) |+| Full outlier tier | About 140,000 cases, the same generator | nightly CI; `OUTLIER_TIER=full node differential/outliers.js` |+| Differential test | Thousands of random rules per run, shrunk on failure | `jsonlogic-hs-differential` |+| Community suite | 1,138 cases of json-logic/compat-tables, scored and pinned | `jsonlogic-hs-community` (`test/community/`) |++The fixture formats are documented in `docs/CONFORMANCE.md`, so other+implementations can use them too.++The outlier fixture is generated by `differential/outliers.js` with no+randomness, and CI regenerates it and fails if the committed file differs.+Every builtin operator has at least 20 cases, in every family listed in+`outliers/Outliers.hs`, and the runner fails if a fixture is missing,+truncated or empty.++In **lenient arity mode** the library agrees with the reference on every+outlier case except the documented deviations below, which are tagged in the+fixture and checked against a stated alternative answer (never silently+skipped, except `DEV-REEVAL`, which is counted).++## Deliberate deviations++### DEV-ARITY: a wrong number of arguments is an error (strict mode, the default)++The reference ignores surplus arguments and treats missing ones as+`undefined`. In the default **strict** mode, `{"==": [1]}` is a precise+`ArityMismatch` instead, because a rule with the wrong number of arguments+is almost always a mistake the reference hides.++Set `arityMode = Lenient` in `Config` to get the reference's behaviour+exactly, including that surplus arguments of ordinary operators are still+*evaluated* (so a surplus argument that fails still fails the rule), while+surplus arguments of `if`, `and`, `or`, `map`, `filter`, `reduce`, `all`,+`some` and `none` are never looked at.++`{"var": []}` means the data itself in both modes (the classic suite+requires it). `{"*": []}` is an `ArityMismatch` in strict mode; in lenient+mode it is accepted and fails with `InvalidOperand "*"` only if it is+evaluated, exactly where the reference throws (`Reduce of empty array with no+initial value`), so `{"if": [true, 1, {"*": []}]}` is `1` as in the+reference.++Pinned by: `CompatSpec` (arity modes), the outlier runner (every case runs in+both modes; strict must either agree or reject by arity, lenient must agree).++### DEV-IDENTITY: arrays and objects are never equal++`==`, `===`, `!=` and `!==` treat two arrays, two objects, or an array and an+object as never equal. The reference compares them by reference identity,+which parsed JSON values do not have. It differs from the reference only+where the *same* array or object is reached twice, for example+`{"===": [{"var": ""}, {"var": ""}]}` on `{}`, which is `true` in+JavaScript. Generated tests avoid such comparisons.++### DEV-LONE-SURROGATE: a severed surrogate pair becomes U+FFFD++Strings are measured, indexed, sliced and ordered by UTF-16 code unit, as in+JavaScript: `"😀"` has length 2, and sorts below `"\uE000"`. A Haskell `Text`+cannot hold half of a surrogate pair, so a slice that cuts through one+(`{"substr": ["😀", 0, 1]}`, or indexing `s.0`) returns U+FFFD for the+severed half. This is exactly what `String.prototype.toWellFormed()` makes+of the reference's answer, and is how the fixture normalises it.++### DEV-INHERITED: only own properties are visible++In JavaScript, `{"var": "constructor"}` on `{}` finds `Object`'s+constructor, and `{"var": "__proto__"}` finds `Object.prototype`, because+property lookup walks the prototype chain. JSON data has no prototype chain+in any other language, and exposing those names to rules is a hazard, so+`var`, `missing` and `missing_some` see own properties only: those paths are+simply absent unless the data really has a key of that name.++Pinned by: `CompatSpec`, and the fixture's `"dev":"inherited"` cases, each of+which carries the reference's answer for a path that is certainly absent.++### DEV-REEVAL: data is never run as rules++The reference implements `missing` and `missing_some` by calling+`apply({"var": key})` on keys that were already evaluated, so a key that+*came from the data* and looks like a one-key object is executed as a rule.+That lets data run code against itself, so jsonlogic-hs does not do it. It+matters only for such a key (the fixture tags the few generated cases+`"dev":"reeval"` and counts them without comparing).++### DEV-LOG: `log` does not print++`log` returns its argument. The pure core performs no I/O.++### DEV-CUSTOM-NAMES: custom operator names are exact++The reference resolves dotted names (`"Math.max"`) by descending into its+operations table. jsonlogic-hs matches custom operator names exactly.+Registering a custom operator named like a builtin is an error+(`ReservedOperatorName`), not a silent no-op.++### DEV-LIMITS: resource limits++The reference has none. jsonlogic-hs bounds nesting depth, evaluation+steps, string length and array length (see `docs/LIMITS.md`), and a rule+that exceeds a limit fails with `LimitExceeded` where the reference would+run on, or run out of memory.++## The JSON Logic 2.0 dialect (not a deviation: a different language)++The community suite in `test/community/` mostly describes the newer dialect+of json-logic-engine: operators the classic language does not have (`val`,+`??`, `exists`, `try`, `throw`, `preserve`), `NaN` as an error instead of a+value, variadic `/` and `-`, chained `==` and lazily evaluated comparison+chains. jsonlogic-hs implements the classic language of json-logic-js 2.0.5.+In lenient mode it scores 782 of 1,138 on the suite, file for file the same+as json-logic-js; in strict mode 839, because a wrong argument count is then+an error, as that dialect also asks. The per-file score is pinned in+`test/community/expected.json`. Implementing the 2.0 dialect as a selectable+`Config` setting is on the roadmap (`VISION.md`).++## Errors are richer than the reference's++Every error carries the JSON Pointer of the rule node that failed, and errors+are values (`Error`), never exceptions. The reference throws JavaScript+exceptions whose messages vary by engine. The outlier runner matches each+of the reference's exceptions to the library's error of the same kind.++## Input differences that live outside the library++These come from the JSON decoder (aeson), before any rule is evaluated.++- **`-0`**: JavaScript's `JSON.parse("-0")` is negative zero; aeson reads it+  as `0`. A literal `-0` in rule or data text therefore loses its sign.+  Negative zero *computed* during evaluation (`{"*": [-1, 0]}`) is exact.+- **Duplicate keys**: `{"a": 1, "a": 2}`: JavaScript keeps the last value,+  aeson keeps the first.+- **Lone surrogates in input**: `"\ud83d"` is rejected by aeson, because a+  `Text` cannot hold it.++## Numbers and strings: behaviours reproduced on purpose++These look like bugs but are the reference's behaviour, and are pinned by the+outlier fixture:++- `{"*": ["3"]}` is the string `"3"`: the reference folds without a seed.+- `{"+": ["1e3"]}` uses `parseFloat`; `{"==": ["0x10", 16]}` uses `Number()`,+  which reads hex, octal and binary literals. They differ and are separate+  functions.+- `NaN` and the infinities are real values while evaluating and become+  `null` in the result, as `JSON.stringify` writes them. Negative zero is+  preserved through `max`, `min`, `%`, `*`, `/` and `-`.+- `{"and": []}` and `{"or": []}` are JavaScript `undefined`, not `null`:+  `undefined == null`, but `undefined === null` is false, and it converts to+  `NaN`, not `0`.+- An object property whose value is `undefined` is left out of the result,+  as `JSON.stringify` leaves it out.+- Numbers print with the shortest digits that round-trip, and where two+  such strings are equally close, the one with the even last digit+  (ECMAScript's rule): `9007199254740993 / 10` prints `900719925474099.2`.+- String-to-number conversion trims ECMAScript's whitespace set: it includes+  U+FEFF, U+2028 and U+2029, and does not include U+0085 or U+180E.+- `missing_some` throws on a `null` option list, counts a string option by+  UTF-16 length, and returns the missing keys when its options have no+  length (`InvalidOperand` is the library's error for the throwing case).+- A key given to `missing` as an array is a `var` argument list: path, then+  default.
+ docs/CONFORMANCE.md view
@@ -0,0 +1,110 @@+# Using these fixtures to test any JsonLogic implementation++The fixtures in this repository are plain JSON, independent of Haskell. If you+implement JsonLogic in another language and want to behave like the+reference, json-logic-js 2.0.5, you can run them against your implementation+without touching any Haskell. This page states their format exactly and how+to judge an answer.++| Fixture | Cases | What it checks |+| --- | --- | --- |+| `test/tests.json` | 278 | The classic shared suite from jsonlogic.com |+| `test/outliers.jsonl` | 26,980 | Awkward rules with json-logic-js 2.0.5's own answers |+| full outlier tier | about 140,000 | The same catalogue, unsampled: `OUTLIER_TIER=full node differential/outliers.js out.jsonl` |+| `test/community/` | 1,138 | The community suite, scored as the compatibility tables score it |++## The classic suite (`test/tests.json`)++A JSON array. A string is a section comment. Every other entry is a+three-element array `[rule, data, expected]`: applying `rule` to `data` must+give exactly `expected`. All 278 must pass.++## The outlier catalogue (`test/outliers.jsonl`)++One JSON object per line, UTF-8, LF line endings.++The first line is `{"meta":{"cases":N,"oracle":"2.0.5","tier":"core"}}`. A+runner should check that exactly `N` lines follow, so that a truncated file+fails instead of passing quietly.++Every other line is one case:++```json+{"id":"pair-009843","fam":"pair","op":"<","n":2,"rule":{"<":[0,0.5]},"data":{"a":1,"b":[1,2]},"expect":{"ok":true}}+```++| Field | Meaning |+| --- | --- |+| `id` | Stable within one version of the generator; family name and a counter. |+| `fam` | The family: `arity`, `unary`, `pair`, `triple`, `numstr`, `numfmt`, `tie`, `signedzero`, `unicode`, `var`, `collection`, `flow`, `literal`, `deep`, `wide`, `coercion`, `community`. |+| `op` | The outermost operator (or `lit`). |+| `n` | The number of operands written. |+| `rule`, `data` | What to evaluate. Numbers are written as the generator wrote them, so a rule may contain tokens such as `1e309` or `-0.0`; read them with your JSON parser as JavaScript's `JSON.parse` would. |+| `expect` | `{"ok": value}`, the reference's result, or `{"error": message}`, the message of what it threw. |+| `dev` | Present only on the few cases that exercise a documented deviation of this library (below). |+| `expect_own` | With `dev: "inherited"`: the answer for a path that is certainly absent. |++How the expected values were written: JavaScript's `JSON.stringify` of the+result, so `NaN`, `Infinity` and `undefined` appear as `null`, negative zero+appears as `0`, and an object property whose value is `undefined` is left out.+A lone surrogate in a result string is written as U+FFFD+(`String.prototype.toWellFormed`).++Judging an answer:++- `{"ok": v}`: your result, written as JSON the same way, must equal `v`+  exactly (numbers compared as doubles).+- `{"error": m}`: your implementation must fail. The messages are V8's+  (`Unrecognized operation x`, `Cannot read properties of null (reading+  'length')`, `Reduce of empty array with no initial value`); match on the+  kind of failure, not on the text.++Cases tagged `dev` are where an implementation may reasonably choose+differently from the reference:++- `"inherited"`: the rule looks up a name such as `constructor` that+  JavaScript finds on the prototype chain of a plain object. Use+  `expect_own` if your implementation sees only a value's own properties, as+  this one does.+- `"reeval"`: the reference re-runs a key that came from the data as a rule.+  This library skips these two cases.++The families cover: every operator at 0 to 4 operands, as a list and as a+bare operand; every operator over pools of awkward values; string-to-number+conversion in fifteen contexts (hex, octal, binary, `Infinity`, every Unicode+space, 700-digit literals); number printing and shortest-digit ties;+signed zero, `NaN` and the infinities through every operator; UTF-16 length,+indexing, `substr` and ordering, including astral characters; `var` paths+on awkward data; iteration over awkward collections; control flow and+short-circuiting (including operands that throw only when evaluated); a full+loose and strict equality and ordering matrix; literals; deep and wide rules;+and every rule of the community suite.++An implementation that matches the reference on all of these, in a mode that+pads missing operands with `undefined` and evaluates but ignores surplus+ones, matches it more closely than any public test suite asks.++## The community suite (`test/community/`)++See `test/community/README.md`. Each file is a JSON array of comments+(strings) and cases `{"description", "rule", "data"?, "result" | "error"}`;+`index.json` lists the files. It is scored the way+[compat-tables](https://github.com/json-logic/compat-tables) scores+implementations: an expected `null` accepts any falsy result, numbers are+equal within 1e-10, arrays and objects are compared element by element, and+an expected error `{"type": T}` passes when the implementation fails with an+error whose name contains `T`. `test/community/expected.json` records this+library's per-file score in both arity modes. json-logic-js 2.0.5 scores+782 of 1,138; an implementation of the JSON Logic 2.0 dialect should score+far higher.++## Regenerating++```+cd differential && npm ci                 # installs exactly json-logic-js 2.0.5+node outliers.js ../test/outliers.jsonl   # the committed tier+node outliers.js --check ../test/outliers.jsonl+```++The generator has no randomness. It refuses to run against any other version+of json-logic-js, because a different oracle changes what "correct" means.
+ docs/LIMITS.md view
@@ -0,0 +1,73 @@+# Resource limits++A JsonLogic rule is data, so rules often come from users. The default+`Config` makes it safe to evaluate them: every way a rule can make the+evaluator do unbounded work or allocate unbounded memory is bounded.++```haskell+import JsonLogic++-- defaults: depth 64, 1,000,000 steps, 1,048,576-unit strings,+--           100,000-element arrays, strict arity+applyWithConfig defaultConfig noOperators rule input++-- tighter, for rules from the open internet+applyWithConfig defaultConfig {maxSteps = 10000, maxStringLength = 4096} noOperators rule input+```++| Limit | Default | Counted in | Error |+| --- | --- | --- | --- |+| `maxDepth` | 64 | levels of nesting of the rule, of any literal in it, and of a custom operator's result | `ParseFailure _ (DepthExceeded n)` or `EvalFailure _ (LimitExceeded (ValueDepthLimit n))` |+| `maxSteps` | 1,000,000 | see below | `EvalFailure _ (LimitExceeded (StepLimit n))` |+| `maxStringLength` | 1,048,576 | UTF-16 code units, the longest string `cat` builds | `EvalFailure _ (LimitExceeded (StringLimit n))` |+| `maxArrayLength` | 100,000 | elements, the longest array `merge` builds and the longest `map` or `filter` iterates | `EvalFailure _ (LimitExceeded (ArrayLimit n))` |++Each limit is exact: a rule that needs exactly the limit succeeds and one+more fails, and the tests check both sides of every boundary. The `_` in each+error is the location of the node that hit the limit.++## What a step is++A step is one rule node evaluated, plus: one extra step per element of an+array operand that an operator converts to a string or number (so comparing a+big array a thousand times is paid for), one per element or code unit that+`cat` or `merge` builds, and one per node of a value a custom operator+returns. Walking a path into the data is paid for where it is not constant time: a+`var`, `missing` or `missing_some` path costs one step per code point of a+string it indexes or measures. Object keys, array indices and array lengths+are constant time and cost nothing extra. Iteration (`map`, `filter`, `reduce`, `all`, `some`, `none`) is+charged through the nodes its body evaluates. Because building a result costs+steps in proportion to its size, the total work of an evaluation is bounded+by `maxSteps` up to a constant factor, even for rules designed to amplify+(nested maps over ten elements, a `reduce` that doubles a string, a rule+with two million operands). These cases are tests, and each finishes in well+under a second.++## What is not bounded++- **Wall-clock time.** The limits bound work, not seconds. Run untrusted+  evaluations with a timeout (`System.Timeout.timeout`) as well if that+  matters; evaluation is pure and can be interrupted safely.+- **A custom operator's own work.** The library cannot see inside it. Its+  *result* is held to the depth and step limits.+- **The size of the input.** The rule and the data are values you already+  hold. Parsing JSON text is aeson's job, and it has no depth limit of its+  own: bound the size of untrusted text before decoding it.+- **Searching or slicing a very large string.** `in`, `substr` and string+  comparison take time linear in the string, which comes from the data you+  hold; the string limit bounds what `cat` builds, not what the data holds.+- **Reading a long numeric string.** It is linear in its length (a+  million-digit string takes about 0.15 s), not bounded by a step.++## Cost++The limits are cheap. Every node costs one counter decrement, and the work+that is charged in proportion to size is work that is done anyway. On one+machine (`bench/Bench.hs`, `run` of a compiled rule): `map` or `filter` over+10,000 elements takes about 2 ms, `reduce` about 3 ms, and a small rule well+under a microsecond. To apply one rule many times, `compile` it once and+`run` it: reading and compiling happen once.++`bash scripts/bench.sh` times a fixed set of workloads and records the+environment; compare two runs on the same machine only+(`bench/README.md`).
+ docs/STABILITY.md view
@@ -0,0 +1,24 @@+# API stability++jsonlogic-hs follows the [Haskell Package Versioning Policy](https://pvp.haskell.org).++- **`JsonLogic`** is the supported API, and everything it exports is covered+  by the policy. That includes the error vocabulary (`Error`, `ParseError`,+  `EvalError`, `LimitKind`, `OperatorsError`, `Arity`), `Location` and+  `Segment`, `Config` and `ArityMode`, and `DataUse`. Match on constructors,+  never on `renderError` text, whose wording may change in any release. The+  rendered form of a `Location` (`renderLocation`) is RFC 6901 and will not+  change.+- **`JsonLogic.Core.*`** is exposed for the test suites and for advanced use+  (working with the rule tree directly). It carries **no stability promise+  before 1.0**, and may change in any release.+- Behaviour pinned by the classic suite, the outlier fixture, the community+  score (`test/community/expected.json`) or `docs/COMPATIBILITY.md` is not+  changed without a changelog entry and, for a deliberate deviation, a version+  bump that says so.+- Which node an error's location names, and how many steps a rule costs, are+  documented (`docs/LIMITS.md`) and tested, and a change to either is noted in+  the changelog.+- `Config` is a record with exported fields; construct it from+  `defaultConfig` (`defaultConfig {maxSteps = 5000}`) rather than with the+  constructor, so new fields do not break your code.
+ docs/TUTORIAL.md view
@@ -0,0 +1,214 @@+# Tutorial++This walks through jsonlogic-hs from a first rule to running untrusted rules+in production. Every example here is compiled and checked by+`tutorial/Tutorial.hs` (`cabal test jsonlogic-hs-tutorial`, part of `cabal test all`), so the+answers shown are the library's real answers.++The examples use these imports:++```haskell+{-# LANGUAGE OverloadedStrings #-}++import Data.Aeson (Value (..), decode, object, (.=))+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Maybe (fromMaybe)+import JsonLogic++-- | JSON from a string literal (examples only: use 'decode' properly in code).+json :: String -> Value+json = fromMaybe Null . decode . Char8.pack+```++## 1. A rule is JSON++A rule is an object with one key, the operator, whose value is the list of+operands. Operands can themselves be rules. `var` reads from the data.++```haskell+apply (json "{\"and\": [{\">=\": [{\"var\": \"age\"}, 18]}, {\"var\": \"verified\"}]}")+      (json "{\"age\": 21, \"verified\": true}")+-- Right (Bool True)+```++Anything that is not a one-key object is a literal: `5`, `"x"`, `null`, and+objects with several keys evaluate to themselves; arrays are evaluated element+by element.++```haskell+apply (json "[{\"var\": \"a\"}, 2]") (json "{\"a\": 1}")+-- Right (Array [Number 1.0,Number 2.0])+```++Paths are dotted, and numbers index arrays:++```haskell+apply (json "{\"var\": \"user.roles.0\"}") (json "{\"user\": {\"roles\": [\"admin\"]}}")+-- Right (String "admin")+apply (json "{\"var\": [\"missing.path\", \"default\"]}") (json "{}")+-- Right (String "default")+```++## 2. JavaScript semantics, on purpose++JsonLogic is defined by its JavaScript reference, so comparisons and+arithmetic coerce the way JavaScript does. This surprises people; it is also+what every other implementation they share rules with does.++```haskell+apply (json "{\"==\": [1, \"1\"]}") Null      -- Right (Bool True)+apply (json "{\"===\": [1, \"1\"]}") Null     -- Right (Bool False)+apply (json "{\"<\": [\"10\", \"9\"]}") Null  -- Right (Bool True): two strings compare as strings+apply (json "{\"+\": [\"3\", 4]}") Null       -- Right (Number 7.0)+apply (json "{\"/\": [1, 0]}") Null           -- Right Null: Infinity has no JSON form+apply (json "{\"cat\": [0.1, \"+\", 0.2]}") Null+-- Right (String "0.1+0.2")+```++An empty array is falsy (JsonLogic's one change to JavaScript truthiness),+and `"0"` is truthy:++```haskell+apply (json "{\"if\": [[], \"yes\", \"no\"]}") Null   -- Right (String "no")+apply (json "{\"!!\": \"0\"}") Null                   -- Right (Bool True)+```++`docs/COMPATIBILITY.md` lists the few places the library deliberately differs.++## 3. Compile once, run many times++`apply` reads the rule every time. To evaluate one rule against many inputs,+`compile` it once: that parses it, checks it, resolves custom operators and+builds the evaluator, and `run` only evaluates.++```haskell+case compile defaultConfig noOperators (json "{\"*\": [{\"var\": \"price\"}, 1.2]}") of+  Left err -> putStrLn (show err)+  Right rule -> print (map (run rule) [json "{\"price\": 10}", json "{\"price\": 2.5}"])+-- [Right (Number 12.0),Right (Number 3.0)]+```++A compiled `Rule` is an ordinary immutable value: share it between threads,+keep it in a cache, run it as often as you like.++## 4. Reading errors++Every failure is an `Error` with a stage, a reason you can match on, and the+location of the node that failed, as a JSON Pointer into the rule.++```haskell+apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null+-- Left (EvalFailure (fromSegments [Key "if",Index 1]) (UnknownOperator (OperatorName "nope")))++either renderError (const "") (apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null)+-- "at /if/1: unknown operator \"nope\""+```++Match on constructors, not on `renderError`'s text, which may change:++```haskell+describe :: Either Error Value -> String+describe result = case result of+  Right v -> "ok: " ++ show v+  Left (ParseFailure at (ArityMismatch (OperatorName op) _ given)) ->+    "rule error at " ++ show (renderLocation at) ++ ": " ++ show op ++ " given " ++ show given+  Left (EvalFailure _ (LimitExceeded _)) -> "too expensive"+  Left other -> "failed: " ++ show (renderError other)+```++Two things are not errors in JsonLogic, because they are not in JavaScript:+odd operands produce `null`, `false` or `NaN`-turned-`null`, and an unknown+operator in a branch that is never taken is never looked at:++```haskell+apply (json "{\"or\": [true, {\"nope\": []}]}") Null   -- Right (Bool True)+```++## 5. Strict and lenient arity++By default a wrong number of operands is an error, because it is almost+always a mistake:++```haskell+apply (json "{\"==\": [1]}") Null+-- Left (ParseFailure (fromSegments []) (ArityMismatch (OperatorName "==") (Exactly 2) 1))+```++The reference instead pads missing operands with `undefined` and ignores+extra ones. If you share rules with systems that rely on that, ask for it:++```haskell+applyWithConfig defaultConfig {arityMode = Lenient} noOperators (json "{\"==\": [1]}") Null+-- Right (Bool False)+```++Lenient mode reproduces the reference exactly, including its quirks (a+surplus operand is still evaluated, so if it fails the rule fails).++## 6. Rules from users: limits++A rule is data, so rules often come from people you do not control. The+default `Config` is already safe; tighten it for the open internet:++```haskell+let strict = defaultConfig {maxSteps = 10000, maxStringLength = 4096, maxArrayLength = 1000}+applyWithConfig strict noOperators (json "{\"map\": [{\"var\": \"xs\"}, {\"map\": [{\"var\": \"\"}, 1]}]}") (object ["xs" .= [[1 .. 2000 :: Int]]])+-- Left (EvalFailure (fromSegments [Key "map",Index 1]) (LimitExceeded (ArrayLimit 1000)))+```++What each limit counts, and what is not bounded (wall-clock time, a custom+operator's own work, the size of the input you decoded), is in+`docs/LIMITS.md`. Decode untrusted JSON with a size limit first, and wrap+evaluation in `System.Timeout.timeout` if seconds matter.++## 7. Custom operators++Operators beyond the builtin set are supplied by you. An operator receives+the datum in scope and its operands, already evaluated, as JSON:++```haskell+import qualified Data.Text as Text++startsWith :: CustomOperator+startsWith _ [String prefix, String s] = Right (Bool (prefix `Text.isPrefixOf` s))+startsWith _ _ = Left "starts_with takes two strings"++operators :: Either OperatorsError Operators+operators = operatorsFromList [(OperatorName "starts_with", startsWith)]++-- with Right ops = operators:+applyWith ops (json "{\"starts_with\": [\"ad\", {\"var\": \"role\"}]}") (json "{\"role\": \"admin\"}")+-- Right (Bool True)+applyWith ops (json "{\"starts_with\": [1]}") Null+-- Left (EvalFailure (fromSegments []) (CustomOperatorFailed (OperatorName "starts_with") "starts_with takes two strings"))+```++A registry refuses a builtin name (`ReservedOperatorName`) and a name given+twice (`DuplicateOperatorName`) instead of silently picking one. Registries+combine with `<>`, the left one winning. Operators are resolved when the rule+is compiled; the full contract is on `CustomOperator`.++## 8. What does this rule read?++`dataUse` tells you, without running the rule, which paths of the data it can+read: useful to fetch only those fields, or to know which rules a change to+the data can affect.++```haskell+dataUse <$> compile defaultConfig noOperators+  (json "{\"and\": [{\"var\": \"user.age\"}, {\"some\": [{\"var\": \"orders\"}, {\">\": [{\"var\": \"total\"}, 100]}]}]}")+-- Right (DataUse {usedPaths = ["orders","user.age"], usesAllData = False})+```++The `var "total"` inside `some` reads each order, not the data, so it is not+listed. When `usesAllData` is `False`, the answer is guaranteed to depend on+the data only through the listed paths. A computed path, `{"var": ""}`, or a+custom operator (which is handed the whole datum) sets `usesAllData`.++## Where next++- `docs/COMPATIBILITY.md`: every deliberate difference from the reference.+- `docs/LIMITS.md`: the resource limits in detail.+- `docs/ARCHITECTURE.md`: how the library is built.+- The Haddock documentation of the `JsonLogic` module: the reference for+  every function and constructor.
+ jsonlogic-hs.cabal view
@@ -0,0 +1,258 @@+cabal-version:      3.0+name:               jsonlogic-hs+version:            0.1.0.0+synopsis:           JsonLogic rule engine, checked against the JavaScript reference+description:+  jsonlogic-hs is a native Haskell implementation of JsonLogic+  (<https://jsonlogic.com>), a small, closed rule language that is itself+  JSON, so rules can be stored, shared between systems, and evaluated+  against data without running arbitrary code.++  It follows the JavaScript reference implementation, json-logic-js 2.0.5:+  all 278 cases of the classic shared suite pass, and in lenient arity mode+  it gives the reference's own answers on a committed catalogue of about+  27,000 deliberately awkward rules (type coercion, UTF-16 strings, signed+  zero, NaN, infinities, undefined, numeric edge cases, odd arities, and+  every rule of the community suite), with a 140,000-case tier and thousands+  of random rules checked against the reference in CI. The few deliberate+  differences are listed in docs/COMPATIBILITY.md.++  Rules are compiled once and run many times. Evaluation is pure and bounded:+  rule depth, evaluation steps, string length and array length all have+  configurable limits, so rules from an untrusted source cannot run+  unbounded work or exhaust memory. Every error carries the JSON Pointer of+  the rule node that failed. Arity checking is strict by default and can be+  switched to the reference's lenient behaviour. Custom operators are+  supplied by the caller, and dataUse reports which parts of the data a rule+  can read.++  The supported API is the "JsonLogic" module; docs/TUTORIAL.md walks through+  it. The "JsonLogic.Core" modules are exposed for advanced use and may+  change before 1.0.+category:           Data, JSON+license:            MIT+license-file:       LICENSE+author:             Sabrinathan Nair+maintainer:         259497327+Bombay-Boyz@users.noreply.github.com+copyright:          (c) 2026 Sabrinathan Nair+homepage:           https://github.com/Bombay-Boyz/jsonlogic-hs+bug-reports:        https://github.com/Bombay-Boyz/jsonlogic-hs/issues+build-type:         Simple+tested-with:        GHC ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.4 || ==9.14.1+extra-doc-files:    CHANGELOG.md+                    README.md+                    docs/ARCHITECTURE.md+                    docs/COMPATIBILITY.md+                    docs/CONFORMANCE.md+                    docs/LIMITS.md+                    docs/STABILITY.md+                    docs/TUTORIAL.md+extra-source-files: test/tests.json+                    test/outliers.jsonl+                    test/community/LICENSE+                    test/community/README.md+                    test/community/*.json+                    test/community/arithmetic/*.json+                    test/community/array/*.json+                    test/community/comparison/*.json+                    test/community/control/*.json+                    test/community/string/*.json+                    differential/README.md+                    differential/oracle.js+                    differential/outliers.js+                    differential/package.json+                    differential/package-lock.json++-- Treat warnings as errors during development and CI, but not in released+-- builds: new GHC versions add warnings that would otherwise break+-- downstream installs (`cabal check` rejects an unconditional -Werror).+-- Enabled for our own builds through cabal.project.+flag werror+  description: Treat warnings as errors (enable in dev/CI, off for releases).+  default:     False+  manual:      True++-- The differential test needs Node.js to run, so it is a development tool:+-- off by default so that installing the package never builds it.+flag differential+  description: Build the differential-test executable (needs Node.js to run).+  default:     False+  manual:      True++-- Benchmarks are a development tool too.+flag bench+  description: Build the benchmark executable.+  default:     False+  manual:      True++-- Standard 5.1: the warning flags, shared so every component is held to the+-- same bar.+common warnings+  default-language: Haskell2010+  ghc-options:      -Wall+                    -Wcompat+                    -Widentities+                    -Wincomplete-record-updates+                    -Wincomplete-uni-patterns+                    -Wmissing-export-lists+                    -Wpartial-fields+                    -Wredundant-constraints+                    -Wunused-imports+                    -Wunused-local-binds+                    -Wunused-matches+                    -Wunused-top-binds+  if flag(werror)+    ghc-options:    -Werror++-- The supported API is the JsonLogic module. JsonLogic.Core.* is exposed for+-- advanced use and for the test suites, and may change before 1.0.+library+  import:           warnings+  hs-source-dirs:   src+  exposed-modules:  JsonLogic+                    JsonLogic.Core.Analysis+                    JsonLogic.Core.Config+                    JsonLogic.Core.Custom+                    JsonLogic.Core.Errors+                    JsonLogic.Core.Eval+                    JsonLogic.Core.Location+                    JsonLogic.Core.Number+                    JsonLogic.Core.Operator+                    JsonLogic.Core.Parse+                    JsonLogic.Core.Rule+                    JsonLogic.Core.Ryu+                    JsonLogic.Core.Utf16+                    JsonLogic.Core.Value+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , containers           >=0.6 && <0.9+                  , data-fix             >=0.3 && <0.4+                  , scientific           >=0.3 && <0.4+                  , text                 >=2.0 && <2.3+                  , vector               >=0.12.3 && <0.14++test-suite jsonlogic-hs-test+  import:           warnings+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test+  main-is:          Spec.hs+  other-modules:    AnalysisSpec+                    CompatSpec+                    EvalSpec+                    Gen+                    LimitsSpec+                    LocationSpec+                    NumberSpec+                    ParseSpec+                    Support+                    ValueSpec+  -- a runaway allocation fails the test instead of swapping the machine+  ghc-options:      -rtsopts "-with-rtsopts=-M2g"+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , data-fix             >=0.3 && <0.4+                  , hspec                >=2.10 && <2.12+                  , QuickCheck           >=2.14 && <2.20+                  , text                 >=2.0 && <2.3+                  , vector               >=0.12.3 && <0.14+                  , jsonlogic-hs++-- Runs the classic JsonLogic suite (test/tests.json) and fails if any case+-- does not pass. Run it with:+--   cabal test jsonlogic-hs-conformance --test-show-details=direct+test-suite jsonlogic-hs-conformance+  import:           warnings+  type:             exitcode-stdio-1.0+  hs-source-dirs:   conformance+  main-is:          Conformance.hs+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , directory            >=1.3 && <1.4+                  , jsonlogic-hs++-- Replays the committed outlier fixture (test/outliers.jsonl), the+-- reference implementation's own answers to about 27,000 awkward cases.+-- Needs neither Node nor a network. Pass a path to replay another tier:+--   cabal run jsonlogic-hs-outliers -- /path/to/full.jsonl+test-suite jsonlogic-hs-outliers+  import:           warnings+  type:             exitcode-stdio-1.0+  hs-source-dirs:   outliers+  main-is:          Outliers.hs+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , containers           >=0.6 && <0.9+                  , directory            >=1.3 && <1.4+                  , scientific           >=0.3 && <0.4+                  , text                 >=2.0 && <2.3+                  , jsonlogic-hs++-- Every example in docs/TUTORIAL.md, checked against the library.+test-suite jsonlogic-hs-tutorial+  import:           warnings+  type:             exitcode-stdio-1.0+  hs-source-dirs:   tutorial+  main-is:          Tutorial.hs+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , text                 >=2.0 && <2.3+                  , jsonlogic-hs++-- Scores the library on the community suite (test/community, from+-- json-logic/compat-tables) and fails if the score differs from+-- test/community/expected.json. See test/community/README.md.+test-suite jsonlogic-hs-community+  import:           warnings+  type:             exitcode-stdio-1.0+  hs-source-dirs:   community+  main-is:          Community.hs+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , directory            >=1.3 && <1.4+                  , text                 >=2.0 && <2.3+                  , vector               >=0.12.3 && <0.14+                  , jsonlogic-hs++-- Differential test against the real JavaScript implementation. Needs+-- Node.js, so it is a flag-gated executable and not part of `cabal test`.+-- See differential/README.md.+executable jsonlogic-hs-differential+  import:           warnings+  if !flag(differential)+    buildable:      False+  hs-source-dirs:   differential+  main-is:          Differential.hs+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , directory            >=1.3 && <1.4+                  , process              >=1.6 && <1.7+                  , QuickCheck           >=2.14 && <2.20+                  , scientific           >=0.3 && <0.4+                  , text                 >=2.0 && <2.3+                  , jsonlogic-hs++-- Benchmarks. See docs/LIMITS.md and bench/Bench.hs.+executable jsonlogic-hs-bench+  import:           warnings+  if !flag(bench)+    buildable:      False+  hs-source-dirs:   bench+  main-is:          Bench.hs+  -- -T makes the RTS keep allocation statistics, which tasty-bench reports+  ghc-options:      -rtsopts "-with-rtsopts=-T"+  build-depends:    base                 >=4.18 && <4.23+                  , aeson                >=2.1 && <2.4+                  , bytestring           >=0.11 && <0.13+                  , tasty-bench          >=0.3 && <0.5+                  , text                 >=2.0 && <2.3+                  , jsonlogic-hs++source-repository head+  type:     git+  location: https://github.com/Bombay-Boyz/jsonlogic-hs.git
+ outliers/Outliers.hs view
@@ -0,0 +1,270 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Replays the outlier fixture (test/outliers.jsonl) offline.+--+-- The fixture is written by differential/outliers.js from the reference+-- implementation's own answers; see that file for how the cases are built.+-- This runner needs neither Node nor a network, and it cannot pass by+-- running nothing: a missing, unreadable, truncated or empty fixture, an+-- operator with too few cases, or a line it cannot classify all fail the+-- run.+--+-- Every case is run twice:+--+--   * in 'Lenient' mode, where the library must agree with the reference+--     exactly: the same value, or an error of the same class; and+--   * in 'Strict' mode (the default), where it must give the same answer+--     unless the rule has an operator with the wrong number of arguments,+--     in which case it must reject it with 'ArityMismatch'.+--+-- Two documented deviations are honoured, each only for cases the+-- generator tagged (docs/COMPATIBILITY.md): "inherited" cases are held to+-- the reference's answer for a property that is certainly absent, and+-- "reeval" cases are counted but not compared.+--+-- Usage: jsonlogic-hs-outliers [FIXTURE]   (default test/outliers.jsonl)+module Main (main) where++import Control.Monad (unless, when)+import Data.Aeson+  ( Value (..)+  , eitherDecodeStrict+  , encode+  )+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Char8 as B+import qualified Data.ByteString.Lazy.Char8 as L+import Data.IORef+import Data.List (isInfixOf)+import qualified Data.Map.Strict as Map+import Data.Scientific (toBoundedInteger)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (doesFileExist)+import System.Environment (getArgs)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, hSetEncoding, stderr, stdout, utf8)++import JsonLogic+import JsonLogic.Core.Operator (allBuiltins, builtinName)++-- | One fixture case.+data Case = Case+  { caseId :: Text+  , caseFam :: Text+  , caseOp :: Text+  , caseRule :: Value+  , caseData :: Value+  , caseExpect :: Reference+  , caseDev :: Maybe Text+  , caseOwn :: Maybe Reference+  }++data Reference = RefValue Value | RefError Text++main :: IO ()+main = do+  hSetEncoding stdout utf8+  hSetEncoding stderr utf8+  args <- getArgs+  let path = case args of+        p : _ -> p+        [] -> "test/outliers.jsonl"+  exists <- doesFileExist path+  unless exists (die' ("outlier fixture not found: " ++ path))+  contents <- B.readFile path+  (meta, rows) <- case B.lines contents of+    metaLine : rest -> do+      declared <- either (die' . ("unreadable meta line: " ++)) pure (eitherDecodeStrict metaLine)+      pure (declared, rest)+    [] -> die' ("outlier fixture is empty: " ++ path)+  expected <- declaredCases meta+  when (length rows /= expected) $+    die' ("fixture has " ++ show (length rows) ++ " cases but declares " ++ show expected ++ ": truncated or edited")+  when (expected < 1000) $ die' "fixture declares fewer than 1000 cases: refusing to pass"+  cases <- mapM parseCase (zip [2 :: Int ..] rows)++  failures <- newIORef ([] :: [String])+  tally <- newIORef (Map.empty :: Map.Map String Int)+  let bump key = modifyIORef' tally (Map.insertWith (+) key 1)+      failWith msg = modifyIORef' failures (msg :)+  mapM_ (runCase bump failWith) cases++  -- coverage gate: every builtin operator, and every family, has cases+  let perOp = Map.fromListWith (+) [(Text.unpack (caseOp c), 1 :: Int) | c <- cases]+      perFam = Map.fromListWith (+) [(Text.unpack (caseFam c), 1 :: Int) | c <- cases]+  mapM_+    ( \o ->+        when (Map.findWithDefault 0 o perOp < 20) $+          failWith ("coverage: operator " ++ show o ++ " has fewer than 20 outlier cases")+    )+    builtins+  mapM_+    ( \f ->+        when (Map.findWithDefault 0 f perFam < 1) $+          failWith ("coverage: family " ++ show f ++ " has no cases")+    )+    families++  counts <- readIORef tally+  let count k = Map.findWithDefault 0 k counts+  putStrLn+    ( "outliers: "+        ++ show (length cases)+        ++ " cases from "+        ++ path+        ++ "\n  lenient agrees with the reference: "+        ++ show (count "lenient-ok")+        ++ "\n  strict agrees with the reference:  "+        ++ show (count "strict-ok")+        ++ "\n  strict rejected by arity:          "+        ++ show (count "strict-arity")+        ++ "\n  inherited-property deviation:      "+        ++ show (count "inherited")+        ++ "\n  re-evaluation deviation (skipped): "+        ++ show (count "reeval")+    )+  found <- readIORef failures+  unless (null found) $ do+    mapM_ (hPutStrLn stderr) (take 40 (reverse found))+    hPutStrLn stderr (show (length found) ++ " failures")+    exitFailure++declaredCases :: Value -> IO Int+declaredCases (Object o)+  | Just (Object m) <- KeyMap.lookup "meta" o+  , Just (Number n) <- KeyMap.lookup "cases" m+  , Just k <- toBoundedInteger n =+      pure k+declaredCases _ = die' "first line of the fixture is not a meta line"++die' :: String -> IO a+die' message = hPutStrLn stderr ("outliers: " ++ message) >> exitFailure++parseCase :: (Int, B.ByteString) -> IO Case+parseCase (line, bytes) = case eitherDecodeStrict bytes of+  Left reason -> die' ("line " ++ show line ++ ": " ++ reason)+  Right (Object o) -> do+    let text k = case KeyMap.lookup k o of+          Just (String t) -> pure t+          _ -> die' ("line " ++ show line ++ ": missing " ++ show k)+        field k = maybe (die' ("line " ++ show line ++ ": missing " ++ show k)) pure (KeyMap.lookup k o)+    i <- text "id"+    f <- text "fam"+    o' <- text "op"+    r <- field "rule"+    d <- field "data"+    e <- field "expect" >>= reference line+    own <- maybe (pure Nothing) (fmap Just . reference line) (KeyMap.lookup "expect_own" o)+    let dev = case KeyMap.lookup "dev" o of+          Just (String t) -> Just t+          _ -> Nothing+    pure (Case i f o' r d e dev own)+  Right _ -> die' ("line " ++ show line ++ ": not an object")++reference :: Int -> Value -> IO Reference+reference _ (Object o)+  | Just v <- KeyMap.lookup "ok" o = pure (RefValue v)+  | Just (String m) <- KeyMap.lookup "error" o = pure (RefError m)+reference line _ = die' ("line " ++ show line ++ ": bad expectation")++-- | Every builtin operator, from the library's own table.+builtins :: [String]+builtins = map (Text.unpack . builtinName) allBuiltins++families :: [String]+families =+  [ "arity"+  , "unary"+  , "pair"+  , "triple"+  , "numstr"+  , "numfmt"+  , "tie"+  , "signedzero"+  , "unicode"+  , "var"+  , "collection"+  , "flow"+  , "literal"+  , "deep"+  , "wide"+  , "coercion"+  , "community"+  ]++runCase :: (String -> IO ()) -> (String -> IO ()) -> Case -> IO ()+runCase bump failWith c+  | caseDev c == Just "reeval" = bump "reeval"+  | otherwise = do+      let target = case (caseDev c, caseOwn c) of+            (Just "inherited", Just own) -> own+            _ -> caseExpect c+          lenient = applyWithConfig defaultConfig{arityMode = Lenient} noOperators (caseRule c) (caseData c)+          strict = applyWithConfig defaultConfig noOperators (caseRule c) (caseData c)+      case judge target lenient of+        Nothing -> bump (if caseDev c == Just "inherited" then "inherited" else "lenient-ok")+        Just why -> failWith (describe c "lenient" target lenient why)+      case strict of+        Left (ParseFailure _ (ArityMismatch (OperatorName name) accepted given))+          | strictRejects c name accepted given -> bump "strict-arity"+          | otherwise ->+              failWith (describe c "strict" target strict "arity rejection that the operator's arity does not justify")+        _ -> case judge target strict of+          Nothing -> bump "strict-ok"+          Just why -> failWith (describe c "strict" target strict why)++-- | Is a strict-mode arity rejection justified: is the argument count+-- really outside what the operator takes?+strictRejects :: Case -> Text -> Arity -> Int -> Bool+strictRejects c _ accepted given = caseFam c `elem` ["flow", "arity", "unary", "pair", "triple", "collection", "community"] && outside+  where+    outside = case accepted of+      Exactly k -> given /= k+      AtLeast k -> given < k+      Between lo hi -> given < lo || given > hi++judge :: Reference -> Either Error Value -> Maybe String+judge (RefValue want) (Right got)+  | want == got = Nothing+  | otherwise = Just "different value"+judge (RefValue _) (Left e) = Just ("library failed: " ++ Text.unpack (renderError e))+judge (RefError _) (Right _) = Just "reference throws but the library returned a value"+judge (RefError message) (Left e)+  | classOf message e = Nothing+  | otherwise = Just ("error of a different class: " ++ Text.unpack (renderError e))++-- | The reference's error, by what it was, against the library's.+classOf :: Text -> Error -> Bool+classOf message e = case e of+  EvalFailure _ (UnknownOperator _) ->+    "Unrecognized operation" `isInfixOf` m || "apply is not a function" `isInfixOf` m+  EvalFailure _ (InvalidOperand (OperatorName "*")) -> "Reduce of empty array" `isInfixOf` m+  EvalFailure _ (InvalidOperand _) -> "Cannot read properties" `isInfixOf` m+  ParseFailure _ (ArityMismatch (OperatorName "*") _ 0) -> "Reduce of empty array" `isInfixOf` m+  _ -> False+  where+    m = Text.unpack message++describe :: Case -> String -> Reference -> Either Error Value -> String -> String+describe c mode target got why =+  concat+    [ Text.unpack (caseId c)+    , " ["+    , mode+    , "] "+    , why+    , "\n    rule:      "+    , L.unpack (encode (caseRule c))+    , "\n    data:      "+    , L.unpack (encode (caseData c))+    , "\n    reference: "+    , showRef target+    , "\n    library:   "+    , either (Text.unpack . renderError) (L.unpack . encode) got+    ]++showRef :: Reference -> String+showRef (RefValue v) = L.unpack (encode v)+showRef (RefError m) = "throws: " ++ Text.unpack m
+ src/JsonLogic.hs view
@@ -0,0 +1,137 @@+-- |+-- Module      : JsonLogic+--+-- Apply JsonLogic rules (<https://jsonlogic.com>) to data, with the+-- semantics of the JavaScript reference implementation, json-logic-js 2.0.5.+--+-- > apply rule data+--+-- Both are aeson 'Value's. The default 'Config' bounds nesting depth,+-- evaluation steps, string length and array length, so a rule from an+-- untrusted source cannot exhaust time or memory.+--+-- To run one rule against many inputs, 'compile' it once and 'run' it:+--+-- > case compile defaultConfig noOperators rule of+-- >   Left err -> ...+-- >   Right compiled -> map (run compiled) inputs+--+-- Every 'Error' carries the 'Location' in the rule where it happened, as a+-- JSON Pointer.+--+-- This module is the whole supported API. The @JsonLogic.Core.*@ modules+-- are exposed for the test suites and for advanced use; they carry no+-- stability promise before 1.0 (see docs/STABILITY.md).+module JsonLogic+  ( -- * Applying rules+    apply+  , applyWith+  , applyWithConfig++    -- * Compiling a rule once+  , Rule+  , compile+  , run++    -- * What a rule reads+  , DataUse (..)+  , dataUse++    -- * Configuration+  , Config (..)+  , ArityMode (..)+  , defaultConfig++    -- * Custom operators+  , CustomOperator+  , Operators+  , noOperators+  , operatorsFromList+  , OperatorsError (..)+  , OperatorName (..)++    -- * Errors+  , Error (..)+  , ParseError (..)+  , EvalError (..)+  , LimitKind (..)+  , Arity (..)+  , errorLocation+  , renderError++    -- * Locations+  , Location+  , Segment (..)+  , locationSegments+  , fromSegments+  , renderLocation+  ) where++import Data.Aeson (Value)+import Data.Bifunctor (first)++import JsonLogic.Core.Analysis (DataUse (..))+import qualified JsonLogic.Core.Analysis as Analysis+import JsonLogic.Core.Config (ArityMode (..), Config (..), defaultConfig)+import JsonLogic.Core.Custom (CustomOperator, Operators, noOperators, operatorsFromList)+import JsonLogic.Core.Errors+  ( Arity (..)+  , Error (..)+  , EvalError (..)+  , LimitKind (..)+  , OperatorsError (..)+  , ParseError (..)+  , errorLocation+  , renderError+  )+import JsonLogic.Core.Eval (Program, compileRule, runProgram)+import JsonLogic.Core.Location (Location, Segment (..), fromSegments, locationSegments, renderLocation)+import JsonLogic.Core.Operator (OperatorName (..))+import JsonLogic.Core.Parse (parseRuleWith)+import qualified JsonLogic.Core.Rule as Core++-- | A rule that has been read, checked and compiled, ready to run against+-- any number of inputs. It keeps the 'Config' and the custom operators it was+-- compiled with.+data Rule = Rule Core.Rule Program++-- | Read a rule and compile it. Fails with a 'ParseFailure' if the rule+-- nests too deeply or, in 'Strict' mode, gives an operator the wrong number of+-- arguments. Custom operators are resolved here, once; an operator the+-- registry does not have is reported as 'UnknownOperator' only if a run+-- reaches it, because the reference evaluates rules lazily.+compile :: Config -> Operators -> Value -> Either Error Rule+compile config operators value = do+  tree <- first (uncurry ParseFailure) (parseRuleWith config value)+  pure (Rule tree (compileRule config operators tree))++-- | Run a compiled rule against some data.+run :: Rule -> Value -> Either Error Value+run (Rule _ program) input = first (uncurry EvalFailure) (runProgram program input)++-- | What a compiled rule can read from its data, found without running it:+-- if 'usesAllData' is false, its result depends on the data only through+-- the values at 'usedPaths'. Unlike the reference's @uses_data@, a @var@+-- inside a @map@ (or other iteration) body is not counted, because it reads+-- the element, not the data. See "JsonLogic.Core.Analysis".+--+-- > dataUse <$> compile defaultConfig noOperators rule+dataUse :: Rule -> DataUse+dataUse (Rule tree _) = Analysis.dataUse tree++-- | Apply a rule to some data, with the builtin operators only and the+-- default 'Config'.+apply :: Value -> Value -> Either Error Value+apply = applyWith noOperators++-- | Apply a rule to some data, with extra operators available and the+-- default 'Config'.+applyWith :: Operators -> Value -> Value -> Either Error Value+applyWith = applyWithConfig defaultConfig++-- | Apply a rule to some data with an explicit 'Config' and extra+-- operators. Equal to 'compile' then 'run'.+applyWithConfig :: Config -> Operators -> Value -> Value -> Either Error Value+applyWithConfig config operators rule input = do+  compiled <- compile config operators rule+  run compiled input
+ src/JsonLogic/Core/Analysis.hs view
@@ -0,0 +1,131 @@+-- |+-- Module      : JsonLogic.Core.Analysis+--+-- What a rule can read from its data, found without running it.+--+-- The reference implementation offers @uses_data@, which lists every @var@+-- name in a rule, including those inside a @map@ body that read the element+-- rather than the data. This analysis follows the scopes instead: the body+-- of @map@, @filter@, @reduce@, @all@, @none@ and @some@ is evaluated against+-- an element (or, for @reduce@, against @current@ and @accumulator@), and+-- classic JsonLogic has no way to reach the outer data from there, so what a+-- body reads is part of what its collection operand reads.+--+-- The answer is an over-approximation, and exact about what it promises: if+-- 'usesAllData' is false, the result of the rule (value or error) depends on+-- the data only through the values at 'usedPaths'. A path is the dotted path+-- @var@ would follow, as written in the rule. Anything that cannot be known+-- statically (a computed path, a path that is the data itself, a key list+-- that comes from the data, a custom operator, which is handed the whole+-- datum) sets 'usesAllData'.+module JsonLogic.Core.Analysis+  ( DataUse (..)+  , dataUse+  ) where++import Data.Aeson (Value (..))+import Data.Fix (foldFix)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text++import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))+import JsonLogic.Core.Value (fromValue, toJsString)++-- | What a rule can read from its data.+data DataUse = DataUse+  { usedPaths :: [Text]+  -- ^ The paths it can read, sorted and without duplicates.+  , usesAllData :: Bool+  -- ^ Whether it can read anything else, so that 'usedPaths' is not the+  -- whole story.+  }+  deriving (Eq, Show)++-- | What the rule can read, as a set while it is built.+data Reads = Reads !(Set.Set Text) !Bool++instance Semigroup Reads where+  Reads a x <> Reads b y = Reads (Set.union a b) (x || y)++instance Monoid Reads where+  mempty = Reads Set.empty False++-- | One layer's analysis: what the node and its operands read, and its+-- shape, so that a parent can see a literal path or key list.+data Layer = Layer+  { layerReads :: Reads+  , layerShape :: Shape+  }++-- | What a node is, as far as a parent's lookups care.+data Shape+  = -- | A literal: a scalar, or an object that is not an operator call.+    Literal Value+  | -- | An array literal, whose elements are each a rule.+    ArrayOf [Shape]+  | -- | Anything computed.+    Computed++-- | What a rule can read from its data.+dataUse :: Rule -> DataUse+dataUse rule = case layerReads (foldFix layer rule) of+  Reads paths whole -> DataUse (Set.toAscList paths) whole++layer :: Node Layer -> Layer+layer (Node _ node) = Layer (readsOf node) shape+  where+    shape = case node of+      Lit value -> Literal value+      Arr items -> ArrayOf (map layerShape items)+      _ -> Computed++readsOf :: RuleF Layer -> Reads+readsOf node = case node of+  Var path _ -> operands <> lookupOf (layerShape path)+  Missing keys -> operands <> missingLookups (map layerShape keys)+  MissingSome _ options -> operands <> optionLookups (layerShape options)+  -- an iteration body reads the element, which its collection already+  -- accounts for; only the collection (and reduce's initial value) are+  -- evaluated against the data+  Map collection _ -> layerReads collection+  Filter collection _ -> layerReads collection+  All collection _ -> layerReads collection+  None collection _ -> layerReads collection+  Some collection _ -> layerReads collection+  Reduce collection _ initial -> layerReads collection <> layerReads initial+  -- a custom operator is handed the whole datum+  CustomOp _ _ -> operands <> everything+  _ -> operands+  where+    operands = foldMap layerReads node++-- | A lookup of the path a node evaluates to: known only for a literal.+lookupOf :: Shape -> Reads+lookupOf (Literal key) = pathRead key+lookupOf _ = everything++-- | The path a literal names, as @var@ reads it: the data itself for+-- @null@ and @""@, otherwise the literal as a string.+pathRead :: Value -> Reads+pathRead key = case key of+  Null -> everything+  String s | Text.null s -> everything+  _ -> Reads (Set.singleton (toJsString (fromValue key))) False++-- | @missing@ looks up each key, and if its first operand is an array, that+-- array is the key list instead. A key that is itself an array is a @var@+-- argument list, which is not analysed.+missingLookups :: [Shape] -> Reads+missingLookups keys = case keys of+  ArrayOf listed : _ -> foldMap lookupOf listed+  _ -> foldMap lookupOf keys++-- | @missing_some@ looks up each of its options, or the one option if they+-- are not an array.+optionLookups :: Shape -> Reads+optionLookups (ArrayOf listed) = foldMap lookupOf listed+optionLookups other = lookupOf other++everything :: Reads+everything = Reads Set.empty True
+ src/JsonLogic/Core/Config.hs view
@@ -0,0 +1,62 @@+-- |+-- Module      : JsonLogic.Core.Config+--+-- Configuration for reading and evaluating rules: the resource limits that+-- make it safe to evaluate rules from an untrusted source, and the arity+-- mode.+--+-- There is no global state. A @Config@ is an ordinary value passed to+-- 'JsonLogic.compile' or 'JsonLogic.applyWithConfig'.+module JsonLogic.Core.Config+  ( ArityMode (..)+  , Config (..)+  , defaultConfig+  ) where++-- | What to do with an operator given the wrong number of arguments.+data ArityMode+  = -- | Reject it with a precise 'JsonLogic.ArityMismatch'. The default:+    -- a rule that passes the wrong number of arguments is almost always a+    -- mistake, and the reference silently hides it.+    Strict+  | -- | Behave like the reference implementation: ignore surplus+    -- arguments (after still evaluating them, as the reference does) and+    -- treat missing ones as JavaScript @undefined@.+    Lenient+  deriving (Eq, Show)++-- | Limits and mode. All limits bound /work and memory/, not wall-clock+-- time: a custom operator that is slow is still slow.+data Config = Config+  { maxDepth :: !Int+  -- ^ How deeply a rule (and any literal inside it, and any value a custom+  -- operator returns) may nest. Default 64.+  , maxSteps :: !Int+  -- ^ How many evaluation steps one run may take. A step is one rule node+  -- evaluated (so each iteration of @map@ and the others costs the nodes of+  -- its body), plus one per array element converted to a string or number,+  -- per code unit or element built by @cat@ or @merge@, per code point of a+  -- string a path indexes or measures, and per node of a custom operator's+  -- result (docs/LIMITS.md). Default 1,000,000.+  , maxStringLength :: !Int+  -- ^ The longest string @cat@ may build, in UTF-16 code units. Default+  -- 1,048,576.+  , maxArrayLength :: !Int+  -- ^ The longest array @merge@, @map@ or @filter@ may build. Default+  -- 100,000.+  , arityMode :: !ArityMode+  -- ^ 'Strict' (the default) or 'Lenient'.+  }+  deriving (Eq, Show)++-- | The default configuration: depth 64, one million steps, 1 MiB strings+-- (in code units), 100,000-element arrays, strict arity.+defaultConfig :: Config+defaultConfig =+  Config+    { maxDepth = 64+    , maxSteps = 1000000+    , maxStringLength = 1048576+    , maxArrayLength = 100000+    , arityMode = Strict+    }
+ src/JsonLogic/Core/Custom.hs view
@@ -0,0 +1,75 @@+-- |+-- Module      : JsonLogic.Core.Custom+--+-- Operators supplied by the caller: the one open extension point of the+-- rule language. A registry is built once, checked when it is built, and+-- handed to 'JsonLogic.compile', which resolves every custom operator in the+-- rule against it.+module JsonLogic.Core.Custom+  ( CustomOperator+  , Operators+  , noOperators+  , operatorsFromList+  , lookupOperator+  ) where++import Control.Monad (foldM)+import qualified Data.Aeson as Aeson+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust)+import Data.Text (Text)++import JsonLogic.Core.Errors (OperatorsError (..))+import JsonLogic.Core.Operator (OperatorName (..), lookupBuiltin)++-- | A caller-supplied operator: given the data being evaluated against and+-- its already-evaluated arguments, produce a result or a reason it failed.+--+-- The contract, in full:+--+-- * All of the operator's operands are evaluated, left to right, before it+--   runs, as for the reference's own ordinary operators. An operator that+--   nobody supplied is therefore reported only after its operands have run,+--   and only if the rule reaches it.+-- * The data argument is the datum in scope: inside @map@, @filter@ and the+--   other iterating operators, the current element. It is converted to JSON+--   lazily, so an operator that ignores it pays nothing for it.+-- * Arguments and result are JSON. JavaScript values with no JSON form+--   cross the boundary as @null@: @NaN@, the infinities and @undefined@.+-- * The number of arguments is not checked by the library; the operator+--   checks its own.+-- * @Left reason@ stops the evaluation with+--   'JsonLogic.Core.Errors.CustomOperatorFailed'.+-- * The result is held to the configured depth and step limits; the+--   operator's own work is not (the library cannot see inside it).+type CustomOperator = Aeson.Value -> [Aeson.Value] -> Either Text Aeson.Value++-- | A registry of custom operators.+newtype Operators = Operators (Map.Map OperatorName CustomOperator)++-- | Registries combine left-biased: where both define a name, the left one+-- wins. Neither can hold a builtin name, so neither can the combination.+instance Semigroup Operators where+  Operators left <> Operators right = Operators (Map.union left right)++instance Monoid Operators where+  mempty = noOperators++-- | No custom operators: any operator outside the builtin set is an+-- 'JsonLogic.Core.Errors.UnknownOperator' when it is reached.+noOperators :: Operators+noOperators = Operators Map.empty++-- | Custom operators from an association list. A name that is a builtin+-- operator, or that appears twice, is refused rather than silently ignored.+operatorsFromList :: [(OperatorName, CustomOperator)] -> Either OperatorsError Operators+operatorsFromList entries = Operators <$> foldM add Map.empty entries+  where+    add table (name@(OperatorName text), implementation)+      | isJust (lookupBuiltin text) = Left (ReservedOperatorName name)+      | Map.member name table = Left (DuplicateOperatorName name)+      | otherwise = Right (Map.insert name implementation table)++-- | The operator registered under a name.+lookupOperator :: Operators -> OperatorName -> Maybe CustomOperator+lookupOperator (Operators table) name = Map.lookup name table
+ src/JsonLogic/Core/Errors.hs view
@@ -0,0 +1,156 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : JsonLogic.Core.Errors+--+-- The closed error vocabulary, layered by stage: reading a rule can fail+-- ('ParseError'), evaluating one can fail ('EvalError'), and 'Error' is+-- either, together with the 'Location' in the rule where it happened.+--+-- Callers match on the constructors, never on rendered text. 'renderError'+-- produces a message for people and logs; its wording may change in any+-- release.+module JsonLogic.Core.Errors+  ( Arity (..)+  , Error (..)+  , EvalError (..)+  , LimitKind (..)+  , OperatorsError (..)+  , ParseError (..)+  , errorLocation+  , renderError+  , renderEvalError+  , renderParseError+  ) where++import Data.Text (Text)+import qualified Data.Text as Text++import JsonLogic.Core.Location (Location, renderLocation)+import JsonLogic.Core.Operator (OperatorName (..))++-- | How many arguments an operator accepts.+data Arity+  = Exactly Int+  | AtLeast Int+  | Between Int Int+  deriving (Eq, Show)++-- | Why a JSON value could not be read as a rule.+data ParseError+  = -- | The rule, or a literal inside it, nests deeper than the configured+    -- limit (carried here). Raised before evaluation ever sees the rule.+    DepthExceeded Int+  | -- | An operator was given a number of arguments it does not take:+    -- the operator, what it accepts, and how many it was given. Raised in+    -- strict arity mode only.+    ArityMismatch OperatorName Arity Int+  deriving (Eq, Show)++-- | Which resource limit stopped an evaluation, with the configured+-- limit. The limits bound work and memory, not wall-clock time.+data LimitKind+  = -- | More evaluation steps than allowed (see @maxSteps@ in "JsonLogic.Core.Config"+    -- for what a step is).+    StepLimit Int+  | -- | A string result longer than allowed, in UTF-16 code units.+    StringLimit Int+  | -- | An array result, or an array to iterate, longer than allowed.+    ArrayLimit Int+  | -- | A value a custom operator returned nests deeper than allowed.+    ValueDepthLimit Int+  deriving (Eq, Show)++-- | Why a well-formed rule could not be evaluated. JavaScript semantics+-- turn most odd inputs into @NaN@ or @null@ rather than a failure, so only+-- an operator nobody supplied, the few operands the reference itself throws+-- on, a custom operator's own failure, and an exhausted limit can go wrong.+data EvalError+  = -- | The rule reached an operator that is neither builtin nor supplied.+    UnknownOperator OperatorName+  | -- | A supplied operator reported failure, with its reason.+    CustomOperatorFailed OperatorName Text+  | -- | An operand the reference implementation throws a @TypeError@ on:+    -- @missing_some@ with a @null@ or missing option list, and @*@ with no+    -- operands at all (reached in lenient arity mode only; strict mode+    -- rejects it as an 'ArityMismatch' before evaluating).+    InvalidOperand OperatorName+  | -- | A configured resource limit was exceeded.+    LimitExceeded LimitKind+  deriving (Eq, Show)++-- | Why a registry of custom operators was refused. Raised when the+-- registry is built, before any rule is read.+data OperatorsError+  = -- | The name belongs to a builtin operator, which would shadow it.+    ReservedOperatorName OperatorName+  | -- | The name was given more than once.+    DuplicateOperatorName OperatorName+  deriving (Eq, Show)++-- | A failure of either stage, and where in the rule it happened.+data Error+  = -- | The rule could not be read. The location is the node that could not+    -- be read: the operator with the wrong argument count, or the first+    -- value that nests too deeply.+    ParseFailure Location ParseError+  | -- | The rule could not be evaluated. The location is the node being+    -- evaluated when it failed: the unknown or failing operator, the+    -- operator given an invalid operand, or the node that ran out of a+    -- resource.+    EvalFailure Location EvalError+  deriving (Eq, Show)++-- | Where an error happened.+errorLocation :: Error -> Location+errorLocation (ParseFailure location _) = location+errorLocation (EvalFailure location _) = location++-- | A one-line description of an error and its location, for logs and+-- messages.+--+-- > at /if/1: operator "*" takes at least 1 argument, but was given 0+renderError :: Error -> Text+renderError e = case e of+  ParseFailure location problem -> at location (renderParseError problem)+  EvalFailure location problem -> at location (renderEvalError problem)+  where+    at location message = case renderLocation location of+      "" -> "at the root of the rule: " <> message+      pointer -> "at " <> pointer <> ": " <> message++-- | A one-line description of a parse error, without its location.+renderParseError :: ParseError -> Text+renderParseError (DepthExceeded limit) =+  "the rule nests deeper than the limit of " <> shown limit+renderParseError (ArityMismatch (OperatorName name) accepted given) =+  "operator " <> quoted name <> " takes " <> renderArity accepted <> ", but was given " <> shown given++-- | A one-line description of an evaluation error, without its location.+renderEvalError :: EvalError -> Text+renderEvalError (UnknownOperator (OperatorName name)) =+  "unknown operator " <> quoted name+renderEvalError (CustomOperatorFailed (OperatorName name) reason) =+  "custom operator " <> quoted name <> " failed: " <> reason+renderEvalError (InvalidOperand (OperatorName name)) =+  "operator " <> quoted name <> " was given an operand it cannot work with"+renderEvalError (LimitExceeded kind) = case kind of+  StepLimit n -> "evaluation exceeded the limit of " <> shown n <> " steps"+  StringLimit n -> "a string result exceeded the limit of " <> shown n <> " code units"+  ArrayLimit n -> "an array exceeded the limit of " <> shown n <> " elements"+  ValueDepthLimit n -> "a value nests deeper than the limit of " <> shown n++renderArity :: Arity -> Text+renderArity (Exactly n) = "exactly " <> shown n <> plural n+renderArity (AtLeast n) = "at least " <> shown n <> plural n+renderArity (Between low high) = "between " <> shown low <> " and " <> shown high <> " arguments"++plural :: Int -> Text+plural 1 = " argument"+plural _ = " arguments"++shown :: Int -> Text+shown = Text.pack . show++quoted :: Text -> Text+quoted name = "\"" <> name <> "\""
+ src/JsonLogic/Core/Eval.hs view
@@ -0,0 +1,624 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : JsonLogic.Core.Eval+--+-- Compiling a rule to a function of the data, and running it.+--+-- Compilation is a fold ('foldFix') over the rule tree, done once. Its+-- carrier is a /function/ from the datum a rule is evaluated against to a+-- result (@Eval@), not a value. That is what lets @map@, @filter@, @reduce@+-- and the others run the same sub-rule once per element against a different+-- datum, and what makes @if@, @and@ and @or@ short-circuit with no extra+-- code: a branch not taken is a function never applied. Everything that does+-- not depend on the data (converting literals, resolving custom operators,+-- choosing each node's code) happens during the fold, so running a compiled+-- rule many times pays for it once.+--+-- Evaluation runs in 'Run', a small state-passing monad whose state is the+-- remaining step budget. It is pure: the budget is threaded through the+-- evaluation, never held in a global or in 'IO'. A failure carries the+-- location of the node that failed. See "JsonLogic.Core.Config" for the+-- limits.+--+-- Operand evaluation order follows the reference: every operand is evaluated+-- before its operator runs, except where the reference evaluates lazily+-- (@if@, @and@, @or@) or against a scoped datum (@map@, @filter@, @reduce@,+-- @all@, @none@, @some@).+module JsonLogic.Core.Eval+  ( Program+  , compileRule+  , runProgram+  , evaluate+  ) where++import Control.Monad (foldM, when)+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Char (digitToInt, isDigit)+import Data.Fix (foldFix)+import qualified Data.List as List+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Vector (Vector)+import qualified Data.Vector as Vector++import JsonLogic.Core.Config (Config (..), defaultConfig)+import JsonLogic.Core.Custom (Operators, lookupOperator, noOperators)+import JsonLogic.Core.Errors (EvalError (..), LimitKind (..))+import JsonLogic.Core.Location (Location)+import JsonLogic.Core.Number (maximumJs, minimumJs, remainderJs)+import JsonLogic.Core.Operator (Builtin (..), builtinOperatorName)+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))+import JsonLogic.Core.Utf16 (utf16CharAt, utf16Length, utf16Slice)+import JsonLogic.Core.Value++-- ---------------------------------------------------------------------+-- The evaluation monad+-- ---------------------------------------------------------------------++-- | The outcome of a step: its value and the budget left, or where and why+-- it failed. One constructor per outcome, strict, so the budget cannot pile+-- up as a chain of thunks and a step allocates one cell.+data Result a+  = Done !a {-# UNPACK #-} !Int+  | Failed !Location !EvalError++-- | A computation that may fail, at a location in the rule, and that spends+-- steps from a budget.+newtype Run a = Run {unRun :: Int -> Result a}++instance Functor Run where+  fmap f (Run m) = Run $ \budget -> case m budget of+    Done a left -> Done (f a) left+    Failed location e -> Failed location e+  {-# INLINE fmap #-}++instance Applicative Run where+  pure a = Run (Done a)+  {-# INLINE pure #-}+  Run mf <*> Run ma = Run $ \budget -> case mf budget of+    Failed location e -> Failed location e+    Done f left -> case ma left of+      Failed location e -> Failed location e+      Done a left' -> Done (f a) left'+  {-# INLINE (<*>) #-}++instance Monad Run where+  Run m >>= k = Run $ \budget -> case m budget of+    Failed location e -> Failed location e+    Done a left -> unRun (k a) left+  {-# INLINE (>>=) #-}++-- | What every node's code knows about itself: the configuration it was+-- compiled with and where it is in the rule.+data At = At !Config !Location++-- | Fail here.+failAt :: At -> EvalError -> Run a+failAt (At _ location) e = Run (const (Failed location e))++-- | Fail here because a limit was exceeded.+exceeded :: At -> (Config -> LimitKind) -> Run a+exceeded at@(At config _) kind = failAt at (LimitExceeded (kind config))++-- | Spend @n@ steps, or fail here if fewer than @n@ remain.+spend :: At -> Int -> Run ()+spend (At config location) n = Run $ \budget ->+  if n > budget+    then Failed location (LimitExceeded (StepLimit (maxSteps config)))+    else Done () (budget - n)+{-# INLINE spend #-}++-- | Spend steps for converting an operand to a string or number, which+-- costs as many steps as an array operand has elements, at any depth.+-- Every other value converts for free.+spendOnConversion :: At -> Val -> Run ()+spendOnConversion at value = case value of+  VArr _ -> Run $ \budget -> unRun (spend at (valWeightUpTo (budget + 1) value)) budget+  _ -> pure ()++-- ---------------------------------------------------------------------+-- Compiling and running+-- ---------------------------------------------------------------------++-- | A rule compiled to a function of the data.+type Eval = Val -> Run Val++-- | A compiled node: its code, and its value if it is a literal (so that a+-- parent can do work ahead of time, such as splitting a @var@ path once).+data Compiled = Compiled+  { compiledLiteral :: Maybe Val+  , compiledEval :: Eval+  }++-- | A compiled rule, with the configuration it was compiled with.+data Program = Program !Config Eval++-- | Compile a rule once: convert its literals, resolve its custom operators+-- against the registry, and build each node's code.+compileRule :: Config -> Operators -> Rule -> Program+compileRule config operators rule =+  Program config (compiledEval (foldFix (compileNode config operators) rule))++-- | Run a compiled rule against some data, with the configured step budget.+runProgram :: Program -> Aeson.Value -> Either (Location, EvalError) Aeson.Value+runProgram (Program config eval) input =+  case unRun (eval (fromValue input)) (maxSteps config) of+    Failed location e -> Left (location, e)+    Done result _ -> Right (toValue result)++-- | Compile and run with 'defaultConfig' and no custom operators.+evaluate :: Rule -> Aeson.Value -> Either (Location, EvalError) Aeson.Value+evaluate = runProgram . compileRule defaultConfig noOperators++-- | One layer of the fold: given each child already compiled, compile this+-- node. Evaluating a node costs one step, before its own work.+compileNode :: Config -> Operators -> Node Compiled -> Compiled+compileNode config operators (Node location node) = Compiled literal (\env -> spend at 1 >> body env)+  where+    at = At config location+    literal = case node of+      Lit value -> Just (fromValue value)+      _ -> Nothing+    body = case node of+      -- the path of a @var@ is almost always a literal: read it once, here+      Var path fallback+        | Just key <- compiledLiteral path ->+            let known = pathOf key+             in variable at (compiledEval path) (compiledEval <$> fallback) (const (pure known))+      _ -> compileBody at operators (fmap compiledEval node)++compileBody :: At -> Operators -> RuleF Eval -> Eval+compileBody at@(At config _) operators node = case node of+  Lit literal ->+    -- converted once, when the rule is compiled, and shared by every run+    let converted = fromValue literal in const (pure converted)+  Arr items ->+    let elements = Vector.fromList items in \env -> VArr <$> Vector.mapM ($ env) elements+  Var path fallback -> variable at path fallback (pure . pathOf)+  Missing keys -> \env -> do+    names <- evalAll keys env+    mapM_ (spendOnConversion at) names+    let (work, absent) = missingKeys (workCap config) names env+    spend at work+    pure (VArr (Vector.fromList absent))+  MissingSome needed options -> \env -> do+    count <- needed env+    listed <- options env+    missingSome at count listed env+  If branches -> evalIf branches+  And operands -> evalAnd operands+  Or operands -> evalOr operands+  Not operand -> fmap (VBool . not . truthy) . operand+  DoubleNot operand -> fmap (VBool . truthy) . operand+  LooseEq x y -> coercing at (\a b -> VBool (looseEquals a b)) x y+  StrictEq x y -> binary (\a b -> VBool (strictEquals a b)) x y+  LooseNeq x y -> coercing at (\a b -> VBool (not (looseEquals a b))) x y+  StrictNeq x y -> binary (\a b -> VBool (not (strictEquals a b))) x y+  Lt x y z -> between at lessThan x y z+  Lte x y z -> between at lessOrEqual x y z+  Gt x y -> coercing at (\a b -> VBool (lessThan b a)) x y+  Gte x y -> coercing at (\a b -> VBool (lessOrEqual b a)) x y+  Max operands -> fmap (VNum . maximumJs . map toNumber) . numeric at operands+  Min operands -> fmap (VNum . minimumJs . map toNumber) . numeric at operands+  Add operands ->+    fmap (VNum . List.foldl' (\total v -> total + parseFloat v) 0) . numeric at operands+  Sub x y -> \env -> do+    a <- x env+    b <- definedOperand <$> traverse ($ env) y+    spendOnConversion at a+    mapM_ (spendOnConversion at) b+    pure (VNum (maybe (negate (toNumber a)) (\v -> toNumber a - toNumber v) b))+  Mul [] -> const (failAt at (InvalidOperand (builtinOperatorName BMul)))+  Mul (first : rest) -> \env -> do+    -- the reference folds without a seed: a single operand is returned as+    -- it is, not converted to a number+    a <- first env+    bs <- evalAll rest env+    mapM_ (spendOnConversion at) (a : bs)+    pure (List.foldl' (\acc v -> VNum (parseFloat acc * parseFloat v)) a bs)+  Div x y -> coercing at (\a b -> VNum (toNumber a / toNumber b)) x y+  Mod x y -> coercing at (\a b -> VNum (remainderJs (toNumber a) (toNumber b))) x y+  Map collection logic ->+    withCollection collection (VArr Vector.empty) $ \items -> do+      guardInput at items+      VArr <$> Vector.mapM logic items+  Filter collection logic ->+    withCollection collection (VArr Vector.empty) $ \items -> do+      guardInput at items+      VArr <$> Vector.filterM (fmap truthy . logic) items+  Reduce collection logic initial -> reduceRule collection logic initial+  All collection logic ->+    withCollection collection (VBool False) $ \items ->+      if Vector.null items then pure (VBool False) else VBool <$> allTruthy logic (Vector.toList items)+  None collection logic ->+    withCollection collection (VBool True) $ \items ->+      if Vector.null items then pure (VBool True) else VBool . not <$> anyTruthy logic (Vector.toList items)+  Some collection logic ->+    withCollection collection (VBool False) $ \items ->+      if Vector.null items then pure (VBool False) else VBool <$> anyTruthy logic (Vector.toList items)+  Merge operands -> \env -> do+    values <- evalAll operands env+    let pieces = map flattenOnce values+        size = List.foldl' (\total piece -> total + Vector.length piece) 0 pieces+    when (size > maxArrayLength config) $ exceeded at (ArrayLimit . maxArrayLength)+    spend at size+    pure (VArr (Vector.concat pieces))+  In needle haystack -> coercing at (\a b -> VBool (contains a b)) needle haystack+  Cat operands -> \env -> do+    values <- evalAll operands env+    mapM_ (spendOnConversion at) values+    let pieces = map toJoinText values+    total <- foldM (addLength at) 0 pieces+    spend at total+    pure (VStr (Text.concat pieces))+  Substr source start len -> \env -> do+    text <- source env+    from <- start env+    limit <- definedOperand <$> traverse ($ env) len+    spendOnConversion at text+    pure (VStr (substring (toJsString text) from limit))+  Log operand -> operand+  Discard inner extras -> \env -> do+    result <- inner env+    mapM_ ($ env) extras+    pure result+  CustomOp name operands -> case lookupOperator operators name of+    Nothing -> \env -> evalAll operands env >> failAt at (UnknownOperator name)+    Just implementation -> \env -> do+      arguments <- evalAll operands env+      case implementation (toValue env) (map toValue arguments) of+        Left reason -> failAt at (CustomOperatorFailed name reason)+        Right result -> do+          when (nestsDeeperThan (maxDepth config) result) $+            exceeded at (ValueDepthLimit . maxDepth)+          spend at (jsonWeightUpTo (maxSteps config + 1) result)+          pure (fromValue result)++-- | @var@: evaluate the path (for its step and its failures), turn its value+-- into a 'Path' (ahead of time, when it is a literal), and look it up.+variable :: At -> Eval -> Maybe Eval -> (Val -> Run Path) -> Eval+variable at@(At config _) path fallback toPath env = do+  key <- path env >>= toPath+  notFound <- maybe (pure VNull) ($ env) fallback+  let (work, found) = lookupPath (workCap config) env key notFound+  spend at work+  pure found++-- ---------------------------------------------------------------------+-- Combinators over compiled operands+-- ---------------------------------------------------------------------++evalAll :: [Eval] -> Val -> Run [Val]+evalAll operands env = traverse ($ env) operands++-- | Two operands, no conversion cost (the combiner never walks an array).+binary :: (Val -> Val -> Val) -> Eval -> Eval -> Eval+binary combine x y env = combine <$> x env <*> y env++-- | Two operands that the combiner may convert to a string or number,+-- which costs as many steps as an array operand has elements.+coercing :: At -> (Val -> Val -> Val) -> Eval -> Eval -> Eval+coercing at combine x y env = do+  a <- x env+  b <- y env+  spendOnConversion at a+  spendOnConversion at b+  pure (combine a b)++-- | Operands about to be converted to numbers, charged for the conversion.+numeric :: At -> [Eval] -> Val -> Run [Val]+numeric at operands env = do+  values <- evalAll operands env+  mapM_ (spendOnConversion at) values+  pure values++-- | @x op y@, or the chained @x op y && y op z@ when a third operand is+-- given. All operands are evaluated first, as in the reference.+between :: At -> (Val -> Val -> Bool) -> Eval -> Eval -> Maybe Eval -> Eval+between at holds x y z env = do+  a <- x env+  b <- y env+  c <- definedOperand <$> traverse ($ env) z+  mapM_ (spendOnConversion at) [a, b]+  mapM_ (spendOnConversion at) c+  pure (VBool (maybe (holds a b) (\third -> holds a b && holds b third) c))++-- | Condition/consequent pairs, then an optional else; no branch means null.+--+-- Hand-written recursion (Standard 4.1): a search that stops at the first+-- condition that holds, in 'Run'. It is a right fold with early exit, and+-- @base@ has no monadic combinator for that shape.+evalIf :: [Eval] -> Eval+evalIf (condition : consequent : rest) env = do+  decided <- condition env+  if truthy decided then consequent env else evalIf rest env+evalIf [fallback] env = fallback env+evalIf [] _ = pure VNull++-- | The first falsy operand, or the last; JavaScript's @undefined@ (not+-- null) when there are none.+--+-- Hand-written recursion (Standard 4.1): a right fold with early exit in+-- 'Run'; @base@ has no monadic combinator for that shape.+evalAnd :: [Eval] -> Eval+evalAnd [] _ = pure VUndefined+evalAnd [only] env = only env+evalAnd (first : rest) env = do+  value <- first env+  if truthy value then evalAnd rest env else pure value++-- | The first truthy operand, or the last; JavaScript's @undefined@ (not+-- null) when there are none.+--+-- Hand-written recursion (Standard 4.1): the dual of 'evalAnd'.+evalOr :: [Eval] -> Eval+evalOr [] _ = pure VUndefined+evalOr [only] env = only env+evalOr (first : rest) env = do+  value <- first env+  if truthy value then pure value else evalOr rest env++-- | Evaluate a collection operand; run @onItems@ if it is an array,+-- otherwise answer @fallback@.+withCollection :: Eval -> Val -> (Vector Val -> Run Val) -> Eval+withCollection collection fallback onItems env = do+  evaluated <- collection env+  case evaluated of+    VArr items -> onItems items+    _ -> pure fallback++-- | Refuse to iterate an array longer than the array limit, before doing+-- any of the work.+guardInput :: At -> Vector Val -> Run ()+guardInput at@(At config _) items =+  when (Vector.length items > maxArrayLength config) $ exceeded at (ArrayLimit . maxArrayLength)++-- | Add a piece's length (in UTF-16 code units) to a running total,+-- failing as soon as the total passes the string limit.+addLength :: At -> Int -> Text -> Run Int+addLength at@(At config _) total piece+  | total' > maxStringLength config = exceeded at (StringLimit . maxStringLength)+  | otherwise = pure total'+  where+    total' = total + utf16Length piece++-- | Fold the array with the sub-rule, evaluated against an object holding+-- the current element and the accumulator.+reduceRule :: Eval -> Eval -> Eval -> Eval+reduceRule collection logic initial env = do+  evaluated <- collection env+  start <- initial env+  case evaluated of+    VArr items -> Vector.foldM' step start items+    _ -> pure start+  where+    step accumulator item =+      logic (VObj (KeyMap.fromList [("current", item), ("accumulator", accumulator)]))++-- | Does the sub-rule hold for every item? Stops at the first that does not.+--+-- Hand-written recursion (Standard 4.1): a monadic @all@, which @base@ does+-- not provide, in 'Run'.+allTruthy :: Eval -> [Val] -> Run Bool+allTruthy _ [] = pure True+allTruthy logic (item : rest) = do+  value <- logic item+  if truthy value then allTruthy logic rest else pure False++-- | Does the sub-rule hold for some item? Stops at the first that does.+--+-- Hand-written recursion (Standard 4.1): a monadic @any@, the dual of+-- 'allTruthy'.+anyTruthy :: Eval -> [Val] -> Run Bool+anyTruthy _ [] = pure False+anyTruthy logic (item : rest) = do+  value <- logic item+  if truthy value then pure True else anyTruthy logic rest++-- ---------------------------------------------------------------------+-- Operator semantics+-- ---------------------------------------------------------------------++-- | @merge@ flattens one level; anything that is not an array is one+-- element.+flattenOnce :: Val -> Vector Val+flattenOnce (VArr items) = items+flattenOnce other = Vector.singleton other++-- | @in@: substring of a string, or strict-equal member of an array.+-- A falsy container (including the empty string) contains nothing.+contains :: Val -> Val -> Bool+contains _ (VStr s) | Text.null s = False+contains needle (VStr s) = Text.isInfixOf (toJsString needle) s+contains needle (VArr items) = Vector.any (strictEquals needle) items+contains _ _ = False++-- | Which of the keys have no value, or an empty-string one. If the first+-- argument is itself an array, it is the list of keys.+--+-- The first component is the work the lookups did, in steps (see+-- 'lookupVariable'); the cap bounds how far any one lookup is followed.+missingKeys :: Int -> [Val] -> Val -> (Int, [Val])+missingKeys cap args dat =+  ( List.foldl' (+) 0 [work | (_, work, _) <- probes]+  , [key | (key, _, True) <- probes]+  )+  where+    keys = case args of+      VArr listed : _ -> Vector.toList listed+      _ -> args+    probes = map probe keys+    probe key =+      let (work, found) = lookupKey key+       in (key, work, isMissing found)+    isMissing found = case found of+      VNull -> True+      VStr s -> Text.null s+      _ -> False+    -- The reference looks each key up with @{"var": key}@, so a key that is+    -- itself an array is a @var@'s argument list: path, then default.+    lookupKey key = case key of+      VArr listed -> case Vector.toList listed of+        [] -> (0, dat)+        path : rest -> lookupVariable cap dat path (case rest of fallback : _ -> fallback; [] -> VNull)+      _ -> lookupVariable cap dat key VNull++-- | @missing_some@: nothing is missing if at least @count@ of the options+-- are present; otherwise the missing ones.+--+-- The reference reads @options.length@, so a @null@ or undefined option+-- list throws; a string counts its code units; anything without a length+-- compares as @NaN@, which is never enough, so the missing ones are+-- returned.+missingSome :: At -> Val -> Val -> Val -> Run Val+missingSome at@(At config _) count options env = case options of+  VNull -> failAt at (InvalidOperand (builtinOperatorName BMissingSome))+  VUndefined -> failAt at (InvalidOperand (builtinOperatorName BMissingSome))+  _ -> do+    spendOnConversion at options+    let names = case options of+          VArr items -> Vector.toList items+          other -> [other]+        (work, absent) = missingKeys (workCap config) names env+    spend at work+    let have = case optionLength of+          Just n -> n - fromIntegral (length absent)+          Nothing -> 0 / 0+    pure (if lessOrEqual count (VNum have) then VArr Vector.empty else VArr (Vector.fromList absent))+  where+    optionLength = case options of+      VArr items -> Just (fromIntegral (Vector.length items))+      VStr s -> Just (fromIntegral (utf16Length s))+      VObj fields -> toNumber <$> KeyMap.lookup "length" fields+      _ -> Nothing++-- | The most work a lookup is followed for: one more than the whole budget,+-- so a lookup that cannot be paid for is cut short rather than completed.+workCap :: Config -> Int+workCap config = maxSteps config + 1++-- | A @var@ path, read: the data itself, or the property names to follow.+data Path = WholeData | Steps [Text]++-- | Read a @var@ path the way the reference does: an empty or null path is+-- the data itself; anything else is converted to a string and split at dots.+pathOf :: Val -> Path+pathOf VNull = WholeData+pathOf VUndefined = WholeData+pathOf (VStr s) | Text.null s = WholeData+pathOf key = Steps (Text.splitOn "." (toJsString key))++-- | @var@: look a path up in the data; a missing step gives the fallback.+lookupVariable :: Int -> Val -> Val -> Val -> (Int, Val)+lookupVariable cap dat = lookupPath cap dat . pathOf++-- | Follow a path. The first component is the work the walk did, in steps:+-- one per code unit of a string that is indexed or measured (see+-- 'property'). The caller charges it. It is counted only up to @cap@.+lookupPath :: Int -> Val -> Path -> Val -> (Int, Val)+lookupPath _ dat WholeData _ = (0, dat)+lookupPath cap dat (Steps keys) notFound = (work, fromMaybe (nullIfUndefined notFound) found)+  where+    (work, found) = descend cap dat keys++-- | An optional operand that evaluates to undefined counts as absent: the+-- reference tests the /value/ (@c === undefined@), not how many+-- arguments were written. So @{"-": [x, y]}@ negates @x@ when @y@ is+-- undefined, @<@ and @<=@ drop an undefined third operand, and @substr@+-- ignores an undefined end.+definedOperand :: Maybe Val -> Maybe Val+definedOperand (Just VUndefined) = Nothing+definedOperand other = other++-- | The reference's fallback for a missing variable: an undefined+-- fallback becomes null.+nullIfUndefined :: Val -> Val+nullIfUndefined VUndefined = VNull+nullIfUndefined value = value++-- | Follow the path one key at a time; 'Nothing' is "no such property".+-- The first component is the work done, summed over the keys and given up+-- as soon as it passes @cap@ (the caller then fails the evaluation).+descend :: Int -> Val -> [Text] -> (Int, Maybe Val)+descend cap = go 0+  where+    go spent current keys+      | spent > cap = (spent, Nothing)+      | otherwise = case keys of+          [] -> (spent, Just current)+          key : rest -> case current of+            VNull -> (spent, Nothing)+            VUndefined -> (spent, Nothing)+            _ ->+              let (work, found) = property cap current key+                  spent' = spent + work+               in case found of+                    Nothing -> (spent', Nothing)+                    -- a property that is itself undefined counts as missing,+                    -- as in the reference+                    Just VUndefined -> (spent', Nothing)+                    Just next -> go spent' next rest++-- | JavaScript property access on a JSON value: object keys, array and+-- string indices, and @length@. Only a value's own properties are seen:+-- the reference would also find names inherited through the prototype+-- chain (@constructor@, @toString@, ...), which JSON data does not have+-- (docs/COMPATIBILITY.md, DEV-INHERITED).+--+-- The first component is the work, in steps: object and array access is+-- constant time and free; indexing or measuring a string walks it, and costs+-- its length in code points, counted up to @cap@.+property :: Int -> Val -> Text -> (Int, Maybe Val)+property _ (VObj fields) key = (0, KeyMap.lookup (Key.fromText key) fields)+property _ (VArr items) key+  | key == "length" = (0, Just (VNum (fromIntegral (Vector.length items))))+  | otherwise = (0, arrayIndex key >>= (items Vector.!?))+property cap (VStr s) key+  | key == "length" = (size, Just (VNum (fromIntegral (utf16Length s))))+  | otherwise = case arrayIndex key of+      Nothing -> (0, Nothing)+      Just i -> (size, VStr <$> utf16CharAt i s)+  where+    size = min cap (Text.length s)+property _ _ _ = (0, Nothing)++-- | A canonical array index: digits, no leading zero, below 10^9.+arrayIndex :: Text -> Maybe Int+arrayIndex key+  | Text.null key || Text.length key > 9 = Nothing+  | not (Text.all isDigit key) = Nothing+  | Text.length key > 1 && "0" `Text.isPrefixOf` key = Nothing+  | otherwise = Just (Text.foldl' (\acc c -> acc * 10 + digitToInt c) 0 key)++-- | @substr@: JavaScript's @substr@, plus the reference's PHP-style+-- twist that a negative third argument means "stop that many characters+-- before the end" rather than a negative length.+substring :: Text -> Val -> Maybe Val -> Text+substring source start end = case end of+  Just limit | lessThan limit (VNum 0) -> negativeEnd limit+  _ -> substrJs source (toNumber start) (fmap toNumber end)+  where+    tailText = substrJs source (toNumber start) Nothing+    negativeEnd (VNum n) =+      substrJs tailText 0 (Just (fromIntegral (utf16Length tailText) + n))+    negativeEnd _ = ""++-- | @String.prototype.substr(start, length)@, in UTF-16 code units.+substrJs :: Text -> Double -> Maybe Double -> Text+substrJs source start len = utf16Slice from count source+  where+    size = utf16Length source+    relative = toClampedInt (negate size, size) start+    from = if relative < 0 then size + relative else relative+    available = size - from+    count = maybe available (toClampedInt (0, available)) len++-- | Truncate toward zero, clamped into the inclusive range; @NaN@ is zero.+toClampedInt :: (Int, Int) -> Double -> Int+toClampedInt (low, high) d+  | isNaN d = max low (min high 0)+  | otherwise = truncate (max (fromIntegral low) (min (fromIntegral high) d))
+ src/JsonLogic/Core/Location.hs view
@@ -0,0 +1,71 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : JsonLogic.Core.Location+--+-- Where in a rule something is: a path from the root of the rule's JSON to+-- one node, written as a JSON Pointer (RFC 6901) when rendered.+--+-- In @{"if": [true, {"*": []}]}@ the @*@ is at @/if/1@: the key @if@, then+-- index 1 of its argument list. An operator written with a bare argument+-- instead of a list (@{"!": x}@) has that argument at @/!@. The root is the+-- empty pointer, @""@.+--+-- A JSON Pointer names a place in the rule's own JSON text, so a location is+-- meaningful to a tool in any language. Locations are attached to every+-- parse and evaluation error.+module JsonLogic.Core.Location+  ( Location+  , Segment (..)+  , root+  , child+  , fromSegments+  , locationSegments+  , renderLocation+  ) where++import Data.Text (Text)+import qualified Data.Text as Text++-- | One step from a JSON value into a part of it.+data Segment+  = -- | An object member, by key.+    Key !Text+  | -- | An array element, by zero-based index.+    Index !Int+  deriving (Eq, Ord, Show)++-- | A path from the root of a rule. Held innermost-first, so that stepping+-- into a child while reading a rule is constant time.+newtype Location = Location [Segment]+  deriving (Eq, Ord)++-- | Shown as the 'fromSegments' call that rebuilds it.+instance Show Location where+  showsPrec d location =+    showParen (d > 10) (showString "fromSegments " . showsPrec 11 (locationSegments location))++-- | The root of the rule.+root :: Location+root = Location []++-- | One step further in.+child :: Location -> Segment -> Location+child (Location reversed) segment = Location (segment : reversed)++-- | A location from its segments, outermost first.+fromSegments :: [Segment] -> Location+fromSegments = Location . reverse++-- | The segments of a location, outermost first.+locationSegments :: Location -> [Segment]+locationSegments (Location reversed) = reverse reversed++-- | The location as a JSON Pointer (RFC 6901): @""@ for the root,+-- otherwise @/@ before each segment, with @~@ written @~0@ and @/@ written+-- @~1@ inside keys.+renderLocation :: Location -> Text+renderLocation = foldMap (("/" <>) . renderSegment) . locationSegments+  where+    renderSegment (Key key) = Text.replace "/" "~1" (Text.replace "~" "~0" key)+    renderSegment (Index i) = Text.pack (show i)
+ src/JsonLogic/Core/Number.hs view
@@ -0,0 +1,463 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : JsonLogic.Core.Number+--+-- JavaScript numbers: reading decimal text into a double, writing a double+-- as the text JavaScript would, and the arithmetic helpers whose JavaScript+-- definitions differ from Haskell's.+--+-- Every conversion is exact: a decimal is rounded to the nearest double+-- once, and a double is written with the shortest digits that read back as+-- it, chosen as ECMAScript chooses them. Reading has a fast path for the+-- common case, taken only when it is provably exact, and an exact general+-- path in 'Rational'; writing uses Ryū. The test suite checks the fast+-- algorithms against slow, exact ones.+module JsonLogic.Core.Number+  ( -- * Reading+    decimalToDouble+  , scientificToDouble+  , stringToNumber+  , parseFloatString+  , isJsSpace++    -- * Writing+  , numberToString+  , doubleToScientific+  , shortestDigits+  , shortestDigitsExact++    -- * Arithmetic+  , remainderJs+  , maximumJs+  , minimumJs+  ) where++import Data.Bifunctor (first)+import Data.Bits (shiftL, shiftR, (.&.))+import Data.Char (digitToInt, isDigit, isHexDigit)+import Data.Int (Int64)+import Data.List (dropWhileEnd, sortOn, stripPrefix)+import qualified Data.List as List+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import Data.Scientific (Scientific, base10Exponent, coefficient, scientific)+import Data.Text (Text)+import qualified Data.Text as Text+import Numeric (floatToDigits)++import JsonLogic.Core.Ryu (shortestDecimalRyu)++nan :: Double+nan = 0 / 0++infinity :: Double+infinity = 1 / 0++-- | 2^53: below it every integer is a double, and so is every product or+-- quotient of such an integer and a power of ten up to 10^22 correctly+-- rounded by one IEEE operation.+twoTo53 :: Double+twoTo53 = 9007199254740992++-- | @10^n@ for @0 <= n <= 22@, the powers of ten that are exact doubles.+-- Every intermediate product of '(^)' is a smaller power of ten, so exact too.+powerOfTen :: Integer -> Double+powerOfTen n = 10 ^ n++-- ---------------------------------------------------------------------+-- Reading+-- ---------------------------------------------------------------------++-- | A JSON number as the double JavaScript's @JSON.parse@ would make of it.+scientificToDouble :: Scientific -> Double+scientificToDouble s = decimalToDouble (coefficient s) (toInteger (base10Exponent s))++-- | @mantissa * 10 ^ exponent10@ as the nearest double (ties to even).+--+-- When the mantissa is below 2^53 and the exponent within 22 of zero, both+-- factors are exact doubles and one IEEE multiplication or division rounds+-- the result correctly (Clinger's fast path, \"How to read floating point+-- numbers accurately\", 1990). Otherwise the value is computed exactly in+-- integers and rounded once ('roundRatio'). Magnitudes that must overflow or+-- underflow are decided from the digit count, so a hostile exponent cannot+-- make this build an enormous number.+decimalToDouble :: Integer -> Integer -> Double+decimalToDouble mantissa exponent10+  | mantissa == 0 = 0+  | mantissa < 0 = negate (decimalToDouble (negate mantissa) exponent10)+  | mantissa < 9007199254740992 && 0 <= exponent10 && exponent10 <= 22 =+      fromInteger mantissa * powerOfTen exponent10+  | mantissa < 9007199254740992 && -22 <= exponent10 && exponent10 < 0 =+      fromInteger mantissa / powerOfTen (negate exponent10)+  | magnitude > 310 = infinity+  | magnitude < -330 = 0+  | exponent10 >= 0 = roundRatio (mantissa * 10 ^ exponent10) 1+  | otherwise = roundRatio mantissa (10 ^ negate exponent10)+  where+    magnitude = exponent10 + toInteger (length (show mantissa))++-- | @numerator / denominator@, both positive, as the nearest double, ties to+-- even, including the subnormal range and overflow to infinity.+--+-- The quotient is taken with at least 56 significant bits; the bits below the+-- 53 a double keeps (fewer for a subnormal), together with whether the+-- division left a remainder, decide the rounding exactly.+roundRatio :: Integer -> Integer -> Double+roundRatio numerator denominator+  | binaryExponent >= 1024 = infinity+  | otherwise = encodeFloat rounded lowestBit+  where+    -- scale so that the quotient has at least 56 bits+    scale = max 0 (56 + bitLength denominator - bitLength numerator)+    (quotient, remainder) = (numerator `shiftL` scale) `quotRem` denominator+    width = bitLength quotient+    -- the value is quotient * 2^-scale, so its leading bit is worth 2^binaryExponent+    binaryExponent = width - 1 - scale+    -- the weight of the last bit kept: 52 below the leading one, but never+    -- below the smallest subnormal's+    lowestBit = max (binaryExponent - 52) (-1074)+    dropped = lowestBit + scale+    kept = quotient `shiftR` dropped+    below = quotient .&. ((1 `shiftL` dropped) - 1)+    half = 1 `shiftL` (dropped - 1)+    roundsUp = below > half || (below == half && (remainder /= 0 || odd kept))+    rounded = if roundsUp then kept + 1 else kept++-- | The number of bits in a positive integer.+bitLength :: Integer -> Int+bitLength = go 0+  where+    go acc v+      | v >= 18446744073709551616 = go (acc + 64) (v `shiftR` 64)+      | v == 0 = acc+      | otherwise = go (acc + 1) (v `shiftR` 1)++-- | ECMAScript's @WhiteSpace@ and @LineTerminator@ code points, which is what+-- string-to-number conversion trims. Listed explicitly rather than taken from+-- @Data.Char.isSpace@ because the two differ: U+FEFF, U+2028 and U+2029 are+-- trimmed by JavaScript and not by @isSpace@, while U+0085 and U+180E are+-- trimmed by neither (U+180E stopped being a space in Unicode 6.3).+isJsSpace :: Char -> Bool+isJsSpace c = case fromEnum c of+  0x0009 -> True+  0x000A -> True+  0x000B -> True+  0x000C -> True+  0x000D -> True+  0x0020 -> True+  0x00A0 -> True+  0x1680 -> True+  0x2028 -> True+  0x2029 -> True+  0x202F -> True+  0x205F -> True+  0x3000 -> True+  0xFEFF -> True+  n -> n >= 0x2000 && n <= 0x200A++-- | The string-to-number conversion of JavaScript's @Number(string)@: the+-- whole trimmed string must be a literal (decimal, @Infinity@, or an+-- unsigned @0x@, @0o@ or @0b@ literal); the empty string is zero; anything+-- else is @NaN@.+stringToNumber :: Text -> Double+stringToNumber source+  | null trimmed = 0+  | otherwise = case radixLiteral trimmed of+      Just number -> number+      Nothing -> case decimalPrefix trimmed of+        Just (number, []) -> number+        _ -> nan+  where+    trimmed = dropWhileEnd isJsSpace (dropWhile isJsSpace (Text.unpack source))++-- | JavaScript's @parseFloat@ on a string: the longest decimal prefix after+-- leading whitespace, ignoring what follows; @NaN@ if there is none. Unlike+-- 'stringToNumber' it accepts trailing junk and does not read @0x@ literals.+parseFloatString :: Text -> Double+parseFloatString source =+  case decimalPrefix (dropWhile isJsSpace (Text.unpack source)) of+    Just (number, _) -> number+    Nothing -> nan++-- | @0x..@, @0o..@, @0b..@ literals (unsigned, at least one digit).+radixLiteral :: String -> Maybe Double+radixLiteral ('0' : marker : digits)+  | not (null digits)+  , Just base <- radixOf marker+  , all (isDigitIn base) digits =+      Just (radixValue base digits)+radixLiteral _ = Nothing++-- | The value of a digit string in a power-of-two base. Anything with more+-- than 1100 significant bits overflows a double, and is decided from the+-- digit count so a hostile million-digit literal costs nothing.+radixValue :: Integer -> String -> Double+radixValue base digits+  | length significant * bitsPerDigit > 1100 = infinity+  | otherwise = fromRational (fromInteger (List.foldl' (\acc c -> acc * base + toInteger (digitToInt c)) 0 significant))+  where+    significant = dropWhile (== '0') digits+    bitsPerDigit = case base of+      16 -> 4+      8 -> 3+      _ -> 1++radixOf :: Char -> Maybe Integer+radixOf 'x' = Just 16+radixOf 'X' = Just 16+radixOf 'o' = Just 8+radixOf 'O' = Just 8+radixOf 'b' = Just 2+radixOf 'B' = Just 2+radixOf _ = Nothing++isDigitIn :: Integer -> Char -> Bool+isDigitIn base c = isHexDigit c && toInteger (digitToInt c) < base++-- | The longest prefix of the input that is a decimal literal+-- (@[+-]digits[.digits][e[+-]digits]@, @.digits@, or @[+-]Infinity@),+-- with its value and the unread remainder.+decimalPrefix :: String -> Maybe (Double, String)+decimalPrefix input =+  case stripPrefix "Infinity" afterSign of+    Just remainder -> Just (applySign infinity, remainder)+    Nothing -> fmap (first applySign) (unsignedDecimal afterSign)+  where+    (negative, afterSign) = case input of+      '-' : rest -> (True, rest)+      '+' : rest -> (False, rest)+      _ -> (False, input)+    applySign x = if negative then negate x else x++unsignedDecimal :: String -> Maybe (Double, String)+unsignedDecimal s+  | null intDigits && null fracDigits = Nothing+  | otherwise = Just (assemble intDigits fracDigits exponentValue, remainder)+  where+    (intDigits, afterInt) = span isDigit s+    (fracDigits, afterFrac) = case afterInt of+      '.' : rest -> span isDigit rest+      _ -> ([], afterInt)+    (exponentValue, remainder) = exponentPart afterFrac++-- | An optional exponent. If the marker is not followed by digits it is+-- not part of the literal and is left unread.+exponentPart :: String -> (Integer, String)+exponentPart input = case input of+  marker : rest+    | marker == 'e' || marker == 'E' ->+        let (isNegative, afterSign) = case rest of+              '-' : more -> (True, more)+              '+' : more -> (False, more)+              _ -> (False, rest)+            (digits, remainder) = span isDigit afterSign+         in if null digits+              then (0, input)+              else (withSign isNegative (exponentMagnitude digits), remainder)+  _ -> (0, input)+  where+    withSign flag n = if flag then negate n else n++-- | An exponent's size, saturated at 10^9: every double is decided long+-- before that, and a hostile exponent must not become a huge 'Integer'.+exponentMagnitude :: String -> Integer+exponentMagnitude digits+  | length significant > 9 = 1000000000+  | otherwise = digitsToInteger significant+  where+    significant = dropWhile (== '0') digits++digitsToInteger :: String -> Integer+digitsToInteger = List.foldl' (\acc c -> acc * 10 + toInteger (digitToInt c)) 0++-- | Build the double from decimal digits and a power of ten, correctly+-- rounded (see 'decimalToDouble').+--+-- Only the first 'significantDigits' digits are converted. A longer literal+-- needs no more: 768 significant digits decide the rounding of every double+-- (the longest exact decimal expansion of a halfway case), and any non-zero+-- digit beyond them can only break a tie, which is recorded as one sticky+-- trailing digit. That keeps the cost linear in the length of the literal;+-- converting every digit is quadratic, and a million-digit string used in a+-- number context would otherwise take minutes.+assemble :: String -> String -> Integer -> Double+assemble intDigits fracDigits exponentValue =+  decimalToDouble mantissa (exponentValue - toInteger (length fracDigits) + dropped)+  where+    digits = dropWhile (== '0') (intDigits ++ fracDigits)+    excess = length digits - significantDigits+    (mantissa, dropped)+      | excess <= 0 = (digitsToInteger digits, 0)+      | any (/= '0') (drop significantDigits digits) =+          (digitsToInteger (take significantDigits digits) * 10 + 1, toInteger excess - 1)+      | otherwise = (digitsToInteger (take significantDigits digits), toInteger excess)++-- | Digits kept when converting a decimal literal; see 'assemble'.+significantDigits :: Int+significantDigits = 800++-- ---------------------------------------------------------------------+-- Writing+-- ---------------------------------------------------------------------++-- | JavaScript's @Number::toString@ (ECMAScript, radix 10): the shortest+-- digits that read back as the double, laid out in plain or exponent form.+numberToString :: Double -> Text+numberToString d+  | isNaN d = "NaN"+  | d == 0 = "0"+  | isInfinite d = if d > 0 then "Infinity" else "-Infinity"+  | d < 0 = "-" <> numberToString (negate d)+  | Just whole <- wholeNumber d = Text.pack (show whole)+  | otherwise = Text.pack (layoutDigits digits (length digits) (length digits + exponent10))+  where+    (coefficientDigits, exponent10) = shortestDecimal d+    digits = show coefficientDigits++-- | A finite double as the decimal JavaScript would print for it. Negative+-- zero becomes zero, as @JSON.stringify@ writes it.+doubleToScientific :: Double -> Scientific+doubleToScientific d+  | d == 0 = 0+  | d < 0 = negate (doubleToScientific (negate d))+  | otherwise = uncurry scientific (shortestDecimal d)++-- | A positive double below 2^53 that is a whole number, as an integer.+wholeNumber :: Double -> Maybe Int64+wholeNumber d+  | d < twoTo53 && fromIntegral whole == d = Just whole+  | otherwise = Nothing+  where+    whole = truncate d++-- | The digits ECMAScript prints for a positive finite double, as+-- @(digits, pointPosition)@ in the convention of 'floatToDigits': the value+-- is @0.d1d2...dn * 10^pointPosition@.+shortestDigits :: Double -> ([Int], Int)+shortestDigits d = (map digitToInt digits, length digits + exponent10)+  where+    (coefficientDigits, exponent10) = shortestDecimal d+    digits = show coefficientDigits++-- | The decimal ECMAScript prints for a positive finite double, as+-- @(significand, exponent)@ with the value @significand * 10^exponent@ and no+-- trailing zeros in the significand.+--+-- ECMAScript asks for the fewest digits that read back as the double, and+-- among those the closest to it, and among two equally close the even one.+-- A whole number below 2^53 is its own digits; anything else is Ryū+-- ("JsonLogic.Core.Ryu"), which answers exactly that question.+shortestDecimal :: Double -> (Integer, Int)+shortestDecimal d = case wholeNumber d of+  Just whole -> stripZeros (toInteger whole) 0+  Nothing -> case shortestDecimalRyu d of+    (digits, exponent10) -> stripZeros (toInteger digits) exponent10+  where+    stripZeros m e+      | m /= 0 && m `rem` 10 == 0 = stripZeros (m `quot` 10) (e + 1)+      | otherwise = (m, e)++-- | 'shortestDigits' computed exactly, for every positive finite double.+--+-- GHC's 'floatToDigits' only accepts digit strings strictly inside the+-- rounding interval of the double. ECMAScript also accepts the exact+-- halfway point when the double's significand is even, because+-- round-half-even reads it back to the same double. For doubles of 2^53 and+-- above those points are whole numbers, so a shorter string can exist where+-- GHC needs one more digit: @2.2531e21@, not @2.2531000000000001e21@. Below+-- 2^53 the halfway points have too many digits to matter, so GHC's answer+-- stands, except that when two candidates of the shortest length are equally+-- close (@9007199254740993 / 10@ is halfway between @...099.2@ and @...099.3@)+-- ECMAScript takes the even one, and 'floatToDigits' does not.+shortestDigitsExact :: Double -> ([Int], Int)+shortestDigitsExact d+  | d < twoTo53 =+      -- A tie between two equally short candidates needs 16 or more digits:+      -- below that, decimal spacing is wider than the spacing of doubles.+      if length digits >= 16 then fromMaybe generated (shorter (length digits)) else generated+  | otherwise = fromMaybe generated (listToMaybe (mapMaybe shorter [1 .. length digits]))+  where+    generated@(digits, pointPosition) = floatToDigits 10 d+    exact = toRational d+    shorter k =+      case sortOn rank (filter readsBack [lower, upper]) of+        best : _ -> Just (digitsOf best k)+        [] -> Nothing+      where+        scale = 10 ^^ (pointPosition - k) :: Rational+        scaled = exact / scale+        lower = floor scaled :: Integer+        upper = ceiling scaled :: Integer+        readsBack m = fromRational (fromInteger m * scale) == d+        rank m = (abs (fromInteger m * scale - exact), odd m)+    digitsOf m k = (map digitToInt (dropWhileEnd (== '0') text), pointPosition + length text - k)+      where+        text = show m++-- | @layoutDigits ds k n@: @k@ significant digits @ds@ whose decimal+-- point sits @n@ places to the right of their start, laid out as+-- ECMAScript's @Number::toString@ does.+layoutDigits :: String -> Int -> Int -> String+layoutDigits ds k n+  | k <= n && n <= 21 = ds ++ replicate (n - k) '0'+  | 0 < n && n <= 21 = take n ds ++ "." ++ drop n ds+  | -6 < n && n <= 0 = "0." ++ replicate (negate n) '0' ++ ds+  | otherwise = exponentForm+  where+    power = n - 1+    sign = if power < 0 then "-" else "+"+    exponentForm = case ds of+      [] -> ""+      [c] -> c : "e" ++ sign ++ show (abs power)+      c : cs -> c : '.' : cs ++ "e" ++ sign ++ show (abs power)++-- ---------------------------------------------------------------------+-- Arithmetic+-- ---------------------------------------------------------------------++-- | JavaScript's @%@ on doubles: the truncating remainder, which takes the+-- dividend's sign, including when it is zero (@-4 % 2@ is @-0@). Exact:+-- whole numbers below 2^53 use integer 'rem', anything else is computed in+-- 'Rational'.+remainderJs :: Double -> Double -> Double+remainderJs x y+  | isNaN x || isNaN y || isInfinite x || y == 0 = nan+  | isInfinite y = x+  | x == 0 = x+  | otherwise = signed (magnitude (abs x) (abs y))+  where+    signed r+      | x < 0 = negate r+      | otherwise = r+    magnitude a b = case (wholeNumber a, wholeNumber b) of+      (Just m, Just n) -> fromIntegral (m `rem` n)+      _ ->+        let ra = toRational a+            rb = toRational b+         in fromRational (ra - rb * fromInteger (truncate (ra / rb)))++-- | @Math.max@: @NaN@ if any argument is, @-Infinity@ for none, and+-- @+0@ above @-0@ regardless of argument order.+maximumJs :: [Double] -> Double+maximumJs xs+  | any isNaN xs = nan+  | otherwise = List.foldl' larger (negate infinity) xs+  where+    larger a b+      | a > b = a+      | b > a = b+      | isNegativeZero a = b+      | otherwise = a++-- | @Math.min@: @NaN@ if any argument is, @Infinity@ for none, and+-- @-0@ below @+0@ regardless of argument order.+minimumJs :: [Double] -> Double+minimumJs xs+  | any isNaN xs = nan+  | otherwise = List.foldl' smaller infinity xs+  where+    smaller a b+      | a < b = a+      | b < a = b+      | isNegativeZero a = a+      | otherwise = b
+ src/JsonLogic/Core/Operator.hs view
@@ -0,0 +1,126 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : JsonLogic.Core.Operator+--+-- The names of operators, and the closed set of builtin ones.+--+-- This module is the single source of truth for which operator names are+-- builtin. The parser, the custom-operator registry (which refuses builtin+-- names), the test generators and the conformance runners all take the set+-- from 'allBuiltins', so adding an operator is one constructor here plus the+-- cases the compiler then demands, never an edit to a list kept elsewhere.+module JsonLogic.Core.Operator+  ( OperatorName (..)+  , Builtin (..)+  , allBuiltins+  , builtinName+  , builtinOperatorName+  , lookupBuiltin+  ) where++import qualified Data.Map.Strict as Map+import Data.Text (Text)++-- | An operator's name, as it appears as the only key of a rule object.+-- A newtype, so it cannot be confused with a variable path or any other+-- string.+newtype OperatorName = OperatorName Text+  deriving (Eq, Ord, Show)++-- | The operators of the classic JsonLogic specification+-- (<https://jsonlogic.com/operations.html>), as implemented by the reference,+-- json-logic-js 2.0.5. @?:@ is the reference's alias of @if@ and has its own+-- constructor because it has its own name.+data Builtin+  = BVar+  | BMissing+  | BMissingSome+  | BIf+  | BTernary+  | BAnd+  | BOr+  | BNot+  | BDoubleNot+  | BLooseEq+  | BStrictEq+  | BLooseNeq+  | BStrictNeq+  | BLt+  | BLte+  | BGt+  | BGte+  | BMax+  | BMin+  | BAdd+  | BSub+  | BMul+  | BDiv+  | BMod+  | BMap+  | BFilter+  | BReduce+  | BAll+  | BNone+  | BSome+  | BMerge+  | BIn+  | BCat+  | BSubstr+  | BLog+  deriving (Eq, Ord, Show, Enum, Bounded)++-- | Every builtin operator, in declaration order.+allBuiltins :: [Builtin]+allBuiltins = [minBound .. maxBound]++-- | The name a rule uses for a builtin.+builtinName :: Builtin -> Text+builtinName builtin = case builtin of+  BVar -> "var"+  BMissing -> "missing"+  BMissingSome -> "missing_some"+  BIf -> "if"+  BTernary -> "?:"+  BAnd -> "and"+  BOr -> "or"+  BNot -> "!"+  BDoubleNot -> "!!"+  BLooseEq -> "=="+  BStrictEq -> "==="+  BLooseNeq -> "!="+  BStrictNeq -> "!=="+  BLt -> "<"+  BLte -> "<="+  BGt -> ">"+  BGte -> ">="+  BMax -> "max"+  BMin -> "min"+  BAdd -> "+"+  BSub -> "-"+  BMul -> "*"+  BDiv -> "/"+  BMod -> "%"+  BMap -> "map"+  BFilter -> "filter"+  BReduce -> "reduce"+  BAll -> "all"+  BNone -> "none"+  BSome -> "some"+  BMerge -> "merge"+  BIn -> "in"+  BCat -> "cat"+  BSubstr -> "substr"+  BLog -> "log"++-- | 'builtinName' as an 'OperatorName'.+builtinOperatorName :: Builtin -> OperatorName+builtinOperatorName = OperatorName . builtinName++-- | The builtin with exactly this name, if there is one. There is no+-- trimming, no case folding and no dotted-path resolution.+lookupBuiltin :: Text -> Maybe Builtin+lookupBuiltin name = Map.lookup name byName++byName :: Map.Map Text Builtin+byName = Map.fromList [(builtinName builtin, builtin) | builtin <- allBuiltins]
+ src/JsonLogic/Core/Parse.hs view
@@ -0,0 +1,249 @@+{-# LANGUAGE LambdaCase #-}++-- |+-- Module      : JsonLogic.Core.Parse+--+-- Reads a JSON 'Value' into a 'Rule'. Reading is an unfold+-- ('unfoldFixM'): a seed is a piece of JSON still to read, with how deeply it+-- is nested and where it is, and each step produces one node whose children+-- are further seeds.+--+-- What counts as a rule follows the reference implementation: an object with+-- exactly one key is an operator call, an array is read element by element,+-- and anything else (including an object with zero or several keys) is a+-- literal.+--+-- __Arity.__ The reference ignores surplus operands and treats missing ones as+-- JavaScript @undefined@. In 'Strict' mode (the default) a wrong count is a+-- precise 'ArityMismatch' instead. In 'Lenient' mode the count is repaired+-- here, exactly as the reference behaves: missing operands become+-- @undefined@ (@null@ for the initial value of @reduce@), and surplus ones are+-- dropped, after being evaluated for their failures where the reference+-- evaluates them ('Discard'). How each builtin treats its operands is stated+-- once, in @shapeOf@.+module JsonLogic.Core.Parse+  ( parseRule+  , parseRuleWith+  ) where++import Data.Aeson (Value (..))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Fix (unfoldFixM)+import Data.Foldable (toList)+import Data.Text (Text)++import JsonLogic.Core.Config (ArityMode (..), Config (..), defaultConfig)+import JsonLogic.Core.Errors (Arity (..), ParseError (..))+import JsonLogic.Core.Location (Location, Segment (..), child, root)+import JsonLogic.Core.Operator+  ( Builtin (..)+  , OperatorName (..)+  , builtinOperatorName+  , lookupBuiltin+  )+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))+import JsonLogic.Core.Value (nestsDeeperThan)++-- | Read a rule with 'defaultConfig'.+parseRule :: Value -> Either (Location, ParseError) Rule+parseRule = parseRuleWith defaultConfig++-- | Read a rule with the configured depth limit and arity mode. A failure+-- says where in the rule it happened.+parseRuleWith :: Config -> Value -> Either (Location, ParseError) Rule+parseRuleWith config value = unfoldFixM (step config) (Seed 0 root (Json value))++-- | Something still to read: how many levels deep it is, where it is, and+-- what it is.+data Seed = Seed !Int !Location !Source++data Source+  = -- | JSON from the rule.+    Json Value+  | -- | An operand the rule leaves out, which the reference reads as+    -- @undefined@ or, in two places, @null@.+    Absent Absence+  | -- | An operator call whose operand count has already been repaired.+    Repaired Builtin [Seed]++data Absence = AbsentUndefined | AbsentNull++-- | One layer of the unfold.+step :: Config -> Seed -> Either (Location, ParseError) (Node Seed)+step config (Seed depth location source)+  | depth > limit = Left (location, DepthExceeded limit)+  | otherwise = Node location <$> layer+  where+    limit = maxDepth config+    layer = case source of+      Json (Array items) ->+        Right (Arr [Seed (depth + 1) (child location (Index i)) (Json item) | (i, item) <- zip [0 ..] (toList items)])+      Json value@(Object fields) -> case KeyMap.toList fields of+        [(key, operands)] -> operator config depth location (Key.toText key) operands+        _ -> literal value+      Json value -> literal value+      -- @{"and": []}@ is how the reference's @undefined@ is written as a rule+      Absent AbsentUndefined -> Right (And [])+      Absent AbsentNull -> Right (Lit Null)+      Repaired builtin operands -> build location builtin operands+    -- A literal's own nesting counts towards the limit, so a deeply nested+    -- object cannot be smuggled past it inside a rule.+    literal value+      | nestsDeeperThan (limit - depth) value = Left (location, DepthExceeded limit)+      | otherwise = Right (Lit value)++-- | An operator call: a builtin, read to its shape, or a custom operator.+operator :: Config -> Int -> Location -> Text -> Value -> Either (Location, ParseError) (RuleF Seed)+operator config depth location name operands = case lookupBuiltin name of+  Nothing -> Right (CustomOp (OperatorName name) seeds)+  Just builtin -> case arityMode config of+    Strict+      | admits (shapeArity shape) (length seeds) -> build location builtin (padded depth location builtin seeds)+      | otherwise -> Left (location, ArityMismatch (builtinOperatorName builtin) (shapeArity shape) (length seeds))+      where+        shape = shapeOf builtin+    Lenient -> lenient depth location builtin seeds+  where+    seeds = case operands of+      Array items ->+        [Seed (depth + 1) (child here (Index i)) (Json item) | (i, item) <- zip [0 ..] (toList items)]+      -- a bare operand is shorthand for a list of one+      other -> [Seed (depth + 1) here (Json other)]+    here = child location (Key name)++-- | Repair the operand count as the reference would, and build the call.+lenient :: Int -> Location -> Builtin -> [Seed] -> Either (Location, ParseError) (RuleF Seed)+lenient depth location builtin seeds = case maximumOf (shapeArity shape) of+  Just most+    | length full > most -> case shapeSurplus shape of+        NeverEvaluated -> build location builtin (take most full)+        Evaluated -> Right (Discard (Seed depth location (Repaired builtin (take most full))) (drop most full))+  _ -> build location builtin full+  where+    shape = shapeOf builtin :: Shape Seed+    full = padded depth location builtin seeds++-- | The operands, followed by the ones the reference reads by position but+-- the rule leaves out, as absent values. There is no JSON for those, so they+-- are given the location of the operator itself.+padded :: Int -> Location -> Builtin -> [Seed] -> [Seed]+padded depth location builtin seeds =+  seeds+    ++ [ Seed (depth + 1) location (Absent (absence builtin i))+       | i <- [length seeds .. shapePositional (shapeOf builtin :: Shape Seed) - 1]+       ]++-- | What the reference reads for a missing operand: @undefined@, except+-- that a missing @var@ path is the data itself (the classic suite requires+-- @{"var": []}@ to mean the data, as @{"var": null}@ does), and the missing+-- initial value of @reduce@ is @null@.+absence :: Builtin -> Int -> Absence+absence BVar 0 = AbsentNull+absence BReduce 2 = AbsentNull+absence _ _ = AbsentUndefined++-- | Build a call whose operand count the shape admits.+build :: Location -> Builtin -> [Seed] -> Either (Location, ParseError) (RuleF Seed)+build location builtin seeds = case shapeBuild shape seeds of+  Just node -> Right node+  Nothing -> Left (location, ArityMismatch (builtinOperatorName builtin) (shapeArity shape) (length seeds))+  where+    shape = shapeOf builtin++-- ---------------------------------------------------------------------+-- How each builtin takes its operands+-- ---------------------------------------------------------------------++-- | How an operator takes its operands, building nodes with children of+-- type @a@.+data Shape a = Shape+  { shapeArity :: Arity+  -- ^ The operand counts strict mode accepts.+  , shapePositional :: Int+  -- ^ How many leading operands the reference reads by position, so that a+  -- missing one is read as absent ('absence').+  , shapeSurplus :: Surplus+  -- ^ Whether the reference evaluates operands past the most it uses.+  , shapeBuild :: [a] -> Maybe (RuleF a)+  -- ^ The node, if the count is one the constructor can hold.+  }++data Surplus+  = -- | Every operand is evaluated before the operator runs.+    Evaluated+  | -- | The operator evaluates only the operands it uses (@map@ and the+    -- other operators with a body).+    NeverEvaluated++-- | The single statement of every builtin's operands. The evaluator follows+-- it; the outlier suite checks it against the reference in both modes.+shapeOf :: Builtin -> Shape a+shapeOf builtin = case builtin of+  BVar -> Shape (Between 0 2) 1 Evaluated (oneOrTwo Var)+  BMissing -> variadic Missing+  BMissingSome -> exactlyTwo MissingSome+  BIf -> variadic If+  BTernary -> variadic If+  BAnd -> variadic And+  BOr -> variadic Or+  BNot -> exactlyOne Not+  BDoubleNot -> exactlyOne DoubleNot+  BLooseEq -> exactlyTwo LooseEq+  BStrictEq -> exactlyTwo StrictEq+  BLooseNeq -> exactlyTwo LooseNeq+  BStrictNeq -> exactlyTwo StrictNeq+  BLt -> twoOrThree Lt+  BLte -> twoOrThree Lte+  BGt -> exactlyTwo Gt+  BGte -> exactlyTwo Gte+  BMax -> variadic Max+  BMin -> variadic Min+  BAdd -> variadic Add+  BSub -> Shape (Between 1 2) 1 Evaluated (oneOrTwo Sub)+  -- strict mode refuses an empty @*@; lenient mode keeps it, and it throws+  -- if it is ever evaluated, as in the reference+  BMul -> Shape (AtLeast 1) 0 Evaluated (Just . Mul)+  BDiv -> exactlyTwo Div+  BMod -> exactlyTwo Mod+  BMap -> lazily (exactlyTwo Map)+  BFilter -> lazily (exactlyTwo Filter)+  BReduce -> lazily (exactlyThree Reduce)+  BAll -> lazily (exactlyTwo All)+  BNone -> lazily (exactlyTwo None)+  BSome -> lazily (exactlyTwo Some)+  BMerge -> variadic Merge+  BIn -> exactlyTwo In+  BCat -> variadic Cat+  BSubstr -> twoOrThree Substr+  BLog -> exactlyOne Log+  where+    variadic make = Shape (AtLeast 0) 0 Evaluated (Just . make)+    exactlyOne make = Shape (Exactly 1) 1 Evaluated $ \case+      [x] -> Just (make x)+      _ -> Nothing+    exactlyTwo make = Shape (Exactly 2) 2 Evaluated $ \case+      [x, y] -> Just (make x y)+      _ -> Nothing+    exactlyThree make = Shape (Exactly 3) 3 Evaluated $ \case+      [x, y, z] -> Just (make x y z)+      _ -> Nothing+    oneOrTwo make = \case+      [x] -> Just (make x Nothing)+      [x, y] -> Just (make x (Just y))+      _ -> Nothing+    twoOrThree make = Shape (Between 2 3) 2 Evaluated $ \case+      [x, y] -> Just (make x y Nothing)+      [x, y, z] -> Just (make x y (Just z))+      _ -> Nothing+    lazily shape = shape{shapeSurplus = NeverEvaluated}++admits :: Arity -> Int -> Bool+admits (Exactly n) given = given == n+admits (AtLeast n) given = given >= n+admits (Between low high) given = low <= given && given <= high++maximumOf :: Arity -> Maybe Int+maximumOf (Exactly n) = Just n+maximumOf (AtLeast _) = Nothing+maximumOf (Between _ high) = Just high
+ src/JsonLogic/Core/Rule.hs view
@@ -0,0 +1,185 @@+{-# LANGUAGE DeriveTraversable #-}++-- |+-- Module      : JsonLogic.Core.Rule+--+-- The rule tree: one layer of it ('RuleF', the base functor), and the+-- recursive tree whose every node records where in the rule's JSON it came+-- from ('Rule').+--+-- Recursion over the tree is never written by hand: the parser builds it+-- with an unfold ('Data.Fix.unfoldFixM') and the evaluator consumes it with a+-- fold ('Data.Fix.foldFix'), both from @data-fix@.+--+-- The builtin operators are a closed sum, matched exhaustively, so an+-- operator cannot be half-implemented. Operators supplied by the caller are+-- the one open extension point, 'CustomOp', whose behaviour is looked up when+-- the rule is compiled.+--+-- Constructors are shaped so that, where an operator takes a fixed number+-- of operands or a small range of them, a wrong count cannot be built+-- ('Lt' takes two operands and an optional third, for instance). The+-- operators that take any number of operands hold a list.+module JsonLogic.Core.Rule+  ( RuleF (..)+  , Node (..)+  , Rule+  , Term+  , stripLocations+  ) where++import Data.Aeson (Value)+import Data.Fix (Fix (..), foldFix)+import Data.Foldable (toList)+import Data.Functor (void)+import Data.Functor.Classes (Eq1 (..), Show1 (..))++import JsonLogic.Core.Location (Location)+import JsonLogic.Core.Operator (OperatorName)++-- | One layer of a JsonLogic rule, with its sub-rules at type @a@. Each+-- constructor's comment states what the reference implementation does, which+-- is what the evaluator implements.+data RuleF a+  = -- | A JSON scalar, or an object that is not an operator call (it has zero+    -- or several keys). Evaluates to itself.+    Lit Value+  | -- | A JSON array. The reference evaluates an array element by element,+    -- each element being a rule.+    Arr [a]+  | -- | @{"var": [path, default]}@. The path is a sub-rule, evaluated and+    -- then read as a dotted path or an index; the default, if given, is+    -- used when the lookup finds nothing.+    Var a (Maybe a)+  | -- | @{"missing": [k1, k2, ...]}@: the keys whose values are absent, or+    -- empty strings. If the first key evaluates to an array, that array is+    -- the key list.+    Missing [a]+  | -- | @{"missing_some": [need, keys]}@: nothing, if at least @need@ of the+    -- keys are present; otherwise the missing ones.+    MissingSome a a+  | -- | @{"if": [c1, t1, c2, t2, ..., else]}@ (also written @?:@): the+    -- consequent of the first truthy condition, else the final operand if the+    -- count is odd, else @null@. Only what is needed is evaluated.+    If [a]+  | -- | The first falsy operand, or the last operand; JavaScript @undefined@+    -- when there are none. Stops at the first falsy one.+    And [a]+  | -- | The first truthy operand, or the last operand; JavaScript+    -- @undefined@ when there are none. Stops at the first truthy one.+    Or [a]+  | -- | @!@: the negation of the operand's truthiness.+    Not a+  | -- | @!!@: the operand's truthiness.+    DoubleNot a+  | -- | @==@, JavaScript's loose equality.+    LooseEq a a+  | -- | @===@, JavaScript's strict equality.+    StrictEq a a+  | -- | @!=@.+    LooseNeq a a+  | -- | @!==@.+    StrictNeq a a+  | -- | @<@, or with a third operand the chained @a < b < c@ ("between").+    Lt a a (Maybe a)+  | -- | @<=@, or with a third operand the chained @a <= b <= c@.+    Lte a a (Maybe a)+  | -- | @>@, strictly binary.+    Gt a a+  | -- | @>=@, strictly binary.+    Gte a a+  | -- | @Math.max@ of the operands: @-Infinity@ (written @null@) for none.+    Max [a]+  | -- | @Math.min@ of the operands: @Infinity@ (written @null@) for none.+    Min [a]+  | -- | The sum of the operands read with @parseFloat@; @0@ for none.+    Add [a]+  | -- | @{"-": [x]}@ negates; @{"-": [x, y]}@ subtracts.+    Sub a (Maybe a)+  | -- | The product. The reference folds without a seed: one operand is+    -- returned as it is, and none at all throws (@InvalidOperand@+    -- when reached; strict mode refuses to read it).+    Mul [a]+  | -- | @/@, strictly binary.+    Div a a+  | -- | @%@, JavaScript's truncating remainder, strictly binary.+    Mod a a+  | -- | @{"map": [array, body]}@: the body evaluated against each element.+    Map a a+  | -- | @{"filter": [array, body]}@: the elements for which the body is+    -- truthy.+    Filter a a+  | -- | @{"reduce": [array, body, initial]}@: the body evaluated against+    -- @{"current": element, "accumulator": so far}@ for each element.+    Reduce a a a+  | -- | Whether the body is truthy for every element (false for none).+    All a a+  | -- | Whether the body is truthy for no element (true for none).+    None a a+  | -- | Whether the body is truthy for some element (false for none).+    Some a a+  | -- | The operands concatenated, arrays flattened by one level.+    Merge [a]+  | -- | @{"in": [needle, haystack]}@: substring of a string, or strict-equal+    -- element of an array.+    In a a+  | -- | The operands joined as strings.+    Cat [a]+  | -- | @{"substr": [string, start, length]}@, the length optional.+    Substr a a (Maybe a)+  | -- | @log@: its operand, unchanged. The reference also prints it; this+    -- library does no I/O.+    Log a+  | -- | @Discard inner extras@: the value of @inner@, after evaluating each+    -- of @extras@ for its failures only. Built in lenient arity mode only,+    -- for the surplus operands of an operator the reference evaluates in+    -- full before applying it, so that a surplus operand that fails still+    -- fails the rule, as in the reference.+    Discard a [a]+  | -- | An operator outside the builtin set, applied to its operands. Its+    -- behaviour is supplied by the caller.+    CustomOp OperatorName [a]+  deriving (Eq, Show, Functor, Foldable, Traversable)++-- | Two layers are equal when they have the same shape (constructor, fields+-- other than sub-rules, and number of sub-rules) and pairwise-equal+-- sub-rules. Written with 'Foldable' instead of derived by Template Haskell:+-- 'void' keeps the shape, 'toList' gives the sub-rules in order.+instance Eq1 RuleF where+  liftEq eq x y = void x == void y && and (zipWith eq (toList x) (toList y))++-- | The derived 'Show', with each sub-rule shown by the given function.+instance Show1 RuleF where+  liftShowsPrec showsChild _ d = showsPrec d . fmap (Shown . flip showsChild)++-- | A layer of the tree and where it came from.+data Node a = Node+  { nodeLocation :: !Location+  -- ^ The JSON Pointer of this node in the rule.+  , nodeRule :: !(RuleF a)+  -- ^ The node itself.+  }+  deriving (Eq, Show, Functor, Foldable, Traversable)++instance Eq1 Node where+  liftEq eq (Node a x) (Node b y) = a == b && liftEq eq x y++instance Show1 Node where+  liftShowsPrec showsChild _ d = showsPrec d . fmap (Shown . flip showsChild)++-- | A rule as read from JSON: every node carries its 'Location'.+type Rule = Fix Node++-- | A rule tree without locations, for comparing shapes.+type Term = Fix RuleF++-- | Forget where every node came from.+stripLocations :: Rule -> Term+stripLocations = foldFix (Fix . nodeRule)++-- | A value shown by a function, so that a derived 'Show' can be reused to+-- write 'Show1' instances.+newtype Shown = Shown (Int -> ShowS)++instance Show Shown where+  showsPrec d (Shown shows') = shows' d
+ src/JsonLogic/Core/Ryu.hs view
@@ -0,0 +1,305 @@+{-# LANGUAGE BangPatterns #-}++-- |+-- Module      : JsonLogic.Core.Ryu+--+-- The shortest decimal that reads back as a double: Ryū, by Ulf Adams+-- (\"Ryū: fast float-to-string conversion\", PLDI 2018), following the+-- reference implementation's @d2d@ (github.com/ulfjack/ryu, @d2s.c@).+--+-- Ryū's answer is the one ECMAScript's @Number::toString@ asks for: the+-- fewest significant digits whose decimal reads back as the double under+-- round-half-even (so an endpoint of the rounding interval counts when the+-- double's significand is even), and among those the closest to the double,+-- an exact tie going to the even digit.+--+-- The 128-bit multipliers of the reference implementation's tables are+-- computed here once, by the formulas its table generator uses, and the+-- products are taken in 64-bit words, as the reference does. The test suite checks this module against+-- an independent exact algorithm (@JsonLogic.Core.Number.shortestDigitsExact@)+-- on hundreds of thousands of doubles.+module JsonLogic.Core.Ryu+  ( shortestDecimalRyu+  ) where++import Data.Bits (shiftL, shiftR, (.&.), (.|.))+import qualified Data.Vector.Unboxed as Unboxed+import Data.Word (Word64)+import GHC.Float (castDoubleToWord64)++-- | For a positive, finite, non-zero double, @(digits, exponent)@ with the+-- double's shortest decimal equal to @digits * 10^exponent@. The digits may+-- end in zeros; the caller strips them.+shortestDecimalRyu :: Double -> (Word64, Int)+shortestDecimalRyu d = removeDigits acceptBounds (interval acceptBounds e2 m2 mmShift)+  where+    bits = castDoubleToWord64 d+    ieeeMantissa = bits .&. ((1 `shiftL` mantissaBits) - 1)+    ieeeExponent = fromIntegral ((bits `shiftR` mantissaBits) .&. 0x7FF) :: Int+    -- Step 1: the double as m2 * 2^e2, with two bits of room below.+    e2+      | ieeeExponent == 0 = 1 - bias - mantissaBits - 2+      | otherwise = ieeeExponent - bias - mantissaBits - 2+    m2+      | ieeeExponent == 0 = ieeeMantissa+      | otherwise = (1 `shiftL` mantissaBits) + ieeeMantissa+    -- An endpoint of the rounding interval reads back to the double under+    -- round-half-even exactly when the double's significand is even.+    acceptBounds = even m2+    -- The lower end of the interval is closer when the significand is a power+    -- of two (the gap below it is half the gap above).+    mmShift = if ieeeMantissa /= 0 || ieeeExponent <= 1 then 1 else 0++-- | Step 2 and 3: the decimals that read back, @[vm, vp]@ around the double's+-- own @vr@, scaled by @10^e10@, and whether the digits dropped by the scaling+-- were all zeros (for @vm@ and for @vr@).+data Interval = Interval+  { _vr :: {-# UNPACK #-} !Word64+  , _vp :: {-# UNPACK #-} !Word64+  , _vm :: {-# UNPACK #-} !Word64+  , _e10 :: {-# UNPACK #-} !Int+  , _vmZeros :: !Bool+  , _vrZeros :: !Bool+  }++interval :: Bool -> Int -> Word64 -> Word64 -> Interval+interval acceptBounds e2 m2 mmShift+  | e2 >= 0 =+      let q = log10Pow2 e2 - (if e2 > 3 then 1 else 0)+          k = pow5InvBitCount + pow5bits q - 1+          i = negate e2 + q + k+          (vr, vp, vm) = mulShiftAll m2 (pow5InvSplit q) i mmShift+       in if q > 21+            then Interval vr vp vm q False False+            else+              if mv `rem` 5 == 0+                then Interval vr vp vm q False (multipleOfPowerOf5 mv q)+                else+                  if acceptBounds+                    then Interval vr vp vm q (multipleOfPowerOf5 (mv - 1 - mmShift) q) False+                    else Interval vr (vp - (if multipleOfPowerOf5 (mv + 2) q then 1 else 0)) vm q False False+  | otherwise =+      let q = log10Pow5 (negate e2) - (if negate e2 > 1 then 1 else 0)+          i = negate e2 - q+          k = pow5bits i - pow5BitCount+          j = q - k+          (vr, vp, vm) = mulShiftAll m2 (pow5Split i) j mmShift+       in if q <= 1+            then+              -- mv has at least q trailing zero bits, so vr is exact+              if acceptBounds+                then Interval vr vp vm (q + e2) (mmShift == 1) True+                else Interval vr (vp - 1) vm (q + e2) False True+            else Interval vr vp vm (q + e2) False (q < 63 && multipleOfPowerOf2 mv q)+  where+    mv = 4 * m2++-- | Step 4: remove digits while the interval still holds a shorter decimal,+-- then round.+removeDigits :: Bool -> Interval -> (Word64, Int)+removeDigits acceptBounds (Interval vr0 vp0 vm0 e10 vmZeros0 vrZeros0)+  | vmZeros0 || vrZeros0 =+      -- The general case, which keeps track of whether the removed digits of+      -- vm and vr were all zeros, so that bounds and exact ties are decided+      -- right.+      case dropWhileWide vr0 vp0 vm0 vmZeros0 vrZeros0 0 0 of+        Dropped vr1 vp1 vm1 vmZeros1 vrZeros1 last1 removed1 ->+          let Dropped vr2 _ vm2 _ vrZeros2 last2 removed2+                | vmZeros1 = dropZerosOfVm vr1 vp1 vm1 vrZeros1 last1 removed1+                | otherwise = Dropped vr1 vp1 vm1 vmZeros1 vrZeros1 last1 removed1+              -- round half to even when the exact value is ...50...0+              last3 = if vrZeros2 && last2 == 5 && even vr2 then 4 else last2+              up = (vr2 == vm2 && (not acceptBounds || not vmZeros1)) || last3 >= 5+           in (vr2 + (if up then 1 else 0), e10 + removed2)+  | otherwise =+      -- The common case: no exact ties or bounds to worry about. Two digits+      -- at a time first, while that is possible.+      let wide = vp0 `quot` 100 > vm0 `quot` 100+          vr1 = if wide then vr0 `quot` 100 else vr0+          vp1 = if wide then vp0 `quot` 100 else vp0+          vm1 = if wide then vm0 `quot` 100 else vm0+          roundUp1 = wide && vr0 `rem` 100 >= 50+       in case dropCommon vr1 vp1 vm1 roundUp1 (if wide then 2 else 0) of+            Dropped vr2 _ vm2 _ roundUp2 _ removed2 ->+              (vr2 + (if vr2 == vm2 || roundUp2 then 1 else 0), e10 + removed2)++-- | The state of digit removal: vr, vp, vm, whether vm's removed digits+-- were zeros, whether vr's were (or, in the common case, whether to round+-- up), the last digit removed from vr, and how many were removed.+data Dropped+  = Dropped+      {-# UNPACK #-} !Word64+      {-# UNPACK #-} !Word64+      {-# UNPACK #-} !Word64+      !Bool+      !Bool+      {-# UNPACK #-} !Word64+      {-# UNPACK #-} !Int++-- | Remove digits while the shortened interval is still non-empty, tracking+-- trailing zeros.+dropWhileWide :: Word64 -> Word64 -> Word64 -> Bool -> Bool -> Word64 -> Int -> Dropped+dropWhileWide !vr !vp !vm !vmZeros !vrZeros !lastDigit !removed+  | vp `quot` 10 <= vm `quot` 10 = Dropped vr vp vm vmZeros vrZeros lastDigit removed+  | otherwise =+      dropWhileWide+        (vr `quot` 10)+        (vp `quot` 10)+        (vm `quot` 10)+        (vmZeros && vm `rem` 10 == 0)+        (vrZeros && lastDigit == 0)+        (vr `rem` 10)+        (removed + 1)++-- | When vm ends in zeros, remove them too: the shorter vm is still in range.+dropZerosOfVm :: Word64 -> Word64 -> Word64 -> Bool -> Word64 -> Int -> Dropped+dropZerosOfVm !vr !vp !vm !vrZeros !lastDigit !removed+  | vm == 0 || vm `rem` 10 /= 0 = Dropped vr vp vm True vrZeros lastDigit removed+  | otherwise =+      dropZerosOfVm+        (vr `quot` 10)+        (vp `quot` 10)+        (vm `quot` 10)+        (vrZeros && lastDigit == 0)+        (vr `rem` 10)+        (removed + 1)++-- | The common case's digit removal: remember only whether to round up.+dropCommon :: Word64 -> Word64 -> Word64 -> Bool -> Int -> Dropped+dropCommon !vr !vp !vm !roundUp !removed+  | vp `quot` 10 <= vm `quot` 10 = Dropped vr vp vm False roundUp 0 removed+  | otherwise = dropCommon (vr `quot` 10) (vp `quot` 10) (vm `quot` 10) (vr `rem` 10 >= 5) (removed + 1)++-- | @floor (m * multiplier / 2^shift)@ for the centre and both ends of the+-- interval: @4m@, @4m + 2@ and @4m - 1 - mmShift@. The multiplier is 128+-- bits, as a (low, high) pair.+mulShiftAll :: Word64 -> (Word64, Word64) -> Int -> Word64 -> (Word64, Word64, Word64)+{-# INLINE mulShiftAll #-}+mulShiftAll m multiplier shift mmShift =+  ( mulShift64 (4 * m) multiplier shift+  , mulShift64 (4 * m + 2) multiplier shift+  , mulShift64 (4 * m - 1 - mmShift) multiplier shift+  )++-- | @floor (m * multiplier / 2^shift)@ for a 64-bit @m@ and a 128-bit+-- multiplier, with @64 < shift < 128@: the reference implementation's+-- @mulShift64@.+mulShift64 :: Word64 -> (Word64, Word64) -> Int -> Word64+{-# INLINE mulShift64 #-}+mulShift64 m (mulLow, mulHigh) shift = shiftRight128 sumLow sumHigh (shift - 64)+  where+    (high0, _) = umul128 m mulLow+    (high2, low2) = umul128 m mulHigh+    sumLow = high0 + low2+    sumHigh = high2 + (if sumLow < high0 then 1 else 0)++-- | The low 64 bits of a 128-bit value shifted right by @0 < n < 64@.+shiftRight128 :: Word64 -> Word64 -> Int -> Word64+{-# INLINE shiftRight128 #-}+shiftRight128 low high n = (high `shiftL` (64 - n)) .|. (low `shiftR` n)++-- | The full 128-bit product of two 64-bit words, as (high, low), from+-- 32-bit halves (the reference implementation's portable @umul128@).+umul128 :: Word64 -> Word64 -> (Word64, Word64)+{-# INLINE umul128 #-}+umul128 a b = (high, low)+  where+    lowMask = 0xFFFFFFFF+    aLow = a .&. lowMask+    aHigh = a `shiftR` 32+    bLow = b .&. lowMask+    bHigh = b `shiftR` 32+    b00 = aLow * bLow+    b01 = aLow * bHigh+    b10 = aHigh * bLow+    b11 = aHigh * bHigh+    mid1 = b10 + (b00 `shiftR` 32)+    mid2 = b01 + (mid1 .&. lowMask)+    high = b11 + (mid1 `shiftR` 32) + (mid2 `shiftR` 32)+    low = ((mid2 .&. lowMask) `shiftL` 32) .|. (b00 .&. lowMask)++mantissaBits :: Int+mantissaBits = 52++bias :: Int+bias = 1023++pow5InvBitCount :: Int+pow5InvBitCount = 125++pow5BitCount :: Int+pow5BitCount = 125++-- | The bit length of @5^e@, for @0 <= e <= 3528@.+pow5bits :: Int -> Int+pow5bits e = ((e * 1217359) `shiftR` 19) + 1++-- | @floor (log10 (2^e))@, for @0 <= e <= 1650@.+log10Pow2 :: Int -> Int+log10Pow2 e = (e * 78913) `shiftR` 18++-- | @floor (log10 (5^e))@, for @0 <= e <= 2620@.+log10Pow5 :: Int -> Int+log10Pow5 e = (e * 732923) `shiftR` 20++multipleOfPowerOf5 :: Word64 -> Int -> Bool+multipleOfPowerOf5 value p = pow5Factor value 0 >= p+  where+    pow5Factor !v !count+      | v /= 0 && v `rem` 5 == 0 = pow5Factor (v `quot` 5) (count + 1)+      | otherwise = count :: Int++multipleOfPowerOf2 :: Word64 -> Int -> Bool+multipleOfPowerOf2 value p = value .&. ((1 `shiftL` p) - 1) == 0++-- | @floor (2^(bitLength (5^q) - 1 + 125) / 5^q) + 1@, the reference+-- implementation's @DOUBLE_POW5_INV_SPLIT[q]@, as (low, high) words.+pow5InvSplit :: Int -> (Word64, Word64)+pow5InvSplit q = (pow5InvLow Unboxed.! q, pow5InvHigh Unboxed.! q)++-- | @5^i@ normalised to 125 bits, the reference implementation's+-- @DOUBLE_POW5_SPLIT[i]@, as (low, high) words.+pow5Split :: Int -> (Word64, Word64)+pow5Split i = (pow5Low Unboxed.! i, pow5High Unboxed.! i)++-- | Both tables are computed once, on first use, from their definitions.+pow5InvLow, pow5InvHigh, pow5Low, pow5High :: Unboxed.Vector Word64+pow5InvLow = Unboxed.generate pow5InvTableSize (lowWord . pow5InvEntry)+pow5InvHigh = Unboxed.generate pow5InvTableSize (highWord . pow5InvEntry)+pow5Low = Unboxed.generate pow5TableSize (lowWord . pow5Entry)+pow5High = Unboxed.generate pow5TableSize (highWord . pow5Entry)+{-# NOINLINE pow5InvLow #-}+{-# NOINLINE pow5InvHigh #-}+{-# NOINLINE pow5Low #-}+{-# NOINLINE pow5High #-}++-- | Large enough for every double: @q@ reaches 291 and @i@ reaches 325.+pow5InvTableSize, pow5TableSize :: Int+pow5InvTableSize = 342+pow5TableSize = 326++pow5InvEntry :: Int -> Integer+pow5InvEntry q = ((1 :: Integer) `shiftL` (bitLength power - 1 + pow5InvBitCount)) `quot` power + 1+  where+    power = 5 ^ q++pow5Entry :: Int -> Integer+pow5Entry i+  | excess >= 0 = power `shiftR` excess+  | otherwise = power `shiftL` negate excess+  where+    power = 5 ^ i :: Integer+    excess = bitLength power - pow5BitCount++lowWord :: Integer -> Word64+lowWord = fromInteger . (.&. 0xFFFFFFFFFFFFFFFF)++highWord :: Integer -> Word64+highWord = fromInteger . (`shiftR` 64)++bitLength :: Integer -> Int+bitLength = go 0+  where+    go n v+      | v == 0 = n+      | otherwise = go (n + 1) (v `shiftR` 1)
+ src/JsonLogic/Core/Utf16.hs view
@@ -0,0 +1,107 @@+-- |+-- Module      : JsonLogic.Core.Utf16+--+-- JavaScript strings are sequences of UTF-16 code units, not of Unicode+-- code points. The reference implementation's @length@, indexing, @substr@+-- and @<@ all work on code units, so a character outside the Basic+-- Multilingual Plane (an emoji, say) has length 2, and sorts /below/+-- U+E000 to U+FFFF because its first code unit is a surrogate in+-- U+D800 to U+DBFF.+--+-- This module is the one place those semantics are defined, over+-- 'Text'. A 'Text' cannot hold a lone surrogate, so a slice that cuts+-- through a surrogate pair yields U+FFFD for the severed half. That is the+-- single accepted deviation from the reference here, and it is exactly what+-- @String.prototype.toWellFormed()@ would make of the reference's answer.+module JsonLogic.Core.Utf16+  ( utf16Length+  , utf16Slice+  , utf16CharAt+  , compareUtf16+  , utf16Units+  , fromUtf16Units+  ) where++import Data.Bits (shiftL, shiftR, (.&.), (.|.))+import Data.Char (chr, ord)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Word (Word16)++-- | The number of UTF-16 code units: JavaScript's @s.length@.+utf16Length :: Text -> Int+utf16Length = Text.foldl' (\n c -> if ord c > 0xFFFF then n + 2 else n + 1) 0++-- | The UTF-16 code units of a text.+utf16Units :: Text -> [Word16]+utf16Units = concatMap charUnits . Text.unpack++charUnits :: Char -> [Word16]+charUnits c+  | n <= 0xFFFF = [fromIntegral n]+  | otherwise =+      let m = n - 0x10000+       in [ fromIntegral (0xD800 .|. (m `shiftR` 10))+          , fromIntegral (0xDC00 .|. (m .&. 0x3FF))+          ]+  where+    n = ord c++-- | Rebuild a text from UTF-16 code units. A surrogate with no partner+-- becomes U+FFFD.+fromUtf16Units :: [Word16] -> Text+fromUtf16Units = Text.pack . go+  where+    go [] = []+    go (hi : lo : rest)+      | isHigh hi && isLow lo = pair hi lo : go rest+    go (u : rest)+      | isHigh u || isLow u = '\xFFFD' : go rest+      | otherwise = chr (fromIntegral u) : go rest+    isHigh u = u >= 0xD800 && u <= 0xDBFF+    isLow u = u >= 0xDC00 && u <= 0xDFFF+    pair hi lo =+      chr+        ( 0x10000+            + ((fromIntegral hi - 0xD800) `shiftL` 10)+            + (fromIntegral lo - 0xDC00)+        )++-- | @slice from count@: the @count@ code units starting at code unit+-- @from@, both already clamped into range by the caller. Cutting through a+-- surrogate pair gives U+FFFD for the severed half.+utf16Slice :: Int -> Int -> Text -> Text+utf16Slice from count text+  | count <= 0 = Text.empty+  | Text.all isBmp text = Text.take count (Text.drop from text)+  | otherwise = fromUtf16Units (take count (drop from (utf16Units text)))++-- | The code unit at an index, as a one-unit string, or 'Nothing' past the+-- end. A lone surrogate half is returned as U+FFFD.+utf16CharAt :: Int -> Text -> Maybe Text+utf16CharAt i text+  | i < 0 = Nothing+  | Text.all isBmp text =+      let picked = Text.take 1 (Text.drop i text)+       in if Text.null picked then Nothing else Just picked+  | otherwise = case drop i (utf16Units text) of+      [] -> Nothing+      unit : _ -> Just (fromUtf16Units [unit])++isBmp :: Char -> Bool+isBmp c = ord c <= 0xFFFF++-- | JavaScript's string ordering: lexicographic by UTF-16 code unit.+--+-- It differs from ordering by code point only when an astral character+-- (first unit U+D800 to U+DBFF) meets a BMP character in U+E000 to U+FFFF:+-- by code unit the astral one is smaller.+compareUtf16 :: Text -> Text -> Ordering+compareUtf16 a b = go (Text.unpack a) (Text.unpack b)+  where+    go [] [] = EQ+    go [] _ = LT+    go _ [] = GT+    go (x : xs) (y : ys)+      | x == y = go xs ys+      | otherwise = compare (charUnits x) (charUnits y)
+ src/JsonLogic/Core/Value.hs view
@@ -0,0 +1,264 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : JsonLogic.Core.Value+--+-- The values rules are evaluated over, and the JavaScript semantics the+-- reference implementation gives them.+--+-- JsonLogic is defined by its JavaScript reference implementation, so its+-- behaviour on mixed types (@"1" == 1@, @[] == 0@, @"10" < "9"@) is+-- JavaScript's, not something derived from first principles. This module is+-- deliberately plain: one explicit clause per case of the JavaScript+-- algorithms, no abstraction layered over the table.+--+-- 'Val' is JSON plus what JSON cannot say but a JavaScript evaluation can+-- produce: @NaN@, the infinities, negative zero and @undefined@. They exist+-- only during evaluation; 'toValue' writes them as @JSON.stringify@ does.+--+-- Recorded deviations (docs/COMPATIBILITY.md): arrays and objects are never+-- equal to one another under @==@ / @===@ (the reference compares them by+-- identity, which JSON values do not have). Strings are measured and ordered+-- by UTF-16 code unit, as in JavaScript (see "JsonLogic.Core.Utf16").+module JsonLogic.Core.Value+  ( Val (..)+  , fromValue+  , toValue+  , truthy+  , strictEquals+  , looseEquals+  , lessThan+  , lessOrEqual+  , toNumber+  , parseFloat+  , toJsString+  , toJoinText+  , nestsDeeperThan+  , jsonWeightUpTo+  , valWeightUpTo+  ) where++import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Vector (Vector)+import qualified Data.Vector as Vector++import JsonLogic.Core.Number+  ( doubleToScientific+  , numberToString+  , parseFloatString+  , scientificToDouble+  , stringToNumber+  )+import JsonLogic.Core.Utf16 (compareUtf16)++-- | A value during evaluation.+data Val+  = VNull+  | -- | JavaScript's @undefined@, which an empty @and@ or @or@ yields. It+    -- is not 'VNull': it converts to @NaN@ (not 0) and to the string+    -- @"undefined"@ (not @"null"@), and @undefined === null@ is false.+    -- Written back as JSON @null@, and left out of objects.+    VUndefined+  | VBool !Bool+  | -- | Any JavaScript number, including @NaN@, the infinities and @-0@.+    VNum {-# UNPACK #-} !Double+  | VStr !Text+  | VArr !(Vector Val)+  | VObj !(KeyMap.KeyMap Val)+  deriving (Eq, Show)++-- ---------------------------------------------------------------------+-- Conversion to and from JSON+-- ---------------------------------------------------------------------++-- | Read JSON into the evaluation domain. Numbers become doubles, as in+-- the reference. The elements of arrays and objects are converted only when+-- they are used.+fromValue :: Aeson.Value -> Val+fromValue Aeson.Null = VNull+fromValue (Aeson.Bool flag) = VBool flag+fromValue (Aeson.Number n) = VNum (scientificToDouble n)+fromValue (Aeson.String s) = VStr s+fromValue (Aeson.Array items) = VArr (Vector.map fromValue items)+fromValue (Aeson.Object fields) = VObj (KeyMap.map fromValue fields)++-- | Write a result back as JSON, as @JSON.stringify@ would: @NaN@, the+-- infinities and @undefined@ become @null@, negative zero becomes @0@, and an+-- object property whose value is @undefined@ is left out.+toValue :: Val -> Aeson.Value+toValue VNull = Aeson.Null+toValue VUndefined = Aeson.Null+toValue (VBool flag) = Aeson.Bool flag+toValue (VNum d)+  | isNaN d || isInfinite d = Aeson.Null+  | otherwise = Aeson.Number (doubleToScientific d)+toValue (VStr s) = Aeson.String s+toValue (VArr items) = Aeson.Array (Vector.map toValue items)+toValue (VObj fields) = Aeson.Object (KeyMap.map toValue (KeyMap.filter (not . isUndefined) fields))+  where+    isUndefined VUndefined = True+    isUndefined _ = False++-- | Does a JSON value nest more than @n@ levels? An array or object that is+-- not empty is one level; a scalar is none. Stops as soon as it knows, so+-- the cost is bounded by @n@ along any path.+nestsDeeperThan :: Int -> Aeson.Value -> Bool+nestsDeeperThan n value = case value of+  Aeson.Array items -> not (null items) && (n <= 0 || any (nestsDeeperThan (n - 1)) items)+  Aeson.Object fields -> not (KeyMap.null fields) && (n <= 0 || any (nestsDeeperThan (n - 1)) (KeyMap.elems fields))+  _ -> False++-- | How many nodes a JSON value has, counted only up to @cap@: the work to+-- take it in, without walking an enormous value to the end.+jsonWeightUpTo :: Int -> Aeson.Value -> Int+jsonWeightUpTo cap value = cap - remaining cap value+  where+    remaining budget v+      | budget <= 0 = 0+      | otherwise = case v of+          Aeson.Array items -> children (budget - 1) (Vector.toList items)+          Aeson.Object fields -> children (budget - 1) (KeyMap.elems fields)+          _ -> budget - 1+    children budget [] = budget+    children budget (x : xs)+      | budget <= 0 = 0+      | otherwise = children (remaining budget x) xs++-- | How many array elements, at any depth, a value holds, counted only up+-- to @cap@. This is the work converting an array to a string or number+-- takes; every other value is free.+valWeightUpTo :: Int -> Val -> Int+valWeightUpTo cap value = cap - remaining cap value+  where+    remaining budget v = case v of+      VArr items -> children budget (Vector.toList items)+      _ -> budget+    children budget [] = budget+    children budget (x : xs)+      | budget <= 0 = 0+      | otherwise = children (remaining (budget - 1) x) xs++-- ---------------------------------------------------------------------+-- Truthiness+-- ---------------------------------------------------------------------++-- | JsonLogic's truthiness: JavaScript's, except that an empty array is+-- falsy (<https://jsonlogic.com/truthy.html>).+truthy :: Val -> Bool+truthy VNull = False+truthy VUndefined = False+truthy (VBool flag) = flag+truthy (VNum d) = not (d == 0 || isNaN d)+truthy (VStr s) = not (Text.null s)+truthy (VArr items) = not (Vector.null items)+truthy (VObj _) = True++-- ---------------------------------------------------------------------+-- Conversion to string and number+-- ---------------------------------------------------------------------++-- | JavaScript's @String(value)@.+toJsString :: Val -> Text+toJsString VNull = "null"+toJsString VUndefined = "undefined"+toJsString (VBool True) = "true"+toJsString (VBool False) = "false"+toJsString (VNum d) = numberToString d+toJsString (VStr s) = s+toJsString (VArr items) = Text.intercalate "," (map toJoinText (Vector.toList items))+toJsString (VObj _) = "[object Object]"++-- | How @Array.prototype.join@ renders an element: like 'toJsString',+-- except that @null@ and @undefined@ become the empty string.+toJoinText :: Val -> Text+toJoinText VNull = ""+toJoinText VUndefined = ""+toJoinText value = toJsString value++-- | JavaScript's @Number(value)@ (the abstract @ToNumber@).+toNumber :: Val -> Double+toNumber VNull = 0+toNumber VUndefined = 0 / 0+toNumber (VBool flag) = boolToNumber flag+toNumber (VNum d) = d+toNumber (VStr s) = stringToNumber s+toNumber value@(VArr _) = stringToNumber (toJsString value)+toNumber (VObj _) = 0 / 0++boolToNumber :: Bool -> Double+boolToNumber flag = if flag then 1 else 0++-- | JavaScript's @parseFloat(String(value))@, which is how @+@ and @*@ read+-- their operands: the longest decimal prefix, ignoring leading whitespace;+-- @NaN@ if there is none. A number is itself, except that negative zero+-- becomes zero (@String(-0)@ is @"0"@).+parseFloat :: Val -> Double+parseFloat (VNum d)+  | d == 0 = 0+  | otherwise = d+parseFloat value = parseFloatString (toJsString value)++-- ---------------------------------------------------------------------+-- Equality+-- ---------------------------------------------------------------------++-- | JavaScript's @===@. Arrays and objects are never equal (see the+-- module header).+strictEquals :: Val -> Val -> Bool+strictEquals VNull VNull = True+strictEquals VUndefined VUndefined = True+strictEquals (VBool a) (VBool b) = a == b+strictEquals (VNum a) (VNum b) = a == b+strictEquals (VStr a) (VStr b) = a == b+strictEquals _ _ = False++-- | JavaScript's @==@ (the abstract equality comparison).+looseEquals :: Val -> Val -> Bool+looseEquals VUndefined VUndefined = True+looseEquals VUndefined VNull = True+looseEquals VNull VUndefined = True+looseEquals VUndefined _ = False+looseEquals _ VUndefined = False+looseEquals VNull VNull = True+looseEquals VNull _ = False+looseEquals _ VNull = False+looseEquals (VBool a) y = looseEquals (VNum (boolToNumber a)) y+looseEquals x (VBool b) = looseEquals x (VNum (boolToNumber b))+looseEquals (VNum a) (VNum b) = a == b+looseEquals (VStr a) (VStr b) = a == b+looseEquals (VNum a) (VStr b) = a == stringToNumber b+looseEquals (VStr a) (VNum b) = stringToNumber a == b+looseEquals (VArr _) (VArr _) = False+looseEquals (VArr _) (VObj _) = False+looseEquals (VObj _) (VArr _) = False+looseEquals (VObj _) (VObj _) = False+looseEquals x y@(VArr _) = looseEquals x (VStr (toJsString y))+looseEquals x y@(VObj _) = looseEquals x (VStr (toJsString y))+looseEquals x@(VArr _) y = looseEquals (VStr (toJsString x)) y+looseEquals x@(VObj _) y = looseEquals (VStr (toJsString x)) y++-- ---------------------------------------------------------------------+-- Ordering+-- ---------------------------------------------------------------------++-- | Arrays and objects compare as the strings they convert to.+toPrimitive :: Val -> Val+toPrimitive value@(VArr _) = VStr (toJsString value)+toPrimitive value@(VObj _) = VStr (toJsString value)+toPrimitive value = value++-- | JavaScript's @<@: two strings compare by UTF-16 code unit, anything+-- else numerically (so any @NaN@ makes it false).+lessThan :: Val -> Val -> Bool+lessThan x y = case (toPrimitive x, toPrimitive y) of+  (VStr a, VStr b) -> compareUtf16 a b == LT+  (a, b) -> toNumber a < toNumber b++-- | JavaScript's @<=@.+lessOrEqual :: Val -> Val -> Bool+lessOrEqual x y = case (toPrimitive x, toPrimitive y) of+  (VStr a, VStr b) -> compareUtf16 a b /= GT+  (a, b) -> toNumber a <= toNumber b
+ test/AnalysisSpec.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE OverloadedStrings #-}++-- | 'dataUse': examples, and the property that makes it worth having: when+-- it says a rule reads only some paths, changing anything else in the data+-- changes nothing about the answer.+module AnalysisSpec (spec) where++import Data.Aeson (Value (..))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Text (Text)+import qualified Data.Text as Text+import Test.Hspec+import Test.QuickCheck++import Gen (genData, genRule)+import JsonLogic+import Support (json)++uses :: String -> Either Error DataUse+uses rule = dataUse <$> compile defaultConfig{arityMode = Lenient} noOperators (json rule)++reads' :: [Text] -> Either Error DataUse+reads' paths = Right (DataUse paths False)++-- | The top-level keys the generated data uses, and one it never does.+topKeys :: [Text]+topKeys = ["a", "b", "l", "unused"]++firstSegment :: Text -> Text+firstSegment = Text.takeWhile (/= '.')++spec :: Spec+spec = describe "dataUse" $ do+  describe "examples" $ do+    it "lists literal var paths, with defaults that are rules too" $+      uses "{\"+\":[{\"var\":\"a.b\"},{\"var\":[\"c\",{\"var\":\"d\"}]}]}" `shouldBe` reads' ["a.b", "c", "d"]+    it "does not count what an iteration body reads, which is the element" $+      uses "{\"map\":[{\"var\":\"xs\"},{\"+\":[{\"var\":\"\"},{\"var\":\"y\"}]}]}" `shouldBe` reads' ["xs"]+    it "counts reduce's initial value, which is evaluated against the data" $+      uses "{\"reduce\":[{\"var\":\"xs\"},{\"var\":\"current\"},{\"var\":\"start\"}]}" `shouldBe` reads' ["start", "xs"]+    it "lists the keys of missing and missing_some" $ do+      uses "{\"missing\":[\"a\",\"b\"]}" `shouldBe` reads' ["a", "b"]+      uses "{\"missing\":[[\"a\",\"b\"],\"ignored\"]}" `shouldBe` reads' ["a", "b"]+      uses "{\"missing_some\":[1,[\"a\",\"b\"]]}" `shouldBe` reads' ["a", "b"]+    it "reads a numeric path as the string var would use" $+      uses "{\"var\":1}" `shouldBe` reads' ["1"]+    it "gives up, explicitly, on what cannot be known" $+      mapM_+        (\rule -> fmap usesAllData (uses rule) `shouldBe` Right True)+        [ "{\"var\":\"\"}"+        , "{\"var\":[]}"+        , "{\"var\":{\"cat\":[\"a\",\"b\"]}}"+        , "{\"missing\":{\"var\":\"keys\"}}"+        , "{\"missing_some\":[1,{\"var\":\"keys\"}]}"+        , "{\"custom\":[1]}"+        ]+    it "reads nothing from a rule with no lookups" $+      uses "{\"if\":[true,1,2]}" `shouldBe` reads' []++  it "a rule's answer does not change when data it does not read changes" $+    checkCoverage $+      forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat -> forAll (genData 2) $ \replacement ->+        forAll (elements [defaultConfig, defaultConfig{arityMode = Lenient}, defaultConfig{maxSteps = 40}]) $ \config ->+          case (compile config noOperators rule, dat) of+            (Right compiled, Object fields)+              | DataUse paths False <- dataUse compiled ->+                  let unread = [k | k <- topKeys, k `notElem` map firstSegment paths]+                      changed k = Object (KeyMap.insert (Key.fromText k) replacement fields)+                      removed k = Object (KeyMap.delete (Key.fromText k) fields)+                   in cover 5 (any (`elem` ["a", "b", "l"]) unread) "a key the data has goes unread" $+                        conjoin+                          [ counterexample (show (k, paths)) (run compiled other === run compiled dat)+                          | k <- unread+                          , other <- [changed k, removed k]+                          ]+            _ -> cover 5 False "a key the data has goes unread" (property True)
+ test/CompatSpec.hs view
@@ -0,0 +1,208 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Compatibility with the reference where the audit found disagreements:+-- arity modes, UTF-16 strings, signed zero, whitespace, the custom+-- operator registry, and the other behaviours the outlier fixture also+-- covers, each stated here as an example a reader can follow.+module CompatSpec (spec) where++import Data.Aeson (Value (..), object, toJSON, (.=))+import qualified Data.Aeson.Key as Key+import qualified Data.Text as Text+import Test.Hspec+import Test.QuickCheck++import Gen (genText)+import JsonLogic+import JsonLogic.Core.Location (root)+import JsonLogic.Core.Utf16+import Support (Problem (..), at, json, problem)++-- | A registry that the test expects to be accepted.+registry :: [(OperatorName, CustomOperator)] -> Operators+registry = either (error . show) id . operatorsFromList++strict, lenient :: String -> String -> Either Problem Value+strict rule dat = problem (apply (json rule) (json dat))+lenient rule dat = problem (applyWithConfig defaultConfig{arityMode = Lenient} noOperators (json rule) (json dat))++-- | Is a rule's numeric result negative zero? Observed through 1 / x.+yieldsNegativeZero :: String -> Bool+yieldsNegativeZero rule = strict ("{\"<\":[{\"/\":[1," ++ rule ++ "]},0]}") "null" == Right (Bool True)++spec :: Spec+spec = do+  describe "arity modes" $ do+    it "strict rejects a wrong argument count precisely" $+      strict "{\"==\":[1]}" "null"+        `shouldBe` Left (ParseProblem (ArityMismatch (OperatorName "==") (Exactly 2) 1))+    it "lenient treats a missing argument as undefined" $ do+      lenient "{\"==\":[null]}" "null" `shouldBe` Right (Bool True)+      lenient "{\"==\":[1]}" "null" `shouldBe` Right (Bool False)+    it "lenient ignores a surplus argument" $+      lenient "{\"+\":[1]}" "null" `shouldBe` Right (Number 1)+    it "lenient still evaluates a surplus argument of an ordinary operator, so it can fail" $+      lenient "{\"==\":[1,1,{\"nope\":[]}]}" "null"+        `shouldBe` Left (EvalProblem (UnknownOperator (OperatorName "nope")))+    it "lenient never looks at a surplus argument of map, as in the reference" $+      lenient "{\"map\":[[1],{\"var\":\"\"},{\"nope\":[]}]}" "null" `shouldBe` Right (toJSON [1 :: Int])+    it "lenient reduce without an initial value starts from null" $+      lenient "{\"reduce\":[[1,2],{\"cat\":[{\"var\":\"accumulator\"},{\"var\":\"current\"}]}]}" "null"+        `shouldBe` Right (String "12")+    it "strict rejects * with no operands before evaluating anything" $+      strict "{\"*\":[]}" "null" `shouldBe` Left (ParseProblem (ArityMismatch (OperatorName "*") (AtLeast 1) 0))+    it "lenient throws on * with no operands only when it is evaluated, as the reference does" $ do+      lenient "{\"*\":[]}" "null" `shouldBe` Left (EvalProblem (InvalidOperand (OperatorName "*")))+      lenient "{\"if\":[true,1,{\"*\":[]}]}" "null" `shouldBe` Right (Number 1)+      lenient "{\"or\":[1,{\"*\":[]}]}" "null" `shouldBe` Right (Number 1)+      lenient "{\"and\":[0,{\"*\":[]}]}" "null" `shouldBe` Right (Number 0)+      lenient "{\"map\":[[],{\"*\":[]}]}" "null" `shouldBe` Right (toJSON ([] :: [Int]))+      lenient "{\"and\":[1,{\"*\":[]}]}" "null" `shouldBe` Left (EvalProblem (InvalidOperand (OperatorName "*")))+    it "locates the empty * that was reached" $+      applyWithConfig defaultConfig{arityMode = Lenient} noOperators (json "{\"and\":[1,{\"*\":[]}]}") Null+        `shouldBe` Left (EvalFailure (at [Left "and", Right 1]) (InvalidOperand (OperatorName "*")))+    it "strict success implies the same lenient answer" $+      forAll (genRuleForArity 4) $ \rule -> case apply rule Null of+        Right v -> applyWithConfig defaultConfig{arityMode = Lenient} noOperators rule Null === Right v+        Left _ -> property True++  describe "UTF-16 strings" $ do+    it "an emoji has length 2" $+      strict "{\"var\":\"s.length\"}" "{\"s\":\"\\ud83d\\ude00\"}" `shouldBe` Right (Number 2)+    it "a BMP character has length 1" $+      strict "{\"var\":\"s.length\"}" "{\"s\":\"\\u00e9\"}" `shouldBe` Right (Number 1)+    it "indexing inside a pair gives U+FFFD, the one accepted deviation" $+      strict "{\"var\":\"s.0\"}" "{\"s\":\"\\ud83d\\ude00\"}" `shouldBe` Right (String "\xFFFD")+    it "substr on pair boundaries returns the whole character" $+      strict "{\"substr\":[\"a\\ud83d\\ude00b\",1,2]}" "null" `shouldBe` Right (String "\x1F600")+    it "substr through a pair gives U+FFFD for the severed half" $+      strict "{\"substr\":[\"\\ud83d\\ude00\",0,1]}" "null" `shouldBe` Right (String "\xFFFD")+    it "orders an astral character below U+E000, as JavaScript does" $ do+      strict "{\"<\":[\"\\ud83d\\ude00\",\"\\ue000\"]}" "null" `shouldBe` Right (Bool True)+      strict "{\"<\":[\"\\ue000\",\"\\ud83d\\ude00\"]}" "null" `shouldBe` Right (Bool False)+    it "orders astral characters by their code points among themselves" $+      strict "{\"<\":[\"\\ud83d\\ude00\",\"\\ud83d\\ude01\"]}" "null" `shouldBe` Right (Bool True)+    it "utf16Length equals the length of the UTF-16 encoding" $+      forAll genText $+        \t -> utf16Length t === length (utf16Units t)+    it "units round-trip" $+      forAll genText $+        \t -> fromUtf16Units (utf16Units t) === t+    it "compareUtf16 is code unit order" $+      forAll genText $+        \a -> forAll genText $ \b -> compareUtf16 a b === compare (utf16Units a) (utf16Units b)+    it "slices on character boundaries put the text back together" $+      forAll genText $ \t -> forAll (choose (0, Text.length t)) $ \k ->+        let n = utf16Length t+            cut = utf16Length (Text.take k t)+         in utf16Slice 0 cut t <> utf16Slice cut (n - cut) t === t++  describe "signed zero" $ do+    it "Math.max puts +0 above -0 in either order" $ do+      yieldsNegativeZero "{\"max\":[0,{\"*\":[-1,0]}]}" `shouldBe` False+      yieldsNegativeZero "{\"max\":[{\"*\":[-1,0]},0]}" `shouldBe` False+    it "Math.min puts -0 below +0 in either order" $ do+      yieldsNegativeZero "{\"min\":[0,{\"*\":[-1,0]}]}" `shouldBe` True+      yieldsNegativeZero "{\"min\":[{\"*\":[-1,0]},0]}" `shouldBe` True+    it "-0 stays -0 through max and min of itself" $ do+      yieldsNegativeZero "{\"max\":[{\"*\":[-1,0]},{\"*\":[-1,0]}]}" `shouldBe` True+      yieldsNegativeZero "{\"min\":[{\"*\":[-1,0]}]}" `shouldBe` True+    it "a remainder of zero takes the sign of the dividend" $ do+      yieldsNegativeZero "{\"%\":[-4,2]}" `shouldBe` True+      yieldsNegativeZero "{\"%\":[4,-2]}" `shouldBe` False+      yieldsNegativeZero "{\"%\":[-1,1]}" `shouldBe` True+      yieldsNegativeZero "{\"%\":[{\"*\":[-1,0]},5]}" `shouldBe` True+    it "the observation 0 > 1 / max(0, -0) is false, as in JavaScript" $+      strict "{\">\":[0,{\"/\":[1,{\"max\":[0,{\"*\":[-1,0]}]}]}]}" "null" `shouldBe` Right (Bool False)++  describe "string to number whitespace" $ do+    let plus c = strict ("{\"+\":[\"" ++ c ++ "12\"]}") "null"+        times c = strict ("{\"*\":[\"" ++ c ++ "12\",1]}") "null"+    it "trims every ECMAScript WhiteSpace and LineTerminator" $+      mapM_+        (\c -> (plus c, times c) `shouldBe` (Right (Number 12), Right (Number 12)))+        ["\\ufeff", "\\u2028", "\\u2029", "\\u00a0", "\\u1680", "\\u2000", "\\u200a", "\\u202f", "\\u205f", "\\u3000", "\\t", "\\n", "\\u000b", "\\u000c", "\\r", " "]+    it "does not trim near misses" $+      mapM_+        (\c -> (plus c, times c) `shouldBe` (Right Null, Right Null))+        ["\\u0085", "\\u180e", "\\u200b", "\\u001c", "\\u0000", "\\u2060"]++  describe "numbers" $ do+    it "rounds halfway decimal ties to the even digit, as ECMAScript prints" $+      strict "{\"/\":[9007199254740993,10]}" "null" `shouldBe` Right (json "900719925474099.2")+    it "converts a million-digit string without hanging" $ do+      let digits = replicate 1000000 '7'+      applyWithConfig defaultConfig noOperators (json ("{\"+\":[\"" ++ digits ++ "\"]}")) Null+        `shouldBe` Right Null+    it "keeps 768 digits significant and a sticky tail" $ do+      let half n = "0." ++ replicate n '0' ++ "1"+      strict ("{\"+\":[\"" ++ half 400 ++ "\"]}") "null" `shouldBe` Right (Number 0)+    it "reads a hex literal of any length without hanging" $+      strict ("{\"==\":[\"0x" ++ replicate 100000 'f' ++ "\",{\"/\":[1,0]}]}") "null" `shouldBe` Right (Bool True)++  describe "missing and missing_some" $ do+    it "reads an array key as a variable's path and default" $ do+      strict "{\"missing\":[\"q\",[\"a\",\"x\"]]}" "{}" `shouldBe` Right (json "[\"q\"]")+      strict "{\"missing\":[\"q\",[\"a\",null]]}" "{}" `shouldBe` Right (json "[\"q\",[\"a\",null]]")+    it "counts string options by UTF-16 length, as the reference does" $+      strict "{\"missing_some\":[2,\"ab\"]}" "{}" `shouldBe` Right (json "[\"ab\"]")+    it "throws where the reference throws on a null option list" $+      strict "{\"missing_some\":[1,null]}" "{}"+        `shouldBe` Left (EvalProblem (InvalidOperand (OperatorName "missing_some")))+    it "returns the missing keys when the options have no length" $+      strict "{\"missing_some\":[0,0]}" "{}" `shouldBe` Right (json "[0]")++  describe "inherited property names (DEV-INHERITED)" $ do+    it "sees only own properties, where the reference walks the prototype chain" $+      mapM_+        (\name -> strict ("{\"var\":\"" ++ name ++ "\"}") "{}" `shouldBe` Right Null)+        ["constructor", "__proto__", "toString", "hasOwnProperty", "valueOf"]+    it "still finds an own property of that name" $+      strict "{\"var\":\"constructor\"}" "{\"constructor\":7}" `shouldBe` Right (Number 7)++  describe "undefined" $ do+    it "an object property that is undefined is left out, as JSON.stringify does" $+      strict "{\"reduce\":[[0],{\"var\":\"\"},{\"and\":[]}]}" "null" `shouldBe` Right (json "{\"current\":0}")++  describe "custom operators" $ do+    let double :: CustomOperator+        double _ args = case args of+          [Number n] -> Right (Number (n * 2))+          _ -> Left "expected one number"+    it "are refused when named like a builtin, instead of being ignored" $+      case operatorsFromList [(OperatorName "var", double)] of+        Left e -> e `shouldBe` ReservedOperatorName (OperatorName "var")+        Right _ -> expectationFailure "a builtin name was accepted"+    it "are refused when a name is given twice" $+      case operatorsFromList [(OperatorName "double", double), (OperatorName "double", double)] of+        Left e -> e `shouldBe` DuplicateOperatorName (OperatorName "double")+        Right _ -> expectationFailure "a duplicate name was accepted"+    it "compose left-biased" $ do+      let a = registry [(OperatorName "f", \_ _ -> Right (toJSON (1 :: Int)))]+          b = registry [(OperatorName "f", \_ _ -> Right (toJSON (2 :: Int))), (OperatorName "g", \_ _ -> Right (toJSON (3 :: Int)))]+          both = a <> b+      applyWith both (object ["f" .= ([] :: [Int])]) Null `shouldBe` Right (Number 1)+      applyWith both (object ["g" .= ([] :: [Int])]) Null `shouldBe` Right (Number 3)+    it "a custom failure inside map stops the evaluation" $ do+      let ops = registry [(OperatorName "double", double)]+      applyWith ops (json "{\"map\":[[1,\"x\"],{\"double\":[{\"var\":\"\"}]}]}") Null+        `shouldBe` Left (EvalFailure (at [Left "map", Right 1]) (CustomOperatorFailed (OperatorName "double") "expected one number"))+    it "combining with the empty registry changes nothing" $ do+      let ops = registry [(OperatorName "double", double)]+      applyWith (ops <> noOperators) (json "{\"double\":[2]}") Null `shouldBe` Right (Number 4)++  describe "renderError" $ do+    it "describes each constructor, after the location" $ do+      renderError (ParseFailure root (DepthExceeded 64)) `shouldBe` "at the root of the rule: the rule nests deeper than the limit of 64"+      renderError (EvalFailure (at [Left "if", Right 2]) (UnknownOperator (OperatorName "x")))+        `shouldBe` "at /if/2: unknown operator \"x\""+      renderError (EvalFailure root (LimitExceeded (StepLimit 9))) `shouldBe` "at the root of the rule: evaluation exceeded the limit of 9 steps"+      renderError (ParseFailure (at [Left "and", Right 0]) (ArityMismatch (OperatorName "==") (Exactly 2) 1))+        `shouldBe` "at /and/0: operator \"==\" takes exactly 2 arguments, but was given 1"++genRuleForArity :: Int -> Gen Value+genRuleForArity _ = do+  name <- elements ["+", "==", "<", "-", "cat", "max", "!", "if", "and", "merge"]+  k <- choose (0, 3)+  args <- vectorOf k (oneof [toJSON <$> (choose (-3, 3) :: Gen Int), pure Null, String <$> genText])+  pure (object [Key.fromText name .= args])
+ test/EvalSpec.hs view
@@ -0,0 +1,254 @@+{-# LANGUAGE OverloadedStrings #-}++module EvalSpec (spec) where++import Data.Aeson (Value (..), decode, object, toJSON, (.=))+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Fix (Fix (..))+import qualified Data.Text as Text+import Test.Hspec+import Test.QuickCheck (Gen, choose, forAll, listOf, oneof)++import JsonLogic hiding (Rule)+import JsonLogic.Core.Eval (evaluate)+import JsonLogic.Core.Location (root)+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))+import Support (Problem (..), at, problem)++-- | Test cases are written with single quotes so the JSON stays readable.+q :: LBS.ByteString -> LBS.ByteString+q = LBS.map (\byte -> if byte == 39 then 34 else byte)++-- | @evaluates rule input expected@+evaluates :: LBS.ByteString -> LBS.ByteString -> LBS.ByteString -> Expectation+evaluates rule input expected =+  case (decode (q rule), decode (q input), decode (q expected)) of+    (Just r, Just i, Just e) -> apply r i `shouldBe` Right e+    _ -> expectationFailure "test case contains invalid JSON"++-- | (rule, data, expected result). Every one of these is behaviour of the+-- reference implementation, including its quirks.+cases :: [(LBS.ByteString, LBS.ByteString, LBS.ByteString)]+cases =+  [ -- equality and logic+    ("{'==':[1,1]}", "{}", "true")+  , ("{'==':[1,'1']}", "{}", "true")+  , ("{'===':[1,'1']}", "{}", "false")+  , ("{'!=':[1,2]}", "{}", "true")+  , ("{'!==':[1,'1']}", "{}", "true")+  , ("{'!':[true]}", "{}", "false")+  , ("{'!!':[[]]}", "{}", "false")+  , ("{'!!':['0']}", "{}", "true")+  , ("{'and':[true,'a',3]}", "{}", "3")+  , ("{'and':[true,'',3]}", "{}", "''")+  , ("{'or':[false,'',0]}", "{}", "0")+  , ("{'or':[false,'a']}", "{}", "'a'")+  , ("{'and':[]}", "{}", "null")+  , -- if+    ("{'if':[true,'yes','no']}", "{}", "'yes'")+  , ("{'if':[false,'a',true,'b','c']}", "{}", "'b'")+  , ("{'if':[false,'a','c']}", "{}", "'c'")+  , ("{'if':[]}", "{}", "null")+  , -- variables+    ("{'var':'a'}", "{'a':1}", "1")+  , ("{'var':['z',26]}", "{'a':1}", "26")+  , ("{'var':'a.b'}", "{'a':{'b':2}}", "2")+  , ("{'var':1}", "['apple','banana']", "'banana'")+  , ("{'var':''}", "{'a':1}", "{'a':1}")+  , ("{'var':'a.length'}", "{'a':[1,2,3]}", "3")+  , ("{'var':['a',5]}", "{'a':null}", "null")+  , ("{'missing':['a','b']}", "{'a':1}", "['b']")+  , ("{'missing_some':[1,['a','b','c']]}", "{'a':1}", "[]")+  , ("{'missing_some':[2,['a','b','c']]}", "{'a':1}", "['b','c']")+  , -- ordering+    ("{'<':[1,2]}", "{}", "true")+  , ("{'<':[1,2,3]}", "{}", "true")+  , ("{'<':[1,3,2]}", "{}", "false")+  , ("{'<=':[1,1]}", "{}", "true")+  , ("{'>':[2,1]}", "{}", "true")+  , ("{'>=':[1,1]}", "{}", "true")+  , ("{'<':['10','9']}", "{}", "true")+  , ("{'<':['2',10]}", "{}", "true")+  , -- arithmetic+    ("{'+':[1,2,3]}", "{}", "6")+  , ("{'+':['3.14']}", "{}", "3.14")+  , ("{'+':[]}", "{}", "0")+  , ("{'*':[2,3,4]}", "{}", "24")+  , ("{'*':['3']}", "{}", "'3'")+  , ("{'-':[5,2]}", "{}", "3")+  , ("{'-':[5]}", "{}", "-5")+  , ("{'/':[4,2]}", "{}", "2")+  , ("{'/':[1,0]}", "{}", "null")+  , ("{'%':[101,2]}", "{}", "1")+  , ("{'max':[1,3,2]}", "{}", "3")+  , ("{'min':[]}", "{}", "null")+  , ("{'max':[1,'a']}", "{}", "null")+  , -- collections+    ("{'map':[{'var':'nums'},{'*':[{'var':''},2]}]}", "{'nums':[1,2,3]}", "[2,4,6]")+  , ("{'filter':[[1,2,3,4],{'%':[{'var':''},2]}]}", "{}", "[1,3]")+  , ("{'reduce':[[1,2,3],{'+':[{'var':'current'},{'var':'accumulator'}]},0]}", "{}", "6")+  , ("{'all':[[1,2],{'>':[{'var':''},0]}]}", "{}", "true")+  , ("{'all':[[],true]}", "{}", "false")+  , ("{'none':[[1,2],{'<':[{'var':''},0]}]}", "{}", "true")+  , ("{'some':[[1,-2],{'<':[{'var':''},0]}]}", "{}", "true")+  , ("{'map':[5,{'var':''}]}", "{}", "[]")+  , ("{'merge':[[1,2],[3,[4]],5]}", "{}", "[1,2,3,[4],5]")+  , -- strings+    ("{'in':['Spring','Springfield']}", "{}", "true")+  , ("{'in':['a',['a','b']]}", "{}", "true")+  , ("{'in':['z',['a']]}", "{}", "false")+  , ("{'cat':['a',1,null,true]}", "{}", "'a1true'")+  , ("{'substr':['jsonlogic',4]}", "{}", "'logic'")+  , ("{'substr':['jsonlogic',-5]}", "{}", "'logic'")+  , ("{'substr':['jsonlogic',1,3]}", "{}", "'son'")+  , ("{'substr':['jsonlogic',4,-2]}", "{}", "'log'")+  , ("{'log':'hi'}", "{}", "'hi'")+  , -- undefined (what an empty and / or yields) is not null+    ("{'max':[{'and':[]}]}", "{}", "null")+  , ("{'+':[1,{'and':[]}]}", "{}", "null")+  , ("{'cat':['a',{'and':[]},'b']}", "{}", "'ab'")+  , ("{'==':[{'and':[]},null]}", "{}", "true")+  , ("{'===':[{'and':[]},null]}", "{}", "false")+  , ("{'==':[{'and':[]},0]}", "{}", "false")+  , ("{'==':[{'and':[]},false]}", "{}", "false")+  , ("{'in':[{'and':[]},'xundefinedx']}", "{}", "true")+  , ("{'substr':[{'and':[]},0,3]}", "{}", "'und'")+  , ("{'<':[{'and':[]},1]}", "{}", "false")+  , ("{'min':[{'>=':[true,{'and':[]}]}]}", "{}", "0")+  , ("{'or':[false,{'and':[]}]}", "{}", "null")+  , ("{'!':[{'and':[]}]}", "{}", "true")+  , ("{'-':[{'and':[]}]}", "{}", "null")+  , ("{'var':[{'and':[]}]}", "{'a':1}", "{'a':1}")+  , ("{'var':['a',{'and':[]}]}", "{}", "null")+  , ("{'map':[[{'and':[]},1],{'var':''}]}", "{}", "[null,1]")+  , ("{'map':[[{'and':[]},1],{'var':'x'}]}", "{}", "[null,null]")+  , ("{'merge':[[{'and':[]}]]}", "{}", "[null]")+  , ("{'missing':[{'and':[]}]}", "{'a':1}", "[]")+  , -- an optional operand that evaluates to undefined counts as absent+    ("{'-':[5,{'and':[]}]}", "{}", "-5")+  , ("{'-':['7',{'and':[]}]}", "{}", "-7")+  , ("{'-':[[1.5],{'and':[]}]}", "{}", "-1.5")+  , ("{'-':[[],{'and':[]}]}", "{}", "0")+  , ("{'<':[1,2,{'and':[]}]}", "{}", "true")+  , ("{'<':[2,1,{'and':[]}]}", "{}", "false")+  , ("{'<=':[1,1,{'and':[]}]}", "{}", "true")+  , ("{'substr':['jsonlogic',4,{'and':[]}]}", "{}", "'logic'")+  , ("{'substr':['jsonlogic',4,null]}", "{}", "''")+  , ("{'substr':['jsonlogic',{'and':[]},3]}", "{}", "'jso'")+  , -- numbers beyond 2^53 print and parse exactly as JavaScript does+    ("{'max':[{'cat':[-2.25,'3',1e21]}]}", "{}", "-2.2531e21")+  , ("{'cat':[2.2531e21]}", "{}", "'2.2531e+21'")+  , ("{'cat':[9007199254740993]}", "{}", "'9007199254740992'")+  , ("{'cat':[1e21]}", "{}", "'1e+21'")+  , ("{'+':['1e400']}", "{}", "null")+  , ("{'+':['1e-400']}", "{}", "0")+  , ("{'+':['5e-324']}", "{}", "5e-324")+  , ("{'+':['123456789012345678901234567890']}", "{}", "1.2345678901234568e29")+  , -- literals, arrays, laziness+    ("[{'var':'a'},2]", "{'a':1}", "[1,2]")+  , ("{'a':1,'b':2}", "{}", "{'a':1,'b':2}")+  , ("5", "{}", "5")+  , ("{'or':[true,{'nope':[]}]}", "{}", "true")+  , ("{'and':[false,{'nope':[]}]}", "{}", "false")+  , ("{'if':[true,'a',{'nope':[]}]}", "{}", "'a'")+  ]++label :: LBS.ByteString -> LBS.ByteString -> String+label rule input = Char8.unpack (q rule) ++ "  on  " ++ Char8.unpack (q input)++-- | A rule nested @n@ operator levels deep around a literal leaf.+nested :: Int -> Value+nested n = foldr (\_ inner -> object ["!" .= inner]) (Number 0) [1 .. n]++doubling :: Operators+doubling = either (error . show) id (operatorsFromList [(OperatorName "double", implementation)])+  where+    implementation _ [Number n] = Right (Number (n * 2))+    implementation _ _ = Left "expected one number"++withJson :: LBS.ByteString -> (Value -> Expectation) -> Expectation+withJson text check =+  maybe (expectationFailure "invalid JSON in test") check (decode (q text))++genJson :: Gen Value+genJson = oneof [scalar, toJSON <$> listOf scalar]+  where+    scalar =+      oneof+        [ pure Null+        , Bool <$> oneof [pure True, pure False]+        , toJSON <$> (choose (-1000, 1000) :: Gen Int)+        , String . Text.pack <$> listOf (choose ('a', 'z'))+        ]++-- | A node built by hand, at the root (the laws do not look at locations).+node :: RuleF Rule -> Rule+node = Fix . Node root++lit :: Value -> Rule+lit = node . Lit++result :: Rule -> Either EvalError Value+result rule = either (Left . snd) Right (evaluate rule Null)++spec :: Spec+spec = describe "evaluation" $ do+  describe "reference behaviour" $+    mapM_ (\(rule, input, expected) -> it (label rule input) (evaluates rule input expected)) cases++  describe "errors" $ do+    it "reports an unknown operator, where it is" $+      withJson "{'nope':[1]}" $ \rule ->+        apply rule Null `shouldBe` Left (EvalFailure root (UnknownOperator (OperatorName "nope")))+    it "evaluates arguments before the operator, so a bad one still fails" $+      withJson "{'+':[1,{'nope':[]}]}" $ \rule ->+        apply rule Null `shouldBe` Left (EvalFailure (at [Left "+", Right 1]) (UnknownOperator (OperatorName "nope")))+    it "locates a failure inside an iteration body" $+      withJson "{'map':[[1],{'nope':[]}]}" $ \rule ->+        apply rule Null `shouldBe` Left (EvalFailure (at [Left "map", Right 1]) (UnknownOperator (OperatorName "nope")))+    it "surfaces parse errors, with their location" $+      withJson "{'!':{'==':[1]}}" $ \rule ->+        apply rule Null+          `shouldBe` Left (ParseFailure (at [Left "!"]) (ArityMismatch (OperatorName "==") (Exactly 2) 1))+    it "rejects rules nested past the depth limit before evaluating" $+      problem (apply (nested 65) Null) `shouldBe` Left (ParseProblem (DepthExceeded 64))+    it "renders an error with its JSON Pointer" $ do+      renderError (EvalFailure (at [Left "if", Right 1]) (UnknownOperator (OperatorName "x")))+        `shouldBe` "at /if/1: unknown operator \"x\""+      renderError (EvalFailure root (UnknownOperator (OperatorName "x")))+        `shouldBe` "at the root of the rule: unknown operator \"x\""+    it "escapes ~ and / in a pointer, as RFC 6901 requires" $+      renderLocation (at [Left "a/b", Left "c~d", Right 3]) `shouldBe` "/a~1b/c~0d/3"++  describe "custom operators" $ do+    it "runs a supplied operator on evaluated arguments" $+      withJson "{'double':[{'var':'x'}]}" $ \rule ->+        withJson "{'x':21}" $ \input ->+          applyWith doubling rule input `shouldBe` Right (Number 42)+    it "reports the operator's own failure" $+      withJson "{'double':['a']}" $ \rule ->+        applyWith doubling rule Null+          `shouldBe` Left (EvalFailure root (CustomOperatorFailed (OperatorName "double") "expected one number"))++  describe "laws" $ do+    it "De Morgan: not (x and y) agrees with (not x) or (not y)" $+      forAll genJson $ \x -> forAll genJson $ \y ->+        result (node (Not (node (And [lit x, lit y]))))+          == result (node (Or [node (Not (lit x)), node (Not (lit y))]))+    it "!! is not-not" $+      forAll genJson $ \x ->+        result (node (DoubleNot (lit x))) == result (node (Not (node (Not (lit x)))))+    it "!! is idempotent" $+      forAll genJson $ \x ->+        result (node (DoubleNot (node (DoubleNot (lit x))))) == result (node (DoubleNot (lit x)))+    it "if with a true condition is its consequent" $+      forAll genJson $ \a -> forAll genJson $ \b ->+        result (node (If [lit (Bool True), lit a, lit b])) == result (lit a)+    it "if with a false condition is its alternative" $+      forAll genJson $ \a -> forAll genJson $ \b ->+        result (node (If [lit (Bool False), lit a, lit b])) == result (lit b)+    it "a one-operand and / or is that operand" $+      forAll genJson $ \x ->+        result (node (And [lit x])) == result (lit x)+          && result (node (Or [lit x])) == result (lit x)
+ test/Gen.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Generators shared by the property tests: bounded random rules and data+-- over every builtin operator, deliberately including the awkward values+-- (empty and non-ASCII strings, signed zero, huge and tiny numbers).+module Gen+  ( genData+  , genRule+  , genText+  , allOperators+  ) where++import Data.Aeson (Value (..), object, toJSON, (.=))+import qualified Data.Aeson.Key as Key+import Data.Text (Text)+import qualified Data.Text as Text+import Test.QuickCheck++import JsonLogic.Core.Operator (allBuiltins, builtinName)++-- | Every builtin operator's name, from the library's own table.+allOperators :: [Text]+allOperators = map builtinName allBuiltins++-- | Text drawn from ASCII, BMP characters above U+E000, and astral+-- characters, so UTF-16 effects show up.+genText :: Gen Text+genText = Text.pack <$> listOf (elements pool)+  where+    pool = "ab0 1.-e" ++ ['\xE000', '\xFFFF', '\xFFFD', '\x1F600', '\x10000', '\x10FFFF', '\x0301']++genScalar :: Gen Value+genScalar =+  oneof+    [ pure Null+    , Bool <$> arbitrary+    , toJSON <$> (choose (-5, 12) :: Gen Int)+    , toJSON <$> elements [0.5, -2.25, 1e21, 1e-7, 5e-324, 1.7976931348623157e308 :: Double]+    , String <$> genText+    , String <$> elements ["", "0", "1", "10", "9", "0x10", " 12 ", "Infinity", "NaN", "a.b"]+    ]++-- | Data: scalars, arrays and objects with the keys rules look for.+genData :: Int -> Gen Value+genData 0 = genScalar+genData n =+  frequency+    [ (3, genScalar)+    , (2, toJSON <$> listOf (genData (n - 1)))+    ,+      ( 2+      , do+          a <- genData (n - 1)+          b <- genData (n - 1)+          l <- listOf (genData (n - 1))+          pure (object ["a" .= a, "b" .= b, "l" .= l])+      )+    ]++-- | A rule of bounded size. Argument counts are arbitrary on purpose, so+-- both arity modes get exercised.+genRule :: Int -> Gen Value+genRule 0 =+  oneof+    [ genScalar+    , (\p -> object ["var" .= (p :: Text)]) <$> elements ["a", "b", "l", "l.0", "l.length", "", "a.b"]+    ]+genRule n = frequency [(2, genRule 0), (6, call)]+  where+    call = do+      name <- elements allOperators+      k <- choose (0, 3)+      args <- vectorOf k (genRule (n `div` 2))+      pure (object [Key.fromText name .= args])
+ test/LimitsSpec.hs view
@@ -0,0 +1,174 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The resource limits: boundary triples (limit - 1, limit, limit + 1)+-- for every limit, the amplification rules that would otherwise hang or+-- exhaust memory, and properties relating the limits to results.+module LimitsSpec (spec) where++import Control.Exception (evaluate)+import Data.Aeson (Value (..), encode, object, toJSON, (.=))+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Either (fromRight)+import qualified Data.Text+import System.Timeout (timeout)+import Test.Hspec+import Test.QuickCheck++import Gen (genData, genRule)+import JsonLogic+import Support (Problem (..), isRight', json, problem)++run' :: Config -> String -> Value -> Either Problem Value+run' config rule = problem . applyWithConfig config noOperators (json rule)++-- | Force the whole answer, and fail the test if it takes over ten seconds.+within10s :: Either e Value -> IO (Either e Value)+within10s result = do+  outcome <- timeout 10000000 (evaluate (Char8.length (encode (fromRight Null result)) `seq` result))+  maybe (expectationFailure "evaluation did not finish in 10 seconds" >> pure result) pure outcome++nestedMap :: Int -> Value+nestedMap levels = foldr (\_ inner -> object ["map" .= [toJSON [1 .. 10 :: Int], inner]]) (object ["var" .= ("" :: String)]) [1 .. levels]++spec :: Spec+spec = describe "resource limits" $ do+  describe "steps" $ do+    -- {"+":[1,1]} is three nodes: the + and its two operands+    it "allows exactly the budget" $+      run' defaultConfig{maxSteps = 3} "{\"+\":[1,1]}" Null `shouldBe` Right (Number 2)+    it "rejects one step short" $+      run' defaultConfig{maxSteps = 2} "{\"+\":[1,1]}" Null+        `shouldBe` Left (EvalProblem (LimitExceeded (StepLimit 2)))+    it "rejects when nothing is allowed" $+      run' defaultConfig{maxSteps = 0} "1" Null `shouldBe` Left (EvalProblem (LimitExceeded (StepLimit 0)))+    it "charges nothing extra for an array index, which is constant time" $ do+      let rule = "{\"var\":\"x.2\"}"+          dat = json "{\"x\":[10,20,30]}"+      -- the var and its path are two steps+      run' defaultConfig{maxSteps = 2} rule dat `shouldBe` Right (Number 30)+      run' defaultConfig{maxSteps = 1} rule dat `shouldSatisfy` isStepLimit+    it "charges a string index for the code points it walks" $ do+      let rule = "{\"var\":\"s.2\"}"+          dat = json "{\"s\":\"abcd\"}"+      -- two nodes, and the four code points of the string+      run' defaultConfig{maxSteps = 6} rule dat `shouldBe` Right (String "c")+      run' defaultConfig{maxSteps = 5} rule dat `shouldSatisfy` isStepLimit+    it "charges every iteration of map" $ do+      let rule = "{\"map\":[[1,2,3],{\"var\":\"\"}]}"+      -- map node (1), the array and its three literals (4), and three+      -- bodies of two nodes each, the var and its path (6)+      run' defaultConfig{maxSteps = 11} rule Null `shouldBe` Right (toJSON [1, 2, 3 :: Int])+      run' defaultConfig{maxSteps = 10} rule Null `shouldSatisfy` isStepLimit++  describe "string length" $ do+    let rule = "{\"cat\":[\"aaaa\",\"bbbb\"]}"+    it "allows exactly the limit" $+      run' defaultConfig{maxStringLength = 8} rule Null `shouldBe` Right (String "aaaabbbb")+    it "rejects one over" $+      run' defaultConfig{maxStringLength = 7} rule Null+        `shouldBe` Left (EvalProblem (LimitExceeded (StringLimit 7)))+    it "counts UTF-16 code units, so an emoji is two" $ do+      run' defaultConfig{maxStringLength = 2} "{\"cat\":[\"\\ud83d\\ude00\"]}" Null `shouldBe` Right (String "\x1F600")+      run' defaultConfig{maxStringLength = 1} "{\"cat\":[\"\\ud83d\\ude00\"]}" Null+        `shouldBe` Left (EvalProblem (LimitExceeded (StringLimit 1)))++  describe "array length" $ do+    it "allows a merge exactly at the limit" $+      run' defaultConfig{maxArrayLength = 3} "{\"merge\":[[1,2],[3]]}" Null `shouldBe` Right (toJSON [1, 2, 3 :: Int])+    it "rejects a merge one over" $+      run' defaultConfig{maxArrayLength = 2} "{\"merge\":[[1,2],[3]]}" Null+        `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 2)))+    it "rejects mapping over an array longer than the limit, before any work" $+      run' defaultConfig{maxArrayLength = 2} "{\"map\":[[1,2,3],{\"var\":\"\"}]}" Null+        `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 2)))+    it "allows mapping over an array at the limit" $+      run' defaultConfig{maxArrayLength = 3} "{\"map\":[[1,2,3],{\"var\":\"\"}]}" Null `shouldBe` Right (toJSON [1, 2, 3 :: Int])+    it "rejects filtering an array longer than the limit" $+      run' defaultConfig{maxArrayLength = 2} "{\"filter\":[[1,2,3],true]}" Null+        `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 2)))++  describe "depth" $ do+    let nested n = foldr (\_ inner -> object ["!" .= inner]) (toJSON (0 :: Int)) [1 .. n :: Int]+        at limit n = problem (applyWithConfig defaultConfig{maxDepth = limit} noOperators (nested n) Null)+    it "accepts exactly the limit" $ at 5 5 `shouldSatisfy` isRight'+    it "rejects one past it" $ at 5 6 `shouldBe` Left (ParseProblem (DepthExceeded 5))+    it "counts the nesting of a literal object too" $ do+      let literal n = foldr (\_ inner -> object ["a" .= inner, "b" .= inner]) (toJSON (0 :: Int)) [1 .. n :: Int]+      problem (applyWithConfig defaultConfig{maxDepth = 5} noOperators (literal 5) Null) `shouldSatisfy` isRight'+      problem (applyWithConfig defaultConfig{maxDepth = 5} noOperators (literal 6) Null)+        `shouldBe` Left (ParseProblem (DepthExceeded 5))+    it "holds a custom operator's result to the same depth" $ do+      let deep n = foldr (\_ inner -> toJSON [inner]) (toJSON (0 :: Int)) [1 .. n :: Int]+          ops = either (error . show) id (operatorsFromList [(OperatorName "deep", \_ _ -> Right (deep 10))])+          call limit = problem (applyWithConfig defaultConfig{maxDepth = limit} ops (object ["deep" .= ([] :: [Int])]) Null)+      call 10 `shouldSatisfy` isRight'+      call 9 `shouldBe` Left (EvalProblem (LimitExceeded (ValueDepthLimit 9)))++  describe "amplification rules finish quickly and fail safely" $ do+    it "nested maps over a ten-element array (10^20 iterations)" $ do+      r <- within10s (problem (applyWithConfig defaultConfig noOperators (nestedMap 20) Null))+      r `shouldSatisfy` isStepLimit+    it "a reduce that doubles a string on every step" $ do+      let rule =+            "{\"reduce\":[[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],\+            \{\"cat\":[{\"var\":\"accumulator\"},{\"var\":\"accumulator\"}]},\"xy\"]}"+      r <- within10s (run' defaultConfig rule Null)+      -- building the string is paid for in steps, which run out first here+      r `shouldSatisfy` isStepLimit+      r' <- within10s (run' defaultConfig{maxSteps = 100000000} rule Null)+      r' `shouldBe` Left (EvalProblem (LimitExceeded (StringLimit 1048576)))+    it "a reduce that doubles an array on every step" $ do+      let rule =+            "{\"reduce\":[[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],\+            \{\"merge\":[{\"var\":\"accumulator\"},{\"var\":\"accumulator\"}]},[1,2]]}"+      r <- within10s (run' defaultConfig rule Null)+      r `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 100000)))+    it "a very wide rule" $ do+      let wide = object ["+" .= replicate 2000000 (1 :: Int)]+      r <- within10s (problem (applyWithConfig defaultConfig noOperators wide Null))+      r `shouldSatisfy` isStepLimit+    it "a deeply nested datum is only read, never recursed into by the evaluator" $ do+      let deepData = foldr (\_ inner -> toJSON [inner]) Null [1 .. 20000 :: Int]+      r <- within10s (problem (applyWithConfig defaultConfig noOperators (object ["var" .= ("" :: String)]) deepData))+      r `shouldSatisfy` isRight'+    it "repeated lookups deep into a large array are constant time each" $ do+      let big = toJSON [1 .. 5000 :: Int]+          rule = object ["+" .= replicate 2000 (object ["var" .= ("x.4999" :: String)])]+      r <- within10s (applyWithConfig defaultConfig noOperators rule (object ["x" .= big]))+      r `shouldBe` Right (Number 10000000)+    it "repeated lookups into a long string are paid for in steps" $ do+      let big = String (Data.Text.replicate 5000 "a")+          rule = object ["+" .= replicate 2000 (object ["var" .= ("s.length" :: String)])]+      r <- within10s (problem (applyWithConfig defaultConfig noOperators rule (object ["s" .= big])))+      r `shouldSatisfy` isStepLimit+    it "repeated comparison of a large array is paid for in steps" $ do+      let big = toJSON [1 .. 5000 :: Int]+          rule = object ["+" .= replicate 2000 (object ["==" .= [object ["var" .= ("x" :: String)], toJSON (1 :: Int)]])]+      r <- within10s (problem (applyWithConfig defaultConfig noOperators rule (object ["x" .= big])))+      r `shouldSatisfy` isStepLimit++  describe "properties" $ do+    it "evaluation always terminates, within a constant factor of the budget" $+      forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat ->+        forAll (choose (0, 60)) $ \budget ->+          ioProperty $ do+            r <- within10s (applyWithConfig defaultConfig{maxSteps = budget, arityMode = Lenient} noOperators rule dat)+            pure (r `seq` True)+    it "raising the step limit never changes an answer that already succeeded" $+      forAll (genRule 5) $ \rule -> forAll (genData 3) $ \dat ->+        forAll (choose (1, 80)) $ \budget ->+          let low = applyWithConfig defaultConfig{maxSteps = budget, arityMode = Lenient} noOperators rule dat+              high = applyWithConfig defaultConfig{maxSteps = budget * 100, arityMode = Lenient} noOperators rule dat+           in case low of+                Right v -> high === Right v+                Left (EvalFailure _ (LimitExceeded _)) -> property True+                Left e -> high === Left e+    it "compile once then run equals apply" $+      forAll (genRule 5) $ \rule -> forAll (genData 3) $ \dat ->+        let direct = apply rule dat+            staged = compile defaultConfig noOperators rule >>= \c -> run c dat+         in direct === staged++isStepLimit :: Either Problem Value -> Bool+isStepLimit (Left (EvalProblem (LimitExceeded (StepLimit _)))) = True+isStepLimit _ = False
+ test/LocationSpec.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Error locations are JSON Pointers into the rule: every one of them must+-- name a value that exists in the rule's JSON, and that value must be the+-- operator call (or literal) the error is about.+module LocationSpec (spec) where++import Data.Aeson (Value (..))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Foldable (toList)+import Data.Maybe (isJust)+import Test.Hspec+import Test.QuickCheck++import Gen (genData, genRule)+import JsonLogic+import Support (at, json)++-- | The value a pointer names, if it names one (RFC 6901 resolution, on+-- already-decoded segments).+resolve :: Location -> Value -> Maybe Value+resolve location = go (locationSegments location)+  where+    go [] v = Just v+    go (Key k : rest) (Object fields) = KeyMap.lookup (Key.fromText k) fields >>= go rest+    go (Index i : rest) (Array items) = case drop i (toList items) of+      item : _ | i >= 0 -> go rest item+      _ -> Nothing+    go _ _ = Nothing++-- | For an operator error, the operator call the pointer names.+operatorAt :: Location -> Value -> Maybe OperatorName+operatorAt location rule = case resolve location rule of+  Just (Object fields) | [(key, _)] <- KeyMap.toList fields -> Just (OperatorName (Key.toText key))+  _ -> Nothing++-- | The operator an error is about, when it is about one.+operatorOf :: Error -> Maybe OperatorName+operatorOf e = case e of+  ParseFailure _ (ArityMismatch name _ _) -> Just name+  EvalFailure _ (UnknownOperator name) -> Just name+  EvalFailure _ (CustomOperatorFailed name _) -> Just name+  EvalFailure _ (InvalidOperand name) -> Just name+  _ -> Nothing++modes :: [Config]+modes = [defaultConfig, defaultConfig{arityMode = Lenient}, defaultConfig{maxSteps = 25}, defaultConfig{maxDepth = 3}]++spec :: Spec+spec = describe "error locations" $ do+  it "always name a value that exists in the rule" $+    forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat -> forAll (elements modes) $ \config ->+      case applyWithConfig config noOperators rule dat of+        Left e -> counterexample (show e) (isJust (resolve (errorLocation e) rule))+        Right _ -> property True+  it "name the very operator an operator error is about" $+    forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat -> forAll (elements modes) $ \config ->+      case applyWithConfig config noOperators rule dat of+        Left e | Just name <- operatorOf e -> counterexample (show e) (operatorAt (errorLocation e) rule === Just name)+        _ -> property True+  it "follow bare operands, which have no index" $+    apply (json "{\"!\":{\"!\":{\"nope\":1}}}") Null+      `shouldBe` Left (EvalFailure (at [Left "!", Left "!"]) (UnknownOperator (OperatorName "nope")))+  it "point into literals' own arrays" $+    apply (json "{\"merge\":[[{\"nope\":[]}]]}") Null+      `shouldBe` Left (EvalFailure (at [Left "merge", Right 0, Right 0]) (UnknownOperator (OperatorName "nope")))+  it "put an operand the rule leaves out at the operator call, which exists" $+    -- five steps: the and, its true, the ==, its 1, and the undefined that+    -- stands in for the missing second operand, which runs out+    applyWithConfig defaultConfig{arityMode = Lenient, maxSteps = 4} noOperators (json "{\"and\":[true,{\"==\":[1]}]}") Null+      `shouldBe` Left (EvalFailure (at [Left "and", Right 1]) (LimitExceeded (StepLimit 4)))
+ test/NumberSpec.hs view
@@ -0,0 +1,115 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The fast paths of "JsonLogic.Core.Number" against the exact general+-- paths they stand in for. Each fast path is used only when it is provably+-- exact; these properties check that claim over hundreds of thousands of+-- doubles, chosen to include the hard cases (whole numbers at 2^53 and+-- above, short decimals, numbers needing 16 or 17 digits, subnormals).+module NumberSpec (spec) where++import Data.Bits (shiftR, (.&.))+import Data.Word (Word64)+import GHC.Float (castWord64ToDouble)+import Test.Hspec+import Test.Hspec.QuickCheck (modifyMaxSuccess)+import Test.QuickCheck hiding ((.&.))++import JsonLogic.Core.Number++-- | Any positive finite double, from its bit pattern.+genAnyDouble :: Gen Double+genAnyDouble = (castWord64ToDouble . (.&. 0x7FFFFFFFFFFFFFFF) <$> (arbitrary :: Gen Word64)) `suchThat` finitePositive++-- | Doubles of the kinds rules usually hold: short decimals, money, ratios,+-- and whole numbers either side of 2^53.+genCommonDouble :: Gen Double+genCommonDouble =+  oneof+    [ (/ 100) . fromInteger <$> choose (1, 10000000)+    , (/ 1000) . fromInteger <$> choose (1, 100000)+    , (\a b -> fromInteger a / fromInteger b) <$> choose (1, 1000000) <*> choose (1, 1000)+    , fromInteger <$> choose (1, 2 ^ (60 :: Int))+    , (* 1e-9) . fromInteger <$> choose (1, 1000000)+    , (\w -> fromIntegral (w `shiftR` 11 :: Word64)) <$> arbitrary+    ]+    `suchThat` finitePositive++finitePositive :: Double -> Bool+finitePositive d = d > 0 && not (isInfinite d) && not (isNaN d)++-- | The slow, obviously correct remainder: exact in 'Rational'.+remainderExact :: Double -> Double -> Double+remainderExact x y+  | isNaN x || isNaN y || isInfinite x || y == 0 = 0 / 0+  | isInfinite y = x+  | x == 0 = x+  | exact == 0 = if x < 0 then -0.0 else 0+  | otherwise = fromRational exact+  where+    rx = toRational x+    ry = toRational y+    exact = rx - ry * fromInteger (truncate (rx / ry))++-- | Run a property this many times instead of the usual hundred.+times :: Int -> SpecWith a -> SpecWith a+times n = modifyMaxSuccess (const n)++sameDouble :: Double -> Double -> Bool+sameDouble a b = (isNaN a && isNaN b) || (a == b && isNegativeZero a == isNegativeZero b)++spec :: Spec+spec = describe "JsonLogic.Core.Number" $ do+  describe "shortest digits" $ do+    times 100000 $ it "the fast path agrees with the exact algorithm on any double" $ forAll genAnyDouble $ \d -> shortestDigits d === shortestDigitsExact d+    times 100000 $ it "the fast path agrees with the exact algorithm on common doubles" $ forAll genCommonDouble $ \d -> shortestDigits d === shortestDigitsExact d+    it "accepts the halfway point above 2^53, as ECMAScript does" $+      numberToString 2.2531e21 `shouldBe` "2.2531e+21"+    it "takes the even digit when two candidates are equally close" $+      numberToString (9007199254740993 / 10) `shouldBe` "900719925474099.2"+    it "writes the extremes" $+      map numberToString [5e-324, 1.7976931348623157e308, 0.1, 123.456, 1e21, 1e-7]+        `shouldBe` ["5e-324", "1.7976931348623157e+308", "0.1", "123.456", "1e+21", "1e-7"]++  describe "reading decimals" $ do+    times 100000 $+      it "the fast path is exact for every mantissa below 2^53 and exponent within 22" $+        forAll (choose (1, 2 ^ (53 :: Int) - 1)) $ \m -> forAll (choose (-22, 22)) $ \e ->+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)+    times 20000 $+      it "is exact beyond the fast path too" $+        forAll (choose (1, 10 ^ (30 :: Int))) $ \m -> forAll (choose (-340, 320)) $ \e ->+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)+    times 50000 $+      it "is exact for the 16- to 19-digit decimals JavaScript and JSON writers produce" $+        forAll (choose (10 ^ (15 :: Int), 10 ^ (19 :: Int))) $ \m -> forAll (choose (-40, 30)) $ \e ->+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)+    times 20000 $+      it "rounds correctly into and out of the subnormal range" $+        forAll (choose (1, 10 ^ (20 :: Int))) $ \m -> forAll (choose (-345, -300)) $ \e ->+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)+    it "rounds an exact tie to even" $ do+      -- 2^53 + 1 is halfway between two doubles; 2^53 + 3 rounds up to 2^53 + 4+      decimalToDouble 9007199254740993 0 `shouldBe` 9007199254740992+      decimalToDouble 9007199254740995 0 `shouldBe` 9007199254740996+      decimalToDouble 90071992547409930 (-1) `shouldBe` 9007199254740992+    it "overflows to infinity and underflows to zero" $ do+      isInfinite (decimalToDouble 17976931348623159 292) `shouldBe` True+      decimalToDouble 17976931348623157 292 `shouldBe` 1.7976931348623157e308+      decimalToDouble 24703282292062327 (-340) `shouldBe` 0+      decimalToDouble 24703282292062328 (-340) `shouldBe` 5e-324+    times 50000 $ it "reads back every double it writes" $ forAll genAnyDouble $ \d -> stringToNumber (numberToString d) === d++  describe "remainder" $ do+    times 50000 $+      it "the integer path agrees with the exact remainder" $+        forAll (choose (negate (2 ^ (53 :: Int)), 2 ^ (53 :: Int))) $ \x -> forAll (choose (-100000, 100000)) $ \y ->+          let (a, b) = (fromInteger x, fromInteger y)+           in counterexample (show (a, b)) (sameDouble (remainderJs a b) (remainderExact a b))+    times 50000 $+      it "agrees with the exact remainder on any doubles" $+        forAll ((,) <$> genAnyDouble <*> genAnyDouble) $ \(a, b) ->+          conjoin+            [ counterexample (show (x, y)) (sameDouble (remainderJs x y) (remainderExact x y))+            | x <- [a, negate a]+            , y <- [b, negate b]+            ]
+ test/ParseSpec.hs view
@@ -0,0 +1,151 @@+{-# LANGUAGE OverloadedStrings #-}++module ParseSpec (spec) where++import Data.Aeson (Value (..), object, toJSON, (.=))+import qualified Data.Aeson.Key as Key+import Data.Either (isRight)+import Data.Fix (Fix (..), foldFix)+import Data.List (nub)+import Data.Text (Text)+import Test.Hspec+import Test.QuickCheck (NonNegative (..), forAll, property)++import Gen (genRule)+import JsonLogic.Core.Config (ArityMode (..), Config (..), defaultConfig)+import JsonLogic.Core.Errors (Arity (..), ParseError (..))+import JsonLogic.Core.Location (Location, root)+import JsonLogic.Core.Operator (OperatorName (..), allBuiltins, builtinName, lookupBuiltin)+import JsonLogic.Core.Parse (parseRule, parseRuleWith)+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..), Term, stripLocations)+import Support (at, json)++lit :: Value -> Term+lit = Fix . Lit++int :: Int -> Value+int = toJSON++-- | A rule nested @n@ operator levels deep around a literal leaf.+nested :: Int -> Value+nested n = foldr (\_ inner -> object ["!" .= inner]) (int 0) [1 .. n]++-- | The shape a rule is read as, without locations.+shape :: Value -> Either ParseError Term+shape = either (Left . snd) (Right . stripLocations) . parseRule++lenientShape :: Value -> Either ParseError Term+lenientShape = either (Left . snd) (Right . stripLocations) . parseRuleWith defaultConfig{arityMode = Lenient}++-- | The location of every node, outermost first.+locations :: Rule -> [Location]+locations = foldFix (\(Node location node) -> location : concat node)++parsedLocations :: Value -> [Location]+parsedLocations = either (const []) locations . parseRule++withDepth :: Int -> Value -> Either (Location, ParseError) Rule+withDepth limit = parseRuleWith defaultConfig{maxDepth = limit}++spec :: Spec+spec = describe "parsing" $ do+  describe "shapes" $ do+    it "reads a bare number as a literal" $+      shape (int 1) `shouldBe` Right (lit (int 1))+    it "reads {\"var\": \"a\"} as a var with no default" $+      shape (object ["var" .= ("a" :: Text)])+        `shouldBe` Right (Fix (Var (lit (String "a")) Nothing))+    it "reads {\"var\": [\"a\", 1]} as a var with a default" $+      shape (object ["var" .= [toJSON ("a" :: Text), int 1]])+        `shouldBe` Right (Fix (Var (lit (String "a")) (Just (lit (int 1)))))+    it "reads {\"var\": []} as a lookup of the data itself" $+      shape (object ["var" .= ([] :: [Int])])+        `shouldBe` Right (Fix (Var (lit Null) Nothing))+    it "reads an array element-wise, as the reference implementation does" $+      shape (toJSON [1, 2 :: Int]) `shouldBe` Right (Fix (Arr [lit (int 1), lit (int 2)]))+    it "reads an empty-argument + as Add []" $+      shape (object ["+" .= ([] :: [Int])]) `shouldBe` Right (Fix (Add []))+    it "treats an object with two keys as a literal, not a rule" $ do+      let v = object ["a" .= int 1, "b" .= int 2]+      shape v `shouldBe` Right (lit v)+    it "turns an unknown operator into a CustomOp" $+      shape (object ["frobnicate" .= ([] :: [Int])])+        `shouldBe` Right (Fix (CustomOp (OperatorName "frobnicate") []))+    it "reads a bare operand as a list of one" $+      shape (object ["!" .= True]) `shouldBe` shape (object ["!" .= [True]])++  describe "strict arity" $ do+    it "rejects a wrong argument count" $+      shape (object ["==" .= [int 1]]) `shouldBe` Left (ArityMismatch (OperatorName "==") (Exactly 2) 1)+    it "accepts the three-argument between form for < but not for >" $ do+      shape (object ["<" .= [int 1, int 2, int 3]]) `shouldSatisfy` isRight+      shape (object [">" .= [int 1, int 2, int 3]])+        `shouldBe` Left (ArityMismatch (OperatorName ">") (Exactly 2) 3)+    it "states var's range as zero to two" $+      shape (object ["var" .= [int 1, int 2, int 3]])+        `shouldBe` Left (ArityMismatch (OperatorName "var") (Between 0 2) 3)+    it "rejects * with no operands" $+      shape (object ["*" .= ([] :: [Int])]) `shouldBe` Left (ArityMismatch (OperatorName "*") (AtLeast 1) 0)++  describe "lenient arity" $ do+    it "pads a missing operand with undefined (an empty and)" $+      lenientShape (object ["==" .= [int 1]])+        `shouldBe` Right (Fix (LooseEq (lit (int 1)) (Fix (And []))))+    it "pads the missing initial value of reduce with null" $+      lenientShape (object ["reduce" .= [String "xs", int 2]])+        `shouldBe` Right (Fix (Reduce (lit (String "xs")) (lit (int 2)) (lit Null)))+    it "keeps surplus operands of an ordinary operator to be evaluated" $+      lenientShape (object ["!" .= [int 1, int 2]])+        `shouldBe` Right (Fix (Discard (Fix (Not (lit (int 1)))) [lit (int 2)]))+    it "drops surplus operands of map, which the reference never looks at" $+      lenientShape (object ["map" .= [int 1, int 2, int 3]])+        `shouldBe` Right (Fix (Map (lit (int 1)) (lit (int 2))))+    it "keeps an empty *, which throws only if it is evaluated" $+      lenientShape (object ["*" .= ([] :: [Int])]) `shouldBe` Right (Fix (Mul []))+    it "never reports an arity error" $+      forAll (genRule 5) $ \rule -> case lenientShape rule of+        Left (ArityMismatch{}) -> False+        _ -> True++  describe "locations" $ do+    it "puts the root at the empty pointer" $+      take 1 (parsedLocations (int 1)) `shouldBe` [root]+    it "locates operands under the operator's key, by index" $+      parsedLocations (json "{\"if\":[true,{\"var\":\"a\"}]}")+        `shouldBe` [ root+                   , at [Left "if", Right 0]+                   , at [Left "if", Right 1]+                   , at [Left "if", Right 1, Left "var"]+                   ]+    it "locates a bare operand at the operator's key" $+      parsedLocations (json "{\"!\":{\"var\":\"a\"}}")+        `shouldBe` [root, at [Left "!"], at [Left "!", Left "var"]]+    it "locates array elements by index" $+      parsedLocations (json "[1,[2]]")+        `shouldBe` [root, at [Right 0], at [Right 1], at [Right 1, Right 0]]+    it "reports where an arity error is" $+      parseRule (json "{\"and\":[true,{\"==\":[1]}]}")+        `shouldSatisfy` either ((== at [Left "and", Right 1]) . fst) (const False)+    it "reports where the depth limit was passed" $+      withDepth 1 (json "{\"!\":{\"!\":{\"!\":1}}}")+        `shouldSatisfy` either ((== at [Left "!", Left "!"]) . fst) (const False)++  describe "depth" $ do+    it "reports the configured limit when nesting is too deep" $+      either (Left . snd) (const (Right ())) (withDepth 3 (nested 10)) `shouldBe` Left (DepthExceeded 3)+    it "accepts nesting exactly up to the depth limit and no deeper" $+      property $ \(NonNegative n) (NonNegative limit) ->+        isRight (withDepth limit (nested n)) == (n <= limit)++  describe "the operator table" $ do+    it "maps every builtin's name back to it" $+      map (lookupBuiltin . builtinName) allBuiltins `shouldBe` map Just allBuiltins+    it "has the 35 builtins of the classic specification, with distinct names" $+      length (nub (map builtinName allBuiltins)) `shouldBe` 35+    it "reads every builtin name as a builtin, never as a custom operator" $+      mapM_+        ( \builtin -> case lenientShape (object [Key.fromText (builtinName builtin) .= ([] :: [Int])]) of+            Right (Fix (CustomOp _ _)) -> expectationFailure ("read as custom: " ++ show builtin)+            _ -> pure ()+        )+        allBuiltins
+ test/Spec.hs view
@@ -0,0 +1,23 @@+module Main (main) where++import Test.Hspec (hspec)++import qualified AnalysisSpec+import qualified CompatSpec+import qualified EvalSpec+import qualified LimitsSpec+import qualified LocationSpec+import qualified NumberSpec+import qualified ParseSpec+import qualified ValueSpec++main :: IO ()+main = hspec $ do+  ParseSpec.spec+  NumberSpec.spec+  ValueSpec.spec+  EvalSpec.spec+  CompatSpec.spec+  LimitsSpec.spec+  LocationSpec.spec+  AnalysisSpec.spec
+ test/Support.hs view
@@ -0,0 +1,41 @@+-- | Helpers shared by the test modules.+module Support+  ( json+  , Problem (..)+  , problem+  , at+  , isRight'+  ) where++import Data.Aeson (Value, decode)+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Maybe (fromMaybe)+import Data.Text (Text)++import JsonLogic++{- HLINT ignore json "Avoid restricted function" -}++-- | A JSON literal written in the test. A malformed one is a bug in the+-- test, so it stops the test with the literal in the message (ADR 0004).+json :: String -> Value+json text = fromMaybe (error ("bad test JSON: " ++ text)) (decode (Char8.pack text))++-- | What went wrong, without where: most tests are about the first.+data Problem+  = ParseProblem ParseError+  | EvalProblem EvalError+  deriving (Eq, Show)++-- | An answer with the location dropped from a failure.+problem :: Either Error a -> Either Problem a+problem (Left (ParseFailure _ e)) = Left (ParseProblem e)+problem (Left (EvalFailure _ e)) = Left (EvalProblem e)+problem (Right a) = Right a++-- | A location from its JSON Pointer segments, written as keys and indices.+at :: [Either Text Int] -> Location+at = fromSegments . map (either Key Index)++isRight' :: Either a b -> Bool+isRight' = either (const False) (const True)
+ test/ValueSpec.hs view
@@ -0,0 +1,247 @@+{-# LANGUAGE OverloadedStrings #-}++module ValueSpec (spec) where++import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Text (Text)+import qualified Data.Text as Text+import Test.Hspec+import Test.QuickCheck (Gen, arbitrary, choose, elements, forAll, listOf, oneof, (==>))++import qualified Data.Vector as Vector++import JsonLogic.Core.Number (maximumJs, minimumJs, numberToString, remainderJs)+import JsonLogic.Core.Value++arr :: [Val] -> Val+arr = VArr . Vector.fromList++nan :: Double+nan = 0 / 0++infinity :: Double+infinity = 1 / 0++genScalar :: Gen Val+genScalar =+  oneof+    [ pure VNull+    , pure VUndefined+    , VBool <$> arbitrary+    , VNum <$> oneof [elements [0, -0.0, 1, -1, 0.5, infinity, negate infinity, nan], arbitrary]+    , VStr . Text.pack <$> listOf (elements "ab01 .-eE")+    ]++genVal :: Gen Val+genVal = oneof [genScalar, arr <$> listOf genScalar, pure (VObj KeyMap.empty)]++numberCases :: [(Double, Text)]+numberCases =+  [ (1, "1")+  , (-1, "-1")+  , (1.5, "1.5")+  , (100, "100")+  , (0.5, "0.5")+  , (0.000001, "0.000001")+  , (1e-7, "1e-7")+  , (1e21, "1e+21")+  , (1e20, "100000000000000000000")+  , (123.456, "123.456")+  , (0.1 + 0.2, "0.30000000000000004")+  , (-0.0, "0")+  , (1.2345e25, "1.2345e+25")+  , (5e-324, "5e-324")+  , (2.2531e21, "2.2531e+21")+  , (-2.2531e21, "-2.2531e+21")+  , (9007199254740992, "9007199254740992")+  , (nan, "NaN")+  , (infinity, "Infinity")+  , (negate infinity, "-Infinity")+  ]++toNumberCases :: [(Val, Double)]+toNumberCases =+  [ (VStr "", 0)+  , (VStr "  12  ", 12)+  , (VStr "0x10", 16)+  , (VStr "0b11", 3)+  , (VStr "1e3", 1000)+  , (VStr ".5", 0.5)+  , (VStr "5.", 5)+  , (VStr "-2.5", -2.5)+  , (VNull, 0)+  , (VBool True, 1)+  , (arr [], 0)+  , (arr [VNum 7], 7)+  , (VStr "Infinity", infinity)+  , (VStr "-Infinity", negate infinity)+  , (VStr "-2.2531e+21", -2.2531e21)+  , (VStr "2.2531e21", 2.2531e21)+  , (VStr "1e400", infinity)+  , (VStr "1e-400", 0)+  , (VStr "5e-324", 5e-324)+  , (VStr "123456789012345678901234567890", 1.2345678901234568e29)+  ]++parseFloatCases :: [(Val, Double)]+parseFloatCases =+  [ (VStr "3.14abc", 3.14)+  , (VStr "  7", 7)+  , (VStr "1e3x", 1000)+  , (VStr ".5", 0.5)+  , (VStr "-12.5e1", -125)+  , (arr [VNum 5], 5)+  , (VNum (-0.0), 0)+  , (VStr "Infinityx", infinity)+  ]++looseCases :: [(Val, Val, Bool)]+looseCases =+  [ (VNum 1, VStr "1", True)+  , (VNum 0, VStr "", True)+  , (VNull, VNull, True)+  , (VNull, VNum 0, False)+  , (VNull, VBool False, False)+  , (VBool True, VNum 1, True)+  , (VBool False, VStr "0", True)+  , (VStr "a", VStr "a", True)+  , (VStr "abc", VNum 0, False)+  , (arr [VNum 1], VStr "1", True)+  , (arr [], VNum 0, True)+  , (arr [], VBool False, True)+  , (arr [], arr [], False)+  , (VNum nan, VNum nan, False)+  , (VUndefined, VNull, True)+  , (VUndefined, VUndefined, True)+  , (VUndefined, VNum 0, False)+  , (VUndefined, VBool False, False)+  ]++lessCases :: [(Val, Val, Bool)]+lessCases =+  [ (VNum 1, VNum 2, True)+  , (VStr "10", VStr "9", True)+  , (VStr "2", VNum 10, True)+  , (VNull, VNum 1, True)+  , (VNum nan, VNum 1, False)+  , (VStr "a", VNum 1, False)+  , (arr [VNum 1], arr [VNum 2], True)+  ]++remainderCases :: [(Double, Double, Double)]+remainderCases =+  [ (5, 3, 2)+  , (-5, 3, -2)+  , (5, -3, 2)+  , (5.5, 2, 1.5)+  , (5, infinity, 5)+  ]++-- | Doubles across the whole range, including the large ones where digit+-- generation has edge cases.+genWideDouble :: Gen Double+genWideDouble = do+  mantissa <- choose (-1000000000000000, 1000000000000000) :: Gen Integer+  power <- choose (-300, 290) :: Gen Int+  pure (fromInteger mantissa * 10 ^^ power)++roundTripsThroughJson :: Double -> Bool+roundTripsThroughJson d = fromValue (toValue (VNum d)) == VNum d++spec :: Spec+spec = describe "JsonLogic.Core.Value" $ do+  describe "truthy" $ do+    it "is false for exactly the reference's falsy values" $+      map truthy [VNull, VUndefined, VBool False, VNum 0, VNum nan, VStr "", arr []]+        `shouldBe` replicate 7 False+    it "is true for everything else, including \"0\" and empty objects" $+      map truthy [VBool True, VNum 1, VNum (-1), VStr "0", VStr "a", arr [VNull], VObj KeyMap.empty]+        `shouldBe` replicate 7 True++  describe "numberToString"+    $ it "follows the ECMAScript Number-to-string rules"+    $ map (numberToString . fst) numberCases `shouldBe` map snd numberCases++  describe "toNumber" $ do+    it "converts the way Number() does" $+      map (toNumber . fst) toNumberCases `shouldBe` map snd toNumberCases+    it "gives NaN for malformed input" $+      map+        (isNaN . toNumber)+        [ VStr "abc"+        , VStr "1 2"+        , VStr "0x"+        , VStr "--1"+        , VStr "1e"+        , VStr "+-1"+        , VStr "infinity"+        , VObj KeyMap.empty+        , arr [VNum 1, VNum 2]+        ]+        `shouldBe` replicate 9 True++  describe "parseFloat" $ do+    it "reads the longest decimal prefix" $+      map (parseFloat . fst) parseFloatCases `shouldBe` map snd parseFloatCases+    it "gives NaN when there is no number to read" $+      map (isNaN . parseFloat) [VStr "abc", VStr "", VStr "x1", VNull, VBool True]+        `shouldBe` replicate 5 True++  describe "equality" $ do+    it "looseEquals matches JavaScript's == on a table of cases" $+      map (\(x, y, _) -> looseEquals x y) looseCases+        `shouldBe` map (\(_, _, expected) -> expected) looseCases+    it "strictEquals never converts types" $+      [ strictEquals (VNum 1) (VStr "1")+      , strictEquals (VNum nan) (VNum nan)+      , strictEquals (VStr "a") (VStr "a")+      , strictEquals (arr []) (arr [])+      ]+        `shouldBe` [False, False, True, False]++  describe "ordering" $ do+    it "lessThan matches JavaScript's < on a table of cases" $+      map (\(x, y, _) -> lessThan x y) lessCases+        `shouldBe` map (\(_, _, expected) -> expected) lessCases+    it "lessOrEqual compares strings lexicographically" $+      [lessOrEqual (VNum 1) (VNum 1), lessOrEqual (VStr "b") (VStr "a")]+        `shouldBe` [True, False]++  describe "arithmetic helpers" $ do+    it "remainderJs takes the sign of the dividend" $+      map (\(x, y, _) -> remainderJs x y) remainderCases+        `shouldBe` map (\(_, _, expected) -> expected) remainderCases+    it "remainderJs gives NaN where JavaScript does" $+      map (isNaN . uncurry remainderJs) [(5, 0), (infinity, 3), (nan, 1)]+        `shouldBe` replicate 3 True+    it "maximumJs and minimumJs follow Math.max and Math.min" $ do+      (maximumJs [1, 3, 2], maximumJs [], minimumJs []) `shouldBe` (3, negate infinity, infinity)+      isNaN (maximumJs [1, nan]) `shouldBe` True++  describe "JSON conversion" $ do+    it "turns NaN and infinities into null" $+      map (toValue . VNum) [nan, infinity, negate infinity]+        `shouldBe` replicate 3 Aeson.Null+    it "keeps ordinary numbers and strings" $ do+      toValue (VNum 2) `shouldBe` Aeson.Number 2+      fromValue (Aeson.String "x") `shouldBe` VStr "x"++  describe "laws" $ do+    it "looseEquals is symmetric" $+      forAll genVal $+        \x -> forAll genVal $ \y -> looseEquals x y == looseEquals y x+    it "strictEquals implies looseEquals" $+      forAll genVal $+        \x -> forAll genVal $ \y -> not (strictEquals x y) || looseEquals x y+    it "lessThan is asymmetric" $+      forAll genVal $+        \x -> forAll genVal $ \y -> not (lessThan x y && lessThan y x)+    it "numberToString round-trips through toNumber" $+      forAll (arbitrary :: Gen Double) $ \d ->+        not (isNaN d || isInfinite d) ==> toNumber (VStr (numberToString d)) == d+    it "writing a double as JSON and reading it back is the identity" $+      forAll genWideDouble roundTripsThroughJson+    it "numberToString round-trips for large and tiny doubles too" $+      forAll genWideDouble $+        \d -> toNumber (VStr (numberToString d)) == d
+ test/community/LICENSE view
@@ -0,0 +1,201 @@+                                 Apache License+                           Version 2.0, January 2004+                        http://www.apache.org/licenses/++   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION++   1. Definitions.++      "License" shall mean the terms and conditions for use, reproduction,+      and distribution as defined by Sections 1 through 9 of this document.++      "Licensor" shall mean the copyright owner or entity authorized by+      the copyright owner that is granting the License.++      "Legal Entity" shall mean the union of the acting entity and all+      other entities that control, are controlled by, or are under common+      control with that entity. For the purposes of this definition,+      "control" means (i) the power, direct or indirect, to cause the+      direction or management of such entity, whether by contract or+      otherwise, or (ii) ownership of fifty percent (50%) or more of the+      outstanding shares, or (iii) beneficial ownership of such entity.++      "You" (or "Your") shall mean an individual or Legal Entity+      exercising permissions granted by this License.++      "Source" form shall mean the preferred form for making modifications,+      including but not limited to software source code, documentation+      source, and configuration files.++      "Object" form shall mean any form resulting from mechanical+      transformation or translation of a Source form, including but+      not limited to compiled object code, generated documentation,+      and conversions to other media types.++      "Work" shall mean the work of authorship, whether in Source or+      Object form, made available under the License, as indicated by a+      copyright notice that is included in or attached to the work+      (an example is provided in the Appendix below).++      "Derivative Works" shall mean any work, whether in Source or Object+      form, that is based on (or derived from) the Work and for which the+      editorial revisions, annotations, elaborations, or other modifications+      represent, as a whole, an original work of authorship. For the purposes+      of this License, Derivative Works shall not include works that remain+      separable from, or merely link (or bind by name) to the interfaces of,+      the Work and Derivative Works thereof.++      "Contribution" shall mean any work of authorship, including+      the original version of the Work and any modifications or additions+      to that Work or Derivative Works thereof, that is intentionally+      submitted to Licensor for inclusion in the Work by the copyright owner+      or by an individual or Legal Entity authorized to submit on behalf of+      the copyright owner. For the purposes of this definition, "submitted"+      means any form of electronic, verbal, or written communication sent+      to the Licensor or its representatives, including but not limited to+      communication on electronic mailing lists, source code control systems,+      and issue tracking systems that are managed by, or on behalf of, the+      Licensor for the purpose of discussing and improving the Work, but+      excluding communication that is conspicuously marked or otherwise+      designated in writing by the copyright owner as "Not a Contribution."++      "Contributor" shall mean Licensor and any individual or Legal Entity+      on behalf of whom a Contribution has been received by Licensor and+      subsequently incorporated within the Work.++   2. Grant of Copyright License. Subject to the terms and conditions of+      this License, each Contributor hereby grants to You a perpetual,+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable+      copyright license to reproduce, prepare Derivative Works of,+      publicly display, publicly perform, sublicense, and distribute the+      Work and such Derivative Works in Source or Object form.++   3. Grant of Patent License. Subject to the terms and conditions of+      this License, each Contributor hereby grants to You a perpetual,+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable+      (except as stated in this section) patent license to make, have made,+      use, offer to sell, sell, import, and otherwise transfer the Work,+      where such license applies only to those patent claims licensable+      by such Contributor that are necessarily infringed by their+      Contribution(s) alone or by combination of their Contribution(s)+      with the Work to which such Contribution(s) was submitted. If You+      institute patent litigation against any entity (including a+      cross-claim or counterclaim in a lawsuit) alleging that the Work+      or a Contribution incorporated within the Work constitutes direct+      or contributory patent infringement, then any patent licenses+      granted to You under this License for that Work shall terminate+      as of the date such litigation is filed.++   4. Redistribution. You may reproduce and distribute copies of the+      Work or Derivative Works thereof in any medium, with or without+      modifications, and in Source or Object form, provided that You+      meet the following conditions:++      (a) You must give any other recipients of the Work or+          Derivative Works a copy of this License; and++      (b) You must cause any modified files to carry prominent notices+          stating that You changed the files; and++      (c) You must retain, in the Source form of any Derivative Works+          that You distribute, all copyright, patent, trademark, and+          attribution notices from the Source form of the Work,+          excluding those notices that do not pertain to any part of+          the Derivative Works; and++      (d) If the Work includes a "NOTICE" text file as part of its+          distribution, then any Derivative Works that You distribute must+          include a readable copy of the attribution notices contained+          within such NOTICE file, excluding those notices that do not+          pertain to any part of the Derivative Works, in at least one+          of the following places: within a NOTICE text file distributed+          as part of the Derivative Works; within the Source form or+          documentation, if provided along with the Derivative Works; or,+          within a display generated by the Derivative Works, if and+          wherever such third-party notices normally appear. The contents+          of the NOTICE file are for informational purposes only and+          do not modify the License. You may add Your own attribution+          notices within Derivative Works that You distribute, alongside+          or as an addendum to the NOTICE text from the Work, provided+          that such additional attribution notices cannot be construed+          as modifying the License.++      You may add Your own copyright statement to Your modifications and+      may provide additional or different license terms and conditions+      for use, reproduction, or distribution of Your modifications, or+      for any such Derivative Works as a whole, provided Your use,+      reproduction, and distribution of the Work otherwise complies with+      the conditions stated in this License.++   5. Submission of Contributions. Unless You explicitly state otherwise,+      any Contribution intentionally submitted for inclusion in the Work+      by You to the Licensor shall be under the terms and conditions of+      this License, without any additional terms or conditions.+      Notwithstanding the above, nothing herein shall supersede or modify+      the terms of any separate license agreement you may have executed+      with Licensor regarding such Contributions.++   6. Trademarks. This License does not grant permission to use the trade+      names, trademarks, service marks, or product names of the Licensor,+      except as required for reasonable and customary use in describing the+      origin of the Work and reproducing the content of the NOTICE file.++   7. Disclaimer of Warranty. Unless required by applicable law or+      agreed to in writing, Licensor provides the Work (and each+      Contributor provides its Contributions) on an "AS IS" BASIS,+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or+      implied, including, without limitation, any warranties or conditions+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A+      PARTICULAR PURPOSE. You are solely responsible for determining the+      appropriateness of using or redistributing the Work and assume any+      risks associated with Your exercise of permissions under this License.++   8. Limitation of Liability. In no event and under no legal theory,+      whether in tort (including negligence), contract, or otherwise,+      unless required by applicable law (such as deliberate and grossly+      negligent acts) or agreed to in writing, shall any Contributor be+      liable to You for damages, including any direct, indirect, special,+      incidental, or consequential damages of any character arising as a+      result of this License or out of the use or inability to use the+      Work (including but not limited to damages for loss of goodwill,+      work stoppage, computer failure or malfunction, or any and all+      other commercial damages or losses), even if such Contributor+      has been advised of the possibility of such damages.++   9. Accepting Warranty or Additional Liability. While redistributing+      the Work or Derivative Works thereof, You may choose to offer,+      and charge a fee for, acceptance of support, warranty, indemnity,+      or other liability obligations and/or rights consistent with this+      License. However, in accepting such obligations, You may act only+      on Your own behalf and on Your sole responsibility, not on behalf+      of any other Contributor, and only if You agree to indemnify,+      defend, and hold each Contributor harmless for any liability+      incurred by, or claims asserted against, such Contributor by reason+      of your accepting any such warranty or additional liability.++   END OF TERMS AND CONDITIONS++   APPENDIX: How to apply the Apache License to your work.++      To apply the Apache License to your work, attach the following+      boilerplate notice, with the fields enclosed by brackets "[]"+      replaced with your own identifying information. (Don't include+      the brackets!)  The text should be enclosed in the appropriate+      comment syntax for the file format. We also recommend that a+      file or class name and description of purpose be included on the+      same "printed page" as the copyright notice for easier+      identification within third-party archives.++   Copyright [yyyy] [name of copyright owner]++   Licensed under the Apache License, Version 2.0 (the "License");+   you may not use this file except in compliance with the License.+   You may obtain a copy of the License at++       http://www.apache.org/licenses/LICENSE-2.0++   Unless required by applicable law or agreed to in writing, software+   distributed under the License is distributed on an "AS IS" BASIS,+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+   See the License for the specific language governing permissions and+   limitations under the License.
+ test/community/README.md view
@@ -0,0 +1,33 @@+# The community test suite++These files are a verbatim copy of the `suites/` directory of+[json-logic/compat-tables](https://github.com/json-logic/compat-tables) at+commit `f70933b8d9ef363afc73af6f6e12e9161fedce17` (2026-10-19), licensed under+the Apache License 2.0 (`LICENSE` in this directory). `index.json` lists the+files in the order the compatibility tables use.++The suite has 1,138 cases. Its first file, `compatible.json`, is the classic+suite (the same 278 rules as `test/tests.json`). The rest describe the+**JSON Logic 2.0 dialect** implemented by json-logic-engine: new operators+(`val`, `??`, `exists`, `try`, `throw`, ...), `NaN` as an error rather than a+value, variadic `/` and `-`, chained comparisons, and lazily evaluated+comparison chains. jsonlogic-hs implements the classic dialect of+json-logic-js 2.0.5, so most of those cases are not expected to pass, and the+gaps are a statement of scope, not bugs (see `docs/COMPATIBILITY.md`).++They are used twice:++- **As a measure.** `cabal test jsonlogic-hs-community` scores every case the+  way the compatibility tables score an implementation, in both arity modes,+  and compares the per-file pass counts with `expected.json`. Any change,+  better or worse, fails the run until `expected.json` is updated in the same+  commit, so the score can only move on purpose. Regenerate it with+  `cabal run jsonlogic-hs-community -- --write`.+- **As more outliers.** `differential/outliers.js` runs every rule of the suite+  through json-logic-js and records its answers in `test/outliers.jsonl`+  (family `community`), so lenient mode is held to the reference on all 1,138+  rules, including the ones written for the other dialect.++To update the suite, copy a newer `suites/` here, record the commit above,+regenerate `test/outliers.jsonl` and `expected.json`, and update+`fixtures.sha256`.
+ test/community/additional.json view
@@ -0,0 +1,92 @@+[+    "# These are some tests from https://github.com/TotalTechGeek/json-logic-engine/commit/9125e91b5137938a8319de1103b0ebc5819e54e1",+    {+        "description": "Rule array with val",+        "rule": [+            1,+            {+                "val": "x"+            },+            3+        ],+        "data": {+            "x": 2+        },+        "result": [+            1,+            2,+            3+        ]+    },+    {+        "description": "If Operator fetching both condition and consequent from data",+        "rule": {+            "if": [+                {+                    "val": "x"+                },+                [+                    {+                        "val": "y"+                    }+                ],+                99+            ]+        },+        "data": {+            "x": true,+            "y": 42+        },+        "result": [+            42+        ]+    },+    {+        "description": "Reduce Operator with val",+        "rule": {+            "reduce": [+                {+                    "val": "integers"+                },+                {+                    "+": [+                        {+                            "val": "current"+                        },+                        {+                            "val": "accumulator"+                        }+                    ]+                },+                {+                    "val": "start_with"+                }+            ]+        },+        "data": {+            "integers": [+                1,+                2,+                3,+                4+            ],+            "start_with": 59+        },+        "result": 69+    },+    {+        "description": "Simple Inlineable Val Chained",+        "rule": {+            "val": {+                "cat": [+                    "te",+                    "st"+                ]+            }+        },+        "data": {+            "test": 1+        },+        "result": 1+    }+]
+ test/community/arithmetic/divide.extra.json view
@@ -0,0 +1,21 @@+[+    {+        "description": "Divide with dynamic empty array",+        "rule": { "/": { "preserve": [] } },+        "error": { "type": "Invalid Arguments" },+        "data": null+    },+    {+        "description": "Divide with dynamic unary array",+        "rule": { "/": { "preserve": [2] } },+        "result": 0.5,+        "decimal": true,+        "data": null+    },+    {+        "description": "Divide with dynamic array",+        "rule": { "/": { "preserve": [8, 2] } },+        "result": 4,+        "data": null+    }+]
+ test/community/arithmetic/divide.json view
@@ -0,0 +1,193 @@+[+    "# Collection of Divide Operator Tests",+    {+        "description": "Divide",+        "rule": { "/": [4, 2] },+        "result": 2,+        "data": null+    },+    {+        "description": "Divide to Decimal",+        "rule": { "/": [2, 4] },+        "result": 0.5,+        "data": null,+        "decimal": true+    },+    {+        "description": "Divide with Multiple Operands",+        "rule": { "/": [8, 2, 2] },+        "result": 2,+        "data": null+    },+    {+        "description": "Divide with Multiple Operands (2)",+        "rule": { "/": [2, 2, 1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide with Negative Numbers",+        "rule": { "/": [-1, 2] },+        "result": -0.5,+        "data": null,+        "decimal": true+    },+    {+        "description": "Divide with Strings",+        "rule": { "/": ["8", "2", "2"] },+        "result": 2,+        "data": null+    },+    {+        "description": "Divide with Booleans",+        "rule": { "/": [false, true] },+        "result": 0,+        "data": null+    },+    {+        "description": "Divide with Multiple Value Types",+        "rule": { "/": ["8", true, 2] },+        "result": 4,+        "data": null+    },+    {+        "description": "Divide with Multiple Value Types (2)",+        "rule": { "/": ["1", 1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide with Single Operand (Number)",+        "rule": { "/": [1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide with zero operands is an error",+        "rule": { "/": [] },+        "error": { "type": "Invalid Arguments" },+        "data": null+    },+    {+        "description": "Divide with Single Operand, Direct (Number)",+        "rule": { "/": 1 },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide with Single Operand, Direct (0)",+        "rule": { "/": 0 },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand (Number)",+        "rule": { "/": [1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand (Negative Number)",+        "rule": { "/": [-1] },+        "result": -1,+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (Number)",+        "rule": { "/": 1 },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (2)",+        "rule": { "/": 2 },+        "result": 0.5,+        "decimal": true,+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (0)",+        "rule": { "/": 0 },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand (String)",+        "rule": { "/": ["1"] },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (Negative Number String)",+        "rule": { "/": "-1" },+        "result": -1,+        "data": null+    },++    {+        "description": "Divide Operator with Single Operand, Direct (String 0)",+        "rule": { "/": "0" },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (true)",+        "rule": { "/": true },+        "result": 1,+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (false)",+        "rule": { "/": false },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide Operator with Single Operand, Direct (Empty String)",+        "rule": { "/": "" },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide Operator with a Single Operand, Direct (null)",+        "rule": { "/": null },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide with val",+        "rule": { "/": [{ "val": "x" }, { "val": "y" }] },+        "data": { "x": 8, "y": 2 },+        "result": 4+    },+    {+        "description": "Divide by Zero",+        "rule": { "/": [0, 0] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide with String produces NaN",+        "rule": { "/": [1, "a"] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Divide with Array produces NaN",+        "rule": { "/": [1, [1]] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Any division by zero should return NaN",+        "rule": { "/": [1, 0] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Any division by zero should return NaN (2)",+        "rule": { "/": [8, 2, 0] },+        "error": { "type": "NaN" },+        "data": null+    }+]
+ test/community/arithmetic/minus.extra.json view
@@ -0,0 +1,20 @@+[+    {+        "description": "Minus with dynamic empty array",+        "rule": { "-": { "preserve": [] }},+        "error": { "type": "Invalid Arguments" },+        "data": null+    },+    {+        "description": "Minus with dynamic unary array",+        "rule": { "-": { "preserve": [7] }},+        "result": -7,+        "data": null+    },+    {+        "description": "Minus with dynamic array",+        "rule": { "-": { "preserve": [7, 8] }},+        "result": -1,+        "data": null+    }+]
+ test/community/arithmetic/minus.json view
@@ -0,0 +1,136 @@+[+    "# Collection of Minus Operator Tests",+    {+        "description": "Subtraction",+        "rule": { "-": [1, 2] },+        "result": -1,+        "data": null+    },+    {+        "description": "Subtraction (2)",+        "rule": { "-": [5, 12] },+        "result": -7,+        "data": null+    },+    {+        "description": "Subtraction with Multiple Operands",+        "rule": { "-": [1, 2, 3, 4] },+        "result": -8,+        "data": null+    },+    {+        "description": "Subtraction with Negative Numbers",+        "rule": { "-": [-1, 0, 5] },+        "result": -6,+        "data": null+    },+    {+        "description": "Subtraction with Strings",+        "rule": { "-": ["1", "2", "3"] },+        "result": -4,+        "data": null+    },+    {+        "description": "Subtraction with Booleans",+        "rule": { "-": [true, false, true] },+        "result": 0,+        "data": null+    },+    {+        "description": "Subtraction with Multiple Value Types",+        "rule": { "-": [1, "2", 3, "4", "", true, false, null] },+        "result": -9,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand (Number)",+        "rule": { "-": [1] },+        "result": -1,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand (Negative Number)",+        "rule": { "-": [-1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Minus with zero operands is an error",+        "rule": { "-": [] },+        "error": { "type": "Invalid Arguments" },+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand, Direct (Number)",+        "rule": { "-": 1 },+        "result": -1,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand, Direct (0)",+        "rule": { "-": 0 },+        "result": 0,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand (String)",+        "rule": { "-": ["1"] },+        "result": -1,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand, Direct (Negative Number String)",+        "rule": { "-": "-1" },+        "result": 1,+        "data": null+    },++    {+        "description": "Minus Operator with Single Operand, Direct (String 0)",+        "rule": { "-": "0" },+        "result": 0,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand, Direct (true)",+        "rule": { "-": true },+        "result": -1,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand, Direct (false)",+        "rule": { "-": false },+        "result": 0,+        "data": null+    },+    {+        "description": "Minus Operator with Single Operand, Direct (Empty String)",+        "rule": { "-": "" },+        "result": 0,+        "data": null+    },+    {+        "description": "Minus Operator with a Single Operand, Direct (null)",+        "rule": { "-": null },+        "result": 0,+        "data": null+    },+    {+        "description": "Subtraction with val",+        "rule": { "-": [{ "val": "x" }, { "val": "y" }] },+        "data": { "x": 1, "y": 2 },+        "result": -1+    },+    {+        "description": "Subtraction with string produces NaN",+        "rule": { "-": ["Hey", 1] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Subtraction with Array produces NaN",+        "rule": { "-": [[1], 1] },+        "error": { "type": "NaN" },+        "data": null+    }+]
+ test/community/arithmetic/modulo.extra.json view
@@ -0,0 +1,14 @@+[+    {+        "description": "Modulo with dynamic array invalid",+        "rule": { "%": { "preserve": [] } },+        "error": { "type": "Invalid Arguments" },+        "data": null+    },+    {+        "description": "Modulo with dynamic array invalid",+        "rule": { "%": { "preserve": [1] } },+        "error": { "type": "Invalid Arguments" },+        "data": null+    }+]
+ test/community/arithmetic/modulo.json view
@@ -0,0 +1,191 @@+[+    "# Collection of Modulo Operator Tests",+    {+        "description": "Modulo",+        "rule": { "%": [4, 2] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo (2)",+        "rule": { "%": [2, 2] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo (3)",+        "rule": { "%": [3, 2] },+        "result": 1,+        "data": null+    },+    {+        "description": "Modulo with a Decimal Operand",+        "rule": { "%": [1, 0.5] },+        "result": 0,+        "data": null,+        "decimal": true+    },+    {+        "description": "Modulo with Multiple Operands",+        "rule": { "%": [8, 6, 3] },+        "result": 2,+        "data": null+    },+    {+        "description": "Modulo with Multiple Operands (2)",+        "rule": { "%": [2, 2, 1] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo with Negative Numbers",+        "rule": { "%": [-1, 2] },+        "result": -1,+        "data": null+    },+    {+        "description": "Modulo with Strings",+        "rule": { "%": ["8", "2"] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo with Booleans",+        "rule": { "%": [false, true] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo with Multiple Value Types",+        "rule": { "%": ["8", 3, true] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo with Multiple Value Types (2)",+        "rule": { "%": ["1", 1] },+        "result": 0,+        "data": null+    },+    {+        "description": "Modulo with Single Operand (Number)",+        "rule": { "%": [1] },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo with Single Operand, Direct (Number)",+        "rule": { "%": 1 },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo with Single Operand, Direct (0)",+        "rule": { "%": 0 },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand (Number)",+        "rule": { "%": [1] },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand (Negative Number)",+        "rule": { "%": [-1] },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo with zero operands is an error",+        "rule": { "%": [] },+        "error": { "type": "Invalid Arguments" },+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand, Direct (Number)",+        "rule": { "%": 1 },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand, Direct (0)",+        "rule": { "%": 0 },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand (String)",+        "rule": { "%": ["1"] },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand, Direct (Negative Number String)",+        "rule": { "%": "-1" },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },++    {+        "description": "Modulo Operator with Single Operand, Direct (String 0)",+        "rule": { "%": "0" },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand, Direct (true)",+        "rule": { "%": true },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand, Direct (false)",+        "rule": { "%": false },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with Single Operand, Direct (Empty String)",+        "rule": { "%": "" },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo Operator with a Single Operand, Direct (null)",+        "rule": { "%": null },+        "error": { "type": "Invalid Arguments"},+        "data": null+    },+    {+        "description": "Modulo with val",+        "rule": { "%": [{ "val": "x" }, { "val": "y" }] },+        "data": { "x": 11, "y": 6 },+        "result": 5+    },+    {+        "description": "Modulo with string produces NaN",+        "rule": { "%": ["Hey", 1] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Modulo with array produces NaN",+        "rule": { "%": [[1], 1] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Modulo with a negative dividend",+        "rule": { "%": [-8, 3] },+        "result": -2,+        "data": null+    },+    { +        "description": "Modulo with a negative divisor",+        "rule": { "%": [8, -3] },+        "result": 2,+        "data": null+    }+]
+ test/community/arithmetic/multiply.extra.json view
@@ -0,0 +1,20 @@+[+    {+        "description": "Multiply with dynamic empty array",+        "rule": { "*": { "preserve": [] } },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply with dynamic unary array",+        "rule": { "*": { "preserve": [2] } },+        "result": 2,+        "data": null+    },+    {+        "description": "Multiply with dynamic array",+        "rule": { "*": { "preserve": [8, 2] } },+        "result": 16,+        "data": null+    }+]
+ test/community/arithmetic/multiply.json view
@@ -0,0 +1,172 @@+[+    "# Collection of Multiply Operator Tests",+    {+        "description": "Multiply",+        "rule": { "*": [3, 2] },+        "result": 6,+        "data": null+    },+    {+        "description": "Multiply with Multiple Operands",+        "rule": { "*": [5, 10, 2] },+        "result": 100,+        "data": null+    },+    {+        "description": "Multiply with Multiple Operands (2)",+        "rule": { "*": [2, 2, 2] },+        "result": 8,+        "data": null+    },+    {+        "description": "Multiply with Negative Numbers",+        "rule": { "*": [-1, 2, 5] },+        "result": -10,+        "data": null+    },+    {+        "description": "Multiply with Strings",+        "rule": { "*": ["1", "2", "3"] },+        "result": 6,+        "data": null+    },+    {+        "description": "Multiply with Booleans",+        "rule": { "*": [true, false, true] },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply with Multiple Value Types",+        "rule": { "*": [1, "2", 3, "4", true] },+        "result": 24,+        "data": null+    },+    {+        "description": "Multiply with Multiple Value Types (2)",+        "rule": { "*": [1, "1"] },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply with Multiple Value Types (3)",+        "rule": { "*": ["1", null] },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply with Single Operand (Number)",+        "rule": { "*": [1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply with Single Operand, Direct (Number)",+        "rule": { "*": 1 },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply with Single Operand, Direct (0)",+        "rule": { "*": 0 },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand (Number)",+        "rule": { "*": [1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand (Negative Number)",+        "rule": { "*": [-1] },+        "result": -1,+        "data": null+    },+    {+        "description": "Multiply with zero operands is 1 (Product)",+        "rule": { "*": [] },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand, Direct (Number)",+        "rule": { "*": 1 },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand, Direct (0)",+        "rule": { "*": 0 },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand (String)",+        "rule": { "*": ["1"] },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand, Direct (Negative Number String)",+        "rule": { "*": "-1" },+        "result": -1,+        "data": null+    },++    {+        "description": "Multiply Operator with Single Operand, Direct (String 0)",+        "rule": { "*": "0" },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand, Direct (true)",+        "rule": { "*": true },+        "result": 1,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand, Direct (false)",+        "rule": { "*": false },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply Operator with Single Operand, Direct (Empty String)",+        "rule": { "*": "" },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply Operator with a Single Operand, Direct (null)",+        "rule": { "*": null },+        "result": 0,+        "data": null+    },+    {+        "description": "Multiply with val",+        "rule": { "*": [{ "val": "x" }, { "val": "y" }] },+        "data": { "x": 8, "y": 2 },+        "result": 16+    },+    {+        "description": "Multiply with string produces NaN",+        "rule": { "*": ["Hey", 1] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Multiply with a single string produces NaN",+        "rule": { "*": ["Hey"] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Multiply with Array produces NaN",+        "rule": { "*": [[1], 1] },+        "error": { "type": "NaN" },+        "data": null+    }+]
+ test/community/arithmetic/plus.extra.json view
@@ -0,0 +1,20 @@+[+    {+        "description": "Plus operator with empty array dynamic",+        "rule": { "+": { "preserve": [] } },+        "result": 0,+        "data": null+    },+    {+        "description": "Plus operator with unary array dynamic",+        "rule": { "+": { "preserve": [7] } },+        "result": 7,+        "data": null+    },+    {+        "description": "Plus operator with array dynamic",+        "rule": { "+": { "preserve": [7, 8] } },+        "result": 15,+        "data": null+    }+]
+ test/community/arithmetic/plus.json view
@@ -0,0 +1,198 @@+[+    "# Collection of Plus Operator Tests",+    {+        "description": "Addition",+        "rule": { "+": [1, 2] },+        "result": 3,+        "data": null+    },+    {+        "description": "Addition (2)",+        "rule": { "+": [5, 12] },+        "result": 17,+        "data": null+    },+    {+        "description": "Addition with Multiple Operands",+        "rule": { "+": [1, 2, 3, 4] },+        "result": 10,+        "data": null+    },+    {+        "description": "Addition with Negative Numbers",+        "rule": { "+": [-1, 0, 5] },+        "result": 4,+        "data": null+    },+    {+        "description": "Addition with Strings",+        "rule": { "+": ["1", "2", "3"] },+        "result": 6,+        "data": null+    },+    {+        "description": "Addition with Booleans",+        "rule": { "+": [true, false, true] },+        "result": 2,+        "data": null+    },+    {+        "description": "Addition with Multiple Value Types",+        "rule": { "+": [1, "2", 3, "4", "", true, false, null] },+        "result": 11,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand (Number)",+        "rule": { "+": [1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand (Negative Number)",+        "rule": { "+": [-1] },+        "result": -1,+        "data": null+    },+    {+        "description": "Plus Operator with zero operands is zero",+        "rule": { "+": [] },+        "result": 0,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (Number)",+        "rule": { "+": 1 },+        "result": 1,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (0)",+        "rule": { "+": 0 },+        "result": 0,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand (String)",+        "rule": { "+": ["1"] },+        "result": 1,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (Negative Number String)",+        "rule": { "+": "-1" },+        "result": -1,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (String Decimal)",+        "rule": { "+": "1.5" },+        "result": 1.5,+        "data": null,+        "decimal": true+    },+    {+        "description": "Plus Operator with Single Operand, Direct (String Negative Decimal)",+        "rule": { "+": "-1.5" },+        "result": -1.5,+        "data": null,+        "decimal": true+    },+    {+        "description": "Plus Operator with Single Operand, Direct (String 0.5)",+        "rule": { "+": "0.5" },+        "result": 0.5,+        "data": null,+        "decimal": true+    },+    {+        "description": "Plus Operator with Single Operand, Direct (String 1e2)",+        "rule": { "+": "1e2" },+        "result": 100,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (String 0)",+        "rule": { "+": "0" },+        "result": 0,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (true)",+        "rule": { "+": true },+        "result": 1,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (false)",+        "rule": { "+": false },+        "result": 0,+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct (Empty String)",+        "rule": { "+": "" },+        "result": 0,+        "data": null+    },+    {+        "description": "Plus Operator with a Single Operand, Direct (null)",+        "rule": { "+": null },+        "result": 0,+        "data": null+    },+    {+        "description": "Addition with val",+        "rule": { "+": [{ "val": "x" }, { "val": "y" }] },+        "result": 3,+        "data": { "x": 1, "y": 2 }+    },+    {+        "description": "Addition with string produces NaN",+        "rule": { "+": ["Hey", 1] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Addition with Array produces NaN",+        "rule": { "+": [[1], 1] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Addition with Array from context produces NaN",+        "rule": { "+": [{ "val": "x" }, 1] },+        "error": { "type": "NaN" },+        "data": { "x": [1] }+    },+    {+        "description": "Addition with Object produces NaN",+        "rule": { "+": [{ "val": "x" }, 1] },+        "error": { "type": "NaN" },+        "data": { "x": {} }+    },+    {+        "description": "Plus Operator with Single Operand, Invalid String Produces NaN",+        "rule": { "+": "Hello" },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Array Input Produces NaN",+        "rule": { "+": [[1]] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Object Input Produces NaN",+        "rule": { "+": [{}] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Plus Operator with Single Operand, Direct Object Input Produces NaN",+        "rule": { "+": {} },+        "error": { "type": "NaN" },+        "data": null+    }+]
+ test/community/array/all.json view
@@ -0,0 +1,174 @@+[+    "Basic Operations",+    {+        "description": "All elements are positive",+        "rule": {+            "all": [+                {"var": "numbers"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [1, 2, 3, 4]},+        "result": true+    },+    {+        "description": "Not all elements are positive",+        "rule": {+            "all": [+                {"var": "numbers"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [-1, 0, 1, 2]},+        "result": false+    },+    "Object Operations",+    {+        "description": "All objects meet age requirement",+        "rule": {+            "all": [+                {"var": "people"},+                {">=": [{"var": "age"}, 21]}+            ]+        },+        "data": {+            "people": [+                {"name": "John", "age": 25},+                {"name": "Jane", "age": 22},+                {"name": "Bob", "age": 30}+            ]+        },+        "result": true+    },+    {+        "description": "Not all objects meet criteria",+        "rule": {+            "all": [+                {"var": "items"},+                {">=": [{"var": "stock"}, 10]}+            ]+        },+        "data": {+            "items": [+                {"id": 1, "stock": 15},+                {"id": 2, "stock": 8},+                {"id": 3, "stock": 12}+            ]+        },+        "result": false+    },+    "Edge Cases",+    {+        "description": "Empty array returns false",+        "rule": {+            "all": [+                {"var": "empty"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"empty": []},+        "result": false+    },+    {+        "description": "Null array should throw",+        "rule": {+            "all": [+                null,+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": null,+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Missing array returns error",+        "rule": {+            "all": [+                {"var": "missing"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {},+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Null predicate returns false",+        "rule": {+            "all": [+                {"var": "numbers"},+                null+            ]+        },+        "data": {"numbers": [1, 2, 3]},+        "result": false+    },+    "Complex Predicates",+    {+        "description": "All with compound conditions",+        "rule": {+            "all": [+                {"var": "data"},+                {"and": [+                    {">=": [{"var": "score"}, 0]},+                    {"<=": [{"var": "score"}, 100]}+                ]}+            ]+        },+        "data": {+            "data": [+                {"score": 75},+                {"score": 82},+                {"score": 95}+            ]+        },+        "result": true+    },+    {+        "description": "All with nested array operations",+        "rule": {+            "all": [+                {"var": "classes"},+                {"all": [+                    {"var": "students"},+                    {">=": [{"var": "grade"}, 60]}+                ]}+            ]+        },+        "data": {+            "classes": [+                {"students": [{"grade": 80}, {"grade": 75}]},+                {"students": [{"grade": 90}, {"grade": 85}]}+            ]+        },+        "result": true+    },+    "Type Coercion",+    {+        "description": "All with mixed types coercion to boolean",+        "rule": {+            "all": [+                {"var": "values"},+                {"!!": [{"var": ""}]}+            ]+        },+        "data": {"values": [1, "hello", true, [1], {"x": 1}]},+        "result": true+    },+    {+        "description": "All with string comparison",+        "rule": {+            "all": [+                {"var": "words"},+                {">": [{"var": "length"}, 3]}+            ]+        },+        "data": {+            "words": [+                {"text": "hello", "length": 5},+                {"text": "world", "length": 5},+                {"text": "test", "length": 4}+            ]+        },+        "result": true+    }+]
+ test/community/array/filter.json view
@@ -0,0 +1,173 @@+[+    "Basic Operations",+    {+        "description": "Filter positive numbers",+        "rule": {+            "filter": [+                {"var": "numbers"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [-2, -1, 0, 1, 2]},+        "result": [1, 2]+    },+    {+        "description": "Filter even numbers",+        "rule": {+            "filter": [+                {"var": "numbers"},+                {"==": [0, {"%": [{"var": ""}, 2]}]}+            ]+        },+        "data": {"numbers": [1, 2, 3, 4, 5, 6]},+        "result": [2, 4, 6]+    },+    "Object Operations",+    {+        "description": "Filter objects by property value",+        "rule": {+            "filter": [+                {"var": "people"},+                {">": [{"var": "age"}, 21]}+            ]+        },+        "data": {+            "people": [+                {"name": "John", "age": 20},+                {"name": "Jane", "age": 25},+                {"name": "Bob", "age": 30}+            ]+        },+        "result": [+            {"name": "Jane", "age": 25},+            {"name": "Bob", "age": 30}+        ]+    },+    {+        "description": "Filter with multiple conditions",+        "rule": {+            "filter": [+                {"var": "items"},+                {"and": [+                    {">": [{"var": "price"}, 10]},+                    {"<": [{"var": "price"}, 50]}+                ]}+            ]+        },+        "data": {+            "items": [+                {"id": 1, "price": 5},+                {"id": 2, "price": 25},+                {"id": 3, "price": 75}+            ]+        },+        "result": [{"id": 2, "price": 25}]+    },+    "Edge Cases",+    {+        "description": "Filter on null array returns empty array",+        "rule": {+            "filter": [+                {"var": "missing"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {},+        "result": []+    },+    {+        "description": "Filter on empty array returns empty array",+        "rule": {+            "filter": [+                {"var": "empty"},+                {"==": [{"var": ""}, 1]}+            ]+        },+        "data": {"empty": []},+        "result": []+    },+    {+        "description": "Filter with null predicate should throw",+        "rule": {+            "filter": [{"var": "numbers"}, null]+        },+        "data": {"numbers": [1, 2, 3]},+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Filter with null array should throw",+        "rule": {+            "filter": [null, {">": [{"var": ""}, 0]}]+        },+        "data": null,+        "error": {"type": "Invalid Arguments"}+    },+    "Complex Predicates",+    {+        "description": "Filter with nested logic",+        "rule": {+            "filter": [+                {"var": "data"},+                {"or": [+                    {"==": [{"var": "type"}, "A"]},+                    {">": [{"var": "value"}, 100]}+                ]}+            ]+        },+        "data": {+            "data": [+                {"type": "A", "value": 50},+                {"type": "B", "value": 150},+                {"type": "C", "value": 75}+            ]+        },+        "result": [+            {"type": "A", "value": 50},+            {"type": "B", "value": 150}+        ]+    },+    {+        "description": "Filter with string operations",+        "rule": {+            "filter": [+                {"var": "words"},+                {"in": ["a", {"var": ""}]}+            ]+        },+        "data": {"words": ["apple", "banana", "cherry"]},+        "result": ["apple", "banana"]+    },+    "Type Coercion",+    {+        "description": "Filter with type coercion",+        "rule": {+            "filter": [+                {"var": "mixed"},+                {"!!": [{"var": ""}]}+            ]+        },+        "data": {"mixed": [0, 1, "", "hello", [], [1]]},+        "result": [1, "hello", [1]]+    },+    "Path Resolution",+    {+        "description": "Filter with deep path access",+        "rule": {+            "filter": [+                {"var": "users"},+                {"var": "settings.active"}+            ]+        },+        "data": {+            "users": [+                {"name": "John", "settings": {"active": true}},+                {"name": "Jane", "settings": {"active": false}},+                {"name": "Bob", "settings": {"active": true}}+            ]+        },+        "result": [+            {"name": "John", "settings": {"active": true}},+            {"name": "Bob", "settings": {"active": true}}+        ]+    }+]
+ test/community/array/map.json view
@@ -0,0 +1,169 @@+[+    "Basic Operations",+    {+        "description": "Map with identity function",+        "rule": {"map": [{"var": "integers"}, {"var": ""}]},+        "data": {"integers": [1, 2, 3]},+        "result": [1, 2, 3]+    },+    {+        "description": "Map with multiplication",+        "rule": {+            "map": [+                {"var": "integers"},+                {"*": [{"var": ""}, 2]}+            ]+        },+        "data": {"integers": [1, 2, 3]},+        "result": [2, 4, 6]+    },+    "Object Operations",+    {+        "description": "Map extracting single field from objects",+        "rule": {+            "map": [{"var": "desserts"}, {"var": "qty"}]+        },+        "data": {+            "desserts": [+                {"name": "apple", "qty": 1},+                {"name": "brownie", "qty": 2},+                {"name": "cupcake", "qty": 3}+            ]+        },+        "result": [1, 2, 3]+    },+    {+        "description": "Map with complex object transformation",+        "rule": {+            "map": [+                {"var": "items"},+                {"+": [{"var": "price"}, {"var": "tax"}]}+            ]+        },+        "data": {+            "items": [+                {"price": 100, "tax": 10},+                {"price": 200, "tax": 20}+            ]+        },+        "result": [110, 220]+    },+    "Edge Cases",+    {+        "description": "Map on null array returns empty array",+        "rule": {+            "map": [+                {"var": "missing"},+                {"*": [{"var": ""}, 2]}+            ]+        },+        "data": null,+        "result": []+    },+    {+        "description": "Map on empty array returns empty array",+        "rule": {+            "map": [+                {"var": "empty"},+                {"*": [{"var": ""}, 2]}+            ]+        },+        "data": {"empty": []},+        "result": []+    },+    {+        "description": "Map with null mapper should throw",+        "rule": {"map": [{"var": "integers"}, null]},+        "data": {"integers": [1, 2, 3]},+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Map with null array should throw",+        "rule": {"map": [null, {"var": ""}]},+        "data": null,+        "error": {"type": "Invalid Arguments"}+    },+    "Complex Transformations",+    {+        "description": "Map with nested logic operations",+        "rule": {+            "map": [+                {"var": "values"},+                {"if": [+                    {">": [{"var": ""}, 0]},+                    "positive",+                    "negative"+                ]}+            ]+        },+        "data": {"values": [-1, 0, 1]},+        "result": ["negative", "negative", "positive"]+    },+    {+        "description": "Map with string operations",+        "rule": {+            "map": [+                {"var": "names"},+                {"cat": [{"var": ""}, "!!"]}+            ]+        },+        "data": {"names": ["hello", "world"]},+        "result": ["hello!!", "world!!"]+    },+    "Path Resolution",+    {+        "description": "Map with nested path access",+        "rule": {+            "map": [+                {"var": "users"},+                {"var": "profile.name"}+            ]+        },+        "data": {+            "users": [+                {"profile": {"name": "John"}},+                {"profile": {"name": "Jane"}}+            ]+        },+        "result": ["John", "Jane"]+    },+    {+        "description": "Map with missing nested paths",+        "rule": {+            "map": [+                {"var": "users"},+                {"var": "profile.age"}+            ]+        },+        "data": {+            "users": [+                {"profile": {"name": "John"}},+                {"profile": {"name": "Jane"}}+            ]+        },+        "result": [null, null]+    },+    "Type Coercion",+    {+        "description": "Map with numeric coercion",+        "rule": {+            "map": [+                {"var": "mixed"},+                {"+": [{"var": ""}, 1]}+            ]+        },+        "data": {"mixed": ["1", 2, "3"]},+        "result": [2, 3, 4]+    },+    {+        "description": "Map with boolean coercion",+        "rule": {+            "map": [+                {"var": "values"},+                {"!!": [{"var": ""}]}+            ]+        },+        "data": {"values": [0, 1, "", "hello", [], [1]]},+        "result": [false, true, false, true, false, true]+    }+]
+ test/community/array/merge.json view
@@ -0,0 +1,105 @@+[+    "Basic Operations",+    {+        "description": "Merge two arrays",+        "rule": {+            "merge": [+                [1, 2],+                [3, 4]+            ]+        },+        "data": null,+        "result": [1, 2, 3, 4]+    },+    {+        "description": "Merge array variables",+        "rule": {+            "merge": [+                {"var": "arr1"},+                {"var": "arr2"}+            ]+        },+        "data": {+            "arr1": [1, 2],+            "arr2": [3, 4]+        },+        "result": [1, 2, 3, 4]+    },+    "Edge Cases",+    {+        "description": "Merge with empty array",+        "rule": {+            "merge": [+                [1, 2],+                []+            ]+        },+        "data": null,+        "result": [1, 2]+    },+    {+        "description": "Merge multiple empty arrays",+        "rule": {+            "merge": [[], [], []]+        },+        "data": null,+        "result": []+    },+    {+        "description": "Merge with null should be ignored",+        "rule": {+            "merge": [+                [1, 2],+                null,+                [3, 4]+            ]+        },+        "data": null,+        "result": [1, 2, null, 3, 4]+    },+    {+        "description": "Merge without arguments returns empty array",+        "rule": {+            "merge": []+        },+        "data": null,+        "result": []+    },+    "Complex Operations",+    {+        "description": "Merge arrays of objects",+        "rule": {+            "merge": [+                {"var": "users1"},+                {"var": "users2"}+            ]+        },+        "data": {+            "users1": [{"id": 1}, {"id": 2}],+            "users2": [{"id": 3}, {"id": 4}]+        },+        "result": [+            {"id": 1},+            {"id": 2},+            {"id": 3},+            {"id": 4}+        ]+    },+    {+        "description": "Merge with filtered arrays",+        "rule": {+            "merge": [+                {"filter": [+                    {"var": "numbers"},+                    {"<": [{"var": ""}, 0]}+                ]},+                {"filter": [+                    {"var": "numbers"},+                    {">": [{"var": ""}, 0]}+                ]}+            ]+        },+        "data": {"numbers": [-2, -1, 0, 1, 2]},+        "result": [-2, -1, 1, 2]+    }+]
+ test/community/array/none.json view
@@ -0,0 +1,180 @@+[+    "Basic Operations",+    {+        "description": "None of the elements are negative",+        "rule": {+            "none": [+                {"var": "numbers"},+                {"<": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [1, 2, 3, 4]},+        "result": true+    },+    {+        "description": "Some elements are negative (none returns false)",+        "rule": {+            "none": [+                {"var": "numbers"},+                {"<": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [-1, 0, 1, 2]},+        "result": false+    },+    "Object Operations",+    {+        "description": "None of the objects are underage",+        "rule": {+            "none": [+                {"var": "people"},+                {"<": [{"var": "age"}, 18]}+            ]+        },+        "data": {+            "people": [+                {"name": "John", "age": 25},+                {"name": "Jane", "age": 22},+                {"name": "Bob", "age": 19}+            ]+        },+        "result": true+    },+    {+        "description": "None of items are out of stock",+        "rule": {+            "none": [+                {"var": "items"},+                {"==": [{"var": "stock"}, 0]}+            ]+        },+        "data": {+            "items": [+                {"id": 1, "stock": 5},+                {"id": 2, "stock": 15},+                {"id": 3, "stock": 3}+            ]+        },+        "result": true+    },+    "Edge Cases",+    {+        "description": "Empty array returns true",+        "rule": {+            "none": [+                {"var": "empty"},+                {"<": [{"var": ""}, 0]}+            ]+        },+        "data": {"empty": []},+        "result": true+    },+    {+        "description": "Null array should throw",+        "rule": {+            "none": [+                null,+                {"<": [{"var": ""}, 0]}+            ]+        },+        "data": null,+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Missing array returns error",+        "rule": {+            "none": [+                {"var": "missing"},+                {"<": [{"var": ""}, 0]}+            ]+        },+        "data": {},+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Null predicate returns true",+        "rule": {+            "none": [+                {"var": "numbers"},+                null+            ]+        },+        "data": {"numbers": [1, 2, 3]},+        "result": true+    },+    "Complex Predicates",+    {+        "description": "None with compound conditions",+        "rule": {+            "none": [+                {"var": "scores"},+                {"and": [+                    {"<": [{"var": ""}, 60]},+                    {">": [{"var": ""}, 0]}+                ]}+            ]+        },+        "data": {+            "scores": [75, 85, 95, 65]+        },+        "result": true+    },+    {+        "description": "None with nested array operations",+        "rule": {+            "none": [+                {"var": "classes"},+                {"none": [+                    {"var": "students"},+                    {"==": [{"var": "grade"}, "F"]}+                ]}+            ]+        },+        "data": {+            "classes": [+                {"students": [{"grade": "B"}, {"grade": "C"}]},+                {"students": [{"grade": "A"}, {"grade": "B"}]}+            ]+        },+        "result": false+    },+    "Type Coercion",+    {+        "description": "None with mixed types coercion to boolean",+        "rule": {+            "none": [+                {"var": "values"},+                {"!!": [{"var": ""}]}+            ]+        },+        "data": {"values": [0, "", false, null]},+        "result": true+    },+    {+        "description": "None with string operations",+        "rule": {+            "none": [+                {"var": "words"},+                {"in": ["x", {"var": ""}]}+            ]+        },+        "data": {"words": ["hello", "world", "test"]},+        "result": true+    },+    {+        "description": "None with zero length strings",+        "rule": {+            "none": [+                {"var": "strings"},+                {"==": [{"var": "length"}, 0]}+            ]+        },+        "data": {+            "strings": [+                {"text": "hello", "length": 5},+                {"text": "world", "length": 5}+            ]+        },+        "result": true+    }+]
+ test/community/array/reduce.json view
@@ -0,0 +1,129 @@+[+    "Basic Operations",+    {+        "description": "Reduce array with sum",+        "rule": {+            "reduce": [+                {"var": "numbers"},+                {"+": [{"var": "current"}, {"var": "accumulator"}]},+                0+            ]+        },+        "data": {"numbers": [1, 2, 3, 4]},+        "result": 10+    },+    {+        "description": "Reduce array with multiplication",+        "rule": {+            "reduce": [+                {"var": "numbers"},+                {"*": [{"var": "current"}, {"var": "accumulator"}]},+                1+            ]+        },+        "data": {"numbers": [1, 2, 3, 4]},+        "result": 24+    },+    "Object Operations",+    {+        "description": "Reduce objects to sum of specific field",+        "rule": {+            "reduce": [+                {"var": "items"},+                {"+": [{"var": "current.price"}, {"var": "accumulator"}]},+                0+            ]+        },+        "data": {+            "items": [+                {"id": 1, "price": 10},+                {"id": 2, "price": 20},+                {"id": 3, "price": 30}+            ]+        },+        "result": 60+    },+    {+        "description": "Reduce to create aggregated object",+        "rule": {+            "reduce": [+                {"var": "transactions"},+                {"+": [{"var": "accumulator"}, {"var": "current.amount"}]},+                0+            ]+        },+        "data": {+            "transactions": [+                {"type": "credit", "amount": 100},+                {"type": "debit", "amount": -50},+                {"type": "credit", "amount": 75}+            ]+        },+        "result": 125+    },+    "Edge Cases",+    {+        "description": "Reduce empty array returns initial value",+        "rule": {+            "reduce": [+                {"var": "empty"},+                {"+": [{"var": "current"}, {"var": "accumulator"}]},+                0+            ]+        },+        "data": {"empty": []},+        "result": 0+    },+    {+        "description": "Reduce null array returns initial value",+        "rule": {+            "reduce": [+                {"var": "missing"},+                {"+": [{"var": "current"}, {"var": "accumulator"}]},+                42+            ]+        },+        "data": {},+        "result": 42+    },+    {+        "description": "Initial value is optional",+        "rule": {+            "reduce": [+                {"var": "numbers"},+                {"+": [{"var": "current"}, {"var": "accumulator"}]}+            ]+        },+        "data": {"numbers": [1, 2, 3]},+        "result": 6+    },+    "Complex Operations",+    {+        "description": "Reduce with string concatenation",+        "rule": {+            "reduce": [+                {"var": "words"},+                {"cat": [{"var": "accumulator"}, " ", {"var": "current"}]},+                ""+            ]+        },+        "data": {"words": ["Hello", "World", "!"]},+        "result": " Hello World !"+    },+    {+        "description": "Reduce with conditional logic",+        "rule": {+            "reduce": [+                {"var": "numbers"},+                {"if": [+                    {">": [{"var": "current"}, 0]},+                    {"+": [{"var": "accumulator"}, {"var": "current"}]},+                    {"var": "accumulator"}+                ]},+                0+            ]+        },+        "data": {"numbers": [-1, 2, -3, 4]},+        "result": 6+    }+]
+ test/community/array/some.json view
@@ -0,0 +1,175 @@+[+    "Basic Operations",+    {+        "description": "Some elements are positive",+        "rule": {+            "some": [+                {"var": "numbers"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [-2, -1, 0, 1]},+        "result": true+    },+    {+        "description": "No elements are positive",+        "rule": {+            "some": [+                {"var": "numbers"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"numbers": [-3, -2, -1]},+        "result": false+    },+    "Object Operations",+    {+        "description": "Some objects meet age requirement",+        "rule": {+            "some": [+                {"var": "people"},+                {">=": [{"var": "age"}, 21]}+            ]+        },+        "data": {+            "people": [+                {"name": "John", "age": 18},+                {"name": "Jane", "age": 22},+                {"name": "Bob", "age": 17}+            ]+        },+        "result": true+    },+    {+        "description": "Some objects meet stock threshold",+        "rule": {+            "some": [+                {"var": "items"},+                {">": [{"var": "stock"}, 100]}+            ]+        },+        "data": {+            "items": [+                {"id": 1, "stock": 50},+                {"id": 2, "stock": 150},+                {"id": 3, "stock": 75}+            ]+        },+        "result": true+    },+    "Edge Cases",+    {+        "description": "Empty array returns false",+        "rule": {+            "some": [+                {"var": "empty"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {"empty": []},+        "result": false+    },+    {+        "description": "Null array should throw",+        "rule": {+            "some": [+                null,+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": null,+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Missing array returns error",+        "rule": {+            "some": [+                {"var": "missing"},+                {">": [{"var": ""}, 0]}+            ]+        },+        "data": {},+        "error": {"type": "Invalid Arguments"}+    },+    {+        "description": "Null predicate returns false",+        "rule": {+            "some": [+                {"var": "numbers"},+                null+            ]+        },+        "data": {"numbers": [1, 2, 3]},+        "result": false+    },+    "Complex Predicates",+    {+        "description": "Some with compound conditions",+        "rule": {+            "some": [+                {"var": "scores"},+                {"and": [+                    {">=": [{"var": ""}, 90]},+                    {"<=": [{"var": ""}, 100]}+                ]}+            ]+        },+        "data": {+            "scores": [75, 85, 95, 65]+        },+        "result": true+    },+    {+        "description": "Some with nested array operations",+        "rule": {+            "some": [+                {"var": "classes"},+                {"some": [+                    {"var": "students"},+                    {"==": [{"var": "grade"}, "A"]}+                ]}+            ]+        },+        "data": {+            "classes": [+                {"students": [{"grade": "B"}, {"grade": "C"}]},+                {"students": [{"grade": "A"}, {"grade": "B"}]}+            ]+        },+        "result": true+    },+    "Type Coercion",+    {+        "description": "Some with mixed types coercion to boolean",+        "rule": {+            "some": [+                {"var": "values"},+                {"!!": [{"var": ""}]}+            ]+        },+        "data": {"values": [0, "", false, 1, null]},+        "result": true+    },+    {+        "description": "Some with string operations",+        "rule": {+            "some": [+                {"var": "words"},+                {"in": ["a", {"var": ""}]}+            ]+        },+        "data": {"words": ["hello", "world", "apple"]},+        "result": true+    },+    {+        "description": "Some with empty strings",+        "rule": {+            "some": [+                {"var": "strings"},+                {"==": [{"var": ""}, ""]}+            ]+        },+        "data": {"strings": ["hello", "", "world"]},+        "result": true+    }+]
+ test/community/chained.json view
@@ -0,0 +1,68 @@+[+  "# These are tests from https://github.com/orgs/json-logic/discussions/2",+  {+      "description": "Standard Max",+      "rule": { "max": [1, 2, 3] },+      "data": {},+      "result": 3+  },+  {+      "description": "Standard Max, Single Argument Sugared",+      "rule": { "max": 1 },+      "data": {},+      "result": 1+  },+  {+      "description": "Max with Logic Chaining",+      "rule": { "max": { "val": "data" } },+      "data": { "data": [1, 2, 3] },+      "result": 3+  },+  {+      "description": "Cat with Logic Chaining",+      "rule": { "cat": { "merge": [["Hello "], ["World", "!"]] } },+      "data": {},+      "result": "Hello World!"+  },+  {+      "description": "Cat with Logic Chaining (Simple)",+      "rule": { "cat": { "val": "text" } },+      "data": { "text": ["Hello ", "World", "!"] },+      "result": "Hello World!"+  },+  {+      "rule": {+          "max": {+              "map": [{+                  "filter": [+                    { "val": "people" }, +                    { "===": [{ "val": "department" }, "Engineering"] }+                ]}, +                { "val": "salary" }+            ]+          }+      },+      "data": {+          "people": [+            { "name": "Jay Ortiz", "salary": 100414, "department": "Engineering"},+            { "name": "Louisa Hall", "salary": 133601, "department": "Sales"},+            { "name": "Kyle Carlson", "salary": 139803, "department": "Sales"},+            { "name": "Grace Ortiz", "salary": 147068, "department": "Engineering"},+            { "name": "Isabelle Harrington", "salary": 112704, "department": "Marketing"},+            { "name": "Harold Moore", "salary": 125221, "department": "Sales"},+            { "name": "Clarence Schultz", "salary": 127985, "department": "Sales"},+            { "name": "Jesse Keller", "salary": 149212, "department": "Engineering"},+            { "name": "Phillip Holland", "salary": 105888, "department": "Marketing"},+            { "name": "Mason Sullivan", "salary": 147161, "department": "Engineering" }+          ]+      },+      "result": 149212,+      "description": "Max with Logic Chaining (Complex)"+  },+  {+    "description": "Addition Chained w/ Merge",+    "rule": { "+": { "merge": [{ "val": "x" }, { "val": "y" }] }},+    "result": 6,+    "data": { "x": [1, 2], "y": 3 }+  }+]
+ test/community/coalesce.json view
@@ -0,0 +1,93 @@+[+    "Test Specification for ??",+    {+        "description": "Coalesces a string alone",+        "rule": { "??": ["hello"] },+        "data": null,+        "result": "hello"+    },+    {+        "description": "Coalesces a number alone",+        "rule": { "??": [1] },+        "data": null,+        "result": 1+    },+    {+        "description": "Coalesces a boolean alone",+        "rule": { "??": [true] },+        "data": null,+        "result": true+    },+    {+        "description": "Coalesces an object from context alone",+        "rule": { "??": [{ "val": "person" }]},+        "data": { "person": { "name": "John" } },+        "result": { "name": "John" }+    },+    {+        "description": "Empty behavior",+        "rule": { "??": [] },+        "data": null,+        "result": null+    },+    {+        "description": "Coalesces a string with nulls before",+        "rule": { "??": [null, "hello"] },+        "data": null,+        "result": "hello"+    },+    {+        "description": "Coalesces a string with multiple nulls before",+        "rule": { "??": [null, null, null, "hello"] },+        "data": null,+        "result": "hello"+    },+    {+        "description": "Coalesces a string with nulls after",+        "rule": { "??": ["hello", null] },+        "data": null,+        "result": "hello"+    },+    {+        "description": "Coalesces a string with nulls both before and after",+        "rule": { "??": [null, "hello", null] },+        "data": null,+        "result": "hello"+    },+    {+        "description": "Coalesces a number with nulls both before and after",+        "rule": { "??": [null, 1, null] },+        "data": null,+        "result": 1+    },+    {+        "description": "Uses the first non-null value",+        "rule": { "??": [null, 1, "hello"] },+        "data": null,+        "result": 1+    },+    {+        "description": "Uses the first non-null value, even if it is false",+        "rule": { "??": [null, false, "hello"] },+        "data": null,+        "result": false+    },+    {+        "description": "Uses the first non-null value from context",+        "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] },+        "data": { "person": { "name": "John" }, "name": "Jane" },+        "result": "John"+    },+    {+        "description": "Uses the first non-null value from context (with person undefined)",+        "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] },+        "data": { "name": "Jane" },+        "result": "Jane"+    },+    {+        "description": "Uses the first non-null value from context (without any context)",+        "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] },+        "data": {},+        "result": "Unknown Name"+    }+]
+ test/community/comparison/greaterThan.json view
@@ -0,0 +1,212 @@+[+    {+        "description": "> with 3 arguments",+        "rule": { ">": [3, 2, 1] },+        "data": {},+        "result": true+    },+    {+        "description": "> with 3 arguments failing",+        "rule": { ">": [3, 2, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "> is lazily evaluated",+        "rule": { ">": [2, 3, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "> with 1 argument",+        "rule": { ">": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "> with direct argument",+        "rule": { ">": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "> with empty argument",+        "rule": { ">": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "> with (false, true)",+        "rule": { ">": [false, true] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (true, false)",+        "rule": { ">": [true, false] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (false, false)",+        "rule": { ">": [false, false] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (true, true)",+        "rule": { ">": [true, true] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (a, b)",+        "rule": { ">": ["a", "b"] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (b, a)",+        "rule": { ">": ["b", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (a, a)",+        "rule": { ">": ["a", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (2024-01-01, 2024-01-02)",+        "rule": { ">": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (2024-01-02, 2024-01-01)",+        "rule": { ">": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (2024-01-01, 2024-01-01)",+        "rule": { ">": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (2023n, 2024-01-01s)",+        "rule": { ">": [2023, "2024-01-01"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "> with (5s, 3n)",+        "rule": { ">": ["5", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (21s, 3n)",+        "rule": { ">": ["21", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (21n, 3s)",+        "rule": { ">": [21, "3"] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (3s, 21n)",+        "rule": { ">": ["3", 21] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (3n, 21s)",+        "rule": { ">": [3, "21"] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (3n, 3s)",+        "rule": { ">": [3, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (3s, 3n)",+        "rule": { ">": ["3", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (1n, As)",+        "rule": { ">": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "> with (null, 1)",+        "rule": { ">": [null, 1] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (1, null)",+        "rule": { ">": [1, null] },+        "data": {},+        "result": true+    },+    {+        "description": "> with (null, 0)",+        "rule": { ">": [null, 0] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (null, true)",+        "rule": { ">": [null, true] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (1, true)",+        "rule": { ">": [1, true] },+        "data": {},+        "result": false+    },+    {+        "description": "> with (0, false)",+        "rule": { ">": [0, false] },+        "data": {},+        "result": false+    },+    {+        "description": "> with [] and [1]",+        "rule": { ">": [[], [1]] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "> with [1] and 5",+        "rule": { ">": [[1], 5] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "> with {} and 1",+        "rule": { ">": [{}, 1] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "> with 1 and {}",+        "rule": { ">": [1, {}] },+        "data": {},+        "error": { "type": "NaN" }+    }+]
+ test/community/comparison/greaterThanEquals.json view
@@ -0,0 +1,170 @@+[+    {+        "description": ">= with 3 arguments",+        "rule": { ">=": [3, 3, 1] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with 3 arguments failing",+        "rule": { ">=": [3, 3, 4] },+        "data": {},+        "result": false+    },+    {+        "description": ">= is lazily evaluated",+        "rule": { ">=": [2, 3, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": ">= with 1 argument",+        "rule": { ">=": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": ">= with direct argument",+        "rule": { ">=": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": ">= with empty argument",+        "rule": { ">=": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": ">= with (false, true)",+        "rule": { ">=": [false, true] },+        "data": {},+        "result": false+    },+    {+        "description": ">= with (true, false)",+        "rule": { ">=": [true, false] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (false, false)",+        "rule": { ">=": [false, false] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (true, true)",+        "rule": { ">=": [true, true] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (a, b)",+        "rule": { ">=": ["a", "b"] },+        "data": {},+        "result": false+    },+    {+        "description": ">= with (b, a)",+        "rule": { ">=": ["b", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (a, a)",+        "rule": { ">=": ["a", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (2024-01-01, 2024-01-02)",+        "rule": { ">=": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": false+    },+    {+        "description": ">= with (2024-01-02, 2024-01-01)",+        "rule": { ">=": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (2024-01-01, 2024-01-01)",+        "rule": { ">=": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (1n, As)",+        "rule": { ">=": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": ">= with (1n, As)",+        "rule": { ">=": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": ">= with (null, 1)",+        "rule": { ">=": [null, 1] },+        "data": {},+        "result": false+    },+    {+        "description": ">= with (1, null)",+        "rule": { ">=": [1, null] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (null, 0)",+        "rule": { ">=": [null, 0] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (null, true)",+        "rule": { ">=": [null, true] },+        "data": {},+        "result": false+    },+    {+        "description": ">= with (1, true)",+        "rule": { ">=": [1, true] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with (0, false)",+        "rule": { ">=": [0, false] },+        "data": {},+        "result": true+    },+    {+        "description": ">= with [] and [1]",+        "rule": { ">=": [[], [1]] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": ">= with [1] and 5",+        "rule": { ">=": [[1], 5] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": ">= with {} and 1",+        "rule": { ">=": [{}, 1] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": ">= with 1 and {}",+        "rule": { ">=": [1, {}] },+        "data": {},+        "error": { "type": "NaN" }+    }+]
+ test/community/comparison/lessThan.json view
@@ -0,0 +1,272 @@+[+    {+        "description": "< with 3 arguments",+        "rule": { "<": [1, 2, 3] },+        "data": {},+        "result": true+    },+    {+        "description": "< is lazily evaluated",+        "rule": { "<": [3, 2, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "< with 1 argument",+        "rule": { "<": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "< with direct argument",+        "rule": { "<": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "< with empty argument",+        "rule": { "<": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "< with (false, true)",+        "rule": { "<": [false, true] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (true, false)",+        "rule": { "<": [true, false] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (false, false)",+        "rule": { "<": [false, false] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (true, true)",+        "rule": { "<": [true, true] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (a, b)",+        "rule": { "<": ["a", "b"] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (b, a)",+        "rule": { "<": ["b", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (a, a)",+        "rule": { "<": ["a", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (2024-01-01, 2024-01-02)",+        "rule": { "<": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (2024-01-02, 2024-01-01)",+        "rule": { "<": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (2024-01-01, 2024-01-01)",+        "rule": { "<": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (2023, 2024-01-01)",+        "rule": { "<": ["2023", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (2023n, 2024-01-01s)",+        "rule": { "<": [2023, "2024-01-01"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "< with (5s, 3n)",+        "rule": { "<": ["5", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (21s, 3n)",+        "rule": { "<": ["21", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (21n, 3s)",+        "rule": { "<": [21, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3s, 21n)",+        "rule": { "<": ["3", 21] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (3n, 21s)",+        "rule": { "<": [3, "21"] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (3n, 3s)",+        "rule": { "<": [3, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3s, 3n)",+        "rule": { "<": ["3", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3n, 3n)",+        "rule": { "<": [3, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3s, 3s)",+        "rule": { "<": ["3", "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3n, 3)",+        "rule": { "<": [3, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3, 3n)",+        "rule": { "<": [3, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3s, 3)",+        "rule": { "<": ["3", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3, 3s)",+        "rule": { "<": [3, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3n, 3n)",+        "rule": { "<": [3, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3s, 3s)",+        "rule": { "<": ["3", "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3n, 3s)",+        "rule": { "<": [3, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (3s, 3n)",+        "rule": { "<": ["3", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (1n, As)",+        "rule": { "<": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "< with (null, 1)",+        "rule": { "<": [null, 1] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (1, null)",+        "rule": { "<": [1, null] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (null, 0)",+        "rule": { "<": [null, 0] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (null, true)",+        "rule": { "<": [null, true] },+        "data": {},+        "result": true+    },+    {+        "description": "< with (1, true)",+        "rule": { "<": [1, true] },+        "data": {},+        "result": false+    },+    {+        "description": "< with (0, false)",+        "rule": { "<": [0, false] },+        "data": {},+        "result": false+    },+    {+        "description": "< with [] and [1]",+        "rule": { "<": [[], [1]] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "< with [1] and 5",+        "rule": { "<": [[1], 5] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "< with {} and 1",+        "rule": { "<": [{}, 1] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "< with 1 and {}",+        "rule": { "<": [1, {}] },+        "data": {},+        "error": { "type": "NaN" }+    }+]
+ test/community/comparison/lessThanEquals.json view
@@ -0,0 +1,122 @@+[+    {+        "description": "<= is lazily evaluated",+        "rule": { "<=": [3, 2, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "<= with (2023n, 2024-01-01s)",+        "rule": { "<=": [2023, "2024-01-01"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "<= with (5s, 3n)",+        "rule": { "<=": ["5", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "<= with (21s, 3n)",+        "rule": { "<=": ["21", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "<= with (21n, 3s)",+        "rule": { "<=": [21, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "<= with (3s, 21n)",+        "rule": { "<=": ["3", 21] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (3n, 21s)",+        "rule": { "<=": [3, "21"] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (3n, 3s)",+        "rule": { "<=": [3, "3"] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (3s, 3n)",+        "rule": { "<=": ["3", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (1n, As)",+        "rule": { "<=": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "<= with (null, 1)",+        "rule": { "<=": [null, 1] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (1, null)",+        "rule": { "<=": [1, null] },+        "data": {},+        "result": false+    },+    {+        "description": "<= with (null, 0)",+        "rule": { "<=": [null, 0] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (null, true)",+        "rule": { "<": [null, true] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (1, true)",+        "rule": { "<=": [1, true] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with (0, false)",+        "rule": { "<=": [0, false] },+        "data": {},+        "result": true+    },+    {+        "description": "<= with [] and [1]",+        "rule": { "<=": [[], [1]] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "<= with [1] and 5",+        "rule": { "<=": [[1], 5] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "<= with {} and 1",+        "rule": { "<=": [{}, 1] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "<= with 1 and {}",+        "rule": { "<=": [1, {}] },+        "data": {},+        "error": { "type": "NaN" }+    }+]
+ test/community/comparison/softEquals.json view
@@ -0,0 +1,212 @@+[+    {+        "description": "== with 3 arguments",+        "rule": { "==": [3, 3, 3] },+        "data": {},+        "result": true+    },+    {+        "description": "== with 3 arguments failing",+        "rule": { "==": [3, 2, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "== is lazily evaluated",+        "rule": { "==": [3, 2, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "== with 1 argument",+        "rule": { "==": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "== with direct argument",+        "rule": { "==": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "== with empty argument",+        "rule": { "==": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "== with (false, true)",+        "rule": { "==": [false, true] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (true, false)",+        "rule": { "==": [true, false] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (false, false)",+        "rule": { "==": [false, false] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (true, true)",+        "rule": { "==": [true, true] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (a, b)",+        "rule": { "==": ["a", "b"] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (b, a)",+        "rule": { "==": ["b", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (a, a)",+        "rule": { "==": ["a", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (2024-01-01, 2024-01-02)",+        "rule": { "==": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (2024-01-02, 2024-01-01)",+        "rule": { "==": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (2024-01-01, 2024-01-01)",+        "rule": { "==": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (2023n, 2024-01-01s)",+        "rule": { "==": [2023, "2024-01-01"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "== with (5s, 3n)",+        "rule": { "==": ["5", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (21s, 3n)",+        "rule": { "==": ["21", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (21n, 3s)",+        "rule": { "==": [21, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (3s, 21n)",+        "rule": { "==": ["3", 21] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (3n, 21s)",+        "rule": { "==": [3, "21"] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (3n, 3s)",+        "rule": { "==": [3, "3"] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (3s, 3n)",+        "rule": { "==": ["3", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (1n, As)",+        "rule": { "==": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "== with (null, 1)",+        "rule": { "==": [null, 1] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (1, null)",+        "rule": { "==": [1, null] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (null, 0)",+        "rule": { "==": [null, 0] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (null, true)",+        "rule": { "==": [null, true] },+        "data": {},+        "result": false+    },+    {+        "description": "== with (1, true)",+        "rule": { "==": [1, true] },+        "data": {},+        "result": true+    },+    {+        "description": "== with (0, false)",+        "rule": { "==": [0, false] },+        "data": {},+        "result": true+    },+    {+        "description": "== with [] and [1]",+        "rule": { "==": [[], [1]] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "== with [1] and 5",+        "rule": { "==": [[1], 5] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "== with {} and 1",+        "rule": { "==": [{}, 1] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "== with 1 and {}",+        "rule": { "==": [1, {}] },+        "data": {},+        "error": { "type": "NaN" }+    }+]
+ test/community/comparison/softNotEquals.json view
@@ -0,0 +1,206 @@+[+    {+        "description": "!= with 3 arguments",+        "rule": { "!=": [3, 2, 3] },+        "data": {},+        "result": true+    },+    {+        "description": "!= is lazily evaluated",+        "rule": { "!=": [3, 3, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with 1 argument",+        "rule": { "!=": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!= with direct argument",+        "rule": { "!=": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!= with empty argument",+        "rule": { "!=": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!= with (false, true)",+        "rule": { "!=": [false, true] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (true, false)",+        "rule": { "!=": [true, false] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (false, false)",+        "rule": { "!=": [false, false] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (true, true)",+        "rule": { "!=": [true, true] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (a, b)",+        "rule": { "!=": ["a", "b"] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (b, a)",+        "rule": { "!=": ["b", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (a, a)",+        "rule": { "!=": ["a", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (2024-01-01, 2024-01-02)",+        "rule": { "!=": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (2024-01-02, 2024-01-01)",+        "rule": { "!=": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (2024-01-01, 2024-01-01)",+        "rule": { "!=": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (2023n, 2024-01-01s)",+        "rule": { "!=": [2023, "2024-01-01"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "!= with (5s, 3n)",+        "rule": { "!=": ["5", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (21s, 3n)",+        "rule": { "!=": ["21", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (21n, 3s)",+        "rule": { "!=": [21, "3"] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (3s, 21n)",+        "rule": { "!=": ["3", 21] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (3n, 21s)",+        "rule": { "!=": [3, "21"] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (3n, 3s)",+        "rule": { "!=": [3, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (3s, 3n)",+        "rule": { "!=": ["3", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (1n, As)",+        "rule": { "!=": [1, "A"] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "!= with (null, 1)",+        "rule": { "!=": [null, 1] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (1, null)",+        "rule": { "!=": [1, null] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (null, 0)",+        "rule": { "!=": [null, 0] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (null, true)",+        "rule": { "!=": [null, true] },+        "data": {},+        "result": true+    },+    {+        "description": "!= with (1, true)",+        "rule": { "!=": [1, true] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with (0, false)",+        "rule": { "!=": [0, false] },+        "data": {},+        "result": false+    },+    {+        "description": "!= with [] and [1]",+        "rule": { "!=": [[], [1]] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "!= with [1] and 5",+        "rule": { "!=": [[1], 5] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "!= with {} and 1",+        "rule": { "!=": [{}, 1] },+        "data": {},+        "error": { "type": "NaN" }+    },+    {+        "description": "!= with 1 and {}",+        "rule": { "!=": [1, {}] },+        "data": {},+        "error": { "type": "NaN" }+    }+]
+ test/community/comparison/strictEquals.json view
@@ -0,0 +1,188 @@+[+    {+        "description": "=== with 3 arguments",+        "rule": { "===": [3, 3, 3] },+        "data": {},+        "result": true+    },+    {+        "description": "=== with 3 arguments failing",+        "rule": { "===": [3, 3, 2] },+        "data": {},+        "result": false+    },+    {+        "description": "=== is lazily evaluated",+        "rule": { "===": [3, 2, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with 1 argument",+        "rule": { "===": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "=== with direct argument",+        "rule": { "===": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "=== with empty argument",+        "rule": { "===": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "=== with (false, true)",+        "rule": { "===": [false, true] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (true, false)",+        "rule": { "===": [true, false] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (false, false)",+        "rule": { "===": [false, false] },+        "data": {},+        "result": true+    },+    {+        "description": "=== with (true, true)",+        "rule": { "===": [true, true] },+        "data": {},+        "result": true+    },+    {+        "description": "=== with (a, b)",+        "rule": { "===": ["a", "b"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (b, a)",+        "rule": { "===": ["b", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (a, a)",+        "rule": { "===": ["a", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": "=== with (2024-01-01, 2024-01-02)",+        "rule": { "===": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (2024-01-02, 2024-01-01)",+        "rule": { "===": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (2024-01-01, 2024-01-01)",+        "rule": { "===": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "=== with (2023n, 2024-01-01s)",+        "rule": { "===": [2023, "2024-01-01"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (5s, 3n)",+        "rule": { "===": ["5", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (21s, 3n)",+        "rule": { "===": ["21", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (21n, 3s)",+        "rule": { "===": [21, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (3s, 21n)",+        "rule": { "===": ["3", 21] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (3n, 21s)",+        "rule": { "===": [3, "21"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (3n, 3s)",+        "rule": { "===": [3, "3"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (3s, 3n)",+        "rule": { "===": ["3", 3] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (1n, As)",+        "rule": { "===": [1, "A"] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (null, 1)",+        "rule": { "===": [null, 1] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (1, null)",+        "rule": { "===": [1, null] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (null, 0)",+        "rule": { "===": [null, 0] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (null, true)",+        "rule": { "===": [null, true] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (1, true)",+        "rule": { "===": [1, true] },+        "data": {},+        "result": false+    },+    {+        "description": "=== with (0, false)",+        "rule": { "===": [0, false] },+        "data": {},+        "result": false+    }+]
+ test/community/comparison/strictNotEquals.json view
@@ -0,0 +1,182 @@+[+    {+        "description": "!== with 3 arguments failing",+        "rule": { "!==": [3, 3, 3] },+        "data": {},+        "result": false+    },+    {+        "description": "!== is lazily evaluated",+        "rule": { "!==": [3, 3, { "throw": "Not Lazy" }] },+        "data": {},+        "result": false+    },+    {+        "description": "!== with 1 argument",+        "rule": { "!==": [1] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!== with direct argument",+        "rule": { "!==": 1 },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!== with empty argument",+        "rule": { "!==": [] },+        "data": {},+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!== with (false, true)",+        "rule": { "!==": [false, true] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (true, false)",+        "rule": { "!==": [true, false] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (false, false)",+        "rule": { "!==": [false, false] },+        "data": {},+        "result": false+    },+    {+        "description": "!== with (true, true)",+        "rule": { "!==": [true, true] },+        "data": {},+        "result": false+    },+    {+        "description": "!== with (a, b)",+        "rule": { "!==": ["a", "b"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (b, a)",+        "rule": { "!==": ["b", "a"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (a, a)",+        "rule": { "!==": ["a", "a"] },+        "data": {},+        "result": false+    },+    {+        "description": "!== with (2024-01-01, 2024-01-02)",+        "rule": { "!==": ["2024-01-01", "2024-01-02"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (2024-01-02, 2024-01-01)",+        "rule": { "!==": ["2024-01-02", "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (2024-01-01, 2024-01-01)",+        "rule": { "!==": ["2024-01-01", "2024-01-01"] },+        "data": {},+        "result": false+    }, +    {+        "description": "!== with (2023n, 2024-01-01s)",+        "rule": { "!==": [2023, "2024-01-01"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (5s, 3n)",+        "rule": { "!==": ["5", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (21s, 3n)",+        "rule": { "!==": ["21", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (21n, 3s)",+        "rule": { "!==": [21, "3"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (3s, 21n)",+        "rule": { "!==": ["3", 21] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (3n, 21s)",+        "rule": { "!==": [3, "21"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (3n, 3s)",+        "rule": { "!==": [3, "3"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (3s, 3n)",+        "rule": { "!==": ["3", 3] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (1n, As)",+        "rule": { "!==": [1, "A"] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (null, 1)",+        "rule": { "!==": [null, 1] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (1, null)",+        "rule": { "!==": [1, null] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (null, 0)",+        "rule": { "!==": [null, 0] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (null, true)",+        "rule": { "!==": [null, true] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (1, true)",+        "rule": { "!==": [1, true] },+        "data": {},+        "result": true+    },+    {+        "description": "!== with (0, false)",+        "rule": { "!==": [0, false] },+        "data": {},+        "result": true+    }+]
+ test/community/compatible.json view
@@ -0,0 +1,1895 @@+[+    "# Non-rules get passed through",+    {"description": "true", "rule": true, "result": true},+    {"description": "false", "rule": false, "result": false},+    {"description": "17", "rule": 17, "result": 17},+    {"description": "3.14", "rule": 3.14, "result": 3.14},+    {"description": "\"apple\"", "rule": "apple", "result": "apple"},+    {"description": "null", "rule": null, "result": null},+    { "description": "[\"a\",\"b\"]", "rule": ["a", "b"], "result": ["a", "b"] },+    "# Single operator tests",+    {+        "description": "{\"==\":[1,1]}",+        "rule": { "==": [1, 1] },+        "result": true+    },+    {+        "description": "{\"==\":[1,\"1\"]}",+        "rule": { "==": [1, "1"] },+        "result": true+    },+    {+        "description": "{\"==\":[1,2]}",+        "rule": { "==": [1, 2] },+        "result": false+    },+    {+        "description": "{\"===\":[1,1]}",+        "rule": { "===": [1, 1] },+        "result": true+    },+    {+        "description": "{\"===\":[1,\"1\"]}",+        "rule": { "===": [1, "1"] },+        "result": false+    },+    {+        "description": "{\"===\":[1,2]}",+        "rule": { "===": [1, 2] },+        "result": false+    },+    {+        "description": "{\"!=\":[1,2]}",+        "rule": { "!=": [1, 2] },+        "result": true+    },+    {+        "description": "{\"!=\":[1,1]}",+        "rule": { "!=": [1, 1] },+        "result": false+    },+    {+        "description": "{\"!=\":[1,\"1\"]}",+        "rule": { "!=": [1, "1"] },+        "result": false+    },+    {+        "description": "{\"!==\":[1,2]}",+        "rule": { "!==": [1, 2] },+        "result": true+    },+    {+        "description": "{\"!==\":[1,1]}",+        "rule": { "!==": [1, 1] },+        "result": false+    },+    {+        "description": "{\"!==\":[1,\"1\"]}",+        "rule": { "!==": [1, "1"] },+        "result": true+    },+    {+        "description": "{\">\":[2,1]}",+        "rule": { ">": [2, 1] },+        "result": true+    },+    {+        "description": "{\">\":[1,1]}",+        "rule": { ">": [1, 1] },+        "result": false+    },+    {+        "description": "{\">\":[1,2]}",+        "rule": { ">": [1, 2] },+        "result": false+    },+    {+        "description": "{\">\":[\"2\",1]}",+        "rule": { ">": ["2", 1] },+        "result": true+    },+    {+        "description": "{\">=\":[2,1]}",+        "rule": { ">=": [2, 1] },+        "result": true+    },+    {+        "description": "{\">=\":[1,1]}",+        "rule": { ">=": [1, 1] },+        "result": true+    },+    {+        "description": "{\">=\":[1,2]}",+        "rule": { ">=": [1, 2] },+        "result": false+    },+    {+        "description": "{\">=\":[\"2\",1]}",+        "rule": { ">=": ["2", 1] },+        "result": true+    },+    {+        "description": "{\"<\":[2,1]}",+        "rule": { "<": [2, 1] },+        "result": false+    },+    {+        "description": "{\"<\":[1,1]}",+        "rule": { "<": [1, 1] },+        "result": false+    },+    {+        "description": "{\"<\":[1,2]}",+        "rule": { "<": [1, 2] },+        "result": true+    },+    {+        "description": "{\"<\":[\"1\",2]}",+        "rule": { "<": ["1", 2] },+        "result": true+    },+    {+        "description": "{\"<\":[1,2,3]}",+        "rule": { "<": [1, 2, 3] },+        "result": true+    },+    {+        "description": "{\"<\":[1,1,3]}",+        "rule": { "<": [1, 1, 3] },+        "result": false+    },+    {+        "description": "{\"<\":[1,4,3]}",+        "rule": { "<": [1, 4, 3] },+        "result": false+    },+    {+        "description": "{\"<=\":[2,1]}",+        "rule": { "<=": [2, 1] },+        "result": false+    },+    {+        "description": "{\"<=\":[1,1]}",+        "rule": { "<=": [1, 1] },+        "result": true+    },+    {+        "description": "{\"<=\":[1,2]}",+        "rule": { "<=": [1, 2] },+        "result": true+    },+    {+        "description": "{\"<=\":[\"1\",2]}",+        "rule": { "<=": ["1", 2] },+        "result": true+    },+    {+        "description": "{\"<=\":[1,2,3]}",+        "rule": { "<=": [1, 2, 3] },+        "result": true+    },+    {+        "description": "{\"<=\":[1,4,3]}",+        "rule": { "<=": [1, 4, 3] },+        "result": false+    },+    {+        "description": "{\"!\":[false]}",+        "rule": { "!": [false] },+        "result": true+    },+    { "description": "{\"!\":false}", "rule": {"!": false}, "result": true },+    {+        "description": "{\"!\":[true]}",+        "rule": { "!": [true] },+        "result": false+    },+    { "description": "{\"!\":true}", "rule": {"!": true}, "result": false },+    { "description": "{\"!\":0}", "rule": {"!": 0}, "result": true },+    { "description": "{\"!\":1}", "rule": {"!": 1}, "result": false },+    {+        "description": "{\"or\":[true,true]}",+        "rule": { "or": [true, true] },+        "result": true+    },+    {+        "description": "{\"or\":[false,true]}",+        "rule": { "or": [false, true] },+        "result": true+    },+    {+        "description": "{\"or\":[true,false]}",+        "rule": { "or": [true, false] },+        "result": true+    },+    {+        "description": "{\"or\":[false,false]}",+        "rule": { "or": [false, false] },+        "result": false+    },+    {+        "description": "{\"or\":[false,false,true]}",+        "rule": { "or": [false, false, true] },+        "result": true+    },+    {+        "description": "{\"or\":[false,false,false]}",+        "rule": { "or": [false, false, false] },+        "result": false+    },+    {+        "description": "{\"or\":[false]}",+        "rule": { "or": [false] },+        "result": false+    },+    {+        "description": "{\"or\":[true]}",+        "rule": { "or": [true] },+        "result": true+    },+    {+        "description": "{\"or\":[1,3]}",+        "rule": { "or": [1, 3] },+        "result": 1+    },+    {+        "description": "{\"or\":[3,false]}",+        "rule": { "or": [3, false] },+        "result": 3+    },+    {+        "description": "{\"or\":[false,3]}",+        "rule": { "or": [false, 3] },+        "result": 3+    },+    {+        "description": "{\"and\":[true,true]}",+        "rule": { "and": [true, true] },+        "result": true+    },+    {+        "description": "{\"and\":[false,true]}",+        "rule": { "and": [false, true] },+        "result": false+    },+    {+        "description": "{\"and\":[true,false]}",+        "rule": { "and": [true, false] },+        "result": false+    },+    {+        "description": "{\"and\":[false,false]}",+        "rule": { "and": [false, false] },+        "result": false+    },+    {+        "description": "{\"and\":[true,true,true]}",+        "rule": { "and": [true, true, true] },+        "result": true+    },+    {+        "description": "{\"and\":[true,true,false]}",+        "rule": { "and": [true, true, false] },+        "result": false+    },+    {+        "description": "{\"and\":[false]}",+        "rule": { "and": [false] },+        "result": false+    },+    {+        "description": "{\"and\":[true]}",+        "rule": { "and": [true] },+        "result": true+    },+    {+        "description": "{\"and\":[1,3]}",+        "rule": { "and": [1, 3] },+        "result": 3+    },+    {+        "description": "{\"and\":[3,false]}",+        "rule": { "and": [3, false] },+        "result": false+    },+    {+        "description": "{\"and\":[false,3]}",+        "rule": { "and": [false, 3] },+        "result": false+    },+    {+        "description": "{\"?:\":[true,1,2]}",+        "rule": { "?:": [true, 1, 2] },+        "result": 1+    },+    {+        "description": "{\"?:\":[false,1,2]}",+        "rule": { "?:": [false, 1, 2] },+        "result": 2+    },+    {+        "description": "{\"in\":[\"Bart\",[\"Bart\",\"Homer\",\"Lisa\",\"Marge\",\"Maggie\"]]}",+        "rule": {+            "in": [ "Bart", ["Bart", "Homer", "Lisa", "Marge", "Maggie"] ]+        },+        "result": true+    },+    {+        "description": "{\"in\":[\"Milhouse\",[\"Bart\",\"Homer\",\"Lisa\",\"Marge\",\"Maggie\"]]}",+        "rule": {+            "in": [ "Milhouse", ["Bart", "Homer", "Lisa", "Marge", "Maggie"] ]+        },+        "result": false+    },+    {+        "description": "{\"in\":[\"Spring\",\"Springfield\"]}",+        "rule": { "in": ["Spring", "Springfield"] },+        "result": true+    },+    {+        "description": "{\"in\":[\"i\",\"team\"]}",+        "rule": { "in": ["i", "team"] },+        "result": false+    },+    { "description": "{\"cat\":\"ice\"}", "rule": {"cat": "ice"}, "result": "ice" },+    {+        "description": "{\"cat\":[\"ice\"]}",+        "rule": { "cat": ["ice"] },+        "result": "ice"+    },+    {+        "description": "{\"cat\":[\"ice\",\"cream\"]}",+        "rule": { "cat": ["ice", "cream"] },+        "result": "icecream"+    },+    {+        "description": "{\"cat\":[1,2]}",+        "rule": { "cat": [1, 2] },+        "result": "12"+    },+    {+        "description": "{\"cat\":[\"Robocop\",2]}",+        "rule": { "cat": ["Robocop", 2] },+        "result": "Robocop2"+    },+    {+        "description": "{\"cat\":[\"we all scream for \",\"ice\",\"cream\"]}",+        "rule": { "cat": ["we all scream for ", "ice", "cream"] },+        "result": "we all scream for icecream"+    },+    {+        "description": "{\"%\":[1,2]}",+        "rule": { "%": [1, 2] },+        "result": 1+    },+    {+        "description": "{\"%\":[2,2]}",+        "rule": { "%": [2, 2] },+        "result": 0+    },+    {+        "description": "{\"%\":[3,2]}",+        "rule": { "%": [3, 2] },+        "result": 1+    },+    {+        "description": "{\"max\":[1,2,3]}",+        "rule": { "max": [1, 2, 3] },+        "result": 3+    },+    {+        "description": "{\"max\":[1,3,3]}",+        "rule": { "max": [1, 3, 3] },+        "result": 3+    },+    {+        "description": "{\"max\":[3,2,1]}",+        "rule": { "max": [3, 2, 1] },+        "result": 3+    },+    {+        "description": "{\"max\":[1]}",+        "rule": { "max": [1] },+        "result": 1+    },+    {+        "description": "{\"min\":[1,2,3]}",+        "rule": { "min": [1, 2, 3] },+        "result": 1+    },+    {+        "description": "{\"min\":[1,1,3]}",+        "rule": { "min": [1, 1, 3] },+        "result": 1+    },+    {+        "description": "{\"min\":[3,2,1]}",+        "rule": { "min": [3, 2, 1] },+        "result": 1+    },+    {+        "description": "{\"min\":[1]}",+        "rule": { "min": [1] },+        "result": 1+    },+    {+        "description": "{\"+\":[1,2]}",+        "rule": { "+": [1, 2] },+        "result": 3+    },+    {+        "description": "{\"+\":[2,2,2]}",+        "rule": { "+": [2, 2, 2] },+        "result": 6+    },+    {+        "description": "{\"+\":[1]}",+        "rule": { "+": [1] },+        "result": 1+    },+    {+        "description": "{\"+\":[\"1\",1]}",+        "rule": { "+": ["1", 1] },+        "result": 2+    },+    {+        "description": "{\"*\":[3,2]}",+        "rule": { "*": [3, 2] },+        "result": 6+    },+    {+        "description": "{\"*\":[2,2,2]}",+        "rule": { "*": [2, 2, 2] },+        "result": 8+    },+    {+        "description": "{\"*\":[1]}",+        "rule": { "*": [1] },+        "result": 1+    },+    {+        "description": "{\"*\":[\"1\",1]}",+        "rule": { "*": ["1", 1] },+        "result": 1+    },+    {+        "description": "{\"-\":[2,3]}",+        "rule": { "-": [2, 3] },+        "result": -1+    },+    {+        "description": "{\"-\":[3,2]}",+        "rule": { "-": [3, 2] },+        "result": 1+    },+    {+        "description": "{\"-\":[3]}",+        "rule": { "-": [3] },+        "result": -3+    },+    {+        "description": "{\"-\":[\"1\",1]}",+        "rule": { "-": ["1", 1] },+        "result": 0+    },+    {+        "description": "{\"/\":[4,2]}",+        "rule": { "/": [4, 2] },+        "result": 2+    },+    {+        "description": "{\"/\":[2,4]}",+        "rule": { "/": [2, 4] },+        "result": 0.5+    },+    {+        "description": "{\"/\":[\"1\",1]}",+        "rule": { "/": ["1", 1] },+        "result": 1+    },+    "Substring",+    {+        "description": "{\"substr\":[\"jsonlogic\",4]}",+        "rule": { "substr": ["jsonlogic", 4] },+        "data": null,+        "result": "logic"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",-5]}",+        "rule": { "substr": ["jsonlogic", -5] },+        "data": null,+        "result": "logic"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",0,1]}",+        "rule": { "substr": ["jsonlogic", 0, 1] },+        "data": null,+        "result": "j"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",-1,1]}",+        "rule": { "substr": ["jsonlogic", -1, 1] },+        "data": null,+        "result": "c"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",4,5]}",+        "rule": { "substr": ["jsonlogic", 4, 5] },+        "data": null,+        "result": "logic"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",-5,5]}",+        "rule": { "substr": ["jsonlogic", -5, 5] },+        "data": null,+        "result": "logic"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",-5,-2]}",+        "rule": { "substr": ["jsonlogic", -5, -2] },+        "data": null,+        "result": "log"+    },+    {+        "description": "{\"substr\":[\"jsonlogic\",1,-5]}",+        "rule": { "substr": ["jsonlogic", 1, -5] },+        "data": null,+        "result": "son"+    },+    "Merge arrays",+    { "description": "{\"merge\":[]}", "rule": {"merge": []}, "data": null, "result": [] },+    {+        "description": "{\"merge\":[[1]]}",+        "rule": {+            "merge": [ [1] ]+        },+        "data": null,+        "result": [1]+    },+    {+        "description": "{\"merge\":[[1],[]]}",+        "rule": {+            "merge": [ [1], [] ]+        },+        "data": null,+        "result": [1]+    },+    {+        "description": "{\"merge\":[[1],[2]]}",+        "rule": {+            "merge": [ [1], [2] ]+        },+        "data": null,+        "result": [1, 2]+    },+    {+        "description": "{\"merge\":[[1],[2],[3]]}",+        "rule": {+            "merge": [ [1], [2], [3] ]+        },+        "data": null,+        "result": [1, 2, 3]+    },+    {+        "description": "{\"merge\":[[1,2],[3]]}",+        "rule": {+            "merge": [ [1, 2], [3] ]+        },+        "data": null,+        "result": [1, 2, 3]+    },+    {+        "description": "{\"merge\":[[1],[2,3]]}",+        "rule": {+            "merge": [ [1], [2, 3] ]+        },+        "data": null,+        "result": [1, 2, 3]+    },+    "Given non-array arguments, merge converts them to arrays",+    { "description": "{\"merge\":1}", "rule": {"merge": 1}, "data": null, "result": [1] },+    {+        "description": "{\"merge\":[1,2]}",+        "rule": { "merge": [1, 2] },+        "data": null,+        "result": [1, 2]+    },+    {+        "description": "{\"merge\":[1,[2]]}",+        "rule": {+            "merge": [ 1, [2] ]+        },+        "data": null,+        "result": [1, 2]+    },+    "Too few args",+    { "description": "{\"if\":[]}", "rule": {"if": []}, "data": null, "result": null },+    {+        "description": "{\"if\":[true]}",+        "rule": { "if": [true] },+        "data": null,+        "result": true+    },+    {+        "description": "{\"if\":[false]}",+        "rule": { "if": [false] },+        "data": null,+        "result": false+    },+    {+        "description": "{\"if\":[\"apple\"]}",+        "rule": { "if": ["apple"] },+        "data": null,+        "result": "apple"+    },+    "Simple if/then/else cases",+    {+        "description": "{\"if\":[true,\"apple\"]}",+        "rule": { "if": [true, "apple"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[false,\"apple\"]}",+        "rule": { "if": [false, "apple"] },+        "data": null,+        "result": null+    },+    {+        "description": "{\"if\":[true,\"apple\",\"banana\"]}",+        "rule": { "if": [true, "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[false,\"apple\",\"banana\"]}",+        "rule": { "if": [false, "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    "Empty arrays are falsey",+    {+        "description": "{\"if\":[[],\"apple\",\"banana\"]}",+        "rule": { "if": [[], "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[[1],\"apple\",\"banana\"]}",+        "rule": {+            "if": [ [1], "apple", "banana" ]+        },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[[1,2,3,4],\"apple\",\"banana\"]}",+        "rule": {+            "if": [ [1, 2, 3, 4], "apple", "banana" ]+        },+        "data": null,+        "result": "apple"+    },+    "Empty strings are falsey, all other strings are truthy",+    {+        "description": "{\"if\":[\"\",\"apple\",\"banana\"]}",+        "rule": { "if": ["", "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[\"zucchini\",\"apple\",\"banana\"]}",+        "rule": { "if": ["zucchini", "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[\"0\",\"apple\",\"banana\"]}",+        "rule": { "if": ["0", "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    "You can cast a string to numeric with a unary + ",+    {+        "description": "{\"===\":[0,\"0\"]}",+        "rule": { "===": [0, "0"] },+        "data": null,+        "result": false+    },+    {+        "description": "{\"===\":[0,{\"+\":\"0\"}]}",+        "rule": {+            "===": [ 0, {"+": "0"} ]+        },+        "data": null,+        "result": true+    },+    {+        "description": "{\"if\":[{\"+\":\"0\"},\"apple\",\"banana\"]}",+        "rule": {+            "if": [ {"+": "0"}, "apple", "banana" ]+        },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[{\"+\":\"1\"},\"apple\",\"banana\"]}",+        "rule": {+            "if": [ {"+": "1"}, "apple", "banana" ]+        },+        "data": null,+        "result": "apple"+    },+    "Zero is falsy, all other numbers are truthy",+    {+        "description": "{\"if\":[0,\"apple\",\"banana\"]}",+        "rule": { "if": [0, "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[1,\"apple\",\"banana\"]}",+        "rule": { "if": [1, "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[3.1416,\"apple\",\"banana\"]}",+        "rule": { "if": [3.1416, "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[-1,\"apple\",\"banana\"]}",+        "rule": { "if": [-1, "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    "Truthy and falsy definitions matter in Boolean operations",+    {+        "description": "{\"!\":[[]]}",+        "rule": { "!": [[]] },+        "result": true+    },+    {+        "description": "{\"!!\":[[]]}",+        "rule": { "!!": [[]] },+        "result": false+    },+    {+        "description": "{\"and\":[[],true]}",+        "rule": { "and": [[], true] },+        "result": []+    },+    {+        "description": "{\"or\":[[],true]}",+        "rule": { "or": [[], true] },+        "result": true+    },+    {+        "description": "{\"!\":[0]}",+        "rule": { "!": [0] },+        "result": true+    },+    {+        "description": "{\"!!\":[0]}",+        "rule": { "!!": [0] },+        "result": false+    },+    {+        "description": "{\"and\":[0,true]}",+        "rule": { "and": [0, true] },+        "result": 0+    },+    {+        "description": "{\"or\":[0,true]}",+        "rule": { "or": [0, true] },+        "result": true+    },+    {+        "description": "{\"!\":[\"\"]}",+        "rule": { "!": [""] },+        "result": true+    },+    {+        "description": "{\"!!\":[\"\"]}",+        "rule": { "!!": [""] },+        "result": false+    },+    {+        "description": "{\"and\":[\"\",true]}",+        "rule": { "and": ["", true] },+        "result": ""+    },+    {+        "description": "{\"or\":[\"\",true]}",+        "rule": { "or": ["", true] },+        "result": true+    },+    {+        "description": "{\"!\":[\"0\"]}",+        "rule": { "!": ["0"] },+        "result": false+    },+    {+        "description": "{\"!!\":[\"0\"]}",+        "rule": { "!!": ["0"] },+        "result": true+    },+    {+        "description": "{\"and\":[\"0\",true]}",+        "rule": { "and": ["0", true] },+        "result": true+    },+    {+        "description": "{\"or\":[\"0\",true]}",+        "rule": { "or": ["0", true] },+        "result": "0"+    },+    "If the conditional is logic, it gets evaluated",+    {+        "description": "{\"if\":[{\">\":[2,1]},\"apple\",\"banana\"]}",+        "rule": {+            "if": [+                { ">": [2, 1] },+                "apple",+                "banana"+            ]+        },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[{\">\":[1,2]},\"apple\",\"banana\"]}",+        "rule": {+            "if": [+                { ">": [1, 2] },+                "apple",+                "banana"+            ]+        },+        "data": null,+        "result": "banana"+    },+    "If the consequents are logic, they get evaluated",+    {+        "description": "{\"if\":[true,{\"cat\":[\"ap\",\"ple\"]},{\"cat\":[\"ba\",\"na\",\"na\"]}]}",+        "rule": {+            "if": [+                true,+                { "cat": ["ap", "ple"] },+                { "cat": ["ba", "na", "na"] }+            ]+        },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[false,{\"cat\":[\"ap\",\"ple\"]},{\"cat\":[\"ba\",\"na\",\"na\"]}]}",+        "rule": {+            "if": [+                false,+                { "cat": ["ap", "ple"] },+                { "cat": ["ba", "na", "na"] }+            ]+        },+        "data": null,+        "result": "banana"+    },+    "If/then/elseif/then cases",+    {+        "description": "{\"if\":[true,\"apple\",true,\"banana\"]}",+        "rule": { "if": [true, "apple", true, "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[true,\"apple\",false,\"banana\"]}",+        "rule": { "if": [true, "apple", false, "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[false,\"apple\",true,\"banana\"]}",+        "rule": { "if": [false, "apple", true, "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[false,\"apple\",false,\"banana\"]}",+        "rule": { "if": [false, "apple", false, "banana"] },+        "data": null,+        "result": null+    },+    {+        "description": "{\"if\":[true,\"apple\",true,\"banana\",\"carrot\"]}",+        "rule": { "if": [true, "apple", true, "banana", "carrot"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[true,\"apple\",false,\"banana\",\"carrot\"]}",+        "rule": { "if": [true, "apple", false, "banana", "carrot"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[false,\"apple\",true,\"banana\",\"carrot\"]}",+        "rule": { "if": [false, "apple", true, "banana", "carrot"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[false,\"apple\",false,\"banana\",\"carrot\"]}",+        "rule": { "if": [false, "apple", false, "banana", "carrot"] },+        "data": null,+        "result": "carrot"+    },+    {+        "description": "{\"if\":[false,\"apple\",false,\"banana\",false,\"carrot\"]}",+        "rule": { "if": [false, "apple", false, "banana", false, "carrot"] },+        "data": null,+        "result": null+    },+    {+        "description": "{\"if\":[false,\"apple\",false,\"banana\",false,\"carrot\",\"date\"]}",+        "rule": { "if": [false, "apple", false, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "date"+    },+    {+        "description": "{\"if\":[false,\"apple\",false,\"banana\",true,\"carrot\",\"date\"]}",+        "rule": { "if": [false, "apple", false, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "carrot"+    },+    {+        "description": "{\"if\":[false,\"apple\",true,\"banana\",false,\"carrot\",\"date\"]}",+        "rule": { "if": [false, "apple", true, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[false,\"apple\",true,\"banana\",true,\"carrot\",\"date\"]}",+        "rule": { "if": [false, "apple", true, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "{\"if\":[true,\"apple\",false,\"banana\",false,\"carrot\",\"date\"]}",+        "rule": { "if": [true, "apple", false, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[true,\"apple\",false,\"banana\",true,\"carrot\",\"date\"]}",+        "rule": { "if": [true, "apple", false, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[true,\"apple\",true,\"banana\",false,\"carrot\",\"date\"]}",+        "rule": { "if": [true, "apple", true, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "{\"if\":[true,\"apple\",true,\"banana\",true,\"carrot\",\"date\"]}",+        "rule": { "if": [true, "apple", true, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    "Arrays with logic",+    {+        "description": "[1,{\"var\":\"x\"},3]",+        "rule": [ 1, {"var": "x"}, 3 ],+        "data": {"x": 2},+        "result": [1, 2, 3]+    },+    {+        "description": "{\"if\":[{\"var\":\"x\"},[{\"var\":\"y\"}],99]}",+        "rule": {+            "if": [+                {"var": "x"},+                [ {"var": "y"} ],+                99+            ]+        },+        "data": {"x": true, "y": 42},+        "result": [42]+    },+    "# Compound Tests",+    {+        "description": "{\"and\":[{\">\":[3,1]},true]}",+        "rule": {+            "and": [+                { ">": [3, 1] },+                true+            ]+        },+        "result": true+    },+    {+        "description": "{\"and\":[{\">\":[3,1]},false]}",+        "rule": {+            "and": [+                { ">": [3, 1] },+                false+            ]+        },+        "result": false+    },+    {+        "description": "{\"and\":[{\">\":[3,1]},{\"!\":true}]}",+        "rule": {+            "and": [+                { ">": [3, 1]            },+                {              "!": true }+            ]+        },+        "result": false+    },+    {+        "description": "{\"and\":[{\">\":[3,1]},{\"<\":[1,3]}]}",+        "rule": {+            "and": [+                { ">": [3, 1]              },+                {              "<": [1, 3] }+            ]+        },+        "result": true+    },+    {+        "description": "{\"?:\":[{\">\":[3,1]},\"visible\",\"hidden\"]}",+        "rule": {+            "?:": [+                { ">": [3, 1] },+                "visible",+                "hidden"+            ]+        },+        "result": "visible"+    },+    "# Data-Driven",+    {+        "description": "{\"var\":[\"a\"]}",+        "rule": { "var": ["a"] },+        "data": {"a": 1},+        "result": 1+    },+    {+        "description": "{\"var\":[\"b\"]}",+        "rule": { "var": ["b"] },+        "data": {"a": 1},+        "result": null+    },+    {+        "description": "{\"var\":[\"a\"]}",+        "rule": { "var": ["a"] },+        "data": null,+        "result": null+    },+    { "description": "{\"var\":\"a\"}", "rule": {"var": "a"}, "data": {"a": 1}, "result": 1 },+    { "description": "{\"var\":\"b\"}", "rule": {"var": "b"}, "data": {"a": 1}, "result": null },+    { "description": "{\"var\":\"a\"}", "rule": {"var": "a"}, "data": null, "result": null },+    {+        "description": "{\"var\":[\"a\",1]}",+        "rule": { "var": ["a", 1] },+        "data": null,+        "result": 1+    },+    {+        "description": "{\"var\":[\"b\",2]}",+        "rule": { "var": ["b", 2] },+        "data": {"a": 1},+        "result": 2+    },+    {+        "description": "{\"var\":\"a.b\"}",+        "rule": {"var": "a.b"},+        "data": { "a": {"b": "c"} },+        "result": "c"+    },+    {+        "description": "{\"var\":\"a.q\"}",+        "rule": {"var": "a.q"},+        "data": { "a": {"b": "c"} },+        "result": null+    },+    {+        "description": "{\"var\":[\"a.q\",9]}",+        "rule": { "var": ["a.q", 9] },+        "data": { "a": {"b": "c"} },+        "result": 9+    },+    {+        "description": "{\"var\":1}",+        "rule": {"var": 1},+        "data": ["apple", "banana"],+        "result": "banana"+    },+    {+        "description": "{\"var\":\"1\"}",+        "rule": {"var": "1"},+        "data": ["apple", "banana"],+        "result": "banana"+    },+    {+        "description": "{\"var\":\"1.1\"}",+        "rule": {"var": "1.1"},+        "data": [ "apple", ["banana", "beer"] ],+        "result": "beer"+    },+    {+        "description": "{\"and\":[{\"<\":[{\"var\":\"temp\"},110]},{\"==\":[{\"var\":\"pie.filling\"},\"apple\"]}]}",+        "rule": {+            "and": [+                {+                    "<": [ {"var": "temp"}, 110 ]+                },+                {+                    "==": [ {"var": "pie.filling"}, "apple" ]+                }+            ]+        },+        "data": { "temp": 100, "pie": {"filling": "apple"} },+        "result": true+    },+    {+        "description": "{\"var\":[{\"?:\":[{\"<\":[{\"var\":\"temp\"},110]},\"pie.filling\",\"pie.eta\"]}]}",+        "rule": {+            "var": [+                {+                    "?:": [+                        {+                            "<": [ {"var": "temp"}, 110 ]+                        },+                        "pie.filling",+                        "pie.eta"+                    ]+                }+            ]+        },+        "data": { "temp": 100, "pie": {"filling": "apple", "eta": "60s"} },+        "result": "apple"+    },+    {+        "description": "{\"in\":[{\"var\":\"filling\"},[\"apple\",\"cherry\"]]}",+        "rule": {+            "in": [ {"var": "filling"}, ["apple", "cherry"] ]+        },+        "data": {"filling": "apple"},+        "result": true+    },+    {+        "description": "{\"var\":\"a.b.c\"}",+        "rule": {"var": "a.b.c"},+        "data": null,+        "result": null+    },+    {+        "description": "{\"var\":\"a.b.c\"}",+        "rule": {"var": "a.b.c"},+        "data": {"a": null},+        "result": null+    },+    {+        "description": "{\"var\":\"a.b.c\"}",+        "rule": {"var": "a.b.c"},+        "data": { "a": {"b": null} },+        "result": null+    },+    { "description": "{\"var\":\"\"}", "rule": {"var": ""}, "data": 1, "result": 1 },+    { "description": "{\"var\":null}", "rule": {"var": null}, "data": 1, "result": 1 },+    { "description": "{\"var\":[]}", "rule": {"var": []}, "data": 1, "result": 1 },+    "Missing",+    { "description": "{\"missing\":[]}", "rule": {"missing": []}, "data": null, "result": [] },+    {+        "description": "{\"missing\":[\"a\"]}",+        "rule": { "missing": ["a"] },+        "data": null,+        "result": ["a"]+    },+    {+        "description": "{\"missing\":\"a\"}",+        "rule": {"missing": "a"},+        "data": null,+        "result": ["a"]+    },+    {+        "description": "{\"missing\":\"a\"}",+        "rule": {"missing": "a"},+        "data": {"a": "apple"},+        "result": []+    },+    {+        "description": "{\"missing\":[\"a\"]}",+        "rule": { "missing": ["a"] },+        "data": {"a": "apple"},+        "result": []+    },+    {+        "description": "{\"missing\":[\"a\",\"b\"]}",+        "rule": { "missing": ["a", "b"] },+        "data": {"a": "apple"},+        "result": ["b"]+    },+    {+        "description": "{\"missing\":[\"a\",\"b\"]}",+        "rule": { "missing": ["a", "b"] },+        "data": {"b": "banana"},+        "result": ["a"]+    },+    {+        "description": "{\"missing\":[\"a\",\"b\"]}",+        "rule": { "missing": ["a", "b"] },+        "data": {"a": "apple", "b": "banana"},+        "result": []+    },+    {+        "description": "{\"missing\":[\"a\",\"b\"]}",+        "rule": { "missing": ["a", "b"] },+        "result": ["a", "b"]+    },+    {+        "description": "{\"missing\":[\"a\",\"b\"]}",+        "rule": { "missing": ["a", "b"] },+        "data": null,+        "result": ["a", "b"]+    },+    {+        "description": "{\"missing\":[\"a.b\"]}",+        "rule": { "missing": ["a.b"] },+        "data": null,+        "result": ["a.b"]+    },+    {+        "description": "{\"missing\":[\"a.b\"]}",+        "rule": { "missing": ["a.b"] },+        "data": {"a": "apple"},+        "result": ["a.b"]+    },+    {+        "description": "{\"missing\":[\"a.b\"]}",+        "rule": { "missing": ["a.b"] },+        "data": { "a": {"c": "apple cake"} },+        "result": ["a.b"]+    },+    {+        "description": "{\"missing\":[\"a.b\"]}",+        "rule": { "missing": ["a.b"] },+        "data": { "a": {"b": "apple brownie"} },+        "result": []+    },+    {+        "description": "{\"missing\":[\"a.b\",\"a.c\"]}",+        "rule": { "missing": ["a.b", "a.c"] },+        "data": { "a": {"b": "apple brownie"} },+        "result": ["a.c"]+    },+    "Missing some",+    {+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",+        "rule": {+            "missing_some": [ 1, ["a", "b"] ]+        },+        "data": {"a": "apple"},+        "result": []+    },+    {+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",+        "rule": {+            "missing_some": [ 1, ["a", "b"] ]+        },+        "data": {"b": "banana"},+        "result": []+    },+    {+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",+        "rule": {+            "missing_some": [ 1, ["a", "b"] ]+        },+        "data": {"a": "apple", "b": "banana"},+        "result": []+    },+    {+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",+        "rule": {+            "missing_some": [ 1, ["a", "b"] ]+        },+        "data": {"c": "carrot"},+        "result": ["a", "b"]+    },+    {+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",+        "rule": {+            "missing_some": [ 2, ["a", "b", "c"] ]+        },+        "data": {"a": "apple", "b": "banana"},+        "result": []+    },+    {+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",+        "rule": {+            "missing_some": [ 2, ["a", "b", "c"] ]+        },+        "data": {"a": "apple", "c": "carrot"},+        "result": []+    },+    {+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",+        "rule": {+            "missing_some": [ 2, ["a", "b", "c"] ]+        },+        "data": {"a": "apple", "b": "banana", "c": "carrot"},+        "result": []+    },+    {+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",+        "rule": {+            "missing_some": [ 2, ["a", "b", "c"] ]+        },+        "data": {"a": "apple", "d": "durian"},+        "result": ["b", "c"]+    },+    {+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",+        "rule": {+            "missing_some": [ 2, ["a", "b", "c"] ]+        },+        "data": {"d": "durian", "e": "eggplant"},+        "result": ["a", "b", "c"]+    },+    "Missing and If are friends, because empty arrays are falsey in JsonLogic",+    {+        "description": "{\"if\":[{\"missing\":\"a\"},\"missed it\",\"found it\"]}",+        "rule": {+            "if": [ {"missing": "a"}, "missed it", "found it" ]+        },+        "data": {"a": "apple"},+        "result": "found it"+    },+    {+        "description": "{\"if\":[{\"missing\":\"a\"},\"missed it\",\"found it\"]}",+        "rule": {+            "if": [ {"missing": "a"}, "missed it", "found it" ]+        },+        "data": {"b": "banana"},+        "result": "missed it"+    },+    "Missing, Merge, and If are friends. VIN is always required, APR is only required if financing is true.",+    {+        "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",+        "rule": {+            "missing": {+                "merge": [+                    "vin",+                    {+                        "if": [ {"var": "financing"}, ["apr"], [] ]+                    }+                ]+            }+        },+        "data": {"financing": true},+        "result": ["vin", "apr"]+    },+    {+        "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",+        "rule": {+            "missing": {+                "merge": [+                    "vin",+                    {+                        "if": [ {"var": "financing"}, ["apr"], [] ]+                    }+                ]+            }+        },+        "data": {"financing": false},+        "result": ["vin"]+    },+    "Filter, map, all, none, and some",+    {+        "description": "{\"filter\":[{\"var\":\"integers\"},true]}",+        "rule": {+            "filter": [ {"var": "integers"}, true ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": [1, 2, 3]+    },+    {+        "description": "{\"filter\":[{\"var\":\"integers\"},false]}",+        "rule": {+            "filter": [ {"var": "integers"}, false ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": []+    },+    {+        "description": "{\"filter\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},2]}]}",+        "rule": {+            "filter": [+                {"var": "integers"},+                {+                    ">=": [ {"var": ""}, 2 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": [2, 3]+    },+    {+        "description": "{\"filter\":[{\"var\":\"integers\"},{\"%\":[{\"var\":\"\"},2]}]}",+        "rule": {+            "filter": [+                {"var": "integers"},+                {+                    "%": [ {"var": ""}, 2 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": [1, 3]+    },+    {+        "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",+        "rule": {+            "map": [+                {"var": "integers"},+                {+                    "*": [ {"var": ""}, 2 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": [2, 4, 6]+    },+    {+        "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",+        "rule": {+            "map": [+                {"var": "integers"},+                {+                    "*": [ {"var": ""}, 2 ]+                }+            ]+        },+        "data": null,+        "result": []+    },+    {+        "description": "{\"map\":[{\"var\":\"desserts\"},{\"var\":\"qty\"}]}",+        "rule": {+            "map": [ {"var": "desserts"}, {"var": "qty"} ]+        },+        "data": {+            "desserts": [+                {"name": "apple"  , "qty": 1},+                {"name": "brownie", "qty": 2},+                {"name": "cupcake", "qty": 3}+            ]+        },+        "result": [1, 2, 3]+    },+    {+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",+        "rule": {+            "reduce": [+                {"var": "integers"},+                {+                    "+": [ {"var": "current"}, {"var": "accumulator"} ]+                },+                0+            ]+        },+        "data": { "integers": [1, 2, 3, 4] },+        "result": 10+    },+    {+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},{\"var\":\"start_with\"}]}",+        "rule": {+            "reduce": [+                {"var": "integers"},+                {+                    "+": [ {"var": "current"}, {"var": "accumulator"} ]+                },+                {"var": "start_with"}+            ]+        },+        "data": { "integers": [1, 2, 3, 4], "start_with": 59 },+        "result": 69+    },+    {+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",+        "rule": {+            "reduce": [+                {"var": "integers"},+                {+                    "+": [ {"var": "current"}, {"var": "accumulator"} ]+                },+                0+            ]+        },+        "data": null,+        "result": 0+    },+    {+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},1]}",+        "rule": {+            "reduce": [+                {"var": "integers"},+                {+                    "*": [ {"var": "current"}, {"var": "accumulator"} ]+                },+                1+            ]+        },+        "data": { "integers": [1, 2, 3, 4] },+        "result": 24+    },+    {+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",+        "rule": {+            "reduce": [+                {"var": "integers"},+                {+                    "*": [ {"var": "current"}, {"var": "accumulator"} ]+                },+                0+            ]+        },+        "data": { "integers": [1, 2, 3, 4] },+        "result": 0+    },+    {+        "description": "{\"reduce\":[{\"var\":\"desserts\"},{\"+\":[{\"var\":\"accumulator\"},{\"var\":\"current.qty\"}]},0]}",+        "rule": {+            "reduce": [+                {"var": "desserts"},+                {+                    "+": [ {"var": "accumulator"}, {"var": "current.qty"} ]+                },+                0+            ]+        },+        "data": {+            "desserts": [+                {"name": "apple"  , "qty": 1},+                {"name": "brownie", "qty": 2},+                {"name": "cupcake", "qty": 3}+            ]+        },+        "result": 6+    },+    {+        "description": "{\"all\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "all": [+                {"var": "integers"},+                {+                    ">=": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": true+    },+    {+        "description": "{\"all\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "all": [+                {"var": "integers"},+                {+                    "==": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": false+    },+    {+        "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "all": [+                {"var": "integers"},+                {+                    "<": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": false+    },+    {+        "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "all": [+                {"var": "integers"},+                {+                    "<": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": {"integers": []},+        "result": false+    },+    {+        "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "all": [+                {"var": "items"},+                {+                    ">=": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": true+    },+    {+        "description": "{\"all\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "all": [+                {"var": "items"},+                {+                    ">": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": false+    },+    {+        "description": "{\"all\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "all": [+                {"var": "items"},+                {+                    "<": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": false+    },+    {+        "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "all": [+                {"var": "items"},+                {+                    ">=": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {"items": []},+        "result": false+    },+    {+        "description": "{\"none\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "none": [+                {"var": "integers"},+                {+                    ">=": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": false+    },+    {+        "description": "{\"none\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "none": [+                {"var": "integers"},+                {+                    "==": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": false+    },+    {+        "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "none": [+                {"var": "integers"},+                {+                    "<": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": true+    },+    {+        "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "none": [+                {"var": "integers"},+                {+                    "<": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": {"integers": []},+        "result": true+    },+    {+        "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "none": [+                {"var": "items"},+                {+                    ">=": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": false+    },+    {+        "description": "{\"none\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "none": [+                {"var": "items"},+                {+                    ">": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": false+    },+    {+        "description": "{\"none\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "none": [+                {"var": "items"},+                {+                    "<": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": true+    },+    {+        "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "none": [+                {"var": "items"},+                {+                    ">=": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {"items": []},+        "result": true+    },+    {+        "description": "{\"some\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "some": [+                {"var": "integers"},+                {+                    ">=": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": true+    },+    {+        "description": "{\"some\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "some": [+                {"var": "integers"},+                {+                    "==": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": true+    },+    {+        "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "some": [+                {"var": "integers"},+                {+                    "<": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": { "integers": [1, 2, 3] },+        "result": false+    },+    {+        "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+        "rule": {+            "some": [+                {"var": "integers"},+                {+                    "<": [ {"var": ""}, 1 ]+                }+            ]+        },+        "data": {"integers": []},+        "result": false+    },+    {+        "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "some": [+                {"var": "items"},+                {+                    ">=": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": true+    },+    {+        "description": "{\"some\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "some": [+                {"var": "items"},+                {+                    ">": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": true+    },+    {+        "description": "{\"some\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "some": [+                {"var": "items"},+                {+                    "<": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+        },+        "result": false+    },+    {+        "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+        "rule": {+            "some": [+                {"var": "items"},+                {+                    ">=": [ {"var": "qty"}, 1 ]+                }+            ]+        },+        "data": {"items": []},+        "result": false+    },+    "EOF"+]
+ test/community/control/and.json view
@@ -0,0 +1,155 @@+[+    "Basic Checks",+    {+        "description": "Two true values should be true",+        "rule": { "and": [true, true] },+        "data": null,+        "result": true+    },+    {+        "description": "false and true should be false",+        "rule": { "and": [false, true] },+        "data": null,+        "result": false+    },+    {+        "description": "true and false should be false",+        "rule": { "and": [true, false] },+        "data": null,+        "result": false+    },+    {+        "description": "Two false values should be false",+        "rule": { "and": [false, false] },+        "data": null,+        "result": false+    },+    {+        "description": "All true values should be true",+        "rule": { "and": [true, true, true] },+        "data": null,+        "result": true+    },+    {+        "description": "Any false value should be false",+        "rule": { "and": [true, true, false] },+        "data": null,+        "result": false+    },+    {+        "description": "And with a single false value should be false",+        "rule": { "and": [false] },+        "data": null,+        "result": false+    },+    {+        "description": "When all values are truthy, the last truthy value should be returned",+        "rule": { "and": [1, 2, 3] },+        "data": null,+        "result": 3+    },+    {+        "description": "When all values are truthy, the last truthy value should be returned (2)",+        "rule": { "and": [true, 1, {}] },+        "data": null,+        "result": {}+    },+    {+        "description": "And with a single falsey value should be return the falsy value",+        "rule": { "and": [[]] },+        "data": null,+        "result": []+    },+    {+        "description": "And with a single falsey value should be return the falsy value (2)",+        "rule": { "and": [0] },+        "data": null,+        "result": 0+    },+    {+        "description": "And with a single falsey value should be return the falsy value (3)",+        "rule": { "and": [""] },+        "data": null,+        "result": ""+    },+    {+        "description": "And with a single true value should be true",+        "rule": { "and": [true] },+        "data": null,+        "result": true+    },+    {+        "description": "And with 2 truthy values should return the last truthy value",+        "rule": { "and": [1, 3] },+        "data": null,+        "result": 3+    },+    {+        "description": "And with a truthy value and a false value should return the false value",+        "rule": { "and": [3, false] },+        "data": null,+        "result": false+    },+    {+        "description": "And with a truthy value and a false value should return the false value (2)",+        "rule": { "and": [false, 3] },+        "data": null,+        "result": false+    },+    {+        "description": "Empty array is falsy, so it is returned",+        "rule": { "and": [[], true] },+        "data": null,+        "result": []+    },+    {+        "description": "0 is falsy, so it is returned",+        "rule": { "and": [0, true] },+        "data": null,+        "result": 0+    },+    {+        "description": "Empty string is falsy, so it is returned",+        "rule": { "and": ["", true] },+        "data": null,+        "result": ""+    },+    {+        "description": "0 as a string is truthy, so the last truthy value is returned",+        "rule": { "and": ["0", true] },+        "data": null,+        "result": true+    },+    {+        "description": "And with no arguments should return false",+        "rule": { "and": [] },+        "data": null,+        "result": false+    },+    "Valid Arguments Checks",+    {+        "description": "And with non-array arguments should throw",+        "rule": { "and": true },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    "Short Circuiting Checks",+    {+        "description": "And will not interpret the second argument if the first is false",+        "rule": { "and": [false, { "throw": "Not Lazy" }] },+        "data": null,+        "result": false+    },+    {+        "description": "And will not interpret the second argument if the first is falsy",+        "rule": { "and": [0, { "throw": "Not Lazy" }] },+        "data": null,+        "result": 0+    },+    {+        "description": "And will not interpret the nth argument if any value before it is false",+        "rule": { "and": [true, 1, 2, 3, 4, [], { "throw": "Not Lazy" }] },+        "data": null,+        "result": []+    }+]
+ test/community/control/doublebang.json view
@@ -0,0 +1,143 @@+[+    "Basic Checks",+    {+        "description": "Double bang on true returns true",+        "rule": { "!!": [true] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on false returns false",+        "rule": { "!!": [false] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang on null returns false",+        "rule": { "!!": [null] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang on empty array returns false",+        "rule": { "!!": [[]] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang on non-empty array returns true",+        "rule": { "!!": [[1]] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on empty string returns false",+        "rule": { "!!": [""] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang on non-empty string returns true",+        "rule": { "!!": ["hello"] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on zero returns false",+        "rule": { "!!": [0] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang on one returns true",+        "rule": { "!!": [1] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on string zero returns true",+        "rule": { "!!": ["0"] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on empty object returns true",+        "rule": { "!!": [{}] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on empty string returns false",+        "rule": { "!!": [""] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang preserves truthy values",+        "rule": { "!!": [[1, 2]] },+        "data": null,+        "result": true+    },+    "Valid Arguments Checks",+    {+        "description": "Double bang without arguments return false",+        "rule": { "!!": [] },+        "data": null,+        "result": false+    },+    "Complex Evaluations",+    {+        "description": "Double bang with nested var operation",+        "rule": { "!!": [{"var": "value"}] },+        "data": {"value": false},+        "result": false+    },+    {+        "description": "Double bang with nested comparison",+        "rule": { "!!": [{"==": [1, 2]}] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang with nested AND",+        "rule": { "!!": [{"and": [true, true]}] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang with nested OR",+        "rule": { "!!": [{"or": [false, false]}] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang with missing variable returns false",+        "rule": { "!!": [{"var": "missing"}] },+        "data": {},+        "result": false+    },+    {+        "description": "Double bang after NOT negates the NOT",+        "rule": { "!!": [{"!": [true]}] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang with undefined variable",+        "rule": { "!!": [{"var": "undefined"}] },+        "data": null,+        "result": false+    },+    {+        "description": "Double bang on negative number returns true",+        "rule": { "!!": [-1] },+        "data": null,+        "result": true+    },+    {+        "description": "Double bang on NaN returns false",+        "rule": { "!!": [{"*": [0, 0]}] },+        "data": null,+        "result": false+    }+]
+ test/community/control/if.json view
@@ -0,0 +1,277 @@+[+    "Boolean checks",+    {+        "description": "true is true, and uses the then",+        "rule": { "if": [true, "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "false is false, and uses the fallback",+        "rule": { "if": [false, "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    "Null check",+    {+        "description": "null is falsy",+        "rule": { "if": [[], "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    "Object checks",+    {+        "description": "Objects are always truthy, even without keys.",+        "rule": { "if": [{}, "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Objects are always truthy, with keys defined",+        "rule": { "if": [{ "val": [] }, "apple", "banana"] },+        "data": { "some": "value" },+        "result": "apple"+    },+    "Array checks",+    {+        "description": "Empty array is falsy",+        "rule": { "if": [[], "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "Array with single element is truthy",+        "rule": { "if": [[1], "apple", "banana" ] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Array with multiple elements is truthy",+        "rule": { "if": [[1, 2, 3, 4], "apple", "banana" ] },+        "data": null,+        "result": "apple"+    },+    "String checks",+    {+        "description": "Empty string is falsy",+        "rule": { "if": ["", "apple", "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "Any non-empty string is truthy",+        "rule": { "if": ["zucchini", "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Any non-empty string is truthy, even 0",+        "rule": { "if": ["0", "apple", "banana"] },+        "data": null,+        "result": "apple"+    },+    "Too few arguments",+    { +        "description": "No arguments is just null.", +        "rule": { "if": [] }, +        "data": null, +        "result": null +    },+    {+        "description": "If no then is defined when true, it'll just return null.",+        "rule": { "if": [true] },+        "data": null,+        "result": true+    },+    {+        "description": "If no fallback is defined when falsy, it'll just return null.",+        "rule": { "if": [false] },+        "data": null,+        "result": false+    },+    {+        "description": "If no then is defined when truthy, it'll just return null.",+        "rule": { "if": ["apple"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "When truthy, it'll return the 'then' even if no fallback is defined.",+        "rule": { "if": [true, "apple"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "When falsy, it'll return null if the fallback is not defined, even if the 'then' is defined.",+        "rule": { "if": [false, "apple"] },+        "data": null,+        "result": null+    },+    "Variadic Tests",+    {+        "description": "Returns the first truthy if condition",+        "rule": { "if": [true, "apple", false, "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Returns the first truthy then condition, even if multiple conditions are true",+        "rule": { "if": [true, "apple", true, "banana"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Retuns the first truthy condition, with the fallback defined.",+        "rule": { "if": [true, "apple", false, "banana", "carrot"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Returns the first truthy condition, with the fallback defined; multiple true conditions.",+        "rule": { "if": [true, "apple", true, "banana", "carrot"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Returns the first truthy condition, with the first condition falsy. Fallback defined.",+        "rule": { "if": [false, "apple", true, "banana", "carrot"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "Returns the first truthy condition, in this case it's the 2nd one.",+        "rule": { "if": [false, "apple", true, "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "When none of the conditions are true, it returns the fallback.",+        "rule": { "if": [false, "apple", false, "banana", "carrot"] },+        "data": null,+        "result": "carrot"+    },+    {+        "description": "When none of the conditions are true, it returns the fallback. In this case the fallback is not defined, so it is null.",+        "rule": { "if": [false, "apple", false, "banana"] },+        "data": null,+        "result": null+    },+    {+        "description": "3 Conditions, all falsy. Returns fallback.",+        "rule": { "if": [false, "apple", false, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "date"+    },+    {+        "description": "3 Conditions, all falsy, no fallback defined. Returns null.",+        "rule": { "if": [false, "apple", false, "banana", false, "carrot"] },+        "data": null,+        "result": null+    },+    {+        "description": "First condition truthy, 3 conditions.",+        "rule": { "if": [true, "apple", false, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },++    {+        "description": "2nd Condition is Truthy",+        "rule": { "if": [false, "apple", true, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "Third Condition is Truthy",+        "rule": { "if": [false, "apple", false, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "carrot"+    },+    {+        "description": "Returns first truthy value, some conditions truthy",+        "rule": { "if": [false, "apple", true, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "Returns first truthy value, some conditions truthy (2)",+        "rule": { "if": [true, "apple", false, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Returns first truthy value, some conditions truthy (3)",+        "rule": { "if": [true, "apple", true, "banana", false, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Returns first truthy value, all conditions truthy",+        "rule": { "if": [true, "apple", true, "banana", true, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    "Some Variadic Checks with Other Types",+    {+        "description": "Returns first truthy value, some conditions truthy (4)",+        "rule": { "if": [false, "apple", [1], "banana", true, "carrot", "date"] },+        "data": null,+        "result": "banana"+    },   +    {+        "description": "Returns first truthy value, some conditions truthy (5)",+        "rule": { "if": [false, "apple", "This is true", "banana", true, "carrot", "date"] },+        "data": null,+        "result": "banana"+    },   +    {+        "description": "Returns first truthy value, some conditions truthy (6)",+        "rule": { "if": [null, "apple", 0, "banana", 7, "carrot", "date"] },+        "data": null,+        "result": "carrot"+    },+    {+        "description": "Returns first truthy value, some conditions truthy (7)",+        "rule": { "if": ["0", "apple", 0, "banana", 7, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "Returns first truthy value, some conditions truthy (8)",+        "rule": { "if": [{}, "apple", 0, "banana", 7, "carrot", "date"] },+        "data": null,+        "result": "apple"+    },+    "Bad Arguments",+    {+        "description": "If with non-array arguments throws",+        "rule": { "if": "apple" },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    "Short circuiting check",+    {+        "description": "If will not evaluate then branch if the condition is false",+        "rule": { "if": [false, { "throw": "Not Lazy" }, "banana"] },+        "data": null,+        "result": "banana"+    },+    {+        "description": "If will not evaluate fallback branch if the condition is true",+        "rule": { "if": [true, "apple", { "throw": "Not Lazy" }] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "If will not evaluate other branches or checks if a prior condition is true",+        "rule": { "if": [false, { "throw": "Not Lazy" }, true, "apple", { "throw": "Not Lazy" }, { "throw": "Not Lazy" }, { "throw": "Not Lazy" }] },+        "data": null,+        "result": "apple"+    },+    {+        "description": "No branches except the fallback check will be evaluated if the condition is false",+        "rule": { "if": [false, { "throw": "Not Lazy" }, false, { "throw": "Not Lazy" }, false, { "throw": "Not Lazy" }, "carrot"] },+        "data": null,+        "result": "carrot"+    }+]
+ test/community/control/not.json view
@@ -0,0 +1,143 @@+[+    "Basic Checks",+    {+        "description": "NOT true returns false",+        "rule": { "!": [true] },+        "data": null,+        "result": false+    },+    {+        "description": "NOT false returns true",+        "rule": { "!": [false] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT null returns true",+        "rule": { "!": [null] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT empty array returns true",+        "rule": { "!": [[]] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT non-empty array returns false",+        "rule": { "!": [[1]] },+        "data": null,+        "result": false+    },+    {+        "description": "NOT empty string returns true",+        "rule": { "!": [""] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT non-empty string returns false",+        "rule": { "!": ["hello"] },+        "data": null,+        "result": false+    },+    {+        "description": "NOT zero returns true",+        "rule": { "!": [0] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT one returns false",+        "rule": { "!": [1] },+        "data": null,+        "result": false+    },+    {+        "description": "NOT string zero returns false",+        "rule": { "!": ["0"] },+        "data": null,+        "result": false+    },+    {+        "description": "NOT empty object returns false",+        "rule": { "!": [{}] },+        "data": null,+        "result": false+    },+    {+        "description": "NOT with non-array argument",+        "rule": { "!": true },+        "data": null,+        "result": false+    },+    {+        "description": "NOT with non-array argument false",+        "rule": { "!": false },+        "data": null,+        "result": true+    },+    {+        "description": "NOT with non-array argument 0",+        "rule": { "!": 0 },+        "data": null,+        "result": true+    },+    {+        "description": "NOT with non-array argument 1",+        "rule": { "!": 1 },+        "data": null,+        "result": false+    },+    "Valid Arguments Checks",+    {+        "description": "NOT without arguments returns true",+        "rule": { "!": [] },+        "data": null,+        "result": true+    },+    "Complex Evaluations",+    {+        "description": "NOT with nested var operation",+        "rule": { "!": [{"var": "value"}] },+        "data": {"value": false},+        "result": true+    },+    {+        "description": "NOT with nested comparison",+        "rule": { "!": [{"==": [1, 2]}] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT with nested AND",+        "rule": { "!": [{"and": [true, false]}] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT with nested OR",+        "rule": { "!": [{"or": [false, false]}] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT with missing variable returns true",+        "rule": { "!": [{"var": "missing"}] },+        "data": {},+        "result": true+    },+    {+        "description": "Double NOT preserves original boolean",+        "rule": { "!": [{"!": [true]}] },+        "data": null,+        "result": true+    },+    {+        "description": "NOT with undefined variable",+        "rule": { "!": [{"var": "undefined"}] },+        "data": null,+        "result": true+    }+]
+ test/community/control/or.json view
@@ -0,0 +1,150 @@+[+    "Basic Checks",+    {+        "description": "Two true values returns first true",+        "rule": { "or": [true, true] },+        "data": null,+        "result": true+    },+    {+        "description": "false and true returns true",+        "rule": { "or": [false, true] },+        "data": null,+        "result": true+    },+    {+        "description": "true and false returns true",+        "rule": { "or": [true, false] },+        "data": null,+        "result": true+    },+    {+        "description": "Two false values returns last false",+        "rule": { "or": [false, false] },+        "data": null,+        "result": false+    },+    {+        "description": "Empty OR returns false",+        "rule": { "or": [] },+        "result": false,+        "data": null+    },+    {+        "description": "OR with single truthy value returns that value",+        "rule": { "or": [1] },+        "result": 1,+        "data": null+    },+    {+        "description": "OR with single falsy value returns that value",+        "rule": { "or": [0] },+        "result": 0,+        "data": null+    },+    {+        "description": "OR with multiple values returns first truthy value",+        "rule": { "or": [false, 0, null, "hello", 1, 2] },+        "result": "hello",+        "data": null+    },+    {+        "description": "When all values are falsy, the last falsy value should be returned",+        "rule": { "or": [false, 0, "", null] },+        "result": null,+        "data": null+    },+    {+        "description": "OR with a single empty array returns empty array",+        "rule": { "or": [[]] },+        "data": null,+        "result": []+    },+    {+        "description": "OR with a single zero returns zero",+        "rule": { "or": [0] },+        "data": null,+        "result": 0+    },+    {+        "description": "OR with a single empty string returns empty string",+        "rule": { "or": [""] },+        "data": null,+        "result": ""+    },+    {+        "description": "OR with 2 truthy values returns first truthy value",+        "rule": { "or": [1, 3] },+        "data": null,+        "result": 1+    },+    {+        "description": "Empty array then true returns true",+        "rule": { "or": [[], true] },+        "data": null,+        "result": true+    },+    {+        "description": "Zero then true returns true",+        "rule": { "or": [0, true] },+        "data": null,+        "result": true+    },+    {+        "description": "Empty string then true returns true",+        "rule": { "or": ["", true] },+        "data": null,+        "result": true+    },+    {+        "description": "String zero is truthy and returned",+        "rule": { "or": ["0", false] },+        "data": null,+        "result": "0"+    },+    "Valid Arguments Checks",+    {+        "description": "OR with non-array arguments should throw",+        "rule": { "or": true },+        "error": {"type": "Invalid Arguments"},+        "data": null+    },+    "Short Circuiting Checks",+    {+        "description": "OR will not interpret the second argument if the first is true",+        "rule": { "or": [true, {"throw": "Not Lazy"}] },+        "data": null,+        "result": true+    },+    {+        "description": "OR will not interpret the second argument if the first is truthy",+        "rule": { "or": [1, {"throw": "Not Lazy"}] },+        "data": null,+        "result": 1+    },+    {+        "description": "OR will not interpret the nth argument if any value before it is truthy",+        "rule": { "or": [false, 0, null, "hello", {"throw": "Not Lazy"}] },+        "data": null,+        "result": "hello"+    },+    "Complex Evaluations",+    {+        "description": "OR with nested operations evaluates left to right",+        "rule": { "or": [{"var": "a"}, {"var": "b"}, {"var": "c"}] },+        "data": {"a": false, "b": "yes", "c": "not reached"},+        "result": "yes"+    },+    {+        "description": "OR with nested logic",+        "rule": { "or": [{"==": [1, 2]}, {"==": [2, 2]}] },+        "result": true,+        "data": null+    },+    {+        "description": "OR with deeply nested operations",+        "rule": { "or": [{"and": [false, true]}, {"and": [true, true]}] },+        "result": true,+        "data": null+    }+]
+ test/community/exists.json view
@@ -0,0 +1,51 @@+[+    "Test Specification for exists",+    {+        "description": "Checks if a normal key exists",+        "rule": { "exists": "hello" },+        "data": { "hello" : 1 },+        "result": true+    },+    {+        "description": "Checks if a normal key exists (array)",+        "rule": { "exists": ["hello"] },+        "data": { "hello" : 1 },+        "result": true+    },+    {+        "description": "Checks if a normal key exists (false)",+        "rule": { "exists": "hello" },+        "data": { "world" : 1 },+        "result": false+    },+    {+        "description": "Checks if an empty key exists (true)",+        "rule": { "exists": [""] },+        "data": { "" : 1 },+        "result": true+    },+    {+        "description": "Checks if an empty key exists (false)",+        "rule": { "exists": [""] },+        "data": { "hello" : 1 },+        "result": false+    },+    {+        "description": "Checks if a nested key exists",+        "rule": { "exists": ["hello", "world"] },+        "data": { "hello" : { "world": false } },+        "result": true+    },+    {+        "description": "Checks if a nested key exists (false)",+        "rule": { "exists": ["hello", "world"] },+        "data": { "hello" : { "x": false } },+        "result": false+    },+    {+        "description": "Checks if a null value exists",+        "rule": { "exists": "hello" },+        "data": { "hello" : null },+        "result": true+    }+]
+ test/community/expected.json view
@@ -0,0 +1,50 @@+{+  "compatible.json": {"cases":278,"lenient":278,"strict":278},+  "arithmetic/plus.json": {"cases":32,"lenient":17,"strict":17},+  "arithmetic/plus.extra.json": {"cases":3,"lenient":0,"strict":0},+  "arithmetic/multiply.json": {"cases":28,"lenient":13,"strict":13},+  "arithmetic/multiply.extra.json": {"cases":3,"lenient":0,"strict":0},+  "arithmetic/minus.json": {"cases":22,"lenient":13,"strict":14},+  "arithmetic/minus.extra.json": {"cases":3,"lenient":0,"strict":0},+  "arithmetic/divide.json": {"cases":31,"lenient":6,"strict":6},+  "arithmetic/divide.extra.json": {"cases":3,"lenient":0,"strict":1},+  "arithmetic/modulo.json": {"cases":31,"lenient":12,"strict":25},+  "arithmetic/modulo.extra.json": {"cases":2,"lenient":0,"strict":2},+  "comparison/greaterThan.json": {"cases":35,"lenient":24,"strict":26},+  "comparison/greaterThanEquals.json": {"cases":28,"lenient":17,"strict":19},+  "comparison/lessThan.json": {"cases":45,"lenient":35,"strict":38},+  "comparison/lessThanEquals.json": {"cases":20,"lenient":13,"strict":13},+  "comparison/softEquals.json": {"cases":35,"lenient":24,"strict":25},+  "comparison/softNotEquals.json": {"cases":34,"lenient":23,"strict":25},+  "comparison/strictEquals.json": {"cases":31,"lenient":26,"strict":28},+  "comparison/strictNotEquals.json": {"cases":30,"lenient":26,"strict":28},+  "control/and.json": {"cases":25,"lenient":23,"strict":23},+  "control/if.json": {"cases":44,"lenient":42,"strict":42},+  "control/or.json": {"cases":24,"lenient":22,"strict":22},+  "control/not.json": {"cases":23,"lenient":23,"strict":22},+  "control/doublebang.json": {"cases":23,"lenient":23,"strict":22},+  "string/in.json": {"cases":8,"lenient":8,"strict":8},+  "string/cat.json": {"cases":9,"lenient":9,"strict":9},+  "string/substr.json": {"cases":12,"lenient":12,"strict":12},+  "array/map.json": {"cases":14,"lenient":12,"strict":12},+  "array/filter.json": {"cases":12,"lenient":10,"strict":10},+  "array/reduce.json": {"cases":9,"lenient":8,"strict":8},+  "array/merge.json": {"cases":8,"lenient":8,"strict":8},+  "array/all.json": {"cases":12,"lenient":10,"strict":10},+  "array/some.json": {"cases":13,"lenient":11,"strict":11},+  "array/none.json": {"cases":13,"lenient":11,"strict":11},+  "truthiness.json": {"cases":13,"lenient":9,"strict":9},+  "additional.json": {"cases":4,"lenient":0,"strict":0},+  "coalesce.json": {"cases":15,"lenient":0,"strict":0},+  "chained.json": {"cases":7,"lenient":2,"strict":2},+  "iterators.extra.json": {"cases":34,"lenient":0,"strict":28},+  "exists.json": {"cases":8,"lenient":0,"strict":0},+  "scopes.json": {"cases":4,"lenient":0,"strict":0},+  "throw.json": {"cases":3,"lenient":0,"strict":0},+  "try.json": {"cases":18,"lenient":0,"strict":0},+  "try.extra.json": {"cases":1,"lenient":0,"strict":0},+  "val.json": {"cases":13,"lenient":0,"strict":0},+  "val.extra.json": {"cases":3,"lenient":0,"strict":0},+  "val-compat.json": {"cases":60,"lenient":0,"strict":0},+  "var.extra.json": {"cases":12,"lenient":12,"strict":12}+}
+ test/community/index.json view
@@ -0,0 +1,50 @@+[+    "compatible.json",+    "arithmetic/plus.json",+    "arithmetic/plus.extra.json",+    "arithmetic/multiply.json",+    "arithmetic/multiply.extra.json",+    "arithmetic/minus.json",+    "arithmetic/minus.extra.json",+    "arithmetic/divide.json",+    "arithmetic/divide.extra.json",+    "arithmetic/modulo.json",+    "arithmetic/modulo.extra.json",+    "comparison/greaterThan.json",+    "comparison/greaterThanEquals.json",+    "comparison/lessThan.json",+    "comparison/lessThanEquals.json",+    "comparison/softEquals.json",+    "comparison/softNotEquals.json",+    "comparison/strictEquals.json",+    "comparison/strictNotEquals.json",+    "control/and.json",+    "control/if.json",+    "control/or.json",+    "control/not.json",+    "control/doublebang.json",+    "string/in.json",+    "string/cat.json",+    "string/substr.json",+    "array/map.json",+    "array/filter.json",+    "array/reduce.json",+    "array/merge.json",+    "array/all.json",+    "array/some.json",+    "array/none.json",+    "truthiness.json",+    "additional.json",+    "coalesce.json",+    "chained.json",+    "iterators.extra.json",+    "exists.json",+    "scopes.json",+    "throw.json",+    "try.json",+    "try.extra.json",+    "val.json",+    "val.extra.json",+    "val-compat.json",+    "var.extra.json"+]
+ test/community/iterators.extra.json view
@@ -0,0 +1,207 @@+[+    {+        "description": "if with dynamic arguments throws",+        "rule": { "if": { "preserve": [true, 1, 2] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "or with dynamic arguments throws",+        "rule": { "or": { "preserve": [false, false, true] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "and with dynamic arguments throws",+        "rule": { "and": { "preserve": [true, true, true] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "map with dynamic arguments throws",+        "rule": { "map": { "preserve": [[1,2,3], { "val": [] }]} },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "filter with dynamic arguments throws",+        "rule": { "filter": { "preserve": [[1,2,3], { "val": [] }]} },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "reduce with dynamic arguments throws",+        "rule": { "reduce": { "preserve": [[1,2,3], { "val": [] }]} },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "some with dynamic arguments throws",+        "rule": { "some": { "preserve": [[1,2,3], { "val": [] }]} },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "all with dynamic arguments throws",+        "rule": { "all": { "preserve": [[1,2,3], { "val": [] }]} },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "none with dynamic arguments throws",+        "rule": { "none": { "preserve": [[1,2,3], { "val": [] }]} },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "< with dynamic arguments throws",+        "rule": { "<": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "<= with dynamic arguments throws",+        "rule": { "<=": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "> with dynamic arguments throws",+        "rule": { ">": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": ">= with dynamic arguments throws",+        "rule": { ">=": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "== with dynamic arguments throws",+        "rule": { "==": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!= with dynamic arguments throws",+        "rule": { "!=": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "=== with dynamic arguments throws",+        "rule": { "===": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!== with dynamic arguments throws",+        "rule": { "!==": { "preserve": [1,2,3] } },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    "Any of the operators listed when called with a non-array argument, will throw",+    {+        "description": "if with static non-array argument throws",+        "rule": { "if": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "or with static non-array argument throws",+        "rule": { "or": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "and with static non-array argument throws",+        "rule": { "and": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "map with static non-array argument throws",+        "rule": { "map": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "filter with static non-array argument throws",+        "rule": { "filter": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "reduce with static non-array argument throws",+        "rule": { "reduce": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "some with static non-array argument throws",+        "rule": { "some": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "all with static non-array argument throws",+        "rule": { "all": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "none with static non-array argument throws",+        "rule": { "none": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "< with static non-array argument throws",+        "rule": { "<": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "<= with static non-array argument throws",+        "rule": { "<=": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "> with static non-array argument throws",+        "rule": { ">": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": ">= with static non-array argument throws",+        "rule": { ">=": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "== with static non-array argument throws",+        "rule": { "==": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!= with static non-array argument throws",+        "rule": { "!=": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "=== with static non-array argument throws",+        "rule": { "===": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    },+    {+        "description": "!== with static non-array argument throws",+        "rule": { "!==": 5 },+        "data": null,+        "error": { "type": "Invalid Arguments" }+    }+]
+ test/community/scopes.json view
@@ -0,0 +1,67 @@+[+    "A proposal for handling scopes",+    {+        "description": "Map can add each number to index",+        "rule": {+            "map": [+                { "val": "numbers" },+                { "+": [{ "val": [[1], "index"] }, { "val": [] }]}+            ]+        },+        "data": { "numbers": [1,2,3] },+        "result": [1,3,5]+    },+    {+        "description": "Map can add each number to value from context",+        "rule": {+            "map": [+                { "val": "numbers" },+                { "+": [{ "val": [[2], "value"] }, { "val": [] }]}+            ]+        },+        "data": { "numbers": [1,2,3], "value": 10 },+        "result": [11,12,13]+    },+    {+        "description": "Filter can use parent context to filter",+        "rule": {+            "filter": [+                { "val": "people" },+                { "===": [{ "val": "department" }, { "val": [[2], "department"] }] }+            ]+        },+        "data": {+            "department": "Engineering",+            "people": [+                { "name": "Jay Ortiz", "department": "Engineering" },+                { "name": "Louisa Hall", "department": "Sales" },+                { "name": "Kyle Carlson", "department": "Sales" },+                { "name": "Grace Ortiz", "department": "Engineering" },+                { "name": "Isabelle Harrington", "department": "Marketing" },+                { "name": "Harold Moore", "department": "Sales" },+                { "name": "Clarence Schultz", "department": "Sales" },+                { "name": "Jesse Keller", "department": "Engineering" },+                { "name": "Phillip Holland", "department": "Marketing" },+                { "name": "Mason Sullivan", "department": "Engineering" }+            ]+        },+        "result": [+            { "name": "Jay Ortiz", "department": "Engineering" },+            { "name": "Grace Ortiz", "department": "Engineering" },+            { "name": "Jesse Keller", "department": "Engineering" },+            { "name": "Mason Sullivan", "department": "Engineering" }+        ]+    },+    "The specification below depends upon escaping from another proposal",+    {+        "description": "Access an escaped key from above",+        "rule": { +            "map": [+                { "val": "arr" }, +                { "+": [{ "val": [[2], "", "", "/"] }, { "val": [[2], "../"] }]}+            ] +        },+        "data": { "arr": [1,2,3], "../": 10, "": { "": { "/": 7 }} },+        "result": [17,17,17]+    }+]
+ test/community/string/cat.json view
@@ -0,0 +1,60 @@+[+    "Basic Concatenation",+    {+        "description": "Cat with single string argument",+        "rule": { "cat": "ice" },+        "data": null,+        "result": "ice"+    },+    {+        "description": "Cat with array of single string",+        "rule": { "cat": ["ice"] },+        "data": null,+        "result": "ice"+    },+    {+        "description": "Cat with two strings",+        "rule": { "cat": ["ice", "cream"] },+        "data": null,+        "result": "icecream"+    },+    "Type Coercion",+    {+        "description": "Cat with numbers",+        "rule": { "cat": [1, 2] },+        "data": null,+        "result": "12"+    },+    {+        "description": "Cat with mixed types",+        "rule": { "cat": ["Robocop", 2] },+        "data": null,+        "result": "Robocop2"+    },+    "Multiple Arguments",+    {+        "description": "Cat with multiple arguments",+        "rule": { "cat": ["we all scream for ", "ice", "cream"] },+        "data": null,+        "result": "we all scream for icecream"+    },+    "Edge Cases",+    {+        "description": "Cat with no arguments returns empty string",+        "rule": { "cat": [] },+        "data": null,+        "result": ""+    },+    {+        "description": "Cat with null values",+        "rule": { "cat": [null, "test", null] },+        "data": null,+        "result": "test"+    },+    {+        "description": "Cat with boolean values",+        "rule": { "cat": [true, false, "test"] },+        "data": null,+        "result": "truefalsetest"+    }+]
+ test/community/string/in.json view
@@ -0,0 +1,53 @@+[+    "Basic Array Checks",+    {+        "description": "In operator with array finds exact match",+        "rule": { "in": ["Bart", ["Bart", "Homer", "Lisa"]] },+        "data": null,+        "result": true+    },+    {+        "description": "In operator with array returns false for no match",+        "rule": { "in": ["Milhouse", ["Bart", "Homer", "Lisa"]] },+        "data": null,+        "result": false+    },+    "String Contains Checks",+    {+        "description": "In operator with string finds substring",+        "rule": { "in": ["Spring", "Springfield"] },+        "data": null,+        "result": true+    },+    {+        "description": "In operator with string is case sensitive",+        "rule": { "in": ["spring", "Springfield"] },+        "data": null,+        "result": false+    },+    {+        "description": "In operator with string returns false for no match",+        "rule": { "in": ["i", "team"] },+        "data": null,+        "result": false+    },+    "Edge Cases",+    {+        "description": "In operator with empty array returns false",+        "rule": { "in": ["test", []] },+        "data": null,+        "result": false+    },+    {+        "description": "In operator with empty string search returns true",+        "rule": { "in": ["", "test"] },+        "data": null,+        "result": true+    },+    {+        "description": "In operator with empty string target returns false",+        "rule": { "in": ["test", ""] },+        "data": null,+        "result": false+    }+]
+ test/community/string/substr.json view
@@ -0,0 +1,78 @@+[+    "Basic Substring",+    {+        "description": "Substr from start index",+        "rule": { "substr": ["jsonlogic", 4] },+        "data": null,+        "result": "logic"+    },+    {+        "description": "Substr with negative start index",+        "rule": { "substr": ["jsonlogic", -5] },+        "data": null,+        "result": "logic"+    },+    "Start and Length",+    {+        "description": "Substr with start and positive length",+        "rule": { "substr": ["jsonlogic", 0, 1] },+        "data": null,+        "result": "j"+    },+    {+        "description": "Substr with negative start and positive length",+        "rule": { "substr": ["jsonlogic", -1, 1] },+        "data": null,+        "result": "c"+    },+    {+        "description": "Substr with start and length",+        "rule": { "substr": ["jsonlogic", 4, 5] },+        "data": null,+        "result": "logic"+    },+    {+        "description": "Substr with negative start and length",+        "rule": { "substr": ["jsonlogic", -5, 5] },+        "data": null,+        "result": "logic"+    },+    "Negative Length",+    {+        "description": "Substr with negative length from end",+        "rule": { "substr": ["jsonlogic", -5, -2] },+        "data": null,+        "result": "log"+    },+    {+        "description": "Substr with positive start and negative length",+        "rule": { "substr": ["jsonlogic", 1, -5] },+        "data": null,+        "result": "son"+    },+    "Edge Cases",+    {+        "description": "Substr with empty string",+        "rule": { "substr": ["", 0, 1] },+        "data": null,+        "result": ""+    },+    {+        "description": "Substr with start beyond string length",+        "rule": { "substr": ["test", 10, 1] },+        "data": null,+        "result": ""+    },+    {+        "description": "Substr with negative start beyond string length",+        "rule": { "substr": ["test", -10, 1] },+        "data": null,+        "result": "t"+    },+    {+        "description": "Substr with non-string input returns empty",+        "rule": { "substr": [42, 0, 1] },+        "data": null,+        "result": "4"+    }+]
+ test/community/throw.json view
@@ -0,0 +1,20 @@+[+    {+        "description": "Throws hello as an error",+        "rule": { "throw": "hello" },+        "data": null,+        "error": { "type": "hello" }+    },+    {+        "description": "Throwing NaN produces an error object or measured equivalent (equivalence class test)",+        "rule": { "throw": "NaN" },+        "data": null,+        "error": { "type": "NaN" }+    },+    {+        "description": "Can throw an error object",+        "rule": { "throw": { "val": "x" } },+        "data": { "x": { "type": "Some error" }},+        "error": { "type": "Some error" }+    }+]
+ test/community/truthiness.json view
@@ -0,0 +1,83 @@+[+    "# This can also be seen as a test suite for !!",+    "# Primitive Truthiness tests",+    {+        "description": "Truthy: true",+        "rule": { "!!": true },+        "result": true,+        "data": null+    },+    {+        "description": "Falsey: false",+        "rule": { "!!": false },+        "result": false,+        "data": null+    },+    {+        "description": "Truthy: 1",+        "rule": { "!!": 1 },+        "result": true,+        "data": null+    },+    {+        "description": "Falsey: 0",+        "rule": { "!!": 0 },+        "result": false,+        "data": null+    },+    {+        "description": "Truthy: -1",+        "rule": { "!!": -1 },+        "result": true,+        "data": null+    },+    {+        "description": "Falsey: []",+        "rule": { "!!": [[]] },+        "result": false,+        "data": null+    },+    {+        "description": "Truthy: {}",+        "rule": { "!!": [{}] },+        "result": true,+        "data": null+    },+    {+        "description": "Falsey: ''",+        "rule": { "!!": "" },+        "result": false,+        "data": null+    },+    {+        "description": "Falsey: null",+        "rule": { "!!": null },+        "result": false,+        "data": null+    },+    "# Context Object Tests",+    {+        "description": "Truthy: Zero Key Object",+        "rule": { "!!": { "val": "obj" } },+        "result": true,+        "data": { "obj": {} }+    },+    {+        "description": "Truthy: Single Key Object",+        "rule": { "!!": { "val": "obj" } },+        "result": true,+        "data": { "obj": { "a": 0 } }+    },+    {+        "description": "Truthy: Multi Key Object",+        "rule": { "!!": { "val": "obj" } },+        "result": true,+        "data": { "obj": { "a": 0, "b": 0 } }+    },+    {+        "description": "Truthy: Single Key Object (Empty Key)",+        "rule": { "!!": { "val": "obj" } },+        "result": true,+        "data": { "obj": { "": 0 } }+    }+]
+ test/community/try.extra.json view
@@ -0,0 +1,17 @@+[+    "# Not Proposed",+    {+        "description": "Try can work further up the AST with Exceptions; Grabbing other Context value",+        "rule": {+            "try": [{+                "if": [+                    true,+                    { "map": [[1,2,3], {"/": [0, 0] }]},+                    null+                ]+            }, { "val": [[2], "fallback"] }]+        },+        "result": "Hello",+        "data": { "fallback": "Hello" }+    }+]
+ test/community/try.json view
@@ -0,0 +1,157 @@+[+    "# Collection of Tests for Try",+    {+        "description": "Coalesce an error",+        "rule": { "try": [{ "throw": "Some error" }, 1] },+        "result": 1,+        "data": null+    },+    {+        "description": "Coalesce an error emitted from an operator",+        "rule": { "try": [{ "/": [0, 0]}, 1] },+        "result": 1,+        "data": { "hello": "world" }+    },+    {+        "description": "Try is variadic",+        "rule": { "try": [{ "throw": "Some error" }, { "/": [0, 0] }, 2] },+        "result": 2,+        "data": null+    },+    {+        "description": "Panics if none of the values are valid",+        "rule": { "try": [{ "throw": "Some error" }, { "throw": "Some other error" }] },+        "error": { "type": "Some other error" },+        "data": null+    },+    {+        "description": "Panics if none of the values are valid (2)",+        "rule": { "try": [{ "throw": "Some error" }, { "/": [0, 0] }] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Panics if none of the values are valid (3)",+        "rule": { "try": [{ "/": [0, 0] }, { "/": [1, 0] }, { "/": [2, 0] }] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Panics when the only argument is an error",+        "rule": { "try": { "throw": "Some error" } },+        "error": { "type": "Some error" },+        "data": null+    },+    {+        "description": "Panic with an error emitted from an operator",+        "rule": { "try": [{ "/": [1, 0] }] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Panic within an iterator",+        "rule": { "map": [[1, 2, 3], { "try": [{ "/": [0,0] }] }] },+        "error": { "type": "NaN" },+        "data": null+    },+    {+        "description": "Panic based on an error emitted from an if",+        "rule": { "try": [{ "if": [{"val": ["user", "admin"]}, true, { "throw": "Not an admin" }] }] },+        "data": { "user": { "admin": false } },+        "error": { "type": "Not an admin" }+    },+    {+        "description": "Try can work further up the AST with Exceptions",+        "rule": {+            "try": [{+                "if": [+                    true,+                    { "map": [[1,2,3], {"/": [0, 0] }]},+                    null+                ]+            }, 10]+        },+        "result": 10,+        "data": null+    },+    {+        "description": "The context switches for the try coalescing to the previous error",+        "rule": {+            "try": [+                { "throw": "Some error" },+                { "val": "type" }+            ]+        },+        "result": "Some error",+        "data": null+    },+    {+        "description": "The context switches for the try coalescing to the previous error (2)",+        "rule": {+            "try": [+                { "if": [true, { "throw": "Some error" }, null] },+                { "val": "type" }+            ]+        },+        "result": "Some error",+        "data": null+    },+    {+        "description": "The context switches for the try coalescing to the previous error (3)",+        "rule": {+            "try": [+                { "throw": "A" },+                { "throw": "B" },+                { "val": "type" }+            ]+        },+        "result": "B",+        "data": null+    },+    {+        "description": "Error can pull from an error object",+        "rule": {+            "try": [{ "throw": { "val": "x" } }, { "val": "type" }]+        },+        "data": { "x": { "type": "Some error" }},+        "result": "Some error"+    },+    {+        "description": "Try can work further up the AST with Exceptions, and return the error",+        "rule": {+            "try": [{+                "if": [+                    true,+                    { "map": [[1,2,3], {"/": [0, 0] }]},+                    null+                ]+            }, { "val": "type" }]+        },+        "result": "NaN",+        "data": null+    },+    {+        "description": "Handles NaN Explicitly",+        "rule": {+            "try": [+                { "if": [{ "/": [1, { "val": "x" }] }, { "throw": "Some error" }, null] },+                {+                    "if": [{ "===": [{ "val": "type" }, "NaN"]}, "Handled", { "throw": { "val": [] } }]+                }+            ]+        },+        "result": "Handled",+        "data": { "x": 0 }+    },+    {+        "description": "Did not NaN, so it errored",+        "rule": {+            "try": [+                { "if": [{ "/": [1, { "val": "x" }] }, { "throw": "Some error" }, null] },+                { "if": [{ "===": [{ "val": "type" }, "NaN"]}, "Handled", { "throw": { "val": [] } }] }+            ]+        },+        "error": { "type": "Some error" },+        "data": { "x": 1 }+    }+]
+ test/community/val-compat.json view
@@ -0,0 +1,665 @@+[+  "If the consequents are logic, they get evaluated",+  {+      "description": "[1,{\"var\":\"x\"},3]",+      "rule": [ 1, {"val": "x"}, 3 ],+      "data": {"x": 2},+      "result": [1, 2, 3]+  },+  {+      "description": "{\"if\":[{\"var\":\"x\"},[{\"var\":\"y\"}],99]}",+      "rule": {+          "if": [+              {"val": "x"},+              [ {"val": "y"} ],+              99+          ]+      },+      "data": {"x": true, "y": 42},+      "result": [42]+  },+  {+      "description": "{\"var\":[\"a\"]}",+      "rule": { "val": ["a"] },+      "data": {"a": 1},+      "result": 1+  },+  {+      "description": "{\"var\":[\"b\"]}",+      "rule": { "val": ["b"] },+      "data": {"a": 1},+      "result": null+  },+  {+      "description": "{\"var\":[\"a\"]}",+      "rule": { "val": ["a"] },+      "data": null,+      "result": null+  },+  { "description": "{\"var\":\"a\"}", "rule": {"val": "a"}, "data": {"a": 1}, "result": 1 },+  { "description": "{\"var\":\"b\"}", "rule": {"val": "b"}, "data": {"a": 1}, "result": null },+  { "description": "{\"var\":\"a\"}", "rule": {"val": "a"}, "data": null, "result": null },+  {+      "description": "{\"var\":\"a.b\"}",+      "rule": { "val": ["a", "b"] },+      "data": { "a": {"b": "c"} },+      "result": "c"+  },+  {+      "description": "{\"var\":\"a.q\"}",+      "rule": { "val": ["a", "q"] },+      "data": { "a": {"b": "c"} },+      "result": null+  },+  {+      "description": "{\"var\":1}",+      "rule": {"val": 1},+      "data": ["apple", "banana"],+      "result": "banana"+  },+  {+      "description": "{\"var\":\"1\"}",+      "rule": {"val": "1"},+      "data": ["apple", "banana"],+      "result": "banana"+  },+  {+      "description": "{\"var\":\"1.1\"}",+      "rule": { "val": [1, 1] },+      "data": [ "apple", ["banana", "beer"] ],+      "result": "beer"+  },+  {+      "description": "{\"and\":[{\"<\":[{\"var\":\"temp\"},110]},{\"==\":[{\"var\":\"pie.filling\"},\"apple\"]}]}",+      "rule": {+          "and": [+              {+                  "<": [ {"val": "temp"}, 110 ]+              },+              {+                  "==": [+                      { "val": ["pie", "filling"] },+                      "apple"+                  ]+              }+          ]+      },+      "data": { "temp": 100, "pie": {"filling": "apple"} },+      "result": true+  },+  {+      "description": "{\"var\":[{\"?:\":[{\"<\":[{\"var\":\"temp\"},110]},\"pie.filling\",\"pie.eta\"]}]}",+      "rule": {+        "?:": [+            { "<": [ {"val": "temp"}, 110 ] },+            { "val": ["pie", "filling"] },+            { "val": ["pie", "eta"] }+        ]+      },+      "data": { "temp": 100, "pie": {"filling": "apple", "eta": "60s"} },+      "result": "apple"+  },+  {+      "description": "{\"in\":[{\"var\":\"filling\"},[\"apple\",\"cherry\"]]}",+      "rule": {+          "in": [ {"val": "filling"}, ["apple", "cherry"] ]+      },+      "data": {"filling": "apple"},+      "result": true+  },+  {+      "description": "{\"var\":\"a.b.c\"}",+      "rule": { "val": ["a", "b", "c"] },+      "data": null,+      "result": null+  },+  {+      "description": "{\"var\":\"a.b.c\"}",+      "rule": { "val": ["a", "b", "c"] },+      "data": {"a": null},+      "result": null+  },+  {+      "description": "{\"var\":\"a.b.c\"}",+      "rule": { "val": ["a", "b", "c"] },+      "data": { "a": {"b": null} },+      "result": null+  },+  { "description": "{\"var\":\"\"}", "rule": {"val": []}, "data": 1, "result": 1 },+  { "description": "{\"val\":[]}", "rule": {"val": []}, "data": 1, "result": 1 },+  {+      "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",+      "rule": {+          "missing": {+              "merge": [+                  "vin",+                  {+                      "if": [ {"val": "financing"}, ["apr"], [] ]+                  }+              ]+          }+      },+      "data": {"financing": true},+      "result": ["vin", "apr"]+  },+  {+      "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",+      "rule": {+          "missing": {+              "merge": [+                  "vin",+                  {+                      "if": [ {"val": "financing"}, ["apr"], [] ]+                  }+              ]+          }+      },+      "data": {"financing": false},+      "result": ["vin"]+  },+  {+      "description": "{\"filter\":[{\"var\":\"integers\"},true]}",+      "rule": {+          "filter": [ {"val": "integers"}, true ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": [1, 2, 3]+  },+  {+      "description": "{\"filter\":[{\"var\":\"integers\"},false]}",+      "rule": {+          "filter": [ {"val": "integers"}, false ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": []+  },+  {+      "description": "{\"filter\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},2]}]}",+      "rule": {+          "filter": [+              {"val": "integers"},+              {+                  ">=": [ {"val": []}, 2 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": [2, 3]+  },+  {+      "description": "{\"filter\":[{\"var\":\"integers\"},{\"%\":[{\"var\":\"\"},2]}]}",+      "rule": {+          "filter": [+              {"val": "integers"},+              {+                  "%": [ {"val": []}, 2 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": [1, 3]+  },+  {+      "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",+      "rule": {+          "map": [+              {"val": "integers"},+              {+                  "*": [ {"val": []}, 2 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": [2, 4, 6]+  },+  {+      "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",+      "rule": {+          "map": [+              {"val": "integers"},+              {+                  "*": [ {"val": []}, 2 ]+              }+          ]+      },+      "data": null,+      "result": []+  },+  {+      "description": "{\"map\":[{\"var\":\"desserts\"},{\"var\":\"qty\"}]}",+      "rule": {+          "map": [ {"val": "desserts"}, {"val": "qty"} ]+      },+      "data": {+          "desserts": [+              {"name": "apple"  , "qty": 1},+              {"name": "brownie", "qty": 2},+              {"name": "cupcake", "qty": 3}+          ]+      },+      "result": [1, 2, 3]+  },+  {+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",+      "rule": {+          "reduce": [+              {"val": "integers"},+              {+                  "+": [ {"val": "current"}, {"val": "accumulator"} ]+              },+              0+          ]+      },+      "data": { "integers": [1, 2, 3, 4] },+      "result": 10+  },+  {+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},{\"var\":\"start_with\"}]}",+      "rule": {+          "reduce": [+              {"val": "integers"},+              {+                  "+": [ {"val": "current"}, {"val": "accumulator"} ]+              },+              {"val": "start_with"}+          ]+      },+      "data": { "integers": [1, 2, 3, 4], "start_with": 59 },+      "result": 69+  },+  {+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",+      "rule": {+          "reduce": [+              {"val": "integers"},+              {+                  "+": [ {"val": "current"}, {"val": "accumulator"} ]+              },+              0+          ]+      },+      "data": null,+      "result": 0+  },+  {+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},1]}",+      "rule": {+          "reduce": [+              {"val": "integers"},+              {+                  "*": [ {"val": "current"}, {"val": "accumulator"} ]+              },+              1+          ]+      },+      "data": { "integers": [1, 2, 3, 4] },+      "result": 24+  },+  {+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",+      "rule": {+          "reduce": [+              {"val": "integers"},+              {+                  "*": [ {"val": "current"}, {"val": "accumulator"} ]+              },+              0+          ]+      },+      "data": { "integers": [1, 2, 3, 4] },+      "result": 0+  },+  {+      "description": "{\"reduce\":[{\"var\":\"desserts\"},{\"+\":[{\"var\":\"accumulator\"},{\"var\":\"current.qty\"}]},0]}",+      "rule": {+          "reduce": [+              {"val": "desserts"},+              {+                  "+": [+                      { "val": "accumulator"      },+                      { "val": ["current", "qty"] }+                  ]+              },+              0+          ]+      },+      "data": {+          "desserts": [+              {"name": "apple"  , "qty": 1},+              {"name": "brownie", "qty": 2},+              {"name": "cupcake", "qty": 3}+          ]+      },+      "result": 6+  },+  {+      "description": "{\"all\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "all": [+              {"val": "integers"},+              {+                  ">=": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": true+  },+  {+      "description": "{\"all\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "all": [+              {"val": "integers"},+              {+                  "==": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": false+  },+  {+      "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "all": [+              {"val": "integers"},+              {+                  "<": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": false+  },+  {+      "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "all": [+              {"val": "integers"},+              {+                  "<": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": {"integers": []},+      "result": false+  },+  {+      "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "all": [+              {"val": "items"},+              {+                  ">=": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": true+  },+  {+      "description": "{\"all\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "all": [+              {"val": "items"},+              {+                  ">": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": false+  },+  {+      "description": "{\"all\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "all": [+              {"val": "items"},+              {+                  "<": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": false+  },+  {+      "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "all": [+              {"val": "items"},+              {+                  ">=": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {"items": []},+      "result": false+  },+  {+      "description": "{\"none\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "none": [+              {"val": "integers"},+              {+                  ">=": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": false+  },+  {+      "description": "{\"none\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "none": [+              {"val": "integers"},+              {+                  "==": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": false+  },+  {+      "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "none": [+              {"val": "integers"},+              {+                  "<": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": true+  },+  {+      "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "none": [+              {"val": "integers"},+              {+                  "<": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": {"integers": []},+      "result": true+  },+  {+      "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "none": [+              {"val": "items"},+              {+                  ">=": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": false+  },+  {+      "description": "{\"none\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "none": [+              {"val": "items"},+              {+                  ">": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": false+  },+  {+      "description": "{\"none\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "none": [+              {"val": "items"},+              {+                  "<": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": true+  },+  {+      "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "none": [+              {"val": "items"},+              {+                  ">=": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {"items": []},+      "result": true+  },+  {+      "description": "{\"some\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "some": [+              {"val": "integers"},+              {+                  ">=": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": true+  },+  {+      "description": "{\"some\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "some": [+              {"val": "integers"},+              {+                  "==": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": true+  },+  {+      "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "some": [+              {"val": "integers"},+              {+                  "<": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": { "integers": [1, 2, 3] },+      "result": false+  },+  {+      "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",+      "rule": {+          "some": [+              {"val": "integers"},+              {+                  "<": [ {"val": []}, 1 ]+              }+          ]+      },+      "data": {"integers": []},+      "result": false+  },+  {+      "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "some": [+              {"val": "items"},+              {+                  ">=": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": true+  },+  {+      "description": "{\"some\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "some": [+              {"val": "items"},+              {+                  ">": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": true+  },+  {+      "description": "{\"some\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "some": [+              {"val": "items"},+              {+                  "<": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]+      },+      "result": false+  },+  {+      "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",+      "rule": {+          "some": [+              {"val": "items"},+              {+                  ">=": [ {"val": "qty"}, 1 ]+              }+          ]+      },+      "data": {"items": []},+      "result": false+  }+]
+ test/community/val.extra.json view
@@ -0,0 +1,23 @@+[+    "Testing out scopes",+    {+        "description": "Climb up to get adder",+        "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, { "val": [[-2], "adder"] }] }] },+        "data": { "adder": 10 },+        "result": [11,12,13]+    },+    {+        "description": "Climb up to get index",+        "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, { "val": [[-1], "index"] }] }] },+        "data": { "adder": 10 },+        "result": [1,3,5]+    },+    {+        "description": "Nested get adder",+        "rule": {+            "map": [["Test"], { "map": [[1,2,3], { "+": [{"val": []}, {"val": [[-4], "adder"]}] }]} ]+        },+        "data": { "adder": 10 },+        "result": [[11,12,13]]+    }+]
+ test/community/val.json view
@@ -0,0 +1,81 @@+[+    "Test Specification for val",+    {+        "description": "Fetches a value",+        "rule": { "val": "hello" },+        "data": { "hello" : 0 },+        "result": 0+    },+    {+        "description": "Fetches a nested value",+        "rule": { "val": ["hello", "world"] },+        "data": { "hello" : { "world": 1 } },+        "result": 1+    },+    {+        "description": "Fetches a value from an empty key",+        "rule": { "val": "" },+        "data": { "" : 1 },+        "result": 1+    },+    {+        "description": "Fetches a value from a nested empty key",+        "rule": { "val": ["", ""] },+        "data": { "" : { "": 2 } },+        "result": 2+    },+    {+        "description": "Fetches a value from an array",+        "rule": { "val": [1] },+        "data": [1, 2],+        "result": 2+    },+    {+        "description": "Fetches a value from an array in an object",+        "rule": { "val": ["arr", 1] },+        "data": { "arr": [1, 2] },+        "result": 2+    },+    {+        "description": "Fetches a value from a doubly nested empty key",+        "rule": { "val": ["", "", ""] },+        "data": { "" : { "": { "": 3 } } },+        "result": 3+    },+    {+        "description": "Fetches a value from a key that is purely a dot",+        "rule": { "val": "." },+        "data": { "." : 20 },+        "result": 20+    },+    {+        "description": "Fetching a value from null returns null",+        "rule": { "val": "hello" },+        "data": { "hello" : null },+        "result": null+    },+    {+        "description": "Fetching a value from a null fetched value returns null",+        "rule": { "val": ["hello", "world"] },+        "data": { "hello" : null },+        "result": null+    },+    {+        "description": "Fetches the entire context",+        "rule": { "val": [] },+        "data": { "": 21 },+        "result": { "": 21 }+    },+    {+        "description": "Fetches the entire context for a nested key",+        "rule": { "val": "" },+        "data": { "": { "": 22 } },+        "result": { "": 22 }+    },+    {+        "description": "Using val in a map",+        "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, 1] }] },+        "data": null,+        "result": [2,3,4]+    }+]
+ test/community/var.extra.json view
@@ -0,0 +1,74 @@+[+  {+    "description": "var returns false when key exists with false value",+    "rule": { "var": "active" },+    "data": { "active": false },+    "result": false+  },+  {+    "description": "var returns 0 when key exists with 0 value",+    "rule": { "var": "count" },+    "data": { "count": 0 },+    "result": 0+  },+  {+    "description": "var returns empty string when key exists with empty string",+    "rule": { "var": "name" },+    "data": { "name": "" },+    "result": ""+  },+  {+    "description": "var with default does NOT use default when value is false",+    "rule": { "var": ["active", true] },+    "data": { "active": false },+    "result": false+  },+  {+    "description": "var with default does NOT use default when value is 0",+    "rule": { "var": ["count", 99] },+    "data": { "count": 0 },+    "result": 0+  },+  {+    "description": "var with default does NOT use default when value is empty string",+    "rule": { "var": ["name", "anonymous"] },+    "data": { "name": "" },+    "result": ""+  },+  {+    "description": "var with default DOES use default when key is missing",+    "rule": { "var": ["missing", "default"] },+    "data": { "other": "value" },+    "result": "default"+  },+  {+    "description": "nested var returns false from nested path",+    "rule": { "var": "user.active" },+    "data": { "user": { "active": false } },+    "result": false+  },+  {+    "description": "nested var returns 0 from nested path",+    "rule": { "var": "user.score" },+    "data": { "user": { "score": 0 } },+    "result": 0+  },+  {+    "description": "equality check with false value from var",+    "rule": { "==": [{ "var": "is_beta" }, false] },+    "data": { "is_beta": false },+    "result": true+  },+  {+    "description": "negation of false value from var",+    "rule": { "!": [{ "var": "disabled" }] },+    "data": { "disabled": false },+    "result": true+  },+  {+    "description": "and with false value from var",+    "rule": { "and": [{ "var": "enabled" }, true] },+    "data": { "enabled": false },+    "result": false+  }+]
+ test/outliers.jsonl view

file too large to diff

+ test/tests.json view
@@ -0,0 +1,532 @@+[+    "# Non-rules get passed through",+    [ true, {}, true ],+    [ false, {}, false ],+    [ 17, {}, 17 ],+    [ 3.14, {}, 3.14 ],+    [ "apple", {}, "apple" ],+    [ null, {}, null ],+    [ ["a","b"], {}, ["a","b"] ],++    "# Single operator tests",+    [ {"==":[1,1]}, {}, true ],+    [ {"==":[1,"1"]}, {}, true ],+    [ {"==":[1,2]}, {}, false ],+    [ {"===":[1,1]}, {}, true ],+    [ {"===":[1,"1"]}, {}, false ],+    [ {"===":[1,2]}, {}, false ],+    [ {"!=":[1,2]}, {}, true ],+    [ {"!=":[1,1]}, {}, false ],+    [ {"!=":[1,"1"]}, {}, false ],+    [ {"!==":[1,2]}, {}, true ],+    [ {"!==":[1,1]}, {}, false ],+    [ {"!==":[1,"1"]}, {}, true ],+    [ {">":[2,1]}, {}, true ],+    [ {">":[1,1]}, {}, false ],+    [ {">":[1,2]}, {}, false ],+    [ {">":["2",1]}, {}, true ],+    [ {">=":[2,1]}, {}, true ],+    [ {">=":[1,1]}, {}, true ],+    [ {">=":[1,2]}, {}, false ],+    [ {">=":["2",1]}, {}, true ],+    [ {"<":[2,1]}, {}, false ],+    [ {"<":[1,1]}, {}, false ],+    [ {"<":[1,2]}, {}, true ],+    [ {"<":["1",2]}, {}, true ],+    [ {"<":[1,2,3]}, {}, true ],+    [ {"<":[1,1,3]}, {}, false ],+    [ {"<":[1,4,3]}, {}, false ],+    [ {"<=":[2,1]}, {}, false ],+    [ {"<=":[1,1]}, {}, true ],+    [ {"<=":[1,2]}, {}, true ],+    [ {"<=":["1",2]}, {}, true ],+    [ {"<=":[1,2,3]}, {}, true ],+    [ {"<=":[1,4,3]}, {}, false ],+    [ {"!":[false]}, {}, true ],+    [ {"!":false}, {}, true ],+    [ {"!":[true]}, {}, false ],+    [ {"!":true}, {}, false ],+    [ {"!":0}, {}, true ],+    [ {"!":1}, {}, false ],+    [ {"or":[true,true]}, {}, true ],+    [ {"or":[false,true]}, {}, true ],+    [ {"or":[true,false]}, {}, true ],+    [ {"or":[false,false]}, {}, false ],+    [ {"or":[false,false,true]}, {}, true ],+    [ {"or":[false,false,false]}, {}, false ],+    [ {"or":[false]}, {}, false ],+    [ {"or":[true]}, {}, true ],+    [ {"or":[1,3]}, {}, 1 ],+    [ {"or":[3,false]}, {}, 3 ],+    [ {"or":[false,3]}, {}, 3 ],+    [ {"and":[true,true]}, {}, true ],+    [ {"and":[false,true]}, {}, false ],+    [ {"and":[true,false]}, {}, false ],+    [ {"and":[false,false]}, {}, false ],+    [ {"and":[true,true,true]}, {}, true ],+    [ {"and":[true,true,false]}, {}, false ],+    [ {"and":[false]}, {}, false ],+    [ {"and":[true]}, {}, true ],+    [ {"and":[1,3]}, {}, 3 ],+    [ {"and":[3,false]}, {}, false ],+    [ {"and":[false,3]}, {}, false ],+    [ {"?:":[true,1,2]}, {}, 1 ],+    [ {"?:":[false,1,2]}, {}, 2 ],+    [ {"in":["Bart",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, true ],+    [ {"in":["Milhouse",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, false ],+    [ {"in":["Spring","Springfield"]}, {}, true ],+    [ {"in":["i","team"]}, {}, false ],+    [ {"cat":"ice"}, {}, "ice" ],+    [ {"cat":["ice"]}, {}, "ice" ],+    [ {"cat":["ice","cream"]}, {}, "icecream" ],+    [ {"cat":[1,2]}, {}, "12" ],+    [ {"cat":["Robocop",2]}, {}, "Robocop2" ],+    [ {"cat":["we all scream for ","ice","cream"]}, {}, "we all scream for icecream" ],+    [ {"%":[1,2]}, {}, 1 ],+    [ {"%":[2,2]}, {}, 0 ],+    [ {"%":[3,2]}, {}, 1 ],+    [ {"max":[1,2,3]}, {}, 3 ],+    [ {"max":[1,3,3]}, {}, 3 ],+    [ {"max":[3,2,1]}, {}, 3 ],+    [ {"max":[1]}, {}, 1 ],+    [ {"min":[1,2,3]}, {}, 1 ],+    [ {"min":[1,1,3]}, {}, 1 ],+    [ {"min":[3,2,1]}, {}, 1 ],+    [ {"min":[1]}, {}, 1 ],++    [ {"+":[1,2]}, {}, 3 ],+    [ {"+":[2,2,2]}, {}, 6 ],+    [ {"+":[1]}, {}, 1 ],+    [ {"+":["1",1]}, {}, 2 ],+    [ {"*":[3,2]}, {}, 6 ],+    [ {"*":[2,2,2]}, {}, 8 ],+    [ {"*":[1]}, {}, 1 ],+    [ {"*":["1",1]}, {}, 1 ],+    [ {"-":[2,3]}, {}, -1 ],+    [ {"-":[3,2]}, {}, 1 ],+    [ {"-":[3]}, {}, -3 ],+    [ {"-":["1",1]}, {}, 0 ],+    [ {"/":[4,2]}, {}, 2 ],+    [ {"/":[2,4]}, {}, 0.5 ],+    [ {"/":["1",1]}, {}, 1 ],++    "Substring",+    [{"substr":["jsonlogic", 4]}, null, "logic"],+    [{"substr":["jsonlogic", -5]}, null, "logic"],+    [{"substr":["jsonlogic", 0, 1]}, null, "j"],+    [{"substr":["jsonlogic", -1, 1]}, null, "c"],+    [{"substr":["jsonlogic", 4, 5]}, null, "logic"],+    [{"substr":["jsonlogic", -5, 5]}, null, "logic"],+    [{"substr":["jsonlogic", -5, -2]}, null, "log"],+    [{"substr":["jsonlogic", 1, -5]}, null, "son"],++    "Merge arrays",+    [{"merge":[]}, null, []],+    [{"merge":[[1]]}, null, [1]],+    [{"merge":[[1],[]]}, null, [1]],+    [{"merge":[[1], [2]]}, null, [1,2]],+    [{"merge":[[1], [2], [3]]}, null, [1,2,3]],+    [{"merge":[[1, 2], [3]]}, null, [1,2,3]],+    [{"merge":[[1], [2, 3]]}, null, [1,2,3]],+    "Given non-array arguments, merge converts them to arrays",+    [{"merge":1}, null, [1]],+    [{"merge":[1,2]}, null, [1,2]],+    [{"merge":[1,[2]]}, null, [1,2]],++    "Too few args",+    [{"if":[]}, null, null],+    [{"if":[true]}, null, true],+    [{"if":[false]}, null, false],+    [{"if":["apple"]}, null, "apple"],++    "Simple if/then/else cases",+    [{"if":[true, "apple"]}, null, "apple"],+    [{"if":[false, "apple"]}, null, null],+    [{"if":[true, "apple", "banana"]}, null, "apple"],+    [{"if":[false, "apple", "banana"]}, null, "banana"],++    "Empty arrays are falsey",+    [{"if":[ [], "apple", "banana"]}, null, "banana"],+    [{"if":[ [1], "apple", "banana"]}, null, "apple"],+    [{"if":[ [1,2,3,4], "apple", "banana"]}, null, "apple"],++    "Empty strings are falsey, all other strings are truthy",+    [{"if":[ "", "apple", "banana"]}, null, "banana"],+    [{"if":[ "zucchini", "apple", "banana"]}, null, "apple"],+    [{"if":[ "0", "apple", "banana"]}, null, "apple"],++    "You can cast a string to numeric with a unary + ",+    [{"===":[0,"0"]}, null, false],+    [{"===":[0,{"+":"0"}]}, null, true],+    [{"if":[ {"+":"0"}, "apple", "banana"]}, null, "banana"],+    [{"if":[ {"+":"1"}, "apple", "banana"]}, null, "apple"],++    "Zero is falsy, all other numbers are truthy",+    [{"if":[ 0, "apple", "banana"]}, null, "banana"],+    [{"if":[ 1, "apple", "banana"]}, null, "apple"],+    [{"if":[ 3.1416, "apple", "banana"]}, null, "apple"],+    [{"if":[ -1, "apple", "banana"]}, null, "apple"],++    "Truthy and falsy definitions matter in Boolean operations",+    [{"!" : [ [] ]}, {}, true],+    [{"!!" : [ [] ]}, {}, false],+    [{"and" : [ [], true ]}, {}, [] ],+    [{"or" : [ [], true ]}, {}, true ],++    [{"!" : [ 0 ]}, {}, true],+    [{"!!" : [ 0 ]}, {}, false],+    [{"and" : [ 0, true ]}, {}, 0 ],+    [{"or" : [ 0, true ]}, {}, true ],++    [{"!" : [ "" ]}, {}, true],+    [{"!!" : [ "" ]}, {}, false],+    [{"and" : [ "", true ]}, {}, "" ],+    [{"or" : [ "", true ]}, {}, true ],++    [{"!" : [ "0" ]}, {}, false],+    [{"!!" : [ "0" ]}, {}, true],+    [{"and" : [ "0", true ]}, {}, true ],+    [{"or" : [ "0", true ]}, {}, "0" ],++    "If the conditional is logic, it gets evaluated",+    [{"if":[ {">":[2,1]}, "apple", "banana"]}, null, "apple"],+    [{"if":[ {">":[1,2]}, "apple", "banana"]}, null, "banana"],++    "If the consequents are logic, they get evaluated",+    [{"if":[ true, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "apple"],+    [{"if":[ false, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "banana"],++    "If/then/elseif/then cases",+    [{"if":[true, "apple", true, "banana"]}, null, "apple"],+    [{"if":[true, "apple", false, "banana"]}, null, "apple"],+    [{"if":[false, "apple", true, "banana"]}, null, "banana"],+    [{"if":[false, "apple", false, "banana"]}, null, null],++    [{"if":[true, "apple", true, "banana", "carrot"]}, null, "apple"],+    [{"if":[true, "apple", false, "banana", "carrot"]}, null, "apple"],+    [{"if":[false, "apple", true, "banana", "carrot"]}, null, "banana"],+    [{"if":[false, "apple", false, "banana", "carrot"]}, null, "carrot"],++    [{"if":[false, "apple", false, "banana", false, "carrot"]}, null, null],+    [{"if":[false, "apple", false, "banana", false, "carrot", "date"]}, null, "date"],+    [{"if":[false, "apple", false, "banana", true, "carrot", "date"]}, null, "carrot"],+    [{"if":[false, "apple", true, "banana", false, "carrot", "date"]}, null, "banana"],+    [{"if":[false, "apple", true, "banana", true, "carrot", "date"]}, null, "banana"],+    [{"if":[true, "apple", false, "banana", false, "carrot", "date"]}, null, "apple"],+    [{"if":[true, "apple", false, "banana", true, "carrot", "date"]}, null, "apple"],+    [{"if":[true, "apple", true, "banana", false, "carrot", "date"]}, null, "apple"],+    [{"if":[true, "apple", true, "banana", true, "carrot", "date"]}, null, "apple"],++    "Arrays with logic",+    [[1, {"var": "x"}, 3], {"x": 2}, [1, 2, 3]],+    [{"if": [{"var": "x"}, [{"var": "y"}], 99]}, {"x": true, "y": 42}, [42]],++    "# Compound Tests",+    [ {"and":[{">":[3,1]},true]}, {}, true ],+    [ {"and":[{">":[3,1]},false]}, {}, false ],+    [ {"and":[{">":[3,1]},{"!":true}]}, {}, false ],+    [ {"and":[{">":[3,1]},{"<":[1,3]}]}, {}, true ],+    [ {"?:":[{">":[3,1]},"visible","hidden"]}, {}, "visible" ],++    "# Data-Driven",+    [ {"var":["a"]},{"a":1},1 ],+    [ {"var":["b"]},{"a":1},null ],+    [ {"var":["a"]},null,null ],+    [ {"var":"a"},{"a":1},1 ],+    [ {"var":"b"},{"a":1},null ],+    [ {"var":"a"},null,null ],+    [ {"var":["a", 1]},null,1 ],+    [ {"var":["b", 2]},{"a":1},2 ],+    [ {"var":"a.b"},{"a":{"b":"c"}},"c" ],+    [ {"var":"a.q"},{"a":{"b":"c"}},null ],+    [ {"var":["a.q", 9]},{"a":{"b":"c"}},9 ],+    [ {"var":1}, ["apple","banana"], "banana" ],+    [ {"var":"1"}, ["apple","banana"], "banana" ],+    [ {"var":"1.1"}, ["apple",["banana","beer"]], "beer" ],+    [ {"and":[{"<":[{"var":"temp"},110]},{"==":[{"var":"pie.filling"},"apple"]}]},{"temp":100,"pie":{"filling":"apple"}},true ],+    [ {"var":[{"?:":[{"<":[{"var":"temp"},110]},"pie.filling","pie.eta"]}]},{"temp":100,"pie":{"filling":"apple","eta":"60s"}},"apple" ],+    [ {"in":[{"var":"filling"},["apple","cherry"]]},{"filling":"apple"},true ],+    [ {"var":"a.b.c"}, null, null ],+    [ {"var":"a.b.c"}, {"a":null}, null ],+    [ {"var":"a.b.c"}, {"a":{"b":null}}, null ],+    [ {"var":""}, 1, 1 ],+    [ {"var":null}, 1, 1 ],+    [ {"var":[]}, 1, 1 ],++    "Missing",+    [{"missing":[]}, null, []],+    [{"missing":["a"]}, null, ["a"]],+    [{"missing":"a"}, null, ["a"]],+    [{"missing":"a"}, {"a":"apple"}, []],+    [{"missing":["a"]}, {"a":"apple"}, []],+    [{"missing":["a","b"]}, {"a":"apple"}, ["b"]],+    [{"missing":["a","b"]}, {"b":"banana"}, ["a"]],+    [{"missing":["a","b"]}, {"a":"apple", "b":"banana"}, []],+    [{"missing":["a","b"]}, {}, ["a","b"]],+    [{"missing":["a","b"]}, null, ["a","b"]],++    [{"missing":["a.b"]}, null, ["a.b"]],+    [{"missing":["a.b"]}, {"a":"apple"}, ["a.b"]],+    [{"missing":["a.b"]}, {"a":{"c":"apple cake"}}, ["a.b"]],+    [{"missing":["a.b"]}, {"a":{"b":"apple brownie"}}, []],+    [{"missing":["a.b", "a.c"]}, {"a":{"b":"apple brownie"}}, ["a.c"]],+++    "Missing some",+    [{"missing_some":[1, ["a", "b"]]}, {"a":"apple"}, [] ],+    [{"missing_some":[1, ["a", "b"]]}, {"b":"banana"}, [] ],+    [{"missing_some":[1, ["a", "b"]]}, {"a":"apple", "b":"banana"}, [] ],+    [{"missing_some":[1, ["a", "b"]]}, {"c":"carrot"}, ["a", "b"]],++    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana"}, [] ],+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "c":"carrot"}, [] ],+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana", "c":"carrot"}, [] ],+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "d":"durian"}, ["b", "c"] ],+    [{"missing_some":[2, ["a", "b", "c"]]}, {"d":"durian", "e":"eggplant"}, ["a", "b", "c"] ],+++    "Missing and If are friends, because empty arrays are falsey in JsonLogic",+    [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"a":"apple"}, "found it"],+    [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"b":"banana"}, "missed it"],++    "Missing, Merge, and If are friends. VIN is always required, APR is only required if financing is true.",+    [+        {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} },+        {"financing":true},+        ["vin","apr"]+    ],++    [+        {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} },+        {"financing":false},+        ["vin"]+    ],++    "Filter, map, all, none, and some",+    [+        {"filter":[{"var":"integers"}, true]},+        {"integers":[1,2,3]},+        [1,2,3]+    ],+    [+        {"filter":[{"var":"integers"}, false]},+        {"integers":[1,2,3]},+        []+    ],+    [+        {"filter":[{"var":"integers"}, {">=":[{"var":""},2]}]},+        {"integers":[1,2,3]},+        [2,3]+    ],+    [+        {"filter":[{"var":"integers"}, {"%":[{"var":""},2]}]},+        {"integers":[1,2,3]},+        [1,3]+    ],++    [+        {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]},+        {"integers":[1,2,3]},+        [2,4,6]+    ],+    [+        {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]},+        null,+        []+    ],+    [+        {"map":[{"var":"desserts"}, {"var":"qty"}]},+        {"desserts":[+            {"name":"apple","qty":1},+            {"name":"brownie","qty":2},+            {"name":"cupcake","qty":3}+        ]},+        [1,2,3]+    ],++    [+        {"reduce":[+            {"var":"integers"},+            {"+":[{"var":"current"}, {"var":"accumulator"}]},+            0+        ]},+        {"integers":[1,2,3,4]},+        10+    ],+    [+        {"reduce":[+            {"var":"integers"},+            {"+":[{"var":"current"}, {"var":"accumulator"}]},+            {"var": "start_with"}+        ]},+        {"integers":[1,2,3,4], "start_with": 59},+        69+    ],+    [+        {"reduce":[+            {"var":"integers"},+            {"+":[{"var":"current"}, {"var":"accumulator"}]},+            0+        ]},+        null,+        0+    ],+    [+        {"reduce":[+            {"var":"integers"},+            {"*":[{"var":"current"}, {"var":"accumulator"}]},+            1+        ]},+        {"integers":[1,2,3,4]},+        24+    ],+    [+        {"reduce":[+            {"var":"integers"},+            {"*":[{"var":"current"}, {"var":"accumulator"}]},+            0+        ]},+        {"integers":[1,2,3,4]},+        0+    ],+    [+        {"reduce": [+            {"var":"desserts"},+            {"+":[ {"var":"accumulator"}, {"var":"current.qty"}]},+            0+        ]},+        {"desserts":[+            {"name":"apple","qty":1},+            {"name":"brownie","qty":2},+            {"name":"cupcake","qty":3}+        ]},+        6+    ],+++    [+        {"all":[{"var":"integers"}, {">=":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        true+    ],+    [+        {"all":[{"var":"integers"}, {"==":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        false+    ],+    [+        {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        false+    ],+    [+        {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},+        {"integers":[]},+        false+    ],+    [+        {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        true+    ],+    [+        {"all":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        false+    ],+    [+        {"all":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        false+    ],+    [+        {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},+        {"items":[]},+        false+    ],+++    [+        {"none":[{"var":"integers"}, {">=":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        false+    ],+    [+        {"none":[{"var":"integers"}, {"==":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        false+    ],+    [+        {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        true+    ],+    [+        {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},+        {"integers":[]},+        true+    ],+    [+        {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        false+    ],+    [+        {"none":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        false+    ],+    [+        {"none":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        true+    ],+    [+        {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},+        {"items":[]},+        true+    ],++    [+        {"some":[{"var":"integers"}, {">=":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        true+    ],+    [+        {"some":[{"var":"integers"}, {"==":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        true+    ],+    [+        {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},+        {"integers":[1,2,3]},+        false+    ],+    [+        {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},+        {"integers":[]},+        false+    ],+    [+        {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        true+    ],+    [+        {"some":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        true+    ],+    [+        {"some":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]},+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},+        false+    ],+    [+        {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},+        {"items":[]},+        false+    ],++    "EOF"+]
+ tutorial/Tutorial.hs view
@@ -0,0 +1,104 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Every example in docs/TUTORIAL.md, with the answer the tutorial shows. The+-- run fails if any answer differs, so the tutorial cannot drift from the+-- library. Uses only the public "JsonLogic" module, as a reader would.+module Main (main) where++import Data.Aeson (Value (..), decode, object, (.=))+import qualified Data.ByteString.Lazy.Char8 as Char8+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Exit (exitFailure)++import JsonLogic++json :: String -> Value+json = fromMaybe Null . decode . Char8.pack++startsWith :: CustomOperator+startsWith _ [String prefix, String s] = Right (Bool (prefix `Text.isPrefixOf` s))+startsWith _ _ = Left "starts_with takes two strings"++operators :: Either OperatorsError Operators+operators = operatorsFromList [(OperatorName "starts_with", startsWith)]++describe :: Either Error Value -> String+describe result = case result of+  Right v -> "ok: " ++ show v+  Left (ParseFailure at (ArityMismatch (OperatorName op) _ given)) ->+    "rule error at " ++ show (renderLocation at) ++ ": " ++ show op ++ " given " ++ show given+  Left (EvalFailure _ (LimitExceeded _)) -> "too expensive"+  Left other -> "failed: " ++ show (renderError other)++-- | (section, what the tutorial shows, what the library says)+checks :: [(String, String, String)]+checks =+  [ check "1 and" (Right (Bool True)) (apply (json "{\"and\": [{\">=\": [{\"var\": \"age\"}, 18]}, {\"var\": \"verified\"}]}") (json "{\"age\": 21, \"verified\": true}"))+  , check "1 array" (Right (json "[1,2]")) (apply (json "[{\"var\": \"a\"}, 2]") (json "{\"a\": 1}"))+  , check "1 path" (Right (String "admin")) (apply (json "{\"var\": \"user.roles.0\"}") (json "{\"user\": {\"roles\": [\"admin\"]}}"))+  , check "1 default" (Right (String "default")) (apply (json "{\"var\": [\"missing.path\", \"default\"]}") (json "{}"))+  , check "2 ==" (Right (Bool True)) (apply (json "{\"==\": [1, \"1\"]}") Null)+  , check "2 ===" (Right (Bool False)) (apply (json "{\"===\": [1, \"1\"]}") Null)+  , check "2 <" (Right (Bool True)) (apply (json "{\"<\": [\"10\", \"9\"]}") Null)+  , check "2 +" (Right (Number 7)) (apply (json "{\"+\": [\"3\", 4]}") Null)+  , check "2 /" (Right Null) (apply (json "{\"/\": [1, 0]}") Null)+  , check "2 cat" (Right (String "0.1+0.2")) (apply (json "{\"cat\": [0.1, \"+\", 0.2]}") Null)+  , check "2 if []" (Right (String "no")) (apply (json "{\"if\": [[], \"yes\", \"no\"]}") Null)+  , check "2 !!" (Right (Bool True)) (apply (json "{\"!!\": \"0\"}") Null)+  , check+      "3 compile"+      (Right [Right (Number 12), Right (Number 3)])+      ((\rule -> map (run rule) [json "{\"price\": 10}", json "{\"price\": 2.5}"]) <$> compile defaultConfig noOperators (json "{\"*\": [{\"var\": \"price\"}, 1.2]}"))+  , check+      "4 location"+      (Left (EvalFailure (fromSegments [Key "if", Index 1]) (UnknownOperator (OperatorName "nope"))))+      (apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null)+  , check "4 render" ("at /if/1: unknown operator \"nope\"" :: Text) (either renderError (const "") (apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null))+  , check "4 describe" "rule error at \"\": \"==\" given 1" (describe (apply (json "{\"==\": [1]}") Null))+  , check "4 lazy" (Right (Bool True)) (apply (json "{\"or\": [true, {\"nope\": []}]}") Null)+  , check+      "5 strict"+      (Left (ParseFailure (fromSegments []) (ArityMismatch (OperatorName "==") (Exactly 2) 1)))+      (apply (json "{\"==\": [1]}") Null)+  , check "5 lenient" (Right (Bool False)) (applyWithConfig defaultConfig{arityMode = Lenient} noOperators (json "{\"==\": [1]}") Null)+  , check+      "6 limits"+      (Left (EvalFailure (fromSegments [Key "map", Index 1]) (LimitExceeded (ArrayLimit 1000))))+      ( applyWithConfig+          defaultConfig{maxSteps = 10000, maxStringLength = 4096, maxArrayLength = 1000}+          noOperators+          (json "{\"map\": [{\"var\": \"xs\"}, {\"map\": [{\"var\": \"\"}, 1]}]}")+          (object ["xs" .= [[1 .. 2000 :: Int]]])+      )+  , check+      "7 custom"+      (Right (Right (Bool True)))+      ((\ops -> applyWith ops (json "{\"starts_with\": [\"ad\", {\"var\": \"role\"}]}") (json "{\"role\": \"admin\"}")) <$> operators)+  , check+      "7 custom failure"+      (Right (Left (EvalFailure (fromSegments []) (CustomOperatorFailed (OperatorName "starts_with") "starts_with takes two strings"))))+      ((\ops -> applyWith ops (json "{\"starts_with\": [1]}") Null) <$> operators)+  , check+      "8 dataUse"+      (Right (DataUse ["orders", "user.age"] False))+      ( dataUse+          <$> compile+            defaultConfig+            noOperators+            (json "{\"and\": [{\"var\": \"user.age\"}, {\"some\": [{\"var\": \"orders\"}, {\">\": [{\"var\": \"total\"}, 100]}]}]}")+      )+  ]+  where+    check :: (Eq a, Show a) => String -> a -> a -> (String, String, String)+    check name shown actual = (name, show shown, if actual == shown then show shown else show actual)++main :: IO ()+main = do+  let wrong = [(name, shown, actual) | (name, shown, actual) <- checks, shown /= actual]+  mapM_ (\(name, shown, actual) -> putStrLn ("tutorial section " ++ name ++ ":\n  shows " ++ shown ++ "\n  gives " ++ actual)) wrong+  if null wrong+    then putStrLn ("tutorial: all " ++ show (length checks) ++ " examples give the answers shown")+    else exitFailure