packages feed

text-rope-zipper-0.1.0.0: src/Data/Text/Lazy/Zipper.hs

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

-- TODO: explicit export list for Data.Text.Lazy.Zipper
module Data.Text.Lazy.Zipper where

import Data.Int (Int64)
import Data.String (IsString (fromString))
import Data.Text.Lazy (Text)
import Data.Text.Lazy qualified as Text
import GHC.Generics (Generic)
import Util
import Prelude

type Position = Word

data TextZipper = TextZipper
    { beforeCursor :: !Text
    -- ^ The text appearing before the cursor
    , afterCursor :: !Text
    -- ^ The text appearing after the cursor
    , cursor :: !Position
    -- ^ The cursor's position in the line of text, i.e. the length of 'beforeCursor'
    }
    deriving stock (Generic, Eq, Show)

-- | Modify the cursor position, updating the 'TextZipper' according to the
-- change.
moveCursor :: (Position -> Position) -> TextZipper -> TextZipper
moveCursor f t = case compare newCursor t.cursor of
    GT ->
        let (before, after)
                | absDelta > fromIntegral @Int64 maxBound = (t.afterCursor, "")
                | otherwise = Text.splitAt (fromIntegral absDelta) t.afterCursor
         in TextZipper
                { beforeCursor = t.beforeCursor <> before
                , afterCursor = after
                , cursor = t.cursor + fromIntegral (Text.length before)
                }
    LT ->
        let (before, after)
                | absDelta > fromIntegral @Int64 maxBound = ("", t.beforeCursor)
                | otherwise = splitAtEnd (fromIntegral absDelta) t.beforeCursor
         in TextZipper
                { beforeCursor = before
                , afterCursor = after <> t.afterCursor
                , cursor = t.cursor - fromIntegral (Text.length after)
                }
    EQ -> t
  where
    newCursor = f t.cursor
    absDelta
        | newCursor > t.cursor = newCursor - t.cursor
        | otherwise = t.cursor - newCursor
    splitAtEnd len t = (Text.dropEnd len t, Text.takeEnd len t)

-- | Set the position of the Cursor to a specific value. The state of the TextZipper
-- will be updated to match the new position.
setCursor :: Position -> TextZipper -> TextZipper
setCursor i = moveCursor $ const i

instance Monoid TextZipper where
    mempty = TextZipper{beforeCursor = mempty, afterCursor = mempty, cursor = 0}

instance Semigroup TextZipper where
    a <> b = a{afterCursor = a.afterCursor <> toText b}

-- | Whether the 'TextZipper' has a trailing newline. A trailing newline is
-- present if the last character of the line is a '\\n' character.
hasTrailingNewline :: TextZipper -> Bool
hasTrailingNewline TextZipper{..} = has afterCursor || Text.null afterCursor && has beforeCursor
  where
    has (Text.unsnoc -> Just (_, '\n')) = True
    has _ = False

-- | Helper function to remove the last character of the provided text iff it is
-- a trailing newline.
removeTrailingNewline :: Text -> Text
removeTrailingNewline (Text.unsnoc -> Just (t, '\n')) = t
removeTrailingNewline t = t

-- | Whether the provided 'TextZipper' is empty.
null :: TextZipper -> Bool
null TextZipper{..} = Text.null beforeCursor && Text.null afterCursor

-- | The length of the entire 'TextZipper' structure.
length :: TextZipper -> Int64
length TextZipper{..} = Text.length beforeCursor + Text.length afterCursor

-- | Convert a 'TextZipper' to 'Text'. Effectively 'beforeCursor <> afterCursor', but slightly more efficient in edge cases.
toText :: TextZipper -> Text
toText TextZipper{..}
    | Text.null afterCursor = beforeCursor
    | otherwise = beforeCursor <> afterCursor

-- | Create a 'TextZipper' from a 'Text' source, with the cursor at the end of it.
fromText :: Text -> TextZipper
fromText = flip fromParts mempty

instance IsString TextZipper where
    fromString = fromText . fromString

-- | Create a 'TextZipper' from a 'Text' source, with the cursor at the specified position.
fromTextAt :: Text -> Position -> TextZipper
fromTextAt t (max 0 -> fromIntegral -> i) = uncurry fromParts $ Text.splitAt i t

-- | Create a 'TextZipper' by concatenating two 'Text' components, with the cursor between them.
fromParts :: Text -> Text -> TextZipper
fromParts beforeCursor afterCursor =
    TextZipper
        { cursor = fromIntegral $ Text.length beforeCursor
        , ..
        }

-- | Insert 'Text' before the current Cursor position, updating its position
-- according to the provided 'Text'\'s length.
insert :: Text -> TextZipper -> TextZipper
insert t TextZipper{..} =
    TextZipper
        { beforeCursor = beforeCursor <> t
        , cursor = cursor + fromIntegral (Text.length t)
        , ..
        }

splitBefore :: TextZipper -> (TextZipper, Maybe Char)
splitBefore t@TextZipper{..} =
    case Text.unsnoc beforeCursor of
        Nothing -> (t, Nothing)
        Just (beforeCursor, c) -> (TextZipper{cursor = cursor - 1, ..}, Just c)

splitAfter :: TextZipper -> (TextZipper, Maybe Char)
splitAfter t@TextZipper{..} =
    case Text.uncons afterCursor of
        Nothing -> (t, Nothing)
        Just (c, afterCursor) -> (TextZipper{..}, Just c)

-- | Delete the first character before the cursor, if any.
deleteBefore :: TextZipper -> TextZipper
deleteBefore = fst . splitBefore

-- | Delete the first character after the cursor, if any.
deleteAfter :: TextZipper -> TextZipper
deleteAfter = fst . splitAfter

-- | Decrement the cursor.
moveBackward :: TextZipper -> TextZipper
moveBackward = moveCursor boundedPred

-- | Increment the cursor.
moveForward :: TextZipper -> TextZipper
moveForward = moveCursor boundedSucc

-- | Move the cursor to the beginning of the text.
moveStart :: TextZipper -> TextZipper
moveStart = setCursor minBound

-- | Move the cursor to the end of the text.
moveEnd :: TextZipper -> TextZipper
moveEnd = setCursor maxBound