diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright Luc Tielen (c) 2021
+
+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 Luc Tielen 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,84 @@
+# debugger-hs
+
+A Haskell library for creating/metaprogramming GDB scripts.
+
+## Why?
+
+GDB has Python integration, and while it is powerful, it has some problems:
+
+- Python code can end up interleaved in the GDB code, making it complicated
+  really quickly.
+- Accessing variables between GDB and Python can get complicated too.
+
+Besides that, also some things in GDB don't work as expected, such as commands
+that behave differently inside a user-defined command ("define"), which can lead
+to unexpected and buggy results.
+
+If we use Haskell however, we get the following nice things:
+
+- A typesystem,
+- Can use full Haskell ecosystem for metaprogramming,
+- The generated output will be 100% GDB script only:
+  - easier to use afterwards,
+  - inspectable,
+  - no need for this package once the script has been generated.
+
+## How?
+
+Partial evaluation/staged programming. You write a Haskell file that makes use
+of the DSL this package provides, and render it to a script that can be
+passed in to GDB like you would normally.
+
+Here's an example of how you can use this library to generate a GDB script:
+
+```haskell
+module Main where
+
+import qualified Debugger.Builder as D
+import qualified Debugger.Render as D
+import qualified Debugger.Statement as D
+
+-- First we build up a Haskell value that represents our GDB scripts.
+script :: D.Builder ()
+script = do
+  bp <- D.break (D.Function "main")
+  D.command bp $ do
+    D.print "42"
+    D.continue
+
+-- And then we can render the GDB script to a file:
+main :: IO ()
+main = do
+  let gdbScript = D.runBuilder script
+  D.renderIO gdbScript "./script.gdb"
+```
+
+This will render the following GDB scripts:
+
+```gdb
+break main
+set $var0 = $bpnum
+command $var0
+  print "42"
+  continue
+end
+```
+
+```bash
+# Replace $PROGRAM with the program you want to debug.
+$ gdb $PROGRAM <<< $(< /path/to/stack-script)
+# or if your shell doesn't support the previous command:
+$ /path/to/stack-script > ./script.gdb
+$ gdb $PROGRAM -ex "source ./script.gdb"
+```
+
+## TODO
+
+- [x] Create core AST datatype
+- [x] Create builder monad for easily constructing GDB scripts using Haskell do-syntax.
+- [x] Write function for compiling AST -> GDB script
+- [ ] Create CLI application (stack/nix?) that takes the DSL and outputs GDB to stdout.
+- [ ] Add helper functions for easily adding breakpoints (using tools like grep)
+- [ ] Extend core AST datatype to support more functionality
+- [ ] Add LLDB support also?
+- [ ] DSL (to support GDB and LLDB at same time)?
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/debugger-hs.cabal b/debugger-hs.cabal
new file mode 100644
--- /dev/null
+++ b/debugger-hs.cabal
@@ -0,0 +1,76 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.34.4.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: ab9171f6a3d676dafad59e27a5c0982d34707fbc6d4ed681346b09e602b2946c
+
+name:           debugger-hs
+version:        0.1.0.0
+synopsis:       Write your GDB scripts in Haskell.
+description:    Please see the README on GitHub at <https://github.com/luc-tielen/debugger-hs#readme>
+category:       debugging
+homepage:       https://github.com/luc-tielen/debugger-hs#readme
+bug-reports:    https://github.com/luc-tielen/debugger-hs/issues
+author:         Luc Tielen
+maintainer:     luc.tielen@gmail.com
+copyright:      Luc Tielen, 2021
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+
+source-repository head
+  type: git
+  location: https://github.com/luc-tielen/debugger-hs
+
+library
+  exposed-modules:
+      Debugger.Builder
+      Debugger.Render
+      Debugger.Statement
+      Lib
+  other-modules:
+      Paths_debugger_hs
+  hs-source-dirs:
+      src
+  default-extensions:
+      DerivingVia
+      LambdaCase
+      OverloadedStrings
+      FlexibleContexts
+      FlexibleInstances
+  ghc-options: -Wall
+  build-depends:
+      base >=4.7 && <5
+    , dlist ==1.*
+    , mtl ==2.*
+    , text ==1.*
+  default-language: Haskell2010
+
+test-suite debugger-hs-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Test.Debugger.BuilderSpec
+      Test.Debugger.RenderSpec
+      Paths_debugger_hs
+  hs-source-dirs:
+      test
+  default-extensions:
+      DerivingVia
+      LambdaCase
+      OverloadedStrings
+      FlexibleContexts
+      FlexibleInstances
+  ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , debugger-hs
+    , hspec >=2.6.1 && <3.0.0
+    , mtl ==2.*
+    , neat-interpolation ==0.*
+    , text ==1.*
+  default-language: Haskell2010
diff --git a/src/Debugger/Builder.hs b/src/Debugger/Builder.hs
new file mode 100644
--- /dev/null
+++ b/src/Debugger/Builder.hs
@@ -0,0 +1,164 @@
+-- NOTE: meant to be imported qualified
+module Debugger.Builder
+  ( Builder
+  , ToSelection(..)
+  , runBuilder
+  , break
+  , command
+  , continue
+  , step
+  , stepN
+  , next
+  , nextN
+  , run
+  , reset
+  , print
+  , set
+  , call
+  , delete
+  , disable
+  , enable
+  , source
+  , shell
+  , target
+  , info
+  ) where
+
+import Prelude hiding (break, print)
+import Control.Monad.State.Strict
+import qualified Data.Text as T
+import qualified Data.DList as DList
+import Debugger.Statement
+
+
+type Counter = Int
+
+data BuilderState
+  = BuilderState
+  { stmts :: DList.DList Statement
+  , varCounter :: Counter
+  }
+
+-- | Builder pattern that allows using monadic do-syntax to build a GDB script.
+newtype Builder a
+  = Builder (State BuilderState a)
+  deriving (Functor, Applicative, Monad, MonadState BuilderState)
+  via State BuilderState
+
+
+-- | Creates a GDB script based on a builder.
+runBuilder :: Builder a -> Script
+runBuilder = runBuilder' 0
+
+runBuilder' :: Counter -> Builder a -> [Statement]
+runBuilder' counter (Builder m) =
+  let result = execState m (BuilderState DList.empty counter)
+   in DList.apply (stmts result) []
+
+-- | Emits a breakpoint statement.
+--   Returns the 'Id' that corresponds with this newly set breakpoint.
+break :: Location -> Builder Id
+break loc = do
+  emit $ Break loc
+  var <- newBreakpointVar
+  set var "$bpnum"
+  pure $ Id var
+
+-- | Emits a command statement, that should be triggered when a breakpoint is triggered.
+command :: Id -> Builder () -> Builder ()
+command bp cmds = do
+  counter <- gets varCounter
+  let statements = runBuilder' counter cmds
+  emit $ Command bp statements
+
+-- | Emits a continue statement.
+continue :: Builder ()
+continue = emit Continue
+
+-- | Emits a run statement.
+run :: Builder ()
+run = emit Run
+
+-- | Emits a reset statement.
+reset :: Builder ()
+reset = emit Reset
+
+-- | Emits a print statement.
+print :: Expr -> Builder ()
+print = emit . Print
+
+-- | Emits a set statement.
+set :: Var -> Expr -> Builder ()
+set var value = emit $ Set var value
+
+-- | Emits a call statement.
+call :: Expr -> Builder ()
+call = emit . Call
+
+-- | Helper typeclass, used for manipulating 1, many, or all 'Id's.
+class ToSelection a where
+  toSelection :: a -> Selection
+
+instance ToSelection Id where
+  toSelection = Single
+
+instance ToSelection [Id] where
+  toSelection = Many
+
+instance ToSelection Selection where
+  toSelection = id
+
+-- | Emits a delete statement.
+delete :: ToSelection a => a -> Builder ()
+delete = emit . Delete . toSelection
+
+-- | Emits a disable statement.
+disable :: ToSelection a => a -> Builder ()
+disable = emit . Disable . toSelection
+
+-- | Emits an enable statement.
+enable = emit . Enable . toSelection
+enable :: ToSelection a => a -> Builder ()
+
+-- | Emits a "next" statement. If you want to repeat next N times, use 'nextN'.
+next :: Builder ()
+next = emit $ Next Nothing
+
+-- | Emits a "next" statement that is repeated N times.
+nextN :: Int -> Builder ()
+nextN = emit . Next . Just
+
+-- | Emits a step statement. If you want to step N times, use 'stepN'.
+step :: Builder ()
+step = emit $ Step Nothing
+
+-- | Emits a step statement that is repeated N times.
+stepN :: Int -> Builder ()
+stepN = emit . Step . Just
+
+-- | Emits a shell statement.
+shell :: ShellCommand -> Builder ()
+shell = emit . Shell
+
+-- | Emits a source statement.
+source :: FilePath -> Builder ()
+source = emit . Source
+
+-- | Emits a target statement.
+target :: TargetConfig -> Builder ()
+target = emit . Target
+
+-- | Emits an info statement.
+info :: InfoOptions -> Builder ()
+info = emit . Info
+
+emit :: Statement -> Builder ()
+emit stmt =
+  modify $ \s -> s { stmts = DList.snoc (stmts s) stmt }
+
+newBreakpointVar :: Builder Var
+newBreakpointVar = do
+  currentId <- gets varCounter
+  modify $ \s -> s { varCounter = varCounter s + 1 }
+  pure $ "$var" <> T.pack (show currentId)
+
diff --git a/src/Debugger/Render.hs b/src/Debugger/Render.hs
new file mode 100644
--- /dev/null
+++ b/src/Debugger/Render.hs
@@ -0,0 +1,104 @@
+module Debugger.Render
+  ( renderIO
+  , renderScript
+  ) where
+
+import Control.Monad.Reader
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+import Debugger.Statement
+
+
+-- | Renders a GDB script and writes it to the given file path.
+renderIO :: Script -> FilePath -> IO ()
+renderIO script path =
+  let txt = renderScript script
+   in TIO.writeFile path txt
+
+-- | Renders a GDB script
+renderScript :: Script -> T.Text
+renderScript script =
+  interleaveNewlines $ map render script
+
+render :: Statement -> T.Text
+render stmt = runReader (go stmt) 0
+  where
+    go = \case
+      Break loc ->
+        pure $ "break " <> renderLoc loc
+      Command bp stmts -> do
+        block <- local (+2) $ traverse (indent <=< go) stmts
+        -- need to indent the end explicitly, because lines will be joined
+        -- together before returning and only start is indented
+        end <- indent "end"
+        pure $ interleaveNewlines
+          [ "command " <> renderId bp
+          , interleaveNewlines block
+          , end
+          ]
+      Continue ->
+        pure "continue"
+      Next count ->
+        pure $ "next" <> renderCount count
+      Step count ->
+        pure $ "step" <> renderCount count
+      Run ->
+        pure "run"
+      Reset ->
+        pure "monitor reset"
+      Set var expr ->
+        pure $ T.unwords ["set", var, "=", expr]
+      Call expr ->
+        pure $ T.unwords ["call", expr]
+      Print val ->
+        pure $ "print " <> "\"" <> val <> "\""
+      Delete sel ->
+        pure $ T.strip $ "delete " <> renderSelection sel
+      Disable sel ->
+        pure $ T.strip $ "disable " <> renderSelection sel
+      Enable sel ->
+        pure $ T.strip $ "enable " <> renderSelection sel
+      Shell cmd ->
+        pure $ "shell " <> cmd
+      Source file ->
+        pure $ "source " <> T.pack file
+      Target target ->
+        pure $ "target " <> renderTargetConfig target
+      Info opts ->
+        pure $ "info " <> renderInfoOpts opts
+
+    indent txt = do
+      spaces <- ask
+      let indentation = T.replicate spaces " "
+      pure $ indentation <> txt
+
+
+renderId :: Id -> T.Text
+renderId (Id txt) = txt
+
+renderLoc :: Location -> T.Text
+renderLoc = \case
+  Function func -> func
+  File path line -> T.pack path <> ":" <> T.pack (show line)
+
+renderSelection :: Selection -> T.Text
+renderSelection = \case
+  Single bp -> renderId bp
+  Many bps -> T.intercalate " " $ map renderId bps
+  All -> ""
+
+renderTargetConfig :: TargetConfig -> T.Text
+renderTargetConfig = \case
+  Remote port -> "remote tcp:localhost:" <> T.pack (show port)
+
+renderInfoOpts :: InfoOptions -> T.Text
+renderInfoOpts = \case
+  Breakpoints -> "breakpoints"
+
+renderCount :: Maybe Int -> T.Text
+renderCount = \case
+  Nothing -> ""
+  Just x -> " " <> T.pack (show x)
+
+interleaveNewlines :: [T.Text] -> T.Text
+interleaveNewlines txts = T.intercalate "\n" txts
diff --git a/src/Debugger/Statement.hs b/src/Debugger/Statement.hs
new file mode 100644
--- /dev/null
+++ b/src/Debugger/Statement.hs
@@ -0,0 +1,87 @@
+module Debugger.Statement
+  ( Line
+  , Location(..)
+  , Var
+  , Expr
+  , ShellCommand
+  , Id(..)
+  , Selection(..)
+  , Port
+  , TargetConfig(..)
+  , InfoOptions(..)
+  , Statement(..)
+  , Script
+  ) where
+
+import Data.Text (Text)
+
+type Var = Text  -- TODO different type?
+
+type Expr = Text  -- TODO different type?
+
+type ShellCommand = Text
+
+-- | Datatype representing a ID of a breakpoint.
+newtype Id = Id Text
+  deriving (Eq, Show)
+
+type Line = Int
+
+-- | Helper datatype for a selection of 1 or more (breakpoints)
+data Selection
+  = Single Id
+  | Many [Id]
+  | All
+  deriving (Eq, Show)
+
+type Port = Int
+
+-- TODO: add other variants
+-- | Datatype for configuring the GDB target.
+data TargetConfig
+  = Remote Port  -- assumes tcp as protocol, and localhost as host for now
+  deriving (Eq, Show)
+
+-- | Enumeration of all things info can be requested about.
+data InfoOptions
+  = Breakpoints
+  deriving (Eq, Show)
+
+-- | A place to set a breakpoint at.
+data Location
+  = Function Text
+  | File FilePath Line
+  deriving (Eq, Show)
+
+-- | Main AST data type used to build a script with.
+data Statement
+  = Break Location
+  | Command Id [Statement]
+  | Continue
+  | Step (Maybe Int)
+  | Next (Maybe Int)
+  | Run
+  | Reset
+  | Delete Selection
+  | Enable Selection
+  | Disable Selection
+  | Shell ShellCommand
+  | Source FilePath
+  | Print Expr
+  | Set Var Expr
+  | Call Expr
+  | Target TargetConfig
+  | Info InfoOptions
+  deriving (Eq, Show)
+
+-- | A script is a collection of statements.
+type Script = [Statement]
+
+  {-
+TODO
+data Statement
+  = Break Location -- hbreak? conditional breakpoints?
+  | Printf Text [Expr]
+  | If Expr [Statement]
+  -- set logging on (opts), ...
+-}
diff --git a/src/Lib.hs b/src/Lib.hs
new file mode 100644
--- /dev/null
+++ b/src/Lib.hs
@@ -0,0 +1,6 @@
+module Lib
+    ( someFunc
+    ) where
+
+someFunc :: IO ()
+someFunc = putStrLn "someFunc"
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,2 @@
+{-# OPTIONS_GHC -Wno-missing-export-lists #-}
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/test/Test/Debugger/BuilderSpec.hs b/test/Test/Debugger/BuilderSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Debugger/BuilderSpec.hs
@@ -0,0 +1,195 @@
+module Test.Debugger.BuilderSpec
+  ( module Test.Debugger.BuilderSpec
+  ) where
+
+import Prelude hiding (break, print)
+import Data.Foldable
+import Test.Hspec
+import Debugger.Builder
+import Debugger.Statement
+
+
+spec :: Spec
+spec = describe "script builder" $ parallel $ do
+  it "handles empty scripts" $ do
+    runBuilder (pure ()) `shouldBe` []
+
+  it "maintains order of statements" $ do
+    let script = do
+          print "1"
+          print "2"
+    runBuilder script `shouldBe` [Print "1", Print "2"]
+
+  it "can emit break statements" $ do
+    let script loc = do
+          break loc
+    for_ [Function "main", File "file.c" 42] $ \loc ->
+      runBuilder (script loc) `shouldBe` [Break loc, Set "$var0" "$bpnum"]
+
+  it "can emit command statements" $ do
+    let script = do
+          bp <- break (Function "func1")
+          command bp $ do
+            print "123"
+            print "456"
+    runBuilder script `shouldBe`
+      [ Break (Function "func1")
+      , Set "$var0" "$bpnum"
+      , Command (Id "$var0") [Print "123", Print "456"]
+      ]
+
+  it "returns correct breakpoint variable for use in commands" $ do
+    let script = do
+          _ <- break (Function "func1")
+          bp2 <- break (Function "func2")
+          command bp2 $ do
+            print "42"
+    runBuilder script `shouldBe`
+      [ Break (Function "func1")
+      , Set "$var0" "$bpnum"
+      , Break (Function "func2")
+      , Set "$var1" "$bpnum"
+      , Command (Id "$var1") [Print "42"]
+      ]
+
+  it "can handle nested commands" $ do
+    let script = do
+          bp1 <- break (Function "func1")
+          command bp1 $ do
+            bp2 <- break (Function "func2")
+            command bp2 $ do
+              print "42"
+              continue
+    runBuilder script `shouldBe`
+      [ Break (Function "func1")
+      , Set "$var0" "$bpnum"
+      , Command (Id "$var0")
+        [ Break (Function "func2")
+        , Set "$var1" "$bpnum"
+        , Command (Id "$var1")
+          [ Print "42"
+          , Continue
+          ]
+        ]
+      ]
+
+  it "can emit continue statements" $ do
+    let script = continue
+    runBuilder script `shouldBe` [Continue]
+
+  it "can emit next statements" $ do
+    let script1 = next
+        script2 = nextN 10
+    runBuilder script1 `shouldBe` [Next Nothing]
+    runBuilder script2 `shouldBe` [Next (Just 10)]
+
+  it "can emit step statements" $ do
+    let script1 = step
+        script2 = stepN 10
+    runBuilder script1 `shouldBe` [Step Nothing]
+    runBuilder script2 `shouldBe` [Step (Just 10)]
+
+  it "can emit run statements" $ do
+    let script = run
+    runBuilder script `shouldBe` [Run]
+
+  it "can emit reset statements" $ do
+    let script = reset
+    runBuilder script `shouldBe` [Reset]
+
+  it "can emit set statements" $ do
+    let script = set "$var" "123"
+    runBuilder script `shouldBe` [Set "$var" "123"]
+
+  it "can emit call statements" $ do
+    let script = call "my_function(123, 456)"
+    runBuilder script `shouldBe` [Call "my_function(123, 456)"]
+
+  it "can emit delete statements" $ do
+    let script1 = do
+          bp <- break (Function "main")
+          delete bp
+        script2 = do
+          bp1 <- break (Function "f")
+          bp2 <- break (Function "g")
+          delete [bp1, bp2]
+        script3 = delete All
+    runBuilder script1 `shouldBe`
+      [ Break (Function "main")
+      , Set "$var0" "$bpnum"
+      , Delete $ Single (Id "$var0")
+      ]
+    runBuilder script2 `shouldBe`
+      [ Break (Function "f")
+      , Set "$var0" "$bpnum"
+      , Break (Function "g")
+      , Set "$var1" "$bpnum"
+      , Delete $ Many [Id "$var0", Id "$var1"]
+      ]
+    runBuilder script3 `shouldBe` [Delete All]
+
+  it "can emit disable statements" $ do
+    let script1 = do
+          bp <- break (Function "main")
+          disable bp
+        script2 = do
+          bp1 <- break (Function "f")
+          bp2 <- break (Function "g")
+          disable [bp1, bp2]
+        script3 = disable All
+    runBuilder script1 `shouldBe`
+      [ Break (Function "main")
+      , Set "$var0" "$bpnum"
+      , Disable $ Single (Id "$var0")
+      ]
+    runBuilder script2 `shouldBe`
+      [ Break (Function "f")
+      , Set "$var0" "$bpnum"
+      , Break (Function "g")
+      , Set "$var1" "$bpnum"
+      , Disable $ Many [Id "$var0", Id "$var1"]
+      ]
+    runBuilder script3 `shouldBe` [Disable All]
+
+  it "can emit disable statements" $ do
+    let script1 = do
+          bp <- break (Function "main")
+          enable bp
+        script2 = do
+          bp1 <- break (Function "f")
+          bp2 <- break (Function "g")
+          enable [bp1, bp2]
+        script3 = enable All
+    runBuilder script1 `shouldBe`
+      [ Break (Function "main")
+      , Set "$var0" "$bpnum"
+      , Enable $ Single (Id "$var0")
+      ]
+    runBuilder script2 `shouldBe`
+      [ Break (Function "f")
+      , Set "$var0" "$bpnum"
+      , Break (Function "g")
+      , Set "$var1" "$bpnum"
+      , Enable $ Many [Id "$var0", Id "$var1"]
+      ]
+    runBuilder script3 `shouldBe` [Enable All]
+
+  it "can emit print statements" $ do
+    let script = print "1"
+    runBuilder script `shouldBe` [Print "1"]
+
+  it "can emit source statements" $ do
+    let script = source "./other_script.gdb"
+    runBuilder script `shouldBe` [Source "./other_script.gdb"]
+
+  it "can emit shell statements" $ do
+    let script = shell "sleep 1"
+    runBuilder script `shouldBe` [Shell "sleep 1"]
+
+  it "can emit target statements" $ do
+    let script = target (Remote 9001)
+    runBuilder script `shouldBe` [Target (Remote 9001)]
+
+  it "can emit info statements" $ do
+    let script = info Breakpoints
+    runBuilder script `shouldBe` [Info Breakpoints]
diff --git a/test/Test/Debugger/RenderSpec.hs b/test/Test/Debugger/RenderSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Debugger/RenderSpec.hs
@@ -0,0 +1,237 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Test.Debugger.RenderSpec
+  ( module Test.Debugger.RenderSpec
+  ) where
+
+import Prelude hiding (break, print)
+import qualified Data.Text as T
+import Test.Hspec
+import NeatInterpolation
+import Debugger.Builder
+import Debugger.Render
+import Debugger.Statement
+import Control.Monad.Cont
+
+
+-- NOTE: 2 $s is to escape the quasiquoter, renders as 1 $
+
+
+shouldRenderAs :: Builder a -> T.Text -> IO ()
+shouldRenderAs script expected =
+  renderScript (runBuilder script) `shouldBe` expected
+
+spec :: Spec
+spec = describe "rendering scripts" $ parallel $ do
+  it "handles empty scripts" $ do
+    pure () `shouldRenderAs` ""
+
+  it "renders break statements" $ do
+    break (Function "main") `shouldRenderAs` [text|
+      break main
+      set $$var0 = $$bpnum
+      |]
+    break (File "./a/b.c" 42) `shouldRenderAs` [text|
+      break ./a/b.c:42
+      set $$var0 = $$bpnum
+      |]
+
+  it "renders command statements" $ do
+    let script = do
+          bp <- break (Function "main")
+          command bp $ do
+            print "42"
+            continue
+    script `shouldRenderAs` [text|
+      break main
+      set $$var0 = $$bpnum
+      command $$var0
+        print "42"
+        continue
+      end
+      |]
+
+  it "renders nested command statements" $ do
+    let script = do
+          bp1 <- break (Function "main")
+          command bp1 $ do
+            bp2 <- break (File "file.cpp" 1234)
+            command bp2 $ do
+              print "42"
+              continue
+    script `shouldRenderAs` [text|
+      break main
+      set $$var0 = $$bpnum
+      command $$var0
+        break file.cpp:1234
+        set $$var1 = $$bpnum
+        command $$var1
+          print "42"
+          continue
+        end
+      end
+      |]
+
+  it "renders continue statements" $ do
+    continue `shouldRenderAs` [text|
+      continue
+      |]
+
+  it "renders next statements" $ do
+    let script1 = next
+        script2 = nextN 10
+    script1 `shouldRenderAs` [text|
+      next
+      |]
+    script2 `shouldRenderAs` [text|
+      next 10
+      |]
+
+  it "renders step statements" $ do
+    let script1 = step
+        script2 = stepN 10
+    script1 `shouldRenderAs` [text|
+      step
+      |]
+    script2 `shouldRenderAs` [text|
+      step 10
+      |]
+
+  it "renders reset statements" $ do
+    reset `shouldRenderAs` [text|
+      monitor reset
+      |]
+
+  it "renders set statements" $ do
+    set "$var" "123" `shouldRenderAs` [text|
+      set $$var = 123
+      |]
+
+  it "renders call statements" $ do
+    call "function(123)" `shouldRenderAs` [text|
+      call function(123)
+      |]
+
+  it "renders delete statements" $ do
+    let script1 = do
+          bp <- break (Function "main")
+          delete bp
+        script2 = do
+          bp1 <- break (Function "f")
+          bp2 <- break (Function "g")
+          delete [bp1, bp2]
+        script3 = delete All
+    script1 `shouldRenderAs` [text|
+      break main
+      set $$var0 = $$bpnum
+      delete $$var0
+      |]
+    script2 `shouldRenderAs` [text|
+      break f
+      set $$var0 = $$bpnum
+      break g
+      set $$var1 = $$bpnum
+      delete $$var0 $$var1
+      |]
+    script3 `shouldRenderAs` [text|
+      delete
+      |]
+
+  it "renders disable statements" $ do
+    let script1 = do
+          bp <- break (Function "main")
+          disable bp
+        script2 = do
+          bp1 <- break (Function "f")
+          bp2 <- break (Function "g")
+          disable [bp1, bp2]
+        script3 = disable All
+    script1 `shouldRenderAs` [text|
+      break main
+      set $$var0 = $$bpnum
+      disable $$var0
+      |]
+    script2 `shouldRenderAs` [text|
+      break f
+      set $$var0 = $$bpnum
+      break g
+      set $$var1 = $$bpnum
+      disable $$var0 $$var1
+      |]
+    script3 `shouldRenderAs` [text|
+      disable
+      |]
+
+  it "renders enable statements" $ do
+    let script1 = do
+          bp <- break (Function "main")
+          enable bp
+        script2 = do
+          bp1 <- break (Function "f")
+          bp2 <- break (Function "g")
+          enable [bp1, bp2]
+        script3 = enable All
+    script1 `shouldRenderAs` [text|
+      break main
+      set $$var0 = $$bpnum
+      enable $$var0
+      |]
+    script2 `shouldRenderAs` [text|
+      break f
+      set $$var0 = $$bpnum
+      break g
+      set $$var1 = $$bpnum
+      enable $$var0 $$var1
+      |]
+    script3 `shouldRenderAs` [text|
+      enable
+      |]
+
+  it "renders print statements" $ do
+    print "42" `shouldRenderAs` [text|
+      print "42"
+      |]
+
+  it "renders shell statements" $ do
+    shell "sleep 1" `shouldRenderAs` [text|
+      shell sleep 1
+      |]
+
+  it "renders source statements" $ do
+    source "./other_script.gdb" `shouldRenderAs` [text|
+      source ./other_script.gdb
+      |]
+
+  it "renders target statements" $ do
+    let script = target (Remote 9001)
+    script `shouldRenderAs` [text|
+      target remote tcp:localhost:9001
+      |]
+
+  it "renders target statements" $ do
+    let script = info Breakpoints
+    script `shouldRenderAs` [text|
+      info breakpoints
+      |]
+
+  it "mixes well with other Haskell concepts" $ do
+    let script = flip runContT delete $ do
+          bp1 <- ContT $ withBreakpoint (Function "func1")
+          bp2 <- ContT $ withBreakpoint (Function "func2")
+          pure [bp1, bp2]
+        withBreakpoint :: Location -> (Id -> Builder ()) -> Builder ()
+        withBreakpoint loc f = do
+          bp <- break loc
+          command bp $ f bp
+
+    script `shouldRenderAs` [text|
+      break func1
+      set $$var0 = $$bpnum
+      command $$var0
+        break func2
+        set $$var1 = $$bpnum
+        command $$var1
+          delete $$var0 $$var1
+        end
+      end
+      |]
