{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE Trustworthy #-}

-- |
-- Module: Text.Ascii.Char
-- Copyright: (C) 2021 Koz Ross
-- License: Apache 2.0
-- Maintainer: Koz Ross <koz.ross@retro-freedom.nz>
-- Stability: stable
-- Portability: GHC only
--
-- An implementation of ASCII characters, as bytes restricted to the range 0 -
-- 127 inclusive.
--
-- /See also:/ [Wikipedia entry for ASCII](https://en.wikipedia.org/wiki/ASCII)
module Text.Ascii.Char
  ( -- * ASCII characters

    -- ** Type
    AsciiChar (AsByte, AsChar),

    -- ** Construction
    char,
    fromChar,
    fromByte,

    -- ** Transformation
    upcase,
    downcase,

    -- * Categorization
    AsciiType (Control, Printable),
    charType,
    AsciiCategory (Other, Punctuation, Letter, Number, Symbol),
    categorize,
    categorizeGeneral,
    AsciiCase (Upper, Lower),
    caseOf,

    -- * Optics
    charWise,
    byteWise,
  )
where

import Control.DeepSeq (NFData)
import Control.Monad (guard)
import Data.Char (GeneralCategory, chr, generalCategory, isAscii, ord)
import Data.Functor (($>))
import Data.Hashable (Hashable)
import Data.Word (Word8)
import Optics.Prism (Prism', prism')
import Text.Ascii.Internal (AsciiChar (AsciiChar), toByte, pattern AsByte, pattern AsChar)
import Text.Ascii.QQ (char)

-- $setup
-- >>> :set -XQuasiQuotes
-- >>> import Text.Ascii.Char
-- >>> import Optics.AffineFold (preview)
-- >>> import Optics.Review (review)

-- | Try and turn a 'Char' into the equivalent 'AsciiChar'. Will return
-- 'Nothing' if given a 'Char' that has no ASCII equivalent.
--
-- >>> fromChar '0'
-- Just '0x30'
-- >>> fromChar '😺'
-- Nothing
--
-- @since 1.0.0
fromChar :: Char -> Maybe AsciiChar
fromChar c =
  if isAscii c
    then pure . AsciiChar . fromIntegral . ord $ c
    else Nothing

-- | Try to give the 'AsciiChar' corresponding to the given byte. Will return
-- 'Nothing' if given a byte that doesn't correspond to an ASCII character.
--
-- >>> fromByte 50
-- Just '0x32'
-- >>> fromByte 128
-- Nothing
--
-- @since 1.0.0
fromByte :: Word8 -> Maybe AsciiChar
fromByte w8 =
  if isAscii . chr . fromIntegral $ w8
    then pure . AsciiChar $ w8
    else Nothing

-- | Give the 'AsciiChar' corresponding to the uppercase version of the
-- argument. Will give 'Nothing' if given an 'AsciiChar' which has no uppercase
-- version, or is uppercase already.
--
-- >>> upcase [char| 'a' |]
-- Just '0x41'
-- >>> upcase [char| '0' |]
-- Nothing
--
-- @since 1.0.0
upcase :: AsciiChar -> Maybe AsciiChar
upcase c@(AsciiChar w8) =
  caseOf c >>= (\cs -> guard (cs == Lower) $> AsciiChar (w8 - 32))

-- | Give the 'AsciiChar' corresponding to the lowercase version of the
-- argument. Will give 'Nothing' if given an 'AsciiChar' which has no lowercase
-- version, or is lowercase already.
--
-- >>> downcase [char| 'C' |]
-- Just '0x63'
-- >>> downcase [char| '\\' |]
-- Nothing
--
-- @since 1.0.0
downcase :: AsciiChar -> Maybe AsciiChar
downcase c@(AsciiChar w8) =
  caseOf c >>= (\cs -> guard (cs == Upper) $> AsciiChar (w8 + 32))

-- Categorization

-- | A categorization of ASCII characters based on whether they're meant to be
-- displayed ('Printable') or for control ('Control').
--
-- @since 1.0.0
newtype AsciiType = AsciiType Word8
  deriving (Eq, Ord, Hashable, NFData) via Word8

-- | @since 1.0.0
instance Show AsciiType where
  {-# INLINEABLE show #-}
  show = \case
    Control -> "Control"
    Printable -> "Printable"

-- | @since 1.0.0
instance Bounded AsciiType where
  minBound = Control
  maxBound = Printable

-- | A control character is any of the first 32 bytes (0-31), plus @DEL@ (127).
--
-- @since 1.0.0
pattern Control :: AsciiType
pattern Control <-
  AsciiType 0
  where
    Control = AsciiType 0

-- | All ASCII characters whose byte is above 31 (and not 127) are printable
-- characters.
--
-- @since 1.0.0
pattern Printable :: AsciiType
pattern Printable <-
  AsciiType 1
  where
    Printable = AsciiType 1

{-# COMPLETE Control, Printable #-}

-- | Classify an 'AsciiChar' according to whether it's a control character or a
-- printable character.
--
-- >>> charType [char| '\0' |]
-- Control
-- >>> charType [char| 'w' |]
-- Printable
--
-- @since 1.0.0
charType :: AsciiChar -> AsciiType
charType (AsciiChar w8)
  | w8 == 127 = Control
  | w8 < 32 = Control
  | otherwise = Printable

-- | A categorization of ASCII characters based on their usage. Based (loosely)
-- on Unicode categories.
--
-- @since 1.0.0
newtype AsciiCategory = AsciiCategory Word8
  deriving (Eq, Ord, Hashable, NFData) via Word8

-- | @since 1.0.0
instance Show AsciiCategory where
  {-# INLINEABLE show #-}
  show = \case
    Other -> "Other"
    Symbol -> "Symbol"
    Number -> "Number"
    Letter -> "Letter"
    Punctuation -> "Punctuation"

-- | @since 1.0.0
instance Bounded AsciiCategory where
  minBound = Other
  maxBound = Symbol

-- | Something which doesn't fit into any of the other categories.
--
-- @since 1.0.0
pattern Other :: AsciiCategory
pattern Other <-
  AsciiCategory 0
  where
    Other = AsciiCategory 0

-- | A punctuation character.
--
-- @since 1.0.0
pattern Punctuation :: AsciiCategory
pattern Punctuation <-
  AsciiCategory 1
  where
    Punctuation = AsciiCategory 1

-- | A letter, either uppercase or lowercase.
--
-- @since 1.0.0
pattern Letter :: AsciiCategory
pattern Letter <-
  AsciiCategory 2
  where
    Letter = AsciiCategory 2

-- | A numerical digit.
--
-- @since 1.0.0
pattern Number :: AsciiCategory
pattern Number <-
  AsciiCategory 3
  where
    Number = AsciiCategory 3

-- | A symbol whose role isn't (normally) punctuation.
--
-- @since 1.0.0
pattern Symbol :: AsciiCategory
pattern Symbol <-
  AsciiCategory 4
  where
    Symbol = AsciiCategory 4

{-# COMPLETE Other, Punctuation, Letter, Number, Symbol #-}

-- | Classify an 'AsciiChar' based on its category.
--
-- >>> categorize [char| ',' |]
-- Punctuation
-- >>> categorize [char| '~' |]
-- Symbol
-- >>> categorize [char| 'w' |]
-- Letter
-- >>> categorize [char| '2' |]
-- Number
-- >>> categorize [char| '\0' |]
-- Other
--
-- @since 1.0.0
categorize :: AsciiChar -> AsciiCategory
categorize c@(AsciiChar w8)
  | charType c == Control = Other
  | w8 == 0x20 = Punctuation
  | w8 >= 0x21 && w8 <= 0x23 = Punctuation
  | w8 == 0x24 = Symbol
  | w8 >= 0x25 && w8 <= 0x2a = Punctuation
  | w8 == 0x2b = Symbol
  | w8 >= 0x2c && w8 <= 0x2f = Punctuation
  | w8 >= 0x30 && w8 <= 0x39 = Number
  | w8 >= 0x3a && w8 <= 0x3b = Punctuation
  | w8 >= 0x3c && w8 <= 0x3e = Symbol
  | w8 >= 0x3f && w8 <= 0x40 = Punctuation
  | w8 >= 0x41 && w8 <= 0x5a = Letter
  | w8 >= 0x5b && w8 <= 0x5d = Punctuation
  | w8 == 0x5e = Symbol
  | w8 == 0x5f = Punctuation
  | w8 == 0x60 = Symbol
  | w8 >= 0x61 && w8 <= 0x7a = Letter
  | w8 == 0x7b = Punctuation
  | w8 == 0x7c = Symbol
  | w8 == 0x7d = Punctuation
  | otherwise = Symbol -- This only leaves ~. - Koz

-- | Compatibility method for the 'GeneralCategory' provided by 'Data.Char'.
--
-- >>> categorizeGeneral [char| ',' |]
-- OtherPunctuation
-- >>> categorizeGeneral [char| '~' |]
-- MathSymbol
-- >>> categorizeGeneral [char| 'w' |]
-- LowercaseLetter
-- >>> categorizeGeneral [char| '2' |]
-- DecimalNumber
-- >>> categorizeGeneral [char| '\0' |]
-- Control
--
-- @since 1.0.0
categorizeGeneral :: AsciiChar -> GeneralCategory
categorizeGeneral (AsciiChar w8) = generalCategory . chr . fromIntegral $ w8

-- | The case of an ASCII character (if it has one).
--
-- @since 1.0.0
newtype AsciiCase = AsciiCase Word8
  deriving (Eq, Ord, Hashable, NFData) via Word8

-- | @since 1.0.0
instance Show AsciiCase where
  {-# INLINEABLE show #-}
  show = \case
    Upper -> "Upper"
    Lower -> "Lower"

-- | @since 1.0.0
instance Bounded AsciiCase where
  minBound = Upper
  maxBound = Lower

-- | Indicator of an uppercase character.
--
-- @since 1.0.0
pattern Upper :: AsciiCase
pattern Upper <-
  AsciiCase 0
  where
    Upper = AsciiCase 0

-- | Indicator of a lowercase character.
--
-- @since 1.0.0
pattern Lower :: AsciiCase
pattern Lower <-
  AsciiCase 1
  where
    Lower = AsciiCase 1

{-# COMPLETE Upper, Lower #-}

-- | Determine the case of an 'AsciiChar'. Returns 'Nothing' if the character
-- doesn't have a case.
--
-- >>> caseOf [char| 'w' |]
-- Just Lower
-- >>> caseOf [char| 'W' |]
-- Just Upper
-- >>> caseOf [char| '~' |]
-- Nothing
--
-- @since 1.0.0
caseOf :: AsciiChar -> Maybe AsciiCase
caseOf c@(AsciiChar w8)
  | categorize c /= Letter = Nothing
  | w8 <= 0x5a = Just Upper
  | otherwise = Just Lower

-- Optics

-- | A representation of the relationship between 'Char' and 'AsciiChar'.
--
-- >>> preview charWise 'w'
-- Just '0x77'
-- >>> preview charWise '😺'
-- Nothing
-- >>> review charWise [char| 'w' |]
-- 'w'
--
-- @since 1.0.0
charWise :: Prism' Char AsciiChar
charWise = prism' (chr . fromIntegral . toByte) fromChar

-- | A representation of the relationship between ASCII characters and bytes.
--
-- >>> preview byteWise 0x20
-- Just '0x20'
-- >>> preview byteWise 0x81
-- Nothing
-- >>> review byteWise [char| 'w' |]
-- 119
--
-- @since 1.0.0
byteWise :: Prism' Word8 AsciiChar
byteWise = prism' toByte fromByte