packages feed

wireform-yaml-0.1.0.0: src/YAML/Decode/Annotated.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

{- | Annotation-preserving YAML decoder.

Parses YAML to 'AStream' / 'ADocument' / 'AValue' carrying:

* Source-byte spans for each top-level mapping entry / sequence
  item, so unmodified entries can be copied verbatim during
  re-emission.
* Comments and blank-line trivia attached to the entry that
  follows them.
* The original scalar style (plain / quoted / literal block /
  folded block) so that fresh edits keep the surrounding
  document's stylistic choices.

The structural parse delegates to "YAML.Decode" so spec
conformance stays in one place; this module layers a second
pass that walks the source text with a small line scanner.
-}
module YAML.Decode.Annotated (
  decodeAnnotated,
  decodeAnnotatedStream,
  decodeAnnotatedDocument,
) where

import Data.ByteString qualified as BS
import Data.Char (isDigit)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import YAML.Annotated (
  ABody (..),
  ADocument (..),
  AMapEntry (..),
  AStream (..),
  AValue (..),
  Chomping (..),
  Comment (..),
  MapStyle (..),
  ScalarStyle (..),
  SeqStyle (..),
  SrcSpan (..),
  Trivia (..),
 )
import YAML.Annotated qualified
import YAML.Decode qualified as YD
import YAML.Decode.Flow (annotateFlow)
import YAML.Internal (isYamlSpace, stripYaml, stripYamlStart, wordsYaml)
import YAML.Value (Document (..), Value (..))
import YAML.Value qualified


-- ---------------------------------------------------------------------------
-- Public entry points
-- ---------------------------------------------------------------------------

{- | Decode a single-document YAML source into an annotated tree.
The original source 'Text' is returned alongside; the renderer
needs it to copy unchanged sub-trees verbatim.
-}
decodeAnnotated :: Text -> Either String (ADocument, Text)
decodeAnnotated src = do
  (AStream docs, original) <- decodeAnnotatedStream src
  case V.toList docs of
    [d] -> pure (d, original)
    [] -> pure (ADocument False False (annotatePlain YNull) [] (Just (SrcSpan 0 (BS.length (TE.encodeUtf8 src)))) [] [], src)
    _ -> Left "decodeAnnotated: source contains multiple documents; use decodeAnnotatedStream"


-- | Decode documents with spans relative to the original stream.
decodeAnnotatedStream :: Text -> Either String (AStream, Text)
decodeAnnotatedStream src = do
  docs <- YD.decodeLocatedDocuments src
  pure (AStream (V.fromList (fmap annotateLocated docs)), src)
  where
    annotateLocated located =
      let offset = YD.locatedOffset located
          doc = annotateDocument (YD.locatedSource located) (YD.locatedDocument located) (YD.locatedAnnotatedBody located)
          shift (SrcSpan a b) = SrcSpan (a + offset) (b + offset)
          shiftValue av =
            av
              { avSpan = shift <$> avSpan av
              , avNodeSpan = shift <$> avNodeSpan av
              , avBody = case avBody av of
                  ASeq xs style -> ASeq (V.map shiftValue xs) style
                  AMap es style -> AMap (V.map (\e -> e {amKey = shiftValue (amKey e), amValue = shiftValue (amValue e)}) es) style
                  ATagged tag inner -> ATagged tag (shiftValue inner)
                  AAnchored anchor inner -> AAnchored anchor (shiftValue inner)
                  body -> body
              }
      in doc {adBody = shiftValue (adBody doc), adOriginalSpan = shift <$> adOriginalSpan doc}


decodeAnnotatedDocument :: Text -> Either String ADocument
decodeAnnotatedDocument src = fst <$> decodeAnnotated src


-- ---------------------------------------------------------------------------
-- Annotation pass
-- ---------------------------------------------------------------------------

-- | Wrap a parsed 'Document' with annotations.
annotateDocument :: Text -> Document -> AValue -> ADocument
annotateDocument src (Document start end body) presentation =
  let !srcLen = BS.length (TE.encodeUtf8 src)
      !lineIdx = buildLineIndex src
      !av = annotateBody src lineIdx presentation
      !av' = av {avSpan = Just (SrcSpan 0 srcLen)}
  in ADocument
       { adDirectivesEnd = start
       , adExplicitEnd = end
       , adBody = av'
       , adLeading = []
       , adOriginalSpan = Just (SrcSpan 0 srcLen)
       , adTrailing = trailingComments body src
       , adDirectives = filter (T.isPrefixOf "%") (takeWhile (\line -> T.null (stripYaml line) || T.isPrefixOf "#" line || T.isPrefixOf "%" line) (logicalLines src))
       }


-- Keep trailing standalone comments when an edit disables whole-document copying.
trailingComments :: Value -> Text -> [Comment]
trailingComments value src
  | isBlockScalar value = []
  | otherwise = reverse (go (reverse (logicalLines src)))
  where
    -- A root block scalar may contain comment-looking lines at column zero.
    -- Its header is the first content line, possibly preceded by properties.
    isBlockScalar (YTagged _ inner) = isBlockScalar inner
    isBlockScalar (YAnchored _ inner) = isBlockScalar inner
    isBlockScalar (YString _) = case dropWhile isPrologue (logicalLines src) of
      line : _ -> case dropWhile isProperty (wordsYaml line) of
        header : _ -> T.isPrefixOf "|" header || T.isPrefixOf ">" header
        [] -> False
      [] -> False
    isBlockScalar _ = False
    isProperty word = T.isPrefixOf "!" word || T.isPrefixOf "&" word
    isPrologue line =
      let stripped = stripYaml line
      in T.null stripped || T.isPrefixOf "#" stripped || T.isPrefixOf "%" stripped || stripped == "---"
    go [] = []
    go (line : rest)
      | T.null (stripYaml line) = go rest
      | stripYaml line == "..." = go rest
      | Just body <- T.stripPrefix "#" line =
          Comment (T.dropWhile (== ' ') body) 0 : go rest
      | otherwise = []


-- | Wrap top-level structures with per-entry spans + trivia.
annotateBody :: Text -> LineIndex -> AValue -> AValue
annotateBody src lineIdx value
  | Just annotated <- annotateFlow src value = annotated
  | otherwise = case avBody value of
      AMap pairs _
        | not (V.null pairs)
        , Just entries <- locateMapEntries src lineIdx ((\entry -> (amKey entry, amValue entry)) <$> V.toList pairs) ->
            value {avBody = AMap (V.fromList entries) MapBlock}
      ASeq items _
        | not (V.null items)
        , Just entries <- locateSeqItems src lineIdx (V.toList items) ->
            value {avBody = ASeq (V.fromList entries) SeqBlock}
      ATagged tag inner -> value {avBody = ATagged tag (annotateBody src lineIdx inner)}
      AAnchored anchor inner -> value {avBody = AAnchored anchor (annotateBody src lineIdx inner)}
      _ -> styleNode src value


-- ---------------------------------------------------------------------------
-- Trivial annotation (no source spans, default styles)
-- ---------------------------------------------------------------------------

annotatePlain :: Value -> AValue
annotatePlain v = AValue (bodyOf v) emptyTrivia Nothing Nothing "" (-1) False
  where
    bodyOf = \case
      YNull -> ANull
      YBool b -> ABool b
      YInt n -> AInt n
      YFloat d -> AFloat d
      YString t -> AString t (defaultScalarStyle t)
      YSeq xs -> ASeq (V.map annotatePlain xs) SeqBlock
      YMap kvs ->
        AMap
          ( V.map
              ( \(k, vv) ->
                  AMapEntry
                    (annotatePlain k)
                    (annotatePlain vv)
                    Nothing
              )
              kvs
          )
          MapBlock
      YTagged tg inner -> ATagged tg (annotatePlain inner)
      YAnchored a inner -> AAnchored a (annotatePlain inner)
      YAlias a -> AAlias a


emptyTrivia :: Trivia
emptyTrivia = Trivia [] 0 Nothing


defaultScalarStyle :: Text -> ScalarStyle
defaultScalarStyle t
  | T.any (\c -> c == '\n' || c == '\r') t = SSDoubleQuoted
  | otherwise = SSPlain


logicalLines :: Text -> [Text]
logicalLines = T.lines . T.replace "\r" "\n" . T.replace "\r\n" "\n"


propertyOnly :: Text -> Bool
propertyOnly source =
  let strip text
        | T.isPrefixOf "!" text || T.isPrefixOf "&" text = strip (stripYamlStart (T.dropWhile (\c -> c /= ' ' && c /= '\t') text))
        | otherwise = T.null text || T.isPrefixOf "#" text
  in (T.isPrefixOf "!" source || T.isPrefixOf "&" source) && strip source


-- ---------------------------------------------------------------------------
-- Line-byte index
-- ---------------------------------------------------------------------------

{- | A 'LineIndex' is a vector mapping 1-based line numbers to
their UTF-8 byte offsets in the source. Index 0 is unused; the
final element is the byte length of the source so that line
@i@ runs from @lineByte i@ inclusive to @lineByte (i + 1)@
exclusive.
-}
data LineIndex = LineIndex !(V.Vector Int)
  deriving (Show)


buildLineIndex :: Text -> LineIndex
buildLineIndex src =
  let bytes = TE.encodeUtf8 src
      size = BS.length bytes
      starts position
        | position >= size = []
        | BS.index bytes position == 13 =
            let next = if position + 1 < size && BS.index bytes (position + 1) == 10 then position + 2 else position + 1
            in next : starts next
        | BS.index bytes position == 10 = (position + 1) : starts (position + 1)
        | otherwise = starts (position + 1)
  in LineIndex (V.fromList (0 : starts 0 <> [size]))
{-# INLINE buildLineIndex #-}


lineRange :: LineIndex -> Int -> Maybe (Int, Int)
lineRange (LineIndex v) lno
  | lno < 1 || lno >= V.length v = Nothing
  | otherwise = Just (V.unsafeIndex v (lno - 1), V.unsafeIndex v lno)


-- ---------------------------------------------------------------------------
-- Top-level mapping entry locator
-- ---------------------------------------------------------------------------

{- | Walk the source in line order, matching each top-level
mapping key from the parsed structure to a line beginning at
column 0 with that key followed by @:@. For each entry we
record the byte span covering the key line + any continuation
lines (lines indented past column 0). Stand-alone comments and
blank lines that immediately precede the key are attached as
'triviaLeading' / 'triviaBlankLines'.

Returns 'Nothing' if the parsed structure can't be aligned with
the source on a 1:1 basis (e.g. mappings nested under @---@,
multi-document streams). The caller falls back to whole-doc
verbatim in that case.
-}
locateMapEntries
  :: Text
  -> LineIndex
  -> [(AValue, AValue)]
  -> Maybe [AMapEntry]
locateMapEntries src lineIdx kvs = go 1 [] 0 kvs
  where
    LineIndex starts = lineIdx
    !lineCount = V.length starts - 1

    -- 'pendingComments' / 'pendingBlanks' accumulate trivia
    -- waiting to attach to the next key line.
    go !lno !pendingComments !pendingBlanks remaining
      | lno > lineCount =
          if null remaining then Just [] else Nothing
      | otherwise =
          case lineSliceUtf8 src lineIdx lno of
            Nothing -> Nothing
            Just lt ->
              let stripped = stripYamlStart lt
                  indent = T.length lt - T.length stripped
              in -- Skip directive / doc-marker lines.
                 case T.uncons stripped of
                   _
                     | T.null stripped ->
                         go
                           (lno + 1)
                           pendingComments
                           (pendingBlanks + 1)
                           remaining
                   Just ('#', rest) ->
                     let cmt = Comment (stripCommentBody rest) indent
                     in go
                          (lno + 1)
                          (pendingComments ++ [cmt])
                          pendingBlanks
                          remaining
                   _ | propertyOnly stripped -> go (lno + 1) pendingComments pendingBlanks remaining
                   Just ('%', _) ->
                     go (lno + 1) pendingComments pendingBlanks remaining
                   Just ('-', rest)
                     | T.isPrefixOf "--" rest ->
                         go (lno + 1) pendingComments pendingBlanks remaining
                   Just ('.', rest)
                     | T.isPrefixOf ".." rest ->
                         go (lno + 1) pendingComments pendingBlanks remaining
                   _
                     | indent /= 0 ->
                         -- A continuation line outside any entry: bail.
                         Nothing
                   _ ->
                     case remaining of
                       [] -> Nothing -- extra content past the parsed entries
                       ((kv, vv) : rest) ->
                         case matchKeyOnLine stripped kv of
                           Nothing -> Nothing
                           Just _ ->
                             -- Find the entry's span: from start
                             -- of key line through last non-blank
                             -- continuation line.
                             let !entryStartByte = V.unsafeIndex starts (lno - 1)
                                 (endLine, _eolCmt) =
                                   scanEntryLines src lineIdx (lno + 1)
                                 !entryEndByte =
                                   -- Line @endLine@ is the first
                                   -- line that does NOT belong to
                                   -- this entry (or @lineCount + 1@
                                   -- when none follows). Bytes
                                   -- BEFORE that line make up the
                                   -- entry.
                                   V.unsafeIndex
                                     starts
                                     (min (endLine - 1) (V.length starts - 1))
                                 trailingCmt =
                                   parseEolComment src lineIdx lno
                                 trivia =
                                   Trivia
                                     pendingComments
                                     pendingBlanks
                                     Nothing
                                 keyAv =
                                   (styleNode (maybe stripped fst (YD.splitMappingLine stripped)) kv)
                                     { avTrivia = trivia
                                     }
                                 valAv =
                                   annotateContainedValue
                                     src
                                     lineIdx
                                     vv
                                     ( Just
                                         ( SrcSpan
                                             entryStartByte
                                             entryEndByte
                                         )
                                     )
                                 entry =
                                   AMapEntry
                                     keyAv
                                     valAv
                                       { avSpan =
                                           Just
                                             ( SrcSpan
                                                 entryStartByte
                                                 entryEndByte
                                             )
                                       }
                                     trailingCmt
                             in (entry :)
                                  <$> go endLine [] 0 rest


-- ---------------------------------------------------------------------------
-- Top-level sequence item locator
-- ---------------------------------------------------------------------------

locateSeqItems
  :: Text -> LineIndex -> [AValue] -> Maybe [AValue]
locateSeqItems src lineIdx items = go 1 [] 0 items
  where
    LineIndex starts = lineIdx
    !lineCount = V.length starts - 1

    go !lno !pendingComments !pendingBlanks remaining
      | lno > lineCount =
          if null remaining then Just [] else Nothing
      | otherwise =
          case lineSliceUtf8 src lineIdx lno of
            Nothing -> Nothing
            Just lt ->
              let stripped = stripYamlStart lt
                  indent = T.length lt - T.length stripped
              in case T.uncons stripped of
                   _
                     | T.null stripped ->
                         go
                           (lno + 1)
                           pendingComments
                           (pendingBlanks + 1)
                           remaining
                   Just ('#', rest) ->
                     let cmt = Comment (stripCommentBody rest) indent
                     in go
                          (lno + 1)
                          (pendingComments ++ [cmt])
                          pendingBlanks
                          remaining
                   _ | propertyOnly stripped -> go (lno + 1) pendingComments pendingBlanks remaining
                   Just ('%', _) ->
                     go (lno + 1) pendingComments pendingBlanks remaining
                   _ | stripped == "---" || stripped == "..." -> go (lno + 1) pendingComments pendingBlanks remaining
                   Just ('-', after) | T.null after || T.head after == ' ' ->
                     case remaining of
                       [] -> Nothing
                       (vv : rest) ->
                         let !entryStartByte = V.unsafeIndex starts (lno - 1)
                             (endLine, _) =
                               scanEntryLines src lineIdx (lno + 1)
                             !entryEndByte =
                               V.unsafeIndex
                                 starts
                                 (min (endLine - 1) (V.length starts - 1))
                             av =
                               annotateContainedValue
                                 src
                                 lineIdx
                                 vv
                                 ( Just
                                     ( SrcSpan
                                         entryStartByte
                                         entryEndByte
                                     )
                                 )
                             av' =
                               av
                                 { avTrivia =
                                     Trivia
                                       pendingComments
                                       pendingBlanks
                                       (case avBody vv of AMap _ _ -> Nothing; ASeq _ _ -> Nothing; _ -> parseEolComment src lineIdx lno)
                                 , avSpan =
                                     Just
                                       ( SrcSpan
                                           entryStartByte
                                           entryEndByte
                                       )
                                 }
                         in (av' :) <$> go endLine [] 0 rest
                   _ -> Nothing
                     where
                       !_ = indent -- silence unused


{- | An entry's body extends until the next non-indented line.
Blank lines and stand-alone comments belong to the NEXT entry's
trivia, not this one.
-}
scanEntryLines :: Text -> LineIndex -> Int -> (Int, Maybe Comment)
scanEntryLines src lineIdx@(LineIndex starts) start = go start
  where
    !lineCount = V.length starts - 1
    scalarEntry = maybe False isScalarHeader (lineSliceUtf8 src lineIdx (start - 1))
    isScalarHeader source =
      let row = stripYamlStart source
          item = if T.isPrefixOf "- " row then T.drop 2 row else row
          value = maybe item snd (YD.splitMappingLine item)
          stripProps text = if T.isPrefixOf "!" text || T.isPrefixOf "&" text then stripProps (stripYamlStart (T.dropWhile (\c -> c /= ' ' && c /= '\t') text)) else text
      in case T.uncons (stripProps (stripYamlStart value)) of Just (c, _) -> c == '|' || c == '>'; Nothing -> False
    go !lno
      | lno > lineCount = (lineCount + 1, Nothing)
      | otherwise =
          case lineSliceUtf8 src lineIdx lno of
            Nothing -> (lno, Nothing)
            Just lt ->
              let stripped = stripYamlStart lt
                  indent = T.length lt - T.length stripped
                  laterIndented = lookAhead (lno + 1)
              in if (not (T.null stripped) && indent > 0) || (T.null stripped && (scalarEntry || laterIndented))
                   then go (lno + 1)
                   else (lno, Nothing)
    lookAhead line
      | line > lineCount = False
      | otherwise = case lineSliceUtf8 src lineIdx line of
          Just text
            | T.null (stripYaml text) -> lookAhead (line + 1)
            | otherwise -> T.length text > T.length (stripYamlStart text)
          Nothing -> False


-- Recursively annotate each entry using a dedented source view, then translate
-- its byte spans back into the original document. This preserves comments and
-- quoting at every block level, including compact sequence-of-mapping items.
annotateContainedValue :: Text -> LineIndex -> AValue -> Maybe SrcSpan -> AValue
annotateContainedValue src _ value Nothing = styleNode src value
annotateContainedValue src _ value span_@(Just (SrcSpan begin end)) =
  let chunk = TE.decodeUtf8 (BS.take (end - begin) (BS.drop begin (TE.encodeUtf8 src)))
      chunkIndex@(LineIndex originalStarts) = buildLineIndex chunk
      rows = (\line -> maybe "" id (lineSliceUtf8 chunk chunkIndex line)) <$> [1 .. V.length originalStarts - 1]
      first = case rows of line : _ -> line; [] -> ""
      stripped = stripYamlStart first
      isItem = stripped == "-" || T.isPrefixOf "- " stripped
      payload =
        if isItem
          then stripYamlStart (T.drop 1 stripped)
          else maybe stripped (stripYamlStart . snd) (YD.splitMappingLine stripped)
      firstRemoved = BS.length (TE.encodeUtf8 first) - BS.length (TE.encodeUtf8 payload)
      remainder = drop 1 rows
      significant = filter (\line -> not (T.null (stripYaml line)) && not (T.isPrefixOf "#" (stripYamlStart line))) remainder
      base = case significant of line : _ -> T.length line - T.length (stripYamlStart line); [] -> 0
      inline = not (T.null payload) && not (T.isPrefixOf "#" payload)
      -- A compact map starts immediately after a sequence dash. Otherwise
      -- inline content is a scalar/flow node; block children start next line.
      compact = isItem && inline && case avBody value of AMap _ _ -> True; _ -> False
      useFirst = inline || compact
      removed line = min base (T.length (T.takeWhile (== ' ') line))
      normalizedRows = (if useFirst then [payload] else []) <> ((\line -> T.drop (removed line) line) <$> remainder)
      rowNumbers = (if useFirst then [0] else []) <> [1 .. length rows - 1]
      prefixes = (if useFirst then [firstRemoved] else []) <> (removed <$> remainder)
      view = T.intercalate "\n" normalizedRows
      viewStarts = V.fromList (scanl (\offset line -> offset + BS.length (TE.encodeUtf8 line) + 1) 0 normalizedRows)
      sourceStarts = V.fromList ((begin +) . (originalStarts V.!) <$> rowNumbers)
      prefixBytes = V.fromList prefixes
      translate isEnd offset
        | V.null sourceStarts = begin
        | otherwise =
            let row = min (V.length sourceStarts - 1) (findRow offset 0 (V.length viewStarts - 1))
                column = offset - viewStarts V.! row
                prefix = if isEnd && column == 0 && row > 0 then 0 else prefixBytes V.! row
            in sourceStarts V.! row + prefix + column
      findRow offset low high
        | low >= high = low
        | otherwise =
            let middle = (low + high + 1) `quot` 2
            in if viewStarts V.! middle <= offset then findRow offset middle high else findRow offset low (middle - 1)
      shift (SrcSpan a b) = SrcSpan (translate False a) (translate True b)
      relocate av =
        av
          { avSpan = shift <$> avSpan av
          , avNodeSpan = shift <$> avNodeSpan av
          , avBody = case avBody av of
              AMap entries style -> AMap (V.map (\entry -> entry {amKey = relocate (amKey entry), amValue = relocate (amValue entry)}) entries) style
              ASeq items style -> ASeq (V.map relocate items) style
              ATagged tag inner -> ATagged tag (relocate inner)
              AAnchored anchor inner -> AAnchored anchor (relocate inner)
              body -> body
          }
      annotated = annotateBody view (buildLineIndex view) value
      childEnds = case avBody annotated of
        AMap entries _ -> srcEnd <$> V.toList (V.mapMaybe (avSpan . amValue) entries)
        ASeq items _ -> srcEnd <$> V.toList (V.mapMaybe avSpan items)
        _ -> []
      suffix = case childEnds of
        [] -> ""
        positions -> TE.decodeUtf8 (BS.drop (maximum positions) (TE.encodeUtf8 view))
  in (relocate annotated) {avSpan = span_, avTrailingText = if isFlow annotated then avTrailingText annotated else suffix}
  where
    isFlow av = case avBody av of
      AMap _ MapFlow -> True
      ASeq _ SeqFlow -> True
      ATagged _ inner -> isFlow inner
      AAnchored _ inner -> isFlow inner
      _ -> False


styleNode :: Text -> AValue -> AValue
styleNode source av = av {avBody = apply (avBody av)}
  where
    line = case filter (\text -> let t = stripYaml text in not (T.null t) && not (T.isPrefixOf "#" t) && not (T.isPrefixOf "%" t) && t /= "---") (logicalLines source) of
      first : _ -> stripYamlStart first
      [] -> ""
    content = stripProperties line
    stripProperties text
      | T.isPrefixOf "!" text || T.isPrefixOf "&" text = stripProperties (stripYamlStart (T.dropWhile (\c -> c /= ' ' && c /= '\t') text))
      | otherwise = text
    chomp
      | T.isInfixOf "-" content = Strip
      | T.isInfixOf "+" content = Keep
      | otherwise = Clip
    apply (AString text _) =
      AString
        text
        ( case T.uncons content of
            Just ('\'', _) -> SSSingleQuoted
            Just ('"', _) -> SSDoubleQuoted
            Just ('|', _) -> SSLiteral chomp
            Just ('>', _) -> SSFolded chomp
            _ -> defaultScalarStyle text
        )
    apply (AMap entries _) | T.isPrefixOf "{" content = AMap entries MapFlow
    apply (ASeq items _) | T.isPrefixOf "[" content = ASeq items SeqFlow
    apply (ATagged tag inner) = ATagged tag (styleNode content inner)
    apply (AAnchored anchor inner) = AAnchored anchor (styleNode content inner)
    apply body = body


-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------

lineSliceUtf8 :: Text -> LineIndex -> Int -> Maybe Text
lineSliceUtf8 src lineIdx lno = do
  (s, e) <- lineRange lineIdx lno
  let bs = TE.encodeUtf8 src
  let raw = BS.take (max 0 (e - s)) (BS.drop s bs)
      bs' = case BS.unsnoc raw of
        Just (init', 0x0A) -> init'
        _ -> raw
      bs'' = case BS.unsnoc bs' of
        Just (init', 0x0D) -> init'
        _ -> bs'
  Right t <- pure (TE.decodeUtf8' bs'')
  pure t


{- | Match a top-level key name against the start of a stripped
line. Recognises plain / single-quoted / double-quoted scalar
keys; bails out for anything more exotic.
-}
matchKeyOnLine :: Text -> AValue -> Maybe Text
matchKeyOnLine line key = do
  (raw, _) <- YD.splitMappingLine line
  case avBody key of
    AResolvedAlias _ (YAML.Value.Anchor name) _ | raw == "*" <> name -> Just line
    _ -> do
      parsed <- either (const Nothing) Just (YD.decode raw)
      if unwrapKey parsed == unwrapKey (YAML.Annotated.astripAnnotations key) then Just line else Nothing
  where
    unwrapKey (YTagged _ value) = unwrapKey value
    unwrapKey (YAnchored _ value) = unwrapKey value
    unwrapKey value = value


stripCommentBody :: Text -> Text
stripCommentBody t = T.dropWhile (== ' ') t


parseEolComment :: Text -> LineIndex -> Int -> Maybe Comment
parseEolComment src lineIdx lno = do
  lt <- lineSliceUtf8 src lineIdx lno
  -- Find an unquoted '#' preceded by whitespace.
  let go !i !inSq !inDq
        | i >= T.length lt = Nothing
        | otherwise =
            let c = T.index lt i
            in case c of
                 '\'' | not inDq -> go (i + 1) (not inSq) inDq
                 '"' | not inSq -> go (i + 1) inSq (not inDq)
                 '#'
                   | not inSq
                   , not inDq
                   , i > 0
                   , isYamlSpace (T.index lt (i - 1)) ->
                       Just
                         ( Comment
                             (stripCommentBody (T.drop (i + 1) lt))
                             i
                         )
                 _ -> go (i + 1) inSq inDq
  go 0 False False


_isDigit :: Char -> Bool
_isDigit = isDigit