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 +90/−0
- LICENSE +21/−0
- README.md +243/−0
- bench/Bench.hs +131/−0
- community/Community.hs +210/−0
- conformance/Conformance.hs +107/−0
- differential/Differential.hs +642/−0
- differential/README.md +106/−0
- differential/oracle.js +61/−0
- differential/outliers.js +663/−0
- differential/package-lock.json +21/−0
- differential/package.json +9/−0
- docs/ARCHITECTURE.md +139/−0
- docs/COMPATIBILITY.md +172/−0
- docs/CONFORMANCE.md +110/−0
- docs/LIMITS.md +73/−0
- docs/STABILITY.md +24/−0
- docs/TUTORIAL.md +214/−0
- jsonlogic-hs.cabal +258/−0
- outliers/Outliers.hs +270/−0
- src/JsonLogic.hs +137/−0
- src/JsonLogic/Core/Analysis.hs +131/−0
- src/JsonLogic/Core/Config.hs +62/−0
- src/JsonLogic/Core/Custom.hs +75/−0
- src/JsonLogic/Core/Errors.hs +156/−0
- src/JsonLogic/Core/Eval.hs +624/−0
- src/JsonLogic/Core/Location.hs +71/−0
- src/JsonLogic/Core/Number.hs +463/−0
- src/JsonLogic/Core/Operator.hs +126/−0
- src/JsonLogic/Core/Parse.hs +249/−0
- src/JsonLogic/Core/Rule.hs +185/−0
- src/JsonLogic/Core/Ryu.hs +305/−0
- src/JsonLogic/Core/Utf16.hs +107/−0
- src/JsonLogic/Core/Value.hs +264/−0
- test/AnalysisSpec.hs +77/−0
- test/CompatSpec.hs +208/−0
- test/EvalSpec.hs +254/−0
- test/Gen.hs +74/−0
- test/LimitsSpec.hs +174/−0
- test/LocationSpec.hs +72/−0
- test/NumberSpec.hs +115/−0
- test/ParseSpec.hs +151/−0
- test/Spec.hs +23/−0
- test/Support.hs +41/−0
- test/ValueSpec.hs +247/−0
- test/community/LICENSE +201/−0
- test/community/README.md +33/−0
- test/community/additional.json +92/−0
- test/community/arithmetic/divide.extra.json +21/−0
- test/community/arithmetic/divide.json +193/−0
- test/community/arithmetic/minus.extra.json +20/−0
- test/community/arithmetic/minus.json +136/−0
- test/community/arithmetic/modulo.extra.json +14/−0
- test/community/arithmetic/modulo.json +191/−0
- test/community/arithmetic/multiply.extra.json +20/−0
- test/community/arithmetic/multiply.json +172/−0
- test/community/arithmetic/plus.extra.json +20/−0
- test/community/arithmetic/plus.json +198/−0
- test/community/array/all.json +174/−0
- test/community/array/filter.json +173/−0
- test/community/array/map.json +169/−0
- test/community/array/merge.json +105/−0
- test/community/array/none.json +180/−0
- test/community/array/reduce.json +129/−0
- test/community/array/some.json +175/−0
- test/community/chained.json +68/−0
- test/community/coalesce.json +93/−0
- test/community/comparison/greaterThan.json +212/−0
- test/community/comparison/greaterThanEquals.json +170/−0
- test/community/comparison/lessThan.json +272/−0
- test/community/comparison/lessThanEquals.json +122/−0
- test/community/comparison/softEquals.json +212/−0
- test/community/comparison/softNotEquals.json +206/−0
- test/community/comparison/strictEquals.json +188/−0
- test/community/comparison/strictNotEquals.json +182/−0
- test/community/compatible.json +1895/−0
- test/community/control/and.json +155/−0
- test/community/control/doublebang.json +143/−0
- test/community/control/if.json +277/−0
- test/community/control/not.json +143/−0
- test/community/control/or.json +150/−0
- test/community/exists.json +51/−0
- test/community/expected.json +50/−0
- test/community/index.json +50/−0
- test/community/iterators.extra.json +207/−0
- test/community/scopes.json +67/−0
- test/community/string/cat.json +60/−0
- test/community/string/in.json +53/−0
- test/community/string/substr.json +78/−0
- test/community/throw.json +20/−0
- test/community/truthiness.json +83/−0
- test/community/try.extra.json +17/−0
- test/community/try.json +157/−0
- test/community/val-compat.json +665/−0
- test/community/val.extra.json +23/−0
- test/community/val.json +81/−0
- test/community/var.extra.json +74/−0
- test/outliers.jsonl too large to diff
- test/tests.json +532/−0
- tutorial/Tutorial.hs +104/−0
+ 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