packages feed

wireform-proto-0.2.0.0: src/Proto/IDL/Print.hs

{-# LANGUAGE BangPatterns #-}

{- | Pretty-printing and exact-printing for protobuf ASTs.

Two modes:

* 'printProtoFile' — pretty-print a semantic AST with normalized
  formatting (2-space indent). Useful for code generation,
  formatting, and debugging.

* 'exactPrint' — reproduce the original source byte-for-byte for
  unmodified 'Parsed'-phase nodes, falling back to pretty-printing
  for modified or programmatically constructed nodes.
-}
module Proto.IDL.Print (
  -- * Pretty-printing (normalized formatting)
  printProtoFile,
  printTopLevel,
  printMessage,
  printEnum,
  printService,
  printField,
  printOption,
  printConstant,
  printComment,
  printCommentBlock,

  -- * Exact-printing (source-faithful)
  exactPrint,
  clearSpan,
) where

import Data.List (sortBy)
import Data.Ord (comparing)
import Data.Text (Text)
import Data.Text qualified as T
import Proto.IDL.AST


-- | Render a complete proto file to source text.
printProtoFile :: ProtoFile -> Text
printProtoFile pf =
  T.intercalate "\n" $
    filter (not . T.null) $
      [printSyntax (protoSyntax pf)]
        <> maybe [] (\pkg -> ["", "package " <> pkg <> ";"]) (protoPackage pf)
        <> (if null (protoImports pf) then [] else "" : fmap printImport (protoImports pf))
        <> (if null (protoOptions pf) then [] else "" : fmap printTopLevelOption (protoOptions pf))
        <> concatMap (\tl -> ["", printTopLevel tl]) (protoTopLevels pf)
        <> [""]


printSyntax :: Syntax -> Text
printSyntax Proto2 = "syntax = \"proto2\";"
printSyntax Proto3 = "syntax = \"proto3\";"
printSyntax (Editions ed) = "edition = \"" <> editionName ed <> "\";"


printImport :: ImportDef -> Text
printImport (ImportDef _span modifier path) =
  "import " <> maybe "" printImportMod modifier <> "\"" <> path <> "\";"


printImportMod :: ImportModifier -> Text
printImportMod ImportPublic = "public "
printImportMod ImportWeak = "weak "


printTopLevelOption :: OptionDef -> Text
printTopLevelOption opt = "option " <> printOptionAssignment opt <> ";"


-- | Render a top-level definition.
printTopLevel :: TopLevel -> Text
printTopLevel = \case
  TLMessage msg -> printMessage 0 msg
  TLEnum ed -> printEnum 0 ed
  TLService svc -> printService svc
  TLExtend name fields ->
    "extend "
      <> name
      <> " {\n"
      <> T.concat (fmap (\f -> indent 1 <> printField f <> "\n") fields)
      <> "}"
  TLOption opt -> "option " <> printOptionAssignment opt <> ";"
  TLComment cs -> printCommentBlock 0 cs


-- | Render a message definition at a given indentation depth.
printMessage :: Int -> MessageDef -> Text
printMessage depth msg =
  printDoc depth (msgDoc msg)
    <> indent depth
    <> "message "
    <> msgName msg
    <> " {\n"
    <> T.concat (fmap (printMessageElement (depth + 1)) (msgElements msg))
    <> indent depth
    <> "}"


printMessageElement :: Int -> MessageElement -> Text
printMessageElement depth = \case
  MEField fd -> printDoc depth (fieldDoc fd) <> indent depth <> printField fd <> "\n"
  MEEnum ed -> printEnum depth ed <> "\n"
  MEMessage msg -> printMessage depth msg <> "\n"
  MEOneof od -> printOneof depth od <> "\n"
  MEMapField mf -> printDoc depth (mapDoc mf) <> indent depth <> printMapField mf <> "\n"
  MEReserved rd -> indent depth <> printReserved rd <> "\n"
  MEExtensions exs opts -> indent depth <> "extensions " <> printExtensionRanges exs <> printFieldOptions opts <> ";\n"
  MEExtend name fields ->
    indent depth
      <> "extend "
      <> name
      <> " {\n"
      <> T.concat (fmap (\f -> indent (depth + 1) <> printField f <> "\n") fields)
      <> indent depth
      <> "}\n"
  MEOption opt -> indent depth <> "option " <> printOptionAssignment opt <> ";\n"
  MEComment cs -> printCommentBlock depth cs <> "\n"


-- | Render a field definition.
printField :: FieldDef -> Text
printField fd =
  maybe "" (\l -> printLabel l <> " ") (fieldLabel fd)
    <> printFieldType (fieldType fd)
    <> " "
    <> fieldName fd
    <> " = "
    <> intToText (unFieldNumber (fieldNumber fd))
    <> printFieldOptions (fieldOptions fd)
    <> ";"


printLabel :: FieldLabel -> Text
printLabel Optional = "optional"
printLabel Required = "required"
printLabel Repeated = "repeated"


printFieldType :: FieldType -> Text
printFieldType = \case
  FTScalar s -> printScalarType s
  FTNamed n -> n


printScalarType :: ScalarType -> Text
printScalarType = \case
  SDouble -> "double"
  SFloat -> "float"
  SInt32 -> "int32"
  SInt64 -> "int64"
  SUInt32 -> "uint32"
  SUInt64 -> "uint64"
  SSInt32 -> "sint32"
  SSInt64 -> "sint64"
  SFixed32 -> "fixed32"
  SFixed64 -> "fixed64"
  SSFixed32 -> "sfixed32"
  SSFixed64 -> "sfixed64"
  SBool -> "bool"
  SString -> "string"
  SBytes -> "bytes"


printMapField :: MapField -> Text
printMapField mf =
  "map<"
    <> printScalarType (mapKeyType mf)
    <> ", "
    <> printFieldType (mapValueType mf)
    <> "> "
    <> mapFieldName mf
    <> " = "
    <> intToText (unFieldNumber (mapFieldNum mf))
    <> printFieldOptions (mapOptions mf)
    <> ";"


printOneof :: Int -> OneofDef -> Text
printOneof depth od =
  printDoc depth (oneofDoc od)
    <> indent depth
    <> "oneof "
    <> oneofName od
    <> " {\n"
    <> T.concat (fmap (\opt -> indent (depth + 1) <> "option " <> printOptionAssignment opt <> ";\n") (oneofOptions od))
    <> T.concat (fmap (\f -> printDoc (depth + 1) (oneofFieldDoc f) <> indent (depth + 1) <> printOneofField f <> "\n") (oneofFields od))
    <> indent depth
    <> "}"


printOneofField :: OneofField -> Text
printOneofField f =
  printFieldType (oneofFieldType f)
    <> " "
    <> oneofFieldName f
    <> " = "
    <> intToText (unFieldNumber (oneofFieldNumber f))
    <> printFieldOptions (oneofFieldOptions f)
    <> ";"


printFieldOptions :: [OptionDef] -> Text
printFieldOptions [] = ""
printFieldOptions opts =
  " [" <> T.intercalate ", " (fmap printOptionAssignment opts) <> "]"


-- | Render an enum definition.
printEnum :: Int -> EnumDef -> Text
printEnum depth ed =
  printDoc depth (enumDoc ed)
    <> indent depth
    <> "enum "
    <> enumName ed
    <> " {\n"
    <> T.concat (fmap (\opt -> indent (depth + 1) <> "option " <> printOptionAssignment opt <> ";\n") (enumOptions ed))
    <> T.concat (fmap (\v -> printDoc (depth + 1) (evDoc v) <> indent (depth + 1) <> printEnumValue v <> "\n") (enumValues ed))
    <> indent depth
    <> "}"


printEnumValue :: EnumValue -> Text
printEnumValue ev =
  evName ev
    <> " = "
    <> intToText (evNumber ev)
    <> printFieldOptions (evOptions ev)
    <> ";"


-- | Render a service definition.
printService :: ServiceDef -> Text
printService svc =
  printDoc 0 (svcDoc svc)
    <> "service "
    <> svcName svc
    <> " {\n"
    <> T.concat (fmap (\opt -> indent 1 <> "option " <> printOptionAssignment opt <> ";\n") (svcOptions svc))
    <> T.concat (fmap (\rpc -> printDoc 1 (rpcDoc rpc) <> indent 1 <> printRpc rpc <> "\n") (svcRpcs svc))
    <> "}"


printRpc :: RpcDef -> Text
printRpc rpc =
  "rpc "
    <> rpcName rpc
    <> " ("
    <> printStream (rpcInputStr rpc)
    <> rpcInput rpc
    <> ")"
    <> " returns ("
    <> printStream (rpcOutputStr rpc)
    <> rpcOutput rpc
    <> ")"
    <> case rpcOptions rpc of
      [] -> ";"
      opts ->
        " {\n"
          <> T.concat (fmap (\opt -> indent 2 <> "option " <> printOptionAssignment opt <> ";\n") opts)
          <> indent 1
          <> "}"


printStream :: StreamQualifier -> Text
printStream NoStream = ""
printStream Streaming = "stream "


-- | Render an option assignment (name = value).
printOption :: OptionDef -> Text
printOption = printOptionAssignment


printOptionAssignment :: OptionDef -> Text
printOptionAssignment opt =
  printOptionName (optName opt) <> " = " <> printConstant (optValue opt)


printOptionName :: OptionName -> Text
printOptionName (OptionName parts) = T.intercalate "." (fmap printOptionNamePart parts)


printOptionNamePart :: OptionNamePart -> Text
printOptionNamePart = \case
  SimpleOption name -> name
  ExtensionOption name -> "(" <> name <> ")"


-- | Render a constant value.
printConstant :: Constant -> Text
printConstant = \case
  CIdent t -> t
  CInt n -> integerToText n
  CFloat d -> T.pack (show d)
  CString s -> "\"" <> escapeProtoString s <> "\""
  CBool True -> "true"
  CBool False -> "false"
  CAggregate kvs -> "{ " <> T.intercalate " " (fmap printAggField kvs) <> " }"
  where
    printAggField (k, v) = k <> ": " <> printConstant v


escapeProtoString :: Text -> Text
escapeProtoString = T.concatMap escChar
  where
    escChar '"' = "\\\""
    escChar '\\' = "\\\\"
    escChar '\n' = "\\n"
    escChar '\r' = "\\r"
    escChar '\t' = "\\t"
    escChar c = T.singleton c


printReserved :: ReservedDef -> Text
printReserved = \case
  ReservedNumbers ranges ->
    "reserved " <> T.intercalate ", " (fmap printReservedRange ranges) <> ";"
  ReservedNames names ->
    "reserved " <> T.intercalate ", " (fmap printReservedName names) <> ";"


printReservedName :: ReservedName -> Text
printReservedName = \case
  QuotedReservedName n -> "\"" <> escapeProtoString n <> "\""
  IdentReservedName n -> n


printReservedRange :: ReservedRange -> Text
printReservedRange = \case
  ReservedSingle n -> intToText n
  ReservedRange lo hi -> intToText lo <> " to " <> intToText hi


printExtensionRanges :: [ExtensionRange] -> Text
printExtensionRanges = T.intercalate ", " . fmap printExtRange
  where
    printExtRange er =
      intToText (extStart er)
        <> case extEnd er of
          ExtBoundNum n | n == extStart er -> ""
          ExtBoundNum n -> " to " <> intToText n
          ExtBoundMax -> " to max"


-- | Render a doc comment as proto // lines at a given indentation.
printDoc :: Int -> Maybe Text -> Text
printDoc _ Nothing = ""
printDoc depth (Just doc) =
  T.concat (fmap (\l -> indent depth <> "// " <> l <> "\n") (T.lines doc))


-- | Render a block of comments at a given indentation.
printCommentBlock :: Int -> [Comment] -> Text
printCommentBlock depth cs =
  T.concat (fmap (\c -> indent depth <> printComment c <> "\n") cs)


-- | Render a single comment.
printComment :: Comment -> Text
printComment (LineComment content) = "//" <> content
printComment (BlockComment content) = "/*" <> content <> "*/"


indent :: Int -> Text
indent n = T.replicate (n * 2) " "


intToText :: Int -> Text
intToText n
  | n < 0 = "-" <> intToText (negate n)
  | n < 10 = T.singleton (toEnum (n + 48))
  | otherwise = go T.empty n
  where
    go !acc 0 = acc
    go !acc v =
      let (!q, !r) = v `quotRem` 10
      in go (T.cons (toEnum (r + 48)) acc) q


integerToText :: Integer -> Text
integerToText n
  | n < 0 = "-" <> integerToText (negate n)
  | n < 10 = T.singleton (toEnum (fromIntegral n + 48))
  | otherwise = go T.empty n
  where
    go !acc 0 = acc
    go !acc v =
      let (!q, !r) = v `quotRem` 10
      in go (T.cons (toEnum (fromIntegral r + 48)) acc) q


-- -----------------------------------------------------------------------
-- Exact-printing
-- -----------------------------------------------------------------------

{- | Exact-print a parsed proto file.

If the file was parsed with 'Proto.IDL.Parser.parseProtoFileWithSpans'
and has not been modified, reproduces the original source
byte-for-byte. Modified nodes (with cleared spans) are
pretty-printed. Falls back to full pretty-print if no source
text is available.
-}
exactPrint :: ProtoFile' Parsed -> Text
exactPrint pf = case protoSource pf of
  Nothing -> printProtoFile (stripSpans pf)
  Just src -> reassemble src pf


{- | Clear a span, marking the node as modified so 'exactPrint'
will pretty-print it instead of using the original source.
-}
clearSpan :: Span -> Span
clearSpan _ = noSpan


data Region
  = Original {-# UNPACK #-} !Int {-# UNPACK #-} !Int
  | Replacement !Text


reassemble :: Text -> ProtoFile' Parsed -> Text
reassemble src pf =
  let regions = collectRegions pf
      sorted = sortBy (comparing regionStart) regions
  in emitRegions src 0 sorted


regionStart :: Region -> Int
regionStart (Original s _) = s
regionStart (Replacement _) = maxBound


emitRegions :: Text -> Int -> [Region] -> Text
emitRegions src !cursor [] = T.drop cursor src
emitRegions src !cursor (r : rs) = case r of
  Original start end ->
    let gap = sliceText cursor start src
        body = sliceText start end src
    in gap <> body <> emitRegions src end rs
  Replacement txt ->
    txt <> emitRegions src cursor rs


sliceText :: Int -> Int -> Text -> Text
sliceText start end src = T.take (end - start) (T.drop start src)


collectRegions :: ProtoFile' Parsed -> [Region]
collectRegions pf =
  concatMap importRegion (protoImports pf)
    <> concatMap optionRegion (protoOptions pf)
    <> concatMap topLevelRegion (protoTopLevels pf)


importRegion :: ImportDef' Parsed -> [Region]
importRegion imp = spanToRegion (importExt imp) Nothing


optionRegion :: OptionDef' Parsed -> [Region]
optionRegion opt = spanToRegion (optExt opt) Nothing


topLevelRegion :: TopLevel' Parsed -> [Region]
topLevelRegion tl = case tl of
  TLMessage m -> spanToRegion (msgExt m) (Just $ printTopLevel (TLMessage (stripMessage m)))
  TLEnum e -> spanToRegion (enumExt e) (Just $ printTopLevel (TLEnum (stripEnum e)))
  TLService s -> spanToRegion (svcExt s) (Just $ printTopLevel (TLService (stripService s)))
  TLExtend _ _ -> [Replacement (printTopLevel (stripTopLevel tl))]
  TLOption o -> spanToRegion (optExt o) Nothing
  TLComment _ -> [] -- Comment nodes are reconstructed from source gaps by exactPrint


spanToRegion :: Span -> Maybe Text -> [Region]
spanToRegion (Span (Just (SrcSpan s e))) _ = [Original s e]
spanToRegion (Span Nothing) (Just txt) = [Replacement txt]
spanToRegion (Span Nothing) Nothing = []