packages feed

idna2008-1.0.0.0: internal/Text/IDNA2008/Internal/Bidi.hs

-- |
-- Module      : Text.IDNA2008.Internal.Bidi
-- Description : Typed wrapper over the Bidi_Class table consumed by
--               the per-label RFC 5893 check in the IDNA parser.
-- Copyright   : (c) Viktor Dukhovni, 2026
-- License     : BSD-3-Clause
--
-- Maintainer  : ietf-dane@dukhovni.org
-- Stability   : unstable
--
-- One lookup, 'bidiClassCp', backed by binary search over the
-- generated range table in "Text.IDNA2008.Internal.Bidi.Data".
-- The table covers every codepoint in @[0, 0x10FFFF]@ with no gaps,
-- so 'bidiClassCp' is a total function.
--
-- The eleven classes named in RFC 5893 (L, R, AL, AN, EN, ES, CS,
-- ET, ON, BN, NSM) get their own pattern synonyms; everything else
-- (paragraph\/segment\/whitespace separators, embedding\/override
-- and isolate format characters) is reported as 'BidiOther', which
-- the parser treats as a Bidi-rule violation in any label where it
-- appears.
{-# LANGUAGE CPP #-}

module Text.IDNA2008.Internal.Bidi
    ( -- * Class lookup
      bidiClassCp
      -- * Per-label summary
    , BidiSummary
    , emptyBidiSummary
    , extendBidiSummary
    , buildBidiSummary
    , bidiSummaryFromCps
      -- * Cross-label trigger fold
    , addBidiTrigger
      -- * Rule check
    , checkBidiSummary
    ) where

import Control.Monad.ST (ST)
import Data.Bits ((.&.), (.|.), bit, testBit, unsafeShiftL, unsafeShiftR)
#if !MIN_VERSION_base(4,20,0)
import Data.Foldable(foldl')
#endif
import Data.Primitive.ByteArray (indexByteArray)
import Data.Primitive.PrimArray
    ( MutablePrimArray
    , readPrimArray
    )
import Data.Word (Word8, Word16, Word32)

import Text.IDNA2008.Internal.Bidi.Data
    ( bidiRangeCount
    , bidiRangeStarts
    , bidiRangeTags
    )
import Text.IDNA2008.Internal.Error (BidiRuleViolation(..))

----------------------------------------------------------------------
-- Class tag
----------------------------------------------------------------------

-- | Resolved Unicode @Bidi_Class@ for the purposes of RFC 5893.
-- Stored as a 'Word8' tag in @[0, 11]@; pattern synonyms below
-- expose the named classes.  Tag 11 ('BidiOther') aggregates the
-- classes that RFC 5893 forbids unconditionally (B, S, WS, the
-- embedding\/override\/isolate format characters), so any label
-- containing one of them fails the rule check.
newtype BidiClass = BidiClass_ Word8 deriving Eq

-- The patterns below cover the classes the parser inspects by
-- name; the underlying type also represents tags 5..9 for the
-- neutral classes (ES, CS, ET, ON, BN) that RFC 5893 permits in
-- both LTR and RTL labels but which never trigger a rule
-- decision -- they flow through the validation code via @_@
-- catch-alls.  Naming them as patterns would add nothing the
-- parser can use, so the tags stay unnamed.
pattern BidiL     :: BidiClass
pattern BidiL     = BidiClass_ 0    -- ^ Left_To_Right
pattern BidiR     :: BidiClass
pattern BidiR     = BidiClass_ 1    -- ^ Right_To_Left
pattern BidiAL    :: BidiClass
pattern BidiAL    = BidiClass_ 2    -- ^ Arabic_Letter
pattern BidiAN    :: BidiClass
pattern BidiAN    = BidiClass_ 3    -- ^ Arabic_Number
pattern BidiEN    :: BidiClass
pattern BidiEN    = BidiClass_ 4    -- ^ European_Number
pattern BidiNSM   :: BidiClass
pattern BidiNSM   = BidiClass_ 10   -- ^ Nonspacing_Mark
pattern BidiOther :: BidiClass
pattern BidiOther = BidiClass_ 11   -- ^ everything else: B, S, WS,
                                    --   embedding\/override\/isolate
                                    --   format characters.  Forbidden
                                    --   in any Bidi-rule-checked label.

-- | Wrap a 'Word8' tag from the generated table.  Tags @0..10@ map
-- to the named classes; anything else collapses to 'BidiOther' on
-- the principle of conservative classification (would-be-failing
-- values fail).
{-# INLINE word8ToClass #-}
word8ToClass :: Word8 -> BidiClass
word8ToClass w
    | w <= 10   = BidiClass_ w
    | otherwise = BidiOther

----------------------------------------------------------------------
-- Public lookup
----------------------------------------------------------------------

-- | Return the resolved 'BidiClass' of a codepoint.  Out-of-range
-- inputs (negative or greater than @0x10FFFF@) report 'BidiOther',
-- which causes any label containing them to fail the Bidi check.
bidiClassCp :: Int -> BidiClass
bidiClassCp !cp
    | cp < 0 || cp > 0x10FFFF = BidiOther
    | otherwise               = word8ToClass (lookupTag cp)
{-# INLINE bidiClassCp #-}

----------------------------------------------------------------------
-- Binary search over the codegen'd range table
----------------------------------------------------------------------

-- | Binary-search for the largest index @i@ in
-- @[0, bidiRangeCount - 1]@ such that @bidiRangeStarts[i] <= cp@.
-- Returns the tag at that index.  Pre-condition: the table starts
-- at @0x000000@, so a hit is guaranteed for any @cp >= 0@.
{-# INLINE lookupTag #-}
lookupTag :: Int -> Word8
lookupTag !cp = go 0 (bidiRangeCount - 1)
  where
    !target = fromIntegral cp :: Word32

    go !lo !hi
      | lo >= hi  = readTag lo
      | otherwise =
          let !mid    = (lo + hi + 1) `unsafeShiftR` 1
              !midKey = readStart mid
          in if midKey <= target
               then go mid hi
               else go lo (mid - 1)

    readStart i = indexByteArray bidiRangeStarts i :: Word32
    readTag   i = indexByteArray bidiRangeTags   i :: Word8

----------------------------------------------------------------------
-- Per-label summary
----------------------------------------------------------------------

-- | Per-label Bidi summary, packed into a single 'Word16':
--
-- @
--   bits  0..3 : first-codepoint class    (0..11)
--   bits  4..7 : last non-NSM class       (0..11)
--   bit      8 : has any L
--   bit      9 : has any R or AL
--   bit     10 : has any AN
--   bit     11 : has any EN
--   bit     12 : has any Other
-- @
--
-- Captures everything Rules 1-6 of RFC 5893 need to decide a
-- label's verdict without retaining the codepoint sequence.
-- Accumulated by left-fold via 'extendBidiSummary' starting from
-- 'emptyBidiSummary'.  The bit layout is internal — inspect via
-- the accessor functions ('bsFirstClass', 'bsLastNonNSM',
-- 'bsHasL', 'bsHasRorAL', 'bsHasAN', 'bsHasEN', 'bsHasOther').
newtype BidiSummary = BidiSummary_ Word16 deriving Eq

-- Bit indices for the presence flags within a 'BidiSummary's
-- 'Word16'.  The low byte stores the first / last class slots
-- (bits 0..3 and 4..7); presence flags live above that.
bsHasLIdx, bsHasRorALIdx, bsHasANIdx, bsHasENIdx, bsHasOtherIdx :: Int
bsHasLIdx     = 8
bsHasRorALIdx = 9
bsHasANIdx    = 10
bsHasENIdx    = 11
bsHasOtherIdx = 12

-- | Class of the first codepoint.  Rule 1 requires this to be
-- 'BidiL', 'BidiR', or 'BidiAL'.
bsFirstClass :: BidiSummary -> BidiClass
bsFirstClass (BidiSummary_ w) = BidiClass_ (fromIntegral (w .&. 0x000F))
{-# INLINE bsFirstClass #-}

-- | Class of the last codepoint that is not 'BidiNSM'.  Rules 3
-- and 6 examine this to decide if the label ends correctly past
-- any trailing combining marks.
bsLastNonNSM :: BidiSummary -> BidiClass
bsLastNonNSM (BidiSummary_ w) =
    BidiClass_ (fromIntegral ((w `unsafeShiftR` 4) .&. 0x000F))
{-# INLINE bsLastNonNSM #-}

bsHasL, bsHasRorAL, bsHasAN, bsHasEN, bsHasOther :: BidiSummary -> Bool
bsHasL     (BidiSummary_ w) = testBit w bsHasLIdx
bsHasRorAL (BidiSummary_ w) = testBit w bsHasRorALIdx
bsHasAN    (BidiSummary_ w) = testBit w bsHasANIdx
bsHasEN    (BidiSummary_ w) = testBit w bsHasENIdx
bsHasOther (BidiSummary_ w) = testBit w bsHasOtherIdx
{-# INLINE bsHasL #-}
{-# INLINE bsHasRorAL #-}
{-# INLINE bsHasAN #-}
{-# INLINE bsHasEN #-}
{-# INLINE bsHasOther #-}

-- | OR a per-label summary's cross-label trigger contribution
-- into a running flag.  Designed for left-fold over a label
-- sequence: @'addBidiTrigger' trig sm@ is 'True' if either @trig@
-- was already 'True' or @sm@ contains an @R@, @AL@, or @AN@
-- codepoint — the conditions under which RFC 5893 fires
-- cross-label Rules 1-6.  Encapsulates the bit-level details of
-- which classes trigger global checks so callers don't need to
-- import the per-flag predicates.
addBidiTrigger :: Bool -> BidiSummary -> Bool
addBidiTrigger !trig (BidiSummary_ w) =
    trig || testBit w bsHasRorALIdx || testBit w bsHasANIdx
{-# INLINE addBidiTrigger #-}

-- | The summary of an empty (zero-codepoint) label.  Encodes
-- 'BidiOther' in both first and last slots and clears every
-- presence flag, so any rule check on it fails Rule 1 cleanly.
-- Empty labels shouldn't normally reach a per-label Bidi check
-- (the parser rejects them earlier) but the constant gives
-- 'extendBidiSummary' something to fold over.
--
-- The empty / initial-state predicate is exactly
-- @(w `.&.` 0xFF00) == 0 && (w `.&.` 0x000F) == 11@, which
-- 'extendBidiSummary' uses to distinguish the first update from
-- subsequent updates.  Encoded value: @(BidiOther << 4) |
-- BidiOther = 0xBB@.
emptyBidiSummary :: BidiSummary
emptyBidiSummary = BidiSummary_ 0x00BB
{-# INLINE emptyBidiSummary #-}

-- | Map a 'BidiClass' to the presence-flag bit it contributes
-- (zero for classes that don't carry a flag: ES, CS, ET, ON,
-- BN, NSM).
classFlagBit :: BidiClass -> Word16
classFlagBit BidiL     = bit bsHasLIdx
classFlagBit BidiR     = bit bsHasRorALIdx
classFlagBit BidiAL    = bit bsHasRorALIdx
classFlagBit BidiAN    = bit bsHasANIdx
classFlagBit BidiEN    = bit bsHasENIdx
classFlagBit BidiOther = bit bsHasOtherIdx
classFlagBit _         = 0
{-# INLINE classFlagBit #-}

-- | Update a 'BidiSummary' with one more codepoint's class.
-- Designed for left-fold over a sequence of codepoints starting
-- from 'emptyBidiSummary'.
extendBidiSummary :: BidiSummary -> BidiClass -> BidiSummary
extendBidiSummary !(BidiSummary_ w) !c@(BidiClass_ cw) =
    let !cw16 = fromIntegral cw :: Word16
        !flag = classFlagBit c
    in if (w .&. 0xFF00) == 0 && (w .&. 0x000F) == 11
         -- Initial state: first slot still BidiOther, no flags
         -- set.  Seed both first and last slots with this
         -- codepoint's class, plus its flag bit.
         then BidiSummary_ (cw16 .|. (cw16 `unsafeShiftL` 4) .|. flag)
         -- Subsequent update: always OR in the flag bit; update
         -- the last-non-NSM slot only if the codepoint is not
         -- NSM.
         else case c of
             BidiNSM -> BidiSummary_ (w .|. flag)
             _       -> BidiSummary_
                 ((w .&. 0xFF0F) .|. (cw16 `unsafeShiftL` 4) .|. flag)
{-# INLINE extendBidiSummary #-}

-- | Walk @cpBuf[0..cnt)@ accumulating a 'BidiSummary'.  Used by
-- the parser-time per-label check (which already holds a
-- 'MutablePrimArray' of decoded codepoints).
buildBidiSummary
    :: forall s. MutablePrimArray s Int -> Int -> ST s BidiSummary
buildBidiSummary !cpBuf !cnt = go 0 emptyBidiSummary
  where
    go :: Int -> BidiSummary -> ST s BidiSummary
    go !i !s
      | i >= cnt  = pure s
      | otherwise = do
          cp <- readPrimArray cpBuf i
          go (i + 1) (extendBidiSummary s (bidiClassCp cp))
{-# INLINE buildBidiSummary #-}

-- | Pure 'BidiSummary' computation over an arbitrary sequence of
-- codepoints; used by the presentation-time global check, which
-- consumes already-decoded U-label codepoints.
bidiSummaryFromCps :: [Int] -> BidiSummary
bidiSummaryFromCps =
    foldl' (\ !s !cp -> extendBidiSummary s (bidiClassCp cp))
           emptyBidiSummary
{-# INLINE bidiSummaryFromCps #-}

----------------------------------------------------------------------
-- Rule check
----------------------------------------------------------------------

-- | Validate a 'BidiSummary' against the per-label conditions of
-- RFC 5893 Rules 1-6.  Returns 'Nothing' if the label is clean,
-- or the offending 'BidiRuleViolation' otherwise.
--
-- The first 'Bool' argument controls the trigger short-circuit:
--
--   * 'False' (per-label mode) -- only check labels whose own
--     codepoints contain an @R@, @AL@, or @AN@ codepoint.
--     Labels without any such content (pure-LTR labels) always
--     return 'Nothing'.  This is the right semantic for the
--     parser, which validates each label in isolation.
--   * 'True' (global mode) -- check every label unconditionally.
--     This is the right semantic for the cross-label
--     presentation-time check, which fires Rules 1-6 on every
--     label of a domain once any label has RTL content.
--
-- In either mode, the rule application itself is identical:
-- Rule 1, then either the RTL trio (Rules 2 \/ 3 \/ 4) or the
-- LTR pair (Rules 5 \/ 6) depending on the first-codepoint
-- direction.  An out-of-set codepoint (anything @BidiOther@)
-- fails the corresponding allowed-set rule (2 in RTL, 5 in LTR).
checkBidiSummary :: Bool -> BidiSummary -> Maybe BidiRuleViolation
checkBidiSummary !forceCheck !s
    -- Per-label trigger: skip clean LTR-only labels unless we're
    -- in global mode.
    | not forceCheck
    , not (bsHasRorAL s || bsHasAN s) = Nothing
    -- Rule 1: first must be L, R, or AL.
    | first /= BidiL
    , first /= BidiR
    , first /= BidiAL = Just BidiRule1FirstNotLRAL
    | first == BidiL =
        -- LTR rules 5 and 6.  Either a forbidden codepoint class
        -- or a wrong-direction trailing class fails.
        if bsHasRorAL s || bsHasAN s || bsHasOther s
          then Just BidiRule5LTRDisallowed
          else case bsLastNonNSM s of
              BidiL  -> Nothing
              BidiEN -> Nothing
              _      -> Just BidiRule6LTRBadEnd
    | otherwise =
        -- 'first' is BidiR or BidiAL.  RTL rules 2, 3, 4.
        if bsHasL s || bsHasOther s
          then Just BidiRule2RTLDisallowed
          else if bsHasEN s && bsHasAN s
            then Just BidiRule4ENANMix
            else case bsLastNonNSM s of
                BidiR  -> Nothing
                BidiAL -> Nothing
                BidiEN -> Nothing
                BidiAN -> Nothing
                _      -> Just BidiRule3RTLBadEnd
  where
    !first = bsFirstClass s
{-# INLINE checkBidiSummary #-}