diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+# 0.3.1.0
+
+- Add `splitAtLine` function
+
+# 0.3.0.0
+
+- Add `rows` and `columns` functions
+- Make `Position` module internal
+- Add `map` and `intersperse` functions
+- Add `foldl`, `foldl'`, `foldr`, `any`, and `all` functions
+
 # 0.2.0.0
 
 - Rename rowColumnCodePoints to rowColumnCodeUnits
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
-# rope-utf16-splay [![Build Status](https://travis-ci.org/ollef/rope-utf16-splay.svg?branch=master)](https://travis-ci.org/ollef/rope-utf16-splay)
+# rope-utf16-splay [![Build Status](https://travis-ci.org/ollef/rope-utf16-splay.svg?branch=master)](https://travis-ci.org/ollef/rope-utf16-splay) [![Hackage](https://img.shields.io/hackage/v/rope-utf16-splay.svg)](https://hackage.haskell.org/package/rope-utf16-splay)
+
 
 Thick strings optimised for indexing and updating using UTF-16 code units and
 row/column pairs.
diff --git a/rope-utf16-splay.cabal b/rope-utf16-splay.cabal
--- a/rope-utf16-splay.cabal
+++ b/rope-utf16-splay.cabal
@@ -1,5 +1,5 @@
 name:                rope-utf16-splay
-version:             0.2.0.0
+version:             0.3.1.0
 synopsis:            Ropes optimised for updating using UTF-16 code units and
                      row/column pairs.
 description:         Ropes optimised for updating using UTF-16 code units and
@@ -29,8 +29,8 @@
   exposed-modules:
                        Data.Rope.UTF16
                        Data.Rope.UTF16.Internal
+                       Data.Rope.UTF16.Internal.Position
                        Data.Rope.UTF16.Internal.Text
-                       Data.Rope.UTF16.Position
                        Data.SplayTree
   build-depends:
                        base >= 4.9 && < 5,
diff --git a/src/Data/Rope/UTF16.hs b/src/Data/Rope/UTF16.hs
--- a/src/Data/Rope/UTF16.hs
+++ b/src/Data/Rope/UTF16.hs
@@ -1,34 +1,50 @@
 module Data.Rope.UTF16
-  ( module Position
-  , Rope.Rope
+  ( Rope.Rope
 
   -- * Queries
   , Rope.null
+  , Rope.length
+  , Rope.rows
+  , Rope.columns
 
-  -- * Conversions to and from 'Text' and 'String'
+  -- * Conversions
   , Rope.toText
   , Rope.toLazyText
   , Rope.fromText
   , Rope.toString
 
+  -- * Transformations
+  , Rope.map
+  , Rope.intercalate
+
   -- * Chunking
   , Rope.toChunks
   , Rope.unconsChunk
   , Rope.unsnocChunk
 
   -- * UTF-16 code unit indexing
-  , Rope.length
   , Rope.splitAt
   , Rope.take
   , Rope.drop
+  , Position.RowColumn(..)
   , Rope.rowColumnCodeUnits
+  , Rope.splitAtLine
 
   -- * Breaking by predicate
   , Rope.span
   , Rope.break
   , Rope.takeWhile
   , Rope.dropWhile
+
+  -- * Folds
+  , Rope.foldl
+  , Rope.foldl'
+  , Rope.foldr
+
+  -- * Special folds
+  , Rope.any
+  , Rope.all
   ) where
 
-import Data.Rope.UTF16.Position as Position
 import Data.Rope.UTF16.Internal as Rope
+import Data.Rope.UTF16.Internal.Position as Position
diff --git a/src/Data/Rope/UTF16/Internal.hs b/src/Data/Rope/UTF16/Internal.hs
--- a/src/Data/Rope/UTF16/Internal.hs
+++ b/src/Data/Rope/UTF16/Internal.hs
@@ -3,8 +3,9 @@
 {-# language MultiParamTypeClasses #-}
 module Data.Rope.UTF16.Internal where
 
-import Data.Foldable
+import Data.Foldable as Foldable
 import Data.Function
+import Data.List
 import Data.Semigroup
 import Data.String
 import Data.Text(Text)
@@ -12,8 +13,8 @@
 import qualified Data.Text.Lazy as Lazy
 import qualified Data.Text.Unsafe as Unsafe
 
+import Data.Rope.UTF16.Internal.Position
 import Data.Rope.UTF16.Internal.Text
-import Data.Rope.UTF16.Position
 import Data.SplayTree(SplayTree)
 import qualified Data.SplayTree as SplayTree
 
@@ -83,8 +84,25 @@
 null :: Rope -> Bool
 null (Rope r) = SplayTree.null r
 
+-- | Length in code units (not characters)
+length :: Rope -> Int
+length = codeUnits . SplayTree.measure
+
+-- | The number of newlines in the rope
+--
+-- @since 0.3.0.0
+rows :: Rope -> Int
+rows (Rope r) = row $ rowColumn $ SplayTree.measure r
+
+-- | The number of code units (not characters) since the last newline or the
+-- start of the rope
+--
+-- @since 0.3.0.0
+columns :: Rope -> Int
+columns (Rope r) = column $ rowColumn $ SplayTree.measure r
+
 -------------------------------------------------------------------------------
--- * Conversions to and from 'Text' and 'String'
+-- * Conversions
 
 toText :: Rope -> Text
 toText = Text.concat . toChunks
@@ -111,9 +129,24 @@
   | otherwise = Rope $ SplayTree.singleton $ chunk t
 
 toString :: Rope -> String
-toString = concatMap Text.unpack . toChunks
+toString = Foldable.concatMap Text.unpack . toChunks
 
 -------------------------------------------------------------------------------
+-- * Transformations
+
+-- | Map over the characters of a rope
+--
+-- @since 0.3.0.0
+map :: (Char -> Char) -> Rope -> Rope
+map f (Rope r) = Rope $ SplayTree.map (chunk . Text.map f . chunkText) r
+
+-- | Concatenate the interspersion of a rope between the elements of a list of ropes
+--
+-- @since 0.3.0.0
+intercalate :: Rope -> [Rope] -> Rope
+intercalate r rs = mconcat $ intersperse r rs
+
+-------------------------------------------------------------------------------
 -- * Chunking
 
 -- | The raw 'Text' data that the 'Rope' is built from
@@ -135,10 +168,6 @@
 -------------------------------------------------------------------------------
 -- * UTF-16 code unit indexing
 
--- | Length in code units (not characters)
-length :: Rope -> Int
-length = codeUnits . SplayTree.measure
-
 -- | Split the rope at the nth code unit (not character)
 splitAt :: Int -> Rope -> (Rope, Rope)
 splitAt n (Rope r) = case SplayTree.split ((> n) . codeUnits) r of
@@ -178,8 +207,20 @@
           Unsafe.Iter _ delta -> go (i + delta) (v' <> RowColumn 0 delta)
 
 -------------------------------------------------------------------------------
+-- * Lines
+
+-- | Split the rope immediately after the i:th newline
+--
+-- @since 0.3.1.0
+splitAtLine :: Int -> Rope -> (Rope, Rope)
+splitAtLine r rope = Data.Rope.UTF16.Internal.splitAt i rope
+  where
+    i = rowColumnCodeUnits (RowColumn r 0) rope
+
+-------------------------------------------------------------------------------
 -- * Breaking by predicate
 
+-- | @span f r = (takeWhile f r, dropWhile f r)@
 span :: (Char -> Bool) -> Rope -> (Rope, Rope)
 span f (Rope r) = case SplayTree.uncons r of
   Nothing -> (mempty, mempty)
@@ -190,11 +231,50 @@
       (pret, postt) = Text.span f $ chunkText t
       (pre', post') = Data.Rope.UTF16.Internal.span f $ Rope r'
 
+-- | @break f = span (not . f)@
 break :: (Char -> Bool) -> Rope -> (Rope, Rope)
 break f = Data.Rope.UTF16.Internal.span (not . f)
 
+-- | @takeWhile f = fst . span f@
 takeWhile :: (Char -> Bool) -> Rope -> Rope
 takeWhile f = fst . Data.Rope.UTF16.Internal.span f
 
+-- | @dropWhile f = snd . span f@
 dropWhile :: (Char -> Bool) -> Rope -> Rope
 dropWhile f = snd . Data.Rope.UTF16.Internal.span f
+
+-------------------------------------------------------------------------------
+-- * Folds
+
+-- | Fold left
+--
+-- @since 0.3.0.0
+foldl :: (a -> Char -> a) -> a -> Rope -> a
+foldl f a (Rope r) = Foldable.foldl (\a' c -> Text.foldl f a' $ chunkText c) a r
+
+-- | A strict version of 'foldl'
+--
+-- @since 0.3.0.0
+foldl' :: (a -> Char -> a) -> a -> Rope -> a
+foldl' f a (Rope r) = Foldable.foldl' (\a' c -> Text.foldl' f a' $ chunkText c) a r
+
+-- | Fold right
+--
+-- @since 0.3.0.0
+foldr :: (Char -> a -> a) -> a -> Rope -> a
+foldr f a (Rope r) = Foldable.foldr (\c a' -> Text.foldr f a' $ chunkText c) a r
+
+-------------------------------------------------------------------------------
+-- * Special folds
+
+-- | Do any characters in the rope satisfy the predicate?
+--
+-- @since 0.3.0.0
+any :: (Char -> Bool) -> Rope -> Bool
+any p (Rope r) = getAny $ foldMap (Any . Text.any p . chunkText) r
+
+-- | Do all characters in the rope satisfy the predicate?
+--
+-- @since 0.3.0.0
+all :: (Char -> Bool) -> Rope -> Bool
+all p (Rope r) = getAll $ foldMap (All . Text.all p . chunkText) r
diff --git a/src/Data/Rope/UTF16/Internal/Position.hs b/src/Data/Rope/UTF16/Internal/Position.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Rope/UTF16/Internal/Position.hs
@@ -0,0 +1,31 @@
+module Data.Rope.UTF16.Internal.Position where
+
+import Data.Semigroup
+
+data RowColumn = RowColumn
+  { row :: !Int -- ^ Number of newlines before this position
+  , column :: !Int -- ^ Number of code units since last newline or start of string
+  } deriving (Eq, Ord, Show)
+
+instance Semigroup RowColumn where
+  RowColumn 0 c1 <> RowColumn 0 c2 = RowColumn 0 $ c1 + c2
+  RowColumn 0 _ <> v2 = v2
+  RowColumn r1 c1 <> RowColumn 0 c2 = RowColumn r1 $ c1 + c2
+  RowColumn r1 _ <> RowColumn r2 c2 = RowColumn (r1 + r2) c2
+
+instance Monoid RowColumn where
+  mempty = RowColumn 0 0
+  mappend = (<>)
+
+data Position = Position
+  { codeUnits :: !Int
+  , rowColumn :: !RowColumn
+  } deriving (Eq, Ord, Show)
+
+instance Semigroup Position where
+  Position cp1 v1 <> Position cp2 v2
+    = Position (cp1 + cp2) (v1 <> v2)
+
+instance Monoid Position where
+  mempty = Position 0 mempty
+  mappend = (<>)
diff --git a/src/Data/Rope/UTF16/Position.hs b/src/Data/Rope/UTF16/Position.hs
deleted file mode 100644
--- a/src/Data/Rope/UTF16/Position.hs
+++ /dev/null
@@ -1,34 +0,0 @@
-module Data.Rope.UTF16.Position where
-
-import Data.Semigroup
-
-data RowColumn = RowColumn
-  { row :: !Int -- ^ Number of newlines before this position
-  , column :: !Int -- ^ Number of code units since last newline or start of string
-  } deriving (Eq, Ord, Show)
-
-instance Semigroup RowColumn where
-  RowColumn 0 c1 <> RowColumn 0 c2 = RowColumn 0 $ c1 + c2
-  RowColumn 0 _ <> v2 = v2
-  RowColumn r1 c1 <> RowColumn 0 c2 = RowColumn r1 $ c1 + c2
-  RowColumn r1 _ <> RowColumn r2 c2 = RowColumn (r1 + r2) c2
-
-instance Monoid RowColumn where
-  mempty = RowColumn 0 0
-  mappend = (<>)
-
-data Position = Position
-  { codeUnits :: !Int
-  , rowColumn :: !RowColumn
-  } deriving (Eq, Ord, Show)
-
-lineStart :: Position -> Position
-lineStart (Position cp (RowColumn r c)) = Position (cp - c) $ RowColumn r 0
-
-instance Semigroup Position where
-  Position cp1 v1 <> Position cp2 v2
-    = Position (cp1 + cp2) (v1 <> v2)
-
-instance Monoid Position where
-  mempty = Position 0 mempty
-  mappend = (<>)
diff --git a/tests/Main.hs b/tests/Main.hs
--- a/tests/Main.hs
+++ b/tests/Main.hs
@@ -1,6 +1,8 @@
+{-# LANGUAGE OverloadedStrings #-}
 module Main where
 
 import Data.Semigroup
+import Data.Text (Text)
 import qualified Data.Text as Text
 import qualified Data.Text.Unsafe as Unsafe
 import Test.QuickCheck.Function as QC
@@ -46,6 +48,11 @@
     let t = Text.pack $ replicate newlines '\n' ++ takeWhile (/= '\n') s
     Rope.clamp16 (newlines + i) t == Rope.rowColumnCodeUnits (Rope.RowColumn newlines i) (Rope.fromText t)
 
+  , testProperty "splitAtLine" $ \s i -> do
+    let t = Text.pack s
+        (rows1', rows2') = Rope.splitAtLine i (Rope.fromText t)
+    splitAtLineSpec i t == (Rope.toText rows1', Rope.toText rows2')
+
   , testProperty "span matches Text" $ \s p -> do
     let t = Text.pack s
         f = QC.applyFun p
@@ -71,4 +78,57 @@
   , testProperty "null matches Text" $ \s -> do
     let t = Text.pack s
     Rope.null (Rope.fromText t) == Text.null t
+
+  , testProperty "rows is number of newlines" $ \s -> do
+    let t = Text.pack s
+        newlines = length $ filter (== '\n') s
+    Rope.rows (Rope.fromText t) == newlines
+
+  , testProperty "columns is number of code units of last line" $ \s -> do
+    let t = Text.pack s
+        lastLine = Text.takeWhileEnd (/= '\n') t
+        cols = Unsafe.lengthWord16 lastLine
+    Rope.columns (Rope.fromText t) == cols
+
+  , testProperty "foldl matches Text" $ \s p a -> do
+    let t = Text.pack s
+        f = QC.applyFun2 p
+    Rope.foldl f (a :: Int) (Rope.fromText t) == Text.foldl f a t
+
+  , testProperty "foldl' matches Text" $ \s p a -> do
+    let t = Text.pack s
+        f = QC.applyFun2 p
+    Rope.foldl' f (a :: Int) (Rope.fromText t) == Text.foldl' f a t
+
+  , testProperty "foldr matches Text" $ \s p a -> do
+    let t = Text.pack s
+        f = QC.applyFun2 p
+    Rope.foldr f (a :: Int) (Rope.fromText t) == Text.foldr f a t
+
+  , testProperty "any matches Text" $ \s p -> do
+    let t = Text.pack s
+        f = QC.applyFun p
+    Rope.any f (Rope.fromText t) == Text.any f t
+
+  , testProperty "all matches Text" $ \s p -> do
+    let t = Text.pack s
+        f = QC.applyFun p
+    Rope.all f (Rope.fromText t) == Text.all f t
+
+  , testProperty "map matches Text" $ \s p -> do
+    let t = Text.pack s
+        f = QC.applyFun p
+    Rope.toText (Rope.map f (Rope.fromText t)) == Text.map f t
+
+  , testProperty "intercalate matches Text" $ \s ss -> do
+    let t = Text.pack s
+        ts = Text.pack <$> ss
+    Rope.toText (Rope.intercalate (Rope.fromText t) (Rope.fromText <$> ts)) == Text.intercalate t ts
   ]
+
+splitAtLineSpec :: Int -> Text -> (Text, Text)
+splitAtLineSpec i t = case Text.splitOn "\n" t of
+  [] -> ("", "")
+  ts -> do
+    let (pres, posts) = splitAt i (fmap (<> "\n") (init ts) <> [last ts])
+    (Text.concat pres, Text.concat posts)
