packages feed

megaparsec-time-0.2.0.1: src/Text/Megaparsec/Time.hs

{-# LANGUAGE FlexibleContexts #-}

-- |
-- Module      : Text.Megaparsec.Time
-- Description : Various parsers for types related to time.
-- Copyright   : (c) drlkf, 2024
-- License     : GPL-3
-- Maintainer  : drlkf@drlkf.net
-- Stability   : experimental
--
-- Parsers for types related to time to use with 'megaparsec'.
module Text.Megaparsec.Time (
  -- * Types
  DayResult,

  -- * Simple parsers
  hoursParser,
  minutesParser,
  secondsParser,
  timeParser,
  dayParser,
  gregorianDayParser,

  -- * Composite parsers
  dateParser,
  durationParser,
) where

import Control.Applicative (optional, (<|>))
import Control.Monad (replicateM)
import Control.Monad.Combinators (choice, some)
import Data.Char (toLower)
import Data.Functor (($>))
import Data.Maybe (fromMaybe)
import Data.Time (
  Day,
  DayOfWeek (..),
  NominalDiffTime,
  TimeOfDay (..),
  defaultTimeLocale,
  makeTimeOfDayValid,
  parseTimeM,
  secondsToNominalDiffTime,
 )
import Text.Megaparsec (Parsec, takeRest, try)
import Text.Megaparsec.Char (
  char,
  digitChar,
  space,
  space1,
  string',
 )
import Text.Printf (printf)
import Text.Megaparsec.Utils (posNumParser)

-- | Representation of a parser result with either a number of days relative to
-- the current day, or a 'DayOfWeek'.
type DayResult = Either Int DayOfWeek

-- | Parse a tuple containing a day or not, and a 'TimeOfDay', separated with a
-- space.
dateParser
  :: Ord e
  => Parsec e String (Maybe DayResult, TimeOfDay)
dateParser = (,) <$> optional (try (dayParser <* space1)) <*> timeParser

-- | Parse a day using one of the following, all case-insensitive:
--
--   * a short (3-letters) or long day name e.g @mon@ or @monday@
--   * @yesterday@ or @tomorrow@
--   * a day number relative to the current day i.e @+2@ is two days from today
--   * an absolute number for a 'DayOfWeek', refer to its 'Num' instance for more information.
dayParser
  :: Ord e
  => Parsec e String DayResult
dayParser =
  choice
    [ Right <$> longDay
    , Right <$> shortDay
    , Left <$> (string' "yesterday" $> -1)
    , Left <$> (string' "tomorrow" $> 1)
    , Right <$> absoluteDay
    , Left <$> relativeDay
    ]
 where
  shortDay = choice $ map (ciString (fmap toLower . take 3 . show)) weekDays
  longDay = choice $ map (ciString (fmap toLower . show)) weekDays
  ciString f d = try (string' (f d)) $> d
  weekDays = [Monday .. Friday]
  sign = (char '-' $> negate) <|> (char '+' $> id)
  absoluteDay = toEnum . read <$> some digitChar
  relativeDay = ($) <$> sign <*> (read <$> some digitChar)

-- | Parse a 'NominalDiffTime' using strings like @1h23m45s@, with all
-- components being optional as long as one is present.
durationParser
  :: Ord e
  => Parsec e String NominalDiffTime
durationParser = try hours <|> try minutes <|> secondsParser
 where
  hours = do
    h <- hoursParser <* space
    m <- fromMaybe zero <$> optional (try minutes)
    s <- fromMaybe zero <$> optional secondsParser

    return (h + m + s)

  minutes = do
    m <- minutesParser <* space
    s <- fromMaybe zero <$> optional secondsParser

    return (m + s)

-- | Parse a Gregorian 'Day' from a @%F@ or @%d\/%m\/%Y@ format.
gregorianDayParser
  :: Ord e
  => Parsec e String Day
gregorianDayParser = do
  s <- takeRest

  let parseTime = flip (parseTimeM False defaultTimeLocale) s

  parseTime "%F" <|> parseTime "%d/%m/%Y"

-- | Parse a 'NominalDiffTime' from a number of hours from a string like @1h@.
hoursParser
  :: Ord e
  => Parsec e String NominalDiffTime
hoursParser = secondsToNominalDiffTime . (* 3600) <$> posNumParser <* char 'h'

-- | Parse a 'NominalDiffTime' from a number of minutes from a string like @1m@.
minutesParser
  :: Ord e
  => Parsec e String NominalDiffTime
minutesParser = secondsToNominalDiffTime . (* 60) <$> posNumParser <* char 'm'

-- | Parse a 'NominalDiffTime' from a number of seconds from a string like @1s@.
secondsParser
  :: Ord e
  => Parsec e String NominalDiffTime
secondsParser = secondsToNominalDiffTime <$> posNumParser <* optional (char 's')

-- | Parse a 'TimeOfDay' from a string like @01:23@.
timeParser
  :: Ord e
  => Parsec e String TimeOfDay
timeParser = do
  h <- read <$> replicateM 2 digitChar <* char ':'
  m <- read <$> replicateM 2 digitChar

  maybe
    (fail (printf "invalid hours or minutes %02d:%02d" h m))
    pure
    (makeTimeOfDayValid h m 0)

-- | Zero seconds in 'NominalDiffTime'.
zero :: NominalDiffTime
zero = secondsToNominalDiffTime 0