packages feed

fluent-1.1.0: src/Language/Fluent/Locale/English.hs

-- | This module provides a small English locale.
--
-- The locale is intended for tests and experimentation, not for production use.
--
-- == Supported
--
-- * Cardinal and ordinal plural rules.
-- * Decimal, percent, currency and unit numbers.
-- * Dates and times in UTC.
--
-- == Limitations
--
-- * No regional differences are supported.
--   Every language code with language @en@, such as @en-US@ and @en-GB@, gives the same output.
-- * With the currency display @symbol@, only currencies in 'currencies' have a symbol.
--   Any other currency is shown by its ISO 4217 code.
-- * The currency display @name@ is not supported. It shows the ISO 4217 code like @code@, so
--   @EUR@ gives @EUR 12.50@, not @12.50 euros@.
-- * Only units in 'units' are supported. Any other unit fails.
-- * The unit displays @narrow@ and @long@ are not supported. They show the same abbreviation as
--   @short@, so @long@ gives @3 s@, not @3 seconds@.
-- * Significant digits have no effect.
-- * Era and time zone name fields are left out of dates.
-- * Time zones other than UTC fail.
module Language.Fluent.Locale.English where

import Data.Char qualified as Char
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.List qualified as List
import Data.Maybe qualified as Maybe
import Data.Scientific qualified as Scientific
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time qualified as Time
import Language.Fluent.Locale (Locale (..))
import Language.Fluent.Number (NumberOptions (..))
import Language.Fluent.Number qualified as NumberOptions
import Language.Fluent.Plural qualified as Plural
import Language.Fluent.Time (TimeOptions (..))
import Language.Fluent.Time qualified as TimeOptions
import Language.Fluent.Width (Width (..))
import Prelude

-- | A small English locale.
--
-- See [the module documentation]("Language.Fluent.Locale.English") for the full list of limitations.
data English = English

-- |
-- ['fromCode']: Accepts any code with language "en", ignoring case:
--
--       * "en", \"EN\", "en-US", and "en_AU" all yield 'English'.
--       * "english" is rejected.
--
-- ['toCode']: Always yields "en".
--
-- ['displayLanguage']: Always yields \"English\".
--
-- ['pluralCategory']:
--
--       * Cardinal: 'Plural.One' for an absolute value of exactly 1 with zero fraction digits;
--                   'Plural.Other' for all other numbers.
--       * Ordinal: 'Plural.One' for numbers ending in 1;
--                  'Plural.Two' for numbers ending in 2;
--                  'Plural.Few' for numbers ending in 3;
--                  'Plural.Other' for all other integers, fractions, and values beyond 'Int'.
--
-- ['formatNumber']: Uses sign, prefix, digits, and suffix layout.
--
--       * Grouping uses commas by default unless disabled.
--       * Percent multiplies by 100 and appends a no-break space and "%".
--       * Currency defaults to ISO 4217 fractional digits, e.g., 0 for JPY.
--       * Units append a no-break space and abbreviation, pluralizing day, week, month, and year
--         with an "s" unless the category is "one".
--
-- ['formatTime']: Renders UTC date and time layouts:
--
--       * Fails if the time zone is anything other than UTC.
--       * Joins weekday, date, and time fields with ", ".
--       * Numeric months, e.g. "21\/08\/2026"
--       * Full month names, e.g. "21 August 2026"
instance Locale English where
    fromCode code
        | Text.toLower (Text.takeWhile Char.isAlpha code) == "en" = Just English
        | otherwise = Nothing

    toCode English = "en"

    displayLanguage English English = Just "English"

    capitalise English (Text.splitAt 1 -> (initial, rest)) = Text.toTitle initial <> rest

    pluralCategory English value options = case options.form of
        Plural.Cardinal
            | abs value == 1 && fst (NumberOptions.fractionDigits options) == 0 -> Plural.One
            | otherwise -> Plural.Other
        Plural.Ordinal -> case Scientific.toBoundedInteger @Int (abs value) of
            Just n | n `mod` 100 `notElem` [11 .. 13] -> case n `mod` 10 of
                1 -> Plural.One
                2 -> Plural.Two
                3 -> Plural.Few
                _ -> Plural.Other
            _ -> Plural.Other

    formatNumber _ value options = case options.style of
        NumberOptions.Decimal -> format "" ""
        NumberOptions.Percent -> format "" "\xA0%"
        NumberOptions.Currency (Text.toUpper -> code)
            | NumberOptions.Symbol <- options.currencyDisplay
            , Just symbol <- HashMap.lookup code currencies ->
                format symbol ""
            | otherwise -> format (code <> "\xA0") ""
        NumberOptions.Unit (NumberOptions.unitIdentifier -> unit) ->
            case HashMap.lookup unit units of
                Nothing -> Left $ "NUMBER: unknown unit: " <> Text.unpack unit
                Just symbol
                    | unit `elem` ["day", "week", "month", "year"]
                    , pluralCategory English value options /= Plural.One ->
                        format "" ("\xA0" <> symbol <> "s")
                    | otherwise -> format "" ("\xA0" <> symbol)
      where
        format before after = Right $ sign <> before <> digits <> after
        sign = if value < 0 then "-" else ""
        amount
            | options.style == NumberOptions.Percent = abs value * 100
            | otherwise = abs value
        (atLeast, atMost) = case options.style of
            NumberOptions.Currency (Text.toUpper -> code)
                | options.minimumFractionDigits == 0
                , Nothing <- options.maximumFractionDigits
                , let places = currencyDigits code ->
                    (places, places)
            _ -> NumberOptions.fractionDigits options
        (whole, fractional) =
            floor @_ @Integer (toRational amount * 10 ^ atMost + 1 / 2) `divMod` (10 ^ atMost)
        padded = Text.justifyRight (NumberOptions.integerDigits options) '0' . Text.show $ whole
        integer
            | options.useGrouping =
                Text.reverse . Text.intercalate "," . Text.chunksOf 3 . Text.reverse $ padded
            | otherwise = padded
        fraction =
            Text.justifyLeft atLeast '0'
                . Text.dropWhileEnd (== '0')
                . Text.justifyRight atMost '0'
                . Text.show
                $ fractional
        digits
            | Text.null fraction = integer
            | otherwise = integer <> "." <> fraction

    formatTime _ when options
        | options.timeZone /= "UTC" =
            Left $ "DATETIME: unsupported time zone: " <> Text.unpack options.timeZone
        | otherwise = Right . Text.pack $ Time.formatTime Time.defaultTimeLocale layout when
      where
        fields = TimeOptions.fields options
        textualMonth = case lookup 'M' fields of
            Just (Right _) -> True
            _ -> False
        layout =
            List.intercalate ", " . filter (not . null) $
                [ part "" "E"
                , part (if textualMonth then " " else "/") "dMy"
                , part ":" "hHjms" <> meridiem
                ]
        meridiem = case lookup 'h' fields of
            Just _ -> " %p"
            Nothing -> ""

        part separator symbols =
            List.intercalate separator $
                Maybe.mapMaybe (\symbol -> render symbol <$> lookup symbol fields) symbols

        render 'E' (Right Long) = "%A"
        render 'E' (Right _) = "%a"
        render 'M' (Right Long) = "%B"
        render 'M' (Right _) = "%b"
        render 'M' (Left _) = "%m"
        render 'd' _ = if textualMonth then "%-d" else "%d"
        render 'y' _ = "%Y"
        render 'h' _ = "%-I"
        render 'H' _ = "%H"
        render 'j' _ = "%H"
        render 'm' _ = "%M"
        render 's' _ = "%S"
        render _ _ = ""

-- | The abbreviations of units of measurement, in the singular, by their CLDR identifier.
units :: HashMap Text Text
units =
    HashMap.fromList . fmap (fmap (Text.drop 1) . Text.breakOn ",") . Text.lines $
        "millimeter,mm\n\
        \centimeter,cm\n\
        \meter,m\n\
        \kilometer,km\n\
        \inch,in\n\
        \foot,ft\n\
        \yard,yd\n\
        \mile,mi\n\
        \square-meter,m²\n\
        \square-kilometer,km²\n\
        \square-foot,sq ft\n\
        \square-mile,sq mi\n\
        \hectare,ha\n\
        \acre,ac\n\
        \milliliter,mL\n\
        \liter,L\n\
        \gallon,gal\n\
        \fluid-ounce,fl oz\n\
        \pint,pt\n\
        \quart,qt\n\
        \tablespoon,tbsp\n\
        \teaspoon,tsp\n\
        \milligram,mg\n\
        \gram,g\n\
        \kilogram,kg\n\
        \ounce,oz\n\
        \pound,lb\n\
        \stone,st\n\
        \metric-ton,t\n\
        \ton,tn\n\
        \millisecond,ms\n\
        \second,s\n\
        \minute,min\n\
        \hour,hr\n\
        \day,day\n\
        \week,wk\n\
        \month,mth\n\
        \year,yr\n\
        \kilometer-per-hour,km/h\n\
        \mile-per-hour,mph\n\
        \meter-per-second,m/s\n\
        \bit,bit\n\
        \byte,byte\n\
        \kilobyte,kB\n\
        \megabyte,MB\n\
        \gigabyte,GB\n\
        \terabyte,TB\n\
        \petabyte,PB\n\
        \watt,W\n\
        \kilowatt,kW\n\
        \megawatt,MW\n\
        \kilowatt-hour,kWh\n\
        \celsius,°C\n\
        \fahrenheit,°F\n\
        \kelvin,K\n\
        \volt,V\n\
        \ampere,A\n\
        \hertz,Hz\n\
        \kilohertz,kHz\n\
        \megahertz,MHz\n\
        \gigahertz,GHz"

-- | The symbols of currencies by their ISO 4217 code.
currencies :: HashMap Text Text
currencies =
    HashMap.fromList . fmap (fmap (Text.drop 1) . Text.breakOn ",") . Text.lines $
        "GBP,£\n\
        \EUR,€\n\
        \USD,$\n\
        \AUD,A$\n\
        \CAD,CA$\n\
        \JPY,¥\n\
        \CNY,CN¥\n\
        \KRW,₩\n\
        \INR,₹\n\
        \HKD,HK$\n\
        \NZD,NZ$\n\
        \BRL,R$\n\
        \MXN,MX$"

-- | The number of digits after the decimal point of an amount of a currency by its ISO 4217 code.
currencyDigits :: Text -> Int
currencyDigits code
    | code `elem` Text.words "BHD JOD KWD LYD OMR TND" = 3
    | code `elem` Text.words "CLF UYW" = 4
    | code `elem` Text.words zeroDigits = 0
    | otherwise = 2
  where
    zeroDigits :: Text
    zeroDigits =
        "ADP AFN ALL BIF BYR CLP COP DJF ESP GNF HUF IDR IQD IRR ISK ITL JPY KMF KPW KRW LAK LBP \
        \LUF MGA MGF MMK MRO PKR PYG RWF SLL SOS STD SYP TMM TRL UGX UYI VND VUV XAF XOF XPF YER \
        \ZMK ZWD"