pw_rpc: Generate RPC definition stubs separately

- Define an interface for generating stubs.
- Generate the method implementation stubs outside the class.
- Share more code between raw and Nanopb RPC stub generation.
- Skip empty names in the C++ namespace to avoid outputting "::".

Change-Id: Ic00b81ae5af07e89408962454432fa227033b904
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/39201
Commit-Queue: Wyatt Hepler <hepler@google.com>
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Reviewed-by: Alexei Frolov <frolv@google.com>
diff --git a/pw_protobuf/py/pw_protobuf/proto_tree.py b/pw_protobuf/py/pw_protobuf/proto_tree.py
index de5a4e5..6dedf82 100644
--- a/pw_protobuf/py/pw_protobuf/proto_tree.py
+++ b/pw_protobuf/py/pw_protobuf/proto_tree.py
@@ -67,8 +67,8 @@
 
     def cpp_namespace(self, root: Optional['ProtoNode'] = None) -> str:
         """C++ namespace of the node, up to the specified root."""
-        return '::'.join(
-            self._attr_hierarchy(lambda node: node.cpp_name(), root))
+        return '::'.join(name for name in self._attr_hierarchy(
+            lambda node: node.cpp_name(), root) if name)
 
     def proto_path(self) -> str:
         """Fully-qualified package path of the node."""
diff --git a/pw_rpc/docs.rst b/pw_rpc/docs.rst
index 8bd39b3..eca6870 100644
--- a/pw_rpc/docs.rst
+++ b/pw_rpc/docs.rst
@@ -136,6 +136,21 @@
   reference or copy and paste these to get started with implementing a service.
   These stub classes are generated at the bottom of the pw_rpc proto header.
 
+  To use the stubs, do the following:
+
+  #. Locate the generated RPC header in the build directory. For example:
+
+     .. code-block:: sh
+
+       find out/ -name <proto_name>.rpc.pb.h
+
+  #. Scroll to the bottom of the generated RPC header.
+  #. Copy the stub class declaration to a header file.
+  #. Copy the member function definitions to a source file.
+  #. Rename the class or change the namespace, if desired.
+  #. List these files in a build target with a dependency on the
+     ``pw_proto_library``.
+
 A Nanopb implementation of this service would be as follows:
 
 .. code-block:: cpp
diff --git a/pw_rpc/py/pw_rpc/codegen.py b/pw_rpc/py/pw_rpc/codegen.py
index 531a955..957a893 100644
--- a/pw_rpc/py/pw_rpc/codegen.py
+++ b/pw_rpc/py/pw_rpc/codegen.py
@@ -13,6 +13,7 @@
 # the License.
 """Common RPC codegen utilities."""
 
+import abc
 from datetime import datetime
 import os
 from typing import cast, Any, Callable, Iterable
@@ -170,7 +171,42 @@
     output.write_line('};\n')
 
 
-StubFunction = Callable[[ProtoServiceMethod, OutputFile], None]
+class StubGenerator(abc.ABC):
+    @abc.abstractmethod
+    def unary_signature(self, method: ProtoServiceMethod, prefix: str) -> str:
+        """Returns the signature of this unary method."""
+
+    @abc.abstractmethod
+    def unary_stub(self, method: ProtoServiceMethod,
+                   output: OutputFile) -> None:
+        """Returns the stub for this unary method."""
+
+    @abc.abstractmethod
+    def server_streaming_signature(self, method: ProtoServiceMethod,
+                                   prefix: str) -> str:
+        """Returns the signature of this server streaming method."""
+
+    def server_streaming_stub(  # pylint: disable=no-self-use
+            self, unused_method: ProtoServiceMethod,
+            output: OutputFile) -> None:
+        """Returns the stub for this server streaming method."""
+        output.write_line(STUB_REQUEST_TODO)
+        output.write_line('static_cast<void>(request);')
+        output.write_line(STUB_WRITER_TODO)
+        output.write_line('static_cast<void>(writer);')
+
+
+def _select_stub_methods(generator: StubGenerator, method: ProtoServiceMethod):
+    if method.type() is ProtoServiceMethod.Type.UNARY:
+        return generator.unary_signature, generator.unary_stub
+
+    if method.type() is ProtoServiceMethod.Type.SERVER_STREAMING:
+        return (generator.server_streaming_signature,
+                generator.server_streaming_stub)
+
+    raise NotImplementedError(
+        'Client and bidirectional streaming not yet implemented')
+
 
 _STUBS_COMMENT = r'''
 /*
@@ -194,36 +230,51 @@
 
 
 def package_stubs(proto_package: ProtoNode, output: OutputFile,
-                  unary_stub: StubFunction,
-                  server_streaming_stub: StubFunction) -> None:
+                  stub_generator: StubGenerator) -> None:
+    """Generates the RPC stubs for a package."""
+    if proto_package.cpp_namespace():
+        file_ns = proto_package.cpp_namespace()
+        if file_ns.startswith('::'):
+            file_ns = file_ns[2:]
+
+        start_ns = lambda: output.write_line(f'namespace {file_ns} {{\n')
+        finish_ns = lambda: output.write_line(f'}}  // namespace {file_ns}\n')
+    else:
+        start_ns = finish_ns = lambda: None
+
+    services = [
+        cast(ProtoService, node) for node in proto_package
+        if node.type() == ProtoNode.Type.SERVICE
+    ]
 
     output.write_line('#ifdef _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS')
     output.write_line(_STUBS_COMMENT)
 
     output.write_line(f'#include "{output.name()}"\n')
 
-    if proto_package.cpp_namespace():
-        file_namespace = proto_package.cpp_namespace()
-        if file_namespace.startswith('::'):
-            file_namespace = file_namespace[2:]
+    start_ns()
 
-        output.write_line(f'namespace {file_namespace} {{')
+    for node in services:
+        _generate_service_class(node, output, stub_generator)
 
-    for node in proto_package:
-        if node.type() == ProtoNode.Type.SERVICE:
-            _generate_service_stub(cast(ProtoService, node), output,
-                                   unary_stub, server_streaming_stub)
-
-    if proto_package.cpp_namespace():
-        output.write_line(f'}}  // namespace {file_namespace}')
-
-    output.write_line('\n#endif  // _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS')
-
-
-def _generate_service_stub(service: ProtoService, output: OutputFile,
-                           unary_stub: StubFunction,
-                           server_streaming_stub: StubFunction) -> None:
     output.write_line()
+
+    finish_ns()
+
+    start_ns()
+
+    for node in services:
+        _generate_service_stubs(node, output, stub_generator)
+        output.write_line()
+
+    finish_ns()
+
+    output.write_line('#endif  // _PW_RPC_COMPILE_GENERATED_SERVICE_STUBS')
+
+
+def _generate_service_class(service: ProtoService, output: OutputFile,
+                            stub_generator: StubGenerator) -> None:
+    output.write_line(f'// Implementation class for {service.proto_path()}.')
     output.write_line(
         f'class {service.name()} '
         f': public generated::{service.name()}<{service.name()}> {{')
@@ -239,12 +290,28 @@
             else:
                 blank_line = True
 
-            if method.type() is ProtoServiceMethod.Type.UNARY:
-                unary_stub(method, output)
-            elif method.type() is ProtoServiceMethod.Type.SERVER_STREAMING:
-                server_streaming_stub(method, output)
-            else:
-                raise NotImplementedError(
-                    'Client and bidirectional streaming not yet implemented')
+            signature, _ = _select_stub_methods(stub_generator, method)
+
+            output.write_line(signature(method, '') + ';')
 
     output.write_line('};\n')
+
+
+def _generate_service_stubs(service: ProtoService, output: OutputFile,
+                            stub_generator: StubGenerator) -> None:
+    output.write_line(f'// Method definitions for {service.proto_path()}.')
+
+    blank_line = False
+
+    for method in service.methods():
+        if blank_line:
+            output.write_line()
+        else:
+            blank_line = True
+
+        signature, stub = _select_stub_methods(stub_generator, method)
+
+        output.write_line(signature(method, f'{service.name()}::') + ' {')
+        with output.indent():
+            stub(method, output)
+        output.write_line('}')
diff --git a/pw_rpc/py/pw_rpc/codegen_nanopb.py b/pw_rpc/py/pw_rpc/codegen_nanopb.py
index 4127062..183856c 100644
--- a/pw_rpc/py/pw_rpc/codegen_nanopb.py
+++ b/pw_rpc/py/pw_rpc/codegen_nanopb.py
@@ -163,35 +163,26 @@
                     _generate_code_for_service, _generate_code_for_client)
 
 
-def _unary_stub(method: ProtoServiceMethod, output: OutputFile) -> None:
-    output.write_line(f'pw::Status {method.name()}(ServerContext&, '
-                      f'const {method.request_type().nanopb_name()}& request, '
-                      f'{method.response_type().nanopb_name()}& response) {{')
+class StubGenerator(codegen.StubGenerator):
+    def unary_signature(self, method: ProtoServiceMethod, prefix: str) -> str:
+        return (f'pw::Status {prefix}{method.name()}(ServerContext&, '
+                f'const {method.request_type().nanopb_name()}& request, '
+                f'{method.response_type().nanopb_name()}& response)')
 
-    with output.indent():
+    def unary_stub(self, method: ProtoServiceMethod,
+                   output: OutputFile) -> None:
         output.write_line(codegen.STUB_REQUEST_TODO)
         output.write_line('static_cast<void>(request);')
         output.write_line(codegen.STUB_RESPONSE_TODO)
         output.write_line('static_cast<void>(response);')
         output.write_line('return pw::Status::Unimplemented();')
 
-    output.write_line('}')
-
-
-def _server_streaming_stub(method: ProtoServiceMethod,
-                           output: OutputFile) -> None:
-    output.write_line(
-        f'void {method.name()}(ServerContext&, '
-        f'const {method.request_type().nanopb_name()}& request, '
-        f'ServerWriter<{method.response_type().nanopb_name()}>& writer) {{')
-
-    with output.indent():
-        output.write_line(codegen.STUB_REQUEST_TODO)
-        output.write_line('static_cast<void>(request);')
-        output.write_line(codegen.STUB_WRITER_TODO)
-        output.write_line('static_cast<void>(writer);')
-
-    output.write_line('}')
+    def server_streaming_signature(self, method: ProtoServiceMethod,
+                                   prefix: str) -> str:
+        return (
+            f'void {prefix}{method.name()}(ServerContext&, '
+            f'const {method.request_type().nanopb_name()}& request, '
+            f'ServerWriter<{method.response_type().nanopb_name()}>& writer)')
 
 
 def process_proto_file(proto_file) -> Iterable[OutputFile]:
@@ -203,7 +194,6 @@
     _generate_code_for_package(proto_file, package_root, output_file)
 
     output_file.write_line()
-    codegen.package_stubs(package_root, output_file, _unary_stub,
-                          _server_streaming_stub)
+    codegen.package_stubs(package_root, output_file, StubGenerator())
 
     return [output_file]
diff --git a/pw_rpc/py/pw_rpc/codegen_raw.py b/pw_rpc/py/pw_rpc/codegen_raw.py
index 8155b41..51fee92 100644
--- a/pw_rpc/py/pw_rpc/codegen_raw.py
+++ b/pw_rpc/py/pw_rpc/codegen_raw.py
@@ -77,32 +77,24 @@
                     _generate_code_for_service, _generate_code_for_client)
 
 
-def _unary_stub(method: ProtoServiceMethod, output: OutputFile) -> None:
-    output.write_line(f'pw::StatusWithSize {method.name()}(ServerContext&, '
-                      'pw::ConstByteSpan request, pw::ByteSpan response) {')
+class StubGenerator(codegen.StubGenerator):
+    def unary_signature(self, method: ProtoServiceMethod, prefix: str) -> str:
+        return (f'pw::StatusWithSize {prefix}{method.name()}(ServerContext&, '
+                'pw::ConstByteSpan request, pw::ByteSpan response)')
 
-    with output.indent():
+    def unary_stub(self, method: ProtoServiceMethod,
+                   output: OutputFile) -> None:
         output.write_line(codegen.STUB_REQUEST_TODO)
         output.write_line('static_cast<void>(request);')
         output.write_line(codegen.STUB_RESPONSE_TODO)
         output.write_line('static_cast<void>(response);')
         output.write_line('return pw::StatusWithSize::Unimplemented();')
 
-    output.write_line('}')
+    def server_streaming_signature(self, method: ProtoServiceMethod,
+                                   prefix: str) -> str:
 
-
-def _server_streaming_stub(method: ProtoServiceMethod,
-                           output: OutputFile) -> None:
-    output.write_line(f'void {method.name()}(ServerContext&, '
-                      'pw::ConstByteSpan request, RawServerWriter& writer) {')
-
-    with output.indent():
-        output.write_line(codegen.STUB_REQUEST_TODO)
-        output.write_line('static_cast<void>(request);')
-        output.write_line(codegen.STUB_WRITER_TODO)
-        output.write_line('static_cast<void>(writer);')
-
-    output.write_line('}')
+        return (f'void {prefix}{method.name()}(ServerContext&, '
+                'pw::ConstByteSpan request, RawServerWriter& writer)')
 
 
 def process_proto_file(proto_file) -> Iterable[OutputFile]:
@@ -114,7 +106,6 @@
     _generate_code_for_package(proto_file, package_root, output_file)
 
     output_file.write_line()
-    codegen.package_stubs(package_root, output_file, _unary_stub,
-                          _server_streaming_stub)
+    codegen.package_stubs(package_root, output_file, StubGenerator())
 
     return [output_file]