diff --git a/looksee.cabal b/looksee.cabal
--- a/looksee.cabal
+++ b/looksee.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           looksee
-version:        0.1.0
+version:        0.2.0
 synopsis:       parser with looksee
 description:    Please see the README on GitHub at <https://github.com/ejconlon/looksee#readme>
 homepage:       https://github.com/ejconlon/looksee#readme
diff --git a/src/Looksee.hs b/src/Looksee.hs
--- a/src/Looksee.hs
+++ b/src/Looksee.hs
@@ -4,14 +4,17 @@
 
 -- | A simple text parser with decent errors
 module Looksee
-  ( Label (..)
-  , Range (..)
-  , range
+  ( Span (..)
+  , LineColLookup ()
+  , calculateLineCol
+  , lookupLineCol
+  , Label (..)
+  , textSpan
   , SplitComp (..)
   , Reason (..)
   , ErrF (..)
   , Err (..)
-  , errRange
+  , errSpan
   , errReason
   , AltPhase (..)
   , InfixPhase (..)
@@ -20,6 +23,7 @@
   , parseT
   , parse
   , parseI
+  , spanP
   , throwP
   , altP
   , emptyP
@@ -130,36 +134,47 @@
 import Errata.Types qualified as E
 import System.IO (stderr)
 
--- private
-type OffsetVec = Vector (Int, Int)
+-- | A generic span, used for tracking ranges of offsets or (line, col)
+data Span a = Span {spanStart :: !a, spanEnd :: !a}
+  deriving stock (Eq, Ord, Show, Functor, Foldable, Traversable)
 
--- private
-mkOffsetVec :: Text -> OffsetVec
-mkOffsetVec t = V.unfoldrN (T.length t) go ((0, 0), T.unpack t)
+-- | Auxiliary data structure to translate offsets to (line, col)
+type LineColLookup = Vector (Int, Int)
+
+-- | Construct an offset lookup from a document
+calculateLineCol :: Text -> LineColLookup
+calculateLineCol t = V.unfoldrN (T.length t) go ((0, 0), T.unpack t)
  where
   go (p@(!line, !col), xs) =
     case xs of
       [] -> Nothing
       x : xs' -> Just (p, if x == '\n' then ((line + 1, 0), xs') else ((line, col + 1), xs'))
 
+-- | Returns 0-based (line, col) for the given offset.
+-- Clamps to the valid range of offsets, returning (0, 0) for
+-- empty text. Note that the valid range is from before the first
+-- character to before the last, so a 3 character string has
+-- three valid offsets (0, 1, and 2).
+lookupLineCol :: Int -> LineColLookup -> (Int, Int)
+lookupLineCol i v =
+  if V.null v
+    then (0, 0)
+    else v V.! max 0 (min i (V.length v - 1))
+
 -- | A parser label (for error reporting)
 newtype Label = Label {unLabel :: Text}
   deriving stock (Show)
   deriving newtype (Eq, Ord, IsString)
 
--- | Range in text character offset
-data Range = Range {rangeStart :: !Int, rangeEnd :: !Int}
-  deriving stock (Eq, Ord, Show)
-
--- | Create a range from the given text
-range :: Text -> Range
-range t = Range 0 (T.length t)
+-- | Create a span from the given text
+textSpan :: Text -> Span Int
+textSpan t = Span 0 (T.length t)
 
 -- private
 -- Parser state
 data St = St
   { stHay :: !Text
-  , stRange :: !Range
+  , stSpan :: !(Span Int)
   , stLabels :: !(Seq Label)
   }
   deriving stock (Eq, Ord, Show)
@@ -168,38 +183,38 @@
 -- Returns list of possible break points with positions
 -- (startStream, breakPt) breakPt (breakPt + needLen, endStream)
 breakAllRP :: Text -> St -> [(St, Int, St)]
-breakAllRP needle (St hay (Range r0 r1) labs) = fmap go (T.breakOnAll needle hay)
+breakAllRP needle (St hay (Span r0 r1) labs) = fmap go (T.breakOnAll needle hay)
  where
   go (hay1, hay2) =
     let end1 = r0 + T.length hay1
         needLen = T.length needle
-        rng1 = Range r0 end1
-        rng2 = Range (end1 + needLen) r1
+        rng1 = Span r0 end1
+        rng2 = Span (end1 + needLen) r1
         st1 = St hay1 rng1 labs
         st2 = St (T.drop needLen hay2) rng2 labs
     in  (st1, end1, st2)
 
 -- private
 breakRP :: Text -> St -> Maybe (St, Int, St)
-breakRP needle (St hay (Range r0 r1) labs) =
+breakRP needle (St hay (Span r0 r1) labs) =
   let (hay1, hay2) = T.breakOn needle hay
   in  if T.null hay2
         then Nothing
         else
           let end1 = r0 + T.length hay1
               needLen = T.length needle
-              rng1 = Range r0 end1
-              rng2 = Range (end1 + needLen) r1
+              rng1 = Span r0 end1
+              rng2 = Span (end1 + needLen) r1
               st1 = St hay1 rng1 labs
               st2 = St (T.drop needLen hay2) rng2 labs
           in  Just (st1, end1, st2)
 
 -- private
 splitRP :: Text -> St -> [(St, Int)]
-splitRP needle (St hay (Range r0 _) labs) = goHead (T.splitOn needle hay)
+splitRP needle (St hay (Span r0 _) labs) = goHead (T.splitOn needle hay)
  where
   needLen = T.length needle
-  mkSt start end hayN = St hayN (Range start end) labs
+  mkSt start end hayN = St hayN (Span start end) labs
   goHead = \case
     [] -> []
     hay0 : hays ->
@@ -245,7 +260,7 @@
 
 -- | Base functor for 'Err' containing the range and reason for the error
 data ErrF e r = ErrF
-  { efRange :: !Range
+  { efSpan :: !(Span Int)
   , efReason :: !(Reason e r)
   }
   deriving stock (Eq, Ord, Show, Functor, Foldable, Traversable)
@@ -283,9 +298,9 @@
 instance Corecursive (Err e) where
   embed = Err
 
--- | Range of a parse error
-errRange :: Err e -> Range
-errRange = efRange . unErr
+-- | Span of a parse error
+errSpan :: Err e -> Span Int
+errSpan = efSpan . unErr
 
 -- | Reason for a parse error
 errReason :: Err e -> Reason e (Err e)
@@ -311,7 +326,7 @@
 
 -- private
 mkErrT :: Monad m => Reason e (Err e) -> T e m (Err e)
-mkErrT re = gets (\st -> Err (ErrF (stRange st) re))
+mkErrT re = gets (\st -> Err (ErrF (stSpan st) re))
 
 -- private
 -- errT :: Monad m => Reason e (Err e) -> T e m a
@@ -396,13 +411,13 @@
 
 -- private
 leftoverP :: Monad m => ParserT e m Int
-leftoverP = getsP (\st -> let Range s e = stRange st in e - s)
+leftoverP = getsP (\st -> let Span s e = stSpan st in e - s)
 
 -- | Run a parser transformer. You must consume all input or this will error!
 -- If you really don't care about the rest of the input, you can always
 -- discard it with 'dropAllP'.
 parseT :: Monad m => ParserT e m a -> Text -> m (Either (Err e) a)
-parseT p h = fmap fst (finishParserT (p <* endP) (St h (range h) Empty))
+parseT p h = fmap fst (finishParserT (p <* endP) (St h (textSpan h) Empty))
 
 -- | Run a parser (see 'parseT')
 parse :: Parser e a -> Text -> Either (Err e) a
@@ -417,6 +432,15 @@
     Right _ -> pure ()
   pure ea
 
+-- | Get the span (in character offset) at the current point representing
+-- the entire parseable range. At the start of parsing this will be `Span 0 n` for
+-- an `n`-character document. The start offset will increase as input is consumed,
+-- and the end offset will decrease as lookahead delimits the range. To evaluate
+-- the "real" range of characters consumed by a parser, construct a span with the
+-- starting offsets before and after executing a subparser.
+spanP :: Monad m => ParserT e m (Span Int)
+spanP = getsP stSpan
+
 -- | Throw a custom parse error
 throwP :: Monad m => e -> ParserT e m a
 throwP = errP . ReasonCustom
@@ -483,7 +507,7 @@
 lookP :: Monad m => ParserT e m a -> ParserT e m a
 lookP (ParserT g) = ParserT $ \j -> do
   st0 <- get
-  g (\ea -> put st0 >> j (first (Err . ErrF (stRange st0) . ReasonLook) ea))
+  g (\ea -> put st0 >> j (first (Err . ErrF (stSpan st0) . ReasonLook) ea))
 
 -- | Labels parse errors
 labelP :: Monad m => Label -> ParserT e m a -> ParserT e m a
@@ -538,11 +562,11 @@
       put st
       unParserT (pa <* endP) $ \case
         Left _ -> do
-          let rng = stRange st0
-              start = rangeStart rng
+          let rng = stSpan st0
+              start = spanStart rng
               hay' = T.drop (start' - start) (stHay st0)
-              range' = rng {rangeStart = start'}
-              st' = st0 {stHay = hay', stRange = range'}
+              range' = rng {spanStart = start'}
+              st' = st0 {stHay = hay', stSpan = range'}
           put st'
           j (Right (acc, False))
         Right a -> go (acc :|> a) sts
@@ -630,9 +654,9 @@
   let h = stHay st
       (o, h') = T.splitAt i h
       l = T.length o
-      r = stRange st
-      r' = r {rangeStart = rangeStart r + l}
-      st' = st {stHay = h', stRange = r'}
+      r = stSpan st
+      r' = r {spanStart = spanStart r + l}
+      st' = st {stHay = h', stSpan = r'}
   in  (o, st')
 
 -- | Take exactly the given number of characters from the start of the range, or error
@@ -642,9 +666,9 @@
     let h = stHay st
         (o, h') = T.splitAt i h
         l = T.length o
-        r = stRange st
-        r' = r {rangeStart = rangeStart r + T.length o}
-        st' = st {stHay = h', stRange = r'}
+        r = stSpan st
+        r' = r {spanStart = spanStart r + T.length o}
+        st' = st {stHay = h', stSpan = r'}
     in  if l == i then (Right o, st') else (Left l, st)
   case et of
     Left l -> errP (ReasonDemand i l)
@@ -665,9 +689,9 @@
       o = T.takeWhile f h
       l = T.length o
       h' = T.drop l h
-      r = stRange st
-      r' = r {rangeStart = rangeStart r + l}
-      st' = st {stHay = h', stRange = r'}
+      r = stSpan st
+      r' = r {spanStart = spanStart r + l}
+      st' = st {stHay = h', stSpan = r'}
   in  (o, st')
 
 -- | Like 'takeWhileP' but ensures at least 1 character has been taken
@@ -678,9 +702,9 @@
         o = T.takeWhile f h
         l = T.length o
         h' = T.drop l h
-        r = stRange st
-        r' = r {rangeStart = rangeStart r + l}
-        st' = st {stHay = h', stRange = r'}
+        r = stSpan st
+        r' = r {spanStart = spanStart r + l}
+        st' = st {stHay = h', stSpan = r'}
     in  if l == 0 then (Nothing, st) else (Just o, st')
   case mt of
     Nothing -> errP ReasonTakeNone
@@ -698,9 +722,9 @@
 takeAllP :: Monad m => ParserT e m Text
 takeAllP = stateP $ \st ->
   let h = stHay st
-      r = stRange st
-      r' = r {rangeStart = rangeEnd r}
-      st' = st {stHay = T.empty, stRange = r'}
+      r = stSpan st
+      r' = r {spanStart = spanEnd r}
+      st' = st {stHay = T.empty, stSpan = r'}
   in  (h, st')
 
 -- | Like 'takeAllP' but ensures at least 1 character has been taken
@@ -708,9 +732,9 @@
 takeAll1P = do
   mt <- stateP $ \st ->
     let h = stHay st
-        r = stRange st
-        r' = r {rangeStart = rangeEnd r}
-        st' = st {stHay = T.empty, stRange = r'}
+        r = stSpan st
+        r' = r {spanStart = spanEnd r}
+        st' = st {stHay = T.empty, stSpan = r'}
     in  if T.null h then (Nothing, st) else (Just h, st')
   case mt of
     Nothing -> errP (ReasonDemand 1 0)
@@ -812,9 +836,9 @@
 -- | Parses and returns the length of the consumed input along with the result
 measureP :: Monad m => ParserT e m a -> ParserT e m (a, Int)
 measureP p = do
-  start <- getsP (rangeStart . stRange)
+  start <- getsP (spanStart . stSpan)
   a <- p
-  end <- getsP (rangeStart . stRange)
+  end <- getsP (spanStart . stSpan)
   pure (a, end - start)
 
 -- | Takes exactly 1 character from the start of the range, returning Nothing
@@ -826,9 +850,9 @@
   in  case mxy of
         Nothing -> (Nothing, st)
         Just (x, y) ->
-          let r = stRange st
-              r' = r {rangeStart = rangeStart r + 1}
-              st' = st {stHay = y, stRange = r'}
+          let r = stSpan st
+              r' = r {spanStart = spanStart r + 1}
+              st' = st {stHay = y, stSpan = r'}
           in  (Just x, st')
 
 -- | Takes exactly 1 character from the start of the range, throwing error
@@ -960,7 +984,7 @@
 indent i = let s = T.replicate (2 * i) " " in fmap (s <>)
 
 instance HasErrMessage e => HasErrMessage (Err e) where
-  getErrMessage (Err (ErrF (Range start end) re)) =
+  getErrMessage (Err (ErrF (Span start end) re)) =
     let pos = "Error in range: (" <> T.pack (show start) <> ", " <> T.pack (show end) <> ")"
         body = case re of
           ReasonCustom e ->
@@ -1005,7 +1029,7 @@
 -- | Create 'Errata' formatting a parse error
 errataE :: HasErrMessage e => FilePath -> (Int -> (E.Line, E.Column)) -> Err e -> [E.Errata]
 errataE fp mkP e =
-  let (line, col) = mkP (rangeStart (errRange e))
+  let (line, col) = mkP (spanStart (errSpan e))
       msg = getErrMessage e
       block = E.blockSimple E.basicStyle E.basicPointer fp Nothing (line, col, col + 1, Nothing) (Just (T.unlines msg))
   in  [E.Errata Nothing [block] Nothing]
@@ -1013,8 +1037,8 @@
 -- | Render a formatted error to text
 renderE :: HasErrMessage e => FilePath -> Text -> Err e -> Text
 renderE fp h e =
-  let ov = mkOffsetVec h
-      mkP = if V.null ov then const (1, 1) else \i -> let (!l, !c) = ov V.! min i (V.length ov - 1) in (l + 1, c + 1)
+  let v = calculateLineCol h
+      mkP i = let (l, c) = lookupLineCol i v in (l + 1, c + 1)
   in  TL.toStrict (E.prettyErrors h (errataE fp mkP e))
 
 -- | Print a formatted error to stderr
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -4,6 +4,7 @@
 module Main (main) where
 
 import Control.Applicative (Alternative (..), liftA2)
+import Control.Exception (throwIO)
 import Data.Bifunctor (first)
 import Data.Foldable (toList)
 import Data.Sequence (Seq (..))
@@ -11,6 +12,7 @@
 import Data.String (IsString)
 import Data.Text (Text)
 import Data.Text qualified as T
+import Data.Void (Void)
 import Looksee
 import Looksee.Examples
 import Test.Tasty (TestName, TestTree, defaultMain, testGroup)
@@ -24,20 +26,20 @@
 
 data ParserCase a = ParserCase !TestName !(TestParser a) !Text !(TestResult (a, Int))
 
-err :: Range -> Reason Error (Err Error) -> TestResult (a, Int)
+err :: Span Int -> Reason Error (Err Error) -> TestResult (a, Int)
 err ra re = Left (Err (ErrF ra re))
 
-errAlt :: Range -> [(AltPhase, Range, Reason Error (Err Error))] -> TestResult (a, Int)
+errAlt :: Span Int -> [(AltPhase, Span Int, Reason Error (Err Error))] -> TestResult (a, Int)
 errAlt ra tups = Left (Err (ErrF ra (ReasonAlt (Seq.fromList (fmap f tups)))))
  where
   f (ap, ra', re) = (ap, Err (ErrF ra' re))
 
-errInfix :: Range -> [(Int, InfixPhase, Range, Reason Error (Err Error))] -> TestResult (a, Int)
+errInfix :: Span Int -> [(Int, InfixPhase, Span Int, Reason Error (Err Error))] -> TestResult (a, Int)
 errInfix ra tups = Left (Err (ErrF ra (ReasonInfix (Seq.fromList (fmap f tups)))))
  where
   f (ix, ip, ra', re) = (ix, ip, Err (ErrF ra' re))
 
-errLook :: Range -> Range -> Reason Error (Err Error) -> TestResult (a, Int)
+errLook :: Span Int -> Span Int -> Reason Error (Err Error) -> TestResult (a, Int)
 errLook ra1 ra2 re = Left (Err (ErrF ra1 (ReasonLook (Err (ErrF ra2 re)))))
 
 suc :: a -> Int -> TestResult (a, Int)
@@ -98,8 +100,8 @@
  where
   parser = emptyP :: TestParser Int
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonEmpty)
-    , ParserCase "non-empty" parser "hi" (err (Range 0 2) ReasonEmpty)
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonEmpty)
+    , ParserCase "non-empty" parser "hi" (err (Span 0 2) ReasonEmpty)
     ]
 
 testPure :: [TestTree]
@@ -116,8 +118,8 @@
  where
   parser = fail "i give up" :: TestParser Int
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonFail "i give up"))
-    , ParserCase "non-empty" parser "hi" (err (Range 0 2) (ReasonFail "i give up"))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonFail "i give up"))
+    , ParserCase "non-empty" parser "hi" (err (Span 0 2) (ReasonFail "i give up"))
     ]
 
 testHead :: [TestTree]
@@ -125,7 +127,7 @@
  where
   parser = headP
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonDemand 1 0))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonDemand 1 0))
     , ParserCase "non-empty" parser "hi" (suc 'h' 1)
     ]
 
@@ -157,7 +159,7 @@
   parser = endP
   cases =
     [ ParserCase "empty" parser "" (suc () 0)
-    , ParserCase "non-empty" parser "hi" (err (Range 0 2) (ReasonLeftover 2))
+    , ParserCase "non-empty" parser "hi" (err (Span 0 2) (ReasonLeftover 2))
     ]
 
 testExpectHead :: [TestTree]
@@ -165,9 +167,9 @@
  where
   parser = charP 'h'
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonExpect "h" ""))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonExpect "h" ""))
     , ParserCase "non-empty" parser "hi" (suc 'h' 1)
-    , ParserCase "non-match" parser "bye" (err (Range 1 3) (ReasonExpect "h" "b"))
+    , ParserCase "non-match" parser "bye" (err (Span 1 3) (ReasonExpect "h" "b"))
     ]
 
 testExpect :: [TestTree]
@@ -175,12 +177,12 @@
  where
   parser = textP "hi"
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonExpect "hi" ""))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonExpect "hi" ""))
     , ParserCase "non-empty" parser "hi" (suc "hi" 0)
     , ParserCase "prefix" parser "hiya" (suc "hi" 2)
-    , ParserCase "partial" parser "hey" (err (Range 2 3) (ReasonExpect "hi" "he"))
-    , ParserCase "non-match" parser "bye" (err (Range 2 3) (ReasonExpect "hi" "by"))
-    , ParserCase "short" parser "h" (err (Range 1 1) (ReasonExpect "hi" "h"))
+    , ParserCase "partial" parser "hey" (err (Span 2 3) (ReasonExpect "hi" "he"))
+    , ParserCase "non-match" parser "bye" (err (Span 2 3) (ReasonExpect "hi" "by"))
+    , ParserCase "short" parser "h" (err (Span 1 1) (ReasonExpect "hi" "h"))
     ]
 
 testGreedy :: [TestTree]
@@ -200,11 +202,11 @@
  where
   parser = fmap (T.pack . toList) (greedy1P (charP 'h')) :: TestParser Text
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonExpect "h" ""))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonExpect "h" ""))
     , ParserCase "non-empty" parser "hi" (suc "h" 1)
     , ParserCase "repeat" parser "hhi" (suc "hh" 1)
     , ParserCase "full" parser "hhh" (suc "hhh" 0)
-    , ParserCase "non-match" parser "bye" (err (Range 1 3) (ReasonExpect "h" "b"))
+    , ParserCase "non-match" parser "bye" (err (Span 1 3) (ReasonExpect "h" "b"))
     ]
 
 testOr :: [TestTree]
@@ -214,17 +216,17 @@
   cases =
     [ ParserCase "empty" parser "" $
         errAlt
-          (Range 0 0)
-          [ (AltPhaseBranch, Range 0 0, ReasonExpect "h" "")
-          , (AltPhaseBranch, Range 0 0, ReasonExpect "xi" "")
+          (Span 0 0)
+          [ (AltPhaseBranch, Span 0 0, ReasonExpect "h" "")
+          , (AltPhaseBranch, Span 0 0, ReasonExpect "xi" "")
           ]
     , ParserCase "first" parser "hi" (suc "h" 1)
     , ParserCase "second" parser "xi" (suc "xi" 0)
     , ParserCase "non-match" parser "bye" $
         errAlt
-          (Range 0 3)
-          [ (AltPhaseBranch, Range 1 3, ReasonExpect "h" "b")
-          , (AltPhaseBranch, Range 2 3, ReasonExpect "xi" "by")
+          (Span 0 3)
+          [ (AltPhaseBranch, Span 1 3, ReasonExpect "h" "b")
+          , (AltPhaseBranch, Span 2 3, ReasonExpect "xi" "by")
           ]
     ]
 
@@ -235,10 +237,10 @@
   cases =
     [ ParserCase "empty" parser "" $
         errAlt
-          (Range 0 0)
-          [ (AltPhaseBranch, Range 0 0, ReasonExpect "h" "")
-          , (AltPhaseBranch, Range 0 0, ReasonDemand 1 0)
-          , (AltPhaseBranch, Range 0 0, ReasonExpect "xi" "")
+          (Span 0 0)
+          [ (AltPhaseBranch, Span 0 0, ReasonExpect "h" "")
+          , (AltPhaseBranch, Span 0 0, ReasonDemand 1 0)
+          , (AltPhaseBranch, Span 0 0, ReasonExpect "xi" "")
           ]
     , ParserCase "first" parser "hi" (suc "h" 1)
     , ParserCase "middle" parser "zi" (suc "y" 1)
@@ -269,8 +271,8 @@
  where
   parser = charP 'x' >>= \c -> pure [c, c]
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonExpect "x" ""))
-    , ParserCase "first" parser "hi" (err (Range 1 2) (ReasonExpect "x" "h"))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonExpect "x" ""))
+    , ParserCase "first" parser "hi" (err (Span 1 2) (ReasonExpect "x" "h"))
     , ParserCase "second" parser "xi" (suc "xx" 1)
     ]
 
@@ -279,8 +281,8 @@
  where
   parser = headP >>= \x -> if x == 'x' then pure 'y' else emptyP
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonDemand 1 0))
-    , ParserCase "first" parser "hi" (err (Range 1 2) ReasonEmpty)
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonDemand 1 0))
+    , ParserCase "first" parser "hi" (err (Span 1 2) ReasonEmpty)
     , ParserCase "second" parser "xi" (suc 'y' 1)
     ]
 
@@ -290,8 +292,8 @@
   cust = Error "boo"
   parser = throwP cust :: TestParser Int
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonCustom cust))
-    , ParserCase "non-empty" parser "hi" (err (Range 0 2) (ReasonCustom cust))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonCustom cust))
+    , ParserCase "non-empty" parser "hi" (err (Span 0 2) (ReasonCustom cust))
     ]
 
 testConsumeThrow :: [TestTree]
@@ -300,8 +302,8 @@
   cust = Error "boo"
   parser = headP *> throwP cust :: TestParser Int
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) (ReasonDemand 1 0))
-    , ParserCase "non-empty" parser "hi" (err (Range 1 2) (ReasonCustom cust))
+    [ ParserCase "empty" parser "" (err (Span 0 0) (ReasonDemand 1 0))
+    , ParserCase "non-empty" parser "hi" (err (Span 1 2) (ReasonCustom cust))
     ]
 
 testOptThrow :: [TestTree]
@@ -364,7 +366,7 @@
  where
   parser = lookP headP
   cases =
-    [ ParserCase "non-match empty" parser "" (errLook (Range 0 0) (Range 0 0) (ReasonDemand 1 0))
+    [ ParserCase "non-match empty" parser "" (errLook (Span 0 0) (Span 0 0) (ReasonDemand 1 0))
     , ParserCase "non-empty" parser "hi" (suc 'h' 2)
     ]
 
@@ -374,8 +376,8 @@
   cust = Error "boo"
   parser = lookP (headP *> throwP cust) :: TestParser Char
   cases =
-    [ ParserCase "non-match empty" parser "" (errLook (Range 0 0) (Range 0 0) (ReasonDemand 1 0))
-    , ParserCase "non-empty" parser "hi" (errLook (Range 0 2) (Range 1 2) (ReasonCustom cust))
+    [ ParserCase "non-match empty" parser "" (errLook (Span 0 0) (Span 0 0) (ReasonDemand 1 0))
+    , ParserCase "non-empty" parser "hi" (errLook (Span 0 2) (Span 1 2) (ReasonCustom cust))
     ]
 
 testTakeWhile :: [TestTree]
@@ -395,8 +397,8 @@
  where
   parser = takeWhile1P (== 'h') :: TestParser Text
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonTakeNone)
-    , ParserCase "non-match" parser "i" (err (Range 0 1) ReasonTakeNone)
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonTakeNone)
+    , ParserCase "non-match" parser "i" (err (Span 0 1) ReasonTakeNone)
     , ParserCase "match" parser "hi" (suc "h" 1)
     , ParserCase "match 2" parser "hhi" (suc "hh" 1)
     , ParserCase "match end" parser "hh" (suc "hh" 0)
@@ -419,8 +421,8 @@
  where
   parser = dropWhile1P (== 'h') :: TestParser Int
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonTakeNone)
-    , ParserCase "non-match" parser "i" (err (Range 0 1) ReasonTakeNone)
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonTakeNone)
+    , ParserCase "non-match" parser "i" (err (Span 0 1) ReasonTakeNone)
     , ParserCase "match" parser "hi" (suc 1 1)
     , ParserCase "match 2" parser "hhi" (suc 2 1)
     , ParserCase "match end" parser "hh" (suc 2 0)
@@ -434,14 +436,14 @@
   parserR = infixRP "+" (textP "x") (textP "x+x") :: TestParser (Text, Text)
   parserL = infixRP "+" (textP "x+x") (textP "x") :: TestParser (Text, Text)
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonEmpty)
-    , ParserCase "fail delim" parser "xy" (err (Range 0 2) ReasonEmpty)
-    , ParserCase "fail first" parser "+y" (errInfix (Range 0 2) [(0, InfixPhaseLeft, Range 0 0, ReasonTakeNone)])
-    , ParserCase "fail second" parser "x+" (errInfix (Range 0 2) [(1, InfixPhaseRight, Range 2 2, ReasonTakeNone)])
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonEmpty)
+    , ParserCase "fail delim" parser "xy" (err (Span 0 2) ReasonEmpty)
+    , ParserCase "fail first" parser "+y" (errInfix (Span 0 2) [(0, InfixPhaseLeft, Span 0 0, ReasonTakeNone)])
+    , ParserCase "fail second" parser "x+" (errInfix (Span 0 2) [(1, InfixPhaseRight, Span 2 2, ReasonTakeNone)])
     , ParserCase "match" parser "x+y" (suc ("x", "y") 0)
     , ParserCase "match multi" parser "x++y" (suc ("x", "+y") 0)
     , ParserCase "match rassoc" parserR "x+x+x" (suc ("x", "x+x") 0)
-    , ParserCase "fail lassoc" parserL "x+x+x" (errInfix (Range 0 5) [(1, InfixPhaseLeft, Range 1 1, ReasonExpect "x+x" "x")])
+    , ParserCase "fail lassoc" parserL "x+x+x" (errInfix (Span 0 5) [(1, InfixPhaseLeft, Span 1 1, ReasonExpect "x+x" "x")])
     ]
 
 testSomeInfixR :: [TestTree]
@@ -452,10 +454,10 @@
   parserR = someInfixRP "+" (textP "x") (textP "x+x") :: TestParser (Text, Text)
   parserL = someInfixRP "+" (textP "x+x") (textP "x") :: TestParser (Text, Text)
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonEmpty)
-    , ParserCase "fail delim" parser "xy" (err (Range 0 2) ReasonEmpty)
-    , ParserCase "fail first" parser "+y" (errInfix (Range 0 2) [(0, InfixPhaseLeft, Range 0 0, ReasonTakeNone)])
-    , ParserCase "fail second" parser "x+" (errInfix (Range 0 2) [(1, InfixPhaseRight, Range 2 2, ReasonTakeNone)])
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonEmpty)
+    , ParserCase "fail delim" parser "xy" (err (Span 0 2) ReasonEmpty)
+    , ParserCase "fail first" parser "+y" (errInfix (Span 0 2) [(0, InfixPhaseLeft, Span 0 0, ReasonTakeNone)])
+    , ParserCase "fail second" parser "x+" (errInfix (Span 0 2) [(1, InfixPhaseRight, Span 2 2, ReasonTakeNone)])
     , ParserCase "match" parser "x+y" (suc ("x", "y") 0)
     , ParserCase "match multi" parser "x++y" (suc ("x", "+y") 0)
     , ParserCase "match rassoc" parserR "x+x+x" (suc ("x", "x+x") 0)
@@ -469,12 +471,12 @@
   parserR = breakP "+" (textP "x")
   parserL = breakP "+" (textP "x+x")
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonEmpty)
-    , ParserCase "fail delim" parser "x" (err (Range 0 1) ReasonEmpty)
-    , ParserCase "fail first" parser "y+" (errInfix (Range 0 2) [(1, InfixPhaseLeft, Range 0 1, ReasonTakeNone)])
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonEmpty)
+    , ParserCase "fail delim" parser "x" (err (Span 0 1) ReasonEmpty)
+    , ParserCase "fail first" parser "y+" (errInfix (Span 0 2) [(1, InfixPhaseLeft, Span 0 1, ReasonTakeNone)])
     , ParserCase "match" parser "x+x+y" (suc "x" 3)
     , ParserCase "match rassoc" parserR "x+x+x" (suc "x" 3)
-    , ParserCase "fail lassoc" parserL "x+x+x" (errInfix (Range 0 5) [(1, InfixPhaseLeft, Range 1 1, ReasonExpect "x+x" "x")])
+    , ParserCase "fail lassoc" parserL "x+x+x" (errInfix (Span 0 5) [(1, InfixPhaseLeft, Span 1 1, ReasonExpect "x+x" "x")])
     ]
 
 testSomeBreak :: [TestTree]
@@ -484,9 +486,9 @@
   parserR = someBreakP "+" (textP "x")
   parserL = someBreakP "+" (textP "x+x")
   cases =
-    [ ParserCase "empty" parser "" (err (Range 0 0) ReasonEmpty)
-    , ParserCase "fail delim" parser "x" (err (Range 0 1) ReasonEmpty)
-    , ParserCase "fail first" parser "y+" (errInfix (Range 0 2) [(1, InfixPhaseLeft, Range 0 1, ReasonTakeNone)])
+    [ ParserCase "empty" parser "" (err (Span 0 0) ReasonEmpty)
+    , ParserCase "fail delim" parser "x" (err (Span 0 1) ReasonEmpty)
+    , ParserCase "fail first" parser "y+" (errInfix (Span 0 2) [(1, InfixPhaseLeft, Span 0 1, ReasonTakeNone)])
     , ParserCase "match" parser "x+x+y" (suc "x" 3)
     , ParserCase "match rassoc" parserR "x+x+x" (suc "x" 3)
     , ParserCase "match lassoc" parserL "x+x+x" (suc "x+x" 1)
@@ -513,13 +515,44 @@
   cases =
     [ ParserCase "empty" parser "" (suc ([""], True) 0)
     , ParserCase "single" parser "x" (suc (["x"], True) 0)
-    , ParserCase "fail" parser "xy" (err (Range 0 2) (ReasonSplitComp SplitCompGE 1 "+" 0))
+    , ParserCase "fail" parser "xy" (err (Span 0 2) (ReasonSplitComp SplitCompGE 1 "+" 0))
     , ParserCase "double" parser "x+x" (suc (["x", "x"], True) 0)
     , ParserCase "triple" parser "x+x+x" (suc (["x", "x", "x"], True) 0)
-    , ParserCase "fail first" parser "y+x" (err (Range 0 3) (ReasonSplitComp SplitCompGE 1 "+" 0))
+    , ParserCase "fail first" parser "y+x" (err (Span 0 3) (ReasonSplitComp SplitCompGE 1 "+" 0))
     , ParserCase "fail second" parser "x+y" (suc (["x"], False) 2)
     ]
 
+testSpan :: TestTree
+testSpan = testCase "span" $ do
+  let p :: Parser Void (Span Int, Span Int) = do
+        charP_ 'x'
+        r1 <- spanP
+        charP_ 'y'
+        r2 <- spanP
+        charP_ 'z'
+        pure (r1, r2)
+  let doc = "xyz"
+  case parse p doc of
+    Left e -> throwIO e
+    Right (r1, r2) -> do
+      r1 @?= Span 1 3
+      r2 @?= Span 2 3
+  let v1 = calculateLineCol doc
+  lookupLineCol (-1) v1 @?= (0, 0)
+  lookupLineCol 0 v1 @?= (0, 0)
+  lookupLineCol 1 v1 @?= (0, 1)
+  lookupLineCol 2 v1 @?= (0, 2)
+  lookupLineCol 3 v1 @?= (0, 2)
+  let v2 = calculateLineCol "a\nbc\nd"
+  lookupLineCol (-1) v2 @?= (0, 0)
+  lookupLineCol 0 v2 @?= (0, 0)
+  lookupLineCol 1 v2 @?= (0, 1)
+  lookupLineCol 2 v2 @?= (1, 0)
+  lookupLineCol 3 v2 @?= (1, 1)
+  lookupLineCol 4 v2 @?= (1, 2)
+  lookupLineCol 5 v2 @?= (2, 0)
+  lookupLineCol 6 v2 @?= (2, 0)
+
 testJson :: TestTree
 testJson = testGroup "json" (fmap test cases)
  where
@@ -610,6 +643,7 @@
     testGroup
       "Looksee"
       [ testBasic
+      , testSpan
       , testJson
       , testSexp
       , testArith
