packages feed

yamlet-1.0.0.0: tests/Yamlet/Test/Encode/Comments.hs

-- | The encodings of syntax trees, kept nodes and comments.
module Yamlet.Test.Encode.Comments
  ( commentTests
  ) where

import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as M
import Data.Set qualified as Set
import Data.Text qualified as T
import Test.Tasty
import Test.Tasty.HUnit

import Yamlet
import Yamlet.Syntax qualified as S
import Yamlet.Test.Helpers

commentTests :: TestTree
commentTests =
  testGroup
    "comments"
    [ testCase "syntax tree" test_syntax
    , testCase "kept nodes" test_keptNodes
    , testCase "comments of keys" test_commentedKeys
    ]

test_syntax :: Assertion
test_syntax =
  assertEqual
    "output"
    expected
    (S.renderSyntax S.defaultRenderOptions [S.document (edit value)])
  where
    value :: S.Node
    value = mapping ["name" .= ("x" :: T.Text), "paths" .= ["a" :: T.Text, "b"]]

    -- Add a comment above the first key and use the flow style for the list.
    edit :: S.Node -> S.Node
    edit n = case n.content of
      S.MappingContent style [(k1, v1), (k2, v2)] ->
        n
          { S.content =
              S.MappingContent
                style
                [
                  ( k1 {S.comments = S.noComments {S.before = [S.Comment "The name."]}}
                  , v1
                  )
                , (k2, v2 {S.content = flow v2.content})
                ]
          }
      _ -> n

    flow :: S.Content -> S.Content
    flow = \case
      S.SequenceContent _ xs -> S.SequenceContent S.Flow xs
      c -> c

    expected :: T.Text
    expected = T.unlines ["# The name.", "name: x", "paths: [a, b]"]

-- | A record that keeps a part of its document as it was written.
data Workflow = Workflow {name :: T.Text, jobs :: Int, matrix :: Node}

instance FromYaml Workflow where
  parseYaml = withMapping $ \o ->
    Workflow
      <$> parseField o "name"
      <*> parseField o "jobs"
      <*> parseField o "matrix"

instance ToYaml Workflow where
  toYaml w = mapping ["name" .= w.name, "jobs" .= w.jobs, "matrix" .= w.matrix]

test_keptNodes :: Assertion
test_keptNodes = do
  case decodeText @Workflow input of
    Left errs -> assertFailure (unlines (map (prettyError "input") (NE.toList errs)))
    Right w -> do
      assertEqual
        "decoded field"
        "demo"
        w.name
      assertEqual
        "output"
        expected
        (encodeText Workflow {name = w.name, jobs = 8, matrix = w.matrix})
  assertEqual
    "kept nodes in a list"
    (Right "- ['9.10', \"9.12\"] # versions\n- {a: 1}\n")
    (encodeText <$> decodeText @[Node] "- ['9.10', \"9.12\"] # versions\n- {a: 1}\n")
  assertEqual
    "comments inside an alias"
    (Right "a: &x\n  k: v # c1\nb: # c2\n  k: v\n")
    (encodeText <$> decodeText @Node "a: &x\n  k: v # c1\nb: *x # c2\n")
  -- The comment belongs to the mapping, and a map has no place for it.
  assertEqual
    "comment after the tag of a mapping"
    (Right "a: 1\n")
    (encodeText <$> decodeText @(M.Map T.Text (Commented Node)) "!!map # c1\na: 1\n")
  assertEqual
    "lines above the first key of a mapping"
    (Right (M.fromList [("b", [Comment "c2"])]))
    $ M.map (.comments.before)
      <$> decodeText @(M.Map T.Text (Commented Int)) "# c1\n\n# c2\nb: 1\n"
  assertEqual
    "comment after the tag of a list"
    (Right "- 1\n")
    (encodeText <$> decodeText @[Commented Node] "!!seq # c1\n- 1\n")
  let valueLines = "# c1\nk:\n  # c2\n\n  # c3\n  - 1\n"
  assertEqual
    "lines above the first item of a value"
    (Right "# c1\nk:\n# c2\n\n# c3\n- 1\n")
    $ encodeText
      <$> decodeText @(M.Map T.Text (Commented (Commented [Commented Int]))) valueLines
  -- The lines belong to the list, and the comments of the entry have no place
  -- for them.
  assertEqual
    "lines above the first item of a value without a commented list"
    (Right "# c1\nk:\n# c3\n- 1\n")
    (encodeText <$> decodeText @(M.Map T.Text (Commented [Commented Int])) valueLines)
  let scalarRoot = "|\n  text\n# end\n"
  assertEqual
    "lines at the end of a scalar root"
    (Right scalarRoot)
    (encodeText <$> decodeText @Node scalarRoot)
  assertEqual
    "scalar root in a list"
    (Right "- |\n  text\n# end\n- 1\n")
    (encodeText . (: [toYaml @Int 1]) <$> decodeText @Node scalarRoot)
  where
    input :: T.Text
    input =
      T.unlines
        [ "name: demo"
        , "jobs: 4"
        , "matrix:"
        , "  # The operating systems."
        , "  os: [ubuntu, macos] # two for now"
        , "  ghc: ['9.10', \"9.12\"]"
        , "  base: &b"
        , "    x: 1"
        , "  copy: *b"
        ]

    -- The alias becomes a copy of the node that it refers to.
    expected :: T.Text
    expected =
      T.unlines
        [ "name: demo"
        , "jobs: 8"
        , "matrix:"
        , "  # The operating systems."
        , "  os: [ubuntu, macos] # two for now"
        , "  ghc: ['9.10', \"9.12\"]"
        , "  base: &b"
        , "    x: 1"
        , "  copy:"
        , "    x: 1"
        ]

-- | A record that keeps the comments of a key.
data Job = Job {name :: T.Text, permissions :: Commented Node}

instance FromYaml Job where
  parseYaml = withMapping $ \o ->
    Job
      <$> parseField o "name"
      <*> parseField o "permissions"

instance ToYaml Job where
  toYaml j = mapping ["name" .= j.name, "permissions" .= j.permissions]

test_commentedKeys :: Assertion
test_commentedKeys = do
  let job =
        T.unlines
          [ "name: build"
          , "# The test reporter writes check runs."
          , "permissions: # read-only"
          , "  contents: read"
          ]
  assertEqual
    "record"
    (Right job)
    (encodeText <$> decodeText @Job job)
  assertEqual
    "map"
    (Right "# one\na: 1\nb: 2 # two\n")
    $ encodeText
      <$> decodeText @(M.Map T.Text (Commented Int)) "# one\na: 1\nb: 2 # two\n"
  -- The parser gives the lines before the marker and at the end to the
  -- document, and the decoder gives them to the root. The renderer separates
  -- the lines of a block root from its first entry, so that they read back
  -- as the lines of the root.
  let top = "# top\n\na: 1\n# end\n"
  assertEqual
    "comments of the document"
    (Right top)
    $ encodeText
      <$> decodeText @(Commented (M.Map T.Text Int)) "# top\n---\na: 1\n# end\n"
  assertEqual
    "comments of the document read back"
    (Right top)
    (encodeText <$> decodeText @(Commented (M.Map T.Text Int)) top)
  let either_ = "# The name.\nLeft: foo # current\n"
  assertEqual
    "key of an either"
    (Right either_)
    (encodeText <$> decodeText @(Either (Commented T.Text) Int) either_)
  let set = "# first\n- a # one\n- b\n"
  assertEqual
    "set"
    (Right set)
    (encodeText <$> decodeText @(Set.Set (Commented T.Text)) set)
  -- The comment after 1 belongs to the value, which an integer cannot keep.
  assertEqual
    "keys of a map"
    (Right "# above\na: 1\n")
    (encodeText <$> decodeText @(M.Map (Commented T.Text) Int) "# above\na: 1 # c\n")
  -- Both take the comments of the key, and the encoder writes them once.
  assertEqual
    "keys and values of a map"
    (Right "# above\na: 1 # c\n")
    $ encodeText
      <$> decodeText @(M.Map (Commented T.Text) (Commented Int)) "# above\na: 1 # c\n"
  assertEqual
    "comments of an explicit key and its value"
    (Right "# above\n# k\na: 1 # v\n")
    $ encodeText
      <$> decodeText @(M.Map (Commented T.Text) (Commented Int)) "# above\n? a # k\n: 1 # v\n"
  assertEqual
    "built keys keep the comments that their values lack"
    "# on key\na: 1 # k\n# on value\nb: 2 # k\n"
    . encodeText
    $ M.fromList
      [
        ( Commented @T.Text "a" noComments {before = [Comment "on key"], inline = Just "k"}
        , Commented @Int 1 noComments
        )
      ,
        ( Commented "b" noComments {inline = Just "k"}
        , Commented 2 noComments {before = [Comment "on value"]}
        )
      ]
  let nodes = "os: [a, b] # two\nsteps:\n- x\n  # end\n"
  assertEqual
    "nodes keep their comments once"
    (Right nodes)
    (encodeText <$> decodeText @(M.Map T.Text (Commented Node)) nodes)
  assertEqual
    "list items"
    (Right [noComments {before = [Comment "c"]}, noComments {inline = Just "d"}])
    (map (.comments) <$> decodeText @[Commented Int] "# c\n- 1\n- 2 # d\n")
  -- The comment after the list belongs to the entry, and the comment above
  -- the first item belongs to the item.
  let branches =
        "branches: # which branches\n# the main branch\n- main # the old default\n- dev\n  # more later\n"
  assertEqual
    "commented items"
    (Right branches)
    (encodeText <$> decodeText @(M.Map T.Text (Commented [Commented T.Text])) branches)
  let commentedRoot = "# c1\n1 # c2\n# c3\n"
  assertEqual
    "commented scalar root"
    (Right commentedRoot)
    (encodeText <$> decodeText @(Commented Int) commentedRoot)
  let linesAfter =
        M.fromList @T.Text @(Commented Int)
          [ ("a", Commented 1 noComments {after = [Comment "c"]})
          , ("b", Commented 2 noComments)
          ]
  assertEqual
    "lines after a commented scalar value"
    "a: 1\n  # c\nb: 2\n"
    (encodeText linesAfter)
  assertEqual
    "lines after a commented scalar value read back"
    (Right (M.map (.comments) linesAfter))
    $ M.map (.comments)
      <$> decodeText @(M.Map T.Text (Commented Int)) (encodeText linesAfter)
  let quotedLinesAfter = "a: \"x\\r\\ny\"\n  # c\nb: d\n"
  assertEqual
    "lines after a text of several lines in double quotes"
    (Right quotedLinesAfter)
    (encodeText <$> decodeText @(M.Map T.Text (Commented T.Text)) quotedLinesAfter)
  let linesAbove =
        [ Commented @[Int]
            [1, 2]
            noComments {before = [Comment "above"], inline = Just "inline"}
        ]
  assertEqual
    "lines above a commented list item"
    "# above\n- # inline\n  - 1\n  - 2\n"
    (encodeText linesAbove)
  assertEqual
    "lines above a commented list item read back"
    (Right [noComments {before = [Comment "above"], inline = Just "inline"}])
    (map (.comments) <$> decodeText @[Commented [Int]] (encodeText linesAbove))
  -- A collection on the line of its indicator takes the lines above the
  -- indicator, so it starts below the indicator to leave them to its first
  -- entry.
  let above = noComments {before = [Comment "c"]}
      nestedItem = [[Commented @Int 1 above]]
      firstKey =
        [ M.fromList @T.Text
            [("a", Commented @Int 1 above), ("b", Commented 2 noComments)]
        ]
  assertEqual
    "lines above a nested first item"
    "# c\n-\n  - 1\n"
    (encodeText nestedItem)
  roundTrip "lines above a nested first item read back" nestedItem
  assertEqual
    "lines above a first key"
    "# c\n-\n  a: 1\n  b: 2\n"
    (encodeText firstKey)
  roundTrip "lines above a first key read back" firstKey