packages feed

ede-0.3.0.0: src/Text/EDE.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}

-- Module      : Text.EDE
-- Copyright   : (c) 2013-2020 Brendan Hay <brendan.g.hay@gmail.com>
-- License     : This Source Code Form is subject to the terms of
--               the Mozilla Public License, v. 2.0.
--               A copy of the MPL can be found in the LICENSE file or
--               you can obtain it at http://mozilla.org/MPL/2.0/.
-- Maintainer  : Brendan Hay <brendan.g.hay@gmail.com>
-- Stability   : experimental
-- Portability : non-portable (GHC extensions)

-- | A (mostly logicless) textual templating language with similar syntax to
-- <https://github.com/Shopify/liquid Liquid> or <http://jinja.pocoo.org/docs/ Jinja2>.
--
-- (ED-E is a character from Fallout New Vegas, pronounced 'Eddie'.)
module Text.EDE
  ( -- * How to use this library
    -- $usage

    -- * Parsing and Rendering
    -- $parsing_and_rendering
    Template,

    -- ** Parsing
    parse,
    parseIO,
    parseFile,
    parseFileWith,
    parseWith,

    -- ** Includes
    -- $resolvers
    Resolver,
    Id,
    includeMap,
    includeFile,

    -- ** Rendering
    render,
    renderWith,

    -- ** Either Variants
    eitherParse,
    eitherParseFile,
    eitherParseWith,
    eitherRender,
    eitherRenderWith,

    -- ** Results and Errors
    -- $results
    Delta (..),
    Result (..),
    eitherResult,
    result,
    success,
    failure,

    -- * Input
    -- $input
    fromValue,
    fromPairs,
    (.=),

    -- * Version
    version,

    -- * Syntax
    Delim,
    Syntax,
    delimPragma,
    delimInline,
    delimComment,
    delimBlock,
    defaultSyntax,
    alternateSyntax,

    -- ** Pragmas
    -- $pragmas

    -- ** Expressions
    -- $expressions

    -- ** Variables
    -- $variables

    -- ** Conditionals
    -- $conditionals

    -- ** Case Analysis
    -- $case

    -- ** Loops
    -- $loops

    -- ** Includes
    -- $includes

    -- ** Filters
    -- $filters

    -- ** Raw
    -- $raw

    -- ** Comments
    -- $comments

    -- ** Let Expressions
    -- $let
  )
where

import Control.Monad
import Data.Aeson ((.=))
import Data.Aeson.Types (Object)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.Foldable (foldrM)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as Map
import Data.List.NonEmpty (NonEmpty (..))
#if !MIN_VERSION_base(4,11,0)
import           Data.Semigroup
#endif
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Lazy as LText
import Data.Text.Lazy.Builder (toLazyText)
import Data.Text.Prettyprint.Doc (Pretty (..))
import Data.Version (Version)
import qualified Paths_ede as Paths
import System.Directory
import System.FilePath
import qualified Text.EDE.Internal.Eval as Eval
import qualified Text.EDE.Internal.Parser as Parser
import Text.EDE.Internal.Quoting (Term)
import Text.EDE.Internal.Syntax
import Text.EDE.Internal.Types hiding ((</>))
import Text.Trifecta.Delta

-- | ED-E Version.
version :: Version
version = Paths.version

-- | Parse Lazy 'LText.Text' into a compiled 'Template'.
--
-- Because this function is pure and does not resolve @include@s,
-- encountering an @include@ expression during parsing will result in an 'Error'.
--
-- See 'parseFile' or 'parseWith' for mechanisms to deal with @include@
-- dependencies.
parse ::
  -- | Strict 'ByteString' template definition.
  ByteString ->
  Result Template
parse = join . parseWith defaultSyntax (includeMap mempty) "Text.EDE.parse"

-- | Parse 'Text' into a compiled 'Template'.
--
-- This function handles all @include@ expressions as 'FilePath's and performs
-- recursive loading/parsing.
parseIO ::
  -- | Parent directory for relatively pathed includes.
  FilePath ->
  -- | Strict 'ByteString' template definition.
  ByteString ->
  IO (Result Template)
parseIO p = parseWith defaultSyntax (includeFile p) "Text.EDE.parse"

-- | Load and parse a 'Template' from a file.
--
-- This function handles all @include@ expressions as 'FilePath's and performs
-- recursive loading/parsing, with pathing of @include@s relatively to the
-- target (unless absolute paths are used).
parseFile ::
  -- | Path to the template to load and parse.
  FilePath ->
  IO (Result Template)
parseFile = parseFileWith defaultSyntax

-- | /See:/ 'parseFile'.
parseFileWith ::
  -- | Delimiters and parsing options.
  Syntax ->
  -- | Path to the template to load and parse.
  FilePath ->
  IO (Result Template)
parseFileWith s p =
  loadFile p
    >>= result
      failure
      (parseWith s (includeFile (takeDirectory p)) (Text.pack p))

-- | Parse a 'Template' from a Strict 'ByteString' using a custom function for
-- resolving @include@ expressions.
--
-- Two custom @include@ resolvers are supplied:
--
-- * 'includeMap'
--
-- * 'includeFile'
--
-- 'parseFile' for example, is defined as: 'parseWith' 'includeFile'.
parseWith ::
  Monad m =>
  -- | Delimiters and parsing options.
  Syntax ->
  -- | Function to resolve includes.
  Resolver m ->
  -- | Strict 'Text' name.
  Text ->
  -- | Strict 'ByteString' template definition.
  ByteString ->
  m (Result Template)
parseWith o f n = result failure resolve . Parser.runParser o n
  where
    resolve (u, is) = do
      r <- foldrM include (Success (Map.singleton n u)) (Map.toList is)
      result
        failure
        (success . Template n u)
        r

    -- Presuming self is always in self's includes, see singleton above.
    -- FIXME: utilise the list of deltas for failures
    include (_, _) (Failure e) = failure e
    include (k, d :| _) (Success ss) =
      f o k d
        >>= result failure (success . mappend ss . _tmplIncl)

-- | 'HashMap' resolver for @include@ expressions.
--
-- The 'identifier' component of the @include@ expression is treated as a lookup
-- key into the supplied 'HashMap'.
-- If the 'identifier' doesn't exist in the 'HashMap', an 'Error' is returned.
includeMap ::
  Monad m =>
  -- | A 'HashMap' of named 'Template's.
  HashMap Id Template ->
  -- | Resolver for 'parseWith'.
  Resolver m
includeMap ts _ k _
  | Just v <- Map.lookup k ts = success v
  | otherwise = failure ("unable to resolve " <> pretty (Text.unpack k))

-- FIXME: utilise deltas in error messages

-- | 'FilePath' resolver for @include@ expressions.
--
-- The 'identifier' component of the @include@ expression is treated as a relative
-- 'FilePath' and the template is loaded and parsed using 'parseFile'.
-- If the 'identifier' doesn't exist as a valid 'FilePath', an 'Error' is returned.
includeFile ::
  -- | Parent directory for relatively pathed includes.
  FilePath ->
  Resolver IO
includeFile p o k _ = loadFile f >>= result failure (parseWith o inc k)
  where
    inc = includeFile (takeDirectory f)

    f
      | Text.null k = Text.unpack k
      | otherwise = p </> Text.unpack k

loadFile :: FilePath -> IO (Result ByteString)
loadFile p = do
  e <- doesFileExist p
  if not e
    then failure ("file " <> pretty p <> " doesn't exist.")
    else BS.readFile p >>= success

-- | Render an 'Object' using the supplied 'Template'.
render ::
  -- | Parsed 'Template' to render.
  Template ->
  -- | Bindings to make available in the environment.
  Object ->
  Result LText.Text
render = renderWith mempty

-- | Render an 'Object' using the supplied 'Template'.
renderWith ::
  -- | Filters to make available in the environment.
  HashMap Id Term ->
  -- | Parsed 'Template' to render.
  Template ->
  -- | Bindings to make available in the environment.
  Object ->
  Result LText.Text
renderWith fs (Template _ u ts) = fmap toLazyText . Eval.render ts fs u

-- | /See:/ 'parse'
eitherParse :: ByteString -> Either String Template
eitherParse = eitherResult . parse

-- | /See:/ 'parseFile'
eitherParseFile :: FilePath -> IO (Either String Template)
eitherParseFile = fmap eitherResult . parseFile

-- | /See:/ 'parseWith'
eitherParseWith ::
  (Functor m, Monad m) =>
  Syntax ->
  Resolver m ->
  Text ->
  ByteString ->
  m (Either String Template)
eitherParseWith o f n = fmap eitherResult . parseWith o f n

-- | /See:/ 'render'
eitherRender ::
  Template ->
  Object ->
  Either String LText.Text
eitherRender t = eitherResult . render t

-- | /See:/ 'renderWith'
eitherRenderWith ::
  HashMap Id Term ->
  Template ->
  Object ->
  Either String LText.Text
eitherRenderWith fs t = eitherResult . renderWith fs t

-- $usage
--
-- A simple example of parsing and rendering 'Text' containing a basic conditional
-- expression and variable interpolation follows.
--
-- First the 'Template' is defined and parsed in the 'Result' monad:
--
-- >>> tmpl <- parse "{% if var %}\nHello, {{ var }}!\n{% else %}\nnegative!\n{% endif %}\n" :: Result Template
--
-- Then an 'Object' is defined containing the environment which will be
-- available to the 'Template' during rendering:
--
-- >>> let env = fromPairs [ "var" .= "World" ] :: Object
--
-- Note: the 'fromPairs' function above is a wrapper over Aeson's 'object'
-- which removes the outer 'Object' 'Value' constructor, exposing the underlying 'HashMap'.
--
-- Then, the 'Template' is rendered using the 'Object' environment:
--
-- >>> render tmpl env :: Result Text
-- > Success "Hello, World!"
--
-- In this manner, 'Template's can be pre-compiled to the internal AST and
-- the cost of parsing can be amortised if the same 'Template' is rendered multiple times.
--
-- Another example, this time rendering a 'Template' from a file:
--
-- > import qualified Data.Text.Lazy as LText
-- > import           Text.EDE
-- >
-- > main :: IO ()
-- > main = do
-- >     r <- eitherParseFile "template.ede"
-- >     either error print $ r >>= (`eitherRender` env)
-- >   where
-- >     env = fromPairs
-- >         [ "text" .= "Some Text."
-- >         , "int"  .= 1
-- >         , "list" .= [5..10]
-- >         ]
--
-- Please see the syntax section for more information about available
-- statements and expressions.

-- $parsing_and_rendering
--
-- Parsing and rendering require two separate steps intentionally so that the
-- more expensive (and potentially impure) action of parsing and resolving
-- @include@s can be embedded and re-used in a pure fashion.
--
-- * Parsing tokenises the input and converts it to an internal AST representation,
-- resolving @include@s using a custom function. The result is a compiled template
-- which can be cached for future use.
--
-- * Rendering takes a 'HashMap' of custom 'Fun's (functions available in the
-- template context), an 'Object' as the binding environment, and a parsed
-- 'Template' to subsitute the values into.
-- The result is a Lazy 'LText.Text' value containing the rendered output.

-- $resolvers
--
-- The 'Resolver' used to resolve @include@ expressions determines the purity
-- of 'Template' parsing.
--
-- For example, using the 'includeFile' 'Resolver' means parsing is restricted
-- to 'IO', while pre-caching a 'HashMap' of 'Template's and supplying them to
-- 'parseWith' using 'includeMap' offers a pure variant for @include@ resolution.

-- $results
--
-- The 'Result' of a 'parse' or 'render' steps can be inspected or analysed using
-- 'result' as follows:
--
-- >>> result failure success $ render tmpl env
--
-- If you're only interested in dealing with errors as strings, and the positional
-- information contained in 'Meta' is not of use you can use the convenience functions
-- 'eitherParse', 'eitherRender', or convert a 'Result' to 'Either' using 'eitherResult'.
--
-- >>> either failure success $ eitherParse tmpl

-- $input
--
-- 'fromPairs' (or 'fromValue') is a wrapper around Aeson's 'object' function which
--  safely strips the outer 'Value' constructor, providing the correct type
-- signature for input into 'render'.
--
-- It is used in combination with the re-exported '.=' as follows:
--
-- >>> render (fromPairs [ "foo" .= "value", "bar" .= 1 ]) :: Template -> Result Text

-- #syntax#
--

-- $pragmas
--
-- Syntax can be modified either via the arguments to 'parseWith' or alternatively
-- by specifying the delimiters via an @EDE_SYNTAX@ pragma.
--
-- /Note:/ The pragmas must start on line1. Subsequently encountered
-- pragmas are parsed as textual template contents.
--
-- For example:
--
-- > {! EDE_SYNTAX pragma=("{*", "*}") inline=("#@", "@#") comment=("<#", "#>") block=("$$", "$$") !}
-- > {* EDE_SYNTAX block=("#[", "]#")  *}
-- > ...
--
-- Would result in the following syntax:
--
-- * Pragmas: @{* ... *}@
--
-- * Inline: @\#\@ ... \@\#@
--
-- * Comment: @\<\# ... \#>@
--
-- * Block: @\#[ ... ]\#@
--
-- /Note:/ @EDE_SYNTAX@ pragmas only take effect for the current template, not
-- child includes. If you want to override the syntax for all templates use 'parseWith'
-- and custom 'Syntax' settings.

-- $expressions
--
-- Expressions behave as any simplistic programming language with a variety of
-- prefix, infix, and postifx operators available. (/See:/ "Text.EDE.Filters")
--
-- A rough overview of the expression grammar:
--
-- > expression ::= literal | identifier | '|' filter
-- > filter     ::= identifier
-- > identifier ::= [a-zA-Z_]{1}[0-9A-Za-z_']*
-- > object     ::= '{' pairs '}'
-- > pairs      ::= string ':' literal | string ':' literal ',' pairs
-- > array      ::= '[' elements ']'
-- > elements   ::= literal | literal ',' elements
-- > literal    ::= object | array | boolean | number | string
-- > boolean    ::= true | false
-- > number     ::= integer | double
-- > string     ::= "char+|escape"

--
-- /Note:/
--

-- * Identifiers are named similarly to Haskell's rules.

--

-- * Booleans are lowered cased.

--

-- * The string quoting and escaping follows Haskell's rules.

--

-- * The Numeric format shares the same characteristics as the <http://json.org/ JSON specification.>

-- $variables
--
-- Variables are substituted directly for their renderable representation.
-- An error is raised if the varaible being substituted is not a literal type
-- (ie. an 'Array' or 'Object') or doesn't exist in the supplied environment.
--
-- > {{ var }}
--
-- Nested variable access is also supported for variables which resolve to an 'Object'.
-- Dot delimiters are used to chain access through multiple nested 'Object's.
-- The right-most accessor must resolve to a renderable type as with the previous
-- non-nested variable access.
--
-- > {{ nested.var.access }}

-- $conditionals
--
-- A conditional is introduced and completed with the section syntax:
--
-- > {% if <expr1> %}
-- >    ... consequent expressions
-- > {% elif <expr2> %}
-- >    ... consequent expressions
-- > {% elif <expr3> %}
-- >    ... consequent expressions
-- > {% else %}
-- >    ... alternate expressions
-- > {% endif %}
--
-- The boolean result of the @expr@ determines the branch that is rendered by
-- the template with multiple (or none) elif branches supported, and the
-- else branch being optional.
--
-- In the case of a literal it conforms directly to the supported boolean or relation logical
-- operators from Haskell.
-- If a variable is singularly used its existence determines the result of the predicate;
-- the exception to this rule is boolean values which will be substituted into the
-- expression if they exist in the supplied environment.
--
-- The following logical expressions are supported as predicates in conditional statements
-- with parameters type checked and an error raised if the left and right
-- hand sides are not type equivalent.
--
-- * @And@: '&&'
--
-- * @Or@: '||'
--
-- * @Equal@: '=='
--
-- * @Not Equal@: @!=@ (/See:/ '/=')
--
-- * @Greater@: '>'
--
-- * @Greater Or Equal@: '>='
--
-- * @Less@: '<'
--
-- * @Less Or Equal@: '<='
--
-- * @Negation@: @!@ (/See:/ 'not')
--
-- /See:/ "Text.EDE.Filters"

-- $case
--
-- To pattern match a literal or variable, you can use the @case@ statement:
--
-- > {% case var %}
-- > {% when "a" %}
-- >    .. matched expressions
-- > {% when "b" %}
-- >    .. matched expressions
-- > {% else %}
-- >    .. alternate expressions
-- > {% endcase %}
--
-- Patterns take the form of @variables@, @literals@, or the wild-card
-- '@_@' pattern (which matches anything).

-- $loops
--
-- Iterating over an 'Array' or 'Object' can be acheived using the 'for ... in' section syntax.
-- Attempting to iterate over any other type will raise an error.
--
-- Example:
--
-- > {% for var in list %}
-- >     ... iteration expression
-- > {% else %}
-- >     ... alternate expression
-- > {% endfor %}
--
-- The iteration branch is rendering per item with the else branch being (which is optional)
-- being rendered if the @{{ list }}@ variable is empty.
--
-- When iterating over an 'Object', a stable sort using key equivalence is applied, 'Array's
-- are unmodified.
--
-- The resulting binding within the iteration expression (in this case, @{{ var }}@) is
-- an 'Object' containing the following keys:
--
-- * @key        :: Text@: They key if the loop target is an 'Object'
--
-- * @value      :: a@: The value of the loop target
--
-- * @loop       :: Object@: Loop metadata.
--
-- * @length     :: Int@: Length of the loop
--
-- * @index      :: Int@: Index of the iteration
--
-- * @index0     :: Int@: Zero based index of the iteration
--
-- * @remainder  :: Int@: Remaining number of iterations
--
-- * @remainder0 :: Int@: Zero based remaining number of iterations
--
-- * @first      :: Bool@: Is this the first iteration?
--
-- * @last       :: Bool@: Is this the last iteration?
--
-- * @odd        :: Bool@: Is this an odd iteration?
--
-- * @even       :: Bool@: Is this an even iteration?
--
-- For example:
--
-- > {% for item in items %}
-- >     {{ item.index }}:{{ item.value }}
-- >     {% if !item.last %}
-- >
-- >     {% endif %}
-- > {% endfor %}
--
-- Will render each item with its (1-based) loop index as a prefix, separated
-- by a blank newline, without a trailing at the end of the document.
--
-- Valid loop targets are 'Object's, 'Array's, and 'String's, with only 'Object's
-- having an available @{{ <var>.key }}@ in scope.

-- $includes
--
-- Includes are a way to reduce the amount of noise in large templates.
-- They can be used to abstract out common snippets and idioms into partials.
--
-- If 'parseFile' or the 'includeFile' resolver is used, templates will be loaded
-- using 'FilePath's. (This is the default.)
--
-- For example:
--
-- > {% include "/var/tmp/partial.ede" %}
--
-- Loads @partial.ede@ from the file system.
--
-- The current environment is made directly available to the included template.
-- Additional bindings can be created (/See:/ @let@) which will be additionally
-- available only within the include under a specific identifier:
--
-- > {% include "/var/tmp/partial.ede" with some_number = 123 %}
--
-- Includes can also be resolved using pure 'Resolver's such as 'includeMap',
-- which will treat the @include@ expression's identifier as a 'HashMap' key:
--
-- > {% include "arbitrary_key" %}
--
-- Uses 'Map.lookup' to find @arbitrary_key@ in the 'HashMap' supplied to 'includeMap'.

-- $filters
--
-- Filters are typed functions which can be applied to variables and literals.
-- An example of rendering a lower cased boolean would be:
--
-- > {{ true | show | lower }}
--
-- The input is on the LHS and chained filters (delimited by the pipe operator @|@)
-- are on the RHS, with filters being applied postfix, left associatively.
--
-- /See:/ "Text.EDE.Filters"

-- $raw
--
-- You can disable template processing for blocks of text using the @raw@ section:
--
-- > {% raw %}
-- > Some {{{ handlebars }}} or {{ mustache }} or {{ jinja2 }} output tags etc.
-- > {% endraw %}
--
-- This can be used to avoid parsing expressions which would otherwise be
-- considered valid @ED-E@ syntax.

-- $comments
--
-- Comments are ignored by the parser and omitted from the rendered output.
--
-- > {# singleline comment #}
--
-- > {#
-- >    multiline
-- >    comment
-- > #}

-- $let
--
-- You can also bind an identifier to values which will be available within
-- the following expression scope.
--
-- For example:
--
-- > {% let var = false %}
-- > ...
-- > {{ var }}
-- > ...
-- > {% endlet %}