packages feed

graft-0.1.0.0: test/LineIndexTests.hs

module LineIndexTests (lineIndexProperties) where

import Data.Char (ord)
import qualified Data.Text as Text
import Graft
import qualified Graft.LineIndex as LineIndex
import Test.QuickCheck
import TestModel (byteLength, genText)

-- An independent oracle over Strings: each character's byte offset, line,
-- byte column and UTF-16 column, plus the position of the end of the text.
data Place = Place
  { placeOffset :: TextLength
  , placeLine :: Int
  , placeUtf8 :: Int
  , placeUtf16 :: Int
  , placeAfterCarriage :: Bool
  }
  deriving (Show)

places :: String -> [Place]
places = go 0 0 0 0 False
  where
    go offset line utf8 utf16 afterCarriage text = Place offset line utf8 utf16 afterCarriage : case text of
      [] -> []
      '\r' : rest@('\n' : _) -> go (offset + 1) line (utf8 + 1) (utf16 + 1) True rest
      c : rest
        | c == '\n' || c == '\r' -> go (offset + 1) (line + 1) 0 0 False rest
        | otherwise -> go (offset + byteLength [c]) line (utf8 + width8 c) (utf16 + width16 c) False rest
    width8 c = fromIntegral (byteLength [c])
    width16 c = if ord c > 0xFFFF then 2 else 1

terminators :: String -> Int
terminators ('\r' : '\n' : rest) = 1 + terminators rest
terminators (c : rest) | c == '\r' || c == '\n' = 1 + terminators rest
terminators (_ : rest) = terminators rest
terminators [] = 0

propPositions :: Property
propPositions = forAll genText $ \text ->
  let index = LineIndex.fromText (Text.pack text)
  in conjoin
      [ LineIndex.lineCount index === terminators text + 1
      , conjoin
          [ counterexample (show place) $ conjoin
              [ LineIndex.utf8Position (placeOffset place) index === Just (Utf8Position (placeLine place) (placeUtf8 place))
              , LineIndex.utf16Position (placeOffset place) index === Just (Utf16Position (placeLine place) (placeUtf16 place))
              , placeAfterCarriage place .||. conjoin
                  [ LineIndex.fromUtf8Position (Utf8Position (placeLine place) (placeUtf8 place)) index === Just (placeOffset place)
                  , LineIndex.fromUtf16Position (Utf16Position (placeLine place) (placeUtf16 place)) index === Just (placeOffset place)
                  ]
              ]
          | place <- places text
          ]
      ]

propInvalid :: Property
propInvalid = forAll genText $ \text ->
  let index = LineIndex.fromText (Text.pack text)
      size = byteLength text
      starts = map placeOffset (places text)
      inside = [offset | offset <- [0 .. size], offset `notElem` starts]
  in conjoin
      [ LineIndex.utf8Position (-1) index === Nothing
      , LineIndex.utf8Position (size + 1) index === Nothing
      , LineIndex.utf16Position (size + 1) index === Nothing
      , conjoin [LineIndex.utf16Position offset index === Nothing | offset <- inside]
      , conjoin [LineIndex.utf8Position offset index === Nothing | offset <- inside]
      , LineIndex.fromUtf16Position (Utf16Position (LineIndex.lineCount index) 0) index === Nothing
      , LineIndex.fromUtf16Position (Utf16Position 0 (-1)) index === Nothing
        -- A column between the two halves of a surrogate pair, including
        -- one at the end of a line or of the text.
      , conjoin
          [ LineIndex.fromUtf16Position (Utf16Position (placeLine here) (placeUtf16 here + 1)) index === Nothing
          | (here, next) <- zip (places text) (drop 1 (places text))
          , placeLine next == placeLine here
          , placeUtf16 next == placeUtf16 here + 2
          ]
        -- A UTF-8 column inside a multibyte character.
      , conjoin
          [ LineIndex.fromUtf8Position (Utf8Position (placeLine here) (placeUtf8 here + k)) index === Nothing
          | (here, next) <- zip (places text) (drop 1 (places text))
          , placeLine next == placeLine here
          , k <- [1 .. placeUtf8 next - placeUtf8 here - 1]
          ]
      ]

regressions :: Property
regressions = once $ conjoin
  [ LineIndex.lineCount (LineIndex.fromText (Text.pack "")) === 1
  , LineIndex.lineCount (LineIndex.fromText (Text.pack "a\r\nb\rc\n")) === 4
  , map (`LineIndex.lineStart` LineIndex.fromText (Text.pack "a\r\nb\rc\n")) [0 .. 4] === map (fmap TextLength) [Just 0, Just 3, Just 5, Just 7, Nothing]
  -- Columns past the end of a line clamp to its content, before "\r\n".
  , LineIndex.fromUtf16Position (Utf16Position 0 99) (LineIndex.fromText (Text.pack "ab\r\ncd")) === Just 2
  , LineIndex.fromUtf8Position (Utf8Position 1 99) (LineIndex.fromText (Text.pack "ab\r\ncd")) === Just 6
  -- U+1F600 takes four bytes and two UTF-16 code units.
  , LineIndex.utf16Position 5 (LineIndex.fromText (Text.pack "x\x1F600y")) === Just (Utf16Position 0 3)
  , LineIndex.fromUtf16Position (Utf16Position 0 2) (LineIndex.fromText (Text.pack "x\x1F600y")) === Nothing
  , LineIndex.fromUtf16Position (Utf16Position 0 3) (LineIndex.fromText (Text.pack "x\x1F600y")) === Just 5
  -- UTF-8 columns and offsets inside a character are rejected, as UTF-16 ones are.
  , LineIndex.fromUtf8Position (Utf8Position 0 2) (LineIndex.fromText (Text.pack "\x1F600x")) === Nothing
  , LineIndex.fromUtf8Position (Utf8Position 0 4) (LineIndex.fromText (Text.pack "\x1F600x")) === Just 4
  , LineIndex.utf8Position 2 (LineIndex.fromText (Text.pack "\x1F600x")) === Nothing
  -- A surrogate pair last on its line is not skipped by the clamp.
  , LineIndex.fromUtf16Position (Utf16Position 0 1) (LineIndex.fromText (Text.pack "\x1F600")) === Nothing
  , LineIndex.fromUtf16Position (Utf16Position 0 2) (LineIndex.fromText (Text.pack "a\x1F600\nb")) === Nothing
  , LineIndex.fromUtf16Position (Utf16Position 0 2) (LineIndex.fromText (Text.pack "\x1F600")) === Just 4
  , LineIndex.fromUtf16Position (Utf16Position 0 9) (LineIndex.fromText (Text.pack "\x1F600")) === Just 4
  ]

lineIndexProperties :: [(String, Property)]
lineIndexProperties =
  [ ("line index positions agree with a String oracle", propPositions)
  , ("line index rejects offsets and columns inside characters, and missing lines", propInvalid)
  , ("line index terminators, clamping and surrogate regressions", regressions)
  ]