packages feed

jsonlogic-hs-0.1.0.0: src/JsonLogic/Core/Utf16.hs

-- |
-- Module      : JsonLogic.Core.Utf16
--
-- JavaScript strings are sequences of UTF-16 code units, not of Unicode
-- code points. The reference implementation's @length@, indexing, @substr@
-- and @<@ all work on code units, so a character outside the Basic
-- Multilingual Plane (an emoji, say) has length 2, and sorts /below/
-- U+E000 to U+FFFF because its first code unit is a surrogate in
-- U+D800 to U+DBFF.
--
-- This module is the one place those semantics are defined, over
-- 'Text'. A 'Text' cannot hold a lone surrogate, so a slice that cuts
-- through a surrogate pair yields U+FFFD for the severed half. That is the
-- single accepted deviation from the reference here, and it is exactly what
-- @String.prototype.toWellFormed()@ would make of the reference's answer.
module JsonLogic.Core.Utf16
  ( utf16Length
  , utf16Slice
  , utf16CharAt
  , compareUtf16
  , utf16Units
  , fromUtf16Units
  ) where

import Data.Bits (shiftL, shiftR, (.&.), (.|.))
import Data.Char (chr, ord)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Word (Word16)

-- | The number of UTF-16 code units: JavaScript's @s.length@.
utf16Length :: Text -> Int
utf16Length = Text.foldl' (\n c -> if ord c > 0xFFFF then n + 2 else n + 1) 0

-- | The UTF-16 code units of a text.
utf16Units :: Text -> [Word16]
utf16Units = concatMap charUnits . Text.unpack

charUnits :: Char -> [Word16]
charUnits c
  | n <= 0xFFFF = [fromIntegral n]
  | otherwise =
      let m = n - 0x10000
       in [ fromIntegral (0xD800 .|. (m `shiftR` 10))
          , fromIntegral (0xDC00 .|. (m .&. 0x3FF))
          ]
  where
    n = ord c

-- | Rebuild a text from UTF-16 code units. A surrogate with no partner
-- becomes U+FFFD.
fromUtf16Units :: [Word16] -> Text
fromUtf16Units = Text.pack . go
  where
    go [] = []
    go (hi : lo : rest)
      | isHigh hi && isLow lo = pair hi lo : go rest
    go (u : rest)
      | isHigh u || isLow u = '\xFFFD' : go rest
      | otherwise = chr (fromIntegral u) : go rest
    isHigh u = u >= 0xD800 && u <= 0xDBFF
    isLow u = u >= 0xDC00 && u <= 0xDFFF
    pair hi lo =
      chr
        ( 0x10000
            + ((fromIntegral hi - 0xD800) `shiftL` 10)
            + (fromIntegral lo - 0xDC00)
        )

-- | @slice from count@: the @count@ code units starting at code unit
-- @from@, both already clamped into range by the caller. Cutting through a
-- surrogate pair gives U+FFFD for the severed half.
utf16Slice :: Int -> Int -> Text -> Text
utf16Slice from count text
  | count <= 0 = Text.empty
  | Text.all isBmp text = Text.take count (Text.drop from text)
  | otherwise = fromUtf16Units (take count (drop from (utf16Units text)))

-- | The code unit at an index, as a one-unit string, or 'Nothing' past the
-- end. A lone surrogate half is returned as U+FFFD.
utf16CharAt :: Int -> Text -> Maybe Text
utf16CharAt i text
  | i < 0 = Nothing
  | Text.all isBmp text =
      let picked = Text.take 1 (Text.drop i text)
       in if Text.null picked then Nothing else Just picked
  | otherwise = case drop i (utf16Units text) of
      [] -> Nothing
      unit : _ -> Just (fromUtf16Units [unit])

isBmp :: Char -> Bool
isBmp c = ord c <= 0xFFFF

-- | JavaScript's string ordering: lexicographic by UTF-16 code unit.
--
-- It differs from ordering by code point only when an astral character
-- (first unit U+D800 to U+DBFF) meets a BMP character in U+E000 to U+FFFF:
-- by code unit the astral one is smaller.
compareUtf16 :: Text -> Text -> Ordering
compareUtf16 a b = go (Text.unpack a) (Text.unpack b)
  where
    go [] [] = EQ
    go [] _ = LT
    go _ [] = GT
    go (x : xs) (y : ys)
      | x == y = go xs ys
      | otherwise = compare (charUnits x) (charUnits y)