packages feed

phino 0.0.113 → 0.0.114

raw patch · 44 files changed

+5753/−1134 lines, 44 filesPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

API changes (from Hackage documentation)

+ CLI.Helpers: printFocused :: PrintContext -> Expression -> IO String
+ CLI.Parsers: optPartial :: Parser Bool
+ CLI.Types: [_partial] :: OptsDataize -> Bool
+ Dataize: Dataized :: Bytes -> Outcome
+ Dataize: OutOfSteps :: Int -> DataizeException
+ Dataize: Residual :: Expression -> Outcome
+ Dataize: Stuck :: Text -> DataizeException
+ Dataize: StuckAt :: Text -> NonEmpty Rewritten -> DataizeException
+ Dataize: [_partial] :: DataizeContext -> Bool
+ Dataize: data DataizeException
+ Dataize: data Outcome
+ Dataize: instance GHC.Classes.Eq Dataize.Outcome
+ Dataize: instance GHC.Show.Show Dataize.Outcome
- CLI.Types: OptsDataize :: LogLevel -> Int -> IOFormat -> IOFormat -> SugarType -> Bool -> LineFormat -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Int -> Bool -> Bool -> Int -> Int -> Int -> Int -> Maybe Int -> Maybe Int -> [String] -> [String] -> String -> String -> Maybe String -> Maybe String -> Maybe String -> Maybe FilePath -> Maybe FilePath -> Maybe FilePath -> OptsDataize
+ CLI.Types: OptsDataize :: LogLevel -> Int -> IOFormat -> IOFormat -> SugarType -> Bool -> LineFormat -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Int -> Bool -> Bool -> Bool -> Int -> Int -> Int -> Int -> Maybe Int -> Maybe Int -> [String] -> [String] -> String -> String -> Maybe String -> Maybe String -> Maybe String -> Maybe FilePath -> Maybe FilePath -> Maybe FilePath -> OptsDataize
- Dataize: DataizeContext :: Expression -> Int -> Int -> Steps -> Bool -> Bool -> BuildTermFunc -> SaveStepFunc -> SaveEvalFunc -> DataizeContext
+ Dataize: DataizeContext :: Expression -> Int -> Int -> Steps -> Bool -> Bool -> Bool -> BuildTermFunc -> SaveStepFunc -> SaveEvalFunc -> DataizeContext
- Dataize: dataize :: Expression -> DataizeContext -> IO Dataized
+ Dataize: dataize :: Expression -> DataizeContext -> IO (Outcome, [Rewritten])
- Deps: Evaluation :: Text -> Expression -> Expression -> Evaluation
+ Deps: Evaluation :: Text -> Expression -> Maybe Expression -> Evaluation
- Deps: [_result] :: Evaluation -> Expression
+ Deps: [_result] :: Evaluation -> Maybe Expression

Files

README.md view
@@ -34,7 +34,7 @@  ```bash cabal update-cabal install --overwrite-policy=always phino-0.0.112+cabal install --overwrite-policy=always phino-0.0.113 phino --version ``` @@ -122,6 +122,50 @@ beginning of every run, and `--output=phi` is the only output format it works with, since one record must fit into one line. +An atom that cannot fire fails the run: its λ function is unknown to phino,+or one of its inputs reaches such an atom. This is what happens when a+data input is replaced on purpose by a placeholder formation, such as+`⟦ λ ⤍ Sym_arg_0 ⟧`. With `--partial`, dataization becomes partial+evaluation instead: what the known inputs decide is computed, the rest+survives as the residual program, which is printed in place of the bytes,+and the run ends successfully:++```bash+$ cat partial.phi+⟦+  bytes(data) ↦ ⟦ φ ↦ data ⟧,+  number(as-bytes) ↦ ⟦+    φ ↦ as-bytes,+    plus(x) ↦ ⟦ λ ⤍ L_number_plus ⟧,+    times(x) ↦ ⟦ λ ⤍ L_number_times ⟧+  ⟧,+  φ ↦ 2.times(3).plus(⟦ λ ⤍ Sym_arg_0 ⟧)+⟧+$ phino dataize --partial --sweet --hide-rho partial.phi+⟦ x ↦ ⟦ λ ⤍ Sym_arg_0 ⟧, λ ⤍ L_number_plus ⟧+```++Here `2.times(3)` was decided by literals, so it was computed (its result,+`6`, sits in the hidden `ρ` of the residual program), while `plus` waits+for an `x` no atom can produce, so it stays in place as a normal-form+subterm. Each such stuck site also lands in the `--evaluations` file, as a+record with the first two fields only, since there is no result to report;+the inner stuck atom comes first, then the known atom whose input reached+it:++```bash+$ phino dataize --partial --evaluations=atoms.tsv --quiet \+    --sweet --hide-rho partial.phi+$ cat -T atoms.tsv+L_number_times^I⟦ x ↦ 3 ⟧^I6+Sym_arg_0^I⟦⟧+L_number_plus^I⟦ x ↦ ⟦ λ ⤍ Sym_arg_0 ⟧ ⟧+```++Evaluation stays demand-driven, as the calculus prescribes: an argument+that nothing asked for before the run got stuck is left as it is in the+residual program, for the next iteration.+ ## Rewrite  You can rewrite this expression with the help of [rules](#rule-structure)@@ -476,55 +520,55 @@ === parse/phi ===   warmup:     3 iterations   batches:    10 x 1-  total:      1548340.503 μs-  avg:        154834.050 μs-  min:        143956.756 μs-  max:        183563.065 μs-  std dev:    16085.516 μs+  total:      1843412.235 μs+  avg:        184341.224 μs+  min:        170612.548 μs+  max:        215203.225 μs+  std dev:    16818.121 μs === parse/xmir ===   warmup:     3 iterations   batches:    10 x 1-  total:      7516206.769 μs-  avg:        751620.677 μs-  min:        682874.922 μs-  max:        842579.811 μs-  std dev:    46693.965 μs+  total:      7584564.832 μs+  avg:        758456.483 μs+  min:        674321.184 μs+  max:        885832.048 μs+  std dev:    58868.246 μs === rewrite/normalize ===   warmup:     3 iterations   batches:    10 x 1-  total:      520198.824 μs-  avg:        52019.882 μs-  min:        51367.239 μs-  max:        52636.252 μs-  std dev:    404.175 μs+  total:      530792.056 μs+  avg:        53079.206 μs+  min:        52059.402 μs+  max:        55045.049 μs+  std dev:    788.817 μs === print/sweet/multiline ===   warmup:     3 iterations   batches:    10 x 1-  total:      4326076.818 μs-  avg:        432607.682 μs-  min:        410101.006 μs-  max:        471442.572 μs-  std dev:    17970.166 μs+  total:      3859233.707 μs+  avg:        385923.371 μs+  min:        366896.885 μs+  max:        399481.094 μs+  std dev:    8247.212 μs === print/sweet/flat ===   warmup:     3 iterations   batches:    10 x 1-  total:      4306991.829 μs-  avg:        430699.183 μs-  min:        407690.710 μs-  max:        450521.293 μs-  std dev:    14268.817 μs+  total:      3805981.640 μs+  avg:        380598.164 μs+  min:        363776.755 μs+  max:        397781.223 μs+  std dev:    8200.622 μs === print/salty/multiline ===   warmup:     3 iterations   batches:    10 x 1-  total:      13770643.090 μs-  avg:        1377064.309 μs-  min:        1349459.112 μs-  max:        1422182.256 μs-  std dev:    22169.348 μs+  total:      14154416.526 μs+  avg:        1415441.653 μs+  min:        1380466.817 μs+  max:        1440553.138 μs+  std dev:    15889.438 μs ```  The results were calculated in [this GHA job][benchmark-gha]-on 2026-09-01 at 07:48,+on 2026-09-02 at 14:36, on Linux with 4 CPUs.  <!-- benchmark_end -->@@ -573,4 +617,4 @@ [jna]: https://github.com/java-native-access/jna [jna-native]: https://github.com/java-native-access/jna/blob/master/src/com/sun/jna/Native.java [jeo]: https://github.com/objectionary/jeo-maven-plugin-[benchmark-gha]: https://github.com/objectionary/phino/actions/runs/33483476688+[benchmark-gha]: https://github.com/objectionary/phino/actions/runs/33642610207
phino.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: phino-version: 0.0.113+version: 0.0.114 license: MIT synopsis: Command-Line Manipulator of 𝜑-Calculus Expressions description: Please see the README on GitHub at <https://github.com/objectionary/phino#readme>@@ -125,14 +125,24 @@   other-modules:     ASTSpec     BuilderSpec+    BytesSpec+    CanonizerSpec+    CLIHelpersSpec     CLISpec+    CLITypesSpec     ConditionSpec     CSTSpec     DataizeSpec+    DepsSpec+    EncodingSpec+    FilesSpec     FilterSpec     FunctionsSpec     LaTeXSpec+    LiningSpec     LocatorSpec+    LoggerSpec+    MarginSpec     MatcherSpec     MergeSpec     MiscSpec@@ -142,10 +152,12 @@     PrinterSpec     RandomSpec     RegexpSpec+    RenderSpec     ReplacerSpec     RewriterSpec     RuleSpec     Spec+    SugarSpec     TauSpec     XMIRSpec     YamlSpec
src/CLI/Helpers.hs view
@@ -106,12 +106,8 @@ printRewrittens :: PrintContext -> Rewrittens' -> IO String printRewrittens ctx@PrintCtx{..} rewrittens@(chain, _)   | _outputFormat == LATEX && _sequence = rewrittensToLatex rewrittens (printCtxToLatexCtx ctx)-  | otherwise = withHeaders <$> mapM render (canonized chain)+  | otherwise = withHeaders <$> mapM (printFocused ctx . fst) (canonized chain)   where-    render :: Rewritten -> IO String-    render (expr, _)-      | _focus == ExRoot = printInFormat ctx expr-      | otherwise = locatedExpression _focus expr >>= printExpression ctx     canonized :: [Rewritten] -> [Rewritten]     canonized = if _canonize then canonize else id     -- Prefix every step with an empty line and its header (see 'stepHeaders')@@ -126,6 +122,13 @@       where         prefixed :: String -> String -> String         prefixed = printf "\n%s\n%s"++-- Render one expression in the output format, narrowed to the '--focus'+-- sub-expression when one is given.+printFocused :: PrintContext -> Expression -> IO String+printFocused ctx@PrintCtx{..} expr+  | _focus == ExRoot = printInFormat ctx expr+  | otherwise = locatedExpression _focus expr >>= printExpression ctx  printExpression :: PrintContext -> Expression -> IO String printExpression ctx@PrintCtx{..} ex = case _outputFormat of
src/CLI/Parsers.hs view
@@ -202,6 +202,9 @@ optStepsDir :: Parser (Maybe FilePath) optStepsDir = optional (strOption (long "steps-dir" <> metavar "FILE" <> help "Directory to save intermediate steps during rewriting/dataizing")) +optPartial :: Parser Bool+optPartial = switch (long "partial" <> help "Partial evaluation: compute what the known inputs decide and, instead of failing on an atom that cannot fire (its λ function is unknown, or an input of it reaches such an atom), leave it in place and print the residual 𝜑-program instead of bytes")+ optEvaluations :: Parser (Maybe FilePath) optEvaluations = optional (strOption (long "evaluations" <> metavar "FILE" <> help "File to record every atom fired during dataizing, as one tab-separated line per firing: the λ function name, its argument formation and its result (requires --output=phi)")) @@ -293,6 +296,7 @@             <*> optShuffle             <*> optSeed             <*> switch (long "quiet" <> help "Don't print the result of dataization")+            <*> optPartial             <*> optCompress             <*> optMaxDepth             <*> optMaxCycles
src/CLI/Runners.hs view
@@ -150,12 +150,20 @@       exclude = (`F.exclude` excluded)       include = (`F.include` included)   save <- saveStepFunc _stepsDir printCtx-  (bytes, chain) <-+  (outcome, chain) <-     withEvalFunc _evaluations printCtx $-      dataize expr . DataizeContext loc _maxDepth _maxCycles (Steps _maxSteps 0) _depthSensitive _shuffle buildTerm save+      dataize expr . DataizeContext loc _maxDepth _maxCycles (Steps _maxSteps 0) _depthSensitive _shuffle _partial buildTerm save   when _sequence (printRewrittens printCtx (exclude $ include chain, False) >>= putStrLn)-  unless _quiet (putStrLn (P.printBytes bytes))+  unless _quiet (printOutcome printCtx outcome >>= putStrLn)   where+    -- The bytes the run reached or, when '--partial' let it end on an atom+    -- that could not fire, the residual program, rendered like a rewriting+    -- result: in the output format, narrowed to '--focus'.+    printOutcome :: PrintContext -> Outcome -> IO String+    printOutcome _ (Dataized bytes) = pure (P.printBytes bytes)+    printOutcome ctx (Residual residue) = do+      logDebug "Dataization got stuck on an atom that cannot fire, printing the residual program (--partial)"+      printFocused ctx residue     validateOpts :: IO ()     validateOpts = do       validateLatexOptions
src/CLI/Types.hs view
@@ -91,6 +91,7 @@   , _shuffle :: Bool   , _seed :: Int   , _quiet :: Bool+  , _partial :: Bool   , _compress :: Bool   , _maxDepth :: Int   , _maxCycles :: Int
src/Dataize.hs view
@@ -1,7 +1,6 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}@@ -11,13 +10,13 @@ -- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com -- SPDX-License-Identifier: MIT -module Dataize (morph, dataize, dataize', DataizeContext (..), Steps (..), State, emptyState, execBuildTerm) where+module Dataize (morph, dataize, dataize', DataizeContext (..), DataizeException (..), Outcome (..), Steps (..), State, emptyState, execBuildTerm) where  import AST import Builder (buildBytesThrows, buildExpressionThrows) import Bytes (btsAnd, btsConcat, btsEqual, btsNot, btsOr, btsShift, btsSize, btsSlice, btsToNum, numToBts, strToBts)-import Control.Exception (Exception, throwIO)-import Control.Monad (foldM)+import Control.Exception (Exception, catch, throwIO, try)+import Control.Monad (foldM, when) import Data.Int (Int32) import Data.List (find, partition) import Data.List.NonEmpty (NonEmpty (..))@@ -76,18 +75,40 @@   , _steps :: Steps   , _depthSensitive :: Bool   , _shuffle :: Bool+  , _partial :: Bool   , _buildTerm :: BuildTermFunc   , _saveStep :: SaveStepFunc   , _saveEval :: SaveEvalFunc   } -newtype DataizeException = OutOfSteps Int+data DataizeException+  = OutOfSteps Int+  | -- An atom could not fire: 'atom' does not know its λ function, or the+    -- dataization of one of its inputs met an atom it does not know. The name+    -- is that of the innermost unknown atom, the one 𝔼 actually failed on.+    Stuck T.Text+  | -- A 'Stuck' caught by a frame of the 𝕄/𝔻 spine, together with the+    -- derivation that frame had reached (see 'parking'). The head of the chain+    -- is the working expression with the stuck application left intact and+    -- everything reduced before it already in place: the residual program that+    -- '_partial' turns into the 'Residual' outcome.+    StuckAt T.Text (NonEmpty Rewritten)   deriving anyclass (Exception)  instance Show DataizeException where   show (OutOfSteps limit) =     printf "Dataization did not finish before reaching the limit of steps: --max-steps=%d" limit+  show (Stuck func) = printf "Atom '%s' does not exist" (T.unpack func)+  show (StuckAt func _) = show (Stuck func) +-- What a run of 𝔻 ends with: the bytes it reached or, under '_partial', the+-- residual program: what the known inputs decided is computed, the stuck atom+-- and everything depending on it survive in place.+data Outcome+  = Dataized Bytes+  | Residual Expression+  deriving stock (Eq, Show)+ -- Charge one step of the 𝕄/𝔻 recursion to the budget, refusing to descend once -- it is gone. '--max-cycles' and '--max-depth' bound only the normalization run -- inside a single step, so before this the recursion itself was unbounded and a@@ -100,32 +121,47 @@   | spent >= limit = throwIO (OutOfSteps limit)   | otherwise = pure ctx{_steps = Steps limit (spent + 1)} --- Resolve formation for LAMBDA Morphing rule.--- If formation contains λ binding, the called atom result is returned, together--- with the name of the fired function and the formation the atom fired against,--- since 𝔼 has to report all three. This is not the report itself: its '_result'--- is the atom's raw answer, while the one 𝔼 reports carries the normal form of--- that answer, which is what 𝔼 hands back to its caller.--- The universe 'univ' is forwarded to the atom.-formation :: [Binding] -> Expression -> State -> DataizeContext -> IO (Maybe (Evaluation, State))-formation bds univ state ctx = do-  let (lambda, bds') = maybeLambda bds-  case lambda of-    Just (BiLambda (Function func)) -> do-      (obj, state') <- atom func (ExFormation bds') univ state ctx-      pure (Just (Evaluation func (ExFormation bds') obj, state'))-    _ -> pure Nothing+-- Split the λ binding off a formation for the LAMBDA morphing rule: the name of+-- the atom to fire and the formation it fires against, the λ binding removed —+-- the two things 𝔼 reports besides the result. A formation with no λ binding,+-- or with more than one, has nothing to fire.+lambda :: [Binding] -> Maybe (T.Text, Expression)+lambda bds = case partition isLambda bds of+  ([BiLambda (Function func)], rest) -> Just (func, ExFormation rest)+  _ -> Nothing   where-    maybeLambda :: [Binding] -> (Maybe Binding, [Binding])-    maybeLambda = maybeBinding (\case BiLambda _ -> True; _ -> False)-    maybeBinding :: (Binding -> Bool) -> [Binding] -> (Maybe Binding, [Binding])-    maybeBinding _ [] = (Nothing, [])-    maybeBinding func bds =-      let (found, rest) = partition func bds-       in case found of-            [bd] -> (Just bd, rest)-            _ -> (Nothing, bds)+    isLambda :: Binding -> Bool+    isLambda (BiLambda _) = True+    isLambda _ = False +-- Run one frame of the 𝕄/𝔻 spine, attaching its derivation to a stuck atom+-- escaping it. 'Stuck' is raised deep inside an atom, which knows nothing about+-- the chain, so the innermost spine frame it reaches is the one to record where+-- the derivation stopped: the head of that frame's chain is the working+-- expression with the stuck application intact and everything reduced before+-- it already in place. Outer frames see 'StuckAt' and let it pass, since their+-- chains are prefixes of that one; a side-computation running on a chain of its+-- own strips the chain off again (see 'unparked') before the signal reaches+-- the spine.+parking :: NonEmpty Rewritten -> IO a -> IO a+parking seq action = action `catch` rethrow+  where+    rethrow :: DataizeException -> IO a+    rethrow (Stuck func) = throwIO (StuckAt func seq)+    rethrow failure = throwIO failure++-- Strip the derivation off a stuck atom escaping a side-computation that ran+-- on a chain of its own — an atom dataizing its input through '_dataize', or a+-- 'morph' premise through '_morph'. That chain is not the spine's, so it is+-- dropped and the spine frame around the side-computation attaches its own+-- (see 'parking').+unparked :: IO a -> IO a+unparked action = action `catch` rethrow+  where+    rethrow :: DataizeException -> IO a+    rethrow (StuckAt func _) = throwIO (Stuck func)+    rethrow failure = throwIO failure+ -- The Morphing function 𝕄 maps normal forms to formations. It is ternary, -- 𝕄(n, e, s): besides the term 'n' it takes the universe 'e' ('univ') — a plain -- expression — and the mutable state 's', returning the morphed term together@@ -148,11 +184,12 @@ morph :: Morphed -> Expression -> State -> DataizeContext -> IO (Morphed, State) morph (expr, seq) univ state caller = do   ctx <- deeper caller-  rules <- if ctx._shuffle then shuffle Y.morphingRules else pure Y.morphingRules-  matched <- firstMatch ctx rules-  case matched of-    Just (rule, subst) -> reduce ctx rule subst-    Nothing -> throwIO (userError "no morphing rule matched")+  parking seq $ do+    rules <- if ctx._shuffle then shuffle Y.morphingRules else pure Y.morphingRules+    matched <- firstMatch ctx rules+    case matched of+      Just (rule, subst) -> reduce ctx rule subst+      Nothing -> throwIO (userError "no morphing rule matched")   where     firstMatch :: DataizeContext -> [Y.MorphRule] -> IO (Maybe (Y.MorphRule, Subst))     firstMatch _ [] = pure Nothing@@ -197,14 +234,21 @@  -- Dataize the expression located at '_locator'. The whole input expression is -- itself the universe Q (the 'e' argument) threaded through 𝔻 and 𝕄, so it is--- passed both as the located target and as the universe.-dataize :: Expression -> DataizeContext -> IO Dataized+-- passed both as the located target and as the universe. An atom that cannot+-- fire fails the run, unless '_partial' is on: dataization is then a partial+-- evaluation, and the run ends on the residual program the spine had reached+-- (see 'StuckAt'), with the stuck application parked in it as a normal-form+-- subterm, and the chain of steps that led there.+dataize :: Expression -> DataizeContext -> IO (Outcome, [Rewritten]) dataize universe ctx@DataizeContext{..} = do   expr <- locatedExpression _locator universe   -- Dataization starts from the empty state; the final state is not yet   -- consumed by any caller, so it is discarded here.-  ((bytes, seq), _state) <- dataize' (expr, (universe, Nothing) :| []) universe emptyState ctx-  pure (bytes, reverse seq)+  result <- try (dataize' (expr, (universe, Nothing) :| []) universe emptyState ctx)+  case result of+    Right ((bytes, seq), _state) -> pure (Dataized bytes, reverse seq)+    Left (StuckAt _ seq) | _partial -> pure (Residual (fst (NE.head seq)), reverse (NE.toList seq))+    Left failure -> throwIO (failure :: DataizeException)  -- The Dataization function 𝔻 retrieves bytes from an expression. It is partial -- and ternary, 𝔻(n, e, s): besides the term 'n' it takes the universe 'e' ('univ'),@@ -230,11 +274,12 @@ dataize' :: Dataizable -> Expression -> State -> DataizeContext -> IO (Dataized, State) dataize' (expr, seq) univ state caller = do   ctx <- deeper caller-  rules <- if ctx._shuffle then shuffle Y.dataizationRules else pure Y.dataizationRules-  matched <- firstMatch ctx rules-  case matched of-    Just (rule, subst) -> reduce ctx rule subst-    Nothing -> throwIO (userError (unmatched expr))+  parking seq $ do+    rules <- if ctx._shuffle then shuffle Y.dataizationRules else pure Y.dataizationRules+    matched <- firstMatch ctx rules+    case matched of+      Just (rule, subst) -> reduce ctx rule subst+      Nothing -> throwIO (userError (unmatched expr))   where     -- 𝔻 is partial: the terminator ⊥ signals an error and lies outside its     -- domain (see #955), so it matches no clause and lands here. Name it in the@@ -398,10 +443,11 @@ -- it first reduces the expression to a normal form, since 𝔻 only accepts normal -- forms. The universe 'univ' itself is forwarded unchanged, so morphing Φ under -- this context still resolves to the true universe rather than to this--- synthetic, binding-prepended formation.+-- synthetic, binding-prepended formation. The chain is the synthetic one, so a+-- stuck atom met on the way leaves without it (see 'unparked'). _dataize :: Expression -> Expression -> State -> DataizeContext -> IO (Bytes, State) _dataize expr univ state ctx@DataizeContext{_buildTerm = buildTerm} = case univ of-  ExFormation bds -> do+  ExFormation bds -> unparked $ do     (TeAttribute attr) <- buildTerm "random-tau" [] substEmpty     let synthetic = ExFormation (BiTau attr expr : bds)     (normal, seq) <- normalized expr ((synthetic, Nothing) :| []) ctx@@ -513,7 +559,7 @@       ExApplication         (ExDispatch self (AtLabel "cant-slice"))         (ArAlpha (Alpha 0) (DataString (strToBts (printf "cannot slice '%d' bytes from offset '%d' of bytes of size %d" count from size))))-atom func _ _ _ _ = throwIO (userError (printf "Atom '%s' does not exist" (T.unpack func)))+atom func _ _ _ _ = throwIO (Stuck func)  -- Augment the injected, context-free term builder with the dataization and -- morphing operations that need the universe: 'evaluate' applies an atom and@@ -539,30 +585,42 @@ -- turns into one record per line. The reported result is the normal form 𝔼 -- returns, never the atom's raw answer, so the protocol and the caller see the -- same term. A nested firing — an atom that dataizes its own arguments —--- completes first, so it is reported before the firing that triggered it.+-- completes first, so it is reported before the firing that triggered it. A+-- firing that gets stuck is reported too, with no result, when the run is a+-- partial evaluation rather than a failure ('_partial'): the site is what the+-- caller wants to learn then, and the nested order holds, since the unknown+-- atom is reported before the known one whose input reached it. The report is+-- made before the signal goes on to the spine, where 'parking' attaches the+-- derivation to it. _evaluate :: DataizeContext -> State -> BuildTermMethodS _evaluate ctx state [ArgExpression expr, ArgExpression universe] subst = do   form <- buildExpressionThrows expr subst   univ <- buildExpressionThrows universe subst   case form of-    ExFormation bds -> do-      resolved <- formation bds univ state ctx-      case resolved of-        Just (fired, state') -> do-          (normal, _) <- normalized fired._result ((univ, Nothing) :| []) ctx-          ctx._saveEval (Evaluation fired._function fired._arguments normal)-          pure (TeExpression normal, state')-        Nothing -> throwIO (userError "Function evaluate() expects a formation with a λ binding")+    ExFormation bds -> case lambda bds of+      Just (func, args) -> do+        (raw, state') <- atom func args univ state ctx `catch` parked func args+        (normal, _) <- normalized raw ((univ, Nothing) :| []) ctx+        ctx._saveEval (Evaluation func args (Just normal))+        pure (TeExpression normal, state')+      Nothing -> throwIO (userError "Function evaluate() expects a formation with a λ binding")     _ -> throwIO (userError "Function evaluate() expects a formation")+  where+    parked :: T.Text -> Expression -> DataizeException -> IO a+    parked func args failure@(Stuck _) = do+      when ctx._partial (ctx._saveEval (Evaluation func args Nothing))+      throwIO failure+    parked _ _ failure = throwIO failure _evaluate _ _ _ _ = throwIO (userError "Function evaluate() requires exactly 2 expression arguments")  -- The Morphing function 𝕄 exposed as a build-term function so a rule can morph -- a sub-expression in its 'where' (the 'md' and 'ma' rules morph -- the head before re-attaching it). The step chain is discarded: the producing--- rule splices the surrounding normalization steps itself. The state is threaded--- through and the new state returned alongside the morphed term.+-- rule splices the surrounding normalization steps itself, and a stuck atom met+-- on the way leaves without it (see 'unparked'). The state is threaded through+-- and the new state returned alongside the morphed term. _morph :: Expression -> DataizeContext -> State -> BuildTermMethodS-_morph univ ctx state [ArgExpression expr] subst = do+_morph univ ctx state [ArgExpression expr] subst = unparked $ do   built <- buildExpressionThrows expr subst   ((morphed, _), state') <- morph (built, (univ, Nothing) :| []) univ state ctx   pure (TeExpression morphed, state')
src/Deps.hs view
@@ -11,6 +11,7 @@  import AST import Data.List (intercalate)+import Data.Maybe (maybeToList) import qualified Data.Text as T import Logger (logDebug) import Matcher@@ -60,23 +61,26 @@  -- One firing of an atom, the way the Evaluation function 𝔼 sees it: the name of -- the λ function, the formation it fired against with the λ binding removed, and--- the term it produced.+-- the term it produced. A firing that got stuck — the atom is unknown, or one of+-- its inputs reached such an atom — and survived in the residual program of a+-- partial evaluation (see '--partial') has no result. data Evaluation = Evaluation   { _function :: T.Text   , _arguments :: Expression-  , _result :: Expression+  , _result :: Maybe Expression   }  type SaveEvalFunc = Evaluation -> IO ()  -- Append one evaluation to the protocol as a single tab-separated line: the λ--- function name, its argument formation and its result. Both expressions are+-- function name, its argument formation and its result; a parked firing has no+-- result, so its record stops after the second field. The expressions are -- rendered by the caller, which flattens them, so a record never spills over -- more than one line. The handle stays open for the whole run, since a run may -- fire thousands of atoms and reopening the file for each of them buys nothing. saveEval :: Handle -> (Expression -> IO String) -> SaveEvalFunc saveEval handle render (Evaluation func bindings outcome) = do-  rendered <- mapM render [bindings, outcome]+  rendered <- mapM render (bindings : maybeToList outcome)   hPutStrLn handle (intercalate "\t" (T.unpack func : rendered))   logDebug (printf "Saved the evaluation of '%s'" (T.unpack func)) 
test/ASTSpec.hs view
@@ -12,8 +12,8 @@  import AST import Control.Monad (forM_)-import Data.List (sort)-import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)+import Data.List (nub, sort)+import Test.Hspec (Spec, describe, it, shouldBe, shouldNotBe, shouldSatisfy)  spec :: Spec spec = do@@ -218,7 +218,180 @@       , ("meta expression", ExMeta "e", 1)       , ("deeply nested dispatch", ExDispatch (ExDispatch ExRoot (AtLabel "a")) (AtLabel "b"), 5)       , ("formation with dispatch inside", ExFormation [BiTau AtRho (ExDispatch ExRoot (AtLabel "x"))], 7)+      , ("application with tau argument", ExApplication ExRoot (ArTau AtRho ExRoot), 6)+      , ("application with alpha argument", ExApplication ExRoot (ArAlpha (Alpha 0) ExRoot), 6)+      , ("phi-meet wraps its inner count", ExPhiMeet Nothing 1 (ExDispatch ExRoot (AtLabel "x")), 3)+      , ("phi-meet with label wraps its inner count", ExPhiMeet (Just "m") 2 ExRoot, 1)+      , ("phi-again wraps its inner count", ExPhiAgain Nothing 1 (ExDispatch ExRoot (AtLabel "x")), 3)+      , ("phi-again with label wraps its inner count", ExPhiAgain (Just "m") 2 ExRoot, 1)+      , ("bare bytes falls into the catch-all", ExBytes BtEmpty, 1)       ]       ( \(desc, expr, expected) ->           it desc $ countNodes expr `shouldBe` expected+      )++  describe "hashExpression" $ do+    it "hashes every Expression/Binding/Bytes/Attribute/Argument/Alpha/Function constructor without collisions" $+      let exprs =+            [ ExFormation []+            , ExFormation [BiTau AtRho ExRoot]+            , ExFormation [BiVoid AtPhi]+            , ExFormation [BiDelta BtEmpty]+            , ExFormation [BiDelta (BtOne "FF")]+            , ExFormation [BiDelta (BtMany ["00", "01"])]+            , ExFormation [BiDelta (BtMeta "b")]+            , ExFormation [BiLambda (Function "Func")]+            , ExFormation [BiLambda (FnMeta "F")]+            , ExFormation [BiMeta "M"]+            , ExXi+            , ExRoot+            , ExTermination+            , ExApplication ExRoot (ArTau AtRho ExXi)+            , ExApplication ExRoot (ArAlpha (Alpha 3) ExXi)+            , ExApplication ExRoot (ArAlpha (AlMeta "A") ExXi)+            , ExDispatch ExRoot (AtLabel "x")+            , ExDispatch ExRoot AtPhi+            , ExDispatch ExRoot AtRho+            , ExDispatch ExRoot AtLambda+            , ExDispatch ExRoot AtDelta+            , ExDispatch ExRoot (AtMeta "m")+            , ExMeta "e"+            , ExMeta "f"+            , ExPhiMeet (Just "m") 2 ExRoot+            , ExPhiMeet Nothing 2 ExRoot+            , ExPhiMeet Nothing 3 ExRoot+            , ExPhiAgain (Just "m") 2 ExRoot+            , ExPhiAgain Nothing 2 ExRoot+            , ExPhiAgain Nothing 3 ExRoot+            , ExBytes BtEmpty+            , ExBytes (BtOne "FF")+            , ExBytes (BtMany ["00", "FF"])+            , ExBytes (BtMeta "b")+            ]+          hashes = map hashExpression exprs+       in nub hashes `shouldBe` hashes++    it "produces different hashes for a couple of hand-picked distinct expressions" $ do+      hashExpression ExRoot `shouldNotBe` hashExpression ExXi+      hashExpression (ExDispatch ExRoot (AtLabel "x")) `shouldNotBe` hashExpression (ExDispatch ExRoot (AtLabel "y"))++  describe "BaseObject pattern" $ do+    it "constructs a Q-dispatch expression" $+      BaseObject "bytes" `shouldBe` ExDispatch ExRoot (AtLabel "bytes")++    forM_+      [ ("matches a Q-dispatch expression, extracting the label", ExDispatch ExRoot (AtLabel "number"), Just "number")+      , ("does not match a non-Q-dispatch expression", ExDispatch ExXi (AtLabel "number"), Nothing)+      ]+      ( \(desc, expr, expected) ->+          it desc $+            let matched = case expr of+                  BaseObject label -> Just label+                  _ -> Nothing+             in matched `shouldBe` expected+      )++  describe "dataBytes" $+    it "builds the bytes-object formation carrying the given bytes" $+      dataBytes (BtOne "48")+        `shouldBe` ExApplication+          (ExDispatch ExRoot (AtLabel "bytes"))+          (ArTau (AtLabel "data") (ExFormation [BiDelta (BtOne "48"), BiVoid AtRho]))++  describe "DataObject/DataString/DataNumber pattern" $ do+    it "constructs the named, unwrapped as-bytes form" $+      DataString (BtOne "48")+        `shouldBe` ExApplication+          (ExDispatch ExRoot (AtLabel "string"))+          (ArTau (AtLabel "as-bytes") (dataBytes (BtOne "48")))++    forM_+      [+        ( "matches the named, unwrapped form"+        , ExApplication (ExDispatch ExRoot (AtLabel "string")) (ArTau (AtLabel "as-bytes") (dataBytes (BtOne "48")))+        , Just (BtOne "48")+        )+      ,+        ( "matches the named form with a phi-again-wrapped outer object"+        , ExApplication+            (ExPhiAgain Nothing 1 (ExDispatch ExRoot (AtLabel "string")))+            (ArTau (AtLabel "as-bytes") (dataBytes (BtOne "48")))+        , Just (BtOne "48")+        )+      ,+        ( "matches the named form with a phi-again-wrapped inner bytes formation"+        , ExApplication+            (ExDispatch ExRoot (AtLabel "number"))+            ( ArTau+                (AtLabel "as-bytes")+                ( ExApplication+                    (ExDispatch ExRoot (AtLabel "bytes"))+                    (ArTau (AtLabel "data") (ExPhiAgain Nothing 1 (ExFormation [BiDelta (BtOne "05"), BiVoid AtRho])))+                )+            )+        , Just (BtOne "05")+        )+      ,+        ( "matches the legacy positional (alpha0) form on both layers"+        , ExApplication+            (ExDispatch ExRoot (AtLabel "number"))+            ( ArAlpha+                (Alpha 0)+                ( ExApplication+                    (ExDispatch ExRoot (AtLabel "bytes"))+                    (ArAlpha (Alpha 0) (ExFormation [BiDelta (BtOne "05"), BiVoid AtRho]))+                )+            )+        , Just (BtOne "05")+        )+      ]+      ( \(desc, expr, expected) ->+          it desc $+            let matched = case expr of+                  DataString bts -> Just bts+                  DataNumber bts -> Just bts+                  _ -> Nothing+             in matched `shouldBe` expected+      )++    forM_+      [+        ( "does not match when the outer object is not a base object (matchOuter fails)"+        , ExApplication ExRoot (ArTau (AtLabel "as-bytes") (dataBytes (BtOne "48")))+        )+      ,+        ( "does not match when the inner object is not an application (matchInner fails)"+        , ExApplication (ExDispatch ExRoot (AtLabel "string")) (ArTau (AtLabel "as-bytes") ExRoot)+        )+      ,+        ( "does not match when the inner base object is not 'bytes' (matchesBytes fails)"+        , ExApplication+            (ExDispatch ExRoot (AtLabel "string"))+            ( ArTau+                (AtLabel "as-bytes")+                ( ExApplication+                    (ExDispatch ExRoot (AtLabel "other"))+                    (ArTau (AtLabel "data") (ExFormation [BiDelta (BtOne "48"), BiVoid AtRho]))+                )+            )+        )+      ,+        ( "does not match when the bytes formation has the wrong shape (matchFormation fails)"+        , ExApplication+            (ExDispatch ExRoot (AtLabel "string"))+            ( ArTau+                (AtLabel "as-bytes")+                ( ExApplication+                    (ExDispatch ExRoot (AtLabel "bytes"))+                    (ArTau (AtLabel "data") (ExFormation [BiDelta (BtOne "48")]))+                )+            )+        )+      ]+      ( \(desc, expr) ->+          it desc $+            let matched = case expr of+                  DataObject label bts -> Just (label, bts)+                  _ -> Nothing+             in matched `shouldBe` Nothing       )
test/BuilderSpec.hs view
@@ -7,8 +7,10 @@  import AST import Builder+import Control.Exception (SomeException) import Control.Monad import Data.Either (isLeft)+import Data.List (isInfixOf) import Data.Map.Strict qualified as Map import Data.Text qualified as T import Matcher@@ -83,6 +85,18 @@                 (AtLabel "t")             )         )+      ,+        ( "Q.c(α!i -> !e) => (!i >> 2, !e >> $) => Q.c(α2 -> $)"+        , ExApplication (ExDispatch ExRoot (AtLabel "c")) (ArAlpha (AlMeta "i") (ExMeta "e"))+        , [("i", MvIndex 2), ("e", MvExpression ExXi)]+        , Right (ExApplication (ExDispatch ExRoot (AtLabel "c")) (ArAlpha (Alpha 2) ExXi))+        )+      ,+        ( "Q.c(α!i -> Q) => () => X"+        , ExApplication (ExDispatch ExRoot (AtLabel "c")) (ArAlpha (AlMeta "i") ExRoot)+        , []+        , Left "meta 'i' is either does not exist or refers to an inappropriate term"+        )       ]    describe "buildExpressions" $ do@@ -100,17 +114,100 @@         [substSingle "e1" (MvExpression (ExDispatch ExRoot (AtLabel "x")))]         `shouldThrow` anyException -  describe "contextualize" $ do-    it "replaces a xi expression with the context" $-      contextualize ExXi (ExFormation [BiVoid AtRho]) `shouldBe` ExFormation [BiVoid AtRho]-    it "keeps a root expression untouched" $-      contextualize ExRoot (ExFormation [BiVoid AtRho]) `shouldBe` ExRoot-    it "keeps an empty formation untouched" $-      contextualize (ExFormation [BiVoid AtRho]) (ExFormation [BiVoid AtRho, BiVoid AtRho])-        `shouldBe` ExFormation [BiVoid AtRho]-    it "recurses into a dispatch application" $-      contextualize (ExDispatch ExXi (AtLabel "z")) (ExFormation [BiVoid AtRho])-        `shouldBe` ExDispatch (ExFormation [BiVoid AtRho]) (AtLabel "z")+  describe "contextualize" $+    let commonContext :: Expression+        commonContext = ExFormation [BiVoid AtRho]+     in forM_+          [ ("replaces a xi expression with the context", ExXi, commonContext, commonContext)+          , ("keeps a root expression untouched", ExRoot, commonContext, ExRoot)+          ,+            ( "keeps an empty formation untouched"+            , ExFormation [BiVoid AtRho]+            , ExFormation [BiVoid AtRho, BiVoid AtRho]+            , ExFormation [BiVoid AtRho]+            )+          ,+            ( "recurses into a dispatch application"+            , ExDispatch ExXi (AtLabel "z")+            , commonContext+            , ExDispatch commonContext (AtLabel "z")+            )+          , ("keeps a termination untouched", ExTermination, commonContext, ExTermination)+          ,+            ( "recurses into both sides of an application with a tau argument"+            , ExApplication ExXi (ArTau (AtLabel "x") ExXi)+            , commonContext+            , ExApplication commonContext (ArTau (AtLabel "x") commonContext)+            )+          ,+            ( "recurses into both sides of an application with an alpha argument"+            , ExApplication ExXi (ArAlpha (Alpha 0) ExXi)+            , commonContext+            , ExApplication commonContext (ArAlpha (Alpha 0) commonContext)+            )+          , ("leaves any other expression untouched", ExMeta "e", commonContext, ExMeta "e")+          ]+          (\(desc, expr, context, expected) -> it desc (contextualize expr context `shouldBe` expected))++  describe "buildBinding: lambda and delta bindings from metas" $+    forM_+      [+        ( "builds a lambda binding from a bound function meta"+        , BiLambda (FnMeta "f")+        , substSingle "f" (MvFunction "Func")+        , Right [BiLambda (Function "Func")]+        )+      ,+        ( "fails to build a lambda binding from an unbound function meta"+        , BiLambda (FnMeta "f")+        , substEmpty+        , Left "meta 'f' is either does not exist or refers to an inappropriate term"+        )+      ,+        ( "builds a delta binding from a bound bytes meta"+        , BiDelta (BtMeta "b")+        , substSingle "b" (MvBytes (BtOne "00"))+        , Right [BiDelta (BtOne "00")]+        )+      ,+        ( "fails to build a delta binding from an unbound bytes meta"+        , BiDelta (BtMeta "b")+        , substEmpty+        , Left "meta 'b' is either does not exist or refers to an inappropriate term"+        )+      ,+        ( "fails to build a meta binding that is unbound"+        , BiMeta "B"+        , substEmpty+        , Left "meta 'B' is either does not exist or refers to an inappropriate term"+        )+      ]+      (\(desc, binding, subst, expected) -> it desc (buildBinding binding subst `shouldBe` expected))++  describe "the throwing builders report a descriptive message" $+    forM_+      [+        ( "buildBytesThrows names the bytes it could not build"+        , void (buildBytesThrows (BtMeta "b") substEmpty)+        , "Couldn't build bytes"+        )+      ,+        ( "buildBindingThrows names the binding it could not build"+        , void (buildBindingThrows (BiMeta "B") substEmpty)+        , "Couldn't build binding"+        )+      ,+        ( "buildAttributeThrows names the attribute it could not build"+        , void (buildAttributeThrows (AtMeta "t") substEmpty)+        , "Couldn't build attribute"+        )+      ,+        ( "buildExpressionThrows names the expression it could not build"+        , void (buildExpressionThrows (ExMeta "e") substEmpty)+        , "Couldn't build expression"+        )+      ]+      (\(desc, action, message) -> it desc (action `shouldThrow` (\exc -> message `isInfixOf` show (exc :: SomeException))))    describe "build with duplicate attributes in bindings" $ do     it "build binding with duplicates" $
+ test/BytesSpec.hs view
@@ -0,0 +1,217 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module BytesSpec where++import AST+import Bytes+  ( btsAnd+  , btsConcat+  , btsEqual+  , btsNot+  , btsOr+  , btsShift+  , btsSize+  , btsSlice+  , btsToNum+  , btsToStr+  , btsToUnescapedStr+  , bytesToBts+  , numToBts+  , strToBts+  )+import Control.Exception (evaluate)+import Control.Monad (forM_)+import Test.Hspec (Spec, anyErrorCall, describe, it, shouldBe, shouldSatisfy, shouldThrow)++spec :: Spec+spec = do+  describe "numToBts" $+    forM_+      [ ("0.0", 0.0 :: Double, BtMany ["00", "00", "00", "00", "00", "00", "00", "00"])+      , ("42", 42, BtMany ["40", "45", "00", "00", "00", "00", "00", "00"])+      , ("-0.25", -0.25, BtMany ["BF", "D0", "00", "00", "00", "00", "00", "00"])+      , ("5", 5, BtMany ["40", "14", "00", "00", "00", "00", "00", "00"])+      ]+      ( \(desc, num, bts) ->+          it desc $ numToBts num `shouldBe` bts+      )++  describe "numToBts/btsToNum round trip" $+    forM_+      [ ("normal integer", 42, (== Left 42))+      , ("normal fraction", 3.5, (== Right 3.5))+      , ("zero", 0.0, (== Left 0))+      , ("negative integer", -2, (== Left (-2)))+      , ("negative fraction", -0.25, (== Right (-0.25)))+      , ("NaN", 0 / 0, either (const False) isNaN)+      , ("positive infinity", 1 / 0, either (const False) (\num -> isInfinite num && num > 0))+      , ("negative infinity", -(1 / 0), either (const False) (\num -> isInfinite num && num < 0))+      , ("negative zero", -0.0, either (const False) isNegativeZero)+      ]+      (\(desc, num, predicate) -> it desc (btsToNum (numToBts num) `shouldSatisfy` predicate))++  describe "btsToNum with a byte array that is not 8 bytes long" $+    it "errors out" $+      evaluate (btsToNum (BtMany ["40", "45"])) `shouldThrow` anyErrorCall++  describe "strToBts" $+    forM_+      [ ("", BtEmpty)+      , ("h", BtOne "68")+      , ("hello", BtMany ["68", "65", "6C", "6C", "6F"])+      , ("\"", BtOne "22")+      , ("\\", BtOne "5C")+      , ("\n", BtOne "0A")+      , ("\t", BtOne "09")+      , ("\x01", BtOne "01")+      ]+      ( \(str, bts) ->+          it (show str) $ strToBts str `shouldBe` bts+      )++  describe "btsToStr" $+    forM_+      [ ("empty", BtEmpty, "")+      , ("single char", BtOne "68", "h")+      , ("multi byte", BtMany ["68", "65", "6C", "6C", "6F"], "hello")+      , ("escapes double quote", BtOne "22", "\\\"")+      , ("escapes backslash", BtOne "5C", "\\\\")+      , ("escapes newline", BtOne "0A", "\\n")+      , ("escapes tab", BtOne "09", "\\t")+      , ("escapes non-printable", BtOne "01", "\\x01")+      , ("mixed printable and quote", BtMany ["61", "22", "62"], "a\\\"b")+      ]+      ( \(desc, bts, str) ->+          it desc $ btsToStr bts `shouldBe` str+      )++  describe "btsToUnescapedStr" $+    forM_+      [ ("non-printable", BtMany ["01", "02"], "\SOH\STX")+      , ("multi byte word", BtMany ["77", "6F", "72", "6C", "64"], "world")+      , ("double quote", BtMany ["68", "22"], "h\"")+      , ("single hex digit padded", BtOne "35", "5")+      ]+      ( \(desc, bts, str) ->+          it desc $ btsToUnescapedStr bts `shouldBe` str+      )++  describe "bytesToBts" $+    forM_+      [ ("empty", "--", BtEmpty)+      , ("single byte with trailing dash", "01-", BtOne "01")+      , ("multi byte", "77-6F", BtMany ["77", "6F"])+      ]+      ( \(desc, str, bts) ->+          it desc $ bytesToBts str `shouldBe` bts+      )++  describe "btsAnd and btsOr" $+    forM_+      [+        ( "btsAnd: matching lengths"+        , btsAnd (BtMany ["02", "EF"]) (BtMany ["12", "33"])+        , Just (BtMany ["02", "23"])+        )+      , ("btsAnd: mismatched lengths", btsAnd (BtOne "20") (BtMany ["CA", "FE"]), Nothing)+      ,+        ( "btsOr: matching lengths"+        , btsOr (BtMany ["02", "EF"]) (BtMany ["12", "33"])+        , Just (BtMany ["12", "FF"])+        )+      , ("btsOr: mismatched lengths", btsOr (BtOne "20") (BtMany ["CA", "FE"]), Nothing)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "btsNot" $+    it "negates every byte" $+      btsNot (BtMany ["CA", "FE", "BE", "BE"]) `shouldBe` BtMany ["35", "01", "41", "41"]++  describe "btsConcat" $+    forM_+      [ ("with BtEmpty on the right", BtMany ["05", "5E"], BtEmpty, BtMany ["05", "5E"])+      , ("two BtEmpty", BtEmpty, BtEmpty, BtEmpty)+      , ("two single bytes", BtOne "01", BtOne "02", BtMany ["01", "02"])+      ]+      ( \(desc, left, right, result) ->+          it desc $ btsConcat left right `shouldBe` result+      )++  describe "btsEqual" $ do+    it "same value via different constructors" $+      btsEqual (BtOne "01") (BtMany ["01"]) `shouldBe` True+    it "different values" $+      btsEqual (BtMany ["01", "02"]) (BtMany ["01", "03"]) `shouldBe` False++  describe "btsSize" $+    forM_+      [ ("empty", BtEmpty, 0)+      , ("three bytes", BtMany ["F1", "20", "5F"], 3)+      ]+      ( \(desc, bts, size) ->+          it desc $ btsSize bts `shouldBe` size+      )++  describe "btsSize on meta bytes" $+    it "errors out since meta bytes cannot be converted to actual bytes" $+      evaluate (btsSize (BtMeta "alpha")) `shouldThrow` anyErrorCall++  describe "hex byte decoding" $+    forM_+      [+        ( "accepts lowercase hex digits the same way as uppercase ones"+        , btsEqual (BtOne "bf") (BtOne "BF") `shouldBe` True+        )+      ,+        ( "decodes a single hex character via the fallback numeric reader"+        , btsEqual (BtOne "5") (BtOne "05") `shouldBe` True+        )+      ,+        ( "errors out on a hex digit that isn't 0-9, a-f or A-F"+        , evaluate (btsToUnescapedStr (BtOne "G1")) `shouldThrow` anyErrorCall+        )+      ,+        ( "errors out when the fallback numeric reader can't parse the byte at all"+        , evaluate (btsToUnescapedStr (BtOne "")) `shouldThrow` anyErrorCall+        )+      ]+      (uncurry it)++  describe "btsSlice" $+    forM_+      [+        ( "in range"+        , btsSlice 1 3 (BtMany ["20", "1F", "EE", "B5", "90"])+        , Just (BtMany ["1F", "EE", "B5"])+        )+      , ("out of range", btsSlice 3 10 (BtMany ["20", "1F", "EE", "B5", "90"]), Nothing)+      , ("negative start", btsSlice (-1) 2 (BtMany ["20", "1F", "EE"]), Nothing)+      , ("zero length slice", btsSlice 0 0 (BtMany ["20", "1F"]), Just BtEmpty)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "btsShift" $+    forM_+      [+        ( "positive shift crossing a bit boundary"+        , btsShift 1 (BtMany ["C0", "43", "00"])+        , BtMany ["60", "21", "80"]+        )+      , ("negative shift crossing a bit boundary", btsShift (-1) (BtMany ["01", "80"]), BtMany ["03", "00"])+      , ("shift by zero is identity", btsShift 0 (BtMany ["FF", "00"]), BtMany ["FF", "00"])+      ,+        ( "positive shift crossing a byte boundary"+        , btsShift 8 (BtMany ["01", "02", "03"])+        , BtMany ["00", "01", "02"]+        )+      ,+        ( "negative shift crossing a byte boundary"+        , btsShift (-8) (BtMany ["01", "02", "03"])+        , BtMany ["02", "03", "00"]+        )+      , ("large negative shift empties out", btsShift (-2147483648) (BtMany ["BF", "F0"]), BtMany ["00", "00"])+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))
+ test/CLIHelpersSpec.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++-- These are direct unit tests of a few 'CLI.Helpers' branches that the CLI+-- itself makes unreachable by construction: the option parser never hands+-- '--input' a 'LATEX' value, and '--output=xmir' is only ever validated+-- together with '--focus=Q', which keeps 'printExpression' out of its own+-- XMIR branch. Both branches still exist as defensive, exhaustive pattern+-- matches, so they are exercised here by calling the functions directly.+module CLIHelpersSpec (spec) where++import AST (Expression (ExRoot))+import CLI.Helpers (parseInput, printExpression)+import CLI.Types (IOFormat (LATEX, PHI, XMIR), PrintContext (PrintCtx))+import Control.Exception (SomeException, try)+import Control.Monad (forM_)+import Lining (LineFormat (MULTILINE))+import Sugar (SugarType (SWEET))+import Test.Hspec (Spec, describe, it, shouldSatisfy)+import XMIR (defaultXmirContext)++isLeft :: Either e a -> Bool+isLeft (Left _) = True+isLeft (Right _) = False++{-# ANN testPrintContext ("HLint: ignore Eta reduce" :: String) #-}+testPrintContext :: IOFormat -> PrintContext+testPrintContext format =+  PrintCtx SWEET False MULTILINE 2 defaultXmirContext False False False False False 1 1 ExRoot Nothing Nothing Nothing format++spec :: Spec+spec = do+  describe "parseInput" $+    it "fails when asked to parse LaTeX as an input format" $ do+      result <- try (parseInput "whatever" LATEX) :: IO (Either SomeException Expression)+      result `shouldSatisfy` isLeft++  describe "printExpression" $+    forM_+      [+        ( "fails when asked to print with --output=xmir (only --output=phi/latex are supported here)"+        , XMIR+        , isLeft+        )+      , ("succeeds when --output=phi is used", PHI, not . isLeft)+      ]+      ( \(desc, format, predicate) -> it desc $ do+          result <- try (printExpression (testPrintContext format) ExRoot) :: IO (Either SomeException String)+          result `shouldSatisfy` predicate+      )
test/CLISpec.hs view
@@ -7,15 +7,16 @@ module CLISpec (spec) where  import CLI (runCLI)+import CLI.Types (CmdException (..), IOFormat (..)) import Control.Exception-import Control.Monad (forM_, unless, when)+import Control.Monad (forM_, unless) import Data.List (intercalate, isInfixOf, sort) import Data.Time.Clock (addUTCTime, getCurrentTime) import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Version (showVersion) import GHC.IO.Handle import Paths_phino (version)-import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, removeFile, setModificationTime)+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, removeFile, removePathForcibly, setModificationTime) import System.Exit (ExitCode (ExitFailure)) import System.FilePath ((</>)) import System.IO@@ -79,6 +80,16 @@     hClose h     action path +-- A fresh, uniquely-named directory under the system temp directory, removed+-- afterwards even when the action throws (an assertion failure included), so a+-- red run never leaves it behind for the next run to depend on.+withTempDirectory :: String -> (FilePath -> IO a) -> IO a+withTempDirectory prefix action = do+  tmp <- getTemporaryDirectory+  stamp <- getPOSIXTime+  let dir = tmp </> (prefix ++ "-" ++ show (round (stamp * 1000000) :: Integer))+  bracket (pure dir) removePathForcibly action+ readUtf8 :: FilePath -> IO String readUtf8 path =   withFile path ReadMode $ \stream -> do@@ -126,79 +137,86 @@       ["--help"]       ["Phino - CLI Manipulator of 𝜑-Calculus Expressions", "Usage:"] -  describe "--pin" $ do-    it "succeeds when --pin matches actual version" $-      withStdin "[[ ]]" $-        testCLISucceeded-          ["--pin=" ++ showVersion version, "rewrite", "--sweet"]-          ["⟦⟧"]--    it "fails when --pin doesn't match actual version" $-      withStdin "[[ ]]" $-        testCLIFailed-          ["--pin=9.9.9.9", "rewrite"]-          ["Version mismatch: --pin requires '9.9.9.9', but this is phino " ++ showVersion version]--    it "fails when --pin is empty" $-      withStdin "[[ ]]" $-        testCLIFailed-          ["--pin=", "rewrite"]-          ["Version mismatch: --pin requires ''"]--  describe "--hide-rho" $ do-    it "drops every rho binding from the default salty output" $-      withStdin "[[ foo -> [[ x -> [[ ]], ^ -> $.y ]], y -> [[ ]] ]]" $-        testCLISucceeded-          ["rewrite", "--flat", "--hide-rho"]-          ["⟦ foo ↦ ⟦ x ↦ ⟦⟧ ⟧, y ↦ ⟦⟧ ⟧"]--    it "also drops the rho that --sweet leaves behind" $-      withStdin "[[ foo -> [[ x -> [[ ]], ^ -> $.y ]], y -> [[ ]] ]]" $-        testCLISucceeded-          ["rewrite", "--flat", "--sweet", "--hide-rho"]-          ["⟦ foo ↦ ⟦ x ↦ ⟦⟧ ⟧, y ↦ ⟦⟧ ⟧"]+  describe "--pin" $+    forM_+      [+        ( "succeeds when --pin matches actual version"+        , ["--pin=" ++ showVersion version, "rewrite", "--sweet"]+        , testCLISucceeded+        , ["⟦⟧"]+        )+      ,+        ( "fails when --pin doesn't match actual version"+        , ["--pin=9.9.9.9", "rewrite"]+        , testCLIFailed+        , ["Version mismatch: --pin requires '9.9.9.9', but this is phino " ++ showVersion version]+        )+      ,+        ( "fails when --pin is empty"+        , ["--pin=", "rewrite"]+        , testCLIFailed+        , ["Version mismatch: --pin requires ''"]+        )+      ]+      (\(desc, args, test, expected) -> it desc (withStdin "[[ ]]" (test args expected))) -    it "keeps sweet numeric literals intact" $-      withStdin "[[ a -> 42 ]]" $-        testCLISucceeded-          ["rewrite", "--flat", "--sweet", "--hide-rho"]-          ["⟦ a ↦ 42 ⟧"]+  describe "--hide-rho" $+    forM_+      [+        ( "drops every rho binding from the default salty output"+        , "[[ foo -> [[ x -> [[ ]], ^ -> $.y ]], y -> [[ ]] ]]"+        , ["rewrite", "--flat", "--hide-rho"]+        , ["⟦ foo ↦ ⟦ x ↦ ⟦⟧ ⟧, y ↦ ⟦⟧ ⟧"]+        )+      ,+        ( "also drops the rho that --sweet leaves behind"+        , "[[ foo -> [[ x -> [[ ]], ^ -> $.y ]], y -> [[ ]] ]]"+        , ["rewrite", "--flat", "--sweet", "--hide-rho"]+        , ["⟦ foo ↦ ⟦ x ↦ ⟦⟧ ⟧, y ↦ ⟦⟧ ⟧"]+        )+      ,+        ( "keeps sweet numeric literals intact"+        , "[[ a -> 42 ]]"+        , ["rewrite", "--flat", "--sweet", "--hide-rho"]+        , ["⟦ a ↦ 42 ⟧"]+        )+      ]+      (\(desc, input, args, expected) -> it desc (withStdin input (testCLISucceeded args expected)))    it "prints debug info with --log-level=DEBUG" $     withStdin "[[]]" $       testCLISucceeded ["rewrite", "--log-level=DEBUG"] ["[DEBUG]:"] -  describe "rewriting" $ do-    describe "fails" $ do-      it "with --input=latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--input=latex"]-            ["The value 'latex' can't be used for '--input' option"]--      it "with negative --log-lines" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--log-lines=-2"]-            ["--log-lines must be >= -1"]--      it "with negative --max-depth" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--max-depth=-1"]-            ["--max-depth must be positive"]+  describe "--log-level accepts every named level" $+    forM_+      ["ERROR", "ERR", "error", "NONE", "none"]+      ( \flagValue ->+          it ("--log-level=" ++ flagValue) $+            withStdin "[[]]" $+              testCLISucceeded ["rewrite", "--log-level=" ++ flagValue] ["⟧"]+      ) -      it "with --normalize and --must=1" $-        withStdin "[[ x -> [[ y -> 5 ]].y ]].x" $-          testCLIFailed-            ["rewrite", "--max-cycles=2", "--max-depth=1", "--normalize", "--must=1"]-            ["it's expected rewriting cycles to be in range [1], but rewriting has already reached 2"]+  it "fails on an unrecognized --log-level value" $+    withStdin "[[]]" $+      testCLIFailed ["rewrite", "--log-level=verbose"] ["unknown log-level: verbose"] -      it "when --in-place is used without input file" $-        withStdin "[[ ]]" $-          testCLIFailed-            ["rewrite", "--in-place"]-            ["--in-place requires an input file"]+  describe "rewriting" $ do+    describe "fails" $ do+      forM_+        [ ("with --input=latex", "", ["rewrite", "--input=latex"], ["The value 'latex' can't be used for '--input' option"])+        , ("with negative --log-lines", "", ["rewrite", "--log-lines=-2"], ["--log-lines must be >= -1"])+        , ("with negative --max-depth", "", ["rewrite", "--max-depth=-1"], ["--max-depth must be positive"])+        , ("with zero --max-cycles", "", ["rewrite", "--max-cycles=0"], ["--max-cycles must be positive"])+        , ("with zero --meet-length", "", ["rewrite", "--output=latex", "--meet-length=0"], ["--meet-length must be positive"])+        ,+          ( "with --normalize and --must=1"+          , "[[ x -> [[ y -> 5 ]].y ]].x"+          , ["rewrite", "--max-cycles=2", "--max-depth=1", "--normalize", "--must=1"]+          , ["it's expected rewriting cycles to be in range [1], but rewriting has already reached 2"]+          )+        , ("when --in-place is used without input file", "[[ ]]", ["rewrite", "--in-place"], ["--in-place requires an input file"])+        ]+        (\(desc, input, args, expected) -> it desc (withStdin input (testCLIFailed args expected)))        it "when --in-place is used with --target" $         withTempFile "inplaceXXXXXX.phi" $ \(path, h) -> do@@ -208,35 +226,34 @@             ["rewrite", "--in-place", "--target=output.phi", path]             ["--in-place and --target cannot be used together"] -      it "when --update is used without --target" $-        withStdin "[[ ]]" $-          testCLIFailed-            ["rewrite", "--update"]-            ["--update requires --target"]--      it "when --update is used without an input file" $-        withStdin "[[ ]]" $-          testCLIFailed-            ["rewrite", "--update", "--target=output.phi"]-            ["--update requires an input file"]--      it "when --update is used with --in-place" $-        withStdin "[[ ]]" $-          testCLIFailed-            ["rewrite", "--update", "--in-place", "input.phi"]-            ["--update and --in-place cannot be used together"]--      it "with --depth-sensitive" $-        withStdin "[[ x -> \"x\"]]" $-          testCLIFailed-            ["rewrite", "--depth-sensitive", "--max-depth=1", "--max-cycles=1", rule "infinite.yaml"]-            ["[ERROR]: With option --depth-sensitive it's expected rewriting iterations amount does not reach the limit: --max-depth=1"]--      it "with looping rules" $-        withStdin "[[ x -> \"0\" ]]" $-          testCLIFailed-            ["rewrite", rule "first.yaml", rule "second.yaml", "--max-depth=1", "--max-cycles=3"]-            ["it seems rewriting is looping"]+      forM_+        [ ("when --update is used without --target", "[[ ]]", ["rewrite", "--update"], ["--update requires --target"])+        ,+          ( "when --update is used without an input file"+          , "[[ ]]"+          , ["rewrite", "--update", "--target=output.phi"]+          , ["--update requires an input file"]+          )+        ,+          ( "when --update is used with --in-place"+          , "[[ ]]"+          , ["rewrite", "--update", "--in-place", "input.phi"]+          , ["--update and --in-place cannot be used together"]+          )+        ,+          ( "with --depth-sensitive"+          , "[[ x -> \"x\"]]"+          , ["rewrite", "--depth-sensitive", "--max-depth=1", "--max-cycles=1", rule "infinite.yaml"]+          , ["[ERROR]: With option --depth-sensitive it's expected rewriting iterations amount does not reach the limit: --max-depth=1"]+          )+        ,+          ( "with looping rules"+          , "[[ x -> \"0\" ]]"+          , ["rewrite", rule "first.yaml", rule "second.yaml", "--max-depth=1", "--max-cycles=3"]+          , ["it seems rewriting is looping"]+          )+        ]+        (\(desc, input, args, expected) -> it desc (withStdin input (testCLIFailed args expected)))        -- Only assert the stable parts of the parse error: phino's envelope and       -- that megaparsec reports an 'unexpected' token. The exact line:column and@@ -250,113 +267,63 @@           , "unexpected"           ] -      it "with --output != latex and --nonumber" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--nonumber", "--output=xmir"]-            ["The --nonumber option can stay together with --output=latex only"]--      it "with --omit-listing and --output != xmir" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--omit-listing", "--output=phi"]-            ["--omit-listing"]--      it "with --omit-comments and --output != xmir" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--omit-comments", "--output=phi"]-            ["--omit-comments"]--      it "with --expression and --output != latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--expression=foo", "--output=phi"]-            ["--expression option can stay together with --output=latex only"]--      it "with --label and --output != latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--label=foo", "--output=phi"]-            ["--label option can stay together with --output=latex only"]--      it "with --compress and --output != latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--compress", "--output=phi"]-            ["--compress option can stay together with --output=latex only"]--      it "with --meet-prefix and --output != latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--meet-prefix=foo", "--output=phi"]-            ["--meet-prefix option can stay together with --output=latex only"]--      it "with wrong --hide option" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--hide=Q.x(Q.y)"]-            ["[ERROR]: Invalid set of arguments: Only dispatch expression", "but given: Φ.x( Φ.y )"]--      it "with many --show options" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--show=Q.x.y", "--show=hello"]-            ["The option --show can be used only once"]--      it "with wrong --show option" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--show=Q.x(Q.y)"]-            ["[ERROR]:", "Only dispatch expression started with Φ (or Q) can be used in --show"]--      it "with --meet-popularity < 0" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--meet-popularity=-1"]-            ["[ERROR]:", "--meet-popularity must be positive"]--      it "with --meet-popularity > 100" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--meet-popularity=102"]-            ["[ERROR]:", "--meet-popularity must be <= 100"]--      it "with --meet-popularity and output != latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--meet-popularity=51", "--output=phi"]-            ["[ERROR]:", "--meet-popularity option can stay together with --output=latex only"]--      it "with --meet-length and output != latex" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--meet-length=4", "--output=phi"]-            ["[ERROR]:", "--meet-length option can stay together with --output=latex only"]--      it "with non-dispatch --focus" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--focus=Q.x(Q.y)"]-            ["[ERROR]"]--      it "with --focus!=Q and --output=XMIR" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--focus=Q.x", "--output=xmir"]-            ["[ERROR]"]--      it "with --margin < 0" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--margin=-1"]-            ["[ERROR]"]--      it "with --breakpoint which does not exist across the rules" $-        withStdin "" $-          testCLIFailed-            ["rewrite", "--breakpoint=hello", "--normalize"]-            ["[ERROR]"]+      forM_+        [+          ( "with --output != latex and --nonumber"+          , ["rewrite", "--nonumber", "--output=xmir"]+          , ["The --nonumber option can stay together with --output=latex only"]+          )+        , ("with --omit-listing and --output != xmir", ["rewrite", "--omit-listing", "--output=phi"], ["--omit-listing"])+        , ("with --omit-comments and --output != xmir", ["rewrite", "--omit-comments", "--output=phi"], ["--omit-comments"])+        ,+          ( "with --expression and --output != latex"+          , ["rewrite", "--expression=foo", "--output=phi"]+          , ["--expression option can stay together with --output=latex only"]+          )+        ,+          ( "with --label and --output != latex"+          , ["rewrite", "--label=foo", "--output=phi"]+          , ["--label option can stay together with --output=latex only"]+          )+        ,+          ( "with --compress and --output != latex"+          , ["rewrite", "--compress", "--output=phi"]+          , ["--compress option can stay together with --output=latex only"]+          )+        ,+          ( "with --meet-prefix and --output != latex"+          , ["rewrite", "--meet-prefix=foo", "--output=phi"]+          , ["--meet-prefix option can stay together with --output=latex only"]+          )+        ,+          ( "with wrong --hide option"+          , ["rewrite", "--hide=Q.x(Q.y)"]+          , ["[ERROR]: Invalid set of arguments: Only dispatch expression", "but given: Φ.x( Φ.y )"]+          )+        , ("with many --show options", ["rewrite", "--show=Q.x.y", "--show=hello"], ["The option --show can be used only once"])+        ,+          ( "with wrong --show option"+          , ["rewrite", "--show=Q.x(Q.y)"]+          , ["[ERROR]:", "Only dispatch expression started with Φ (or Q) can be used in --show"]+          )+        , ("with --meet-popularity < 0", ["rewrite", "--meet-popularity=-1"], ["[ERROR]:", "--meet-popularity must be positive"])+        , ("with --meet-popularity > 100", ["rewrite", "--meet-popularity=102"], ["[ERROR]:", "--meet-popularity must be <= 100"])+        ,+          ( "with --meet-popularity and output != latex"+          , ["rewrite", "--meet-popularity=51", "--output=phi"]+          , ["[ERROR]:", "--meet-popularity option can stay together with --output=latex only"]+          )+        ,+          ( "with --meet-length and output != latex"+          , ["rewrite", "--meet-length=4", "--output=phi"]+          , ["[ERROR]:", "--meet-length option can stay together with --output=latex only"]+          )+        , ("with non-dispatch --focus", ["rewrite", "--focus=Q.x(Q.y)"], ["[ERROR]"])+        , ("with --focus!=Q and --output=XMIR", ["rewrite", "--focus=Q.x", "--output=xmir"], ["[ERROR]"])+        , ("with --margin < 0", ["rewrite", "--margin=-1"], ["[ERROR]"])+        , ("with --breakpoint which does not exist across the rules", ["rewrite", "--breakpoint=hello", "--normalize"], ["[ERROR]"])+        ]+        (\(desc, args, expected) -> it desc (withStdin "" (testCLIFailed args expected)))      it "prints help" $       testCLISucceeded@@ -396,40 +363,46 @@           ["rewrite", "--seed=abc"]           ["[ERROR]"] -    it "saves steps to dir with --steps-dir" $ do-      let dir = "test-steps-temp"-      dirExists <- doesDirectoryExist dir-      when dirExists (removeDirectoryRecursive dir)-      withStdin "[[ x -> \"hello\"]]" $ do-        testCLISucceeded-          ["rewrite", rule "infinite.yaml", "--max-cycles=2", "--max-depth=2", "--steps-dir=" ++ dir, "--sweet"]-          ["hello_hi_hi"]-        (`shouldBe` True) <$> doesDirectoryExist dir-        files <- listDirectory dir-        length files `shouldBe` 4-        (`shouldBe` True) <$> doesFileExist (dir ++ "/00001.phi")-        (`shouldBe` True) <$> doesFileExist (dir ++ "/00003.phi")-        removeDirectoryRecursive dir+    it "saves steps to dir with --steps-dir" $+      withTempDirectory "phino-steps" $ \dir ->+        withStdin "[[ x -> \"hello\"]]" $ do+          testCLISucceeded+            ["rewrite", rule "infinite.yaml", "--max-cycles=2", "--max-depth=2", "--steps-dir=" ++ dir, "--sweet"]+            ["hello_hi_hi"]+          doesDirectoryExist dir `shouldReturn` True+          files <- listDirectory dir+          length files `shouldBe` 4+          doesFileExist (dir ++ "/00001.phi") `shouldReturn` True+          doesFileExist (dir ++ "/00003.phi") `shouldReturn` True -    it "saves dataize steps to dir with --steps-dir" $ do-      let dir = "test-steps-temp-dataize"-      dirExists <- doesDirectoryExist dir-      when dirExists (removeDirectoryRecursive dir)-      withStdin "[[ bytes(data) -> [[ @ -> $.data ]], number(as-bytes) -> [[ @ -> $.as-bytes, plus(x) -> [[ L> L_number_plus ]] ]], @ -> 5.plus(6) ]]" $ do-        testCLISucceeded-          ["dataize", "--steps-dir=" ++ dir, "--sweet"]-          ["40-26"]-        (`shouldBe` True) <$> doesDirectoryExist dir-        files <- listDirectory dir-        let steps = sort files-        -- The fix is about numbering, not about a specific rule set: the file-        -- names must be distinct and contiguous from 00001, and there must be-        -- more of them than a single normalization pass produces (this input-        -- runs several normalizations, so a global counter yields more steps).-        steps `shouldBe` map (\n -> printf "%05d.phi" (n :: Int)) [1 .. length steps]-        length steps `shouldSatisfy` (> 18)-        removeDirectoryRecursive dir+    it "saves dataize steps to dir with --steps-dir" $+      withTempDirectory "phino-steps-dataize" $ \dir ->+        withStdin "[[ bytes(data) -> [[ @ -> $.data ]], number(as-bytes) -> [[ @ -> $.as-bytes, plus(x) -> [[ L> L_number_plus ]] ]], @ -> 5.plus(6) ]]" $ do+          testCLISucceeded+            ["dataize", "--steps-dir=" ++ dir, "--sweet"]+            ["40-26"]+          doesDirectoryExist dir `shouldReturn` True+          files <- listDirectory dir+          let steps = sort files+          -- The fix is about numbering, not about a specific rule set: the file+          -- names must be distinct and contiguous from 00001, and there must be+          -- more of them than a single normalization pass produces (this input+          -- runs several normalizations, so a global counter yields more steps).+          steps `shouldBe` map (\n -> printf "%05d.phi" (n :: Int)) [1 .. length steps]+          length steps `shouldSatisfy` (> 18) +    it "saves steps with a .tex extension when --output=latex is used with --steps-dir" $+      withTempDirectory "phino-steps-latex" $ \dir ->+        withStdin "[[ x -> \"hello\"]]" $ do+          testCLISucceeded+            ["rewrite", rule "infinite.yaml", "--max-cycles=2", "--max-depth=2", "--steps-dir=" ++ dir, "--output=latex", "--sweet"]+            ["\\begin{phiquation}"]+          doesDirectoryExist dir `shouldReturn` True+          files <- listDirectory dir+          length files `shouldBe` 4+          doesFileExist (dir ++ "/00001.tex") `shouldReturn` True+          doesFileExist (dir ++ "/00003.tex") `shouldReturn` True+     it "desugares without any rules flag from file" $       testCLISucceeded         ["rewrite", resource "desugar.phi"]@@ -921,6 +894,32 @@           content <- readFile tgt           content `shouldBe` "ORIGINAL" +    it "logs the skip reason at debug level when --update finds a newer target" $+      withTempFileContent "src-XXXXXX.phi" "[[ x -> \"foo\" ]]" $ \src ->+        withTempFileContent "tgt-XXXXXX.phi" "ORIGINAL" $ \tgt -> do+          now <- getCurrentTime+          setModificationTime src (addUTCTime (-60) now)+          setModificationTime tgt now+          testCLISucceeded+            ["rewrite", rule "simple.yaml", "--update", "--sweet", "--log-level=DEBUG", "--target=" ++ tgt, src]+            ["is newer than source", "skipping rewriting (--update)"]++    it "logs progress at debug level when printing to --target" $+      withStdin "[[ ]]" $+        withTempFile "targetXXXXXX.tmp" $ \(path, h) -> do+          hClose h+          testCLISucceeded+            ["rewrite", "--sweet", "--log-level=DEBUG", printf "--target=%s" path]+            ["The option '--target' is specified, printing to", "The command result was saved in"]++    it "logs progress at debug level when modifying a file in-place" $+      withTempFile "inplaceXXXXXX.phi" $ \(path, h) -> do+        hPutStr h "[[ x -> \"foo\" ]]"+        hClose h+        testCLISucceeded+          ["rewrite", rule "simple.yaml", "--in-place", "--sweet", "--log-level=DEBUG", path]+          ["The option '--in-place' is specified, writing back to", "was modified in-place"]+     it "rewrites with --update when source is newer than target" $       withTempFileContent "src-XXXXXX.phi" "[[ x -> \"foo\" ]]" $ \src ->         withTempFileContent "tgt-XXXXXX.phi" "ORIGINAL" $ \tgt -> do@@ -1147,6 +1146,53 @@             ["dataize", "--evaluations=evaluations.txt", "--output=latex"]             ["The --evaluations option can stay together with --output=phi only"] +    -- A placeholder formation ⟦ λ ⤍ Sym_arg_0 ⟧ standing in for a data input+    -- names an atom phino cannot fire; the run used to die on it, discarding+    -- what it had already evaluated (#1060)+    describe "--partial" $ do+      let stuck = "[[ bytes(data) -> [[ @ -> $.data ]], number(as-bytes) -> [[ @ -> $.as-bytes, plus(x) -> [[ L> L_number_plus ]], times(x) -> [[ L> L_number_times ]] ]], @ -> 2.times(3).plus([[ L> Sym_arg_0 ]]) ]]"+      it "fails on an atom that cannot fire without the flag" $+        withStdin stuck $+          testCLIFailed ["dataize", "--sweet", "--hide-rho"] ["Atom 'Sym_arg_0' does not exist"]++      it "prints the residue with the stuck application intact and exits successfully" $+        withStdin stuck $+          testCLISucceeded+            ["dataize", "--partial", "--sweet", "--hide-rho"]+            ["⟦ x ↦ ⟦ λ ⤍ Sym_arg_0 ⟧, λ ⤍ L_number_plus ⟧"]++      it "keeps what was evaluated before the stuck site in the residue" $+        withStdin stuck $+          testCLISucceeded+            ["dataize", "--partial", "--sweet"]+            ["as-bytes ↦ Φ.bytes( data ↦ ⟦ Δ ⤍ 40-18-00-00-00-00-00-00 ⟧ )"]++      it "records every stuck site in --evaluations with no result" $+        withTempFile "evaluationsXXXXXX.txt" $ \(path, stream) -> do+          hClose stream+          withStdin stuck $+            testCLISucceeded ["dataize", "--partial", "--evaluations=" ++ path, "--quiet", "--sweet", "--hide-rho"] []+          records <- readUtf8 path+          lines records+            `shouldBe` [ "L_number_times\t⟦ x ↦ 3 ⟧\t6"+                       , "Sym_arg_0\t⟦⟧"+                       , "L_number_plus\t⟦ x ↦ ⟦ λ ⤍ Sym_arg_0 ⟧ ⟧"+                       ]++      it "still prints bytes when nothing gets stuck" $+        withStdin "[[ bytes(data) -> [[ @ -> $.data ]], number(as-bytes) -> [[ @ -> $.as-bytes, plus(x) -> [[ L> L_number_plus ]] ]], @ -> 5.plus(6) ]]" $+          testCLISucceeded ["dataize", "--partial"] ["40-26-00-00-00-00-00-00"]++      it "prints the chain of steps ending in the residue with --sequence" $+        withStdin stuck $+          testCLISucceeded+            ["dataize", "--partial", "--sequence", "--sweet", "--hide-rho", "--flat"]+            ["2.times( 3 ).plus( ⟦ λ ⤍ Sym_arg_0 ⟧ )", "⟦ x ↦ ⟦ λ ⤍ Sym_arg_0 ⟧, λ ⤍ L_number_plus ⟧"]++      it "still stops on the terminator ⊥, since a wrong operand is not a stuck atom" $+        withStdin "[[ ]]" $+          testCLIFailed ["dataize", "--partial"] ["terminator ⊥"]+     describe "fails" $ do       it "with --output != latex and --nonumber" $         withStdin "" $@@ -1184,7 +1230,34 @@             ["dataize", "--hide=Q.x(Q.y)"]             ["[ERROR]: Invalid set of arguments: Only dispatch expression", "but given: Φ.x( Φ.y )"] +      it "with wrong --show option" $+        withStdin "" $+          testCLIFailed+            ["dataize", "--show=Q.x(Q.y)"]+            ["[ERROR]:", "Only dispatch expression started with Φ (or Q) can be used in --show"]++      it "with wrong --locator option" $+        withStdin "" $+          testCLIFailed+            ["dataize", "--locator=Q.x(Q.y)"]+            ["[ERROR]:", "Only dispatch expression started with Φ (or Q) can be used in --locator"]++      it "with wrong --focus option" $+        withStdin "" $+          testCLIFailed+            ["dataize", "--focus=Q.x(Q.y)"]+            ["[ERROR]:", "Only dispatch expression started with Φ (or Q) can be used in --focus"]++    it "accepts --depth-sensitive" $+      withStdin "[[ D> 01- ]]" $+        testCLISucceeded ["dataize", "--depth-sensitive"] ["01-"]+   describe "explain" $ do+    it "prints help" $+      testCLISucceeded+        ["explain", "--help"]+        ["Explain built-in morphing rules", "Explain built-in dataization rules", "Explain built-in contextualization rules"]+     it "explains single rule" $       testCLISucceeded         ["explain", "--rule=resources/normalize/copy.yaml"]@@ -1481,6 +1554,9 @@         )    describe "merge" $ do+    it "prints help" $+      testCLISucceeded ["merge", "--help"] ["Paths to input files"]+     it "merges single expression" $       testCLISucceeded         ["merge", resource "desugar.phi", "--sweet", "--flat"]@@ -1519,7 +1595,17 @@         ["merge"]         ["At least one input file must be specified for 'merge' command"] +    it "merges and prints as XMIR, with the listing rendered from the merged expression" $+      testCLISucceeded+        ["merge", resource "desugar.phi", "--output=xmir"]+        ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>", "<listing>⟦ foo ↦ ξ.x, ρ ↦ ∅ ⟧</listing>", "<o base=\"ξ.x\" name=\"foo\"/>"]+   describe "match" $ do+    it "prints help" $+      testCLISucceeded+        ["match", "--help"]+        ["Pattern expression to match against", "Predicate for matched substitutions"]+     it "takes from stdin" $       withStdin "[[]]" $         testCLISucceeded ["match", "--log-level=debug"] ["[DEBUG]"]@@ -1565,3 +1651,37 @@         testCLIFailed           ["match", "--pattern=$.!t"]           ["[ERROR]"]++  describe "CmdException Show instance" $+    forM_+      [ ("InvalidCLIArguments", InvalidCLIArguments "bad flag", "Invalid set of arguments: bad flag")+      , ("CouldNotReadFromStdin", CouldNotReadFromStdin "broken pipe", "Could not read input from stdin\nReason: broken pipe")+      , ("CouldNotDataize", CouldNotDataize, "Could not dataize given expression")+      ,+        ( "CouldNotPrintExpressionInXMIR"+        , CouldNotPrintExpressionInXMIR+        , "Could not print expression with --output=xmir, only expression printing is allowed"+        )+      , ("EmptySubstsOnMatch", EmptySubstsOnMatch, "Provided pattern was not matched, no substitutions are built")+      ,+        ( "VersionMismatch"+        , VersionMismatch "1.2.3" "4.5.6"+        , "Version mismatch: --pin requires '1.2.3', but this is phino 4.5.6"+        )+      ]+      ( \(desc, exception, expected) ->+          it (desc ++ " renders its message") $ do+            show exception `shouldBe` expected+            displayException exception `shouldBe` expected+      )++  describe "IOFormat Show instance" $+    forM_+      [ ("XMIR", XMIR, "xmir")+      , ("PHI", PHI, "phi")+      , ("LATEX", LATEX, "latex")+      ]+      ( \(desc, format, expected) ->+          it (desc ++ " renders as " ++ expected) $+            show format `shouldBe` expected+      )
+ test/CLITypesSpec.hs view
@@ -0,0 +1,45 @@+-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module CLITypesSpec (spec) where++import CLI.Types+import Control.Monad (forM_)+import Test.Hspec++spec :: Spec+spec = do+  describe "Show CmdException" $+    forM_+      [ ("InvalidCLIArguments", show (InvalidCLIArguments "bad args"), "Invalid set of arguments: bad args")+      ,+        ( "CouldNotReadFromStdin"+        , show (CouldNotReadFromStdin "reason")+        , "Could not read input from stdin\nReason: reason"+        )+      , ("CouldNotDataize", show CouldNotDataize, "Could not dataize given expression")+      ,+        ( "CouldNotPrintExpressionInXMIR"+        , show CouldNotPrintExpressionInXMIR+        , "Could not print expression with --output=xmir, only expression printing is allowed"+        )+      ,+        ( "EmptySubstsOnMatch"+        , show EmptySubstsOnMatch+        , "Provided pattern was not matched, no substitutions are built"+        )+      ,+        ( "VersionMismatch"+        , show (VersionMismatch "1.0.0" "2.0.0")+        , "Version mismatch: --pin requires '1.0.0', but this is phino 2.0.0"+        )+      ]+      (\(name, actual, expected) -> it ("shows " ++ name) (actual `shouldBe` expected))++  describe "Show IOFormat" $+    forM_+      [ (XMIR, "xmir")+      , (PHI, "phi")+      , (LATEX, "latex")+      ]+      (\(format, expected) -> it ("shows " ++ show format) (show format `shouldBe` expected))
test/CSTSpec.hs view
@@ -1,5 +1,7 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-}  -- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com@@ -23,6 +25,7 @@ import Sugar import System.FilePath import Test.Hspec+import Yaml qualified as Y  data CSTPack = CSTPack   { expression :: String@@ -124,3 +127,669 @@               ascii = withLineFormat SINGLELINE cst           render ascii `shouldBe` result pack       )++  describe "expressionToCSTFrom lays out a formation from a given base indent" $+    it "nests the body one level below the given tabs and closes at it" $+      expressionToCSTFrom 2 (ExFormation [BiTau (AtLabel "x") ExRoot])+        `shouldBe` EX_FORMATION+          LSB+          EOL+          (TAB 3)+          (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW (EX_GLOBAL Φ)) (BDS_EMPTY (TAB 3)) (TAB 3))+          EOL+          (TAB 2)+          RSB++  describe "sweetNumber" $+    forM_+      [ ("is true for a finite integral value", BtMany ["40", "45", "00", "00", "00", "00", "00", "00"], True)+      , ("is true for a finite fractional value", BtMany ["BF", "D0", "00", "00", "00", "00", "00", "00"], True)+      , ("is false for NaN", BtMany ["7F", "F8", "00", "00", "00", "00", "00", "00"], False)+      , ("is false for positive infinity", BtMany ["7F", "F0", "00", "00", "00", "00", "00", "00"], False)+      , ("is false for negative infinity", BtMany ["FF", "F0", "00", "00", "00", "00", "00", "00"], False)+      ]+      (\(desc, bts, expected) -> it desc (sweetNumber bts `shouldBe` expected))++  describe "sweetCollapsible" $+    forM_+      [+        ( "delegates to sweetNumber for a data number"+        , DataNumber (BtMany ["7F", "F8", "00", "00", "00", "00", "00", "00"])+        , False+        )+      ,+        ( "is true for a data number with a sweet literal"+        , DataNumber (BtMany ["40", "45", "00", "00", "00", "00", "00", "00"])+        , True+        )+      , ("is true for any other expression", ExXi, True)+      ]+      (\(desc, expressionValue, expected) -> it desc (sweetCollapsible expressionValue `shouldBe` expected))++  describe "metaTail drops the leading kind character" $+    forM_+      [ ("single char meta", "x", "")+      , ("two char meta", "ex", "x")+      ]+      (\(desc, metaName, expected) -> it desc (metaTail metaName `shouldBe` expected))++  describe "exMetaHead classifies a meta name by its leading character" $+    forM_+      [ ("n-prefixed becomes a normal-form meta", "nx", N)+      , ("k-prefixed becomes an absolute meta", "kx", K)+      , ("anything else becomes an ordinary meta", "ex", E)+      , ("a name with neither prefix also becomes ordinary", "tx", E)+      ]+      (\(desc, metaName, expected) -> it desc (exMetaHead metaName `shouldBe` expected))++  describe "expressionToCST on rendering-only and meta nodes" $+    forM_+      [ ("ExBytes becomes a bare EX_BYTES chain node", ExBytes (BtOne "1F"), EX_BYTES (BT_ONE "1F"))+      , ("an n-prefixed ExMeta becomes a normal-form meta", ExMeta "nX", EX_META (META NO_EXCL N "X"))+      , ("a k-prefixed ExMeta becomes an absolute meta", ExMeta "kX", EX_META (META NO_EXCL K "X"))+      , ("any other ExMeta becomes an ordinary meta", ExMeta "eX", EX_META (META NO_EXCL E "X"))+      , ("ExPhiMeet keeps its prefix", ExPhiMeet (Just "p") 3 ExXi, EX_PHI_MEET (Just "p") 3 (EX_XI XI))+      , ("ExPhiAgain keeps its prefix", ExPhiAgain (Just "p") 3 ExXi, EX_PHI_AGAIN (Just "p") 3 (EX_XI XI))+      ]+      (\(desc, expressionValue, expected) -> it desc (expressionToCST expressionValue `shouldBe` expected))++  describe "attributeToCST and bindingsToCST" $ do+    forM_+      [ ("attributeToCST on a label", AtLabel "x", AT_LABEL "x")+      , ("attributeToCST on rho", AtRho, AT_RHO RHO)+      ]+      (\(desc, attribute, expected) -> it desc (attributeToCST attribute `shouldBe` expected))++    forM_+      [ ("bindingsToCST on an empty list", [], BI_EMPTY (TAB 0))+      ,+        ( "bindingsToCST on a single binding"+        , [BiVoid (AtLabel "y")]+        , BI_PAIR (PA_VOID (AT_LABEL "y") ARROW EMPTY) (BDS_EMPTY (TAB 0)) (TAB 0)+        )+      ,+        ( "bindingsToCST on a meta binding collapses the whole list to BI_META"+        , [BiMeta "B"]+        , BI_META (META NO_EXCL B "") (BDS_EMPTY (TAB 0)) (TAB 0)+        )+      ,+        ( "bindingsToCST on a delta binding"+        , [BiDelta (BtOne "01")]+        , BI_PAIR (PA_DELTA (BT_ONE "01")) (BDS_EMPTY (TAB 0)) (TAB 0)+        )+      ,+        ( "bindingsToCST recurses into a meta binding that is not the head"+        , [BiVoid (AtLabel "x"), BiMeta "B"]+        , BI_PAIR+            (PA_VOID (AT_LABEL "x") ARROW EMPTY)+            (BDS_META EOL (TAB 0) (META NO_EXCL B "") (BDS_EMPTY (TAB 0)))+            (TAB 0)+        )+      ]+      (\(desc, bindingsList, expected) -> it desc (bindingsToCST bindingsList `shouldBe` expected))++  describe "expressionToCST on formation and dispatch edge cases" $ do+    it+      "a single void rho binding collapses to the same CST as an empty formation"+      (expressionToCST (ExFormation [BiVoid AtRho]) `shouldBe` expressionToCST (ExFormation []))+    it+      "dispatch on Xi becomes a bare attribute"+      (expressionToCST (ExDispatch ExXi (AtLabel "foo")) `shouldBe` EX_ATTR (AT_LABEL "foo"))++  describe "expressionToCST on non-primitive applications" $ do+    it+      "an application with only named (tau) arguments and no primitive base"+      ( expressionToCST+          ( ExApplication+              (ExApplication (ExDispatch ExRoot (AtLabel "bar")) (ArTau (AtLabel "x") ExRoot))+              (ArTau (AtLabel "y") ExXi)+          )+          `shouldSatisfy` isApplicationWithTaus+      )+    it+      "an application with positional (alpha) arguments and no primitive base"+      ( expressionToCST+          ( ExApplication+              (ExApplication (ExDispatch ExRoot (AtLabel "bar")) (ArAlpha (Alpha 0) ExRoot))+              (ArAlpha (Alpha 1) ExXi)+          )+          `shouldSatisfy` isApplicationWithExprs+      )++  describe "conditionToCST on every Y.Condition constructor" $ do+    let voidYBinding :: BINDING+        voidYBinding = BI_PAIR (PA_VOID (AT_LABEL "y") ARROW EMPTY) (BDS_EMPTY (TAB 0)) (TAB 0)+    forM_+      [ ("In", Y.In (AtLabel "x") (BiVoid (AtLabel "y")), CO_BELONGS (AT_LABEL "x") IN (ST_BINDING voidYBinding))+      ,+        ( "Not (In ...) flips the belonging"+        , Y.Not (Y.In (AtLabel "x") (BiVoid (AtLabel "y")))+        , CO_BELONGS (AT_LABEL "x") NOT_IN (ST_BINDING voidYBinding)+        )+      ,+        ( "Eq"+        , Y.Eq (Y.CmpAttr (AtLabel "x")) (Y.CmpNum (Y.Literal 3))+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) EQUAL (CMP_NUM (LITERAL 3))+        )+      ,+        ( "Not (Eq ...) becomes a not-equal comparison"+        , Y.Not (Y.Eq (Y.CmpAttr (AtLabel "x")) (Y.CmpNum (Y.Literal 3)))+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) NOT_EQUAL (CMP_NUM (LITERAL 3))+        )+      ,+        ( "Gt"+        , Y.Gt (Y.CmpAttr (AtLabel "x")) (Y.CmpNum (Y.Literal 3))+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) GREATER (CMP_NUM (LITERAL 3))+        )+      ,+        ( "Not (Gt ...) becomes a not-greater comparison"+        , Y.Not (Y.Gt (Y.CmpAttr (AtLabel "x")) (Y.CmpNum (Y.Literal 3)))+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) NOT_GREATER (CMP_NUM (LITERAL 3))+        )+      , ("Absolute", Y.Absolute ExXi, CO_ABSOLUTE (EX_XI XI) IN)+      , ("Not (Absolute ...) flips membership", Y.Not (Y.Absolute ExXi), CO_ABSOLUTE (EX_XI XI) NOT_IN)+      , ("Disjoint", Y.Disjoint [AtLabel "a"] [BiVoid (AtLabel "y")], CO_DISJOINT [AT_LABEL "a"] [voidYBinding])+      , ("And on an empty list collapses to CO_EMPTY", Y.And [], CO_EMPTY)+      , ("And on a non-empty list wraps every condition", Y.And [Y.NF ExXi], CO_LOGIC [CO_NF (EX_XI XI)] AND)+      , ("Or on an empty list collapses to CO_EMPTY", Y.Or [], CO_EMPTY)+      , ("Or on a non-empty list wraps every condition", Y.Or [Y.NF ExXi], CO_LOGIC [CO_NF (EX_XI XI)] OR)+      , ("NF", Y.NF ExXi, CO_NF (EX_XI XI))+      ,+        ( "Not on any other condition falls back to a generic negation"+        , Y.Not (Y.NF ExXi)+        , CO_NOT (CO_NF (EX_XI XI))+        )+      , ("Matches", Y.Matches "abc" ExXi, CO_MATCHES "abc" (EX_XI XI))+      , ("PartOf", Y.PartOf ExXi (BiVoid (AtLabel "y")), CO_PART_OF (EX_XI XI) voidYBinding)+      , ("IsFormation", Y.IsFormation ExXi, CO_FORMATION (EX_XI XI))+      ]+      (\(desc, yamlCondition, expected) -> it desc (conditionToCST yamlCondition `shouldBe` expected))++  describe "comparableToCST on every Y.Comparable constructor" $+    forM_+      [ ("CmpAttr", Y.CmpAttr (AtLabel "x"), CMP_ATTR (AT_LABEL "x"))+      , ("CmpExpr", Y.CmpExpr ExXi, CMP_EXPR (EX_XI XI))+      , ("CmpNum", Y.CmpNum (Y.Literal 3), CMP_NUM (LITERAL 3))+      ]+      (\(desc, comparable, expected) -> it desc (comparableToCST comparable `shouldBe` expected))++  describe "numberToCST on every Y.Number constructor" $+    forM_+      [ ("MetaIndex", Y.MetaIndex "i1", IDX_META (META NO_EXCL I "1"))+      ,+        ( "Length"+        , Y.Length (BiVoid (AtLabel "y"))+        , LENGTH (BI_PAIR (PA_VOID (AT_LABEL "y") ARROW EMPTY) (BDS_EMPTY (TAB 0)) (TAB 0))+        )+      ,+        ( "Domain"+        , Y.Domain (BiVoid (AtLabel "y"))+        , DOMAIN (BI_PAIR (PA_VOID (AT_LABEL "y") ARROW EMPTY) (BDS_EMPTY (TAB 0)) (TAB 0))+        )+      , ("Literal", Y.Literal 5, LITERAL 5)+      ]+      (\(desc, number, expected) -> it desc (numberToCST number `shouldBe` expected))++  describe "extraToCST on every Y.ExtraArgument constructor" $+    it+      "converts the meta and every kind of argument"+      ( extraToCST+          (Y.Extra (Y.ArgAttribute (AtLabel "m")) "g" [Y.ArgExpression ExXi, Y.ArgBinding (BiVoid (AtLabel "y")), Y.ArgBytes (BtOne "1F")])+          `shouldBe` EXTRA+            (ARG_ATTR (AT_LABEL "m"))+            "g"+            [ ARG_EXPR (EX_XI XI)+            , ARG_BINDING (BI_PAIR (PA_VOID (AT_LABEL "y") ARROW EMPTY) (BDS_EMPTY (TAB 0)) (TAB 0))+            , ARG_BYTES (BT_ONE "1F")+            ]+      )++  -- This codebase always destructures CST nodes via RecordWildCards/pattern+  -- matching, never by calling a field's named accessor directly, and never+  -- calls '==' or 'show' on a bare CST node either. HPC instruments every+  -- derived accessor, and every derived Eq/Show instance, as its own+  -- top-level declaration, so those stay uncovered by the line-based+  -- coverage metric no matter how many tests render or pattern-match CST+  -- trees. The tests below call every accessor by name (via record-dot+  -- syntax, since these types share many field names and a bare call like+  -- 'tab node' stays ambiguous even with DuplicateRecordFields) and invoke+  -- 'show'/'==' on one value of every node type to close that gap.+  describe "CST token derived instances" $+    forM_+      [ ("LCB", shouldShowAndEqSelf "LCB" LCB)+      , ("BIG_LCB", shouldShowAndEqSelf "BIG_LCB" BIG_LCB)+      , ("RCB", shouldShowAndEqSelf "RCB" RCB)+      , ("BIG_RCB", shouldShowAndEqSelf "BIG_RCB" BIG_RCB)+      , ("LSB", shouldShowAndEqSelf "LSB" LSB)+      , ("LSB'", shouldShowAndEqSelf "LSB'" LSB')+      , ("RSB", shouldShowAndEqSelf "RSB" RSB)+      , ("RSB'", shouldShowAndEqSelf "RSB'" RSB')+      , ("COMMA", shouldShowAndEqSelf "COMMA" COMMA)+      , ("NO_COMMA", shouldShowAndEqSelf "NO_COMMA" NO_COMMA)+      , ("ARROW", shouldShowAndEqSelf "ARROW" ARROW)+      , ("ARROW'", shouldShowAndEqSelf "ARROW'" ARROW')+      , ("DASHED_ARROW", shouldShowAndEqSelf "DASHED_ARROW" DASHED_ARROW)+      , ("EMPTY", shouldShowAndEqSelf "EMPTY" EMPTY)+      , ("QUESTION", shouldShowAndEqSelf "QUESTION" QUESTION)+      , ("PHI", shouldShowAndEqSelf "PHI" PHI)+      , ("AT", shouldShowAndEqSelf "AT" AT)+      , ("RHO", shouldShowAndEqSelf "RHO" RHO)+      , ("CARET", shouldShowAndEqSelf "CARET" CARET)+      , ("RHO'", shouldShowAndEqSelf "RHO'" RHO')+      , ("DELTA", shouldShowAndEqSelf "DELTA" DELTA)+      , ("DELTA'", shouldShowAndEqSelf "DELTA'" DELTA')+      , ("XI", shouldShowAndEqSelf "XI" XI)+      , ("DOLLAR", shouldShowAndEqSelf "DOLLAR" DOLLAR)+      , ("XI'", shouldShowAndEqSelf "XI'" XI')+      , ("LAMBDA", shouldShowAndEqSelf "LAMBDA" LAMBDA)+      , ("LAMBDA'", shouldShowAndEqSelf "LAMBDA'" LAMBDA')+      , ("Q", shouldShowAndEqSelf "Q" Q)+      , ("DEAD", shouldShowAndEqSelf "DEAD" DEAD)+      , ("T", shouldShowAndEqSelf "T" T)+      , ("SPACE", shouldShowAndEqSelf "SPACE" SPACE)+      , ("NO_SPACE", shouldShowAndEqSelf "NO_SPACE" NO_SPACE)+      , ("EOL", shouldShowAndEqSelf "EOL" EOL)+      , ("NO_EOL", shouldShowAndEqSelf "NO_EOL" NO_EOL)+      , ("DOTS", shouldShowAndEqSelf "DOTS" DOTS)+      , ("DOTS'", shouldShowAndEqSelf "DOTS'" DOTS')+      , ("BT_EMPTY", shouldShowAndEqSelf "BT_EMPTY" BT_EMPTY)+      , ("E", shouldShowAndEqSelf "E" E)+      , ("EXCL", shouldShowAndEqSelf "EXCL" EXCL)+      , ("NO_EXCL", shouldShowAndEqSelf "NO_EXCL" NO_EXCL)+      , ("IN", shouldShowAndEqSelf "IN" IN)+      , ("NOT_IN", shouldShowAndEqSelf "NOT_IN" NOT_IN)+      , ("AND", shouldShowAndEqSelf "AND" AND)+      , ("OR", shouldShowAndEqSelf "OR" OR)+      , ("EQUAL", shouldShowAndEqSelf "EQUAL" EQUAL)+      , ("NOT_EQUAL", shouldShowAndEqSelf "NOT_EQUAL" NOT_EQUAL)+      , ("GREATER", shouldShowAndEqSelf "GREATER" GREATER)+      , ("NOT_GREATER", shouldShowAndEqSelf "NOT_GREATER" NOT_GREATER)+      ]+      (uncurry it)++  describe "META field accessors" $+    it "exposes every META field via its accessor" $ do+      let metaVal = META{excl = EXCL, hd = TAU, rest = "x"}+      metaVal.excl `shouldBe` EXCL+      metaVal.hd `shouldBe` TAU+      metaVal.rest `shouldBe` "x"+      shouldShowAndEqSelf "META" metaVal++  describe "TAB field accessors" $+    it "exposes every TAB field via its accessor" $ do+      let tabVal = TAB{indent = 3}+      tabVal.indent `shouldBe` 3+      shouldShowAndEqSelf "TAB" tabVal+      shouldShowAndEqSelf "TAB'" TAB'+      shouldShowAndEqSelf "NO_TAB" NO_TAB++  describe "ALPHA field accessors" $+    it "exposes every ALPHA field via its accessor" $ do+      let idxAlpha = AL_IDX{sym = ALPHA, idx = 2}+          metaAlpha = AL_META{sym = ALPHA', meta = META EXCL TAU "x"}+      idxAlpha.sym `shouldBe` ALPHA+      idxAlpha.idx `shouldBe` 2+      metaAlpha.sym `shouldBe` ALPHA'+      metaAlpha.meta `shouldBe` META EXCL TAU "x"+      shouldShowAndEqSelf "AL_IDX" idxAlpha+      shouldShowAndEqSelf "AL_META" metaAlpha++  describe "PAIR field accessors" $+    it "exposes every PAIR constructor's fields via their accessors" $ do+      let attrLabel = AT_LABEL "x"+          exprGlobal = EX_GLOBAL Φ+          metaVal = META EXCL TAU "x"+          pairTau = PA_TAU{attr = attrLabel, arrow = ARROW, expr = exprGlobal}+          pairAlpha = PA_ALPHA{alpha = AL_IDX ALPHA 0, arrow = ARROW, expr = exprGlobal}+          pairFormation = PA_FORMATION{attr = attrLabel, voids = [AT_RHO RHO], arrow = ARROW, expr = exprGlobal}+          pairVoid = PA_VOID{attr = attrLabel, arrow = ARROW, void = EMPTY}+          pairLambda = PA_LAMBDA{func = "fn"}+          pairLambda' = PA_LAMBDA'{func = "fn"}+          pairMetaLambda = PA_META_LAMBDA{meta = metaVal}+          pairMetaLambda' = PA_META_LAMBDA'{meta = metaVal}+          pairDelta = PA_DELTA{bytes = BT_ONE "40"}+          pairDelta' = PA_DELTA'{bytes = BT_ONE "40"}+          pairMetaDelta = PA_META_DELTA{meta = metaVal}+          pairMetaDelta' = PA_META_DELTA'{meta = metaVal}+      pairTau.attr `shouldBe` attrLabel+      pairTau.arrow `shouldBe` ARROW+      pairTau.expr `shouldBe` exprGlobal+      pairAlpha.alpha `shouldBe` AL_IDX ALPHA 0+      pairAlpha.arrow `shouldBe` ARROW+      pairAlpha.expr `shouldBe` exprGlobal+      pairFormation.attr `shouldBe` attrLabel+      pairFormation.voids `shouldBe` [AT_RHO RHO]+      pairFormation.arrow `shouldBe` ARROW+      pairFormation.expr `shouldBe` exprGlobal+      pairVoid.attr `shouldBe` attrLabel+      pairVoid.arrow `shouldBe` ARROW+      pairVoid.void `shouldBe` EMPTY+      pairLambda.func `shouldBe` "fn"+      pairLambda'.func `shouldBe` "fn"+      pairMetaLambda.meta `shouldBe` metaVal+      pairMetaLambda'.meta `shouldBe` metaVal+      pairDelta.bytes `shouldBe` BT_ONE "40"+      pairDelta'.bytes `shouldBe` BT_ONE "40"+      pairMetaDelta.meta `shouldBe` metaVal+      pairMetaDelta'.meta `shouldBe` metaVal+      shouldShowAndEqSelf "PA_TAU" pairTau+      shouldShowAndEqSelf "PA_ALPHA" pairAlpha+      shouldShowAndEqSelf "PA_FORMATION" pairFormation+      shouldShowAndEqSelf "PA_VOID" pairVoid+      shouldShowAndEqSelf "PA_LAMBDA" pairLambda+      shouldShowAndEqSelf "PA_LAMBDA'" pairLambda'+      shouldShowAndEqSelf "PA_META_LAMBDA" pairMetaLambda+      shouldShowAndEqSelf "PA_META_LAMBDA'" pairMetaLambda'+      shouldShowAndEqSelf "PA_DELTA" pairDelta+      shouldShowAndEqSelf "PA_DELTA'" pairDelta'+      shouldShowAndEqSelf "PA_META_DELTA" pairMetaDelta+      shouldShowAndEqSelf "PA_META_DELTA'" pairMetaDelta'++  describe "APP_BINDING field accessors" $+    it "exposes every APP_BINDING field via its accessor" $ do+      let pairTau = PA_TAU{attr = AT_LABEL "x", arrow = ARROW, expr = EX_GLOBAL Φ}+          appBinding = APP_BINDING{pair = pairTau}+      appBinding.pair `shouldBe` pairTau+      shouldShowAndEqSelf "APP_BINDING" appBinding++  describe "BINDING field accessors" $+    it "exposes every BINDING constructor's fields via their accessors" $ do+      let pairTau = PA_TAU{attr = AT_LABEL "x", arrow = ARROW, expr = EX_GLOBAL Φ}+          metaVal = META EXCL TAU "x"+          bindingsEmpty = BDS_EMPTY{tab = TAB 0}+          biPair = BI_PAIR{pair = pairTau, bindings = bindingsEmpty, tab = TAB 1}+          biMeta = BI_META{meta = metaVal, bindings = bindingsEmpty, tab = TAB 1}+      biPair.pair `shouldBe` pairTau+      biPair.bindings `shouldBe` bindingsEmpty+      biPair.tab `shouldBe` TAB 1+      biMeta.meta `shouldBe` metaVal+      biMeta.bindings `shouldBe` bindingsEmpty+      biMeta.tab `shouldBe` TAB 1+      shouldShowAndEqSelf "BI_PAIR" biPair+      shouldShowAndEqSelf "BI_META" biMeta++  describe "BINDINGS field accessors" $+    it "exposes every BINDINGS constructor's fields via their accessors" $ do+      let pairTau = PA_TAU{attr = AT_LABEL "x", arrow = ARROW, expr = EX_GLOBAL Φ}+          metaVal = META EXCL TAU "x"+          bindingsEmpty = BDS_EMPTY{tab = TAB 0}+          bdsPair = BDS_PAIR{eol = EOL, tab = TAB 1, pair = pairTau, bindings = bindingsEmpty}+          bdsMeta = BDS_META{eol = EOL, tab = TAB 1, meta = metaVal, bindings = bindingsEmpty}+      bdsPair.eol `shouldBe` EOL+      bdsPair.tab `shouldBe` TAB 1+      bdsPair.pair `shouldBe` pairTau+      bdsPair.bindings `shouldBe` bindingsEmpty+      bdsMeta.eol `shouldBe` EOL+      bdsMeta.tab `shouldBe` TAB 1+      bdsMeta.meta `shouldBe` metaVal+      bdsMeta.bindings `shouldBe` bindingsEmpty+      shouldShowAndEqSelf "BDS_PAIR" bdsPair+      shouldShowAndEqSelf "BDS_META" bdsMeta++  describe "APP_ARG field accessors" $+    it "exposes every APP_ARG field via its accessor" $ do+      let exprGlobal = EX_GLOBAL Φ+          appArgsEmpty = AAS_EMPTY+          appArg = APP_ARG{expr = exprGlobal, args = appArgsEmpty}+      appArg.expr `shouldBe` exprGlobal+      appArg.args `shouldBe` appArgsEmpty+      shouldShowAndEqSelf "APP_ARG" appArg++  describe "APP_ARGS field accessors" $+    it "exposes every APP_ARGS constructor's fields via their accessors" $ do+      let exprGlobal = EX_GLOBAL Φ+          appArgsEmpty = AAS_EMPTY+          appArgsExpr = AAS_EXPR{eol = EOL, tab = TAB 1, expr = exprGlobal, args = appArgsEmpty}+      appArgsExpr.eol `shouldBe` EOL+      appArgsExpr.tab `shouldBe` TAB 1+      appArgsExpr.expr `shouldBe` exprGlobal+      appArgsExpr.args `shouldBe` appArgsEmpty+      shouldShowAndEqSelf "AAS_EXPR" appArgsExpr++  describe "APP_ARGUMENT derived instances" $+    it "derives Eq and Show for every APP_ARGUMENT constructor" $ do+      let pairTau = PA_TAU{attr = AT_LABEL "x", arrow = ARROW, expr = EX_GLOBAL Φ}+      shouldShowAndEqSelf "AA_TAU" (AA_TAU (APP_BINDING pairTau))+      shouldShowAndEqSelf "AA_TAUS" (AA_TAUS (BI_EMPTY (TAB 0)))+      shouldShowAndEqSelf "AA_EXPRS" (AA_EXPRS (APP_ARG (EX_GLOBAL Φ) AAS_EMPTY))++  describe "EXPRESSION field accessors" $+    it "exposes every EXPRESSION constructor's fields via their accessors" $ do+      let attrLabel = AT_LABEL "x"+          bindingEmpty = BI_EMPTY{tab = TAB 1}+          metaVal = META EXCL TAU "x"+          argumentVal = AA_EXPRS (APP_ARG (EX_GLOBAL Φ) AAS_EMPTY)+          exGlobal = EX_GLOBAL{global = Φ}+          exXi = EX_XI{xi = XI}+          exAttr = EX_ATTR{attr = attrLabel}+          exTermination = EX_TERMINATION{termination = DEAD}+          exFormation =+            EX_FORMATION+              { lsb = LSB+              , eol = EOL+              , tab = TAB 1+              , binding = bindingEmpty+              , eol' = EOL+              , tab' = TAB 0+              , rsb = RSB+              }+          exDispatch = EX_DISPATCH{expr = exGlobal, space = NO_SPACE, attr = attrLabel}+          exApplication =+            EX_APPLICATION+              { expr = exGlobal+              , space = NO_SPACE+              , eol = EOL+              , tab = TAB 1+              , argument = argumentVal+              , eol' = EOL+              , tab' = TAB 0+              , indent = 1+              }+          exString = EX_STRING{str = "hi", tab = TAB 0, rhos = []}+          exNumber = EX_NUMBER{num = Left 5, tab = TAB 0, rhos = []}+          exMeta = EX_META{meta = metaVal}+          exPhiMeet = EX_PHI_MEET{prefix = Just "p", idx = 1, expr = exGlobal}+          exBytes = EX_BYTES{bytes = BT_ONE "40"}+      exGlobal.global `shouldBe` Φ+      exXi.xi `shouldBe` XI+      exAttr.attr `shouldBe` attrLabel+      exTermination.termination `shouldBe` DEAD+      exFormation.lsb `shouldBe` LSB+      exFormation.eol `shouldBe` EOL+      exFormation.tab `shouldBe` TAB 1+      exFormation.binding `shouldBe` bindingEmpty+      exFormation.eol' `shouldBe` EOL+      exFormation.tab' `shouldBe` TAB 0+      exFormation.rsb `shouldBe` RSB+      exDispatch.expr `shouldBe` exGlobal+      exDispatch.space `shouldBe` NO_SPACE+      exDispatch.attr `shouldBe` attrLabel+      exApplication.expr `shouldBe` exGlobal+      exApplication.space `shouldBe` NO_SPACE+      exApplication.eol `shouldBe` EOL+      exApplication.tab `shouldBe` TAB 1+      exApplication.argument `shouldBe` argumentVal+      exApplication.eol' `shouldBe` EOL+      exApplication.tab' `shouldBe` TAB 0+      exApplication.indent `shouldBe` 1+      exString.str `shouldBe` "hi"+      exString.tab `shouldBe` TAB 0+      exString.rhos `shouldBe` []+      exNumber.num `shouldBe` Left 5+      exNumber.tab `shouldBe` TAB 0+      exNumber.rhos `shouldBe` []+      exMeta.meta `shouldBe` metaVal+      exPhiMeet.prefix `shouldBe` Just "p"+      exPhiMeet.idx `shouldBe` 1+      exPhiMeet.expr `shouldBe` exGlobal+      exBytes.bytes `shouldBe` BT_ONE "40"+      shouldShowAndEqSelf "EX_GLOBAL" exGlobal+      shouldShowAndEqSelf "EX_XI" exXi+      shouldShowAndEqSelf "EX_ATTR" exAttr+      shouldShowAndEqSelf "EX_TERMINATION" exTermination+      shouldShowAndEqSelf "EX_FORMATION" exFormation+      shouldShowAndEqSelf "EX_DISPATCH" exDispatch+      shouldShowAndEqSelf "EX_APPLICATION" exApplication+      shouldShowAndEqSelf "EX_STRING" exString+      shouldShowAndEqSelf "EX_NUMBER" exNumber+      shouldShowAndEqSelf "EX_META" exMeta+      shouldShowAndEqSelf "EX_PHI_MEET" exPhiMeet+      shouldShowAndEqSelf "EX_BYTES" exBytes++  describe "ATTRIBUTE field accessors" $+    it "exposes every ATTRIBUTE constructor's fields via their accessors" $ do+      let metaVal = META EXCL TAU "x"+          atLabel = AT_LABEL{label = "x"}+          atRho = AT_RHO{rho = RHO}+          atPhi = AT_PHI{phi = PHI}+          atLambda = AT_LAMBDA{lambda = LAMBDA}+          atDelta = AT_DELTA{delta = DELTA}+          atMeta = AT_META{meta = metaVal}+          atRest = AT_REST{dots = DOTS}+      atLabel.label `shouldBe` "x"+      atRho.rho `shouldBe` RHO+      atPhi.phi `shouldBe` PHI+      atLambda.lambda `shouldBe` LAMBDA+      atDelta.delta `shouldBe` DELTA+      atMeta.meta `shouldBe` metaVal+      atRest.dots `shouldBe` DOTS+      shouldShowAndEqSelf "AT_LABEL" atLabel+      shouldShowAndEqSelf "AT_RHO" atRho+      shouldShowAndEqSelf "AT_PHI" atPhi+      shouldShowAndEqSelf "AT_LAMBDA" atLambda+      shouldShowAndEqSelf "AT_DELTA" atDelta+      shouldShowAndEqSelf "AT_META" atMeta+      shouldShowAndEqSelf "AT_REST" atRest++  describe "BELONGING derived instances" $+    it "derives Eq and Show for every BELONGING constructor" $ do+      shouldShowAndEqSelf "IN" IN+      shouldShowAndEqSelf "NOT_IN" NOT_IN++  describe "SET field accessors" $+    it "exposes every SET constructor's fields via their accessors" $ do+      let bindingEmpty = BI_EMPTY{tab = TAB 0}+          stBinding = ST_BINDING{binding = bindingEmpty}+          stAttributes = ST_ATTRIBUTES{attrs = [AT_LABEL "x"]}+      stBinding.binding `shouldBe` bindingEmpty+      stAttributes.attrs `shouldBe` [AT_LABEL "x"]+      shouldShowAndEqSelf "ST_BINDING" stBinding+      shouldShowAndEqSelf "ST_ATTRIBUTES" stAttributes++  describe "LOGIC_OPERATOR derived instances" $+    it "derives Eq and Show for every LOGIC_OPERATOR constructor" $ do+      shouldShowAndEqSelf "AND" AND+      shouldShowAndEqSelf "OR" OR++  describe "EQUAL derived instances" $+    it "derives Eq and Show for every EQUAL constructor" $ do+      shouldShowAndEqSelf "EQUAL" EQUAL+      shouldShowAndEqSelf "NOT_EQUAL" NOT_EQUAL+      shouldShowAndEqSelf "GREATER" GREATER+      shouldShowAndEqSelf "NOT_GREATER" NOT_GREATER++  describe "NUMBER field accessors" $+    it "exposes every NUMBER constructor's fields via their accessors" $ do+      let metaVal = META EXCL TAU "x"+          bindingEmpty = BI_EMPTY{tab = TAB 0}+          idxMeta = IDX_META{meta = metaVal}+          lengthVal = LENGTH{binding = bindingEmpty}+          literalVal = LITERAL{num = 5}+      idxMeta.meta `shouldBe` metaVal+      lengthVal.binding `shouldBe` bindingEmpty+      literalVal.num `shouldBe` 5+      shouldShowAndEqSelf "IDX_META" idxMeta+      shouldShowAndEqSelf "LENGTH" lengthVal+      shouldShowAndEqSelf "LITERAL" literalVal++  describe "COMPARABLE field accessors" $+    it "exposes every COMPARABLE constructor's fields via their accessors" $ do+      let attrLabel = AT_LABEL "x"+          exprGlobal = EX_GLOBAL Φ+          cmpAttr = CMP_ATTR{attr = attrLabel}+          cmpExpr = CMP_EXPR{expr = exprGlobal}+          cmpNum = CMP_NUM{num = LITERAL 5}+      cmpAttr.attr `shouldBe` attrLabel+      cmpExpr.expr `shouldBe` exprGlobal+      cmpNum.num `shouldBe` LITERAL 5+      shouldShowAndEqSelf "CMP_ATTR" cmpAttr+      shouldShowAndEqSelf "CMP_EXPR" cmpExpr+      shouldShowAndEqSelf "CMP_NUM" cmpNum++  describe "CONDITION field accessors" $+    it "exposes every CONDITION constructor's fields via their accessors" $ do+      let attrLabel = AT_LABEL "x"+          exprGlobal = EX_GLOBAL Φ+          bindingEmpty = BI_EMPTY{tab = TAB 0}+          cmpAttr = CMP_ATTR attrLabel+          coBelongs = CO_BELONGS{attr = attrLabel, belongs = IN, set = ST_BINDING bindingEmpty}+          coLogic = CO_LOGIC{conditions = [CO_EMPTY], operator = AND}+          coNf = CO_NF{expr = exprGlobal}+          coNot = CO_NOT{condition = CO_EMPTY}+          coCompare = CO_COMPARE{left = cmpAttr, equal = EQUAL, right = cmpAttr}+          coMatches = CO_MATCHES{regex = "x*", expr = exprGlobal}+          coPartOf = CO_PART_OF{expr = exprGlobal, binding = bindingEmpty}+          coDisjoint = CO_DISJOINT{attrs = [attrLabel], groups = [bindingEmpty]}+      coBelongs.attr `shouldBe` attrLabel+      coBelongs.belongs `shouldBe` IN+      coBelongs.set `shouldBe` ST_BINDING bindingEmpty+      coLogic.conditions `shouldBe` [CO_EMPTY]+      coLogic.operator `shouldBe` AND+      coNf.expr `shouldBe` exprGlobal+      coNot.condition `shouldBe` CO_EMPTY+      coCompare.left `shouldBe` cmpAttr+      coCompare.equal `shouldBe` EQUAL+      coCompare.right `shouldBe` cmpAttr+      coMatches.regex `shouldBe` "x*"+      coMatches.expr `shouldBe` exprGlobal+      coPartOf.expr `shouldBe` exprGlobal+      coPartOf.binding `shouldBe` bindingEmpty+      coDisjoint.attrs `shouldBe` [attrLabel]+      coDisjoint.groups `shouldBe` [bindingEmpty]+      shouldShowAndEqSelf "CO_BELONGS" coBelongs+      shouldShowAndEqSelf "CO_LOGIC" coLogic+      shouldShowAndEqSelf "CO_NF" coNf+      shouldShowAndEqSelf "CO_NOT" coNot+      shouldShowAndEqSelf "CO_COMPARE" coCompare+      shouldShowAndEqSelf "CO_MATCHES" coMatches+      shouldShowAndEqSelf "CO_PART_OF" coPartOf+      shouldShowAndEqSelf "CO_DISJOINT" coDisjoint++  describe "EXTRA_ARG field accessors" $+    it "exposes every EXTRA_ARG constructor's fields via their accessors" $ do+      let attrLabel = AT_LABEL "x"+          exprGlobal = EX_GLOBAL Φ+          bindingEmpty = BI_EMPTY{tab = TAB 0}+          argExpr = ARG_EXPR{expr = exprGlobal}+          argAttr = ARG_ATTR{attr = attrLabel}+          argBinding = ARG_BINDING{binding = bindingEmpty}+          argBytes = ARG_BYTES{bytes = BT_ONE "40"}+      argExpr.expr `shouldBe` exprGlobal+      argAttr.attr `shouldBe` attrLabel+      argBinding.binding `shouldBe` bindingEmpty+      argBytes.bytes `shouldBe` BT_ONE "40"+      shouldShowAndEqSelf "ARG_EXPR" argExpr+      shouldShowAndEqSelf "ARG_ATTR" argAttr+      shouldShowAndEqSelf "ARG_BINDING" argBinding+      shouldShowAndEqSelf "ARG_BYTES" argBytes++  describe "EXTRA field accessors" $+    it "exposes every EXTRA field via its accessor" $ do+      let exprGlobal = EX_GLOBAL Φ+          extraArgExpr = ARG_EXPR exprGlobal+          extra = EXTRA{meta = extraArgExpr, func = "fn", args = [extraArgExpr]}+      extra.meta `shouldBe` extraArgExpr+      extra.func `shouldBe` "fn"+      extra.args `shouldBe` [extraArgExpr]+      shouldShowAndEqSelf "EXTRA" extra+  where+    shouldShowAndEqSelf :: (Eq node, Show node) => String -> node -> Expectation+    shouldShowAndEqSelf expectedName node = do+      show node `shouldContain` expectedName+      node `shouldBe` node+    isApplicationWithTaus :: EXPRESSION -> Bool+    isApplicationWithTaus EX_APPLICATION{argument = AA_TAUS{}} = True+    isApplicationWithTaus _ = False+    isApplicationWithExprs :: EXPRESSION -> Bool+    isApplicationWithExprs EX_APPLICATION{argument = AA_EXPRS{}} = True+    isApplicationWithExprs _ = False
+ test/CanonizerSpec.hs view
@@ -0,0 +1,93 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module CanonizerSpec where++import AST+import Canonizer (canonize, canonizeExpr)+import Control.Monad (forM_)+import Test.Hspec (Spec, describe, it, shouldBe)++spec :: Spec+spec = do+  describe "canonizeExpr" $ do+    let nestedInput :: Expression+        nestedInput =+          ExFormation+            [ BiLambda (Function "First")+            , BiTau+                (AtLabel "child")+                (ExFormation [BiLambda (Function "Second")])+            , BiTau+                (AtLabel "app")+                ( ExApplication+                    (ExFormation [BiLambda (Function "Third")])+                    (ArTau (AtLabel "y") ExRoot)+                )+            ]+        nestedExpected :: Expression+        nestedExpected =+          ExFormation+            [ BiLambda (Function "Fn1")+            , BiTau+                (AtLabel "child")+                (ExFormation [BiLambda (Function "Fn2")])+            , BiTau+                (AtLabel "app")+                ( ExApplication+                    (ExFormation [BiLambda (Function "Fn3")])+                    (ArTau (AtLabel "y") ExRoot)+                )+            ]+        unchanged :: Expression+        unchanged = ExFormation [BiTau (AtLabel "x") ExRoot, BiVoid AtRho]+        metaLambda :: Expression+        metaLambda = ExFormation [BiLambda (FnMeta "F")]+    forM_+      [ ("leaves an expression with no lambdas unchanged", unchanged, unchanged)+      ,+        ( "renames a single top level lambda to Fn1"+        , ExFormation [BiLambda (Function "Foo")]+        , ExFormation [BiLambda (Function "Fn1")]+        )+      , ("leaves a meta lambda binding untouched", metaLambda, metaLambda)+      , ("numbers several lambdas at different nesting depths in document order", nestedInput, nestedExpected)+      ,+        ( "recurses through ExDispatch"+        , ExDispatch (ExFormation [BiLambda (Function "Wrapped")]) (AtLabel "attr")+        , ExDispatch (ExFormation [BiLambda (Function "Fn1")]) (AtLabel "attr")+        )+      ,+        ( "recurses through ExApplication's alpha argument"+        , ExApplication ExRoot (ArAlpha (Alpha 0) (ExFormation [BiLambda (Function "Wrapped")]))+        , ExApplication ExRoot (ArAlpha (Alpha 0) (ExFormation [BiLambda (Function "Fn1")]))+        )+      ,+        ( "recurses through ExPhiMeet"+        , ExPhiMeet (Just "p") 1 (ExFormation [BiLambda (Function "Meet")])+        , ExPhiMeet (Just "p") 1 (ExFormation [BiLambda (Function "Fn1")])+        )+      ,+        ( "recurses through ExPhiAgain"+        , ExPhiAgain Nothing 2 (ExFormation [BiLambda (Function "Again")])+        , ExPhiAgain Nothing 2 (ExFormation [BiLambda (Function "Fn1")])+        )+      ]+      (\(desc, input, expected) -> it desc (canonizeExpr input `shouldBe` expected))++  describe "canonize" $ do+    it "returns an empty list for an empty input" $+      canonize [] `shouldBe` []++    it "restarts the counter independently for each Rewritten in the chain" $ do+      let first = ExFormation [BiLambda (Function "X")]+          second = ExFormation [BiLambda (Function "Y")]+          expected = ExFormation [BiLambda (Function "Fn1")]+      canonize [(first, Just "rule-1"), (second, Just "rule-2")]+        `shouldBe` [(expected, Just "rule-1"), (expected, Just "rule-2")]++    it "preserves the rule tag alongside the canonized expression" $+      canonize [(ExFormation [BiLambda (Function "Foo")], Nothing)]+        `shouldBe` [(ExFormation [BiLambda (Function "Fn1")], Nothing)]
test/ConditionSpec.hs view
@@ -7,9 +7,11 @@  import AST (Attribute (AtLabel, AtMeta), Binding (BiMeta), Expression (ExDispatch, ExMeta, ExRoot)) import Condition+import Control.Exception (SomeException) import Control.Monad (forM_) import Data.Either (isLeft, isRight)-import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)+import Data.List (isInfixOf)+import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy, shouldThrow) import Yaml qualified as Y  spec :: Spec@@ -51,3 +53,8 @@       , "or(or(), or())"       ]       (\expr -> it expr (parseCondition expr `shouldSatisfy` isLeft))++  describe "parseConditionThrows" $+    it "throws with a descriptive message on unparsable input" $+      parseConditionThrows "some()"+        `shouldThrow` (\exc -> "Couldn't parse given condition" `isInfixOf` show (exc :: SomeException))
test/DataizeSpec.hs view
@@ -9,24 +9,26 @@ import AST import Control.Exception (SomeException) import Control.Monad+import Data.IORef (modifyIORef', newIORef, readIORef) import Data.List (find, isInfixOf, nub) import Data.List.NonEmpty (NonEmpty (..))-import Data.Maybe (fromMaybe)-import Dataize (DataizeContext (DataizeContext), Steps (Steps), dataize, dataize', emptyState, execBuildTerm, morph)-import Deps (dontSaveEval, dontSaveStep)+import Data.Maybe (fromMaybe, isJust)+import Dataize (DataizeContext (..), Outcome (..), Steps (..), dataize, dataize', emptyState, execBuildTerm, morph)+import Deps (Evaluation (..), Term (TeExpression), dontSaveEval, dontSaveStep) import Functions (buildTerm) import Matcher (substEmpty) import Parser (parseExpressionThrows) import Rewriter (Rewritten) import Rule (RuleContext (RuleContext), matchExpressionWithRule') import Test.Hspec+import Yaml (ExtraArgument (..)) import Yaml qualified  -- Shuffle is enabled so the suite exercises the order-independence of the -- dataization rules (#909): a hidden overlap surfaces as a nondeterministic -- failure instead of staying silently green. defaultDataizeContext :: Expression -> DataizeContext-defaultDataizeContext loc = DataizeContext loc 25 25 (Steps 250 0) False True buildTerm dontSaveStep dontSaveEval+defaultDataizeContext loc = DataizeContext loc 25 25 (Steps 250 0) False True False buildTerm dontSaveStep dontSaveEval  test :: (Eq a, Show a) => ((Expression, NonEmpty Rewritten) -> Expression -> String -> DataizeContext -> IO ((a, [Rewritten]), String)) -> [(String, Expression, Expression, a)] -> Spec test func useCases =@@ -49,7 +51,7 @@       expr <- parseExpressionThrows src       loc' <- parseExpressionThrows loc       (value, _) <- dataize expr (defaultDataizeContext loc')-      value `shouldBe` res+      value `shouldBe` Dataized res  -- The 12 primitive λ-atoms every EO data operation reduces to, declared the way -- 'number.eo' and 'bytes.eo' declare them, so a case below only has to spell the@@ -79,7 +81,8 @@     , "    plus -> [[ x -> ?, L> L_number_plus ]],"     , "    times -> [[ x -> ?, L> L_number_times ]],"     , "    div -> [[ x -> ?, L> L_number_div ]],"-    , "    gt -> [[ x -> ?, L> L_number_gt ]]"+    , "    gt -> [[ x -> ?, L> L_number_gt ]],"+    , "    eq -> [[ x -> ?, y -> ?, L> L_number_eq ]]"     , "  ]],"     , "  string -> [[ as-bytes -> ?, @ -> $.as-bytes ]],"     , "  true -> [[ @ -> [[ D> 01- ]] ]],"@@ -99,8 +102,19 @@       expr <- parseExpressionThrows (primitives src)       loc <- parseExpressionThrows "Q"       (value, _) <- dataize expr (defaultDataizeContext loc)-      value `shouldBe` res+      value `shouldBe` Dataized res +-- Dataize under '--partial', collecting every report 𝔼 makes on the way, in+-- the order it makes them+partially :: String -> IO ((Outcome, [Rewritten]), [Evaluation])+partially src = do+  expr <- parseExpressionThrows (primitives src)+  reports <- newIORef []+  let ctx = (defaultDataizeContext ExRoot){_partial = True, _saveEval = \report -> modifyIORef' reports (report :)}+  result <- dataize expr ctx+  collected <- readIORef reports+  pure (result, reverse collected)+ -- An atom with no answer yields ⊥, which stops the whole dataization testStuckAtom :: [(String, String)] -> Spec testStuckAtom useCases =@@ -136,8 +150,116 @@         , ExRoot         , ExTermination         )+      , -- Same as above but through the alpha-argument sibling 'maad' instead of+        -- 'mad': a void slot fed a non-absolute alpha-indexed argument also+        -- morphs straight to ⊥.++        ( "[[ ^ -> ? ]](α0 -> $.foo) => T"+        , ExApplication (ExFormation [BiVoid AtRho]) (ArAlpha (Alpha 0) (ExDispatch ExXi (AtLabel "foo")))+        , ExRoot+        , ExTermination+        )+      , -- 'universe' fires only when the universe 'e' differs from Φ itself+        -- ('not (eq(e, Φ))'); it then normalizes and re-morphs that universe.+        -- Here the universe is a plain formation, already a normal form, so+        -- re-morphing it lands straight on 'mf' and returns it unchanged.++        ( "Q => [[]] (a universe distinct from Φ) => [[]]"+        , ExRoot+        , ExFormation []+        , ExFormation []+        )       ] +  -- 𝕄's first argument is always a normal form reachable through normalization,+  -- and every such normal form is covered by some morphing clause (an axiom+  -- like 'mf'/'dead'/'xi'/'universe'/'mg' or a recursive rule), so the "no rule+  -- matched" fallback never fires along any real derivation. It is still total+  -- code, reachable by calling 'morph' directly (bypassing normalization) on a+  -- raw meta 𝑛, an AST node the matcher never binds to any concrete pattern.+  describe "morph fails when no morphing rule matches the term" $+    it "throws instead of looping when handed a bare, unmatched meta" $+      morph (ExMeta "unbound", (ExRoot, Nothing) :| []) ExRoot emptyState (defaultDataizeContext ExRoot)+        `shouldThrow` (\e -> "no morphing rule matched" `isInfixOf` show (e :: SomeException))++  -- Symmetric to the morphing fallback above: every normal form 𝔻 actually+  -- receives is covered by 'delta'/'box'/'fire'/'none' (formations) or 'norm'+  -- (everything else, disjoint from ⊥ and formations), so this fallback is+  -- unreachable through the public 'dataize'/'dataize'' entry points on any+  -- term produced by normalization. A raw meta again reaches it directly,+  -- proving the fallback itself is live code, not dead weight.+  describe "dataize' fails when no dataization rule matches the term" $+    it "throws instead of treating the unmatched meta as ⊥" $+      dataize' (ExMeta "unbound", (ExRoot, Nothing) :| []) ExRoot emptyState (defaultDataizeContext ExRoot)+        `shouldThrow` (\e -> "no dataization rule matched" `isInfixOf` show (e :: SomeException))++  -- 'execBuildTerm's "evaluate" and "morph" cases expose 𝔼 and 𝕄 to the+  -- matcher's condition path (guards in 'when'/'having'). No built-in rule's+  -- guard actually calls either function, so these error paths — reachable only+  -- by malformed arguments — are exercised here directly through the exported+  -- 'execBuildTerm', the same way the matcher would call it.+  describe "execBuildTerm 'evaluate'" $ do+    let univ = ExFormation []+        ctx = defaultDataizeContext ExRoot+        runEvaluate args = execBuildTerm univ ctx "evaluate" args substEmpty+    forM_+      [+        ( "the first argument is not a formation"+        , [ArgExpression ExRoot, ArgExpression univ]+        , "Function evaluate() expects a formation"+        )+      ,+        ( "the formation has no λ binding at all"+        , [ArgExpression (ExFormation []), ArgExpression univ]+        , "expects a formation with a"+        )+      ,+        ( "a non-λ formation still has other bindings"+        , [ArgExpression (ExFormation [BiVoid AtRho]), ArgExpression univ]+        , "expects a formation with a"+        )+      ,+        ( "not given exactly two expression arguments"+        , [ArgExpression univ]+        , "requires exactly 2 expression arguments"+        )+      ]+      ( \(desc, args, message) ->+          it ("throws when " ++ desc) $+            runEvaluate args `shouldThrow` (\e -> message `isInfixOf` show (e :: SomeException))+      )+    it "evaluates a λ-bearing formation to the atom's normalized result" $ do+      let form = ExFormation [BiLambda (Function "L_bytes_not"), BiTau AtRho (ExFormation [BiDelta (BtOne "00")])]+      result <- runEvaluate [ArgExpression form, ArgExpression univ]+      case result of+        TeExpression expr -> expr `shouldBe` dataBytes (BtOne "FF")+        _ -> expectationFailure "expected TeExpression"++  describe "execBuildTerm 'morph'" $ do+    let univ = ExFormation []+        ctx = defaultDataizeContext ExRoot+    it "throws when not given exactly one expression argument" $+      execBuildTerm univ ctx "morph" [] substEmpty+        `shouldThrow` (\e -> "requires exactly 1 expression argument" `isInfixOf` show (e :: SomeException))+    it "morphs a single expression argument to its already-normal form" $ do+      result <- execBuildTerm univ ctx "morph" [ArgExpression (ExFormation [BiDelta (BtOne "00")])] substEmpty+      case result of+        TeExpression expr -> expr `shouldBe` ExFormation [BiDelta (BtOne "00")]+        _ -> expectationFailure "expected TeExpression"++  -- Every atom's operand is fetched through the synthetic '_dataize', which+  -- rebuilds the universe as a formation to bind the operand into before+  -- reducing it. A universe that is not itself a formation can never arise+  -- from the public 'dataize' entry point (its own universe argument doubles+  -- as the located root of a real program, always a formation), but 'dataize''+  -- lets a test drive an atom-bearing term against one directly, proving the+  -- guard fires instead of the atom looping or crashing some other way.+  describe "atoms refuse to run under a non-formation universe" $+    it "fails fast instead of dispatching against a non-formation universe" $ do+      let form = ExFormation [BiLambda (Function "L_bytes_not"), BiVoid AtRho]+      dataize' (form, (ExRoot, Nothing) :| []) ExRoot emptyState (defaultDataizeContext ExRoot)+        `shouldThrow` (\e -> "non-formation universe" `isInfixOf` show (e :: SomeException))+   -- 'defaultDataizeContext' runs with '_shuffle' on, so 'morph' walks the   -- morphing rules in a random order on every step. Every clause is   -- order-independent (the known overlaps were removed in #856 and #860), so the@@ -280,9 +402,93 @@   describe "stops a dataization that never reaches bytes" $     it "fails on the step limit instead of morphing forever" $ do       expr <- parseExpressionThrows "⟦ @ ↦ ⟦ λ ⤍ L_number_div, ρ ↦ ⟦ Δ ⤍ 40-45-00-00-00-00-00-00 ⟧, x ↦ ⟦ Δ ⤍ 40-00-00-00-00-00-00-00 ⟧ ⟧ ⟧"-      dataize expr (DataizeContext ExRoot 25 25 (Steps 40 0) False True buildTerm dontSaveStep dontSaveEval)+      dataize expr (DataizeContext ExRoot 25 25 (Steps 40 0) False True False buildTerm dontSaveStep dontSaveEval)         `shouldThrow` (\e -> "--max-steps=40" `isInfixOf` show (e :: SomeException)) +  -- An atom phino does not know — a placeholder such as ⟦ λ ⤍ Sym_arg_0 ⟧+  -- standing in for a data input (#1060) — fails the run, and so does a known+  -- atom whose input reaches one. Under '_partial' the run ends on the residue+  -- instead: the working expression the spine had reached, with the stuck+  -- application intact and everything the calculus demanded before it already+  -- evaluated, while 𝔼 reports each parked site with no result.+  describe "partially evaluates around an atom that cannot fire (--partial)" $ do+    -- the parser gives every formation its void ρ+    let placeholder = ExFormation [BiLambda (Function "Sym_arg_0"), BiVoid AtRho]+    it "fails on it without the flag, naming the unknown atom" $ do+      expr <- parseExpressionThrows (primitives "2.times(3).plus([[ L> Sym_arg_0 ]])")+      dataize expr (defaultDataizeContext ExRoot)+        `shouldThrow` (\e -> "Atom 'Sym_arg_0' does not exist" `isInfixOf` show (e :: SomeException))+    it "leaves the saturated application of the known atom in place, the placeholder inside it" $ do+      ((outcome, _), _) <- partially "2.times(3).plus([[ L> Sym_arg_0 ]])"+      case outcome of+        Residual (ExFormation bds) -> do+          bds `shouldContain` [BiLambda (Function "L_number_plus")]+          bds `shouldContain` [BiTau (AtLabel "x") placeholder]+        other -> expectationFailure ("expected a residual formation, got " ++ show other)+    it "keeps what was evaluated before the stuck site in the residue" $ do+      ((outcome, _), _) <- partially "2.times(3).plus([[ L> Sym_arg_0 ]])"+      case outcome of+        Residual (ExFormation bds) -> do+          let rho = [value | BiTau AtRho value <- bds]+          length rho `shouldBe` 1+          -- 2 × 3 = 6.0, whose IEEE 754 bytes are 40-18-00-00-00-00-00-00+          show rho `shouldContain` show (BtMany ["40", "18", "00", "00", "00", "00", "00", "00"])+          -- the times application is gone: ρ is the number it produced, its 'as-bytes' bound+          [() | ExFormation inner <- rho, BiTau (AtLabel "as-bytes") _ <- inner] `shouldBe` [()]+        other -> expectationFailure ("expected a residual formation, got " ++ show other)+    it "reports the firing that succeeded with its result and every stuck site without one" $ do+      (_, reports) <- partially "2.times(3).plus([[ L> Sym_arg_0 ]])"+      map (._function) reports `shouldBe` ["L_number_times", "Sym_arg_0", "L_number_plus"]+      map (isJust . (._result)) reports `shouldBe` [True, False, False]+    it "leaves an unknown atom dataized directly as the whole residue" $ do+      ((outcome, chain), reports) <- partially "[[ L> Sym_arg_0 ]]"+      outcome `shouldBe` Residual placeholder+      map (._function) reports `shouldBe` ["Sym_arg_0"]+      map fst chain `shouldEndWith` [placeholder]+    it "still reaches bytes when nothing is stuck" $ do+      ((outcome, _), reports) <- partially "2.times(3)"+      outcome `shouldBe` Dataized (BtMany ["40", "18", "00", "00", "00", "00", "00", "00"])+      map (._function) reports `shouldBe` ["L_number_times"]+    it "stops on the terminator ⊥ as before, since a wrong operand is not a stuck atom" $ do+      expr <- parseExpressionThrows (primitives (raw "20-1F" ++ ".and( " ++ raw "CA-FE-BE" ++ " )"))+      dataize expr ((defaultDataizeContext ExRoot){_partial = True})+        `shouldThrow` (\e -> "terminator" `isInfixOf` show (e :: SomeException))++  -- '_maxDepth'/'_maxCycles' bound the normalization rewriter that a 'box' or+  -- 'norm' dataization step splices in (see 'normalized'); with+  -- '_depthSensitive' on, exhausting either one propagates the very same+  -- exception the rewriter itself throws, and with it off the limit is+  -- absorbed silently, so dataization still reaches an answer.+  describe "DataizeContext's --max-depth/--max-cycles reach into the normalization it splices in" $ do+    let boxed = "[[ @ -> [[ D> 00- ]] ]]"+    forM_+      [+        ( "--max-cycles"+        , DataizeContext ExRoot 25 0 (Steps 250 0) True True False buildTerm dontSaveStep dontSaveEval+        , "--max-cycles=0"+        )+      ,+        ( "--max-depth"+        , DataizeContext ExRoot 0 25 (Steps 250 0) True True False buildTerm dontSaveStep dontSaveEval+        , "--max-depth=0"+        )+      ]+      ( \(flag, ctx, message) ->+          it ("throws once " ++ flag ++ " is exhausted with --depth-sensitive") $ do+            expr <- parseExpressionThrows boxed+            dataize expr ctx `shouldThrow` (\e -> message `isInfixOf` show (e :: SomeException))+      )+    forM_+      [ ("--max-cycles", DataizeContext ExRoot 25 0 (Steps 250 0) False True False buildTerm dontSaveStep dontSaveEval)+      , ("--max-depth", DataizeContext ExRoot 0 25 (Steps 250 0) False True False buildTerm dontSaveStep dontSaveEval)+      ]+      ( \(flag, ctx) ->+          it ("does not throw without --depth-sensitive even once " ++ flag ++ " is exhausted") $ do+            expr <- parseExpressionThrows boxed+            (value, _) <- dataize expr ctx+            value `shouldBe` Dataized (BtOne "00")+      )+   describe "labels every step with a defined rule or operation" $ do     let verb op = case op of           Yaml.OpMorph _ -> "morph"@@ -538,4 +744,15 @@       , ("cannot disjoin bytes of different lengths", raw "20-1F" ++ ".or( " ++ raw "CA-FE-BE" ++ " )")       , ("cannot slice from an offset beyond the int range", raw "20-1F-EE-B5-90" ++ ".slice( 3000000000, 1 )")       , ("cannot slice a negative length", raw "20-1F-EE-B5-90" ++ ".slice( 1, -1 )")+      , -- A number atom rejects an operand that carries no number (empty bytes),+        -- yielding ⊥ rather than a result; dataizing ⊥ then fails through the+        -- terminator path, exactly like the bytes-atom cases above.+        ("cannot add a non-numeric operand", "5.plus( " ++ raw "--" ++ " )")+      , ("cannot multiply by a non-numeric operand", "5.times( " ++ raw "--" ++ " )")+      , ("cannot divide by a non-numeric divisor", "5.div( " ++ raw "--" ++ " )")+      , ("cannot compare against a non-numeric threshold", "5.gt( " ++ raw "--" ++ " )")+      , ("cannot test equality against a non-numeric operand", "5.eq( " ++ raw "--" ++ ", 6 )")+      , -- 'right' rejects a shift distance that is not a plain 8-byte integer;+        -- empty bytes carry no such integer, so the shift atom is stuck too.+        ("cannot shift right by a non-integer distance", raw "C0-43-00-00-00-00-00-00" ++ ".right( " ++ raw "--" ++ " )")       ]
+ test/DepsSpec.hs view
@@ -0,0 +1,59 @@+-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module DepsSpec where++import AST (Expression (ExRoot))+import Control.Exception (bracket)+import Control.Monad (when)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Deps (dontSaveStep, saveStep)+import Logger (LogLevel (DEBUG, ERROR), setLogConfig)+import System.Directory+  ( doesDirectoryExist+  , doesFileExist+  , getTemporaryDirectory+  , removeDirectoryRecursive+  )+import System.FilePath ((</>))+import System.IO (stderr)+import System.IO.Silently (hSilence)+import Test.Hspec (Spec, describe, it, shouldBe)++withScratchDir :: (FilePath -> IO a) -> IO a+withScratchDir =+  bracket+    ( do+        tmp <- getTemporaryDirectory+        stamp <- getPOSIXTime+        pure (tmp </> ("phino-deps-spec-" ++ show (floor (stamp * 1000000) :: Integer)))+    )+    ( \dir -> do+        exists <- doesDirectoryExist dir+        when exists (removeDirectoryRecursive dir)+    )++spec :: Spec+spec = do+  describe "dontSaveStep" $+    it "is a no-op that never touches the filesystem" $+      withScratchDir $ \dir -> do+        dontSaveStep ExRoot+        exists <- doesDirectoryExist dir+        exists `shouldBe` False++  describe "saveStep" $ do+    it "creates the directory if missing, writes the rendered step and logs it" $ withScratchDir $ \dir -> do+      setLogConfig DEBUG 25+      hSilence [stderr] (saveStep (Just dir) "phi" (pure . show) 3 ExRoot)+      setLogConfig ERROR 25+      let path = dir </> "00003.phi"+      exists <- doesFileExist path+      exists `shouldBe` True+      content <- readFile path+      content `shouldBe` show ExRoot++    it "numbers the file after the given step, zero padded to five digits" $ withScratchDir $ \dir -> do+      saveStep (Just dir) "txt" (pure . show) 42 ExRoot+      exists <- doesFileExist (dir </> "00042.txt")+      exists `shouldBe` True
+ test/EncodingSpec.hs view
@@ -0,0 +1,282 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module EncodingSpec where++import CST+import Control.Monad (forM_)+import Encoding (Encoding (..), toASCII, withEncoding)+import Test.Hspec (Spec, describe, it, shouldBe)++leafExpr :: EXPRESSION+leafExpr = EX_GLOBAL Φ++leafExprASCII :: EXPRESSION+leafExprASCII = EX_GLOBAL Q++biPair :: BINDING+biPair = BI_PAIR (PA_TAU (AT_PHI PHI) ARROW leafExpr) (BDS_EMPTY (TAB 1)) (TAB 1)++biPairASCII :: BINDING+biPairASCII = BI_PAIR (PA_TAU (AT_PHI AT) ARROW' leafExprASCII) (BDS_EMPTY (TAB 1)) (TAB 1)++spec :: Spec+spec = do+  describe "toASCII on EXPRESSION" $ do+    forM_+      [ ("EX_GLOBAL becomes Q", toASCII (EX_GLOBAL Φ), EX_GLOBAL Q)+      , ("EX_XI becomes $", toASCII (EX_XI XI), EX_XI DOLLAR)+      , ("EX_ATTR recurses into its attribute", toASCII (EX_ATTR (AT_PHI PHI)), EX_ATTR (AT_PHI AT))+      , ("EX_TERMINATION becomes T", toASCII (EX_TERMINATION DEAD), EX_TERMINATION T)+      ,+        ( "EX_FORMATION recurses into its binding and forces LSB'/RSB'"+        , toASCII (EX_FORMATION LSB EOL (TAB 1) biPair EOL (TAB 0) RSB)+        , EX_FORMATION LSB' EOL (TAB 1) biPairASCII EOL (TAB 0) RSB'+        )+      ,+        ( "EX_DISPATCH recurses into its expression and attribute"+        , toASCII (EX_DISPATCH leafExpr NO_SPACE (AT_RHO RHO))+        , EX_DISPATCH leafExprASCII NO_SPACE (AT_RHO CARET)+        )+      ,+        ( "EX_APPLICATION recurses into its expression and argument"+        , toASCII (EX_APPLICATION leafExpr NO_SPACE EOL (TAB 1) (AA_TAUS biPair) EOL (TAB 0) 1)+        , EX_APPLICATION leafExprASCII NO_SPACE EOL (TAB 1) (AA_TAUS biPairASCII) EOL (TAB 0) 1+        )+      ,+        ( "EX_META with an 'n'-headed meta becomes N'"+        , toASCII (EX_META (META NO_EXCL N "abc"))+        , EX_META (META EXCL N' "abc")+        )+      ,+        ( "EX_META with a 'k'-headed meta becomes K'"+        , toASCII (EX_META (META NO_EXCL K "abc"))+        , EX_META (META EXCL K' "abc")+        )+      ,+        ( "EX_META with any other head becomes E'"+        , toASCII (EX_META (META NO_EXCL E "abc"))+        , EX_META (META EXCL E' "abc")+        )+      ,+        ( "EX_PHI_MEET recurses into its expression"+        , toASCII (EX_PHI_MEET (Just "p") 3 leafExpr)+        , EX_PHI_MEET (Just "p") 3 leafExprASCII+        )+      ,+        ( "EX_PHI_AGAIN recurses into its expression"+        , toASCII (EX_PHI_AGAIN Nothing 4 leafExpr)+        , EX_PHI_AGAIN Nothing 4 leafExprASCII+        )+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++    it "leaves every other constructor untouched" $ do+      toASCII (EX_STRING "hi" (TAB 1) []) `shouldBe` EX_STRING "hi" (TAB 1) []+      toASCII (EX_NUMBER (Left 5) (TAB 1) []) `shouldBe` EX_NUMBER (Left 5) (TAB 1) []+      toASCII (EX_BYTES BT_EMPTY) `shouldBe` EX_BYTES BT_EMPTY++  describe "toASCII on APP_BINDING" $+    it "recurses into the pair" $+      toASCII (APP_BINDING (PA_TAU (AT_PHI PHI) ARROW leafExpr)) `shouldBe` APP_BINDING (PA_TAU (AT_PHI AT) ARROW' leafExprASCII)++  describe "toASCII on BINDING" $+    forM_+      [ ("recurses through BI_PAIR", toASCII biPair, biPairASCII)+      ,+        ( "recurses through BI_META, forcing the meta head to B'"+        , toASCII (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1))+        , BI_META (META EXCL B' "X") (BDS_EMPTY (TAB 1)) (TAB 1)+        )+      , ("leaves BI_EMPTY untouched", toASCII (BI_EMPTY (TAB 1)), BI_EMPTY (TAB 1))+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on BINDINGS" $+    forM_+      [+        ( "recurses through BDS_PAIR"+        , toASCII (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_PHI PHI) ARROW leafExpr) (BDS_EMPTY (TAB 1)))+        , BDS_PAIR EOL (TAB 1) (PA_TAU (AT_PHI AT) ARROW' leafExprASCII) (BDS_EMPTY (TAB 1))+        )+      ,+        ( "recurses through BDS_META, forcing the meta head to B'"+        , toASCII (BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)))+        , BDS_META EOL (TAB 1) (META EXCL B' "X") (BDS_EMPTY (TAB 1))+        )+      , ("leaves BDS_EMPTY untouched", toASCII (BDS_EMPTY (TAB 1)), BDS_EMPTY (TAB 1))+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on APP_ARGUMENT" $+    forM_+      [+        ( "recurses through AA_TAU"+        , toASCII (AA_TAU (APP_BINDING (PA_TAU (AT_PHI PHI) ARROW leafExpr)))+        , AA_TAU (APP_BINDING (PA_TAU (AT_PHI AT) ARROW' leafExprASCII))+        )+      , ("recurses through AA_TAUS", toASCII (AA_TAUS biPair), AA_TAUS biPairASCII)+      ,+        ( "recurses through AA_EXPRS"+        , toASCII (AA_EXPRS (APP_ARG leafExpr AAS_EMPTY))+        , AA_EXPRS (APP_ARG leafExprASCII AAS_EMPTY)+        )+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on APP_ARG" $+    it "recurses through both fields" $+      toASCII (APP_ARG leafExpr (AAS_EXPR EOL (TAB 1) leafExpr AAS_EMPTY))+        `shouldBe` APP_ARG leafExprASCII (AAS_EXPR EOL (TAB 1) leafExprASCII AAS_EMPTY)++  describe "toASCII on APP_ARGS" $+    forM_+      [+        ( "recurses through AAS_EXPR"+        , toASCII (AAS_EXPR EOL (TAB 1) leafExpr AAS_EMPTY)+        , AAS_EXPR EOL (TAB 1) leafExprASCII AAS_EMPTY+        )+      , ("leaves AAS_EMPTY untouched", toASCII AAS_EMPTY, AAS_EMPTY)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on PAIR" $+    forM_+      [+        ( "recurses through PA_TAU, forcing the arrow to ARROW'"+        , toASCII (PA_TAU (AT_PHI PHI) ARROW leafExpr)+        , PA_TAU (AT_PHI AT) ARROW' leafExprASCII+        )+      ,+        ( "recurses through PA_ALPHA, forcing the arrow to ARROW'"+        , toASCII (PA_ALPHA (AL_IDX ALPHA 0) ARROW leafExpr)+        , PA_ALPHA (AL_IDX ALPHA' 0) ARROW' leafExprASCII+        )+      ,+        ( "recurses through PA_FORMATION, forcing the arrow to ARROW'"+        , toASCII (PA_FORMATION (AT_PHI PHI) [AT_RHO RHO] ARROW leafExpr)+        , PA_FORMATION (AT_PHI AT) [AT_RHO CARET] ARROW' leafExprASCII+        )+      ,+        ( "PA_VOID forces the arrow and the void marker"+        , toASCII (PA_VOID (AT_PHI PHI) ARROW EMPTY)+        , PA_VOID (AT_PHI AT) ARROW' QUESTION+        )+      , ("PA_LAMBDA becomes PA_LAMBDA'", toASCII (PA_LAMBDA "Func"), PA_LAMBDA' "Func")+      , ("PA_DELTA becomes PA_DELTA'", toASCII (PA_DELTA BT_EMPTY), PA_DELTA' BT_EMPTY)+      ,+        ( "PA_META_LAMBDA becomes PA_META_LAMBDA' with head F'"+        , toASCII (PA_META_LAMBDA (META NO_EXCL F "fn"))+        , PA_META_LAMBDA' (META EXCL F' "fn")+        )+      ,+        ( "PA_META_DELTA becomes PA_META_DELTA' with head D'"+        , toASCII (PA_META_DELTA (META NO_EXCL D "dl"))+        , PA_META_DELTA' (META EXCL D' "dl")+        )+      , ("leaves an already-ASCII PA_LAMBDA' untouched", toASCII (PA_LAMBDA' "Func"), PA_LAMBDA' "Func")+      , ("leaves an already-ASCII PA_DELTA' untouched", toASCII (PA_DELTA' BT_EMPTY), PA_DELTA' BT_EMPTY)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on ALPHA" $+    forM_+      [ ("recurses through AL_IDX", toASCII (AL_IDX ALPHA 7), AL_IDX ALPHA' 7)+      ,+        ( "recurses through AL_META"+        , toASCII (AL_META ALPHA (META NO_EXCL I "abc"))+        , AL_META ALPHA' (META EXCL I' "abc")+        )+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on ATTRIBUTE" $+    forM_+      [ ("AT_PHI becomes AT", toASCII (AT_PHI PHI), AT_PHI AT)+      , ("AT_RHO becomes CARET", toASCII (AT_RHO RHO), AT_RHO CARET)+      , ("AT_META forces the head to A", toASCII (AT_META (META NO_EXCL TAU "abc")), AT_META (META EXCL A "abc"))+      , ("leaves AT_LABEL untouched", toASCII (AT_LABEL "x"), AT_LABEL "x")+      , ("leaves AT_LAMBDA untouched", toASCII (AT_LAMBDA LAMBDA), AT_LAMBDA LAMBDA)+      , ("leaves AT_DELTA untouched", toASCII (AT_DELTA DELTA), AT_DELTA DELTA)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on SET" $+    forM_+      [ ("recurses through ST_BINDING", toASCII (ST_BINDING biPair), ST_BINDING biPairASCII)+      ,+        ( "maps toASCII over ST_ATTRIBUTES"+        , toASCII (ST_ATTRIBUTES [AT_PHI PHI, AT_RHO RHO])+        , ST_ATTRIBUTES [AT_PHI AT, AT_RHO CARET]+        )+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on NUMBER" $+    forM_+      [ ("IDX_META forces the head to I'", toASCII (IDX_META (META NO_EXCL I "abc")), IDX_META (META EXCL I' "abc"))+      , ("recurses through LENGTH", toASCII (LENGTH biPair), LENGTH biPairASCII)+      , ("recurses through DOMAIN", toASCII (DOMAIN biPair), DOMAIN biPairASCII)+      , ("leaves LITERAL untouched", toASCII (LITERAL 5), LITERAL 5)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on COMPARABLE" $+    forM_+      [ ("recurses through CMP_ATTR", toASCII (CMP_ATTR (AT_PHI PHI)), CMP_ATTR (AT_PHI AT))+      , ("recurses through CMP_EXPR", toASCII (CMP_EXPR leafExpr), CMP_EXPR leafExprASCII)+      , ("recurses through CMP_NUM", toASCII (CMP_NUM (LENGTH biPair)), CMP_NUM (LENGTH biPairASCII))+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on CONDITION" $+    forM_+      [+        ( "recurses through CO_BELONGS"+        , toASCII (CO_BELONGS (AT_PHI PHI) IN (ST_BINDING biPair))+        , CO_BELONGS (AT_PHI AT) IN (ST_BINDING biPairASCII)+        )+      ,+        ( "maps toASCII over CO_LOGIC"+        , toASCII (CO_LOGIC [CO_NF leafExpr, CO_EMPTY] AND)+        , CO_LOGIC [CO_NF leafExprASCII, CO_EMPTY] AND+        )+      , ("recurses through CO_NF", toASCII (CO_NF leafExpr), CO_NF leafExprASCII)+      , ("recurses through CO_ABSOLUTE", toASCII (CO_ABSOLUTE leafExpr IN), CO_ABSOLUTE leafExprASCII IN)+      , ("recurses through CO_NOT", toASCII (CO_NOT (CO_NF leafExpr)), CO_NOT (CO_NF leafExprASCII))+      ,+        ( "recurses through CO_COMPARE"+        , toASCII (CO_COMPARE (CMP_ATTR (AT_PHI PHI)) EQUAL (CMP_EXPR leafExpr))+        , CO_COMPARE (CMP_ATTR (AT_PHI AT)) EQUAL (CMP_EXPR leafExprASCII)+        )+      , ("recurses through CO_MATCHES", toASCII (CO_MATCHES "abc" leafExpr), CO_MATCHES "abc" leafExprASCII)+      , ("recurses through CO_PART_OF", toASCII (CO_PART_OF leafExpr biPair), CO_PART_OF leafExprASCII biPairASCII)+      ,+        ( "maps toASCII over CO_DISJOINT"+        , toASCII (CO_DISJOINT [AT_PHI PHI] [biPair])+        , CO_DISJOINT [AT_PHI AT] [biPairASCII]+        )+      , ("recurses through CO_FORMATION", toASCII (CO_FORMATION leafExpr), CO_FORMATION leafExprASCII)+      , ("leaves CO_EMPTY untouched", toASCII CO_EMPTY, CO_EMPTY)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on EXTRA_ARG" $+    forM_+      [ ("recurses through ARG_ATTR", toASCII (ARG_ATTR (AT_PHI PHI)), ARG_ATTR (AT_PHI AT))+      , ("recurses through ARG_EXPR", toASCII (ARG_EXPR leafExpr), ARG_EXPR leafExprASCII)+      , ("recurses through ARG_BINDING", toASCII (ARG_BINDING biPair), ARG_BINDING biPairASCII)+      , ("leaves ARG_BYTES untouched", toASCII (ARG_BYTES BT_EMPTY), ARG_BYTES BT_EMPTY)+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "toASCII on EXTRA" $+    it "recurses through meta and every arg, keeping func untouched" $+      toASCII (EXTRA (ARG_ATTR (AT_PHI PHI)) "func" [ARG_EXPR leafExpr, ARG_BYTES BT_EMPTY])+        `shouldBe` EXTRA (ARG_ATTR (AT_PHI AT)) "func" [ARG_EXPR leafExprASCII, ARG_BYTES BT_EMPTY]++  describe "withEncoding" $ do+    it "UNICODE is the identity" $ withEncoding UNICODE biPair `shouldBe` biPair+    it "ASCII is toASCII" $ withEncoding ASCII biPair `shouldBe` toASCII biPair
+ test/FilesSpec.hs view
@@ -0,0 +1,91 @@+-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module FilesSpec where++import Control.Exception (bracket, try)+import Control.Monad (forM_, void)+import Data.List (sort)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Files (FsException (..), allPathsIn, ensuredFile)+import System.Directory+  ( createDirectoryIfMissing+  , getTemporaryDirectory+  , removeDirectoryRecursive+  )+import System.FilePath ((</>))+import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)++exceptionPath :: FsException -> FilePath+exceptionPath (FileDoesNotExist file) = file+exceptionPath (DirectoryDoesNotExist directory) = directory++withScratchDir :: (FilePath -> IO a) -> IO a+withScratchDir =+  bracket+    ( do+        tmp <- getTemporaryDirectory+        stamp <- getPOSIXTime+        let dir = tmp </> ("phino-files-spec-" ++ show (floor (stamp * 1000000) :: Integer))+        createDirectoryIfMissing True dir+        pure dir+    )+    removeDirectoryRecursive++spec :: Spec+spec = do+  describe "ensuredFile" $+    it "returns the path of an existing file" $+      withScratchDir $ \dir -> do+        let path = dir </> "existing.txt"+        writeFile path "content"+        ensuredFile path >>= (`shouldBe` path)++  describe "allPathsIn" $ do+    it "collects every leaf file path recursively" $ withScratchDir $ \dir -> do+      let nested = dir </> "a" </> "b"+      createDirectoryIfMissing True nested+      writeFile (dir </> "top.txt") "top"+      writeFile (dir </> "a" </> "mid.txt") "mid"+      writeFile (nested </> "leaf.txt") "leaf"+      paths <- allPathsIn dir+      sort paths+        `shouldBe` sort+          [ dir </> "top.txt"+          , dir </> "a" </> "mid.txt"+          , nested </> "leaf.txt"+          ]++    it "returns an empty list for an empty directory" $ withScratchDir $ \dir -> do+      paths <- allPathsIn dir+      paths `shouldBe` []++  describe "FsException" $ do+    forM_+      [ ("throws FileDoesNotExist for a missing file", "missing.txt", void . ensuredFile)+      , ("throws DirectoryDoesNotExist for a missing directory", "does-not-exist", void . allPathsIn)+      ]+      ( \(desc, name, action) -> it desc $ withScratchDir $ \dir -> do+          let path = dir </> name+          result <- try (action path) :: IO (Either FsException ())+          case result of+            Left exc -> exceptionPath exc `shouldBe` path+            _ -> fail "expected an FsException to be thrown"+      )++    forM_+      [+        ( "shows a readable message for FileDoesNotExist"+        , FileDoesNotExist "/no/such/file"+        , "File '/no/such/file' does not exist"+        )+      ,+        ( "shows a readable message for DirectoryDoesNotExist"+        , DirectoryDoesNotExist "/no/such/dir"+        , "Directory '/no/such/dir' does not exist"+        )+      ]+      (\(desc, exc, message) -> it desc (show exc `shouldBe` message))++    it "FsException values can be inspected without throwing" $+      show (FileDoesNotExist "x") `shouldSatisfy` (not . null)
test/FilterSpec.hs view
@@ -11,18 +11,14 @@ -} module FilterSpec where -import Control.Monad (forM_, when)+import AST (Expression (ExRoot))+import Control.Monad (forM_) import Data.Aeson import Data.Yaml qualified as Yaml-import Encoding (Encoding (UNICODE)) import Files (allPathsIn) import Filter qualified as F import GHC.Generics (Generic)-import Lining (LineFormat (MULTILINE))-import Margin (defaultMargin) import Parser (parseExpressionThrows)-import Printer (printExpression')-import Sugar (SugarType (SALTY)) import System.FilePath import Test.Hspec @@ -38,27 +34,64 @@ yamlPack = Yaml.decodeFileThrow  spec :: Spec-spec = describe "filter packs" $ do-  let resources = "test-resources/filter-packs"-  packs <- runIO (allPathsIn resources)-  forM_-    packs-    ( \pth -> it (makeRelative resources pth) $ do-        YamlPack{..} <- yamlPack pth-        expr <- parseExpressionThrows expression-        included <- traverse parseExpressionThrows shown-        excluded <- traverse parseExpressionThrows hidden-        res <- parseExpressionThrows result-        let [(expr', _)] = F.exclude (F.include [(expr, Nothing)] included) excluded-            cfg = (SALTY, UNICODE, MULTILINE, defaultMargin)-        expr' `shouldBe` res-        when-          (expr' /= res)-          ( expectationFailure-              ( "Expected:\n"-                  ++ printExpression' res cfg-                  ++ "\nbut got:\n"-                  ++ printExpression' expr' cfg-              )-          )-    )+spec = do+  describe "filter packs" $ do+    let resources = "test-resources/filter-packs"+    packs <- runIO (allPathsIn resources)+    forM_+      packs+      ( \pth -> it (makeRelative resources pth) $ do+          YamlPack{..} <- yamlPack pth+          expr <- parseExpressionThrows expression+          included <- traverse parseExpressionThrows shown+          excluded <- traverse parseExpressionThrows hidden+          res <- parseExpressionThrows result+          let [(expr', _)] = F.exclude (F.include [(expr, Nothing)] included) excluded+          expr' `shouldBe` res+      )++  describe "direct unit tests" $ do+    describe "exclude" $ do+      it "leaves the expression untouched when the fqn is not a Q-dispatch chain" $ do+        expr <- parseExpressionThrows "[[ x -> ?, y -> ? ]]"+        badFqn <- parseExpressionThrows "$.x"+        let [(expr', _)] = F.exclude [(expr, Nothing)] [badFqn]+        expr' `shouldBe` expr++      it "leaves a non-formation expression untouched" $ do+        expr <- parseExpressionThrows "Q.x"+        fqn <- parseExpressionThrows "Q.y"+        let [(expr', _)] = F.exclude [(expr, Nothing)] [fqn]+        expr' `shouldBe` expr++      it "recurses over a multi-element rewrite list, preserving each rule label" $ do+        first' <- parseExpressionThrows "[[ x -> ?, y -> ? ]]"+        second' <- parseExpressionThrows "[[ x -> ?, y -> ? ]]"+        fqn <- parseExpressionThrows "Q.x"+        expected <- parseExpressionThrows "[[ y -> ? ]]"+        let excluded = F.exclude [(first', Just "rule-a"), (second', Just "rule-b")] [fqn]+        map fst excluded `shouldBe` [expected, expected]+        map snd excluded `shouldBe` [Just "rule-a", Just "rule-b"]++    describe "include" $ do+      forM_+        [ ("falls back to the default hidden formation when the fqn is not a Q-dispatch chain", "[[ x -> ? ]]", "$.x")+        , ("falls back to the default hidden formation when nothing matches the fqn", "[[ x -> ? ]]", "Q.absent")+        , ("falls back to the default hidden formation for a non-formation expression", "Q.x", "Q.y")+        ]+        ( \(desc, exprText, fqnText) -> it desc $ do+            expr <- parseExpressionThrows exprText+            fqn <- parseExpressionThrows fqnText+            defaultHidden <- parseExpressionThrows "[[ ]]"+            let [(expr', _)] = F.include [(expr, Nothing)] [fqn]+            expr' `shouldBe` defaultHidden+        )++      it "recurses over a multi-element rewrite list, pinning every element to the first fqn" $ do+        first' <- parseExpressionThrows "[[ x -> ?, y -> ? ]]"+        second' <- parseExpressionThrows "[[ x -> ?, y -> ? ]]"+        fqn <- parseExpressionThrows "Q.x"+        expected <- parseExpressionThrows "[[ x -> ? ]]"+        let included = F.include [(first', Just "rule-a"), (second', Just "rule-b")] [fqn, ExRoot]+        map fst included `shouldBe` [expected, expected]+        map snd included `shouldBe` [Just "rule-a", Just "rule-b"]
test/FunctionsSpec.hs view
@@ -6,25 +6,209 @@ module FunctionsSpec where  import AST+import Bytes (numToBts, strToBts)+import Control.Exception (SomeException)+import Control.Monad (forM_)+import Data.List (isInfixOf) import Data.Map.Strict qualified as Map-import Deps (Term (TeBindings))+import Deps (Term (TeAttribute, TeBindings, TeBytes, TeExpression)) import Functions (buildTerm) import Logger (logDebug)-import Matcher (MetaValue (MvBindings), Subst (Subst))+import Matcher (MetaValue (MvBindings), Subst (Subst), substEmpty) import Misc (uniqueBindings') import Printer (printExpression)-import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Hspec (Expectation, Spec, describe, it, shouldBe, shouldThrow) import Text.Printf (printf)-import Yaml (ExtraArgument (ArgBinding))+import Yaml (ExtraArgument (ArgAttribute, ArgBinding, ArgBytes, ArgExpression)) +throwsWith :: IO a -> String -> IO ()+throwsWith action needle =+  action `shouldThrow` (\exc -> needle `isInfixOf` show (exc :: SomeException))++-- 'Term' carries no 'Show'/'Eq' instance, so a term coming back from+-- 'buildTerm' is checked by pattern-matching out the constructor expected and+-- comparing the payload, which does have both.+expectExpression :: Term -> Expression -> Expectation+expectExpression (TeExpression got) want = got `shouldBe` want+expectExpression _ _ = fail "expected a TeExpression term"++expectBytes :: Term -> Bytes -> Expectation+expectBytes (TeBytes got) want = got `shouldBe` want+expectBytes _ _ = fail "expected a TeBytes term"++expectAttribute :: Term -> Attribute -> Expectation+expectAttribute (TeAttribute got) want = got `shouldBe` want+expectAttribute _ _ = fail "expected a TeAttribute term"+ spec :: Spec-spec = describe "Functions" $-  it "contains only unique bindings after 'join'" $ do-    let first = ("B1", MvBindings [BiVoid AtRho, BiDelta BtEmpty, BiTau (AtLabel "x") ExRoot, BiVoid (AtLabel "a0")])-        second = ("B2", MvBindings [BiTau AtRho ExXi, BiLambda (Function "Func"), BiDelta (BtOne "00"), BiVoid (AtLabel "a1")])-        third = ("B3", MvBindings [BiLambda (Function "Some"), BiTau (AtLabel "y") ExXi, BiTau (AtLabel "x") ExXi, BiVoid (AtLabel "a0")])-        subst = Subst (Map.fromList [first, second, third])-    TeBindings bds <- buildTerm "join" [ArgBinding (BiMeta "B1"), ArgBinding (BiMeta "B2"), ArgBinding (BiMeta "B3")] subst-    bds' <- uniqueBindings' bds-    logDebug (printf "Joined bindings:\n%s" (printExpression (ExFormation bds')))-    length bds' `shouldBe` 9+spec = describe "Functions" $ do+  describe "join" $ do+    it "contains only unique bindings after 'join'" $ do+      let first = ("B1", MvBindings [BiVoid AtRho, BiDelta BtEmpty, BiTau (AtLabel "x") ExRoot, BiVoid (AtLabel "a0")])+          second = ("B2", MvBindings [BiTau AtRho ExXi, BiLambda (Function "Func"), BiDelta (BtOne "00"), BiVoid (AtLabel "a1")])+          third = ("B3", MvBindings [BiLambda (Function "Some"), BiTau (AtLabel "y") ExXi, BiTau (AtLabel "x") ExXi, BiVoid (AtLabel "a0")])+          subst = Subst (Map.fromList [first, second, third])+      TeBindings bds <- buildTerm "join" [ArgBinding (BiMeta "B1"), ArgBinding (BiMeta "B2"), ArgBinding (BiMeta "B3")] subst+      bds' <- uniqueBindings' bds+      logDebug (printf "Joined bindings:\n%s" (printExpression (ExFormation bds')))+      length bds' `shouldBe` 9++    it "renames a duplicate tau binding (not rho/delta/lambda) instead of dropping it" $ do+      let first = ("B1", MvBindings [BiTau (AtLabel "x") ExRoot])+          second = ("B2", MvBindings [BiTau (AtLabel "x") ExXi])+          subst = Subst (Map.fromList [first, second])+      TeBindings bds <- buildTerm "join" [ArgBinding (BiMeta "B1"), ArgBinding (BiMeta "B2")] subst+      length bds `shouldBe` 2++    it "joins no bindings when given no arguments" $ do+      TeBindings bds <- buildTerm "join" [] substEmpty+      bds `shouldBe` []++  describe "contextualize" $+    it "replaces xi with the given context" $ do+      term <- buildTerm "contextualize" [ArgExpression ExXi, ArgExpression (ExFormation [BiVoid AtRho])] substEmpty+      expectExpression term (ExFormation [BiVoid AtRho])++  describe "random-tau" $+    it "returns a fresh label attribute" $ do+      term <- buildTerm "random-tau" [] substEmpty+      case term of+        TeAttribute (AtLabel _) -> pure ()+        _ -> fail "expected a label attribute"++  describe "dataize" $ do+    it "extracts bytes from a bytes argument" $ do+      term <- buildTerm "dataize" [ArgBytes (BtOne "00")] substEmpty+      expectBytes term (BtOne "00")+    it "extracts bytes from a data-object expression" $ do+      term <- buildTerm "dataize" [ArgExpression (DataNumber (numToBts 5))] substEmpty+      expectBytes term (numToBts 5)++  describe "size" $+    it "counts the bindings bound to a meta" $ do+      let subst = Subst (Map.singleton "B" (MvBindings [BiVoid AtRho, BiVoid (AtLabel "x")]))+      term <- buildTerm "size" [ArgBinding (BiMeta "B")] subst+      expectExpression term (DataNumber (numToBts 2))++  describe "successful calls" $+    forM_+      successCases+      (\(desc, name, args, check) -> it desc (buildTerm name args substEmpty >>= check))++  describe "calls that fail with a descriptive error" $+    forM_+      failureCases+      (\(desc, name, args, message) -> it desc (buildTerm name args substEmpty `throwsWith` message))+  where+    successCases :: [(String, String, [ExtraArgument], Term -> Expectation)]+    successCases =+      [+        ( "concat concatenates several string arguments"+        , "concat"+        , [ArgExpression (DataString (strToBts "foo")), ArgExpression (DataString (strToBts "bar"))]+        , \term -> expectExpression term (DataString (strToBts "foobar"))+        )+      ,+        ( "sed replaces every occurrence with the 'g' flag"+        , "sed"+        , [ArgExpression (DataString (strToBts "hello")), ArgExpression (DataString (strToBts "s/l/L/g"))]+        , \term -> expectExpression term (DataString (strToBts "heLLo"))+        )+      ,+        ( "sed replaces only the first occurrence without the 'g' flag"+        , "sed"+        , [ArgExpression (DataString (strToBts "hello")), ArgExpression (DataString (strToBts "s/l/L/"))]+        , \term -> expectExpression term (DataString (strToBts "heLlo"))+        )+      ,+        ( "sed applies several patterns in sequence"+        , "sed"+        ,+          [ ArgExpression (DataString (strToBts "hello"))+          , ArgExpression (DataString (strToBts "s/h/H/"))+          , ArgExpression (DataString (strToBts "s/o/O/"))+          ]+        , \term -> expectExpression term (DataString (strToBts "HellO"))+        )+      ,+        ( "random-string returns a literal pattern with no specials unchanged"+        , "random-string"+        , [ArgExpression (DataString (strToBts "static-name"))]+        , \term -> expectExpression term (DataString (strToBts "static-name"))+        )+      ,+        ( "tau parses an attribute out of a string expression"+        , "tau"+        , [ArgExpression (DataString (strToBts "x"))]+        , \term -> expectAttribute term (AtLabel "x")+        )+      ,+        ( "string converts a number expression to a string"+        , "string"+        , [ArgExpression (DataNumber (numToBts 5))]+        , \term -> expectExpression term (DataString (strToBts "5"))+        )+      ,+        ( "string keeps a string expression as is"+        , "string"+        , [ArgExpression (DataString (strToBts "already"))]+        , \term -> expectExpression term (DataString (strToBts "already"))+        )+      ,+        ( "string renders an attribute"+        , "string"+        , [ArgAttribute AtRho]+        , \term -> expectExpression term (DataString (strToBts "ρ"))+        )+      ,+        ( "number parses a number out of a string expression"+        , "number"+        , [ArgExpression (DataString (strToBts "42"))]+        , \term -> expectExpression term (DataNumber (numToBts 42))+        )+      ,+        ( "sum adds several numeric arguments"+        , "sum"+        , [ArgExpression (DataNumber (numToBts 2)), ArgExpression (DataNumber (numToBts 3))]+        , \term -> expectExpression term (DataNumber (numToBts 5))+        )+      ]++    failureCases :: [(String, String, [ExtraArgument], String)]+    failureCases =+      [ ("contextualize fails on the wrong number of arguments", "contextualize", [ArgExpression ExXi], "contextualize() requires exactly 2 arguments")+      , ("random-tau fails when given arguments", "random-tau", [ArgExpression ExRoot], "random-tau() requires exactly 0 arguments")+      , ("dataize fails on a non-data-object expression", "dataize", [ArgExpression ExRoot], "Only data objects and bytes are supported")+      , ("dataize fails on the wrong number of arguments", "dataize", [ArgExpression ExRoot, ArgExpression ExXi], "dataize() requires exactly 1 argument")+      , ("sed fails on fewer than two arguments", "sed", [ArgExpression (DataString (strToBts "hello"))], "sed() requires at least two arguments")+      ,+        ( "sed fails when the pattern does not start with 's/'"+        , "sed"+        , [ArgExpression (DataString (strToBts "hello")), ArgExpression (DataString (strToBts "l/L/"))]+        , "sed pattern must start with s/"+        )+      ,+        ( "sed fails when the pattern has an unknown trailing flag"+        , "sed"+        , [ArgExpression (DataString (strToBts "hello")), ArgExpression (DataString (strToBts "s/l/L/x"))]+        , "sed pattern must be in format s/pat/rep/[g]"+        )+      , ("random-string fails on the wrong number of arguments", "random-string", [], "random-string() requires exactly 1")+      , ("size fails on a non-meta binding argument", "size", [ArgBinding (BiVoid AtRho)], "size() requires exactly 1 meta binding")+      , ("tau fails on the wrong number of arguments", "tau", [], "tau() requires exactly 1 argument")+      ,+        ( "string fails on an expression that is neither a number nor a string"+        , "string"+        , [ArgExpression ExRoot]+        , "only 'Φ.number' or 'Φ.string' are allowed"+        )+      , ("string fails on the wrong number of arguments", "string", [], "string() requires exactly 1 argument")+      , ("number fails on an expression that is not a string", "number", [ArgExpression (DataNumber (numToBts 1))], "expects expression to be 'Φ.string'")+      , ("number fails on the wrong number of arguments", "number", [], "number() requires exactly 1 argument")+      ,+        ( "an unsupported function name fails with a descriptive message"+        , "no-such-function"+        , []+        , "Function no-such-function() is not supported or does not exist"+        )+      ]
test/LaTeXSpec.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-}  -- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com@@ -8,11 +9,23 @@ -} module LaTeXSpec where -import AST (Expression (ExMeta))+import AST (Attribute (AtLabel, AtPhi, AtRho), Binding (BiTau, BiVoid), Bytes (BtOne), Expression (ExFormation, ExMeta, ExPhiAgain, ExPhiMeet, ExRoot)) import Control.Monad (forM_) import Data.List (intercalate) import Data.Text qualified as T-import LaTeX (LatexContext (..), conditionToLatex, defaultLatexContext, meetInExpression, meetInExpressions, rewrittensToLatex)+import LaTeX+  ( LatexContext (..)+  , conditionToLatex+  , defaultLatexContext+  , explainContextualizeRules+  , explainDataizeRules+  , explainMorphRules+  , explainRules+  , expressionToLaTeX+  , meetInExpression+  , meetInExpressions+  , rewrittensToLatex+  ) import Lining (LineFormat (MULTILINE)) import Parser (parseExpressionThrows) import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe, shouldContain)@@ -70,5 +83,299 @@     forM_       [ ("formation", Y.IsFormation (ExMeta "n"), "{ \\phinoIsFormation{ n } }")       , ("not formation", Y.Not (Y.IsFormation (ExMeta "n")), "{ \\phinoNotFormation{ n } }")+      , ("empty (And [])", Y.And [], "{ }")+      , ("empty (Or [])", Y.Or [], "{ }")+      , ("normal form", Y.NF (ExMeta "n"), "{ \\isnormal{ n } }")+      , ("matches", Y.Matches "abc" (ExMeta "n"), "{ matches\\lparen abc, n \\rparen }")+      , ("part-of", Y.PartOf (ExMeta "n") (BiVoid AtRho), "{ part-of\\lparen n, \\phiTerminal{\\rho} -> ? \\rparen }")+      , ("compare equal", Y.Eq (Y.CmpAttr AtRho) (Y.CmpAttr AtPhi), "{ \\phiTerminal{\\rho} = @ }")+      , ("compare greater", Y.Gt (Y.CmpNum (Y.Literal 3)) (Y.CmpNum (Y.Literal 4)), "{ 3 > 4 }")+      , ("not normal form", Y.Not (Y.NF (ExMeta "n")), "{ not\\lparen \\isnormal{ n } \\rparen }")+      , ("disjoint", Y.Disjoint [AtRho] [BiVoid AtRho], "{ [ \\phiTerminal{\\rho} ] \\cap \\phiTerminal{\\rho} -> ? = \\emptyset }")+      , ("absolute", Y.Absolute (ExMeta "n"), "{ \\phinoAbsolute{ n } }")       ]       (\(desc, cond, expected) -> it desc (conditionToLatex (Just cond) `shouldBe` expected))++  describe "expressionToLaTeX" $ do+    forM_+      [+        ( "renders '\\phiquation*' (unnumbered) when '_nonumber' is set"+        , \ctx -> ctx{_nonumber = True}+        , "\\begin{phiquation*}\n[[ |x| -> Q . |y| ]]{.}\n\\end{phiquation*}"+        )+      ,+        ( "renders a '\\label{}' when '_label' is set"+        , \ctx -> ctx{_label = Just "eq:one"}+        , "\\begin{phiquation}\n\\label{eq:one}\n[[ |x| -> Q . |y| ]]{.}\n\\end{phiquation}"+        )+      ,+        ( "renders a '\\phiExpression{}' prefix when '_expression' is set"+        , \ctx -> ctx{_expression = Just "e"}+        , "\\begin{phiquation}\n\\phiExpression{e} [[ |x| -> Q . |y| ]]{.}\n\\end{phiquation}"+        )+      ]+      ( \(desc, adjustContext, expected) -> it desc $ do+          expr <- parseExpressionThrows "[[ x -> Q.y ]]"+          expressionToLaTeX expr (adjustContext defaultLatexContext) `shouldBe` expected+      )++    it "escapes '@' and '^' in an attribute label, same as '$' and '_'" $ do+      let weird = ExFormation [BiTau (AtLabel "a@b^c") ExRoot, BiVoid AtRho]+      expressionToLaTeX weird defaultLatexContext+        `shouldBe` "\\begin{phiquation}\n[[ |a\\char64{}b\\char94{}c| -> Q ]]{.}\n\\end{phiquation}"++    forM_+      [+        ( "renders a \\phinoMeet{} marker with its prefix"+        , ExPhiMeet (Just "pfx") 2+        , "\\begin{phiquation}\n\\phinoMeet{pfx:2}{ Q . |y| }{.}\n\\end{phiquation}"+        )+      ,+        ( "renders a \\phinoAgain{} marker without a prefix"+        , ExPhiAgain Nothing 3+        , "\\begin{phiquation}\n\\phinoAgain{3}{.}\n\\end{phiquation}"+        )+      ]+      ( \(desc, wrapInner, expected) -> it desc $ do+          inner <- parseExpressionThrows "Q.y"+          expressionToLaTeX (wrapInner inner) defaultLatexContext `shouldBe` expected+      )++  describe "rewrittensToLatex" $ do+    it "renders the ellipsis ending when the chain exceeded its bound" $ do+      step1 <- parseExpressionThrows "[[ x -> Q.y ]]"+      latex <- rewrittensToLatex ([(step1, Nothing)], True) defaultLatexContext+      latex `shouldBe` "\\begin{phiquation}\n[[ |x| -> Q . |y| ]] \\leadsto\n  \\leadsto \\dots\n\\end{phiquation}"++    it "prefixes each step with a '% === Step' header when '_headers' is set" $ do+      step1 <- parseExpressionThrows "[[ x -> Q.y ]]"+      step2 <- parseExpressionThrows "[[ x -> Q.z ]]"+      latex <- rewrittensToLatex ([(step1, Nothing), (step2, Just "myrule")], False) defaultLatexContext{_headers = True}+      latex+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phiquation}"+          , "% === Step #1"+          , "[[ |x| -> Q . |y| ]]"+          , "% === Step #2, Rule '?', 11t -> 11t"+          , "  \\leadsto [[ |x| -> Q . |z| ]] \\leadsto_{\\nameref{r:myrule}}{.}"+          , "\\end{phiquation}"+          ]++    it "locates the focused sub-expression at every step" $ do+      step1 <- parseExpressionThrows "[[ x -> Q.aaa.bbb.ccc.ddd ]]"+      step2 <- parseExpressionThrows "[[ x -> Q.aaa.bbb.ccc.ddd.eee ]]"+      focus <- parseExpressionThrows "Q.x"+      latex <- rewrittensToLatex ([(step1, Nothing), (step2, Just "r")], False) defaultLatexContext{_focus = focus}+      latex+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phiquation}"+          , "Q . |aaa| . |bbb| . |ccc| . |ddd|"+          , "  \\leadsto Q . |aaa| . |bbb| . |ccc| . |ddd| . |eee| \\leadsto_{\\nameref{r:r}}{.}"+          , "\\end{phiquation}"+          ]++    it "compresses and canonizes a --sequence of full expressions above the default meet threshold" $ do+      step1 <- parseExpressionThrows "[[ x -> Q.a.b.c.d ]]"+      step2 <- parseExpressionThrows "[[ y -> Q.a.b.c.d ]]"+      step3 <- parseExpressionThrows "[[ z -> Q.a.b.c.d ]]"+      latex <-+        rewrittensToLatex+          ([(step1, Nothing), (step2, Just "r1"), (step3, Just "r2")], False)+          defaultLatexContext{_compress = True, _canonize = True}+      latex+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phiquation}"+          , "[[ |x| -> \\phinoMeet{1}{ Q . |a| . |b| . |c| . |d| } ]]"+          , "  \\leadsto [[ |y| -> \\phinoAgain{1} ]] \\leadsto_{\\nameref{r:r1}}"+          , "  \\leadsto [[ |z| -> \\phinoAgain{1} ]] \\leadsto_{\\nameref{r:r2}}{.}"+          , "\\end{phiquation}"+          ]++    it "compresses and canonizes a --sequence of focused sub-expressions above the default meet threshold" $ do+      focus <- parseExpressionThrows "Q.x"+      step1 <- parseExpressionThrows "[[ x -> [[ w -> Q.a.b.c.d ]] ]]"+      step2 <- parseExpressionThrows "[[ x -> [[ w -> Q.a.b.c.d ]] ]]"+      step3 <- parseExpressionThrows "[[ x -> [[ w -> Q.a.b.c.d ]] ]]"+      latex <-+        rewrittensToLatex+          ([(step1, Nothing), (step2, Just "r1"), (step3, Just "r2")], False)+          defaultLatexContext{_focus = focus, _compress = True, _canonize = True}+      latex+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phiquation}"+          , "\\phinoMeet{1}{ [[ |w| -> Q . |a| . |b| . |c| . |d| ]] }"+          , "  \\leadsto \\phinoAgain{1} \\leadsto_{\\nameref{r:r1}}"+          , "  \\leadsto \\phinoAgain{1} \\leadsto_{\\nameref{r:r2}}{.}"+          , "\\end{phiquation}"+          ]++  describe "meetInExpressions" $ do+    it "returns an empty list for an empty sequence" $+      meetInExpressions [] defaultLatexContext `shouldBe` []++    it "finds a meet using the default meet length and popularity, without overriding them" $ do+      step1 <- parseExpressionThrows "[[ x -> Q.a.b.c.d ]]"+      step2 <- parseExpressionThrows "[[ y -> Q.a.b.c.d ]]"+      step3 <- parseExpressionThrows "[[ z -> Q.a.b.c.d ]]"+      let compressed = meetInExpressions [step1, step2, step3] defaultLatexContext{_compress = True}+      length compressed `shouldBe` 3+      case compressed of+        (firstStep : _) -> T.count "ExPhiMeet" (T.pack (show firstStep)) `shouldBe` 1+        [] -> expectationFailure "meetInExpressions returned no expressions"++  describe "explainRules" $+    forM_+      [+        ( "renders a rule's label, combined 'when'/'having' condition and 'where' extras"+        , Y.Rule+            { name = "myrule"+            , label = Just "disp"+            , description = Nothing+            , pattern = ExMeta "n"+            , result = ExMeta "n"+            , when = Just (Y.NF (ExMeta "n"))+            , having = Just (Y.IsFormation (ExMeta "n"))+            , where_ =+                Just+                  [ Y.Extra+                      { meta = Y.ArgAttribute AtRho+                      , function = "foo"+                      , args = [Y.ArgExpression (ExMeta "n"), Y.ArgBinding (BiVoid AtRho), Y.ArgBytes (BtOne "01-02")]+                      }+                  , Y.Extra+                      { meta = Y.ArgAttribute AtPhi+                      , function = "bar"+                      , args = [Y.ArgExpression (ExMeta "n")]+                      }+                  ]+            }+        ,+          [ "\\phinoNormalizationRule[disp]{myrule}"+          , "{ n }"+          , "{ n }"+          , "{ \\isnormal{ n } \\;\\text{and}\\; \\phinoIsFormation{ n } }"+          , "{ \\phiTerminal{\\rho} \\coloneqq \\foo{ n, \\phiTerminal{\\rho} -> ?, 01-02- } and @ \\coloneqq \\bar{ n } }"+          ]+        )+      ,+        ( "omits the label and the 'if'/'where' clauses when they are absent"+        , Y.Rule+            { name = "myrule2"+            , label = Nothing+            , description = Nothing+            , pattern = ExMeta "n"+            , result = ExMeta "n"+            , when = Nothing+            , having = Nothing+            , where_ = Nothing+            }+        ,+          [ "\\phinoNormalizationRule{myrule2}"+          , "{ n }"+          , "{ n }"+          , "{ }"+          , "{ }"+          ]+        )+      ,+        ( "keeps just the 'having' condition when 'when' is absent"+        , Y.Rule+            { name = "myrule3"+            , label = Nothing+            , description = Nothing+            , pattern = ExMeta "n"+            , result = ExMeta "n"+            , when = Nothing+            , having = Just (Y.IsFormation (ExMeta "n"))+            , where_ = Nothing+            }+        ,+          [ "\\phinoNormalizationRule{myrule3}"+          , "{ n }"+          , "{ n }"+          , "{ \\phinoIsFormation{ n } }"+          , "{ }"+          ]+        )+      ]+      (\(desc, rule, expectedLines) -> it desc (explainRules [rule] `shouldBe` intercalate "\n  " expectedLines))++  describe "explainMorphRules" $+    it "threads the state through every premise operation (morph, normalize, evaluate, contextualize, dataize)" $ do+      let rule =+            Y.MorphRule+              { name = "morph1"+              , label = Just "mlbl"+              , match = ExMeta "n"+              , ematch = ExMeta "e"+              , nresult = ExMeta "n1"+              , when = Just (Y.NF (ExMeta "n"))+              , premises =+                  [ Y.Premise{result = "n1", operation = Y.OpMorph (ExMeta "n")}+                  , Y.Premise{result = "n2", operation = Y.OpNormalize (ExMeta "n1")}+                  , Y.Premise{result = "n3", operation = Y.OpEvaluate (ExMeta "n2") (ExMeta "e")}+                  , Y.Premise{result = "n4", operation = Y.OpContextualize (ExMeta "n3") (ExMeta "e")}+                  , Y.Premise{result = "n5", operation = Y.OpDataize (ExMeta "n4")}+                  ]+              }+      explainMorphRules [rule]+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phinoMorphingInference}"+          , "  \\phinoName{morph1}"+          , "  \\phinoLabel{mlbl}"+          , "  \\phinoCondition{ \\isnormal{ n } }"+          , "  \\phinoPremise{ \\phinoMorph{ n }{ e }{ s_1 }{ n_1 }{ s_2 } }"+          , "  \\phinoPremise{ \\phinoNormalize{ n_1 }{ n_2 } }"+          , "  \\phinoPremise{ \\phinoEvaluate{ n_2 }{ e }{ s_2 }{ n_3 }{ s_3 } }"+          , "  \\phinoPremise{ \\phinoContextualize{ n_3 }{ e }{ n_4 } }"+          , "  \\phinoPremise{ \\phinoDataize{ n_4 }{ e }{ s_3 }{ \\delta_5 }{ s_4 } }"+          , "  \\phinoConclusion{ \\phinoMorph{ n }{ e }{ s_1 }{ n_1 }{ s_4 } }"+          , "\\end{phinoMorphingInference}"+          ]++  describe "explainDataizeRules" $+    it "renders a bare 's' in the conclusion and omits an empty 'when' condition" $ do+      let rule =+            Y.DataizeRule+              { name = "dataize1"+              , label = Nothing+              , match = ExMeta "n"+              , ematch = ExMeta "e"+              , dresult = BtOne "05"+              , when = Just (Y.And [])+              , premises = []+              }+      explainDataizeRules [rule]+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phinoDataizationInference}"+          , "  \\phinoName{dataize1}"+          , "  \\phinoConclusion{ \\phinoDataize{ n }{ e }{ s }{ |05-| }{ s } }"+          , "\\end{phinoDataizationInference}"+          ]++  describe "explainContextualizeRules" $+    it "threads a morph premise through the rule's own 'e' universe" $ do+      let rule =+            Y.ContextualizeRule+              { name = "ctx1"+              , label = Nothing+              , match = ExMeta "n"+              , cmatch = ExMeta "c"+              , cresult = ExMeta "n1"+              , premises = [Y.Premise{result = "n1", operation = Y.OpMorph (ExMeta "n")}]+              }+      explainContextualizeRules [rule]+        `shouldBe` intercalate+          "\n"+          [ "\\begin{phinoContextualizationInference}"+          , "  \\phinoName{ctx1}"+          , "  \\phinoPremise{ \\phinoMorph{ n }{ e }{ s_1 }{ n_1 }{ s_2 } }"+          , "  \\phinoConclusion{ \\phinoContextualize{ n }{ e }{ n_1 } }"+          , "\\end{phinoContextualizationInference}"+          ]
+ test/LiningSpec.hs view
@@ -0,0 +1,232 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module LiningSpec where++import CST+import Control.Monad (forM_)+import Lining (LineFormat (..), toSingleLine, withLineFormat)+import Test.Hspec (Spec, describe, it, shouldBe)++leafExpr :: EXPRESSION+leafExpr = EX_GLOBAL Φ++multilineFormation :: EXPRESSION+multilineFormation =+  EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW leafExpr) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB++singlelineFormation :: EXPRESSION+singlelineFormation =+  EX_FORMATION LSB NO_EOL TAB' (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW leafExpr) (BDS_EMPTY (TAB 1)) TAB') NO_EOL TAB' RSB++biPair :: BINDING+biPair = BI_PAIR (PA_TAU (AT_LABEL "x") ARROW leafExpr) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "y") ARROW leafExpr) (BDS_EMPTY (TAB 1))) (TAB 1)++expectedBiPair :: BINDING+expectedBiPair = BI_PAIR (PA_TAU (AT_LABEL "x") ARROW leafExpr) (BDS_PAIR NO_EOL TAB' (PA_TAU (AT_LABEL "y") ARROW leafExpr) (BDS_EMPTY (TAB 1))) TAB'++spec :: Spec+spec = do+  describe "toSingleLine on EXPRESSION" $ do+    forM_+      [ ("collapses a multiline formation into one line", multilineFormation, singlelineFormation)+      ,+        ( "keeps the BI_EMPTY special case, only touching the outer tabs"+        , EX_FORMATION LSB EOL (TAB 1) (BI_EMPTY (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB NO_EOL NO_TAB (BI_EMPTY (TAB 1)) NO_EOL NO_TAB RSB+        )+      ,+        ( "recurses through EX_DISPATCH"+        , EX_DISPATCH multilineFormation NO_SPACE (AT_LABEL "y")+        , EX_DISPATCH singlelineFormation NO_SPACE (AT_LABEL "y")+        )+      ,+        ( "recurses through EX_APPLICATION"+        , EX_APPLICATION multilineFormation NO_SPACE EOL (TAB 1) (AA_TAUS biPair) EOL (TAB 0) 1+        , EX_APPLICATION singlelineFormation NO_SPACE NO_EOL TAB' (AA_TAUS expectedBiPair) NO_EOL TAB' 1+        )+      , ("recurses through EX_PHI_MEET", EX_PHI_MEET (Just "p") 3 multilineFormation, EX_PHI_MEET (Just "p") 3 singlelineFormation)+      , ("recurses through EX_PHI_AGAIN", EX_PHI_AGAIN Nothing 4 multilineFormation, EX_PHI_AGAIN Nothing 4 singlelineFormation)+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++    it "leaves every other constructor untouched" $+      forM_+        [ EX_GLOBAL Φ+        , EX_XI XI+        , EX_ATTR (AT_LABEL "z")+        , EX_TERMINATION DEAD+        , EX_STRING "hi" (TAB 2) []+        , EX_NUMBER (Left 5) (TAB 2) []+        , EX_META (META NO_EXCL E "x")+        , EX_BYTES BT_EMPTY+        ]+        (\node -> toSingleLine node `shouldBe` node)++  describe "toSingleLine on BINDING" $+    forM_+      [ ("recurses through BI_PAIR, forcing TAB'", biPair, expectedBiPair)+      ,+        ( "recurses through BI_META, keeping the meta untouched"+        , BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)+        , BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) TAB'+        )+      , ("leaves BI_EMPTY untouched", BI_EMPTY (TAB 1), BI_EMPTY (TAB 1))+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on BINDINGS" $+    forM_+      [+        ( "recurses through BDS_PAIR, forcing TAB'"+        , BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "x") ARROW leafExpr) (BDS_EMPTY (TAB 1))+        , BDS_PAIR NO_EOL TAB' (PA_TAU (AT_LABEL "x") ARROW leafExpr) (BDS_EMPTY (TAB 1))+        )+      ,+        ( "recurses through BDS_META, keeping the meta untouched"+        , BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))+        , BDS_META NO_EOL TAB' (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))+        )+      , ("leaves BDS_EMPTY untouched", BDS_EMPTY (TAB 1), BDS_EMPTY (TAB 1))+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on PAIR" $+    forM_+      [+        ( "recurses through PA_TAU"+        , PA_TAU (AT_LABEL "x") ARROW multilineFormation+        , PA_TAU (AT_LABEL "x") ARROW singlelineFormation+        )+      ,+        ( "recurses through PA_ALPHA"+        , PA_ALPHA (AL_IDX ALPHA 0) ARROW multilineFormation+        , PA_ALPHA (AL_IDX ALPHA 0) ARROW singlelineFormation+        )+      ,+        ( "recurses through PA_FORMATION"+        , PA_FORMATION (AT_LABEL "x") [AT_RHO RHO] ARROW multilineFormation+        , PA_FORMATION (AT_LABEL "x") [AT_RHO RHO] ARROW singlelineFormation+        )+      , ("leaves every other constructor untouched", PA_VOID (AT_LABEL "x") ARROW EMPTY, PA_VOID (AT_LABEL "x") ARROW EMPTY)+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on APP_BINDING" $+    it "recurses into the pair" $+      toSingleLine (APP_BINDING (PA_TAU (AT_LABEL "x") ARROW multilineFormation))+        `shouldBe` APP_BINDING (PA_TAU (AT_LABEL "x") ARROW singlelineFormation)++  describe "toSingleLine on APP_ARGUMENT" $+    forM_+      [+        ( "recurses through AA_TAU"+        , AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "x") ARROW multilineFormation))+        , AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "x") ARROW singlelineFormation))+        )+      , ("recurses through AA_TAUS", AA_TAUS biPair, AA_TAUS expectedBiPair)+      ,+        ( "recurses through AA_EXPRS"+        , AA_EXPRS (APP_ARG multilineFormation AAS_EMPTY)+        , AA_EXPRS (APP_ARG singlelineFormation AAS_EMPTY)+        )+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on APP_ARG" $+    it "recurses through both fields" $ do+      let appArg = APP_ARG multilineFormation (AAS_EXPR EOL (TAB 1) leafExpr AAS_EMPTY)+          expected = APP_ARG singlelineFormation (AAS_EXPR NO_EOL TAB' leafExpr AAS_EMPTY)+      toSingleLine appArg `shouldBe` expected++  describe "toSingleLine on APP_ARGS" $+    forM_+      [+        ( "recurses through AAS_EXPR"+        , AAS_EXPR EOL (TAB 1) multilineFormation AAS_EMPTY+        , AAS_EXPR NO_EOL TAB' singlelineFormation AAS_EMPTY+        )+      , ("leaves AAS_EMPTY untouched", AAS_EMPTY, AAS_EMPTY)+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on SET" $+    forM_+      [ ("recurses through ST_BINDING", ST_BINDING biPair, ST_BINDING expectedBiPair)+      , ("leaves ST_ATTRIBUTES untouched", ST_ATTRIBUTES [AT_LABEL "x"], ST_ATTRIBUTES [AT_LABEL "x"])+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on NUMBER" $ do+    forM_+      [ ("recurses through LENGTH", LENGTH biPair, LENGTH expectedBiPair)+      , ("recurses through DOMAIN", DOMAIN biPair, DOMAIN expectedBiPair)+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++    it "leaves IDX_META and LITERAL untouched" $ do+      let idxMeta = IDX_META (META NO_EXCL I "x")+          literalNum = LITERAL 5+      toSingleLine idxMeta `shouldBe` idxMeta+      toSingleLine literalNum `shouldBe` literalNum++  describe "toSingleLine on COMPARABLE" $+    forM_+      [ ("leaves CMP_ATTR untouched", CMP_ATTR (AT_LABEL "x"), CMP_ATTR (AT_LABEL "x"))+      , ("recurses through CMP_EXPR", CMP_EXPR multilineFormation, CMP_EXPR singlelineFormation)+      , ("recurses through CMP_NUM", CMP_NUM (LENGTH biPair), CMP_NUM (LENGTH expectedBiPair))+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on CONDITION" $ do+    let stBinding = ST_BINDING biPair+        coBelongs = CO_BELONGS (AT_LABEL "x") IN stBinding+    forM_+      [ ("recurses through CO_BELONGS", coBelongs, CO_BELONGS (AT_LABEL "x") IN (ST_BINDING expectedBiPair))+      ,+        ( "recurses through every condition in CO_LOGIC"+        , CO_LOGIC [coBelongs, CO_EMPTY] AND+        , CO_LOGIC [CO_BELONGS (AT_LABEL "x") IN (ST_BINDING expectedBiPair), CO_EMPTY] AND+        )+      , ("recurses through CO_NF", CO_NF multilineFormation, CO_NF singlelineFormation)+      , ("recurses through CO_ABSOLUTE", CO_ABSOLUTE multilineFormation IN, CO_ABSOLUTE singlelineFormation IN)+      , ("recurses through CO_NOT", CO_NOT coBelongs, CO_NOT (CO_BELONGS (AT_LABEL "x") IN (ST_BINDING expectedBiPair)))+      ,+        ( "recurses through CO_COMPARE"+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) EQUAL (CMP_EXPR multilineFormation)+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) EQUAL (CMP_EXPR singlelineFormation)+        )+      , ("recurses through CO_MATCHES", CO_MATCHES "abc" multilineFormation, CO_MATCHES "abc" singlelineFormation)+      , ("recurses through CO_PART_OF", CO_PART_OF multilineFormation biPair, CO_PART_OF singlelineFormation expectedBiPair)+      ,+        ( "recurses through every group in CO_DISJOINT"+        , CO_DISJOINT [AT_LABEL "x"] [biPair]+        , CO_DISJOINT [AT_LABEL "x"] [expectedBiPair]+        )+      , ("recurses through CO_FORMATION", CO_FORMATION multilineFormation, CO_FORMATION singlelineFormation)+      , ("leaves CO_EMPTY untouched", CO_EMPTY, CO_EMPTY)+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on EXTRA_ARG" $+    forM_+      [ ("recurses through ARG_EXPR", ARG_EXPR multilineFormation, ARG_EXPR singlelineFormation)+      , ("recurses through ARG_BINDING", ARG_BINDING biPair, ARG_BINDING expectedBiPair)+      , ("leaves ARG_ATTR untouched", ARG_ATTR (AT_LABEL "x"), ARG_ATTR (AT_LABEL "x"))+      , ("leaves ARG_BYTES untouched", ARG_BYTES BT_EMPTY, ARG_BYTES BT_EMPTY)+      ]+      (\(desc, node, expected) -> it desc (toSingleLine node `shouldBe` expected))++  describe "toSingleLine on EXTRA" $+    it "recurses through meta and every arg, keeping func untouched" $ do+      let extra = EXTRA (ARG_EXPR multilineFormation) "func" [ARG_ATTR (AT_LABEL "x"), ARG_BYTES BT_EMPTY]+          expected = EXTRA (ARG_EXPR singlelineFormation) "func" [ARG_ATTR (AT_LABEL "x"), ARG_BYTES BT_EMPTY]+      toSingleLine extra `shouldBe` expected++  describe "withLineFormat" $ do+    it "MULTILINE is the identity" $+      withLineFormat MULTILINE multilineFormation `shouldBe` multilineFormation+    it "SINGLELINE is toSingleLine" $+      withLineFormat SINGLELINE multilineFormation `shouldBe` toSingleLine multilineFormation
test/LocatorSpec.hs view
@@ -1,17 +1,32 @@+{-# LANGUAGE OverloadedStrings #-}+ -- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com -- SPDX-License-Identifier: MIT  module LocatorSpec where +import AST (Attribute (AtLabel), Binding (BiTau, BiVoid), Expression (ExFormation, ExRoot, ExXi))+import Control.Exception (SomeException, displayException, try) import Control.Monad (forM_) import Data.List (intercalate) import Locator (locatedExpression, withLocatedExpression) import Parser (parseExpressionThrows)-import Test.Hspec (Spec, describe, it, shouldBe)+import Printer (printExpression)+import Test.Hspec (Spec, anyException, describe, expectationFailure, it, shouldBe, shouldThrow)+import Text.Printf (printf) +invalidLocatorMessage :: Expression -> String+invalidLocatorMessage locator =+  printf+    "Invalid locator is provided. 'Q' or dispatch started with 'Q' expected, but got: '%s'"+    (printExpression locator)++canNotFindObjectMessage :: Expression -> String+canNotFindObjectMessage locator = printf "Can't find object by locator: '%s'" (printExpression locator)+ spec :: Spec spec = do-  describe "located expression" $+  describe "located expression" $ do     forM_       [ ("[[ x -> [[ y -> [[ z -> ? ]] ]] ]]", "Q.x.y", "[[ z -> ? ]]")       , ("[[ x -> ?, y -> [[ z -> ?, w -> [[ a -> $.x ]] ]], z -> ? ]]", "Q.y.w.a", "$.x")@@ -25,11 +40,41 @@           located `shouldBe` res'       ) -  describe "with located expression" $+    it "short-circuits on ExRoot regardless of the given expression" $ do+      expr' <- parseExpressionThrows "[[ x -> ? ]]"+      located <- locatedExpression ExRoot expr'+      located `shouldBe` expr'+     forM_+      [ ("fails with CanNotFindObjectByLocator on a missing multi-level attribute", "[[ x -> [[ y -> ? ]] ]]", parseExpressionThrows "Q.x.z", canNotFindObjectMessage)+      , ("fails with CanNotFindObjectByLocator when an intermediate (non-final) attribute is missing", "[[ w -> ? ]]", parseExpressionThrows "Q.x.y.z", canNotFindObjectMessage)+      , ("fails with CanNotFindObjectByLocator when the chain runs into a non-formation", "[[ x -> $ ]]", parseExpressionThrows "Q.x.y", canNotFindObjectMessage)+      , ("fails with InvalidLocatorProvided on a non-Q-dispatch locator", "[[ x -> ? ]]", parseExpressionThrows "$.x", invalidLocatorMessage)+      , ("throws InvalidLocatorProvided with the exact message for a non-dispatch-chain locator expression", "[[ x -> ? ]]", pure ExXi, invalidLocatorMessage)+      , ("throws InvalidLocatorProvided when the locator is a bare formation, not a dispatch chain", "[[ x -> ? ]]", pure (ExFormation []), invalidLocatorMessage)+      , ("throws CanNotFindObjectByLocator with the exact message for a missing attribute", "[[ x -> ? ]]", parseExpressionThrows "Q.y", canNotFindObjectMessage)+      ]+      ( \(desc, exprText, locatorAction, messageOf) -> it desc $ do+          expr' <- parseExpressionThrows exprText+          locator' <- locatorAction+          locatedExpression locator' expr' `shouldThrow` anyException+          result <- try (locatedExpression locator' expr') :: IO (Either SomeException Expression)+          case result of+            Left err -> do+              show err `shouldBe` messageOf locator'+              displayException err `shouldBe` messageOf locator'+            Right _ -> expectationFailure "expected locatedExpression to throw"+      )++  describe "with located expression" $ do+    forM_       [ ("[[ x -> $ ]]", "Q.x", "[[ y -> ? ]]", "[[ x -> [[ y -> ? ]] ]]")       , ("[[ x -> ?, y -> [[ x -> ?, y -> [[ ]] ]] ]]", "Q.y.y", "Q.x.y", "[[ x -> ?, y -> [[ x -> ?, y -> Q.x.y ]] ]]")       , ("[[ x -> [[ y -> [[ z -> [[ w -> ? ]] ]] ]] ]]", "Q.x.y", "$.a(x -> [[]])", "[[ x -> [[ y -> $.a(x -> [[]]) ]] ]]")+      , ("[[ b -> 1, x -> [[ y -> $ ]] ]]", "Q.x.y", "5", "[[ b -> 1, x -> [[ y -> 5 ]] ]]")+      , ("[[ a -> ?, x -> [[ y -> $ ]] ]]", "Q.x.y", "5", "[[ a -> ?, x -> [[ y -> 5 ]] ]]")+      , ("[[ L> Func, y -> $ ]]", "Q.y", "5", "[[ L> Func, y -> 5 ]]")+      , ("[[ x -> ?, y -> $ ]]", "Q.y", "5", "[[ x -> ?, y -> 5 ]]")       ]       ( \(input, locator, expr, res) -> it (intercalate " => " [input, locator, expr, res]) $ do           input' <- parseExpressionThrows input@@ -38,4 +83,50 @@           res' <- parseExpressionThrows res           loc <- withLocatedExpression locator' expr' input'           loc `shouldBe` res'+      )++    it "short-circuits on ExRoot, ignoring the input expression" $ do+      target <- parseExpressionThrows "[[ y -> ? ]]"+      input' <- parseExpressionThrows "[[ x -> ? ]]"+      loc <- withLocatedExpression ExRoot target input'+      loc `shouldBe` target++    -- locatedInBindings only ever matches a BiTau binding, so a lone `x -> ?`+    -- (BiVoid) target can never satisfy the "Just" check that gates the+    -- replace in withLocatedExpression', even though withReplacedExpression+    -- itself does have a case for replacing a BiVoid. The only way to reach+    -- that case is a duplicate attribute name where a later BiTau binding+    -- with the same attribute makes locatedInBindings succeed, while+    -- withReplacedExpression still replaces the earlier (BiVoid) occurrence+    -- first.+    it "replaces the first (BiVoid) occurrence of a duplicated attribute" $ do+      -- Built directly rather than parsed: the parser itself rejects a+      -- duplicated attribute name, but Locator operates on Expression+      -- values regardless of how they were constructed.+      let input' = ExFormation [BiVoid (AtLabel "x"), BiTau (AtLabel "x") ExXi]+      locator' <- parseExpressionThrows "Q.x"+      expr' <- parseExpressionThrows "5"+      let res' = ExFormation [BiTau (AtLabel "x") expr', BiTau (AtLabel "x") ExXi]+      loc <- withLocatedExpression locator' expr' input'+      loc `shouldBe` res'++    forM_+      [ ("fails with CanNotFindObjectByLocator when the multi-attr chain is exhausted", "[[ x -> [[ z -> ? ]] ]]", parseExpressionThrows "Q.x.y.w", canNotFindObjectMessage)+      , ("fails with CanNotFindObjectByLocator when the multi-attr chain runs into a non-formation", "[[ x -> $ ]]", parseExpressionThrows "Q.x.y", canNotFindObjectMessage)+      , ("fails with InvalidLocatorProvided on a non-Q-dispatch locator", "[[ x -> ? ]]", parseExpressionThrows "$.x", invalidLocatorMessage)+      , ("throws InvalidLocatorProvided with the exact message for a non-dispatch-chain locator expression", "[[ x -> ? ]]", pure ExXi, invalidLocatorMessage)+      , ("throws InvalidLocatorProvided when the locator is a bare formation, not a dispatch chain", "[[ x -> ? ]]", pure (ExFormation []), invalidLocatorMessage)+      , ("throws CanNotFindObjectByLocator with the exact message for a missing attribute", "[[ x -> ? ]]", parseExpressionThrows "Q.y", canNotFindObjectMessage)+      ]+      ( \(desc, inputText, locatorAction, messageOf) -> it desc $ do+          input' <- parseExpressionThrows inputText+          locator' <- locatorAction+          expr' <- parseExpressionThrows "5"+          withLocatedExpression locator' expr' input' `shouldThrow` anyException+          result <- try (withLocatedExpression locator' expr' input') :: IO (Either SomeException Expression)+          case result of+            Left err -> do+              show err `shouldBe` messageOf locator'+              displayException err `shouldBe` messageOf locator'+            Right _ -> expectationFailure "expected withLocatedExpression to throw"       )
+ test/LoggerSpec.hs view
@@ -0,0 +1,61 @@+-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module LoggerSpec where++import Control.Monad (forM_)+import Logger (LogLevel (..), logDebug, logError, setLogConfig)+import System.IO (stderr)+import System.IO.Silently (hCapture_)+import Test.Hspec (Spec, after_, describe, it, shouldBe)++-- setLogConfig mutates a global IORef, so every example resets it afterwards+-- to the module's own default; otherwise the last example to run here would+-- leak its log level/line-limit into whichever spec runs next.+spec :: Spec+spec = after_ (setLogConfig ERROR 25) $ do+  describe "logDebug" $+    forM_+      [ ("prints when the level allows debug messages", DEBUG, 25, "hello", "[DEBUG]: hello\n")+      , ("is suppressed when the configured level is above debug", ERROR, 25, "hello", "")+      , ("is suppressed when the line limit is zero", DEBUG, 0, "hello", "")+      ,+        ( "truncates a message with more lines than the configured limit"+        , DEBUG+        , 2+        , "line1\nline2\nline3\nline4"+        , "[DEBUG]: line1\nline2\n---| log is limited by --log-lines=2 option |---\n"+        )+      ,+        ( "does not truncate a message with no more lines than the configured limit"+        , DEBUG+        , 3+        , "line1\nline2"+        , "[DEBUG]: line1\nline2\n"+        )+      ,+        ( "prints the whole message unlimited when lines is -1, however many lines"+        , DEBUG+        , -1+        , "line1\nline2\nline3\nline4\nline5"+        , "[DEBUG]: line1\nline2\nline3\nline4\nline5\n"+        )+      ]+      ( \(desc, level, lineLimit, message, expected) -> it desc $ do+          setLogConfig level lineLimit+          captured <- hCapture_ [stderr] (logDebug message)+          captured `shouldBe` expected+      )++  describe "logError" $+    forM_+      [ ("prints when the level allows error messages", ERROR, 25, "[ERROR]: oops\n")+      , ("prints at the debug level too, since error is more severe", DEBUG, 25, "[ERROR]: oops\n")+      , ("is suppressed when the configured level is NONE", NONE, 25, "")+      , ("is suppressed when the line limit is zero", ERROR, 0, "")+      ]+      ( \(desc, level, lineLimit, expected) -> it desc $ do+          setLogConfig level lineLimit+          captured <- hCapture_ [stderr] (logError "oops")+          captured `shouldBe` expected+      )
+ test/MarginSpec.hs view
@@ -0,0 +1,168 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module MarginSpec where++import AST+import Bytes (numToBts, strToBts)+import CST+import Control.Monad (forM_)+import Lining (toSingleLine)+import Margin (withMargin)+import Render (render)+import Test.Hspec (Spec, describe, it, shouldBe, shouldNotBe)++bigLabel :: Attribute+bigLabel = AtLabel "aVeryLongAttributeNameThatWontFitOnOneLine"++bigFormation :: Expression+bigFormation = ExFormation [BiTau bigLabel ExRoot]++nestedFormation :: Expression+nestedFormation =+  ExFormation+    [ BiTau (AtLabel "x") ExRoot+    , BiTau (AtLabel "y") (ExFormation [BiTau bigLabel ExRoot, BiTau AtRho ExRoot])+    ]++longCalleeShortArg :: Expression+longCalleeShortArg = ExApplication bigFormation (ArTau (AtLabel "y") (ExDispatch ExXi AtRho))++shortCalleeLongArg :: Expression+shortCalleeLongArg = ExApplication (ExDispatch ExRoot (AtLabel "x")) (ArTau (AtLabel "y") bigFormation)++spec :: Spec+spec = do+  describe "withMargin on EX_FORMATION" $ do+    it "leaves an empty formation untouched at any margin" $+      withMargin 0 (expressionToCST (ExFormation [])) `shouldBe` expressionToCST (ExFormation [])++    forM_+      [+        ( "keeps a formation on one line when it fits the margin"+        , 100+        , nestedFormation+        , "⟦ x ↦ Φ, y ↦ ⟦ aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ, ρ ↦ Φ ⟧ ⟧"+        )+      ,+        ( "wraps a formation across lines when it does not fit the margin"+        , 1+        , nestedFormation+        , "⟦\n  x ↦ Φ,\n  y ↦ ⟦\n    aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ,\n    ρ ↦ Φ\n  ⟧\n⟧"+        )+      ]+      (\(desc, margin, expression, expected) -> it desc (render (withMargin margin (expressionToCST expression)) `shouldBe` expected))++  describe "withMargin leaves data primitives untouched" $ do+    it "a number literal is never wrapped" $+      let cst = expressionToCST (DataNumber (numToBts 42))+       in withMargin 0 cst `shouldBe` cst+    it "a string literal is never wrapped" $+      let cst = expressionToCST (DataString (strToBts "hello"))+       in withMargin 0 cst `shouldBe` cst++  describe "withMargin on EX_APPLICATION" $+    forM_+      [+        ( "keeps the whole application on one line when it all fits"+        , 100+        , longCalleeShortArg+        , "⟦ aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ ⟧( y ↦ ρ )"+        )+      ,+        ( "wraps only the callee formation when the callee alone still fits alongside the argument"+        , 10+        , longCalleeShortArg+        , "⟦\n  aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ\n⟧( y ↦ ρ )"+        )+      ,+        ( "wraps both the callee and the argument when neither fits alongside the other"+        , 1+        , longCalleeShortArg+        , "⟦\n  aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ\n⟧(\n  y ↦ ρ\n)"+        )+      ,+        ( "keeps a short callee on one line and wraps only the argument"+        , 60+        , shortCalleeLongArg+        , "Φ.x(\n  y ↦ ⟦ aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ ⟧\n)"+        )+      ,+        ( "wraps the argument formation itself when it does not fit even on its own line"+        , 1+        , shortCalleeLongArg+        , "Φ.x(\n  y ↦ ⟦\n    aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ\n  ⟧\n)"+        )+      ]+      (\(desc, margin, expression, expected) -> it desc (render (withMargin margin (expressionToCST expression)) `shouldBe` expected))++  describe "withMargin on positional (AA_EXPRS) application arguments" $+    forM_+      [ (100, "Φ.x( ⟦ aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ, ρ ↦ Φ ⟧ )")+      ,+        ( 1+        , "Φ.x(\n  ⟦\n    aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ,\n    ρ ↦ Φ\n  ⟧\n)"+        )+      ]+      ( \(margin, expected) ->+          it ("margin " ++ show margin) $+            let ex = ExApplication (ExDispatch ExRoot (AtLabel "x")) (ArAlpha (Alpha 0) (ExFormation [BiTau bigLabel ExRoot, BiTau AtRho ExRoot]))+             in render (withMargin margin (expressionToCST ex)) `shouldBe` expected+      )++  describe "withMargin on mixed tau/alpha (AA_TAUS with PA_ALPHA) application arguments" $+    forM_+      [ (100, "Φ.x( a ↦ Φ, α5 ↦ ⟦ aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ ⟧ )")+      ,+        ( 1+        , "Φ.x(\n  a ↦ Φ,\n  α5 ↦ ⟦\n    aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ\n  ⟧\n)"+        )+      ]+      ( \(margin, expected) ->+          it ("margin " ++ show margin) $+            let ex = ExApplication (ExApplication (ExDispatch ExRoot (AtLabel "x")) (ArTau (AtLabel "a") ExRoot)) (ArAlpha (Alpha 5) bigFormation)+             in render (withMargin margin (expressionToCST ex)) `shouldBe` expected+      )++  describe "withMargin on a manually built AA_TAU / APP_BINDING argument" $ do+    let manual =+          EX_APPLICATION+            (EX_DISPATCH (EX_GLOBAL Φ) NO_SPACE (AT_LABEL "x"))+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "aVeryLongAttributeNameThatWontFitOnOneLine") ARROW (EX_GLOBAL Φ))))+            EOL+            (TAB 0)+            1+    it "keeps it on one line when it fits" $+      render (withMargin 100 manual) `shouldBe` "Φ.x( aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ )"+    it "wraps it when it does not fit" $+      render (withMargin 1 manual) `shouldBe` "Φ.x(\n  aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ\n)"++  describe "withMargin on EX_DISPATCH" $+    forM_+      [+        ( "recurses into the dispatched-upon expression"+        , 1+        , ExDispatch bigFormation (AtLabel "z")+        , "⟦\n  aVeryLongAttributeNameThatWontFitOnOneLine ↦ Φ\n⟧.z"+        )+      ]+      (\(desc, margin, expression, expected) -> it desc (render (withMargin margin (expressionToCST expression)) `shouldBe` expected))++  describe "withMargin on EX_PHI_MEET" $ do+    it "produces the same result whether the margin is 1 or 1000" $+      let meet = expressionToCST (ExPhiMeet Nothing 3 bigFormation)+       in withMargin 1 meet `shouldBe` withMargin 1000 meet++    it "collapses its body via toSingleLine regardless of the margin" $+      let meet = expressionToCST (ExPhiMeet Nothing 3 bigFormation)+       in withMargin 1 meet `shouldBe` EX_PHI_MEET Nothing 3 (toSingleLine (expressionToCST bigFormation))++  describe "withMargin on EX_PHI_AGAIN" $+    it "threads the margin into its body" $ do+      let again = expressionToCST (ExPhiAgain Nothing 3 bigFormation)+      withMargin 1 again `shouldNotBe` withMargin 1000 again
test/MatcherSpec.hs view
@@ -8,7 +8,6 @@ import AST import Control.Monad (forM_) import Data.Map.Strict qualified as Map-import Data.Maybe (fromMaybe) import Data.Text qualified as T import Matcher import Test.Hspec (Example (Arg), Expectation, Spec, SpecWith, describe, it, shouldBe)@@ -16,12 +15,6 @@ substs :: [[(T.Text, MetaValue)]] -> [Subst] substs = map (Subst . Map.fromList) -maybeCombined :: Subst -> Subst -> Subst-maybeCombined first second =-  fromMaybe-    (error "combine returned Nothing")-    (combine first second)- test ::   (a -> a -> [Subst]) ->   [(String, a, a, [Subst])] ->@@ -168,6 +161,53 @@           it desc $ matchAlpha ptn tgt `shouldBe` expected       ) +  describe "matchFunction: function => function => substitution" $+    forM_+      [ ("!f => Func => [(!f >> Func)]", FnMeta "f", Function "Func", substs [[("f", MvFunction "Func")]])+      , ("Func => Func => [()]", Function "Func", Function "Func", substs [[]])+      , ("Func => Other => []", Function "Func", Function "Other", substs [])+      ]+      ( \(desc, ptn, tgt, expected) ->+          it desc $ matchFunction ptn tgt `shouldBe` expected+      )++  describe "matchExpression': phi-meet/phi-again nodes" $+    forM_+      [+        ( "PhiMeet(same prefix/idx) matches its inner expression"+        , ExPhiMeet (Just "p") 1 ExRoot+        , ExPhiMeet (Just "p") 1 ExRoot+        , substs [[]]+        )+      ,+        ( "PhiMeet with different idx does not match"+        , ExPhiMeet (Just "p") 1 ExRoot+        , ExPhiMeet (Just "p") 2 ExRoot+        , substs []+        )+      ,+        ( "PhiMeet with different prefix does not match"+        , ExPhiMeet (Just "p") 1 ExRoot+        , ExPhiMeet (Just "q") 1 ExRoot+        , substs []+        )+      ,+        ( "PhiAgain(same prefix/idx) matches its inner expression"+        , ExPhiAgain Nothing 0 ExXi+        , ExPhiAgain Nothing 0 ExXi+        , substs [[]]+        )+      ,+        ( "PhiAgain with different idx does not match"+        , ExPhiAgain Nothing 0 ExXi+        , ExPhiAgain Nothing 1 ExXi+        , substs []+        )+      ]+      ( \(desc, ptn, tgt, expected) ->+          it desc $ matchExpression' ptn tgt `shouldBe` expected+      )+   describe "matchBindings: [binding] => [binding] => substitution" $     test       matchBindings@@ -426,42 +466,34 @@         )       ] -  describe "combine" $ do-    it "combines empty substitutions" $-      combine substEmpty substEmpty `shouldBe` Just substEmpty-    it "combines two empty substs from list" $-      combine (Subst Map.empty) (Subst Map.empty) `shouldBe` Just substEmpty-    it "combines empty subst with single one" $ do-      let Subst joined = maybeCombined substEmpty (Subst (Map.singleton "at" (MvAttribute AtPhi)))-      Map.lookup "at" joined `shouldBe` Just (MvAttribute AtPhi)-    it "combines two different subst" $ do-      let Subst joined =-            maybeCombined-              (Subst (Map.singleton "first" (MvAttribute AtPhi)))-              (Subst (Map.singleton "second" (MvBytes (BtOne "00"))))-      Map.lookup "first" joined `shouldBe` Just (MvAttribute AtPhi)-      Map.lookup "second" joined `shouldBe` Just (MvBytes (BtOne "00"))-    it "leave values in the same substs" $ do-      let rho = MvAttribute AtRho-          first =-            Subst-              ( Map.fromList-                  [ ("first", rho)-                  , ("second", MvAttribute AtPhi)-                  ]-              )-          second = Subst (Map.singleton "first" rho)-          Subst joined = maybeCombined first second-      Map.lookup "first" joined `shouldBe` Just (MvAttribute AtRho)-    it "returns Nothing if values are different" $-      combine (Subst (Map.singleton "x" (MvAttribute AtPhi))) (Subst (Map.singleton "x" (MvAttribute AtRho))) `shouldBe` Nothing-    it "clears all the values" $ do-      let first =-            Subst-              ( Map.fromList-                  [ ("x", MvAttribute AtRho)-                  , ("y", MvBytes (BtOne "1F"))-                  ]-              )-          second = Subst (Map.singleton "x" (MvAttribute AtPhi))-      combine first second `shouldBe` Nothing+  describe "combine" $+    forM_+      [ ("combines two empty substitutions", substEmpty, substEmpty, Just substEmpty)+      , ("combines two empty substs built directly from an empty map", Subst Map.empty, Subst Map.empty, Just substEmpty)+      ,+        ( "combines an empty subst with a single-entry one"+        , substEmpty+        , Subst (Map.singleton "at" (MvAttribute AtPhi))+        , Just (Subst (Map.singleton "at" (MvAttribute AtPhi)))+        )+      ,+        ( "combines two substs with disjoint keys"+        , Subst (Map.singleton "first" (MvAttribute AtPhi))+        , Subst (Map.singleton "second" (MvBytes (BtOne "00")))+        , Just (Subst (Map.fromList [("first", MvAttribute AtPhi), ("second", MvBytes (BtOne "00"))]))+        )+      ,+        ( "keeps a shared key when both substs agree on its value"+        , Subst (Map.fromList [("first", MvAttribute AtRho), ("second", MvAttribute AtPhi)])+        , Subst (Map.singleton "first" (MvAttribute AtRho))+        , Just (Subst (Map.fromList [("first", MvAttribute AtRho), ("second", MvAttribute AtPhi)]))+        )+      , ("returns Nothing when a shared key disagrees", Subst (Map.singleton "x" (MvAttribute AtPhi)), Subst (Map.singleton "x" (MvAttribute AtRho)), Nothing)+      ,+        ( "returns Nothing for the whole merge when any key conflicts"+        , Subst (Map.fromList [("x", MvAttribute AtRho), ("y", MvBytes (BtOne "1F"))])+        , Subst (Map.singleton "x" (MvAttribute AtPhi))+        , Nothing+        )+      ]+      (\(desc, first, second, expected) -> it desc (combine first second `shouldBe` expected))
test/MergeSpec.hs view
@@ -3,11 +3,13 @@  module MergeSpec where +import AST (Expression)+import Control.Exception (SomeException, try) import Control.Monad (forM_) import Data.List (intercalate) import Merge (merge) import Parser (parseExpressionThrows)-import Test.Hspec (Spec, anyException, describe, it, shouldBe, shouldThrow)+import Test.Hspec (Spec, anyException, describe, it, shouldBe, shouldContain, shouldThrow)  spec :: Spec spec = do@@ -59,3 +61,35 @@           parsed <- mapM parseExpressionThrows exprs           merge parsed `shouldThrow` anyException       )++  describe "merge exception messages" $+    forM_+      [+        ( "EmptyExpressionList explains there is nothing to merge"+        , []+        , "Nothing to merge: provide at least one expression"+        )+      ,+        ( "WrongExpressionFormat renders the offending non-formation expression"+        , ["Q"]+        , "Invalid expression format"+        )+      ,+        ( "CanNotMergeBinding renders both conflicting bindings"+        , ["[[ x -> 1 ]]", "[[ x -> 2 ]]"]+        , "Can't merge two bindings, conflict found"+        )+      ]+      ( \(desc, exprs, message) -> it desc $ do+          parsed <- mapM parseExpressionThrows exprs+          result <- try (merge parsed) :: IO (Either SomeException Expression)+          case result of+            Left err -> show err `shouldContain` message+            Right _ -> fail ("expected merge to throw for: " ++ desc)+      )++  describe "merge of a single expression" $+    it "returns that expression unchanged" $ do+      parsed <- parseExpressionThrows "[[ x -> 1 ]]"+      merged <- merge [parsed]+      merged `shouldBe` parsed
test/MiscSpec.hs view
@@ -6,10 +6,21 @@ module MiscSpec where  import AST+import Control.Exception (IOException, try) import Control.Monad (forM_) import Data.Either (isLeft, isRight)-import Misc (uniqueBindings, withVoidRho)-import Test.Hspec (Example (Arg), Expectation, Spec, SpecWith, describe, it, shouldBe, shouldSatisfy)+import Misc+  ( attributeFromBinding+  , attributesFromBindings+  , attributesFromBindings'+  , fqnToAttrs+  , orThrow+  , recoverFormations+  , toDouble+  , uniqueBindings+  , withVoidRho+  )+import Test.Hspec (Example (Arg), Expectation, Spec, SpecWith, describe, it, shouldBe, shouldContain, shouldReturn, shouldSatisfy)  testWithVoidRho :: [(String, [Binding], [Binding])] -> SpecWith (Arg Expectation) testWithVoidRho useCases =@@ -64,3 +75,80 @@       uniqueBindings [BiVoid AtRho, BiVoid AtRho] `shouldSatisfy` isLeft     it "does not fail on different attributes" $       uniqueBindings [BiVoid AtPhi, BiVoid AtRho] `shouldSatisfy` isRight++  describe "orThrow" $ do+    it "returns the value on Right" $+      orThrow userError (Right (5 :: Int)) `shouldReturn` 5+    it "throws the built exception on Left" $ do+      result <- try (orThrow userError (Left "boom")) :: IO (Either IOException Int)+      case result of+        Left err -> show err `shouldContain` "boom"+        Right _ -> fail "expected orThrow to throw"++  describe "attributeFromBinding" $+    forM_+      [ ("BiTau yields its attribute", BiTau AtRho ExRoot, Just AtRho)+      , ("BiVoid yields its attribute", BiVoid AtPhi, Just AtPhi)+      , ("BiDelta yields AtDelta", BiDelta BtEmpty, Just AtDelta)+      , ("BiLambda yields AtLambda", BiLambda (Function "F"), Just AtLambda)+      , ("BiMeta yields Nothing", BiMeta "B", Nothing)+      ]+      (\(desc, binding, expected) -> it desc (attributeFromBinding binding `shouldBe` expected))++  describe "attributesFromBindings" $+    forM_+      [ ("is empty for an empty list", [], [])+      ,+        ( "drops BiMeta entries, which carry no attribute"+        , [BiVoid AtRho, BiMeta "B", BiVoid AtPhi]+        , [AtRho, AtPhi]+        )+      ]+      (\(desc, bindings, expected) -> it desc (attributesFromBindings bindings `shouldBe` expected))++  describe "attributesFromBindings'" $+    it "keeps a Nothing placeholder for each BiMeta entry" $+      attributesFromBindings' [BiVoid AtRho, BiMeta "B"] `shouldBe` [Just AtRho, Nothing]++  describe "recoverFormations" $+    forM_+      [ ("adds a missing void rho to an empty formation", ExFormation [], ExFormation [BiVoid AtRho])+      ,+        ( "recovers a nested formation reached through a BiTau binding"+        , ExFormation [BiTau (AtLabel "x") (ExFormation [])]+        , ExFormation [BiTau (AtLabel "x") (ExFormation [BiVoid AtRho]), BiVoid AtRho]+        )+      ,+        ( "recurses through ExDispatch"+        , ExDispatch (ExFormation []) (AtLabel "y")+        , ExDispatch (ExFormation [BiVoid AtRho]) (AtLabel "y")+        )+      ,+        ( "recurses through ExApplication's tau argument"+        , ExApplication ExRoot (ArTau (AtLabel "a") (ExFormation []))+        , ExApplication ExRoot (ArTau (AtLabel "a") (ExFormation [BiVoid AtRho]))+        )+      ,+        ( "recurses through ExApplication's alpha argument"+        , ExApplication ExRoot (ArAlpha (Alpha 0) (ExFormation []))+        , ExApplication ExRoot (ArAlpha (Alpha 0) (ExFormation [BiVoid AtRho]))+        )+      , ("leaves every other expression untouched", ExXi, ExXi)+      ]+      (\(desc, expr, expected) -> it desc (recoverFormations expr `shouldBe` expected))++  describe "fqnToAttrs" $+    forM_+      [+        ( "converts a dispatch chain into an attribute list, root first"+        , ExDispatch (ExDispatch (ExDispatch ExRoot (AtLabel "org")) (AtLabel "eolang")) (AtLabel "number")+        , Just [AtLabel "org", AtLabel "eolang", AtLabel "number"]+        )+      , ("is Just [] for the bare root", ExRoot, Just [])+      , ("is Nothing for an expression that is not a dispatch chain", ExFormation [], Nothing)+      ]+      (\(desc, expr, expected) -> it desc (fqnToAttrs expr `shouldBe` expected))++  describe "toDouble" $+    it "converts an Int to the equal Double" $+      toDouble 5 `shouldBe` 5.0
test/MustSpec.hs view
@@ -8,311 +8,125 @@  import Control.Monad (forM_) import Must (Must (..), exceedsUpperBound, inRange, validateMust)-import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)+import Test.Hspec (Spec, describe, it, shouldBe) import Text.Read (readMaybe)  spec :: Spec spec = do-  describe "Show instance renders MtDisabled" $-    it "displays zero" $-      show MtDisabled `shouldBe` "0"--  describe "Show instance renders MtExact" $-    forM_-      [ ("positive integer", MtExact 42, "42")-      , ("large integer", MtExact 999999, "999999")-      , ("one", MtExact 1, "1")-      ]-      ( \(desc, must, expected) ->-          it desc $ show must `shouldBe` expected-      )--  describe "Show instance renders MtRange with both bounds" $-    forM_-      [ ("small range", MtRange (Just 1) (Just 5), "1..5")-      , ("same bounds", MtRange (Just 3) (Just 3), "3..3")-      , ("large range", MtRange (Just 0) (Just 1000), "0..1000")-      ]-      ( \(desc, must, expected) ->-          it desc $ show must `shouldBe` expected-      )--  describe "Show instance renders MtRange with only minimum" $-    forM_-      [ ("minimum only", MtRange (Just 5) Nothing, "5..")-      , ("zero minimum", MtRange (Just 0) Nothing, "0..")-      ]-      ( \(desc, must, expected) ->-          it desc $ show must `shouldBe` expected-      )--  describe "Show instance renders MtRange with only maximum" $-    forM_-      [ ("maximum only", MtRange Nothing (Just 10), "..10")-      , ("zero maximum", MtRange Nothing (Just 0), "..0")-      ]-      ( \(desc, must, expected) ->-          it desc $ show must `shouldBe` expected-      )--  describe "Show instance renders MtRange with no bounds" $-    it "displays empty range" $-      show (MtRange Nothing Nothing) `shouldBe` ".."--  describe "Read instance parses zero as MtDisabled" $-    it "parses disabled" $-      (readMaybe "0" :: Maybe Must) `shouldBe` Just MtDisabled--  describe "Read instance parses positive integers as MtExact" $-    forM_-      [ ("single digit", "5", Just (MtExact 5))-      , ("multi digit", "123", Just (MtExact 123))-      , ("large number", "999999", Just (MtExact 999999))-      ]-      ( \(desc, input, expected) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` expected-      )--  describe "Read instance rejects negative integers" $-    forM_-      [ ("negative one", "-1")-      , ("negative large", "-999")-      ]-      ( \(desc, input) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` Nothing-      )--  describe "Read instance rejects non-numeric input" $-    forM_-      [ ("alphabetic", "abc")-      , ("mixed", "12abc")-      , ("empty", "")-      , ("unicode", "日本語")-      ]-      ( \(desc, input) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` Nothing-      )--  describe "Read instance parses full range" $-    forM_-      [ ("simple range", "1..5", Just (MtRange (Just 1) (Just 5)))-      , ("same bounds", "3..3", Just (MtRange (Just 3) (Just 3)))-      , ("zero start", "0..10", Just (MtRange (Just 0) (Just 10)))-      ]-      ( \(desc, input, expected) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` expected-      )--  describe "Read instance parses minimum-only range" $-    forM_-      [ ("with minimum", "5..", Just (MtRange (Just 5) Nothing))-      , ("zero minimum", "0..", Just (MtRange (Just 0) Nothing))-      ]-      ( \(desc, input, expected) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` expected-      )--  describe "Read instance parses maximum-only range" $-    forM_-      [ ("with maximum", "..10", Just (MtRange Nothing (Just 10)))-      , ("zero maximum", "..0", Just (MtRange Nothing (Just 0)))-      ]-      ( \(desc, input, expected) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` expected-      )--  describe "Read instance parses empty range" $-    it "round-trips dots only" $-      (readMaybe ".." :: Maybe Must) `shouldBe` Just (MtRange Nothing Nothing)--  describe "Read instance rejects invalid range with negative minimum" $-    it "fails on negative min" $-      (readMaybe "-1..5" :: Maybe Must) `shouldBe` Nothing--  describe "Read instance rejects invalid range with negative maximum" $-    it "fails on negative max" $-      (readMaybe "1..-5" :: Maybe Must) `shouldBe` Nothing--  describe "Read instance rejects range where min exceeds max" $-    it "fails on inverted range" $-      (readMaybe "10..5" :: Maybe Must) `shouldBe` Nothing--  describe "Read instance rejects non-numeric range parts" $-    forM_-      [ ("alphabetic min", "abc..5")-      , ("alphabetic max", "5..abc")-      , ("both alphabetic", "abc..xyz")-      ]-      ( \(desc, input) ->-          it desc $ (readMaybe input :: Maybe Must) `shouldBe` Nothing-      )--  describe "Eq instance compares MtDisabled" $-    it "equals itself" $-      MtDisabled == MtDisabled `shouldBe` True--  describe "Eq instance compares MtExact" $-    forM_-      [ ("same values equal", MtExact 5, MtExact 5, True)-      , ("different values not equal", MtExact 5, MtExact 10, False)-      ]-      ( \(desc, lhs, rhs, expected) ->-          it desc $ (lhs == rhs) `shouldBe` expected-      )--  describe "Eq instance compares MtRange" $-    forM_-      [ ("same ranges equal", MtRange (Just 1) (Just 5), MtRange (Just 1) (Just 5), True)-      , ("different min not equal", MtRange (Just 1) (Just 5), MtRange (Just 2) (Just 5), False)-      , ("different max not equal", MtRange (Just 1) (Just 5), MtRange (Just 1) (Just 6), False)-      ]-      ( \(desc, lhs, rhs, expected) ->-          it desc $ (lhs == rhs) `shouldBe` expected-      )--  describe "Eq instance compares different types" $-    forM_-      [ ("disabled vs exact", MtDisabled, MtExact 0, False)-      , ("exact vs range", MtExact 5, MtRange (Just 5) (Just 5), False)-      ]-      ( \(desc, lhs, rhs, expected) ->-          it desc $ (lhs == rhs) `shouldBe` expected-      )--  describe "inRange with MtDisabled accepts any value" $-    forM_-      [ ("zero", 0)-      , ("large positive", 999999)-      , ("negative", -42)-      ]-      ( \(desc, val) ->-          it desc $ inRange MtDisabled val `shouldBe` True-      )--  describe "inRange with MtExact checks equality" $-    forM_-      [ ("exact match", MtExact 5, 5, True)-      , ("below exact", MtExact 5, 4, False)-      , ("above exact", MtExact 5, 6, False)-      ]-      ( \(desc, must, val, expected) ->-          it desc $ inRange must val `shouldBe` expected-      )--  describe "inRange with MtRange checks bounds" $-    forM_-      [ ("within range", MtRange (Just 1) (Just 10), 5, True)-      , ("at minimum", MtRange (Just 1) (Just 10), 1, True)-      , ("at maximum", MtRange (Just 1) (Just 10), 10, True)-      , ("below minimum", MtRange (Just 5) (Just 10), 4, False)-      , ("above maximum", MtRange (Just 1) (Just 5), 6, False)-      ]-      ( \(desc, must, val, expected) ->-          it desc $ inRange must val `shouldBe` expected-      )--  describe "inRange with minimum-only range" $-    forM_-      [ ("at minimum", MtRange (Just 5) Nothing, 5, True)-      , ("above minimum", MtRange (Just 5) Nothing, 100, True)-      , ("below minimum", MtRange (Just 5) Nothing, 4, False)-      ]-      ( \(desc, must, val, expected) ->-          it desc $ inRange must val `shouldBe` expected-      )--  describe "inRange with maximum-only range" $-    forM_-      [ ("at maximum", MtRange Nothing (Just 10), 10, True)-      , ("below maximum", MtRange Nothing (Just 10), 0, True)-      , ("above maximum", MtRange Nothing (Just 10), 11, False)-      ]-      ( \(desc, must, val, expected) ->-          it desc $ inRange must val `shouldBe` expected-      )--  describe "inRange with unbounded range" $+  describe "Show instance" $     forM_-      [ ("zero", 0)-      , ("large positive", 999999)-      , ("negative", -42)+      [ ("displays a disabled must as zero", MtDisabled, "0")+      , ("displays a positive exact value", MtExact 42, "42")+      , ("displays a large exact value", MtExact 999999, "999999")+      , ("displays an exact value of one", MtExact 1, "1")+      , ("displays a range with both bounds", MtRange (Just 1) (Just 5), "1..5")+      , ("displays a range with equal bounds", MtRange (Just 3) (Just 3), "3..3")+      , ("displays a large range", MtRange (Just 0) (Just 1000), "0..1000")+      , ("displays a range with only a minimum", MtRange (Just 5) Nothing, "5..")+      , ("displays a range with only a zero minimum", MtRange (Just 0) Nothing, "0..")+      , ("displays a range with only a maximum", MtRange Nothing (Just 10), "..10")+      , ("displays a range with only a zero maximum", MtRange Nothing (Just 0), "..0")+      , ("displays a range with no bounds", MtRange Nothing Nothing, "..")       ]-      ( \(desc, val) ->-          it desc $ inRange (MtRange Nothing Nothing) val `shouldBe` True-      )+      (\(desc, must, expected) -> it desc (show must `shouldBe` expected)) -  describe "exceedsUpperBound with MtDisabled" $+  describe "Read instance" $     forM_-      [ ("zero", 0)-      , ("large positive", 999999)+      [ ("parses zero as MtDisabled", "0", Just MtDisabled)+      , ("parses a single-digit exact value", "5", Just (MtExact 5))+      , ("parses a multi-digit exact value", "123", Just (MtExact 123))+      , ("parses a large exact value", "999999", Just (MtExact 999999))+      , ("rejects a negative exact value", "-1", Nothing)+      , ("rejects a large negative exact value", "-999", Nothing)+      , ("rejects alphabetic input", "abc", Nothing)+      , ("rejects mixed alphanumeric input", "12abc", Nothing)+      , ("rejects empty input", "", Nothing)+      , ("rejects unicode input", "日本語", Nothing)+      , ("parses a simple range", "1..5", Just (MtRange (Just 1) (Just 5)))+      , ("parses a range with equal bounds", "3..3", Just (MtRange (Just 3) (Just 3)))+      , ("parses a range with a zero start", "0..10", Just (MtRange (Just 0) (Just 10)))+      , ("parses a minimum-only range", "5..", Just (MtRange (Just 5) Nothing))+      , ("parses a minimum-only range with a zero minimum", "0..", Just (MtRange (Just 0) Nothing))+      , ("parses a maximum-only range", "..10", Just (MtRange Nothing (Just 10)))+      , ("parses a maximum-only range with a zero maximum", "..0", Just (MtRange Nothing (Just 0)))+      , ("round-trips an empty range with dots only", "..", Just (MtRange Nothing Nothing))+      , ("rejects a range with a negative minimum", "-1..5", Nothing)+      , ("rejects a range with a negative maximum", "1..-5", Nothing)+      , ("rejects an inverted range where min exceeds max", "10..5", Nothing)+      , ("rejects a range with an alphabetic minimum", "abc..5", Nothing)+      , ("rejects a range with an alphabetic maximum", "5..abc", Nothing)+      , ("rejects a range with both parts alphabetic", "abc..xyz", Nothing)+      , ("rejects a range with more than one '..' separator", "3.4..5", Nothing)+      , ("rejects a maximum-only range with a negative bound", "..-5", Nothing)+      , ("rejects a minimum-only range with a negative bound", "-5..", Nothing)       ]-      ( \(desc, val) ->-          it desc $ exceedsUpperBound MtDisabled val `shouldBe` False-      )+      (\(desc, input, expected) -> it desc ((readMaybe input :: Maybe Must) `shouldBe` expected)) -  describe "exceedsUpperBound with MtExact" $+  describe "Eq instance" $     forM_-      [ ("at bound", MtExact 5, 5, False)-      , ("below bound", MtExact 5, 4, False)-      , ("above bound", MtExact 5, 6, True)+      [ ("MtDisabled equals itself", MtDisabled, MtDisabled, True)+      , ("equal MtExact values are equal", MtExact 5, MtExact 5, True)+      , ("different MtExact values are not equal", MtExact 5, MtExact 10, False)+      , ("equal MtRange values are equal", MtRange (Just 1) (Just 5), MtRange (Just 1) (Just 5), True)+      , ("MtRange values with different minimums are not equal", MtRange (Just 1) (Just 5), MtRange (Just 2) (Just 5), False)+      , ("MtRange values with different maximums are not equal", MtRange (Just 1) (Just 5), MtRange (Just 1) (Just 6), False)+      , ("MtDisabled is not equal to MtExact", MtDisabled, MtExact 0, False)+      , ("MtExact is not equal to MtRange", MtExact 5, MtRange (Just 5) (Just 5), False)       ]-      ( \(desc, must, val, expected) ->-          it desc $ exceedsUpperBound must val `shouldBe` expected-      )+      (\(desc, lhs, rhs, expected) -> it desc ((lhs == rhs) `shouldBe` expected)) -  describe "exceedsUpperBound with MtRange with maximum" $+  describe "inRange" $     forM_-      [ ("at maximum", MtRange (Just 0) (Just 10), 10, False)-      , ("below maximum", MtRange (Just 0) (Just 10), 5, False)-      , ("above maximum", MtRange (Just 0) (Just 10), 11, True)+      [ ("MtDisabled accepts zero", MtDisabled, 0, True)+      , ("MtDisabled accepts a large positive value", MtDisabled, 999999, True)+      , ("MtDisabled accepts a negative value", MtDisabled, -42, True)+      , ("MtExact accepts the exact value", MtExact 5, 5, True)+      , ("MtExact rejects a value below it", MtExact 5, 4, False)+      , ("MtExact rejects a value above it", MtExact 5, 6, False)+      , ("MtRange accepts a value within bounds", MtRange (Just 1) (Just 10), 5, True)+      , ("MtRange accepts the minimum bound", MtRange (Just 1) (Just 10), 1, True)+      , ("MtRange accepts the maximum bound", MtRange (Just 1) (Just 10), 10, True)+      , ("MtRange rejects a value below the minimum", MtRange (Just 5) (Just 10), 4, False)+      , ("MtRange rejects a value above the maximum", MtRange (Just 1) (Just 5), 6, False)+      , ("a minimum-only range accepts the minimum", MtRange (Just 5) Nothing, 5, True)+      , ("a minimum-only range accepts a value above the minimum", MtRange (Just 5) Nothing, 100, True)+      , ("a minimum-only range rejects a value below the minimum", MtRange (Just 5) Nothing, 4, False)+      , ("a maximum-only range accepts the maximum", MtRange Nothing (Just 10), 10, True)+      , ("a maximum-only range accepts a value below the maximum", MtRange Nothing (Just 10), 0, True)+      , ("a maximum-only range rejects a value above the maximum", MtRange Nothing (Just 10), 11, False)+      , ("an unbounded range accepts zero", MtRange Nothing Nothing, 0, True)+      , ("an unbounded range accepts a large positive value", MtRange Nothing Nothing, 999999, True)+      , ("an unbounded range accepts a negative value", MtRange Nothing Nothing, -42, True)       ]-      ( \(desc, must, val, expected) ->-          it desc $ exceedsUpperBound must val `shouldBe` expected-      )+      (\(desc, must, value, expected) -> it desc (inRange must value `shouldBe` expected)) -  describe "exceedsUpperBound with MtRange without maximum" $+  describe "exceedsUpperBound" $     forM_-      [ ("zero", 0)-      , ("large positive", 999999)+      [ ("MtDisabled never exceeds for zero", MtDisabled, 0, False)+      , ("MtDisabled never exceeds for a large positive value", MtDisabled, 999999, False)+      , ("MtExact at the bound does not exceed", MtExact 5, 5, False)+      , ("MtExact below the bound does not exceed", MtExact 5, 4, False)+      , ("MtExact above the bound exceeds", MtExact 5, 6, True)+      , ("MtRange at the maximum does not exceed", MtRange (Just 0) (Just 10), 10, False)+      , ("MtRange below the maximum does not exceed", MtRange (Just 0) (Just 10), 5, False)+      , ("MtRange above the maximum exceeds", MtRange (Just 0) (Just 10), 11, True)+      , ("a maximum-less MtRange never exceeds for zero", MtRange (Just 0) Nothing, 0, False)+      , ("a maximum-less MtRange never exceeds for a large positive value", MtRange (Just 0) Nothing, 999999, False)       ]-      ( \(desc, val) ->-          it desc $ exceedsUpperBound (MtRange (Just 0) Nothing) val `shouldBe` False-      )--  describe "validateMust with MtDisabled" $-    it "returns nothing" $-      validateMust MtDisabled `shouldBe` Nothing--  describe "validateMust with valid MtExact" $-    it "returns nothing for positive" $-      validateMust (MtExact 5) `shouldBe` Nothing+      (\(desc, must, value, expected) -> it desc (exceedsUpperBound must value `shouldBe` expected)) -  describe "validateMust with valid MtRange" $+  describe "validateMust" $     forM_-      [ ("both bounds", MtRange (Just 1) (Just 10))-      , ("minimum only", MtRange (Just 5) Nothing)-      , ("maximum only", MtRange Nothing (Just 10))-      , ("no bounds", MtRange Nothing Nothing)+      [ ("MtDisabled is always valid", MtDisabled, Nothing)+      , ("a positive MtExact is valid", MtExact 5, Nothing)+      , ("a range with both bounds is valid", MtRange (Just 1) (Just 10), Nothing)+      , ("a minimum-only range is valid", MtRange (Just 5) Nothing, Nothing)+      , ("a maximum-only range is valid", MtRange Nothing (Just 10), Nothing)+      , ("an unbounded range is valid", MtRange Nothing Nothing, Nothing)+      , ("an inverted range is rejected", MtRange (Just 10) (Just 5), Just "--must range invalid: minimum (10) is greater than maximum (5)")+      , ("a zero MtExact is rejected", MtExact 0, Just "--must exact value must be positive")+      , ("a negative MtExact is rejected", MtExact (-3), Just "--must exact value must be positive")+      , ("a negative minimum is rejected", MtRange (Just (-1)) (Just 5), Just "--must minimum must be non-negative")+      , ("a negative maximum is rejected", MtRange (Just 0) (Just (-1)), Just "--must maximum must be non-negative")       ]-      ( \(desc, must) ->-          it desc $ validateMust must `shouldBe` Nothing-      )--  describe "validateMust with inverted MtRange" $-    it "returns error message" $-      validateMust (MtRange (Just 10) (Just 5)) `shouldSatisfy` present--  describe "validateMust with zero MtExact" $-    it "returns error for zero" $-      validateMust (MtExact 0) `shouldSatisfy` present--  describe "validateMust with negative minimum in range" $-    it "returns error for negative min" $-      validateMust (MtRange (Just (-1)) (Just 5)) `shouldSatisfy` present--  describe "validateMust with negative maximum in range" $-    it "returns error for negative max" $-      validateMust (MtRange (Just 0) (Just (-1))) `shouldSatisfy` present-  where-    present (Just _) = True-    present Nothing = False+      (\(desc, must, expected) -> it desc (validateMust must `shouldBe` expected))
test/ParserSpec.hs view
@@ -8,13 +8,15 @@ module ParserSpec where  import AST+import Control.Exception (SomeException, displayException, try) import Control.Monad (forM_) import Data.Either (isLeft, isRight) import Data.List (isInfixOf) import Files (allPathsIn) import Parser import System.FilePath (takeBaseName)-import Test.Hspec (Example (Arg), Expectation, Spec, SpecWith, anyException, describe, it, runIO, shouldBe, shouldReturn, shouldSatisfy, shouldThrow)+import Test.Hspec (Example (Arg), Expectation, Spec, SpecWith, anyException, describe, it, runIO, shouldBe, shouldReturn, shouldSatisfy, shouldStartWith, shouldThrow)+import Text.Megaparsec (parseMaybe)  test ::   (Eq a, Show a) =>@@ -36,6 +38,16 @@   forM_ useCases $ \(ipt, fragment) ->     it ipt (function ipt `shouldSatisfy` either (isInfixOf fragment) (const False)) +tryAny :: IO a -> IO (Either SomeException a)+tryAny = try++renderFailure :: IO a -> IO String+renderFailure action = do+  result <- tryAny action+  case result of+    Left exc -> pure (displayException exc)+    Right _ -> fail "expected the parser action to fail"+ spec :: Spec spec = do   describe "parse expression" $@@ -45,12 +57,7 @@       , ("T(x -> Q)", Just (ExApplication ExTermination (ArTau (AtLabel "x") ExRoot)))       , ("Q.org.eolang", Just (ExDispatch (ExDispatch ExRoot (AtLabel "org")) (AtLabel "eolang")))       , ("[[x -> $, y -> ?]]", Just (ExFormation [BiTau (AtLabel "x") ExXi, BiVoid (AtLabel "y"), BiVoid AtRho]))-      ]--  describe "parse expression" $-    test-      parseExpression-      [ ("Q.!t", Just (ExDispatch ExRoot (AtMeta "t")))+      , ("Q.!t", Just (ExDispatch ExRoot (AtMeta "t")))       , ("[[]](!t1 -> $)", Just (ExApplication (ExFormation [BiVoid AtRho]) (ArTau (AtMeta "t1") ExXi)))       ,         ( "[[]](~0 -> $)(~11 -> Q)"@@ -410,32 +417,37 @@       , ("", Nothing)       ] -  describe "parseExpressionThrows" $ do-    it "returns expression on valid input" $-      parseExpressionThrows "T" `shouldReturn` ExTermination-    it "throws on invalid input" $-      parseExpressionThrows "invalid expression ]][[" `shouldThrow` anyException--  describe "parseExpressionThrows" $ do-    it "returns expression on valid input" $-      parseExpressionThrows "Q.x" `shouldReturn` ExDispatch ExRoot (AtLabel "x")-    it "throws on invalid input" $-      parseExpressionThrows "[[invalid" `shouldThrow` anyException+  describe "parseExpressionThrows" $+    forM_+      [ ("returns expression on valid input 'T'", "T", Just ExTermination)+      , ("throws on invalid input 'invalid expression ]][['", "invalid expression ]][[", Nothing)+      , ("returns expression on valid input 'Q.x'", "Q.x", Just (ExDispatch ExRoot (AtLabel "x")))+      , ("throws on invalid input '[[invalid'", "[[invalid", Nothing)+      ]+      ( \(desc, input, expected) -> it desc $ case expected of+          Just result -> parseExpressionThrows input `shouldReturn` result+          Nothing -> parseExpressionThrows input `shouldThrow` anyException+      ) -  describe "parseAttributeThrows" $ do-    it "returns attribute on valid input" $-      parseAttributeThrows "foo" `shouldReturn` AtLabel "foo"-    it "throws on invalid input" $-      parseAttributeThrows "123invalid" `shouldThrow` anyException+  describe "parseAttributeThrows" $+    forM_+      [ ("returns attribute on valid input", "foo", Just (AtLabel "foo"))+      , ("throws on invalid input", "123invalid", Nothing)+      ]+      ( \(desc, input, expected) -> it desc $ case expected of+          Just result -> parseAttributeThrows input `shouldReturn` result+          Nothing -> parseAttributeThrows input `shouldThrow` anyException+      ) -  describe "parseNumberThrows" $ do-    it "returns number on valid input" $ do-      result <- parseNumberThrows "42"-      case result of-        DataNumber _ -> return ()-        _ -> fail "expected DataNumber"-    it "throws on invalid input" $-      parseNumberThrows "notanumber" `shouldThrow` anyException+  describe "parseNumberThrows" $+    forM_+      [ ("returns number on valid input", "42", Just (DataNumber (BtMany ["40", "45", "00", "00", "00", "00", "00", "00"])))+      , ("throws on invalid input", "notanumber", Nothing)+      ]+      ( \(desc, input, expected) -> it desc $ case expected of+          Just result -> parseNumberThrows input `shouldReturn` result+          Nothing -> parseNumberThrows input `shouldThrow` anyException+      )    describe "parse string escapes" $     test@@ -527,3 +539,73 @@       , "  [[  x  ->  Q  ]]  "       ]       (\expr -> it expr (parseExpression expr `shouldSatisfy` isRight))++  describe "parse unicode meta-k expression" $+    test+      parseExpression+      [ ("𝑘", Just (ExMeta "k"))+      , ("𝑘1", Just (ExMeta "k1"))+      , ("𝑘.x", Just (ExDispatch (ExMeta "k") (AtLabel "x")))+      ]++  describe "ParserException Show instance" $+    forM_+      [+        ( "renders CouldNotParseExpression via parseExpressionThrows, embedding the megaparsec cause"+        , renderFailure (parseExpressionThrows "invalid expression ]][[")+        , "Couldn't parse given phi expression, cause:"+        , "expression:1:"+        )+      ,+        ( "renders CouldNotParseAttribute via parseAttributeThrows, embedding the megaparsec cause"+        , renderFailure (parseAttributeThrows "123invalid")+        , "Couldn't parse given attribute, cause:"+        , "attribute:1:"+        )+      ,+        ( "renders CouldNotParseNumber via parseNumberThrows, embedding the megaparsec cause"+        , renderFailure (parseNumberThrows "notanumber")+        , "Couldn't parse given number to 'Φ.number', cause:"+        , "number:1:"+        )+      ]+      ( \(desc, action, prefix, fragment) -> it desc $ do+          rendered <- action+          rendered `shouldStartWith` prefix+          rendered `shouldSatisfy` isInfixOf fragment+      )++  describe "phiParser record" $+    forM_+      [ ("exposes an _alpha field parsing an alpha directly", parseMaybe (_alpha phiParser) "~3" `shouldBe` Just (Alpha 3))+      , ("exposes an _attribute field parsing an attribute directly", parseMaybe (_attribute phiParser) "foo" `shouldBe` Just (AtLabel "foo"))+      , ("exposes an _index field parsing an index meta directly", parseMaybe (_index phiParser) "!i0" `shouldBe` Just "i0")+      , ("exposes a _binding field parsing a binding directly", parseMaybe (_binding phiParser) "x -> $" `shouldBe` Just (BiTau (AtLabel "x") ExXi))+      , ("exposes an _expression field parsing an expression directly", parseMaybe (_expression phiParser) "Q.x" `shouldBe` Just (ExDispatch ExRoot (AtLabel "x")))+      , ("exposes a _string field parsing a quoted string directly", parseMaybe (_string phiParser) "\"hi\"" `shouldBe` Just "hi")+      ]+      (uncurry it)++  describe "parse bytes rejects a lowercase hex digit" $+    fails+      parseBytes+      [ ("0a-", "expected 0-9 or A-F")+      , ("a0-", "expected 0-9 or A-F")+      ]++  describe "parser errors are tagged with their entry point name" $+    forM_+      [ ("parseBinding error is tagged with its entry point name", parseBinding "L>" `shouldSatisfy` either (isInfixOf "binding:1:") (const False))+      , ("parseNumber error is tagged with its entry point name", parseNumber "abc" `shouldSatisfy` either (isInfixOf "number:1:") (const False))+      , ("parseAttribute error is tagged with its entry point name", parseAttribute "123" `shouldSatisfy` either (isInfixOf "attribute:1:") (const False))+      , ("parseAlpha error is tagged with its entry point name", parseAlpha "bogus" `shouldSatisfy` either (isInfixOf "alpha:1:") (const False))+      , ("parseIndex error is tagged with its entry point name", parseIndex "bogus" `shouldSatisfy` either (isInfixOf "index meta:1:") (const False))+      ]+      (uncurry it)++  describe "surrogate escape failures embed their specific cause" $+    fails+      parseExpression+      [ ("[[ x -> \"\\uD835\\u0041\"]]", "Invalid low surrogate:")+      , ("[[ x -> \"\\uDFFF\"]]", "Unexpected low surrogate:")+      ]
test/PrinterSpec.hs view
@@ -11,9 +11,11 @@  import AST import Control.Monad (forM_)+import Data.Map.Strict qualified as Map import Encoding (Encoding (..)) import Lining (LineFormat (..)) import Margin (defaultMargin)+import Matcher (MetaValue (..), Subst (Subst)) import Parser (parseExpression) import Printer import Sugar (SugarType (..))@@ -187,13 +189,18 @@       , ("φ", AtPhi, "φ")       , ("λ", AtLambda, "λ")       , ("Δ", AtDelta, "Δ")+      , ("meta", AtMeta "t", "𝜏")       ]       ( \(desc, attr, expected) ->           it desc (printAttribute attr `shouldBe` expected)       )    describe "printAlpha with default encoding" $-    it "α42" (printAlpha (Alpha 42) `shouldBe` "α42")+    forM_+      [ ("α42", Alpha 42, "α42")+      , ("meta alpha", AlMeta "i", "α𝑖")+      ]+      (\(desc, alpha, expected) -> it desc (printAlpha alpha `shouldBe` expected))    describe "printBinding renders as formation" $     forM_@@ -201,6 +208,7 @@       , ("void binding", BiVoid (AtLabel "y"), "y ↦ ∅")       , ("delta binding", BiDelta (BtOne "00"), "Δ ⤍ 00-")       , ("lambda binding", BiLambda (Function "Func"), "λ ⤍ Func")+      , ("meta binding", BiMeta "B", "𝐵")       ]       ( \(desc, bd, expected) ->           it desc (printBinding bd `shouldContain` expected)@@ -211,6 +219,7 @@       [ ("empty bytes", BtEmpty, "--")       , ("single byte", BtOne "1F", "1F-")       , ("multiple bytes", BtMany ["00", "01", "02"], "00-01-02")+      , ("meta bytes", BtMeta "D", "δ")       ]       ( \(desc, bts, expected) ->           it desc (printBytes bts `shouldBe` expected)@@ -226,3 +235,54 @@       ( \(desc, arg, expected) ->           it desc (printExtraArg arg `shouldContain` expected)       )++  describe "printSubsts and printSubsts' render substitutions" $+    forM_+      [ ("MvAttribute", [Subst (Map.singleton "t" (MvAttribute (AtLabel "x")))], (SWEET, UNICODE, MULTILINE, defaultMargin), "t >> x")+      , ("MvIndex", [Subst (Map.singleton "i" (MvIndex 3))], (SWEET, UNICODE, MULTILINE, defaultMargin), "i >> 3")+      , ("MvExpression", [Subst (Map.singleton "e" (MvExpression ExRoot))], (SWEET, UNICODE, MULTILINE, defaultMargin), "e >> Φ")+      , ("MvBytes", [Subst (Map.singleton "b" (MvBytes (BtOne "1F")))], (SWEET, UNICODE, MULTILINE, defaultMargin), "b >> 1F-")+      , ("MvBindings", [Subst (Map.singleton "bnd" (MvBindings [BiVoid (AtLabel "y")]))], (SWEET, UNICODE, MULTILINE, defaultMargin), "bnd >> ⟦ y ↦ ∅ ⟧")+      , ("MvFunction", [Subst (Map.singleton "f" (MvFunction "func"))], (SWEET, UNICODE, MULTILINE, defaultMargin), "f >> func")+      ,+        ( "keys of a multi-entry substitution are sorted and each is on its own line"+        , [Subst (Map.fromList [("a", MvIndex 1), ("b", MvIndex 2)])]+        , (SWEET, UNICODE, MULTILINE, defaultMargin)+        , "a >> 1\nb >> 2"+        )+      ,+        ( "multiple substitutions are separated with a dashed line"+        , [Subst (Map.singleton "a" (MvIndex 1)), Subst (Map.singleton "b" (MvIndex 2))]+        , (SWEET, UNICODE, MULTILINE, defaultMargin)+        , "a >> 1\n------\nb >> 2"+        )+      , ("an empty substitution list renders the dashed placeholder", [], (SWEET, UNICODE, SINGLELINE, defaultMargin), "------")+      ,+        ( "picks the encoding from its PrintConfig for an attribute meta value (ASCII rho)"+        , [Subst (Map.singleton "t" (MvAttribute AtRho))]+        , (SWEET, ASCII, SINGLELINE, defaultMargin)+        , "t >> ^"+        )+      ]+      ( \(desc, substs, config, expected) ->+          it desc (printSubsts' substs config `shouldBe` expected)+      )++  describe "printExpressionHidingRho strips rho at every nesting depth" $+    it "three nested formations, each with its own rho" $ do+      let deep =+            ExFormation+              [ BiTau+                  (AtLabel "a")+                  ( ExFormation+                      [ BiTau (AtLabel "b") (ExFormation [BiVoid AtRho])+                      , BiTau AtRho ExXi+                      ]+                  )+              , BiTau AtRho ExRoot+              ]+      printExpressionHidingRho' deep (SWEET, UNICODE, SINGLELINE, defaultMargin) `shouldBe` "⟦ a ↦ ⟦ b ↦ ⟦⟧ ⟧ ⟧"++  describe "logPrintConfig" $+    it "is a fixed SWEET/UNICODE/SINGLELINE config at the default margin" $+      logPrintConfig `shouldBe` (SWEET, UNICODE, SINGLELINE, defaultMargin)
test/RandomSpec.hs view
@@ -9,6 +9,7 @@ -} module RandomSpec where +import Control.Monad (forM_) import Data.Char (isDigit, isHexDigit) import Data.Set qualified as Set import Random (randomString)@@ -17,15 +18,13 @@  spec :: Spec spec = do-  describe "randomString with empty pattern" $-    it "returns empty string" $ do-      result <- randomString ""-      result `shouldBe` ""--  describe "randomString with literal pattern" $-    it "returns literal unchanged" $ do-      result <- randomString "hello"-      result `shouldBe` "hello"+  describe "randomString passes a pattern with no specials through unchanged" $+    forM_+      [ ("empty pattern returns an empty string", "")+      , ("literal pattern returns the literal unchanged", "hello")+      , ("literal pattern with spaces returns the literal with spaces", "hello world")+      ]+      (\(desc, literal) -> it desc $ randomString literal >>= (`shouldBe` literal))    describe "randomString called twice with the same literal pattern" $     it "does not loop forever on the second call" $ do@@ -33,56 +32,29 @@       result <- timeout 1000000 (randomString "repeated")       result `shouldBe` Just "repeated" -  describe "randomString with literal pattern containing spaces" $-    it "returns literal with spaces" $ do-      result <- randomString "hello world"-      result `shouldBe` "hello world"--  describe "randomString with %d pattern" $-    it "generates numeric digits" $ do-      result <- randomString "%d"-      result `shouldSatisfy` all isDigit--  describe "randomString with %d pattern length" $-    it "generates a fixed 4-digit number" $ do-      result <- randomString "%d"-      let len = length result-      len `shouldBe` 4--  describe "randomString with %x pattern" $-    it "generates hex digits" $ do-      result <- randomString "%x"-      result `shouldSatisfy` all isHexDigit--  describe "randomString with %x pattern length" $-    it "generates exactly 8 hex chars" $ do-      result <- randomString "%x"-      length result `shouldBe` 8--  describe "randomString with unknown % pattern" $-    it "preserves unknown pattern" $ do-      result <- randomString "%q"-      result `shouldBe` "%q"--  describe "randomString with unknown % pattern z" $-    it "preserves %z pattern" $ do-      result <- randomString "%z"-      result `shouldBe` "%z"--  describe "randomString with prefix and %d" $-    it "combines prefix with digits" $ do-      result <- randomString "id_%d"-      result `shouldSatisfy` (\s -> take 3 s == "id_")+  describe "randomString with %d/%x patterns" $+    forM_+      [ ("generates numeric digits", "%d", all isDigit)+      , ("generates a fixed 4-digit number", "%d", \result -> length result == 4)+      , ("generates hex digits", "%x", all isHexDigit)+      , ("generates exactly 8 hex chars", "%x", \result -> length result == 8)+      ]+      (\(desc, pattern, predicate) -> it desc (randomString pattern >>= (`shouldSatisfy` predicate))) -  describe "randomString with suffix and %d" $-    it "combines digits with suffix" $ do-      result <- randomString "%d_end"-      result `shouldSatisfy` (\s -> drop (length s - 4) s == "_end")+  describe "randomString passes through a pattern with no recognized specials" $+    forM_+      [ ("preserves an unknown %q pattern", "%q")+      , ("preserves an unknown %z pattern", "%z")+      ]+      (\(desc, literal) -> it desc $ randomString literal >>= (`shouldBe` literal)) -  describe "randomString with prefix and %x" $-    it "combines prefix with hex" $ do-      result <- randomString "hex_%x"-      result `shouldSatisfy` (\s -> take 4 s == "hex_" && length s == 12)+  describe "randomString with a prefix or suffix around %d/%x" $+    forM_+      [ ("combines prefix with digits", "id_%d", \s -> take 3 s == "id_")+      , ("combines digits with suffix", "%d_end", \s -> drop (length s - 4) s == "_end")+      , ("combines prefix with hex", "hex_%x", \s -> take 4 s == "hex_" && length s == 12)+      ]+      (\(desc, pattern, predicate) -> it desc (randomString pattern >>= (`shouldSatisfy` predicate)))    describe "randomString with suffix and %x" $     it "combines hex with suffix" $ do@@ -122,25 +94,14 @@       result <- randomString "pre_%d_mid_%x_suf"       result `shouldSatisfy` (\s -> take 4 s == "pre_") -  describe "randomString with trailing percent" $-    it "preserves trailing percent" $ do-      result <- randomString "test%"-      result `shouldBe` "test%"--  describe "randomString with double percent" $-    it "handles %% as unknown pattern" $ do-      result <- randomString "%%"-      result `shouldBe` "%%"--  describe "randomString with special chars" $-    it "preserves special characters" $ do-      result <- randomString "a!@#b"-      result `shouldBe` "a!@#b"--  describe "randomString with unicode" $-    it "preserves unicode characters" $ do-      result <- randomString "test"-      result `shouldBe` "test"+  describe "randomString passes through everything else that is not a recognized pattern" $+    forM_+      [ ("preserves a trailing percent", "test%")+      , ("handles %% as an unknown pattern", "%%")+      , ("preserves special characters", "a!@#b")+      , ("preserves a plain literal", "test")+      ]+      (\(desc, literal) -> it desc $ randomString literal >>= (`shouldBe` literal))    describe "randomString %d range" $     it "generates numbers in 0-9999 range" $ do@@ -152,6 +113,15 @@     it "generates lowercase hex digits" $ do       result <- randomString "%x"       result `shouldSatisfy` all (\c -> isHexDigit c && (isDigit c || c `elem` "abcdef"))++  describe "randomString retries on a collision" $+    it "still returns fresh, unique 4-digit numbers well past the birthday bound of a 10000-value space" $ do+      -- Forces at least one regenerate retry (Set.member match) with+      -- overwhelming probability, without exhausting the whole space+      -- (which would loop forever).+      results <- mapM (const (randomString "%d")) [1 :: Int .. 2000]+      let unique = Set.fromList results+      Set.size unique `shouldBe` 2000  wordsBy :: (Char -> Bool) -> String -> [String] wordsBy predicate str = case dropWhile predicate str of
test/RegexpSpec.hs view
@@ -6,255 +6,131 @@ -} module RegexpSpec where +import Control.Exception (SomeException, displayException, try)+import Control.Monad (forM_, void) import Data.ByteString.Char8 qualified as B+import Data.List (isInfixOf) import Regexp qualified as R-import Test.Hspec (Spec, anyException, describe, it, shouldBe, shouldReturn, shouldThrow)+import Test.Hspec (Spec, anyException, describe, it, shouldBe, shouldReturn, shouldSatisfy, shouldThrow)  spec :: Spec spec = do   describe "compile" $ do-    it "compiles a valid pattern" $ do-      _ <- R.compile (B.pack "foo")-      matched <- R.match (B.pack "foo") (B.pack "foobar")-      matched `shouldBe` True+    forM_+      [ ("compiles a valid pattern", "foo", "foobar")+      , ("compiles pattern with groups", "(a)(b)(c)", "abc")+      , ("compiles pattern with unicode", "кирилиця", "текст кирилиця тут")+      , ("compiles empty pattern", "", "anything")+      ]+      ( \(desc, pattern, input) -> it desc $ do+          _ <- R.compile (B.pack pattern)+          matched <- R.match (B.pack pattern) (B.pack input)+          matched `shouldBe` True+      )      it "throws on invalid pattern" $       R.compile (B.pack "[invalid") `shouldThrow` anyException -    it "compiles pattern with groups" $ do-      _ <- R.compile (B.pack "(a)(b)(c)")-      matched <- R.match (B.pack "(a)(b)(c)") (B.pack "abc")-      matched `shouldBe` True--    it "compiles pattern with unicode" $ do-      _ <- R.compile (B.pack "кирилиця")-      matched <- R.match (B.pack "кирилиця") (B.pack "текст кирилиця тут")-      matched `shouldBe` True--    it "compiles empty pattern" $ do-      _ <- R.compile B.empty-      matched <- R.match B.empty (B.pack "anything")-      matched `shouldBe` True--  describe "match" $ do-    it "returns true when pattern matches" $-      R.match (B.pack "hello") (B.pack "hello world") `shouldReturn` True--    it "returns false when pattern does not match" $-      R.match (B.pack "goodbye") (B.pack "hello world") `shouldReturn` False--    it "returns true for partial match" $-      R.match (B.pack "wor") (B.pack "hello world") `shouldReturn` True--    it "returns true for match at start" $-      R.match (B.pack "^hello") (B.pack "hello world") `shouldReturn` True--    it "returns false for anchored pattern not at start" $-      R.match (B.pack "^world") (B.pack "hello world") `shouldReturn` False--    it "returns true for match at end" $-      R.match (B.pack "world$") (B.pack "hello world") `shouldReturn` True--    it "returns true with empty input and empty pattern" $-      R.match B.empty B.empty `shouldReturn` True--    it "returns true with non-empty input and empty pattern" $-      R.match B.empty (B.pack "text") `shouldReturn` True--    it "returns false with empty input and non-empty pattern" $-      R.match (B.pack "text") B.empty `shouldReturn` False--    it "handles special regex characters" $-      R.match (B.pack "a\\.b") (B.pack "a.b") `shouldReturn` True--    it "handles character class" $-      R.match (B.pack "[0-9]+") (B.pack "abc123def") `shouldReturn` True--    it "handles alternation" $-      R.match (B.pack "cat|dog") (B.pack "I have a dog") `shouldReturn` True--    it "handles unicode input" $-      R.match (B.pack "日本語") (B.pack "これは日本語です") `shouldReturn` True--    it "handles case sensitive match" $-      R.match (B.pack "Hello") (B.pack "hello") `shouldReturn` False--  describe "extractGroups" $ do-    it "extracts groups from pattern with capturing groups" $ do-      regex <- R.compile (B.pack "(\\w+)@(\\w+)")-      groups <- R.extractGroups regex (B.pack "user@domain")-      groups `shouldBe` [B.pack "user@domain", B.pack "user", B.pack "domain"]--    it "returns empty list when no match" $ do-      regex <- R.compile (B.pack "(foo)")-      groups <- R.extractGroups regex (B.pack "bar")-      groups `shouldBe` []--    it "extracts nested groups" $ do-      regex <- R.compile (B.pack "((a)(b))")-      groups <- R.extractGroups regex (B.pack "ab")-      groups `shouldBe` [B.pack "ab", B.pack "ab", B.pack "a", B.pack "b"]--    it "handles optional group that did not match" $ do-      regex <- R.compile (B.pack "(a)(b)?")-      groups <- R.extractGroups regex (B.pack "a")-      length groups `shouldBe` 3--    it "extracts multiple groups" $ do-      regex <- R.compile (B.pack "(x)(y)(z)")-      groups <- R.extractGroups regex (B.pack "prefix xyz suffix")-      groups `shouldBe` [B.pack "xyz", B.pack "x", B.pack "y", B.pack "z"]--    it "handles pattern without groups" $ do-      regex <- R.compile (B.pack "test")-      groups <- R.extractGroups regex (B.pack "this is a test")-      groups `shouldBe` [B.pack "test"]--  describe "substituteGroups" $ do-    it "substitutes group zero" $-      R.substituteGroups (B.pack "[$0]") [B.pack "match"] `shouldBe` B.pack "[match]"--    it "substitutes multiple groups" $-      R.substituteGroups (B.pack "$1-$2") [B.pack "full", B.pack "a", B.pack "b"]-        `shouldBe` B.pack "a-b"--    it "keeps dollar sign when no digits follow" $-      R.substituteGroups (B.pack "$ test") [B.pack "x"] `shouldBe` B.pack "$ test"--    it "keeps original reference for out of bounds index" $-      R.substituteGroups (B.pack "$9") [B.pack "only"] `shouldBe` B.pack "$9"--    it "handles replacement without group references" $-      R.substituteGroups (B.pack "plain") [B.pack "x"] `shouldBe` B.pack "plain"--    it "handles empty replacement" $-      R.substituteGroups B.empty [B.pack "x"] `shouldBe` B.empty--    it "handles empty groups list with reference" $-      R.substituteGroups (B.pack "$0") [] `shouldBe` B.pack "$0"--    it "handles multi-digit group reference" $-      R.substituteGroups (B.pack "$12") (replicate 13 (B.pack "x"))-        `shouldBe` B.pack "x"--    it "handles consecutive group references" $-      R.substituteGroups (B.pack "$0$1$2") [B.pack "a", B.pack "b", B.pack "c"]-        `shouldBe` B.pack "abc"--    it "handles unicode in replacement" $-      R.substituteGroups (B.pack "結果: $1") [B.pack "all", B.pack "データ"]-        `shouldBe` B.pack "結果: データ"--    it "handles dollar at end of string" $-      R.substituteGroups (B.pack "test$") [B.pack "x"] `shouldBe` B.pack "test$"--    it "handles double dollar" $-      R.substituteGroups (B.pack "$$1") [B.pack "x", B.pack "y"]-        `shouldBe` B.pack "$y"--  describe "replaceFirst" $ do-    it "replaces first occurrence" $ do-      regex <- R.compile (B.pack "cat")-      result <- R.replaceFirst regex (B.pack "dog") (B.pack "cat and cat")-      result `shouldBe` B.pack "dog and cat"--    it "returns input when no match" $ do-      regex <- R.compile (B.pack "xyz")-      result <- R.replaceFirst regex (B.pack "abc") (B.pack "hello world")-      result `shouldBe` B.pack "hello world"--    it "replaces with empty string" $ do-      regex <- R.compile (B.pack "remove")-      result <- R.replaceFirst regex B.empty (B.pack "please remove this")-      result `shouldBe` B.pack "please  this"--    it "replaces at start of string" $ do-      regex <- R.compile (B.pack "^start")-      result <- R.replaceFirst regex (B.pack "begin") (B.pack "start here")-      result `shouldBe` B.pack "begin here"--    it "replaces at end of string" $ do-      regex <- R.compile (B.pack "end$")-      result <- R.replaceFirst regex (B.pack "finish") (B.pack "the end")-      result `shouldBe` B.pack "the finish"--    it "uses captured groups in replacement" $ do-      regex <- R.compile (B.pack "(\\w+)@(\\w+)")-      result <- R.replaceFirst regex (B.pack "[$1 AT $2]") (B.pack "email: test@example here")-      result `shouldBe` B.pack "email: [test AT example] here"--    it "handles unicode pattern and replacement" $ do-      regex <- R.compile (B.pack "古い")-      result <- R.replaceFirst regex (B.pack "新しい") (B.pack "これは古いです")-      result `shouldBe` B.pack "これは新しいです"--    it "handles empty input" $ do-      regex <- R.compile (B.pack "x")-      result <- R.replaceFirst regex (B.pack "y") B.empty-      result `shouldBe` B.empty--    it "replaces entire string when pattern matches all" $ do-      regex <- R.compile (B.pack "^.*$")-      result <- R.replaceFirst regex (B.pack "replaced") (B.pack "original")-      result `shouldBe` B.pack "replaced"--  describe "replaceAll" $ do-    it "replaces all occurrences" $ do-      regex <- R.compile (B.pack "a")-      result <- R.replaceAll regex (B.pack "X") (B.pack "banana")-      result `shouldBe` B.pack "bXnXnX"--    it "returns input when no match" $ do-      regex <- R.compile (B.pack "xyz")-      result <- R.replaceAll regex (B.pack "abc") (B.pack "hello world")-      result `shouldBe` B.pack "hello world"--    it "replaces consecutive matches" $ do-      regex <- R.compile (B.pack "o")-      result <- R.replaceAll regex (B.pack "0") (B.pack "oooo")-      result `shouldBe` B.pack "0000"--    it "replaces with captured groups" $ do-      regex <- R.compile (B.pack "(\\d+)")-      result <- R.replaceAll regex (B.pack "[$1]") (B.pack "a1b2c3")-      result `shouldBe` B.pack "a[1]b[2]c[3]"--    it "handles empty replacement" $ do-      regex <- R.compile (B.pack "x")-      result <- R.replaceAll regex B.empty (B.pack "axbxcx")-      result `shouldBe` B.pack "abc"--    it "handles empty input" $ do-      regex <- R.compile (B.pack "x")-      result <- R.replaceAll regex (B.pack "y") B.empty-      result `shouldBe` B.empty--    it "handles unicode input and pattern" $ do-      regex <- R.compile (B.pack "кіт")-      result <- R.replaceAll regex (B.pack "пес") (B.pack "кіт і кіт")-      result `shouldBe` B.pack "пес і пес"+    it "throws with the underlying PCRE error embedded in the message" $ do+      result <- try (void (R.compile (B.pack "[invalid"))) :: IO (Either SomeException ())+      case result of+        Left exc -> displayException exc `shouldSatisfy` isInfixOf "Regex compilation failed:"+        Right () -> fail "expected R.compile to fail" -    it "replaces overlapping potential matches correctly" $ do-      regex <- R.compile (B.pack "aa")-      result <- R.replaceAll regex (B.pack "X") (B.pack "aaaa")-      result `shouldBe` B.pack "XX"+  describe "match" $+    forM_+      [ ("returns true when pattern matches", "hello", "hello world", True)+      , ("returns false when pattern does not match", "goodbye", "hello world", False)+      , ("returns true for partial match", "wor", "hello world", True)+      , ("returns true for match at start", "^hello", "hello world", True)+      , ("returns false for anchored pattern not at start", "^world", "hello world", False)+      , ("returns true for match at end", "world$", "hello world", True)+      , ("returns true with empty input and empty pattern", "", "", True)+      , ("returns true with non-empty input and empty pattern", "", "text", True)+      , ("returns false with empty input and non-empty pattern", "text", "", False)+      , ("handles special regex characters", "a\\.b", "a.b", True)+      , ("handles character class", "[0-9]+", "abc123def", True)+      , ("handles alternation", "cat|dog", "I have a dog", True)+      , ("handles unicode input", "日本語", "これは日本語です", True)+      , ("handles case sensitive match", "Hello", "hello", False)+      ]+      (\(desc, pattern, input, expected) -> it desc $ R.match (B.pack pattern) (B.pack input) `shouldReturn` expected) -    it "handles single character replacement" $ do-      regex <- R.compile (B.pack ".")-      result <- R.replaceAll regex (B.pack "*") (B.pack "abc")-      result `shouldBe` B.pack "***"+  describe "extractGroups" $+    forM_+      [ ("extracts groups from pattern with capturing groups", "(\\w+)@(\\w+)", "user@domain", ["user@domain", "user", "domain"])+      , ("returns empty list when no match", "(foo)", "bar", [])+      , ("extracts nested groups", "((a)(b))", "ab", ["ab", "ab", "a", "b"])+      , ("handles optional group that did not match", "(a)(b)?", "a", ["a", "a", ""])+      , ("extracts multiple groups", "(x)(y)(z)", "prefix xyz suffix", ["xyz", "x", "y", "z"])+      , ("handles pattern without groups", "test", "this is a test", ["test"])+      ]+      ( \(desc, pattern, input, expected) -> it desc $ do+          regex <- R.compile (B.pack pattern)+          groups <- R.extractGroups regex (B.pack input)+          groups `shouldBe` map B.pack expected+      ) -    it "handles word boundary" $ do-      regex <- R.compile (B.pack "\\bword\\b")-      result <- R.replaceAll regex (B.pack "WORD") (B.pack "word in a word")-      result `shouldBe` B.pack "WORD in a WORD"+  describe "substituteGroups" $+    forM_+      [ ("substitutes group zero", "[$0]", ["match"], "[match]")+      , ("substitutes multiple groups", "$1-$2", ["full", "a", "b"], "a-b")+      , ("keeps dollar sign when no digits follow", "$ test", ["x"], "$ test")+      , ("keeps original reference for out of bounds index", "$9", ["only"], "$9")+      , ("handles replacement without group references", "plain", ["x"], "plain")+      , ("handles empty replacement", "", ["x"], "")+      , ("handles empty groups list with reference", "$0", [], "$0")+      , ("handles multi-digit group reference", "$12", replicate 13 "x", "x")+      , ("handles consecutive group references", "$0$1$2", ["a", "b", "c"], "abc")+      , ("handles unicode in replacement", "結果: $1", ["all", "データ"], "結果: データ")+      , ("handles dollar at end of string", "test$", ["x"], "test$")+      , ("handles double dollar", "$$1", ["x", "y"], "$y")+      ]+      ( \(desc, template, groups, expected) ->+          it desc $ R.substituteGroups (B.pack template) (map B.pack groups) `shouldBe` B.pack expected+      ) -    it "terminates on an empty-match pattern (anchored ^)" $ do-      regex <- R.compile (B.pack "^")-      result <- R.replaceAll regex (B.pack "X") (B.pack "hello")-      result `shouldBe` B.pack "XhXeXlXlXoX"+  describe "replaceFirst" $+    forM_+      [ ("replaces first occurrence", "cat", "dog", "cat and cat", "dog and cat")+      , ("returns input when no match", "xyz", "abc", "hello world", "hello world")+      , ("replaces with empty string", "remove", "", "please remove this", "please  this")+      , ("replaces at start of string", "^start", "begin", "start here", "begin here")+      , ("replaces at end of string", "end$", "finish", "the end", "the finish")+      ,+        ( "uses captured groups in replacement"+        , "(\\w+)@(\\w+)"+        , "[$1 AT $2]"+        , "email: test@example here"+        , "email: [test AT example] here"+        )+      , ("handles unicode pattern and replacement", "古い", "新しい", "これは古いです", "これは新しいです")+      , ("handles empty input", "x", "y", "", "")+      , ("replaces entire string when pattern matches all", "^.*$", "replaced", "original", "replaced")+      ]+      ( \(desc, pattern, replacement, input, expected) -> it desc $ do+          regex <- R.compile (B.pack pattern)+          result <- R.replaceFirst regex (B.pack replacement) (B.pack input)+          result `shouldBe` B.pack expected+      ) -    it "terminates on an empty regex pattern" $ do-      regex <- R.compile B.empty-      result <- R.replaceAll regex (B.pack "X") (B.pack "hello")-      result `shouldBe` B.pack "XhXeXlXlXoX"+  describe "replaceAll" $+    forM_+      [ ("replaces all occurrences", "a", "X", "banana", "bXnXnX")+      , ("returns input when no match", "xyz", "abc", "hello world", "hello world")+      , ("replaces consecutive matches", "o", "0", "oooo", "0000")+      , ("replaces with captured groups", "(\\d+)", "[$1]", "a1b2c3", "a[1]b[2]c[3]")+      , ("handles empty replacement", "x", "", "axbxcx", "abc")+      , ("handles empty input", "x", "y", "", "")+      , ("handles unicode input and pattern", "кіт", "пес", "кіт і кіт", "пес і пес")+      , ("replaces overlapping potential matches correctly", "aa", "X", "aaaa", "XX")+      , ("handles single character replacement", ".", "*", "abc", "***")+      , ("handles word boundary", "\\bword\\b", "WORD", "word in a word", "WORD in a WORD")+      , ("terminates on an empty-match pattern (anchored ^)", "^", "X", "hello", "XhXeXlXlXoX")+      , ("terminates on an empty regex pattern", "", "X", "hello", "XhXeXlXlXoX")+      ]+      ( \(desc, pattern, replacement, input, expected) -> it desc $ do+          regex <- R.compile (B.pack pattern)+          result <- R.replaceAll regex (B.pack replacement) (B.pack input)+          result `shouldBe` B.pack expected+      )
+ test/RenderSpec.hs view
@@ -0,0 +1,356 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module RenderSpec (spec) where++import CST+import Control.Monad (forM_)+import Data.Text qualified as T+import Render (Render (render))+import Test.Hspec++xiExpr :: EXPRESSION+xiExpr = EX_XI XI++rootExpr :: EXPRESSION+rootExpr = EX_GLOBAL Φ++pairXi :: T.Text -> PAIR+pairXi attrLabel = PA_TAU (AT_LABEL attrLabel) ARROW xiExpr++bindingXi :: T.Text -> BINDING+bindingXi attrLabel = BI_PAIR (pairXi attrLabel) (BDS_EMPTY NO_TAB) NO_TAB++spec :: Spec+spec = do+  describe "render primitive wrapper instances" $+    forM_+      [ ("String", render ("hi" :: String), "hi")+      , ("Char", render 'z', "z")+      , ("Int", render (7 :: Int), "7")+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "render braces, comma and arrows" $+    forM_+      [ ("LCB", render LCB, "{")+      , ("BIG_LCB", render BIG_LCB, "\\Big\\{")+      , ("RCB", render RCB, "}")+      , ("BIG_RCB", render BIG_RCB, "\\Big\\}")+      , ("LSB'", render LSB', "[[")+      , ("RSB'", render RSB', "]]")+      , ("COMMA", render COMMA, ",")+      , ("NO_COMMA", render NO_COMMA, "")+      , ("ARROW'", render ARROW', "->")+      , ("DASHED_ARROW", render DASHED_ARROW, "⤍")+      , ("QUESTION", render QUESTION, "?")+      , ("AT", render AT, "@")+      , ("CARET", render CARET, "^")+      , ("RHO'", render RHO', "\\phiTerminal{\\rho}")+      , ("DELTA'", render DELTA', "D")+      , ("LAMBDA'", render LAMBDA', "L")+      , ("Q", render Q, "Q")+      , ("DEAD", render DEAD, "⊥")+      , ("SPACE", render SPACE, " ")+      , ("DOTS", render DOTS, "...")+      , ("DOTS'", render DOTS', "\\dots")+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "render BYTES" $+    forM_+      [ ("empty", BT_EMPTY, "--")+      , ("one", BT_ONE "1F", "1F-")+      , ("many", BT_MANY ["00", "01", "02"], "00-01-02")+      , ("meta", BT_META (META NO_EXCL D "1"), "δ1")+      , ("piped", BT_PIPED (BT_ONE "1F"), "|1F-|")+      ]+      (\(desc, bts, expected) -> it desc (render bts `shouldBe` expected))++  describe "render every META_HEAD" $+    forM_+      [ (E, "𝑒")+      , (E', "e")+      , (N, "𝑛")+      , (N', "n")+      , (K, "𝑘")+      , (K', "k")+      , (A, "t")+      , (TAU, "𝜏")+      , (TAU', "\\tau")+      , (I, "𝑖")+      , (I', "i")+      , (B, "𝐵")+      , (B', "B")+      , (D, "δ")+      , (D', "\\delta")+      , (F, "𝑓")+      , (F', "F")+      ]+      (\(metaHead, expected) -> it (show metaHead) (render (META NO_EXCL metaHead "") `shouldBe` expected))++  describe "render META" $+    forM_+      [ ("without exclamation", render (META NO_EXCL E "x"), "𝑒x")+      , ("with exclamation", render (META EXCL E' "42"), "!e42")+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "render ALPHA'" $+    forM_+      [ ("ALPHA", ALPHA, "α")+      , ("ALPHA'", ALPHA', "~")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render ALPHA" $+    forM_+      [ ("AL_IDX unicode", AL_IDX ALPHA 3, "α3")+      , ("AL_IDX ascii", AL_IDX ALPHA' 3, "~3")+      , ("AL_META unicode", AL_META ALPHA (META NO_EXCL I "5"), "α𝑖5")+      , ("AL_META ascii", AL_META ALPHA' (META EXCL I' "5"), "~!i5")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render TAB" $+    forM_+      [ ("no indent", TAB 0, "")+      , ("two levels", TAB 2, "    ")+      , ("TAB'", TAB', " ")+      , ("NO_TAB", NO_TAB, "")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render PAIR" $+    forM_+      [ ("PA_TAU", PA_TAU (AT_LABEL "x") ARROW xiExpr, "x ↦ ξ")+      , ("PA_ALPHA", PA_ALPHA (AL_IDX ALPHA 0) ARROW xiExpr, "α0 ↦ ξ")+      , ("PA_FORMATION with no voids delegates to PA_TAU", PA_FORMATION (AT_LABEL "f") [] ARROW xiExpr, "f ↦ ξ")+      ,+        ( "PA_FORMATION with voids"+        , PA_FORMATION (AT_LABEL "f") [AT_LABEL "p", AT_LABEL "q"] ARROW xiExpr+        , "f(p, q) ↦ ξ"+        )+      , ("PA_LAMBDA", PA_LAMBDA "Func", "λ ⤍ Func")+      , ("PA_LAMBDA'", PA_LAMBDA' "Func", "L> Func")+      , ("PA_VOID question", PA_VOID (AT_LABEL "x") ARROW QUESTION, "x ↦ ?")+      , ("PA_VOID empty", PA_VOID (AT_LABEL "x") ARROW EMPTY, "x ↦ ∅")+      , ("PA_DELTA", PA_DELTA (BT_ONE "1F"), "Δ ⤍ 1F-")+      , ("PA_DELTA'", PA_DELTA' (BT_ONE "1F"), "D> 1F-")+      , ("PA_META_LAMBDA", PA_META_LAMBDA (META NO_EXCL F "n"), "λ ⤍ 𝑓n")+      , ("PA_META_LAMBDA'", PA_META_LAMBDA' (META EXCL F' "n"), "L> !Fn")+      , ("PA_META_DELTA", PA_META_DELTA (META NO_EXCL D "n"), "Δ ⤍ δn")+      , ("PA_META_DELTA'", PA_META_DELTA' (META EXCL D' "n"), "D> !\\deltan")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render BINDINGS" $ do+    it "empty" (render (BDS_EMPTY (TAB 1)) `shouldBe` "")+    it+      "two pairs"+      ( render+          ( BDS_PAIR+              EOL+              (TAB 1)+              (pairXi "a")+              (BDS_PAIR EOL (TAB 1) (pairXi "b") (BDS_EMPTY (TAB 1)))+          )+          `shouldBe` ",\n  a ↦ ξ,\n  b ↦ ξ"+      )+    it+      "meta tail"+      (render (BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))) `shouldBe` ",\n  𝐵X")++  describe "render APP_BINDING" $+    it "delegates to its pair" (render (APP_BINDING (pairXi "a")) `shouldBe` "a ↦ ξ")++  describe "render BINDING" $+    forM_+      [ ("BI_EMPTY", BI_EMPTY (TAB 0), "")+      , ("BI_PAIR", bindingXi "a", "a ↦ ξ")+      , ("BI_META", BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 0)) (TAB 0), "𝐵X")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render APP_ARGUMENT" $+    forM_+      [ ("AA_TAU", AA_TAU (APP_BINDING (pairXi "a")), "a ↦ ξ")+      , ("AA_TAUS", AA_TAUS (bindingXi "a"), "a ↦ ξ")+      , ("AA_EXPRS", AA_EXPRS (APP_ARG xiExpr AAS_EMPTY), "ξ")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render APP_ARG and APP_ARGS" $+    forM_+      [ ("APP_ARG with trailing arg", render (APP_ARG xiExpr (AAS_EXPR EOL (TAB 1) xiExpr AAS_EMPTY)), "ξ,\n  ξ")+      , ("APP_ARGS empty", render AAS_EMPTY, "")+      , ("APP_ARGS one", render (AAS_EXPR EOL (TAB 1) xiExpr AAS_EMPTY), ",\n  ξ")+      ]+      (\(desc, actual, expected) -> it desc (actual `shouldBe` expected))++  describe "render EXPRESSION" $+    forM_+      [ ("EX_GLOBAL Φ", rootExpr, "Φ")+      , ("EX_GLOBAL Q", EX_GLOBAL Q, "Q")+      , ("EX_XI", xiExpr, "ξ")+      , ("EX_XI DOLLAR", EX_XI DOLLAR, "$")+      , ("EX_XI XI'", EX_XI XI', "\\phiTerminal{\\xi}")+      , ("EX_ATTR", EX_ATTR (AT_LABEL "x"), "x")+      , ("EX_TERMINATION DEAD", EX_TERMINATION DEAD, "⊥")+      , ("EX_TERMINATION T", EX_TERMINATION T, "T")+      ,+        ( "EX_FORMATION multiline"+        , EX_FORMATION LSB EOL (TAB 1) (bindingXi "x") EOL (TAB 0) RSB+        , "⟦\n  x ↦ ξ\n⟧"+        )+      , ("EX_DISPATCH no space", EX_DISPATCH xiExpr NO_SPACE (AT_LABEL "y"), "ξ.y")+      , ("EX_DISPATCH with space", EX_DISPATCH xiExpr SPACE (AT_LABEL "y"), "ξ . y")+      ,+        ( "EX_APPLICATION"+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_TAU (APP_BINDING (pairXi "a"))) EOL (TAB 0) 1+        , "ξ(\n  a ↦ ξ\n)"+        )+      , ("EX_STRING", EX_STRING "hi" (TAB 0) [], "\"hi\"")+      , ("EX_NUMBER integer", EX_NUMBER (Left 42) (TAB 0) [], "42")+      , ("EX_NUMBER double", EX_NUMBER (Right 3.5) (TAB 0) [], "3.5")+      , ("EX_META", EX_META (META NO_EXCL E "x"), "𝑒x")+      , ("EX_PHI_MEET without prefix", EX_PHI_MEET Nothing 5 xiExpr, "\\phinoMeet{5}{ ξ }")+      , ("EX_PHI_MEET with prefix", EX_PHI_MEET (Just "p") 5 xiExpr, "\\phinoMeet{p:5}{ ξ }")+      , ("EX_PHI_AGAIN without prefix", EX_PHI_AGAIN Nothing 3 xiExpr, "\\phinoAgain{3}")+      , ("EX_PHI_AGAIN with prefix", EX_PHI_AGAIN (Just "p") 3 xiExpr, "\\phinoAgain{p:3}")+      , ("EX_BYTES", EX_BYTES (BT_ONE "1F"), "1F-")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render [ATTRIBUTE]" $+    it "joins with comma space" (render [AT_LABEL "a", AT_LABEL "b"] `shouldBe` "a, b")++  describe "render ATTRIBUTE" $+    forM_+      [ ("label", AT_LABEL "x", "x")+      , ("rho", AT_RHO RHO, "ρ")+      , ("phi", AT_PHI PHI, "φ")+      , ("lambda", AT_LAMBDA LAMBDA, "λ")+      , ("delta", AT_DELTA DELTA, "Δ")+      , ("meta", AT_META (META NO_EXCL TAU "x"), "𝜏x")+      , ("dots", AT_REST DOTS, "...")+      , ("dots ascii", AT_REST DOTS', "\\dots")+      ]+      (\(desc, attribute, expected) -> it desc (render attribute `shouldBe` expected))++  describe "render BELONGING" $+    forM_+      [ ("IN", IN, "\\in")+      , ("NOT_IN", NOT_IN, "\\notin")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render SET" $+    forM_+      [ ("ST_BINDING", ST_BINDING (bindingXi "x"), "x ↦ ξ")+      , ("ST_ATTRIBUTES", ST_ATTRIBUTES [AT_LABEL "a", AT_LABEL "b"], "[ a \\char44{} b ]")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render LOGIC_OPERATOR" $+    forM_+      [ ("AND", AND, "\\;\\text{and}\\;")+      , ("OR", OR, "\\;\\text{or}\\;")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render NUMBER" $+    forM_+      [ ("IDX_META", IDX_META (META NO_EXCL I "x"), "𝑖x")+      , ("LENGTH", LENGTH (bindingXi "x"), "\\vert x ↦ ξ \\vert")+      , ("DOMAIN", DOMAIN (bindingXi "x"), "\\vert \\overline{ x ↦ ξ } \\vert")+      , ("LITERAL", LITERAL 7, "7")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render COMPARABLE" $+    forM_+      [ ("CMP_ATTR", CMP_ATTR (AT_LABEL "x"), "x")+      , ("CMP_EXPR", CMP_EXPR xiExpr, "ξ")+      , ("CMP_NUM", CMP_NUM (LITERAL 3), "3")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render EQUAL" $+    forM_+      [ (EQUAL, "=")+      , (NOT_EQUAL, "\\not=")+      , (GREATER, ">")+      , (NOT_GREATER, "\\leq")+      ]+      (\(eq, expected) -> it (show eq) (render eq `shouldBe` expected))++  describe "render CONDITION" $+    forM_+      [ ("CO_BELONGS", CO_BELONGS (AT_LABEL "x") IN (ST_BINDING (bindingXi "y")), "x \\in y ↦ ξ")+      , ("CO_LOGIC single condition is unwrapped", CO_LOGIC [CO_NF xiExpr] AND, "\\isnormal{ ξ }")+      ,+        ( "CO_LOGIC joins multiple conditions"+        , CO_LOGIC [CO_NF xiExpr, CO_NF rootExpr] AND+        , "\\isnormal{ ξ } \\;\\text{and}\\; \\isnormal{ Φ }"+        )+      ,+        ( "CO_LOGIC wraps a nested non-singleton CO_LOGIC in parens"+        , CO_LOGIC [CO_LOGIC [CO_NF xiExpr, CO_NF rootExpr] OR, CO_NF xiExpr] AND+        , "\\lparen \\isnormal{ ξ } \\;\\text{or}\\; \\isnormal{ Φ } \\rparen \\;\\text{and}\\; \\isnormal{ ξ }"+        )+      , ("CO_NF", CO_NF xiExpr, "\\isnormal{ ξ }")+      , ("CO_ABSOLUTE in", CO_ABSOLUTE xiExpr IN, "\\phinoAbsolute{ ξ }")+      , ("CO_ABSOLUTE not in", CO_ABSOLUTE xiExpr NOT_IN, "\\phinoNotAbsolute{ ξ }")+      , ("CO_NOT wrapping CO_FORMATION", CO_NOT (CO_FORMATION xiExpr), "\\phinoNotFormation{ ξ }")+      ,+        ( "CO_NOT wrapping a generic condition"+        , CO_NOT (CO_NF xiExpr)+        , "not\\lparen \\isnormal{ ξ } \\rparen"+        )+      , ("CO_COMPARE", CO_COMPARE (CMP_ATTR (AT_LABEL "x")) EQUAL (CMP_NUM (LITERAL 3)), "x = 3")+      , ("CO_MATCHES", CO_MATCHES "^a+$" xiExpr, "matches\\lparen ^a+$, ξ \\rparen")+      , ("CO_PART_OF", CO_PART_OF xiExpr (bindingXi "y"), "part-of\\lparen ξ, y ↦ ξ \\rparen")+      , ("CO_FORMATION", CO_FORMATION xiExpr, "\\phinoIsFormation{ ξ }")+      , ("CO_DISJOINT single group", CO_DISJOINT [AT_LABEL "a"] [bindingXi "x"], "[ a ] \\cap x ↦ ξ = \\emptyset")+      ,+        ( "CO_DISJOINT multiple groups"+        , CO_DISJOINT [AT_LABEL "a", AT_LABEL "b"] [bindingXi "x", bindingXi "y"]+        , "[ a \\char44{} b ] \\cap \\lparen x ↦ ξ \\cup y ↦ ξ \\rparen = \\emptyset"+        )+      , ("CO_EMPTY", CO_EMPTY, "")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render EXTRA_ARG" $+    forM_+      [ ("ARG_ATTR", ARG_ATTR (AT_LABEL "x"), "x")+      , ("ARG_EXPR", ARG_EXPR xiExpr, "ξ")+      , ("ARG_BINDING", ARG_BINDING (bindingXi "x"), "x ↦ ξ")+      , ("ARG_BYTES", ARG_BYTES (BT_ONE "1F"), "1F-")+      ]+      (\(desc, node, expected) -> it desc (render node `shouldBe` expected))++  describe "render EXTRA" $ do+    it+      "contextualize wraps the first arg, the rest, and the meta separately"+      ( render (EXTRA (ARG_EXPR xiExpr) "contextualize" [ARG_ATTR (AT_LABEL "a"), ARG_ATTR (AT_LABEL "b")])+          `shouldBe` "\\phinoContextualize{ a }{ b }{ ξ }"+      )+    it+      "morph renders the fixed universe and state arguments"+      ( render (EXTRA (ARG_EXPR xiExpr) "morph" [ARG_ATTR (AT_LABEL "n")])+          `shouldBe` "ξ \\coloneqq \\phinoMorph{ n }{ e }{ s_1 }"+      )+    it+      "evaluate uses its dedicated macro name"+      ( render (EXTRA (ARG_EXPR xiExpr) "evaluate" [ARG_ATTR (AT_LABEL "n")])+          `shouldBe` "ξ \\coloneqq \\phinoEvaluate{ n }"+      )+    it+      "any other function becomes its own backslash macro"+      ( render (EXTRA (ARG_EXPR xiExpr) "dataize" [ARG_ATTR (AT_LABEL "n")])+          `shouldBe` "ξ \\coloneqq \\dataize{ n }"+      )
test/ReplacerSpec.hs view
@@ -169,6 +169,34 @@         , [ExXi, ExTermination, ExRoot]         , ExXi         )+      ,+        ( "Q -> Q.a(α0 -> Q.c) => ([Q.a, Q.c], [$, T]) => Q -> $(α0 -> T)"+        , ExApplication (ExDispatch ExRoot (AtLabel "a")) (ArAlpha (Alpha 0) (ExDispatch ExRoot (AtLabel "c")))+        , [ExDispatch ExRoot (AtLabel "a"), ExDispatch ExRoot (AtLabel "c")]+        , [ExXi, ExTermination]+        , ExApplication ExXi (ArAlpha (Alpha 0) ExTermination)+        )+      ,+        ( "Q -> Q.x => ([Q.x], []) => Q -> Q.x (replacements exhausted before patterns)"+        , ExDispatch ExRoot (AtLabel "x")+        , [ExDispatch ExRoot (AtLabel "x")]+        , []+        , ExDispatch ExRoot (AtLabel "x")+        )+      ,+        ( "Q -> [[a -> Q, b -> $]] => ([Q, $], [T]) => Q -> [[a -> T, b -> $]] (repls run out mid-bindings)"+        , ExFormation [BiTau (AtLabel "a") ExRoot, BiTau (AtLabel "b") ExXi]+        , [ExRoot, ExXi]+        , [ExTermination]+        , ExFormation [BiTau (AtLabel "a") ExTermination, BiTau (AtLabel "b") ExXi]+        )+      ,+        ( "Q -> [[D> --, b -> Q]] => ([Q], [$]) => Q -> [[D> --, b -> $]] (non-tau binding passed through)"+        , ExFormation [BiDelta BtEmpty, BiTau (AtLabel "b") ExRoot]+        , [ExRoot]+        , [ExXi]+        , ExFormation [BiDelta BtEmpty, BiTau (AtLabel "b") ExXi]+        )       ]    describe "replace expression fast: ([Expression], [Expression]) => Expression" $@@ -258,6 +286,20 @@         , [ExFormation [BiLambda (Function "Код")]]         , ExFormation [BiLambda (Function "Код")]         )+      ,+        ( "Q -> Q.a(α0 -> [[c -> ?]]) => ([[c -> ?]], [[c -> T]]) => Q -> Q.a(α0 -> [[c -> T]])"+        , ExApplication (ExDispatch ExRoot (AtLabel "a")) (ArAlpha (Alpha 0) (ExFormation [BiVoid (AtLabel "c")]))+        , [ExFormation [BiVoid (AtLabel "c")]]+        , [ExFormation [BiTau (AtLabel "c") ExTermination]]+        , ExApplication (ExDispatch ExRoot (AtLabel "a")) (ArAlpha (Alpha 0) (ExFormation [BiTau (AtLabel "c") ExTermination]))+        )+      ,+        ( "Q -> [[a -> ?]] => ([[a -> ?]], []) => Q -> [[a -> ?]] (replacements exhausted before patterns)"+        , ExFormation [BiVoid (AtLabel "a")]+        , [ExFormation [BiVoid (AtLabel "a")]]+        , []+        , ExFormation [BiVoid (AtLabel "a")]+        )       ]    describe "replace expression fast with depth 0" $@@ -281,5 +323,12 @@         , [ExFormation [BiVoid AtRho]]         , [ExFormation [BiTau AtRho (ExFormation [BiVoid AtRho])]]         , ExFormation [BiTau AtRho (ExFormation [BiVoid AtRho])]+        )+      ,+        ( "Q -> [[a -> ?]] => ([[ ]], [[z -> Q]]) => Q -> [[z -> Q]] (empty pattern formation wholesale-replaces bindings)"+        , ExFormation [BiVoid (AtLabel "a")]+        , [ExFormation []]+        , [ExFormation [BiTau (AtLabel "z") ExRoot]]+        , ExFormation [BiTau (AtLabel "z") ExRoot]         )       ]
test/RewriterSpec.hs view
@@ -9,9 +9,11 @@ module RewriterSpec where  import AST (Expression (ExRoot))+import Control.Exception (SomeException) import Control.Monad (forM_, unless) import Data.Aeson import Data.Char (isSpace)+import Data.List (isInfixOf) import Data.List.NonEmpty qualified as NE import Data.Yaml qualified as Yaml import Deps (dontSaveStep)@@ -24,7 +26,7 @@ import Rewriter (RewriteContext (RewriteContext), rewrite) import System.FilePath (makeRelative, replaceExtension, (</>)) import Tau (seedTaus)-import Test.Hspec (Spec, describe, expectationFailure, it, pending, runIO)+import Test.Hspec (Spec, describe, expectationFailure, it, pending, runIO, shouldSatisfy, shouldThrow) import Yaml (normalizationRules) import Yaml qualified as Y @@ -61,7 +63,43 @@ noSpaces = filter (not . isSpace)  spec :: Spec-spec =+spec = do+  describe "--max-cycles and --max-depth limits" $+    forM_+      [+        ( "throws with --depth-sensitive once --max-cycles is reached"+        , []+        , (5, 0, True)+        , Left "--max-cycles=0"+        )+      ,+        ( "stops silently without --depth-sensitive once --max-cycles is reached"+        , []+        , (5, 0, False)+        , Right snd+        )+      ,+        ( "throws with --depth-sensitive once --max-depth is reached for a rule"+        , normalizationRules+        , (0, 5, True)+        , Left "--max-depth=0"+        )+      ,+        ( "does not throw without --depth-sensitive once --max-depth is reached for a rule"+        , normalizationRules+        , (0, 5, False)+        , Right (\(rewrittens, _) -> fst (NE.last rewrittens) == ExRoot)+        )+      ]+      ( \(desc, rewriteRules, (maxDepth, maxCycles, depthSensitive), expected) -> it desc $ do+          let action = rewrite ExRoot rewriteRules (RewriteContext ExRoot maxDepth maxCycles depthSensitive buildTerm MtDisabled Nothing dontSaveStep)+          case expected of+            Left fragment -> action `shouldThrow` (\exc -> fragment `isInfixOf` show (exc :: SomeException))+            Right predicate -> do+              result <- action+              result `shouldSatisfy` predicate+      )+   describe "rewrite packs" $ do     let resources = "test-resources/rewriter-packs"     packs <- runIO (allPathsIn resources)
test/RuleSpec.hs view
@@ -16,9 +16,9 @@ import GHC.Generics import Matcher import Printer (printSubsts)-import Rule (RuleContext (RuleContext), isNF, meetCondition)+import Rule (RuleContext (RuleContext), isNF, matchExpressionWithRule, meetCondition) import System.FilePath-import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe)+import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe, shouldSatisfy) import Yaml qualified  data ConditionPack = ConditionPack@@ -73,5 +73,91 @@       , ("returns true for formation with only void binding", ExFormation [BiVoid (AtLabel "x")], True)       , ("returns true for formation with only lambda binding", ExFormation [BiLambda (Function "Func")], True)       , ("returns true for formation with delta void and lambda", ExFormation [BiDelta (BtOne "FF"), BiVoid (AtLabel "y"), BiLambda (Function "G")], True)+      , ("returns true for a formation with a tau binding whose expression is already normal", ExFormation [BiTau (AtLabel "x") ExRoot], True)+      , ("returns false for a formation with a tau binding matching a normalization rule", ExFormation [BiTau (AtLabel "x") (ExDispatch ExTermination (AtLabel "y"))], False)       ]       (\(desc, expr, expected) -> it desc $ isNF expr ctx `shouldBe` expected)++  describe "matchExpressionWithRule via a 'where' extension or a φ-marker meta" $ do+    let ctx :: RuleContext+        ctx = RuleContext buildTerm++        joinRule :: Yaml.Rule+        joinRule =+          Yaml.Rule+            "extra-join-test"+            Nothing+            Nothing+            (ExFormation [BiMeta "B"])+            (ExMeta "B")+            Nothing+            (Just [Yaml.Extra (Yaml.ArgBinding (BiMeta "J")) "join" [Yaml.ArgBinding (BiMeta "B")]])+            Nothing++        cascadeRule :: Yaml.Rule+        cascadeRule =+          Yaml.Rule+            "extra-cascade-test"+            Nothing+            Nothing+            (ExMeta "e")+            (ExMeta "e")+            Nothing+            ( Just+                [ Yaml.Extra (Yaml.ArgExpression ExRoot) "random-tau" []+                , Yaml.Extra (Yaml.ArgAttribute (AtMeta "J")) "random-tau" []+                ]+            )+            Nothing++        phiMeetRule :: Yaml.Rule+        phiMeetRule =+          Yaml.Rule+            "phimeet-n-test"+            Nothing+            Nothing+            (ExFormation [BiTau (AtLabel "x") (ExPhiMeet Nothing 0 (ExMeta "n1")), BiVoid AtRho])+            (ExMeta "n1")+            Nothing+            Nothing+            Nothing++        phiAgainRule :: Yaml.Rule+        phiAgainRule =+          Yaml.Rule+            "phiagain-n-test"+            Nothing+            Nothing+            (ExFormation [BiTau (AtLabel "x") (ExPhiAgain Nothing 0 (ExMeta "n2")), BiVoid AtRho])+            (ExMeta "n2")+            Nothing+            Nothing+            Nothing++    forM_+      [+        ( "binds a fresh meta from a bindings-list built by join()"+        , ExFormation [BiTau (AtLabel "x") ExRoot, BiVoid AtRho]+        , joinRule+        , True+        )+      , ("drops a substitution once an earlier extension fails to name a meta", ExRoot, cascadeRule, False)+      ,+        ( "finds an 'n' meta nested inside a φ-meet marker"+        , ExFormation [BiTau (AtLabel "x") (ExPhiMeet Nothing 0 ExRoot), BiVoid AtRho]+        , phiMeetRule+        , True+        )+      ,+        ( "finds an 'n' meta nested inside a φ-again marker"+        , ExFormation [BiTau (AtLabel "x") (ExPhiAgain Nothing 0 ExRoot), BiVoid AtRho]+        , phiAgainRule+        , True+        )+      ]+      ( \(desc, expr, rule, expectNonEmpty) -> it desc $ do+          matched <- matchExpressionWithRule expr rule ctx+          if expectNonEmpty+            then matched `shouldSatisfy` (not . null)+            else matched `shouldBe` []+      )
+ test/SugarSpec.hs view
@@ -0,0 +1,600 @@+{-# LANGUAGE OverloadedStrings #-}++-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com+-- SPDX-License-Identifier: MIT++module SugarSpec (spec) where++import AST+import CST+import Control.Monad (forM_)+import Encoding (Encoding (UNICODE))+import Lining (LineFormat (SINGLELINE))+import Margin (defaultMargin)+import Printer (printExpression')+import Render (Render (render))+import Sugar+import Test.Hspec++xiExpr :: EXPRESSION+xiExpr = EX_XI XI++rootExpr :: EXPRESSION+rootExpr = EX_GLOBAL Φ++-- `x` dispatched off the root, used as a representative attribute-valued+-- callee for the application collapse cases.+rootDotX :: EXPRESSION+rootDotX = EX_DISPATCH rootExpr NO_SPACE (AT_LABEL "x")++-- `$.y`, the salty desugaring of the bare attribute `y`.+dottedY :: EXPRESSION+dottedY = EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y")++-- The bare attribute `y`, sugar for `$.y`.+exYAttr :: EXPRESSION+exYAttr = EX_ATTR (AT_LABEL "y")++spec :: Spec+spec = do+  describe "withSugarType" $ do+    it "SWEET leaves a CST node untouched" (withSugarType SWEET xiExpr `shouldBe` xiExpr)+    it "SALTY dispatches to toSalty" (withSugarType SALTY (EX_ATTR (AT_LABEL "x")) `shouldBe` toSalty (EX_ATTR (AT_LABEL "x")))++  describe "toSalty EXPRESSION" $ do+    forM_+      [+        ( "EX_ATTR sugars $.x into an explicit xi dispatch"+        , EX_ATTR (AT_LABEL "x")+        , EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "x")+        )+      ,+        ( "EX_DISPATCH recurses into its callee"+        , EX_DISPATCH (EX_ATTR (AT_LABEL "y")) NO_SPACE (AT_LABEL "x")+        , EX_DISPATCH (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y")) NO_SPACE (AT_LABEL "x")+        )+      ,+        ( "EX_FORMATION with an empty binding collapses to the TAB' layout and gains a void rho"+        , EX_FORMATION LSB NO_EOL NO_TAB (BI_EMPTY NO_TAB) NO_EOL NO_TAB RSB+        , EX_FORMATION+            LSB+            NO_EOL+            TAB'+            (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY NO_TAB) NO_TAB)+            NO_EOL+            TAB'+            RSB+        )+      ,+        ( "EX_FORMATION with a real binding keeps its layout and appends a trailing void rho"+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1))+            EOL+            (TAB 0)+            RSB+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            ( BI_PAIR+                (PA_TAU (AT_LABEL "x") ARROW xiExpr)+                (BDS_PAIR EOL (TAB 1) (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)))+                (TAB 1)+            )+            EOL+            (TAB 0)+            RSB+        )+      ,+        ( "EX_FORMATION already ending in a void rho is left with that one tail unchanged"+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            ( BI_PAIR+                (PA_TAU (AT_LABEL "x") ARROW xiExpr)+                (BDS_PAIR EOL (TAB 1) (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)))+                (TAB 1)+            )+            EOL+            (TAB 0)+            RSB+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            ( BI_PAIR+                (PA_TAU (AT_LABEL "x") ARROW xiExpr)+                (BDS_PAIR EOL (TAB 1) (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)))+                (TAB 1)+            )+            EOL+            (TAB 0)+            RSB+        )+      ,+        ( "EX_FORMATION whose head binding is itself the void rho is left unchanged"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "EX_FORMATION whose head binding is a tau bound to rho is left unchanged"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_TAU (AT_RHO RHO) ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_TAU (AT_RHO RHO) ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "EX_FORMATION whose head binding is an object-with-params rho is left unchanged"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_FORMATION (AT_RHO RHO) [] ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_FORMATION (AT_RHO RHO) [] ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "EX_FORMATION with a meta binding at the head is left unchanged"+        , EX_FORMATION LSB EOL (TAB 1) (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "EX_APPLICATION with a single tau argument (AA_TAU) recurses into callee and argument"+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "y") ARROW xiExpr))) EOL (TAB 0) 1+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "y") ARROW xiExpr))) EOL (TAB 0) 1+        )+      ,+        ( "EX_FORMATION with a meta tail (no head rho) leaves the meta tail untouched"+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW xiExpr) (BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))) (TAB 1))+            EOL+            (TAB 0)+            RSB+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW xiExpr) (BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))) (TAB 1))+            EOL+            (TAB 0)+            RSB+        )+      ,+        ( "EX_APPLICATION with an empty AA_TAUS binding collapses to its bare callee"+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAUS (BI_EMPTY (TAB 1))) EOL (TAB 0) 1+        , rootDotX+        )+      ,+        ( "EX_PHI_MEET recurses into its wrapped expression"+        , EX_PHI_MEET Nothing 3 (EX_ATTR (AT_LABEL "x"))+        , EX_PHI_MEET Nothing 3 (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "x"))+        )+      ,+        ( "EX_PHI_AGAIN recurses into its wrapped expression"+        , EX_PHI_AGAIN (Just "p") 2 (EX_ATTR (AT_LABEL "x"))+        , EX_PHI_AGAIN (Just "p") 2 (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "x"))+        )+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++    -- These sugar out to deeply nested application chains, so the expected+    -- shape is checked as rendered text rather than as an equally-nested+    -- 'EXPRESSION' literal.+    forM_+      [+        ( "EX_APPLICATION with several tau bindings (AA_TAUS) unrolls into a chain of applications"+        , EX_APPLICATION+            xiExpr+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAUS (BI_PAIR (PA_TAU (AT_LABEL "a") ARROW xiExpr) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "b") ARROW xiExpr) (BDS_EMPTY (TAB 1))) (TAB 1)))+            EOL+            (TAB 0)+            1+        , "ξ(\n  a ↦ ξ\n)(\n  b ↦ ξ\n)"+        )+      ,+        ( "EX_APPLICATION with positional arguments (AA_EXPRS) sugars them into alpha-indexed applications"+        , EX_APPLICATION+            xiExpr+            NO_SPACE+            EOL+            (TAB 1)+            (AA_EXPRS (APP_ARG xiExpr (AAS_EXPR EOL (TAB 1) rootExpr AAS_EMPTY)))+            EOL+            (TAB 0)+            1+        , "ξ(\n  α0 ↦ ξ\n)(\n  α1 ↦ Φ\n)"+        )+      ,+        ( "EX_NUMBER with no extra rho expands into the Q.number(Q.bytes(...)) form"+        , EX_NUMBER (Left 42) (TAB 1) []+        , "Φ.number(\n    as-bytes ↦ Φ.bytes(\n      data ↦ ⟦\n        Δ ⤍ 40-45-00-00-00-00-00-00,\n        ρ ↦ ∅\n      ⟧\n    )\n  )"+        )+      ,+        ( "EX_NUMBER preserves an extra rho argument carried alongside the primitive"+        , EX_NUMBER (Left 42) (TAB 1) [ArTau AtRho (ExDispatch ExXi (AtLabel "y"))]+        , "Φ.number(\n    as-bytes ↦ Φ.bytes(\n      data ↦ ⟦\n        Δ ⤍ 40-45-00-00-00-00-00-00,\n        ρ ↦ ∅\n      ⟧\n    )\n  )(\n    ρ ↦ ξ.y\n  )"+        )+      ,+        ( "EX_STRING expands into the Q.string(Q.bytes(...)) form"+        , EX_STRING "hi" (TAB 1) []+        , "Φ.string(\n    as-bytes ↦ Φ.bytes(\n      data ↦ ⟦\n        Δ ⤍ 68-69,\n        ρ ↦ ∅\n      ⟧\n    )\n  )"+        )+      ]+      (\(desc, sweetExpr, expected) -> it desc (render (toSalty sweetExpr) `shouldBe` expected))++    it+      "default clause leaves terminals untouched"+      $ forM_+        [ rootExpr+        , EX_TERMINATION DEAD+        , EX_META (META NO_EXCL E "x")+        , EX_BYTES (BT_ONE "1F")+        ]+        (\terminal -> toSalty terminal `shouldBe` terminal)++  describe "toSalty BINDING" $+    forM_+      [+        ( "BI_PAIR recurses into its pair and tail"+        , BI_PAIR (PA_TAU (AT_LABEL "x") ARROW (EX_ATTR (AT_LABEL "y"))) (BDS_EMPTY (TAB 1)) (TAB 1)+        , BI_PAIR (PA_TAU (AT_LABEL "x") ARROW dottedY) (BDS_EMPTY (TAB 1)) (TAB 1)+        )+      , ("BI_EMPTY is unchanged", BI_EMPTY (TAB 1), BI_EMPTY (TAB 1))+      ,+        ( "BI_META is unchanged"+        , BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)+        , BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)+        )+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty APP_BINDING" $+    it+      "delegates to the pair instance"+      (toSalty (APP_BINDING (PA_TAU (AT_LABEL "x") ARROW (EX_ATTR (AT_LABEL "y")))) `shouldBe` APP_BINDING (PA_TAU (AT_LABEL "x") ARROW (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))))++  describe "toSalty BINDINGS" $+    forM_+      [+        ( "BDS_PAIR recurses into its pair and tail"+        , BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "x") ARROW (EX_ATTR (AT_LABEL "y"))) (BDS_EMPTY (TAB 1))+        , BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "x") ARROW (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))) (BDS_EMPTY (TAB 1))+        )+      , ("BDS_EMPTY is unchanged", BDS_EMPTY (TAB 1), BDS_EMPTY (TAB 1))+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty PAIR" $+    forM_+      [+        ( "PA_TAU recurses into its value"+        , PA_TAU (AT_LABEL "x") ARROW (EX_ATTR (AT_LABEL "y"))+        , PA_TAU (AT_LABEL "x") ARROW (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))+        )+      ,+        ( "PA_ALPHA recurses into its value"+        , PA_ALPHA (AL_IDX ALPHA 0) ARROW (EX_ATTR (AT_LABEL "y"))+        , PA_ALPHA (AL_IDX ALPHA 0) ARROW (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))+        )+      ,+        ( "PA_FORMATION with an empty object body joins its void params ahead of the body and gains a trailing rho"+        , PA_FORMATION (AT_LABEL "f") [AT_LABEL "p"] ARROW (EX_FORMATION LSB EOL (TAB 2) (BI_EMPTY (TAB 2)) EOL (TAB 1) RSB)+        , PA_TAU+            (AT_LABEL "f")+            ARROW+            ( EX_FORMATION+                LSB+                EOL+                (TAB 2)+                ( BI_PAIR+                    (PA_VOID (AT_LABEL "p") ARROW EMPTY)+                    (BDS_PAIR EOL (TAB 2) (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 2)))+                    (TAB 2)+                )+                EOL+                (TAB 1)+                RSB+            )+        )+      ,+        ( "PA_FORMATION with a non-empty object body joins several void params ahead of the existing bindings"+        , PA_FORMATION+            (AT_LABEL "f")+            [AT_LABEL "p", AT_LABEL "q"]+            ARROW+            (EX_FORMATION LSB EOL (TAB 2) (BI_PAIR (PA_TAU (AT_LABEL "z") ARROW xiExpr) (BDS_EMPTY (TAB 2)) (TAB 2)) EOL (TAB 1) RSB)+        , PA_TAU+            (AT_LABEL "f")+            ARROW+            ( EX_FORMATION+                LSB+                EOL+                (TAB 2)+                ( BI_PAIR+                    (PA_VOID (AT_LABEL "p") ARROW EMPTY)+                    ( BDS_PAIR+                        EOL+                        (TAB 2)+                        (PA_VOID (AT_LABEL "q") ARROW EMPTY)+                        ( BDS_PAIR+                            EOL+                            (TAB 2)+                            (PA_TAU (AT_LABEL "z") ARROW xiExpr)+                            (BDS_PAIR EOL (TAB 2) (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 2)))+                        )+                    )+                    (TAB 2)+                )+                EOL+                (TAB 1)+                RSB+            )+        )+      , ("default clause leaves a PA_VOID pair untouched", PA_VOID (AT_LABEL "x") ARROW QUESTION, PA_VOID (AT_LABEL "x") ARROW QUESTION)+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty SET" $+    forM_+      [+        ( "ST_BINDING recurses into its binding"+        , ST_BINDING (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW (EX_ATTR (AT_LABEL "y"))) (BDS_EMPTY (TAB 1)) (TAB 1))+        , ST_BINDING (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))) (BDS_EMPTY (TAB 1)) (TAB 1))+        )+      , ("ST_ATTRIBUTES is unchanged", ST_ATTRIBUTES [AT_LABEL "a"], ST_ATTRIBUTES [AT_LABEL "a"])+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty NUMBER" $+    forM_+      [+        ( "LENGTH recurses into its binding"+        , LENGTH (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW (EX_ATTR (AT_LABEL "y"))) (BDS_EMPTY (TAB 1)) (TAB 1))+        , LENGTH (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))) (BDS_EMPTY (TAB 1)) (TAB 1))+        )+      , ("DOMAIN recurses into its binding", DOMAIN (BI_EMPTY (TAB 1)), DOMAIN (BI_EMPTY (TAB 1)))+      , ("LITERAL is unchanged", LITERAL 3, LITERAL 3)+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty COMPARABLE" $+    forM_+      [ ("CMP_ATTR is unchanged", CMP_ATTR (AT_LABEL "x"), CMP_ATTR (AT_LABEL "x"))+      ,+        ( "CMP_EXPR recurses into its expression"+        , CMP_EXPR (EX_ATTR (AT_LABEL "y"))+        , CMP_EXPR (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))+        )+      , ("CMP_NUM recurses into its number", CMP_NUM (LITERAL 4), CMP_NUM (LITERAL 4))+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty CONDITION" $+    forM_+      [+        ( "CO_BELONGS recurses into its set"+        , CO_BELONGS (AT_LABEL "x") IN (ST_BINDING (BI_EMPTY (TAB 1)))+        , CO_BELONGS (AT_LABEL "x") IN (ST_BINDING (BI_EMPTY (TAB 1)))+        )+      , ("CO_LOGIC recurses into every condition", CO_LOGIC [CO_NF exYAttr] AND, CO_LOGIC [CO_NF dottedY] AND)+      , ("CO_NF recurses into its expression", CO_NF exYAttr, CO_NF dottedY)+      , ("CO_ABSOLUTE recurses into its expression", CO_ABSOLUTE exYAttr IN, CO_ABSOLUTE dottedY IN)+      , ("CO_NOT recurses into its condition", CO_NOT (CO_NF exYAttr), CO_NOT (CO_NF dottedY))+      ,+        ( "CO_COMPARE recurses into both sides"+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) EQUAL (CMP_EXPR exYAttr)+        , CO_COMPARE (CMP_ATTR (AT_LABEL "x")) EQUAL (CMP_EXPR dottedY)+        )+      , ("CO_MATCHES recurses into its expression", CO_MATCHES "abc" exYAttr, CO_MATCHES "abc" dottedY)+      , ("CO_PART_OF recurses into its expression", CO_PART_OF exYAttr (BI_EMPTY (TAB 1)), CO_PART_OF dottedY (BI_EMPTY (TAB 1)))+      , ("CO_DISJOINT recurses into every group", CO_DISJOINT [AT_LABEL "a"] [BI_EMPTY (TAB 1)], CO_DISJOINT [AT_LABEL "a"] [BI_EMPTY (TAB 1)])+      , ("CO_FORMATION recurses into its expression", CO_FORMATION exYAttr, CO_FORMATION dottedY)+      , ("CO_EMPTY is unchanged", CO_EMPTY, CO_EMPTY)+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty EXTRA_ARG" $+    forM_+      [ ("ARG_EXPR recurses", ARG_EXPR exYAttr, ARG_EXPR dottedY)+      , ("ARG_BINDING recurses", ARG_BINDING (BI_EMPTY (TAB 1)), ARG_BINDING (BI_EMPTY (TAB 1)))+      , ("ARG_ATTR is unchanged", ARG_ATTR (AT_LABEL "x"), ARG_ATTR (AT_LABEL "x"))+      , ("ARG_BYTES is unchanged", ARG_BYTES (BT_ONE "1F"), ARG_BYTES (BT_ONE "1F"))+      ]+      (\(desc, sweet, salty) -> it desc (toSalty sweet `shouldBe` salty))++  describe "toSalty EXTRA" $+    it+      "recurses into its meta and every argument"+      ( toSalty (EXTRA (ARG_EXPR (EX_ATTR (AT_LABEL "y"))) "f" [ARG_ATTR (AT_LABEL "n")])+          `shouldBe` EXTRA (ARG_EXPR (EX_DISPATCH (EX_XI XI) NO_SPACE (AT_LABEL "y"))) "f" [ARG_ATTR (AT_LABEL "n")]+      )++  describe "withoutRho" $+    forM_+      [+        ( "a formation whose only binding is rho collapses to the compact empty layout"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB NO_EOL NO_TAB (BI_EMPTY (TAB 1)) NO_EOL NO_TAB RSB+        )+      ,+        ( "a formation whose head binding is a meta is left with its own layout"+        , EX_FORMATION LSB EOL (TAB 1) (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "dropping a leading rho promotes a following meta tail into the head binding"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "dropping a leading rho promotes a following pair tail into the head binding"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "x") ARROW xiExpr) (BDS_EMPTY (TAB 1))) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ,+        ( "a rho binding in the middle of a chain is dropped, its neighbours kept"+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            ( BI_PAIR+                (PA_TAU (AT_LABEL "x") ARROW xiExpr)+                (BDS_PAIR EOL (TAB 1) (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "y") ARROW xiExpr) (BDS_EMPTY (TAB 1))))+                (TAB 1)+            )+            EOL+            (TAB 0)+            RSB+        , EX_FORMATION+            LSB+            EOL+            (TAB 1)+            (BI_PAIR (PA_TAU (AT_LABEL "x") ARROW xiExpr) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "y") ARROW xiExpr) (BDS_EMPTY (TAB 1))) (TAB 1))+            EOL+            (TAB 0)+            RSB+        )+      ,+        ( "a positional alpha binding is not rho and is kept, recursing through goPair"+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_TAUS (BI_PAIR (PA_ALPHA (AL_IDX ALPHA 0) ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1))) EOL (TAB 0) 1+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_TAUS (BI_PAIR (PA_ALPHA (AL_IDX ALPHA 0) ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1))) EOL (TAB 0) 1+        )+      ,+        ( "an application whose only tau argument (AA_TAU) is rho collapses to its bare callee"+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAU (APP_BINDING (PA_TAU (AT_RHO RHO) ARROW xiExpr))) EOL (TAB 0) 1+        , rootDotX+        )+      ,+        ( "an application whose only tau argument (AA_TAU) is not rho is kept"+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "y") ARROW xiExpr))) EOL (TAB 0) 1+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAU (APP_BINDING (PA_TAU (AT_LABEL "y") ARROW xiExpr))) EOL (TAB 0) 1+        )+      ,+        ( "an application argument chain (AA_TAUS) that strips down to nothing collapses to the bare callee"+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAUS (BI_PAIR (PA_TAU (AT_RHO RHO) ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1))) EOL (TAB 0) 1+        , rootDotX+        )+      ,+        ( "an application argument chain (AA_TAUS) keeps whatever remains after dropping rho"+        , EX_APPLICATION+            rootDotX+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAUS (BI_PAIR (PA_TAU (AT_RHO RHO) ARROW xiExpr) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_LABEL "z") ARROW xiExpr) (BDS_EMPTY (TAB 1))) (TAB 1)))+            EOL+            (TAB 0)+            1+        , EX_APPLICATION+            rootDotX+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAUS (BI_PAIR (PA_TAU (AT_LABEL "z") ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)))+            EOL+            (TAB 0)+            1+        )+      ,+        ( "an application argument chain (AA_TAUS) headed by a meta is kept"+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAUS (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1))) EOL (TAB 0) 1+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAUS (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1))) EOL (TAB 0) 1+        )+      ,+        ( "an application argument chain (AA_TAUS) promotes a meta tail after dropping the leading rho"+        , EX_APPLICATION+            rootDotX+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAUS (BI_PAIR (PA_TAU (AT_RHO RHO) ARROW xiExpr) (BDS_META EOL (TAB 1) (META NO_EXCL B "X") (BDS_EMPTY (TAB 1))) (TAB 1)))+            EOL+            (TAB 0)+            1+        , EX_APPLICATION rootDotX NO_SPACE EOL (TAB 1) (AA_TAUS (BI_META (META NO_EXCL B "X") (BDS_EMPTY (TAB 1)) (TAB 1))) EOL (TAB 0) 1+        )+      ,+        ( "an application argument chain (AA_TAUS) drops a rho pair in its tail, keeping the head"+        , EX_APPLICATION+            rootDotX+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAUS (BI_PAIR (PA_TAU (AT_LABEL "a") ARROW xiExpr) (BDS_PAIR EOL (TAB 1) (PA_TAU (AT_RHO RHO) ARROW xiExpr) (BDS_EMPTY (TAB 1))) (TAB 1)))+            EOL+            (TAB 0)+            1+        , EX_APPLICATION+            rootDotX+            NO_SPACE+            EOL+            (TAB 1)+            (AA_TAUS (BI_PAIR (PA_TAU (AT_LABEL "a") ARROW xiExpr) (BDS_EMPTY (TAB 1)) (TAB 1)))+            EOL+            (TAB 0)+            1+        )+      ,+        ( "an application argument list (AA_EXPRS) is always kept and recurses"+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_EXPRS (APP_ARG xiExpr (AAS_EXPR EOL (TAB 1) rootExpr AAS_EMPTY))) EOL (TAB 0) 1+        , EX_APPLICATION xiExpr NO_SPACE EOL (TAB 1) (AA_EXPRS (APP_ARG xiExpr (AAS_EXPR EOL (TAB 1) rootExpr AAS_EMPTY))) EOL (TAB 0) 1+        )+      ,+        ( "a xi.rho dispatch value is left untouched (only bindings and app arguments are stripped)"+        , EX_DISPATCH xiExpr NO_SPACE (AT_RHO RHO)+        , EX_DISPATCH xiExpr NO_SPACE (AT_RHO RHO)+        )+      ,+        ( "a phi-meet wrapper recurses into its wrapped expression via goExpr"+        , EX_PHI_MEET Nothing 2 (EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB)+        , EX_PHI_MEET Nothing 2 (EX_FORMATION LSB NO_EOL NO_TAB (BI_EMPTY (TAB 1)) NO_EOL NO_TAB RSB)+        )+      ,+        ( "a phi-again wrapper recurses into its wrapped expression via goExpr"+        , EX_PHI_AGAIN (Just "a") 1 (EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_VOID (AT_RHO RHO) ARROW EMPTY) (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB)+        , EX_PHI_AGAIN (Just "a") 1 (EX_FORMATION LSB NO_EOL NO_TAB (BI_EMPTY (TAB 1)) NO_EOL NO_TAB RSB)+        )+      ,+        ( "a lambda pair is not rho and is kept via the goPair catch-all"+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_LAMBDA "some.func") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        , EX_FORMATION LSB EOL (TAB 1) (BI_PAIR (PA_LAMBDA "some.func") (BDS_EMPTY (TAB 1)) (TAB 1)) EOL (TAB 0) RSB+        )+      ]+      (\(desc, input, expected) -> it desc (withoutRho input `shouldBe` expected))++  describe "full pipeline round trips, SWEET vs SALTY" $ do+    let config :: SugarType -> (SugarType, Encoding, LineFormat, Int)+        config sugar = (sugar, UNICODE, SINGLELINE, defaultMargin)+    it+      "a sweet numeric literal expands into Q.number(Q.bytes(...)) when salted"+      $ do+        let number = DataNumber (BtMany ["40", "45", "00", "00", "00", "00", "00", "00"])+        printExpression' number (config SWEET) `shouldBe` "42"+        printExpression' number (config SALTY) `shouldBe` "Φ.number( as-bytes ↦ Φ.bytes( data ↦ ⟦ Δ ⤍ 40-45-00-00-00-00-00-00, ρ ↦ ∅ ⟧ ) )"+    it+      "a sweet string literal expands into Q.string(Q.bytes(...)) when salted"+      $ do+        let string = DataString (BtMany ["68", "69"])+        printExpression' string (config SWEET) `shouldBe` "\"hi\""+        printExpression' string (config SALTY) `shouldBe` "Φ.string( as-bytes ↦ Φ.bytes( data ↦ ⟦ Δ ⤍ 68-69, ρ ↦ ∅ ⟧ ) )"+    it+      "an application with multiple positional arguments sugars/salts between e(e0, e1) and e(α0 ↦ e0)(α1 ↦ e1)"+      $ do+        let multiArgApp = ExApplication (ExApplication (ExDispatch ExRoot (AtLabel "e")) (ArAlpha (Alpha 0) ExRoot)) (ArAlpha (Alpha 1) ExXi)+        printExpression' multiArgApp (config SWEET) `shouldBe` "Φ.e( Φ, ξ )"+        printExpression' multiArgApp (config SALTY) `shouldBe` "Φ.e( α0 ↦ Φ )( α1 ↦ ξ )"+    it+      "a nested object-with-params formation sugars/salts between obj(p, q) -> [[..]] and its expanded void bindings"+      $ do+        let nestedForm = ExFormation [BiTau (AtLabel "obj") (ExFormation [BiVoid (AtLabel "p"), BiVoid (AtLabel "q"), BiTau (AtLabel "z") ExXi])]+        printExpression' nestedForm (config SWEET) `shouldBe` "⟦ obj(p, q) ↦ ⟦ z ↦ ξ ⟧ ⟧"+        printExpression' nestedForm (config SALTY) `shouldBe` "⟦ obj ↦ ⟦ p ↦ ∅, q ↦ ∅, z ↦ ξ, ρ ↦ ∅ ⟧, ρ ↦ ∅ ⟧"+    it+      "a phi-meet/phi-again chain renders identically under both sugar types"+      $ do+        let meetChain = ExFormation [BiTau (AtLabel "x") (ExPhiMeet Nothing 2 (ExPhiAgain (Just "a") 1 (ExDispatch ExXi (AtLabel "y"))))]+        printExpression' meetChain (config SWEET) `shouldBe` "⟦ x ↦ \\phinoMeet{2}{ \\phinoAgain{a:1} } ⟧"+        printExpression' meetChain (config SALTY) `shouldBe` "⟦ x ↦ \\phinoMeet{2}{ \\phinoAgain{a:1} }, ρ ↦ ∅ ⟧"
test/TauSpec.hs view
@@ -6,7 +6,7 @@ module TauSpec where  import AST-import Control.Monad (replicateM)+import Control.Monad (forM_, replicateM) import Tau (freshTau, seedTaus) import Test.Hspec (Spec, describe, it, shouldBe) @@ -31,3 +31,12 @@       )     names <- replicateM 2 freshTau     names `shouldBe` ["a🌵1", "a🌵3"]+  forM_+    [ ("scans labels through an ExPhiMeet wrapper", ExPhiMeet Nothing 1)+    , ("scans labels through an ExPhiAgain wrapper", ExPhiAgain Nothing 1)+    ]+    ( \(desc, wrap) -> it desc $ do+        seedTaus (wrap (ExFormation [BiTau (AtLabel "a🌵0") ExRoot]))+        name <- freshTau+        name `shouldBe` "a🌵1"+    )
test/XMIRSpec.hs view
@@ -8,20 +8,23 @@  module XMIRSpec where -import Control.Monad (forM_, unless)+import AST (Attribute (AtLabel, AtRho), Binding (BiMeta, BiTau, BiVoid), Expression (ExFormation))+import Control.Exception (SomeException, displayException, try)+import Control.Monad (forM_, unless, void) import Data.Aeson import Data.Char (isDigit) import Data.List (intercalate)+import Data.Map qualified as M import Data.Text qualified as T import Data.Yaml qualified as Yaml import Files (allPathsIn) import GHC.Generics (Generic) import Parser (parseExpressionThrows) import System.FilePath (makeRelative)-import Test.Hspec (Spec, anyException, describe, expectationFailure, it, runIO, shouldBe, shouldThrow)-import Text.XML (Document (..), Element (..))+import Test.Hspec (Spec, anyException, describe, expectationFailure, it, runIO, shouldBe, shouldContain, shouldThrow)+import Text.XML (Document (..), Element (..), Node (NodeElement), Prologue (..)) import Text.XML.Cursor qualified as C-import XMIR (defaultXmirContext, expressionToXMIR, parseXMIRThrows, printXMIR, toName, xmirToPhi)+import XMIR (XmirContext (XmirContext), defaultXmirContext, escapeXML, expressionToXMIR, parseXMIRThrows, printXMIR, toName, xmirToPhi)  data ParsePack = ParsePack   { failure :: Maybe Bool@@ -215,6 +218,10 @@       , "\"Hello\""       , "Q"       , "$"+      , "[[ x -> T ]]"+      , "[[ top -> [[ x -> T ]] ]]"+      , "[[ x -> [[ !t1 -> 5 ]] ]]"+      , "[[ org -> [[ z -> ?, L> Package ]] ]]"       ]       ( \phi' -> it phi' $ do           expr <- parseExpressionThrows phi'@@ -238,21 +245,156 @@               (expectationFailure ("Failed xpaths:\n - " ++ intercalate "\n - " failed ++ "\nXMIR is:\n" ++ printXMIR xmir'))       ) -  describe "XMIR round-trip" $ do-    it "keeps λ function name and bound ρ" $ do-      expr <- parseExpressionThrows "[[ k -> [[ x -> ?, L> Lorg_eolang_number_plus, ^ -> [[ y -> ? ]] ]] ]]"+  describe "XMIR round-trip" $+    forM_+      [ ("keeps λ function name and bound ρ", "[[ k -> [[ x -> ?, L> Lorg_eolang_number_plus, ^ -> [[ y -> ? ]] ]] ]]")+      , ("keeps Δ data bound to a named attribute", "[[ k -> [[ a -> [[ D> 01-02 ]], ^ -> [[ D> 03-04 ]] ]] ]]")+      , ("keeps Δ data in a dispatched formation", "[[ k -> [[ D> 01-02 ]].plus ]]")+      , ("keeps a bare 'Q' bound to a named attribute", "[[ x -> Q ]]")+      ]+      ( \(desc, source) -> it desc $ do+          expr <- parseExpressionThrows source+          xmir' <- expressionToXMIR expr defaultXmirContext+          back <- xmirToPhi xmir'+          back `shouldBe` expr+      )++  describe "XMIR exception messages" $+    forM_+      [+        ( "explains an unsupported top-level expression"+        , do+            expr <- parseExpressionThrows "[[ x -> $ ]]"+            try (void (expressionToXMIR expr defaultXmirContext)) :: IO (Either SomeException ())+        , ["XMIR does not support such top-level expression"]+        )+      ,+        ( "explains an unsupported nested expression"+        , do+            expr <- parseExpressionThrows "[[ x -> [[ y -> T ]] ]]"+            try (void (expressionToXMIR expr defaultXmirContext)) :: IO (Either SomeException ())+        , ["XMIR does not support such expression"]+        )+      ,+        ( "explains an unsupported binding"+        , try (void (expressionToXMIR (ExFormation [BiTau (AtLabel "x") (ExFormation [BiMeta "n", BiVoid AtRho]), BiVoid AtRho]) defaultXmirContext)) ::+            IO (Either SomeException ())+        , ["XMIR does not support such bindings"]+        )+      ,+        ( "explains a parse failure"+        , try (void (parseXMIRThrows "not-xml-at-all <<<")) :: IO (Either SomeException ())+        , ["Couldn't parse given XMIR"]+        )+      ,+        ( "explains an invalid XMIR structure, including the offending element"+        , do+            doc <- parseXMIRThrows "<object><o name=\"app\"><o/></o></object>"+            try (void (xmirToPhi doc)) :: IO (Either SomeException ())+        , ["Couldn't traverse though given XMIR", "XMIR:"]+        )+      ]+      ( \(desc, action, messages) -> it desc $ do+          result <- action+          case result of+            Left err -> mapM_ (displayException err `shouldContain`) messages+            Right () -> expectationFailure "expected an exception"+      )++  describe "escapeXML" $+    it "escapes an apostrophe alongside the other reserved characters" $+      escapeXML "it's a & <b> \"quote\"" `shouldBe` "it&apos;s a &amp; &lt;b&gt; &quot;quote&quot;"++  describe "XMIR document structure" $ do+    it "produces an empty prologue and epilogue" $ do+      expr <- parseExpressionThrows "[[ x -> 5 ]]"+      Document prologue _ epilogue <- expressionToXMIR expr defaultXmirContext+      prologue `shouldBe` Prologue [] Nothing []+      epilogue `shouldBe` []++    it "renders package metas with empty attributes and a matching part" $ do+      expr <- parseExpressionThrows "[[ org -> [[ eolang -> [[ foo -> [[ x -> 5 ]], L> Package ]], L> Package ]] ]]"       xmir' <- expressionToXMIR expr defaultXmirContext-      back <- xmirToPhi xmir'-      back `shouldBe` expr+      let root = C.fromDocument xmir'+      case root C.$/ C.element (toName "metas") of+        [metasCur] -> case C.node metasCur of+          NodeElement metasEl -> elementAttributes metasEl `shouldBe` M.empty+          _ -> expectationFailure "expected <metas> to be an element"+        _ -> expectationFailure "expected exactly one <metas> element"+      case root C.$/ C.element (toName "metas") C.&/ C.element (toName "meta") of+        [metaCur] -> case C.node metaCur of+          NodeElement metaEl -> elementAttributes metaEl `shouldBe` M.empty+          _ -> expectationFailure "expected <meta> to be an element"+        _ -> expectationFailure "expected exactly one <meta> element"+      let parts = root C.$/ C.element (toName "metas") C.&/ C.element (toName "meta") C.&/ C.element (toName "part") C.&/ C.content+      parts `shouldBe` ["org.eolang"] -    it "keeps Δ data bound to a named attribute" $ do-      expr <- parseExpressionThrows "[[ k -> [[ a -> [[ D> 01-02 ]], ^ -> [[ D> 03-04 ]] ]] ]]"+  describe "XMIR comments" $ do+    let commentedContext :: XmirContext+        commentedContext = XmirContext True False (const "")++    it "includes a decimal comment for a number when comments aren't omitted" $ do+      expr <- parseExpressionThrows "[[ x -> 5 ]]"+      xmir' <- expressionToXMIR expr commentedContext+      printXMIR xmir' `shouldContain` "<!-- 5 -->"++    it "includes a quoted comment for a string when comments aren't omitted" $ do+      expr <- parseExpressionThrows "[[ x -> \"foo\" ]]"+      xmir' <- expressionToXMIR expr commentedContext+      printXMIR xmir' `shouldContain` "<!-- \"foo\" -->"++  describe "XMIR printing edge cases" $ do+    it "wraps a chained dispatch on a formation literal with a @base attribute" $ do+      expr <- parseExpressionThrows "[[ x -> [[ y -> 5 ]].plus.minus ]]"       xmir' <- expressionToXMIR expr defaultXmirContext-      back <- xmirToPhi xmir'-      back `shouldBe` expr+      let root = C.fromDocument xmir'+          xCur = root C.$/ C.element (toName "o")+          outer = filter (\cur -> C.attribute (toName "base") cur == [".minus"]) xCur+          inner =+            concatMap+              (filter (\cur -> C.attribute (toName "base") cur == [".plus"]) . (C.$/ C.element (toName "o")))+              xCur+      length outer `shouldBe` 1+      length inner `shouldBe` 1 -    it "keeps Δ data in a dispatched formation" $ do-      expr <- parseExpressionThrows "[[ k -> [[ D> 01-02 ]].plus ]]"+    it "renders a void φ binding as a nested formation" $ do+      expr <- parseExpressionThrows "[[ x -> [[ @ -> ? ]] ]]"       xmir' <- expressionToXMIR expr defaultXmirContext-      back <- xmirToPhi xmir'-      back `shouldBe` expr+      let root = C.fromDocument xmir'+          nested = root C.$/ C.element (toName "o") C.&/ C.element (toName "o")+          phiVoid =+            filter+              (\cur -> C.attribute (toName "name") cur == ["φ"] && C.attribute (toName "base") cur == ["∅"])+              nested+      length phiVoid `shouldBe` 1++    it "renders a bare global reference as the top-level value" $ do+      expr <- parseExpressionThrows "[[ x -> Q ]]"+      xmir' <- expressionToXMIR expr defaultXmirContext+      let root = C.fromDocument xmir'+          xCur = filter (\cur -> C.attribute (toName "base") cur == ["Φ"]) (root C.$/ C.element (toName "o"))+      length xCur `shouldBe` 1++    it "omits @base when an application argument is a bare formation" $ do+      expr <- parseExpressionThrows "[[ foo -> Q.bar(x -> 5, α1 -> [[ z -> ? ]]) ]]"+      xmir' <- expressionToXMIR expr defaultXmirContext+      let root = C.fromDocument xmir'+          args = root C.$/ C.element (toName "o") C.&/ C.element (toName "o")+          namedArg = filter (\cur -> C.attribute (toName "as") cur == ["x"]) args+          formationArg = filter (\cur -> C.attribute (toName "as") cur == ["α1"]) args+      case namedArg of+        [argCur] -> C.attribute (toName "base") argCur `shouldBe` ["Φ.number"]+        _ -> expectationFailure "expected exactly one 'x' argument"+      case formationArg of+        [argCur] -> C.attribute (toName "base") argCur `shouldBe` []+        _ -> expectationFailure "expected exactly one α1 argument"++  describe "XMIR malformed input containing a processing instruction" $+    it "embeds a processing instruction verbatim when rendering the offending element" $ do+      doc <-+        parseXMIRThrows+          "<object><o name=\"x\" base=\"∅\"/><?a-pi some-data?><o name=\"y\" base=\"∅\"/></object>"+      result <- try (xmirToPhi doc) :: IO (Either SomeException Expression)+      case result of+        Left exc -> displayException exc `shouldContain` "Couldn't traverse though given XMIR"+        Right _ -> expectationFailure "expected an exception"
test/YamlSpec.hs view
@@ -6,8 +6,10 @@  module YamlSpec where +import AST (Alpha, Attribute, Binding, Bytes, Expression (ExRoot)) import Control.Exception (Exception (displayException), SomeException) import Control.Monad+import Data.Either (isLeft) import Data.List (isInfixOf, nub, (\\)) import Data.Maybe (fromMaybe) import Data.Text qualified as T@@ -15,9 +17,17 @@ import Data.Yaml qualified as Yaml import Files (allPathsIn) import System.FilePath-import Test.Hspec (Spec, describe, it, runIO, shouldBe, shouldReturn, shouldSatisfy, shouldThrow)-import Yaml (ContextualizeRule (..), DataizeRule (..), MorphRule (..), Operation (..), Premise (..), contextualizationRules, dataizationRules, morphingRules, yamlRule)+import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe, shouldSatisfy, shouldThrow)+import Yaml (Condition (..), ContextualizeRule (..), DataizeRule (..), MorphRule (..), Number, Operation (..), Premise (..), contextualizationRules, dataizationRules, morphingRules, yamlRule) +decodeYaml' :: (Yaml.FromJSON a) => String -> Either Yaml.ParseException a+decodeYaml' = Yaml.decodeEither' . encodeUtf8 . T.pack++failsAsRedundant :: Either Yaml.ParseException a -> Bool+failsAsRedundant decoded = case decoded of+  Left err -> "redundant" `isInfixOf` Yaml.prettyPrintParseException err+  Right _ -> False+ spec :: Spec spec = do   describe "parses yaml rule" $ do@@ -25,10 +35,7 @@     packs <- runIO (allPathsIn resources)     forM_       packs-      ( \pth -> it (makeRelative resources pth) $ do-          _ <- yamlRule pth-          pure () `shouldReturn` ()-      )+      (\pth -> it (makeRelative resources pth) (void (yamlRule pth)))    describe "fails on yaml typos" $ do     let resources = "test-resources/yaml-typos"@@ -45,21 +52,24 @@               )       ) -  describe "rejects a label that equals the name" $ do-    let failsAsRedundant decoded = case decoded of-          Left err -> "redundant" `isInfixOf` Yaml.prettyPrintParseException err-          Right _ -> False-        decodeYaml :: (Yaml.FromJSON a) => String -> Either Yaml.ParseException a-        decodeYaml = Yaml.decodeEither' . encodeUtf8 . T.pack-    it "in a morphing rule" $-      (decodeYaml "name: prim\nlabel: prim\nmatch: ⟦𝐵⟧\ne-match: 𝑒\nn-result: ⟦𝐵⟧" :: Either Yaml.ParseException MorphRule)-        `shouldSatisfy` failsAsRedundant-    it "in a dataization rule" $-      (decodeYaml "name: end\nlabel: end\nmatch: ⊥\ne-match: 𝑒\nd-result: '--'" :: Either Yaml.ParseException DataizeRule)-        `shouldSatisfy` failsAsRedundant-    it "in a contextualization rule" $-      (decodeYaml "name: cxi\nlabel: cxi\nmatch: ξ\nc-match: 𝑘\nc-result: 𝑘" :: Either Yaml.ParseException ContextualizeRule)-        `shouldSatisfy` failsAsRedundant+  describe "rejects malformed rule content" $ do+    let primYaml = "name: prim\nlabel: prim\nmatch: ⟦𝐵⟧\ne-match: 𝑒\nn-result: ⟦𝐵⟧"+        endYaml = "name: end\nlabel: end\nmatch: ⊥\ne-match: 𝑒\nd-result: '--'"+        cxiYaml = "name: cxi\nlabel: cxi\nmatch: ξ\nc-match: 𝑘\nc-result: 𝑘"+    forM_+      [ ("a label that equals the name in a morphing rule", primYaml, failsAsRedundant (decodeYaml' primYaml :: Either Yaml.ParseException MorphRule))+      , ("a label that equals the name in a dataization rule", endYaml, failsAsRedundant (decodeYaml' endYaml :: Either Yaml.ParseException DataizeRule))+      , ("a label that equals the name in a contextualization rule", cxiYaml, failsAsRedundant (decodeYaml' cxiYaml :: Either Yaml.ParseException ContextualizeRule))+      , ("a malformed embedded 𝜑-syntax: an index meta that does not parse", "'bogus'", isLeft (decodeYaml' "'bogus'" :: Either Yaml.ParseException Number))+      , ("a malformed embedded 𝜑-syntax: an attribute that does not parse", "'123'", isLeft (decodeYaml' "'123'" :: Either Yaml.ParseException Attribute))+      , ("a malformed embedded 𝜑-syntax: an alpha that does not parse", "'bogus'", isLeft (decodeYaml' "'bogus'" :: Either Yaml.ParseException Alpha))+      , ("a malformed embedded 𝜑-syntax: bytes that do not parse", "'0a-'", isLeft (decodeYaml' "'0a-'" :: Either Yaml.ParseException Bytes))+      , ("a malformed embedded 𝜑-syntax: an expression that does not parse", "'L>'", isLeft (decodeYaml' "'L>'" :: Either Yaml.ParseException Expression))+      , ("a malformed embedded 𝜑-syntax: a binding that does not parse", "'L>'", isLeft (decodeYaml' "'L>'" :: Either Yaml.ParseException Binding))+      ]+      ( \(desc, yaml, valid) ->+          it ("rejects " ++ desc) (unless valid (expectationFailure ("expected rejection for: " ++ yaml)))+      )    describe "keeps effective labels unique across rule sets" $     -- The effective label of a rule is its 'label' when present, else its@@ -105,3 +115,44 @@             , T.isPrefixOf (T.pack "n") result             ]       offenders `shouldBe` []++  describe "parses a 'formation' condition" $+    it "decodes 'formation: <expr>' into IsFormation" $+      case (decodeYaml' "formation: 'Q'" :: Either Yaml.ParseException Condition) of+        Right cond -> cond `shouldBe` IsFormation ExRoot+        Left err -> expectationFailure (Yaml.prettyPrintParseException err)++  describe "rejects a condition object naming no known key" $+    it "fails with 'Unknown condition type'" $+      case (decodeYaml' "{}" :: Either Yaml.ParseException Condition) of+        Left err -> "Unknown condition type" `isInfixOf` Yaml.prettyPrintParseException err `shouldBe` True+        Right _ -> expectationFailure "expected decoding to fail"++  describe "rejects a condition whose arguments count is wrong" $+    -- 'asum' discards each branch's specific failure message once every+    -- branch has failed, so only the overall Left/Right outcome (not the+    -- message text) is observable from here; each case still exercises the+    -- condition's own "expects exactly two arguments" guard internally.+    forM_+      [ ("'eq' with a single argument", "eq: [1]")+      , ("'gt' with a single argument", "gt: [1]")+      , ("'in' with a single argument", "in: ['!t']")+      , ("'matches' with a single argument", "matches: ['hi']")+      , ("'part-of' with a single argument", "part-of: ['!e']")+      , ("'disjoint' with a single argument", "disjoint: [[]]")+      ]+      (\(desc, yaml) -> it desc ((decodeYaml' yaml :: Either Yaml.ParseException Condition) `shouldSatisfy` isLeft))++  describe "rejects a malformed premise" $+    forM_+      [ ("fails when neither 'n-result' nor 'd-result' is present", "morph: 𝑛")+      , ("fails when 'n-result' is not an expression meta", "n-result: Q\nmorph: 𝑛")+      , ("fails when 'd-result' is not a bytes meta", "d-result: '--'\ndataize: 𝑛")+      , ("fails when 'evaluate' does not take exactly two arguments", "n-result: 𝑛\nevaluate: [𝑛]")+      , ("fails when 'contextualize' does not take exactly two arguments", "n-result: 𝑛\ncontextualize: [𝑛]")+      ]+      (\(desc, yaml) -> it desc ((decodeYaml' yaml :: Either Yaml.ParseException Premise) `shouldSatisfy` isLeft))++  describe "rejects a numerable expression that is neither an object, a number nor an index meta" $+    it "fails on a bare boolean" $+      (decodeYaml' "true" :: Either Yaml.ParseException Number) `shouldSatisfy` isLeft