This is an automated email from the ASF dual-hosted git repository.

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new dc9f3e7a0 feat(gpRc): update FDL lexer/parser to support service and 
rpc definitions (#3308)
dc9f3e7a0 is described below

commit dc9f3e7a033e34262d4507ac358de26b1f67797b
Author: Ayush Kumar <[email protected]>
AuthorDate: Mon Feb 9 00:00:22 2026 +0530

    feat(gpRc): update FDL lexer/parser to support service and rpc definitions 
(#3308)
    
    ## Why?
    
    Using fory with gRPCs forced using the protobuffs for the services and
    methods of a class. Users needed to define separate files for generating
    stubs. Which turns their focus on using protobuff completely, ignoring
    the fory's performance.
    
    ## What does this PR do?
    Adds the parser for rpc methods and services to allow users use FDL with
    gRPC.
    
    ### Added Keywords
    `service`
    `rpc`
    `returns`
    `stream`
    
    ### Changes Made
    
    #### 1. compiler/fory_compiler/ir/ast.py
    Added `RpcMethod` and `Service` Data classes.
    ```python
    @dataclass
    class Service:
        """A service definition."""
    
        name: str
        methods: List[RpcMethod] = field(default_factory=list)
        options: dict = field(default_factory=dict)
        line: int = 0
        column: int = 0
        location: Optional[SourceLocation] = None
    
        def __repr__(self) -> str:
            opts_str = f", options={len(self.options)}" if self.options else ""
            return f"Service({self.name}, 
methods={len(self.methods)}{opts_str})"
    
    ```
    ### 2. compiler/fory_compiler/frontend/fdl/lexer.py
    
    - Added the `SERVICE` `RPC` `RETURNS` `STREAM` to the `TokenType`  enum
    - Mapped Keywords in `Lexer.Keywords`
    ```python
        SERVICE = auto()
        RPC = auto()
        RETURNS = auto()
        STREAM = auto()
    ```
    ```python
       "service": TokenType.SERVICE,
       "rpc": TokenType.RPC,
       "returns": TokenType.RETURNS,
       "stream": TokenType.STREAM,
    ```
    
    ### 3. compiler/fory_compiler/frontend/fdl/parser.py
    - Added the `parse_service` and `parse_rpc_method` method.
    ```python
        def parse_service(self) -> Service:
            """Parse a service definition: service Greeter { rpc ... }"""
            start = self.current()
            self.consume(TokenType.SERVICE)
            name = self.consume(TokenType.IDENT, "Expected service name").value
            self.consume(TokenType.LBRACE, "Expected '{' after service name")
    .....
    ```
    
    ```python
        def parse_rpc_method(self) -> RpcMethod:
            """Parse an RPC method: rpc Name (stream? Req) returns (stream? 
Res) { option ... };"""
            start = self.current()
            self.consume(TokenType.RPC)
            name = self.consume(TokenType.IDENT, "Expected method name").value
    
            # Parse request type
            self.consume(TokenType.LPAREN, "Expected '(' before request type")
            client_streaming = False
            if self.check(TokenType.STREAM):
                self.advance()
                client_streaming = True
    
    ```
    
    ### 4. Done the same updates just a slight different based on
    implementations for the protobuffs and flatbuffers.
    
    ## Related issues
    - Closes #3268
    
    ## Does this PR introduce any user-facing change?
    
    - [x] **Does this PR introduce any public API change?**
    This PR extends the FDL language with support for service definitions
    using `service` `rpc` `returns` and `stream` keywords. This allows users
    to define gRPC services directly in their FDL schemas.
    - [ ] **Does this PR introduce any binary protocol compatibility
    change?**
    ## Benchmark
    N/A
    ---
---
 compiler/fory_compiler/frontend/fbs/ast.py         |  24 +++
 compiler/fory_compiler/frontend/fbs/lexer.py       |   9 ++
 compiler/fory_compiler/frontend/fbs/parser.py      |  75 ++++++++-
 compiler/fory_compiler/frontend/fbs/translator.py  |  51 +++++++
 compiler/fory_compiler/frontend/fdl/lexer.py       |   8 +
 compiler/fory_compiler/frontend/fdl/parser.py      | 121 +++++++++++++++
 compiler/fory_compiler/frontend/proto/ast.py       |  26 ++++
 compiler/fory_compiler/frontend/proto/lexer.py     |   2 +
 compiler/fory_compiler/frontend/proto/parser.py    |  96 ++++++++++--
 .../fory_compiler/frontend/proto/translator.py     |  40 +++++
 compiler/fory_compiler/ir/ast.py                   |  45 +++++-
 compiler/fory_compiler/tests/test_fbs_service.py   | 109 +++++++++++++
 compiler/fory_compiler/tests/test_fdl_service.py   | 170 +++++++++++++++++++++
 compiler/fory_compiler/tests/test_proto_service.py | 122 +++++++++++++++
 14 files changed, 885 insertions(+), 13 deletions(-)

diff --git a/compiler/fory_compiler/frontend/fbs/ast.py 
b/compiler/fory_compiler/frontend/fbs/ast.py
index 17e0f4c1a..ad3bde947 100644
--- a/compiler/fory_compiler/frontend/fbs/ast.py
+++ b/compiler/fory_compiler/frontend/fbs/ast.py
@@ -109,6 +109,29 @@ class FbsStruct:
     column: int = 0
 
 
+@dataclass
+class FbsRpcMethod:
+    """An RPC method declaration."""
+
+    name: str
+    request_type: str
+    response_type: str
+    attributes: Dict[str, object] = field(default_factory=dict)
+    line: int = 0
+    column: int = 0
+
+
+@dataclass
+class FbsService:
+    """A FlatBuffers service declaration."""
+
+    name: str
+    methods: List[FbsRpcMethod] = field(default_factory=list)
+    attributes: Dict[str, object] = field(default_factory=dict)
+    line: int = 0
+    column: int = 0
+
+
 @dataclass
 class FbsSchema:
     """The root node representing a FlatBuffers schema."""
@@ -120,5 +143,6 @@ class FbsSchema:
     unions: List[FbsUnion] = field(default_factory=list)
     tables: List[FbsTable] = field(default_factory=list)
     structs: List[FbsStruct] = field(default_factory=list)
+    services: List[FbsService] = field(default_factory=list)
     root_type: Optional[str] = None
     source_file: Optional[str] = None
diff --git a/compiler/fory_compiler/frontend/fbs/lexer.py 
b/compiler/fory_compiler/frontend/fbs/lexer.py
index f70b5a95a..a392ab983 100644
--- a/compiler/fory_compiler/frontend/fbs/lexer.py
+++ b/compiler/fory_compiler/frontend/fbs/lexer.py
@@ -38,6 +38,10 @@ class TokenType(Enum):
     FILE_EXTENSION = auto()
     TRUE = auto()
     FALSE = auto()
+    SERVICE = auto()
+    RPC = auto()
+    RETURNS = auto()
+    STREAM = auto()
 
     # Literals
     IDENT = auto()
@@ -97,6 +101,11 @@ class Lexer:
         "file_extension": TokenType.FILE_EXTENSION,
         "true": TokenType.TRUE,
         "false": TokenType.FALSE,
+        "service": TokenType.SERVICE,
+        "rpc_service": TokenType.SERVICE,
+        "rpc": TokenType.RPC,
+        "returns": TokenType.RETURNS,
+        "stream": TokenType.STREAM,
     }
 
     PUNCTUATION = {
diff --git a/compiler/fory_compiler/frontend/fbs/parser.py 
b/compiler/fory_compiler/frontend/fbs/parser.py
index 5c84e008e..67013c26e 100644
--- a/compiler/fory_compiler/frontend/fbs/parser.py
+++ b/compiler/fory_compiler/frontend/fbs/parser.py
@@ -30,6 +30,8 @@ from fory_compiler.frontend.fbs.ast import (
     FbsTypeName,
     FbsTypeRef,
     FbsVectorType,
+    FbsService,
+    FbsRpcMethod,
 )
 from fory_compiler.frontend.fbs.lexer import Token, TokenType
 
@@ -96,7 +98,9 @@ class Parser:
         enums: List[FbsEnum] = []
         unions: List[FbsUnion] = []
         tables: List[FbsTable] = []
+        tables: List[FbsTable] = []
         structs: List[FbsStruct] = []
+        services: List[FbsService] = []
         root_type: Optional[str] = None
 
         while not self.at_end():
@@ -122,6 +126,8 @@ class Parser:
                 self.parse_file_extension()
             elif self.check(TokenType.UNION):
                 unions.append(self.parse_union())
+            elif self.check(TokenType.SERVICE) or self.check(TokenType.RPC):
+                services.append(self.parse_service())
             elif self.check(TokenType.SEMI):
                 self.advance()
             else:
@@ -135,6 +141,7 @@ class Parser:
             unions=unions,
             tables=tables,
             structs=structs,
+            services=services,
             root_type=root_type,
             source_file=self.filename,
         )
@@ -369,7 +376,11 @@ class Parser:
         if self.match(TokenType.FALSE):
             return False
         if self.check(TokenType.INT):
-            return int(self.advance().value, 0)
+            value = self.advance().value
+            try:
+                return int(value, 0)
+            except ValueError:
+                return int(value)
         if self.check(TokenType.FLOAT):
             return float(self.advance().value)
         if self.check(TokenType.STRING):
@@ -377,3 +388,65 @@ class Parser:
         if self.check(TokenType.IDENT):
             return self.advance().value
         raise self.error("Expected value")
+
+    def parse_service(self) -> FbsService:
+        start = self.current()
+        # Support both 'service' and 'rpc_service' keywords for defining 
services.
+        if self.check(TokenType.SERVICE):
+            self.advance()
+        elif self.check(TokenType.RPC):
+            # 'rpc_service' is mapped to TokenType.SERVICE in the lexer,
+            # but we also check for separate 'rpc' token just in case.
+            self.advance()
+            if self.check(TokenType.SERVICE):
+                self.advance()
+        else:
+            raise self.error("Expected 'service' or 'rpc_service'")
+
+        name = self.consume(TokenType.IDENT, "Expected service name").value
+        attributes = self.parse_metadata()
+        self.consume(TokenType.LBRACE, "Expected '{' after service name")
+
+        methods: List[FbsRpcMethod] = []
+        while not self.check(TokenType.RBRACE):
+            if self.check(TokenType.SEMI):
+                self.advance()
+                continue
+            methods.append(self.parse_rpc_method())
+
+        self.consume(TokenType.RBRACE, "Expected '}' after service body")
+        if self.check(TokenType.SEMI):
+            self.advance()
+
+        return FbsService(
+            name=name,
+            methods=methods,
+            attributes=attributes,
+            line=start.line,
+            column=start.column,
+        )
+
+    def parse_rpc_method(self) -> FbsRpcMethod:
+        # Parse method signature: name(RequestType):ResponseType;
+        start = self.current()
+        name = self.consume(TokenType.IDENT, "Expected method name").value
+
+        self.consume(TokenType.LPAREN, "Expected '(' after method name")
+        # Parsing request type. FBS allows type name here.
+        req_type = self.parse_qualified_ident()
+        self.consume(TokenType.RPAREN, "Expected ')' after request type")
+
+        self.consume(TokenType.COLON, "Expected ':' before response type")
+        res_type = self.parse_qualified_ident()
+
+        attributes = self.parse_metadata()
+        self.consume(TokenType.SEMI, "Expected ';' after method declaration")
+
+        return FbsRpcMethod(
+            name=name,
+            request_type=req_type,
+            response_type=res_type,
+            attributes=attributes,
+            line=start.line,
+            column=start.column,
+        )
diff --git a/compiler/fory_compiler/frontend/fbs/translator.py 
b/compiler/fory_compiler/frontend/fbs/translator.py
index c42ce0bfc..677cf1420 100644
--- a/compiler/fory_compiler/frontend/fbs/translator.py
+++ b/compiler/fory_compiler/frontend/fbs/translator.py
@@ -28,6 +28,8 @@ from fory_compiler.frontend.fbs.ast import (
     FbsTypeRef,
     FbsVectorType,
     FbsUnion,
+    FbsService,
+    FbsRpcMethod,
 )
 from fory_compiler.ir.ast import (
     Enum,
@@ -39,6 +41,8 @@ from fory_compiler.ir.ast import (
     NamedType,
     PrimitiveType,
     Schema,
+    Service,
+    RpcMethod,
     SourceLocation,
     Union,
 )
@@ -82,6 +86,7 @@ class FbsTranslator:
             enums=[self._translate_enum(e) for e in self.schema.enums],
             unions=[self._translate_union(u) for u in self.schema.unions],
             messages=self._translate_messages(),
+            services=[self._translate_service(s) for s in 
self.schema.services],
             options={},
             source_file=self.schema.source_file,
             source_format="fbs",
@@ -278,3 +283,49 @@ class FbsTranslator:
                 fbs_type.name, location=self._location(fbs_type.line, 
fbs_type.column)
             )
         raise ValueError("Unknown FlatBuffers type")
+
+    def _translate_service(self, fbs_service: FbsService) -> Service:
+        return Service(
+            name=fbs_service.name,
+            methods=[self._translate_rpc_method(m) for m in 
fbs_service.methods],
+            options=dict(fbs_service.attributes),
+            line=fbs_service.line,
+            column=fbs_service.column,
+            location=self._location(fbs_service.line, fbs_service.column),
+        )
+
+    def _translate_rpc_method(self, fbs_method: FbsRpcMethod) -> RpcMethod:
+        # Map FBS 'streaming' attribute to Fory's 
client_streaming/server_streaming flags.
+        # Expected 'streaming' values: "client", "server", "bidi".
+        # Default is unary (no streaming).
+
+        attributes = dict(fbs_method.attributes)
+        client_streaming = False
+        server_streaming = False
+
+        # Check for streaming attributes if any (convention)
+        if attributes.get("streaming") == "client":
+            client_streaming = True
+        elif attributes.get("streaming") == "server":
+            server_streaming = True
+        elif attributes.get("streaming") == "bidi":
+            client_streaming = True
+            server_streaming = True
+
+        return RpcMethod(
+            name=fbs_method.name,
+            request_type=NamedType(
+                name=fbs_method.request_type,
+                location=self._location(fbs_method.line, fbs_method.column),
+            ),
+            response_type=NamedType(
+                name=fbs_method.response_type,
+                location=self._location(fbs_method.line, fbs_method.column),
+            ),
+            client_streaming=client_streaming,
+            server_streaming=server_streaming,
+            options=attributes,
+            line=fbs_method.line,
+            column=fbs_method.column,
+            location=self._location(fbs_method.line, fbs_method.column),
+        )
diff --git a/compiler/fory_compiler/frontend/fdl/lexer.py 
b/compiler/fory_compiler/frontend/fdl/lexer.py
index 8bd160849..6043de496 100644
--- a/compiler/fory_compiler/frontend/fdl/lexer.py
+++ b/compiler/fory_compiler/frontend/fdl/lexer.py
@@ -44,6 +44,10 @@ class TokenType(Enum):
     RESERVED = auto()
     TO = auto()
     MAX = auto()
+    SERVICE = auto()
+    RPC = auto()
+    RETURNS = auto()
+    STREAM = auto()
 
     # Literals
     IDENT = auto()
@@ -112,6 +116,10 @@ class Lexer:
         "reserved": TokenType.RESERVED,
         "to": TokenType.TO,
         "max": TokenType.MAX,
+        "service": TokenType.SERVICE,
+        "rpc": TokenType.RPC,
+        "returns": TokenType.RETURNS,
+        "stream": TokenType.STREAM,
     }
 
     PUNCTUATION = {
diff --git a/compiler/fory_compiler/frontend/fdl/parser.py 
b/compiler/fory_compiler/frontend/fdl/parser.py
index f2caf1be4..79c568f0a 100644
--- a/compiler/fory_compiler/frontend/fdl/parser.py
+++ b/compiler/fory_compiler/frontend/fdl/parser.py
@@ -25,6 +25,8 @@ from fory_compiler.ir.ast import (
     Message,
     Enum,
     Union,
+    Service,
+    RpcMethod,
     Field,
     EnumValue,
     Import,
@@ -107,6 +109,14 @@ KNOWN_UNION_OPTIONS: Set[str] = {
     "deprecated",
 }
 
+KNOWN_SERVICE_OPTIONS: Set[str] = {
+    "deprecated",
+}
+
+KNOWN_METHOD_OPTIONS: Set[str] = {
+    "deprecated",
+}
+
 
 class ParseError(Exception):
     """Error during parsing."""
@@ -196,6 +206,7 @@ class Parser:
         enums = []
         messages = []
         unions = []
+        services = []
         options = {}
 
         while not self.at_end():
@@ -215,6 +226,8 @@ class Parser:
                 unions.append(self.parse_union())
             elif self.check(TokenType.MESSAGE):
                 messages.append(self.parse_message())
+            elif self.check(TokenType.SERVICE):
+                services.append(self.parse_service())
             else:
                 raise self.error(f"Unexpected token: {self.current().value}")
 
@@ -225,6 +238,7 @@ class Parser:
             enums=enums,
             messages=messages,
             unions=unions,
+            services=services,
             options=options,
             source_file=self.filename,
             source_format=self.source_format,
@@ -822,6 +836,113 @@ class Parser:
         if thread_safe is not None:
             target["thread_safe_pointer"] = thread_safe
 
+    def parse_service(self) -> Service:
+        """Parse a service definition: service Greeter { rpc ... }"""
+        start = self.current()
+        self.consume(TokenType.SERVICE)
+        name = self.consume(TokenType.IDENT, "Expected service name").value
+        self.consume(TokenType.LBRACE, "Expected '{' after service name")
+
+        methods = []
+        options = {}
+
+        while not self.check(TokenType.RBRACE):
+            if self.check(TokenType.OPTION):
+                # Service-level option
+                name_token = self.current()
+                opt_name, opt_value = (
+                    self.parse_file_option()
+                )  # Reusing generic option parser
+                options[opt_name] = opt_value
+                if opt_name not in KNOWN_SERVICE_OPTIONS:
+                    warnings.warn(
+                        f"Line {name_token.line}: ignoring unknown service 
option '{opt_name}'",
+                        stacklevel=2,
+                    )
+            elif self.check(TokenType.RPC):
+                methods.append(self.parse_rpc_method())
+            else:
+                raise self.error("Expected 'rpc' or 'option' inside service 
block")
+
+        self.consume(TokenType.RBRACE, "Expected '}' after service body")
+
+        return Service(
+            name=name,
+            methods=methods,
+            options=options,
+            line=start.line,
+            column=start.column,
+            location=self.make_location(start),
+        )
+
+    def parse_rpc_method(self) -> RpcMethod:
+        """Parse an RPC method: rpc Name (stream? Req) returns (stream? Res) { 
option ... };"""
+        start = self.current()
+        self.consume(TokenType.RPC)
+        name = self.consume(TokenType.IDENT, "Expected method name").value
+
+        # Parse request type
+        self.consume(TokenType.LPAREN, "Expected '(' before request type")
+        client_streaming = False
+        if self.check(TokenType.STREAM):
+            self.advance()
+            client_streaming = True
+
+        req_type_token = self.consume(TokenType.IDENT, "Expected request 
message type")
+        request_type = NamedType(
+            name=req_type_token.value, 
location=self.make_location(req_type_token)
+        )
+        self.consume(TokenType.RPAREN, "Expected ')' after request type")
+
+        # Parse return type
+        self.consume(TokenType.RETURNS, "Expected 'returns' keyword")
+        self.consume(TokenType.LPAREN, "Expected '(' before response type")
+        server_streaming = False
+        if self.check(TokenType.STREAM):
+            self.advance()
+            server_streaming = True
+
+        res_type_token = self.consume(TokenType.IDENT, "Expected response 
message type")
+        response_type = NamedType(
+            name=res_type_token.value, 
location=self.make_location(res_type_token)
+        )
+        self.consume(TokenType.RPAREN, "Expected ')' after response type")
+
+        # Parse optional method options block
+        options = {}
+        if self.check(TokenType.LBRACE):
+            self.consume(TokenType.LBRACE)
+            while not self.check(TokenType.RBRACE):
+                if self.check(TokenType.OPTION):
+                    name_token = self.current()
+                    opt_name, opt_value = self.parse_file_option()
+                    options[opt_name] = opt_value
+                    if opt_name not in KNOWN_METHOD_OPTIONS:
+                        warnings.warn(
+                            f"Line {name_token.line}: ignoring unknown method 
option '{opt_name}'",
+                            stacklevel=2,
+                        )
+                elif self.check(TokenType.SEMI):
+                    # Allow empty ; inside block
+                    self.advance()
+                else:
+                    raise self.error("Expected 'option' inside method block")
+            self.consume(TokenType.RBRACE, "Expected '}' after method options")
+        else:
+            self.consume(TokenType.SEMI, "Expected ';' after method 
definition")
+
+        return RpcMethod(
+            name=name,
+            request_type=request_type,
+            response_type=response_type,
+            client_streaming=client_streaming,
+            server_streaming=server_streaming,
+            options=options,
+            line=start.line,
+            column=start.column,
+            location=self.make_location(start),
+        )
+
     def parse_type_options(
         self, type_name: str, known_options: Set[str], allow_zero_id: bool = 
False
     ) -> dict:
diff --git a/compiler/fory_compiler/frontend/proto/ast.py 
b/compiler/fory_compiler/frontend/proto/ast.py
index b881f467f..8b5ece33f 100644
--- a/compiler/fory_compiler/frontend/proto/ast.py
+++ b/compiler/fory_compiler/frontend/proto/ast.py
@@ -91,6 +91,31 @@ class ProtoMessage:
     column: int = 0
 
 
+@dataclass
+class ProtoRpcMethod:
+    """An RPC method declaration."""
+
+    name: str
+    request_type: str
+    response_type: str
+    client_streaming: bool = False
+    server_streaming: bool = False
+    options: Dict[str, object] = field(default_factory=dict)
+    line: int = 0
+    column: int = 0
+
+
+@dataclass
+class ProtoService:
+    """A ProtoBuffers service declaration."""
+
+    name: str
+    methods: List["ProtoRpcMethod"] = field(default_factory=list)
+    options: Dict[str, object] = field(default_factory=dict)
+    line: int = 0
+    column: int = 0
+
+
 @dataclass
 class ProtoSchema:
     """Represents a proto file."""
@@ -99,6 +124,7 @@ class ProtoSchema:
     package: Optional[str]
     imports: List[str] = field(default_factory=list)
     enums: List[ProtoEnum] = field(default_factory=list)
+    services: List[ProtoService] = field(default_factory=list)
     messages: List[ProtoMessage] = field(default_factory=list)
     options: Dict[str, object] = field(default_factory=dict)
     source_file: Optional[str] = None
diff --git a/compiler/fory_compiler/frontend/proto/lexer.py 
b/compiler/fory_compiler/frontend/proto/lexer.py
index c8afe4e7c..67a4c71fb 100644
--- a/compiler/fory_compiler/frontend/proto/lexer.py
+++ b/compiler/fory_compiler/frontend/proto/lexer.py
@@ -48,6 +48,7 @@ class TokenType(Enum):
     FALSE = auto()
     TO = auto()
     MAX = auto()
+    STREAM = auto()
 
     # Literals
     IDENT = auto()
@@ -120,6 +121,7 @@ class Lexer:
         "false": TokenType.FALSE,
         "to": TokenType.TO,
         "max": TokenType.MAX,
+        "stream": TokenType.STREAM,
     }
 
     PUNCTUATION = {
diff --git a/compiler/fory_compiler/frontend/proto/parser.py 
b/compiler/fory_compiler/frontend/proto/parser.py
index 127a5cad0..a5c69b407 100644
--- a/compiler/fory_compiler/frontend/proto/parser.py
+++ b/compiler/fory_compiler/frontend/proto/parser.py
@@ -27,6 +27,8 @@ from fory_compiler.frontend.proto.ast import (
     ProtoField,
     ProtoOneof,
     ProtoType,
+    ProtoService,
+    ProtoRpcMethod,
 )
 from fory_compiler.frontend.proto.lexer import Token, TokenType
 
@@ -92,6 +94,7 @@ class Parser:
         imports: List[str] = []
         enums: List[ProtoEnum] = []
         messages: List[ProtoMessage] = []
+        services: List[ProtoService] = []
         options = {}
 
         while not self.at_end():
@@ -116,7 +119,7 @@ class Parser:
             elif self.check(TokenType.ENUM):
                 enums.append(self.parse_enum())
             elif self.check(TokenType.SERVICE):
-                self.parse_service()
+                services.append(self.parse_service())
             elif self.check(TokenType.SEMI):
                 self.advance()
             else:
@@ -133,6 +136,7 @@ class Parser:
             imports=imports,
             enums=enums,
             messages=messages,
+            services=services,
             options=options,
             source_file=self.filename,
         )
@@ -392,20 +396,90 @@ class Parser:
             self.advance()
         self.consume(TokenType.SEMI, "Expected ';' after extensions")
 
-    def parse_service(self) -> None:
+    def parse_service(self) -> ProtoService:
+        start = self.current()
         self.consume(TokenType.SERVICE, "Expected 'service'")
-        self.consume(TokenType.IDENT, "Expected service name")
+        name = self.consume(TokenType.IDENT, "Expected service name").value
         self.consume(TokenType.LBRACE, "Expected '{' after service name")
-        depth = 1
-        while depth > 0:
-            if self.at_end():
-                raise self.error("Unterminated service block")
-            if self.match(TokenType.LBRACE):
-                depth += 1
-            elif self.match(TokenType.RBRACE):
-                depth -= 1
+
+        methods: List[ProtoRpcMethod] = []
+        options = {}
+
+        while not self.check(TokenType.RBRACE):
+            if self.check(TokenType.OPTION):
+                opt_name, opt_value = self.parse_option_statement()
+                options[opt_name] = opt_value
+            elif self.check(TokenType.RPC):
+                methods.append(self.parse_rpc_method())
+            elif self.check(TokenType.SEMI):
+                self.advance()
             else:
+                raise self.error("Expected 'rpc' or 'option' inside service")
+
+        self.consume(TokenType.RBRACE, "Expected '}' after service")
+        if self.check(TokenType.SEMI):
+            self.advance()
+
+        return ProtoService(
+            name=name,
+            methods=methods,
+            options=options,
+            line=start.line,
+            column=start.column,
+        )
+
+    def parse_rpc_method(self) -> ProtoRpcMethod:
+        start = self.current()
+        self.consume(TokenType.RPC, "Expected 'rpc'")
+        name = self.consume(TokenType.IDENT, "Expected method name").value
+
+        # Request
+        self.consume(TokenType.LPAREN, "Expected '(' before request type")
+        client_streaming = False
+        if self.match(TokenType.STREAM):
+            client_streaming = True
+
+        req_type = self.parse_full_ident()
+        self.consume(TokenType.RPAREN, "Expected ')' after request type")
+
+        self.consume(TokenType.RETURNS, "Expected 'returns'")
+
+        # Response
+        self.consume(TokenType.LPAREN, "Expected '(' before response type")
+        server_streaming = False
+        if self.match(TokenType.STREAM):
+            server_streaming = True
+
+        res_type = self.parse_full_ident()
+        self.consume(TokenType.RPAREN, "Expected ')' after response type")
+
+        options = {}
+        if self.check(TokenType.LBRACE):
+            self.consume(TokenType.LBRACE, "Expected '{'")
+            while not self.check(TokenType.RBRACE):
+                if self.check(TokenType.OPTION):
+                    opt_name, opt_value = self.parse_option_statement()
+                    options[opt_name] = opt_value
+                elif self.check(TokenType.SEMI):
+                    self.advance()
+                else:
+                    raise self.error("Expected 'option' in method body")
+            self.consume(TokenType.RBRACE, "Expected '}'")
+            if self.check(TokenType.SEMI):
                 self.advance()
+        else:
+            self.consume(TokenType.SEMI, "Expected ';' or '{' after method 
signature")
+
+        return ProtoRpcMethod(
+            name=name,
+            request_type=req_type,
+            response_type=res_type,
+            client_streaming=client_streaming,
+            server_streaming=server_streaming,
+            options=options,
+            line=start.line,
+            column=start.column,
+        )
 
     def parse_option_name(self) -> str:
         if self.match(TokenType.LPAREN):
diff --git a/compiler/fory_compiler/frontend/proto/translator.py 
b/compiler/fory_compiler/frontend/proto/translator.py
index 2828dc0e8..00c598433 100644
--- a/compiler/fory_compiler/frontend/proto/translator.py
+++ b/compiler/fory_compiler/frontend/proto/translator.py
@@ -26,9 +26,13 @@ from fory_compiler.frontend.proto.ast import (
     ProtoField,
     ProtoType,
     ProtoOneof,
+    ProtoService,
+    ProtoRpcMethod,
 )
 from fory_compiler.ir.ast import (
     Schema,
+    Service,
+    RpcMethod,
     Message,
     Enum,
     Union,
@@ -101,6 +105,7 @@ class ProtoTranslator:
             imports=self._translate_imports(),
             enums=[self._translate_enum(e) for e in self.proto_schema.enums],
             messages=[self._translate_message(m) for m in 
self.proto_schema.messages],
+            services=[self._translate_service(s) for s in 
self.proto_schema.services],
             options=self._translate_file_options(self.proto_schema.options),
             source_file=self.proto_schema.source_file,
             source_format="proto",
@@ -318,6 +323,8 @@ class ProtoTranslator:
                 type_id = value
             elif name.startswith("fory."):
                 translated[name.removeprefix("fory.")] = value
+            else:
+                translated[name] = value
         return type_id, translated
 
     def _translate_field_options(
@@ -363,3 +370,36 @@ class ProtoTranslator:
         if isinstance(field_type, PrimitiveType):
             return PrimitiveType(override, location=self._location(line, 
column))
         raise ValueError("fory.type overrides are only supported for primitive 
fields")
+
+    def _translate_service(self, proto_service: ProtoService) -> Service:
+        # Translate ProtoService to Service
+        _, options = self._translate_type_options(proto_service.options)
+        return Service(
+            name=proto_service.name,
+            methods=[self._translate_rpc_method(m) for m in 
proto_service.methods],
+            options=options,
+            line=proto_service.line,
+            column=proto_service.column,
+            location=self._location(proto_service.line, proto_service.column),
+        )
+
+    def _translate_rpc_method(self, proto_method: ProtoRpcMethod) -> RpcMethod:
+        # Translate ProtoRpcMethod to RpcMethod
+        _, options = self._translate_type_options(proto_method.options)
+        return RpcMethod(
+            name=proto_method.name,
+            request_type=NamedType(
+                name=proto_method.request_type,
+                location=self._location(proto_method.line, 
proto_method.column),
+            ),
+            response_type=NamedType(
+                name=proto_method.response_type,
+                location=self._location(proto_method.line, 
proto_method.column),
+            ),
+            client_streaming=proto_method.client_streaming,
+            server_streaming=proto_method.server_streaming,
+            options=options,
+            line=proto_method.line,
+            column=proto_method.column,
+            location=self._location(proto_method.line, proto_method.column),
+        )
diff --git a/compiler/fory_compiler/ir/ast.py b/compiler/fory_compiler/ir/ast.py
index 4a1f2296b..0c1d75009 100644
--- a/compiler/fory_compiler/ir/ast.py
+++ b/compiler/fory_compiler/ir/ast.py
@@ -245,6 +245,47 @@ class Union:
         return f"Union({self.name}{id_str}, fields={self.fields}{opts_str})"
 
 
+@dataclass
+class RpcMethod:
+    """An RPC method inside a service."""
+
+    name: str
+    request_type: NamedType
+    response_type: NamedType
+    client_streaming: bool = False
+    server_streaming: bool = False
+    options: dict = field(default_factory=dict)
+    line: int = 0
+    column: int = 0
+    location: Optional[SourceLocation] = None
+
+    def __repr__(self) -> str:
+        opts_str = f" [{self.options}]" if self.options else ""
+        req_stream = "stream " if self.client_streaming else ""
+        res_stream = "stream " if self.server_streaming else ""
+        return (
+            f"RpcMethod({self.name} "
+            f"({req_stream}{self.request_type}) returns 
({res_stream}{self.response_type})"
+            f"{opts_str})"
+        )
+
+
+@dataclass
+class Service:
+    """A service definition."""
+
+    name: str
+    methods: List[RpcMethod] = field(default_factory=list)
+    options: dict = field(default_factory=dict)
+    line: int = 0
+    column: int = 0
+    location: Optional[SourceLocation] = None
+
+    def __repr__(self) -> str:
+        opts_str = f", options={len(self.options)}" if self.options else ""
+        return f"Service({self.name}, methods={len(self.methods)}{opts_str})"
+
+
 @dataclass
 class Schema:
     """The root AST node representing a complete FDL file."""
@@ -255,6 +296,7 @@ class Schema:
     enums: List[Enum] = field(default_factory=list)
     messages: List[Message] = field(default_factory=list)
     unions: List[Union] = field(default_factory=list)
+    services: List[Service] = field(default_factory=list)
     options: dict = field(
         default_factory=dict
     )  # File-level options (java_package, go_package, etc.)
@@ -266,7 +308,8 @@ class Schema:
         alias = f", package_alias={self.package_alias}" if self.package_alias 
else ""
         return (
             f"Schema(package={self.package}{alias}, 
imports={len(self.imports)}, "
-            f"enums={len(self.enums)}, messages={len(self.messages)}, 
unions={len(self.unions)}{opts})"
+            f"enums={len(self.enums)}, messages={len(self.messages)}, 
unions={len(self.unions)}, "
+            f"services={len(self.services)}{opts})"
         )
 
     def get_option(self, name: str, default: Optional[str] = None) -> 
Optional[str]:
diff --git a/compiler/fory_compiler/tests/test_fbs_service.py 
b/compiler/fory_compiler/tests/test_fbs_service.py
new file mode 100644
index 000000000..27b86c784
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_fbs_service.py
@@ -0,0 +1,109 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for FBS service parsing."""
+
+from fory_compiler.frontend.fbs.lexer import Lexer
+from fory_compiler.frontend.fbs.parser import Parser
+from fory_compiler.frontend.fbs.translator import FbsTranslator
+
+
+def parse_and_translate(source):
+    lexer = Lexer(source)
+    parser = Parser(lexer.tokenize())
+    schema = parser.parse()
+    translator = FbsTranslator(schema)
+    return translator.translate()
+
+
+def test_rpc_service_parsing():
+    source = """
+    namespace demo;
+
+    table Request {
+        id: int;
+    }
+
+    table Response {
+        result: string;
+    }
+
+    rpc_service Greeter {
+        SayHello(Request):Response;
+        SayGoodbye(Request):Response (deprecated);
+    }
+    """
+    schema = parse_and_translate(source)
+    assert len(schema.services) == 1
+    service = schema.services[0]
+    assert service.name == "Greeter"
+    assert len(service.methods) == 2
+
+    assert service.methods[0].name == "SayHello"
+    assert service.methods[0].request_type.name == "Request"
+    assert service.methods[0].response_type.name == "Response"
+    assert not service.methods[0].client_streaming
+    assert not service.methods[0].server_streaming
+
+    assert service.methods[1].name == "SayGoodbye"
+    assert service.methods[1].options["deprecated"] is True
+
+
+def test_service_keyword_parsing():
+    """Test using 'service' keyword instead of 'rpc_service'."""
+    source = """
+    namespace demo;
+
+    service Greeter {
+        SayHello(Request):Response;
+    }
+    """
+    schema = parse_and_translate(source)
+    assert len(schema.services) == 1
+    assert schema.services[0].name == "Greeter"
+
+
+def test_streaming_attributes():
+    source = """
+    namespace demo;
+
+    rpc_service Streamer {
+        ClientStream(Request):Response (streaming: "client");
+        ServerStream(Request):Response (streaming: "server");
+        BidiStream(Request):Response (streaming: "bidi");
+    }
+    """
+    schema = parse_and_translate(source)
+    service = schema.services[0]
+
+    # Client streaming
+    m1 = service.methods[0]
+    assert m1.name == "ClientStream"
+    assert m1.client_streaming is True
+    assert m1.server_streaming is False
+
+    # Server streaming
+    m2 = service.methods[1]
+    assert m2.name == "ServerStream"
+    assert m2.client_streaming is False
+    assert m2.server_streaming is True
+
+    # Bidi streaming
+    m3 = service.methods[2]
+    assert m3.name == "BidiStream"
+    assert m3.client_streaming is True
+    assert m3.server_streaming is True
diff --git a/compiler/fory_compiler/tests/test_fdl_service.py 
b/compiler/fory_compiler/tests/test_fdl_service.py
new file mode 100644
index 000000000..a884c9820
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_fdl_service.py
@@ -0,0 +1,170 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+from fory_compiler.frontend.fdl.parser import Parser, ParseError
+
+
+def parse(source: str):
+    parser = Parser.from_source(source)
+    schema = parser.parse()
+    return schema
+
+
+def test_empty_service():
+    source = """
+    package test;
+    service Greeter {}
+    """
+    schema = parse(source)
+    assert len(schema.services) == 1
+    service = schema.services[0]
+    assert service.name == "Greeter"
+    assert len(service.methods) == 0
+
+
+def test_unary_rpc():
+    source = """
+    package test;
+    
+    message HelloRequest {}
+    message HelloReply {}
+
+    service Greeter {
+        rpc SayHello (HelloRequest) returns (HelloReply);
+    }
+    """
+    schema = parse(source)
+    service = schema.services[0]
+    assert len(service.methods) == 1
+    method = service.methods[0]
+    assert method.name == "SayHello"
+    assert method.request_type.name == "HelloRequest"
+    assert method.response_type.name == "HelloReply"
+    assert not method.client_streaming
+    assert not method.server_streaming
+
+
+def test_client_streaming_rpc():
+    source = """
+    package test;
+    
+    message HelloRequest {}
+    message HelloReply {}
+
+    service Greeter {
+        rpc LotsOfGreetings (stream HelloRequest) returns (HelloReply);
+    }
+    """
+    schema = parse(source)
+    service = schema.services[0]
+    method = service.methods[0]
+    assert method.name == "LotsOfGreetings"
+    assert method.client_streaming
+    assert not method.server_streaming
+
+
+def test_server_streaming_rpc():
+    source = """
+    package test;
+    
+    message HelloRequest {}
+    message HelloReply {}
+
+    service Greeter {
+        rpc LotsOfReplies (HelloRequest) returns (stream HelloReply);
+    }
+    """
+    schema = parse(source)
+    service = schema.services[0]
+    method = service.methods[0]
+    assert method.name == "LotsOfReplies"
+    assert not method.client_streaming
+    assert method.server_streaming
+
+
+def test_bidi_streaming_rpc():
+    source = """
+    package test;
+    
+    message HelloRequest {}
+    message HelloReply {}
+
+    service Greeter {
+        rpc BidiHello (stream HelloRequest) returns (stream HelloReply);
+    }
+    """
+    schema = parse(source)
+    service = schema.services[0]
+    method = service.methods[0]
+    assert method.name == "BidiHello"
+    assert method.client_streaming
+    assert method.server_streaming
+
+
+def test_service_options():
+    source = """
+    package test;
+    
+    service Greeter {
+        option deprecated = true;
+    }
+    """
+    schema = parse(source)
+    service = schema.services[0]
+    assert service.options["deprecated"] is True
+
+
+def test_method_options():
+    source = """
+    package test;
+    
+    message HelloRequest {}
+    message HelloReply {}
+
+    service Greeter {
+        rpc SayHello (HelloRequest) returns (HelloReply) {
+            option deprecated = true;
+        }
+    }
+    """
+    schema = parse(source)
+    service = schema.services[0]
+    method = service.methods[0]
+    assert method.options["deprecated"] is True
+
+
+def test_invalid_syntax_missing_returns():
+    source = """
+    package test;
+    service Greeter {
+        rpc SayHello (HelloRequest);
+    }
+    """
+    with pytest.raises(ParseError):
+        parse(source)
+
+
+def test_invalid_syntax_missing_parens():
+    source = """
+    package test;
+    service Greeter {
+        rpc SayHello HelloRequest returns HelloReply;
+    }
+    """
+    with pytest.raises(ParseError):
+        parse(source)
diff --git a/compiler/fory_compiler/tests/test_proto_service.py 
b/compiler/fory_compiler/tests/test_proto_service.py
new file mode 100644
index 000000000..c7878cbc3
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_proto_service.py
@@ -0,0 +1,122 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for Proto service parsing."""
+
+from fory_compiler.frontend.proto.lexer import Lexer
+from fory_compiler.frontend.proto.parser import Parser
+from fory_compiler.frontend.proto.translator import ProtoTranslator
+
+
+def parse_and_translate(source):
+    lexer = Lexer(source)
+    parser = Parser(lexer.tokenize())
+    schema = parser.parse()
+    translator = ProtoTranslator(schema)
+    return translator.translate()
+
+
+def test_service_parsing():
+    source = """
+    syntax = "proto3";
+    package demo;
+
+    message Request {
+        int32 id = 1;
+    }
+
+    message Response {
+        string result = 1;
+    }
+
+    service Greeter {
+        rpc SayHello (Request) returns (Response);
+        rpc SayGoodbye (Request) returns (Response) {
+            option deprecated = true;
+        }
+    }
+    """
+    schema = parse_and_translate(source)
+    assert len(schema.services) == 1
+    service = schema.services[0]
+    assert service.name == "Greeter"
+    assert len(service.methods) == 2
+
+    m1 = service.methods[0]
+    assert m1.name == "SayHello"
+    assert m1.request_type.name == "Request"
+    assert m1.response_type.name == "Response"
+    assert not m1.client_streaming
+    assert not m1.server_streaming
+
+    m2 = service.methods[1]
+    assert m2.name == "SayGoodbye"
+    assert m2.options["deprecated"] is True
+
+
+def test_streaming_rpc():
+    source = """
+    syntax = "proto3";
+    package demo;
+
+    message Request {}
+    message Response {}
+
+    service Streamer {
+        rpc ClientStream (stream Request) returns (Response);
+        rpc ServerStream (Request) returns (stream Response);
+        rpc BidiStream (stream Request) returns (stream Response);
+    }
+    """
+    schema = parse_and_translate(source)
+    service = schema.services[0]
+
+    # Client streaming
+    m1 = service.methods[0]
+    assert m1.name == "ClientStream"
+    assert m1.client_streaming is True
+    assert m1.server_streaming is False
+
+    # Server streaming
+    m2 = service.methods[1]
+    assert m2.name == "ServerStream"
+    assert m2.client_streaming is False
+    assert m2.server_streaming is True
+
+    # Bidi streaming
+    m3 = service.methods[2]
+    assert m3.name == "BidiStream"
+    assert m3.client_streaming is True
+    assert m3.server_streaming is True
+
+
+def test_service_options():
+    source = """
+    syntax = "proto3";
+    package demo;
+
+    service OptionsService {
+        option deprecated = true;
+        rpc Method (Req) returns (Res);
+    }
+    
+    message Req {}
+    message Res {}
+    """
+    schema = parse_and_translate(source)
+    service = schema.services[0]
+    assert service.options["deprecated"] is True


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to