acolyte-codegen-0.1.0.0: src/Acolyte/Codegen/Proto.hs
-- | Parse proto3 service definitions into the shared API IR.
--
-- Supports enough proto3 syntax to extract services, RPCs, messages,
-- and enums. Does not follow imports or act as a full protobuf compiler.
module Acolyte.Codegen.Proto
( -- * Parsing
parseProtoFile
, parseProtoText
-- * Types
, ProtoFile (..)
, ProtoService (..)
, ProtoRpc (..)
, ProtoMessage (..)
, ProtoField (..)
, ProtoEnum (..)
, ProtoParseError (..)
-- * Conversion to IR
, protoToIR
) where
import Data.Char (isAlphaNum, isAlpha, isDigit, isSpace)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Acolyte.Codegen.IR
-- ===================================================================
-- Types
-- ===================================================================
-- | Errors that can occur when parsing a proto file.
data ProtoParseError
= ProtoSyntaxError !Text
| ProtoUnsupportedSyntax !Text
deriving (Show)
-- | A parsed .proto file.
data ProtoFile = ProtoFile
{ protoSyntax :: !Text -- ^ "proto3"
, protoPackage :: !Text -- ^ Package name
, protoServices :: ![ProtoService]
, protoMessages :: ![ProtoMessage]
, protoEnums :: ![ProtoEnum]
, protoImports :: ![Text]
} deriving (Show)
-- | A service definition.
data ProtoService = ProtoService
{ psName :: !Text
, psRpcs :: ![ProtoRpc]
} deriving (Show)
-- | An RPC method.
data ProtoRpc = ProtoRpc
{ prName :: !Text
, prInputType :: !Text
, prOutputType :: !Text
, prClientStream :: !Bool
, prServerStream :: !Bool
} deriving (Show)
-- | A message definition.
data ProtoMessage = ProtoMessage
{ pmName :: !Text
, pmFields :: ![ProtoField]
} deriving (Show)
-- | A field in a message.
data ProtoField = ProtoField
{ pfFieldType :: !Text
, pfFieldName :: !Text
, pfFieldNumber :: !Int
, pfRepeated :: !Bool
, pfOptional :: !Bool
} deriving (Show)
-- | A basic enum definition.
data ProtoEnum = ProtoEnum
{ peName :: !Text
, peValues :: ![(Text, Int)]
} deriving (Show)
-- ===================================================================
-- Parsing
-- ===================================================================
-- | Parse a .proto file from disk.
parseProtoFile :: FilePath -> IO (Either ProtoParseError ProtoFile)
parseProtoFile path = do
content <- TIO.readFile path
pure (parseProtoText content)
-- | Parse proto3 text content.
parseProtoText :: Text -> Either ProtoParseError ProtoFile
parseProtoText input =
let cleaned = stripComments input
stmts = splitStatements cleaned
in buildProtoFile stmts
-- | Strip // and /* */ comments.
stripComments :: Text -> Text
stripComments = go
where
go t
| T.null t = T.empty
| Just rest <- T.stripPrefix "//" t =
let (_, after) = T.breakOn "\n" rest
in go (T.drop 1 after) -- skip the \n itself
| Just rest <- T.stripPrefix "/*" t =
let (_, after) = T.breakOn "*/" rest
in go (T.drop 2 after) -- skip the */
| otherwise =
case T.uncons t of
Just (c, rest) -> T.cons c (go rest)
Nothing -> T.empty
-- | Split on top-level statements, respecting braces.
-- Returns a list of statement strings, where brace-delimited blocks
-- are kept together.
splitStatements :: Text -> [Text]
splitStatements input = go 0 T.empty (T.unpack input)
where
go :: Int -> Text -> String -> [Text]
go _ acc [] =
let s = T.strip acc
in [s | not (T.null s)]
go depth acc ('{':cs) =
go (depth + 1) (T.snoc acc '{') cs
go depth acc ('}':cs) =
let newDepth = max 0 (depth - 1)
acc' = T.snoc acc '}'
in if newDepth == 0
then let s = T.strip acc'
in [s | not (T.null s)] ++ go 0 T.empty cs
else go newDepth acc' cs
go 0 acc (';':cs) =
let s = T.strip acc
in [s | not (T.null s)] ++ go 0 T.empty cs
go depth acc (c:cs) =
go depth (T.snoc acc c) cs
-- | Build a ProtoFile from parsed top-level statements.
buildProtoFile :: [Text] -> Either ProtoParseError ProtoFile
buildProtoFile stmts =
let syntax = findSyntax stmts
package = findPackage stmts
imports = findImports stmts
services = findServices stmts
messages = findMessages stmts
enums = findEnums stmts
in case syntax of
Just s | s /= "proto3" -> Left (ProtoUnsupportedSyntax s)
_ -> Right ProtoFile
{ protoSyntax = maybe "proto3" id syntax
, protoPackage = maybe "" id package
, protoServices = services
, protoMessages = messages
, protoEnums = enums
, protoImports = imports
}
-- | Extract syntax value.
findSyntax :: [Text] -> Maybe Text
findSyntax [] = Nothing
findSyntax (s:ss)
| "syntax" `T.isPrefixOf` T.stripStart s =
Just (extractQuoted s)
| otherwise = findSyntax ss
-- | Extract package name.
findPackage :: [Text] -> Maybe Text
findPackage [] = Nothing
findPackage (s:ss)
| "package" `T.isPrefixOf` T.stripStart s =
let ws = T.words (T.stripStart s)
in case ws of
(_:name:_) -> Just (T.strip name)
_ -> findPackage ss
| otherwise = findPackage ss
-- | Extract import paths.
findImports :: [Text] -> [Text]
findImports = map extractQuoted . filter (\s -> "import" `T.isPrefixOf` T.stripStart s)
-- | Extract quoted string from a statement like: syntax = "proto3"
extractQuoted :: Text -> Text
extractQuoted t =
let afterQuote = T.drop 1 (snd (T.breakOn "\"" t))
value = fst (T.breakOn "\"" afterQuote)
in value
-- | Parse all service blocks.
findServices :: [Text] -> [ProtoService]
findServices [] = []
findServices (s:ss)
| isServiceBlock s = parseService s : findServices ss
| otherwise = findServices ss
isServiceBlock :: Text -> Bool
isServiceBlock t =
let stripped = T.stripStart t
in "service " `T.isPrefixOf` stripped && T.isInfixOf "{" t
parseService :: Text -> ProtoService
parseService t =
let stripped = T.stripStart t
afterKeyword = T.drop 8 stripped -- drop "service "
(name, rest) = T.break (\c -> c == '{' || isSpace c) (T.stripStart afterKeyword)
body = extractBraceBody rest
rpcs = parseRpcs body
in ProtoService (T.strip name) rpcs
-- | Extract content between first { and last }.
extractBraceBody :: Text -> Text
extractBraceBody t =
let afterOpen = T.drop 1 (snd (T.breakOn "{" t))
-- Find last }
reversed = T.reverse afterOpen
afterClose = T.drop 1 (snd (T.breakOn "}" reversed))
in T.reverse afterClose
-- | Parse rpc statements from a service body.
parseRpcs :: Text -> [ProtoRpc]
parseRpcs body =
let -- Split on "rpc " to find each rpc declaration
parts = T.splitOn "rpc " body
in [ rpc | p <- drop 1 parts, let rpc = parseRpc p ]
parseRpc :: Text -> ProtoRpc
parseRpc t =
let -- Format: MethodName (Input) returns (Output) [;{}]
stripped = T.stripStart t
(name, rest1) = T.break (\c -> c == '(' || isSpace c) stripped
-- Find input type in parens
(clientStream, inputType) = extractParenType rest1
-- Find "returns"
afterReturns = T.drop 1 (snd (T.breakOn "returns" rest1))
rest2 = T.drop 6 afterReturns -- drop "eturns" (we dropped "r" with breakOn)
(serverStream, outputType) = extractParenType (T.stripStart rest2)
in ProtoRpc
{ prName = T.strip name
, prInputType = inputType
, prOutputType = outputType
, prClientStream = clientStream
, prServerStream = serverStream
}
-- | Extract type from parens, detecting "stream" keyword.
-- Input: "(stream Foo)" or "(Foo)"
-- Returns: (isStream, typeName)
extractParenType :: Text -> (Bool, Text)
extractParenType t =
let afterOpen = T.drop 1 (snd (T.breakOn "(" t))
inner = T.strip (fst (T.breakOn ")" afterOpen))
in if "stream " `T.isPrefixOf` inner
then (True, T.strip (T.drop 7 inner))
else (False, inner)
-- | Parse all message blocks.
findMessages :: [Text] -> [ProtoMessage]
findMessages [] = []
findMessages (s:ss)
| isMessageBlock s = parseMessage s : findMessages ss
| otherwise = findMessages ss
isMessageBlock :: Text -> Bool
isMessageBlock t =
let stripped = T.stripStart t
in "message " `T.isPrefixOf` stripped && T.isInfixOf "{" t
parseMessage :: Text -> ProtoMessage
parseMessage t =
let stripped = T.stripStart t
afterKeyword = T.drop 8 stripped -- drop "message "
(name, rest) = T.break (\c -> c == '{' || isSpace c) (T.stripStart afterKeyword)
body = extractBraceBody rest
fields = parseFields body
in ProtoMessage (T.strip name) fields
-- | Parse fields from a message body.
parseFields :: Text -> [ProtoField]
parseFields body =
let lines_ = filter (not . T.null) $ map T.strip $ T.splitOn ";" body
-- Filter out nested messages, enums, reserved, option statements, oneof blocks
fieldLines = filter isFieldLine lines_
in map parseField fieldLines
isFieldLine :: Text -> Bool
isFieldLine t =
let stripped = T.stripStart t
in not (T.null stripped)
&& not ("message " `T.isPrefixOf` stripped)
&& not ("enum " `T.isPrefixOf` stripped)
&& not ("reserved " `T.isPrefixOf` stripped)
&& not ("option " `T.isPrefixOf` stripped)
&& not ("oneof " `T.isPrefixOf` stripped)
&& not ("{" `T.isPrefixOf` stripped)
&& not ("}" `T.isPrefixOf` stripped)
&& T.any isDigit stripped -- must have a field number
&& T.any (== '=') stripped
parseField :: Text -> ProtoField
parseField t =
let ws = T.words (T.stripStart t)
in case ws of
("repeated":typ:name:_:"=":numStr:_) ->
ProtoField typ (cleanName name) (readInt numStr) True False
("repeated":typ:name:"=":numStr:_) ->
ProtoField typ (cleanName name) (readInt numStr) True False
("optional":typ:name:_:"=":numStr:_) ->
ProtoField typ (cleanName name) (readInt numStr) False True
("optional":typ:name:"=":numStr:_) ->
ProtoField typ (cleanName name) (readInt numStr) False True
(typ:name:"=":numStr:_) ->
ProtoField typ (cleanName name) (readInt numStr) False False
-- Handle "type name = num" without spaces around =
(typ:nameEq:_) | T.isInfixOf "=" nameEq ->
let (name, rest') = T.break (== '=') nameEq
numStr = T.strip (T.drop 1 rest')
in ProtoField typ (cleanName name) (readInt numStr) False False
_ -> ProtoField "string" "unknown" 0 False False
cleanName :: Text -> Text
cleanName = T.filter (\c -> isAlphaNum c || c == '_')
readInt :: Text -> Int
readInt t =
let digits = T.takeWhile isDigit (T.filter (\c -> isDigit c || c == '-') t)
in if T.null digits then 0 else read (T.unpack digits)
-- | Parse all enum blocks.
findEnums :: [Text] -> [ProtoEnum]
findEnums [] = []
findEnums (s:ss)
| isEnumBlock s = parseEnum s : findEnums ss
| otherwise = findEnums ss
isEnumBlock :: Text -> Bool
isEnumBlock t =
let stripped = T.stripStart t
in "enum " `T.isPrefixOf` stripped && T.isInfixOf "{" t
parseEnum :: Text -> ProtoEnum
parseEnum t =
let stripped = T.stripStart t
afterKeyword = T.drop 5 stripped -- drop "enum "
(name, rest) = T.break (\c -> c == '{' || isSpace c) (T.stripStart afterKeyword)
body = extractBraceBody rest
values = parseEnumValues body
in ProtoEnum (T.strip name) values
parseEnumValues :: Text -> [(Text, Int)]
parseEnumValues body =
let lines_ = filter (not . T.null) $ map T.strip $ T.splitOn ";" body
valLines = filter (\l -> T.any (== '=') l && not ("option " `T.isPrefixOf` l)) lines_
in map parseEnumValue valLines
parseEnumValue :: Text -> (Text, Int)
parseEnumValue t =
let ws = T.words (T.stripStart t)
in case ws of
(name:"=":numStr:_) -> (name, readInt numStr)
_ -> ("UNKNOWN", 0)
-- ===================================================================
-- Conversion to IR
-- ===================================================================
-- | Convert a parsed proto file to the shared API IR.
protoToIR :: ProtoFile -> ApiIR
protoToIR pf = ApiIR
{ apiTitle = if T.null (protoPackage pf)
then "Proto API"
else protoPackage pf
, apiVersion = "0.0.0"
, apiEndpoints = concatMap serviceEndpoints (protoServices pf)
, apiSchemas = map messageToSchema (protoMessages pf)
++ map enumToSchema (protoEnums pf)
}
-- | Convert a service's RPCs to endpoints.
-- gRPC uses POST for all RPCs, with path /ServiceName/MethodName.
serviceEndpoints :: ProtoService -> [EndpointIR]
serviceEndpoints svc = map (rpcToEndpoint (psName svc)) (psRpcs svc)
rpcToEndpoint :: Text -> ProtoRpc -> EndpointIR
rpcToEndpoint serviceName rpc = EndpointIR
{ epMethod = POST
, epPath = [LitSegment serviceName, LitSegment (prName rpc)]
, epOperationId = Just (prName rpc)
, epSummary = Nothing
, epRequestBody = if isEmptyType (prInputType rpc)
then Nothing
else Just (SchemaRef (prInputType rpc))
, epResponseType = if isEmptyType (prOutputType rpc)
then Nothing
else Just (SchemaRef (prOutputType rpc))
, epStatusCode = 200
, epRequiresAuth = False
}
-- | Check if a type represents "no data" (google.protobuf.Empty).
isEmptyType :: Text -> Bool
isEmptyType t = t == "google.protobuf.Empty" || t == "Empty"
-- | Convert a proto message to a named schema.
messageToSchema :: ProtoMessage -> (Text, SchemaIR)
messageToSchema msg =
( pmName msg
, SchemaObject (pmName msg) (map fieldToFieldIR (pmFields msg))
)
fieldToFieldIR :: ProtoField -> FieldIR
fieldToFieldIR pf =
let baseSchema = protoTypeToSchema (pfFieldType pf)
schema = if pfRepeated pf
then SchemaArray baseSchema
else baseSchema
in FieldIR
{ fieldName = pfFieldName pf
, fieldSchema = schema
, fieldRequired = not (pfOptional pf)
}
-- | Map proto types to schema IR types.
protoTypeToSchema :: Text -> SchemaIR
protoTypeToSchema "string" = SchemaString
protoTypeToSchema "bytes" = SchemaString
protoTypeToSchema "bool" = SchemaBoolean
protoTypeToSchema "int32" = SchemaInteger
protoTypeToSchema "int64" = SchemaInteger
protoTypeToSchema "uint32" = SchemaInteger
protoTypeToSchema "uint64" = SchemaInteger
protoTypeToSchema "sint32" = SchemaInteger
protoTypeToSchema "sint64" = SchemaInteger
protoTypeToSchema "fixed32" = SchemaInteger
protoTypeToSchema "fixed64" = SchemaInteger
protoTypeToSchema "sfixed32" = SchemaInteger
protoTypeToSchema "sfixed64" = SchemaInteger
protoTypeToSchema "float" = SchemaNumber
protoTypeToSchema "double" = SchemaNumber
protoTypeToSchema name = SchemaRef name -- message reference
-- | Convert an enum to a schema (represented as a string with known values).
enumToSchema :: ProtoEnum -> (Text, SchemaIR)
enumToSchema e = (peName e, SchemaString)