yamlet-1.0.0.0: src/Yamlet/Internal/Syntax.hs
{-# LANGUAGE PatternSynonyms #-}
{-# OPTIONS_HADDOCK not-home #-}
-- | The types of the syntax tree.
--
-- This module is intended for internal use only, and may change without warning
-- in subsequent releases.
module Yamlet.Internal.Syntax
( -- * Documents
Document (..)
, YamlVersion (..)
, document
-- * Nodes
, Node (..)
, Content (.., ScalarContent)
, Props (..)
, noProps
, Tag (..)
, ScalarStyle (..)
, isBlockScalar
, CollectionStyle (..)
-- ** Construction
, contentNode
, scalarNode
, plainNode
, sequenceNode
, mappingNode
-- * Comments
, Comments (..)
, noComments
, withComments
, Commented (..)
, Line (.., Comment)
-- * Positions
, Offset (..)
, noOffset
, Located (..)
-- * Copies
, copyDocument
, copyNode
, copyComments
) where
import Control.DeepSeq
import Data.Text qualified as T
import GHC.Generics
import Yamlet.Internal.Utils
-- | A document of a YAML stream.
data Document = Document
{ version :: !(Maybe YamlVersion)
-- ^ The version from the @%YAML@ directive.
, explicitStart :: !Bool
-- ^ The document starts with a @---@ marker.
, explicitEnd :: !Bool
-- ^ The document ends with a @...@ marker.
, docComments :: !Comments
-- ^ The lines before the directives or the @---@ marker, the comment on the
-- line of the marker and the lines at the end of the document: below the
-- @...@ marker, or below a flow collection root.
, root :: !Node
}
deriving stock (Eq, Show, Generic)
deriving anyclass (NFData)
-- | The version of YAML that a document declares.
data YamlVersion = YamlVersion
{ major :: !Int
, minor :: !Int
}
deriving stock (Eq, Ord, Show, Generic)
deriving anyclass (NFData)
-- | A document with the given root, without directives, markers and comments.
document :: Node -> Document
document n =
Document
{ version = Nothing
, explicitStart = False
, explicitEnd = False
, docComments = noComments
, root = n
}
-- | A node of a document.
data Node = Node
{ offset :: !Offset
-- ^ The position of the first character of the content.
, endOffset :: !Offset
-- ^ The position after the last character of the content.
, props :: !Props
, comments :: !Comments
, content :: !Content
}
deriving stock (Eq, Show, Generic)
deriving anyclass (NFData)
-- | The content of a node.
data Content
= -- | A scalar with the positions in its text where the source continues on
-- a new line. A position counts the characters from the start of the
-- text, and the positions are in ascending order. The parser gives them
-- for the plain, quoted and folded styles, which join the lines of the
-- source, so that the renderer can write the text on the same lines. The
-- renderer ignores a position where the style of the output cannot start
-- a new line and keep the text.
ScalarLinesContent !ScalarStyle !T.Text ![Int]
| SequenceContent !CollectionStyle ![Node]
| MappingContent !CollectionStyle ![(Node, Node)]
| -- | An alias, with the name of its anchor. An alias has no properties.
AliasContent !T.Text
deriving stock (Eq, Show, Generic)
-- | A scalar without positions of new lines. As a pattern, it matches every
-- scalar and ignores its positions.
pattern ScalarContent :: ScalarStyle -> T.Text -> Content
pattern ScalarContent style t <- ScalarLinesContent style t _
where
ScalarContent style t = ScalarLinesContent style t []
{-# COMPLETE ScalarContent, SequenceContent, MappingContent, AliasContent #-}
-- The instances of the sum types are written by hand, because GHC does not
-- always remove the generic representation of a sum type. A strict field of
-- a type without lazy parts, e.g. a text, is already in normal form.
instance NFData Content where
rnf = \case
ScalarLinesContent _ _ ls -> rnf ls
SequenceContent _ xs -> rnf xs
MappingContent _ kvs -> rnf kvs
AliasContent _ -> ()
-- | The properties of a node.
data Props = Props
{ anchor :: !(Maybe T.Text)
, tag :: !Tag
}
deriving stock (Eq, Show, Generic)
deriving anyclass (NFData)
-- | No anchor and no tag.
noProps :: Props
noProps = Props Nothing NoTag
-- | The tag of a node after the tag handles are expanded.
data Tag
= -- | The node has no tag.
NoTag
| -- | The @!@ tag.
NonSpecificTag
| -- | A specific tag, e.g. @tag:yaml.org,2002:str@ for @!!str@. YAML has
-- no syntax for the empty tag or a tag of one character, e.g. @x@ or
-- @!@. The renderer writes such a tag as @!@, which reads back as
-- 'NonSpecificTag'.
Tag !T.Text
deriving stock (Eq, Ord, Show, Generic)
instance NFData Tag where
rnf = rwhnf
-- | The style of a scalar: without quotes, in single or double quotes, or a
-- literal (@|@) or folded (@>@) block scalar.
data ScalarStyle
= Plain
| SingleQuoted
| DoubleQuoted
| Literal
| Folded
deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)
instance NFData ScalarStyle where
rnf = rwhnf
-- | The literal or the folded style.
isBlockScalar :: ScalarStyle -> Bool
isBlockScalar s = s == Literal || s == Folded
-- | The style of a collection: with indentation, or with brackets and commas.
data CollectionStyle
= Block
| Flow
deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)
instance NFData CollectionStyle where
rnf = rwhnf
-- | A node with the given content, without properties and comments.
contentNode :: Content -> Node
contentNode c =
Node
{ offset = noOffset
, endOffset = noOffset
, props = noProps
, comments = noComments
, content = c
}
-- | A scalar in the given style. 'Yamlet.Syntax.renderSyntax' uses quotes if
-- the style cannot hold the text, and for a block scalar that is a key or an
-- entry of a flow collection.
--
-- >>> T.putStr (renderSyntax defaultRenderOptions [document (mappingNode [(plainNode "key", scalarNode Plain "a: b")])])
-- key: 'a: b'
scalarNode :: ScalarStyle -> T.Text -> Node
scalarNode style = contentNode . ScalarContent style
-- | A plain scalar.
plainNode :: T.Text -> Node
plainNode = scalarNode Plain
-- | A block sequence.
sequenceNode :: [Node] -> Node
sequenceNode = contentNode . SequenceContent Block
-- | A block mapping.
mappingNode :: [(Node, Node)] -> Node
mappingNode = contentNode . MappingContent Block
-- | The comments and the empty lines that belong to a node.
data Comments = Comments
{ before :: ![Line]
-- ^ The lines above the node.
, inline :: !(Maybe T.Text)
-- ^ The comment at the end of the line where the node ends, or of the
-- first line of a block collection or a block scalar. The renderer
-- writes each line break in it as a space. The line breaks are the
-- characters that 'CommentLine' lists.
, after :: ![Line]
-- ^ The lines below the node: after the last entry of a collection,
-- between the brackets of an empty collection, or below a scalar or an
-- alias in a block collection or at the root. The section
-- [Comments]("Yamlet.Syntax#comments") gives the rules. The renderer
-- writes such lines below any node.
}
deriving stock (Eq, Ord, Show, Generic)
deriving anyclass (NFData)
-- | No comments and no empty lines.
noComments :: Comments
noComments = Comments {before = [], inline = Nothing, after = []}
-- | The node with the comments in place of its own. A record update of the
-- field is ambiguous where t'Commented' is in scope.
withComments :: Comments -> Node -> Node
withComments c n =
Node
{ offset = n.offset
, endOffset = n.endOffset
, props = n.props
, comments = c
, content = n.content
}
-- | A value with the comments of its mapping entry:
--
-- * 'Yamlet.Syntax.before': the lines above the entry,
--
-- * 'Yamlet.Syntax.inline': the comment at the end of the line of the key,
-- or of the line where a value on several lines ends,
--
-- * 'Yamlet.Syntax.after': the lines after the value, e.g. after the last
-- entry of a collection.
--
-- A t'Commented' value of a mapping entry has the comments of the entry. A
-- block list or mapping under the key has its own comments: the lines below
-- the key up to the last empty line above its first entry. A t'Commented'
-- value inside the first one keeps them, so a type that keeps both nests two
-- t'Commented' values:
--
-- >>> input = "# The CI jobs.\njobs:\n # Run on every push.\n\n # Check the formatting.\n - lint\n"
--
-- >>> T.putStr input
-- # The CI jobs.
-- jobs:
-- # Run on every push.
-- <BLANKLINE>
-- # Check the formatting.
-- - lint
--
-- >>> Right entries = decodeText @(M.Map T.Text (Commented (Commented [Commented T.Text]))) input
-- >>> Just jobs = M.lookup "jobs" entries
--
-- >>> jobs.comments
-- Comments {before = [Comment "The CI jobs."], inline = Nothing, after = []}
--
-- >>> jobs.value.comments
-- Comments {before = [Comment "Run on every push.",EmptyLine], inline = Nothing, after = []}
--
-- >>> map (.comments) jobs.value.value
-- [Comments {before = [Comment "Check the formatting."], inline = Nothing, after = []}]
--
-- A value without a key, e.g. an item of a list, has the comments of its
-- node. The comments of a list or a mapping stay with it, not with its first
-- item or key: the lines up to the last empty line above its first entry, and
-- the comment on its first line, e.g. after its tag. A t'Commented' value of
-- the whole list or mapping keeps them.
--
-- By the rules in [Comments]("Yamlet.Syntax#comments"), some lines read
-- back with a change:
--
-- * The lines after a text of several lines, which the encoder writes as a
-- block scalar, read back as the lines above the next entry. After the
-- last entry, they belong to the end of the collection around the entry.
--
-- * The lines above a list or a mapping without a key can get an empty line
-- below them, e.g. at the top level. The empty line reads back as the last
-- of these lines.
--
-- * The lines above the first item of a list or the first key of a mapping
-- that end with an empty line read back as the lines of the list or the
-- mapping. A t'Commented' list or mapping keeps them. Under a key, the
-- inner of two nested t'Commented' values keeps them, as above. Otherwise
-- they are lost.
--
-- A comment is lost if its node has no place for it, i.e. if the node does
-- not decode into a node or a t'Commented' value:
--
-- * the comments of a key without a Haskell field, e.g. the tag of a
-- constructor;
--
-- * a comment at the end of a nested mapping, unless the field that holds
-- the mapping is t'Commented', because a record has no place for the end
-- of its mapping;
--
-- * the comments of a record's mapping above its first key, e.g. a comment
-- at the top of a file above an empty line;
--
-- * the comments of the key for a type such as
-- @data Name = Name (Commented Text)@ that derives its instances through
-- 'Generic', because a derived instance for one constructor with one field
-- without a name does not give the key of its entry to the value inside.
-- Declare such a type as a newtype and derive its instances with
-- @deriving newtype@, which gives the key to the value.
--
-- In a map, use t'Commented' on the key or on the value, not on both. With
-- both, the decoder gives the comments of the key to both, and the encoder
-- writes only those of the value, so a change to the comments of the key is
-- lost.
--
-- A change of the value keeps the comments:
--
-- >>> input = "# The port.\nport: 80 # the default\n"
--
-- >>> :{
-- either printErrors (T.putStr . encodeText . M.map (fmap (+ 1))) $
-- decodeText @(M.Map T.Text (Commented Int)) input
-- :}
-- # The port.
-- port: 81 # the default
--
-- The order compares the values first and then the comments, e.g. in a set.
data Commented a = Commented
{ value :: !a
, comments :: !Comments
}
-- The derived order compares the fields in this order.
deriving stock (Eq, Ord, Show, Functor, Foldable, Traversable, Generic)
deriving anyclass (NFData)
-- | A value with the offset of its node, e.g. for the error of a check that
-- runs after the decode. The encoder writes only the value.
--
-- 'Yamlet.Error.documentErrors' turns the offsets into errors with lines,
-- columns and paths. It needs the text and the document of the decode, so
-- decode with 'Yamlet.decodeWithDocument'. Here the check gives an offset and
-- a message for each problem:
--
-- >>> input = "paths:\n- src\n- /etc\n"
--
-- >>> :{
-- case decodeWithDocument @(M.Map T.Text [Located T.Text]) input of
-- Right (config, doc) ->
-- let errs =
-- [ (p.offset, "the path is outside the repository")
-- | p <- concat (M.elems config)
-- , "/" `T.isPrefixOf` p.value
-- ]
-- in printErrors (documentErrors input doc errs)
-- Left errs -> printErrors errs
-- :}
-- input.yaml:3:3: paths[1]: the path is outside the repository
-- |
-- 3 | - /etc
-- | ^
--
-- A value that no node gives, e.g. a value of 'Yamlet.Generic.yamlDefault',
-- has 'noOffset'. Its error has no position, and 'Yamlet.Error.prettyError'
-- prints only the file and the message. A value inside an alias has the
-- offset of the alias, i.e. of the place where the document uses the value.
--
-- Two equal values at different places are not equal as t'Located' values,
-- e.g. a set keeps both. The equality and the order compare the values first
-- and then the offsets. To compare only the values, e.g. in a test, use the
-- field @value@.
data Located a = Located
{ value :: !a
, offset :: !Offset
}
-- The derived order compares the fields in this order.
deriving stock (Eq, Ord, Show, Functor, Foldable, Traversable, Generic)
deriving anyclass (NFData)
-- | A line of comments.
data Line
= EmptyLine
| -- | The number of @#@ characters at the start of the comment, e.g. 2 for
-- @## Section@, and the text after them, without the one space after the
-- @#@ characters and without the white space at its end. The renderer
-- writes a count below 1 as 1. It writes a text with line breaks as
-- several comment lines with the same @#@ characters, and the parser
-- reads them back as several comments. U+0085, U+2028 and U+2029 count as
-- line breaks here, because YAML 1.1 reads them as line breaks.
--
-- The comment at the end of a line in t'Comments' is a text without a
-- count. It keeps the @#@ characters after the first one in its text.
CommentLine !Int !T.Text
deriving stock (Eq, Ord, Generic)
-- | A comment with one @#@. As a pattern, it matches every comment and
-- ignores the number of @#@ characters.
pattern Comment :: T.Text -> Line
pattern Comment t <- CommentLine _ t
where
Comment t = CommentLine 1 t
{-# COMPLETE EmptyLine, Comment #-}
-- A comment with one @#@ shows as 'Comment', as a program usually writes it.
instance Show Line where
showsPrec d = \case
EmptyLine -> showString "EmptyLine"
CommentLine 1 t -> showParen (d > 10) $ showString "Comment " . showsPrec 11 t
CommentLine n t ->
showParen (d > 10) $
showString "CommentLine " . showsPrec 11 n . showChar ' ' . showsPrec 11 t
instance NFData Line where
rnf = rwhnf
-- | The offset of a byte in the input text, in its UTF-8 encoding. For an
-- input in UTF-16 or UTF-32, the offset counts the bytes of the text after
-- 'Yamlet.Syntax.decodeInput', not the bytes of the input.
newtype Offset = Offset Int
deriving stock (Generic)
deriving newtype (Eq, Ord, Show, NFData)
-- | The offset of a node that does not come from an input.
noOffset :: Offset
noOffset = Offset (-1)
-- | Copy every text of a document, so that the document does not keep the
-- input alive.
copyDocument :: Document -> Document
copyDocument doc =
doc
{ docComments = copyComments doc.docComments
, root = copyNode doc.root
}
-- | Copy every text of a node, so that the node does not keep the input
-- alive.
copyNode :: Node -> Node
copyNode n =
n
{ props = case n.props of
-- Most nodes share one empty value, which a copy would duplicate.
Props Nothing (Tag t) -> Props Nothing (Tag (T.copy t))
Props Nothing _ -> n.props
Props anchor tag ->
Props
{ anchor = copyMaybe anchor
, tag = case tag of
Tag t -> Tag (T.copy t)
t -> t
}
, comments = case n.comments of
-- Most nodes share one empty value. GHC returns the result of
-- 'copyComments' unboxed, so the caller would build a new one.
c@(Comments [] Nothing []) -> c
c -> copyComments c
, content = case n.content of
ScalarLinesContent style t ls -> ScalarLinesContent style (T.copy t) ls
SequenceContent style xs -> SequenceContent style (strictMap copyNode xs)
MappingContent style kvs ->
MappingContent
style
(strictMap (\(k, v) -> strictPair (copyNode k) (copyNode v)) kvs)
AliasContent name -> AliasContent (T.copy name)
}
copyComments :: Comments -> Comments
copyComments c = case c of
Comments [] Nothing [] -> c
_ ->
Comments
{ before = strictMap copyLine c.before
, inline = copyMaybe c.inline
, after = strictMap copyLine c.after
}
where
copyLine :: Line -> Line
copyLine = \case
CommentLine n t -> CommentLine n (T.copy t)
EmptyLine -> EmptyLine
-- | A copy without a thunk, which would keep the original text alive.
copyMaybe :: Maybe T.Text -> Maybe T.Text
copyMaybe = \case
Just t -> Just $! T.copy t
Nothing -> Nothing
-- $setup
-- >>> import Data.Map.Strict qualified as M
-- >>> import Data.Text.IO qualified as T
-- >>> import Yamlet
-- >>> import Yamlet.Syntax
-- >>> printErrors = mapM_ (putStrLn . prettyError "input.yaml")