packages feed

yamlet-1.0.0.0: tests/Yamlet/Test/YamlTestSuite.hs

-- | The official YAML test suite, https://github.com/yaml/yaml-test-suite.
module Yamlet.Test.YamlTestSuite (testSuiteTests) where

import Control.Applicative
import Control.Monad
import Data.Aeson qualified as J
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Parser qualified as J
import Data.Attoparsec.ByteString.Char8 qualified as A
import Data.ByteString qualified as BS
import Data.List qualified as L
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as M
import Data.Maybe
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Vector qualified as V
import System.Directory
import System.Environment
import System.FilePath
import Test.Tasty
import Test.Tasty.HUnit

import Yamlet qualified as Y
import Yamlet.Error
import Yamlet.Syntax
import Yamlet.Test.YamlTestSuite.Events

-- | The tests of the suite. The directory with the data branch of the
-- repository is in @YAML_TEST_SUITE@, or in
-- @tests/fixtures/yaml-test-suite@.
testSuiteTests :: IO TestTree
testSuiteTests = do
  dir <- fromMaybe "tests/fixtures/yaml-test-suite" <$> lookupEnv "YAML_TEST_SUITE"
  exists <- doesDirectoryExist dir
  if not exists
    then
      pure . testCase "yaml-test-suite" $
        assertFailure
          "The test suite is missing, run scripts/fetch-test-suite.sh or set YAML_TEST_SUITE"
    else do
      paths <- findCases dir
      pure . testGroup "yaml-test-suite" $
        testCase "error messages" (checkErrorMessages dir paths)
          : [testCase (makeRelative dir path) (runTest path) | path <- paths]

-- | The directories of the test cases, in order.
findCases :: FilePath -> IO [FilePath]
findCases dir = do
  -- The name and tags directories link to the tests by other names.
  entries <- L.sort . filter (`notElem` ["name", "tags"]) <$> listDirectory dir
  fmap concat . forM entries $ \entry -> do
    let path = dir </> entry
    isDir <- doesDirectoryExist path
    hasInput <- doesFileExist (path </> "in.yaml")
    if
      | isDir && hasInput -> pure [path]
      | isDir -> findCases path
      | otherwise -> pure []

-- | The error messages for the invalid inputs match the file
-- @tests/fixtures/error-messages.txt@. The messages come from heuristics
-- that look at the input around an error, so a change in one can change
-- others. If @YAMLET_ACCEPT_ERRORS@ is set, the test writes the file
-- instead.
checkErrorMessages :: FilePath -> [FilePath] -> Assertion
checkErrorMessages root paths = do
  actual <- fmap (unlines . concat) . forM paths $ \path -> do
    (name, input, isError) <- readCase path
    if not isError
      then pure []
      else do
        let message = case parseDocumentsText input of
              Left err ->
                show err.location.line
                  ++ ":"
                  ++ show err.location.column
                  ++ ": "
                  ++ err.message
              Right _ -> "no error"
        pure ["# " ++ makeRelative root path ++ ": " ++ T.unpack name, message]
  accept <- lookupEnv "YAMLET_ACCEPT_ERRORS"
  case accept of
    Just _ -> writeFile file actual
    Nothing -> do
      expected <- readFile file
      let changes =
            [ header ++ "\n- " ++ old ++ "\n+ " ++ new
            | ((header, old), (_, new)) <- zip (entries expected) (entries actual)
            , old /= new
            ]
          preface =
            "the error messages differ from "
              ++ file
              ++ ", set YAMLET_ACCEPT_ERRORS to update it"
      when (length (entries expected) /= length (entries actual)) $
        assertFailure (preface ++ ": the number of invalid inputs changed")
      unless (null changes) $ assertFailure (preface ++ ":\n" ++ unlines changes)
  where
    file :: FilePath
    file = "tests/fixtures/error-messages.txt"

    -- The pairs of a case header and its message.
    entries :: String -> [(String, String)]
    entries s = pairs (lines s)
      where
        pairs :: [String] -> [(String, String)]
        pairs = \case
          header : message : rest -> (header, message) : pairs rest
          _ -> []

-- | The name of a case, its input and whether the input is invalid.
readCase :: FilePath -> IO (T.Text, T.Text, Bool)
readCase path = do
  name <- T.strip . T.decodeUtf8 <$> BS.readFile (path </> "===")
  input <- T.decodeUtf8 <$> BS.readFile (path </> "in.yaml")
  isError <- doesFileExist (path </> "error")
  pure (name, input, isError)

runTest :: FilePath -> Assertion
runTest path = do
  (name, input, isError) <- readCase path
  let preface = T.unpack name ++ "\n" ++ T.unpack input
  case parseDocumentsText input of
    Left err
      | isError -> pure ()
      | otherwise ->
          assertFailure $ preface ++ "\nunexpected error: " ++ prettyError "in.yaml" err
    Right docs
      | isError ->
          assertFailure $
            preface
              ++ "\nexpected an error, got:\n"
              ++ unlines (map renderEvent (toEvents docs))
      | otherwise -> do
          expected <-
            lines . T.unpack . T.decodeUtf8 <$> BS.readFile (path </> "test.event")
          assertEqual
            preface
            expected
            (map renderEvent (toEvents docs))
          let styles =
                [ ("rendered", defaultRenderOptions)
                , ("rendered in block style", defaultRenderOptions {forceBlock = True})
                ]
          forM_ styles $ \(label, options) -> do
            let out = renderSyntax options docs
            case parseDocumentsText out of
              Left err ->
                assertFailure $
                  preface
                    ++ "\n"
                    ++ label
                    ++ ":\n"
                    ++ T.unpack out
                    ++ "\nerror: "
                    ++ prettyError "out.yaml" err
              Right docs' -> do
                assertEqual
                  (preface ++ "\n" ++ label ++ ":\n" ++ T.unpack out)
                  (rendered (toEvents docs))
                  (rendered (toEvents docs'))
                assertEqual
                  (preface ++ "\n" ++ label ++ " again")
                  out
                  (renderSyntax options docs')
          hasJson <- doesFileExist (path </> "in.json")
          case Y.decodeAllText @Y.Value input of
            Left err
              -- The decoder rejects duplicate keys, which the syntax allows.
              | not hasJson && "duplicate key" `L.isPrefixOf` (NE.head err).message ->
                  pure ()
              | otherwise ->
                  assertFailure $
                    preface
                      ++ "\nunexpected error: "
                      ++ prettyError "in.yaml" (NE.head err)
            Right nodes -> do
              when hasJson $ do
                json <- BS.readFile (path </> "in.json")
                expectedValues <- case A.parseOnly jsonValues json of
                  Right vs -> pure vs
                  Left err -> assertFailure $ "invalid in.json: " ++ err
                assertEqual
                  (preface ++ "\nvalues")
                  expectedValues
                  (map toJson nodes)
              let encoded = Y.encodeAllText nodes
              case Y.decodeAllText @Y.Value encoded of
                Left err ->
                  assertFailure $
                    preface
                      ++ "\nencoded:\n"
                      ++ T.unpack encoded
                      ++ "\nerror: "
                      ++ prettyError "out.yaml" (NE.head err)
                Right nodes' ->
                  assertEqual
                    (preface ++ "\nencoded:\n" ++ T.unpack encoded)
                    nodes
                    nodes'
  where
    jsonValues :: A.Parser [J.Value]
    jsonValues = many (A.skipSpace *> J.json') <* A.skipSpace <* A.endOfInput

    -- The JSON form of a value. The keys of the mappings in the tests with
    -- JSON are strings.
    toJson :: Y.Value -> J.Value
    toJson = \case
      Y.Null -> J.Null
      Y.Bool b -> J.Bool b
      Y.Int i -> J.Number (fromInteger i)
      Y.Float (Y.Finite s) -> J.Number s
      Y.Float _ -> J.Null
      Y.String t -> J.String t
      Y.Sequence xs -> J.Array . V.fromList $ map toJson xs
      Y.Mapping kvs -> J.Object $ KM.fromList [(key k, toJson v) | (k, v) <- kvs]
      Y.Tagged _ v -> toJson v
      where
        key :: Y.Value -> K.Key
        key = \case
          Y.String t -> K.fromText t
          Y.Null -> K.fromText ""
          Y.Tagged _ v -> key v
          v -> K.fromString (show v)

    -- An event in the format of the test suite.
    renderEvent :: Event -> String
    renderEvent = \case
      StreamStart -> "+STR"
      StreamEnd -> "-STR"
      DocumentStart explicit -> "+DOC" ++ if explicit then " ---" else ""
      DocumentEnd explicit -> "-DOC" ++ if explicit then " ..." else ""
      SequenceStart props style -> "+SEQ" ++ flow style "[]" ++ renderProps props
      SequenceEnd -> "-SEQ"
      MappingStart props style -> "+MAP" ++ flow style "{}" ++ renderProps props
      MappingEnd -> "-MAP"
      ScalarEvent props style t ->
        "=VAL" ++ renderProps props ++ " " ++ styleChar style : escape (T.unpack t)
      AliasEvent name -> "=ALI *" ++ T.unpack name
      where
        flow :: CollectionStyle -> String -> String
        flow style s = case style of
          Flow -> ' ' : s
          Block -> ""

        renderProps :: Props -> String
        renderProps props =
          maybe "" (\a -> " &" ++ T.unpack a) props.anchor ++ case props.tag of
            NoTag -> ""
            NonSpecificTag -> " <!>"
            Tag t -> " <" ++ T.unpack t ++ ">"

        styleChar :: ScalarStyle -> Char
        styleChar = \case
          Plain -> ':'
          SingleQuoted -> '\''
          DoubleQuoted -> '"'
          Literal -> '|'
          Folded -> '>'

        escape :: String -> String
        escape = concatMap $ \case
          '\\' -> "\\\\"
          '\n' -> "\\n"
          '\t' -> "\\t"
          '\b' -> "\\b"
          '\r' -> "\\r"
          c -> [c]

-- | The events that the renderer keeps. It writes a start marker for every
-- document after the first one. It can rename an anchor, so a name becomes
-- the number of the names before its first use in the document.
rendered :: [Event] -> [Event]
rendered = go M.empty
  where
    go :: M.Map T.Text Int -> [Event] -> [Event]
    go names = \case
      DocumentEnd explicit : DocumentStart _ : rest ->
        DocumentEnd explicit : DocumentStart True : go M.empty rest
      e : rest ->
        let (names', e') = numbered names (withoutStyle e)
        in e' : go names' rest
      [] -> []

    numbered :: M.Map T.Text Int -> Event -> (M.Map T.Text Int, Event)
    numbered names = \case
      SequenceStart props style -> (`SequenceStart` style) <$> numberedProps props
      MappingStart props style -> (`MappingStart` style) <$> numberedProps props
      ScalarEvent props style t -> (\p -> ScalarEvent p style t) <$> numberedProps props
      AliasEvent a -> AliasEvent <$> number a
      e -> (names, e)
      where
        numberedProps :: Props -> (M.Map T.Text Int, Props)
        numberedProps props = case props.anchor of
          Just a -> (\n -> props {anchor = Just n}) <$> number a
          Nothing -> (names, props)

        number :: T.Text -> (M.Map T.Text Int, T.Text)
        number a = case M.lookup a names of
          Just i -> (names, T.pack (show i))
          Nothing -> (M.insert a (M.size names) names, T.pack (show (M.size names)))

    -- The renderer can change the styles.
    withoutStyle :: Event -> Event
    withoutStyle = \case
      SequenceStart props _ -> SequenceStart props Block
      MappingStart props _ -> MappingStart props Block
      ScalarEvent props _ t -> ScalarEvent props Plain t
      e -> e