diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,3 @@
+# Changelog for interp
+
+## Unreleased changes
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright sam raker (c) 2019
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of sam raker nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+# interp
+
+A randomized text-interpolation tool inspired by [Tracery](https://tracery.io), in Haskell.
+
+### Installation
+#### Prerequisites
+  * Haskell
+  * [Stack](https://www.haskellstack.org), unless you can work Cabal yourself
+
+Clone this repo, `cd` into it, and then:
+  * if you want to use the CLI, run `stack install`
+  * if you want to use it as a library, just run `stack build`
+
+#### Development
+Fork this repo, make some changes, run `stack test` to make sure everything's ok, then (if you want) submit a PR. Thank you!
+
+### Use
+#### CLI
+```
+Usage: interp (--substitutions ARG | SUBST) (--interpolations ARG | INTERP)
+  Randomly interpolate values into a template
+
+Available options:
+  --substitutions ARG      JSON file containing map of substitutions
+  SUBST                    JSON file containing map of substitutions
+  --interpolations ARG     file containing text to interpolate
+  INTERP                   file containing text to interpolate
+  -h,--help                Show this help text
+```
+
+`substitutions` is the (path to the) file containing the JSON map of substitutions. `interpolations` is (path to the) file containing the text to interpolate. See below for more on both.
+
+#### Library
+`Data.Text.Interp.Interpolate.interp` is probably what you'll want to use. It takes a `Subst` object (usually parsed from JSON, but you can make one yourself) and a `NonEmpty` list of `IText`s. Check out `Data.Text.Interp.Types` for more on those types.
+
+### Syntax
+In both substitution and interpolation files, keys can be comprised of any letter or number, or the characters "\_" or "-". Spaces and other punctuation are not allowed and will give you Problems.
+#### Substitutions
+```
+{"coolThings": [
+   {"name": "computers",
+    "reason": "you can do haskell on them"
+   },
+   {"name": "dogs",
+    "reason": "they're good"
+   }
+ ],
+ "badThings": [
+  {"name": "flat tires",
+   "reason": "they make you late"
+  },
+  {"name": "jobs",
+   "reason": "they make you tired"
+  }
+ ],
+ "sam": {
+   "likes": "computers"
+}
+```
+The substitutions file is a JSON file. JSON maps are treated how one would expect. The values in a JSON array are chosen among randomly (but see also below on binding). Regular values are treated regularly, but note:
+  * because of how JSON is parsed, _all_ numeric literals are treated as floating-point, so a `1` will be interpolated as `"1.0"`. If this is an issue, just wrap your numbers in quotes and they'll be treated as strings.
+  * `null` values are explicitly forbidden; empty lists and maps should be avoided.
+
+#### Interpolations
+`{{ ... }}` sets off a thing to interpolate. Keys, corresponding to keys in the substitutions file, can be period-separated to 'dig into' nested maps. Thus, in the example above, `sam.likes` will be have the value `"computers"`.
+
+##### Binding
+Within braces, values can be bound to variables using the syntax `(varToBind#keys.to.bind).other.keys`. When a variable is bound, references to that variable will be interpolated with that same value. Thus, given the interpolation template
+```
+{{ (cool#coolThings).name }} are cool because {{ cool.reason }}. {{ (bad#badThings).name }} are bad because {{ bad.reason }}.
+```
+`cool` in the first interpolation will be the same as `cool` in the second. Note that keys within the parentheses are part of the binding, while those after the closing parentheses are interpolated normally--`{{ (cool#coolThings).name }}` will be either `"computers"` or `"dogs"`, while `{{ cool.reason }}` will be `"you can do haskell on them"` or `"they're good"`, depending on which sub-map was randomly chosen to be bound to `cool`.
+
+Binding is primarily useful for map values. It's possible to bind arrays, but since array elements are chosen randomly evey time, there's not much of a point. Similarly, there's no real difference between binding a simple value and just using the full path to it, unless you're trying to save characters.
+
+
+### Etc.
+#### To-Do
+ - [ ] Haddock documentation/comments
+ - [x] Add positional args in CLI
+ - [ ] Other formats (e.g. YAML) for substitutions file
+ - [ ] More features
+   - [ ] Preprocessing (prepending "a(n)", title casing, etc.)
+   - [ ] Other stuff??
+     - [ ] Check Tracery documentation for stuff to steal
+   - [ ] Web interface??
+ - [ ] Come up with a better name
+ - [ ] Publish to Hackage
+
+#### Contributing
+PRs, issues, feature requests, etc. are always more than welcome! Feel free to hit me up on [twitter](https://twitter.com/swizzard) or email me at sam dot raker at gmail dot com. Let me know if you like this, hate this, wish it were better, wish it were worse, whatever.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,19 @@
+{-# LANGUAGE LambdaCase #-}
+module Main where
+
+import Control.Monad.Except
+import Data.Text (unpack, Text)
+import Options.Applicative
+import System.IO
+
+import Data.Text.Interp (doInterp, opts, Input(..))
+
+
+run :: Input -> IO (Either Text Text)
+run (Input sf tf) = runExceptT $ doInterp sf tf
+
+
+main :: IO ()
+main = execParser opts >>= run >>= \case
+    (Left e) -> hPutStrLn stderr $ unpack e
+    (Right v) -> hPutStrLn stdout $ unpack v
diff --git a/interp.cabal b/interp.cabal
new file mode 100644
--- /dev/null
+++ b/interp.cabal
@@ -0,0 +1,117 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.31.1.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: 2c5ae1e8028bb10c6d701cd2ccc86981da63658513595f95c99c55d09c26074e
+
+name:           interp
+version:        1.0.0.0
+synopsis:       Tracery-like randomized text interpolation
+description:    Please see the README on GitHub at <https://github.com/swizzard/interp#readme>
+category:       Text, Interpolation
+homepage:       https://github.com/swizzard/interp#readme
+bug-reports:    https://github.com/swizzard/interp/issues
+author:         sam raker
+maintainer:     sam.raker@gmail.com
+copyright:      (c) 2019 sam raker
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    ChangeLog.md
+
+source-repository head
+  type: git
+  location: https://github.com/swizzard/interp
+
+library
+  exposed-modules:
+      Data.Text.Interp
+      Data.Text.Interp.CLI
+      Data.Text.Interp.Get
+      Data.Text.Interp.Interpolate
+      Data.Text.Interp.Parse
+      Data.Text.Interp.Types
+  other-modules:
+      Paths_interp
+  hs-source-dirs:
+      src
+  build-depends:
+      aeson
+    , base >=4.7 && <5
+    , bytestring
+    , containers
+    , hspec
+    , megaparsec
+    , mtl
+    , optparse-applicative
+    , parser-combinators
+    , random-fu
+    , rvar
+    , semigroups
+    , text
+    , transformers
+    , unordered-containers
+    , vector
+  default-language: Haskell2010
+
+executable interp
+  main-is: Main.hs
+  other-modules:
+      Paths_interp
+  hs-source-dirs:
+      app
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      aeson
+    , base >=4.7 && <5
+    , bytestring
+    , containers
+    , hspec
+    , interp
+    , megaparsec
+    , mtl
+    , optparse-applicative
+    , parser-combinators
+    , random-fu
+    , rvar
+    , semigroups
+    , text
+    , transformers
+    , unordered-containers
+    , vector
+  default-language: Haskell2010
+
+test-suite interp-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      GetSpec
+      InterpolateSpec
+      ParseSpec
+      Paths_interp
+  hs-source-dirs:
+      test
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      aeson
+    , base >=4.7 && <5
+    , bytestring
+    , containers
+    , hspec
+    , interp
+    , megaparsec
+    , mtl
+    , optparse-applicative
+    , parser-combinators
+    , random-fu
+    , rvar
+    , semigroups
+    , text
+    , transformers
+    , unordered-containers
+    , vector
+  default-language: Haskell2010
diff --git a/src/Data/Text/Interp.hs b/src/Data/Text/Interp.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Interp.hs
@@ -0,0 +1,34 @@
+module Data.Text.Interp
+  ( opts
+  , doInterp
+  , Input(..)
+  ) where
+
+import Control.Monad.Except
+import Control.Monad.Trans (lift)
+import Data.Aeson
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.IO as T
+import System.IO (FilePath)
+import Text.Megaparsec
+
+import Data.Text.Interp.CLI (Input(..), opts)
+import Data.Text.Interp.Interpolate (interp)
+import Data.Text.Interp.Parse (itP)
+import Data.Text.Interp.Types (I)
+
+
+-- | Generate interpolated text within a `Data.Text.Interp.Types.I` monad
+doInterp :: FilePath -- ^ path to substitutions file
+         -> FilePath -- ^ path to interpolations file
+         -> I Text   -- ^ resulting interpolated text
+doInterp sf tf = do
+  i <- parse itP "" <$> T.strip <$> (lift $ T.readFile tf)
+  case i of
+    (Left e) -> throwError . T.pack $ errorBundlePretty e
+    (Right i') -> do
+      s <- lift $ eitherDecodeFileStrict sf
+      case s of
+        (Left e) -> throwError $ T.pack e
+        (Right s') -> interp s' i'
diff --git a/src/Data/Text/Interp/CLI.hs b/src/Data/Text/Interp/CLI.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Interp/CLI.hs
@@ -0,0 +1,37 @@
+module Data.Text.Interp.CLI
+  ( Input(..)
+  , opts
+  ) where
+
+import Data.Semigroup ((<>))
+import Options.Applicative
+import System.IO (FilePath)
+
+
+-- | Record to hold CLI options
+data Input = Input {
+             substF :: FilePath -- ^ path to the substitutions file
+           , interpF :: FilePath -- ^ path to the interpolations file
+           }
+
+-- | CLI parser for `Input`
+input :: Parser Input
+input = Input <$> (
+        (strOption $ long "substitutions"
+         <> help "JSON file containing map of substitutions")
+        <|>
+        (strArgument $ metavar "SUBST"
+          <> help "JSON file containing map of substitutions")
+        ) <*> (
+        (strOption $ long "interpolations"
+           <> help "file containing text to interpolate")
+        <|>
+         (strArgument $ metavar "INTERP"
+           <> help "file containing text to interpolate"))
+
+-- | ParserInfo for `Input`
+opts :: ParserInfo Input
+opts = info (input <**> helper)
+          (fullDesc
+          <> progDesc "Randomly interpolate values into a template"
+          <> header "interp")
diff --git a/src/Data/Text/Interp/Get.hs b/src/Data/Text/Interp/Get.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Interp/Get.hs
@@ -0,0 +1,34 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Data.Text.Interp.Get
+  ( get
+  ) where
+
+import Control.Monad.Except
+import qualified Data.Map.Strict as M
+import Data.Random (MonadRandom, randomElement)
+import Data.RVar (sampleRVar)
+import Data.Text (Text, append)
+
+import Data.Text.Interp.Types
+
+
+-- | Randomly retrieve a value out of a `Data.Text.Interp.Types.Subst` mapping
+get :: Subst -- ^ source mapping
+    -> [Key] -- ^ a list of `Data.Text.Interp.Types.Key`s leading to the value
+    -> BindingMap -- ^ the `Data.Text.Interp.Types.BindingMap` to check for bound values in
+    -> I Subst -- ^ retrieved value
+get v@(SubstV _) [] m = return v
+get (SubstV _) xs _ = throwError "too many keys"
+get m@(SubstM _) [] _ = return $ m
+get (SubstM m) (k:ks) bm = case M.lookup k bm of
+                             Nothing -> do
+                               res <- g k m
+                               get res ks bm
+                             (Just s') -> get s' ks bm
+
+-- | Helper function that digs one layer down in a `Data.Text.Interp.Types.Subst` mapping
+g :: Key -> (M.Map Key Subst) -> I Subst
+g k m = case M.lookup k m of
+  Nothing -> throwError $ "bad key: " `append` (unKey k)
+  (Just (SubstL xs)) -> lift $ sampleRVar (randomElement xs)
+  (Just v) -> return v
diff --git a/src/Data/Text/Interp/Interpolate.hs b/src/Data/Text/Interp/Interpolate.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Interp/Interpolate.hs
@@ -0,0 +1,49 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Data.Text.Interp.Interpolate where
+
+import Control.Monad (foldM)
+import Control.Monad.Except
+import Data.List.NonEmpty (NonEmpty(..))
+import qualified Data.List.NonEmpty as NE
+import qualified Data.Map.Strict as M
+import Data.Text (Text, append)
+
+import Data.Text.Interp.Get
+import Data.Text.Interp.Types
+
+
+-- | Record holding intermediary interpolation state
+data IState = IState {
+              acc :: Text -- ^ interpolated text so far
+            , bm :: BindingMap -- ^ mapping of bound variables
+            }
+
+newIState :: IState
+newIState = IState "" M.empty
+
+-- | Build interpolated text out of a `Data.Text.Interp.Types.Subst` mapping and
+-- a list of segments to interpolate
+interp :: Subst -- ^ mapping to get substitutions out of
+       -> NonEmpty IText -- ^ lits of segments to interpolate
+       -> I Text -- ^ final interpolation
+interp s its = acc <$> foldM (interp' s) newIState its
+
+-- | Interpolate a single segment
+interp' :: Subst -- ^ mapping
+        -> IState -- ^ intermediary state
+        -> IText -- ^ segment to interpolate
+        -> I IState -- ^ updated state, after the segment's been interpolated
+interp' _ (IState a m) (RawText t) = return $ IState (a `append` t) m
+interp' s (IState a m) (ToInterpolate Nothing p) = do
+  res <- get s p m
+  case res of
+    (SubstM _) -> throwError "too few keys"
+    (SubstL _) -> throwError "too few keys"
+    (SubstV x) -> return $ IState (a `append` x) m
+interp' s (IState a m) (ToInterpolate (Just (Binding n bp)) p) = do
+  toBind <- get s (NE.toList bp) m
+  res <- get toBind p m
+  case res of
+    (SubstM _) -> throwError "too few keys"
+    (SubstL _) -> throwError "too few keys"
+    (SubstV x) -> return $ IState (a `append` x) $ M.insert n toBind m
diff --git a/src/Data/Text/Interp/Parse.hs b/src/Data/Text/Interp/Parse.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Interp/Parse.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.Text.Interp.Parse where
+
+import Data.List.NonEmpty (NonEmpty(..))
+import qualified Data.List.NonEmpty as NE
+import Data.Text (Text, pack)
+import Data.Void (Void)
+import Text.Megaparsec
+import Text.Megaparsec.Char
+
+import Data.Text.Interp.Types
+
+type Parser = Parsec Void Text
+
+-- | Parse `Data.Text.Interp.Types.IText`s
+itP :: Parser (NonEmpty IText)
+itP = NE.fromList <$> (some $ try $ toInterpolateP <|> rawTextP)
+
+interpStart = "{{"
+interpStop = "}}"
+
+-- | parse raw text (i.e., text without interpolations)
+rawTextP :: Parser IText
+rawTextP = RawText <$> takeWhile1P (Just "raw text") (\c -> not (c `elem` ['}', '{']))
+
+-- | parse text that needs interpolation
+toInterpolateP :: Parser IText
+toInterpolateP = between (string interpStart >> space)
+                         (space >> string interpStop)
+                 interP
+
+-- | parse interpolation instructions (with or without binding)
+interP :: Parser IText
+interP = try $ bindP <|> keyP
+
+-- | parse interpolation that needs binding
+bindP :: Parser IText
+bindP = do
+  char '('
+  bk <- key <* char '#'
+  bp <- NE.fromList <$> keys <* string ")"
+  path <- (optional (char '.')) *> keys
+  let b = Just $ Binding bk bp
+  return $ ToInterpolate b path
+
+-- | parse interpolation that doesn't need binding
+keyP :: Parser IText
+keyP = ToInterpolate Nothing <$> keys
+
+-- | parse a single key
+key :: Parser Key
+key = Key <$> pack <$> some (alphaNumChar <|> satisfy (`elem` ['_', '-'])) <?>
+          "keys can only contain letters, numbers, _, and -"
+
+-- | parse a list of keys
+keys :: Parser [Key]
+keys = sepBy key (char '.')
diff --git a/src/Data/Text/Interp/Types.hs b/src/Data/Text/Interp/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Interp/Types.hs
@@ -0,0 +1,66 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+module Data.Text.Interp.Types
+  ( Binding(..)
+  , BindingMap
+  , I
+  , IText(..)
+  , Key(..)
+  , Subst(..)
+  , substm
+  ) where
+
+import Control.Monad.Except
+import Data.Aeson
+import Data.Aeson.Types (Parser)
+import qualified Data.HashMap.Strict as HM
+import Data.List.NonEmpty (NonEmpty(..))
+import qualified Data.Map.Strict as M
+import Data.Map.Strict (Map)
+import Data.String (IsString, fromString)
+import Data.Text (Text, pack)
+import qualified Data.Vector as V
+
+
+newtype Key = Key { unKey :: Text } deriving (Eq, IsString, Monoid, Ord, Semigroup, Show)
+
+-- | The main substitution type, either a list, mapping, or single value
+data Subst = SubstL [Subst] -- ^ a list of values, one of which will be randomly selected
+           | SubstM (Map Key Subst) -- ^ a mapping of `Key`s to values
+           | SubstV Text -- ^ a single value
+           deriving (Eq, Show)
+
+instance FromJSON Subst where
+  parseJSON (String s) = return $ SubstV s
+  parseJSON (Number n) = return $ SubstV . pack . show $ n
+  parseJSON (Bool b) = return $ SubstV . pack . show $ b
+  parseJSON Null = fail "null not allowed"
+  parseJSON (Array a) = SubstL <$> (sequence $ V.toList $ parseJSON <$> a)
+  parseJSON (Object o) = SubstM <$> HM.foldlWithKey' g (return M.empty) o where
+    g :: Parser (Map Key Subst) -> Text -> Value -> Parser (Map Key Subst)
+    -- considered liftM2 but it was hard to read imo
+    g p k v = do
+      acc <- p
+      x <- parseJSON v
+      return $ M.insert (Key k) x acc
+
+-- | Helper function to build a `Subst` mapping
+substm :: [(Key, Subst)] -> Subst
+substm = SubstM . M.fromList
+
+-- | A binding of a `Key` to a path of `Key`s
+data Binding = Binding {
+               name :: Key
+             , pathToBind :: NonEmpty Key
+             } deriving (Eq, Show)
+
+-- | A segment of text to interpolate (or not)
+data IText = RawText Text -- ^ 'Raw' text that doesn't need interpolation
+             -- | Text that requires interpolation
+           | ToInterpolate {
+                binding :: Maybe Binding -- ^ Optional binding
+             ,  path :: [Key] -- ^ Path of `Key`s to the value to interpolate
+             } deriving (Eq, Show)
+
+type I = ExceptT Text IO
+
+type BindingMap = M.Map Key Subst
diff --git a/test/GetSpec.hs b/test/GetSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/GetSpec.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE OverloadedStrings #-}
+module GetSpec where
+
+import Control.Monad.Except
+import qualified Data.Map.Strict as M
+import Test.Hspec
+
+import Data.Text.Interp.Get
+import Data.Text.Interp.Types
+
+samFedoraColors :: Subst
+samFedoraColors = SubstL $ SubstV <$> ["green", "red", "blue"]
+
+samBallcapColors :: Subst
+samBallcapColors = SubstL $ SubstV <$> ["white", "black", "orange"]
+
+testSubst :: Subst
+testSubst = substm [
+  ("people", SubstL [
+    substm [
+      ("sam", substm [
+        ("hats", SubstL [
+          substm [
+            ("type", SubstV "fedora"),
+            ("color", samFedoraColors)
+         ],
+          substm [
+            ("type", SubstV "ballcap"),
+            ("color", samBallcapColors)
+         ]
+        ]),
+        ("shoes", SubstL $ SubstV <$> ["sneakers", "boots"])
+      ])
+     ]
+    ])
+   ]
+
+expectedColors = let (SubstL fcs) = samFedoraColors
+                     (SubstL bcs) = samBallcapColors
+                  in fcs ++ bcs
+
+spec :: Spec
+spec = do
+  describe "get" $ do
+    it "should get a color" $ do
+      (Right c) <- runExceptT $
+        get testSubst ["people", "sam", "hats", "color"] M.empty
+      c `shouldSatisfy` (`elem` expectedColors)
+
+    it "should error on bad key" $ do
+      (Left e) <- runExceptT $ get testSubst ["people", "sam", "shirts"] M.empty
+      e `shouldBe` "bad key: shirts"
+
+    it "should error on too many keys" $ do
+      (Left e) <- runExceptT $
+        get testSubst ["people", "sam", "hats", "color", "hue"] M.empty
+      e `shouldBe` "too many keys"
+
+    it "should re-use bound results" $ do
+      let bm = M.singleton "hats"  $
+                  substm [("type", SubstV "fedora"), ("color", samFedoraColors)]
+      (Right c) <- runExceptT $ get testSubst ["people", "sam", "hats", "type"] bm
+      c `shouldBe` SubstV "fedora"
diff --git a/test/InterpolateSpec.hs b/test/InterpolateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/InterpolateSpec.hs
@@ -0,0 +1,69 @@
+{-# LANGUAGE OverloadedStrings #-}
+module InterpolateSpec where
+
+import Control.Monad.Except
+import qualified Data.Map.Strict as M
+import Data.List.NonEmpty (NonEmpty(..))
+import Data.Text (append)
+import Test.Hspec
+
+import Data.Text.Interp.Interpolate
+import Data.Text.Interp.Types
+
+fcs = ["green", "red"]
+bcs = ["white", "black"]
+
+
+samFedoraColors :: Subst
+samFedoraColors = SubstL $ SubstV <$> fcs
+
+samBallcapColors :: Subst
+samBallcapColors = SubstL $ SubstV <$> bcs
+
+testSubst :: Subst
+testSubst = substm [
+  ("people", SubstL [
+    substm [
+      ("sam", substm [
+        ("hats", SubstL [
+          substm [
+            ("type", SubstV "fedora"),
+            ("color", samFedoraColors)
+         ],
+          substm [
+            ("type", SubstV "ballcap"),
+            ("color", samBallcapColors)
+         ]
+        ]),
+        ("shoes", SubstL $ SubstV <$> ["sneakers", "boots"])
+      ])
+     ]
+    ])
+   ]
+
+spec :: Spec
+spec = do
+  describe "interp" $ do
+    it "should interpolate without binding" $ do
+      let its = RawText "sam was wearing a " :|
+                [ToInterpolate Nothing ["people", "sam", "hats", "type"]]
+      (Right res) <- runExceptT $ interp testSubst its
+      let expected = ["sam was wearing a fedora", "sam was wearing a ballcap"]
+      res `shouldSatisfy` (`elem` expected)
+
+    it "should interpolate with binding" $ do
+      let its = RawText "sam was wearing a " :|
+                [ToInterpolate (Just (Binding "h" $ "people" :|
+                                              ["sam", "hats"])) ["color"],
+                 RawText " ",
+                 ToInterpolate Nothing ["h", "type"]]
+      (Right res) <- runExceptT $ interp testSubst its
+      let expected = ["sam was wearing a " `append` fc `append` " fedora" | fc <- fcs] ++
+                     ["sam was wearing a " `append` bc `append` " ballcap" | bc <- bcs]
+      res `shouldSatisfy` (`elem` expected)
+
+    it "should throw an error with too few keys" $ do
+      let its = RawText "sam was wearing a  " :|
+                [ToInterpolate Nothing ["people", "sam"]]
+      (Left e) <- runExceptT $ interp testSubst its
+      e `shouldBe` "too few keys"
diff --git a/test/ParseSpec.hs b/test/ParseSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ParseSpec.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE OverloadedStrings #-}
+module ParseSpec where
+
+import Data.Either
+import Data.Text (Text)
+import Data.List.NonEmpty (NonEmpty(..))
+import Test.Hspec
+import Text.Megaparsec
+
+import Data.Text.Interp.Parse
+import Data.Text.Interp.Types
+
+
+spec :: Spec
+spec = do
+  describe "parsing" $ do
+    it "should parse a single key" $ do
+      let (Right k) = parse key "" "key"
+      k `shouldBe` (Key "key")
+
+    it "should parse multiple keys separated by periods" $ do
+      let (Right ks) = parse keys "" "foo.bar.baz"
+      ks `shouldBe` [Key "foo", Key "bar", Key "baz"]
+
+    it "should create a ToInterpolate without any binding" $ do
+      let (Right ti) = parse keyP "" "foo.bar.baz"
+      ti `shouldBe` ToInterpolate Nothing [Key "foo", Key "bar", Key "baz"]
+
+    it "should create a ToInterpolate with a binding" $ do
+      let (Right ti) = parse bindP "" "(b#foo.bar).baz.quux"
+      ti `shouldBe` ToInterpolate (Just $ Binding (Key "b")
+                                    (Key "foo" :| [Key "bar"]))
+                                    [Key "baz", Key "quux"]
+
+    it "should parse raw text" $ do
+      let (Right txt) = parse rawTextP "" "raw text"
+      txt `shouldBe` RawText "raw text"
+
+    it "shouldn't permit an empty binding key" $ do
+      let res = parse bindP "" "(#foo).bar.baz"
+      res `shouldSatisfy` isLeft
+
+    it "shouldn't permit parens without binding stuff" $ do
+      let res = parse bindP "" "(foo).bar.baz"
+      res `shouldSatisfy` isLeft
+
+    it "should deal properly with errant periods" $ do
+      let res = parse bindP "" ".foo.bar"
+      res `shouldSatisfy` isLeft
+      let res' = parse bindP "" "foo.bar."
+      res' `shouldSatisfy` isLeft
+
+    it "should parse a mixture properly" $ do
+      let s = "there is some {{ (text#to.parse) }} {{ for.us }} here"
+      let (Right res) = parse itP "" s
+      let expected = (RawText "there is some ") :|
+                      [
+                        ToInterpolate (Just $ Binding (Key "text")
+                                  (Key "to" :| [Key "parse"])) [],
+                        RawText " ",
+                        ToInterpolate Nothing [Key "for", Key "us"],
+                        RawText " here" ]
+      res `shouldBe` expected
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
