packages feed

ginger 0.3.5.3 → 0.3.7.0

raw patch · 7 files changed

+1242/−9 lines, 7 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Text.Ginger.Optimizer: instance GHC.Base.Monoid Text.Ginger.Optimizer.Collected
+ Text.Ginger.Optimizer: instance GHC.Base.Monoid Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance GHC.Classes.Eq Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance GHC.Classes.Ord Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance GHC.Enum.Bounded Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance GHC.Enum.Enum Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance GHC.Read.Read Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance GHC.Show.Show Text.Ginger.Optimizer.Purity
+ Text.Ginger.Optimizer: instance Text.Ginger.GVal.ToGVal m Text.Ginger.Optimizer.Collected
+ Text.Ginger.Run: easyContext :: (Monad m, ContextEncodable h, ToGVal (Run m h) v) => (h -> m ()) -> v -> GingerContext m h
+ Text.Ginger.Run: easyRender :: (ContextEncodable h, Monoid h, ToGVal (Run (Writer h) h) v, ToGVal (Run (Writer h) h) h) => v -> Template -> h
+ Text.Ginger.Run: easyRenderM :: (Monad m, ContextEncodable h, Monoid h, ToGVal (Run m h) v, ToGVal (Run m h) h) => (h -> m ()) -> v -> Template -> m ()

Files

ginger.cabal view
@@ -2,7 +2,7 @@ -- documentation, see http://haskell.org/cabal/users-guide/  name:                ginger-version:             0.3.5.3+version:             0.3.7.0 synopsis:            An implementation of the Jinja2 template language in Haskell description:         Ginger is Jinja, minus the most blatant pythonisms. Wants                      to be feature complete, but isn't quite there yet.@@ -66,6 +66,8 @@ test-suite tests     type: exitcode-stdio-1.0     main-is: Spec.hs+    other-modules: Text.Ginger.PropertyTests+                 , Text.Ginger.SimulationTests     hs-source-dirs: test     default-language: Haskell2010     build-depends: base >=4.5 && <5
src/Text/Ginger/GVal.hs view
@@ -698,6 +698,10 @@ scientificToPico s =     MkFixed (Prelude.floor $ scientific (coefficient s) (base10Exponent s + 12)) +{-#RULES "GVal/round-trip-Maybe" fromGVal . toGVal = Just #-}+{-#RULES "GVal/round-trip-Either" fromGValEither . toGVal = Right #-}+{-#RULES "GVal/text-shortcut" asText . toGVal = id #-}+ class FromGVal m a where     fromGValEither :: GVal m -> Either Prelude.String a     fromGValEither = Prelude.maybe (Left "Conversion from GVal failed") Right . fromGVal
src/Text/Ginger/Optimizer.hs view
@@ -1,3 +1,7 @@+{-#LANGUAGE GeneralizedNewtypeDeriving #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE OverloadedStrings #-} -- | A syntax tree optimizer module Text.Ginger.Optimizer ( Optimizable (..) )@@ -5,9 +9,17 @@  import Text.Ginger.AST import Text.Ginger.GVal+import Text.Ginger.Run import Data.Monoid import Control.Monad.Identity import Data.Default+import Control.Monad.State (execState, evalState)+import Control.Monad.Writer (Writer, execWriter, tell)+import qualified Data.HashMap.Strict as HashMap+import Data.Maybe (fromMaybe)+import Control.Applicative+import Data.Text (Text)+import qualified Data.Aeson as JSON  class Optimizable a where     optimize :: a -> a@@ -55,6 +67,8 @@     InterpolationS (optimize e) optimizeStatement s@(IfS c t f) =     let c' = optimize c+        t' = optimize t+        f' = optimize f     in case compileTimeEval c' of         Just gv -> case asBoolean gv of             True -> t@@ -100,15 +114,115 @@     deriving (Show) -} +data Purity = Pure | Impure+    deriving (Show, Eq, Enum, Read, Ord, Bounded)++bothPure :: Purity -> Purity -> Purity+bothPure Pure Pure = Pure+bothPure _ _ = Impure++instance Monoid Purity where+    mempty = Pure+    mappend = bothPure++pureExpression :: Expression -> Purity+pureExpression (StringLiteralE _) = Pure+pureExpression (NumberLiteralE _) = Pure+pureExpression NullLiteralE = Pure+pureExpression (ListE items) = mconcat . map pureExpression $ items+pureExpression (ObjectE pairs) =+    mconcat [ bothPure (pureExpression k) (pureExpression v)+            | (k, v) <- pairs+            ]+pureExpression (LambdaE args body) = pureExpression body+pureExpression (TernaryE cond yes no) =+    pureExpression cond <> pureExpression yes <> pureExpression no+pureExpression (MemberLookupE k v) =+    pureExpression k <> pureExpression v+pureExpression (CallE (VarE name) args) =+    pureFunction name <> mconcat (map (pureExpression . snd) args)+pureExpression _ = Impure++pureFunction name+    | name `elem` pureFunctionNames = Pure+    | otherwise = Impure++pureFunctionNames =+    [ "raw"+    , "abs"+    , "any"+    , "all"+    , "capitalize"+    , "ceil"+    , "center"+    , "concat"+    , "contains"+    , "default"+    , "dictsort"+    , "difference"+    , "e"+    , "equals"+    , "escape"+    , "filesizeformat"+    , "filter"+    , "floor"+    , "format"+    , "greater"+    , "greaterEquals"+    , "int"+    , "int_ratio"+    , "iterable"+    , "length"+    , "less"+    , "lessEquals"+    , "modulo"+    , "nequals"+    , "num"+    , "product"+    , "ratio"+    , "replace"+    , "round"+    , "show"+    , "slice"+    , "sort"+    , "str"+    , "sum"+    , "truncate"+    , "urlencode"+    ]+ optimizeExpression :: Expression -> Expression-optimizeExpression = expandConstExpressions . optimizeSubexpressions+optimizeExpression = preEvalExpression . expandConstExpressions . optimizeSubexpressions +preEvalExpression :: Expression -> Expression+preEvalExpression e = fromMaybe e $ do+    compileTimeEval e >>= gvalToExpression++gvalToExpression :: GVal m -> Maybe Expression+gvalToExpression g =+    (jsonLiteral =<< asJSON g) <|>+    (ObjectE <$> (recurseDict =<< asDictItems g)) <|>+    (ListE <$> (mapM gvalToExpression =<< asList g))+    where+        jsonLiteral :: JSON.Value -> Maybe Expression+        jsonLiteral (JSON.Bool b) = Just (BoolLiteralE b)+        jsonLiteral (JSON.String s) = Just (StringLiteralE s)+        jsonLiteral (JSON.Null) = Just NullLiteralE+        jsonLiteral (JSON.Number n) = Just (NumberLiteralE n)+        jsonLiteral _ = Nothing+        recurseDict :: [(Text, GVal m)] -> Maybe [(Expression, Expression)]+        recurseDict = mapM $ \(key, val) -> do+            let key' = StringLiteralE key+            val' <- gvalToExpression val+            return (key', val')++ expandConstExpressions :: Expression -> Expression expandConstExpressions e@(TernaryE c t f) =     case compileTimeEval c of         Just gv -> case asBoolean gv of-            True -> t-            False -> f+            True -> optimizeExpression t+            False -> optimizeExpression f         _ -> e expandConstExpressions e = e @@ -134,4 +248,30 @@ compileTimeEval (NumberLiteralE n) = Just . toGVal $ n compileTimeEval (BoolLiteralE b) = Just . toGVal $ b compileTimeEval NullLiteralE = Just def-compileTimeEval e = Nothing+compileTimeEval e = case pureExpression e of+    Pure -> do+        let tpl = Template (InterpolationS e) HashMap.empty Nothing+        Just . toGVal . runCT $ tpl+    Impure -> Nothing++newtype Collected = Collected [GVal Identity]+    deriving (Monoid)++instance ToGVal m Collected where+    toGVal = collectedToGVal++collectedToGVal :: Collected -> GVal m+collectedToGVal (Collected []) = def+collectedToGVal (Collected (x:_)) = marshalGVal x++runCT :: Template -> Collected+runCT = runGinger ctContext++ctContext :: GingerContext (Writer Collected) Collected+ctContext = makeContext' ctLookup ctEncode++ctLookup :: VarName -> GVal m+ctLookup = const def++ctEncode :: GVal m -> Collected+ctEncode g = Collected [marshalGVal g]
src/Text/Ginger/Run.hs view
@@ -19,9 +19,18 @@ -- >        context = makeContext contextLookup -- >    in htmlSource $ runGinger context template module Text.Ginger.Run-( runGingerT+(+-- * The \"easy\" interface+-- | Provides a straightforward way of rendering templates monadically+-- as well as purely.+  easyRenderM+, easyRender+, easyContext+-- * The \"direct\" interface+-- | This interface gives more control than the easy interface, at the+-- expense of requiring more yak shaving.+, runGingerT , runGinger-, GingerContext , makeContext , makeContextM , makeContext'@@ -30,7 +39,11 @@ , makeContextHtmlM , makeContextText , makeContextTextM+-- * The context type+, GingerContext+-- * The Run monad , Run, liftRun, liftRun2+-- * Helper functions for interpreting argument lists , extractArgs, extractArgsT, extractArgsL, extractArgsDefL ) where@@ -67,6 +80,7 @@ import Text.Ginger.Run.VM import Text.Printf import Text.PrintfA+import Text.Ginger.Parse (parseGinger)  import Data.Text (Text) import Data.String (fromString)@@ -88,7 +102,7 @@ import Debug.Trace (trace) import Data.List (lookup, zipWith, unzip) -defaultScope :: forall m h. Monad m => [(Text, GVal (Run m h))]+defaultScope :: forall m h. (Monoid h, Monad m, ToGVal (Run m h) h) => [(Text, GVal (Run m h))] defaultScope =     [ ("raw", fromFunction gfnRawHtml)     , ("abs", fromFunction . unaryNumericFunc 0 $ Prelude.abs)@@ -109,6 +123,7 @@     , ("e", fromFunction gfnEscape)     , ("equals", fromFunction gfnEquals)     , ("escape", fromFunction gfnEscape)+    , ("eval", fromFunction gfnEval)     , ("filesizeformat", fromFunction gfnFileSizeFormat)     , ("filter", fromFunction gfnFilter)     , ("floor", fromFunction . unaryNumericFunc 0 $ Prelude.fromIntegral . Prelude.floor)@@ -138,6 +153,37 @@     , ("urlencode", fromFunction gfnUrlEncode)     ] +-- | Simplified interface to render a ginger template \"into\" a monad.+--+-- @easyRenderM emit context template@ renders the @template@ with the+-- given @context@ object (which should represent some sort of+-- dictionary-like object) by feeding any output to the @emit@ function.+easyRenderM :: ( Monad m+               , ContextEncodable h+               , Monoid h+               , ToGVal (Run m h) v+               , ToGVal (Run m h) h+               )+            => (h -> m ()) -> v -> Template -> m ()+easyRenderM emit context template =+    runGingerT (easyContext emit context) template++-- | Simplified interface to render a ginger template in a pure fashion.+--+-- @easyRender context template@ renders the @template@ with the+-- given @context@ object (which should represent some sort of+-- dictionary-like object) by returning the concatenated output.+easyRender :: ( ContextEncodable h+              , Monoid h+              , ToGVal (Run (Writer h) h) v+              , ToGVal (Run (Writer h) h) h+              )+           => v+           -> Template+           -> h+easyRender context template =+    execWriter $ easyRenderM tell context template+ -- | Purely expand a Ginger template. The underlying carrier monad is 'Writer' -- 'h', which is used to collect the output and render it into a 'h' -- value.@@ -337,7 +383,9 @@     p <- asks contextWrite     p . e $ src -defRunState :: forall m h. (Monoid h, Monad m) => Template -> RunState m h+defRunState :: forall m h. (ToGVal (Run m h) h, Monoid h, Monad m)+            => Template+            -> RunState m h defRunState tpl =     RunState         { rsScope = HashMap.fromList defaultScope@@ -346,3 +394,36 @@         , rsCurrentBlockName = Nothing         } +gfnEval :: (Monad m, Monoid h, ToGVal (Run m h) h) => Function (Run m h)+gfnEval args =+    let extracted =+            extractArgsDefL+                [ ("src", def)+                , ("context", def)+                ]+                args+    in case extracted of+        Left _ -> fail "Invalid arguments to 'dictsort'"+        Right [gSrc, gContext] -> do+            result <- parseGinger+                (Prelude.const . return $ Nothing) -- include resolver+                Nothing -- source name+                (Text.unpack . asText $ gSrc) -- source code+            tpl <- case result of+                Left err -> fail $ "Error in evaluated code: " ++ show err+                Right t -> return t+            let localLookup varName = return $+                    lookupLooseDef def (toGVal varName) gContext+                localContext c = c+                    { contextWrite = appendCapture+                    , contextLookup = localLookup+                    }+            withLocalState $ do+                put $ defRunState tpl+                local localContext $ do+                    clearCapture+                    runStatement $ templateBody tpl+                    -- At this point, we're still inside the local state, so the+                    -- capture contains the macro's output; we now simply return+                    -- the capture as the function's return value.+                    toGVal <$> fetchCapture
src/Text/Ginger/Run/Type.hs view
@@ -15,6 +15,8 @@ , makeContextHtmlM , makeContextText , makeContextTextM+, easyContext+, ContextEncodable (..) , liftRun , liftRun2 , Run (..)@@ -85,6 +87,34 @@ contextWriteEncoded :: GingerContext m h -> GVal (Run m h) -> Run m h () contextWriteEncoded context =     contextWrite context . contextEncode context++easyContext :: (Monad m, ContextEncodable h, ToGVal (Run m h) v)+            => (h -> m ())+            -> v+            -> GingerContext m h+easyContext emit context =+    makeContextM'+        (\varName ->+            return+                (lookupLooseDef def+                    (toGVal varName)+                    (toGVal context)))+        emit+        encode+++-- | Typeclass that defines how to encode 'GVal's into a given type.+class ContextEncodable h where+    encode :: forall m. GVal m -> h++-- | Encoding to text just takes the text representation without further+-- processing.+instance ContextEncodable Text where+    encode = asText++-- | Encoding to Html is implemented as returning the 'asHtml' representation.+instance ContextEncodable Html where+    encode = toHtml  -- | Create an execution context for runGingerT. -- Takes a lookup function, which returns ginger values into the carrier monad
+ test/Text/Ginger/PropertyTests.hs view
@@ -0,0 +1,145 @@+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE FlexibleContexts #-}+module Text.Ginger.PropertyTests+where++import Test.Tasty+import Test.Tasty.QuickCheck+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Default (def)+import qualified Data.HashMap.Strict as HashMap+import Control.Exception+import System.IO.Unsafe (unsafePerformIO)+import Control.Monad.Identity (Identity)+import Data.Time++import Text.Ginger+import Text.Ginger.Html++instance Arbitrary Text where+    arbitrary = Text.pack <$> arbitrary++instance Arbitrary Html where+    arbitrary = oneof+        [ html <$> arbitrary+        , arbitraryTag+        ]++arbitraryTag = do+    tagName <- arbitrary+    inner <- arbitrary+    return $ mconcat+        [ unsafeRawHtml "<"+        , tagName+        , unsafeRawHtml ">"+        , html inner+        , unsafeRawHtml "</"+        , tagName+        , unsafeRawHtml ">"+        ]++instance Arbitrary Day where+    arbitrary = ModifiedJulianDay <$> arbitrary++instance Arbitrary TimeOfDay where+    arbitrary =+        TimeOfDay+            <$> resize 24 arbitrarySizedNatural+            <*> resize 60 arbitrarySizedNatural+            <*> (fromIntegral <$> resize 61 arbitrarySizedNatural)++instance Arbitrary LocalTime where+    arbitrary =+        LocalTime <$> arbitrary <*> arbitrary++instance Arbitrary TimeZone where+    arbitrary =+        TimeZone <$> arbitrary <*> arbitrary <*> arbitrary++instance Arbitrary ZonedTime where+    arbitrary =+        ZonedTime <$> arbitrary <*> arbitrary++instance Arbitrary Statement where+    arbitrary = arbitraryStatement 2++arbitrarySimpleStatement = oneof+        [ return NullS+        , ScopedS <$> arbitrary+        , LiteralS <$> arbitrary+        -- , InterpolationS <$> arbitrary+        -- , IfS <$> arbitrary <*> arbitrary <*> arbitrary+        -- , ForS <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary+        -- , SetVarS <$> arbitrary <*> arbitrary+        -- , DefMacroS <$> arbitrary <*> arbitrary+        -- , BlockRefS <$> arbitrary+        -- , PreprocessedIncludeS <$> arbitrary+        ]++arbitraryStatement :: Int -> Gen Statement+arbitraryStatement 0 = arbitrarySimpleStatement+arbitraryStatement n = oneof+    [ arbitrarySimpleStatement+    , MultiS <$> resize 5 (listOf $ arbitraryStatement (pred n))+    ]++instance Arbitrary Template where+    arbitrary =+        Template <$> arbitrary <*> return HashMap.empty <*> return Nothing++propertyTests :: TestTree+propertyTests = testGroup "Properties"+    [ testGroup "Optimizer" $+        [ testProperty "optimizer doesn't change behavior" $+            \ast -> unsafePerformIO $ (==) <$> expand ast <*> expand (optimize ast)+        ]+    , testGroup "ToGVal / FromGVal round tripping"+        [ testProperty "Int" (roundTripGValP :: Int -> Bool)+        , testProperty "Bool" (roundTripGValP :: Bool -> Bool)+        , testProperty "[Text]" (roundTripGValP :: [Text] -> Bool)+        , testProperty "Maybe Text" (roundTripGValP :: Maybe Text -> Bool)+        , testProperty "Text" (roundTripGValP :: Text -> Bool)+        , testProperty "LocalTime" (roundTripGValP :: LocalTime -> Bool)+        , testProperty "TimeZone" (roundTripGValP :: TimeZone -> Bool)+        -- For ZonedTime, we don't have an Eq instance because it equality+        -- of datetimes across time zones is unsolvable; we can, however, use+        -- a "strict" equality test by simply rendering zoned times through+        -- 'show', i.e., we consider two ZonedTime values equal iff they render+        -- to the exact same string representations.+        , testProperty "ZonedTime" (roundTripGValProjP show :: ZonedTime -> Bool)+        ]+    ]++roundTripGValExP :: (ToGVal Identity a, FromGVal Identity a)+               => (a -> a -> Bool)+               -> a+               -> Bool+roundTripGValExP cmp orig =+    let g :: GVal Identity+        g = toGVal orig+    in case fromGVal g of+        Nothing -> False+        Just final -> cmp orig final++roundTripGValProjP :: (Eq b, ToGVal Identity a, FromGVal Identity a)+               => (a -> b)+               -> a+               -> Bool+roundTripGValProjP+    proj = roundTripGValExP f+    where+        f x y = proj x == proj y++roundTripGValP :: (Eq a, ToGVal Identity a, FromGVal Identity a)+               => a -> Bool+roundTripGValP = roundTripGValExP (==)++expand :: Template -> IO (Either String Text)+expand tpl =+    mapLeft (const "ERROR" :: SomeException -> String) <$>+        try (return $ runGinger (makeContextText (const def)) tpl)++mapLeft :: (a -> b) -> Either a c -> Either b c+mapLeft f (Left x) = Left (f x)+mapLeft f (Right x) = Right x
+ test/Text/Ginger/SimulationTests.hs view
@@ -0,0 +1,831 @@+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE FlexibleContexts #-}+module Text.Ginger.SimulationTests+where++import Text.Ginger+import Text.Ginger.Html+import Test.Tasty+import Test.Tasty.HUnit+import Data.Default (def)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Maybe (fromMaybe)+import Control.Monad.Writer (WriterT, runWriterT)+import Control.Monad.IO.Class (liftIO)+import Data.IORef+import Data.Monoid+import qualified Data.Aeson as JSON+import qualified Data.ByteString.Lazy.UTF8 as LUTF8+import qualified Data.ByteString.Lazy as LBS+import Data.Time (TimeLocale)++simulationTests :: TestTree+simulationTests = testGroup "Simulation"+    [ testCase "Smoke Test" $ mkTestHtml [] [] "Hello" "Hello"+    , testGroup "Comments"+        -- There is a comment between the two dashes that should not appear in+        -- the output.+        [ testCase "Comment does not appear in output" $ mkTestHtml+            [] [] "- {# Comments #} -" "-  -"+        ]+    , testGroup "Dashed limiters eat whitespace"+        [ testCase "comments" $ mkTestHtml+            [] [] "- {#- Comment -#} -" "--"+        , testCase "interpolations" $ mkTestHtml+            [] [] "- {{- '' -}} -" "--"+        , testCase "flow" $ mkTestHtml+            [] [] "- {%- set x=1 -%} -" "--"+        ]+    , testGroup "Literals"+        [ testCase "String: \"foobar\"" $ mkTestHtml+            [] [] "{{ \"foobar\" }}" "foobar"+        , testGroup "Numbers"+            [ testCase "123" $ mkTestHtml+                [] [] "{{ 123 }}" "123"+            , testCase "3.1415" $ mkTestHtml+                [] [] "{{ 3.1415 }}" "3.1415"+            ]+        , testGroup "Booleans"+            [ testCase "true" $ mkTestHtml+                [] [] "{{ true }}" "1"+            , testCase "false" $ mkTestHtml+                [] [] "{{ false }}" ""+            ]+        , testCase "Null" $ mkTestHtml+            [] [] "{{ null }}" ""+        ]+    , testGroup "Simple list/object constructs"+        [ testCase "Lists" $ mkTestHtml+            [] [] "{{ [\"foo\",\"bar\",\"baz\" ] }}" "foobarbaz"+        , testCase "Nested lists" $ mkTestHtml+            [] [] "{{ [ \"foo\", \"bar\", [ 1, 2, 3 ], \"baz\" ] }}" "foobar123baz"+        , testCase "Objects" $ mkTestHtml+            [] [] "{{ { \"foo\":\"bar\" } }}" "bar"+        , testCase "Nested object/list constructs" $ mkTestHtml+            [] [] "{{ { \"foo\":[\"foo\", {\"asdf\" : \"bar\"}, [\"baz\" ]] } }}" "foobarbaz"+        ]+    , testGroup "Accessing object/list members"+        [ testCase "by integer index" $ mkTestHtml+            [] [] "{{ [ \"foo\", \"bar\" ][1] }}" "bar"+        , testCase "by string key" $ mkTestHtml+            [] [] "{{ { \"foo\": \"bar\", \"baz\": \"quux\" }['foo'] }}" "bar"+        , testCase "by property name" $ mkTestHtml+            [] [] "{{ { \"foo\": \"bar\", \"baz\": \"quux\" }.foo }}" "bar"+        , testCase "multi-level mixed" $ mkTestHtml+            [] [] "{{ { \"foo\": { \"oink\": \"nope\", \"baz\": { \"boop\": [], \"quux\": \"bar\" }}}.foo.baz[\"quux\"] }}" "bar"+        ]+    , testGroup "Function calls"+        -- In order to make sure the correct function is called, we write one+        -- that stores its argument in an IORef that it closes over, thus+        -- making it impossible for other functions to modify it; then we+        -- assert that the IORef contains what we expect.+        [ testCase "print(\"Hello\")" $ do+            buf <- newIORef ""+            let printF :: Function (Run IO Html)+                printF = \xs -> do+                    liftIO . writeIORef buf . mconcat . map (asText . snd) $ xs+                    return def+            mkTestHtml [("print", fromFunction printF)] [] "{{ print(\"Hello\") }}" ""+            actual <- readIORef buf+            let expected = "Hello"+            assertEqual "" actual expected+        , testCase "\"Hello\"|print" $ do+            buf <- newIORef ""+            let printF :: Function (Run IO Html)+                printF = \xs -> do+                    liftIO . writeIORef buf . mconcat . map (asText . snd) $ xs+                    return def+            mkTestHtml [("print", fromFunction printF)] [] "{{ \"Hello\"|print }}" ""+            actual <- readIORef buf+            let expected = "Hello"+            assertEqual "" actual expected+        ]+    , testGroup "Addition"+        [ testCase "1 + 1 = 2" $ do+            mkTestHtml [] [] "{{ sum(1, 1) }}" "2"+        , testCase "1 + 1 = 2" $ do+            mkTestHtml [] [] "{{ 1 + 1 }}" "2"+        ]+    , testGroup "Subtraction"+        [ testCase "1 - 1 = 0" $ do+            mkTestHtml [] [] "{{ 1 - 1 }}" "0"+        ]+    , testGroup "Concatenation"+        [ testCase "1 ~ \"foo\" = 1foo" $ do+            mkTestHtml [] [] "{{ 1 ~ \"foo\" }}" "1foo"+        ]+    , testGroup "Multiplication"+        [ testCase "5 * 5 = 25" $ do+            mkTestHtml [] [] "{{ 5 * 5 }}" "25"+        ]+    , testGroup "Division"+        [ testCase "24 / 6 = 4" $ do+            mkTestHtml [] [] "{{ 24 / 6 }}" "4"+        , testCase "3 / 2 = 1.5" $ do+            mkTestHtml [] [] "{{ 3 / 2 }}" "1.5"+        ]+    , testGroup "Integer Division"+        [ testCase "24 // 6 = 4" $ do+            mkTestHtml [] [] "{{ 24 // 6 }}" "4"+        , testCase "3 // 2 = 1" $ do+            mkTestHtml [] [] "{{ 3 // 2 }}" "1"+        ]+    , testGroup "Modulo"+        [ testCase "7 % 3 = 2" $ do+            mkTestHtml [] [] "{{ 7 % 3 }}" "1"+        ]+    , testGroup "Iteration"+        [ testCase "for x in [ \"foo\", \"bar\", \"baz\" ]: <x>" $ do+            mkTestHtml [] [] "{% for x in [ \"foo\", \"bar\", \"baz\" ] %}<{{x}}>{% endfor %}" "<foo><bar><baz>"+        , testCase "for x in []: <x> else <no>" $ do+            mkTestHtml [] [] "{% for x in [] %}<{{x}}>{% else %}<no>{% endfor %}" "<no>"+        , testCase "for x in [a]: <x> else <no>" $ do+            mkTestHtml [] [] "{% for x in [\"a\"] %}<{{x}}>{% else %}<no>{% endfor %}" "<a>"+        ]+    , testGroup "The `loop` auto-variable"+        [ testCase "loop.cycle" $ do+            mkTestHtml [] []+                ( "{% for x in [\"foo\", \"bar\", \"baz\"] recursive -%}" +++                  "({{ loop.cycle(\"red\", \"green\") }}/{{ x }})" +++                  "{%- endfor %}"+                )+                "(red/foo)(green/bar)(red/baz)"+        , testCase "recursive loops" $ do+            mkTestHtml [] []+                ( "{% for k, x in {\"a\":{\"b\":null,\"c\":{\"d\":null}}} -%}" +++                  "{{ k }}@{{ loop.depth }}({{ loop(x) }})" +++                  "{%- endfor %}" :: String+                )+                "a@1(b@2()c@2(d@3()))"+        ]+    , testGroup "Conditionals"+        [ testCase "if true then \"yes\" else \"no\"" $ do+            mkTestHtml [] [] "{% if true %}yes{% else %}no{% endif %}" "yes"+        , testCase "if false then \"yes\" else if false then \"maybe\" else \"no\"" $ do+            mkTestHtml [] [] "{% if false %}yes{% elif false %}maybe{% else %}no{% endif %}" "no"+        , testCase "if false then \"yes\" else if true then \"maybe\" else \"no\"" $ do+            mkTestHtml [] [] "{% if false %}yes{% elif true %}maybe{% else %}no{% endif %}" "maybe"+        ]+    , testGroup "Comparisons"+        [ testCase "if 1 == 1 then \"yes\" else \"no\"" $ do+            mkTestHtml [] [] "{% if (1 == 1) %}yes{% else %}no{% endif %}" "yes"+        , testCase "if 1 > 0 then \"yes\" else \"no\"" $ do+            mkTestHtml [] [] "{% if (1 > 0) %}yes{% else %}no{% endif %}" "yes"+        , testCase "if 1 > null then \"yes\" else \"no\"" $ do+            mkTestHtml [] [] "{% if (1 > null) %}yes{% else %}no{% endif %}" "no"+        , testCase "if 1 < 2 then \"yes\" else \"no\"" $ do+            mkTestHtml [] [] "{% if (1 < 2) %}yes{% else %}no{% endif %}" "yes"+        , testCase "if null < 1 then \"yes\" else \"no\"" $ do+            mkTestHtml [] [] "{% if (null < 1) %}yes{% else %}no{% endif %}" "no"+        ]+    , testGroup "Boolean AND"+        [ testCase "AND (both)" $ do+            mkTestHtml [] [] "{% if 1 && 2 %}yes{% else %}no{% endif %}" "yes"+        , testCase "AND (only one)" $ do+            mkTestHtml [] [] "{% if 1 && 0 %}yes{% else %}no{% endif %}" "no"+        , testCase "AND (neither)" $ do+            mkTestHtml [] [] "{% if 0 && 0 %}yes{% else %}no{% endif %}" "no"+        ]+    , testGroup "Boolean AND"+        [ testCase "OR (both)" $ do+            mkTestHtml [] [] "{% if 1 || 2 %}yes{% else %}no{% endif %}" "yes"+        , testCase "OR (only one)" $ do+            mkTestHtml [] [] "{% if 1 || 0 %}yes{% else %}no{% endif %}" "yes"+        , testCase "OR (either)" $ do+            mkTestHtml [] [] "{% if 0 || 0 %}yes{% else %}no{% endif %}" "no"+        ]+    , testGroup "Slicing brackets"+        [ testCase "from/to, both positive" $ do+            mkTestHtml [] []+                "{{ 'abcdef'[1:2] }}"+                "bc"+        , testCase "from/to, from negative" $ do+            mkTestHtml [] []+                "{{ 'abcdef'[-3:2] }}"+                "de"+        , testCase "from/to, to implicit" $ do+            mkTestHtml [] []+                "{{ 'abcdef'[1:] }}"+                "bcdef"+        , testCase "from/to, from implicit" $ do+            mkTestHtml [] []+                "{{ 'abcdef'[:2] }}"+                "ab"+        ]+    , testGroup "Built-in filters/functions"+        [ testCase "\"abs\"" $ do+            mkTestHtml [] [] "{{ -2|abs }}" "2"+        , testGroup "\"any\""+            [ testCase "\"any\" (both)" $ do+                mkTestHtml [] [] "{% if any(1, 1, true) %}yes{% else %}no{% endif %}" "yes"+            , testCase "\"any\" (just one)" $ do+                mkTestHtml [] []+                    "{% if any(0, 1, false) %}yes{% else %}no{% endif %}"+                    "yes"+            , testCase "\"any\" (neither)" $ do+                mkTestHtml [] []+                    "{% if any(0, 0, false) %}yes{% else %}no{% endif %}"+                    "no"+            ]+        , testGroup "\"all\""+            [ testCase "\"all\" (both)" $ do+                mkTestHtml [] []+                    "{% if all(1, 1, true) %}yes{% else %}no{% endif %}"+                    "yes"+            , testCase "\"all\" (just one)" $ do+                mkTestHtml [] []+                    "{% if all(0, 1, false) %}yes{% else %}no{% endif %}"+                    "no"+            , testCase "\"all\" (neither)" $ do+                mkTestHtml [] []+                    "{% if all(0, 0, false) %}yes{% else %}no{% endif %}"+                    "no"+            ]+        , testGroup "\"ceil\""+            [ testCase "14.1" $ do+                mkTestHtml [] []+                    "{{ 14.1|ceil }}"+                    "15"+            , testCase "-14.1" $ do+                mkTestHtml [] []+                    "{{ -14.1|ceil }}"+                    "-14"+            , testCase "-14.8" $ do+                mkTestHtml [] []+                    "{{ -14.8|ceil }}"+                    "-14"+            ]+        , testCase "\"capitalize\"" $ do+            mkTestHtml [] []+                "{{ \"this is the end of the world\"|capitalize }}"+                "This is the end of the world"+        , testGroup "\"center\""+            [ testCase "extra space" $ do+                mkTestHtml [] []+                    "{{ \"asdf\"|center(12) }}"+                    "    asdf    "+            , testCase "no extra space" $ do+                mkTestHtml [] []+                    "{{ \"foobar\"|center(2) }}"+                    "foobar"+            ]+        , testCase "\"concat\"" $ do+            mkTestHtml [] []+                "{{ [\"hello\", \"world\"]|concat }}"+                "helloworld"+        , testGroup "\"contains\""+            [ testCase "single match" $ do+                mkTestHtml [] []+                    "{% if ['hello', 'world']|contains('hello') %}yes{% else %}no{% endif %}"+                    "yes"+            , testCase "multi match" $ do+                mkTestHtml [] []+                    "{% if ['hello', 'world']|contains('hello', 'world') %}yes{% else %}no{% endif %}"+                    "yes"+            , testCase "non-match" $ do+                mkTestHtml [] []+                    "{% if ['hello', 'world']|contains('hello', 'you', 'world') %}yes{% else %}no{% endif %}"+                    "no"+            ]+        , testGroup "\"date\""+            [ testCase "format a date" $ do+                mkTestHtml [] []+                    "{{ {'year':2015, 'month':6, 'day':13}|date('%Y-%m-%d') }}"+                    "2015-06-13"+            , testCase "format a list as a date" $ do+                mkTestHtml [] []+                    "{{ [2015, 6, 13, 12, 45, 21]|date('%Y-%m-%d') }}"+                    "2015-06-13"+            , testCase "format a 5-element list as a date" $ do+                mkTestHtml [] []+                    "{{ [2015, 6, 13, 12, 45]|date('%Y-%m-%d') }}"+                    "2015-06-13"+            , testCase "format a 3-element list as a date" $ do+                mkTestHtml [] []+                    "{{ [2015, 6, 13]|date('%Y-%m-%d') }}"+                    "2015-06-13"+            , testCase "use correct default time (noon) with 3-element list" $ do+                mkTestHtml [] []+                    "{{ [2015, 6, 13]|date('%H:%M:%S') }}"+                    "12:00:00"+            , testCase "format a string as a date" $ do+                mkTestHtml [] []+                    "{{ '2015-06-13 12:05:43'|date('%Y-%m-%d') }}"+                    "2015-06-13"+            , testCase "format a string as a date (JSON-style formatting)" $ do+                mkTestHtml [] []+                    "{{ '2015-06-13T12:05:43Z'|date('%Y-%m-%d %H:%M:%S') }}"+                    "2015-06-13 12:05:43"+            , testCase "format a string as a time-of-day" $ do+                mkTestHtml [] []+                    "{{ '2015-06-13 12:05:43'|date('%H-%M-%S') }}"+                    "12-05-43"+            , testCase "format a string as a date, with timezone" $ do+                mkTestHtml [] []+                    "{{ '2015-06-13 12:05:43-05:00'|date('%Y-%m-%d %z') }}"+                    "2015-06-13 -0500"+            , testCase "format a local-time string as a date + time, with explicit timezone" $ do+                mkTestHtml [] []+                    "{{ '2015-06-13 12:05:43'|date('%Y-%m-%d %H:%M %z', tz='+0200') }}"+                    "2015-06-13 12:05 +0200"+            , testCase "format a zoned-time string as a date, with explicit timezone" $ do+                mkTestHtml [] []+                    "{{ '2015-06-13 12:05:43-05:00'|date('%Y-%m-%d %H:%M %z', tz='+0200') }}"+                    "2015-06-13 19:05 +0200"+            , testCase "use a custom locale" $ do+                sillyLocale <- loadSillyLocale+                mkTestHtml+                    [("silly", toGVal (sillyLocale :: Maybe JSON.Value))]+                    []+                    "{{ '2015-06-13 12:05:43'|date('%c', locale=silly) }}"+                    "It be The Day Of Saturn, in the year 15, the clock striketh 12"+            ]+        , testGroup "\"default\""+            [ testCase "trigger default" $ do+                mkTestHtml [] []+                    "{{ 0|default(\"hi\") }}"+                    "hi"+            , testCase "use truthy value" $ do+                mkTestHtml [] []+                    "{{ \"hi\"|default(\"nope\") }}"+                    "hi"+            ]+        , testCase "\"difference\"" $ do+            mkTestHtml [] []+                "{{ difference(5,2) }}"+                "3"+        , testGroup "\"dictsort\""+            [ testCase "by key" $ do+                mkTestHtml [] []+                    "{{ dictsort({4:4, 1:5}, by='key') }}"+                    "54"+            , testCase "by value" $ do+                mkTestHtml [] []+                    "{{ dictsort({4:4, 1:5}, by='value') }}"+                    "45"+            ]+        , testGroup "\"escape\""+            [ testCase "single item" $ do+                mkTestHtml [] []+                    "{{ escape('<')|raw }}"+                    "&lt;"+            , testCase "multiple items" $ do+                mkTestHtml [] []+                    "{{ escape('<', '>')|raw }}"+                    "&lt;&gt;"+            ]+        , testGroup "\"equals\""+            [ testCase "all equal" $ do+                mkTestHtml [] []+                    "{% if equals(1, 1, 1) %}yes{% else %}no{% endif %}"+                    "yes"+            , testCase "some equal" $ do+                mkTestHtml [] []+                    "{% if equals(1, 1, 2) %}yes{% else %}no{% endif %}"+                    "no"+            ]+        , testGroup "\"eval\""+            [ testCase "simple" $ do+                mkTestHtml [] []+                    "{{ eval('{% set x = 1 %}{{x}}') }}"+                    "1"+            , testCase "with extra state" $ do+                mkTestHtml [] []+                    "{{ eval('{{x}}', { 'x': 1 }) }}"+                    "1"+            , testCase "outside state does not bleed into eval()" $ do+                mkTestHtml [] []+                    "{% set x = 1 %}{{ eval('{{x}}') }}"+                    ""+            , testCase "standard functions available inside eval" $ do+                mkTestHtml [] []+                    "{{ eval(\"{{'foobar'|capitalize()}}\") }}"+                    "Foobar"+            ]+        , testGroup "\"filesizeformat\""+            [ testCase "bytes" $ do+                mkTestHtml [] []+                    "{{ 100|filesizeformat }}"+                    "100 B"+            , testCase "kilobytes" $ do+                mkTestHtml [] []+                    "{{ 12000|filesizeformat }}"+                    "12 kB"+            , testCase "megabytes" $ do+                mkTestHtml [] []+                    "{{ 1500000|filesizeformat }}"+                    "1.5 MB"+            , testCase "bytes (2-based)" $ do+                mkTestHtml [] []+                    "{{ 100|filesizeformat(true) }}"+                    "100 B"+            , testCase "kibibytes" $ do+                mkTestHtml [] []+                    "{{ 12000|filesizeformat(true) }}"+                    "11.7 kiB"+            , testCase "mebibytes" $ do+                mkTestHtml [] []+                    "{{ 1500000|filesizeformat(true) }}"+                    "1.4 MiB"+            ]+        , testGroup "\"filter\""+            [ testCase "simple case" $ do+                mkTestHtml [] []+                    "{{ [1, 0, 3]|filter(int) }}"+                    "13"+            , testCase "with extra argument" $ do+                mkTestHtml [] []+                    "{{ [1, 2, 3]|filter(greater, 2) }}"+                    "3"+            ]+        , testGroup "\"format\""+            [ testCase "jinja.pocoo.org example" $ do+                mkTestHtml [] []+                    "{{ \"%s - %s\"|format('Hello?', 'Foo!') }}"+                    "Hello? - Foo!"+            ]+        , testGroup "\"not-equals\""+            [ testCase "all equal" $ do+                mkTestHtml [] []+                    "{% if nequals(1, 1, 1) %}yes{% else %}no{% endif %}"+                    "no"+            , testCase "not all equal" $ do+                mkTestHtml [] []+                    "{% if nequals(1, 1, 2) %}yes{% else %}no{% endif %}"+                    "yes"+            ]+        , testGroup "\"floor\""+            [ testCase "14.1" $ do+                mkTestHtml [] []+                    "{{ 14.1|floor }}"+                    "14"+            , testCase "14.8" $ do+                mkTestHtml [] []+                    "{{ 14.8|floor }}"+                    "14"+            , testCase "-14.1" $ do+                mkTestHtml [] []+                    "{{ -14.1|floor }}"+                    "-15"+            , testCase "-14.8" $ do+                mkTestHtml [] []+                    "{{ -14.8|floor }}"+                    "-15"+            ]+        , testGroup "\"int\""+            [ testCase "14.1" $ do+                mkTestHtml [] []+                    "{{ 14.1|int }}"+                    "14"+            , testCase "14.8" $ do+                mkTestHtml [] []+                    "{{ 14.8|int }}"+                    "14"+            , testCase "-14.1" $ do+                mkTestHtml [] []+                    "{{ -14.1|int }}"+                    "-14"+            , testCase "-14.8" $ do+                mkTestHtml [] []+                    "{{ -14.8|int }}"+                    "-14"+            ]+        -- \"int_ratio\"+        -- TODO+        -- \"iterable\"+        -- TODO+        , testCase "\"length\"" $ do+            mkTestHtml [] []+                "{{ [1,2,3]|length }}"+                "3"+        -- \"modulo\"+        -- \"num\"+        -- TODO+        -- \"printf\"+        , testGroup "\"printf\""+            [ testCase "%s, passed as int" $ do+                mkTestHtml [] []+                    "{{ printf(\"%i\", 1) }}"+                    "1"+            , testCase "%s, passed as string" $ do+                mkTestHtml [] []+                    "{{ printf(\"%i\", \"1\") }}"+                    "1"+            , testCase "%i, passed as float" $ do+                mkTestHtml [] []+                    "{{ printf(\"%i\", 1.3) }}"+                    "1"+            , testCase "%f, passed as int" $ do+                mkTestHtml [] []+                    "{{ printf(\"%f\", 1) }}"+                    "1.0"+            , testCase "%.3f, passed as int" $ do+                mkTestHtml [] []+                    "{{ printf(\"%.3f\", 1) }}"+                    "1.000"+            , testCase "%s, string" $ do+                mkTestHtml [] []+                    "{{ printf(\"%s\", \"Hello\") }}"+                    "Hello"+            ]+        , testCase "\"product\"" $ do+            mkTestHtml [] []+                "{{ product(1,2,3) }}"+                "6"+        , testCase "\"ratio\"" $ do+            mkTestHtml [] []+                "{{ ratio(6, 1.5, 2) }}"+                "2"+        , testGroup "\"round\""+            [ testCase "14.1" $ do+                mkTestHtml [] []+                    "{{ 14.1|round }}"+                    "14"+            , testCase "14.8" $ do+                mkTestHtml [] []+                    "{{ 14.8|round }}"+                    "15"+            , testCase "-14.1" $ do+                mkTestHtml [] []+                    "{{ -14.1|round }}"+                    "-14"+            , testCase "-14.8" $ do+                mkTestHtml [] []+                    "{{ -14.8|round }}"+                    "-15"+            ]+        -- \"show\"+        -- TODO+        , testCase "\"str\"" $ do+            mkTestHtml [] []+                "{{ str(123) }}"+                "123"+        , testCase "\"sum\"" $ do+            mkTestHtml [] []+                "{{sum(1, 2, 3)}}"+                "6"+        , testGroup "\"truncate\""+            [ testCase "14.1" $ do+                mkTestHtml [] []+                    "{{ 14.1|truncate }}"+                    "14"+            , testCase "14.8" $ do+                mkTestHtml [] []+                    "{{ 14.8|truncate }}"+                    "14"+            , testCase "-14.1" $ do+                mkTestHtml [] []+                    "{{ -14.1|truncate }}"+                    "-14"+            , testCase "-14.8" $ do+                mkTestHtml [] []+                    "{{ -14.8|truncate }}"+                    "-14"+            ]+        , testCase "\"urlencode\"" $ do+            mkTestHtml [] []+                "{{ \"a/b c\"|urlencode }}"+                "a%2Fb%20c"+        , testGroup "\"sort\""+            [ testCase "simple" $ do+                mkTestHtml [] []+                    "{{ [2,3,1]|sort }}"+                    "123"+            , testCase "reverse" $ do+                mkTestHtml [] []+                    "{{ [2,3,1]|sort(reverse=true) }}"+                    "321"+            , testCase "sort by key" $ do+                mkTestHtml [] []+                    "{{ ([ {\"age\":30, \"name\":\"zzz\"}, {\"age\":41, \"name\":\"aaa\"} ]|sort(by=\"name\"))[0]['name'] }}"+                    "aaa"+            , testCase "sort by key, reverse" $ do+                mkTestHtml [] []+                    "{{ ([ {\"age\":30, \"name\":\"zzz\"}, {\"age\":41, \"name\":\"aaa\"} ]|sort(by=\"age\", reverse=true))[0]['name'] }}"+                    "aaa"+            , testCase "sort dictionary by keys" $ do+                mkTestHtml [] []+                    "{{ {'foo': 1, 'bar': 2}|sort(by='__key') }}"+                    "21"+            , testCase "sort by a projection function" $ do+                mkTestHtml [] []+                    "{{ ['foo', 'bar', 'quux']|sort(by=(str) -> str[1:]) }}"+                    "barfooquux"+            , testCase "sort by a path" $ do+                mkTestHtml [] []+                    "{{ [{'main':{'sub': 2}, 'msg':'no'},{'main':{'sub': 1}, 'msg':'yes'}]|sort(by=['main', 'sub']) }}"+                    "1yes2no"+            ]+        , testGroup "\"slice\""+            [ testCase "full positional args" $ do+                mkTestHtml [] []+                    "{{ [1, 2, 3, 4, 5]|slice(1,3) }}"+                    "234"+            , testCase "implicit 'to end'" $ do+                mkTestHtml [] []+                    "{{ [1, 2, 3, 4, 5]|slice(1) }}"+                    "2345"+            , testCase "full named args" $ do+                mkTestHtml [] []+                    "{{ [1, 2, 3, 4, 5]|slice(length=3,start=1) }}"+                    "234"+            , testCase "negative offset" $ do+                mkTestHtml [] []+                    "{{ [1, 2, 3, 4, 5]|slice(start=-1) }}"+                    "5"+            , testCase "call on string subject" $ do+                mkTestHtml [] []+                    "{{ \"12345\"|slice(1,3) }}"+                    "234"+            ]+        , testGroup "\"replace\""+            [ testCase "simple case" $ do+                mkTestHtml [] []+                    "{{ replace('foobar', 'a', 'e') }}"+                    "foober"+            , testCase "multiple replacements" $ do+                mkTestHtml [] []+                    "{{ replace('foobar', 'o', 'e') }}"+                    "feebar"+            , testCase "longer replacements" $ do+                mkTestHtml [] []+                    "{{ replace('foobar', 'oo', 'e') }}"+                    "febar"+            , testCase "deletion" $ do+                mkTestHtml [] []+                    "{{ replace('foobar', 'o') }}"+                    "fbar"+            ]+        ]+    , testGroup "Setting variables"+        [ testCase "plain" $ do+            mkTestHtml [] []+                "{% set x = \"world\" %}Hello, {{ x }}!"+                "Hello, world!"+        , testCase "self-referential" $ do+            mkTestHtml [] []+                "{% set x = { \"foo\":\"bar\" } %}{% set x = x[\"foo\"] %}Hello, {{ x }}!"+                "Hello, bar!"+        ]+    , testGroup "HTML encoding"+        [ testCase "no encoding outside of constructs" $ do+            mkTestHtml [] []+                "<foo bar=\"baz\"/>Ampersand is '&'</foo>."+                "<foo bar=\"baz\"/>Ampersand is '&'</foo>."+        , testCase "auto-encode inside interpolations" $ do+            mkTestHtml [] []+                "{{ \"<foo bar=\\\"baz\\\"/>amp is '&'</foo>.\" }}"+                "&lt;foo bar=&quot;baz&quot;/&gt;amp is &apos;&amp;&apos;&lt;/foo&gt;."+        , testCase "raw filter bypasses encoding" $ do+            mkTestHtml [] []+                "{{ \"<a>&amp;\"|raw }}"+                "<a>&amp;"+        ]+    , testGroup "Includes"+        [ testCase "include plain" $ do+            mkTestHtml [] [("./features-included.html", "This is an included template")]+                "{% include 'features-included.html' %}"+                "This is an included template"+        , testCase "include with a variable" $ do+            mkTestHtml [] [("./features-included.html", "Hello, {{ user }}!")]+                "{% set user='world' %}{% include 'features-included.html' %}"+                "Hello, world!"+        , testCase "include referencing an included variable" $ do+            mkTestHtml [] [("./features-included.html", "{% set user = 'foobar' %}")]+                "{% include 'features-included.html' %}Hello, {{ user }}!"+                "Hello, foobar!"+        ]+    , testGroup "Explicit Local Scopes"+        [ testCase "baseline" $ do+            mkTestHtml [] [] "{% set bedazzle = \"no\" %}{{ bedazzle }}" "no"+        , testCase "inside local scope" $ do+            mkTestHtml [] [] "{% set bedazzle = \"no\" %}{% scope %}{% set bedazzle = \"ya\" %}{{ bedazzle }}{% endscope %}" "ya"+        , testCase "after exiting local scope" $ do+            mkTestHtml [] [] "{% set bedazzle = \"no\" %}{% scope %}{% set bedazzle = \"ya\" %}{% endscope %}{{ bedazzle }}" "no"+        ]+    , testGroup "Macros"+        [ testCase "simple" $ do+            mkTestHtml [] []+                "{% macro foobar -%}baz{%- endmacro %}{{ foobar() }}"+                "baz"+        , testCase "with args" $ do+            mkTestHtml [] []+                "{% macro foobar2(baz) -%}{{ baz }}oo{%- endmacro %}{{ foobar2(boink=\"nope\", baz=\"blabber\") }}"+                "blabberoo"+        ]+    , testGroup "Lambdas"+        [ testCase "single arg" $ do+            mkTestHtml [] [] "{{ ((x) -> x * x)(2) }}" "4"+        , testCase "two args" $ do+            mkTestHtml [] [] "{{ ((greeting, name) -> greeting ~ \", \" ~ name ~ \"!\")(\"Hello\", \"world\") }}" "Hello, world!"+        ]+    , testGroup "Ternary operator"+        [ testCase "C syntax" $ do+            mkTestHtml [] [] "{{ 1 ? \"yes\" : \"no\" }}" "yes"+        , testCase "python syntax" $ do+            mkTestHtml [] [] "{{ \"yes\" if 1 else \"no\" }}" "yes"+        , testCase "C syntax, nested, true/false" $ do+            mkTestHtml [] [] "{{ true ? false ? \"a\" : \"b\" : \"c\" }}" "b"+        , testCase "Python syntax, nested, true/false" $ do+            mkTestHtml [] [] "{{ \"a\" if false else \"b\" if true else \"c\" }}" "b"+        , testCase "C syntax, nested, true/true" $ do+            mkTestHtml [] [] "{{ true ? true ? \"a\" : \"b\" : \"c\" }}" "a"+        , testCase "Python syntax, nested, true/true" $ do+            mkTestHtml [] [] "{{ false ? true ? \"a\" : \"b\" : \"c\" }}" "c"+        ]+    , testGroup "Call syntax"+        [ testCase "\"caller\"" $ do+            mkTestHtml [] [] "{% macro foobar3(a) -%}{{ a }}({{ caller(\"asdf\") }}){%- endmacro %}{% call (a) foobar3(\"hey\") %}<{{a}}>{% endcall %}" "hey(<asdf>)"+        ]+    , testGroup "Inheritance"+        [ testCase "inheritance" $ do+            mkTestHtml+                []+                [ ("./inherit-child.html", "{% extends \"inherit-parent.html\" %}{% block test -%}This is right.{% endblock %}")+                , ("./inherit-parent.html", "{%- block test -%}This is wrong.{% endblock -%}")+                ]+                "{% include \"inherit-child.html\" %}"+                "This is right."+        , testCase "multi-tier inheritance" $ do+            mkTestHtml+                []+                [ ( "./inherit-parent.html"+                  , "{%- extends \"inherit-grandparent.html\" %}" +++                    "{%- block outer -%}Outer{%- block inner -%}{%- endblock -%}{%- endblock -%}"+                  )+                , ( "./inherit-grandparent.html"+                  , "*{% block outer -%}{%- endblock %}*"+                  )+                ]+                ( "{%- extends \"inherit-parent.html\" -%}" +++                  "{%- block inner %} Space{%- endblock -%}"+                )+                "*Outer Space*"+        ]+    , testGroup "Non-HTML Output"+        [ testCase "text" $ do+            mkTestText+                []+                []+                "<>{{ '<>' }}<>"+                "<><><>"+        , testCase "json" $ do+            mkTestJSON+                []+                []+                "{{ '<>' }}{{ 1 }}{{ {'foo': true} }}"+                "[\"<>\",1,{\"foo\":true}]"+        ]+    ]++mkTestHtml :: [(VarName, GVal (Run IO Html))]+           -> [(FilePath, String)]+           -> String+           -> Text+           -> Assertion+mkTestHtml = mkTest makeContextHtmlM htmlSource++mkTestText :: [(VarName, GVal (Run IO Text))]+           -> [(FilePath, String)]+           -> String+           -> Text+           -> Assertion+mkTestText = mkTest makeContextTextM id++mkTestJSON :: [(VarName, GVal (Run IO [JSON.Value]))]+           -> [(FilePath, String)]+           -> String+           -> Text+           -> Assertion+mkTestJSON = mkTest+    (\l w -> makeContextM' l w toJSONSingleton) encodeText+    where+        toJSONSingleton = (:[]) . JSON.toJSON++encodeText :: JSON.ToJSON a => a -> Text+encodeText = Text.pack . LUTF8.toString . JSON.encode++mkTest :: (Monoid a, ToGVal (Run IO a) a)+       => ((VarName -> Run IO a (GVal (Run IO a))) -> (a -> IO ()) -> GingerContext IO a) -- ^ mkContextM flavor+       -> (a -> Text) -- ^ Convert a to Text for output+       -> [(VarName, GVal (Run IO a))] -- ^ Context dictionary+       -> [(FilePath, String)] -- ^ Lookup table for includes+       -> String -- ^ Template source+       -> Text -- ^ Expected output+       -> Assertion+mkTest mContext valToText contextDict includeLookup src expected = do+    let resolver srcName = return $ lookup srcName includeLookup+    template <- either (fail . show) return =<< parseGinger resolver Nothing src+    output <- newIORef mempty+    let write h = modifyIORef output (<> h)+    let context = mContext+                    (\key -> return $ fromMaybe def (lookup key contextDict))+                    write+    runGingerT context (optimize template)+    actual <- valToText <$> readIORef output+    assertEqual "" expected actual++loadSillyLocale :: IO (Maybe JSON.Value)+loadSillyLocale = do+    JSON.decode <$> LBS.readFile "test/fixtures/silly-locale.json"