packages feed

wireform-yaml-0.1.0.0: test/Test/YAML/Conformance.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Optional spec-conformance harness.

The official YAML test suite (https://github.com/yaml/yaml-test-suite)
ships ~400 test cases of the form

@
  <case-id>/in.yaml         -- raw YAML input
  <case-id>/error           -- present iff the input must fail
  <case-id>/in.json         -- equivalent JSON (where applicable)
  <case-id>/===             -- one-line label
@

We run every test we can find by reading the @YAML_TEST_SUITE@
environment variable and walking the directory. When the variable
is unset, the suite is skipped and a stub success test is emitted
so CI stays green out-of-the-box.

A built-in mini-suite (the cases in @wireform-yaml/test-data/yaml@)
always runs so that core compliance is exercised even without the
external suite.
-}
module Test.YAML.Conformance (tests) where

import Control.DeepSeq (force)
import Control.Exception (SomeException, evaluate, try)
import Control.Monad (filterM)
import Data.Aeson qualified as A
import Data.Aeson.Decoding (toEitherValue)
import Data.Aeson.Decoding.ByteString.Lazy (lbsToTokens)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL
import Data.Foldable (traverse_)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import System.Directory (
  doesDirectoryExist,
  doesFileExist,
  listDirectory,
 )
import System.Environment (lookupEnv)
import System.FilePath (takeFileName, (</>))
import Test.Syd
import YAML.Decode qualified as YD
import YAML.Decode.Annotated qualified as YA
import YAML.Encode qualified as YE
import YAML.JSON qualified as YJ
import YAML.Pretty qualified as YP
import YAML.Value qualified as YV


tests :: IO Spec
tests = do
  builtin <- builtinSuite
  ext <- externalSuite
  pure (describe "conformance" $ sequence_ [builtin, ext])


-- ---------------------------------------------------------------------------
-- Built-in mini-suite
-- ---------------------------------------------------------------------------

{- | A handful of cases distilled from the YAML 1.2 spec that we
never want to regress on.
-}
builtinSuite :: IO Spec
builtinSuite =
  pure $
    describe "builtin" $
      sequence_
        [ caseOK
            "spec ex 2.1 seq of strings"
            ( T.unlines
                [ "- Mark McGwire"
                , "- Sammy Sosa"
                , "- Ken Griffey"
                ]
            )
            ( \v -> case v of
                YV.YSeq xs -> V.length xs `shouldBe` 3
                _ -> expectationFailure "expected sequence"
            )
        , caseOK
            "spec ex 2.2 mapping of scalars"
            ( T.unlines
                [ "hr:  65"
                , "avg: 0.278"
                , "rbi: 147"
                ]
            )
            ( \v -> do
                YV.lookupKey "hr" v `shouldBe` Just (YV.YInt 65)
                case YV.lookupKey "rbi" v of
                  Just (YV.YInt 147) -> pure ()
                  r -> expectationFailure (show r)
            )
        , caseOK
            "spec ex 7.1 alias nodes"
            ( T.unlines
                [ "First occurrence: &anchor Foo"
                , "Second occurrence: *anchor"
                ]
            )
            ( \v -> do
                fmap YV.unwrap (YV.lookupKey "First occurrence" v)
                  `shouldBe` Just (YV.YString "Foo")
                fmap YV.unwrap (YV.lookupKey "Second occurrence" v)
                  `shouldBe` Just (YV.YString "Foo")
            )
        , caseOK
            "spec ex 8.1 block scalar header"
            ( T.unlines
                [ "literal: |"
                , "  text"
                , "folded: >"
                , "  text"
                ]
            )
            ( \v -> do
                YV.lookupKey "literal" v `shouldBe` Just (YV.YString "text\n")
                YV.lookupKey "folded" v `shouldBe` Just (YV.YString "text\n")
            )
        , caseOK
            "spec ex 5.3 block sequence"
            ( T.unlines
                [ "block sequence:"
                , "  - one"
                , "  - two : three"
                ]
            )
            ( \v -> case YV.lookupKey "block sequence" v of
                Just (YV.YSeq xs) -> V.length xs `shouldBe` 2
                _ -> expectationFailure "expected nested sequence"
            )
        , caseOK
            "flow nested in block"
            ( T.unlines
                [ "flow: { a: 1, b: 2, c: [3, 4] }"
                ]
            )
            ( \v -> case YV.lookupKey "flow" v of
                Just (YV.YMap _) -> pure ()
                r -> expectationFailure (show r)
            )
        , caseOK
            "string preserves int-like value when quoted"
            "version: \"1.0\""
            (\v -> YV.lookupKey "version" v `shouldBe` Just (YV.YString "1.0"))
        ]


caseOK :: String -> T.Text -> (YV.Value -> IO ()) -> Spec
caseOK name src k = it name $
  case YD.decode src of
    Left e -> expectationFailure $ "decode failed: " ++ e
    Right v -> k v


-- ---------------------------------------------------------------------------
-- External suite (yaml-test-suite)
-- ---------------------------------------------------------------------------

externalSuite :: IO Spec
externalSuite = do
  mDir <- lookupEnv "YAML_TEST_SUITE"
  case mDir of
    Nothing -> pure (describe "yaml-test-suite (skipped, set YAML_TEST_SUITE)" $ sequence_ [])
    Just dir -> do
      exists <- doesDirectoryExist dir
      if not exists
        then pure (it "YAML_TEST_SUITE must exist" (expectationFailure ("missing conformance path: " ++ dir) :: IO ()))
        else do
          cases <- discoverCases dir
          pure
            ( describe
                ("yaml-test-suite (" ++ show (length cases) ++ " cases)")
                (if null cases then it "suite contains cases" (expectationFailure "no in.yaml cases found; use yaml-test-suite data branch" :: IO ()) else mapM_ (mkCase dir) cases)
            )


-- | Each case-id is a path relative to the test-suite root.
discoverCases :: FilePath -> IO [FilePath]
discoverCases root = walk root
  where
    -- "tags/" and "name/" are symlink farms grouping the same
    -- cases by category / human-readable name; skip them to
    -- avoid double-counting.
    isTagDir d = case reverse (splitPath d) of
      (last_ : _) ->
        let l = dropTrailingSlash last_
        in l == "tags" || l == "name"
      [] -> False

    splitPath = words . map (\c -> if c == '/' then ' ' else c)
    dropTrailingSlash s = case reverse s of
      ('/' : rs) -> reverse rs
      _ -> s

    walk d
      | isTagDir d = pure []
      | otherwise = do
          entries <- listDirectory d
          let absEntries = map (d </>) entries
          dirs <- filterM doesDirectoryExist absEntries
          hasIn <- doesFileExist (d </> "in.yaml")
          let here = if hasIn then [d] else []
          subs <- mapM walk dirs
          pure (here ++ concat subs)


mkCase :: FilePath -> FilePath -> Spec
mkCase _root caseDir = it caseDir $ do
  let inPath = caseDir </> "in.yaml"
      errPath = caseDir </> "error"
      jsonPath = caseDir </> "in.json"
  isErr <- doesFileExist errPath
  bytes <- BS.readFile inPath
  res <-
    try (evaluate (force (YD.decodeStreamBS bytes)))
      :: IO (Either SomeException (Either String YV.Stream))
  case (isErr, res) of
    (True, Right (Left _)) -> pure ()
    (True, Right (Right _)) -> expectationFailure "expected parse error, got success"
    (True, Left e) -> expectationFailure $ "exception instead of parse error: " ++ show e
    (False, Right (Right s))
      | takeFileName caseDir `elem` ["X38W", "2JQS"] -> expectationFailure "duplicate keys must fail composition"
      | otherwise -> do
          compareToExpectedJSON jsonPath s
          checkEmittedForms bytes s
    (False, Right (Left e))
      | takeFileName caseDir `elem` ["X38W", "2JQS"] ->
          -- Event-only fixtures with duplicate collection/null keys are valid
          -- parser events, but cannot form a mapping value with unique keys.
          e `shouldBe` "YAML: duplicate mapping key"
      | otherwise -> expectationFailure $ "decode failed: " ++ e
    (False, Left e) -> expectationFailure $ "exception: " ++ show e


-- Check every valid upstream value through each public renderer as well as the
-- decoder. JSON alone cannot detect corruption of collection-valued keys.
checkEmittedForms :: BS.ByteString -> YV.Stream -> IO ()
checkEmittedForms bytes stream = do
  traverse_
    check
    [ ("encode", YE.encodeStream stream)
    , ("pretty", YP.renderStream YP.defaultOptions stream)
    , ("compact", YP.renderStream YP.compactOptions stream)
    ]
  case TE.decodeUtf8' bytes of
    Left _ -> pure ()
    Right source -> case YA.decodeAnnotatedStream source of
      Left err -> expectationFailure err
      Right (annotated, original) -> check ("normalized annotations", YP.renderAnnotatedStream (YP.defaultOptions {YP.roPreserveOriginalText = False}) annotated (Just original))
  where
    expected = representation stream
    check (label, text) = case YD.decodeStream text of
      Left err -> expectationFailure (label <> ": " <> err <> "\n" <> T.unpack text)
      Right result -> representation result `shouldBe` expected
    representation = show . V.map (strip . YV.docBody) . YV.unStream
    strip = \case
      YV.YAnchored _ inner -> strip inner
      YV.YTagged _ inner -> strip inner
      YV.YSeq xs -> YV.YSeq (strip <$> xs)
      YV.YMap entries -> YV.YMap ((\(k, v) -> (strip k, strip v)) <$> entries)
      value -> value


{- | When the case ships an @in.json@ companion, compare it against
the JSON projection of every parsed document. Numeric and string
values stay distinct, and multi-document results are checked in full.
-}
compareToExpectedJSON :: FilePath -> YV.Stream -> IO ()
compareToExpectedJSON jsonPath stream = do
  hasJson <- doesFileExist jsonPath
  if not hasJson
    then pure ()
    else do
      raw <- BSL.readFile jsonPath
      case decodeJSONStream raw of
        Left err -> expectationFailure ("invalid expected JSON: " ++ err)
        Right expected ->
          fmap (YJ.yamlToJSON . YV.docBody) (V.toList (YV.unStream stream)) `shouldBe` expected


-- The upstream data branch stores one JSON value per YAML document, including
-- pretty-printed and scalar values. Decode the whole stream, retaining types.
decodeJSONStream :: BSL.ByteString -> Either String [A.Value]
decodeJSONStream bytes
  | BSL.null rest = Right []
  | otherwise = do
      (value, remaining) <- toEitherValue (lbsToTokens rest)
      (value :) <$> decodeJSONStream remaining
  where
    rest = BSL.dropWhile (\c -> c == 32 || c == 9 || c == 10 || c == 13) bytes