diff --git a/ginger.cabal b/ginger.cabal
--- a/ginger.cabal
+++ b/ginger.cabal
@@ -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
diff --git a/src/Text/Ginger/GVal.hs b/src/Text/Ginger/GVal.hs
--- a/src/Text/Ginger/GVal.hs
+++ b/src/Text/Ginger/GVal.hs
@@ -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
diff --git a/src/Text/Ginger/Optimizer.hs b/src/Text/Ginger/Optimizer.hs
--- a/src/Text/Ginger/Optimizer.hs
+++ b/src/Text/Ginger/Optimizer.hs
@@ -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]
diff --git a/src/Text/Ginger/Run.hs b/src/Text/Ginger/Run.hs
--- a/src/Text/Ginger/Run.hs
+++ b/src/Text/Ginger/Run.hs
@@ -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
diff --git a/src/Text/Ginger/Run/Type.hs b/src/Text/Ginger/Run/Type.hs
--- a/src/Text/Ginger/Run/Type.hs
+++ b/src/Text/Ginger/Run/Type.hs
@@ -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
diff --git a/test/Text/Ginger/PropertyTests.hs b/test/Text/Ginger/PropertyTests.hs
new file mode 100644
--- /dev/null
+++ b/test/Text/Ginger/PropertyTests.hs
@@ -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
diff --git a/test/Text/Ginger/SimulationTests.hs b/test/Text/Ginger/SimulationTests.hs
new file mode 100644
--- /dev/null
+++ b/test/Text/Ginger/SimulationTests.hs
@@ -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"
