wireform-yaml-0.1.0.0: src/YAML/Annotated.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{- | Annotated YAML representation that preserves enough source-level
information to round-trip a parsed document /verbatim/, while still
allowing programmatic modification.
The intended workflow is:
@
Right (adoc, original) <- 'YAML.Decode.Annotated.decodeAnnotated' yamlText
let adoc' = adoc { 'adBody' = 'updateAt' [\"server\", \"port\"] ('aInt' 8081) ('adBody' adoc) }
let yamlText' = 'YAML.Pretty.renderAnnotatedDocument' 'YAML.Pretty.defaultOptions' adoc' (Just original)
@
The renderer copies original source bytes for sub-trees that haven't
been modified (preserving comments, blank lines, quote style,
block-vs-flow choice, indentation, etc.) and falls back to the
pretty-printer's settings for fresh content.
-}
module YAML.Annotated (
-- * Annotated value
AValue (..),
ABody (..),
AMapEntry (..),
Trivia (..),
Comment (..),
SrcSpan (..),
-- * Style
ScalarStyle (..),
Chomping (..),
SeqStyle (..),
MapStyle (..),
-- * Document / stream
ADocument (..),
AStream (..),
-- * Construction (un-annotated, ready for fresh emission)
aNull,
aBool,
aInt,
aFloat,
aString,
aStringStyled,
aSeq,
aMap,
aTagged,
aAnchored,
-- * Querying
abody,
astripAnnotations,
isDirty,
markDirty,
prepareForRendering,
-- * Modification helpers
setKey,
updateKey,
deleteKey,
appendKey,
updateAt,
setIndex,
appendItem,
deleteIndex,
) where
import Control.DeepSeq (NFData)
import Control.Monad.State.Strict (State, evalState, get, modify')
import Data.HashMap.Strict qualified as HM
import Data.HashSet qualified as HS
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.Generics (Generic)
import YAML.Value (Anchor (..), Tag (..))
import YAML.Value qualified as YV
-- ---------------------------------------------------------------------------
-- Source spans + trivia
-- ---------------------------------------------------------------------------
{- | A half-open byte range @[start, end)@ in the original source
'Text' (encoded as UTF-8 and indexed by byte position so the
offsets are stable across text-encoding boundaries).
-}
data SrcSpan = SrcSpan
{ srcStart :: {-# UNPACK #-} !Int
, srcEnd :: {-# UNPACK #-} !Int
}
deriving stock (Show, Eq, Ord, Generic)
deriving anyclass (NFData)
{- | A single comment token: a leading @#@ on its own line, or an
end-of-line comment trailing a value.
-}
data Comment = Comment
{ commentText :: !Text
{- ^ The text after the @#@, without the @#@ itself or any
indenting whitespace. May contain trailing whitespace.
-}
, commentColumn :: {-# UNPACK #-} !Int
{- ^ Column where the @#@ sat in the source, useful for
preserving indentation when re-emitting fresh content
around the comment.
-}
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | Whitespace / comments / blank-lines that decorate a value.
data Trivia = Trivia
{ triviaLeading :: ![Comment]
{- ^ Stand-alone comments on lines /before/ this value
(between the previous structural token and this one).
-}
, triviaBlankLines :: {-# UNPACK #-} !Int
{- ^ Number of blank lines immediately preceding the value
(after any leading comments).
-}
, triviaTrailing :: !(Maybe Comment)
{- ^ End-of-line comment on the same line as the value, if
any.
-}
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | Trivia with no comments and no blank lines.
emptyTrivia :: Trivia
emptyTrivia = Trivia [] 0 Nothing
-- ---------------------------------------------------------------------------
-- Style
-- ---------------------------------------------------------------------------
-- | How a scalar was (or should be) emitted.
data ScalarStyle
= -- | @value@ — unquoted plain scalar.
SSPlain
| -- | @\"value\"@ — double-quoted; supports YAML escapes.
SSDoubleQuoted
| -- | @\'value\'@ — single-quoted.
SSSingleQuoted
| -- | @|@ — literal block scalar.
SSLiteral !Chomping
| -- | @>@ — folded block scalar.
SSFolded !Chomping
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | Chomping indicator for block scalars.
data Chomping = Clip | Strip | Keep
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | How a sequence was (or should be) emitted.
data SeqStyle = SeqBlock | SeqFlow
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | How a mapping was (or should be) emitted.
data MapStyle = MapBlock | MapFlow
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- ---------------------------------------------------------------------------
-- The annotated tree
-- ---------------------------------------------------------------------------
{- | Annotated YAML value.
Carries enough information for the renderer to reproduce the
original source byte-for-byte when nothing has been modified
('avSpan' present and 'avDirty' False) and to fall back to the
'YAML.Pretty.RenderOptions' pretty-printer otherwise.
-}
data AValue = AValue
{ avBody :: !ABody
, avTrivia :: !Trivia
, avSpan :: !(Maybe SrcSpan)
{- ^ The half-open byte range in the original source that
produced this value, if any. 'Nothing' means the value
was constructed fresh and has no original-source backing.
-}
, avNodeSpan :: !(Maybe SrcSpan)
-- ^ Exact flow-node token range, excluding its surrounding entry and trivia.
, avTrailingText :: !Text
-- ^ Container-ending trivia retained when children are edited.
, avAnchorId :: {-# UNPACK #-} !Int
-- ^ Original binding identity on an anchor definition; negative for fresh nodes.
, avDirty :: !Bool
{- ^ Set to 'True' when a sub-tree has been modified; the
renderer falls back to fresh emission for any node with
@'avDirty' == True@ even if 'avSpan' is present.
-}
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | The actual structural body of an 'AValue'.
data ABody
= ANull
| ABool !Bool
| AInt !Integer
| AFloat !Double
| AString !Text !ScalarStyle
| ASeq !(Vector AValue) !SeqStyle
| AMap !(Vector AMapEntry) !MapStyle
| ATagged !Tag !AValue
| AAnchored !Anchor !AValue
| {- | A @*alias@ reference. The annotated layer keeps it
explicit; projection to 'YAML.Value.Value' preserves the
reference as 'YAML.Value.YAlias'.
-}
AAlias !Anchor
| {- | An original alias with its immutable binding identity and resolved
target. The target permits safe materialization if its definition is deleted
or moved after this reference during an edit.
-}
AResolvedAlias {-# UNPACK #-} !Int !Anchor !AValue
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
{- | A mapping entry.
The @amTrailing@ comment is the eol comment on the value line
(if any); per-entry trivia for blank lines and stand-alone
comments lives on the @amKey@'s 'avTrivia'.
-}
data AMapEntry = AMapEntry
{ amKey :: !AValue
, amValue :: !AValue
, amTrailing :: !(Maybe Comment)
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- ---------------------------------------------------------------------------
-- Documents / streams
-- ---------------------------------------------------------------------------
-- | An annotated document.
data ADocument = ADocument
{ adDirectivesEnd :: !Bool
, adExplicitEnd :: !Bool
, adBody :: !AValue
, adLeading :: ![Comment]
{- ^ Comments before the document body (after any preceding
@---@ marker).
-}
, adOriginalSpan :: !(Maybe SrcSpan)
-- ^ Original document bytes, including framing and trailing trivia.
, adTrailing :: ![Comment]
{- ^ Comments after the body (before any @...@ marker or the
next document).
-}
, adDirectives :: ![Text]
-- ^ Original directive lines, retained when edited content is re-emitted.
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
-- | An annotated multi-document stream.
newtype AStream = AStream {aStreamDocs :: Vector ADocument}
deriving stock (Show, Eq, Generic)
deriving newtype (NFData)
-- ---------------------------------------------------------------------------
-- Construction (fresh, un-annotated)
-- ---------------------------------------------------------------------------
freshAValue :: ABody -> AValue
freshAValue b = AValue b emptyTrivia Nothing Nothing "" (-1) False
aNull :: AValue
aNull = freshAValue ANull
aBool :: Bool -> AValue
aBool b = freshAValue (ABool b)
aInt :: Integer -> AValue
aInt n = freshAValue (AInt n)
aFloat :: Double -> AValue
aFloat d = freshAValue (AFloat d)
{- | Construct a string in the renderer's default scalar style
(typically plain unless the body would parse back as some other
core-schema literal, in which case the renderer will quote it).
-}
aString :: Text -> AValue
aString t = freshAValue (AString t SSPlain)
-- | Construct a string with an explicit scalar style.
aStringStyled :: Text -> ScalarStyle -> AValue
aStringStyled t s = freshAValue (AString t s)
aSeq :: [AValue] -> AValue
aSeq xs = freshAValue (ASeq (V.fromList xs) SeqBlock)
aMap :: [(AValue, AValue)] -> AValue
aMap kvs =
freshAValue
(AMap (V.fromList ((\(k, v) -> AMapEntry k v Nothing) <$> kvs)) MapBlock)
aTagged :: Tag -> AValue -> AValue
aTagged t v = freshAValue (ATagged t v)
aAnchored :: Anchor -> AValue -> AValue
aAnchored a v = freshAValue (AAnchored a v)
-- ---------------------------------------------------------------------------
-- Querying
-- ---------------------------------------------------------------------------
abody :: AValue -> ABody
abody = avBody
isDirty :: AValue -> Bool
isDirty = avDirty
{- | Mark a value as dirty so the renderer will re-emit it from
scratch (using 'YAML.Pretty.RenderOptions') instead of copying
the original source bytes.
-}
markDirty :: AValue -> AValue
markDirty v = v {avDirty = True}
{-# INLINE markDirty #-}
{- | Preserve references to definitions that still precede them. References to
deleted or displaced definitions become independent copies of their targets;
immutable identities keep legal anchor-name rebinding unambiguous.
-}
prepareForRendering :: AValue -> AValue
prepareForRendering root
| not (hasAliases root) = root
| otherwise = evalState (go root) HM.empty
where
definitions = collect Nothing HM.empty root
hasAliases value = case avBody value of
AResolvedAlias _ _ _ -> True
ATagged _ inner -> hasAliases inner
AAnchored _ inner -> hasAliases inner
ASeq items _ -> V.any hasAliases items
AMap entries _ -> V.any (\entry -> hasAliases (amKey entry) || hasAliases (amValue entry)) entries
_ -> False
collect outerTag found value = case avBody value of
AAnchored _ inner ->
let target = maybe inner (`aTagged` inner) outerTag
updated = if avAnchorId value < 0 then found else HM.insert (avAnchorId value) target found
in collect Nothing updated inner
ATagged tag inner -> collect (Just tag) found inner
ASeq items _ -> V.foldl' (collect Nothing) found items
AMap entries _ -> V.foldl' (\acc entry -> collect Nothing (collect Nothing acc (amKey entry)) (amValue entry)) found entries
_ -> found
go :: AValue -> State (HM.HashMap Text Int) AValue
go value = case avBody value of
AResolvedAlias identity anchor snapshot -> do
active <- get
if HM.lookup (unAnchor anchor) active == Just identity
then pure value
else pure (materialize HS.empty (HM.lookupDefault snapshot identity definitions)) {avTrivia = avTrivia value}
AAnchored anchor inner -> do
modify' (HM.insert (unAnchor anchor) (avAnchorId value))
updated <- go inner
pure value {avBody = AAnchored anchor updated, avDirty = avDirty value || avDirty updated}
ATagged tag inner -> do
updated <- go inner
pure value {avBody = ATagged tag updated, avDirty = avDirty value || avDirty updated}
ASeq items style -> do
updated <- V.mapM go items
pure value {avBody = ASeq updated style, avDirty = avDirty value || V.any avDirty updated}
AMap entries style -> do
updated <- V.mapM (\entry -> do key <- go (amKey entry); item <- go (amValue entry); pure entry {amKey = key, amValue = item}) entries
pure value {avBody = AMap updated style, avDirty = avDirty value || V.any (\entry -> avDirty (amKey entry) || avDirty (amValue entry)) updated}
_ -> pure value
materialize visited value = case avBody value of
AAnchored _ inner -> materialize visited inner
AResolvedAlias identity _ snapshot ->
let target = if HS.member identity visited then snapshot else HM.lookupDefault snapshot identity definitions
in materialize (HS.insert identity visited) target
body ->
value
{ avSpan = Nothing
, avDirty = True
, avAnchorId = -1
, avBody = case body of
ATagged tag inner -> ATagged tag (materialize visited inner)
ASeq items style -> ASeq (materialize visited <$> items) style
AMap entries style -> AMap ((\entry -> entry {amKey = materialize visited (amKey entry), amValue = materialize visited (amValue entry)}) <$> entries) style
other -> other
}
{- | Project an 'AValue' down to the unannotated 'YAML.Value.Value'
representation. Explicit aliases project to 'YAML.Value.YAlias'.
-}
astripAnnotations :: AValue -> YV.Value
astripAnnotations a = case avBody a of
ANull -> YV.YNull
ABool b -> YV.YBool b
AInt n -> YV.YInt n
AFloat d -> YV.YFloat d
AString t _ -> YV.YString t
ASeq xs _ -> YV.YSeq (V.map astripAnnotations xs)
AMap es _ ->
YV.YMap
( V.map
( \(AMapEntry k v _) ->
(astripAnnotations k, astripAnnotations v)
)
es
)
ATagged tg v -> YV.YTagged tg (astripAnnotations v)
AAnchored an v -> YV.YAnchored an (astripAnnotations v)
AAlias anchor -> YV.YAlias anchor
AResolvedAlias _ anchor _ -> YV.YAlias anchor
-- ---------------------------------------------------------------------------
-- Modification helpers
-- ---------------------------------------------------------------------------
{- | Replace the value at the given mapping key. If the key isn't
present the original mapping is returned unchanged. Marks the
mapping (and only the mapping) as dirty.
-}
setKey :: Text -> AValue -> AValue -> AValue
setKey k newV m = updateKey k (const (Just newV)) m
{- | Update the value at the given mapping key. Returning 'Nothing'
from the function deletes the entry; returning 'Just' replaces
it. If the key isn't present the original mapping is returned.
-}
updateKey :: Text -> (AValue -> Maybe AValue) -> AValue -> AValue
updateKey k f m = case avBody m of
AMap entries style ->
if V.any (matchesKey k . amKey) entries
then markDirty m {avBody = AMap (V.mapMaybe updateEntry entries) style}
else m
ATagged tag inner -> modifyInner (ATagged tag) (updateKey k f) m inner
AAnchored anchor inner -> modifyInner (AAnchored anchor) (updateKey k f) m inner
AResolvedAlias _ _ target -> editAlias (updateKey k f) m target
_ -> m
where
updateEntry entry
| matchesKey k (amKey entry) = (\value -> entry {amValue = preserveStyle (amValue entry) value}) <$> f (amValue entry)
| otherwise = Just entry
matchesKey :: Text -> AValue -> Bool
matchesKey key value = case avBody value of
AString text _ -> text == key
ATagged _ inner -> matchesKey key inner
AAnchored _ inner -> matchesKey key inner
AResolvedAlias _ _ target -> matchesKey key target
_ -> False
modifyInner :: (AValue -> ABody) -> (AValue -> AValue) -> AValue -> AValue -> AValue
modifyInner wrap f outer inner =
let changed = f inner
in if avDirty changed then markDirty outer {avBody = wrap changed} else outer
editAlias :: (AValue -> AValue) -> AValue -> AValue -> AValue
editAlias change reference target =
let updated = change target
in if avDirty updated then markDirty updated {avTrivia = avTrivia reference, avSpan = Nothing} else reference
preserveStyle :: AValue -> AValue -> AValue
preserveStyle old new =
let styled = case (avBody old, avBody new) of
(AString _ style, AString text SSPlain) -> new {avBody = AString text style}
(AAnchored anchor inner, _) | not (hasAnchor new) -> (aAnchored anchor (preserveStyle inner (detachSelf (avAnchorId old) new))) {avAnchorId = avAnchorId old}
(AAnchored anchor _, _) -> inheritIdentity anchor (avAnchorId old) new
(ATagged _ inner, _) -> preserveStyle inner new
_ -> new
in markDirty styled {avTrivia = avTrivia old, avTrailingText = avTrailingText old}
where
hasAnchor value = case avBody value of
AAnchored _ _ -> True
ATagged _ inner -> hasAnchor inner
_ -> False
inheritIdentity anchor identity value = case avBody value of
AAnchored name _ | name == anchor -> value {avAnchorId = identity}
ATagged tag inner -> value {avBody = ATagged tag (inheritIdentity anchor identity inner)}
_ -> value
detachSelf identity value
| not (dependsOn identity value) = value
| otherwise = case avBody value of
AResolvedAlias _ _ target -> detachSelf identity target
ATagged tag inner -> markDirty value {avBody = ATagged tag (detachSelf identity inner)}
AAnchored anchor inner -> markDirty value {avBody = AAnchored anchor (detachSelf identity inner)}
ASeq items style -> markDirty value {avBody = ASeq (detachSelf identity <$> items) style}
AMap entries style -> markDirty value {avBody = AMap ((\entry -> entry {amKey = detachSelf identity (amKey entry), amValue = detachSelf identity (amValue entry)}) <$> entries) style}
_ -> value
dependsOn identity value = case avBody value of
AResolvedAlias original _ target -> original == identity || dependsOn identity target
ATagged _ inner -> dependsOn identity inner
AAnchored _ inner -> dependsOn identity inner
ASeq items _ -> V.any (dependsOn identity) items
AMap entries _ -> V.any (\entry -> dependsOn identity (amKey entry) || dependsOn identity (amValue entry)) entries
_ -> False
-- | Delete a key from a mapping. No-op if absent.
deleteKey :: Text -> AValue -> AValue
deleteKey k m = updateKey k (const Nothing) m
{- | Append a key-value pair to the end of a mapping. If the key
already exists the existing value is left in place; use
'setKey' to replace.
-}
appendKey :: Text -> AValue -> AValue -> AValue
appendKey k v m = case avBody m of
AMap entries style
| V.any (matchesKey k . amKey) entries -> m
| otherwise ->
let entry =
AMapEntry
(markDirty (aString k))
(markDirty v)
Nothing
in markDirty
m
{ avBody = AMap (V.snoc entries entry) style
}
ATagged tag inner -> modifyInner (ATagged tag) (appendKey k v) m inner
AAnchored anchor inner -> modifyInner (AAnchored anchor) (appendKey k v) m inner
AResolvedAlias _ _ target -> editAlias (appendKey k v) m target
_ -> m
{- | Walk a path of mapping keys and apply 'setKey' at the leaf.
The intermediate mappings are also marked dirty so the renderer
knows their layout might need re-flowing.
-}
updateAt :: [Text] -> AValue -> AValue -> AValue
updateAt [] _ m = m
updateAt [k] v m = setKey k v m
updateAt (k : ks) v m = updateKey k (Just . updateAt ks v) m
{- | Replace an item in a sequence. If the index is out of bounds
the sequence is returned unchanged.
-}
setIndex :: Int -> AValue -> AValue -> AValue
setIndex i x s = case avBody s of
ASeq items style
| i >= 0
, i < V.length items ->
markDirty
s
{ avBody = ASeq (items V.// [(i, preserveStyle (items V.! i) x)]) style
}
ATagged tag inner -> modifyInner (ATagged tag) (setIndex i x) s inner
AAnchored anchor inner -> modifyInner (AAnchored anchor) (setIndex i x) s inner
AResolvedAlias _ _ target -> editAlias (setIndex i x) s target
_ -> s
-- | Append an item to the end of a sequence.
appendItem :: AValue -> AValue -> AValue
appendItem x s = case avBody s of
ASeq items style ->
markDirty
s
{ avBody = ASeq (V.snoc items (markDirty x)) style
}
ATagged tag inner -> modifyInner (ATagged tag) (appendItem x) s inner
AAnchored anchor inner -> modifyInner (AAnchored anchor) (appendItem x) s inner
AResolvedAlias _ _ target -> editAlias (appendItem x) s target
_ -> s
-- | Remove an item from a sequence.
deleteIndex :: Int -> AValue -> AValue
deleteIndex i s = case avBody s of
ASeq items style
| i >= 0
, i < V.length items ->
let items' = V.ifilter (\j _ -> j /= i) items
in markDirty s {avBody = ASeq items' style}
ATagged tag inner -> modifyInner (ATagged tag) (deleteIndex i) s inner
AAnchored anchor inner -> modifyInner (AAnchored anchor) (deleteIndex i) s inner
AResolvedAlias _ _ target -> editAlias (deleteIndex i) s target
_ -> s