packages feed

jsonschema-0.3.0.1: src/JSONSchema/URIFragment.hs

{- |
Module:      JSONSchema.URIFragment
Copyright:   (c) DPella AB 2025
License:     LicenseRef-AllRightsReserved
Maintainer:  <matti@dpella.io>, <lobo@dpella.io>

UTF-8 percent encoding and decoding for URI fragments.
-}
module JSONSchema.URIFragment (
    encodeURIFragment,
    decodeURIFragment,
) where

import Data.ByteString qualified as BS
import Data.Char (ord)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Word (Word8)

-- | Percent-encode bytes that are not legal in an RFC 3986 URI fragment.
encodeURIFragment :: Text -> Text
encodeURIFragment = TE.decodeLatin1 . BS.concatMap encodeByte . TE.encodeUtf8
  where
    encodeByte byte
        | isFragmentByte byte = BS.singleton byte
        | otherwise = BS.pack [percent, hexDigit (byte `div` 16), hexDigit (byte `mod` 16)]

-- | Percent-decode a URI fragment and require the result to be valid UTF-8.
decodeURIFragment :: Text -> Maybe Text
decodeURIFragment fragment = do
    bytes <- decodeBytes (BS.unpack (TE.encodeUtf8 fragment))
    either (const Nothing) Just (TE.decodeUtf8' (BS.pack bytes))
  where
    decodeBytes [] = Just []
    decodeBytes (byte : rest)
        | byte /= percent = (byte :) <$> decodeBytes rest
    decodeBytes (_ : high : low : rest) = do
        highNibble <- hexValue high
        lowNibble <- hexValue low
        ((highNibble * 16 + lowNibble) :) <$> decodeBytes rest
    decodeBytes _ = Nothing

-- RFC 3986: fragment = *(pchar / "/" / "?").
isFragmentByte :: Word8 -> Bool
isFragmentByte byte =
    isAsciiAlphaNumeric byte
        || byte `elem` map (fromIntegral . ord) ("-._~!$&'()*+,;=:@/?" :: String)

isAsciiAlphaNumeric :: Word8 -> Bool
isAsciiAlphaNumeric byte =
    (byte >= ascii 'a' && byte <= ascii 'z')
        || (byte >= ascii 'A' && byte <= ascii 'Z')
        || (byte >= ascii '0' && byte <= ascii '9')

hexDigit :: Word8 -> Word8
hexDigit nibble
    | nibble < 10 = ascii '0' + nibble
    | otherwise = ascii 'A' + nibble - 10

hexValue :: Word8 -> Maybe Word8
hexValue byte
    | byte >= ascii '0' && byte <= ascii '9' = Just (byte - ascii '0')
    | byte >= ascii 'A' && byte <= ascii 'F' = Just (byte - ascii 'A' + 10)
    | byte >= ascii 'a' && byte <= ascii 'f' = Just (byte - ascii 'a' + 10)
    | otherwise = Nothing

ascii :: Char -> Word8
ascii = fromIntegral . ord

percent :: Word8
percent = ascii '%'