formatting: %Ez and %EZ for ±HH:MM format (#52)
authorAshley Yakeley <ashley@semantic.org>
Thu, 11 Jan 2018 02:27:02 +0000 (18:27 -0800)
committerAshley Yakeley <ashley@semantic.org>
Thu, 11 Jan 2018 02:27:02 +0000 (18:27 -0800)
changelog.md
lib/Data/Time/Format.hs
lib/Data/Time/Format/Parse.hs
lib/Data/Time/LocalTime/Internal/TimeZone.hs
test/main/Test/Format/Format.hs

index 880c843..860b1b2 100644 (file)
@@ -2,6 +2,7 @@
 
 ## [1.9]
 - new Data.Week module with DayOfWeek type
+- formatting: %Ez and %EZ for ±HH:MM format
 - parseTimeM: use MonadFail constraint when supported
 - parsing: reject invalid (and empty) time-zones with %z and %Z
 - parsing: reject invalid hour/minute/second specifiers
index 2658e8d..14f0256 100644 (file)
@@ -47,30 +47,37 @@ getPadOption trunc fdef idef cdef mnpad mi = let
             Just (Just _) -> True
     in if f then Pad i c else NoPad
 
-padGeneral :: Bool -> Bool -> Int -> Char -> (TimeLocale -> PadOption -> t -> String) -> (TimeLocale -> Maybe NumericPadOption -> Maybe Int -> t -> String)
-padGeneral trunc fdef idef cdef ff locale mnpad mi = ff locale $ getPadOption trunc fdef idef cdef mnpad mi
+data FormatOptions = MkFormatOptions {
+    foLocale :: TimeLocale,
+    foPadding :: Maybe NumericPadOption,
+    foWidth :: Maybe Int,
+    foAlternate :: Bool
+}
 
-padString :: (TimeLocale -> t -> String) -> (TimeLocale -> Maybe NumericPadOption -> Maybe Int -> t -> String)
+padGeneral :: Bool -> Bool -> Int -> Char -> (TimeLocale -> PadOption -> t -> String) -> (FormatOptions -> t -> String)
+padGeneral trunc fdef idef cdef ff fo = ff (foLocale fo) $ getPadOption trunc fdef idef cdef (foPadding fo) (foWidth fo)
+
+padString :: (TimeLocale -> t -> String) -> (FormatOptions -> t -> String)
 padString ff = padGeneral False False 1 ' ' $ \locale pado -> showPadded pado . ff locale
 
-padNum :: (ShowPadded i) => Bool -> Int -> Char -> (t -> i) -> (TimeLocale -> Maybe NumericPadOption -> Maybe Int -> t -> String)
+padNum :: (ShowPadded i) => Bool -> Int -> Char -> (t -> i) -> (FormatOptions -> t -> String)
 padNum fdef idef cdef ff = padGeneral False fdef idef cdef $ \_ pado -> showPaddedNum pado . ff
 
 -- <http://www.opengroup.org/onlinepubs/007908799/xsh/strftime.html>
 class FormatTime t where
-    formatCharacter :: Char -> Maybe (TimeLocale -> Maybe NumericPadOption -> Maybe Int -> t -> String)
+    formatCharacter :: Char -> Maybe (FormatOptions -> t -> String)
 
-formatChar :: (FormatTime t) => Char -> TimeLocale -> Maybe NumericPadOption -> Maybe Int -> t -> String
+formatChar :: (FormatTime t) => Char -> FormatOptions -> t -> String
 formatChar '%' = padString $ \_ _ -> "%"
 formatChar 't' = padString $ \_ _ -> "\t"
 formatChar 'n' = padString $ \_ _ -> "\n"
 formatChar c = case formatCharacter c of
     Just f -> f
-    _ -> \_ _ _ _ -> ""
+    _ -> \_ _ -> ""
 
 -- | Substitute various time-related information for each %-code in the string, as per 'formatCharacter'.
 --
--- The general form is @%\<modifier\>\<width\>\<specifier\>@, where @\<modifier\>@ and @\<width\>@ are optional.
+-- The general form is @%\<modifier\>\<width\>\<alternate\>\<specifier\>@, where @\<modifier\>@, @\<width\>@, and @\<alternate\>@ are optional.
 --
 -- == @\<modifier\>@
 -- glibc-style modifiers can be used before the specifier (here marked as @z@):
@@ -92,6 +99,11 @@ formatChar c = case formatCharacter c of
 --
 -- [@%_12z@] pad with spaces to 12 characters
 --
+-- == @\<alternate\>@
+-- An optional @E@ character indicates an alternate formatting. Currently this only affects @%Z@ and @%z@.
+--
+-- [@%Ez@] alternate formatting
+--
 -- == @\<specifier\>@
 --
 -- For all types (note these three are done by 'formatTime', not by 'formatCharacter'):
@@ -105,9 +117,13 @@ formatChar c = case formatCharacter c of
 -- === 'TimeZone'
 -- For 'TimeZone' (and 'ZonedTime' and 'UTCTime'):
 --
--- [@%z@] timezone offset in the format @-HHMM@.
+-- [@%z@] timezone offset in the format @±HHMM@
 --
--- [@%Z@] timezone name
+-- [@%Ez@] timezone offset in the format @±HH:MM@
+--
+-- [@%Z@] timezone name (or else offset in the format @±HHMM@)
+--
+-- [@%EZ@] timezone name (or else offset in the format @±HH:MM@)
 --
 -- === 'LocalTime'
 -- For 'LocalTime' (and 'ZonedTime' and 'UTCTime' and 'UniversalTime'):
@@ -231,15 +247,19 @@ formatTime2 locale recase mpad cs t = let
     in formatTime3 locale recase mpad mwidth rest t
 
 formatTime3 :: (FormatTime t) => TimeLocale -> (String -> String) -> Maybe NumericPadOption -> Maybe Int -> String -> t -> Maybe String
-formatTime3 locale recase mpad mwidth (c:cs) t = Just $ (recase (formatChar c locale mpad mwidth t)) ++ (formatTime locale cs t)
-formatTime3 _locale _recase _mpad _mwidth [] _t = Nothing
+formatTime3 locale recase mpad mwidth ('E':cs) = formatTime4 recase (MkFormatOptions locale mpad mwidth True) cs
+formatTime3 locale recase mpad mwidth cs = formatTime4 recase (MkFormatOptions locale mpad mwidth False) cs
+
+formatTime4 :: (FormatTime t) => (String -> String) -> FormatOptions -> String -> t -> Maybe String
+formatTime4 recase fo (c:cs) t = Just $ (recase (formatChar c fo t)) ++ (formatTime (foLocale fo) cs t)
+formatTime4 _recase _fo [] _t = Nothing
 
 instance FormatTime LocalTime where
-    formatCharacter 'c' = Just $ \locale _ _ -> formatTime locale (dateTimeFmt locale)
+    formatCharacter 'c' = Just $ \fo -> formatTime (foLocale fo) $ dateTimeFmt $ foLocale fo
     formatCharacter c = case formatCharacter c of
-        Just f -> Just $ \locale mpado mwidth dt -> f locale mpado mwidth (localDay dt)
+        Just f -> Just $ \fo dt -> f fo (localDay dt)
         Nothing -> case formatCharacter c of
-            Just f -> Just $ \locale mpado mwidth dt -> f locale mpado mwidth (localTimeOfDay dt)
+            Just f -> Just $ \fo dt -> f fo (localTimeOfDay dt)
             Nothing -> Nothing
 
 todAMPM :: TimeLocale -> TimeOfDay -> String
@@ -290,16 +310,19 @@ instance FormatTime ZonedTime where
     formatCharacter 'c' = Just $ padString $ \locale -> formatTime locale (dateTimeFmt locale)
     formatCharacter 's' = Just $ padNum True  1 '0' $ (floor . utcTimeToPOSIXSeconds . zonedTimeToUTC :: ZonedTime -> Integer)
     formatCharacter c = case formatCharacter c of
-        Just f -> Just $ \locale mpado mwidth dt -> f locale mpado mwidth (zonedTimeToLocalTime dt)
+        Just f -> Just $ \fo dt -> f fo (zonedTimeToLocalTime dt)
         Nothing -> case formatCharacter c of
-            Just f -> Just $ \locale mpado mwidth dt -> f locale mpado mwidth (zonedTimeZone dt)
+            Just f -> Just $ \fo dt -> f fo (zonedTimeZone dt)
             Nothing -> Nothing
 
 instance FormatTime TimeZone where
-    formatCharacter 'z' = Just $ padGeneral False True  4 '0' $ \_ pado -> showPadded pado . timeZoneOffsetString'' pado
-    formatCharacter 'Z' = Just $ \locale mnpo mi z -> let
+    formatCharacter 'z' = Just $ \fo z -> let
+        alt = foAlternate fo
+        in timeZoneOffsetString'' alt (getPadOption False True (if alt then 5 else 4) '0' (foPadding fo) (foWidth fo)) z
+    formatCharacter 'Z' = Just $ \fo z -> let
         n = timeZoneName z
-        in if null n then timeZoneOffsetString'' (getPadOption False True 4 '0' mnpo mi) z else padString (\_ -> timeZoneName) locale mnpo mi z
+        alt = foAlternate fo
+        in if null n then timeZoneOffsetString'' alt (getPadOption False True (if alt then 5 else 4) '0' (foPadding fo) (foWidth fo)) z else padString (\_ -> timeZoneName) fo z
     formatCharacter _ = Nothing
 
 instance FormatTime DayOfWeek where
@@ -349,7 +372,7 @@ instance FormatTime Day where
     formatCharacter _   = Nothing
 
 instance FormatTime UTCTime where
-    formatCharacter c = fmap (\f locale mpado mwidth t -> f locale mpado mwidth (utcToZonedTime utc t)) (formatCharacter c)
+    formatCharacter c = fmap (\f fo t -> f fo (utcToZonedTime utc t)) (formatCharacter c)
 
 instance FormatTime UniversalTime where
-    formatCharacter c = fmap (\f locale mpado mwidth t -> f locale mpado mwidth (ut1ToLocalTime 0 t)) (formatCharacter c)
+    formatCharacter c = fmap (\f fo t -> f fo (ut1ToLocalTime 0 t)) (formatCharacter c)
index 0b9d1be..207e8d4 100644 (file)
@@ -84,7 +84,7 @@ class ParseTime t where
 -- Case is not significant in the input string.
 -- Some variations in the input are accepted:
 --
--- [@%z@] accepts any of @-HHMM@ or @-HH:MM@.
+-- [@%z@] accepts any of @±HHMM@ or @±HH:MM@.
 --
 -- [@%Z@] accepts any string of letters, or any of the formats accepted by @%z@.
 --
index e2b2f49..6fe6dad 100644 (file)
@@ -57,21 +57,26 @@ minutesToTimeZone m = TimeZone m False ""
 hoursToTimeZone :: Int -> TimeZone
 hoursToTimeZone i = minutesToTimeZone (60 * i)
 
-showT :: PadOption -> Int -> String
-showT opt t = showPaddedNum opt ((div t 60) * 100 + (mod t 60))
-
-timeZoneOffsetString'' :: PadOption -> TimeZone -> String
-timeZoneOffsetString'' opt (TimeZone t _ _) | t < 0 = '-':(showT opt (negate t))
-timeZoneOffsetString'' opt (TimeZone t _ _) = '+':(showT opt t)
+showT :: Bool -> PadOption -> Int -> String
+showT False opt t = showPaddedNum opt ((div t 60) * 100 + (mod t 60))
+showT True opt t = let
+    opt' = case opt of
+        NoPad -> NoPad
+        Pad i c -> Pad (max 0 $ i - 3) c
+    in showPaddedNum opt' (div t 60) ++ ":" ++ show2 (mod t 60)
+
+timeZoneOffsetString'' :: Bool -> PadOption -> TimeZone -> String
+timeZoneOffsetString'' colon opt (TimeZone t _ _) | t < 0 = '-':(showT colon opt (negate t))
+timeZoneOffsetString'' colon opt (TimeZone t _ _) = '+':(showT colon opt t)
 
 -- | Text representing the offset of this timezone, such as \"-0800\" or \"+0400\" (like @%z@ in formatTime), with arbitrary padding.
 timeZoneOffsetString' :: Maybe Char -> TimeZone -> String
-timeZoneOffsetString' Nothing = timeZoneOffsetString'' NoPad
-timeZoneOffsetString' (Just c) = timeZoneOffsetString'' $ Pad 4 c
+timeZoneOffsetString' Nothing = timeZoneOffsetString'' False NoPad
+timeZoneOffsetString' (Just c) = timeZoneOffsetString'' False $ Pad 4 c
 
 -- | Text representing the offset of this timezone, such as \"-0800\" or \"+0400\" (like @%z@ in formatTime).
 timeZoneOffsetString :: TimeZone -> String
-timeZoneOffsetString = timeZoneOffsetString'' (Pad 4 '0')
+timeZoneOffsetString = timeZoneOffsetString'' False (Pad 4 '0')
 
 instance Show TimeZone where
     show zone@(TimeZone _ _ "") = timeZoneOffsetString zone
index 36c918d..a17f5af 100644 (file)
@@ -57,8 +57,46 @@ testDayOfWeek  = testGroup "DayOfWeek" $ tgroup "uwaA" $ \fmt -> tgroup days $ \
     dowFormat = formatTime defaultTimeLocale ['%',fmt] $ dayOfWeek day
     in assertEqual "" dayFormat dowFormat
 
+testZone :: String -> String -> Int -> TestTree
+testZone fmt expected minutes = testCase (show fmt) $ assertEqual "" expected $ formatTime defaultTimeLocale fmt $ TimeZone minutes False ""
+
+testZonePair :: String -> String -> Int -> TestTree
+testZonePair mods expected minutes = testGroup (show mods ++ " " ++ show minutes)
+    [
+        testZone ("%" ++ mods ++ "z") expected minutes,
+        testZone ("%" ++ mods ++ "Z") expected minutes
+    ]
+
+testTimeZone :: TestTree
+testTimeZone = testGroup "TimeZone"
+    [
+    testZonePair "" "+0000" 0,
+    testZonePair "E" "+00:00" 0,
+    testZonePair "" "+0500" 300,
+    testZonePair "E" "+05:00" 300,
+    testZonePair "3" "+0500" 300,
+    testZonePair "4E" "+05:00" 300,
+    testZonePair "4" "+0500" 300,
+    testZonePair "5E" "+05:00" 300,
+    testZonePair "5" "+00500" 300,
+    testZonePair "6E" "+005:00" 300,
+    testZonePair "" "-0700" (-420),
+    testZonePair "E" "-07:00" (-420),
+    testZonePair "" "+1015" 615,
+    testZonePair "E" "+10:15" 615,
+    testZonePair "3" "+1015" 615,
+    testZonePair "4E" "+10:15" 615,
+    testZonePair "4" "+1015" 615,
+    testZonePair "5E" "+10:15" 615,
+    testZonePair "5" "+01015" 615,
+    testZonePair "6E" "+010:15" 615,
+    testZonePair "" "-1130" (-690),
+    testZonePair "E" "-11:30" (-690)
+    ]
+
 testFormat :: TestTree
 testFormat = testGroup "testFormat" $ [
     testCheckParse,
-    testDayOfWeek
+    testDayOfWeek,
+    testTimeZone
     ]