diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,10 @@
-<!-- markdownlint-disable first-line-h1   -->
+<!-- markdownlint-disable first-line-h1 -->
+
+## v0.4.0.1 \[2023-12-28\]
+
+* Improve error messages, warnings, and `--help` text
+* Fix warnings not being shown if there were also compilation errors
+* Update dependencies to allow compiling with GHC 9.8
 
 ## v0.4.0.0 \[2023-05-06\]
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -36,21 +36,21 @@
 
 To run the project locally (without installing), use `cabal run . -- <args>`.
 
-## Language Features
+## Language features
 
 OpLang is a strict superset of Brainfuck.
 
 Its main improvement is the addition of user-defined operators, which are analogous to user-defined functions in languages like C or Python.
 
-The "memory tape" in OpLang is specific to each operator invocation (akin to the stack space allocated for functions in C), and there is a separate "stack" which persists across operator invocations.
+The "memory tape" in OpLang is specific to each operator invocation (akin to the stack frames of functions in C), and there is a separate "stack" which persists across operator invocations.
 
 Each cell in the tape(s) and stack is 1 byte, and overflow/underflow is allowed.
 
-The default size of the stack is 4KB, and the default size for each memory tape is 64KB. These can be modified via the command-line.
+The default sizes of the stack and of memory tapes is 4KB. These defaults can be individually modified via command-line arguments.
 
 ### Syntax
 
-OpLang has 10 "intrinsic" operators, 8 of which are the Brainfuck operators, with the same semantics:
+OpLang has 10 built-in operators, 8 of which are the Brainfuck operators, with the same semantics:
 
 * `+`: Increment the current cell
 * `-`: Decrement the current cell
@@ -79,13 +79,13 @@
 ,: a ;.
 ```
 
-For more example programs, see the [examples](examples) folder.
+For more example programs, see the [examples](examples/) directory.
 
-## Compiler Architecture
+## Compiler architecture
 
 The compiler works by translating the input OpLang source into C, and then compiling that using the system's C compiler. To use a different C compiler, set the `CC` environment variable.
 
-The compiler uses an intermediate representation (which can be shown with the `--dump-ir` option) on which it performs a number of optimizations, generating C code that is much smaller and faster than a direct translation would be.
+The compiler uses an intermediate representation (which can be shown with the `--dump-ir` option) on which it performs a number of optimizations, generating C code that is much smaller and faster than a direct translation would yield.
 
 ## License
 
diff --git a/examples/errors/both.op b/examples/errors/both.op
new file mode 100644
--- /dev/null
+++ b/examples/errors/both.op
@@ -0,0 +1,7 @@
+# Should fail to compile, producing the following warnings and errors:
+# Warning: Unused operator 'f'.
+# Warning: Unused operator 'g'.
+# Error: In definition of 'f': Call to undefined operator 'a'.
+# Error: In definition of 'g': Call to undefined operator 'b'.
+f { a }
+g { b }
diff --git a/examples/errors/errors.op b/examples/errors/errors.op
new file mode 100644
--- /dev/null
+++ b/examples/errors/errors.op
@@ -0,0 +1,5 @@
+# Should fail to compile with the following errors:
+# Error: In top level: Call to undefined operator 'b'.
+# Error: In definition of 'f': Call to undefined operator 'a'.
+f { a }
+f b
diff --git a/examples/errors/warnings.op b/examples/errors/warnings.op
new file mode 100644
--- /dev/null
+++ b/examples/errors/warnings.op
@@ -0,0 +1,5 @@
+# Should produce the following warnings (unless compiled with --no-warn):
+# Warning: Unused operator 'f'.
+# Warning: Unused operator 'g'.
+f { }
+g { }
diff --git a/examples/factorial.op b/examples/factorial.op
new file mode 100644
--- /dev/null
+++ b/examples/factorial.op
@@ -0,0 +1,15 @@
+# Reads a single-digit number n, then prints factorial(n) '*' chars.
+# To easily check the value, you can pipe the output into `wc -c`, like so:
+# `echo 5 | ./factorial.out | wc -c`
+# ^ Should print 120
+
+0 { +++ +++ [> ++++ ++++ < -] > : }
+s { ; > ; [- < - >] < : }
+r { 0 , : s }
+
+m { ; > ; < [->[->+>+<<]>[-<+>]<<]>[-]>>[-<<<+>>>]<<< : }
+f { ; > + < [: > : m ; < -] > : }
+
+* { +++ +++ + [> +++ +++ < -] > : }
+
+*; > rf ; [ < . > - ]
diff --git a/examples/hello-world.bf b/examples/hello-world.bf
new file mode 100644
--- /dev/null
+++ b/examples/hello-world.bf
@@ -0,0 +1,4 @@
+# Prints 'hello world'.
+# Relies on cells overflowing in order to work.
+
++[-[<<[+[--->]-[<<<]]]>>>-]>-.---.>..>.<<<<-.<+.>>>>>.>.<<.<-.
diff --git a/examples/lost-kingdom.bf b/examples/lost-kingdom.bf
new file mode 100644
# file too large to diff: examples/lost-kingdom.bf
diff --git a/examples/stack.op b/examples/stack.op
new file mode 100644
--- /dev/null
+++ b/examples/stack.op
@@ -0,0 +1,5 @@
+# Prints 'cba'.
+
+f { ++++ ++++ [> ++++ ++++ ++++ < -] > + : + : + : }
+m { f ;. ;. ;. }
+m
diff --git a/examples/triangle.bf b/examples/triangle.bf
new file mode 100644
--- /dev/null
+++ b/examples/triangle.bf
@@ -0,0 +1,8 @@
+# Downloaded from http://www.hevanet.com/cristofd/brainfuck/sierpinski.b
+# Prints the Sierpinski triangle
+
+++++++++[>+>++++<<-]>++>>+<[-[>>+<<-]+>>]>+[
+    -<<<[
+        ->[+[-]+>++>>>-<<]<[<]>>++++++[<<+++++>>-]+<<++.[-]<<
+    ]>.>+[>>]>+
+]
diff --git a/oplang.cabal b/oplang.cabal
--- a/oplang.cabal
+++ b/oplang.cabal
@@ -1,7 +1,7 @@
 cabal-version: 3.0
 
 name: oplang
-version: 0.4.0.0
+version: 0.4.0.1
 synopsis: Stack-based esoteric programming language
 description: Please see the README on GitHub at <https://github.com/aionescu/oplang#readme>
 homepage: https://github.com/aionescu/oplang#readme
@@ -14,57 +14,33 @@
 category: Compilers/Interpreters, Language
 build-type: Simple
 
+tested-with: GHC == { 9.2.8, 9.4.8, 9.6.3, 9.8.1 }
+
 extra-doc-files:
   CHANGELOG.md
-
-extra-source-files:
   README.md
+  examples/**/*.bf
+  examples/**/*.op
 
 source-repository head
   type: git
   location: https://github.com/aionescu/oplang
 
-common ghc-flags
+executable oplang
   default-language: GHC2021
   default-extensions:
-    ApplicativeDo
     BlockArguments
-    DataKinds
-    DefaultSignatures
-    DeriveAnyClass
-    DerivingVia
-    DuplicateRecordFields
-    FunctionalDependencies
-    GADTs
-    ImplicitParams
-    ImpredicativeTypes
+    DerivingStrategies
     LambdaCase
-    LexicalNegation
-    MagicHash
-    MultiWayIf
-    NegativeLiterals
     NoFieldSelectors
-    NoMonomorphismRestriction
-    NoStarIsType
-    OverloadedLabels
     OverloadedRecordDot
     OverloadedStrings
-    PartialTypeSignatures
-    PatternSynonyms
-    QuantifiedConstraints
     RecordWildCards
-    RecursiveDo
-    TypeFamilyDependencies
-    UnboxedTuples
-    UndecidableInstances
-    UnliftedDatatypes
-    UnliftedNewtypes
-    ViewPatterns
 
+  other-extensions:
+    StrictData
+
   ghc-options:
-    -threaded
-    -rtsopts
-    -with-rtsopts=-N
     -Wall
     -Wcompat
     -Widentities
@@ -75,14 +51,11 @@
     -Wredundant-constraints
     -Wunused-packages
 
-executable oplang
-  import: ghc-flags
-
   hs-source-dirs: src
   main-is: Main.hs
+  autogen-modules: Paths_oplang
 
   other-modules:
-    Control.Monad.Comp
     Language.OpLang.Codegen
     Language.OpLang.Optimizer
     Language.OpLang.Parser
@@ -91,18 +64,15 @@
     Opts
     Paths_oplang
 
-  autogen-modules:
-    Paths_oplang
-
   build-depends:
     base >=4.16 && <5
     , containers ^>= 0.6.7
     , directory ^>= 1.3.8
     , filepath ^>= 1.4.100
-    , megaparsec ^>= 9.3
+    , megaparsec ^>= 9.6.1
+    , monad-chronicle ^>= 1.0.1
     , mtl ^>= 2.3
     , optparse-applicative ^>= 0.17
     , process ^>= 1.6.17
-    , text ^>= 2
+    , text >=2 && <2.2
     , text-builder-linear ^>= 0.1
-    , transformers ^>= 0.6
diff --git a/src/Control/Monad/Comp.hs b/src/Control/Monad/Comp.hs
deleted file mode 100644
--- a/src/Control/Monad/Comp.hs
+++ /dev/null
@@ -1,34 +0,0 @@
-module Control.Monad.Comp(CompT(..), runCompT) where
-
-import Control.Applicative(Alternative)
-import Control.Monad(MonadPlus)
-import Control.Monad.IO.Class(MonadIO)
-import Control.Monad.Reader(MonadReader, ReaderT(..))
-import Control.Monad.Trans(MonadTrans(..))
-import Control.Monad.Trans.Maybe(MaybeT(..))
-import Control.Monad.Trans.Writer.CPS(WriterT, runWriterT)
-import Control.Monad.Writer.CPS(MonadWriter)
-import Data.Text(Text)
-
-import Opts(Opts)
-
--- The "Compilation" Monad Transformer
-newtype CompT m a =
-  CompT (ReaderT Opts (MaybeT (WriterT [Text] m)) a)
-  deriving newtype
-    ( Functor
-    , Applicative
-    , Alternative
-    , Monad
-    , MonadPlus
-    , MonadReader Opts
-    , MonadWriter [Text]
-    , MonadIO
-    )
-
-instance MonadTrans CompT where
-  lift :: Monad m => m a -> CompT m a
-  lift = CompT . lift . lift . lift
-
-runCompT :: CompT m a -> Opts -> m (Maybe a, [Text])
-runCompT (CompT m) = runWriterT . runMaybeT . runReaderT m
diff --git a/src/Language/OpLang/Codegen.hs b/src/Language/OpLang/Codegen.hs
--- a/src/Language/OpLang/Codegen.hs
+++ b/src/Language/OpLang/Codegen.hs
@@ -1,23 +1,12 @@
-module Language.OpLang.Codegen(compile) where
+module Language.OpLang.Codegen(codegen) where
 
-import Control.Monad.Reader(ask)
-import Control.Monad.Trans(lift)
 import Data.Char(ord)
 import Data.Foldable(foldMap')
 import Data.Map.Strict qualified as M
-import Data.Maybe(fromMaybe)
 import Data.Text(Text)
 import Data.Text.Builder.Linear(Builder, fromDec, runBuilder)
-import Data.Text.IO qualified as T
-import System.Directory(createDirectoryIfMissing, removeFile)
-import System.Environment(lookupEnv)
-import System.FilePath(dropExtension, takeDirectory)
-import System.Info(os)
-import System.Process(callProcess)
 
-import Control.Monad.Comp(CompT)
 import Language.OpLang.Syntax
-import Opts(Opts(..))
 
 type CCode = Builder
 
@@ -32,14 +21,14 @@
 forwardDecl :: Id -> CCode
 forwardDecl name = "void " <> cName name <> "();"
 
-compileDef :: Id -> [Instr] -> CCode
-compileDef name body = "void " <> cName name <> "(){char u[T]={0},*t=u;" <> compileOps body <> "}"
+codegenDef :: Id -> [Instr] -> CCode
+codegenDef name body = "void " <> cName name <> "(){char u[T]={0},*t=u;" <> codegenOps body <> "}"
 
-compileMain :: [Instr] -> CCode
-compileMain body = "int main(){char u[T]={0},*t=u;" <> compileOps body <> "return 0;}"
+codegenMain :: [Instr] -> CCode
+codegenMain body = "int main(){char u[T]={0},*t=u;" <> codegenOps body <> "return 0;}"
 
-compileOps :: [Instr] -> CCode
-compileOps = foldMap' compileOp
+codegenOps :: [Instr] -> CCode
+codegenOps = foldMap' codegenOp
 
 tape :: Offset -> CCode
 tape 0 = "*t"
@@ -56,8 +45,8 @@
     times 1 = ""
     times n = "*" <> fromDec n
 
-compileOp :: Instr -> CCode
-compileOp = \case
+codegenOp :: Instr -> CCode
+codegenOp = \case
   Add n o -> tape o <> plusEq n <> fromDec (abs n) <> ";"
   Set n o -> tape o <> "=" <> fromDec n <> ";"
   Pop o -> tape o <> "=*(--s);"
@@ -66,7 +55,7 @@
   Write o -> "printf(\"%c\"," <> tape o <> ");"
   Move n -> "t" <> plusEq n <> fromDec (abs n) <> ";"
   AddCell n o o' -> addCell n o o'
-  Loop ops -> "while(*t){" <> compileOps ops <> "}"
+  Loop ops -> "while(*t){" <> codegenOps ops <> "}"
   Call c -> cName c <> "();"
 
 codegen :: Word -> Word -> Program Instr -> Text
@@ -74,36 +63,5 @@
   runBuilder
   $ programHeader stackSize tapeSize
   <> foldMap' forwardDecl (M.keys opDefs)
-  <> M.foldMapWithKey compileDef opDefs
-  <> compileMain topLevel
-
-exePath :: FilePath -> FilePath
-exePath path = dropExtension path <> ext os
-  where
-    ext "mingw32" = ".exe"
-    ext _ = ".out"
-
-ccPath :: IO FilePath
-ccPath = fromMaybe "cc" <$> lookupEnv "CC"
-
-compile :: Program Instr -> CompT IO ()
-compile p = do
-  Opts{..} <- ask
-  let cFile = dropExtension path <> ".c"
-  let cCode = codegen stackSize tapeSize p
-
-  lift
-    if noCC then do
-      let outFile = fromMaybe cFile outPath
-      createDirectoryIfMissing True $ takeDirectory outFile
-
-      T.writeFile outFile cCode
-    else do
-      T.writeFile cFile cCode
-
-      let outFile = fromMaybe (exePath path) outPath
-      createDirectoryIfMissing True $ takeDirectory outFile
-
-      cc <- ccPath
-      callProcess cc ["-o", outFile, cFile]
-      removeFile cFile
+  <> M.foldMapWithKey codegenDef opDefs
+  <> codegenMain topLevel
diff --git a/src/Language/OpLang/Optimizer.hs b/src/Language/OpLang/Optimizer.hs
--- a/src/Language/OpLang/Optimizer.hs
+++ b/src/Language/OpLang/Optimizer.hs
@@ -1,13 +1,6 @@
 module Language.OpLang.Optimizer(optimize) where
 
-import Control.Monad(when)
-import Control.Monad.Reader(ask)
-import Control.Monad.Trans(lift)
-import Data.Functor(($>))
-
-import Control.Monad.Comp(CompT)
 import Language.OpLang.Syntax
-import Opts(Opts(..))
 
 syncTally :: Bool -> Val -> Offset -> [Instr] -> [Instr]
 syncTally False 0 _ acc = acc
@@ -55,13 +48,9 @@
 
             l' -> go loop False 0 0 (Loop l' : syncAll known tally offset acc) ops
 
-optimize :: Program Op -> CompT IO (Program Instr)
-optimize Program{..} = do
-  Opts{..} <- ask
-  when dumpIR (lift $ putStrLn $ "IR:\n" <> show p <> "\n") $> p
-  where
-    p =
-      Program
-      { opDefs = optimizeOps <$> opDefs
-      , topLevel = optimizeOps topLevel
-      }
+optimize :: Program Op -> Program Instr
+optimize Program{..} =
+  Program
+  { opDefs = optimizeOps <$> opDefs
+  , topLevel = optimizeOps topLevel
+  }
diff --git a/src/Language/OpLang/Parser.hs b/src/Language/OpLang/Parser.hs
--- a/src/Language/OpLang/Parser.hs
+++ b/src/Language/OpLang/Parser.hs
@@ -1,9 +1,6 @@
 module Language.OpLang.Parser(parse) where
 
-import Control.Monad(when)
-import Control.Monad.Reader(ask)
-import Control.Monad.Trans(lift)
-import Control.Monad.Writer(tell)
+import Control.Monad.Chronicle(MonadChronicle(..))
 import Data.Functor(($>))
 import Data.List(intercalate)
 import Data.Map.Strict(Map)
@@ -16,9 +13,7 @@
 import Text.Megaparsec.Char(space1)
 import Text.Megaparsec.Char.Lexer qualified as L
 
-import Control.Monad.Comp(CompT)
 import Language.OpLang.Syntax
-import Opts(Opts(..))
 
 type Parser = Parsec Void Text
 
@@ -34,8 +29,8 @@
 reserved :: Text
 reserved = "+-<>,.;:[]{}"
 
-intrinsic :: Parser Op
-intrinsic =
+builtIn :: Parser Op
+builtIn =
   choice
   [ symbol "+" $> Incr
   , symbol "-" $> Decr
@@ -45,7 +40,7 @@
   , symbol "." $> Write'
   , symbol ";" $> Pop'
   , symbol ":" $> Push'
-  ] <?> "intrinsic operator"
+  ] <?> "built-in operator"
 
 block :: Text -> Text -> Parser [Op]
 block b e = between (symbol b) (symbol e) $ many op
@@ -60,7 +55,7 @@
 op =
   choice
   [ loop
-  , intrinsic
+  , builtIn
   , Call' <$> custom
   ] <?> "operator"
 
@@ -81,9 +76,8 @@
 program :: Parser (Program Op)
 program = Program <$> defs <*> (many op <?> "toplevel")
 
-parse :: Text -> CompT IO (Program Op)
-parse code = do
-  Opts{..} <- ask
+parse :: MonadChronicle [Text] m => FilePath -> Text -> m (Program Op)
+parse path code =
   case runParser (ws *> program <* eof) path code of
-    Left e -> tell ["Parse error at " <> T.pack (errorBundlePretty e)] *> empty
-    Right p -> when dumpAST (lift $ putStrLn $ "AST:\n" <> show p <> "\n") $> p
+    Left e -> confess ["Parse error at " <> T.pack (errorBundlePretty e)]
+    Right p -> pure p
diff --git a/src/Language/OpLang/Syntax.hs b/src/Language/OpLang/Syntax.hs
--- a/src/Language/OpLang/Syntax.hs
+++ b/src/Language/OpLang/Syntax.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE StrictData #-}
 module Language.OpLang.Syntax where
 
 import Data.Int(Int8)
diff --git a/src/Language/OpLang/Validation.hs b/src/Language/OpLang/Validation.hs
--- a/src/Language/OpLang/Validation.hs
+++ b/src/Language/OpLang/Validation.hs
@@ -1,33 +1,29 @@
 module Language.OpLang.Validation(validate) where
 
-import Control.Monad(guard, unless)
-import Control.Monad.Reader(ask)
-import Control.Monad.Writer(tell)
+import Control.Monad(unless)
+import Control.Monad.Chronicle(MonadChronicle(..))
 import Data.Bifunctor(bimap)
-import Data.Functor(($>))
-import Data.List(intercalate)
+import Data.Foldable(foldMap')
+import Data.Functor((<&>))
 import Data.Map.Strict(Map)
 import Data.Map.Strict qualified as M
+import Data.Maybe(fromMaybe)
 import Data.Set(Set)
 import Data.Set qualified as S
 import Data.Text(Text)
 import Data.Text qualified as T
 
-import Control.Monad.Comp(CompT)
 import Language.OpLang.Syntax
-import Opts(Opts(..))
 
 calledOps :: [Op] -> Set Id
-calledOps = foldMap \case
+calledOps = foldMap' \case
   Call' op -> S.singleton op
   Loop' ops -> calledOps ops
   _ -> S.empty
 
-enumerate :: Show a => [a] -> Text
-enumerate l = T.pack $ intercalate ", " $ show <$> l
-
-checkUndefinedCalls :: Monad m => Program Op -> CompT m ()
-checkUndefinedCalls Program{..} = tell errors *> guard (null errors)
+undefinedCalls :: Program Op -> [Text]
+undefinedCalls Program{..} =
+  toMsgs =<< filter (not . S.null . snd) (undefinedInTopLevel : undefinedInDefs)
   where
     defined = M.keysSet opDefs
 
@@ -35,27 +31,31 @@
     undefinedInDefs = bimap Just ((S.\\ defined) . calledOps) <$> M.toList opDefs
 
     fmt = maybe "top level" $ ("definition of " <>) . T.pack . show
-    toMsg (name, ops) =
-      "Error (in " <> fmt name <> "): Calls to undefined operators: " <> enumerate (S.toList ops)
-
-    errors = toMsg <$> filter (not . S.null . snd) (undefinedInTopLevel : undefinedInDefs)
+    toMsgs (name, ops) = S.toList ops <&> \op ->
+      "Error: In " <> fmt name <> ": Call to undefined operator " <> T.pack (show op) <> "."
 
 allUsedOps :: Map Id [Op] -> Set Id -> [Op] -> Set Id
 allUsedOps defs seen ops
   | S.null used = seen
-  | otherwise = foldMap (allUsedOps defs (seen <> used) . (defs M.!)) used
+  | otherwise = foldMap' ((allUsedOps defs (seen <> used)) . fromMaybe [] . (defs M.!?)) used
   where
     used = calledOps ops S.\\ seen
 
-removeUnusedOps :: Monad m => Program Op -> CompT m (Program Op)
-removeUnusedOps p@Program{..} = do
-  Opts{..} <- ask
-  unless (noWarn || M.null unusedDefs) warn $> p { opDefs = usedDefs }
+removeUnusedOps :: Program Op -> ([Text], Program Op)
+removeUnusedOps p@Program{..} = (warnings, p{opDefs = usedDefs})
   where
-    warn = tell ["Warning: Unused operators: " <> enumerate (M.keys unusedDefs)]
-    unusedDefs = opDefs M.\\ usedDefs
-    usedDefs = M.restrictKeys opDefs usedOps
     usedOps = allUsedOps opDefs S.empty topLevel
+    unusedDefs = M.withoutKeys opDefs usedOps
+    usedDefs = M.restrictKeys opDefs usedOps
 
-validate :: Monad m => Program Op -> CompT m (Program Op)
-validate p = checkUndefinedCalls p *> removeUnusedOps p
+    warnings = M.keys unusedDefs <&> \op ->
+      "Warning: Unused operator " <> T.pack (show op) <> "."
+
+validate :: MonadChronicle [Text] m => Bool -> Program Op -> m (Program Op)
+validate noWarn p = do
+  let (warnings, p') = removeUnusedOps p
+  unless noWarn $ dictate warnings
+
+  case undefinedCalls p of
+    [] -> pure p'
+    errs -> confess errs
diff --git a/src/Main.hs b/src/Main.hs
--- a/src/Main.hs
+++ b/src/Main.hs
@@ -1,31 +1,71 @@
 module Main(main) where
 
-import Control.Monad((>=>))
-import Control.Monad.Reader(asks)
+import Control.Monad(when)
+import Control.Monad.Chronicle(ChronicleT(..))
 import Control.Monad.Trans(lift)
 import Data.Bifoldable(bitraverse_)
 import Data.Foldable(traverse_)
+import Data.Maybe(fromMaybe)
 import Data.Text(Text)
 import Data.Text.IO qualified as T
-import Data.Tuple(swap)
-import System.Exit(exitFailure)
+import System.Directory(createDirectoryIfMissing, removeFile)
+import System.Environment(lookupEnv)
+import System.Exit(exitFailure, exitSuccess)
+import System.FilePath(dropExtension, takeDirectory)
+import System.Info(os)
+import System.Process(callProcess)
 
-import Control.Monad.Comp(CompT, runCompT)
-import Language.OpLang.Codegen(compile)
+import Language.OpLang.Codegen(codegen)
 import Language.OpLang.Optimizer(optimize)
 import Language.OpLang.Parser(parse)
 import Language.OpLang.Validation(validate)
 import Opts(Opts(..), getOpts)
 
-getCode :: CompT IO Text
-getCode = lift . T.readFile =<< asks (.path)
+exePath :: FilePath -> FilePath
+exePath path = dropExtension path <> ext os
+  where
+    ext "mingw32" = ".exe"
+    ext _ = ".out"
 
-pipeline :: CompT IO ()
-pipeline =
-  getCode >>= (parse >=> validate >=> optimize >=> compile)
+compileCCode :: Opts -> Text -> IO ()
+compileCCode opts cCode = do
+  let cFile = dropExtension opts.path <> ".c"
 
+  if opts.noCC then do
+    let outFile = fromMaybe cFile opts.outPath
+    createDirectoryIfMissing True $ takeDirectory outFile
+    T.writeFile outFile cCode
+  else do
+    T.writeFile cFile cCode
+
+    let outFile = fromMaybe (exePath opts.path) opts.outPath
+    createDirectoryIfMissing True $ takeDirectory outFile
+
+    cc <- fromMaybe "cc" <$> lookupEnv "CC"
+    callProcess cc ["-o", outFile, cFile]
+    removeFile cFile
+
+runCompiler :: Opts -> Text -> ChronicleT [Text] IO ()
+runCompiler opts code = do
+  ast <- parse opts.path code
+  when opts.dumpAST do
+    lift $ putStrLn $ "AST:\n" <> show ast <> "\n"
+
+  ast' <- validate opts.noWarn ast
+
+  let ir = optimize ast'
+  when opts.dumpIR do
+    lift $ putStrLn $ "IR:\n" <> show ir <> "\n"
+
+  let cCode = codegen opts.stackSize opts.tapeSize ir
+  lift $ compileCCode opts cCode
+
 main :: IO ()
-main =
-  getOpts
-  >>= runCompT pipeline
-  >>= bitraverse_ (traverse_ T.putStrLn) (maybe exitFailure pure) . swap
+main = do
+  opts <- getOpts
+  code <- T.readFile opts.path
+
+  runChronicleT (runCompiler opts code)
+    >>= bitraverse_ (traverse_ T.putStrLn) (\_ -> exitSuccess)
+
+  exitFailure
diff --git a/src/Opts.hs b/src/Opts.hs
--- a/src/Opts.hs
+++ b/src/Opts.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE StrictData #-}
-
 module Opts(Opts(..), getOpts) where
 
 import Data.Version(showVersion)
@@ -19,29 +18,22 @@
   , path :: FilePath
   }
 
-optsParser :: ParserInfo Opts
-optsParser =
-  info
-    (infoOption ("oplang v" <> ver) (short 'v' <> long "version" <> help "Shows version information.")
-      <*> helper
-      <*> programOptions)
-    (fullDesc
-      <> progDesc "Compiles an OpLang source file to a native executable."
-      <> header "oplang - The OpLang Compiler")
+getOpts :: IO Opts
+getOpts = execParser optsParser
   where
-    ver = showVersion version
+    ver = "oplang v" <> showVersion version
+    verOpt = infoOption ver (short 'v' <> long "version" <> help "Show version information")
 
-    programOptions :: Parser Opts
-    programOptions =
+    opts :: Parser Opts
+    opts =
       Opts
-      <$> option auto (long "stack-size" <> value 4096 <> metavar "SIZE" <> help "Size of the stack.")
-      <*> option auto (long "tape-size" <> value 65536 <> metavar "SIZE" <> help "Size of the memory tape.")
-      <*> switch (long "no-warn" <> help "Don't report warnings.")
-      <*> switch (long "no-cc" <> help "Output a C file without compiling it.")
-      <*> switch (long "dump-ast" <> help "Print the AST after parsing.")
-      <*> switch (long "dump-ir" <> help "Print the IR after optimization.")
-      <*> optional (strOption (short 'o' <> long "out-path" <> metavar "PATH" <> help "Path of the resulting executable (or C file if --no-cc is passed)."))
-      <*> strArgument (metavar "PATH" <> help "The source file to compile.")
+      <$> option auto (long "stack-size" <> value 4096 <> metavar "SIZE" <> help "Size of the stack, in bytes (default 4096)")
+      <*> option auto (long "tape-size" <> value 4096 <> metavar "SIZE" <> help "Size of the memory tape, in bytes (default 4096)")
+      <*> switch (long "no-warn" <> help "Don't report warnings")
+      <*> switch (long "no-cc" <> help "Output a C file without compiling it")
+      <*> switch (long "dump-ast" <> help "Print the AST after parsing")
+      <*> switch (long "dump-ir" <> help "Print the IR after optimization")
+      <*> optional (strOption $ short 'o' <> long "out-path" <> metavar "PATH" <> help "Path of the resulting executable (or C file if --no-cc is passed)")
+      <*> strArgument (metavar "PATH" <> help "The source file to compile")
 
-getOpts :: IO Opts
-getOpts = execParser optsParser
+    optsParser = info (helper <*> verOpt <*> opts) (fullDesc <> header ver)
