diff --git a/bricks-parsec.cabal b/bricks-parsec.cabal
new file mode 100644
--- /dev/null
+++ b/bricks-parsec.cabal
@@ -0,0 +1,80 @@
+-- This file has been generated from package.yaml by hpack version 0.20.0.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: 2fff74c1e98a9b2d2205089727ad3ad2828ae83f37e1a4b5a35ebcaa79c68835
+
+name:           bricks-parsec
+version:        0.0.0.4
+synopsis:       ...
+
+description:    ...
+category:       Language
+homepage:       https://github.com/chris-martin/bricks#readme
+bug-reports:    https://github.com/chris-martin/bricks/issues
+author:         Chris Martin <ch.martin@gmail.com>
+maintainer:     Chris Martin <ch.martin@gmail.com>
+license:        Apache-2.0
+license-file:   license.txt
+build-type:     Simple
+cabal-version:  >= 1.10
+
+source-repository head
+  type: git
+  location: https://github.com/chris-martin/bricks
+
+library
+  hs-source-dirs:
+      src
+  ghc-options: -Wall
+  build-depends:
+      base >=4.9 && <4.11
+    , bricks-internal
+    , bricks-syntax
+    , containers >=0.5.7 && <0.6
+    , parsec >=3.1.6 && <3.2
+    , text >=1.2.2 && <1.3
+  exposed-modules:
+      Bricks.Parsec
+  other-modules:
+      Paths_bricks_parsec
+  default-language: Haskell2010
+
+test-suite cases
+  type: exitcode-stdio-1.0
+  main-is: cases.hs
+  hs-source-dirs:
+      test
+  ghc-options: -Wall -threaded
+  build-depends:
+      base >=4.9 && <4.11
+    , bricks-internal
+    , bricks-internal-test
+    , bricks-parsec
+    , bricks-rendering
+    , bricks-syntax
+    , containers >=0.5.7 && <0.6
+    , hedgehog >=0.5 && <0.6
+    , parsec >=3.1.6 && <3.2
+    , text >=1.2.2 && <1.3
+  other-modules:
+      Paths_bricks_parsec
+  default-language: Haskell2010
+
+test-suite doctest
+  type: exitcode-stdio-1.0
+  main-is: doctest.hs
+  hs-source-dirs:
+      test
+  ghc-options: -Wall -threaded
+  build-depends:
+      base >=4.9 && <4.11
+    , bricks-internal
+    , bricks-syntax
+    , containers >=0.5.7 && <0.6
+    , doctest >=0.11 && <0.14
+    , parsec >=3.1.6 && <3.2
+    , text >=1.2.2 && <1.3
+  other-modules:
+      Paths_bricks_parsec
+  default-language: Haskell2010
diff --git a/license.txt b/license.txt
new file mode 100644
--- /dev/null
+++ b/license.txt
@@ -0,0 +1,13 @@
+Copyright 2017 Chris Martin
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/src/Bricks/Parsec.hs b/src/Bricks/Parsec.hs
new file mode 100644
--- /dev/null
+++ b/src/Bricks/Parsec.hs
@@ -0,0 +1,952 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+
+Parsec 'Parser's for the Bricks language.
+
+Most parsers consume trailing whitespace, except ones that operate within quoted
+string environments where whitespace is significant.
+
+-}
+module Bricks.Parsec
+  (
+  -- * Expressions
+    parse'expression
+  , parse'expression'paren
+  , parse'expression'antiquote
+  , parse'expression'dictKey
+
+  -- * Expression lists
+  , parse'expressionList
+  , parse'expressionList'1
+  , parse'expressionList'1'noDot
+
+  -- * Variables
+  , parse'var
+
+  -- * Strings
+  , parse'strUnquoted
+  , parse'strStatic
+  , parse'strStatic'quoted
+  , parse'strStatic'unquoted
+  , parse'str'dynamic
+  , parse'str'within'normalQ
+  , parse'str'escape'normalQ
+  , parse'inStr
+  , parse'inStr'1
+
+  -- * Lists
+  , parse'list
+
+  -- * Dicts
+  , parse'dict
+  , parse'dict'rec
+  , parse'dict'noRec
+  , parse'dictBinding
+  , parse'dictBinding'inherit
+  , parse'dictBinding'eq
+
+  -- * Dict lookup
+  , parse'dot'rhs'chain
+
+  -- * Lambdas
+  , parse'lambda
+
+  -- * Function parameters
+  , parse'param
+  , parse'param'var
+  , parse'param'noVar
+  , parse'dictPattern
+  , parse'dictPattern'start
+
+  -- * @let@
+  , parse'let
+  , parse'letBinding
+  , parse'letBinding'eq
+  , parse'letBinding'inherit
+
+  -- * Comments and whitespace
+  , parse'spaces
+  , parse'comment
+  , parse'comment'inline
+  , parse'comment'block
+
+  -- * Keywords
+  , parse'keyword
+
+  ) where
+
+-- Bricks
+import Bricks.Expression
+import Bricks.Keyword
+import Bricks.Source
+import Bricks.UnquotedString
+
+-- Bricks internal
+import           Bricks.Internal.Prelude
+import           Bricks.Internal.Seq     (Seq, (|>))
+import qualified Bricks.Internal.Seq     as Seq
+import           Bricks.Internal.Text    (Text)
+import qualified Bricks.Internal.Text    as Text
+
+-- Parsec
+import           Text.Parsec      ((<?>))
+import qualified Text.Parsec      as P
+import           Text.Parsec.Text (Parser)
+
+-- Containers
+import           Data.Set (Set)
+import qualified Data.Set as Set
+
+-- Base
+import Control.Monad (fail)
+import Prelude       (fromIntegral, succ)
+
+-- $setup
+--
+-- ==== Doctest setup
+--
+-- >>> import Data.Foldable (length)
+-- >>> import Text.Parsec (parseTest)
+-- >>> import Prelude (putStrLn)
+
+parse'position :: Parser SourcePosition
+parse'position = do
+  x <- P.getPosition
+  pure SourcePosition
+    { sourcePosition'line = fromIntegral $ P.sourceLine x
+    , sourcePosition'column = fromIntegral $ P.sourceColumn x
+    }
+
+parse'spaces :: Parser ()
+parse'spaces =
+  void $ P.many (void (P.space <?> "") <|> parse'comment)
+
+parse'comment :: Parser ()
+parse'comment =
+  parse'comment'inline <|> parse'comment'block
+
+parse'comment'inline :: Parser ()
+parse'comment'inline =
+  void $ P.try (P.string "--" <?> "") *> P.manyTill P.anyChar (P.char '\n')
+
+parse'comment'block :: Parser ()
+parse'comment'block =
+  start <* P.manyTill middle end
+  where
+    start  = void $ P.try (P.string "{-" <?> "")
+    middle = parse'comment'block <|> void P.anyChar
+    end    = P.try (P.string "-}")
+
+{- | Backtracking parser for a particular keyword. -}
+
+parse'keyword :: Keyword -> Parser ()
+parse'keyword k =
+  P.try (void p)
+  where
+    p =
+      -- Consume the keyword
+      P.string (keywordString k) *>
+
+      -- Do /not/ consume any subsequent character that are allowed to be part
+      -- of a valid identifier. For example, this prevents this parser from
+      -- interpreting the beginning of an identifier named "letter" as the
+      -- keyword "let".
+      P.notFollowedBy (P.satisfy char'canBeUnquoted) *>
+
+      -- As usual, consume trailing spaces.
+      parse'spaces
+
+{- | Parser for an unquoted string. Unquoted strings are restricted to a
+conservative set of characters, and they may not be any of the keywords. See
+'text'canBeUnquoted' for a complete description of the unquoted string rules. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'strUnquoted "abc"
+-- ("abc",1:1-1:4)
+--
+-- Here the parser consumes letters up to but not including @{@, because that
+-- character does not satisfy 'char'canBeUnquoted':
+--
+-- >>> parseTest parse'strUnquoted "ab{c"
+-- ("ab",1:1-1:3)
+--
+-- \"let\" does not parse as an unquoted string because @let@ is a keyword:
+--
+-- >>> parseTest parse'strUnquoted "let"
+-- parse error at (line 1, column 4):
+-- unexpected end of input
+--
+-- This parser does /not/ parse quoted strings:
+--
+-- >>> parseTest parse'strUnquoted "\"abc\""
+-- parse error at (line 1, column 1):
+-- unexpected "\""
+
+parse'strUnquoted :: Parser (UnquotedString, SourceRange)
+parse'strUnquoted = do
+
+  pos'1 <- parse'position
+  text <- P.many1 (P.satisfy char'canBeUnquoted) <&> Text.pack
+  pos'2 <- parse'position
+
+  -- Fail if what we just parsed isn't a valid unquoted string
+  case unquotedString'try text of
+    Nothing -> P.parserZero
+    Just b  -> do
+      _ <- parse'spaces
+      pure (b, SourceRange pos'1 pos'2)
+
+parse'var :: Parser Var
+parse'var = do
+  (a, b) <- parse'strUnquoted
+  pure $ Var a (Just b)
+
+{- | Parser for a static string which may be either quoted or unquoted. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'strStatic "\"hello\""
+-- {- 1:1-1:8 -} "hello"
+--
+-- >>> parseTest parse'strStatic "hello"
+-- {- 1:1-1:6 -} "hello"
+--
+-- >>> parseTest parse'strStatic "\"a b\""
+-- {- 1:1-1:6 -} "a b"
+--
+-- >>> parseTest parse'strStatic "a b"
+-- {- 1:1-1:2 -} "a"
+--
+-- By "static," we mean that the string may /not/ contain antiquotation:
+--
+-- >>> parseTest parse'strStatic "\"a${x}b\" xyz"
+-- parse error at (line 1, column 5):
+-- antiquotation is not allowed in this context
+
+parse'strStatic :: Parser Str'Static
+parse'strStatic =
+  (parse'strStatic'quoted <|> parse'strStatic'unquoted) <?> "static string"
+
+{- | Parser for a static string that is quoted. -}
+
+parse'strStatic'quoted :: Parser Str'Static
+parse'strStatic'quoted =
+  do
+    pos'1 <- parse'position
+    _ <- P.char '"'
+    Str'Static text _ <- parse'str'within'normalQ
+    _ <- P.char '"' <|> anti
+    pos'2 <- parse'position
+    _ <- parse'spaces
+    let src = Just (SourceRange pos'1 pos'2)
+    pure $ Str'Static text src
+  where
+    anti = do
+      _ <- P.string "${"
+      fail "antiquotation is not allowed in this context"
+
+{- | Parser for an unquoted static string. -}
+
+parse'strStatic'unquoted :: Parser Str'Static
+parse'strStatic'unquoted = do
+  (a, b) <- parse'strUnquoted
+  pure $ Str'Static (unquotedString'text a) (Just b)
+
+{- | Parser for a dynamic string enclosed in quotes (@"@ ... @"@). -}
+
+parse'str'dynamic :: Parser Str'Dynamic
+parse'str'dynamic =
+  do
+    pos'1 <- parse'position
+    _ <- P.char '"'
+    xs <- go Seq.empty
+    pos'2 <- parse'position
+    _ <- parse'spaces
+    let src = Just (SourceRange pos'1 pos'2)
+    pure $ Str'Dynamic xs src
+  where
+    go :: Seq Str'1 -> Parser (Seq Str'1)
+    go previousParts =
+      asum
+        [ do
+            _ <- P.char '"'
+            pure previousParts
+        , do
+            x <- lit <|> (parse'expression'antiquote <&> Str'1'Antiquote)
+            go $ previousParts |> x
+        ]
+
+    -- Read some literal characters
+    lit :: Parser Str'1
+    lit = parse'str'within'normalQ <&> Str'1'Literal
+
+{- | Parser for at least one normal character, within a normally-quoted string
+context, up to but not including the end of the string or the start of an
+antiquotation. -}
+
+parse'str'within'normalQ :: Parser Str'Static
+parse'str'within'normalQ =
+  do
+    pos'1 <- parse'position
+    t <- text
+    pos'2 <- parse'position
+    let src = Just (SourceRange pos'1 pos'2)
+    pure $ Str'Static t src
+
+  where
+    text :: Parser Text
+    text = P.many1 (char <|> parse'str'escape'normalQ) <&> Text.concat
+
+    char :: Parser Text
+    char = asum
+      [ P.satisfy (\x -> x /= '$' && x /= '"' && x /= '\\')
+      , P.try $ P.char '$' <* P.notFollowedBy (P.char '{')
+      ] <&> Text.singleton
+
+parse'str'escape'normalQ :: Parser Text
+parse'str'escape'normalQ =
+  P.char '\\' *> esc
+  where
+    esc = asum
+      [ P.char '\\'   $> "\\"
+      , P.char '"'    $> "\""
+      , P.char 'n'    $> "\n"
+      , P.char 'r'    $> "\r"
+      , P.char 't'    $> "\t"
+      , P.string "${" $> "${"
+      ]
+
+{- | Parser for a dynamic string enclosed in "indented string" format, delimited
+by two single-quotes @''@ ... @''@.
+
+This form of string does not have any escape sequences. Therefore the only way
+to express @''@ or @${@ within an indented string is to antiquote them. -}
+
+-- | ==== Examples
+--
+-- >>> x = "''${\"''\"} and ${\"\\${\"}''"
+--
+-- >>> putStrLn x
+-- ''${"''"} and ${"\${"}''
+--
+-- >>> parseTest (inStr'discardSource <$> parse'inStr) x
+-- str'indented [indent 0 [antiquote (str ["''"]), " and ", antiquote (str ["${"])] Nothing]
+--
+-- >>> parseTest parse'inStr x
+-- {- 1:1-1:25 -} str'indented [indent {- 1:3-1:3 -} 0 [antiquote ({- 1:5-1:9 -} str [{- 1:6-1:8 -} "''"]), {- 1:10-1:15 -} " and ", antiquote ({- 1:17-1:22 -} str [{- 1:18-1:21 -} "${"])] Nothing]
+
+parse'inStr :: Parser InStr
+parse'inStr =
+  do
+    pos'1 <- parse'position
+    _ <- P.string "''"
+    lines <- go Seq.empty
+    pos'2 <- parse'position
+    _ <- parse'spaces
+    let src = Just (SourceRange pos'1 pos'2)
+    pure $ InStr lines src
+
+  where
+    go :: Seq InStr'1 -> Parser (Seq InStr'1)
+    go previousLines =
+      do
+        x <- parse'inStr'1
+        let newLines = previousLines |> x
+
+        if isJust (inStr'1'lineBreak x)
+          then go newLines
+          else P.string "''" $> newLines
+
+{- | Parser for a single line of an 'InStr'. -}
+
+parse'inStr'1 :: Parser InStr'1
+parse'inStr'1 =
+  do
+    pos'1 <- parse'position
+    n <- parse'count (P.char ' ')
+    pos'2 <- parse'position
+    (str, break) <- go Seq.empty
+    pure InStr'1
+      { inStr'1'level = n
+      , inStr'1'indentSource = Just (SourceRange pos'1 pos'2)
+      , inStr'1'str = str
+      , inStr'1'lineBreak = break
+      }
+  where
+    go :: Seq Str'1 -> Parser (Seq Str'1, Maybe Str'Static)
+    go previousParts =
+      asum
+        [ do
+            break <- end
+            pure (previousParts, break)
+        , do
+            x <- chars
+            go (previousParts |> x)
+        , do
+            x <- parse'expression'antiquote
+            go (previousParts |> Str'1'Antiquote x)
+        ]
+
+    end :: Parser (Maybe Str'Static)
+    end = asum
+      [ do
+          end'pos'1 <- parse'position
+          s <- P.char '\n'
+          end'pos'2 <- parse'position
+          pure $ Just $ Str'Static
+            { str'static'text = Text.singleton s
+            , str'static'source = Just (SourceRange end'pos'1 end'pos'2)
+            }
+      , do
+          _ <- P.lookAhead . P.try $ P.string "''"
+          pure Nothing
+      ]
+
+    chars :: Parser Str'1
+    chars = do
+      chars'pos'1 <- parse'position
+      s <- P.many1 $ asum
+        [ P.satisfy (\c -> c /= '$' && c /= '\'' && c /= '\n')
+        , P.try $ P.char '$'  <* P.notFollowedBy (P.char '{')
+        , P.try $ P.char '\'' <* P.notFollowedBy (P.char '\'')
+        ]
+      chars'pos'2 <- parse'position
+      pure $ Str'1'Literal $ Str'Static
+        { str'static'text = Text.pack s
+        , str'static'source = Just (SourceRange chars'pos'1 chars'pos'2)
+        }
+
+{- | Parser for a function parameter (the beginning of a 'Lambda'), including
+the colon. This forms part of 'parse'expression', so it backtracks in places
+where it has overlap with other types of expressions. -}
+
+parse'param :: Parser Param
+parse'param =
+  parse'param'var <|> parse'param'noVar
+
+{- | Parser for a parameter that starts with a variable. This could be a simple
+param that consists only of /only/ the variable, or the variable may be followed
+by a dict pattern. -}
+
+parse'param'var :: Parser Param
+parse'param'var = do
+  -- This part backtracks because until we get to the : or @, we don't
+  -- know whether the variable name we're reading is a lambda parameter
+  -- or just the name by itself (and not part of a lambda).
+  (a, b) <- P.try $ do
+    a <- parse'var <* parse'spaces
+    b <- ((P.char ':' $> False) <|> (P.char '@' $> True)) <* parse'spaces
+    pure (a, b)
+  if b
+    -- If we read an @, then the next thing is a pattern.
+    then parse'dictPattern <* P.char ':' <* parse'spaces <&> Param'Both a
+    -- Otherwise it's just the variable and we're done.
+    else pure $ Param'Name a
+
+{- | Parser for a param that has no variable, only a a dict pattern. This parser
+backtracks because the beginning of a dict pattern looks like the beginning of a
+dict expression. -}
+
+parse'param'noVar :: Parser Param
+parse'param'noVar = Param'DictPattern <$> do
+  -- First we look ahead to determine whether it looks like a lambda.
+  _ <- P.try . P.lookAhead $ parse'dictPattern'start
+
+  -- And if so, then we go on and parse the dict pattern with no
+  -- further backtracking.
+  parse'dictPattern <* P.char ':' <* parse'spaces
+
+{- | Parser for a dict pattern (the type of lambda parameter that does dict
+destructuring. This parser does not backtrack. -}
+
+parse'dictPattern :: Parser DictPattern
+parse'dictPattern =
+  P.char '{' *> parse'spaces *> go Seq.empty Set.empty
+  where
+
+    -- We keep track of what we've parsed so far in two forms:
+    go :: Seq DictPattern'1 -- 1. A sequence of items (which will be
+                            --    included directly in the result)
+       -> Set Text          -- 2. A set of the names of the items (which is
+                            --    used to test each new item so we can issue
+                            --    an error message if the list contains two
+                            --    items having the same name)
+       -> Parser DictPattern
+
+    go previousItems previousNames =
+      asum
+        [ end $> DictPattern previousItems False
+        , ellipsis $> DictPattern previousItems True
+        , more
+        ]
+      where
+        more :: Parser DictPattern
+        more = item >>= \newItem ->
+          let
+            newName = var'text (dictPattern'1'name newItem)
+            newItems = previousItems |> newItem
+            newNames = Set.insert newName previousNames
+
+          in
+            if newName `Set.member` previousNames
+            then fail $ "Name " <> Text.unpack newName <>
+                      " appears twice in a dict pattern"
+            else asum
+              [ P.char ',' *> parse'spaces *> go newItems newNames
+              , end $> DictPattern newItems False
+              ]
+
+    item :: Parser DictPattern'1
+    item = DictPattern'1 <$> parse'var <*> P.optionMaybe def
+
+    def :: Parser Expression
+    def = P.char '?' *> parse'spaces *> parse'expression
+
+    ellipsis :: Parser ()
+    ellipsis = P.string "..." *> parse'spaces *> end
+
+    end :: Parser ()
+    end = P.char '}' *> parse'spaces
+
+{- | This is used in a lookahead by 'parse'param' to determine whether we're
+about to start parsing a 'DictPattern'. -}
+
+parse'dictPattern'start :: Parser ()
+parse'dictPattern'start =
+  P.char '{' *> parse'spaces *> asum
+    [ void $ P.string "..."
+    , void $ P.char '}' *> parse'spaces *> P.char ':'
+    , void $ parse'strUnquoted *> (P.char ',' <|> P.char '?' <|> P.char '}')
+    ]
+
+{- | Parser for a lambda expression (@x: y@). -}
+
+-- | ==== Examples
+--
+-- >>> test = parseTest (lambda'discardSource <$> parse'lambda)
+--
+-- >>> test "x: [x x \"a\"]"
+-- lambda (param "x") (list [var "x", var "x", str ["a"]])
+--
+-- >>> test "{a,b}:a"
+-- lambda (pattern [dict'param "a", dict'param "b"]) (var "a")
+--
+-- >>> test "{ ... }: \"x\""
+-- lambda (pattern [] <> ellipsis) (str ["x"])
+--
+-- >>> test "a@{ f, b ? g x, ... }: f b"
+-- lambda (param "a" <> pattern [dict'param "f", dict'param "b" & def (apply (var "g") (var "x"))] <> ellipsis) (apply (var "f") (var "b"))
+--
+-- >>> test "a: b: \"x\""
+-- lambda (param "a") (lambda (param "b") (str ["x"]))
+
+parse'lambda :: Parser Lambda
+parse'lambda = do
+  pos'1 <- parse'position
+  head <- parse'param
+  body <- parse'expression
+  let pos'2 = sourceRange'end <$> expression'source body
+  let src = SourceRange pos'1 <$> pos'2
+  pure $ Lambda head body src
+
+{- | Parser for a list expression (@[ ... ]@). -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'list "[]"
+-- {- 1:1-1:3 -} list []
+--
+-- >>> :{
+-- >>> parseTest (list'discardSource <$> parse'list)
+-- >>>   "[x \"one\" (a: b) (c d)]"
+-- >>> :}
+-- list [var "x", str ["one"], lambda (param "a") (var "b"), apply (var "c") (var "d")]
+
+parse'list :: Parser List
+parse'list =
+  do
+    pos'1 <- parse'position
+    _ <- P.char '['
+    _ <- parse'spaces
+    x <- parse'expressionList
+    _ <- P.char ']'
+    pos'2 <- parse'position
+    _ <- parse'spaces
+    let src = (Just (SourceRange pos'1 pos'2))
+    pure $ List (Seq.fromList x) src
+
+{- | Parser for a dict expression, either recursive (@rec@ keyword) or not. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'dict "{}"
+-- {- 1:1-1:3 -} dict []
+--
+-- >>> parseTest parse'dict "rec { }"
+-- {- 1:1-1:8 -} rec'dict []
+--
+-- >>> :{
+-- >>> parseTest (dict'discardSource <$> parse'dict)
+-- >>>   "{ a = b; inherit (x) y z \"s t\"; }"
+-- >>> :}
+-- dict [dict'eq (str ["a"]) (var "b"), dict'inherit'from (var "x") ["y", "z", "s t"]]
+
+parse'dict :: Parser Dict
+parse'dict =
+  parse'dict'noRec <|> parse'dict'rec
+
+{- | Parser for a recursive (@rec@ keyword) dict. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'dict'rec "rec { }"
+-- {- 1:1-1:8 -} rec'dict []
+--
+-- >>> :{
+-- >>> parseTest (dict'discardSource <$> parse'dict'rec)
+-- >>>   "rec { a = \"1\"; b = \"${a}2\"; }"
+-- >>> :}
+-- rec'dict [dict'eq (str ["a"]) (str ["1"]), dict'eq (str ["b"]) (str [antiquote (var "a"), "2"])]
+
+parse'dict'rec :: Parser Dict
+parse'dict'rec =
+  do
+    pos'1 <- parse'position
+    _ <- parse'keyword keyword'rec
+    Dict _ xs src' <- parse'dict'noRec
+    let src = src' <&> \s -> s{ sourceRange'start = pos'1 }
+    pure $ Dict True xs src
+
+{- | Parser for a non-recursive (no @rec@ keyword) dict. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'dict'noRec "{ }"
+-- {- 1:1-1:4 -} dict []
+--
+-- >>> :{
+-- >>> parseTest (dict'discardSource <$> parse'dict'noRec)
+-- >>>   "{ a = \"1\"; b = \"${a}2\"; }"
+-- >>> :}
+-- dict [dict'eq (str ["a"]) (str ["1"]), dict'eq (str ["b"]) (str [antiquote (var "a"), "2"])]
+
+parse'dict'noRec :: Parser Dict
+parse'dict'noRec =
+  do
+    pos'1 <- parse'position
+    _ <- P.char '{'
+    _ <- parse'spaces
+    xs <- go Seq.empty
+    pos'2 <- parse'position
+    _ <- parse'spaces
+    let src = Just (SourceRange pos'1 pos'2)
+    pure $ Dict False xs src
+  where
+    go :: Seq DictBinding -> Parser (Seq DictBinding)
+    go previousBindings =
+      asum
+        [ do
+            _ <- P.char '}'
+            pure previousBindings
+        , do
+            a <- parse'dictBinding
+            go (previousBindings |> a)
+        ]
+
+{- | Parser for a chain of dict lookups (like @.a.b.c@) on the right-hand side
+of a 'Dot' expression. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'dot'rhs'chain ""
+-- []
+--
+-- >>> parseTest parse'dot'rhs'chain ".abc"
+-- [{- 1:2-1:5 -} str [{- 1:2-1:5 -} "abc"]]
+--
+-- >>> :{
+-- >>> parseTest (fmap expression'discardSource <$> parse'dot'rhs'chain)
+-- >>>   ".a.${b}.\"c\".\"d${e}\""
+-- >>> :}
+-- [str ["a"],var "b",str ["c"],str ["d", antiquote (var "e")]]
+
+parse'dot'rhs'chain :: Parser [Expression]
+parse'dot'rhs'chain =
+  P.many dot
+  where
+    dot = do
+      _ <- P.char '.'
+      _ <- parse'spaces
+      x <- parse'expression'dictKey
+      _ <- parse'spaces
+      pure x
+
+parse'let :: Parser Let
+parse'let =
+  do
+    pos'1 <- parse'position
+    _ <- parse'keyword keyword'let
+    (xs, e) <- go Seq.empty
+    let
+      src = do
+        pos'2 <- sourceRange'end <$> expression'source e
+        pure $ SourceRange pos'1 pos'2
+    pure $ Let xs e src
+  where
+    go :: Seq LetBinding -> Parser (Seq LetBinding, Expression)
+    go previousBindings =
+      asum
+        [ do
+            _ <- parse'keyword keyword'in
+            e <- parse'expression
+            pure (previousBindings, e)
+        , do
+            a <- parse'letBinding
+            go (previousBindings |> a)
+        ]
+
+parse'dictBinding :: Parser DictBinding
+parse'dictBinding =
+  parse'dictBinding'inherit <|> parse'dictBinding'eq
+
+parse'dictBinding'inherit :: Parser DictBinding
+parse'dictBinding'inherit =
+  do
+    _ <- parse'keyword keyword'inherit
+    asum
+      [ do
+          a <- parse'expression'paren
+          xs <- go'strs Seq.empty
+          pure $ DictBinding'Inherit'Dict a xs
+      , do
+          xs <- go'vars Seq.empty
+          pure $ DictBinding'Inherit'Var xs
+      ]
+  where
+    go'strs :: Seq Str'Static -> Parser (Seq Str'Static)
+    go'strs previousList =
+      asum
+        [ do
+            _ <- P.char ';'
+            _ <- parse'spaces
+            pure previousList
+        , do
+            x <- parse'strStatic
+            go'strs (previousList |> x)
+        ]
+
+    go'vars :: Seq Var -> Parser (Seq Var)
+    go'vars previousList =
+      asum
+        [ do
+            _ <- P.char ';'
+            _ <- parse'spaces
+            pure previousList
+        , do
+            x <- parse'var
+            go'vars (previousList |> x)
+        ]
+
+parse'dictBinding'eq :: Parser DictBinding
+parse'dictBinding'eq =
+  do
+    key <- parse'expression'dictKey
+    _ <- parse'spaces
+    _ <- P.char '='
+    _ <- parse'spaces
+    val <- parse'expression
+    _ <- parse'spaces
+    _ <- P.char ';'
+    _ <- parse'spaces
+    pure $ DictBinding'Eq key val
+
+parse'letBinding :: Parser LetBinding
+parse'letBinding =
+  parse'letBinding'inherit <|> parse'letBinding'eq
+
+parse'letBinding'eq :: Parser LetBinding
+parse'letBinding'eq =
+  do
+    key <- parse'var
+    _ <- parse'spaces
+    _ <- P.char '='
+    _ <- parse'spaces
+    val <- parse'expression
+    _ <- parse'spaces
+    _ <- P.char ';'
+    _ <- parse'spaces
+    pure $ LetBinding'Eq key val
+
+parse'letBinding'inherit :: Parser LetBinding
+parse'letBinding'inherit =
+  do
+    _ <- parse'keyword keyword'inherit
+    a <- parse'expression'paren
+    xs <- go Seq.empty
+    pure $ LetBinding'Inherit a xs
+  where
+    go :: Seq Var -> Parser (Seq Var)
+    go previousList =
+      asum
+        [ do
+            _ <- P.char ';'
+            _ <- parse'spaces
+            pure previousList
+        , do
+            x <- parse'var
+            go (previousList |> x)
+        ]
+
+{- | The primary, top-level expression parser. This is what you use to parse a
+@.nix@ file. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'expression ""
+-- parse error at (line 1, column 1):
+-- unexpected end of input
+-- expecting expression
+
+parse'expression :: Parser Expression
+parse'expression =
+  p <?> "expression"
+  where
+    p = asum
+      [ parse'let    <&> Expr'Let
+      , parse'lambda <&> Expr'Lambda
+      , list
+      ]
+    list = parse'expressionList >>= \case
+      [] -> P.parserZero
+      f : args -> pure $ expression'applyArgs f args
+
+{- | Parser for a list of expressions in a list literal (@[ x y z ]@) or in a
+chain of function arguments (@f x y z@). -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'expressionList ""
+-- []
+--
+-- >>> parseTest (length <$> parse'expressionList) "x \"one two\" (a: b) (c d)"
+-- 4
+--
+-- >>> parseTest (length <$> parse'expressionList) "(x \"one two\" (a: b) (c d))"
+-- 1
+
+parse'expressionList :: Parser [Expression]
+parse'expressionList =
+  P.many parse'expressionList'1 <?> "expression list"
+
+{- | Parser for a single item within an expression list ('expressionListP').
+This expression is not a lambda, a function application, a @let@-@in@
+expression, or a @with@ expression. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'expressionList'1 "ab.xy"
+-- {- 1:1-1:6 -} dot ({- 1:1-1:3 -} var "ab") ({- 1:4-1:6 -} str [{- 1:4-1:6 -} "xy"])
+--
+-- >>> :{
+-- >>> parseTest (expression'discardSource <$> parse'expressionList'1)
+-- >>>   "(x: f x x) y z"
+-- >>> :}
+-- lambda (param "x") (apply (apply (var "f") (var "x")) (var "x"))
+--
+-- >>> :{
+-- >>> parseTest (expression'discardSource <$> parse'expressionList'1)
+-- >>>   "{ a = b; }.a y"
+-- >>> :}
+-- dot (dict [dict'eq (str ["a"]) (var "b")]) (str ["a"])
+
+parse'expressionList'1 :: Parser Expression
+parse'expressionList'1 =
+  expression'applyDots
+    <$> parse'expressionList'1'noDot
+    <*> parse'dot'rhs'chain
+    <?> "expression list item"
+
+{- | Like 'parse'expressionList'1', but with the further restriction that the
+expression may not be a 'Dot'. -}
+
+-- | ==== Examples
+--
+-- >>> parseTest parse'expressionList'1'noDot "ab.xy"
+-- {- 1:1-1:3 -} var "ab"
+--
+-- >>> :{
+-- >>> parseTest (expression'discardSource <$> parse'expressionList'1'noDot)
+-- >>>   "(x: f x x) y z"
+-- >>> :}
+-- lambda (param "x") (apply (apply (var "f") (var "x")) (var "x"))
+--
+-- >>> :{
+-- >>> parseTest (expression'discardSource <$> parse'expressionList'1'noDot)
+-- >>>   "{ a = b; }.a y"
+-- >>> :}
+-- dict [dict'eq (str ["a"]) (var "b")]
+
+parse'expressionList'1'noDot :: Parser Expression
+parse'expressionList'1'noDot =
+  asum
+    [ parse'str'dynamic <&> Expr'Str
+    , parse'inStr       <&> Expr'Str'Indented
+    , parse'list        <&> Expr'List
+    , parse'dict        <&> Expr'Dict
+    , parse'var         <&> Expr'Var
+    , parse'expression'paren
+    ]
+    <?> "expression list item without a dot"
+
+{- | Parser for a parenthesized expression, from opening parenthesis to closing
+parenthesis. -}
+
+parse'expression'paren :: Parser Expression
+parse'expression'paren =
+  P.char '(' *> parse'spaces *> parse'expression <* P.char ')' <* parse'spaces
+
+parse'expression'antiquote :: Parser Expression
+parse'expression'antiquote =
+  P.try (P.string "${") *> parse'spaces *> parse'expression <* P.char '}'
+
+{- | Parser for an expression in a context that is expecting a dict key.
+
+One of:
+
+  - an unquoted string
+  - a quoted dynamic string
+  - an arbitrary expression wrapped in antiquotes (@${@ ... @}@) -}
+
+parse'expression'dictKey :: Parser Expression
+parse'expression'dictKey =
+  quoted <|> antiquoted <|> unquoted
+
+  where
+    quoted :: Parser Expression
+    quoted = parse'str'dynamic <&> Expr'Str
+
+    antiquoted :: Parser Expression
+    antiquoted = do
+      _ <- P.string "${"
+      _ <- parse'spaces
+      e <- parse'expression
+      _ <- P.char '}'
+      _ <- parse'spaces
+      pure e
+
+    unquoted :: Parser Expression
+    unquoted = do
+      (x, src) <- parse'strUnquoted
+      pure $ Expr'Str $ str'static'to'dynamic $
+        Str'Static (unquotedString'text x) (Just src)
+
+parse'count :: Parser a -> Parser Natural
+parse'count p =
+  go 0
+  where
+    go :: Natural -> Parser Natural
+    go n = (p *> go (succ n)) <|> pure n
diff --git a/test/cases.hs b/test/cases.hs
new file mode 100644
--- /dev/null
+++ b/test/cases.hs
@@ -0,0 +1,641 @@
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes       #-}
+{-# LANGUAGE TemplateHaskell   #-}
+
+-- Bricks
+import Bricks.Expression
+import Bricks.Parsec
+import Bricks.Rendering
+
+-- Bricks internal
+import           Bricks.Internal.Prelude
+import qualified Bricks.Internal.Seq     as Seq
+import           Bricks.Internal.Text    (Text)
+import qualified Bricks.Internal.Text    as Text
+
+-- Bricks test
+import Bricks.Test.Hedgehog
+import Bricks.Test.QQ
+
+-- Parsec
+import qualified Text.Parsec      as P
+import           Text.Parsec.Text (Parser)
+
+-- Hedgehog
+import           Hedgehog (Property, property, withTests, (===))
+import qualified Hedgehog
+
+-- Base
+import System.IO (IO)
+import Text.Show (show)
+
+main :: IO ()
+main = runTests $$(Hedgehog.discover)
+
+{- | We'll use the @parseTest@ function a lot to test parsers. It's a bit like
+'P.parseTest' from the Parsec library, but it works on parsers of type 'Text'
+rather than @'Show' a => a@. It also prints the unparsed input so we can verify
+that our parser consumes the right amount of input, and it prints a message if
+the parser fails and consumes input. -}
+parseTest :: Parser Text -> Text -> Text
+parseTest p input =
+  Text.intercalate "\n" $
+  catMaybes
+
+    [ Just $ case P.parse p "" input of
+        Left err ->
+          "Parse error at " <>
+          Text.replace "\n" "\n - " (Text.pack (show err))
+        Right x -> x
+
+    , let
+        p' = do
+          _ <- p
+          r <- P.many1 P.anyChar
+          pure $ "Remaining input: " <> Text.pack (show r)
+      in
+        case P.parse p' "" input of
+          Left _  -> Nothing
+          Right x -> Just x
+
+    , case P.parse (void p <|> pure ()) "" input of
+        Left _  -> Just "Parser failed and consumed input"
+        Right _ -> Nothing
+
+    ]
+
+prop_parse_str_unquoted :: Property
+prop_parse_str_unquoted = withTests 1 $ property $ do
+
+  let test = parseTest $ fmap var'text $ parse'var
+
+  test "-ab_c" === [text|-ab_c|]
+
+  test ""      === [text|┃Parse error at (line 1, column 1):
+                         ┃ - unexpected end of input|]
+
+  test "a\"b"  === [text|┃a
+                         ┃Remaining input: "\"b"|]
+
+  test "a b"   === [text|┃a
+                         ┃Remaining input: "b"|]
+
+  -- The unquoted string parser doesn't backtrack.
+  -- Note how in this example it fails and consumes input.
+  test "rec { }" === [text|┃Parse error at (line 1, column 4):
+                           ┃ - unexpected " "
+                           ┃Parser failed and consumed input|]
+
+prop_parse_expression_dictKey :: Property
+prop_parse_expression_dictKey = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (render'expression renderContext'default) $
+      parse'expression'dictKey
+
+  test "a"      === [text|"a"|]
+
+  test "\"a\""  === [text|"a"|]
+
+  test "a b"    === [text|┃"a"
+                          ┃Remaining input: "b"|]
+
+  test "${a.b}" === [text|a.b|]
+
+prop_parse_strDynamic :: Property
+prop_parse_strDynamic = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (render'strDynamic'quoted renderContext'default) $
+      parse'str'dynamic
+
+  test "\"a\""        === [text|"a"|]
+
+  test "\"a\" x"      === [text|┃"a"
+                                ┃Remaining input: "x"|]
+
+  test "\"a ${b} c\"" === [text|"a ${b} c"|]
+
+  test "\"a${ b }c\"" === [text|"a${b}c"|]
+
+  test "\"$\""        === [text|"$"|]
+
+  test "\"a$\""       === [text|"a$"|]
+
+  test "\"\\${\""     === [text|"\${"|]
+
+  test "\"a\\${\""    === [text|"a\${"|]
+
+prop_parse_strIndented :: Property
+prop_parse_strIndented = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $ fmap render $
+      P.spaces *> parse'inStr
+
+    render :: InStr -> Text
+    render =
+      Text.intercalateMap "\n" render'1 . inStr'toSeq
+
+    render'1 :: InStr'1 -> Text
+    render'1 x =
+      Text.show (inStr'1'level x) <> " [" <>
+      Text.intercalateMap ", "
+        (\s ->
+          render'strDynamic'quoted renderContext'default $
+          Str'Dynamic (Seq.singleton s) Nothing
+        )
+        (inStr'1'str x) <>
+      "] " <> Text.show (isJust (inStr'1'lineBreak x))
+
+  test "''hello''x"  === [text|┃0 ["hello"] False
+                               ┃Remaining input: "x"|]
+
+  test "''hello'' x" === [text|┃0 ["hello"] False
+                               ┃Remaining input: "x"|]
+
+  test [text|┃  ''
+             ┃    one
+             ┃    two
+             ┃  ''x|] === [text|┃0 [] True
+                                ┃4 ["one"] True
+                                ┃4 ["two"] True
+                                ┃2 [] False
+                                ┃Remaining input: "x"|]
+
+  test [text|┃  ''
+             ┃    one
+             ┃
+             ┃    two
+             ┃  ''x|] === [text|┃0 [] True
+                                ┃4 ["one"] True
+                                ┃0 [] True
+                                ┃4 ["two"] True
+                                ┃2 [] False
+                                ┃Remaining input: "x"|]
+
+  test "'''' x"   === [text|┃0 [] False
+                            ┃Remaining input: "x"|]
+
+  test "''abc''"  === [text|0 ["abc"] False|]
+
+  test [text|┃''
+             ┃''|]
+    === [text|┃0 [] True
+              ┃0 [] False|]
+
+  test [text|┃''   abc
+             ┃def''|]
+    === [text|┃3 ["abc"] True
+              ┃0 ["def"] False|]
+
+prop_parse_dict_pattern_start :: Property
+prop_parse_dict_pattern_start = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      (P.try parse'dictPattern'start $> "yes") <|> pure "no"
+
+  test "{a, b}:"     === [text|┃yes
+                               ┃Remaining input: " b}:"|]
+
+  test "{a ? 4, b}:" === [text|┃yes
+                               ┃Remaining input: " 4, b}:"|]
+
+  test "{ }: x"      === [text|┃yes
+                               ┃Remaining input: " x"|]
+
+  -- { } is not enough to determine whether we're parsing a dict param, because
+  -- if it isn't followed by a colon, then it's actually an empty dict literal.
+  test "{ } x"       === [text|┃no
+                               ┃Remaining input: "{ } x"|]
+
+  test "{ ... }:"    === [text|┃yes
+                               ┃Remaining input: " }:"|]
+
+prop_parse_dict_pattern :: Property
+prop_parse_dict_pattern = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (render'dictPattern renderContext'default) $
+      parse'dictPattern
+
+  test "{}"               === [text|{ }|]
+  test "{...}"            === [text|{ ... }|]
+  test "{a}"              === [text|{ a }|]
+  test "{a , b}"          === [text|{ a, b }|]
+  test "{a , b ? c, ...}" === [text|{ a, b ? c, ... }|]
+
+prop_parse_dot_rhs_chain :: Property
+prop_parse_dot_rhs_chain = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (
+        Text.intercalate "\n" .
+        fmap (render'expression renderContext'default)
+      ) $
+      parse'dot'rhs'chain
+
+  -- The dots parser /does/ match the empty string.
+  test ""         === [text||]
+
+  -- The simplest nonempty dot list
+  test ".a"       === [text|"a"|]
+
+  -- The dots parser consumes any trailing whitespace beyond the dot list.
+  test ".a "      === [text|"a"|]
+
+  -- Dot attributes are usually unquoted strings, but they may also be quoted.
+  test ".\"a\""   === [text|"a"|]
+  test ". \"a\""  === [text|"a"|]
+
+  -- Here we throw some extra whitespace into the middle, which makes no
+  -- difference, and some extra stuff onto the end, which does not get consumed.
+  test ".a . b c" === [text|┃"a"
+                            ┃"b"
+                            ┃Remaining input: "c"|]
+
+  -- Another example of a quoted dot, this time following an unquoted dot
+  test ".a.\"b\"" === [text|┃"a"
+                            ┃"b"|]
+
+  -- If quotes or braces are involved, the stuff that follows a dot expression
+  -- can directly abut it with no whitespace in between.
+  test [text|.a."b"x|] === [text|┃"a"
+                                 ┃"b"
+                                 ┃Remaining input: "x"|]
+
+  test [text|.a.b"x"|] === [text|┃"a"
+                                 ┃"b"
+                                 ┃Remaining input: "\"x\""|]
+
+  test ".a.b(x)"   === [text|┃"a"
+                             ┃"b"
+                             ┃Remaining input: "(x)"|]
+
+  test ". a . b"   === [text|┃"a"
+                             ┃"b"|]
+
+  test ". \"a\".b" === [text|┃"a"
+                             ┃"b"|]
+
+  test ". \"a\".${b}" === [text|┃"a"
+                                ┃b|]
+
+prop_parse_expression :: Property
+prop_parse_expression = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (render'expression renderContext'default) $
+      parse'expression
+
+  -- The empty string is /not/ a valid expression.
+  test "" === [text|┃Parse error at (line 1, column 1):
+                    ┃ - unexpected end of input
+                    ┃ - expecting expression|]
+
+  -- A very simple expression: a one-letter variable
+  test "a"         === [text|a|]
+
+  -- Parsing an expression consumes any subsequent whitespace.
+  test "a "        === [text|a|]
+
+  -- When there are multiple expressions, that is parsed as a function call.
+  test "f x"       === [text|f x|]
+
+  -- Expressions can directly abut each other, so it's important that the
+  -- expression parser is also able to read an expression even when another
+  -- expression directly follows it.
+  test "f[x y]"    === [text|┃f [
+                             ┃  x
+                             ┃  y
+                             ┃]|]
+
+  -- A simple example of parsing a dot expression
+  test "a.b"       === [text|a.b|]
+
+  -- Dot parsing also consumes trailing whitespace.
+  test "a.b "      === [text|a.b|]
+
+  -- It looks odd when a subsequent expression appears after a dot expression
+  -- with no whitespace, but it is permitted.
+  test "a.b\"c\""  === [text|a.b "c"|]
+
+  -- A simple list example
+  test "[ a b ]"   === [text|┃[
+                             ┃  a
+                             ┃  b
+                             ┃]|]
+
+  -- A list with trailing whitespace that get consumed
+  test "[ a b ] "  === [text|┃[
+                             ┃  a
+                             ┃  b
+                             ┃]|]
+
+  -- A list that is in the left-hand side of a function call. This will fail at
+  -- runtime if the call is evaluated, because a list is not a function, but it
+  -- should /parse/ successfully.
+  test "[ a b ] x" === [text|┃[
+                             ┃  a
+                             ┃  b
+                             ┃] x|]
+
+  -- The same thing with other weird stuff on the left-hand side of a function
+  -- call.
+  test "{ a = b; } x" === [text|┃{
+                                ┃  a = b;
+                                ┃} x|]
+
+  test "{ } x"        === [text|{ } x|]
+
+  -- Note that the case where an empty dict is on the left-hand side of a
+  -- function call looks very similar to the case where a function expression
+  -- using dict deconstruction with no bindings. The only difference is the
+  -- colon.
+  test "{ }: x"      === [text|┃{ }:
+                               ┃x|]
+
+  -- A list with a function call inside
+  test "[ (f x) ]"   === [text|┃[
+                               ┃  (f x)
+                               ┃]|]
+
+  test "[ a (f x) ]" === [text|┃[
+                               ┃  a
+                               ┃  (f x)
+                               ┃]|]
+
+  -- A minimal dict literal
+  test "{ x = y; }"  === [text|┃{
+                               ┃  x = y;
+                               ┃}|]
+
+  -- The left-hand side of a dict binding is allowed to be any expression.
+  test "{ \"a b\" = y; }"  === [text|┃{
+                                     ┃  "a b" = y;
+                                     ┃}|]
+  test "{ ${x} = y; }"     === [text|┃{
+                                     ┃  ${x} = y;
+                                     ┃}|]
+
+  -- It may even be the empty string.
+  test "{ \"\" = y; }"     === [text|┃{
+                                     ┃  "" = y;
+                                     ┃}|]
+
+  -- None of the conventional whitespace within a dict literal is mandatory.
+  test "{x=y;}"            === [text|┃{
+                                     ┃  x = y;
+                                     ┃}|]
+
+  -- A simple dict literal with two bindings
+  test "{ x = y; a = b; }" === [text|┃{
+                                     ┃  x = y;
+                                     ┃  a = b;
+                                     ┃}|]
+
+  -- The same thing without any whitespace
+  test "{x=y;a=b;}" === [text|┃{
+                              ┃  x = y;
+                              ┃  a = b;
+                              ┃}|]
+
+  -- Dicts with 'inherit' bindings
+  test "{ inherit a; }"       === [text|┃{
+                                        ┃  inherit a;
+                                        ┃}|]
+  test "{ inherit a b; }"     === [text|┃{
+                                        ┃  inherit a b;
+                                        ┃}|]
+  test "{ inherit (x) a b; }" === [text|┃{
+                                        ┃  inherit (x) a b;
+                                        ┃}|]
+
+  -- An inherit binding can be empty, although it is weird.
+  test "{ inherit; }"   === [text|┃{
+                                  ┃  inherit;
+                                  ┃}|]
+
+  -- A simple function
+  test "x : y" === [text|┃x:
+                         ┃y|]
+
+  -- Whitespace before the colon is unconventional, but allowed.
+  test "x : y" === [text|┃x:
+                         ┃y|]
+
+  -- The space after the colon is not mandatory. (In Nix, this example would be
+  -- parsed as the string "x:y", but here we do not support URI literals.)
+  test "x:y"   === [text|┃x:
+                         ┃y|]
+
+  -- A slightly bigger example where we're starting to nest more things
+  test "[ \"abc\" f { x = y; } ]"
+    === [text|┃[
+              ┃  "abc"
+              ┃  f
+              ┃  {
+              ┃    x = y;
+              ┃  }
+              ┃]|]
+
+  test "[ \"abc\" (f { x = y; }) ]"
+    === [text|┃[
+              ┃  "abc"
+              ┃  (f {
+              ┃    x = y;
+              ┃  })
+              ┃]|]
+
+  -- This is not valid a expression (though the first bit of it is).
+  test "a b: c"   === [text|┃a b
+                            ┃Remaining input: ": c"|]
+
+  -- This is not a valid expression.
+  test "(a b: c)" === [text|┃Parse error at (line 1, column 5):
+                            ┃ - unexpected ":"
+                            ┃ - expecting expression list item or ")"
+                            ┃Parser failed and consumed input|]
+
+  -- Some functions that use dict deconstruction
+  test "{ a, b, c ? x, ... }: g b (f a c)"
+    === [text|┃{ a, b, c ? x, ... }:
+              ┃g b (f a c)|]
+
+  test "{ x, ... }: f x"    === [text|┃{ x, ... }:
+                                      ┃f x|]
+  test "{ x?\"abc\" }: x"   === [text|┃{ x ? "abc" }:
+                                      ┃x|]
+  test "{ ... }: x"         === [text|┃{ ... }:
+                                      ┃x|]
+  test "a@{ x, ... }: f x"  === [text|┃a@{ x, ... }:
+                                      ┃f x|]
+  test "a@{ x?\"abc\" }: x" === [text|┃a@{ x ? "abc" }:
+                                      ┃x|]
+  test "a@{ ... }: x"       === [text|┃a@{ ... }:
+                                      ┃x|]
+
+  -- A let expression
+  test "let f = x: plus one x; in f seven"
+    === [text|┃let
+              ┃  f = x:
+              ┃    plus one x;
+              ┃in
+              ┃  f seven|]
+
+  -- A let binding list may be empty, although it is silly.
+  test "let in f x"
+    === [text|┃let
+              ┃in
+              ┃  f x|]
+
+  -- Indented strings do not support any escape sequences.
+  test [text|┃''
+             ┃  There \ is \n no \$ escape.
+             ┃''|]
+    === [text|┃''
+              ┃  There \\ is \\n no \\$ escape.
+              ┃''|]
+
+  -- Therefore if you want to include something like '' in an indented string,
+  -- you have to put it inside an antiquote.
+  test [text|┃''
+             ┃  Isn't it
+             ┃  ${"''"}interesting
+             ┃''|]
+    === [text|┃''
+              ┃  Isn't it
+              ┃  ${"''"}interesting
+              ┃''|]
+
+  -- Comments
+  test [text|┃let                -- hi
+             ┃  x {- ! -} = "a"; -- yep
+             ┃ in                -- lol
+             ┃   f x
+             ┃|]
+    === [text|┃let
+              ┃  x = "a";
+              ┃in
+              ┃  f x|]
+
+  -- Nested block comments
+  test [text|┃f{- a
+             ┃  -- b
+             ┃    {- c {- d
+             ┃    -}-} e
+             ┃  -}x|]
+    === "f x"
+
+prop_parse_expression_list :: Property
+prop_parse_expression_list = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (
+        Text.intercalate "\n" .
+        fmap (render'expression renderContext'default)
+      ) $
+      parse'expressionList
+
+  test ""                       === [text||]
+
+  test "x y z"                  === [text|┃x
+                                          ┃y
+                                          ┃z|]
+
+
+  test "(a)b c(d)"              === [text|┃a
+                                          ┃b
+                                          ┃c
+                                          ┃d|]
+
+  test "a.\"b\"c"               === [text|┃a.b
+                                          ┃c|]
+
+  test "\"abc\" (f { x = y; })" === [text|┃"abc"
+                                          ┃f {
+                                          ┃  x = y;
+                                          ┃}|]
+
+  -- Parsing lists of variables that are similar to keywords
+  test "r re reck"              === [text|┃r
+                                          ┃re
+                                          ┃reck|]
+  test "r re rec { } reck"      === [text|┃r
+                                          ┃re
+                                          ┃rec { }
+                                          ┃reck|]
+  test "l le lets"              === [text|┃l
+                                          ┃le
+                                          ┃lets|]
+  test "i ins"                  === [text|┃i
+                                          ┃ins|]
+  test "inheri inherits"        === [text|┃inheri
+                                          ┃inherits|]
+
+prop_parse_expression_list_item :: Property
+prop_parse_expression_list_item = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (render'expression renderContext'default) $
+      parse'expressionList'1
+
+  test "abc def"  === [text|┃abc
+                            ┃Remaining input: "def"|]
+  test "a.b c"    === [text|┃a.b
+                            ┃Remaining input: "c"|]
+  test "a.\"b\"c" === [text|┃a.b
+                            ┃Remaining input: "c"|]
+  test "(a.b)c"   === [text|┃a.b
+                            ┃Remaining input: "c"|]
+  test "a.b(c)"   === [text|┃a.b
+                            ┃Remaining input: "(c)"|]
+  test "[ a b ]c" === [text|┃[
+                            ┃  a
+                            ┃  b
+                            ┃]
+                            ┃Remaining input: "c"|]
+  test "a[ b c ]" === [text|┃a
+                            ┃Remaining input: "[ b c ]"|]
+  test "\"a\"b"   === [text|┃"a"
+                            ┃Remaining input: "b"|]
+
+prop_parse_expression_list_item_no_dot :: Property
+prop_parse_expression_list_item_no_dot = withTests 1 $ property $ do
+
+  let
+    test :: Text -> Text
+    test =
+      parseTest $
+      fmap (render'expression renderContext'default) $
+      parse'expressionList'1'noDot
+
+  test "a.b c" === [text|┃a
+                         ┃Remaining input: ".b c"|]
diff --git a/test/doctest.hs b/test/doctest.hs
new file mode 100644
--- /dev/null
+++ b/test/doctest.hs
@@ -0,0 +1,4 @@
+import Test.DocTest (doctest)
+
+main :: IO ()
+main = doctest ["src", "-XOverloadedStrings"]
