packages feed

wireform-proto-0.1.0.0: src/Proto/IDL/Parser.hs

{- | Parser for the Protocol Buffers IDL (.proto files).

Supports proto2, proto3, and Editions (2023+) syntax including messages,
enums, services, oneofs, map fields, extensions, imports, packages,
and custom options.
-}
module Proto.IDL.Parser (
  parseProtoFile,
  parseProtoFileWithSpans,
  parseProto,
  Parser,
  renderParseError,
  renderParseErrors,
) where

import Control.Monad (void)
import Data.IntMap.Strict qualified as IntMap
import Data.IntSet (IntSet)
import Data.IntSet qualified as IntSet
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Void (Void)
import Proto.IDL.AST
import Proto.IDL.AST.Span (Span, SrcSpan (..), mkSpan, noSpan)
import Proto.IDL.Parser.Error (renderParseError, renderParseErrors)
import Proto.IDL.Parser.Lexer
import Text.Megaparsec hiding (option)
import Text.Megaparsec.Char ()


-- | Get the doc comment for the current source position from the comment map.
getDoc :: CommentMap -> Parser (Maybe Text)
getDoc cm = do
  pos <- getSourcePos
  let line = unPos (sourceLine pos)
  pure (lookupDoc cm line)


{- | Parse a .proto file, returning a semantic AST (no source spans).
This is the standard entry point for codegen, analysis, etc.
-}
parseProtoFile :: FilePath -> Text -> Either (ParseErrorBundle Text Void) ProtoFile
parseProtoFile fp src = stripSpans <$> parseProtoFileWithSpans fp src


{- | Parse a .proto file, returning a 'Parsed'-phase AST with source
spans and the original source text. Use this for exactprint.
-}
parseProtoFileWithSpans :: FilePath -> Text -> Either (ParseErrorBundle Text Void) (ProtoFile' Parsed)
parseProtoFileWithSpans fp src =
  let cm = buildCommentMap src
      allComments = buildAllComments src
  in case parse (parseProto cm) fp src of
      Left e -> Left e
      Right pf ->
        let pf' = insertComments src allComments cm pf
        in Right pf' {protoSource = Just src}


-- | Core parser for a proto file given a pre-built comment map.
parseProto :: CommentMap -> Parser (ProtoFile' Parsed)
parseProto cm = do
  sc
  syn <- option Proto3 syntaxOrEdition
  stmts <- many (topLevelStmt cm)
  eof
  let pkg = firstJust (\case TLStmtPackage p -> Just p; _ -> Nothing) stmts
  let imps = concatMap (\case TLStmtImport i -> [i]; _ -> []) stmts
  let opts = concatMap (\case TLStmtOption o -> [o]; _ -> []) stmts
  let topDefs = concatMap (\case TLStmtTopLevel t -> [t]; _ -> []) stmts
  pure
    ProtoFile
      { protoSyntax = syn
      , protoPackage = pkg
      , protoImports = imps
      , protoOptions = opts
      , protoTopLevels = topDefs
      , protoSource = Nothing
      }


firstJust :: (a -> Maybe b) -> [a] -> Maybe b
firstJust _ [] = Nothing
firstJust f (x : xs) = case f x of
  Just v -> Just v
  Nothing -> firstJust f xs


data TLStmt
  = TLStmtPackage Text
  | TLStmtImport (ImportDef' Parsed)
  | TLStmtOption (OptionDef' Parsed)
  | TLStmtTopLevel (TopLevel' Parsed)


syntaxOrEdition :: Parser Syntax
syntaxOrEdition = syntaxDecl <|> editionDecl


syntaxDecl :: Parser Syntax
syntaxDecl = do
  reserved "syntax"
  equals
  s <- stringLiteral
  case s of
    "proto2" -> semi >> pure Proto2
    "proto3" -> semi >> pure Proto3
    _ ->
      fail
        ( "unknown syntax \""
            <> T.unpack s
            <> "\": expected \"proto2\" or \"proto3\""
        )


editionDecl :: Parser Syntax
editionDecl = do
  reserved "edition"
  equals
  ed <- stringLiteral
  semi
  pure (Editions (Edition ed))


topLevelStmt :: CommentMap -> Parser TLStmt
topLevelStmt cm = do
  start <- getOffset
  doc <- getDoc cm
  kw <- lookAhead (identifier <?> "top-level declaration (message, enum, service, import, package, or option)")
  case kw of
    "package" -> TLStmtPackage <$> packageDecl
    "import" -> TLStmtImport <$> importDecl start
    "option" -> TLStmtOption <$> optionDecl start
    "message" -> TLStmtTopLevel . TLMessage <$> messageDef cm doc start
    "enum" -> TLStmtTopLevel . TLEnum <$> enumDef cm doc start
    "service" -> TLStmtTopLevel . TLService <$> serviceDef cm doc start
    "extend" -> TLStmtTopLevel <$> extendDef cm
    _ ->
      fail
        ( "unexpected keyword '"
            <> T.unpack kw
            <> "', expected one of: message, enum, service, import, package, option, or extend"
        )


extendDef :: CommentMap -> Parser (TopLevel' Parsed)
extendDef cm = do
  reserved "extend"
  name <- fullIdent
  fields <- braces (many (fieldDef cm))
  pure (TLExtend name fields)


packageDecl :: Parser Text
packageDecl = do
  reserved "package"
  pkg <- fullIdent
  semi
  pure pkg


importDecl :: Int -> Parser (ImportDef' Parsed)
importDecl start = do
  reserved "import"
  modifier <-
    optional $
      choice
        [ ImportPublic <$ reserved "public"
        , ImportWeak <$ reserved "weak"
        ]
  path <- stringLiteral
  semi
  end <- getOffset
  pure ImportDef {importExt = mkSpan start end, importModifier = modifier, importPath = path}


optionDecl :: Int -> Parser (OptionDef' Parsed)
optionDecl start = do
  reserved "option"
  opt <- optionAssignment
  semi
  end <- getOffset
  pure opt {optExt = mkSpan start end}


optionAssignment :: Parser (OptionDef' Parsed)
optionAssignment = do
  start <- getOffset
  name <- optionName
  equals
  val <- constant
  end <- getOffset
  pure OptionDef {optExt = mkSpan start end, optName = name, optValue = val}


optionName :: Parser OptionName
optionName = do
  first <- optionNamePart
  rest <- many (symbol "." *> simpleOptionPart)
  pure OptionName {optNameParts = first : rest}
  where
    simpleOptionPart = SimpleOption <$> identifier


optionNamePart :: Parser OptionNamePart
optionNamePart =
  choice
    [ ExtensionOption <$> parens fullIdent
    , SimpleOption <$> identifier
    ]


constant :: Parser Constant
constant =
  choice
    [ CBool <$> try boolLiteral
    , CString <$> try stringLiteral
    , try (CFloat <$> floatLiteral)
    , CInt <$> try intLiteral
    , CAggregate <$> aggregateLiteral
    , CIdent <$> fullIdent
    ]
    <?> "constant value (string, number, boolean, identifier, or aggregate)"


aggregateLiteral :: Parser [(Text, Constant)]
aggregateLiteral = braces (many aggregateField)
  where
    aggregateField = do
      key <- identifier
      -- Both "key: value" and "key { ... }" are valid
      val <- (symbol ":" *> constant) <|> (CAggregate <$> aggregateLiteral)
      _ <- optional (comma <|> semi)
      pure (key, val)


messageDef :: CommentMap -> Maybe Text -> Int -> Parser (MessageDef' Parsed)
messageDef cm doc start = do
  reserved "message"
  name <- identifier <?> "message name"
  elems <- braces (many (messageElement cm))
  end <- getOffset
  pure MessageDef {msgExt = mkSpan start end, msgDoc = doc, msgName = name, msgElements = elems}


messageElement :: CommentMap -> Parser (MessageElement' Parsed)
messageElement cm = do
  start <- getOffset
  doc <- getDoc cm
  kw <- lookAhead (identifier <?> "message element (field, enum, message, oneof, map, option, reserved, or extensions)")
  case kw of
    "reserved" -> MEReserved <$> reservedDecl
    "extensions" -> MEExtensions <$> extensionsDecl
    "option" -> MEOption <$> optionDecl start
    "enum" -> MEEnum <$> enumDef cm doc start
    "message" -> MEMessage <$> messageDef cm doc start
    "oneof" -> MEOneof <$> oneofDef cm doc start
    "map" -> MEMapField <$> mapFieldDef cm doc start
    _ -> MEField <$> fieldDef' doc start


fieldDef :: CommentMap -> Parser (FieldDef' Parsed)
fieldDef cm = do
  start <- getOffset
  doc <- getDoc cm
  fieldDef' doc start


fieldDef' :: Maybe Text -> Int -> Parser (FieldDef' Parsed)
fieldDef' doc start = do
  lbl <-
    optional
      ( choice
          [ try (Optional <$ reserved "optional")
          , try (Required <$ reserved "required")
          , try (Repeated <$ reserved "repeated")
          ]
          <?> "field label (optional, required, or repeated)"
      )
  ft <- parseFieldType
  name <- identifier <?> "field name"
  equals
  num <- FieldNumber . fromIntegral <$> (intLiteral <?> "field number")
  opts <- fieldOptionList
  semi
  end <- getOffset
  pure
    FieldDef
      { fieldExt = mkSpan start end
      , fieldDoc = doc
      , fieldLabel = lbl
      , fieldType = ft
      , fieldName = name
      , fieldNumber = num
      , fieldOptions = opts
      }


parseFieldType :: Parser FieldType
parseFieldType =
  choice
    [ FTScalar SDouble <$ reserved "double"
    , FTScalar SFloat <$ reserved "float"
    , FTScalar SInt32 <$ reserved "int32"
    , FTScalar SInt64 <$ reserved "int64"
    , FTScalar SUInt32 <$ reserved "uint32"
    , FTScalar SUInt64 <$ reserved "uint64"
    , FTScalar SSInt32 <$ reserved "sint32"
    , FTScalar SSInt64 <$ reserved "sint64"
    , FTScalar SFixed32 <$ reserved "fixed32"
    , FTScalar SFixed64 <$ reserved "fixed64"
    , FTScalar SSFixed32 <$ reserved "sfixed32"
    , FTScalar SSFixed64 <$ reserved "sfixed64"
    , FTScalar SBool <$ reserved "bool"
    , FTScalar SString <$ reserved "string"
    , FTScalar SBytes <$ reserved "bytes"
    , FTNamed <$> fullIdent
    ]
    <?> "field type (double, float, int32, int64, string, bytes, bool, or message/enum name)"


mapFieldDef :: CommentMap -> Maybe Text -> Int -> Parser (MapField' Parsed)
mapFieldDef _cm doc start = do
  reserved "map"
  (kt, vt) <- angles $ do
    k <- scalarType
    comma
    v <- parseFieldType
    pure (k, v)
  name <- identifier
  equals
  num <- FieldNumber . fromIntegral <$> intLiteral
  opts <- fieldOptionList
  semi
  end <- getOffset
  pure
    MapField
      { mapExt = mkSpan start end
      , mapDoc = doc
      , mapKeyType = kt
      , mapValueType = vt
      , mapFieldName = name
      , mapFieldNum = num
      , mapOptions = opts
      }


scalarType :: Parser ScalarType
scalarType =
  choice
    [ SDouble <$ reserved "double"
    , SFloat <$ reserved "float"
    , SInt32 <$ reserved "int32"
    , SInt64 <$ reserved "int64"
    , SUInt32 <$ reserved "uint32"
    , SUInt64 <$ reserved "uint64"
    , SSInt32 <$ reserved "sint32"
    , SSInt64 <$ reserved "sint64"
    , SFixed32 <$ reserved "fixed32"
    , SFixed64 <$ reserved "fixed64"
    , SSFixed32 <$ reserved "sfixed32"
    , SSFixed64 <$ reserved "sfixed64"
    , SBool <$ reserved "bool"
    , SString <$ reserved "string"
    , SBytes <$ reserved "bytes"
    ]
    <?> "scalar type (double, float, int32, int64, uint32, uint64, sint32, sint64, fixed32, fixed64, sfixed32, sfixed64, bool, string, or bytes)"


oneofDef :: CommentMap -> Maybe Text -> Int -> Parser (OneofDef' Parsed)
oneofDef cm doc start = do
  reserved "oneof"
  name <- identifier
  (fields, opts) <- braces $ do
    items <- many (Left <$> try oneofOption <|> Right <$> oneofField cm)
    let os = concatMap (either (: []) (const [])) items
    let fs = concatMap (either (const []) (: [])) items
    pure (fs, os)
  _ <- optional semi
  end <- getOffset
  pure
    OneofDef
      { oneofExt = mkSpan start end
      , oneofDoc = doc
      , oneofName = name
      , oneofFields = fields
      , oneofOptions = opts
      }
  where
    oneofOption = do
      s <- getOffset
      optionDecl s
    oneofField cm' = do
      s <- getOffset
      doc' <- getDoc cm'
      ft <- parseFieldType
      name <- identifier
      equals
      num <- FieldNumber . fromIntegral <$> intLiteral
      opts <- fieldOptionList
      semi
      e <- getOffset
      pure
        OneofField
          { oneofFieldExt = mkSpan s e
          , oneofFieldDoc = doc'
          , oneofFieldType = ft
          , oneofFieldName = name
          , oneofFieldNumber = num
          , oneofFieldOptions = opts
          }


fieldOptionList :: Parser [OptionDef' Parsed]
fieldOptionList = fromMaybe [] <$> optional (brackets (optionAssignment `sepBy1` comma))


reservedDecl :: Parser ReservedDef
reservedDecl = do
  reserved "reserved"
  res <- try reservedNames <|> reservedNumbers
  semi
  pure res
  where
    reservedNames = ReservedNames <$> (stringLiteral `sepBy1` comma)
    reservedNumbers = ReservedNumbers <$> (reservedRange `sepBy1` comma)
    reservedRange = do
      start <- fromIntegral <$> intLiteral
      end' <- optional (reserved "to" *> (Nothing <$ reserved "max" <|> Just . fromIntegral <$> intLiteral))
      case end' of
        Nothing -> pure (ReservedSingle start)
        Just Nothing -> pure (ReservedRange start 536870911) -- max field number
        Just (Just e) -> pure (ReservedRange start e)


extensionsDecl :: Parser [ExtensionRange]
extensionsDecl = do
  reserved "extensions"
  ranges <- extensionRange `sepBy1` comma
  -- proto2 syntax allows a trailing options block, e.g.
  --
  --   extensions 1000 to max [
  --     declaration = { number: 1000, full_name: ".foo", type: ".Foo" },
  --     declaration = { ... }
  --   ];
  --
  -- These options carry registration metadata (which is interesting
  -- only to protoc / descriptor-pool consumers, not the wire codecs we
  -- emit), so for now we parse-and-discard them rather than extending
  -- 'ExtensionRange' with a new field. The names+numbers we *do* keep
  -- are the only things downstream codegen looks at.
  _opts <- fieldOptionList
  semi
  pure ranges
  where
    extensionRange = do
      start <- fromIntegral <$> intLiteral
      end' <- optional (reserved "to" *> (ExtBoundMax <$ reserved "max" <|> ExtBoundNum . fromIntegral <$> intLiteral))
      pure
        ExtensionRange
          { extStart = start
          , extEnd = fromMaybe (ExtBoundNum start) end'
          }


data EnumItem = EIOption (OptionDef' Parsed) | EIValue (EnumValue' Parsed) | EIReserved


enumDef :: CommentMap -> Maybe Text -> Int -> Parser (EnumDef' Parsed)
enumDef cm doc start = do
  reserved "enum"
  name <- identifier
  items <- braces (many (enumItem cm))
  let vals = mapMaybe (\case EIValue v -> Just v; _ -> Nothing) items
      opts = mapMaybe (\case EIOption o -> Just o; _ -> Nothing) items
  end <- getOffset
  pure
    EnumDef
      { enumExt = mkSpan start end
      , enumDoc = doc
      , enumName = name
      , enumValues = vals
      , enumOptions = opts
      }


enumItem :: CommentMap -> Parser EnumItem
enumItem cm =
  choice
    [ EIOption <$> try (getOffset >>= optionDecl)
    , EIReserved <$ try enumReservedDecl
    , EIValue <$> enumValueDef cm
    ]
    <?> "enum value, option, or reserved declaration"


enumReservedDecl :: Parser ()
enumReservedDecl = do
  reserved "reserved"
  _ <- (stringLiteral `sepBy1` comma) <|> (fmap (T.pack . show) <$> (intLiteral `sepBy1` comma))
  semi


enumValueDef :: CommentMap -> Parser (EnumValue' Parsed)
enumValueDef cm = do
  start <- getOffset
  doc <- getDoc cm
  name <- identifier
  equals
  num <- fromIntegral <$> intLiteral
  opts <- fieldOptionList
  semi
  end <- getOffset
  pure
    EnumValue
      { evExt = mkSpan start end
      , evDoc = doc
      , evName = name
      , evNumber = num
      , evOptions = opts
      }


serviceDef :: CommentMap -> Maybe Text -> Int -> Parser (ServiceDef' Parsed)
serviceDef cm doc start = do
  reserved "service"
  name <- identifier
  (rpcs, opts) <- braces $ do
    items <- many (Left <$> try (getOffset >>= optionDecl) <|> Right <$> rpcDef cm)
    pure
      ( concatMap (either (const []) (: [])) items
      , concatMap (either (: []) (const [])) items
      )
  end <- getOffset
  pure
    ServiceDef
      { svcExt = mkSpan start end
      , svcDoc = doc
      , svcName = name
      , svcRpcs = rpcs
      , svcOptions = opts
      }


rpcDef :: CommentMap -> Parser (RpcDef' Parsed)
rpcDef cm = do
  start <- getOffset
  doc <- getDoc cm
  reserved "rpc"
  name <- identifier
  void (symbol "(")
  inStream <- option NoStream (Streaming <$ reserved "stream")
  inType <- fullIdent
  void (symbol ")")
  reserved "returns"
  void (symbol "(")
  outStream <- option NoStream (Streaming <$ reserved "stream")
  outType <- fullIdent
  void (symbol ")")
  opts <- rpcBody <|> ([] <$ semi)
  _ <- optional semi
  end <- getOffset
  pure
    RpcDef
      { rpcExt = mkSpan start end
      , rpcDoc = doc
      , rpcName = name
      , rpcInput = inType
      , rpcInputStr = inStream
      , rpcOutput = outType
      , rpcOutputStr = outStream
      , rpcOptions = opts
      }
  where
    rpcBody = braces (many (getOffset >>= optionDecl >>= \o -> optional semi >> pure o))


-- -----------------------------------------------------------------------
-- Comment insertion post-pass
-- -----------------------------------------------------------------------

{- | Build a mapping from byte offset to 1-based line number.
Returns a function that converts a byte offset to its line number.
-}
offsetToLine :: Text -> Int -> Int
offsetToLine src offset =
  let prefix = T.take offset src
  in 1 + T.count "\n" prefix


{- | Compute the set of 1-based line numbers that are "claimed" as doc
comments by the CommentMap.  For each entry @(defLine, docText)@, the
comment lines that precede @defLine@ and were merged into @docText@
are claimed.
-}
claimedDocLines :: Text -> CommentMap -> IntSet
claimedDocLines src cm =
  let allLines = zip [1 ..] (T.lines src)
      defLines = IntMap.keys cm
  in IntSet.fromList (concatMap (claimedFor allLines) defLines)
  where
    claimedFor allLines defLine =
      -- Walk backwards from defLine-1, claiming comment and blank lines
      -- until we hit a non-comment, non-blank line.
      let preceding = reverse $ take (defLine - 1) allLines
      in claimBackward preceding []

    claimBackward [] acc = acc
    claimBackward ((ln, t) : rest) acc
      | isCommentLine t = claimBackward rest (ln : acc)
      | isBlankLine t = claimBackward rest acc -- blank lines between comments and def
      | otherwise = acc

    isCommentLine t = "//" `T.isPrefixOf` T.stripStart t
    isBlankLine t = T.null (T.strip t)


{- | Insert standalone comment nodes into a parsed proto file.
Comments that are NOT claimed as doc comments for any definition
become 'TLComment' (at top level) or 'MEComment' (inside messages).
-}
insertComments :: Text -> [LocComment] -> CommentMap -> ProtoFile' Parsed -> ProtoFile' Parsed
insertComments src allComments cm pf =
  let claimed = claimedDocLines src cm
      unclaimed = filter (\lc -> not (IntSet.member (lcLine lc) claimed)) allComments
      -- Insert unclaimed comments at the top level
      topLevels' = insertTopLevelComments src unclaimed (protoTopLevels pf)
  in pf {protoTopLevels = topLevels'}


-- | Get the start line of a top-level definition from its span.
topLevelStartLine :: Text -> TopLevel' Parsed -> Maybe Int
topLevelStartLine src = \case
  TLMessage m -> spanLine src (msgExt m)
  TLEnum e -> spanLine src (enumExt e)
  TLService s -> spanLine src (svcExt s)
  TLOption o -> spanLine src (optExt o)
  TLExtend _ _ -> Nothing -- extends don't have a span on the TLExtend itself
  TLComment _ -> Nothing


-- | Get the end line of a top-level definition from its span.
topLevelEndLine :: Text -> TopLevel' Parsed -> Maybe Int
topLevelEndLine src = \case
  TLMessage m -> spanEndLine src (msgExt m)
  TLEnum e -> spanEndLine src (enumExt e)
  TLService s -> spanEndLine src (svcExt s)
  TLOption o -> spanEndLine src (optExt o)
  TLExtend _ _ -> Nothing
  TLComment _ -> Nothing


spanLine :: Text -> Span -> Maybe Int
spanLine src (Span (Just (SrcSpan s _))) = Just (offsetToLine src s)
spanLine _ _ = Nothing


spanEndLine :: Text -> Span -> Maybe Int
spanEndLine src (Span (Just (SrcSpan _ e))) = Just (offsetToLine src (max 0 (e - 1)))
spanEndLine _ _ = Nothing


-- | Insert unclaimed comments between top-level definitions.
insertTopLevelComments :: Text -> [LocComment] -> [TopLevel' Parsed] -> [TopLevel' Parsed]
insertTopLevelComments _ [] tls = tls
insertTopLevelComments src unclaimed tls =
  let
    -- For each gap between definitions, find unclaimed comments that belong there
    result = go 0 tls
  in
    result
  where
    go :: Int -> [TopLevel' Parsed] -> [TopLevel' Parsed]
    go afterLine [] =
      -- Trailing comments after the last definition
      let trailing = filter (\lc -> lcLine lc > afterLine) unclaimed
          groups = groupConsecutive trailing
      in fmap (TLComment . fmap lcComment) groups
    go afterLine (tl : rest) =
      let startLine = fromMaybe maxBound (topLevelStartLine src tl)
          endLine = fromMaybe afterLine (topLevelEndLine src tl)
          -- Comments between afterLine and startLine that are unclaimed
          between = filter (\lc -> lcLine lc > afterLine && lcLine lc < startLine) unclaimed
          groups = groupConsecutive between
          commentNodes = fmap (TLComment . fmap lcComment) groups
          -- Also insert comments inside messages
          tl' = insertCommentsInTopLevel src unclaimed tl
      in commentNodes ++ [tl'] ++ go endLine rest


-- | Insert comments inside a top-level definition (recursively into messages).
insertCommentsInTopLevel :: Text -> [LocComment] -> TopLevel' Parsed -> TopLevel' Parsed
insertCommentsInTopLevel src unclaimed = \case
  TLMessage m -> TLMessage (insertCommentsInMessage src unclaimed m)
  other -> other


-- | Insert unclaimed comments between message elements.
insertCommentsInMessage :: Text -> [LocComment] -> MessageDef' Parsed -> MessageDef' Parsed
insertCommentsInMessage src unclaimed m =
  let msgStart = case msgExt m of
        Span (Just (SrcSpan s _)) -> offsetToLine src s
        _ -> 0
      msgEnd = case msgExt m of
        Span (Just (SrcSpan _ e)) -> offsetToLine src (max 0 (e - 1))
        _ -> maxBound
      -- Only consider unclaimed comments within the message's span
      relevant = filter (\lc -> lcLine lc > msgStart && lcLine lc < msgEnd) unclaimed
      elems' = insertMessageElementComments src relevant (msgElements m)
  in m {msgElements = elems'}


-- | Get the start/end line of a message element from its span.
msgElemStartLine :: Text -> MessageElement' Parsed -> Maybe Int
msgElemStartLine src = \case
  MEField f -> spanLine src (fieldExt f)
  MEEnum e -> spanLine src (enumExt e)
  MEMessage m -> spanLine src (msgExt m)
  MEOneof o -> spanLine src (oneofExt o)
  MEMapField mf -> spanLine src (mapExt mf)
  MEOption o -> spanLine src (optExt o)
  MEReserved _ -> Nothing
  MEExtensions _ -> Nothing
  MEComment _ -> Nothing


msgElemEndLine :: Text -> MessageElement' Parsed -> Maybe Int
msgElemEndLine src = \case
  MEField f -> spanEndLine src (fieldExt f)
  MEEnum e -> spanEndLine src (enumExt e)
  MEMessage m -> spanEndLine src (msgExt m)
  MEOneof o -> spanEndLine src (oneofExt o)
  MEMapField mf -> spanEndLine src (mapExt mf)
  MEOption o -> spanEndLine src (optExt o)
  MEReserved _ -> Nothing
  MEExtensions _ -> Nothing
  MEComment _ -> Nothing


insertMessageElementComments :: Text -> [LocComment] -> [MessageElement' Parsed] -> [MessageElement' Parsed]
insertMessageElementComments _ [] elems = elems
insertMessageElementComments src unclaimed elems = go 0 elems
  where
    go :: Int -> [MessageElement' Parsed] -> [MessageElement' Parsed]
    go afterLine [] =
      let trailing = filter (\lc -> lcLine lc > afterLine) unclaimed
          groups = groupConsecutive trailing
      in fmap (MEComment . fmap lcComment) groups
    go afterLine (el : rest) =
      let startLine = fromMaybe maxBound (msgElemStartLine src el)
          endLine = fromMaybe afterLine (msgElemEndLine src el)
          between = filter (\lc -> lcLine lc > afterLine && lcLine lc < startLine) unclaimed
          groups = groupConsecutive between
          commentNodes = fmap (MEComment . fmap lcComment) groups
          -- Recurse into nested messages
          el' = case el of
            MEMessage inner -> MEMessage (insertCommentsInMessage src unclaimed inner)
            _ -> el
      in commentNodes ++ [el'] ++ go endLine rest


{- | Group consecutive located comments (no gap of more than 1 line between them)
into blocks.
-}
groupConsecutive :: [LocComment] -> [[LocComment]]
groupConsecutive [] = []
groupConsecutive (lc : rest) = go [lc] (lcLine lc) rest
  where
    go acc _ [] = [reverse acc]
    go acc prevLine (x : xs)
      | lcLine x <= prevLine + 2 = go (x : acc) (lcLine x) xs -- allow 1 blank line gap
      | otherwise = reverse acc : go [x] (lcLine x) xs