packages feed

text-rope-zipper-0.1.1.0: test/Data/Text/Rope/ZipperSpec.hs

{-# OPTIONS_GHC -Wno-name-shadowing #-}
{-# OPTIONS_GHC -Wno-orphans #-}

module Data.Text.Rope.ZipperSpec where

import Data.Text qualified as Text
import Data.Text.Lazy.Zipper qualified as TextZipper
import Data.Text.Rope qualified as Rope
import Data.Text.Rope.Zipper
import Data.Text.Rope.Zipper qualified as RopeZipper
import Util
import Data.Foldable (foldl')

rawOutput :: (HasCallStack) => Property -> IO ()
rawOutput =
    quickCheckWithResult stdArgs >=> \case
        Success{} -> pure ()
        Failure{output} -> expectationFailure output
        GaveUp{output} -> expectationFailure output
        NoExpectedFailure{output} -> expectationFailure output

data Move
    = Backward
    | Forward
    | Up
    | Down
    | LineStart
    | LineEnd
    | FirstLine
    | LastLine
    | Rel !(Int, Int)
    | Abs !Position
    deriving stock (Eq, Show)

instance Arbitrary Position where
    arbitrary = do
        posLine <- arbitrary
        posColumn <- arbitrary
        pure Position{..}

instance Arbitrary Move where
    arbitrary =
        oneof
            [ pure Backward
            , pure Forward
            , pure LineStart
            , pure LineEnd
            , Rel <$> arbitrary
            , Abs <$> arbitrary
            ]

newtype VerticalMove = VerticalMove Move
    deriving newtype (Eq, Show)

instance Arbitrary VerticalMove where
    arbitrary = VerticalMove <$> elements [Up, Down, FirstLine, LastLine]

moveZipper :: [Move] -> RopeZipper -> RopeZipper
moveZipper = flip . foldl' $ flip \case
    Forward -> moveForward
    Backward -> moveBackward
    Up -> moveUp
    Down -> moveDown
    LineStart -> moveToLineStart
    LineEnd -> moveToLineEnd
    FirstLine -> moveToFirstLine
    LastLine -> moveToLastLine
    (Rel (dy, dx)) -> moveCursor \Position{..} ->
        Position
            { posLine = boundedAdd dy posLine
            , posColumn = boundedAdd dx posColumn
            }
    (Abs c) -> setCursor c

positionToPair :: Position -> (Int, Int)
positionToPair Position{..} = (fromIntegral posLine, fromIntegral posColumn)

pairToPosition :: (Int, Int) -> Position
pairToPosition (y, x) =
    Position
        { posLine = fromIntegral $ max 0 y
        , posColumn = fromIntegral $ max 0 x
        }

spec :: Spec
spec = parallel $ modifyMaxSuccess (* 100) do
    it "fromText is a right inverse of toText" $ property $ \t -> do
        toText (fromText t) `shouldBe` t

    it "fromRope is a right inverse of toRope" $ property $ \r -> do
        toRope (fromRope r) `shouldBe` r

    it "concatenates" $ property $ \r1 r2 -> do
        fromRope r1 <> fromRope r2 `shouldBe` fromParts r1 r2
        toRope (fromParts r1 r2) `shouldBe` r1 <> r2

    it "concatenates newline" $ property $ \zipper -> do
        splitFirstLine "\n" `shouldBe` ("\n", "")
        toRope (zipper <> "\n") `shouldBe` toRope zipper <> "\n"

    it "inserts" $ property $ \r1 r2 r3 -> do
        insertRope r2 (fromParts r1 r3) `shouldBe` fromParts (r1 <> r2) r3

    it "deletes before" $ property $ \r1 (Text.singleton -> Rope.fromText -> c) r2 ->
        deleteBefore (fromParts (r1 <> c) r2) `shouldBe` fromParts r1 r2

    it "deletes after" $ property $ \r1 (Text.singleton -> Rope.fromText -> c) r2 ->
        deleteAfter (fromParts r1 (c <> r2)) `shouldBe` fromParts r1 r2

    it "moves" $ property $ \t1 t2 move -> do
        let zipper = fromParts (Rope.fromText t1) (Rope.fromText t2)
            cursor =
                Position
                    { posLine = fromIntegral $ Text.count "\n" t1
                    , posColumn = fromIntegral . Text.length . snd $ Text.breakOnEnd "\n" t1
                    }
        zipper.cursor `shouldBe` cursor
        let t = t1 <> t2
            numLines = Text.count "\n" t + 1
            clampRow = clamp (0, numLines - 1)
            lineLength y = maybe 0 Text.length $ Text.lines t !? y
            clampCol (clampRow -> y, x) = (y, clamp (0, lineLength y) x)
            moveCursor' (positionToPair -> (y, x)) = pairToPosition . clampCol $ case move of
                Backward -> (y, x - 1)
                Forward -> (y, x + 1)
                Up -> (y - 1, x)
                Down -> (y + 1, x)
                LineStart -> (y, minBound)
                LineEnd -> (y, maxBound)
                FirstLine -> (minBound, x)
                LastLine -> (maxBound, x)
                Rel (dy, dx) -> (y + dy, x + dx)
                Abs pos -> positionToPair pos
        let zipper' = moveZipper [move] zipper
        zipper'.cursor `shouldBe` moveCursor' zipper.cursor
        toRope zipper `shouldBe` toRope zipper'

    it "counts lines correctly" $ property $ \zipper -> do
        lengthInLines zipper
            `shouldBe` (fromIntegral . length . RopeZipper.lines) zipper

    it "sticks the last requested column" $ property $ \zipper (VerticalMove move) -> do
        let zipper' = moveZipper [move] zipper
        let lineLen' =
                fromIntegral $
                    TextZipper.length zipper'.currentLine
                        - if TextZipper.hasTrailingNewline zipper'.currentLine then 1 else 0
        let expectedColumn = min zipper.cursor.posColumn lineLen'
        zipper'.cursor.posColumn `shouldBe` expectedColumn
        moveCursor (\p -> p{posLine = zipper.cursor.posLine}) zipper' `shouldBe` zipper

    it "unsticks the last requested column" do
        let zipper = fromText "main :: IO ()\nmain = pure ()"
            lefts = 4 :: Int
            ups = 1 :: Int
            zipper' = zipper & moveZipper (replicate lefts Backward <> replicate ups Up)
        zipper'.cursor `shouldBe` Position{posLine = zipper.cursor.posLine - fromIntegral ups, posColumn = zipper.cursor.posColumn - fromIntegral lefts}