graft-0.1.0.0: src/Graft/LineIndex.hs
{-# LANGUAGE BangPatterns #-}
-- | Conversion between the UTF-8 byte offsets that Graft uses and line and
-- column positions, including the UTF-16 columns that the Language Server
-- Protocol uses by default.
--
-- A t'LineIndex' describes one source text. Build it from the rendered tree,
-- and rebuild it after an edit; it does not follow the tree.
--
-- Lines end at @\\n@, @\\r\\n@ or a lone @\\r@, as in the Language Server
-- Protocol. Lines and columns are zero-based. A line's terminator belongs to
-- that line, so an offset just after a @\\r@ that precedes @\\n@ still lies
-- on the @\\r@'s line. Its column is past the line's content, and converting
-- the position back clamps it to the end of the content, before the @\\r@:
-- that one offset per @\\r\\n@ does not round-trip.
--
-- This module is meant to be imported qualified; its types are also
-- exported by "Graft":
--
-- > import Graft
-- > import qualified Graft.LineIndex as LineIndex
--
-- >>> :set -XOverloadedStrings
-- >>> import qualified Graft.LineIndex as LineIndex
-- >>> let index = LineIndex.fromText "let x =\r\n \"\x1F600\" in x\n"
-- >>> LineIndex.utf8Position 16 index
-- Just (Utf8Position {utf8Line = 1, utf8Column = 7})
-- >>> LineIndex.utf16Position 16 index
-- Just (Utf16Position {utf16Line = 1, utf16Column = 5})
-- >>> LineIndex.fromUtf16Position (Utf16Position 1 5) index
-- Just 16
module Graft.LineIndex
( LineIndex
, fromText
, lineCount
, lineStart
, Utf8Position (..)
, utf8Position
, fromUtf8Position
, Utf16Position (..)
, utf16Position
, fromUtf16Position
) where
import Control.DeepSeq (NFData (..))
import Data.Bits ((.&.))
import Data.Hashable (Hashable (..))
import Data.Primitive.PrimArray
import Data.Text (Text)
import qualified Data.Text.Array as Array
import Data.Text.Internal (Text (..))
import Data.Word (Word8)
import Graft.Green.Internal (TextLength (..))
-- | A text together with the byte offset at which each of its lines starts.
data LineIndex = LineIndex !Text !(PrimArray Int)
instance NFData LineIndex where
rnf (LineIndex text _) = rnf text
instance Show LineIndex where
showsPrec precedence (LineIndex text _) = showParen (precedence > 10) $
showString "LineIndex.fromText " . showsPrec 11 text
-- | A zero-based line and a column counted in UTF-8 bytes. The two kinds of
-- position are separate types so that a column in one unit cannot be passed
-- where the other is expected.
data Utf8Position = Utf8Position
{ utf8Line :: !Int
, utf8Column :: !Int
}
deriving (Eq, Ord, Show)
instance NFData Utf8Position where
rnf (Utf8Position _ _) = ()
instance Hashable Utf8Position where
hashWithSalt salt (Utf8Position line column) = salt `hashWithSalt` line `hashWithSalt` column
-- | A zero-based line and a column counted in UTF-16 code units, as the
-- Language Server Protocol counts them by default.
data Utf16Position = Utf16Position
{ utf16Line :: !Int
, utf16Column :: !Int
}
deriving (Eq, Ord, Show)
instance NFData Utf16Position where
rnf (Utf16Position _ _) = ()
instance Hashable Utf16Position where
hashWithSalt salt (Utf16Position line column) = salt `hashWithSalt` line `hashWithSalt` column
-- | Index the lines of a text, in time proportional to its length.
fromText :: Text -> LineIndex
fromText text@(Text _ _ len) = LineIndex text (primArrayFromList (0 : go 0))
where
go !index
| index >= len = []
| byte == 10 = (index + 1) : go (index + 1)
| byte == 13 && (index + 1 >= len || byteAt text (index + 1) /= 10) = (index + 1) : go (index + 1)
| otherwise = go (index + 1)
where
byte = byteAt text index
-- | Number of lines. An empty text, and a text ending in a terminator, end
-- with an empty line.
lineCount :: LineIndex -> Int
lineCount (LineIndex _ starts) = sizeofPrimArray starts
-- | Offset at which a line starts, or 'Nothing' for a line that does not exist.
lineStart :: Int -> LineIndex -> Maybe TextLength
lineStart line (LineIndex _ starts)
| line < 0 || line >= sizeofPrimArray starts = Nothing
| otherwise = Just (TextLength (indexPrimArray starts line))
-- | Line and UTF-8 byte column of an offset in @[0, length]@, or 'Nothing' if
-- the offset lies outside the text or inside a multibyte character.
utf8Position :: TextLength -> LineIndex -> Maybe Utf8Position
utf8Position (TextLength offset) index@(LineIndex text _)
| offset < 0 || offset > textBytes text = Nothing
| offset < textBytes text && continuation (byteAt text offset) = Nothing
| otherwise = Just (Utf8Position line (offset - startOf index line))
where
line = lineOf offset index
-- | Offset of a line and UTF-8 byte column. A column past the end of the
-- line's content is clamped to it, as the Language Server Protocol requires.
-- Returns 'Nothing' for a line that does not exist or a column that falls
-- inside a multibyte character, so the result is always a character boundary.
fromUtf8Position :: Utf8Position -> LineIndex -> Maybe TextLength
fromUtf8Position (Utf8Position line column) index@(LineIndex text _)
| line < 0 || line >= lineCount index || column < 0 = Nothing
| position < end && continuation (byteAt text position) = Nothing
| otherwise = Just (TextLength position)
where
start = startOf index line
end = contentEnd index line
position = start + min column (end - start)
-- | Line and UTF-16 column of an offset in @[0, length]@, or 'Nothing' if the
-- offset lies outside the text or inside a multibyte character.
utf16Position :: TextLength -> LineIndex -> Maybe Utf16Position
utf16Position (TextLength offset) index@(LineIndex text _)
| offset < 0 || offset > textBytes text = Nothing
| offset < textBytes text && continuation (byteAt text offset) = Nothing
| otherwise = Just (Utf16Position line (go 0 (startOf index line)))
where
line = lineOf offset index
go !units !position
| position >= offset = units
| otherwise = go (units + utf16Units (byteAt text position)) (position + 1)
-- | Offset of a line and UTF-16 column. A column past the end of the line's
-- content is clamped to it, as the Language Server Protocol requires. Returns
-- 'Nothing' for a line that does not exist or a column that falls between
-- the two halves of a surrogate pair.
fromUtf16Position :: Utf16Position -> LineIndex -> Maybe TextLength
fromUtf16Position (Utf16Position line column) index@(LineIndex text _)
| line < 0 || line >= lineCount index || column < 0 = Nothing
| otherwise = TextLength <$> go 0 (startOf index line)
where
end = contentEnd index line
go !units !position
| units > column = Nothing
| units == column || position >= end = Just position
| otherwise = go (units + utf16Units (byteAt text position)) (next position)
next position
| position + 1 < end && continuation (byteAt text (position + 1)) = next (position + 1)
| otherwise = position + 1
-- The last line whose start is at or before the offset.
lineOf :: Int -> LineIndex -> Int
lineOf offset (LineIndex _ starts) = go 0 (sizeofPrimArray starts - 1)
where
go low high
| low >= high = low
| indexPrimArray starts middle <= offset = go middle high
| otherwise = go low (middle - 1)
where
middle = (low + high + 1) `div` 2
startOf :: LineIndex -> Int -> Int
startOf (LineIndex _ starts) = indexPrimArray starts
-- The end of a line's content, before its terminator.
contentEnd :: LineIndex -> Int -> Int
contentEnd index@(LineIndex text starts) line = stripCarriage (stripNewline end)
where
start = startOf index line
end
| line + 1 < sizeofPrimArray starts = indexPrimArray starts (line + 1)
| otherwise = textBytes text
stripNewline position
| position > start && byteAt text (position - 1) == 10 = position - 1
| otherwise = position
stripCarriage position
| position > start && byteAt text (position - 1) == 13 = position - 1
| otherwise = position
textBytes :: Text -> Int
textBytes (Text _ _ len) = len
byteAt :: Text -> Int -> Word8
byteAt (Text array start _) index = Array.unsafeIndex array (start + index)
continuation :: Word8 -> Bool
continuation byte = byte .&. 0xC0 == 0x80
-- UTF-16 code units contributed by the character that starts with this byte;
-- continuation bytes contribute none.
utf16Units :: Word8 -> Int
utf16Units byte
| continuation byte = 0
| byte >= 0xF0 = 2
| otherwise = 1