packages feed

nano-rope-0.1.0.0: test/Main.hs

{-# OPTIONS_GHC -Wno-orphans #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Compare rope operations with a simple 'Text' model. Check tree
-- invariants, cached metrics, and annotations after each operation, and
-- test the laws of the public instances and custom measures.
--
-- Compiled with both default and small chunk/node sizes; see nano-rope.cabal.
module Main (main) where

import Control.Exception (ErrorCall, bracket, try)
import qualified Data.ByteString as B
import qualified Data.List as L
import Data.Maybe (fromMaybe)
import Data.Primitive.ByteArray (ByteArray (..), cloneByteArray, indexByteArray, sizeofByteArray)
import Data.Proxy (Proxy (..))
import Data.String (IsString (..))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Array as A
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Internal as TI
import qualified Data.Text.Lazy as TL
import qualified Data.Text.NanoRope as Plain
import Data.Text.NanoRope.Internal (ChunkLine (..), Kernels (..), height, invariants, kernels, maxChunk)
import Data.Text.NanoRope.Measured (Measure (..), Metrics (..), Position (..), Rope, Unit (..))
import qualified Data.Text.NanoRope.Measured as Rope
import Data.Text.Unsafe (dropWord8, takeWord8)
import Data.Word (Word8)
import System.Directory (getTemporaryDirectory, removeFile)
import System.IO (Handle, hClose, openBinaryTempFile)
import Test.QuickCheck.Classes.Base (Laws (..), commutativeMonoidLaws, eqLaws, monoidLaws, ordLaws, semigroupLaws, semigroupMonoidLaws, showLaws)
import Test.Tasty (TestTree, adjustOption, defaultMain, localOption, testGroup)
import Test.Tasty.QuickCheck

main :: IO ()
main =
  defaultMain $
    testGroup
      ("nano-rope, chunks of " ++ show maxChunk ++ " bytes")
      [ testGroup
          "conversions"
          [ testProperty "fromText / toText" prop_roundtrip
          , testProperty "lazy text" prop_lazy
          , testProperty "toChunks" prop_chunks
          , testProperty "chunkAt" prop_chunkAt
          , testProperty "hPutUtf8 / writeFileUtf8" prop_output
          , testProperty "output of more than the buffer" (once prop_outputLarge)
          , testProperty "output of a rope that fails" (once prop_outputFails)
          , testProperty "remeasure" prop_remeasure
          ]
      , testGroup
          "combining and breaking"
          [ testProperty "append" prop_append
          , testProperty "mconcat of many pieces" prop_mconcat
          , testProperty "splitAt" prop_splitAt
          , testProperty "slice / sliceText" prop_slice
          ]
      , testGroup
          "editing"
          [ testProperty "sequences of operations" prop_ops
          , testProperty "typing and erasing at a cursor" prop_typing
          , testProperty "keystrokes that continue each other" prop_run
          , testProperty "typing on from the same rope twice" prop_branching
          , testProperty "an insertion anywhere near a run" prop_nearRun
          ]
      , testGroup
          "units"
          [ testProperty "metricsAt" prop_metricsAt
          , testProperty "convert" prop_convert
          , testProperty "metricsAtPosition / metricsAtLineAndPosition / splitAtPosition" prop_position
          , testProperty "metricsToPosition" prop_toPosition
          , testProperty "position round trip" prop_positionRoundtrip
          ]
      , testGroup
          "lines"
          [ testProperty "lines / lineCount" prop_lines
          , testProperty "getLine" prop_getLine
          ]
      , testGroup
          "measures"
          [ testProperty "splitWhere by width" prop_splitWhereWidth
          , testProperty "splitWhere by line breaks" prop_splitWhereBreaks
          , testProperty "splitWhere by metrics" prop_splitWhereMetrics
          ]
      , testGroup
          "instances"
          [ testProperty "Eq ignores the shape of the tree" prop_eq
          , testProperty "Ord agrees with Text" prop_ord
          , testProperty "Show agrees with Text" prop_show
          ]
      , testGroup
          "laws"
          [ testProperty "the ropes they are tried on" prop_lawRopes
          , -- Use enough samples to exercise laws requiring three equal ropes.
            adjustOption (\(QuickCheckTests n) -> QuickCheckTests (max 500 n)) $
              lawsOf "Rope" $
                map ($ Proxy @R) [eqLaws, ordLaws, eqOrdLaws, semigroupLaws, monoidLaws, semigroupMonoidLaws, showLaws]
                  ++ [homomorphismLaws, isStringLaws]
          , lawsOf "Metrics" $ map ($ Proxy @Metrics) [semigroupLaws, monoidLaws, commutativeMonoidLaws, semigroupMonoidLaws]
          , testGroup
              "Measure"
              [ lawsOf "()" [measureLaws (Proxy @())]
              , lawsOf "pairs" [measureLaws (Proxy @(Breaks, Width))]
              , lawsOf "triples" [measureLaws (Proxy @((), Width, Breaks))]
              ]
          , -- Validate the custom measures used by the other properties.
            testGroup
              "test measures"
              [ lawsOf "Breaks" $ map ($ Proxy @Breaks) [semigroupLaws, monoidLaws, semigroupMonoidLaws, measureLaws]
              , lawsOf "Width" $ map ($ Proxy @Width) [semigroupLaws, monoidLaws, semigroupMonoidLaws, measureLaws]
              ]
          ]
      , testGroup
          "plain interface"
          [ testProperty "agrees with the measured one" prop_plain
          ]
      , testGroup
          "big"
          [ localOption (QuickCheckTests 5) (testProperty "a tall tree" prop_big)
          ]
      , testGroup
          ("chunk scans: " ++ L.intercalate ", " (map kernelsName kernels))
          [ testProperty "metrics" prop_scanMetrics
          , testProperty "line feeds" prop_scanNewlines
          , testProperty "the next line feed" prop_scanNext
          , testProperty "the previous line feed" prop_scanPrevious
          , testProperty "the k-th line" prop_scanLine
          , testProperty "code points and UTF-16 code units" prop_scanUnits
          ]
      ]

------------------------------------------------------------------------------
-- Custom test measures

-- | Count CRLF, CR, and LF as one break each. Track boundary characters
-- so combining @"a\\r"@ and @"\\nb"@ counts their shared CRLF only once.
data Breaks
  = NoText
  | Breaks !Int !Bool !Bool
  deriving (Eq, Show)

breakCount :: Breaks -> Int
breakCount NoText = 0
breakCount (Breaks n _ _) = n

instance Semigroup Breaks where
  NoText <> b = b
  a <> NoText = a
  Breaks n1 lf1 cr1 <> Breaks n2 lf2 cr2 =
    Breaks (n1 + n2 - (if cr1 && lf2 then 1 else 0)) lf1 cr2

instance Monoid Breaks where
  mempty = NoText

instance Measure Breaks where
  measureChunk t
    | T.null t = NoText
    | otherwise =
        Breaks
          (T.count "\r" t + T.count "\n" t - T.count "\r\n" t)
          (T.head t == '\n')
          (T.last t == '\r')

-- | A synthetic width measure for testing: code points from U+1100 count
-- as two, all others as one. This is not a Unicode display-width algorithm.
newtype Width = Width Int
  deriving (Eq, Ord, Show)

instance Semigroup Width where
  Width a <> Width b = Width (a + b)

instance Monoid Width where
  mempty = Width 0

charWidth :: Char -> Int
charWidth c = if c >= '\x1100' then 2 else 1

instance Measure Width where
  measureChunk = Width . T.foldl' (\n c -> n + charWidth c) 0

type R = Rope (Breaks, Width)

------------------------------------------------------------------------------
-- The model

utf8Len, utf16Len :: Char -> Int
utf8Len c
  | c < '\x80' = 1
  | c < '\x800' = 2
  | c < '\x10000' = 3
  | otherwise = 4
utf16Len c = if c < '\x10000' then 1 else 2

naiveMetrics :: Text -> Metrics
naiveMetrics t =
  Metrics
    { bytes = sum (map utf8Len s)
    , chars = L.length s
    , utf16Units = sum (map utf16Len s)
    , newlines = L.length (filter (== '\n') s)
    }
  where
    s = T.unpack t

-- | Convert a clamped, rounded offset to a code point count.
charsAt :: Unit -> Int -> Text -> Int
charsAt u k t
  | k <= 0 = 0
  | otherwise = case u of
      Chars -> min k (T.length t)
      Bytes -> fitting utf8Len
      Utf16 -> fitting utf16Len
      Lines -> case L.drop (k - 1) [i + 1 | (i, '\n') <- zip [0 ..] s] of
        p : _ -> p
        [] -> L.length s
  where
    s = T.unpack t
    fitting w = L.length (takeWhile (<= k) (drop 1 (scanl (+) 0 (map w s))))

-- | Resolve both range endpoints to code point counts in the original text.
charRange :: Unit -> Int -> Int -> Text -> (Int, Int)
charRange u i j t = (ni, if j <= i then ni else charsAt u j t)
  where
    ni = charsAt u i t

-- | Code point offset and content of each line, keeping an empty final line
-- when present.
lineTable :: Text -> [(Int, Text)]
lineTable = go 0 . T.splitOn "\n"
  where
    go _ [] = []
    go s [final] = [(s, final)]
    go s (l : ls) = (s, fromMaybe l (T.stripSuffix "\r" l)) : go (s + T.length l + 1) ls

naiveLines :: Text -> [Text]
naiveLines t
  | T.null t || T.last t == '\n' = L.init table
  | otherwise = table
  where
    table = map snd (lineTable t)

-- | The number of characters before a position.
charsAtPosition :: Unit -> Position -> Text -> Int
charsAtPosition u (Position l c) t = case L.drop (max 0 l) (lineTable t) of
  [] -> T.length t
  (s, content) : _ -> s + charsAt u c content

naivePosition :: Unit -> Int -> Text -> Position
naivePosition u n t = Position (T.count "\n" before) (Rope.count u (naiveMetrics column))
  where
    before = T.take n t
    column = T.takeWhileEnd (/= '\n') before

------------------------------------------------------------------------------
-- Generators

-- | Scale inputs by chunk size (1 for the small build, 32 for the default)
-- so both builds exercise trees with several levels.
sizeFactor :: Int
sizeFactor = maxChunk `quot` 16

genPiece :: Gen String
genPiece =
  frequency
    [ (12, pure <$> elements "abcxyz ")
    , (3, pure "\n")
    , (1, pure "\r")
    , (1, pure "\r\n")
    , (2, pure <$> elements "\233\223\241") -- two bytes
    , (2, pure <$> elements "\8364\20013\12354") -- three bytes
    , (2, pure <$> elements "\128512\119070\127881") -- four bytes, surrogate pairs
    ]

-- | Generate ASCII-only chunks to exercise paths skipped by mixed UTF-8 input.
genAsciiPiece :: Gen String
genAsciiPiece =
  frequency
    [ (12, pure <$> elements "abcxyz ")
    , (3, pure "\n")
    , (1, pure "\r\n")
    ]

-- | Generate mixed UTF-8, ASCII-only, and mostly ASCII documents so trees
-- exercise both ASCII and general Unicode paths.
genText :: Int -> Gen Text
genText n = do
  oneLine <- frequency [(4, pure False), (1, pure True)]
  piece <-
    frequency
      [ (3, pure genPiece)
      , (2, pure genAsciiPiece)
      , (1, pure (frequency [(40 * sizeFactor, genAsciiPiece), (1, genPiece)]))
      ]
  t <- T.pack . concat <$> vectorOf n piece
  pure (if oneLine then T.filter (/= '\n') t else t)

-- | Produce strictly shorter candidates so shrinking terminates.
shrinkText :: Text -> [Text]
shrinkText t =
  [half | h > 0, half <- [T.take h t, T.drop h t]]
    ++ [T.take i t <> T.drop (i + 1) t | i <- [0 .. min 30 (n - 1)]]
  where
    n = T.length t
    h = n `quot` 2

-- | A document.
newtype Doc = Doc Text
  deriving (Show)

instance Arbitrary Doc where
  arbitrary = do
    n <- frequency [(1, pure 0), (2, choose (0, 8)), (8, choose (0, 200 * sizeFactor))]
    Doc <$> genText n
  shrink (Doc t) = Doc <$> shrinkText t

-- | Inserted text, weighted toward keystrokes with occasional larger pastes.
newtype Snippet = Snippet Text
  deriving (Show)

instance Arbitrary Snippet where
  arbitrary = do
    n <- frequency [(4, choose (0, 3)), (2, choose (0, 40)), (1, choose (0, 80 * sizeFactor))]
    Snippet <$> genText n
  shrink (Snippet t) = Snippet <$> shrinkText t

instance Arbitrary Unit where
  arbitrary = elements [minBound .. maxBound]

-- | A relative offset, resolved against the input length with values just
-- outside both ends to exercise clamping.
newtype Offset = Offset Int
  deriving (Show)

instance Arbitrary Offset where
  arbitrary = Offset <$> choose (0, 1000)
  shrink (Offset k) = Offset <$> shrink k

resolve :: Unit -> Offset -> Text -> Int
resolve u (Offset k) t = k * (Rope.count u (naiveMetrics t) + 5) `quot` 1000 - 2

data Op
  = Insert Unit Offset Snippet
  | Delete Unit Offset Offset
  | Replace Unit Offset Offset Snippet
  | Append Snippet
  | Prepend Snippet
  | Take Unit Offset
  | Drop Unit Offset
  | Slice Unit Offset Offset
  | Rejoin Unit Offset
  | Typed Unit Offset [Snippet] Int
  deriving (Show)

instance Arbitrary Op where
  arbitrary =
    frequency
      [ (6, Insert <$> arbitrary <*> arbitrary <*> arbitrary)
      , (6, Delete <$> arbitrary <*> arbitrary <*> nearby)
      , (4, Replace <$> arbitrary <*> arbitrary <*> nearby <*> arbitrary)
      , (2, Append <$> arbitrary)
      , (2, Prepend <$> arbitrary)
      , (1, Take <$> arbitrary <*> arbitrary)
      , (1, Drop <$> arbitrary <*> arbitrary)
      , (1, Slice <$> arbitrary <*> arbitrary <*> arbitrary)
      , (3, Rejoin <$> arbitrary <*> arbitrary)
      , (4, Typed <$> arbitrary <*> arbitrary <*> resize 6 (listOf arbitrary) <*> choose (0, 4))
      ]
    where
      -- Prefer short ranges so edit sequences retain enough text to test.
      nearby = Offset <$> frequency [(5, choose (0, 20)), (1, choose (0, 1000))]
  shrink op = case op of
    Insert u i s -> Insert u i <$> shrink s
    Replace u i j s -> Delete u i j : (Replace u i j <$> shrink s)
    Append s -> Append <$> shrink s
    Prepend s -> Prepend <$> shrink s
    Typed u i ss erased -> [Typed u i ss' erased | ss' <- shrink ss] ++ [Typed u i ss 0 | erased > 0]
    _ -> []

-- | Ranges are generated as a start and a length.
range :: Unit -> Offset -> Offset -> Text -> (Int, Int)
range u i (Offset len) t = (start, start + len * (Rope.count u (naiveMetrics t) + 5) `quot` 1000)
  where
    start = resolve u i t

apply :: Op -> (R, Text) -> (R, Text)
apply op (r, t) = case op of
  Insert u i (Snippet s) ->
    let k = resolve u i t
        n = charsAt u k t
     in (Rope.insert u k s r, T.take n t <> s <> T.drop n t)
  Delete u i j ->
    let (a, b) = range u i j t
        (na, nb) = charRange u a b t
     in (Rope.delete u a b r, T.take na t <> T.drop nb t)
  Replace u i j (Snippet s) ->
    let (a, b) = range u i j t
        (na, nb) = charRange u a b t
     in (Rope.replace u a b s r, T.take na t <> s <> T.drop nb t)
  Append (Snippet s) -> (r <> Rope.fromText s, t <> s)
  Prepend (Snippet s) -> (Rope.fromText s <> r, s <> t)
  Take u i ->
    let k = resolve u i t
     in (Rope.take u k r, T.take (charsAt u k t) t)
  Drop u i ->
    let k = resolve u i t
     in (Rope.drop u k r, T.drop (charsAt u k t) t)
  Slice u i j ->
    let (a, b) = range u i j t
        (na, nb) = charRange u a b t
     in (Rope.slice u a b r, T.take (nb - na) (T.drop na t))
  Rejoin u i ->
    let (a, b) = Rope.splitAt u (resolve u i t) r
     in (a <> b, t)
  Typed u i snippets erased ->
    let (r', t', cursor) = L.foldl' (keystroke u) (r, t, resolve u i t) snippets
        (na, nb) = charRange u (cursor - erased) cursor t'
     in (Rope.delete u (cursor - erased) cursor r', T.take na t' <> T.drop nb t')

-- | Insert at the cursor and advance by the inserted text's length in the
-- chosen unit, starting from at least zero.
keystroke :: Unit -> (R, Text, Int) -> Snippet -> (R, Text, Int)
keystroke u (r, t, cursor) (Snippet s) =
  (Rope.insert u cursor s r, T.take n t <> s <> T.drop n t, max 0 cursor + Rope.count u (naiveMetrics s))
  where
    n = charsAt u cursor t

-- | A rope after a sequence of edits, paired with its expected text.
data Edited = Edited R Text

instance Show Edited where
  show (Edited r t) = show t ++ " as a tree of height " ++ show (height r)

instance Arbitrary Edited where
  arbitrary = do
    Doc t <- arbitrary
    ops <- resize 8 (listOf arbitrary)
    pure (uncurry Edited (L.foldl' (flip apply) (Rope.fromText t, t) ops))
  shrink (Edited _ t) = [Edited (Rope.fromText t') t' | t' <- shrinkText t]

------------------------------------------------------------------------------
-- Properties

-- | The rope is a valid tree holding exactly this text.
holds :: R -> Text -> Property
holds r t =
  conjoin
    [ counterexample "invariants" (invariants r === [])
    , counterexample "text" (Rope.toText r === t)
    , counterexample "metrics" (Rope.metrics r === naiveMetrics t)
    , counterexample "measure" (Rope.measure r === (measureChunk t, measureChunk t))
    ]

prop_roundtrip :: Doc -> Property
prop_roundtrip (Doc t) = holds (Rope.fromText t) t

prop_lazy :: [Doc] -> Property
prop_lazy docs =
  holds (Rope.fromLazyText lazy) (TL.toStrict lazy)
    .&&. Rope.toLazyText (Rope.fromLazyText lazy :: R) === lazy
    .&&. Rope.toString (Rope.fromLazyText lazy :: R) === TL.unpack lazy
  where
    lazy = TL.fromChunks [t | Doc t <- docs]

prop_chunks :: Edited -> Property
prop_chunks (Edited r t) =
  T.concat chunks === t
    .&&. counterexample "empty chunk" (not (any T.null chunks))
    .&&. counterexample "oversized chunk" (all ((<= maxChunk) . bytes . naiveMetrics) chunks)
    .&&. Rope.foldrChunks (\c n -> T.length c + n) 0 r === T.length t
    .&&. reverse (Rope.foldlChunks' (flip (:)) [] r) === chunks
  where
    chunks = Rope.toChunks r

-- | Check exact UTF-8 bytes when writing to an existing handle and when
-- replacing a file.
prop_output :: Edited -> Property
prop_output (Edited r t) = ioProperty $ do
  (written, replaced) <- withTempFile $ \path h -> do
    B.hPut h "before "
    Rope.hPutUtf8 h r
    hClose h
    written <- B.readFile path
    Rope.writeFileUtf8 path r
    replaced <- B.readFile path
    pure (written, replaced)
  pure (written === "before " <> TE.encodeUtf8 t .&&. replaced === TE.encodeUtf8 t)

-- | Exercise multiple output-buffer flushes and a partially filled final
-- buffer with a document larger than the default buffer.
prop_outputLarge :: Property
prop_outputLarge = ioProperty $ do
  written <- withTempFile $ \path h -> do
    Rope.hPutUtf8 h (Rope.fromText t :: R)
    hClose h
    B.readFile path
  pure (written == TE.encodeUtf8 t)
  where
    t = T.replicate 9000 "na\239ve \20013\25991 \128512\r\n"

-- | A deliberately partial measure that throws on @!@.
data Calm = Calm
  deriving (Eq, Show)

instance Semigroup Calm where
  Calm <> Calm = Calm

instance Monoid Calm where
  mempty = Calm

instance Measure Calm where
  measureChunk t
    | T.any (== '!') t = error "not calm"
    | otherwise = Calm

-- | A failure while measuring pending input must occur before the existing
-- file is opened and truncated.
prop_outputFails :: Property
prop_outputFails = ioProperty $ do
  (outcome, kept) <- withTempFile $ \path h -> do
    B.hPut h "kept"
    hClose h
    outcome <- try (Rope.writeFileUtf8 path typed) :: IO (Either ErrorCall ())
    kept <- B.readFile path
    pure (outcome, kept)
  pure (counterexample "it was written" (either (const True) (const False) outcome) .&&. kept === "kept")
  where
    typed = L.foldl' (\r (i, key) -> Rope.insert Chars i key r) (Rope.fromText "calm" :: Rope Calm) [(4, "a"), (5, "b"), (6, "!")]

withTempFile :: (FilePath -> Handle -> IO a) -> IO a
withTempFile act = do
  dir <- getTemporaryDirectory
  bracket
    (openBinaryTempFile dir "nano-rope.txt")
    (\(path, h) -> hClose h >> removeFile path)
    (uncurry act)

prop_chunkAt :: Edited -> Unit -> Offset -> Property
prop_chunkAt (Edited r t) u i =
  counterexample (show chunk) $
    (chunk `T.isPrefixOf` rest) .&&. (T.null chunk === T.null rest)
  where
    k = resolve u i t
    chunk = Rope.chunkAt u k r
    rest = T.drop (charsAt u k t) t

prop_remeasure :: Edited -> Property
prop_remeasure (Edited r t) =
  invariants plain === []
    .&&. Rope.toText plain === t
    .&&. holds (Rope.remeasure plain) t
  where
    plain = Rope.remeasure r :: Rope ()

prop_append :: Edited -> Edited -> Property
prop_append (Edited a ta) (Edited b tb) = holds (a <> b) (ta <> tb)

prop_mconcat :: [Snippet] -> Property
prop_mconcat snippets =
  holds (mconcat (map Rope.fromText ts)) (T.concat ts)
    .&&. holds (L.foldl' (\acc t -> Rope.fromText t <> acc) mempty ts) (T.concat (reverse ts))
  where
    ts = [t | Snippet t <- snippets]

prop_splitAt :: Edited -> Unit -> Offset -> Property
prop_splitAt (Edited r t) u i =
  counterexample (show (u, k)) $
    holds a (T.take n t) .&&. holds b (T.drop n t)
  where
    k = resolve u i t
    n = charsAt u k t
    (a, b) = Rope.splitAt u k r

prop_slice :: Edited -> Unit -> Offset -> Offset -> Property
prop_slice (Edited r t) u i j =
  counterexample (show (u, a, b)) $
    holds (Rope.slice u a b r) expected .&&. Rope.sliceText u a b r === expected
  where
    (a, b) = range u i j t
    (na, nb) = charRange u a b t
    expected = T.take (nb - na) (T.drop na t)

prop_ops :: Doc -> [Op] -> Property
prop_ops (Doc t0) = go (1 :: Int) (Rope.fromText t0, t0)
  where
    go _ (r, t) [] = holds r t
    go i (r, t) (op : ops) =
      holds r t .&&. counterexample ("operation " ++ show i) (go (i + 1) (apply op (r, t)) ops)

-- | A burst of keystrokes at the cursor.
data Burst
  = Typing Text Int
  | Erasing Int
  deriving (Show)

instance Arbitrary Burst where
  arbitrary =
    oneof
      [ Typing <$> (choose (1, 3) >>= genText) <*> choose (1, 8 * sizeFactor)
      , Erasing <$> choose (1, 16 * sizeFactor)
      ]

-- | Repeated typing and erasing at a cursor exercises chunk splits and merges.
prop_typing :: Doc -> Offset -> Property
prop_typing (Doc t0) start = forAll (resize 10 (listOf arbitrary)) $ \bursts ->
  let cursor = max 0 (min (T.length t0) (resolve Chars start t0))
   in go (Rope.fromText t0, t0, cursor) bursts
  where
    go (r, t, _) [] = holds r t
    go st@(r, t, _) (b : bs) = holds r t .&&. counterexample (show b) (go (burst b st) bs)

    burst (Typing s n) st = L.foldl' (\acc _ -> typeOne s acc) st [1 .. n]
    burst (Erasing n) st = L.foldl' (\acc _ -> eraseOne acc) st [1 .. n]

    typeOne s (r, t, c) = (Rope.insert Chars c s r, T.take c t <> s <> T.drop c t, c + T.length s)
    eraseOne st@(r, t, c)
      | c <= 0 = st
      | otherwise = (Rope.delete Chars (c - 1) c r, T.take (c - 1) t <> T.drop c t, c - 1)

-- | Consecutive insertions agree with the model in every unit, including
-- clamped offsets and offsets inside code points. Check intermediate ropes
-- as well as a run whose intermediate values are not read.
prop_run :: Edited -> Unit -> Offset -> Property
prop_run (Edited r0 t0) u i = forAll (resize 12 (listOf arbitrary)) $ \snippets ->
  let steps = L.scanl (keystroke u) (r0, t0, resolve u i t0) snippets
      (unread, final, _) = L.foldl' (keystroke u) (r0, t0, resolve u i t0) snippets
   in counterexample (show (u, resolve u i t0)) $
        holds unread final .&&. conjoin [holds r t | (r, t, _) <- steps]

-- | Insertions near buffered input must use the actual offset, rather than
-- incorrectly extending the buffer at an interior or differently counted offset.
prop_nearRun :: Edited -> Unit -> Offset -> Property
prop_nearRun (Edited r0 t0) u i = forAll ((,) <$> key <*> key) $ \(s1, s2) ->
  let (r, t, _) = L.foldl' (keystroke u) (r0, t0, start) [Snippet "a", Snippet s1, Snippet s2]
      -- Byte length bounds the inserted length in every unit.
      typed = Rope.count Bytes (naiveMetrics t) - Rope.count Bytes (naiveMetrics t0)
   in conjoin
        [ counterexample (show (u, start, k)) (holds (Rope.insert u k "-" r) (T.take n t <> "-" <> T.drop n t))
        | k <- [start - 1 .. start + typed + 1]
        , let n = charsAt u k t
        ]
  where
    start = resolve u i t0
    -- Short inputs can stay in the typing buffer.
    key = choose (1, 2) >>= genText

-- | Branching from a rope with pending input preserves the original and
-- produces independent edited versions.
prop_branching :: Edited -> Offset -> Snippet -> Snippet -> Snippet -> Property
prop_branching (Edited r0 t0) i s1 s2 s3 =
  conjoin
    [ counterexample "one way" (holds ra ta)
    , counterexample "the other way" (holds rb tb)
    , counterexample "erased" (holds rc tc)
    , counterexample "the rope they came from" (holds r t)
    ]
  where
    start = max 0 (min (T.length t0) (resolve Chars i t0))
    (r, t, cursor) = L.foldl' (keystroke Chars) (r0, t0, start) [Snippet "ab", s1]
    (ra, ta, _) = keystroke Chars (r, t, cursor) s2
    (rb, tb, _) = keystroke Chars (r, t, cursor) s3
    rc = Rope.delete Chars (cursor - 1) cursor r
    tc = T.take (cursor - 1) t <> T.drop cursor t

prop_metricsAt :: Edited -> Unit -> Offset -> Property
prop_metricsAt (Edited r t) u i =
  counterexample (show (u, k)) $
    Rope.metricsAt u k r === naiveMetrics (T.take (charsAt u k t) t)
  where
    k = resolve u i t

prop_convert :: Edited -> Unit -> Unit -> Offset -> Property
prop_convert (Edited r t) from to i =
  counterexample (show (from, to, k)) $
    Rope.convert from to k r === Rope.count to (naiveMetrics (T.take (charsAt from k t) t))
  where
    k = resolve from i t

genPosition :: Text -> Gen Position
genPosition t =
  Position
    <$> choose (-1, T.count "\n" t + 1)
    <*> frequency [(6, choose (-1, 12)), (2, choose (0, 40 * sizeFactor)), (1, pure maxBound)]

prop_position :: Edited -> Unit -> Property
prop_position (Edited r t) u = forAll (genPosition t) $ \pos ->
  let n = charsAtPosition u pos t
      (a, b) = Rope.splitAtPosition u pos r
      -- The position again, with the start of its line.
      (line, at) = Rope.metricsAtLineAndPosition u pos r
   in Rope.metricsAtPosition u pos r === naiveMetrics (T.take n t)
        .&&. at === naiveMetrics (T.take n t)
        .&&. line === naiveMetrics (T.take (charsAt Lines (posLine pos) t) t)
        .&&. Rope.toText a === T.take n t
        .&&. Rope.toText b === T.drop n t
        .&&. Rope.positionToOffset u Chars pos r === n

prop_toPosition :: Edited -> Unit -> Unit -> Offset -> Property
prop_toPosition (Edited r t) from to i =
  counterexample (show (from, to, k)) $
    Rope.offsetToPosition from to k r === naivePosition to (charsAt from k t) t
  where
    k = resolve from i t

-- | Positions round-trip through offsets except inside CRLF, where input
-- positions clamp to the end of the line's content.
prop_positionRoundtrip :: Edited -> Unit -> Unit -> Offset -> Property
prop_positionRoundtrip (Edited r t) u via i =
  via /= Lines && not insideTerminator ==>
    Rope.offsetToPosition via u (Rope.positionToOffset u via pos r) r === pos
  where
    n = charsAt Chars (resolve Chars i t) t
    pos = naivePosition u n t
    insideTerminator = T.take 1 (T.drop n t) == "\n" && T.takeEnd 1 (T.take n t) == "\r"

prop_lines :: Edited -> Property
prop_lines (Edited r t) =
  Rope.lines r === naiveLines t
    .&&. Rope.lineCount r === T.count "\n" t + 1
    .&&. Rope.length Lines r === T.count "\n" t

prop_getLine :: Edited -> Property
prop_getLine (Edited r t) = forAll (choose (-1, L.length table + 1)) $ \l ->
  Rope.getLine l r === (if l < 0 then "" else maybe "" snd (L.lookup l (zip [0 ..] table)))
  where
    table = lineTable t

-- | The longest prefix (in characters) on which a predicate fails.
longestPrefix :: (Text -> Bool) -> Text -> Int
longestPrefix p t = L.length (takeWhile (not . p) (drop 1 (T.inits t)))

prop_splitWhereWidth :: Edited -> Offset -> Property
prop_splitWhereWidth (Edited r t) (Offset k) =
  counterexample (show limit) $
    holds a (T.take n t) .&&. holds b (T.drop n t)
  where
    Width whole = measureChunk t
    limit = k * (whole + 5) `quot` 1000 - 2
    (a, b) = Rope.splitWhere (\_ (_, Width w) -> w > limit) r
    n = longestPrefix (\p -> measureChunk p > Width limit) t

prop_splitWhereBreaks :: Edited -> Offset -> Property
prop_splitWhereBreaks (Edited r t) (Offset k) =
  counterexample (show limit) $
    Rope.metricsWhere (\_ (bs, _) -> breakCount bs >= limit) r === naiveMetrics (T.take n t)
  where
    limit = k * (breakCount (measureChunk t) + 3) `quot` 1000
    n = longestPrefix (\p -> breakCount (measureChunk p) >= limit) t

prop_splitWhereMetrics :: Edited -> Unit -> Offset -> Property
prop_splitWhereMetrics (Edited r t) u i =
  u /= Lines ==>
    Rope.metricsWhere (\m _ -> Rope.count u m > k) r === Rope.metricsAt u k r
  where
    k = max 0 (resolve u i t)

prop_eq :: Edited -> Property
prop_eq (Edited r t) =
  r === Rope.fromText t
    .&&. compare r (Rope.fromText t) === EQ
    .&&. (r /= Rope.fromText (t <> "!")) === True
    .&&. (r == Rope.fromText swapped) === (t == swapped)
  where
    -- Same metrics, so that equality has to look at the text.
    swapped = T.map (\c -> case c of 'a' -> 'b'; 'b' -> 'a'; _ -> c) t

prop_ord :: Edited -> Edited -> Offset -> Property
prop_ord (Edited a ta) (Edited b tb) i =
  compare a b === compare ta tb
    .&&. (a == b) === (ta == tb)
    .&&. compare a (Rope.fromText grafted) === compare ta grafted
  where
    -- Shares a prefix with the first text.
    grafted = T.take (resolve Chars i ta) ta <> tb

prop_show :: Edited -> Property
prop_show (Edited r t) = show r === show t

------------------------------------------------------------------------------
-- Laws

-- | The laws of some classes as a group of tests.
lawsOf :: String -> [Laws] -> TestTree
lawsOf name sets =
  testGroup name [testGroup cls [testProperty law p | (law, p) <- properties] | Laws cls properties <- sets]

-- | Related texts make equality and ordering laws useful: some are equal,
-- some differ only near the end, and some share metrics despite differing
-- in content. Sizes exercise multiple chunks and tree heights in both builds.
lawTexts :: [(Int, Text)]
lawTexts =
  [ (1, "")
  , (3, stem <> "xyz")
  , (2, stem <> "xzy")
  , (2, stem <> "xyz\128512")
  , (2, T.replicate 8 stem <> "xyz")
  , (1, T.replicate 8 stem <> "xzy")
  ]
  where
    stem = T.take (2 * maxChunk) (T.replicate maxChunk "ab\nc\233 \8364\r\n\128512xyz")

-- | Build the same text with different chunk boundaries: directly, by
-- concatenation, by typing a suffix, by filling a gap, or by slicing.
ropeOf :: Measure a => Text -> Gen (Rope a)
ropeOf t =
  oneof
    [ pure (Rope.fromText t)
    , do
        cuts <- L.sort <$> resize 6 (listOf (choose (0, n)))
        pure (mconcat [Rope.fromText (T.take (j - i) (T.drop i t)) | (i, j) <- zip (0 : cuts) (cuts ++ [n])])
    , do
        k <- choose (max 0 (n - 8 * sizeFactor), n)
        pure (L.foldl' (\r (i, c) -> Rope.insert Chars i (T.singleton c) r) (Rope.fromText (T.take k t)) (zip [k ..] (T.unpack (T.drop k t))))
    , do
        i <- choose (0, n)
        j <- choose (i, n)
        pure (Rope.insert Chars i (T.take (j - i) (T.drop i t)) (Rope.fromText (T.take i t <> T.drop j t)))
    , do
        Snippet before <- arbitrary
        Snippet after <- arbitrary
        let i = T.length before
        pure (Rope.slice Chars i (i + n) (Rope.fromText (before <> t <> after)))
    ]
  where
    n = T.length t

-- | Generator for instance laws; see 'lawTexts'. Operation tests use 'Edited'.
instance Measure a => Arbitrary (Rope a) where
  arbitrary = frequency [(w, pure t) | (w, t) <- lawTexts] >>= ropeOf
  shrink r = Rope.fromText <$> shrinkText (Rope.toText r)

-- | Require coverage of equal ropes with different chunks and unequal ropes
-- with identical metrics.
prop_lawRopes :: R -> R -> Property
prop_lawRopes a b =
  checkCoverage $
    cover 10 (a == b && Rope.toChunks a /= Rope.toChunks b) "equal, in different chunks" $
      cover 5 (a /= b && Rope.metrics a == Rope.metrics b) "different, with the same metrics" $
        holds a (Rope.toText a) .&&. holds b (Rope.toText b)

-- | Additional checks that derived comparison operators agree with '=='
-- and 'compare'.
eqOrdLaws :: forall a. (Ord a, Arbitrary a, Show a) => Proxy a -> Laws
eqOrdLaws _ =
  Laws
    "Eq and Ord"
    [ ("Negation", property $ \(a :: a) b -> (a /= b) === not (a == b))
    , ("compare is EQ where == holds", property $ \(a :: a) b -> (compare a b == EQ) === (a == b))
    , ("compare, turned around", property $ \(a :: a) b -> compare a b === opposite (compare b a))
    ,
      ( "Operators"
      , property $ \(a :: a) b ->
          let o = compare a b
           in conjoin [(a < b) === (o == LT), (a <= b) === (o /= GT), (a > b) === (o == GT), (a >= b) === (o /= LT)]
      )
    , ("min and max", property $ \(a :: a) b -> (min a b, max a b) === (if a <= b then (a, b) else (b, a)))
    ]
  where
    opposite o = case o of
      LT -> GT
      EQ -> EQ
      GT -> LT

-- | Text, metrics, and measures preserve concatenation and the empty rope.
homomorphismLaws :: Laws
homomorphismLaws =
  Laws
    "Monoid homomorphisms"
    [ ("toText", homomorphism Rope.toText)
    , ("metrics", homomorphism Rope.metrics)
    , ("measure", homomorphism Rope.measure)
    ]
  where
    homomorphism :: (Monoid b, Eq b, Show b) => (R -> b) -> Property
    homomorphism f = property $ \a b -> f (a <> b) === f a <> f b .&&. f mempty === mempty

-- | String conversion matches 'Text', including replacement of surrogate
-- code points with the Unicode replacement character.
isStringLaws :: Laws
isStringLaws =
  Laws
    "IsString"
    [ ("fromString, like Text", forAll genString $ \s -> holds (fromString s) (fromString s))
    , ("toString . fromString", forAll genString $ \s -> Rope.toString (fromString s :: R) === T.unpack (fromString s))
    ]
  where
    genString = concat <$> listOf (frequency [(9, genPiece), (1, vectorOf 1 (choose ('\xD800', '\xDFFF')))])

-- | Measures preserve concatenation and identity, independent of chunk boundaries.
measureLaws :: forall a. (Measure a, Eq a, Show a) => Proxy a -> Laws
measureLaws _ =
  Laws
    "Measure"
    [ ("Homomorphism", property $ \(Snippet x) (Snippet y) -> (measureChunk (x <> y) :: a) === measureChunk x <> measureChunk y)
    , ("Identity", (measureChunk T.empty :: a) === mempty)
    ]

-- | Generate a measurement from text rather than arbitrary field values.
measured :: (Text -> a) -> Gen a
measured f = (\(Snippet t) -> f t) <$> arbitrary

instance Arbitrary Metrics where
  arbitrary = measured naiveMetrics

instance Arbitrary Breaks where
  arbitrary = measured measureChunk

instance Arbitrary Width where
  arbitrary = measured measureChunk

prop_plain :: Edited -> Unit -> Offset -> Snippet -> Property
prop_plain (Edited r t) u i (Snippet s) =
  conjoin
    [ Plain.toText plain === t
    , Plain.metrics plain === Rope.metrics r
    , Plain.toText (Plain.insert u k s plain) === Rope.toText (Rope.insert u k s r)
    , Plain.toText (fst (Plain.splitAt u k plain)) === Rope.toText (Rope.take u k r)
    , Plain.metricsWhere (\m -> Rope.count Chars m > k) plain === Rope.metricsAt Chars k r
    , holds (Plain.measured plain) t
    , Plain.fromText t === plain
    ]
  where
    k = resolve u i t
    plain = Plain.unmeasured r

-- | Exercise edits on a tree of height at least two in both builds.
prop_big :: Property
prop_big = forAll (genText (6000 * sizeFactor)) $ \t0 ->
  forAll (vectorOf 30 arbitrary) $ \ops ->
    let r0 = Rope.fromText t0 :: R
     in counterexample ("height " ++ show (height r0)) $
          height r0 >= 2 .&&. prop_ops (Doc t0) ops

------------------------------------------------------------------------------
-- Chunk scans

-- | UTF-8 inputs for comparing every scan implementation with a model.
-- Long lines exercise searches beyond the first vector. Repeated pieces
-- exercise counter flushing at the 255-vector limit.
newtype Scanned = Scanned Text
  deriving (Show)

instance Arbitrary Scanned where
  arbitrary =
    Scanned
      <$> frequency
        [ (6, choose (0, 1100) >>= \n -> T.pack . concat <$> vectorOf n genPiece)
        , (3, choose (0, 1100) >>= \n -> T.pack . concat <$> vectorOf n (frequency [(1, pure "\n"), (60, filter (/= '\n') <$> genPiece)]))
        , (1, genPiece >>= \p -> choose (8000, 9000) >>= \n -> pure (T.pack (concat (replicate n p))))
        ]
  shrink (Scanned t) = Scanned <$> shrinkText t

bytesOfText :: Text -> ByteArray
bytesOfText (TI.Text (A.ByteArray ba) off len) = cloneByteArray (ByteArray ba) off len

byteList :: ByteArray -> [Word8]
byteList arr = [indexByteArray arr i | i <- [0 .. sizeofByteArray arr - 1]]

isContByte :: Word8 -> Bool
isContByte b = b >= 0x80 && b < 0xC0

-- | Check every available scan implementation against the expected result.
allKernels :: (Eq b, Show b) => (Kernels -> b) -> b -> Property
allKernels run expected = conjoin [counterexample (kernelsName k) (run k === expected) | k <- kernels]

-- | Generate short and long slices, including array boundaries.
genSlice :: Int -> Gen (Int, Int)
genSlice size = do
  off <- frequency [(1, pure 0), (4, choose (0, size))]
  len <- frequency [(1, pure (size - off)), (2, choose (0, min 40 (size - off))), (2, choose (0, size - off))]
  pure (off, len)

prop_scanMetrics :: Scanned -> Property
prop_scanMetrics (Scanned t) = forAll (genSlice (sizeofByteArray arr)) $ \(off, len) ->
  let bs = L.take len (L.drop off (byteList arr))
      cs = len - L.length (filter isContByte bs)
   in allKernels
        (\k -> kernelMetrics k arr off len)
        (Metrics len cs (cs + L.length (filter (>= 0xF0) bs)) (L.length (filter (== 0x0A) bs)))
  where
    arr = bytesOfText t

prop_scanNewlines :: Scanned -> Property
prop_scanNewlines (Scanned t) = forAll (genSlice (sizeofByteArray arr)) $ \(off, len) ->
  allKernels (\k -> kernelNewlines k arr off len) (L.length (filter (== 0x0A) (L.take len (L.drop off (byteList arr)))))
  where
    arr = bytesOfText t

-- | Offsets of the line feeds.
lineFeeds :: ByteArray -> [Int]
lineFeeds arr = [i | (i, b) <- zip [0 ..] (byteList arr), b == 0x0A]

prop_scanNext :: Scanned -> Property
prop_scanNext (Scanned t) = forAll (choose (0, size)) $ \from ->
  allKernels (\k -> kernelFindNewline k arr from) (fromMaybe size (L.find (>= from) (lineFeeds arr)))
  where
    arr = bytesOfText t
    size = sizeofByteArray arr

prop_scanPrevious :: Scanned -> Property
prop_scanPrevious (Scanned t) = forAll (choose (0, sizeofByteArray arr)) $ \to ->
  allKernels (\k -> kernelFindNewlineBack k arr to) (last (-1 : [i | i <- lineFeeds arr, i < to]))
  where
    arr = bytesOfText t

-- | Check line-start and terminator offsets, including indices before
-- the first line and beyond the last.
prop_scanLine :: Scanned -> Property
prop_scanLine (Scanned t) = forAll (choose (-1, L.length lfs + 2)) $ \n ->
  let from
        | n <= 0 = 0
        | otherwise = case L.drop (n - 1) lfs of i : _ -> i + 1; [] -> size
   in allKernels (\k -> kernelLineSpan k n arr) (ChunkLine from (fromMaybe size (L.find (>= from) lfs)))
  where
    arr = bytesOfText t
    size = sizeofByteArray arr
    lfs = lineFeeds arr

-- | Compare unit scans with a decoded model of the longest prefix that fits
-- in @k@ units. Both slice endpoints are code point boundaries.
prop_scanUnits :: Scanned -> Bool -> Property
prop_scanUnits (Scanned t) wide = forAll (genSlice (L.length bounds - 1)) $ \(i, n) ->
  let from = bounds !! i
      to = bounds !! (i + n)
      piece = T.unpack (takeWord8 (to - from) (dropWord8 from t))
      units c = if wide then utf16Len c else 1
      model k = go from 0 piece
        where
          go b _ [] = b
          go b u (c : cs)
            | u + units c > k = b
            | otherwise = go (b + utf8Len c) (u + units c) cs
   in forAll (choose (-1, sum (map units piece) + 2)) $ \k ->
        allKernels (\kn -> kernelScanUnits kn wide k arr from to) (model k)
  where
    arr = bytesOfText t
    bounds = scanl (+) 0 (map utf8Len (T.unpack t))