packages feed

tramaj-hs 0.3.0.0 → 0.4.0.0

raw patch · 17 files changed

+2174/−589 lines, 17 filesdep ~aesonPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: aeson

API changes (from Hackage documentation)

- Tramaj.Ast: NumberLit :: Double -> Expr
- Tramaj.Ast: TCScalarNum :: Double -> TypeConstraintArg
- Tramaj.Types: RCScalarNum :: Double -> ResolvedConstraintArg
+ Tramaj.Analysis: arithmeticNames :: [Text]
+ Tramaj.Analysis: arithmeticOps :: Program -> Set Text
+ Tramaj.Analysis: deepArithmeticOps :: Map Text Program -> Program -> Set Text
+ Tramaj.Ast: FloatLit :: Double -> Expr
+ Tramaj.Ast: IntLit :: Int64 -> Expr
+ Tramaj.Ast: SortBy :: Bool -> Expr -> Expr -> Expr
+ Tramaj.Ast: TCScalarFloat :: Double -> TypeConstraintArg
+ Tramaj.Ast: TCScalarInt :: Int64 -> TypeConstraintArg
+ Tramaj.Eval: NotRepresentable :: Text -> EvalError
+ Tramaj.Eval: Options :: Mode -> Bool -> Options
+ Tramaj.Eval: [optArithmetic] :: Options -> Bool
+ Tramaj.Eval: [optMode] :: Options -> Mode
+ Tramaj.Eval: data Options
+ Tramaj.Eval: defaultOptions :: Options
+ Tramaj.Eval: emittedConstraintCount :: Options -> LibraryTable -> Json -> Program -> Either EvalError Int
+ Tramaj.Eval: evalProgramWith :: Options -> LibraryTable -> Json -> Program -> Either EvalError Output
+ Tramaj.Eval: instance GHC.Classes.Eq Tramaj.Eval.Options
+ Tramaj.Eval: instance GHC.Classes.Eq Tramaj.Eval.SortKey
+ Tramaj.Eval: instance GHC.Classes.Ord Tramaj.Eval.SortKey
+ Tramaj.Eval: instance GHC.Show.Show Tramaj.Eval.Options
+ Tramaj.Eval: runProgramWith :: Options -> LibraryTable -> Json -> Program -> Either EvalError Json
+ Tramaj.Json: JArray :: [Json] -> Json
+ Tramaj.Json: JBool :: Bool -> Json
+ Tramaj.Json: JFloat :: Double -> Json
+ Tramaj.Json: JInt :: Integer -> Json
+ Tramaj.Json: JNull :: Json
+ Tramaj.Json: JObject :: Map Text Json -> Json
+ Tramaj.Json: JString :: Text -> Json
+ Tramaj.Json: data Json
+ Tramaj.Json: formatFloat :: Double -> Text
+ Tramaj.Json: formatInteger :: Integer -> Text
+ Tramaj.Json: fromAeson :: Value -> Json
+ Tramaj.Json: inIntegerRange :: Integer -> Bool
+ Tramaj.Json: instance GHC.Classes.Eq Tramaj.Json.Json
+ Tramaj.Json: instance GHC.Show.Show Tramaj.Json.Json
+ Tramaj.Json: jsonParser :: Text -> Either String Json
+ Tramaj.Json: maxInteger :: Integer
+ Tramaj.Json: minInteger :: Integer
+ Tramaj.Json: normalizeFloat :: Double -> Either String Double
+ Tramaj.Json: normalizeInteger :: Integer -> Either String Int64
+ Tramaj.Json: normalizeNumbers :: Json -> Either String Json
+ Tramaj.Json: quoteString :: Text -> Text
+ Tramaj.Json: stringify :: Json -> Text
+ Tramaj.Json: toAeson :: Json -> Value
+ Tramaj.Types: RCScalarFloat :: Double -> ResolvedConstraintArg
+ Tramaj.Types: RCScalarInt :: Int64 -> ResolvedConstraintArg
- Tramaj.Eval: OValue :: Value -> Output
+ Tramaj.Eval: OValue :: Json -> Output
- Tramaj.Eval: evalProgram :: Mode -> LibraryTable -> Value -> Program -> Either EvalError Output
+ Tramaj.Eval: evalProgram :: Mode -> LibraryTable -> Json -> Program -> Either EvalError Output
- Tramaj.Eval: runProgram :: Mode -> LibraryTable -> Value -> Program -> Either EvalError Value
+ Tramaj.Eval: runProgram :: Mode -> LibraryTable -> Json -> Program -> Either EvalError Json
- Tramaj.Node: NAction :: Text -> Text -> Value -> NodeAttribute
+ Tramaj.Node: NAction :: Text -> Text -> Json -> NodeAttribute
- Tramaj.Node: NAttr :: Text -> Value -> NodeAttribute
+ Tramaj.Node: NAttr :: Text -> Json -> NodeAttribute
- Tramaj.Node: NElement :: Text -> [NodeAttribute] -> Value -> [Node] -> Annotations -> Node
+ Tramaj.Node: NElement :: Text -> [NodeAttribute] -> Json -> [Node] -> Annotations -> Node
- Tramaj.Node: NText :: Value -> Annotations -> Node
+ Tramaj.Node: NText :: Json -> Annotations -> Node
- Tramaj.Node: mapActions :: Applicative f => (Text -> Text -> Value -> f NodeAttribute) -> Node -> f Node
+ Tramaj.Node: mapActions :: Applicative f => (Text -> Text -> Json -> f NodeAttribute) -> Node -> f Node
- Tramaj.Node: nodeAttributeToJson :: NodeAttribute -> Value
+ Tramaj.Node: nodeAttributeToJson :: NodeAttribute -> Json
- Tramaj.Node: nodeFromJson :: Value -> Either String Node
+ Tramaj.Node: nodeFromJson :: Json -> Either String Node
- Tramaj.Node: nodeToJson :: Node -> Value
+ Tramaj.Node: nodeToJson :: Node -> Json
- Tramaj.Node: type Annotations = Map Text Value
+ Tramaj.Node: type Annotations = Map Text Json

Files

CHANGELOG.md view
@@ -1,5 +1,94 @@ # Changelog +## 0.4.0.0++**Breaking: integers and floats are two number types** (`../specs/decisions.md`+\S18, `../specs/reference.md` \S3). A literal or a JSON number with neither a+fraction nor an exponent is an integer; one with either is a float. Nothing+converts between them: `eq(1, 1.0)` is `false`, `lt`/`lte`/`gt`/`gte` over an+integer and a float are a `TypeMismatch`, and `has`/`lookup` take an integer+index. The integer range is the signed 64-bit one; an integer literal outside+it is a parse error and a context integer outside it a `TypeMismatch`. A+float is written with a fraction or an exponent everywhere, so `str(1.0)` is+`1.0` where it was `1`. The arithmetic builtins are not part of this.++New `Tramaj.Json`: a JSON value with `JInt` and `JFloat`, `jsonParser` (which+types a number by its text) and `stringify`. `evalProgram`, `runProgram`,+`Output`, `Node`, `NodeAttribute`, `Annotations`, `nodeToJson`,+`nodeFromJson`, `nodeAttributeToJson` and `mapActions` use it where they used+`Data.Aeson.Value`, which holds one number type; `fromAeson` and `toAeson`+bridge the two. `NumberLit` is split into `IntLit`/`FloatLit`, `TCScalarNum`+into `TCScalarInt`/`TCScalarFloat` and `RCScalarNum` into+`RCScalarInt`/`RCScalarFloat`. The type primitive `number` is gone; `int` and+`float` replace it. Needs `aeson >= 2.2.1`, for its token decoder.++**The arithmetic profile** (`../specs/reference.md` \S9, \S11, \S12,+`../specs/v3-symbols.md` \S1.9, \S5.2 to \S5.5), as an option of one+evaluation that is off by default. Nine builtins: `sum`, `product`, `negate`,+`inverse`, `quotient`, `floor-quotient`, `modulo`, `floor` and `real`, over+the two number types with no conversion or promotion. Integer results are+held to the signed 64-bit range at every step of a fold and nothing wraps;+float results are one correctly rounded double operation at a time, in a left+fold. Over a symbol they build a term, written+`{"$term": <op>, "arguments": [...]}`, which crosses wherever a symbol does+and is refused wherever a symbol is.++New in `Tramaj.Eval`: `Options (..)` (`optMode`, `optArithmetic`),+`defaultOptions` (concrete mode, arithmetic off), `evalProgramWith` and+`runProgramWith`, which take an `Options` where `evalProgram` and+`runProgram` take a `Mode`. Those two keep their signatures and run without+the profile, so the nine names stay unbound there and `sum(1, 2)` is an+`UnboundName`. The option applies to the root and to every library the+evaluation runs. New in `Tramaj.Analysis`: `arithmeticNames`, `arithmeticOps`+and `deepArithmeticOps`, which report the arithmetic names a program+references free, so a host that leaves the profile off can refuse a program+before running it.++**Breaking**, with or without the profile:++- `EvalError` gains the constructor `NotRepresentable`: integer overflow, a+  zero divisor, a float result that is not finite. A host that matches every+  constructor needs a case for it.+- `"$term"` joins `"$sym"` and `"$type"` as a reserved key. `{"$term": 1}` in+  a program is a parse error, and a context carrying that key is a+  `TypeMismatch`, in concrete mode always and in symbolic mode unless it is a+  well-formed term and the profile is on.++**Sorting, number formatting and `round`** (`../specs/decisions.md` \S20,+`../specs/reference.md` \S11).++- `sort-by(list, fn)` and `sort-by-descending(list, fn)` order a list by the+  key `fn` gives each element. Keys are all integers, all floats or all+  strings (compared by code point); anything else is a `TypeMismatch`, and a+  symbol or a term a `NotConcrete`. Both are stable, each on its own terms,+  and `fn` is applied once per element, in index order. They are core+  forms, in every profile.+- `format-number(x, decimals, group)` writes an integer or a float in+  positional decimal notation with `decimals` digits (0 to 20) after the+  point and `group` between the groups of three digits of the integer part.+  It rounds the exact value of the number, ties away from zero, never writes+  an exponent or a negative zero, and has no locale. An ordinary builtin, in+  every profile.+- `round(x)` is the tenth builtin of the arithmetic profile: the integer+  nearest to `x`, ties away from zero, a `NotRepresentable` outside the+  integer range, and a term over a symbol.++**Breaking:**++- `Expr` gains the constructor `SortBy Bool Expr Expr` (descending,+  collection, key function). A host that matches every constructor needs a+  case for it; `subExprs` and every analysis already traverse it.+- `sort-by` and `sort-by-descending` are special-form names, as `map` is. A+  program that bound one of them and called it (`$sort-by(...)`) now gets+  the form, or a parse error if the call does not have two arguments.+- `format-number` joins `builtinNames`, and `round` joins `arithmeticNames`,+  so `arithmeticOps` reports it. A program that binds either name still+  shadows it.++New in `Tramaj.Eval`: `emittedConstraintCount`, the number of constraints an+evaluation emitted before equal ones are made one. It is there for tests,+which have no other way to count the applications of a function.+ ## 0.3.0.0  Implements v2 of the language. This is a rewrite of the semantic core, not an@@ -63,8 +152,6 @@ than reported as an interpolation that cannot go there.  Not yet ported to the PureScript `tramaj` package, which still implements v1.--## Unreleased  Adds `fold(arr, init, fn)`, a fourth functional array primitive alongside `map`/`filter`/`scan`: same `(acc, item)` step and `scanl` iteration order as
src/Tramaj/Analysis.hs view
@@ -29,6 +29,9 @@   , symbolSites   , symbolDemands   , deepSymbolDemands+  , arithmeticNames+  , arithmeticOps+  , deepArithmeticOps   , typeDeclarations   , typeParams   , unsuppliedTypeParams@@ -256,6 +259,62 @@ deepSymbolDemands libs prog =   symbolDemands prog     <> foldMap (maybe Set.empty symbolDemands . flip Map.lookup libs) (transitiveImportNames libs prog)++-- Arithmetic -------------------------------------------------------------------++-- | The ten names of the arithmetic profile (reference.md \S11), @round@+-- being the tenth (decisions \S20). They are+-- names and not syntax: an evaluation with the profile on binds them in the+-- initial environment ('Tramaj.Eval.optArithmetic'), and a program may bind+-- any of them itself.+arithmeticNames :: [Text]+arithmeticNames =+  [ "sum"+  , "product"+  , "negate"+  , "inverse"+  , "quotient"+  , "floor-quotient"+  , "modulo"+  , "floor"+  , "real"+  , "round"+  ]++-- | Which of 'arithmeticNames' this program references free (reference.md+-- \S9), called (@sum(1, 2)@) or passed by reference (@fold($xs, 0, $sum)@)+-- alike, since both are a 'Path' rooted at the name.+--+-- Scope-aware: a name bound by a binding, a lambda parameter or a pattern+-- name is not reported where that binding is in scope. A pattern is lowered+-- to plain bindings by the parser, so it needs no case here. A binding's own+-- right-hand side is outside its scope, so @\@sum = sum(1, 2)@ reports+-- @sum@.+--+-- Over-approximates like every analysis here: a name used only under a+-- 'Branch' arm no context will select is still reported.+arithmeticOps :: Program -> Set Text+arithmeticOps = go Set.empty . programRoot+  where+    go bound = \case+      Path root _+        | root `elem` arithmeticNames && not (Set.member root bound) -> Set.singleton root+        | otherwise -> Set.empty+      Let name value body -> go bound value <> go (Set.insert name bound) body+      TypeAnnotate name _ value body -> go bound value <> go (Set.insert name bound) body+      Lambda params body -> go (Set.union (Set.fromList params) bound) body+      other -> foldMap (go bound) (subExprs other)++-- | 'arithmeticOps' of this program and of every library it imports,+-- directly or not. A library has its own scope, so a binding in the+-- importing program shadows nothing there. This is what a host that leaves+-- the arithmetic profile off checks before running a program: non-empty+-- means the program would fail with 'Tramaj.Eval.UnboundName', and with the+-- profile on it names the operations a term can carry (v3-symbols \S7).+deepArithmeticOps :: Map Text Program -> Program -> Set Text+deepArithmeticOps libs prog =+  arithmeticOps prog+    <> foldMap (maybe Set.empty arithmeticOps . flip Map.lookup libs) (transitiveImportNames libs prog)  -- Types ----------------------------------------------------------------------- 
src/Tramaj/Ast.hs view
@@ -43,6 +43,7 @@   , numberAllocs   ) where +import Data.Int (Int64) import Data.Text (Text) import qualified Data.Text as T @@ -84,7 +85,13 @@     -- ones.     Let Text Expr Expr   | StringLit Text-  | NumberLit Double+  | -- | A number literal with neither a fraction nor an exponent+    -- (reference.md \S5): an integer, denoting exactly its value. The parser+    -- refuses one outside the signed 64-bit range.+    IntLit Int64+  | -- | A number literal with a fraction or an exponent: a float, the+    -- finite double nearest to its decimal value, never a negative zero.+    FloatLit Double   | BoolLit Bool   | NullLit   | ArrayLit [Expr]@@ -106,6 +113,12 @@   | Filter Expr Expr   | Scan Expr Expr Expr   | Fold Expr Expr Expr+  | -- | @sort-by(collection, function)@ and, with the flag set,+    -- @sort-by-descending(collection, function)@ (reference.md \S11,+    -- /Sorting/): the elements of the collection ordered by the key the+    -- function gives each of them. A core constructor for the reason 'Map'+    -- is one: the key function needs a fresh binding per element.+    SortBy Bool Expr Expr   | -- | The monoid operation @a \<\> b@, over @String@, @Array@ or @Object@ --     -- same type on both sides, right-biased on object key collisions.     Concat Expr Expr@@ -177,7 +190,8 @@ data TypeConstraintArg   = TCType TypeExpr   | TCScalarStr Text-  | TCScalarNum Double+  | TCScalarInt Int64+  | TCScalarFloat Double   | TCScalarBool Bool   | TCScalarNull   deriving stock (Eq, Show)@@ -191,7 +205,7 @@ -- declaration name from a typo. -- -- 'TPrim' is recognized here, at parse time, rather than left for resolution--- to classify: the five primitive names are a closed, reserved lexical set+-- to classify: the six primitive names are a closed, reserved lexical set -- (v4-types \S1), not ordinary identifiers that happen to resolve to a -- primitive. data TypeExpr@@ -284,7 +298,8 @@ subExprs (Lambda _ body) = [body] subExprs (Let _ value body) = [value, body] subExprs (StringLit _) = []-subExprs (NumberLit _) = []+subExprs (IntLit _) = []+subExprs (FloatLit _) = [] subExprs (BoolLit _) = [] subExprs NullLit = [] subExprs (ArrayLit elems) = elems@@ -296,6 +311,7 @@ subExprs (Filter coll fn) = [coll, fn] subExprs (Scan coll initial fn) = [coll, initial, fn] subExprs (Fold coll initial fn) = [coll, initial, fn]+subExprs (SortBy _ coll fn) = [coll, fn] subExprs (Concat l r) = [l, r] subExprs (Import _ params) = [e | (_, PExpr e) <- params] subExprs (AdaptActions target _ fn) = target : maybe [] pure fn@@ -408,7 +424,8 @@           (n2, body') = go n1 body        in (n2, Let name value' body')     go n e'@(StringLit _) = (n, e')-    go n e'@(NumberLit _) = (n, e')+    go n e'@(IntLit _) = (n, e')+    go n e'@(FloatLit _) = (n, e')     go n e'@(BoolLit _) = (n, e')     go n NullLit = (n, NullLit)     go n (ArrayLit elems) = let (n', elems') = goList n elems in (n', ArrayLit elems')@@ -443,6 +460,10 @@           (n2, initial') = go n1 initial           (n3, fn') = go n2 fn        in (n3, Fold coll' initial' fn')+    go n (SortBy descending coll fn) =+      let (n1, coll') = go n coll+          (n2, fn') = go n1 fn+       in (n2, SortBy descending coll' fn')     go n (Concat l r) =       let (n1, l') = go n l           (n2, r') = go n1 r
src/Tramaj/Eval.hs view
@@ -20,38 +20,37 @@   ( EvalError (..)   , Output (..)   , Mode (..)+  , Options (..)+  , defaultOptions   , LibraryTable   , evalProgram+  , evalProgramWith   , runProgram+  , runProgramWith   , evalExprWith+  , emittedConstraintCount   , builtinNames   ) where -import Data.Aeson (Value (..), encode)-import qualified Data.Aeson.Key as Key-import qualified Data.Aeson.KeyMap as KeyMap+import Control.Monad (foldM) import Data.Bifunctor (first)-import Data.Char (intToDigit) import Data.Foldable (traverse_)-#if __GLASGOW_HASKELL__ >= 910-import Data.List (sortOn)-#else-import Data.List (foldl', sortOn)+import Data.Int (Int64)+import Data.List (sortBy)+#if __GLASGOW_HASKELL__ < 910+import Data.List (foldl') #endif+import Data.Ord (Down (..), comparing) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Maybe (catMaybes)-import Data.Scientific (Scientific, fromFloatDigits, toRealFloat) import Data.Set (Set) import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T-import qualified Data.Text.Lazy as TL-import qualified Data.Text.Lazy.Encoding as TLE-import qualified Data.Vector as V-import Numeric (floatToDigits)-import Tramaj.Analysis (symbolSites)+import Tramaj.Analysis (arithmeticNames, symbolSites) import Tramaj.Ast+import Tramaj.Json (Json (..), inIntegerRange, normalizeNumbers, stringify) import Tramaj.Node import Tramaj.Types (ResolvedConstraintArg (..), ResolvedType (..), TypeError, canonicalId, deepTypeConstraints, eraseTypes, programTypeRoots, typeClosure) @@ -92,6 +91,11 @@     -- -- but 'Tramaj.Types.TypeError' still needs a home in the one error     -- type every entry point already returns.     TypeErr TypeError+  | -- | An arithmetic operation has no result in the type of its operands+    -- (reference.md \S11, \S12): an integer result outside the integer+    -- range, a zero divisor, a float result that is not finite. Nothing+    -- wraps, saturates or rounds instead.+    NotRepresentable Text   deriving stock (Eq, Show)  -- | What a program produced. Which one it is follows from the value the root@@ -99,7 +103,7 @@ -- root is @$header@ yields a document if that binding holds one. data Output   = ONode Node-  | OValue Value+  | OValue Json   deriving stock (Eq, Show)  -- | A host parameter, not a property of the program (v3-symbols \S5): what an@@ -111,6 +115,31 @@ data Mode = Concrete | Symbolic   deriving stock (Eq, Show) +-- | What a host chooses for one evaluation. Like 'Mode', each field is a+-- host parameter and not a property of the program.+--+-- 'optArithmetic' is reference.md \S11's arithmetic profile, per+-- evaluation: with it on, the ten names of+-- 'Tramaj.Analysis.arithmeticNames' are in the initial environment of the+-- program and of every library it runs, and a seeded term is accepted in+-- symbolic mode (v3-symbols \S5.4). With it off they are unbound, so a+-- program that uses one fails with 'UnboundName', and every seeded term is+-- refused. The two number types and the reserved @"$term"@ key do not+-- depend on it.+data Options = Options+  { optMode :: Mode+  , optArithmetic :: Bool+  }+  deriving stock (Eq, Show)++-- | Concrete mode, without the arithmetic profile. 'evalProgram' and+-- 'runProgram' run with these options and the mode they are given. The+-- profile is off unless a host asks for it, so that a host which has not+-- opted in never receives a term, and can refuse a program up front with+-- 'Tramaj.Analysis.deepArithmeticOps'.+defaultOptions :: Options+defaultOptions = Options {optMode = Concrete, optArithmetic = False}+ -- | Host-supplied library store. Where a library came from -- a file, an -- embedded string, a fetch -- is entirely the host's business; the evaluator -- only ever sees an already-parsed 'Program'.@@ -125,10 +154,15 @@ -- -- There is no recursion: 'Let' inserts a binding only after evaluating its -- right-hand side, so a closure cannot see its own name.+--+-- A number is an integer or a float (reference.md \S3), and nothing here+-- converts one into the other. 'VInt' covers the signed 64-bit range;+-- 'VFloat' holds a finite double that is never a negative zero. data Value'   = VNull   | VBool Bool-  | VNumber Scientific+  | VInt Int64+  | VFloat Double   | VString Text   | VArray [Value']   | VObject (Map Text Value')@@ -148,6 +182,13 @@     -- demand (@?ctx.path@) once minted -- its own path is baked into the id,     -- not carried here.     VSymbol SymbolId [Text]+  | -- | A term (\S1.9): an arithmetic operation left unevaluated because one+    -- of its operands is a symbol or a term. It holds the name of the+    -- builtin and its operands exactly as the call had them once flattened:+    -- each a 'VInt', a 'VFloat', a 'VSymbol' or a 'VTerm', nothing folded+    -- and no nested term spliced. It is data as a symbol is, and is refused+    -- wherever a symbol is.+    VTerm Text [Value']  type Env = Map Text Value' @@ -173,7 +214,7 @@ -- the key it was allocated with already reduced to JSON; or an unsupplied -- @?ctx.path@ demand, minted at the root. data SymbolOrigin-  = OAlloc Int Value+  = OAlloc Int Json   | ODemand [Text]   deriving stock (Eq, Show) @@ -201,11 +242,15 @@ -- whether this is the root program or somewhere inside a library (\S1.3, -- \S1.4) -- bundled so that adding one more such fact, as \S1.4's did, -- touches this record instead of every function's argument list.+-- 'ecArithmetic' is whether the arithmetic profile is on ('Options'): the+-- same for the root and for every library, since it is what the host+-- enabled. data EvalCtx = EvalCtx   { ecLibs :: LibraryTable   , ecInProgress :: Set Text   , ecMode :: Mode   , ecIsRoot :: Bool+  , ecArithmetic :: Bool   }  -- | Enters a library: adds it to the in-progress set (so re-entering it is a@@ -291,25 +336,42 @@ -- unsupplied root demand mints a symbol in symbolic mode and raises -- 'SymbolsUnavailable' in concrete mode. There is no mode-independent -- evaluation any more, so every entry point takes one.-evalProgram :: Mode -> LibraryTable -> Value -> Program -> Either EvalError Output-evalProgram mode libs input prog = fst <$> evalProgramWithEmissions mode libs input prog+evalProgram :: Mode -> LibraryTable -> Json -> Program -> Either EvalError Output+evalProgram mode = evalProgramWith defaultOptions {optMode = mode} +-- | As 'evalProgram', with every per-evaluation option given ('Options').+evalProgramWith :: Options -> LibraryTable -> Json -> Program -> Either EvalError Output+evalProgramWith options libs input prog = fst <$> evalProgramWithEmissions options libs input prog+ -- | As 'evalProgram', but also returns the deduplicated 'Emissions' -- (v3-symbols \S4) -- empty for any program that emits or allocates nothing, -- and always empty in what concrete mode goes on to serialize, since -- concrete mode discards them (\S5.1) and cannot produce a symbol at all.-evalProgramWithEmissions :: Mode -> LibraryTable -> Value -> Program -> Either EvalError (Output, Emissions)-evalProgramWithEmissions mode libs input prog = do-  erased <- first TypeErr (eraseTypes libs prog)-  (v, emitted) <- runEval $ do-    ctx <- liftEither (checkedFromJson mode input)-    let evalCtx = EvalCtx {ecLibs = libs, ecInProgress = Set.empty, ecMode = mode, ecIsRoot = True}-    evalExpr evalCtx (initialEnv ctx) (programRoot erased)+evalProgramWithEmissions :: Options -> LibraryTable -> Json -> Program -> Either EvalError (Output, Emissions)+evalProgramWithEmissions options libs input prog = do+  (v, emitted) <- evalProgramRaw options libs input prog   output <- case v of     VNode' n -> Right (ONode n)     other -> OValue <$> toJson other   pure (output, dedupe emitted) +-- | The value of a program and everything it emitted, in evaluation order+-- and before 'dedupe'.+evalProgramRaw :: Options -> LibraryTable -> Json -> Program -> Either EvalError (Value', Emissions)+evalProgramRaw options libs input prog = do+  erased <- first TypeErr (eraseTypes libs prog)+  runEval $ do+    ctx <- liftEither (checkedFromJson options input)+    evalExpr (rootCtx options libs) (initialEnv (optArithmetic options) ctx) (programRoot erased)++-- | How many constraints an evaluation emitted, counted before 'dedupe'+-- makes equal ones one. No program and no output can tell this number: it+-- is here for tests, as the one way to count how many times a function was+-- applied, which reference.md \S11 fixes for the key function of a sort.+emittedConstraintCount :: Options -> LibraryTable -> Json -> Program -> Either EvalError Int+emittedConstraintCount options libs input prog =+  length . emConstraints . snd <$> evalProgramRaw options libs input prog+ -- | Two constraints with the same name and equal arguments are one -- constraint, and two symbol-table entries with the same id are one entry -- -- each kept at the position of the first (v3-symbols \S4). Constraint@@ -346,13 +408,17 @@ -- discarded (v3-symbols \S5.1). Symbolic mode wraps the same 'Output' in the -- v3 envelope (\S5.2), including the deduplicated symbol table and -- constraint list.-runProgram :: Mode -> LibraryTable -> Value -> Program -> Either EvalError Value-runProgram mode libs input prog = do-  result <- evalProgramWithEmissions mode libs input prog-  typesInfo <- case mode of+runProgram :: Mode -> LibraryTable -> Json -> Program -> Either EvalError Json+runProgram mode = runProgramWith defaultOptions {optMode = mode}++-- | As 'runProgram', with every per-evaluation option given ('Options').+runProgramWith :: Options -> LibraryTable -> Json -> Program -> Either EvalError Json+runProgramWith options libs input prog = do+  result <- evalProgramWithEmissions options libs input prog+  typesInfo <- case optMode options of     Concrete -> Right (Map.empty, [])     Symbolic -> first TypeErr (buildTypesInfo libs prog)-  pure (renderOutput mode typesInfo result)+  pure (renderOutput (optMode options) typesInfo result)  -- | The @\"types\"@ table's entries and the deduplicated @\"type-constraints\"@ -- list (v4-types \S8, roadmap Phase 13), computed from @prog@ /before/@@ -369,112 +435,130 @@   tcs <- deepTypeConstraints libs prog   pure (closure, tcs) -renderOutput :: Mode -> (Map Text ResolvedType, [(Text, [ResolvedConstraintArg])]) -> (Output, Emissions) -> Value+renderOutput :: Mode -> (Map Text ResolvedType, [(Text, [ResolvedConstraintArg])]) -> (Output, Emissions) -> Json renderOutput Concrete _ (output, _) = case output of   ONode n -> nodeToJson n   OValue v -> v renderOutput Symbolic (typesTable, typeConstraintsList) (output, Emissions constraints symbols) =-  Object-    ( KeyMap.fromList-        [ ("format", String "tramaj/symbolic/1")-        , ("kind", String kind)-        , ("root", root)-        , ("symbols", Array (V.fromList (map symbolEntryToJson symbols)))-        , ("constraints", Array (V.fromList (map constraintToJson constraints)))-        , ("types", Array (V.fromList (map typeEntryToJson (Map.toList typesTable))))-        , ("type-constraints", Array (V.fromList (map typeConstraintToJson typeConstraintsList)))-        ]-    )+  object+    [ ("format", JString "tramaj/symbolic/1")+    , ("kind", JString kind)+    , ("root", root)+    , ("symbols", JArray (map symbolEntryToJson symbols))+    , ("constraints", JArray (map constraintToJson constraints))+    , ("types", JArray (map typeEntryToJson (Map.toList typesTable)))+    , ("type-constraints", JArray (map typeConstraintToJson typeConstraintsList))+    ]   where     (kind, root) = case output of       ONode n -> ("document", nodeToJson n)       OValue v -> ("expression", v) +object :: [(Text, Json)] -> Json+object = JObject . Map.fromList+ -- | One @\"types\"@ table entry (v4-types \S8): the id, and the definition -- behind it, rendered by 'resolvedTypeToJson'.-typeEntryToJson :: (Text, ResolvedType) -> Value+typeEntryToJson :: (Text, ResolvedType) -> Json typeEntryToJson (tid, rt) =-  Object (KeyMap.fromList [("id", String tid), ("definition", resolvedTypeToJson rt)])+  object [("id", JString tid), ("definition", resolvedTypeToJson rt)]  -- | A 'ResolvedType'\'s @\"definition\"@ shape (v4-types \S8's example): a -- tagged union whose @kind@ names which of the six algebra shapes it is. A -- 'RRef' renders as a pointer only -- its own definition is a separate entry -- in the table, not inlined here -- which is \S3's "stop at declaration -- boundaries" clause, still honoured at the JSON boundary.-resolvedTypeToJson :: ResolvedType -> Value-resolvedTypeToJson (RPrim name) = Object (KeyMap.fromList [("kind", String "prim"), ("name", String name)])-resolvedTypeToJson (RArray t) = Object (KeyMap.fromList [("kind", String "array"), ("element", resolvedTypeToJson t)])+resolvedTypeToJson :: ResolvedType -> Json+resolvedTypeToJson (RPrim name) = object [("kind", JString "prim"), ("name", JString name)]+resolvedTypeToJson (RArray t) = object [("kind", JString "array"), ("element", resolvedTypeToJson t)] resolvedTypeToJson (RRecord fields) =-  Object (KeyMap.fromList [("kind", String "record"), ("fields", Array (V.fromList (map field fields)))])+  object [("kind", JString "record"), ("fields", JArray (map field fields))]   where-    field (name, t) = Object (KeyMap.fromList [("name", String name), ("type", resolvedTypeToJson t)])+    field (name, t) = object [("name", JString name), ("type", resolvedTypeToJson t)] resolvedTypeToJson (RUnion arms) =-  Object (KeyMap.fromList [("kind", String "union"), ("arms", Array (V.fromList (map arm arms)))])+  object [("kind", JString "union"), ("arms", JArray (map arm arms))]   where     arm (name, mt) =-      Object (KeyMap.fromList (("name", String name) : maybe [] (\t -> [("payload", resolvedTypeToJson t)]) mt))-resolvedTypeToJson r@(RRef _ _ _) = Object (KeyMap.fromList [("kind", String "ref"), ("id", String (canonicalId r))])-resolvedTypeToJson (RVar path) = Object (KeyMap.fromList [("kind", String "var"), ("path", Array (V.fromList (map String path)))])+      object (("name", JString name) : maybe [] (\t -> [("payload", resolvedTypeToJson t)]) mt)+resolvedTypeToJson r@(RRef _ _ _) = object [("kind", JString "ref"), ("id", JString (canonicalId r))]+resolvedTypeToJson (RVar path) = object [("kind", JString "var"), ("path", JArray (map JString path))]  -- | A @\"type-constraints\"@ entry (v4-types \S8): a name and its resolved -- arguments, a type argument rendered as the erased @{\"$type\": ...}@ tag -- \S7 already reserves so a host reads both lists the same way, a scalar -- argument as the plain JSON it already is.-typeConstraintToJson :: (Text, [ResolvedConstraintArg]) -> Value+typeConstraintToJson :: (Text, [ResolvedConstraintArg]) -> Json typeConstraintToJson (name, args) =-  Object (KeyMap.fromList [("name", String name), ("arguments", Array (V.fromList (map arg args)))])+  object [("name", JString name), ("arguments", JArray (map arg args))]   where-    arg (RCType rt) = Object (KeyMap.fromList [("$type", String (canonicalId rt))])-    arg (RCScalarStr s) = String s-    arg (RCScalarNum n) = Number (fromFloatDigits n)-    arg (RCScalarBool b) = Bool b-    arg RCScalarNull = Null+    arg (RCType rt) = object [("$type", JString (canonicalId rt))]+    arg (RCScalarStr s) = JString s+    arg (RCScalarInt n) = JInt (toInteger n)+    arg (RCScalarFloat n) = JFloat n+    arg (RCScalarBool b) = JBool b+    arg RCScalarNull = JNull  -- | A constraint's envelope rendering (v3-symbols \S5.2): its name and its -- arguments, each already-evaluated to plain JSON, an argument that is -- itself a symbol rendering as \S5.3's @{"$sym": ..., "path": [...]}@ tag -- via 'toJson'.-constraintToJson :: Value' -> Value+constraintToJson :: Value' -> Json constraintToJson (VConstraint name args) =-  Object-    ( KeyMap.fromList-        [ ("name", String name)-        , ("arguments", Array (V.fromList (map (either (const Null) id . toJson) args)))-        ]-    )-constraintToJson other = either (const Null) id (toJson other)+  object+    [ ("name", JString name)+    , ("arguments", JArray (map (either (const JNull) id . toJson) args))+    ]+constraintToJson other = either (const JNull) id (toJson other)  -- | A symbol table entry's envelope rendering (\S5.2): id, origin (the -- structured form of the id, so a host never has to parse it) and binding.-symbolEntryToJson :: SymbolEntry -> Value+symbolEntryToJson :: SymbolEntry -> Json symbolEntryToJson (SymbolEntry sid origin binding) =-  Object-    ( KeyMap.fromList-        [ ("id", String sid)-        , ("origin", originToJson origin)-        , ("binding", maybe Null String binding)-        ]-    )+  object+    [ ("id", JString sid)+    , ("origin", originToJson origin)+    , ("binding", maybe JNull JString binding)+    ]   where     originToJson (OAlloc site key) =-      Object (KeyMap.fromList [("kind", String "alloc"), ("site", Number (fromIntegral site)), ("key", key)])+      object [("kind", JString "alloc"), ("site", JInt (toInteger site)), ("key", key)]     originToJson (ODemand path) =-      Object (KeyMap.fromList [("kind", String "demand"), ("path", Array (V.fromList (map String path)))])+      object [("kind", JString "demand"), ("path", JArray (map JString path))]  -- | Evaluates one expression against a context value, with the builtins in -- scope -- the shared path 'evalProgram' and library evaluation both take. -- Any emissions reached are discarded; callers that need them should use--- 'evalExpr' directly inside 'Eval'.+-- 'evalExpr' directly inside 'Eval'. Runs without the arithmetic profile,+-- as 'evalProgram' does. evalExprWith :: Mode -> LibraryTable -> Value' -> Expr -> Either EvalError Value' evalExprWith mode libs ctx e =-  fst <$> runEval (evalExpr (EvalCtx {ecLibs = libs, ecInProgress = Set.empty, ecMode = mode, ecIsRoot = True}) (initialEnv ctx) e)+  fst <$> runEval (evalExpr (rootCtx options libs) (initialEnv (optArithmetic options) ctx) e)+  where+    options = defaultOptions {optMode = mode} -initialEnv :: Value' -> Env-initialEnv ctx = Map.insert "ctx" ctx (Map.fromList [(n, VBuiltin n) | n <- builtinNames])+-- | Where the evaluation of a root program starts.+rootCtx :: Options -> LibraryTable -> EvalCtx+rootCtx options libs =+  EvalCtx+    { ecLibs = libs+    , ecInProgress = Set.empty+    , ecMode = optMode options+    , ecIsRoot = True+    , ecArithmetic = optArithmetic options+    } --- | The fixed builtin vocabulary. Builtins are ordinary values in the initial--- environment rather than a separate call form, so @cardinality($xs)@,--- @$f($x)@ and @map($xs, $not)@ all go through 'Call'.+-- | @$ctx@ and the builtins. The ten arithmetic names are bound only with+-- the arithmetic profile on (reference.md \S11); without it they are+-- ordinary unbound names.+initialEnv :: Bool -> Value' -> Env+initialEnv arithmeticOn ctx = Map.insert "ctx" ctx (Map.fromList [(n, VBuiltin n) | n <- names])+  where+    names = if arithmeticOn then builtinNames <> arithmeticNames else builtinNames++-- | The fixed builtin vocabulary of every profile; the arithmetic profile+-- adds 'Tramaj.Analysis.arithmeticNames' to it. Builtins are ordinary values+-- in the initial environment rather than a separate call form, so+-- @cardinality($xs)@, @$f($x)@ and @map($xs, $not)@ all go through 'Call'. builtinNames :: [Text] builtinNames =   [ "cardinality"@@ -490,6 +574,7 @@   , "gte"   , "has"   , "lookup"+  , "format-number"   , "concat"   , "append"   ]@@ -517,7 +602,8 @@   v <- evalBindable ctx env (if isHiddenName name then Nothing else Just name) valueExpr   evalExpr ctx (Map.insert name v env) body evalExpr _ _ (StringLit s) = pure (VString s)-evalExpr _ _ (NumberLit n) = pure (VNumber (fromFloatDigits n))+evalExpr _ _ (IntLit n) = pure (VInt n)+evalExpr _ _ (FloatLit n) = pure (VFloat n) evalExpr _ _ (BoolLit b) = pure (VBool b) evalExpr _ _ NullLit = pure VNull evalExpr ctx env (ArrayLit elems) =@@ -555,6 +641,25 @@   acc0 <- evalExpr ctx env initExpr   fnVal <- evalExpr ctx env fnExpr   foldSteps ctx fnVal acc0 items+-- | reference.md \S11, /Sorting/. The checks come in the order the+-- reference gives them: the collection, then, element by element in index+-- order, the key function's application and the key it gave. Only then is+-- anything reordered, so the key function runs exactly once per element.+--+-- 'sortBy' is a stable merge sort, and so is the one over 'Down': elements+-- whose keys are equal keep their input order under both names, which makes+-- the descending sort something other than the reversal of the ascending+-- one.+evalExpr ctx env (SortBy descending collExpr fnExpr) = do+  items <- evalCollection ctx env who collExpr+  fnVal <- evalExpr ctx env fnExpr+  keyed <- sortKeys ctx who fnVal items+  pure (VArray (map snd (ordered keyed)))+  where+    who = if descending then "sort-by-descending" else "sort-by"+    ordered+      | descending = sortBy (comparing (Down . fst))+      | otherwise = sortBy (comparing fst) evalExpr ctx env (Concat leftExpr rightExpr) = do   l <- evalExpr ctx env leftExpr   r <- evalExpr ctx env rightExpr@@ -752,23 +857,67 @@   next <- apply ctx "fold" fnVal [acc, item]   foldSteps ctx fnVal next rest +-- | What a sort orders by (reference.md \S11, /Sorting/). The keys of one+-- call all have the same constructor, which 'sortKeys' checks, so the+-- derived order only ever compares two integers, two floats or two strings.+-- A float key is never a NaN and never a negative zero (\S3), so its order+-- is total; 'Text' compares by code point.+data SortKey+  = KeyInt Int64+  | KeyFloat Double+  | KeyString Text+  deriving stock (Eq, Ord)++-- | Each element with its key: the key function is applied once per+-- element, in index order, and each key is checked as soon as it is known,+-- so the first element whose application or key fails decides the error. A+-- symbol or a term as a key is 'NotConcrete'; a value of a kind that is+-- never a key, or of another type than the first key, is a 'TypeMismatch'.+sortKeys :: EvalCtx -> Text -> Value' -> [Value'] -> Eval [(SortKey, Value')]+sortKeys ctx who fnVal = go Nothing+  where+    go _ [] = pure []+    go firstKey (item : rest) = do+      key <- apply ctx who fnVal [item] >>= liftEither . asKey+      case firstKey of+        Just k0 | not (sameType k0 key) ->+          evalError (TypeMismatch (who <> " expects keys of one type, got " <> describeKey k0 <> " and then " <> describeKey key))+        _ -> ((key, item) :) <$> go (Just (maybe key id firstKey)) rest++    asKey (VInt n) = Right (KeyInt n)+    asKey (VFloat d) = Right (KeyFloat d)+    asKey (VString s) = Right (KeyString s)+    asKey v+      | isSymbolic v = Left (NotConcrete who)+      | otherwise = Left (TypeMismatch (who <> " expects a key that is an integer, a float or a string, got " <> describeValue v))++    sameType (KeyInt _) (KeyInt _) = True+    sameType (KeyFloat _) (KeyFloat _) = True+    sameType (KeyString _) (KeyString _) = True+    sameType _ _ = False++    describeKey :: SortKey -> Text+    describeKey (KeyInt _) = "an integer"+    describeKey (KeyFloat _) = "a float"+    describeKey (KeyString _) = "a string"+ evalCollection :: EvalCtx -> Env -> Text -> Expr -> Eval [Value'] evalCollection ctx env who e =   evalExpr ctx env e >>= \case     VArray xs -> pure xs     VSymbol _ _ -> evalError (NotConcrete who)+    VTerm _ _ -> evalError (NotConcrete who)     other -> evalError (TypeMismatch (who <> " expects an array as its first argument, got " <> describeValue other))  -- Concat -------------------------------------------------------------------------  -- | The monoid operation over the three types that have one. Mixed types are -- an error rather than a coercion, and objects merge right-biased. Either--- side being a symbol is 'NotConcrete' (v3-symbols \S1.5), ahead of the--- generic mismatch: the spine of a concatenation must be known, unlike an--- element it might merely carry.+-- side being a symbol or a term is 'NotConcrete' (v3-symbols \S1.5, \S1.9),+-- ahead of the generic mismatch: the spine of a concatenation must be known,+-- unlike an element it might merely carry. concatValues :: Value' -> Value' -> Either EvalError Value'-concatValues (VSymbol _ _) _ = Left (NotConcrete "<>")-concatValues _ (VSymbol _ _) = Left (NotConcrete "<>")+concatValues l r | isSymbolic l || isSymbolic r = Left (NotConcrete "<>") concatValues (VString a) (VString b) = Right (VString (a <> b)) concatValues (VArray a) (VArray b) = Right (VArray (a <> b)) concatValues (VObject a) (VObject b) = Right (VObject (Map.union b a))@@ -815,7 +964,7 @@       mapEvalError (InLibrary name) $ do         let ctx' = enterLibrary name ctx             (statements, root) = unlets (programRoot prog)-        libEnv <- foldlEval (bindStep ctx') (initialEnv ctxVal) statements+        libEnv <- foldlEval (bindStep ctx') (initialEnv (ecArithmetic ctx) ctxVal) statements         rendered <- evalExpr ctx' libEnv root         let bindings = filter (not . isHiddenName . fst) (letBindings statements)         pure (VEnv (Map.fromList [("rendered", rendered), ("vals", VEnv (Map.fromList (map (\(n, _) -> (n, libEnv Map.! n)) bindings)))]))@@ -867,18 +1016,18 @@ -- and may change only its event type and payload. A @key@ in the closure's -- result is ignored -- letting it win would put the action vocabulary back -- beyond static reach, which is the whole point of restricting adaptation.-adaptAction :: EvalCtx -> ActionAdaptation -> Maybe Value' -> Text -> Text -> Value -> Eval NodeAttribute+adaptAction :: EvalCtx -> ActionAdaptation -> Maybe Value' -> Text -> Text -> Json -> Eval NodeAttribute adaptAction ctx adaptation fnVal event key payload =   case fnVal of     Nothing -> pure (NAction event key' payload)     Just fn -> do       result <- apply ctx "adapt-actions" fn [actionAsValue] >>= liftEither . toJson       case result of-        Object obj -> do-          event' <- liftEither $ case KeyMap.lookup (Key.fromText "eventType") obj of-            Just (String s) -> Right s+        JObject obj -> do+          event' <- liftEither $ case Map.lookup "eventType" obj of+            Just (JString s) -> Right s             _ -> Left (TypeMismatch "adapt-actions: the function's result needs a string \"eventType\" field")-          let payload' = maybe Null id (KeyMap.lookup (Key.fromText "payload") obj)+          let payload' = maybe JNull id (Map.lookup "payload" obj)           pure (NAction event' key' payload')         _ -> evalError (TypeMismatch "adapt-actions: the function must return an object with an eventType field")   where@@ -911,6 +1060,8 @@   -- whoever owns its meaning, not the language. Consumes every remaining   -- segment at once, since a projection just extends the path.   VSymbol sid path -> pure (VSymbol sid (path <> fields))+  -- A term has no projection (\S1.9): it stands for a number, and a number+  -- has no fields, so it gets the 'TypeMismatch' a number gets.   other ->     evalError       ( TypeMismatch@@ -926,13 +1077,14 @@ -- The values that cannot cross say why. A node is deliberately included: -- documents nest as children, not as attribute values, and silently -- serializing one here would hide a mistake rather than report it.-toJson :: Value' -> Either EvalError Value-toJson VNull = Right Null-toJson (VBool b) = Right (Bool b)-toJson (VNumber n) = Right (Number n)-toJson (VString s) = Right (String s)-toJson (VArray xs) = Array . V.fromList <$> traverse toJson xs-toJson (VObject o) = Object . KeyMap.fromList <$> traverse (\(k, v) -> (,) (Key.fromText k) <$> toJson v) (Map.toList o)+toJson :: Value' -> Either EvalError Json+toJson VNull = Right JNull+toJson (VBool b) = Right (JBool b)+toJson (VInt n) = Right (JInt (toInteger n))+toJson (VFloat n) = Right (JFloat n)+toJson (VString s) = Right (JString s)+toJson (VArray xs) = JArray <$> traverse toJson xs+toJson (VObject o) = JObject <$> traverse toJson o toJson (VNode' _) = Left (TypeMismatch "a document node is not a plain value -- nest it as a child rather than using it where a value is expected") toJson (VConstraint name _) = Left (TypeMismatch ("a constraint (" <> tshow name <> ") cannot cross a JSON boundary -- only \"!\" may consume it")) -- | A symbol may sit in an attribute, a payload, a value slot or a text@@ -941,7 +1093,13 @@ -- shape: both fields required, matching what the decoder in -- 'checkedFromJson' accepts back. toJson (VSymbol sid path) =-  Right (Object (KeyMap.fromList [("$sym", String sid), ("path", Array (V.fromList (map String path)))]))+  Right (object [("$sym", JString sid), ("path", JArray (map JString path))])+-- | A term crosses wherever a symbol does, as \S5.3's other tagged shape.+-- Its operands are numbers, symbols and terms, so they always cross, and+-- each number keeps its type (@1@ and @1.0@ stay distinct), which the+-- residual law depends on.+toJson (VTerm op operands) =+  (\args -> object [("$term", JString op), ("arguments", JArray args)]) <$> traverse toJson operands toJson (VClosure _ _ _) = Left (TypeMismatch "expected a value, got a function -- call it first, e.g. $my-fn(...)") toJson (VBuiltin name) = Left (TypeMismatch ("expected a value, got the builtin " <> tshow name <> " -- call it first")) toJson (VEnv _) = Left (TypeMismatch "expected a value, got an import result -- read .rendered, .vals, or a binding name from it first")@@ -954,47 +1112,87 @@         )     ) -fromJson :: Value -> Value'-fromJson Null = VNull-fromJson (Bool b) = VBool b-fromJson (Number n) = VNumber n-fromJson (String s) = VString s-fromJson (Array arr) = VArray (map fromJson (V.toList arr))-fromJson (Object obj) = VObject (Map.fromList (map (\(k, v) -> (Key.toText k, fromJson v)) (KeyMap.toList obj)))+-- | A JSON value this evaluator produced, back as a 'Value'', so its+-- numbers are already values: nothing is checked. The context goes through+-- 'checkedFromJson' instead.+fromJson :: Json -> Value'+fromJson JNull = VNull+fromJson (JBool b) = VBool b+fromJson (JInt n) = VInt (fromInteger n)+fromJson (JFloat n) = VFloat n+fromJson (JString s) = VString s+fromJson (JArray xs) = VArray (map fromJson xs)+fromJson (JObject o) = VObject (fmap fromJson o) --- | The input context's boundary: as 'fromJson', but recursively refusing--- @"$sym"@ and @"$type"@ as ordinary object keys (v3-symbols \S5.3). In--- concrete mode either key is refused unconditionally. In symbolic mode,--- seeding (\S5.4) accepts a well-formed @{"$sym": ..., "path": [...]}@ back--- as an actual symbol -- anything else carrying @"$sym"@, or @"$type"@ at--- all (v3 has no valid shape for it yet), is still refused.-checkedFromJson :: Mode -> Value -> Either EvalError Value'-checkedFromJson _ Null = Right VNull-checkedFromJson _ (Bool b) = Right (VBool b)-checkedFromJson _ (Number n) = Right (VNumber n)-checkedFromJson _ (String s) = Right (VString s)-checkedFromJson mode (Array arr) = VArray <$> traverse (checkedFromJson mode) (V.toList arr)-checkedFromJson mode (Object obj)-  | KeyMap.member (Key.fromText "$type") obj =-      Left (TypeMismatch "the context carries the reserved key \"$type\", which only a typed envelope may use")-  | Just symVal <- KeyMap.lookup (Key.fromText "$sym") obj =-      case mode of-        Concrete -> Left (TypeMismatch "the context carries the reserved key \"$sym\", which only a symbolic envelope may use")-        Symbolic -> case (symVal, KeyMap.toList (KeyMap.delete (Key.fromText "$sym") obj)) of-          (String sid, [("path", Array pathArr)]) -> do-            path <- traverse expectString (V.toList pathArr)-            Right (VSymbol sid path)-          _ -> Left (TypeMismatch "a \"$sym\" object must be exactly {\"$sym\": <id>, \"path\": [<segment>, ...]}")-  | otherwise =-      VObject . Map.fromList <$> traverse (\(k, v) -> (,) (Key.toText k) <$> checkedFromJson mode v) (KeyMap.toList obj)+-- | The input context's boundary. It is decoded whole, before evaluation+-- starts, so what it refuses does not depend on what the program reads, and+-- every refusal is a 'TypeMismatch'.+--+-- Numbers first (reference.md \S3): a JSON number is read as the literal of+-- the same text, so @3@ is an integer and @3.0@ a float, and one the value+-- domain does not hold is refused or normalized by+-- 'Tramaj.Json.normalizeNumbers', which lists the cases.+--+-- Then the reserved keys: @"$sym"@, @"$type"@ and @"$term"@ are recursively+-- refused as ordinary object keys (v3-symbols \S5.3), in every profile. In+-- concrete mode each is refused unconditionally. In symbolic mode, seeding+-- (\S5.4) accepts a well-formed @{"$sym": ..., "path": [...]}@ back as an+-- actual symbol and, with the arithmetic profile on, a well-formed term back+-- as an actual term -- anything else carrying @"$sym"@ or @"$term"@, or+-- @"$type"@ at all (v3 has no valid shape for it yet), is still refused.+checkedFromJson :: Options -> Json -> Either EvalError Value'+checkedFromJson options input =+  first (\why -> TypeMismatch ("the context holds a number that is not a value: " <> T.pack why)) (normalizeNumbers input) >>= decode   where-    expectString (String s) = Right s+    decode (JArray xs) = VArray <$> traverse decode xs+    decode (JObject obj)+      | Map.member "$type" obj =+          Left (TypeMismatch "the context carries the reserved key \"$type\", which only a typed envelope may use")+      | Just symVal <- Map.lookup "$sym" obj =+          case optMode options of+            Concrete -> Left (TypeMismatch "the context carries the reserved key \"$sym\", which only a symbolic envelope may use")+            Symbolic -> case (symVal, Map.toList (Map.delete "$sym" obj)) of+              (JString sid, [("path", JArray pathArr)]) -> do+                path <- traverse expectString pathArr+                Right (VSymbol sid path)+              _ -> Left (TypeMismatch "a \"$sym\" object must be exactly {\"$sym\": <id>, \"path\": [<segment>, ...]}")+      | Just opVal <- Map.lookup "$term" obj =+          case optMode options of+            Concrete -> Left (TypeMismatch "the context carries the reserved key \"$term\", which only a symbolic envelope may use")+            Symbolic -> case (opVal, Map.toList (Map.delete "$term" obj)) of+              (JString op, [("arguments", JArray args)]) -> traverse decode args >>= seededTerm op+              _ -> Left (TypeMismatch "a \"$term\" object must be exactly {\"$term\": <op>, \"arguments\": [<argument>, ...]}")+      | otherwise = VObject <$> traverse decode obj+    decode scalar = Right (fromJson scalar)++    -- A well-formed term is one a call could have built (\S5.3), so this is+    -- the call's own check, 'arithmeticOperands', on arguments already+    -- decoded, which holds a nested term to the same rule. Two things a call+    -- accepts are refused first: an array, since a term holds its operands+    -- already flattened, and operands that are all numbers, since the call+    -- would have computed. Without the arithmetic profile no @op@ is known,+    -- so every term is refused.+    seededTerm op args+      | not (optArithmetic options) = Left (TypeMismatch ("the context carries a term (" <> tshow op <> "), which needs the arithmetic profile"))+      | op `notElem` arithmeticNames = Left (TypeMismatch ("a term names an unknown operation: " <> tshow op))+      | any isArray args = Left (TypeMismatch ("a term (" <> tshow op <> ") holds its operands flattened, not in an array"))+      | otherwise = do+          operands <- arithmeticOperands op args+          if any isSymbolic operands+            then Right (VTerm op operands)+            else Left (TypeMismatch ("a term (" <> tshow op <> ") must hold a symbol or a term among its arguments"))++    isArray (VArray _) = True+    isArray _ = False++    expectString (JString s) = Right s     expectString _ = Left (TypeMismatch "a symbol reference's \"path\" must be an array of strings")  describeValue :: Value' -> Text describeValue VNull = "null" describeValue (VBool _) = "a boolean"-describeValue (VNumber _) = "a number"+describeValue (VInt _) = "an integer"+describeValue (VFloat _) = "a float" describeValue (VString _) = "a string" describeValue (VArray _) = "an array" describeValue (VObject _) = "an object"@@ -1005,30 +1203,42 @@ describeValue (VImport pending) = "the not-yet-run import of " <> tshow (pName pending) describeValue (VConstraint name _) = "a constraint (" <> tshow name <> ")" describeValue (VSymbol _ _) = "a symbol"+describeValue (VTerm op _) = "a term (" <> tshow op <> ")"  -- | Control flow must be concrete (v3-symbols \S1.5): a symbolic condition is -- 'NotConcrete', not merely the wrong type. requireBool :: Text -> Value' -> Either EvalError Bool requireBool _ (VBool b) = Right b requireBool who (VSymbol _ _) = Left (NotConcrete who)+requireBool who (VTerm _ _) = Left (NotConcrete who) requireBool who other = Left (TypeMismatch (who <> " must be a boolean, got " <> describeValue other)) --- | Whether a value is, or contains, a symbol -- what makes a value+-- | Whether a value is itself a symbol or a term (v3-symbols \S1.9), which+-- is the depth at which a container, a collection, a condition or an operand+-- is refused: a concrete structure that merely holds one is not special+-- (\S1.7). 'containsSymbol' is the other depth.+isSymbolic :: Value' -> Bool+isSymbolic (VSymbol _ _) = True+isSymbolic (VTerm _ _) = True+isSymbolic _ = False++-- | Whether a value is, or contains, a symbol or a term -- what makes a value -- concrete's negation (\S1.5, \S1.7): a structure built of concrete pieces is -- itself concrete and every structural operation on it works as normal; -- only a symbol itself, wherever it sits, makes the whole not concrete. containsSymbol :: Value' -> Bool containsSymbol (VSymbol _ _) = True+containsSymbol (VTerm _ _) = True containsSymbol (VArray xs) = any containsSymbol xs containsSymbol (VObject o) = any containsSymbol (Map.elems o) containsSymbol _ = False --- | Requires a value with no symbol anywhere in it, for the handful of+-- | Requires a value with no symbol and no term anywhere in it, for the handful of -- operations \S1.5 lists as needing to *know* something about their -- argument rather than merely carry it: @str@, @eq@, and an allocation key. -- Everything else about crossing a JSON boundary is 'toJson'\'s ordinary -- business, which this defers to once a symbol is ruled out.-requireConcrete :: Text -> Value' -> Either EvalError Value+requireConcrete :: Text -> Value' -> Either EvalError Json requireConcrete who v   | containsSymbol v = Left (NotConcrete who)   | otherwise = toJson v@@ -1036,12 +1246,12 @@ -- | v3-symbols \S1.4: compact JSON with keys sorted, agreeing with @str@ on -- arrays and objects and differing at the top level for strings, where -- @str@ renders raw and this quotes -- the difference that makes it--- injective. This is exactly 'compactJson', which already quotes a string--- unconditionally; the two are one function under two names because they--- serve the same requirement (\S1.4's canon, \S6's @str@) for the same--- reason.-canon :: Value -> Text-canon = compactJson+-- injective. This is exactly 'stringify', which already quotes a string+-- unconditionally and sorts keys. It writes an integer and a float+-- differently (@1@ and @1.0@), which injectivity needs now that they are+-- two values.+canon :: Json -> Text+canon = stringify  tshow :: (Show a) => a -> Text tshow = T.pack . show@@ -1059,16 +1269,21 @@   "not" -> arity1 (fmap (VBool . not) . asBool)   "and" -> variadicBool (&&) True   "or" -> variadicBool (||) False+  -- No coercion across types: 'Json' equality never equates an integer+  -- with a float, so @eq(1, 1.0)@ is @false@, like @eq(1, "1")@.   "eq" -> binary (\a b -> VBool <$> ((==) <$> requireConcrete name a <*> requireConcrete name b))-  "lt" -> comparison (<)-  "lte" -> comparison (<=)-  "gt" -> comparison (>)-  "gte" -> comparison (>=)+  "lt" -> comparison (<) (<)+  "lte" -> comparison (<=) (<=)+  "gt" -> comparison (>) (>)+  "gte" -> comparison (>=) (>=)   "has" -> binary hasImpl   "lookup" -> ternary lookupImpl+  "format-number" -> ternary formatNumberImpl   "concat" -> concatImpl   "append" -> binary appendImpl-  _ -> Left (UnboundName name)+  _+    | name `elem` arithmeticNames -> arithmetic name args+    | otherwise -> Left (UnboundName name)   where     arity1 :: (Value' -> Either EvalError a) -> Either EvalError a     arity1 f = case args of@@ -1087,18 +1302,24 @@      cardinality :: Either EvalError Value'     cardinality = arity1 $ \case-      VArray xs -> Right (VNumber (fromIntegral (length xs)))-      VObject o -> Right (VNumber (fromIntegral (Map.size o)))+      VArray xs -> Right (VInt (fromIntegral (length xs)))+      VObject o -> Right (VInt (fromIntegral (Map.size o)))       VSymbol _ _ -> Left (NotConcrete name)+      VTerm _ _ -> Left (NotConcrete name)       other -> Left (TypeMismatch (name <> " expects an array or object, got " <> describeValue other))      asBool :: Value' -> Either EvalError Bool     asBool (VBool b) = Right b     asBool other = Left (TypeMismatch (name <> " expects a boolean argument, got " <> describeValue other)) -    asNumber :: Value' -> Either EvalError Scientific-    asNumber (VNumber n) = Right n+    -- | A number operand, returned as it is so that its type is still+    -- there to check. A symbol or a term is 'NotConcrete' (v3-symbols+    -- \S1.5).+    asNumber :: Value' -> Either EvalError Value'+    asNumber v@(VInt _) = Right v+    asNumber v@(VFloat _) = Right v     asNumber (VSymbol _ _) = Left (NotConcrete name)+    asNumber (VTerm _ _) = Left (NotConcrete name)     asNumber other = Left (TypeMismatch (name <> " expects a number argument, got " <> describeValue other))      asArray :: Value' -> Either EvalError [Value']@@ -1110,8 +1331,18 @@     variadicBool :: (Bool -> Bool -> Bool) -> Bool -> Either EvalError Value'     variadicBool op identityVal = VBool . foldl' op identityVal <$> traverse asBool args -    comparison :: (Scientific -> Scientific -> Bool) -> Either EvalError Value'-    comparison op = binary $ \a b -> VBool <$> (op <$> asNumber a <*> asNumber b)+    -- | Two integers or two floats (reference.md \S11). A mixed pair is a+    -- 'TypeMismatch' like any other pair of two types: nothing is promoted,+    -- so @gt(1.5, 0)@ is written @gt(1.5, 0.0)@. The operator is passed+    -- once per type because each pair is compared in its own domain.+    comparison :: (Int64 -> Int64 -> Bool) -> (Double -> Double -> Bool) -> Either EvalError Value'+    comparison opInt opFloat = binary $ \a b -> do+      na <- asNumber a+      nb <- asNumber b+      case (na, nb) of+        (VInt x, VInt y) -> Right (VBool (opInt x y))+        (VFloat x, VFloat y) -> Right (VBool (opFloat x y))+        _ -> Left (TypeMismatch (name <> " expects two integers or two floats, got " <> describeValue na <> " and " <> describeValue nb))      -- | Deliberately tolerant: a missing key, an out-of-range index, or a     -- container of the wrong shape all answer @false@ rather than erroring@@ -1120,9 +1351,10 @@     -- something about a container the language cannot see into.     hasImpl :: Value' -> Value' -> Either EvalError Value'     hasImpl (VSymbol _ _) _ = Left (NotConcrete name)+    hasImpl (VTerm _ _) _ = Left (NotConcrete name)     hasImpl container key = Right . VBool $ case (container, key) of       (VObject o, VString k) -> Map.member k o-      (VArray xs, VNumber n) -> maybe False (\i -> i >= 0 && i < length xs) (asIndex n)+      (VArray xs, VInt n) -> maybe False (\i -> i < length xs) (asIndex n)       _ -> False      -- | Dynamic access by a computed key or index -- the counterpart to a@@ -1131,23 +1363,49 @@     -- would silently discard the symbol (\S1.5).     lookupImpl :: Value' -> Value' -> Value' -> Either EvalError Value'     lookupImpl (VSymbol _ _) _ _ = Left (NotConcrete name)+    lookupImpl (VTerm _ _) _ _ = Left (NotConcrete name)     lookupImpl container key fallback = Right $ case (container, key) of       (VObject o, VString k) -> maybe fallback id (Map.lookup k o)-      (VArray xs, VNumber n) -> maybe fallback id (asIndex n >>= atIndex xs)+      (VArray xs, VInt n) -> maybe fallback id (asIndex n >>= atIndex xs)       _ -> fallback +    -- | @format-number(x, decimals, group)@ (reference.md \S11, /Number+    -- formatting/). The arguments are examined left to right and the first+    -- that is not acceptable decides the error: a symbol or a term is+    -- 'NotConcrete', anything else of the wrong type a 'TypeMismatch'.+    formatNumberImpl :: Value' -> Value' -> Value' -> Either EvalError Value'+    formatNumberImpl x decimals group = do+      v <- case x of+        VInt n -> Right (toRational n)+        -- Exact: a double is a binary fraction, and this is its value.+        VFloat d -> Right (toRational d)+        other -> refuse "a number as its first argument" other+      places <- case decimals of+        VInt n | n >= 0 && n <= 20 -> Right (fromIntegral n :: Int)+        VInt n -> Left (TypeMismatch (name <> " expects a number of decimals from 0 to 20, got " <> tshow n))+        other -> refuse "an integer number of decimals" other+      separator <- case group of+        VString s -> Right s+        other -> refuse "a string as its separator" other+      Right (VString (formatNumber v places separator))+      where+        refuse :: Text -> Value' -> Either EvalError a+        refuse wanted other+          | isSymbolic other = Left (NotConcrete name)+          | otherwise = Left (TypeMismatch (name <> " expects " <> wanted <> ", got " <> describeValue other))+     atIndex :: [Value'] -> Int -> Maybe Value'     atIndex xs i       | i >= 0 && i < length xs = Just (xs !! i)       | otherwise = Nothing -    -- | An index must be a non-negative whole number: @2.5@ and @-1@ are not-    -- indices.-    asIndex :: Scientific -> Maybe Int-    asIndex n =-      let d = toRealFloat n :: Double-          i = round d :: Int-       in if fromIntegral i == d && i >= 0 then Just i else Nothing+    -- | An index must be a non-negative integer: @-1@ is not an index, and+    -- neither is a float, @1.0@ included, since nothing converts a float+    -- into an integer here.+    asIndex :: Int64 -> Maybe Int+    asIndex n+      | n >= 0 && n <= fromIntegral (maxBound :: Int) = Just (fromIntegral n)+      | otherwise = Nothing      -- | Variadic array join, preserving order. @concat()@ is @[]@; a     -- non-array argument anywhere is an error rather than being wrapped.@@ -1159,80 +1417,197 @@     appendImpl :: Value' -> Value' -> Either EvalError Value'     appendImpl arr item = (\xs -> VArray (xs <> [item])) <$> asArray arr --- | How a value reads when it is rendered into a string by @str@ (and so by--- string interpolation): a string is itself, @null@ is empty, and anything--- structured is compact JSON.------ This is a normative rendering, not a debugging one, so it must agree--- across implementations character for character -- it is what a template--- interpolates into its output. See @../specs/reference.md@.-displayString :: Value -> Text-displayString Null = ""-displayString (Bool b) = if b then "true" else "false"-displayString (Number n) = formatNumber n-displayString (String s) = s-displayString v = compactJson v+-- Arithmetic (reference.md \S11) ---------------------------------------------------------------- --- | Compact JSON, with object keys in sorted order and numbers formatted by--- 'formatNumber'.+-- | One of the ten arithmetic builtins, applied. Operands that are all+-- numbers compute; if one is a symbol or a term the result is a term holding+-- the flattened operands exactly as written (v3-symbols \S1.9). Every+-- operand is checked before either happens, so a 'TypeMismatch' takes+-- precedence over a 'NotRepresentable'.+arithmetic :: Text -> [Value'] -> Either EvalError Value'+arithmetic name args = do+  operands <- arithmeticOperands name args+  if any isSymbolic operands then Right (VTerm name operands) else compute name operands++-- | The operands of a call, checked as far as they can be without knowing+-- what a symbol stands for; every refusal is a 'TypeMismatch'. ----- Deliberately not aeson's own 'encode': that writes every number through--- its 'Scientific' representation (@1.0@, @1.0e11@) where a JavaScript host--- writes @1@ and @100000000000@, so using it here would leave two--- conforming implementations rendering the same value differently. Keys are--- sorted for the same reason -- object key order is not semantically--- significant, so it must not be observable through @str@ either.-compactJson :: Value -> Text-compactJson Null = "null"-compactJson (Bool b) = if b then "true" else "false"-compactJson (Number n) = formatNumber n-compactJson (String s) = quoteString s-compactJson (Array xs) = "[" <> T.intercalate "," (map compactJson (V.toList xs)) <> "]"-compactJson (Object o) =-  "{" <> T.intercalate "," (map entry (sortOn fst (map (\(k, v) -> (Key.toText k, v)) (KeyMap.toList o)))) <> "}"+-- * @sum@ and @product@ flatten their arguments by the rule children use:+--   an array contributes each of its elements, recursively, in order. They+--   need at least one operand afterwards. The eight others take a fixed+--   count and do not flatten, so an array given to one is refused whatever+--   it holds.+-- * Each operand is a number, a symbol or a term. A symbol or a term stands+--   for one number of either type and is not looked into.+-- * The operands that are numbers agree with each other in type and with+--   what the builtin accepts. Nothing is converted or promoted.+arithmeticOperands :: Text -> [Value'] -> Either EvalError [Value']+arithmeticOperands name args = do+  operands <- shape+  traverse_ requireOperand operands+  let numbers = filter (not . isSymbolic) operands+  if accepted numbers+    then Right operands+    else Left (TypeMismatch (name <> " expects " <> wanted <> ", got " <> T.intercalate ", " (map describeValue numbers)))   where-    entry (k, v) = quoteString k <> ":" <> compactJson v+    shape+      | variadic = case flatten args of+          [] -> Left (TypeMismatch (name <> " expects at least one operand: seed it with the zero or the one of the intended type"))+          operands -> Right operands+      | length args == arity = Right args+      | otherwise = Left (TypeMismatch (name <> " expects exactly " <> tshow arity <> " argument(s), got " <> tshow (length args))) --- | A JSON string literal, escaped by aeson itself so this does not grow a--- second, subtly different escaping table.-quoteString :: Text -> Text-quoteString = TL.toStrict . TLE.decodeUtf8 . encode . String+    variadic = name `elem` (["sum", "product"] :: [Text]) -formatNumber :: Scientific -> Text-formatNumber = formatDouble . toRealFloat+    arity :: Int+    arity = if name `elem` (["quotient", "floor-quotient", "modulo"] :: [Text]) then 2 else 1 --- | Formats a double exactly as ECMAScript's @Number::toString@ does.+    flatten = concatMap $ \case+      VArray xs -> flatten xs+      other -> [other]++    requireOperand v+      | isInt v || isFloat v || isSymbolic v = Right ()+      | otherwise = Left (TypeMismatch (name <> " expects number operands, got " <> describeValue v))++    isInt (VInt _) = True+    isInt _ = False++    isFloat (VFloat _) = True+    isFloat _ = False++    accepted numbers = case name of+      "quotient" -> all isFloat numbers+      "inverse" -> all isFloat numbers+      "floor-quotient" -> all isInt numbers+      "modulo" -> all isInt numbers+      "sum" -> all isInt numbers || all isFloat numbers+      "product" -> all isInt numbers || all isFloat numbers+      -- @negate@, @floor@, @real@ and @round@ take a number of either type.+      _ -> True++    wanted :: Text+    wanted = case name of+      "quotient" -> "two floats"+      "inverse" -> "a float"+      "floor-quotient" -> "two integers"+      "modulo" -> "two integers"+      _ -> "all integers or all floats"++-- | The concrete rules (reference.md \S11, /Semantics/), over operands+-- 'arithmeticOperands' accepted and that are all numbers. ----- Matching that specific algorithm is the point: the PureScript--- implementation runs on a JavaScript host, where this is simply what a--- number's text /is/. Haskell's own 'show' picks different thresholds for--- scientific notation -- @0.05@ prints as @5.0e-2@, @1e11@ as @1.0e11@ ----- so leaving it to 'show' would make the two implementations disagree on--- something as ordinary as interpolating a price or a count.+-- An integer result is computed over the unbounded 'Integer' and then held+-- to the signed 64-bit range, at every step of a fold: nothing is ever+-- computed in an 'Int64', whose own operators wrap. ----- NaN and infinities cannot reach here: JSON has no way to express them.-formatDouble :: Double -> Text-formatDouble d-  | isNaN d = "NaN"-  | isInfinite d = if d < 0 then "-Infinity" else "Infinity"-  | d == 0 = "0"-  | d < 0 = "-" <> formatPositive (negate d)-  | otherwise = formatPositive d+-- A float result is one 'Double' operation at a time, which GHC compiles+-- to the one IEEE 754 binary64 instruction, correctly rounded to nearest,+-- ties to even. It never contracts a product and a sum into a fused+-- multiply-add; that takes a primop this module does not use.+compute :: Text -> [Value'] -> Either EvalError Value'+compute name operands = case (name, operands) of+  ("sum", _) -> leftFold (+) (+)+  ("product", _) -> leftFold (*) (*)+  ("negate", [VInt x]) -> VInt <$> integer (negate (toInteger x))+  ("negate", [VFloat x]) -> VFloat <$> float (negate x)+  ("quotient", [VFloat a, VFloat b]) -> VFloat <$> float (a / b)+  ("inverse", [VFloat x]) -> VFloat <$> float (1 / x)+  -- 'div' rounds toward negative infinity and 'mod' is its remainder, zero+  -- or of the sign of the divisor, which is what \S11 asks of the two. Each+  -- is checked on its own: the remainder of @-2^63@ by @-1@ is @0@, although+  -- the quotient is out of range.+  ("floor-quotient", [VInt a, VInt b]) -> VInt <$> (nonZero b *> integer (toInteger a `div` toInteger b))+  ("modulo", [VInt a, VInt b]) -> VInt <$> (nonZero b *> integer (toInteger a `mod` toInteger b))+  ("floor", [VInt x]) -> Right (VInt x)+  ("floor", [VFloat x]) -> VInt <$> integer (floor x)+  -- The double nearest to the integer, ties to even: exact up to @2^53@,+  -- rounded beyond. This is the machine's own conversion.+  ("real", [VInt x]) -> Right (VFloat (fromIntegral x))+  ("real", [VFloat x]) -> Right (VFloat x)+  -- The integer nearest to the exact value of the float, a tie going away+  -- from zero: the rule 'formatNumber' applies to no decimals.+  ("round", [VInt x]) -> Right (VInt x)+  ("round", [VFloat x]) -> VInt <$> integer (roundHalfAway (toRational x))+  _ -> mismatch+  where+    mismatch :: Either EvalError a+    mismatch = Left (TypeMismatch (name <> " cannot be applied to " <> T.intercalate ", " (map describeValue operands))) --- | The digit-placement rules of ECMA-262's @Number::toString@, given the--- shortest round-tripping digit sequence @ds@ and exponent @n@ for which--- the value is @0.ds * 10^n@ -- which is exactly what 'floatToDigits'--- returns.-formatPositive :: Double -> Text-formatPositive d-  | n >= k && n <= 21 = digits <> T.replicate (n - k) "0"-  | n > 0 && n <= 21 = T.take n digits <> "." <> T.drop n digits-  | n > (-6) && n <= 0 = "0." <> T.replicate (negate n) "0" <> digits-  | otherwise = mantissa <> "e" <> sign <> tshow (abs e)+    -- A left fold from the first operand, each step checked: an integer+    -- step out of range is an error although the total would be in range.+    leftFold :: (Integer -> Integer -> Integer) -> (Double -> Double -> Double) -> Either EvalError Value'+    leftFold opInt opFloat = case operands of+      VInt x : rest -> VInt <$> foldM intStep x rest+      VFloat x : rest -> VFloat <$> foldM floatStep x rest+      _ -> mismatch+      where+        intStep acc (VInt y) = integer (opInt (toInteger acc) (toInteger y))+        intStep _ _ = mismatch+        floatStep acc (VFloat y) = float (opFloat acc y)+        floatStep _ _ = mismatch++    -- An integer result, or 'NotRepresentable' outside the integer range.+    integer :: Integer -> Either EvalError Int64+    integer n+      | inIntegerRange n = Right (fromInteger n)+      | otherwise = Left (NotRepresentable (name <> ": the result is outside the integer range, -2^63 to 2^63 - 1"))++    -- A float result, or 'NotRepresentable' when it is not finite, which+    -- covers overflow and a zero divisor alike. There is no negative zero.+    float :: Double -> Either EvalError Double+    float d+      | isNaN d || isInfinite d = Left (NotRepresentable (name <> ": the result is not a finite float"))+      | d == 0 = Right 0+      | otherwise = Right d++    nonZero :: Int64 -> Either EvalError ()+    nonZero 0 = Left (NotRepresentable (name <> ": the divisor is zero"))+    nonZero _ = Right ()++-- Number formatting (reference.md \S11) ---------------------------------------------------------++-- | The integer nearest to an exact value; when two are equally near, the+-- one farther from zero. This is the one rounding rule of @round@ and+-- @format-number@, written out over 'Rational' because no native formatter+-- or 'round' (which takes a tie to even) follows it.+roundHalfAway :: Rational -> Integer+roundHalfAway v+  | v < 0 = negate (roundHalfAway (negate v))+  | otherwise = floor (v + 1 / 2)++-- | An exact value in positional decimal notation: an optional @-@, the+-- integer part with the separator between its groups of three digits+-- counted from the point leftward, and, when there are decimals, a @.@ and+-- exactly that many digits. Never an exponent, and no sign on a result+-- whose digits are all zero.+formatNumber :: Rational -> Int -> Text -> Text+formatNumber v places separator =+  (if scaled < 0 then "-" else "") <> grouped <> (if places == 0 then "" else "." <> fractionPart)   where-    (ds, n) = floatToDigits 10 d-    k = length ds-    digits = T.pack (map intToDigit ds)-    e = n - 1-    mantissa = if k == 1 then digits else T.take 1 digits <> "." <> T.drop 1 digits-    sign = if e >= 0 then "+" else "-" :: Text+    -- The value in units of the last digit asked for, rounded.+    scaled :: Integer+    scaled = roundHalfAway (v * 10 ^ places)++    -- At least one digit before the point: @0.50@, never @.50@.+    digits :: Text+    digits = T.justifyRight (places + 1) '0' (T.pack (show (abs scaled)))++    (integerPart, fractionPart) = T.splitAt (T.length digits - places) digits++    grouped :: Text+    grouped+      | T.null separator = integerPart+      | otherwise = T.intercalate separator (reverse (map T.reverse (T.chunksOf 3 (T.reverse integerPart))))++-- | How a value reads when it is rendered into a string by @str@ (and so by+-- string interpolation): a string is itself, @null@ is empty, and anything+-- structured is compact JSON.+--+-- This is a normative rendering, not a debugging one, so it must agree+-- across implementations character for character -- it is what a template+-- interpolates into its output. See @../specs/reference.md@.+displayString :: Json -> Text+displayString JNull = ""+displayString (JString s) = s+displayString other = stringify other
+ src/Tramaj/Json.hs view
@@ -0,0 +1,275 @@+-- | JSON as Tramaj reads and writes it: like any JSON value, except that a+-- number is an integer or a float, decided by its text (@reference.md@ \S3,+-- @node-json.md@ /Numbers/, @decisions.md@ \S18).+--+-- This module exists because aeson's 'Aeson.Value' cannot carry that+-- difference: it holds every number as a 'Scientific', where @3@, @3.0@ and+-- @3e0@ are one value, and its encoder writes all three as @3@. Everything+-- Tramaj takes in or hands out as JSON (the context, a @Node@'s payloads, an+-- expression program's result, the symbolic envelope) is therefore this+-- 'Json', read by 'jsonParser' and written by 'stringify'.+--+-- 'fromAeson' and 'toAeson' bridge to an aeson value, and say what each+-- direction costs.+--+-- __Integer range.__ This implementation has the signed 64-bit range,+-- @-2^63@ to @2^63 - 1@ (@reference.md@ \S13): the evaluator holds an integer+-- in an 'Int64'.+module Tramaj.Json+  ( Json (..)++    -- * Reading and writing+  , jsonParser+  , stringify+  , formatInteger+  , formatFloat+  , quoteString++    -- * The number rules of a decoding boundary+  , minInteger+  , maxInteger+  , inIntegerRange+  , normalizeInteger+  , normalizeFloat+  , normalizeNumbers++    -- * Bridging to aeson+  , fromAeson+  , toAeson+  ) where++import qualified Data.Aeson as Aeson+import Data.Aeson.Decoding.Text (textToTokens)+import Data.Aeson.Decoding.Tokens (Lit (..), Number (..), TkArray (..), TkRecord (..), Tokens (..))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Char (intToDigit)+import Data.Int (Int64)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Scientific (fromFloatDigits, toBoundedInteger, toRealFloat)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Encoding as TLE+import qualified Data.Vector as V+import Numeric (floatToDigits)++-- | A JSON value whose numbers keep their type.+--+-- A value Tramaj produced always holds a 'JInt' inside the integer range and+-- a 'JFloat' that is finite and not a negative zero. 'jsonParser' is more+-- lenient on purpose, so that refusing a number is left to whoever decodes+-- the value (see 'normalizeNumbers'): it reads an integer-form number of any+-- size exactly, which is why 'JInt' holds an 'Integer', and a float too+-- large for a double as an infinity.+--+-- Equality is structural, with object keys unordered. An integer and a float+-- are never equal, whatever they hold: @3@ and @3.0@ are two values.+data Json+  = JNull+  | JBool Bool+  | JInt Integer+  | JFloat Double+  | JString Text+  | JArray [Json]+  | JObject (Map Text Json)+  deriving stock (Eq, Show)++-- Reading -------------------------------------------------------------------++-- | Reads JSON text, typing each number by its text (@reference.md@ \S3): one+-- with neither a fraction nor an exponent is a 'JInt', one with either is a+-- 'JFloat', so @3@ is an integer and @3.0@ and @3e0@ are floats.+--+-- No number is refused here. An integer keeps every digit, and a float is the+-- double nearest to its decimal value, which is an infinity when the value is+-- too large for a double; 'normalizeNumbers' is what refuses.+--+-- The tokens come from aeson's own decoder, which is the layer that still+-- knows which of the three forms a number was written in; 'Aeson.Value' is+-- one step too late.+jsonParser :: Text -> Either String Json+jsonParser src = value (textToTokens src) $ \v rest ->+  if T.all isJsonSpace rest+    then Right v+    else Left "Unexpected trailing input after the JSON value"+  where+    isJsonSpace c = c == ' ' || c == '\n' || c == '\r' || c == '\t'++value :: Tokens k String -> (Json -> k -> Either String r) -> Either String r+value (TkLit LitNull k) kont = kont JNull k+value (TkLit LitTrue k) kont = kont (JBool True) k+value (TkLit LitFalse k) kont = kont (JBool False) k+value (TkText t k) kont = kont (JString t) k+value (TkNumber n k) kont = kont (number n) k+value (TkArrayOpen items) kont = array [] items kont+value (TkRecordOpen pairs) kont = record [] pairs kont+value (TkErr e) _ = Left e++array :: [Json] -> TkArray k String -> (Json -> k -> Either String r) -> Either String r+array acc (TkItem tokens) kont = value tokens (\v rest -> array (v : acc) rest kont)+array acc (TkArrayEnd k) kont = kont (JArray (reverse acc)) k+array _ (TkArrayErr e) _ = Left e++-- | A repeated key keeps its last value, as aeson's own decoder does.+record :: [(Text, Json)] -> TkRecord k String -> (Json -> k -> Either String r) -> Either String r+record acc (TkPair key tokens) kont = value tokens (\v rest -> record ((Key.toText key, v) : acc) rest kont)+record acc (TkRecordEnd k) kont = kont (JObject (Map.fromList (reverse acc))) k+record _ (TkRecordErr e) _ = Left e++number :: Number -> Json+number (NumInteger n) = JInt n+number (NumDecimal s) = JFloat (toRealFloat s)+number (NumScientific s) = JFloat (toRealFloat s)++-- Writing -------------------------------------------------------------------++-- | Compact JSON, with object keys in sorted order and a number written by+-- its type (@node-json.md@, /Numbers/): an integer as its digits, a float+-- always with a fraction or an exponent.+--+-- This is also @str@'s rendering of an array or an object and v3-symbols+-- \S1.4's canon, which is why the key order is fixed: object key order is+-- not semantically significant, so it must not be observable there.+stringify :: Json -> Text+stringify JNull = "null"+stringify (JBool b) = if b then "true" else "false"+stringify (JInt n) = formatInteger n+stringify (JFloat d) = formatFloat d+stringify (JString s) = quoteString s+stringify (JArray xs) = "[" <> T.intercalate "," (map stringify xs) <> "]"+stringify (JObject o) = "{" <> T.intercalate "," (map entry (Map.toAscList o)) <> "}"+  where+    entry (k, v) = quoteString k <> ":" <> stringify v++-- | A JSON string literal, escaped by aeson itself so this does not grow a+-- second, subtly different escaping table.+quoteString :: Text -> Text+quoteString = TL.toStrict . TLE.decodeUtf8 . Aeson.encode . Aeson.String++-- | An integer as its decimal digits, with a @-@ when negative: no fraction+-- and no exponent, whatever its size.+formatInteger :: Integer -> Text+formatInteger = T.pack . show++-- | A float as the shortest round-trip text of ECMAScript's+-- @Number::toString@, with @.0@ appended when that text has neither a+-- fraction nor an exponent: @1.0@, @0.1@, @100000000000.0@, @1e+21@,+-- @1e-7@. A float therefore never reads back as an integer.+formatFloat :: Double -> Text+formatFloat d+  | isNaN d || isInfinite d = shortest+  | T.any (\c -> c == '.' || c == 'e') shortest = shortest+  | otherwise = shortest <> ".0"+  where+    shortest = formatDouble d++-- | Formats a double exactly as ECMAScript's @Number::toString@ does.+--+-- Matching that specific algorithm is the point: the PureScript+-- implementation runs on a JavaScript host, where this is simply what a+-- number's text /is/. Haskell's own 'show' picks different thresholds for+-- scientific notation -- @0.05@ prints as @5.0e-2@, @1e11@ as @1.0e11@ --+-- so leaving it to 'show' would make the two implementations disagree on+-- something as ordinary as interpolating a price.+--+-- NaN and infinities are not values (@reference.md@ \S3); they are written+-- as ECMAScript names them only so that a 'Json' nobody normalized still+-- shows what it holds.+formatDouble :: Double -> Text+formatDouble d+  | isNaN d = "NaN"+  | isInfinite d = if d < 0 then "-Infinity" else "Infinity"+  | d == 0 = "0"+  | d < 0 = "-" <> formatPositive (negate d)+  | otherwise = formatPositive d++-- | The digit-placement rules of ECMA-262's @Number::toString@, given the+-- shortest round-tripping digit sequence @ds@ and exponent @n@ for which+-- the value is @0.ds * 10^n@ -- which is exactly what 'floatToDigits'+-- returns.+formatPositive :: Double -> Text+formatPositive d+  | n >= k && n <= 21 = digits <> T.replicate (n - k) "0"+  | n > 0 && n <= 21 = T.take n digits <> "." <> T.drop n digits+  | n > (-6) && n <= 0 = "0." <> T.replicate (negate n) "0" <> digits+  | otherwise = mantissa <> "e" <> sign <> T.pack (show (abs e))+  where+    (ds, n) = floatToDigits 10 d+    k = length ds+    digits = T.pack (map intToDigit ds)+    e = n - 1+    mantissa = if k == 1 then digits else T.take 1 digits <> "." <> T.drop 1 digits+    sign = if e >= 0 then "+" else "-" :: Text++-- The number rules of a decoding boundary ------------------------------------++-- | The bottom of this implementation's integer range, @-2^63@.+minInteger :: Integer+minInteger = toInteger (minBound :: Int64)++-- | The top of this implementation's integer range, @2^63 - 1@.+maxInteger :: Integer+maxInteger = toInteger (maxBound :: Int64)++inIntegerRange :: Integer -> Bool+inIntegerRange n = n >= minInteger && n <= maxInteger++-- | An integer-form number as the value it denotes: one outside the integer+-- range is refused, never rounded (@reference.md@ \S3, \S13).+normalizeInteger :: Integer -> Either String Int64+normalizeInteger n+  | inIntegerRange n = Right (fromInteger n)+  | otherwise = Left ("the integer " <> show n <> " is outside the signed 64-bit range")++-- | A float-form number as the value it denotes. One too large for a double+-- is refused, as the literal @1e400@ is a parse error; a negative zero is+-- zero, as the literal @-0.0@ evaluates to @0.0@.+normalizeFloat :: Double -> Either String Double+normalizeFloat d+  | isNaN d || isInfinite d = Left "a float is too large for a double"+  | d == 0 = Right 0+  | otherwise = Right d++-- | What a decoder does to the numbers of a JSON value it is about to treat+-- as a Tramaj value, at any depth: 'normalizeInteger' for each integer and+-- 'normalizeFloat' for each float. The context decoder and+-- 'Tramaj.Node.nodeFromJson' both go through here, so these three functions+-- are the one place that decides which numbers a boundary refuses and which+-- it rewrites.+normalizeNumbers :: Json -> Either String Json+normalizeNumbers (JInt n) = JInt . toInteger <$> normalizeInteger n+normalizeNumbers (JFloat d) = JFloat <$> normalizeFloat d+normalizeNumbers (JArray xs) = JArray <$> traverse normalizeNumbers xs+normalizeNumbers (JObject o) = JObject <$> traverse normalizeNumbers o+normalizeNumbers scalar = Right scalar++-- Bridging to aeson ----------------------------------------------------------++-- | From an aeson value, which has one number type and so cannot say which+-- of the two a number is. A whole number in the integer range becomes an+-- integer and any other number a float, so a host that means the float+-- @3.0@ must build a 'JFloat' itself, or read its JSON text with+-- 'jsonParser' instead of aeson's decoder.+fromAeson :: Aeson.Value -> Json+fromAeson Aeson.Null = JNull+fromAeson (Aeson.Bool b) = JBool b+fromAeson (Aeson.Number s) = case toBoundedInteger s :: Maybe Int64 of+  Just n -> JInt (toInteger n)+  Nothing -> JFloat (toRealFloat s)+fromAeson (Aeson.String s) = JString s+fromAeson (Aeson.Array xs) = JArray (map fromAeson (V.toList xs))+fromAeson (Aeson.Object o) = JObject (Map.fromList (map (\(k, v) -> (Key.toText k, fromAeson v)) (KeyMap.toList o)))++-- | To an aeson value, giving the number type up: @1@ and @1.0@ become the+-- same 'Aeson.Number', and aeson's encoder writes both as @1@. Use+-- 'stringify' to write a value whose numbers must keep their type.+toAeson :: Json -> Aeson.Value+toAeson JNull = Aeson.Null+toAeson (JBool b) = Aeson.Bool b+toAeson (JInt n) = Aeson.Number (fromInteger n)+toAeson (JFloat d) = Aeson.Number (fromFloatDigits d)+toAeson (JString s) = Aeson.String s+toAeson (JArray xs) = Aeson.Array (V.fromList (map toAeson xs))+toAeson (JObject o) = Aeson.Object (KeyMap.fromList (map (\(k, v) -> (Key.fromText k, toAeson v)) (Map.toList o)))
src/Tramaj/Node.hs view
@@ -18,20 +18,18 @@   , mapActions   ) where -import Data.Aeson (Value (..), object, (.=))-import qualified Data.Aeson.Key as Key-import qualified Data.Aeson.KeyMap as KeyMap+import Data.Bifunctor (first) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Text (Text)-import qualified Data.Vector as V+import Tramaj.Json (Json (..), normalizeNumbers)  -- | Arbitrary host\/tooling metadata hung off any node. The core language -- assigns no meaning to any key; unknown annotations must not affect -- semantics, and every node-to-node transformation must carry them through -- unchanged. This is where a future type\/domain\/constraint pass puts what -- it derives, without the 'Node' constructors having to change.-type Annotations = Map Text Value+type Annotations = Map Text Json  noAnnotations :: Annotations noAnnotations = Map.empty@@ -39,14 +37,15 @@ -- | Three constructors, with enough expressivity inside them to carry -- scalars losslessly -- see @../specs/decisions.md@ #5. ----- 'NText' holds a 'Value', not a 'Text': @.p($ctx.count)@ with @count = 3@--- keeps the number @3@ rather than stringifying it. Rendering a scalar to+-- 'NText' holds a 'Json' value, not a 'Text': @.p($ctx.count)@ with+-- @count = 3@ keeps the integer @3@ rather than stringifying it, and the+-- float @3.0@ stays a float (@../specs/node-json.md@, /Numbers/). Rendering a scalar to -- characters is a host decision, so the interchange format declines to make -- it. -- -- 'NElement' holds a @value@ slot alongside its children, for targets that -- attach a body value to a tagged node (a YAML\/HCL scalar leaf, a config--- value); it is 'Null' unless a template sets one. Its attributes are an+-- value); it is 'JNull' unless a template sets one. Its attributes are an -- ordered list rather than a map: an element may carry any number of -- attributes /and/ any number of actions, and source order is preserved. --@@ -54,8 +53,8 @@ -- wrapper element. A host may flatten fragments when folding; evaluation -- does not. data Node-  = NText Value Annotations-  | NElement Text [NodeAttribute] Value [Node] Annotations+  = NText Json Annotations+  | NElement Text [NodeAttribute] Json [Node] Annotations   | NFragment [Node] Annotations   deriving stock (Eq, Show) @@ -63,8 +62,8 @@ -- @event@ and @key@ are plain 'Text' because both are static in the source -- AST (see "Tramaj.Ast"); only the payload is computed. data NodeAttribute-  = NAttr Text Value-  | NAction Text Text Value+  = NAttr Text Json+  | NAction Text Text Json   deriving stock (Eq, Show)  -- Serialization -----------------------------------------------------------@@ -73,51 +72,57 @@ -- including an empty @annotations@ and a @null@ element @value@ -- see -- @../specs/node-json.md@, "Decoding": on the wire a missing field is a bug, -- not a default.-nodeToJson :: Node -> Value+nodeToJson :: Node -> Json nodeToJson (NText v anns) =-  object ["type" .= ("text" :: Text), "value" .= v, "annotations" .= annotationsToJson anns]+  object [("type", JString "text"), ("value", v), ("annotations", JObject anns)] nodeToJson (NElement tag attrs val children anns) =   object-    [ "type" .= ("element" :: Text)-    , "tag" .= tag-    , "attributes" .= V.fromList (map nodeAttributeToJson attrs)-    , "value" .= val-    , "children" .= V.fromList (map nodeToJson children)-    , "annotations" .= annotationsToJson anns+    [ ("type", JString "element")+    , ("tag", JString tag)+    , ("attributes", JArray (map nodeAttributeToJson attrs))+    , ("value", val)+    , ("children", JArray (map nodeToJson children))+    , ("annotations", JObject anns)     ] nodeToJson (NFragment children anns) =   object-    [ "type" .= ("fragment" :: Text)-    , "children" .= V.fromList (map nodeToJson children)-    , "annotations" .= annotationsToJson anns+    [ ("type", JString "fragment")+    , ("children", JArray (map nodeToJson children))+    , ("annotations", JObject anns)     ] -nodeAttributeToJson :: NodeAttribute -> Value+nodeAttributeToJson :: NodeAttribute -> Json nodeAttributeToJson (NAttr name val) =-  object ["kind" .= ("attribute" :: Text), "name" .= name, "value" .= val]+  object [("kind", JString "attribute"), ("name", JString name), ("value", val)] nodeAttributeToJson (NAction event key payload) =-  object ["kind" .= ("action" :: Text), "event" .= event, "key" .= key, "payload" .= payload]+  object [("kind", JString "action"), ("event", JString event), ("key", JString key), ("payload", payload)] -annotationsToJson :: Annotations -> Value-annotationsToJson = Object . KeyMap.fromList . map (\(k, v) -> (Key.fromText k, v)) . Map.toList+object :: [(Text, Json)] -> Json+object = JObject . Map.fromList  -- | Decodes the normative representation, strictly: a missing or -- ill-typed field is an error rather than a silently-defaulted value, so -- @nodeFromJson . nodeToJson@ round-trips and a malformed document is -- reported where it is read rather than where it later misbehaves. The -- 'Left' carries a human-readable path-ish description of what was wrong.-nodeFromJson :: Value -> Either String Node-nodeFromJson (Object obj) = do+--+-- A number in a @Value@ follows /Numbers/ and /Decoding/ there: an+-- integer-form number outside the integer range or a float too large for a+-- double is refused rather than rounded, at any depth, and a negative zero+-- decodes as zero ('reqValue'). Annotations are arbitrary JSON, not a+-- @Value@, and are kept as they were read.+nodeFromJson :: Json -> Either String Node+nodeFromJson (JObject obj) = do   ty <- reqString "node" "type" obj   case ty of     "text" -> do-      v <- req "text node" "value" obj+      v <- reqValue "text node" "value" obj       anns <- reqAnnotations obj       pure (NText v anns)     "element" -> do       tag <- reqString "element node" "tag" obj       attrs <- reqArray "element node" "attributes" obj >>= traverse nodeAttributeFromJson-      val <- req "element node" "value" obj+      val <- reqValue "element node" "value" obj       children <- reqArray "element node" "children" obj >>= traverse nodeFromJson       anns <- reqAnnotations obj       pure (NElement tag attrs val children anns)@@ -128,40 +133,46 @@     other -> Left ("unknown node type: " <> show other) nodeFromJson _ = Left "expected a JSON object for a node" -nodeAttributeFromJson :: Value -> Either String NodeAttribute-nodeAttributeFromJson (Object obj) = do+nodeAttributeFromJson :: Json -> Either String NodeAttribute+nodeAttributeFromJson (JObject obj) = do   kind <- reqString "node attribute" "kind" obj   case kind of-    "attribute" -> NAttr <$> reqString "attribute" "name" obj <*> req "attribute" "value" obj+    "attribute" -> NAttr <$> reqString "attribute" "name" obj <*> reqValue "attribute" "value" obj     "action" ->       NAction         <$> reqString "action" "event" obj         <*> reqString "action" "key" obj-        <*> req "action" "payload" obj+        <*> reqValue "action" "payload" obj     other -> Left ("unknown node attribute kind: " <> show other) nodeAttributeFromJson _ = Left "expected a JSON object for a node attribute" -req :: String -> Text -> KeyMap.KeyMap Value -> Either String Value-req what field obj = case KeyMap.lookup (Key.fromText field) obj of+req :: String -> Text -> Map Text Json -> Either String Json+req what field obj = case Map.lookup field obj of   Nothing -> Left (what <> ": missing required field " <> show field)   Just v -> Right v -reqString :: String -> Text -> KeyMap.KeyMap Value -> Either String Text+-- | A field holding a @Value@, with its numbers checked ('normalizeNumbers').+reqValue :: String -> Text -> Map Text Json -> Either String Json+reqValue what field obj = do+  v <- req what field obj+  first (\why -> what <> ": field " <> show field <> ": " <> why) (normalizeNumbers v)++reqString :: String -> Text -> Map Text Json -> Either String Text reqString what field obj =   req what field obj >>= \case-    String s -> Right s+    JString s -> Right s     _ -> Left (what <> ": field " <> show field <> " must be a string") -reqArray :: String -> Text -> KeyMap.KeyMap Value -> Either String [Value]+reqArray :: String -> Text -> Map Text Json -> Either String [Json] reqArray what field obj =   req what field obj >>= \case-    Array arr -> Right (V.toList arr)+    JArray arr -> Right arr     _ -> Left (what <> ": field " <> show field <> " must be an array") -reqAnnotations :: KeyMap.KeyMap Value -> Either String Annotations+reqAnnotations :: Map Text Json -> Either String Annotations reqAnnotations obj =   req "node" "annotations" obj >>= \case-    Object anns -> Right (Map.fromList (map (\(k, v) -> (Key.toText k, v)) (KeyMap.toList anns)))+    JObject anns -> Right anns     _ -> Left "node: field \"annotations\" must be an object"  -- Transformation -----------------------------------------------------------@@ -172,7 +183,7 @@ -- that only fails ('Either') and one that also accumulates something -- alongside its result (v3-symbols constraint emission) can share this one -- traversal.-mapActions :: (Applicative f) => (Text -> Text -> Value -> f NodeAttribute) -> Node -> f Node+mapActions :: (Applicative f) => (Text -> Text -> Json -> f NodeAttribute) -> Node -> f Node mapActions _ n@(NText _ _) = pure n mapActions f (NElement tag attrs val children anns) =   NElement tag <$> traverse step attrs <*> pure val <*> traverse (mapActions f) children <*> pure anns
src/Tramaj/Parser.hs view
@@ -33,6 +33,7 @@   ) where  import Data.Char (chr, isAlphaNum, isDigit, isHexDigit, isLetter)+import Data.Int (Int64) import Data.List (nub) import Data.Maybe (fromMaybe) import Data.Text (Text)@@ -220,9 +221,14 @@ -- may sit between two digits (reference.md §5, /Number literals/). The -- fraction and exponent are taken only when complete, so a stray @.@, @e@ -- or @_@ is left for the caller to reject. The underscores are dropped and--- the exponent's sign normalized before the text reaches 'reads'; an--- overflow to infinity is refused and a zero is normalized so @-0@ never--- escapes.+-- the exponent's sign normalized before the text is read.+--+-- The form decides the type (\S5, decisions \S18). With neither a fraction+-- nor an exponent the literal is an integer, denoting exactly its value, and+-- one outside the signed 64-bit range is refused; the sign is part of the+-- literal, so @-9223372036854775808@ is in range. With either it is a float:+-- an overflow to infinity is refused and a zero is normalized so @-0.0@+-- never escapes. numberLit :: P Expr numberLit = lexeme $ try $ do   sign <- optional (char '-')@@ -238,12 +244,19 @@           <> intPart           <> maybe "" ("." <>) fracPart           <> maybe "" ("e" <>) expPart-  case reads (T.unpack fullStr) :: [(Double, String)] of-    [(n, "")]-      | isInfinite n -> fail ("number literal out of range: " <> T.unpack fullStr)-      | n == 0 -> pure (NumberLit 0)-      | otherwise -> pure (NumberLit n)-    _ -> fail ("invalid number literal: " <> T.unpack fullStr)+  case (fracPart, expPart) of+    (Nothing, Nothing) -> case reads (T.unpack fullStr) :: [(Integer, String)] of+      [(n, "")]+        | n < toInteger (minBound :: Int64) || n > toInteger (maxBound :: Int64) ->+            fail ("integer literal outside the signed 64-bit range: " <> T.unpack fullStr)+        | otherwise -> pure (IntLit (fromInteger n))+      _ -> fail ("invalid number literal: " <> T.unpack fullStr)+    _ -> case reads (T.unpack fullStr) :: [(Double, String)] of+      [(n, "")]+        | isInfinite n -> fail ("number literal out of range: " <> T.unpack fullStr)+        | n == 0 -> pure (FloatLit 0)+        | otherwise -> pure (FloatLit n)+      _ -> fail ("invalid number literal: " <> T.unpack fullStr)   where     digits = do       first <- takeWhile1P (Just "digit") isDigit@@ -297,14 +310,15 @@ objectKey :: P Text objectKey = staticString <|> identifier --- | @"$sym"@ and @"$type"@ are reserved across the value domain (v3-symbols--- \S5.3, v4-types \S0): the tag a symbolic or typed envelope uses to mark a--- value that is not an ordinary object. An object literal spelling either as--- a key is a parse error in every profile, not just the symbolic one, so a--- program's legality never depends on which profile runs it.+-- | @"$sym"@, @"$type"@ and @"$term"@ are reserved across the value domain+-- (v3-symbols \S5.3, v4-types \S0): the tag a symbolic or typed envelope+-- uses to mark a value that is not an ordinary object. An object literal+-- spelling one of them as a key is a parse error in every profile, not just+-- the symbolic or the arithmetic one, so a program's legality never depends+-- on which profile runs it. reservedKeyRefused :: Text -> P () reservedKeyRefused k-  | k `elem` (["$sym", "$type"] :: [Text]) =+  | k `elem` (["$sym", "$type", "$term"] :: [Text]) =       fail ("\"" <> T.unpack k <> "\" is a reserved key and cannot be used as an object key")   | otherwise = pure () @@ -450,7 +464,7 @@   name <- try $ do     _ <- optional (char '$')     n <- identifier-    if n `elem` (["map", "filter", "scan", "fold", "branch", "import", "adapt-actions", "constraint"] :: [Text])+    if n `elem` (["map", "filter", "scan", "fold", "sort-by", "sort-by-descending", "branch", "import", "adapt-actions", "constraint"] :: [Text])       then pure n       else fail "not a special form"   base <- case name of@@ -458,6 +472,8 @@     "filter" -> binaryShape Filter     "scan" -> ternaryShape Scan     "fold" -> ternaryShape Fold+    "sort-by" -> binaryShape (SortBy False)+    "sort-by-descending" -> binaryShape (SortBy True)     "branch" -> branchShape     "import" -> importShape     "adapt-actions" -> adaptActionsShape@@ -604,11 +620,13 @@  -- Types --------------------------------------------------------------------- --- | The five value-domain shapes v4-types \S1 reserves as type primitives.+-- | The six value-domain shapes v4-types \S1 reserves as type primitives. -- Fixed and closed, so recognized here rather than left for a later--- resolution pass to classify.+-- resolution pass to classify. @int@ and @float@ are the value domain's two+-- number types; @number@ is no longer a primitive, so it parses as an+-- ordinary name and resolves only if a program declares it. primNames :: [Text]-primNames = ["string", "number", "bool", "null", "document"]+primNames = ["string", "int", "float", "bool", "null", "document"]  -- | A type expression (v4-types \S1), in the position a full 'TypeExpr' may -- appear: a declaration's right-hand side, a record field's type, an array's@@ -742,7 +760,8 @@         <|> (asScalar <$> keywordLit)      asScalar :: Expr -> TypeConstraintArg-    asScalar (NumberLit n) = TCScalarNum n+    asScalar (IntLit n) = TCScalarInt n+    asScalar (FloatLit n) = TCScalarFloat n     asScalar (BoolLit b) = TCScalarBool b     asScalar NullLit = TCScalarNull     asScalar (StringLit s) = TCScalarStr s
src/Tramaj/Types.hs view
@@ -65,6 +65,7 @@   , programTypeRoots   ) where +import Data.Int (Int64) import Data.List (sortOn) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map@@ -394,7 +395,8 @@     go (Lambda params body) = Lambda params <$> go body     go (Let name value body) = Let name <$> go value <*> go body     go e@(StringLit _) = pure e-    go e@(NumberLit _) = pure e+    go e@(IntLit _) = pure e+    go e@(FloatLit _) = pure e     go e@(BoolLit _) = pure e     go NullLit = pure NullLit     go (ArrayLit elems) = ArrayLit <$> traverse go elems@@ -407,6 +409,7 @@     go (Filter coll fn) = Filter <$> go coll <*> go fn     go (Scan coll initial fn) = Scan <$> go coll <*> go initial <*> go fn     go (Fold coll initial fn) = Fold <$> go coll <*> go initial <*> go fn+    go (SortBy descending coll fn) = SortBy descending <$> go coll <*> go fn     go (Concat l r) = Concat <$> go l <*> go r     go (Import name params) = Import name <$> traverse goParam params     go (AdaptActions target adaptation fn) = AdaptActions <$> go target <*> pure adaptation <*> traverse go fn@@ -433,12 +436,13 @@ -- Type constraints (v4-types \S5, roadmap Phase 12) -----------------------  -- | A resolved @!type-constraint@ argument: a type, or one of the four--- scalar shapes \S5 allows -- the type-realm counterpart of the+-- scalar shapes \S5 allows, a number being an integer or a float -- the type-realm counterpart of the -- already-evaluated 'Value'' a v3 constraint's argument becomes. data ResolvedConstraintArg   = RCType ResolvedType   | RCScalarStr Text-  | RCScalarNum Double+  | RCScalarInt Int64+  | RCScalarFloat Double   | RCScalarBool Bool   | RCScalarNull   deriving stock (Eq, Ord, Show)@@ -446,7 +450,8 @@ resolveConstraintArg :: Map Text Program -> Program -> TypeConstraintArg -> Either TypeError ResolvedConstraintArg resolveConstraintArg libs prog (TCType t) = RCType <$> resolveTypeExpr libs prog t resolveConstraintArg _ _ (TCScalarStr s) = Right (RCScalarStr s)-resolveConstraintArg _ _ (TCScalarNum n) = Right (RCScalarNum n)+resolveConstraintArg _ _ (TCScalarInt n) = Right (RCScalarInt n)+resolveConstraintArg _ _ (TCScalarFloat n) = Right (RCScalarFloat n) resolveConstraintArg _ _ (TCScalarBool b) = Right (RCScalarBool b) resolveConstraintArg _ _ TCScalarNull = Right RCScalarNull 
test/unit/Tramaj/AnalysisSpec.hs view
@@ -23,6 +23,8 @@   holeSpec   constraintSpec   symbolSpec+  arithmeticSpec+  sortSpec   typeParamSpec  prog :: Text -> Program@@ -36,6 +38,8 @@     , ("needs", prog "@p=import(\"button\", {name: ctx(inner.name)})\n$p({}).rendered")     , ("panel", prog "@n=$ctx.replicas\n.section(.h2($ctx.name), .p($n))")     , ("loopy", prog ".div(import(\"loopy\", {}).rendered)")+    , ("adds", prog "sum($ctx.a, 1)")+    , ("scales", prog "@f=(product) => $product\n.p(floor($ctx.x), import(\"adds\", {a: 1}).rendered)")     , ("typed", prog "!constraint(\"has-type\", $ctx, \"Deployment\")\n1")     ] @@ -198,7 +202,7 @@ typeParamSpec :: Spec typeParamSpec = describe "type declarations and parameters" $ do   it "finds every type this program declares" $-    typeDeclarations (prog "type A = string\ntype B = number\ntrue") `shouldBe` Set.fromList ["A", "B"]+    typeDeclarations (prog "type A = string\ntype B = int\ntrue") `shouldBe` Set.fromList ["A", "B"]    it "is empty for a program with no type declaration" $     typeDeclarations (prog "1") `shouldBe` Set.empty@@ -224,3 +228,102 @@     let typedLibs = Map.singleton "message" (prog "type Envelope = { payload : %ctx.payload }\ntrue")      in unsuppliedTypeParams typedLibs (prog "@msg=import(\"message\", {payload: %string})\ntrue")           `shouldBe` [("message", Set.empty)]++-- | reference.md \S9: every analysis traverses a sort as it traverses a+-- @map@, through the collection and through the key function. The corpus+-- cannot state this for the analyses it has no case shape for, so each one+-- is checked here, under both names.+sortSpec :: Spec+sortSpec = describe "a sort" $ do+  it "has its collection and its key function read by contextReads" $ do+    contextReads (prog "sort-by($ctx.rows, (r) => lookup($r, $ctx.column, 0))")+      `shouldBe` Set.fromList [["rows"], ["column"]]+    contextReads (prog "sort-by-descending($ctx.rows, (r) => lookup($r, $ctx.column, 0))")+      `shouldBe` Set.fromList [["rows"], ["column"]]++  it "has a hole inside its key function reported by contextHoles" $+    contextHoles (prog "sort-by($ctx.rows, (r) => import(\"button\", {name: ctx(sort.label)}).vals.n)")+      `shouldBe` Set.fromList [["sort", "label"]]++  it "has an import inside its key function reported, and followed" $ do+    staticImportNames (prog "sort-by-descending(import(\"panel\", {}).vals.rows, (r) => import(\"row\", {}).vals.k)")+      `shouldBe` Set.fromList ["panel", "row"]+    transitiveImportNames libs (prog "sort-by([], (r) => import(\"row\", {}).vals.k)")+      `shouldBe` Set.fromList ["row", "button"]++  it "has an action key inside its key function reported" $ do+    staticActionKeys (prog "sort-by($ctx.rows, (r) => .b(action(\"on-click\", \"pick\", {})))")+      `shouldBe` Set.fromList ["pick"]+    deepActionKeys libs (prog "sort-by(import(\"row\", {}).rendered, (r) => .b(action(\"on-click\", \"pick\", {})))")+      `shouldBe` Set.fromList ["pick", "select", "deploy"]++  it "has an arithmetic name inside its key function reported, called or by reference" $ do+    arithmeticOps (prog "sort-by($ctx.rows, (r) => real($r.spend))") `shouldBe` Set.fromList ["real"]+    arithmeticOps (prog "sort-by-descending(map($ctx.xs, $negate), $round)") `shouldBe` Set.fromList ["negate", "round"]++  it "scopes the parameter of its key function, for arithmeticOps" $+    arithmeticOps (prog "[sort-by($ctx.xs, (floor) => $floor), sum(1, 2)]") `shouldBe` Set.fromList ["sum"]++  it "has a demand and an allocation inside its key function reported" $ do+    symbolDemands (prog "sort-by($ctx.rows, (r) => ?ctx.weight)") `shouldBe` Set.fromList [["weight"]]+    length (symbolSites (prog "sort-by($ctx.rows, (r) => ?($r.id))")) `shouldBe` 1++-- | reference.md \S9: which of the ten arithmetic names a program+-- references free. The shared corpus holds the same cases as+-- @"expect": "analysis"@ ones (@corpus/README.md@), which the PureScript+-- port runs too.+arithmeticSpec :: Spec+arithmeticSpec = describe "arithmetic operations" $ do+  it "names the ten builtins of the profile" $+    arithmeticNames+      `shouldBe` ["sum", "product", "negate", "inverse", "quotient", "floor-quotient", "modulo", "floor", "real", "round"]++  it "reports a name that is called" $+    arithmeticOps (prog "sum(1, negate($ctx.x))") `shouldBe` Set.fromList ["sum", "negate"]++  it "reports a name passed by reference" $+    arithmeticOps (prog "fold($ctx.xs, 0, $sum)") `shouldBe` Set.fromList ["sum"]++  it "reports every one of the ten" $+    arithmeticOps (prog "[$sum, $product, $negate, $inverse, $quotient, $floor-quotient, $modulo, $floor, $real, $round]")+      `shouldBe` Set.fromList arithmeticNames++  it "reports none in a program that uses none, whatever other builtin it calls" $+    arithmeticOps (prog "and(eq(1, 1), gt($ctx.n, 0))") `shouldBe` Set.empty++  it "does not report a name shadowed by a binding" $+    arithmeticOps (prog "@sum=(a, b) => $a\n$sum(1, 2)") `shouldBe` Set.empty++  it "reports a name used in the right-hand side of its own binding" $+    arithmeticOps (prog "@sum=sum(1, 2)\n$sum") `shouldBe` Set.fromList ["sum"]++  it "reports a name used before the binding that shadows it" $+    arithmeticOps (prog "@a=floor(1.5)\n@floor=1\n$floor") `shouldBe` Set.fromList ["floor"]++  it "does not report a name shadowed by a lambda parameter, inside that lambda only" $+    arithmeticOps (prog "[map($ctx.xs, (real) => $real), real(1)]") `shouldBe` Set.fromList ["real"]++  it "does not report a name shadowed by a pattern name" $+    arithmeticOps (prog "@{product, meta: {floor}} = $ctx.item\n[$product, $floor, modulo(1, 2)]")+      `shouldBe` Set.fromList ["modulo"]++  it "does not report a name shadowed by a lambda's pattern parameter" $+    arithmeticOps (prog "map($ctx.xs, ({sum}) => $sum)") `shouldBe` Set.empty++  it "reports a name under an arm no context will select" $+    arithmeticOps (prog "branch(1, false, inverse(0.0))") `shouldBe` Set.fromList ["inverse"]++  it "does not reach into a library on its own" $+    arithmeticOps (prog "import(\"adds\", {a: 1}).rendered") `shouldBe` Set.empty++  it "follows imports, directly or not, when deep" $+    deepArithmeticOps libs (prog "@x=negate(1)\nimport(\"scales\", {x: 1.5}).rendered")+      `shouldBe` Set.fromList ["negate", "floor", "sum"]++  it "gives a library its own scope: a binding of the importer shadows nothing there" $+    deepArithmeticOps libs (prog "@sum=1\nimport(\"adds\", {a: $sum}).rendered")+      `shouldBe` Set.fromList ["sum"]++  it "reports nothing for a missing library, and terminates on a cycle" $ do+    deepArithmeticOps libs (prog "import(\"absent\", {}).rendered") `shouldBe` Set.empty+    deepArithmeticOps libs (prog "import(\"loopy\", {}).rendered") `shouldBe` Set.empty
test/unit/Tramaj/CorpusSpec.hs view
@@ -5,45 +5,122 @@ -- itself" -- see @roadmap-to-v4@ Phase 0. module Tramaj.CorpusSpec (spec) where -import Control.Monad (filterM, forM)-import Data.Aeson (FromJSON (..), Value (..), eitherDecodeStrict, encode, withObject, (.:), (.:?))+import Control.Monad (filterM, forM, forM_, when)+import Data.Aeson (FromJSON (..), eitherDecodeStrict, withObject, (.:), (.:?))+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Types as Aeson (Parser)+import Data.Aeson.Types (parseEither) import qualified Data.ByteString as BS-import qualified Data.ByteString.Lazy as BL import Data.List (isSuffixOf, sort) import qualified Data.Map.Strict as Map-import Data.Maybe (fromMaybe)+import Data.Maybe (fromMaybe, isJust)+import Data.Set (Set)+import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import System.Directory (canonicalizePath, doesDirectoryExist, listDirectory) import System.FilePath (dropExtension, takeFileName, (</>)) import Test.Hspec-import Tramaj.Eval (EvalError, LibraryTable, Mode (..), evalProgram, runProgram)+import Tramaj.Analysis+  ( arithmeticOps+  , contextHoles+  , contextReads+  , deepActionKeys+  , deepArithmeticOps+  , deepContextHoles+  , staticActionKeys+  , staticImportNames+  , transitiveImportNames+  )+import Tramaj.Ast (Program)+import Tramaj.Eval (EvalError, LibraryTable, Mode (..), Options (..), defaultOptions, runProgramWith)+import Tramaj.Json (Json, jsonParser, stringify) import Tramaj.Parser (parseProgram)  -- | `"kind"` is part of the format (corpus/README.md) but not read here: a -- successful run's shape is checked by comparing against @expected.json@--- wholesale, via 'runProgram', which already reflects the mode and (once §4+-- wholesale, via 'runProgramWith', which already reflects the mode and (once §4 -- lands) the kind in what it produces. data CaseMeta = CaseMeta   { metaName :: Text-  , metaMode :: Text+  , metaMode :: Maybe Text+  -- ^ Absent in an @"expect": "analysis"@ case, which evaluates nothing.   , metaExpect :: Text   , metaErrorKind :: Maybe Text+  , metaProfiles :: [Text]+  , metaHasRequires :: Bool+  -- ^ The key @profiles@ replaced. A case that still has it is refused+  -- rather than run without its gate.   }  instance FromJSON CaseMeta where   parseJSON = withObject "meta.json" $ \o ->-    CaseMeta <$> o .: "name" <*> o .: "mode"+    CaseMeta <$> o .: "name" <*> o .:? "mode"       <*> (fromMaybe "success" <$> o .:? "expect")       <*> o .:? "errorKind"+      <*> (fromMaybe [] <$> o .:? "profiles")+      <*> (isJust <$> (o .:? "requires" :: Aeson.Parser (Maybe Aeson.Value))) +-- | The profiles (@profiles@ in @meta.json@, see @corpus/README.md@) this+-- port can provide. A case naming any other one is skipped. @base@: the+-- language without any profile. @int-float@: integers and floats are two+-- types (a transition tag, not a profile). @int64@: an integer covers the+-- signed 64-bit range, so not @int53@. @arithmetic@: the arithmetic profile,+-- which is an option of each evaluation here ('optionsFromMeta'). @sort@,+-- @format-number@ and @round@: transition tags for the two sort forms, the+-- @format-number@ builtin and the tenth arithmetic name (decisions \S20).+providedProfiles :: [Text]+providedProfiles = ["base", "int-float", "int64", "arithmetic", "sort", "format-number", "round"]++-- | The profiles a case names that this port does not provide.+missingProfiles :: CaseMeta -> [Text]+missingProfiles = filter (`notElem` providedProfiles) . metaProfiles++-- | The result of a static analysis, in the two shapes @analysis.json@+-- holds: a set of names, or a set of paths.+data AnalysisResult+  = Names (Set Text)+  | Paths (Set [Text])+  deriving (Eq, Show)++-- | The static analyses (reference.md \S9) this port provides to an+-- @"expect": "analysis"@ case, by the name @analysis.json@ gives them. A case+-- naming any other one is skipped.+providedAnalyses :: Map.Map Text (LibraryTable -> Program -> AnalysisResult)+providedAnalyses =+  Map.fromList+    [ ("staticImportNames", \_ p -> Names (staticImportNames p))+    , ("transitiveImportNames", \libs p -> Names (transitiveImportNames libs p))+    , ("staticActionKeys", \_ p -> Names (staticActionKeys p))+    , ("deepActionKeys", \libs p -> Names (deepActionKeys libs p))+    , ("contextHoles", \_ p -> Paths (contextHoles p))+    , ("deepContextHoles", \libs p -> Paths (deepContextHoles libs p))+    , ("contextReads", \_ p -> Paths (contextReads p))+    , ("arithmeticOps", \_ p -> Names (arithmeticOps p))+    , ("deepArithmeticOps", \libs p -> Names (deepArithmeticOps libs p))+    ]++-- | What 'checkCase' did with a case. A failing case throws instead.+data Outcome+  = Passed+  | -- | Not run: the case names these profiles (or, written+    -- @analysis \<name\>@, these analyses), which this port does not provide.+    Skipped [Text]+  deriving (Eq, Show)+ spec :: Spec spec = do   root <- runIO findCorpusRoot   cases <- runIO (loadCaseDirs root)   describe "shared corpus" $     mapM_ (\dir -> it (takeFileName dir) (runCase dir)) cases+  describe "a case naming a profile this port does not provide" $+    -- corpus/runner-checks/unsupported-profile would fail if it ran: its+    -- expected.json does not match what the template evaluates to.+    it "is skipped" $+      checkCase (root </> ".." </> "runner-checks" </> "unsupported-profile")+        `shouldReturn` Skipped ["never-declared"]  -- | The corpus lives at @corpus/cases@ relative to the repo root, but -- @cabal test@'s working directory depends on how it is invoked -- walk@@ -82,6 +159,16 @@   bytes <- BS.readFile path   either (\e -> error (path <> ": " <> e)) pure (eitherDecodeStrict bytes) +-- | Reads @ctx.json@ or @expected.json@ with the reader that types a number+-- by its text (@corpus/README.md@): @3@ is an integer and @3.0@ a float, and+-- an integer keeps its digits whatever its size. aeson's own decoder would+-- make the two one number, and no fixture could then catch a result of the+-- wrong type.+readTypedJsonFile :: FilePath -> IO Json+readTypedJsonFile path = do+  bytes <- BS.readFile path+  either (\e -> error (path <> ": " <> e)) pure (jsonParser (TE.decodeUtf8 bytes))+ readLibs :: FilePath -> IO LibraryTable readLibs dir = do   let libsDir = dir </> "libs"@@ -98,19 +185,86 @@           Right prog -> pure (name, prog)       pure (Map.fromList entries) -modeFromMeta :: FilePath -> Text -> IO Mode+modeFromMeta :: FilePath -> Maybe Text -> IO Mode modeFromMeta dir m = case m of-  "concrete" -> pure Concrete-  "symbolic" -> pure Symbolic-  other -> error (dir <> ": unknown mode " <> T.unpack other)+  Just "concrete" -> pure Concrete+  Just "symbolic" -> pure Symbolic+  Just other -> error (dir <> ": unknown mode " <> T.unpack other)+  Nothing -> error (dir <> ": meta.json has no mode") +-- | The options a case runs with: its mode, and the arithmetic profile only+-- if it lists @arithmetic@ in @profiles@. Every other case runs with the+-- profile off, as a host that never asked for it does, so the ten+-- arithmetic names are unbound there.+optionsFromMeta :: FilePath -> CaseMeta -> IO Options+optionsFromMeta dir meta = do+  mode <- modeFromMeta dir (metaMode meta)+  pure defaultOptions {optMode = mode, optArithmetic = "arithmetic" `elem` metaProfiles meta}++-- | A skipped case is reported as pending, which hspec counts and prints+-- apart from the passed ones. runCase :: FilePath -> Expectation runCase dir = do+  outcome <- checkCase dir+  case outcome of+    Passed -> pure ()+    Skipped missing -> pendingWith ("not provided: " <> T.unpack (T.intercalate ", " missing))++checkCase :: FilePath -> IO Outcome+checkCase dir = do   meta <- (readJsonFile (dir </> "meta.json") :: IO CaseMeta)-  mode <- modeFromMeta dir (metaMode meta)+  when (metaHasRequires meta) $+    error (dir <> ": \"requires\" was replaced by \"profiles\" (corpus/README.md)")+  if metaExpect meta == "analysis"+    then do+      -- The names are the keys of @analysis.json@, which holds no number, so+      -- reading it before deciding to skip is safe on every port.+      expected <- (readJsonFile (dir </> "analysis.json") :: IO (Map.Map Text Aeson.Value))+      let missingAnalyses = filter (`Map.notMember` providedAnalyses) (Map.keys expected)+      case missingProfiles meta <> map ("analysis " <>) missingAnalyses of+        [] -> Passed <$ checkAnalysisCase dir meta expected+        missing -> pure (Skipped missing)+    else case missingProfiles meta of+      [] -> Passed <$ checkSupportedCase dir meta+      missing -> pure (Skipped missing)++-- | An @"expect": "analysis"@ case: no context and no evaluation. Each named+-- analysis runs over the parsed template (and @libs/@, for a deep variant)+-- and its result is compared, as a set, with the array @analysis.json@ gives.+checkAnalysisCase :: FilePath -> CaseMeta -> Map.Map Text Aeson.Value -> Expectation+checkAnalysisCase dir meta expected = do   src <- TE.decodeUtf8 <$> BS.readFile (dir </> "template.tramaj")   libs <- readLibs dir   let label = T.unpack (metaName meta)+  case parseProgram src of+    Left e -> expectationFailure (label <> ": parse error: " <> show e)+    Right prog -> forM_ (Map.toList expected) $ \(key, value) ->+      case Map.lookup key providedAnalyses of+        Nothing -> expectationFailure (label <> ": unknown analysis " <> T.unpack key)+        Just analysis ->+          let actual = analysis libs prog+           in case decodeExpected actual value of+                Left e -> expectationFailure (label <> ": analysis.json: " <> T.unpack key <> ": " <> e)+                Right want -> (key, actual) `shouldBe` (key, want)+  where+    -- Decoded in the shape of the actual result, since @[]@ alone does not+    -- say which of the two it is. A repeated element is refused: the file is+    -- a set.+    decodeExpected :: AnalysisResult -> Aeson.Value -> Either String AnalysisResult+    decodeExpected (Names _) v = Names <$> (distinct =<< parseEither parseJSON v)+    decodeExpected (Paths _) v = Paths <$> (distinct =<< parseEither parseJSON v)++    distinct :: (Ord a) => [a] -> Either String (Set a)+    distinct xs =+      let set = Set.fromList xs+       in if Set.size set == length xs then Right set else Left "an element is repeated"++checkSupportedCase :: FilePath -> CaseMeta -> Expectation+checkSupportedCase dir meta = do+  options <- optionsFromMeta dir meta+  src <- TE.decodeUtf8 <$> BS.readFile (dir </> "template.tramaj")+  libs <- readLibs dir+  let label = T.unpack (metaName meta)   case metaExpect meta of     "parse-error" -> case parseProgram src of       Left _ -> pure ()@@ -118,10 +272,15 @@     "eval-error" -> case metaErrorKind meta of       Nothing -> expectationFailure (label <> ": eval-error case needs errorKind")       Just errorKind -> do-        ctx <- (readJsonFile (dir </> "ctx.json") :: IO Value)+        ctx <- readTypedJsonFile (dir </> "ctx.json")         case parseProgram src of           Left e -> expectationFailure (label <> ": parse error: " <> show e)-          Right prog -> case evalProgram mode libs ctx prog of+          -- 'runProgramWith', as for a success case and as corpus/README.md+          -- says of @mode@: in symbolic mode it also builds the envelope's+          -- @"types"@ table, which is where an annotation naming an+          -- undeclared type is refused. Every error 'evalProgramWith' raises+          -- is raised first by 'runProgramWith'.+          Right prog -> case runProgramWith options libs ctx prog of             Right _ -> expectationFailure (label <> ": expected eval error " <> T.unpack errorKind <> ", but evaluation succeeded")             Left e ->               let actualKind = errorConstructor e@@ -129,13 +288,21 @@                     then pure ()                     else expectationFailure (label <> ": expected eval error " <> T.unpack errorKind <> ", got " <> T.unpack actualKind <> " (" <> show e <> ")")     "success" -> do-      ctx <- (readJsonFile (dir </> "ctx.json") :: IO Value)-      expected <- (readJsonFile (dir </> "expected.json") :: IO Value)+      ctx <- readTypedJsonFile (dir </> "ctx.json")+      expected <- readTypedJsonFile (dir </> "expected.json")       case parseProgram src of         Left e -> expectationFailure (label <> ": parse error: " <> show e)-        Right prog -> case runProgram mode libs ctx prog of+        Right prog -> case runProgramWith options libs ctx prog of           Left e -> expectationFailure (label <> ": eval error: " <> show e)-          Right actual -> encode actual `shouldBe` (encode expected :: BL.ByteString)+          -- Typed values, so an integer @1@ against an expected float @1.0@+          -- is a failure: the text of every number is compared, up to the+          -- spelling of one value of one type (@1.0@ and @1e0@ are the same+          -- float). Object key order is not compared.+          --+          -- The result goes through the writer and the reader first, since+          -- the text is what a host receives: a float written without its+          -- fraction would read back as an integer and fail here.+          Right actual -> jsonParser (stringify actual) `shouldBe` Right expected     other -> expectationFailure (label <> ": unknown expect " <> T.unpack other)  -- | The constructor name an 'EvalError''s 'Show' instance leads with -- every
test/unit/Tramaj/EvalSpec.hs view
@@ -6,17 +6,16 @@ -- failure should say which decision broke. See @../specs/decisions.md@. module Tramaj.EvalSpec (spec) where -import Data.Aeson (Value (..), object, (.=))-import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.Map.Strict as Map import Data.Text (Text) import qualified Data.Text as T-import qualified Data.Vector as V import Test.Hspec-import Tramaj.Ast (Program)+import Tramaj.Ast (Expr (..), Program (..)) import Tramaj.Eval+import Tramaj.Json (Json (..)) import Tramaj.Node import Tramaj.Parser+import Tramaj.TestJson (ToJ (..), object, (.=)) import Tramaj.Types (TypeError (..))  spec :: Spec@@ -34,6 +33,8 @@   adaptSpec   errorSpec   typeSpec+  arithmeticSpec+  sortSpec  -- Helpers ------------------------------------------------------------------- @@ -53,32 +54,32 @@  -- | Parse and evaluate, flattening a parse failure into the same 'Either' so -- a fixture that stops parsing fails loudly instead of being skipped.-run :: Text -> Value -> Either String Output+run :: Text -> Json -> Either String Output run src ctx = case parseProgram src of   Left e -> Left ("parse error: " <> show e)   Right prog -> either (Left . show) Right (evalProgram Concrete libs ctx prog)  -- | The document a program produced, as its normative JSON -- what a host -- receives, rather than the internal representation.-doc :: Text -> Value -> Either String Value+doc :: Text -> Json -> Either String Json doc src ctx =   run src ctx >>= \case     ONode n -> Right (nodeToJson n)     OValue v -> Left ("expected a document, got the value " <> show v) -val :: Text -> Value -> Either String Value+val :: Text -> Json -> Either String Json val src ctx =   run src ctx >>= \case     OValue v -> Right v     ONode _ -> Left "expected a value, got a document" -failsWith :: (EvalError -> Bool) -> Text -> Value -> Bool+failsWith :: (EvalError -> Bool) -> Text -> Json -> Bool failsWith p src ctx = case parseProgram src of   Left _ -> False   Right prog -> either p (const False) (evalProgram Concrete libs ctx prog)  -- | Expected-node builders, matching @specs/node-json.md@.-elemJ :: Text -> [Value] -> [Value] -> Value -> Value+elemJ :: Text -> [Json] -> [Json] -> Json -> Json elemJ tag attrs children value =   object     [ "type" .= ("element" :: Text)@@ -89,27 +90,27 @@     , "annotations" .= object []     ] -el :: Text -> [Value] -> Value-el tag children = elemJ tag [] children Null+el :: Text -> [Json] -> Json+el tag children = elemJ tag [] children JNull  -- | A JSON array, spelled as a list at the call site.-arr :: [Value] -> Value-arr = Array . V.fromList+arr :: [Json] -> Json+arr = JArray -textJ :: Value -> Value+textJ :: Json -> Json textJ v = object ["type" .= ("text" :: Text), "value" .= v, "annotations" .= object []] -fragJ :: [Value] -> Value+fragJ :: [Json] -> Json fragJ children = object ["type" .= ("fragment" :: Text), "children" .= children, "annotations" .= object []] -attrJ :: Text -> Value -> Value+attrJ :: Text -> Json -> Json attrJ name v = object ["kind" .= ("attribute" :: Text), "name" .= name, "value" .= v] -actionJ :: Text -> Text -> Value -> Value+actionJ :: Text -> Text -> Json -> Json actionJ event key payload =   object ["kind" .= ("action" :: Text), "event" .= event, "key" .= key, "payload" .= payload] -items :: [Text] -> Value+items :: [Text] -> Json items names = object ["items" .= [object ["name" .= n] | n <- names]]  -- Documents -------------------------------------------------------------------@@ -117,16 +118,16 @@ documentSpec :: Spec documentSpec = describe "documents" $ do   it "builds a nested element tree" $-    doc ".div(.p(\"Hello\"))" Null `shouldBe` Right (el "div" [el "p" [textJ (String "Hello")]])+    doc ".div(.p(\"Hello\"))" JNull `shouldBe` Right (el "div" [el "p" [textJ (JString "Hello")]])    it "puts attributes and children in source order" $-    doc ".div(class: \"panel\", \"data-id\": 7, .h1(\"T\"), .p(\"B\"))" Null+    doc ".div(class: \"panel\", \"data-id\": 7, .h1(\"T\"), .p(\"B\"))" JNull       `shouldBe` Right         ( elemJ             "div"-            [attrJ "class" (String "panel"), attrJ "data-id" (Number 7)]-            [el "h1" [textJ (String "T")], el "p" [textJ (String "B")]]-            Null+            [attrJ "class" (JString "panel"), attrJ "data-id" (JInt 7)]+            [el "h1" [textJ (JString "T")], el "p" [textJ (JString "B")]]+            JNull         )    it "keeps an attribute value as a value rather than a display string" $@@ -134,30 +135,30 @@       `shouldBe` Right         ( elemJ             "div"-            [attrJ "count" (Number 3), attrJ "tags" (arr [Number 1, Number 2]), attrJ "on" (Bool True)]+            [attrJ "count" (JInt 3), attrJ "tags" (arr [JInt 1, JInt 2]), attrJ "on" (JBool True)]             []-            Null+            JNull         )    it "fills the element value slot, defaulting to null" $ do-    doc ".Replicas(value($ctx.n))" (object ["n" .= (3 :: Int)]) `shouldBe` Right (elemJ "Replicas" [] [] (Number 3))-    doc ".Replicas()" Null `shouldBe` Right (elemJ "Replicas" [] [] Null)+    doc ".Replicas(value($ctx.n))" (object ["n" .= (3 :: Int)]) `shouldBe` Right (elemJ "Replicas" [] [] (JInt 3))+    doc ".Replicas()" JNull `shouldBe` Right (elemJ "Replicas" [] [] JNull)    it "splices a document held in a binding rather than stringifying it" $-    doc "@header=.header(.h1(\"T\"))\n.main($header)" Null-      `shouldBe` Right (el "main" [el "header" [el "h1" [textJ (String "T")]]])+    doc "@header=.header(.h1(\"T\"))\n.main($header)" JNull+      `shouldBe` Right (el "main" [el "header" [el "h1" [textJ (JString "T")]]])    it "renders a document returned from a lambda" $-    doc "@row=(x) => .li($x)\n.ul($row(\"a\"), $row(\"b\"))" Null-      `shouldBe` Right (el "ul" [el "li" [textJ (String "a")], el "li" [textJ (String "b")]])+    doc "@row=(x) => .li($x)\n.ul($row(\"a\"), $row(\"b\"))" JNull+      `shouldBe` Right (el "ul" [el "li" [textJ (JString "a")], el "li" [textJ (JString "b")]])    -- End to end, because the grammar checks in 'Tramaj.ParserSpec' cannot say   -- that a stripped comment leaves the *output* alone -- and that a `--`   -- inside a string reaches it intact.   it "strips comments and keeps a `--` inside a string as text" $     doc "-- a heading\n@n=cardinality($ctx.items) -- how many\n.p(\"`$n` -- so far\")"-      (object ["items" .= arr [String "a", String "b"]])-      `shouldBe` Right (el "p" [textJ (String "2 -- so far")])+      (object ["items" .= arr [JString "a", JString "b"]])+      `shouldBe` Right (el "p" [textJ (JString "2 -- so far")])  -- Scalars ------------------------------------------------------------------------ @@ -165,64 +166,77 @@ scalarSpec :: Spec scalarSpec = describe "scalar children" $ do   it "keeps a number child a number" $-    doc ".td($ctx.count)" (object ["count" .= (3 :: Int)]) `shouldBe` Right (el "td" [textJ (Number 3)])+    doc ".td($ctx.count)" (object ["count" .= (3 :: Int)]) `shouldBe` Right (el "td" [textJ (JInt 3)])    it "keeps booleans, null and objects unconverted" $-    doc ".div(true, null, {\"a\": 1})" Null+    doc ".div(true, null, {\"a\": 1})" JNull       `shouldBe` Right-        (el "div" [textJ (Bool True), textJ Null, textJ (object ["a" .= (1 :: Int)])])+        (el "div" [textJ (JBool True), textJ JNull, textJ (object ["a" .= (1 :: Int)])])    -- An array in child position is a sibling sequence, not one value: this is   -- the same rule that lets map(...) produce repeated children, applied   -- uniformly. An array wanted as data belongs in an attribute or the value   -- slot, which is what those are for.   it "splices an array child into siblings rather than nesting it as one value" $-    doc ".div([1, \"a\"])" Null `shouldBe` Right (el "div" [textJ (Number 1), textJ (String "a")])+    doc ".div([1, \"a\"])" JNull `shouldBe` Right (el "div" [textJ (JInt 1), textJ (JString "a")])    it "converts only where the source asks, through interpolation" $     doc ".p(\"n is `$ctx.n`\")" (object ["n" .= (3 :: Int)])-      `shouldBe` Right (el "p" [textJ (String "n is 3")])+      `shouldBe` Right (el "p" [textJ (JString "n is 3")])    it "renders a whole number without a trailing .0 when interpolated" $-    val "\"`$ctx.n`\"" (object ["n" .= (3 :: Int)]) `shouldBe` Right (String "3")+    val "\"`$ctx.n`\"" (object ["n" .= (3 :: Int)]) `shouldBe` Right (JString "3")    it "resolves escape sequences" $-    val "\"a\\tb\\nc\\u{1F600}\"" Null `shouldBe` Right (String "a\tb\nc\128512")+    val "\"a\\tb\\nc\\u{1F600}\"" JNull `shouldBe` Right (JString "a\tb\nc\128512")  -- | How @str@ -- and therefore string interpolation -- renders each kind of -- value. This is a normative rendering that both implementations must -- produce character for character, so every case here is pinned exactly--- rather than described loosely; the numbers follow ECMAScript's+-- rather than described loosely; a float follows ECMAScript's -- @Number::toString@, which is simply what a number's text is on the--- PureScript implementation's host.+-- PureScript implementation's host, with @.0@ appended when that text has+-- neither a fraction nor an exponent. displayStringSpec :: Spec displayStringSpec = describe "str" $ do   let renders :: Text -> Text -> Spec       renders src expected =         it (T.unpack src <> " -> " <> show expected) $-          val ("str(" <> src <> ")") Null `shouldBe` Right (String expected)+          val ("str(" <> src <> ")") JNull `shouldBe` Right (JString expected)    renders "\"hi\"" "hi"   renders "null" ""   renders "true" "true"   renders "false" "false" +  -- An integer is its digits; a float always carries a fraction or an+  -- exponent, so @str@ keeps the two apart (reference.md \S6).   renders "3" "3"+  renders "-7" "-7"+  renders "123456789" "123456789"+  renders "9223372036854775807" "9223372036854775807"+  renders "3.0" "3.0"+  renders "0.0" "0.0"+  renders "-0.0" "0.0"   renders "1.5" "1.5"   renders "0.05" "0.05"-  renders "123456789" "123456789"    -- v1 rendered this as "100000000000.0" on the PureScript side, whose-  -- integrality test went through a 32-bit Int.+  -- integrality test went through a 32-bit Int. It is now what the float+  -- of that value renders as, and the integer renders without it.   renders "100000000000" "100000000000"+  renders "100000000000.0" "100000000000.0"    -- The thresholds where ECMAScript switches to scientific notation.-  renders "1000000000000000000000" "1e+21"+  renders "1000000000000000000000.0" "1e+21"+  renders "100000000000000000000.0" "100000000000000000000.0"   renders "0.0000001" "1e-7"+  renders "0.000001" "0.000001"    -- v1 rendered these through Haskell's own Show, leaking-  -- "Array [Number 1.0,Number 2.0]" into template output.+  -- "Array [JInt 1.0,JInt 2.0]" into template output.   renders "[1, 2]" "[1,2]"+  renders "[1, 1.0]" "[1,1.0]"   renders "{\"a\": 1}" "{\"a\":1}"    -- Keys are sorted: object key order is not semantically significant, so@@ -230,37 +244,37 @@   renders "{\"b\": 2, \"a\": [1, {\"c\": true}]}" "{\"a\":[1,{\"c\":true}],\"b\":2}"    it "renders a nested string with JSON escaping, but a bare one raw" $ do-    val "str([\"a\\\"b\"])" Null `shouldBe` Right (String "[\"a\\\"b\"]")-    val "str(\"a\\\"b\")" Null `shouldBe` Right (String "a\"b")+    val "str([\"a\\\"b\"])" JNull `shouldBe` Right (JString "[\"a\\\"b\"]")+    val "str(\"a\\\"b\")" JNull `shouldBe` Right (JString "a\"b")    it "is what string interpolation uses" $-    val "\"n=`$ctx.xs`\"" (object ["xs" .= ([1, 2] :: [Int])]) `shouldBe` Right (String "n=[1,2]")+    val "\"n=`$ctx.xs`\"" (object ["xs" .= ([1, 2] :: [Int])]) `shouldBe` Right (JString "n=[1,2]")  -- Fragments ---------------------------------------------------------------------  fragmentSpec :: Spec fragmentSpec = describe "fragments" $ do   it "introduces siblings with no wrapper element" $-    doc ".(.p(\"one\"), .p(\"two\"))" Null-      `shouldBe` Right (fragJ [el "p" [textJ (String "one")], el "p" [textJ (String "two")]])+    doc ".(.p(\"one\"), .p(\"two\"))" JNull+      `shouldBe` Right (fragJ [el "p" [textJ (JString "one")], el "p" [textJ (JString "two")]])    it "survives as a real node when nested, rather than being flattened" $-    doc ".div(.(.p(\"a\")))" Null `shouldBe` Right (el "div" [fragJ [el "p" [textJ (String "a")]]])+    doc ".div(.(.p(\"a\")))" JNull `shouldBe` Right (el "div" [fragJ [el "p" [textJ (JString "a")]]])    -- The JSX-children case, and the reason documents had to become values.   it "can be bound and passed to a lambda as an ordinary argument" $-    doc "@kids=.(.p(\"a\"), .p(\"b\"))\n@panel=(t, c) => .section(.h2($t), $c)\n$panel(\"T\", $kids)" Null+    doc "@kids=.(.p(\"a\"), .p(\"b\"))\n@panel=(t, c) => .section(.h2($t), $c)\n$panel(\"T\", $kids)" JNull       `shouldBe` Right         ( el             "section"-            [ el "h2" [textJ (String "T")]-            , fragJ [el "p" [textJ (String "a")], el "p" [textJ (String "b")]]+            [ el "h2" [textJ (JString "T")]+            , fragJ [el "p" [textJ (JString "a")], el "p" [textJ (JString "b")]]             ]         )    it "can be returned from a lambda over a mapped collection" $     doc "@bs=(xs) => .(map($xs, (x) => .button($x.name)))\n$bs($ctx.items)" (items ["a", "b"])-      `shouldBe` Right (fragJ [el "button" [textJ (String "a")], el "button" [textJ (String "b")]])+      `shouldBe` Right (fragJ [el "button" [textJ (JString "a")], el "button" [textJ (JString "b")]])  -- Actions ------------------------------------------------------------------------- @@ -272,19 +286,19 @@         ( elemJ             "b"             [actionJ "on-click" "deploy" (object ["id" .= (1 :: Int)])]-            [textJ (String "Go")]-            Null+            [textJ (JString "Go")]+            JNull         )    -- v1 allowed at most one action per element.   it "allows several on one element, in source order alongside attributes" $-    doc ".b(class: \"c\", action(\"on-click\", \"a\", {}), action(\"on-key\", \"b\", {}))" Null+    doc ".b(class: \"c\", action(\"on-click\", \"a\", {}), action(\"on-key\", \"b\", {}))" JNull       `shouldBe` Right         ( elemJ             "b"-            [attrJ "class" (String "c"), actionJ "on-click" "a" (object []), actionJ "on-key" "b" (object [])]+            [attrJ "class" (JString "c"), actionJ "on-click" "a" (object []), actionJ "on-key" "b" (object [])]             []-            Null+            JNull         )  -- Bindings ----------------------------------------------------------------------------@@ -292,24 +306,24 @@ bindingSpec :: Spec bindingSpec = describe "bindings and closures" $ do   it "evaluates in declaration order, each seeing the earlier ones" $-    val "@a=1\n@b=$a\n{\"b\": $b}" Null `shouldBe` Right (object ["b" .= (1 :: Int)])+    val "@a=1\n@b=$a\n{\"b\": $b}" JNull `shouldBe` Right (object ["b" .= (1 :: Int)])    it "does not let a binding see a later one" $-    val "@a=$b\n@b=1\n$a" Null `shouldSatisfy` isLeftWith "UnboundName"+    val "@a=$b\n@b=1\n$a" JNull `shouldSatisfy` isLeftWith "UnboundName"    it "captures the environment at the point the lambda is created" $-    val "@t=10\n@big=(x) => gt($x, $t)\n@t2=$big(42)\n$t2" Null `shouldBe` Right (Bool True)+    val "@t=10\n@big=(x) => gt($x, $t)\n@t2=$big(42)\n$t2" JNull `shouldBe` Right (JBool True)    -- Not an accident: a binding's value is inserted only after it is   -- evaluated, so nothing in the language can recurse.   it "does not let a lambda call itself by its own binding name" $-    val "@f=(x) => $f($x)\n$f(1)" Null `shouldSatisfy` isLeftWith "UnboundName"+    val "@f=(x) => $f($x)\n$f(1)" JNull `shouldSatisfy` isLeftWith "UnboundName"    it "allows kebab-case names" $-    val "@my-var=1\n$my-var" Null `shouldBe` Right (Number 1)+    val "@my-var=1\n$my-var" JNull `shouldBe` Right (JInt 1)    it "passes a builtin by reference" $-    val "map($ctx.xs, $not)" (object ["xs" .= [True, False]]) `shouldBe` Right (arr [Bool False, Bool True])+    val "map($ctx.xs, $not)" (object ["xs" .= [True, False]]) `shouldBe` Right (arr [JBool False, JBool True])   where     isLeftWith needle = either (\e -> needle `elem` words (map (\c -> if c == '"' then ' ' else c) e)) (const False) @@ -317,25 +331,25 @@  concatSpec :: Spec concatSpec = describe "concat" $ do-  it "joins strings" $ val "\"a\" <> \"b\"" Null `shouldBe` Right (String "ab")-  it "appends arrays" $ val "[1, 2] <> [3]" Null `shouldBe` Right (arr [Number 1, Number 2, Number 3])+  it "joins strings" $ val "\"a\" <> \"b\"" JNull `shouldBe` Right (JString "ab")+  it "appends arrays" $ val "[1, 2] <> [3]" JNull `shouldBe` Right (arr [JInt 1, JInt 2, JInt 3])    it "merges objects right-biased" $-    val "{\"a\": 1, \"b\": 2} <> {\"b\": 3, \"c\": 4}" Null+    val "{\"a\": 1, \"b\": 2} <> {\"b\": 3, \"c\": 4}" JNull       `shouldBe` Right (object ["a" .= (1 :: Int), "b" .= (3 :: Int), "c" .= (4 :: Int)])    it "rejects mixed types rather than coercing" $     ("\"a\" <> [1]" `failsWith'` \case ConcatMismatch _ _ -> True; _ -> False) `shouldBe` True    it "is associative over a chain" $-    val "\"a\" <> \"b\" <> \"c\"" Null `shouldBe` Right (String "abc")+    val "\"a\" <> \"b\" <> \"c\"" JNull `shouldBe` Right (JString "abc")    it "has the natural identity for each type" $ do-    val "\"a\" <> \"\"" Null `shouldBe` Right (String "a")-    val "[1] <> []" Null `shouldBe` Right (arr [Number 1])-    val "{\"a\": 1} <> {}" Null `shouldBe` Right (object ["a" .= (1 :: Int)])+    val "\"a\" <> \"\"" JNull `shouldBe` Right (JString "a")+    val "[1] <> []" JNull `shouldBe` Right (arr [JInt 1])+    val "{\"a\": 1} <> {}" JNull `shouldBe` Right (object ["a" .= (1 :: Int)])   where-    failsWith' src p = failsWith p src Null+    failsWith' src p = failsWith p src JNull  -- Branch ---------------------------------------------------------------------------------- @@ -344,25 +358,25 @@ branchSpec = describe "branch" $ do   it "selects a value by the first true predicate" $     val "branch(\"unknown\", eq($ctx.s, \"ready\"), \"ready\", eq($ctx.s, \"err\"), \"error\")" (object ["s" .= ("err" :: Text)])-      `shouldBe` Right (String "error")+      `shouldBe` Right (JString "error")    it "falls back when no predicate holds" $-    val "branch(\"unknown\", false, \"a\")" Null `shouldBe` Right (String "unknown")+    val "branch(\"unknown\", false, \"a\")" JNull `shouldBe` Right (JString "unknown")    it "selects a document in a child position" $     doc ".div(branch(.p(\"fallback\"), eq($ctx.s, \"ready\"), .p(\"ready\")))" (object ["s" .= ("ready" :: Text)])-      `shouldBe` Right (el "div" [el "p" [textJ (String "ready")]])+      `shouldBe` Right (el "div" [el "p" [textJ (JString "ready")]])    it "does not evaluate the arm it does not select, so its errors never surface" $-    val "branch(\"fallback\", false, $nope.deeply.broken)" Null `shouldBe` Right (String "fallback")+    val "branch(\"fallback\", false, $nope.deeply.broken)" JNull `shouldBe` Right (JString "fallback")    it "does not evaluate a later predicate once one has matched" $-    val "branch(\"fallback\", true, \"first\", $nope, \"second\")" Null `shouldBe` Right (String "first")+    val "branch(\"fallback\", true, \"first\", $nope, \"second\")" JNull `shouldBe` Right (JString "first")    it "requires its condition to be a boolean" $     ("branch(\"f\", \"not a bool\", \"a\")" `failsWithT` \case TypeMismatch _ -> True; _ -> False) `shouldBe` True   where-    failsWithT src p = failsWith p src Null+    failsWithT src p = failsWith p src JNull  -- Collections ------------------------------------------------------------------------------- @@ -370,27 +384,27 @@ collectionSpec = describe "collections" $ do   it "maps to repeated children when its function returns documents" $     doc ".ul(map($ctx.items, (i) => .li($i.name)))" (items ["a", "b"])-      `shouldBe` Right (el "ul" [el "li" [textJ (String "a")], el "li" [textJ (String "b")]])+      `shouldBe` Right (el "ul" [el "li" [textJ (JString "a")], el "li" [textJ (JString "b")]])    -- One map, two uses: the array is the value; splicing is the child rule.   it "maps to an array when used as a value" $-    val "map($ctx.items, (i) => $i.name)" (items ["a", "b"]) `shouldBe` Right (arr [String "a", String "b"])+    val "map($ctx.items, (i) => $i.name)" (items ["a", "b"]) `shouldBe` Right (arr [JString "a", JString "b"])    it "splices a mapped array among ordinary siblings" $     doc ".ul(.li(\"first\"), map($ctx.items, (i) => .li($i.name)), .li(\"last\"))" (items ["a"])       `shouldBe` Right-        (el "ul" [el "li" [textJ (String "first")], el "li" [textJ (String "a")], el "li" [textJ (String "last")]])+        (el "ul" [el "li" [textJ (JString "first")], el "li" [textJ (JString "a")], el "li" [textJ (JString "last")]])    it "filters" $     val "filter($ctx.xs, (x) => gt($x, 1))" (object ["xs" .= ([1, 2, 3] :: [Int])])-      `shouldBe` Right (arr [Number 2, Number 3])+      `shouldBe` Right (arr [JInt 2, JInt 3])    it "scans, keeping the initial accumulator and every step" $     val "scan($ctx.xs, 0, (a, x) => $x)" (object ["xs" .= ([1, 2] :: [Int])])-      `shouldBe` Right (arr [Number 0, Number 1, Number 2])+      `shouldBe` Right (arr [JInt 0, JInt 1, JInt 2])    it "folds, keeping only the final accumulator" $-    val "fold($ctx.xs, 0, (a, x) => $x)" (object ["xs" .= ([1, 2] :: [Int])]) `shouldBe` Right (Number 2)+    val "fold($ctx.xs, 0, (a, x) => $x)" (object ["xs" .= ([1, 2] :: [Int])]) `shouldBe` Right (JInt 2)    it "scopes the lambda parameter to its own body" $     val "@r=map($ctx.xs, (x) => $x)\n$x" (object ["xs" .= ([1] :: [Int])])@@ -401,15 +415,15 @@ importSpec :: Spec importSpec = describe "imports" $ do   it "renders a complete import" $-    doc "import(\"panel\", {\"name\": \"web\", \"replicas\": 3}).rendered" Null-      `shouldBe` Right (el "section" [el "h2" [textJ (String "web")], el "p" [textJ (Number 3)]])+    doc "import(\"panel\", {\"name\": \"web\", \"replicas\": 3}).rendered" JNull+      `shouldBe` Right (el "section" [el "h2" [textJ (JString "web")], el "p" [textJ (JInt 3)]])    it "exposes an expression-rooted library's result" $-    val "import(\"data\", {\"b\": 2}).rendered" Null+    val "import(\"data\", {\"b\": 2}).rendered" JNull       `shouldBe` Right (object ["a" .= (1 :: Int), "b" .= (2 :: Int)])    it "exposes the library's top-level bindings as .vals" $-    val "import(\"data\", {\"b\": 2}).vals.a" Null `shouldBe` Right (Number 1)+    val "import(\"data\", {\"b\": 2}).vals.a" JNull `shouldBe` Right (JInt 1)    -- decisions.md #10: ctx(path) reads the importing program's own context,   -- where the import is written -- the same value @$ctx.path@ would give.@@ -417,29 +431,29 @@     doc       "import(\"panel\", {name: ctx(n), replicas: ctx(spec.replicas)}).rendered"       (object ["n" .= ("web" :: Text), "spec" .= object ["replicas" .= (3 :: Int)]])-      `shouldBe` Right (el "section" [el "h2" [textJ (String "web")], el "p" [textJ (Number 3)]])+      `shouldBe` Right (el "section" [el "h2" [textJ (JString "web")], el "p" [textJ (JInt 3)]])    -- ... and omission, not ctx(...), is what leaves a parameter for later.   it "saturates an omitted parameter by calling the import" $-    doc "@p=import(\"panel\", {\"name\": \"web\"})\n$p({\"replicas\": 3}).rendered" Null-      `shouldBe` Right (el "section" [el "h2" [textJ (String "web")], el "p" [textJ (Number 3)]])+    doc "@p=import(\"panel\", {\"name\": \"web\"})\n$p({\"replicas\": 3}).rendered" JNull+      `shouldBe` Right (el "section" [el "h2" [textJ (JString "web")], el "p" [textJ (JInt 3)]])    it "accumulates parameters across calls, one at a time" $-    doc "@p=import(\"panel\", {})\n@half=$p({\"name\": \"web\"})\n$half({\"replicas\": 2}).rendered" Null-      `shouldBe` Right (el "section" [el "h2" [textJ (String "web")], el "p" [textJ (Number 2)]])+    doc "@p=import(\"panel\", {})\n@half=$p({\"name\": \"web\"})\n$half({\"replicas\": 2}).rendered" JNull+      `shouldBe` Right (el "section" [el "h2" [textJ (JString "web")], el "p" [textJ (JInt 2)]])    it "lets a later call override an earlier parameter" $     doc "@p=import(\"panel\", {name: ctx(n), \"replicas\": 3})\n$p({\"name\": \"web\"}).rendered" (object ["n" .= ("stale" :: Text)])-      `shouldBe` Right (el "section" [el "h2" [textJ (String "web")], el "p" [textJ (Number 3)]])+      `shouldBe` Right (el "section" [el "h2" [textJ (JString "web")], el "p" [textJ (JInt 3)]])    -- The point of running a library on field access rather than where the   -- import is written: one wired-up import, reused per iteration.   it "reuses one import with different parameters" $-    val "@p=import(\"data\", {})\nmap([1, 2], (b) => $p({\"b\": $b}).rendered.b)" Null-      `shouldBe` Right (Array (V.fromList [Number 1, Number 2]))+    val "@p=import(\"data\", {})\nmap([1, 2], (b) => $p({\"b\": $b}).rendered.b)" JNull+      `shouldBe` Right (JArray [JInt 1, JInt 2])    it "reports a parameter nobody supplied as the library's own missing path" $-    run "import(\"panel\", {\"name\": \"web\"}).rendered" Null+    run "import(\"panel\", {\"name\": \"web\"}).rendered" JNull       `shouldBe` Left (show (InLibrary "panel" (PathNotFound ["ctx", "replicas"])))    it "reports a ctx(path) the importing context lacks, at the import" $@@ -461,122 +475,122 @@     ("import(\"nope\", {}).rendered" `failsWithN` \case UnknownLibrary _ -> True; _ -> False) `shouldBe` True    it "passes a document into a library as an ordinary parameter" $-    val "import(\"data\", {\"b\": 2}).vals.a" Null `shouldBe` Right (Number 1)+    val "import(\"data\", {\"b\": 2}).vals.a" JNull `shouldBe` Right (JInt 1)   where-    failsWithN src p = failsWith p src Null+    failsWithN src p = failsWith p src JNull  -- Action adaptation ------------------------------------------------------------------------------  adaptSpec :: Spec adaptSpec = describe "adapt-actions" $ do   it "prefixes every action key in the subtree" $-    doc "adapt-actions(import(\"two-actions\", {}).rendered, prefix(\"user:\"))" Null+    doc "adapt-actions(import(\"two-actions\", {}).rendered, prefix(\"user:\"))" JNull       `shouldBe` Right         ( el             "div"-            [ elemJ "b" [actionJ "on-click" "user:save" (object [])] [] Null-            , elemJ "b" [actionJ "on-click" "user:delete" (object [])] [] Null+            [ elemJ "b" [actionJ "on-click" "user:save" (object [])] [] JNull+            , elemJ "b" [actionJ "on-click" "user:delete" (object [])] [] JNull             ]         )    it "composes, outermost prefix last" $-    val "cardinality({})" Null `shouldBe` Right (Number 0)+    val "cardinality({})" JNull `shouldBe` Right (JInt 0)    it "composes two adaptations as b:a:key" $-    doc "adapt-actions(adapt-actions(import(\"button\", {\"name\": \"w\"}).rendered, prefix(\"a:\")), prefix(\"b:\"))" Null+    doc "adapt-actions(adapt-actions(import(\"button\", {\"name\": \"w\"}).rendered, prefix(\"a:\")), prefix(\"b:\"))" JNull       `shouldBe` Right         ( elemJ             "button"             [actionJ "on-click" "b:a:deploy" (object ["n" .= ("w" :: Text)])]-            [textJ (String "go: w")]-            Null+            [textJ (JString "go: w")]+            JNull         )    it "leaves keys alone under the identity adaptation" $-    doc "adapt-actions(import(\"button\", {\"name\": \"w\"}).rendered, identity)" Null+    doc "adapt-actions(import(\"button\", {\"name\": \"w\"}).rendered, identity)" JNull       `shouldBe` Right         ( elemJ             "button"             [actionJ "on-click" "deploy" (object ["n" .= ("w" :: Text)])]-            [textJ (String "go: w")]-            Null+            [textJ (JString "go: w")]+            JNull         )    -- The closure sees the already-prefixed action and may change only the   -- event type and payload; a key it returns is ignored, which is what keeps   -- the vocabulary statically knowable.   it "lets the closure rewrite the event type and payload but not the key" $-    doc "adapt-actions(import(\"button\", {\"name\": \"w\"}).rendered, prefix(\"x:\"), (a) => {\"eventType\": \"on-tap\", \"key\": \"ignored\", \"payload\": {\"orig\": $a.key}})" Null+    doc "adapt-actions(import(\"button\", {\"name\": \"w\"}).rendered, prefix(\"x:\"), (a) => {\"eventType\": \"on-tap\", \"key\": \"ignored\", \"payload\": {\"orig\": $a.key}})" JNull       `shouldBe` Right         ( elemJ             "button"             [actionJ "on-tap" "x:deploy" (object ["orig" .= ("x:deploy" :: Text)])]-            [textJ (String "go: w")]-            Null+            [textJ (JString "go: w")]+            JNull         )    it "reaches actions inside a nested import" $-    doc "adapt-actions(import(\"wrapper\", {}).rendered, prefix(\"w:\"))" Null+    doc "adapt-actions(import(\"wrapper\", {}).rendered, prefix(\"w:\"))" JNull       `shouldBe` Right         ( el             "div"             [ elemJ                 "button"                 [actionJ "on-click" "w:deploy" (object ["n" .= ("inner" :: Text)])]-                [textJ (String "go: inner")]-                Null+                [textJ (JString "go: inner")]+                JNull             ]         )    it "queues on an import that has not run and applies to its result" $-    doc "@p=import(\"button\", {})\n@a=adapt-actions($p, prefix(\"q:\"))\n$a({\"name\": \"w\"}).rendered" Null+    doc "@p=import(\"button\", {})\n@a=adapt-actions($p, prefix(\"q:\"))\n$a({\"name\": \"w\"}).rendered" JNull       `shouldBe` Right         ( elemJ             "button"             [actionJ "on-click" "q:deploy" (object ["n" .= ("w" :: Text)])]-            [textJ (String "go: w")]-            Null+            [textJ (JString "go: w")]+            JNull         )    it "leaves ordinary attributes and the value slot untouched" $-    doc "adapt-actions(.b(class: \"c\", value(1), action(\"on-click\", \"k\", {})), prefix(\"p:\"))" Null+    doc "adapt-actions(.b(class: \"c\", value(1), action(\"on-click\", \"k\", {})), prefix(\"p:\"))" JNull       `shouldBe` Right-        (elemJ "b" [attrJ "class" (String "c"), actionJ "on-click" "p:k" (object [])] [] (Number 1))+        (elemJ "b" [attrJ "class" (JString "c"), actionJ "on-click" "p:k" (object [])] [] (JInt 1))    it "passes through a value that has no actions at all" $-    val "adapt-actions(\"plain\", prefix(\"p:\"))" Null `shouldBe` Right (String "plain")+    val "adapt-actions(\"plain\", prefix(\"p:\"))" JNull `shouldBe` Right (JString "plain")  -- Errors ----------------------------------------------------------------------------------------------  errorSpec :: Spec errorSpec = describe "errors" $ do   it "reports an unbound name" $-    (failsWith (\case UnboundName _ -> True; _ -> False) "$nope" Null) `shouldBe` True+    (failsWith (\case UnboundName _ -> True; _ -> False) "$nope" JNull) `shouldBe` True    it "reports a missing field with the path as written" $     (failsWith (\case PathNotFound ["ctx", "a", "b"] -> True; _ -> False) "$ctx.a.b" (object ["a" .= object []]) )       `shouldBe` True    it "refuses a function used where a value is expected" $-    (failsWith (\case TypeMismatch _ -> True; _ -> False) "@f=(x) => $x\n[$f, 1]" Null) `shouldBe` True+    (failsWith (\case TypeMismatch _ -> True; _ -> False) "@f=(x) => $x\n[$f, 1]" JNull) `shouldBe` True    it "accepts the same function once it is called" $-    val "@f=(x) => $x\n[$f(1), 2]" Null `shouldBe` Right (arr [Number 1, Number 2])+    val "@f=(x) => $x\n[$f(1), 2]" JNull `shouldBe` Right (arr [JInt 1, JInt 2])    it "refuses a document used where a plain value is expected" $-    (failsWith (\case TypeMismatch _ -> True; _ -> False) ".div(class: .p(\"x\"))" Null) `shouldBe` True+    (failsWith (\case TypeMismatch _ -> True; _ -> False) ".div(class: .p(\"x\"))" JNull) `shouldBe` True    it "reports a closure applied to the wrong number of arguments" $-    (failsWith (\case TypeMismatch _ -> True; _ -> False) "@f=(x, y) => $x\n$f(1)" Null) `shouldBe` True+    (failsWith (\case TypeMismatch _ -> True; _ -> False) "@f=(x, y) => $x\n$f(1)" JNull) `shouldBe` True    it "reports a non-array given to map" $-    (failsWith (\case TypeMismatch _ -> True; _ -> False) "map(1, (x) => $x)" Null) `shouldBe` True+    (failsWith (\case TypeMismatch _ -> True; _ -> False) "map(1, (x) => $x)" JNull) `shouldBe` True  -- Types (v4-types, roadmap Phases 11-13) ---------------------------------------------------------------  -- | 'runProgram' over 'libs', for a given mode -- what a host actually -- serializes, rather than the internal 'Output'.-runMode :: Mode -> Text -> Value -> Either String Value+runMode :: Mode -> Text -> Json -> Either String Json runMode mode src ctx = case parseProgram src of   Left e -> Left ("parse error: " <> show e)   Right prog -> either (Left . show) Right (runProgram mode libs ctx prog)@@ -584,66 +598,205 @@ typeSpec :: Spec typeSpec = describe "types" $ do   it "erasure invariant (\\S8): a concrete-mode run is byte-identical to the same program with its annotation deleted" $-    runMode Concrete "@d : string = \"x\"\n$d" Null-      `shouldBe` runMode Concrete "@d = \"x\"\n$d" Null+    runMode Concrete "@d : string = \"x\"\n$d" JNull+      `shouldBe` runMode Concrete "@d = \"x\"\n$d" JNull    it "an annotated binding emits a has-type constraint carrying the erased $type tag (\\S7)" $-    case runMode Symbolic "type Deployment = { replicas : number }\n@d : Deployment = {\"replicas\": 3}\n$d" Null of-      Right (Object o) ->-        KeyMap.lookup "constraints" o+    case runMode Symbolic "type Deployment = { replicas : int }\n@d : Deployment = {\"replicas\": 3}\n$d" JNull of+      Right (JObject o) ->+        Map.lookup "constraints" o           `shouldBe` Just-            ( Array-                ( V.fromList-                    [ object-                        [ "name" .= ("has-type" :: Text)-                        , "arguments"-                            .= [ object ["replicas" .= (3 :: Int)]-                               , object ["$type" .= ("root:Deployment" :: Text)]-                               ]-                        ]+            ( JArray+                [ object+                    [ "name" .= ("has-type" :: Text)+                    , "arguments"+                        .= [ object ["replicas" .= (3 :: Int)]+                           , object ["$type" .= ("root:Deployment" :: Text)]+                           ]                     ]-                )+                ]             )       other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)    it "the \"types\" table carries the referenced type's own definition, closed over its fields" $-    case runMode Symbolic "type Deployment = { replicas : number }\n@d : Deployment = {\"replicas\": 3}\n$d" Null of-      Right (Object o) ->-        KeyMap.lookup "types" o+    case runMode Symbolic "type Deployment = { replicas : int }\n@d : Deployment = {\"replicas\": 3}\n$d" JNull of+      Right (JObject o) ->+        Map.lookup "types" o           `shouldBe` Just-            ( Array-                ( V.fromList-                    [ object-                        [ "id" .= ("root:Deployment" :: Text)-                        , "definition"-                            .= object-                              [ "kind" .= ("record" :: Text)-                              , "fields" .= [object ["name" .= ("replicas" :: Text), "type" .= object ["kind" .= ("prim" :: Text), "name" .= ("number" :: Text)]]]-                              ]-                        ]+            ( JArray+                [ object+                    [ "id" .= ("root:Deployment" :: Text)+                    , "definition"+                        .= object+                          [ "kind" .= ("record" :: Text)+                          , "fields" .= [object ["name" .= ("replicas" :: Text), "type" .= object ["kind" .= ("prim" :: Text), "name" .= ("int" :: Text)]]]+                          ]                     ]-                )+                ]             )       other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)    it "a !type-constraint appears in \"type-constraints\", resolved, and never in \"constraints\"" $-    case runMode Symbolic "type Json = string\n!type-constraint(\"has-default\", %Json)\ntrue" Null of-      Right (Object o) -> do-        KeyMap.lookup "type-constraints" o-          `shouldBe` Just (Array (V.fromList [object ["name" .= ("has-default" :: Text), "arguments" .= [object ["$type" .= ("root:Json" :: Text)]]]]))-        KeyMap.lookup "constraints" o `shouldBe` Just (Array V.empty)+    case runMode Symbolic "type Json = string\n!type-constraint(\"has-default\", %Json)\ntrue" JNull of+      Right (JObject o) -> do+        Map.lookup "type-constraints" o+          `shouldBe` Just (JArray [object ["name" .= ("has-default" :: Text), "arguments" .= [object ["$type" .= ("root:Json" :: Text)]]]])+        Map.lookup "constraints" o `shouldBe` Just (JArray [])       other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)    it "a symbol-free, type-free program's envelope carries empty \"types\" and \"type-constraints\" (\\S8: a v3 consumer sees nothing new)" $-    case runMode Symbolic "1" Null of-      Right (Object o) -> do-        KeyMap.lookup "types" o `shouldBe` Just (Array V.empty)-        KeyMap.lookup "type-constraints" o `shouldBe` Just (Array V.empty)+    case runMode Symbolic "1" JNull of+      Right (JObject o) -> do+        Map.lookup "types" o `shouldBe` Just (JArray [])+        Map.lookup "type-constraints" o `shouldBe` Just (JArray [])       other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)    it "an annotation whose type is still partial is a static PartialType error (\\S4), not an evaluation one" $     let libsHere = Map.fromList [("message", either (\e -> error (show e)) id (parseProgram "type Envelope = { payload : %ctx.payload }\ntrue"))]         p = either (\e -> error (show e)) id (parseProgram "@msg=import(\"message\", {})\n@m : $msg.types.Envelope = 1\ntrue")-     in case runProgram Concrete libsHere Null p of+     in case runProgram Concrete libsHere JNull p of           Left (TypeErr (PartialType _ _)) -> pure ()           other -> expectationFailure ("expected a PartialType error, got " <> show other)++-- | The arithmetic profile as an option of one evaluation (reference.md+-- \S11, v3-symbols \S5.5). What the builtins compute is the shared+-- corpus's business; here is what it has no shape for: the option, its+-- default, and the ends of the 64-bit range in integer division.+arithmeticSpec :: Spec+arithmeticSpec = describe "the arithmetic profile" $ do+  let on mode = defaultOptions {optMode = mode, optArithmetic = True}+      parsed src = either (\e -> error ("fixture does not parse: " <> show e)) id (parseProgram src)+      withOptions options libsHere src ctx = runProgramWith options libsHere ctx (parsed src)+      arith src = withOptions (on Concrete) Map.empty src JNull+      isUnbound name = \case+        Left (UnboundName n) -> n == name+        _ -> False+      isTypeMismatch = \case+        Left (TypeMismatch _) -> True+        _ -> False+      isNotRepresentable = \case+        Left (NotRepresentable _) -> True+        _ -> False+      symS = object ["$sym" .= ("#ctx.s" :: Text), "path" .= ([] :: [Text])]+      termS = object ["$term" .= ("sum" :: Text), "arguments" .= [symS, toJ (1 :: Int)]]+      adds = Map.fromList [("adds", parsed "@total=sum($ctx.a, 1)\n$total")]++  it "is off by default: concrete mode, no arithmetic" $+    defaultOptions `shouldBe` Options {optMode = Concrete, optArithmetic = False}++  it "leaves the names unbound through evalProgram and runProgram" $ do+    evalProgram Concrete Map.empty JNull (parsed "sum(1, 2)") `shouldSatisfy` isUnbound "sum"+    runProgram Concrete Map.empty JNull (parsed "sum(1, 2)") `shouldSatisfy` isUnbound "sum"+    runProgram Symbolic Map.empty JNull (parsed "map([1.5], $floor)") `shouldSatisfy` isUnbound "floor"++  it "leaves them unbound with the option off, and binds them with it on" $ do+    withOptions defaultOptions Map.empty "sum(1, 2)" JNull `shouldSatisfy` isUnbound "sum"+    withOptions (on Concrete) Map.empty "sum(1, 2)" JNull `shouldBe` Right (JInt 3)+    evalProgramWith (on Concrete) Map.empty JNull (parsed "sum(1, 2)") `shouldBe` Right (OValue (JInt 3))++  it "keeps a program that binds one of the names working with the option off" $+    runProgram Concrete Map.empty JNull (parsed "@sum=(a, b) => $a\n$sum(1, 2)") `shouldBe` Right (JInt 1)++  it "runs a library with the profile of the evaluation that reached it" $ do+    withOptions (on Concrete) adds "import(\"adds\", {a: 2}).vals.total" JNull `shouldBe` Right (JInt 3)+    withOptions defaultOptions adds "import(\"adds\", {a: 2}).vals.total" JNull+      `shouldBe` Left (InLibrary "adds" (UnboundName "sum"))++  it "accepts a seeded term in symbolic mode with the profile on, and hands it back" $+    case withOptions (on Symbolic) Map.empty "$ctx.t" (object ["t" .= termS]) of+      Right (JObject o) -> Map.lookup "root" o `shouldBe` Just termS+      other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)++  it "refuses every seeded term with the profile off, read or not" $ do+    runProgram Symbolic Map.empty (object ["t" .= termS]) (parsed "$ctx.t") `shouldSatisfy` isTypeMismatch+    runProgram Symbolic Map.empty (object ["t" .= termS]) (parsed "1") `shouldSatisfy` isTypeMismatch++  it "refuses the reserved \"$term\" key in concrete mode whatever the profile" $ do+    withOptions (on Concrete) Map.empty "1" (object ["t" .= termS]) `shouldSatisfy` isTypeMismatch+    withOptions defaultOptions Map.empty "1" (object ["$term" .= (1 :: Int)]) `shouldSatisfy` isTypeMismatch++  it "keeps the two number types with the profile off" $+    runProgram Concrete Map.empty JNull (parsed "[eq(1, 1.0), 1, 1.0]")+      `shouldBe` Right (JArray [JBool False, JInt 1, JFloat 1.0])++  it "builds a term over a seeded symbol only with the profile on" $ do+    case withOptions (on Symbolic) Map.empty "sum($ctx.s, 1)" (object ["s" .= symS]) of+      Right (JObject o) -> Map.lookup "root" o `shouldBe` Just termS+      other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)+    runProgram Symbolic Map.empty (object ["s" .= symS]) (parsed "sum($ctx.s, 1)") `shouldSatisfy` isUnbound "sum"++  it "divides exactly at the ends of the 64-bit range" $ do+    arith "floor-quotient(9223372036854775807, 2)" `shouldBe` Right (JInt 4611686018427387903)+    arith "floor-quotient(-9223372036854775808, 2)" `shouldBe` Right (JInt (-4611686018427387904))+    arith "floor-quotient(-9223372036854775808, 1)" `shouldBe` Right (JInt (-9223372036854775808))+    arith "floor-quotient(9223372036854775807, -1)" `shouldBe` Right (JInt (-9223372036854775807))+    arith "floor-quotient(-9223372036854775807, 9223372036854775807)" `shouldBe` Right (JInt (-1))+    arith "floor-quotient(9223372036854775807, -9223372036854775808)" `shouldBe` Right (JInt (-1))+    arith "modulo(9223372036854775807, -9223372036854775808)" `shouldBe` Right (JInt (-1))+    arith "modulo(-9223372036854775808, 9223372036854775807)" `shouldBe` Right (JInt 9223372036854775806)+    arith "modulo(-9223372036854775807, 2)" `shouldBe` Right (JInt 1)++  it "never wraps: a product or a sum that leaves the 64-bit range is an error at that step" $ do+    arith "product(-9223372036854775808, -1)" `shouldSatisfy` isNotRepresentable+    arith "product(4294967296, 4294967296, 0)" `shouldSatisfy` isNotRepresentable+    arith "product(3037000500, 3037000500)" `shouldSatisfy` isNotRepresentable+    arith "sum(-9223372036854775808, -9223372036854775808, 9223372036854775807)" `shouldSatisfy` isNotRepresentable+    arith "sum(-9223372036854775808, 9223372036854775807, 1)" `shouldBe` Right (JInt 0)++  it "folds floats left to right, one rounding at a time" $ do+    arith "sum(0.1, 0.2, 0.3)" `shouldBe` Right (JFloat 0.6000000000000001)+    arith "sum(1e16, 1.0, 1.0)" `shouldBe` Right (JFloat 1.0e16)+    arith "sum(1.0, 1.0, 1e16)" `shouldBe` Right (JFloat 1.0000000000000002e16)+    arith "product(49.0, inverse(49.0))" `shouldBe` Right (JFloat 0.9999999999999999)++-- | Sorting (reference.md \S11). What a sort gives is the shared corpus's+-- business; here is what no program can observe and the corpus therefore+-- cannot state: how many times the key function is applied. The key+-- function below emits one constraint each time it runs. A lambda written+-- in a template cannot do that, so the program is built as an AST, and+-- 'emittedConstraintCount' counts the emissions before equal ones are made+-- one.+sortSpec :: Spec+sortSpec = describe "the key function of a sort" $ do+  let row :: Int -> Expr+      row k = ObjectLit [("k", IntLit (fromIntegral k))]+      -- (x) => { !constraint("applied"); $x.k }+      counting = Lambda ["x"] (Emit (Constrain "applied" []) (Path "x" ["k"]))+      -- (x) => { !constraint("applied", $x.k); $x.k }+      tracing = Lambda ["x"] (Emit (Constrain "applied" [Path "x" ["k"]]) (Path "x" ["k"]))+      sorting descending keys fn = ExpressionProgram (SortBy descending (ArrayLit (map row keys)) fn)+      applications descending keys = emittedConstraintCount defaultOptions Map.empty JNull (sorting descending keys counting)+      shuffled = [5, 3, 9, 1, 7, 3, 8, 2, 6, 4, 0, 5] :: [Int]++  it "is applied exactly once per element, whatever the order of the keys" $ do+    applications False shuffled `shouldBe` Right (length shuffled)+    applications False [1 .. 16] `shouldBe` Right 16+    applications False (reverse [1 .. 16]) `shouldBe` Right 16+    applications False (replicate 9 4) `shouldBe` Right 9++  it "is applied exactly once per element by the descending sort" $ do+    applications True shuffled `shouldBe` Right (length shuffled)+    applications True [1 .. 16] `shouldBe` Right 16++  it "is applied once to a single element, and not at all to an empty list" $ do+    applications False [7] `shouldBe` Right 1+    applications False [] `shouldBe` Right 0+    applications True [] `shouldBe` Right 0++  it "is applied in index order, not in the order of the result" $+    case runProgramWith defaultOptions {optMode = Symbolic} Map.empty JNull (sorting False [3, 1, 2] tracing) of+      Right (JObject o) -> do+        Map.lookup "root" o `shouldBe` Just (arr [object ["k" .= (1 :: Int)], object ["k" .= (2 :: Int)], object ["k" .= (3 :: Int)]])+        Map.lookup "constraints" o+          `shouldBe` Just (arr [object ["name" .= ("applied" :: Text), "arguments" .= [k]] | k <- [3, 1, 2 :: Int]])+      other -> expectationFailure ("expected a symbolic envelope object, got " <> show other)++  it "is not applied past the first element whose key is refused" $+    emittedConstraintCount+      defaultOptions+      Map.empty+      JNull+      (ExpressionProgram (SortBy False (ArrayLit [row 1, ObjectLit [("k", NullLit)], row 2]) counting))+      `shouldSatisfy` \case+        Left (TypeMismatch _) -> True+        _ -> False
+ test/unit/Tramaj/JsonSpec.hs view
@@ -0,0 +1,238 @@+-- | The typed JSON reader and writer, and what the number split asks of the+-- boundaries around them (@specs/reference.md@ \S3, @specs/node-json.md@+-- /Numbers/, @specs/decisions.md@ \S18). The shared corpus covers the+-- language-level behaviour; what is here is this port's own: the reader, the+-- writer, the aeson bridge and the 64-bit range.+module Tramaj.JsonSpec (spec) where++import qualified Data.Aeson as Aeson+import Data.Either (isLeft)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import Test.Hspec+import Tramaj.Ast (Expr (..))+import Tramaj.Eval+import Tramaj.Json+import Tramaj.Node+import Tramaj.Parser (parseExpr, parseProgram)+import Tramaj.TestJson (object, (.=))++spec :: Spec+spec = describe "Tramaj.Json" $ do+  readerSpec+  writerSpec+  normalizeSpec+  bridgeSpec+  literalSpec+  contextSpec+  nodeSpec++readerSpec :: Spec+readerSpec = describe "jsonParser" $ do+  let reads' :: Text -> Json -> Spec+      reads' src expected = it (T.unpack src) (jsonParser src `shouldBe` Right expected)+      refuses :: Text -> Spec+      refuses src = it ("refuses " <> show src) (jsonParser src `shouldSatisfy` isLeft)++  describe "types a number by its text" $ do+    reads' "3" (JInt 3)+    reads' "-7" (JInt (-7))+    reads' "0" (JInt 0)+    reads' "-0" (JInt 0)+    reads' "3.0" (JFloat 3)+    reads' "3e0" (JFloat 3)+    reads' "3E0" (JFloat 3)+    reads' "1.5e3" (JFloat 1500)+    reads' "1e-7" (JFloat 1.0e-7)+    reads' "0.1" (JFloat 0.1)++  describe "keeps the digits of an integer, whatever its size" $ do+    reads' "9007199254740993" (JInt 9007199254740993)+    reads' "9223372036854775807" (JInt 9223372036854775807)+    reads' "-9223372036854775808" (JInt (-9223372036854775808))+    reads' "9223372036854775808" (JInt 9223372036854775808)+    reads' "123456789012345678901234567890" (JInt 123456789012345678901234567890)++  it "reads a float too large for a double as an infinity, for a decoder to refuse" $+    jsonParser "1e400" `shouldSatisfy` \case+      Right (JFloat d) -> isInfinite d+      _ -> False++  it "reads a float too small for a double as zero" $+    jsonParser "1e-400" `shouldBe` Right (JFloat 0)++  describe "reads every other JSON value" $ do+    reads' "null" JNull+    reads' "true" (JBool True)+    reads' " [1, 1.0, \"a\", null] " (JArray [JInt 1, JFloat 1, JString "a", JNull])+    reads' "{\"b\": {\"c\": 2.5}, \"a\": []}" (object ["a" .= ([] :: [Json]), "b" .= object ["c" .= (2.5 :: Double)]])+    reads' "\"a\\n\\u00e9\\ud83d\\ude00\"" (JString "a\n\233\128512")++  describe "refuses malformed JSON" $ do+    refuses ""+    refuses "[1,]"+    refuses "{\"a\": 1,}"+    refuses "01"+    refuses "1."+    refuses ".5"+    refuses "+1"+    refuses "1 2"+    refuses "nul"+    refuses "{\"a\" 1}"++writerSpec :: Spec+writerSpec = describe "stringify" $ do+  let writes :: Json -> Text -> Spec+      writes v expected = it (T.unpack expected) (stringify v `shouldBe` expected)++  describe "writes an integer as its digits" $ do+    writes (JInt 3) "3"+    writes (JInt (-7)) "-7"+    writes (JInt 100000000000) "100000000000"+    writes (JInt 9223372036854775807) "9223372036854775807"+    writes (JInt (-9223372036854775808)) "-9223372036854775808"++  describe "writes a float with a fraction or an exponent" $ do+    writes (JFloat 1) "1.0"+    writes (JFloat 0) "0.0"+    writes (JFloat (-2)) "-2.0"+    writes (JFloat 1.5) "1.5"+    writes (JFloat 0.1) "0.1"+    writes (JFloat 0.05) "0.05"+    writes (JFloat 100000000000) "100000000000.0"+    writes (JFloat 1.0e21) "1e+21"+    writes (JFloat 1.0e-7) "1e-7"+    writes (JFloat 1.5e-7) "1.5e-7"+    writes (JFloat 9.223372036854776e18) "9223372036854776000.0"++  describe "writes structures compactly, keys sorted" $ do+    writes (object ["b" .= (2 :: Int), "a" .= [JInt 1, JFloat 1, JNull, JBool False]]) "{\"a\":[1,1.0,null,false],\"b\":2}"+    writes (JString "a\"b\n") "\"a\\\"b\\n\""++  it "round-trips what it writes, type included" $+    let v = object ["n" .= (1 :: Int), "x" .= (1 :: Double), "big" .= JInt 9223372036854775807, "xs" .= [JFloat 1.0e21, JFloat 1.0e-7]]+     in jsonParser (stringify v) `shouldBe` Right v++normalizeSpec :: Spec+normalizeSpec = describe "normalizeNumbers" $ do+  it "keeps the two ends of the signed 64-bit range" $ do+    normalizeNumbers (JInt 9223372036854775807) `shouldBe` Right (JInt 9223372036854775807)+    normalizeNumbers (JInt (-9223372036854775808)) `shouldBe` Right (JInt (-9223372036854775808))++  it "refuses an integer one past either end, at any depth" $ do+    normalizeNumbers (JInt 9223372036854775808) `shouldSatisfy` isLeft+    normalizeNumbers (JInt (-9223372036854775809)) `shouldSatisfy` isLeft+    normalizeNumbers (object ["a" .= [object ["b" .= JInt 9223372036854775808]]]) `shouldSatisfy` isLeft++  it "refuses a float that is not finite" $ do+    normalizeNumbers (JFloat (1 / 0)) `shouldSatisfy` isLeft+    normalizeNumbers (JArray [JFloat (-1 / 0)]) `shouldSatisfy` isLeft++  it "turns a negative zero into zero" $+    fmap stringify (normalizeNumbers (JFloat (-0.0))) `shouldBe` Right "0.0"++  it "leaves a float beyond the integer range alone: it is a float by its text" $+    normalizeNumbers (JFloat 1.0e19) `shouldBe` Right (JFloat 1.0e19)++bridgeSpec :: Spec+bridgeSpec = describe "the aeson bridge" $ do+  it "reads a whole number in the integer range as an integer, any other as a float" $ do+    fromAeson (Aeson.Number 3) `shouldBe` JInt 3+    fromAeson (Aeson.Number 3.0) `shouldBe` JInt 3+    fromAeson (Aeson.Number 1.5) `shouldBe` JFloat 1.5+    fromAeson (Aeson.Number 9223372036854775807) `shouldBe` JInt 9223372036854775807+    fromAeson (Aeson.Number 9223372036854775808) `shouldBe` JFloat 9.223372036854775808e18++  it "gives the number type up on the way out" $ do+    toAeson (JInt 1) `shouldBe` Aeson.Number 1+    toAeson (JFloat 1) `shouldBe` Aeson.Number 1+    toAeson (object ["a" .= [JFloat 1.5]]) `shouldBe` Aeson.object ["a" Aeson..= [1.5 :: Double]]++literalSpec :: Spec+literalSpec = describe "number literals" $ do+  it "types a literal by its form" $ do+    parseExpr "1" `shouldBe` Right (IntLit 1)+    parseExpr "-7" `shouldBe` Right (IntLit (-7))+    parseExpr "1_000_000" `shouldBe` Right (IntLit 1000000)+    parseExpr "1.0" `shouldBe` Right (FloatLit 1)+    parseExpr "1e5" `shouldBe` Right (FloatLit 100000)+    parseExpr "-1.5" `shouldBe` Right (FloatLit (-1.5))++  it "accepts both ends of the signed 64-bit range" $ do+    parseExpr "9223372036854775807" `shouldBe` Right (IntLit maxBound)+    parseExpr "-9223372036854775808" `shouldBe` Right (IntLit minBound)++  it "refuses an integer literal one past either end" $ do+    parseExpr "9223372036854775808" `shouldSatisfy` isLeft+    parseExpr "-9223372036854775809" `shouldSatisfy` isLeft++  it "has no negative zero" $ do+    parseExpr "-0" `shouldBe` Right (IntLit 0)+    fmap show (parseExpr "-0.0") `shouldBe` Right (show (FloatLit 0))++contextSpec :: Spec+contextSpec = describe "the context boundary" $ do+  let run :: Mode -> Text -> Text -> Either String Text+      run mode src ctxText = do+        ctx <- jsonParser ctxText+        prog <- either (Left . show) Right (parseProgram src)+        either (Left . show) (Right . stringify) (runProgram mode Map.empty ctx prog)+      mismatch :: Mode -> Text -> Text -> Expectation+      mismatch mode src ctxText = do+        ctx <- either (\e -> fail ("fixture is not JSON: " <> e)) pure (jsonParser ctxText)+        prog <- either (\e -> fail ("fixture does not parse: " <> show e)) pure (parseProgram src)+        runProgram mode Map.empty ctx prog `shouldSatisfy` \case+          Left (TypeMismatch _) -> True+          _ -> False++  it "keeps the type each number was written with, through to the output" $+    run Concrete "[$ctx.a, $ctx.b, $ctx.c]" "{\"a\": 2, \"b\": 2.0, \"c\": 2e0}" `shouldBe` Right "[2,2.0,2.0]"++  it "holds a 64-bit integer exactly" $+    run Concrete "[$ctx.top, $ctx.bottom, str($ctx.top)]" "{\"top\": 9223372036854775807, \"bottom\": -9223372036854775808}"+      `shouldBe` Right "[9223372036854775807,-9223372036854775808,\"9223372036854775807\"]"++  it "refuses an integer outside the range, in both modes, read or not" $ do+    mismatch Concrete "$ctx.n" "{\"n\": 9223372036854775808}"+    mismatch Symbolic "$ctx.n" "{\"n\": -9223372036854775809}"+    mismatch Concrete "1" "{\"deep\": [{\"n\": 9223372036854775808}]}"++  it "refuses a float too large for a double, read or not" $ do+    mismatch Concrete "$ctx.x" "{\"x\": 1e400}"+    mismatch Symbolic "1" "{\"x\": -1e400}"++  it "reads a negative zero as the zero of its type" $+    run Concrete "[$ctx.a, $ctx.b, $ctx.c]" "{\"a\": -0, \"b\": -0.0, \"c\": -1e-400}" `shouldBe` Right "[0,0.0,0.0]"++  it "does not equate or compare an integer with a float" $ do+    run Concrete "[eq($ctx.a, $ctx.b), eq($ctx.a, 2), eq($ctx.b, 2.0)]" "{\"a\": 2, \"b\": 2.0}" `shouldBe` Right "[false,true,true]"+    mismatch Concrete "lt($ctx.a, $ctx.b)" "{\"a\": 2, \"b\": 2.5}"++  it "takes an integer as an index, never a float" $+    run Concrete "[lookup($ctx.xs, 1, \"d\"), lookup($ctx.xs, 1.0, \"d\"), has($ctx.xs, 1), has($ctx.xs, 1.0)]" "{\"xs\": [10, 20]}"+      `shouldBe` Right "[20,\"d\",true,false]"++  it "writes a float allocation key with its fraction in the symbol id" $+    run Symbolic "[?(1), ?(1.0)]" "null"+      `shouldSatisfy` either (const False) (\out -> "\"id\":\"#0:1\"" `T.isInfixOf` out && "\"id\":\"#1:1.0\"" `T.isInfixOf` out)++nodeSpec :: Spec+nodeSpec = describe "numbers in a Node" $ do+  let text v = object ["type" .= ("text" :: Text), "value" .= v, "annotations" .= object []]++  it "encodes an integer and a float as two different nodes" $ do+    stringify (nodeToJson (NText (JInt 3) noAnnotations)) `shouldBe` "{\"annotations\":{},\"type\":\"text\",\"value\":3}"+    stringify (nodeToJson (NText (JFloat 3) noAnnotations)) `shouldBe` "{\"annotations\":{},\"type\":\"text\",\"value\":3.0}"++  it "decodes each back as what it was" $ do+    (jsonParser "{\"type\":\"text\",\"value\":3,\"annotations\":{}}" >>= nodeFromJson) `shouldBe` Right (NText (JInt 3) noAnnotations)+    (jsonParser "{\"type\":\"text\",\"value\":3e0,\"annotations\":{}}" >>= nodeFromJson) `shouldBe` Right (NText (JFloat 3) noAnnotations)++  it "refuses an integer outside the range in a value, at any depth" $ do+    nodeFromJson (text (JInt 9223372036854775808)) `shouldSatisfy` isLeft+    nodeFromJson (text (object ["a" .= [JInt (-9223372036854775809)]])) `shouldSatisfy` isLeft++  it "keeps a 64-bit integer through a round trip" $+    let n = NElement "n" [NAttr "top" (JInt 9223372036854775807)] (JInt (-9223372036854775808)) [] noAnnotations+     in (jsonParser (stringify (nodeToJson n)) >>= nodeFromJson) `shouldBe` Right n
test/unit/Tramaj/NodeJsonSpec.hs view
@@ -6,10 +6,12 @@ -- values of every JSON type. module Tramaj.NodeJsonSpec (spec) where -import Data.Aeson (ToJSON, Value (..), object, (.=)) import qualified Data.Map.Strict as Map+import Data.Text (Text) import Test.Hspec+import Tramaj.Json (Json (..)) import Tramaj.Node+import Tramaj.TestJson (ToJ, object, (.=))  spec :: Spec spec = describe "Tramaj.Node JSON representation" $ do@@ -21,39 +23,39 @@ -- | Every sample below must survive @nodeFromJson . nodeToJson@ unchanged. samples :: [(String, Node)] samples =-  [ ("text holding a string", NText (String "hello") noAnnotations)-  , ("text holding a number", NText (Number 3) noAnnotations)-  , ("text holding null", NText Null noAnnotations)-  , ("text holding a boolean", NText (Bool True) noAnnotations)+  [ ("text holding a string", NText (JString "hello") noAnnotations)+  , ("text holding a number", NText (JInt 3) noAnnotations)+  , ("text holding null", NText JNull noAnnotations)+  , ("text holding a boolean", NText (JBool True) noAnnotations)   , ("text holding an object", NText (object ["a" .= (1 :: Int)]) noAnnotations)-  , ("empty element", NElement "div" [] Null [] noAnnotations)-  , ("element with an ordinary attribute", NElement "div" [NAttr "class" (String "panel")] Null [] noAnnotations)+  , ("empty element", NElement "div" [] JNull [] noAnnotations)+  , ("element with an ordinary attribute", NElement "div" [NAttr "class" (JString "panel")] JNull [] noAnnotations)   ,     ( "element with several actions and attributes interleaved"     , NElement         "button"-        [ NAttr "class" (String "primary")+        [ NAttr "class" (JString "primary")         , NAction "on-click" "save" (object ["id" .= (1 :: Int)])-        , NAttr "data-n" (Number 2)-        , NAction "on-double-click" "open" Null+        , NAttr "data-n" (JInt 2)+        , NAction "on-double-click" "open" JNull         ]-        Null-        [NText (String "Save") noAnnotations]+        JNull+        [NText (JString "Save") noAnnotations]         noAnnotations     )-  , ("element with a value slot", NElement "Replicas" [] (Number 3) [] noAnnotations)-  , ("fragment", NFragment [NText (String "one") noAnnotations, NText (String "two") noAnnotations] noAnnotations)+  , ("element with a value slot", NElement "Replicas" [] (JInt 3) [] noAnnotations)+  , ("fragment", NFragment [NText (JString "one") noAnnotations, NText (JString "two") noAnnotations] noAnnotations)   , ("empty fragment", NFragment [] noAnnotations)   ,     ( "annotations at every level, including ones the core does not understand"     , NElement         "section"-        [NAttr "id" (String "x")]-        Null-        [NFragment [NText (String "deep") (Map.fromList [("origin", String "lib")])] (Map.fromList [("flattenable", Bool True)])]-        (Map.fromList [("type", String "Section"), ("domain", object ["min" .= (1 :: Int)])])+        [NAttr "id" (JString "x")]+        JNull+        [NFragment [NText (JString "deep") (Map.fromList [("origin", JString "lib")])] (Map.fromList [("flattenable", JBool True)])]+        (Map.fromList [("type", JString "Section"), ("domain", object ["min" .= (1 :: Int)])])     )-  , ("repeated attribute names are preserved, not deduplicated", NElement "div" [NAttr "class" (String "a"), NAttr "class" (String "b")] Null [] noAnnotations)+  , ("repeated attribute names are preserved, not deduplicated", NElement "div" [NAttr "class" (JString "a"), NAttr "class" (JString "b")] JNull [] noAnnotations)   ]  roundTripSpec :: Spec@@ -66,41 +68,41 @@ shapeSpec :: Spec shapeSpec = describe "encodes the shape specs/node-json.md documents" $ do   it "a text node carries its value unconverted, plus annotations" $-    nodeToJson (NText (Number 3) noAnnotations)-      `shouldBe` object ["type" .= ("text" :: String), "value" .= (3 :: Int), "annotations" .= object []]+    nodeToJson (NText (JInt 3) noAnnotations)+      `shouldBe` object ["type" .= ("text" :: Text), "value" .= (3 :: Int), "annotations" .= object []]    it "an element always emits tag, attributes, value, children and annotations" $-    nodeToJson (NElement "p" [] Null [] noAnnotations)+    nodeToJson (NElement "p" [] JNull [] noAnnotations)       `shouldBe` object-        [ "type" .= ("element" :: String)-        , "tag" .= ("p" :: String)-        , "attributes" .= ([] :: [Value])-        , "value" .= Null-        , "children" .= ([] :: [Value])+        [ "type" .= ("element" :: Text)+        , "tag" .= ("p" :: Text)+        , "attributes" .= ([] :: [Json])+        , "value" .= JNull+        , "children" .= ([] :: [Json])         , "annotations" .= object []         ]    it "attributes and actions are discriminated by \"kind\" in one ordered list" $-    nodeToJson (NElement "b" [NAttr "class" (String "c"), NAction "on-click" "save" Null] Null [] noAnnotations)+    nodeToJson (NElement "b" [NAttr "class" (JString "c"), NAction "on-click" "save" JNull] JNull [] noAnnotations)       `shouldBe` object-        [ "type" .= ("element" :: String)-        , "tag" .= ("b" :: String)+        [ "type" .= ("element" :: Text)+        , "tag" .= ("b" :: Text)         , "attributes"-            .= [ object ["kind" .= ("attribute" :: String), "name" .= ("class" :: String), "value" .= ("c" :: String)]-               , object ["kind" .= ("action" :: String), "event" .= ("on-click" :: String), "key" .= ("save" :: String), "payload" .= Null]+            .= [ object ["kind" .= ("attribute" :: Text), "name" .= ("class" :: Text), "value" .= ("c" :: Text)]+               , object ["kind" .= ("action" :: Text), "event" .= ("on-click" :: Text), "key" .= ("save" :: Text), "payload" .= JNull]                ]-        , "value" .= Null-        , "children" .= ([] :: [Value])+        , "value" .= JNull+        , "children" .= ([] :: [Json])         , "annotations" .= object []         ]    it "a fragment emits no tag, attributes or value" $     nodeToJson (NFragment [] noAnnotations)-      `shouldBe` object ["type" .= ("fragment" :: String), "children" .= ([] :: [Value]), "annotations" .= object []]+      `shouldBe` object ["type" .= ("fragment" :: Text), "children" .= ([] :: [Json]), "annotations" .= object []]  -- | @specs/node-json.md@, "Decoding": a decoder must reject rather than -- default, so that a malformed document is reported where it is read.--- Malformed documents are built as 'Value's directly rather than parsed from+-- Malformed documents are built as 'Json's directly rather than parsed from -- JSON text, so each one differs from a well-formed node in exactly the one -- way its name describes. rejectionSpec :: Spec@@ -110,54 +112,54 @@   rejects "a node with no type" $     object ["value" .= (1 :: Int), "annotations" .= object []]   rejects "an unknown node type" $-    object ["type" .= ("comment" :: String), "annotations" .= object []]+    object ["type" .= ("comment" :: Text), "annotations" .= object []]   rejects "a text node with no value" $-    object ["type" .= ("text" :: String), "annotations" .= object []]+    object ["type" .= ("text" :: Text), "annotations" .= object []]   rejects "a node with no annotations" $-    object ["type" .= ("text" :: String), "value" .= (1 :: Int)]+    object ["type" .= ("text" :: Text), "value" .= (1 :: Int)]   rejects "an element with no value slot" $     object-      [ "type" .= ("element" :: String)-      , "tag" .= ("p" :: String)-      , "attributes" .= ([] :: [Value])-      , "children" .= ([] :: [Value])+      [ "type" .= ("element" :: Text)+      , "tag" .= ("p" :: Text)+      , "attributes" .= ([] :: [Json])+      , "children" .= ([] :: [Json])       , "annotations" .= object []       ]   rejects "an element with a non-string tag" $-    element (Number 1) ([] :: [Value]) ([] :: [Value])+    element (JInt 1) ([] :: [Json]) ([] :: [Json])   rejects "an element whose children are not an array" $-    element (String "p") ([] :: [Value]) (object [])+    element (JString "p") ([] :: [Json]) (object [])   rejects "an element whose attributes are not an array" $-    element (String "p") (object []) ([] :: [Value])+    element (JString "p") (object []) ([] :: [Json])   rejects "annotations that are not an object" $-    object ["type" .= ("fragment" :: String), "children" .= ([] :: [Value]), "annotations" .= ([] :: [Value])]+    object ["type" .= ("fragment" :: Text), "children" .= ([] :: [Json]), "annotations" .= ([] :: [Json])]   rejects "an attribute with no kind" $-    element (String "p") [object ["name" .= ("a" :: String), "value" .= (1 :: Int)]] ([] :: [Value])+    element (JString "p") [object ["name" .= ("a" :: Text), "value" .= (1 :: Int)]] ([] :: [Json])   rejects "an unknown attribute kind" $-    element (String "p") [object ["kind" .= ("listener" :: String)]] ([] :: [Value])+    element (JString "p") [object ["kind" .= ("listener" :: Text)]] ([] :: [Json])   rejects "an action with no key" $-    element (String "p") [object ["kind" .= ("action" :: String), "event" .= ("e" :: String), "payload" .= Null]] ([] :: [Value])+    element (JString "p") [object ["kind" .= ("action" :: Text), "event" .= ("e" :: Text), "payload" .= JNull]] ([] :: [Json])   rejects "an attribute with a non-string name" $-    element (String "p") [object ["kind" .= ("attribute" :: String), "name" .= (1 :: Int), "value" .= Null]] ([] :: [Value])+    element (JString "p") [object ["kind" .= ("attribute" :: Text), "name" .= (1 :: Int), "value" .= JNull]] ([] :: [Json])   rejects "a malformed node nested deep in a child" $     object-      [ "type" .= ("fragment" :: String)-      , "children" .= [object ["type" .= ("text" :: String)]]+      [ "type" .= ("fragment" :: Text)+      , "children" .= [object ["type" .= ("text" :: Text)]]       , "annotations" .= object []       ]-  rejects "a bare scalar where a node was expected" $ Number 42+  rejects "a bare scalar where a node was expected" $ JInt 42   rejects "an attribute that is not an object" $-    element (String "p") [String "class"] ([] :: [Value])+    element (JString "p") [JString "class"] ([] :: [Json])   where     -- | A well-formed element apart from whichever of its three variable     -- parts the caller deliberately breaks.-    element :: (ToJSON a, ToJSON b) => Value -> a -> b -> Value+    element :: (ToJ a, ToJ b) => Json -> a -> b -> Json     element tag attrs children =       object-        [ "type" .= ("element" :: String)+        [ "type" .= ("element" :: Text)         , "tag" .= tag         , "attributes" .= attrs-        , "value" .= Null+        , "value" .= JNull         , "children" .= children         , "annotations" .= object []         ]@@ -175,25 +177,25 @@       ( NElement           "div"           []-          Null-          [NFragment [NElement "b" [NAction "on-click" "save" Null] Null [] noAnnotations] noAnnotations]+          JNull+          [NFragment [NElement "b" [NAction "on-click" "save" JNull] JNull [] noAnnotations] noAnnotations]           noAnnotations       )       `shouldBe` Right         ( NElement             "div"             []-            Null-            [NFragment [NElement "b" [NAction "on-click" "ns:save" Null] Null [] noAnnotations] noAnnotations]+            JNull+            [NFragment [NElement "b" [NAction "on-click" "ns:save" JNull] JNull [] noAnnotations] noAnnotations]             noAnnotations         )    it "leaves ordinary attributes, value slots and annotations untouched" $-    let anns = Map.fromList [("origin", String "lib")]-        n = NElement "div" [NAttr "class" (String "c"), NAction "on-click" "save" Null] (Number 1) [] anns+    let anns = Map.fromList [("origin", JString "lib")]+        n = NElement "div" [NAttr "class" (JString "c"), NAction "on-click" "save" JNull] (JInt 1) [] anns      in prefix "ns:" n-          `shouldBe` Right (NElement "div" [NAttr "class" (String "c"), NAction "on-click" "ns:save" Null] (Number 1) [] anns)+          `shouldBe` Right (NElement "div" [NAttr "class" (JString "c"), NAction "on-click" "ns:save" JNull] (JInt 1) [] anns)    it "propagates a failing rewrite instead of dropping it" $-    mapActions (\_ _ _ -> Left "boom") (NElement "b" [NAction "on-click" "save" Null] Null [] noAnnotations)+    mapActions (\_ _ _ -> Left "boom") (NElement "b" [NAction "on-click" "save" JNull] JNull [] noAnnotations)       `shouldBe` (Left "boom" :: Either String Node)
test/unit/Tramaj/ParserSpec.hs view
@@ -80,6 +80,16 @@   -- meaningless Call that only fails much later.   rejects "a malformed import" "import(\"lib\")"   rejects "a malformed map" "map($xs)"+  -- reference.md \S5: a sort takes exactly two arguments, under both names+  -- and both spellings.+  rejects "a sort with one argument" "sort-by($xs)"+  rejects "a sort with three arguments" "sort-by($xs, (x) => $x, true)"+  rejects "a sort with no argument" "sort-by()"+  rejects "a descending sort with one argument" "sort-by-descending($xs)"+  rejects "a descending sort with three arguments" "sort-by-descending($xs, (x) => $x, true)"+  rejects "a dollar-spelled sort with one argument" "$sort-by($xs)"+  -- Being recognized by the parser, the name cannot be passed by reference.+  rejects "a sort passed by reference" "map($xs, $sort-by)"   rejects "import parameters that are not a parameter list" "import(\"lib\", $ctx)"    rejects "an unknown escape sequence" "\"a\\qb\""@@ -169,15 +179,31 @@    parsesTo "an empty string" "\"\"" (StringLit "") +  -- reference.md \S2, \S5: the two sort names lower to the one constructor.   parsesTo+    "sort-by into SortBy, ascending"+    "sort-by($xs, (x) => $x.k)"+    (SortBy False (Path "xs" []) (Lambda ["x"] (Path "x" ["k"])))++  parsesTo+    "sort-by-descending into the same constructor, descending"+    "sort-by-descending($xs, $key-of)"+    (SortBy True (Path "xs" []) (Path "key-of" []))++  parsesTo+    "the dollar spelling of a sort into the same form"+    "$sort-by($xs, $str)"+    (SortBy False (Path "xs" []) (Path "str" []))++  parsesTo     "object shorthand into an explicit field reading the same name"     "{foo, bar: 1}"-    (ObjectLit [("foo", Path "foo" []), ("bar", NumberLit 1)])+    (ObjectLit [("foo", Path "foo" []), ("bar", IntLit 1)])    parsesTo     "a multi-armed branch into nested Branch, fallback innermost"     "branch(0, $a, 1, $b, 2)"-    (Branch (Path "a" []) (NumberLit 1) (Branch (Path "b" []) (NumberLit 2) (NumberLit 0)))+    (Branch (Path "a" []) (IntLit 1) (Branch (Path "b" []) (IntLit 2) (IntLit 0)))    parsesTo     "concat as a left-associative chain"@@ -194,8 +220,8 @@     ".div(class: \"a\", action(\"on-click\", \"save\", 1), value(2), \"kid\")"     ( Element         "div"-        [Attr "class" (StringLit "a"), ActionAttr "on-click" "save" (NumberLit 1)]-        (NumberLit 2)+        [Attr "class" (StringLit "a"), ActionAttr "on-click" "save" (IntLit 1)]+        (IntLit 2)         [StringLit "kid"]     ) @@ -224,7 +250,7 @@   parsesTo     "a field access on a call's result into FieldAccess"     "$f(1).rendered"-    (FieldAccess (Call (Path "f" []) [NumberLit 1]) ["rendered"])+    (FieldAccess (Call (Path "f" []) [IntLit 1]) ["rendered"])    parsesTo "the $ prefix on a call as optional" "cardinality($x)" (Call (Path "cardinality" []) [Path "x" []])   parsesTo "the $ prefix on a call as meaning the same thing" "$cardinality($x)" (Call (Path "cardinality" []) [Path "x" []])@@ -233,7 +259,7 @@     parseProgram "@a=1\n@b=$a\n.p($b)"       `shouldBe` Right         ( DocumentProgram-            (Let "a" (NumberLit 1) (Let "b" (Path "a" []) (Element "p" [] NullLit [Path "b" []])))+            (Let "a" (IntLit 1) (Let "b" (Path "a" []) (Element "p" [] NullLit [Path "b" []])))         )  -- | Which kind of program it is follows from the root's own form; there is no@@ -264,16 +290,16 @@    declares     "a record declaration"-    "type Point = { x : number, y : number }\ntrue"-    (ExpressionProgram (TypeDecl "Point" (TRecord [("x", TPrim "number"), ("y", TPrim "number")]) (BoolLit True)))+    "type Point = { x : float, y : float }\ntrue"+    (ExpressionProgram (TypeDecl "Point" (TRecord [("x", TPrim "float"), ("y", TPrim "float")]) (BoolLit True)))    declares     "a union declaration with payload-carrying and nullary arms"-    "type Shape = | Circle { r : number } | Dev\ntrue"+    "type Shape = | Circle { r : float } | Dev\ntrue"     ( ExpressionProgram         ( TypeDecl             "Shape"-            (TUnion [("Circle", Just (TRecord [("r", TPrim "number")])), ("Dev", Nothing)])+            (TUnion [("Circle", Just (TRecord [("r", TPrim "float")])), ("Dev", Nothing)])             (BoolLit True)         )     )@@ -320,8 +346,8 @@         ( DocumentProgram             ( Let                 "a"-                (NumberLit 1)-                (TypeDecl "T" (TPrim "string") (Let "b" (NumberLit 2) (Element "p" [] NullLit [StringLit "x"])))+                (IntLit 1)+                (TypeDecl "T" (TPrim "string") (Let "b" (IntLit 2) (Element "p" [] NullLit [StringLit "x"])))             )         ) @@ -350,7 +376,7 @@   declares     "an annotation naming a library-qualified type"     "@m : $msg.types.Envelope = 1\ntrue"-    (ExpressionProgram (TypeAnnotate "m" (TLibRef "msg" "Envelope") (NumberLit 1) (BoolLit True)))+    (ExpressionProgram (TypeAnnotate "m" (TLibRef "msg" "Envelope") (IntLit 1) (BoolLit True)))    -- roadmap Phase 12: !type-constraint.   declares@@ -372,4 +398,4 @@    it "does not confuse !type-constraint with an ordinary !expr emission" $     parseProgram "!constraint(\"k\", 1)\ntrue"-      `shouldBe` Right (ExpressionProgram (Emit (Constrain "k" [NumberLit 1]) (BoolLit True)))+      `shouldBe` Right (ExpressionProgram (Emit (Constrain "k" [IntLit 1]) (BoolLit True)))
+ test/unit/Tramaj/TestJson.hs view
@@ -0,0 +1,41 @@+-- | Builders for 'Json' fixtures, so a spec can spell an object as a list of+-- @key .= value@ pairs. A Haskell 'Int' becomes an integer and a 'Double' a+-- float, which is the host binding these specs use throughout.+module Tramaj.TestJson+  ( ToJ (..)+  , object+  , (.=)+  ) where++import qualified Data.Map.Strict as Map+import Data.Text (Text)+import Tramaj.Json (Json (..))++class ToJ a where+  toJ :: a -> Json++instance ToJ Json where+  toJ = id++instance ToJ Int where+  toJ = JInt . toInteger++instance ToJ Double where+  toJ = JFloat++instance ToJ Bool where+  toJ = JBool++instance ToJ Text where+  toJ = JString++instance (ToJ a) => ToJ [a] where+  toJ = JArray . map toJ++object :: [(Text, Json)] -> Json+object = JObject . Map.fromList++infixr 8 .=++(.=) :: (ToJ a) => Text -> a -> (Text, Json)+k .= v = (k, toJ v)
test/unit/Tramaj/TypesSpec.hs view
@@ -48,12 +48,12 @@    resolvesTo     Map.empty-    (prog "type Inner = { x : number }\ntype Outer = { items : [ Inner ] }\ntrue")+    (prog "type Inner = { x : int }\ntype Outer = { items : [ Inner ] }\ntrue")     "Outer"     "{items:[root:Inner]}"    resolvesTo-    (Map.fromList [("message", prog "type Envelope = { to : string, id : number }\ntrue")])+    (Map.fromList [("message", prog "type Envelope = { to : string, id : int }\ntrue")])     (prog "@msg=import(\"message\", {})\ntype UsesEnvelope = { env : $msg.types.Envelope }\ntrue")     "UsesEnvelope"     "{env:\"message\":Envelope}"@@ -63,9 +63,9 @@   -- author happened to write things in.   resolvesTo     Map.empty-    (prog "type Shape = | Square { s : number } | Circle { r : number } | Dev\ntrue")+    (prog "type Shape = | Square { s : float } | Circle { r : float } | Dev\ntrue")     "Shape"-    "|Circle {r:number}|Dev|Square {s:number}"+    "|Circle {r:float}|Dev|Square {s:float}"    -- A root declaration and a same-named library declaration must render to   -- different strings -- the whole point of \"root\" being a token no
tramaj-hs.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               tramaj-hs-version:            0.3.0.0+version:            0.4.0.0 synopsis:           Haskell parser/AST/evaluator for the tramaj language description:   Parser, AST and evaluator for the tramaj language specified in@@ -46,12 +46,13 @@     Tramaj.Analysis     Tramaj.Ast     Tramaj.Eval+    Tramaj.Json     Tramaj.Node     Tramaj.Parser     Tramaj.Types   build-depends:     , base            >=4.19  && <4.22-    , aeson           >=2.1   && <2.4+    , aeson           >=2.2.1 && <2.4     , containers      >=0.6   && <0.9     , megaparsec      >=9.5   && <9.9     , scientific      >=0.3   && <0.4@@ -67,8 +68,10 @@     Tramaj.AnalysisSpec     Tramaj.CorpusSpec     Tramaj.EvalSpec+    Tramaj.JsonSpec     Tramaj.NodeJsonSpec     Tramaj.ParserSpec+    Tramaj.TestJson     Tramaj.TypesSpec   build-depends:     , base