packages feed

idn-0.1.2.0: src/Data/Text/IDN/Types.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Core types for Internationalized Domain Names (IDN) processing.
--
-- This module provides types for domain name processing following the IDNA2008
-- specification (RFC 5890-5893). Types are organized into logical groups:
-- errors, domain structures, Unicode properties, and Punycode internals.
--
-- == Example
--
-- >>> mkLabel "example"
-- Right (ULabel "example")
--
-- >>> mkDomainName "example.com"
-- Right (DomainName {labels = ...})
module Data.Text.IDN.Types
  ( -- * Error Types
    IDNError(..)
  , HyphenError(..)
  , PunycodeError(..)
  
    -- * Domain Types
  , Label(..)
  , DomainName(..)
  , mkLabel
  , mkDomainName
  
    -- * Unicode Property Types
  , CodePointStatus(..)
  , BidiClass(..)
  , ContextRule(..)
  , BidiRule(..)
  , Script(..)
  
    -- * Punycode Types
  , PunycodeState(..)
  , PunycodeParams(..)
  , initialState
  , punycodeParams
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as NE
import GHC.Generics (Generic)
import Control.DeepSeq (NFData)

-- | IDN processing errors with detailed context.
--
-- Errors include validation failures, encoding issues, and constraint violations
-- per IDNA2008 (RFC 5890-5893).
data IDNError
  = EmptyLabel
    -- ^ Domain label cannot be empty
  | LabelTooLong 
      { actualLength :: Int
        -- ^ Actual label length
      , maxLength :: Int
        -- ^ Maximum allowed length (63)
      }
    -- ^ Domain label exceeds maximum length
  | TotalDomainTooLong
      { actualLength :: Int
        -- ^ Actual domain length
      , maxLength :: Int
        -- ^ Maximum allowed length (253)
      }
    -- ^ Complete domain name exceeds maximum length
  | InvalidHyphenPosition HyphenError
    -- ^ Hyphen appears in invalid position
  | StartsWithCombiningMark
      { character :: Char
        -- ^ The combining mark character
      , codepoint :: Int
        -- ^ Unicode code point value
      }
    -- ^ Label starts with a combining mark (invalid)
  | DisallowedCodePoint
      { character :: Char
        -- ^ The disallowed character
      , codepoint :: Int
        -- ^ Unicode code point value
      }
    -- ^ Code point is not allowed in IDN labels
  | InvalidContext
      { character :: Char
        -- ^ The character that failed context validation
      , contextRule :: ContextRule
        -- ^ The specific context rule violated
      , position :: Int
        -- ^ Position in label where error occurred
      , context :: Text
        -- ^ The full label context
      }
    -- ^ Contextual rule violation (CONTEXTJ/CONTEXTO)
  | BidiViolation
      { bidiRule :: BidiRule
        -- ^ The bidirectional rule violated
      , label :: Text
        -- ^ The label that failed validation
      }
    -- ^ Bidirectional text rule violation (RFC 5893)
  | PunycodeDecodeError
      { input :: Text
        -- ^ The input that failed to decode
      , reason :: Text
        -- ^ Reason for decode failure
      }
    -- ^ Error during Punycode decoding
  deriving (Show, Eq, Generic)

instance NFData IDNError

-- | Hyphen position errors.
--
-- Domain labels cannot start or end with hyphens, and hyphens at positions
-- 3-4 are reserved for Punycode encoding.
data HyphenError
  = StartsWithHyphen
    -- ^ Label starts with hyphen (invalid)
  | EndsWithHyphen
    -- ^ Label ends with hyphen (invalid)
  | HyphensAt3And4NotPunycode
    -- ^ Hyphens at positions 3-4 but not a valid Punycode label
  deriving (Show, Eq, Generic)

instance NFData HyphenError

-- | Punycode encoding/decoding errors.
--
-- These errors occur during low-level Punycode operations. For domain-level
-- errors, see 'IDNError'.
data PunycodeError
  = InvalidPunycode Text
    -- ^ Malformed Punycode string with reason
  | Overflow
    -- ^ Integer overflow during encoding/decoding
  deriving (Show, Eq, Generic)

instance NFData PunycodeError

-- | Domain label in either Unicode or ASCII/Punycode form.
--
-- Labels are the components of a domain name separated by dots. They can be
-- in Unicode form (U-label) or ASCII/Punycode form (A-label).
data Label
  = ULabel Text
    -- ^ Unicode label (U-label) - contains Unicode characters
  | ALabel Text
    -- ^ ASCII/Punycode label (A-label) - contains ASCII or Punycode with xn-- prefix
  deriving (Show, Eq, Generic)

instance NFData Label

-- | Complete domain name consisting of one or more labels.
--
-- A domain name is a non-empty sequence of labels separated by dots.
-- The total length must not exceed 253 characters.
newtype DomainName = DomainName 
  { labels :: NonEmpty Label
    -- ^ Non-empty list of domain labels
  }
  deriving (Show, Eq, Generic)

instance NFData DomainName

-- | Smart constructor for 'Label' with invariant enforcement.
--
-- Validates that the label is non-empty and within length constraints.
-- Automatically detects A-labels (those starting with "xn--").
--
-- === Examples
--
-- >>> mkLabel "example"
-- Right (ULabel "example")
--
-- >>> mkLabel "xn--mnchen-3ya"
-- Right (ALabel "xn--mnchen-3ya")
--
-- >>> mkLabel ""
-- Left EmptyLabel
mkLabel :: Text -> Either IDNError Label
mkLabel text
  | T.null text = Left EmptyLabel
  | T.length text > 63 = Left (LabelTooLong (T.length text) 63)
  | otherwise =
      -- RFC 5891/3490: Domain labels are case-insensitive, normalize to lowercase
      let normalized = T.toLower text
      -- RFC 5891 Section 5.1: A-labels start with "xn--"
      in if "xn--" `T.isPrefixOf` normalized
           then Right (ALabel normalized)
           else Right (ULabel normalized)

-- | Smart constructor for 'DomainName' with invariant enforcement.
--
-- Validates that the domain is non-empty, within length constraints, and
-- properly formatted. Splits on dots to create individual labels.
--
-- === Examples
--
-- >>> mkDomainName "example.com"
-- Right (DomainName {labels = ...})
--
-- >>> mkDomainName ""
-- Left EmptyLabel
mkDomainName :: Text -> Either IDNError DomainName
mkDomainName text = do
  if T.null text
    then Left EmptyLabel
    else if T.length text > 253
      then Left (TotalDomainTooLong (T.length text) 253)
      else do
        -- RFC 3490 Section 3.1: Normalize alternative dot separators
        -- U+3002 IDEOGRAPHIC FULL STOP, U+FF0E FULLWIDTH FULL STOP, U+FF61 HALFWIDTH IDEOGRAPHIC FULL STOP
        let normalized = T.map normalizeDot text
            labelTexts = T.splitOn "." normalized
        labelList <- traverse mkLabel labelTexts
        case NE.nonEmpty labelList of
          Nothing -> Left EmptyLabel
          Just ne -> Right (DomainName ne)
  where
    normalizeDot '\x3002' = '.'  -- IDEOGRAPHIC FULL STOP
    normalizeDot '\xFF0E' = '.'  -- FULLWIDTH FULL STOP
    normalizeDot '\xFF61' = '.'  -- HALFWIDTH IDEOGRAPHIC FULL STOP
    normalizeDot c = c

-- | IDNA2008 code point status per RFC 5892.
--
-- Each Unicode code point is classified according to its validity for use
-- in IDN labels.
data CodePointStatus
  = PVALID
    -- ^ Always valid in domain names
  | DISALLOWED
    -- ^ Never valid in domain names
  | CONTEXTJ
    -- ^ Valid only in specific contexts (Join_Control)
  | CONTEXTO
    -- ^ Valid only in specific contexts (Other)
  | UNASSIGNED
    -- ^ Not assigned in this Unicode version
  deriving (Show, Eq, Ord, Enum, Bounded, Generic)

instance NFData CodePointStatus

-- | Unicode bidirectional character class per Unicode Character Database (UCD).
--
-- Used for bidirectional text validation per RFC 5893.
data BidiClass
  = L    -- ^ Left-to-Right
  | R    -- ^ Right-to-Left
  | AL   -- ^ Right-to-Left Arabic
  | AN   -- ^ Arabic Number
  | EN   -- ^ European Number
  | ES   -- ^ European Number Separator
  | ET   -- ^ European Number Terminator
  | CS   -- ^ Common Number Separator
  | NSM  -- ^ Nonspacing Mark
  | BN   -- ^ Boundary Neutral
  | B    -- ^ Paragraph Separator
  | S    -- ^ Segment Separator
  | WS   -- ^ Whitespace
  | ON   -- ^ Other Neutral
  | LRE  -- ^ Left-to-Right Embedding
  | LRO  -- ^ Left-to-Right Override
  | RLE  -- ^ Right-to-Left Embedding
  | RLO  -- ^ Right-to-Left Override
  | PDF  -- ^ Pop Directional Format
  | LRI  -- ^ Left-to-Right Isolate
  | RLI  -- ^ Right-to-Left Isolate
  | FSI  -- ^ First Strong Isolate
  | PDI  -- ^ Pop Directional Isolate
  deriving (Show, Eq, Ord, Enum, Bounded, Generic)

instance NFData BidiClass

-- | Contextual validation rules from RFC 5892 Appendix A.
--
-- Some code points are only valid in specific contexts. These rules define
-- those contexts.
data ContextRule
  = ZWNJRule
    -- ^ A.1: Zero Width Non-Joiner (U+200C)
  | ZWJRule
    -- ^ A.2: Zero Width Joiner (U+200D)
  | MiddleDotRule
    -- ^ A.3: Middle Dot (U+00B7)
  | GreekKeraiaRule
    -- ^ A.4: Greek Lower Numeral Sign (U+0375)
  | HebrewGereshRule
    -- ^ A.5: Hebrew Punctuation Geresh (U+05F3)
  | HebrewGershayimRule
    -- ^ A.6: Hebrew Punctuation Gershayim (U+05F4)
  | KatakanaMiddleDotRule
    -- ^ A.7: Katakana Middle Dot (U+30FB)
  | ArabicIndicDigitsRule
    -- ^ A.8: Arabic-Indic Digits (U+0660..U+0669)
  | ExtendedArabicIndicDigitsRule
    -- ^ A.9: Extended Arabic-Indic Digits (U+06F0..U+06F9)
  deriving (Show, Eq, Ord, Enum, Bounded, Generic)

instance NFData ContextRule

-- | Bidirectional text rules from RFC 5893.
--
-- These rules ensure proper bidirectional text handling in RTL labels.
data BidiRule
  = Rule1
    -- ^ Presence of RTL characters triggers RTL validation
  | Rule2
    -- ^ First character must be R or AL
  | Rule3
    -- ^ Last character must be R, AL, AN, or EN
  | Rule4
    -- ^ NSM may only follow allowed character types
  | Rule5
    -- ^ ES and CS constraints in RTL labels
  | Rule6
    -- ^ EN and AN cannot be mixed
  deriving (Show, Eq, Ord, Enum, Bounded, Generic)

instance NFData BidiRule

-- | Unicode script assignment.
--
-- Scripts are used for contextual validation of certain code points.
data Script
  = Latin
  | Greek
  | Cyrillic
  | Hebrew
  | Arabic
  | Devanagari
  | Bengali
  | Gurmukhi
  | Gujarati
  | Oriya
  | Tamil
  | Telugu
  | Kannada
  | Malayalam
  | Thai
  | Lao
  | Tibetan
  | Myanmar
  | Georgian
  | Hangul
  | Hiragana
  | Katakana
  | Han
  | OtherScript
  deriving (Show, Eq, Ord, Enum, Bounded, Generic)

instance NFData Script

-- | Punycode algorithm state per RFC 3492.
--
-- Internal state used during Punycode encoding/decoding. Not typically
-- needed by users of the library.
data PunycodeState = PunycodeState
  { n :: {-# UNPACK #-}!Int
    -- ^ Next code point to encode
  , delta :: {-# UNPACK #-}!Int
    -- ^ Delta value for encoding
  , bias :: {-# UNPACK #-}!Int
    -- ^ Bias for variable-length integer encoding
  , h :: {-# UNPACK #-}!Int
    -- ^ Number of code points handled
  }
  deriving (Show, Eq, Generic)

instance NFData PunycodeState

-- | Initial Punycode state per RFC 3492.
--
-- Starting state for Punycode encoding operations.
initialState :: PunycodeState
initialState = PunycodeState
  { n = 0x80      -- Initial code point (128)
  , delta = 0
  , bias = 72     -- Initial bias
  , h = 0
  }

-- | Punycode algorithm parameters per RFC 3492.
--
-- Constants used in the Punycode algorithm. Not typically needed by users.
data PunycodeParams = PunycodeParams
  { base :: Int   -- ^ Base (36)
  , tmin :: Int   -- ^ Minimum threshold (1)
  , tmax :: Int   -- ^ Maximum threshold (26)
  , skew :: Int   -- ^ Skew (38)
  , damp :: Int   -- ^ Damp (700)
  }
  deriving (Show, Eq, Generic)

instance NFData PunycodeParams

-- | Standard Punycode parameters from RFC 3492.
punycodeParams :: PunycodeParams
punycodeParams = PunycodeParams
  { base = 36
  , tmin = 1
  , tmax = 26
  , skew = 38
  , damp = 700
  }