packages feed

yamlet-1.0.0.0: src/Yamlet/Syntax.hs

-- | The representation of a YAML stream that keeps every detail of the
-- presentation: the styles of scalars and collections, the lines of
-- scalars, anchors, aliases, unresolved tags, comments and empty lines. The
-- t'Yamlet.Decode.FromYaml' and t'Yamlet.Encode.ToYaml' classes read and
-- write the nodes of this tree.
--
-- Most texts in the tree share the memory of the input, so a node keeps the
-- whole input alive. To keep a text longer than the tree, copy it with
-- 'Data.Text.copy', or copy the whole tree with 'copyDocument'. Copy only
-- what the program keeps: a copy of a whole tree usually needs more memory
-- than the input it frees.
--
-- A document that the renderer writes back keeps its comments, empty lines
-- and styles:
--
-- >>> input = "# The server.\nhost: localhost # only local\n\nports: [80, 443]\n"
--
-- >>> :{
-- case parseDocumentsText input of
--   Left err -> putStrLn (prettyError "input.yaml" err)
--   Right docs -> T.putStr (renderSyntax defaultRenderOptions docs)
-- :}
-- # The server.
-- host: localhost # only local
-- <BLANKLINE>
-- ports: [80, 443]
--
-- The section [Comments]("Yamlet.Syntax#comments") gives the rules that
-- decide the node of each comment.
module Yamlet.Syntax
  ( -- * Parsing
    parseDocuments
  , parseDocumentsText
  , decodeInput
  , copyDocument
  , copyNode

    -- * Rendering
  , renderSyntax
  , RenderOptions (..)
  , defaultRenderOptions

    -- * Documents
  , Document (..)
  , YamlVersion (..)
  , document

    -- * Nodes
  , Node (..)
  , Content (..)
  , Props (..)
  , noProps
  , Tag (..)
  , ScalarStyle (..)
  , CollectionStyle (..)

    -- ** Construction
  , contentNode
  , scalarNode
  , plainNode
  , foldedNode
  , sequenceNode
  , mappingNode

    -- * Positions
  , Offset (..)
  , noOffset

    -- * Comments
    -- $comments
  , Comments (..)
  , noComments
  , withComments
  , Line (..)

    -- ** Lines above a node
    -- $linesAbove

    -- ** Comments at the end of a line
    -- $endOfLine

    -- ** Lines at the end of a collection
    -- $endOfCollection

    -- ** Documents
    -- $documents

    -- ** Empty lines
    -- $emptyLines
  ) where

import Data.ByteString qualified as BS
import Data.Text qualified as T

import Yamlet.Error
import Yamlet.Internal.Input
import Yamlet.Internal.Parser
import Yamlet.Internal.Render
import Yamlet.Internal.Syntax

-- | Parse the documents of a stream. The encoding is UTF-8, UTF-16 or UTF-32,
-- detected as the YAML specification describes.
--
-- 'errorAt' and 'Yamlet.decodeDocument' need the text of the input. To report
-- errors with the lines of the input, decode the input with 'decodeInput' and
-- parse it with 'parseDocumentsText'.
parseDocuments :: BS.ByteString -> Either Error [Document]
parseDocuments bs = decodeInput bs >>= parseStream

-- | Parse the documents of a stream.
--
-- >>> length <$> parseDocumentsText "a\n---\nb\n"
-- Right 2
parseDocumentsText :: T.Text -> Either Error [Document]
parseDocumentsText = parseStream

-- | A folded block scalar (@>-@) with the given lines. The empty lines at the
-- end are dropped, because @>-@ strips them.
--
-- >>> T.putStr (renderSyntax defaultRenderOptions [document (mappingNode [(plainNode "options", foldedNode ["--health-cmd pg_isready", "--health-interval 5s"])])])
-- options: >-
--   --health-cmd pg_isready
--   --health-interval 5s
foldedNode :: [T.Text] -> Node
foldedNode ls = contentNode (ScalarLinesContent Folded t starts)
  where
    (t, starts) = foldedText (contentLines 0 ls)

    -- The lines with content, each with the number of empty lines above it.
    contentLines :: Int -> [T.Text] -> [BlockLine]
    contentLines !empties = \case
      [] -> []
      l : rest
        | T.null l -> contentLines (empties + 1) rest
        | otherwise -> BlockLine empties l : contentLines 0 rest

-- $comments
-- #comments#
-- The parser gives each comment to one node or document, and the renderer
-- writes it back at that place. A stream without documents, e.g. a stream of
-- only comments, has no such place. The parser drops its comments.
--
-- In the examples below, @printComments@ parses a text and prints each node
-- that has comments, with its path and the fields of t'Comments'. The key and
-- the value of an entry have the same path, with @(key)@ or @(value)@ after
-- it.

-- $linesAbove
-- A comment on a line of its own belongs to the node below it. Above the
-- first entry of a block collection, the lines up to the last empty line
-- belong to the collection, e.g. a comment at the top of a file.
--
-- >>> input = "# The server.\n\n# The host.\nhost: localhost\n# The port.\nport: 80\n"
--
-- >>> T.putStr input
-- # The server.
-- <BLANKLINE>
-- # The host.
-- host: localhost
-- # The port.
-- port: 80
--
-- >>> printComments input
-- root before: [Comment "The server.",EmptyLine]
-- root.host (key) before: [Comment "The host."]
-- root.port (key) before: [Comment "The port."]
--
-- A block collection after @- @ on the same line keeps all the lines above
-- it, so that a comment above an item belongs to the item.
--
-- >>> input = "# The first server.\n- host: localhost\n# The second server.\n- host: example.com\n"
--
-- >>> T.putStr input
-- # The first server.
-- - host: localhost
-- # The second server.
-- - host: example.com
--
-- >>> printComments input
-- root[0] before: [Comment "The first server."]
-- root[1] before: [Comment "The second server."]
--
-- The rules in the sections below give some of these lines to a document or
-- to the end of a collection instead, e.g. @# a@ and @# b@ below. The node
-- below still gets @# c@.
--
-- >>> input = "# a\n---\nserver:\n  host: localhost\n  # b\n# c\nuser: admin\n"
--
-- >>> T.putStr input
-- # a
-- ---
-- server:
--   host: localhost
--   # b
-- # c
-- user: admin
--
-- >>> printComments input
-- document before: [Comment "a"]
-- root.server (value) after: [Comment "b"]
-- root.user (key) before: [Comment "c"]

-- $endOfLine
-- A comment at the end of a line belongs to the node that ends last before
-- it on that line, if only spaces, a colon or a comma come between them.
-- E.g. the value gets the comment in @key: value # comment@. In
-- @key: # comment@, the key gets it if the value starts on a later line.
-- Otherwise the value is empty and ends after the colon, so it gets the
-- comment. A comment on the line of a block scalar header belongs to the
-- block scalar.
--
-- >>> input = "host: localhost # a\nports: # b\n- 80\nproxy: # c\ntext: | # d\n  Hello.\n"
--
-- >>> T.putStr input
-- host: localhost # a
-- ports: # b
-- - 80
-- proxy: # c
-- text: | # d
--   Hello.
--
-- >>> printComments input
-- root.host (value) inline: "a"
-- root.ports (key) inline: "b"
-- root.proxy (value) inline: "c"
-- root.text (value) inline: "d"
--
-- A comment at the end of a line that the rule above does not give to a
-- node, e.g. after @- @, belongs to the node below it. If that node also has
-- a comment at the end of its line, the first comment becomes a line above
-- the node, e.g. @# c@ below.
--
-- >>> input = "- # a\n  host: localhost # b\n- # c\n  'a string' # d\n"
--
-- >>> T.putStr input
-- - # a
--   host: localhost # b
-- - # c
--   'a string' # d
--
-- >>> printComments input
-- root[0] inline: "a"
-- root[0].host (value) inline: "b"
-- root[1] before: [Comment "c"]
-- root[1] inline: "d"
--
-- The same holds for a comment after the tag of a block collection.
--
-- >>> input = "server: !!map # a\n  host: localhost\n"
--
-- >>> T.putStr input
-- server: !!map # a
--   host: localhost
--
-- >>> printComments input
-- root.server (value) inline: "a"
--
-- On the line of the @---@ marker, the document gets such a comment.
--
-- >>> input = "--- !!map # a\nhost: localhost\n"
--
-- >>> T.putStr input
-- --- !!map # a
-- host: localhost
--
-- >>> printComments input
-- document inline: "a"

-- $endOfCollection
-- A comment below a scalar or an alias in a block collection belongs to the
-- end of that node if it is indented deeper than the key or the @-@ of its
-- entry. Below the last item of a list without indentation, it belongs to
-- the end of the list, by the rule below.
--
-- >>> input = "host: localhost\n  # a\n# b\nports:\n- 80\n  # c\n# d\n- 443\n  # e\n# f\nuser: admin\n"
--
-- >>> T.putStr input
-- host: localhost
--   # a
-- # b
-- ports:
-- - 80
--   # c
-- # d
-- - 443
--   # e
-- # f
-- user: admin
--
-- >>> printComments input
-- root.host (value) after: [Comment "a"]
-- root.ports (key) before: [Comment "b"]
-- root.ports (value) after: [Comment "e"]
-- root.ports[0] after: [Comment "c"]
-- root.ports[1] before: [Comment "d"]
-- root.user (key) before: [Comment "f"]
--
-- Below a block scalar, such a line is part of the scalar if it is indented
-- as deep as the content. Otherwise it belongs to the node below. E.g.
-- @# a@ below is a line of the text, and @user@ gets @# b@.
--
-- >>> input = "text: |\n    Hello.\n    # a\n  # b\nuser: admin\n"
--
-- >>> T.putStr input
-- text: |
--     Hello.
--     # a
--   # b
-- user: admin
--
-- >>> printComments input
-- root.user (key) before: [Comment "b"]
--
-- Thus the text has no place for the lines after a block scalar. They read
-- back as the lines of the node below, or of the end of an outer collection.
--
-- Below a scalar or an alias key after @?@, such a line belongs to the value:
-- as a line above it if the @:@ of the value follows, and as a line after it
-- if the key has no value. A list or a mapping as the key keeps it. E.g. the
-- value of @a@ gets @# b@ above it, the empty value of @e@ gets @# f@ after
-- it, and the list key gets @# d@.
--
-- >>> input = "? a\n  # b\n: x\n? e\n  # f\n? - c\n  # d\n: y\n"
--
-- >>> T.putStr input
-- ? a
--   # b
-- : x
-- ? e
--   # f
-- ? - c
--   # d
-- : y
--
-- >>> printComments input
-- root.a (value) before: [Comment "b"]
-- root.e (value) after: [Comment "f"]
-- root.? (key) after: [Comment "d"]
--
-- Thus the text has no place for the lines after a scalar or an alias key.
-- They read back as lines of the value.
--
-- A comment after the last entry of a block collection belongs to the end of
-- the collection if it is indented at least as deep as the entries, and
-- deeper than the key of the collection. Otherwise it belongs to the node
-- below it, or to the end of an outer collection if no node is below it.
--
-- >>> input = "server:\n  ports:\n  - 80\n  # a\n  # b\n# c\nuser: admin\n"
--
-- >>> T.putStr input
-- server:
--   ports:
--   - 80
--   # a
--   # b
-- # c
-- user: admin
--
-- >>> printComments input
-- root.server (value) after: [Comment "a",Comment "b"]
-- root.user (key) before: [Comment "c"]
--
-- A comment before the closing bracket of a flow collection belongs to the
-- end of the collection.
--
-- >>> input = "ports: [80, 443,\n  # a\n  ]\n"
--
-- >>> T.putStr input
-- ports: [80, 443,
--   # a
--   ]
--
-- >>> printComments input
-- root.ports (value) after: [Comment "a"]

-- $documents
-- The optional @---@ marker starts a document, and the optional @...@ marker
-- ends it. The lines above the directives or the @---@ marker belong to the
-- document. So do the comment on the line of the @...@ marker and the lines
-- below it. Without the markers, the root gets these lines.
--
-- A comment on the line of the @---@ marker belongs to the document, unless
-- the rule for comments at the end of a line gives it to a node.
--
-- >>> input = "# a\n--- # b\n# c\n\nentry: value\n\n# e\n...\n# f\n"
--
-- >>> T.putStr input
-- # a
-- --- # b
-- # c
-- <BLANKLINE>
-- entry: value
-- <BLANKLINE>
-- # e
-- ...
-- # f
--
-- >>> printComments input
-- document before: [Comment "a"]
-- document inline: "b"
-- root before: [Comment "c",EmptyLine]
-- root after: [EmptyLine,Comment "e"]
-- document after: [Comment "f"]
--
-- Between two documents, the first empty line below the @...@ marker ends
-- the lines of the first document. The empty line and the lines below it
-- belong to the second document: to the lines above its @---@ marker, or to
-- its root without the marker.
--
-- >>> input = "x: 1\n...\n# a\n\n# b\n---\ny: 2\n"
--
-- >>> T.putStr input
-- x: 1
-- ...
-- # a
-- <BLANKLINE>
-- # b
-- ---
-- y: 2
--
-- >>> printComments input
-- document after: [Comment "a"]
-- next document
-- document before: [EmptyLine,Comment "b"]
--
-- Without the @...@ marker, the first empty line below the root ends the
-- lines of the root in the same way.
--
-- >>> input = "x: 1\n# a\n\n# b\n---\ny: 2\n"
--
-- >>> T.putStr input
-- x: 1
-- # a
-- <BLANKLINE>
-- # b
-- ---
-- y: 2
--
-- >>> printComments input
-- root after: [Comment "a"]
-- next document
-- document before: [EmptyLine,Comment "b"]
--
-- The lines of a flow collection are between its brackets, so the lines
-- below a flow collection root belong to the document, also without the
-- @...@ marker.
--
-- >>> input = "[80, 443]\n# a\n"
--
-- >>> T.putStr input
-- [80, 443]
-- # a
--
-- >>> printComments input
-- document after: [Comment "a"]
--
-- The renderer writes the markers and the empty lines that these rules
-- need, so that the lines read back at the same places. One exception: the
-- empty lines at the end of a document can read back as the lines of the
-- next document.

-- $emptyLines
-- Empty lines go with the node below them, or with the end of the document.
-- Thus, if a program removes an entry, the gap below the entry stays.
--
-- >>> input = "server:\n  host: localhost\n  # The end of the server.\n\nuser: admin\n"
--
-- >>> T.putStr input
-- server:
--   host: localhost
--   # The end of the server.
-- <BLANKLINE>
-- user: admin
--
-- >>> printComments input
-- root.server (value) after: [Comment "The end of the server."]
-- root.user (key) before: [EmptyLine]
--
-- Empty lines above a comment go with the comment. Each empty line is a
-- line of its own.
--
-- >>> input = "host: localhost\n\n\n# The port.\nport: 80\n\nuser: admin\n"
--
-- >>> T.putStr input
-- host: localhost
-- <BLANKLINE>
-- <BLANKLINE>
-- # The port.
-- port: 80
-- <BLANKLINE>
-- user: admin
--
-- >>> printComments input
-- root.port (key) before: [EmptyLine,EmptyLine,Comment "The port."]
-- root.user (key) before: [EmptyLine]
--
-- One place is an exception. Above the first entry of a block collection,
-- the last empty line stays with the collection. If the lines of a block
-- collection do not end with an empty line, e.g. lines that a program added,
-- the renderer can write one below them, so that they read back as the lines
-- of the collection. The added empty line reads back as their last line.
--
-- >>> input = "# The file.\n\nhost: localhost\n"
--
-- >>> T.putStr input
-- # The file.
-- <BLANKLINE>
-- host: localhost
--
-- >>> printComments input
-- root before: [Comment "The file.",EmptyLine]

-- $setup
-- >>> import Data.Text.IO qualified as T
--
-- >>> :{
-- printComments :: T.Text -> IO ()
-- printComments input = either print docs (parseDocumentsText input)
--   where
--     docs :: [Document] -> IO ()
--     docs = \case
--       d : ds -> doc d >> mapM_ (\d' -> putStrLn "next document" >> doc d') ds
--       [] -> pure ()
--     doc :: Document -> IO ()
--     doc d = do
--       report "document" d.docComments {after = []}
--       node "root" "" d.root
--       report "document" noComments {after = d.docComments.after}
--     node :: String -> String -> Node -> IO ()
--     node path role n = do
--       report (path <> role) n.comments
--       case n.content of
--         SequenceContent _ items ->
--           sequence_
--             [ node (path <> "[" <> show i <> "]") "" item
--             | (i, item) <- zip [0 :: Int ..] items
--             ]
--         MappingContent _ entries ->
--           sequence_
--             [ node (path <> "." <> name k) " (key)" k
--                 >> node (path <> "." <> name k) " (value)" v
--             | (k, v) <- entries
--             ]
--         _ -> pure ()
--     name :: Node -> String
--     name k = case k.content of
--       ScalarContent _ t -> T.unpack t
--       _ -> "?"
--     report :: String -> Comments -> IO ()
--     report path c =
--       mapM_ putStrLn $
--         [path <> " before: " <> show c.before | not (null c.before)]
--           <> [path <> " inline: " <> show t | Just t <- [c.inline]]
--           <> [path <> " after: " <> show c.after | not (null c.after)]
-- :}