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 +3/−1
- src/Text/Ginger/GVal.hs +4/−0
- src/Text/Ginger/Optimizer.hs +144/−4
- src/Text/Ginger/Run.hs +85/−4
- src/Text/Ginger/Run/Type.hs +30/−0
- test/Text/Ginger/PropertyTests.hs +145/−0
- test/Text/Ginger/SimulationTests.hs +831/−0
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 }}"+ "<"+ , testCase "multiple items" $ do+ mkTestHtml [] []+ "{{ escape('<', '>')|raw }}"+ "<>"+ ]+ , 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>.\" }}"+ "<foo bar="baz"/>amp is '&'</foo>."+ , testCase "raw filter bypasses encoding" $ do+ mkTestHtml [] []+ "{{ \"<a>&\"|raw }}"+ "<a>&"+ ]+ , 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"