diff --git a/diagnostician.cabal b/diagnostician.cabal
--- a/diagnostician.cabal
+++ b/diagnostician.cabal
@@ -1,11 +1,11 @@
 cabal-version:   3.4
 name:            diagnostician
-description:     A minimal, opinionated library for compiler error messages
-version:         0.1.0.0
+version:         0.1.1.0
 license:         MPL-2.0
 license-file:    LICENSE
 maintainer:      root@owenlynch.org
 author:          Owen Lynch
+description:     A minimal, opinionated library for compiler error messages
 category:        Language
 build-type:      Simple
 extra-doc-files: CHANGELOG.md
@@ -44,5 +44,5 @@
     ghc-options:        -Wall
     build-depends:
         base ^>=4.21.0.0,
-        containers >=0.7,
+        containers,
         diagnostician
diff --git a/src/Diagnostician.hs b/src/Diagnostician.hs
--- a/src/Diagnostician.hs
+++ b/src/Diagnostician.hs
@@ -1,5 +1,7 @@
 module Diagnostician where
 
+import Data.Functor.Contravariant
+import Data.IORef (IORef, atomicModifyIORef)
 import Data.Map (Map)
 import Data.Map qualified as Map
 import Data.Maybe (maybeToList)
@@ -75,6 +77,8 @@
 
 type LineNum = Int
 
+type ColNum = Int
+
 lineStart :: File -> LineNum -> Pos
 lineStart f l = (f.lineBreaks UV.! l) + 1
 
@@ -87,19 +91,20 @@
 lineContents :: File -> LineNum -> T.Text
 lineContents f l = sliceWord8 (lineStart f l) (lineEnd f l) f.contents
 
-lineOf :: File -> Pos -> LineNum
-lineOf f i =
+srcOf :: File -> Pos -> (LineNum, ColNum)
+srcOf f i =
   seq
-    (0 <= i && i < TU.lengthWord8 f.contents || error "position out of bounds")
+    (0 <= i && i <= TU.lengthWord8 f.contents || error "position out of bounds")
     (go 0 (UV.length f.lineBreaks - 1))
   where
     go l r
-      | l == r = l
+      | l == r = (l, c l)
       | i < lineStart f m = go l m
       | i > lineEnd f m = go m r
-      | otherwise = m
+      | otherwise = (m, c m)
       where
         m = (l + r) `div` 2
+        c lineNo = i - lineStart f lineNo
 
 repeated :: Int -> Char -> Doc ann
 repeated n c
@@ -136,22 +141,37 @@
     | l <- [ls .. le]
     ]
   where
-    ls = lineOf f s
-    le = lineOf f e
+    ls = fst $ srcOf f s
+    le = fst $ srcOf f e
     numWidth = max (numDigits ls) (numDigits le)
 
 -- Reporter
 --------------------------------------------------------------------------------
 
--- | A @Reporter@ is a destination for diagnostic messages. Currently this is
--- very simplistic; it's just a byte sink. The @reporterFancy@ flag is in theory
--- used to configure whether colors should be used, but we don't even look at
--- that yet.
-data Reporter = Reporter
-  { handle :: Handle,
-    fancy :: Bool
-  }
+-- | A @Reporter@ is a destination for diagnostic messages. It corresponds
+-- to an IO action `reportIO` that logs a diagnostic
+newtype Reporter a = Reporter {reportIO :: Diagnostic a -> IO ()}
 
+reportTo :: Reporter a -> Diagnostic a -> IO ()
+reportTo r d = r.reportIO d
+
+instance Contravariant Reporter where
+  contramap f (Reporter r) = Reporter $ r . fmap f
+
+-- | Create a `Reporter` that writes the prettyprinted diagnostic to a file
+fileReporter :: (Code a) => Handle -> Reporter a
+fileReporter handle =
+  Reporter
+    { reportIO = \d -> hPutDoc handle (hardline <> dpretty d <> hardline)
+    }
+
+-- | Create a `Reporter` that appends a diagnostic to a list
+pureReporter :: (Code a) => IORef [Diagnostic a] -> Reporter a
+pureReporter ref =
+  Reporter
+    { reportIO = \d -> atomicModifyIORef ref (\ds -> (d : ds, ()))
+    }
+
 -- Diagnostics
 --------------------------------------------------------------------------------
 
@@ -213,14 +233,3 @@
   dpretty d =
     vsep $
       (prtCode d.code <> ": " <> unAnnotate d.summary) : (map dpretty d.notes)
-
-reportIO :: (Code a) => Reporter -> Diagnostic a -> IO ()
-reportIO r d = hPutDoc r.handle (hardline <> dpretty d <> hardline)
-
-data ReporterFor a
-  = forall c.
-  (Code c) =>
-  ReporterFor {translator :: a -> c, reporter :: Reporter}
-
-reportTo :: ReporterFor a -> Diagnostic a -> IO ()
-reportTo (ReporterFor t r) d = reportIO r (fmap t d)
