packages feed

htsn-import-0.1.0: src/TSN/Parse.hs

module TSN.Parse (
  ParseError,
  format_parse_error,
  parse_game_id,
  parse_message,
  parse_schedule_id,
  parse_tests,
  parse_time_stamp,
  parse_xml_time_stamp,
  parse_xmlfid,
  time_format,
  time_stamp_format )
where

import Data.Either.Utils ( maybeToEither )
import Data.Time.Clock ( UTCTime )
import Data.Time.Format ( parseTime )
import System.Locale ( defaultTimeLocale )
import Test.Tasty ( TestTree, testGroup )
import Test.Tasty.HUnit ( (@?=), testCase )
import Text.Read ( readMaybe )
import Text.XML.HXT.Core (
  XmlTree,
  (>>>),
  (/>),
  getChildren,
  getText,
  hasName,
  runLA )

-- Local imports
import Xml ( unsafe_read_document )


-- | When parsing an element from an XML document (like the
--   XML_File_ID), there are a few things that can happen. First of
--   all, it can work. Good for you.
--
--   Or, you may find nothing. Like, the element is missing. We
--   represent that with a 'ParseNotFound' containing the name of
--   thing thing not-found as a 'String'.
--
--   Finally, you could find something, but be unable to interpret it
--   as the type you were expecting. For example, if you parse
--   \"WHATSUP\" out of a \<game_id\> which is supposed to contain
--   integers. We represent this case with a 'ParseMismatch'
--   containing the name of the thing that you were looking for, the
--   value that had the unexpected type, and finally the name of the
--   expected type (used in error messages).
--
data ParseError =
  ParseNotFound String | ParseMismatch String String String
  deriving (Eq, Show)


-- | Take a 'ParseError' and turn it into a human-readable description
--   of the problem.
--
format_parse_error :: ParseError -> String
format_parse_error (ParseNotFound item) =
  "No " ++ item ++ " elements found."
format_parse_error (ParseMismatch item val expected_type) =
  "Could not parse " ++ item ++ " " ++ val ++ " as " ++ expected_type ++ "."



-- | Parse the \"message\" element out of a document tree and return
--   it as an 'XmlTree'. We use an 'Either' for consistency.
--
parse_message :: XmlTree -> Either ParseError XmlTree
parse_message xmltree =
  case elements of
    []    -> Left $ ParseNotFound "message"
    (x:_) -> Right x
  where
    parse :: XmlTree -> [XmlTree]
    parse = runLA $ hasName "/" /> hasName "message"

    elements = parse xmltree





-- | Parse an 'Int' from a direct descendent of the (top-level)
--   \<message\> element in an XmlTree. This is used to implement the
--   XML_File_ID, game_id, and schedule_id (the last two are specific
--   to "TSN.XML.GameInfo") parsers.
--
--   If the parse fails, we return the corresponding 'ParseError'
--   wrapped in a 'Left'. Otherwise the parsed value is returned in a
--   'Right'.
--
parse_message_int :: String -> XmlTree -> Either ParseError Int
parse_message_int child xmltree =
  case parse_results of
    []    -> Left $ ParseNotFound child
    (x:_) -> x
  where
    parse :: XmlTree -> [String]
    parse = runLA $ hasName "/"
                      /> hasName "message"
                      /> hasName child
                      >>> getChildren
                      >>> getText

    read_either_int :: String -> Either ParseError Int
    read_either_int s =
      maybeToEither (ParseMismatch child s "integer") (readMaybe s)

    elements = parse xmltree
    parse_results = map read_either_int elements



-- | Parse an optional 'Int' from a direct descendent of the
--   (top-level) \<message\> element in an XmlTree. This is just like
--   'parse_message_int', except we expect the element/value to be
--   missing sometimes.
--
--   To handle the fact that the element/value is optional, we pattern
--   match on the 'ParseError' that comes back in case of failure. If
--   we didn't find anything, we turn that into a \"successful
--   nothing\". But if we find a value and it can't be parsed, we let
--   the error propagate, because that shouldn't happen. Of course, if
--   the parse worked, that's nice too: we wrap the parsed value in a
--   'Just' and return that wrapped in a 'Right'
--
parse_message_int_optional :: String
                           -> XmlTree
                           -> Either ParseError (Maybe Int)
parse_message_int_optional child xmltree =
  case (parse_message_int child xmltree) of
    Left (ParseNotFound _)     -> Right Nothing
    Left pm@(ParseMismatch {}) -> Left pm
    Right whatever             -> Right (Just whatever)



-- | Extract the \"XML_File_ID\" element from a document. If we fail
--   to parse an XML_File_ID, we return an appropriate 'ParseError'
--   wrapped in a 'Left' constructor. The reason should be one of two
--   things:
--
--     1. No XML_File_ID elements were found.
--
--     2. An XML_File_ID element was found, but it could not be read
--        into an Int.
--
--   In general we expect some non-integer XML_File_IDs, because they
--   appear on the feed. But the htsn daemon refuses to save them at
--   the moment, so if we ever see an XML_File_ID that we can't parse,
--   it's truly an error.
--
parse_xmlfid :: XmlTree -> Either ParseError Int
parse_xmlfid = parse_message_int "XML_File_ID"



-- | Extract the \<game_id\> element from within the top-level
--   \<message\> of a document. These appear in the "TSN.XML.GameInfo"
--   documents. Unlike the \<XML_File_ID\> elements, the \<game_id\>
--   can be missing from GameInfo documents, so for our implementation
--   we use 'parse_message_int_optional' instead.
--
parse_game_id :: XmlTree -> Either ParseError (Maybe Int)
parse_game_id = parse_message_int_optional "game_id"



-- | Extract the \<schedule_id\> element from within the top-level
--   \<message\> of a document. Identical to 'parse_game_id' except
--   for the element name.
--
parse_schedule_id :: XmlTree -> Either ParseError (Maybe Int)
parse_schedule_id = parse_message_int_optional "schedule_id"



-- | The format string for times appearing in the feed.
--
time_format :: String
time_format = "%I:%M %p"



-- | The format string for a time_stamp. We keep the leading/trailing
--   space so that parseTime and formatTime are inverses are one
--   another, even though there is some confusion as to how these two
--   functions should behave:
--
--   <https://ghc.haskell.org/trac/ghc/ticket/9150>
--
time_stamp_format :: String
time_stamp_format = " %B %-d, %Y, at " ++ time_format ++ " ET "



-- | Parse a time stamp from a 'String' (maybe). TSN doesn't provide a
--   proper time zone name, so we parse it as UTC, and maybe our
--   eventual consumer can figure out a way to deduce the time zone.
--
parse_time_stamp :: String -> Maybe UTCTime
parse_time_stamp =
  parseTime defaultTimeLocale time_stamp_format



-- | Extract the \"time_stamp\" element from a document. If we fail to
--   parse a time_stamp, we return an appropriate 'ParseError'. The
--   reason should be one of two things:
--
--     1. No time_Stamp elements were found.
--
--     2. A time_stamp element was found, but it could not be read
--        into a UTCTime.
--
--   We don't expect to run into any time_stamps that we can't parse,
--   and they can never be missing, so both conditions are truly
--   errors.
--
parse_xml_time_stamp :: XmlTree -> Either ParseError UTCTime
parse_xml_time_stamp xmltree =
  case parse_results of
    []    -> Left $ ParseNotFound "time_stamp"
    (x:_) -> x
  where
    parse :: XmlTree -> [String]
    parse = runLA $ hasName "/"
                      /> hasName "message"
                      /> hasName "time_stamp"
                      >>> getChildren
                      >>> getText

    read_either_utctime :: String -> Either ParseError UTCTime
    read_either_utctime s =
      maybeToEither (ParseMismatch "time_stamp" s "date/time")
                    (parse_time_stamp s)

    elements = parse xmltree
    parse_results = map read_either_utctime elements



--
-- * Tests
--

-- | A list of all tests for this module.
--
parse_tests :: TestTree
parse_tests =
  testGroup
    "TSN.Parse tests"
    [ test_parse_game_id,
      test_parse_missing_game_id,
      test_parse_missing_schedule_id,
      test_parse_schedule_id,
      test_parse_xmlfid ]
  where
    sample_path :: String
    sample_path = "test/xml/gameinfo/CBASK_Lineup_XML.xml"

    desc :: String -> String
    desc child = "a known " ++ child ++ " is parsed correctly"


    -- | Actual implementation of the test for parse_xmlfid,
    --   parse_game_id, and parse_schedule_id.
    --
    test_child :: String -> Int -> TestTree
    test_child child expected =
      testCase (desc child) $ do
        xmltree <- unsafe_read_document sample_path
        let actual = parse_message_int child xmltree
        actual @?= (Right expected)


    -- | Make sure we can parse a game_id into the expected value.
    --
    test_parse_game_id :: TestTree
    test_parse_game_id = test_child "game_id" 97865


    -- | Make sure we can parse a schedule_id (different from the
    --   game_id) into the expected value.
    --
    test_parse_schedule_id :: TestTree
    test_parse_schedule_id = test_child "schedule_id" 10199


    -- | Make sure we can parse an XML_File_ID into the expected value.
    --
    test_parse_xmlfid :: TestTree
    test_parse_xmlfid = test_child "XML_File_ID" 17



-- | The game_id element can be missing, so we test that too.
--
test_parse_missing_game_id :: TestTree
test_parse_missing_game_id =
  testCase "missing game_id is not an error" $ do
    xmltree <- unsafe_read_document "test/xml/gameinfo/MLB_Matchup_XML.xml"
    let actual = parse_game_id xmltree
    let expected = Right Nothing
    actual @?= expected


-- | The schedule_id element can be missing, so we test that too.
--
test_parse_missing_schedule_id :: TestTree
test_parse_missing_schedule_id =
  testCase "missing schedule_id is not an error" $ do
    let path = "test/xml/gameinfo/recapxml-no-game-schedule-ids.xml"
    xmltree <- unsafe_read_document path
    let actual = parse_schedule_id xmltree
    let expected = Right Nothing
    actual @?= expected