diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Main (main) where
+
+import Control.Applicative ((<|>))
+import Control.Monad (forM_, guard)
+import qualified Data.ByteString as SB
+import Data.Maybe (fromMaybe)
+import qualified Data.Text.Lazy as L
+import System.Exit (exitFailure)
+import System.IO (hClose, hPutStrLn, stderr)
+import Text.Printf (printf)
+
+import Data.GraphViz
+import qualified Data.GraphViz.Attributes.Colors.X11 as C
+import qualified Data.GraphViz.Attributes.Complete as A
+import qualified Data.GraphViz.Attributes.HTML as H
+import qualified Data.GraphViz.Types.Generalised as G
+import Data.GraphViz.Types.Monadic
+import Data.GraphViz.Commands (isGraphvizInstalled)
+
+import qualified Erd.ER as ER
+
+import Erd.Config
+import Erd.ER
+import Erd.Parse
+
+main :: IO ()
+main = do
+  checkRequirements -- application may terminate here
+  conf <- configIO
+  er' <- uncurry loadER (cin conf)
+  case er' of
+    Left err -> do
+      hPutStrLn stderr err
+      exitFailure
+    Right er -> let dotted = dotER conf er
+                    toFile h = SB.hGetContents h >>= SB.hPut (snd $ cout conf)
+                    fmt = fromMaybe Pdf (outfmt conf)
+                 in graphvizWithHandle Dot dotted fmt toFile
+  hClose (snd $ cin conf)
+  hClose (snd $ cout conf)
+
+-- | Converts an entire ER-diagram from an ER file into a GraphViz graph.
+dotER :: Config -> ER -> G.DotGraph L.Text
+dotER conf er = graph' $ do
+  graphAttrs (graphTitle $ title er)
+  graphAttrs [ A.RankDir A.FromLeft
+             , A.Splines (edgeType conf)
+             ]
+  nodeAttrs [shape PlainText] -- recommended for HTML labels
+  edgeAttrs [ A.Color [A.toWC $ A.toColor C.Gray50] -- easier to read labels
+            , A.MinLen 2 -- give some breathing room
+            , A.Style [A.SItem A.Dashed []] -- easier to read labels, maybe?
+            ]
+  forM_ (entities er) $ \e ->
+    node (name e) [toLabel (htmlEntity e)]
+  forM_ (rels er) $ \r -> do
+    let opts = roptions r
+    let rlab = A.HtmlLabel . H.Text . htmlFont opts . L.pack . show
+    let (l1, l2) = (A.TailLabel $ rlab $ card1 r, A.HeadLabel $ rlab $ card2 r)
+    let label = A.Label $ A.HtmlLabel $ H.Text $ withLabelFmt " %s " opts []
+    edge (entity1 r) (entity2 r) [label, l1, l2]
+
+-- | Converts a single entity to an HTML label.
+htmlEntity :: Entity -> H.Label
+htmlEntity e = H.Table H.HTable
+                 { H.tableFontAttrs = Just $ optionsTo optToFont $ eoptions e
+                 , H.tableAttrs = optionsTo optToHtml (eoptions e)
+                 , H.tableRows = rows
+                 }
+  where rows = headerRow : map htmlAttr (attribs e)
+        headerRow = H.Cells [H.LabelCell [] $ H.Text text]
+        text = withLabelFmt " [%s]" (hoptions e) $ bold hname
+        hname = htmlFont (hoptions e) (name e)
+        bold s = [H.Format H.Bold s]
+
+-- | Converts a single attribute to an HTML table row.
+htmlAttr :: ER.Attribute -> H.Row
+htmlAttr a = H.Cells [cell]
+  where cell = H.LabelCell cellAttrs (H.Text $ withLabelFmt " [%s]" opts name)
+        name = fkfmt $ pkfmt $ htmlFont opts (field a)
+        pkfmt s = if pk a then [H.Format H.Underline s] else s
+        fkfmt s = if fk a then [H.Format H.Italics s] else s
+        cellAttrs = H.Align H.HLeft : optionsTo optToHtml opts
+        opts = aoptions a
+
+-- | Formats HTML text with a label. The format string given should be
+-- in `Data.Text.printf` style. (Only font options are used from the options
+-- given.)
+withLabelFmt :: String -> Options -> H.Text -> H.Text
+withLabelFmt fmt opts s =
+  case optionsTo optToLabel opts of
+    (x:_) -> s ++ htmlFont opts (L.pack $ printf fmt $ L.unpack x)
+    _ -> s
+
+-- | Formats an arbitrary string with the options given (using only font
+-- attributes).
+htmlFont :: Options -> L.Text -> H.Text
+htmlFont opts s = [H.Font (optionsTo optToFont opts) [H.Str s]]
+
+-- | Extracts and formats a graph title from the options given.
+-- The options should be title options from an ER value.
+-- If a title does not exist, an empty list is returned and `graphAttrs attrs`
+-- should be a no-op.
+graphTitle :: Options -> [A.Attribute]
+graphTitle topts =
+  let glabel = optionsTo optToLabel topts
+  in if null glabel then [] else
+       [ A.LabelJust A.JLeft
+       , A.LabelLoc A.VTop
+       , A.Label $ A.HtmlLabel $ H.Text $ htmlFont topts (head glabel)
+       ]
+
+checkRequirements :: IO ()
+checkRequirements = (isGraphvizInstalled >>= guard) <|> quitWithoutGraphviz msg
+  where
+    msg = "GraphViz is not installed on your system.\n" ++
+          "Please install it first, https://github.com/BurntSushi/erd"
diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,16 @@
+0.2.0.0
+
+* Adding test suite. (PR #46)
+
+* Adding TravisCI script. (PR #46)
+
+* Not falling to *bottom* when GraphViz does not exists on the host system. (PR #45)
+
+* Supporting to build with *stack* and/or *cabal*. (PR #43, PR #18)
+
+* Adding optional -e command-line argument to determine the type of edges
+  between nodes/tables. (PR #13)
+
+* Allow spaces in table identifiers, quoted with backticks. (PR #9)
+
+0.1.3.0
diff --git a/erd.cabal b/erd.cabal
--- a/erd.cabal
+++ b/erd.cabal
@@ -1,8 +1,8 @@
--- Initial erd.cabal generated by cabal init.  For further documentation, 
+-- Initial erd.cabal generated by cabal init.  For further documentation,
 -- see http://haskell.org/cabal/users-guide/
 
 name:                erd
-version:             0.1.3.0
+version:             0.2.0.0
 homepage:            https://github.com/BurntSushi/erd
 license:             PublicDomain
 license-file:        UNLICENSE
@@ -13,15 +13,15 @@
 cabal-version:       >=1.8
 synopsis:
   An entity-relationship diagram generator from a plain text description.
-description:         
-  erd transforms a plain text description of a relational database schema to a 
+description:
+  erd transforms a plain text description of a relational database schema to a
   graphical representation of that schema. It is intended that the graph make
   use of common conventions when depicting entity-relationship diagrams,
   including modeling the cardinality of relationships between entities.
   .
   A quick example that transforms an `er` file to a PDF:
   .
-  > $ curl 'https://raw.github.com/BurntSushi/erd/master/examples/simple.er' > simple.er
+  > $ curl 'https://raw.githubusercontent.com/BurntSushi/erd/master/examples/simple.er' > simple.er
   > $ cat simple.er
   > # Entities are declared in '[' ... ']'. All attributes after the entity header
   > # up until the end of the file (or the next entity declaration) correspond
@@ -31,13 +31,13 @@
   > height
   > weight
   > +birth_location_id
-  > 
+  >
   > [Location]
   > *id
   > city
   > state
   > country
-  > 
+  >
   > # Each relationship must be between exactly two entities, which need not
   > # be distinct. Each entity in the relationship has exactly one of four
   > # possible cardinalities:
@@ -54,22 +54,54 @@
   .
   <<http://burntsushi.net/stuff/erd-example-simple.png>>
   .
-  See the <https://github.com/BurntSushi/erd#readme README.md> file for more 
+  See the <https://github.com/BurntSushi/erd#readme README.md> file for more
   examples and instructions on how to write ER files.
 
+extra-source-files:
+    changelog.md
+
 source-repository head
   type: git
   location: git://github.com/BurntSushi/erd.git
 
 executable erd
-  hs-source-dirs: src
+  hs-source-dirs: app, src
+  other-modules:
+      Erd.Config
+      Erd.ER
+      Erd.Parse
+      Text.Parsec.Erd.Parser
   main-is: Main.hs
   ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-unused-do-bind
-  other-modules: Config, ER, Parse
   build-depends:
-    base == 4.7.*
+      base >=4.5 && <5
+      , graphviz == 2999.*
+      , text == 1.*
+      , parsec == 3.1.*
+      , containers == 0.5.*
+      , bytestring == 0.10.*
+
+test-suite spec
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-unused-do-bind
+  hs-source-dirs:
+      test, src
+  other-modules:
+      Erd.Config
+      Erd.ER
+      Erd.Parse
+      Text.Parsec.Erd.Parser
+      Test.Text.Parsec.Erd.Parser
+  build-depends:
+      QuickCheck
+    , hspec
+    , base >=4.5 && <5
     , graphviz == 2999.*
     , text == 1.*
     , parsec == 3.1.*
     , containers == 0.5.*
     , bytestring == 0.10.*
+    , tasty >= 0.11
+    , tasty-hunit >= 0.9
+    , raw-strings-qq >= 1.1
diff --git a/src/Config.hs b/src/Config.hs
deleted file mode 100644
--- a/src/Config.hs
+++ /dev/null
@@ -1,135 +0,0 @@
-module Config
-  ( Config(..)
-  , configIO
-  )
-where
-
-import Data.Char (isSpace)
-import Data.List (dropWhileEnd, intercalate)
-import qualified Data.Map as M
-import Data.Maybe (isNothing)
-import qualified System.Console.GetOpt as O
-import System.Environment (getArgs)
-import System.Exit (exitFailure)
-import System.IO (Handle, IOMode(..), stdin, stdout, stderr, openFile)
-import Text.Printf (HPrintfType, hPrintf, printf)
-
-import qualified Data.GraphViz.Commands as G
-
--- | Config represents all information from command line flags.
-data Config =
-  Config { cin :: (String, Handle)
-         , cout :: (String, Handle)
-         , outfmt :: Maybe G.GraphvizOutput
-         }
-
-defaultConfig :: Config
-defaultConfig =
-  Config { cin = ("<stdin>", stdin)
-         , cout = ("<stdout>", stdout)
-         , outfmt = Nothing
-         }
-
--- | Creates a new Config value from command line options.
--- If an output path is given and `--fmt` is omitted, then a format
--- will be inferred from the output path extension.
--- Failing all of that, PDF is used by default.
-configIO :: IO Config
-configIO = do
-  args <- getArgs
-  case O.getOpt O.Permute opts args of
-    (flags, [], []) -> do
-      conf <- foldl (\c app -> app c) (return defaultConfig) flags
-      let outpath = fst (cout conf)
-      return $
-        if isNothing (outfmt conf) && outpath /= "<stdout>" then
-          conf { outfmt = toGraphFmt $ takeExtension outpath }
-        else
-          conf
-    (_, _, errs@(_:_)) -> do
-      ef "Error(s) parsing flags:\n\t%s\n" $
-        intercalate "\n\t" $ map strip errs
-      exitFailure
-    (_, _, []) -> do
-      ef "erd does not have any positional arguments.\n\n"
-      usageExit
-
-opts :: [O.OptDescr (IO Config -> IO Config)]
-opts =
-  [ O.Option "i" ["input"]
-      (O.ReqArg (\fpath cIO -> do
-                   c <- cIO
-                   i <- openFile fpath ReadMode
-                   return $ c { cin = (fpath, i) }
-                )
-                "FILE")
-      ("When set, input will be read from the given file.\n"
-       ++ "Otherwise, stdin will be used.")
-  , O.Option "o" ["output"]
-      (O.ReqArg (\fpath cIO -> do
-                    c <- cIO
-                    o <- openFile fpath WriteMode
-                    return $ c { cout = (fpath, o) }
-                )
-                "FILE")
-      ("When set, output will be written to the given file.\n"
-       ++ "Otherwise, stdout will be used.\n"
-       ++ "If given and if --fmt is omitted, then the format will be\n"
-       ++ "guessed from the file extension.")
-  , O.Option "h" ["help"]
-      (O.NoArg $ const usageExit)
-      "Show this usage message."
-  , O.Option "f" ["fmt"]
-      (O.ReqArg (\fmt cIO -> do
-                    c <- cIO
-                    let mfmt = toGraphFmt fmt
-                    case mfmt of
-                      Nothing -> do
-                        ef "'%s' is not a valid output format." fmt
-                        exitFailure
-                      Just gfmt ->
-                        return $ c { outfmt = Just gfmt }
-                )
-                "FMT")
-      (printf "Force the output format to one of:\n%s"
-              (intercalate ", " $ M.keys fmts))
-  ]
-
--- | A subset of formats supported from GraphViz.
-fmts :: M.Map String (Maybe G.GraphvizOutput)
-fmts = M.fromList
-  [ ("pdf", Just G.Pdf)
-  , ("svg", Just G.Svg)
-  , ("eps", Just G.Eps)
-  , ("bmp", Just G.Bmp)
-  , ("jpg", Just G.Jpeg)
-  , ("png", Just G.Png)
-  , ("gif", Just G.Gif)
-  , ("tiff", Just G.Tiff)
-  , ("dot", Just G.Canon)
-  , ("ps",  Just G.Ps)
-  , ("ps2", Just G.Ps2)
-  , ("plain", Just G.Plain)
-  ]
-
--- | takeExtension returns the last extension from a file path, or the
--- empty string if no extension was found. e.g., the extension of
--- "wat.pdf" is "pdf".
-takeExtension :: String -> String
-takeExtension s = if null rest then "" else reverse ext
-  where (ext, rest) = span (/= '.') $ reverse s
-
-toGraphFmt :: String -> Maybe G.GraphvizOutput
-toGraphFmt ext = M.findWithDefault Nothing ext fmts
-
-usageExit :: IO a
-usageExit = usage >> exitFailure
-
-usage :: IO ()
-usage = ef "%s\n" $ O.usageInfo "Usage: erd [flags]" opts
-
-ef :: HPrintfType r => String -> r
-ef = hPrintf stderr
-
-strip :: String -> String
-strip = dropWhile isSpace . dropWhileEnd isSpace
diff --git a/src/ER.hs b/src/ER.hs
deleted file mode 100644
--- a/src/ER.hs
+++ /dev/null
@@ -1,193 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module ER
-  ( ER(..)
-  , Entity(..)
-  , Attribute(..)
-  , Options, mergeOpts, optionsTo
-  , Option(..), optionByName, optToFont, optToHtml, optToLabel
-  , Relation(..) , Cardinality(..), cardByName
-  , defaultTitleOpts, defaultEntityOpts, defaultHeaderOpts, defaultRelOpts
-  )
-where
-
-import qualified Data.Map as M
-import Data.Maybe (mapMaybe)
-import Data.Text.Lazy
-import Data.Word (Word8)
-import Text.Printf (printf)
-
-import Data.GraphViz.Parsing (ParseDot, parse, runParser)
-import qualified Data.GraphViz.Attributes.HTML as H
-import Data.GraphViz.Attributes.Colors (Color)
-
--- | Represents a single schema.
-data ER = ER { entities :: [Entity]
-             , rels :: [Relation]
-             , title :: Options
-             }
-          deriving Show
-
--- | Represents a single entity in a schema.
-data Entity = Entity { name :: Text
-                     , attribs :: [Attribute]
-                     , hoptions :: Options
-                     , eoptions :: Options
-                     }
-              deriving Show
-
-instance Eq Entity where
-  e1 == e2 = name e1 == name e2
-
-instance Ord Entity where
-  e1 `compare` e2 = name e1 `compare` name e2
-
--- | Represents a single attribute in a particular entity.
-data Attribute = Attribute { field :: Text
-                           , pk :: Bool
-                           , fk :: Bool
-                           , aoptions :: Options
-                           }
-                 deriving Show
-
-instance Eq Attribute where
-  a1 == a2 = field a1 == field a2
-
-instance Ord Attribute where
-  a1 `compare` a2 = field a1 `compare` field a2
-
--- | Represents any number of options for an item in an ER diagram.
--- An item may be the graph title, an entity, an entity header or a
--- relationship between entities. Keys are options as specified in ER files.
---
--- Note that a set of options may include a label for any item.
-type Options = M.Map String Option
-
--- | Given two sets of options, merge the second into first, where elements
--- in the first take precedence.
-mergeOpts :: Options -> Options -> Options
-mergeOpts opts1 opts2 = opts1 `M.union` opts2
-
--- | Given a set of options and a selector function, return the list of
--- only those options which matched. Examples of the selector function are
--- `optToFont`, `optToHtml` and `optToLabel`.
-optionsTo :: (Option -> Maybe a) -> Options -> [a]
-optionsTo f = mapMaybe f . M.elems
-
--- | A restricted subset of options in GraphViz that can be configured in
--- an ER file.
-data Option = Label String
-            | BgColor Color
-            | Color Color
-            | FontFace Text
-            | FontSize Double
-            | Border Word8
-            | BorderColor Color
-            | CellSpacing Word8
-            | CellBorder Word8
-            | CellPadding Word8
-            deriving Show
-
--- | Given an option name and a string representation of its value,
--- `optionByName` will attempt to parse the string as a value corresponding
--- to the option. If the option doesn't exist or there was a problem parsing
--- the value, an error is returned.
-optionByName :: String -> String -> Either String Option
-optionByName "label" = Right . Label
-optionByName "color" = optionParse Color
-optionByName "bgcolor" = optionParse BgColor
-optionByName "size" = optionParse FontSize
-optionByName "font" = optionParse FontFace
-optionByName "border" = optionParse Border
-optionByName "border-color" = optionParse BorderColor
-optionByName "cellspacing" = optionParse CellSpacing
-optionByName "cellborder" = optionParse CellBorder
-optionByName "cellpadding" = optionParse CellPadding
-optionByName unk = const (Left $ printf "Option '%s' does not exist." unk)
-
--- | A wrapper around the GraphViz's parser for any particular option.
-optionParse :: ParseDot a => (a -> Option) -> String -> Either String Option
-optionParse con s =
-  case fst $ runParser parse quoted of
-    Left err -> Left (printf "%s (bad value '%s')" err s)
-    Right a -> Right (con a)
-  where quoted = "\"" `append` pack s `append` "\""
-
--- | Selects an option if and only if it corresponds to a font attribute.
-optToFont :: Option -> Maybe H.Attribute
-optToFont (Color c) = Just $ H.Color c
-optToFont (FontFace s) = Just $ H.Face s
-optToFont (FontSize d) = Just $ H.PointSize d
-optToFont _ = Nothing
-
--- | Selects an option if and only if it corresponds to an HTML attribute.
--- In particular, for tables or table cells.
-optToHtml :: Option -> Maybe H.Attribute
-optToHtml (BgColor c) = Just $ H.BGColor c
-optToHtml (Border w) = Just $ H.Border w
-optToHtml (BorderColor c) = Just $ H.Color c
-optToHtml (CellSpacing w) = Just $ H.CellSpacing w
-optToHtml (CellBorder w) = Just $ H.CellBorder w
-optToHtml (CellPadding w) = Just $ H.CellPadding w
-optToHtml _ = Nothing
-
--- | Selects an option if and only if it corresponds to a label.
-optToLabel :: Option -> Maybe Text
-optToLabel (Label s) = Just $ pack s
-optToLabel _ = Nothing
-
--- | Represents a relationship between exactly two entities. After parsing,
--- each `rel` is guaranteed to correspond to an entity defined in the same
--- ER file.
---
--- Each relationship has one of four cardinalities specified for both entities.
--- Those cardinalities are: 0 or 1, exactly 1, 0 or more and 1 or more.
-data Relation = Relation { entity1, entity2 :: Text
-                         , card1,   card2   :: Cardinality
-                         , roptions :: Options
-                         }
-                deriving Show
-
-data Cardinality = ZeroOne
-                 | One
-                 | ZeroPlus
-                 | OnePlus
-
-instance Show Cardinality where
-  show ZeroOne = "{0,1}"
-  show One = "1"
-  show ZeroPlus = "0..N"
-  show OnePlus ="1..N"
-
--- | Maps a string representation to a particular relationship cardinality.
-cardByName :: Char -> Maybe Cardinality
-cardByName '?' = Just ZeroOne
-cardByName '1' = Just One
-cardByName '*' = Just ZeroPlus
-cardByName '+' = Just OnePlus
-cardByName _ = Nothing
-
--- | Hard-coded default options for all graph titles.
-defaultTitleOpts :: Options
-defaultTitleOpts = M.fromList
-  [ ("size", FontSize 30)
-  ]
-
--- | Hard-coded default options for all entity headers.
-defaultHeaderOpts :: Options
-defaultHeaderOpts = M.fromList
-  [ ("size", ER.FontSize 16)
-  ]
-
--- | Hard-coded default options for all entities.
-defaultEntityOpts :: Options
-defaultEntityOpts = M.fromList
-  [ ("border", Border 0)
-  , ("cellborder", CellBorder 1)
-  , ("cellspacing", CellSpacing 0)
-  , ("cellpadding", CellPadding 4)
-  , ("font", FontFace "Helvetica")
-  ]
-
--- | Hard-coded default options for all relationships.
-defaultRelOpts :: Options
-defaultRelOpts = M.empty
diff --git a/src/Erd/Config.hs b/src/Erd/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Erd/Config.hs
@@ -0,0 +1,164 @@
+module Erd.Config
+  ( Config(..)
+  , configIO
+  )
+where
+
+import Data.Char (isSpace)
+import Data.List (dropWhileEnd, intercalate)
+import qualified Data.Map as M
+import Data.Maybe (isNothing)
+import qualified System.Console.GetOpt as O
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import System.IO (Handle, IOMode(..), stdin, stdout, stderr, openFile)
+import Text.Printf (HPrintfType, hPrintf, printf)
+
+import qualified Data.GraphViz.Commands as G
+import qualified Data.GraphViz.Attributes.Complete as A
+
+-- | Config represents all information from command line flags.
+data Config =
+  Config { cin :: (String, Handle)
+         , cout :: (String, Handle)
+         , outfmt :: Maybe G.GraphvizOutput
+         , edgeType :: A.EdgeType
+         }
+
+defaultConfig :: Config
+defaultConfig =
+  Config { cin = ("<stdin>", stdin)
+         , cout = ("<stdout>", stdout)
+         , outfmt = Nothing
+         , edgeType = A.SplineEdges
+         }
+
+-- | Creates a new Config value from command line options.
+-- If an output path is given and `--fmt` is omitted, then a format
+-- will be inferred from the output path extension.
+-- Failing all of that, PDF is used by default.
+configIO :: IO Config
+configIO = do
+  args <- getArgs
+  case O.getOpt O.Permute opts args of
+    (flags, [], []) -> do
+      conf <- foldl (\c app -> app c) (return defaultConfig) flags
+      let outpath = fst (cout conf)
+      return $
+        if isNothing (outfmt conf) && outpath /= "<stdout>" then
+          conf { outfmt = toGraphFmt $ takeExtension outpath }
+        else
+          conf
+    (_, _, errs@(_:_)) -> do
+      ef "Error(s) parsing flags:\n\t%s\n" $
+        intercalate "\n\t" $ map strip errs
+      exitFailure
+    (_, _, []) -> do
+      ef "erd does not have any positional arguments.\n\n"
+      usageExit
+
+opts :: [O.OptDescr (IO Config -> IO Config)]
+opts =
+  [ O.Option "i" ["input"]
+      (O.ReqArg (\fpath cIO -> do
+                   c <- cIO
+                   i <- openFile fpath ReadMode
+                   return $ c { cin = (fpath, i) }
+                )
+                "FILE")
+      ("When set, input will be read from the given file.\n"
+       ++ "Otherwise, stdin will be used.")
+  , O.Option "o" ["output"]
+      (O.ReqArg (\fpath cIO -> do
+                    c <- cIO
+                    o <- openFile fpath WriteMode
+                    return $ c { cout = (fpath, o) }
+                )
+                "FILE")
+      ("When set, output will be written to the given file.\n"
+       ++ "Otherwise, stdout will be used.\n"
+       ++ "If given and if --fmt is omitted, then the format will be\n"
+       ++ "guessed from the file extension.")
+  , O.Option "h" ["help"]
+      (O.NoArg $ const usageExit)
+      "Show this usage message."
+  , O.Option "f" ["fmt"]
+      (O.ReqArg (\fmt cIO -> do
+                    c <- cIO
+                    let mfmt = toGraphFmt fmt
+                    case mfmt of
+                      Nothing -> do
+                        ef "'%s' is not a valid output format." fmt
+                        exitFailure
+                      Just gfmt ->
+                        return $ c { outfmt = Just gfmt }
+                )
+                "FMT")
+      (printf "Force the output format to one of:\n%s"
+              (intercalate ", " $ M.keys fmts))
+  , O.Option "e" ["edge"]
+      (O.ReqArg (\edge cIO -> do
+                    c <- cIO
+                    let edgeG = toEdgeG edge
+                    case edgeG of
+                      Nothing -> do
+                        ef "'%s' is not a valid type of edge." edge
+                        exitFailure
+                      Just edgeType ->
+                        return $ c { edgeType = edgeType }
+                )
+                "EDGE")
+      (printf "Select one type of edge:\n%s"
+              (intercalate ", " $ M.keys edges))
+  ]
+
+-- | A subset of formats supported from GraphViz.
+fmts :: M.Map String (Maybe G.GraphvizOutput)
+fmts = M.fromList
+  [ ("pdf", Just G.Pdf)
+  , ("svg", Just G.Svg)
+  , ("eps", Just G.Eps)
+  , ("bmp", Just G.Bmp)
+  , ("jpg", Just G.Jpeg)
+  , ("png", Just G.Png)
+  , ("gif", Just G.Gif)
+  , ("tiff", Just G.Tiff)
+  , ("dot", Just G.Canon)
+  , ("ps",  Just G.Ps)
+  , ("ps2", Just G.Ps2)
+  , ("plain", Just G.Plain)
+  ]
+
+edges :: M.Map String (Maybe A.EdgeType)
+edges = M.fromList
+  [ ("spline", Just A.SplineEdges)
+  , ("ortho", Just A.Ortho)
+  , ("noedge", Just A.NoEdges)
+  , ("poly", Just A.PolyLine)
+  , ("compound", Just A.CompoundEdge)
+  ]
+
+-- | takeExtension returns the last extension from a file path, or the
+-- empty string if no extension was found. e.g., the extension of
+-- "wat.pdf" is "pdf".
+takeExtension :: String -> String
+takeExtension s = if null rest then "" else reverse ext
+  where (ext, rest) = span (/= '.') $ reverse s
+
+toGraphFmt :: String -> Maybe G.GraphvizOutput
+toGraphFmt ext = M.findWithDefault Nothing ext fmts
+
+toEdgeG :: String -> Maybe A.EdgeType
+toEdgeG edge = M.findWithDefault Nothing edge edges
+
+usageExit :: IO a
+usageExit = usage >> exitFailure
+
+usage :: IO ()
+usage = ef "%s\n" $ O.usageInfo "Usage: erd [flags]" opts
+
+ef :: HPrintfType r => String -> r
+ef = hPrintf stderr
+
+strip :: String -> String
+strip = dropWhile isSpace . dropWhileEnd isSpace
diff --git a/src/Erd/ER.hs b/src/Erd/ER.hs
new file mode 100644
--- /dev/null
+++ b/src/Erd/ER.hs
@@ -0,0 +1,187 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Erd.ER
+  ( ER(..)
+  , Entity(..)
+  , Attribute(..)
+  , Options, mergeOpts, optionsTo
+  , Option(..), optionByName, optToFont, optToHtml, optToLabel
+  , Relation(..) , Cardinality(..), cardByName
+  , defaultTitleOpts, defaultEntityOpts, defaultHeaderOpts, defaultRelOpts
+  )
+where
+
+import qualified Data.Map as M
+import Data.Maybe (mapMaybe)
+import Data.Text.Lazy
+import Data.Word (Word8)
+import Text.Printf (printf)
+
+import Data.GraphViz.Parsing (ParseDot, parse, runParser)
+import qualified Data.GraphViz.Attributes.HTML as H
+import Data.GraphViz.Attributes.Colors (Color)
+
+-- | Represents a single schema.
+data ER = ER { entities :: [Entity]
+             , rels :: [Relation]
+             , title :: Options
+             }
+          deriving (Show, Eq)
+
+-- | Represents a single entity in a schema.
+data Entity = Entity { name :: Text
+                     , attribs :: [Attribute]
+                     , hoptions :: Options
+                     , eoptions :: Options
+                     }
+              deriving (Show, Eq)
+
+instance Ord Entity where
+  e1 `compare` e2 = name e1 `compare` name e2
+
+-- | Represents a single attribute in a particular entity.
+data Attribute = Attribute { field :: Text
+                           , pk :: Bool
+                           , fk :: Bool
+                           , aoptions :: Options
+                           }
+                 deriving (Show, Eq)
+
+instance Ord Attribute where
+  a1 `compare` a2 = field a1 `compare` field a2
+
+-- | Represents any number of options for an item in an ER diagram.
+-- An item may be the graph title, an entity, an entity header or a
+-- relationship between entities. Keys are options as specified in ER files.
+--
+-- Note that a set of options may include a label for any item.
+type Options = M.Map String Option
+
+-- | Given two sets of options, merge the second into first, where elements
+-- in the first take precedence.
+mergeOpts :: Options -> Options -> Options
+mergeOpts opts1 opts2 = opts1 `M.union` opts2
+
+-- | Given a set of options and a selector function, return the list of
+-- only those options which matched. Examples of the selector function are
+-- `optToFont`, `optToHtml` and `optToLabel`.
+optionsTo :: (Option -> Maybe a) -> Options -> [a]
+optionsTo f = mapMaybe f . M.elems
+
+-- | A restricted subset of options in GraphViz that can be configured in
+-- an ER file.
+data Option = Label String
+            | BgColor Color
+            | Color Color
+            | FontFace Text
+            | FontSize Double
+            | Border Word8
+            | BorderColor Color
+            | CellSpacing Word8
+            | CellBorder Word8
+            | CellPadding Word8
+            deriving (Show, Eq)
+
+-- | Given an option name and a string representation of its value,
+-- `optionByName` will attempt to parse the string as a value corresponding
+-- to the option. If the option doesn't exist or there was a problem parsing
+-- the value, an error is returned.
+optionByName :: String -> String -> Either String Option
+optionByName "label" = Right . Label
+optionByName "color" = optionParse Color
+optionByName "bgcolor" = optionParse BgColor
+optionByName "size" = optionParse FontSize
+optionByName "font" = optionParse FontFace
+optionByName "border" = optionParse Border
+optionByName "border-color" = optionParse BorderColor
+optionByName "cellspacing" = optionParse CellSpacing
+optionByName "cellborder" = optionParse CellBorder
+optionByName "cellpadding" = optionParse CellPadding
+optionByName unk = const (Left $ printf "Option '%s' does not exist." unk)
+
+-- | A wrapper around the GraphViz's parser for any particular option.
+optionParse :: ParseDot a => (a -> Option) -> String -> Either String Option
+optionParse con s =
+  case fst $ runParser parse quoted of
+    Left err -> Left (printf "%s (bad value '%s')" err s)
+    Right a -> Right (con a)
+  where quoted = "\"" `append` pack s `append` "\""
+
+-- | Selects an option if and only if it corresponds to a font attribute.
+optToFont :: Option -> Maybe H.Attribute
+optToFont (Color c) = Just $ H.Color c
+optToFont (FontFace s) = Just $ H.Face s
+optToFont (FontSize d) = Just $ H.PointSize d
+optToFont _ = Nothing
+
+-- | Selects an option if and only if it corresponds to an HTML attribute.
+-- In particular, for tables or table cells.
+optToHtml :: Option -> Maybe H.Attribute
+optToHtml (BgColor c) = Just $ H.BGColor c
+optToHtml (Border w) = Just $ H.Border w
+optToHtml (BorderColor c) = Just $ H.Color c
+optToHtml (CellSpacing w) = Just $ H.CellSpacing w
+optToHtml (CellBorder w) = Just $ H.CellBorder w
+optToHtml (CellPadding w) = Just $ H.CellPadding w
+optToHtml _ = Nothing
+
+-- | Selects an option if and only if it corresponds to a label.
+optToLabel :: Option -> Maybe Text
+optToLabel (Label s) = Just $ pack s
+optToLabel _ = Nothing
+
+-- | Represents a relationship between exactly two entities. After parsing,
+-- each `rel` is guaranteed to correspond to an entity defined in the same
+-- ER file.
+--
+-- Each relationship has one of four cardinalities specified for both entities.
+-- Those cardinalities are: 0 or 1, exactly 1, 0 or more and 1 or more.
+data Relation = Relation { entity1, entity2 :: Text
+                         , card1,   card2   :: Cardinality
+                         , roptions :: Options
+                         }
+                deriving (Show, Eq)
+
+data Cardinality = ZeroOne
+                 | One
+                 | ZeroPlus
+                 | OnePlus deriving (Eq)
+
+instance Show Cardinality where
+  show ZeroOne = "{0,1}"
+  show One = "1"
+  show ZeroPlus = "0..N"
+  show OnePlus ="1..N"
+
+-- | Maps a string representation to a particular relationship cardinality.
+cardByName :: Char -> Maybe Cardinality
+cardByName '?' = Just ZeroOne
+cardByName '1' = Just One
+cardByName '*' = Just ZeroPlus
+cardByName '+' = Just OnePlus
+cardByName _ = Nothing
+
+-- | Hard-coded default options for all graph titles.
+defaultTitleOpts :: Options
+defaultTitleOpts = M.fromList
+  [ ("size", FontSize 30)
+  ]
+
+-- | Hard-coded default options for all entity headers.
+defaultHeaderOpts :: Options
+defaultHeaderOpts = M.fromList
+  [ ("size", FontSize 16)
+  ]
+
+-- | Hard-coded default options for all entities.
+defaultEntityOpts :: Options
+defaultEntityOpts = M.fromList
+  [ ("border", Border 0)
+  , ("cellborder", CellBorder 1)
+  , ("cellspacing", CellSpacing 0)
+  , ("cellpadding", CellPadding 4)
+  , ("font", FontFace "Helvetica")
+  ]
+
+-- | Hard-coded default options for all relationships.
+defaultRelOpts :: Options
+defaultRelOpts = M.empty
diff --git a/src/Erd/Parse.hs b/src/Erd/Parse.hs
new file mode 100644
--- /dev/null
+++ b/src/Erd/Parse.hs
@@ -0,0 +1,78 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Erd.Parse
+  ( loadER
+  )
+where
+
+import Control.Monad (when)
+import Data.List (find)
+import Data.Maybe
+import Data.Text.Lazy hiding (find, map, reverse)
+import Data.Text.Lazy.IO
+import System.IO (Handle)
+import Text.Parsec
+import Text.Printf (printf)
+import Text.Parsec.Erd.Parser (AST(..), GlobalOptions(..), document)
+
+import Erd.ER
+
+loadER :: String -> Handle -> IO (Either String ER)
+loadER fpath f = do
+  s <- hGetContents f
+  case parse (do { (opts, ast) <- document; return $ toER opts ast}) fpath s of
+    Left err -> return $ Left $ show err
+    Right err@(Left _) -> return err
+    Right (Right er) -> return $ Right er
+
+-- | Converts a list of syntactic categories in an entity-relationship
+-- description to an ER representation. If there was a problem with the
+-- conversion, an error is reported. This includes checking that each
+-- relationship contains only valid entity names.
+--
+-- This preserves the ordering of the syntactic elements in the original
+-- description.
+toER :: GlobalOptions -> [AST] -> Either String ER
+toER gopts = toER' (ER [] [] title)
+  where title = gtoptions gopts `mergeOpts` defaultTitleOpts
+
+        toER' :: ER -> [AST] -> Either String ER
+        toER' er [] = Right (reversed er) >>= validRels
+        toER' (ER { entities = [] }) (A a:_) =
+          let name = show (field a)
+           in Left $ printf "Attribute '%s' comes before first entity." name
+        toER' er@(ER { entities = e':es }) (A a:xs) = do
+          let e = e' { attribs = a:attribs e' }
+          toER' (er { entities = e:es }) xs
+        toER' er@(ER { entities = es }) (E e:xs) = do
+          let opts = eoptions e
+                     `mergeOpts` geoptions gopts
+                     `mergeOpts` defaultEntityOpts
+          let hopts = eoptions e
+                      `mergeOpts` ghoptions gopts
+                      `mergeOpts` defaultHeaderOpts
+          toER' (er { entities = e { eoptions = opts, hoptions = hopts }:es}) xs
+        toER' er@(ER { rels = rs }) (R r:xs) = do
+          let opts = roptions r
+                     `mergeOpts` groptions gopts
+                     `mergeOpts` defaultRelOpts
+          toER' (er { rels = r { roptions = opts }:rs }) xs
+
+        reversed :: ER -> ER
+        reversed er@(ER { entities = es, rels = rs }) =
+          let es' = map (\e -> e { attribs = reverse (attribs e) }) es
+          in  er { entities = reverse es', rels = reverse rs }
+
+        validRels :: ER -> Either String ER
+        validRels er = validRels' (rels er) er
+
+        validRels' :: [Relation] -> ER -> Either String ER
+        validRels' [] er = return er
+        validRels' (r:_) er = do
+          let r1 = find (\e -> name e == entity1 r) (entities er)
+          let r2 = find (\e -> name e == entity2 r) (entities er)
+          let err getter = Left
+                             $ printf "Unknown entity '%s' in relationship."
+                             $ unpack $ getter r
+          when (isNothing r1) (err entity1)
+          when (isNothing r2) (err entity2)
+          return er
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,105 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module Main (main) where
-
-import Control.Monad (forM_)
-import qualified Data.ByteString as SB
-import Data.Maybe (fromMaybe)
-import qualified Data.Text.Lazy as L
-import System.Exit (exitFailure)
-import System.IO (hClose, hPutStrLn, stderr)
-import Text.Printf (printf)
-
-import Data.GraphViz
-import qualified Data.GraphViz.Attributes.Colors.X11 as C
-import qualified Data.GraphViz.Attributes.Complete as A
-import qualified Data.GraphViz.Attributes.HTML as H
-import qualified Data.GraphViz.Types.Generalised as G
-import Data.GraphViz.Types.Monadic
-
-import Config
-import ER
-import Parse
-
-main :: IO ()
-main = do
-  conf <- configIO
-  er' <- uncurry loadER (cin conf)
-  case er' of
-    Left err -> do
-      hPutStrLn stderr err
-      exitFailure
-    Right er -> let dotted = dotER er
-                    toFile h = SB.hGetContents h >>= SB.hPut (snd $ cout conf)
-                    fmt = fromMaybe Pdf (outfmt conf)
-                 in graphvizWithHandle Dot dotted fmt toFile
-  hClose (snd $ cin conf)
-  hClose (snd $ cout conf)
-
--- | Converts an entire ER-diagram from an ER file into a GraphViz graph.
-dotER :: ER -> G.DotGraph L.Text
-dotER er = graph' $ do
-  graphAttrs (graphTitle $ title er)
-  graphAttrs [A.RankDir A.FromLeft]
-  nodeAttrs [shape PlainText] -- recommended for HTML labels
-  edgeAttrs [ A.Color [A.toWC $ A.toColor C.Gray50] -- easier to read labels
-            , A.MinLen 2 -- give some breathing room
-            , A.Style [A.SItem A.Dashed []] -- easier to read labels, maybe?
-            ]
-  forM_ (entities er) $ \e ->
-    node (name e) [toLabel (htmlEntity e)]
-  forM_ (rels er) $ \r -> do
-    let opts = roptions r
-    let rlab = A.HtmlLabel . H.Text . htmlFont opts . L.pack . show
-    let (l1, l2) = (A.TailLabel $ rlab $ card1 r, A.HeadLabel $ rlab $ card2 r)
-    let label = A.Label $ A.HtmlLabel $ H.Text $ withLabelFmt " %s " opts []
-    edge (entity1 r) (entity2 r) [label, l1, l2]
-
--- | Converts a single entity to an HTML label.
-htmlEntity :: Entity -> H.Label
-htmlEntity e = H.Table H.HTable
-                 { H.tableFontAttrs = Just $ optionsTo optToFont $ eoptions e
-                 , H.tableAttrs = optionsTo optToHtml (eoptions e)
-                 , H.tableRows = rows
-                 }
-  where rows = headerRow : map htmlAttr (attribs e)
-        headerRow = H.Cells [H.LabelCell [] $ H.Text text]
-        text = withLabelFmt " [%s]" (hoptions e) $ bold hname
-        hname = htmlFont (hoptions e) (name e)
-        bold s = [H.Format H.Bold s]
-
--- | Converts a single attribute to an HTML table row.
-htmlAttr :: ER.Attribute -> H.Row
-htmlAttr a = H.Cells [cell]
-  where cell = H.LabelCell cellAttrs (H.Text $ withLabelFmt " [%s]" opts name)
-        name = fkfmt $ pkfmt $ htmlFont opts (field a)
-        pkfmt s = if pk a then [H.Format H.Underline s] else s
-        fkfmt s = if fk a then [H.Format H.Italics s] else s
-        cellAttrs = H.Align H.HLeft : optionsTo optToHtml opts
-        opts = aoptions a
-
--- | Formats HTML text with a label. The format string given should be
--- in `Data.Text.printf` style. (Only font options are used from the options
--- given.)
-withLabelFmt :: String -> Options -> H.Text -> H.Text
-withLabelFmt fmt opts s =
-  case optionsTo optToLabel opts of
-    (x:_) -> s ++ htmlFont opts (L.pack $ printf fmt $ L.unpack x)
-    _ -> s
-
--- | Formats an arbitrary string with the options given (using only font
--- attributes).
-htmlFont :: Options -> L.Text -> H.Text
-htmlFont opts s = [H.Font (optionsTo optToFont opts) [H.Str s]]
-
--- | Extracts and formats a graph title from the options given.
--- The options should be title options from an ER value.
--- If a title does not exist, an empty list is returned and `graphAttrs attrs`
--- should be a no-op.
-graphTitle :: Options -> [A.Attribute]
-graphTitle topts =
-  let glabel = optionsTo optToLabel topts
-  in if null glabel then [] else
-       [ A.LabelJust A.JLeft
-       , A.LabelLoc A.VTop
-       , A.Label $ A.HtmlLabel $ H.Text $ htmlFont topts (head glabel)
-       ]
diff --git a/src/Parse.hs b/src/Parse.hs
deleted file mode 100644
--- a/src/Parse.hs
+++ /dev/null
@@ -1,208 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-module Parse
-  ( loadER
-  )
-where
-
-import Prelude hiding (null)
-
-import Control.Monad (liftM2, when, void)
-import Data.Char (isAlphaNum, isSpace)
-import Data.List (find)
-import qualified Data.Map as M
-import Data.Maybe
-import Data.Text.Lazy hiding (find, map, reverse)
-import Data.Text.Lazy.IO
-import System.IO (Handle)
-import Text.Parsec
-import Text.Parsec.Text.Lazy
-import Text.Printf (printf)
-
-import ER
-
-data AST = E Entity
-         | A Attribute
-         | R Relation
-         deriving Show
-
-data GlobalOptions = GlobalOptions { gtoptions :: Options
-                                   , ghoptions :: Options
-                                   , geoptions :: Options
-                                   , groptions :: Options
-                                   }
-                     deriving Show
-
-emptyGlobalOptions :: GlobalOptions
-emptyGlobalOptions = GlobalOptions M.empty M.empty M.empty M.empty
-
-loadER :: String -> Handle -> IO (Either String ER)
-loadER fpath f = do
-  s <- hGetContents f
-  case parse (do { (opts, ast) <- document; return $ toER opts ast}) fpath s of
-    Left err -> return $ Left $ show err
-    Right err@(Left _) -> return err
-    Right (Right er) -> return $ Right er
-
--- | Converts a list of syntactic categories in an entity-relationship
--- description to an ER representation. If there was a problem with the
--- conversion, an error is reported. This includes checking that each
--- relationship contains only valid entity names.
---
--- This preserves the ordering of the syntactic elements in the original
--- description.
-toER :: GlobalOptions -> [AST] -> Either String ER
-toER gopts = toER' (ER [] [] title)
-  where title = gtoptions gopts `mergeOpts` defaultTitleOpts
-
-        toER' :: ER -> [AST] -> Either String ER
-        toER' er [] = Right (reversed er) >>= validRels
-        toER' (ER { entities = [] }) (A a:_) =
-          let name = show (field a)
-           in Left $ printf "Attribute '%s' comes before first entity." name
-        toER' er@(ER { entities = e':es }) (A a:xs) = do
-          let e = e' { attribs = a:attribs e' }
-          toER' (er { entities = e:es }) xs
-        toER' er@(ER { entities = es }) (E e:xs) = do
-          let opts = eoptions e
-                     `mergeOpts` geoptions gopts
-                     `mergeOpts` defaultEntityOpts
-          let hopts = eoptions e
-                      `mergeOpts` ghoptions gopts
-                      `mergeOpts` defaultHeaderOpts
-          toER' (er { entities = e { eoptions = opts, hoptions = hopts }:es}) xs
-        toER' er@(ER { rels = rs }) (R r:xs) = do
-          let opts = roptions r
-                     `mergeOpts` groptions gopts
-                     `mergeOpts` defaultRelOpts
-          toER' (er { rels = r { roptions = opts }:rs }) xs
-
-        reversed :: ER -> ER
-        reversed er@(ER { entities = es, rels = rs }) =
-          let es' = map (\e -> e { attribs = reverse (attribs e) }) es
-          in  er { entities = reverse es', rels = reverse rs }
-
-        validRels :: ER -> Either String ER
-        validRels er = validRels' (rels er) er
-
-        validRels' :: [Relation] -> ER -> Either String ER
-        validRels' [] er = return er
-        validRels' (r:_) er = do
-          let r1 = find (\e -> name e == entity1 r) (entities er)
-          let r2 = find (\e -> name e == entity2 r) (entities er)
-          let err getter = Left
-                             $ printf "Unknown entity '%s' in relationship."
-                             $ unpack $ getter r
-          when (isNothing r1) (err entity1)
-          when (isNothing r2) (err entity2)
-          return er
-
-document :: Parser (GlobalOptions, [AST])
-document = do skipMany (comment <|> blanks)
-              opts <- globalOptions emptyGlobalOptions
-              ast <- fmap catMaybes $ manyTill top eof
-              return (opts, ast)
-  where top = (entity <?> "entity declaration")
-              <|> (try rel <?> "relationship") -- must come before attr
-              <|> (try attr <?> "attribute")
-              <|> (comment <?> "comment")
-              <|> blanks
-        blanks = many1 (space <?> "whitespace") >> return Nothing
-
-entity :: Parser (Maybe AST)
-entity = do n <- between (char '[') (char ']') ident
-            spacesNoNew
-            opts <- options
-            eolComment
-            return $ Just $ E Entity { name = n, attribs = [],
-                                       hoptions = opts, eoptions = opts }
-
-attr :: Parser (Maybe AST)
-attr = do
-  keys <- many $ oneOf "*+ \t"
-  let (ispk, isfk) = ('*' `elem` keys, '+' `elem` keys)
-  n <- ident
-  opts <- options
-  eolComment
-  return
-    $ Just
-    $ A Attribute { field = n, pk = ispk, fk = isfk, aoptions = opts }
-
-rel :: Parser (Maybe AST)
-rel = do
-  let ops = "?1*+"
-  e1 <- ident
-  op1 <- oneOf ops
-  string "--"
-  op2 <- oneOf ops
-  e2 <- ident
-  opts <- options
-
-  let getCard op =
-        case cardByName op of
-          Just t -> return t
-          Nothing -> unexpected (printf "Cardinality '%s' does not exist." op)
-  t1 <- getCard op1
-  t2 <- getCard op2
-  return $ Just $ R Relation { entity1 = e1, entity2 = e2
-                               , card1 = t1, card2 = t2, roptions = opts }
-
-globalOptions :: GlobalOptions -> Parser GlobalOptions
-globalOptions gopts =
-  option gopts $ try $ do
-    n <- ident 
-    opts <- options
-    case n of
-      "title"        -> emptiness >> globalOptions (gopts { gtoptions = opts})
-      "header"       -> emptiness >> globalOptions (gopts { ghoptions = opts})
-      "entity"       -> emptiness >> globalOptions (gopts { geoptions = opts})
-      "relationship" -> emptiness >> globalOptions (gopts { groptions = opts})
-      _ -> fail "not a valid directive"
-
-options :: Parser (M.Map String Option)
-options =
-  option M.empty
-    $ fmap M.fromList
-    $ try
-    $ between (char '{' >> emptiness) (emptiness >> char '}')
-    $ opt `sepEndBy` (emptiness >> char ',' >> emptiness)
-
-opt :: Parser (String, Option)
-opt = do
-  name <- liftM2 (:) letter (manyTill (letter <|> char '-') (char ':'))
-          <?> "option name"
-  emptiness
-  value <- between (char '"') (char '"') (many $ noneOf "\"")
-           <?> "option value"
-  case optionByName name value of
-    Left err -> fail err
-    Right o' -> emptiness >> return (name, o')
-
-comment :: Parser (Maybe AST)
-comment = do
-  char '#'
-  manyTill anyChar $ try eol
-  return Nothing
-
-ident :: Parser Text
-ident = do
-  spacesNoNew
-  let p = satisfy (\c -> c == '_' || isAlphaNum c)
-            <?> "letter, digit or underscore"
-  n <- fmap pack (many1 p)
-  spacesNoNew
-  return n
-
-emptiness :: Parser ()
-emptiness = skipMany (void (many1 space) <|> eolComment)
-
-eolComment :: Parser ()
-eolComment = spacesNoNew >> (eol <|> void comment)
-
-spacesNoNew :: Parser ()
-spacesNoNew = skipMany $ satisfy $ \c -> c /= '\n' && c /= '\r' && isSpace c
-
-eol :: Parser ()
-eol = eof <|> do
-  c <- oneOf "\n\r"
-  when (c == '\r') $ optional $ char '\n'
-
diff --git a/src/Text/Parsec/Erd/Parser.hs b/src/Text/Parsec/Erd/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Parsec/Erd/Parser.hs
@@ -0,0 +1,163 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Text.Parsec.Erd.Parser
+  (
+    AST(..),
+    GlobalOptions(..),
+    document,
+    globalOptions,
+    entity,
+    rel,
+    attr,
+    comment,
+  )
+where
+
+import Control.Monad (liftM2, when, void)
+import Data.Char (isAlphaNum, isSpace, isControl)
+import qualified Data.Map as M
+import Data.Maybe
+import Data.Text.Lazy
+import Text.Parsec
+import Text.Parsec.Text.Lazy
+import Text.Printf (printf)
+
+import Erd.ER
+
+data AST = E Entity
+         | A Attribute
+         | R Relation
+         deriving (Show, Eq)
+
+data GlobalOptions = GlobalOptions { gtoptions :: Options
+                                   , ghoptions :: Options
+                                   , geoptions :: Options
+                                   , groptions :: Options
+                                   }
+                     deriving (Show, Eq)
+
+emptyGlobalOptions :: GlobalOptions
+emptyGlobalOptions = GlobalOptions M.empty M.empty M.empty M.empty
+
+document :: Parser (GlobalOptions, [AST])
+document = do skipMany (comment <|> blanks)
+              opts <- globalOptions emptyGlobalOptions
+              ast <-  catMaybes <$> manyTill top eof
+              return (opts, ast)
+  where top = (entity <?> "entity declaration")
+              <|> (try rel <?> "relationship") -- must come before attr
+              <|> (try attr <?> "attribute")
+              <|> (comment <?> "comment")
+              <|> blanks
+        blanks = many1 (space <?> "whitespace") >> return Nothing
+
+entity :: Parser (Maybe AST)
+entity = do n <- between (char '[') (char ']') ident
+            spacesNoNew
+            opts <- options
+            eolComment
+            return $ Just $ E Entity { name = n, attribs = [],
+                                       hoptions = opts, eoptions = opts }
+
+attr :: Parser (Maybe AST)
+attr = do
+  keys <- many $ oneOf "*+ \t"
+  let (ispk, isfk) = ('*' `elem` keys, '+' `elem` keys)
+  n <- ident
+  opts <- options
+  eolComment
+  return
+    $ Just
+    $ A Attribute { field = n, pk = ispk, fk = isfk, aoptions = opts }
+
+rel :: Parser (Maybe AST)
+rel = do
+  let ops = "?1*+"
+  e1 <- ident
+  op1 <- oneOf ops
+  string "--"
+  op2 <- oneOf ops
+  e2 <- ident
+  opts <- options
+
+  let getCard op =
+        case cardByName op of
+          Just t -> return t
+          Nothing -> unexpected (printf "Cardinality '%s' does not exist." op)
+  t1 <- getCard op1
+  t2 <- getCard op2
+  return $ Just $ R Relation { entity1 = e1, entity2 = e2
+                               , card1 = t1, card2 = t2, roptions = opts }
+
+globalOptions :: GlobalOptions -> Parser GlobalOptions
+globalOptions gopts =
+  option gopts $ try $ do
+    n <- ident 
+    opts <- options
+    case n of
+      "title"        -> emptiness >> globalOptions (gopts { gtoptions = opts})
+      "header"       -> emptiness >> globalOptions (gopts { ghoptions = opts})
+      "entity"       -> emptiness >> globalOptions (gopts { geoptions = opts})
+      "relationship" -> emptiness >> globalOptions (gopts { groptions = opts})
+      _ -> fail "not a valid directive"
+
+options :: Parser (M.Map String Option)
+options =
+  option M.empty
+    $ fmap M.fromList
+    $ try
+    $ between (char '{' >> emptiness) (emptiness >> char '}')
+    $ opt `sepEndBy` (emptiness >> char ',' >> emptiness)
+
+opt :: Parser (String, Option)
+opt = do
+  name <- liftM2 (:) letter (manyTill (letter <|> char '-') (char ':'))
+          <?> "option name"
+  emptiness
+  value <- between (char '"') (char '"') (many $ noneOf "\"")
+           <?> "option value"
+  case optionByName name value of
+    Left err -> fail err
+    Right o' -> emptiness >> return (name, o')
+
+comment :: Parser (Maybe AST)
+comment = do
+  char '#'
+  manyTill anyChar $ try eol
+  return Nothing
+
+ident :: Parser Text
+ident = do
+  spacesNoNew
+  n <- identQuoted <|> identNoSpace
+  spacesNoNew
+  return n
+
+identQuoted :: Parser Text
+identQuoted = do
+  quote <- oneOf "'\"`"
+  let p = satisfy (\c -> c /= quote && not (isControl c) )
+            <?> "any character except " ++ [quote] ++ " or control characters"
+  n <- fmap pack (many1 p)
+  char quote
+  return n
+
+identNoSpace :: Parser Text
+identNoSpace = do
+  let p = satisfy (\c -> c == '_' || isAlphaNum c)
+            <?> "letter, digit or underscore"
+  fmap pack (many1 p)
+
+emptiness :: Parser ()
+emptiness = skipMany (void (many1 space) <|> eolComment)
+
+eolComment :: Parser ()
+eolComment = spacesNoNew >> (eol <|> void comment)
+
+spacesNoNew :: Parser ()
+spacesNoNew = skipMany $ satisfy $ \c -> c /= '\n' && c /= '\r' && isSpace c
+
+eol :: Parser ()
+eol = eof <|> do
+  c <- oneOf "\n\r"
+  when (c == '\r') $ optional $ char '\n'
+
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,9 @@
+module Main (main) where
+import Test.Tasty
+import Test.Text.Parsec.Erd.Parser (testEr)
+
+main :: IO ()
+main = defaultMain $ testGroup "Erd Tests"  tests
+
+tests :: [TestTree]
+tests = [testEr]
diff --git a/test/Test/Text/Parsec/Erd/Parser.hs b/test/Test/Text/Parsec/Erd/Parser.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Text/Parsec/Erd/Parser.hs
@@ -0,0 +1,275 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+module Test.Text.Parsec.Erd.Parser
+  (testEr)
+  where
+import Test.Tasty
+import Test.Tasty.HUnit
+import Text.RawString.QQ (r)
+import Data.Text  (Text)
+import Data.Text.Lazy (fromStrict)
+import Text.Parsec (parse)
+import qualified Data.Map as M
+import Data.Map (fromList)
+import Erd.ER
+import Text.Parsec.Erd.Parser (document, AST(..), GlobalOptions(..))
+import Data.GraphViz.Attributes.Colors (Color(..))
+
+parseDoc :: Text -> (GlobalOptions, [AST]) -> Assertion
+parseDoc input expect= Right expect `shouldBe` parse document "" (fromStrict input) where
+  shouldBe = assertEqual ""
+
+testEr :: TestTree
+testEr = testGroup "Parse Er" [
+  testCase "Parse Simple case" $ parseDoc simpleText simpleResult,
+  testCase "Parse nfldb case" $ parseDoc nfldbText nfldbResult
+                              ]
+
+
+simpleText :: Text
+simpleText = [r|
+# Entities are declared in '[' ... ']'. All attributes after the entity header
+# up until the end of the file (or the next entity declaration) correspond
+# to this entity.
+[Person]
+*name
+height
+weight
+`birth date`
++birth_place_id
+
+[`Birth Place`]
+*id
+`birth city`
+'birth state'
+"birth country"
+
+# Each relationship must be between exactly two entities, which need not
+# be distinct. Each entity in the relationship has exactly one of four
+# possible cardinalities:
+#
+# Cardinality    Syntax
+# 0 or 1         ?
+# exactly 1      1
+# 0 or more      *
+# 1 or more      +
+Person *--1 `Birth Place`
+|]
+
+simpleResult :: (GlobalOptions, [AST])
+simpleResult = (opts, asts) where
+  opts = GlobalOptions M.empty M.empty M.empty M.empty
+  asts = [
+    E (Entity {name = "Person", attribs = [], hoptions = fromList [], eoptions = fromList []}),
+    A (Attribute {field = "name", pk = True, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "height", pk = False, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "weight", pk = False, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "birth date", pk = False, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "birth_place_id", pk = False, fk = True, aoptions = fromList []}),
+    E (Entity {name = "Birth Place", attribs = [], hoptions = fromList [], eoptions = fromList []}),
+    A (Attribute {field = "id", pk = True, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "birth city", pk = False, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "birth state", pk = False, fk = False, aoptions = fromList []}),
+    A (Attribute {field = "birth country", pk = False, fk = False, aoptions = fromList []}),
+    R (Relation {entity1 = "Person", entity2 = "Birth Place", card1 = ZeroPlus , card2 = One, roptions = fromList []})]
+
+nfldbText :: Text
+nfldbText = [r|
+title {label: "nfldb Entity-Relationship diagram (condensed)", size: "20"}
+
+# Nice colors from Erwiz:
+# red #fcecec
+# blue #ececfc
+# green #d0e0d0
+# yellow #fbfbdb
+# orange #eee0a0
+
+# Entities
+
+[player] {bgcolor: "#d0e0d0"}
+  *player_id {label: "varchar, not null"}
+  full_name {label: "varchar, null"}
+  team {label: "varchar, not null"}
+  position {label: "player_pos, not null"}
+  status {label: "player_status, not null"}
+
+[team] {bgcolor: "#d0e0d0"}
+  *team_id {label: "varchar, not null"}
+  city {label: "varchar, not null"}
+  name {label: "varchar, not null"}
+
+[game] {bgcolor: "#ececfc"}
+  *gsis_id {label: "gameid, not null"}
+  start_time {label: "utctime, not null"}
+  week {label: "usmallint, not null"}
+  season_year {label: "usmallint, not null"}
+  season_type {label: "season_phase, not null"}
+  finished {label: "boolean, not null"}
+  home_team {label: "varchar, not null"}
+  home_score {label: "usmallint, not null"}
+  away_team {label: "varchar, not null"}
+  away_score {label: "usmallint, not null"}
+
+[drive] {bgcolor: "#ececfc"}
+  *+gsis_id {label: "gameid, not null"}
+  *drive_id {label: "usmallint, not null"}
+  start_field {label: "field_pos, null"}
+  start_time {label: "game_time, not null"}
+  end_field {label: "field_pos, null"}
+  end_time {label: "game_time, not null"}
+  pos_team {label: "varchar, not null"}
+  pos_time {label: "pos_period, null"}
+
+[play] {bgcolor: "#ececfc"}
+  *+gsis_id {label: "gameid, not null"}
+  *+drive_id {label: "usmallint, not null"}
+  *play_id {label: "usmallint, not null"}
+  time {label: "game_time, not null"}
+  pos_team {label: "varchar, not null"}
+  yardline {label: "field_pos, null"}
+  down {label: "smallint, null"}
+  yards_to_go {label: "smallint, null"}
+
+[play_player] {bgcolor: "#ececfc"}
+  *+gsis_id {label: "gameid, not null"}
+  *+drive_id {label: "usmallint, not null"}
+  *+play_id {label: "usmallint, not null"}
+  *+player_id {label: "varchar, not null"}
+  team {label: "varchar, not null"}
+
+[meta] {bgcolor: "#fcecec"}
+  version {label: "smallint, null"}
+  season_type {label: "season_phase, null"}
+  season_year {label: "usmallint, null"}
+  week {label: "usmallint, null"}
+
+# Relationships
+
+player      *--1 team
+game        *--1 team {label: "home"}
+game        *--1 team {label: "away"}
+drive       *--1 team
+play        *--1 team
+play_player *--1 team
+
+game        1--* drive
+game        1--* play
+game        1--* play_player
+
+drive       1--* play
+drive       1--* play_player
+
+play        1--* play_player
+
+player      1--* play_player
+|]
+
+
+data ChunckAST = CE [Entity] | CA [Attribute] | CR [Relation] deriving (Eq)
+toAST :: ChunckAST -> [AST]
+toAST (CE x) = map E x
+toAST (CA x) = map A x
+toAST (CR x) = map R x
+
+nfldbResult :: (GlobalOptions, [AST])
+nfldbResult = (opts, asts) where
+  opts = GlobalOptions {gtoptions = fromList [("label",Label "nfldb Entity-Relationship diagram (condensed)"),("size",FontSize 20.0)], ghoptions = fromList [], geoptions = fromList [], groptions = fromList []}
+  asts = concatMap toAST $ entities:(attributes ++ [relations])
+  entities = CE [
+    Entity {name = "player", attribs = [],
+      hoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))],
+      eoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))]
+           }
+    ]
+  attributes = [
+    CA [
+        Attribute {field = "player_id", pk = True, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "full_name", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, null")]},
+        Attribute {field = "team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "position", pk = False, fk = False, aoptions = fromList [("label",Label "player_pos, not null")]},
+        Attribute {field = "status", pk = False, fk = False, aoptions = fromList [("label",Label "player_status, not null")]}
+      ],
+    CE [
+        Entity {name = "team", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))]}
+      ],
+    CA [
+        Attribute {field = "team_id", pk = True, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "city", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "name", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]}
+      ],
+    CE [
+        Entity {name = "game", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}
+      ],
+    CA [
+        Attribute {field = "gsis_id", pk = True, fk = False, aoptions = fromList [("label",Label "gameid, not null")]},
+        Attribute {field = "start_time", pk = False, fk = False, aoptions = fromList [("label",Label "utctime, not null")]},
+        Attribute {field = "week", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "season_year", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "season_type", pk = False, fk = False, aoptions = fromList [("label",Label "season_phase, not null")]},
+        Attribute {field = "finished", pk = False, fk = False, aoptions = fromList [("label",Label "boolean, not null")]},
+        Attribute {field = "home_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "home_score", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "away_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "away_score", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]}
+      ],
+    CE [
+        Entity {name = "drive", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}
+      ],
+    CA [
+        Attribute {field = "gsis_id", pk = True, fk = True, aoptions = fromList [("label",Label "gameid, not null")]},
+        Attribute {field = "drive_id", pk = True, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "start_field", pk = False, fk = False, aoptions = fromList [("label",Label "field_pos, null")]},
+        Attribute {field = "start_time", pk = False, fk = False, aoptions = fromList [("label",Label "game_time, not null")]},
+        Attribute {field = "end_field", pk = False, fk = False, aoptions = fromList [("label",Label "field_pos, null")]},
+        Attribute {field = "end_time", pk = False, fk = False, aoptions = fromList [("label",Label "game_time, not null")]},
+        Attribute {field = "pos_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "pos_time", pk = False, fk = False, aoptions = fromList [("label",Label "pos_period, null")]}
+      ],
+    CE [
+        Entity {name = "play", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}
+      ],
+    CA [
+        Attribute {field = "gsis_id", pk = True, fk = True, aoptions = fromList [("label",Label "gameid, not null")]},
+        Attribute {field = "drive_id", pk = True, fk = True, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "play_id", pk = True, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "time", pk = False, fk = False, aoptions = fromList [("label",Label "game_time, not null")]},
+        Attribute {field = "pos_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "yardline", pk = False, fk = False, aoptions = fromList [("label",Label "field_pos, null")]},
+        Attribute {field = "down", pk = False, fk = False, aoptions = fromList [("label",Label "smallint, null")]},
+        Attribute {field = "yards_to_go", pk = False, fk = False, aoptions = fromList [("label",Label "smallint, null")]}
+      ],
+    CE [
+        Entity {name = "play_player", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}
+      ],
+    CA [
+        Attribute {field = "gsis_id", pk = True, fk = True, aoptions = fromList [("label",Label "gameid, not null")]},
+        Attribute {field = "drive_id", pk = True, fk = True, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "play_id", pk = True, fk = True, aoptions = fromList [("label",Label "usmallint, not null")]},
+        Attribute {field = "player_id", pk = True, fk = True, aoptions = fromList [("label",Label "varchar, not null")]},
+        Attribute {field = "team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]}
+     ],
+     CE [
+        Entity {name = "meta", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 252, green = 236, blue = 236}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 252, green = 236, blue = 236}))]}
+       ],
+    CA [
+        Attribute {field = "version", pk = False, fk = False, aoptions = fromList [("label",Label "smallint, null")]},
+        Attribute {field = "season_type", pk = False, fk = False, aoptions = fromList [("label",Label "season_phase, null")]},
+        Attribute {field = "season_year", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, null")]},
+        Attribute {field = "week", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, null")]}
+      ]
+    ]
+  relations = CR [
+     Relation {entity1 = "player", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},
+     Relation {entity1 = "game", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList [("label",Label "home")]},
+     Relation {entity1 = "game", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList [("label",Label "away")]},
+     Relation {entity1 = "drive", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},
+     Relation {entity1 = "play", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},
+     Relation {entity1 = "play_player", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},
+     Relation {entity1 = "game", entity2 = "drive", card1 = One, card2 = ZeroPlus, roptions = fromList []},
+     Relation {entity1 = "game", entity2 = "play", card1 = One, card2 = ZeroPlus, roptions = fromList []},
+     Relation {entity1 = "game", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []},
+     Relation {entity1 = "drive", entity2 = "play", card1 = One, card2 = ZeroPlus, roptions = fromList []},
+     Relation {entity1 = "drive", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []},
+     Relation {entity1 = "play", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []},
+     Relation {entity1 = "player", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []}
+               ]
