pw_protobuf_compiler: Pass plugin paths

- Rather than relying on Python entry point scripts to get paths to
  protoc plugins, pass the paths to the generate_protos.py script. This
  works in environments where the Python entry points are not available.
- Use unique paths for generated proto code in the GN build. This allows
  multiple GN targets to generate code from the same .proto files. The
  CMake build already worked like this.
- Cleanup and reduce code duplication in the CMake and GN proto
  generation code.
- Support Windows builds: generate .bat wrapper script for plugins, fix
  compilation issue that affects some older compilers.

Change-Id: Iab27a3d193d6008567b324dc1c4e5f540894b8ff
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/24380
Commit-Queue: Wyatt Hepler <hepler@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Reviewed-by: Zoltan Szatmary-Ban <szatmz@google.com>
diff --git a/pw_build/docs.rst b/pw_build/docs.rst
index d78818b..d1c5b46 100644
--- a/pw_build/docs.rst
+++ b/pw_build/docs.rst
@@ -422,16 +422,24 @@
 The ``pw_add_facade`` function declares a cache variable named
 ``<module_name>_BACKEND`` for each facade. Cache variables can be awkward to
 work with, since their values only change when they're assigned, but then
-persist accross CMake invocations. It is recommended set these variables as
-follows:
+persist accross CMake invocations. These variables should be set in one of the
+following ways:
 
-* Use ``pw_set_backend`` to set backends appropriate for the target in the
+* Call ``pw_set_backend`` to set backends appropriate for the target in the
   target's toolchain file. The toolchain file is provided to ``cmake`` with
   ``-DCMAKE_TOOLCHAIN_FILE=<toolchain file>``.
-* To temporarily override a backend, set it interactively with ``ccmake`` or
+* Call ``pw_set_backend`` in the top-level ``CMakeLists.txt`` before other
+  CMake code executes.
+* Set the backend variable at the command line with the ``-D`` option.
+
+  .. code-block:: sh
+
+    cmake -B out/cmake_host -S "$PW_ROOT" -G Ninja \
+        -DCMAKE_TOOLCHAIN_FILE=$PW_ROOT/pw_toolchain/host_clang/toolchain.cmake \
+        -Dpw_log_BACKEND=pw_log_basic
+
+* Temporarily override a backend by setting it interactively with ``ccmake`` or
   ``cmake-gui``.
-* To force to a backend to a particular value globally, call ``pw_set_backend``
-  in the top-level ``CMakeLists.txt`` before any other CMake code is executed.
 
 Toolchain setup
 ---------------
@@ -458,16 +466,25 @@
 "")``), the dependency is not available. Otherwise, it is available and
 libraries declared by it can be referenced.
 
-The third_party variable may be set with the CMake ``set`` function in the
-toolchain file or a ``CMakeLists.txt`` prior to adding any directories. This
-statement sets the third-party variable for Nanopb to ``PRESENT``:
+Third party variables are set like any other cache global variable in CMake. It
+is recommended to set these in one of the following ways:
 
-.. code-block:: cmake
+* Set with the CMake ``set`` function in the toolchain file or a
+  ``CMakeLists.txt`` before other CMake code executes.
 
-  set(dir_pw_third_party_nanopb PRESENT CACHE STRING "" FORCE)
+  .. code-block:: cmake
 
-Alternately, the variable may be set temporarily with ``ccmake`` or
-``cmake-gui``.
+    set(dir_pw_third_party_nanopb PRESENT CACHE STRING "" FORCE)
+
+* Set the variable at the command line with the ``-D`` option.
+
+  .. code-block:: sh
+
+    cmake -B out/cmake_host -S "$PW_ROOT" -G Ninja \
+        -DCMAKE_TOOLCHAIN_FILE=$PW_ROOT/pw_toolchain/host_clang/toolchain.cmake \
+        -Ddir_pw_third_party_nanopb=/path/to/nanopb
+
+* Set the variable interactively with ``ccmake`` or ``cmake-gui``.
 
 Use Pigweed from an existing CMake project
 ------------------------------------------
diff --git a/pw_protobuf/BUILD.gn b/pw_protobuf/BUILD.gn
index 2923932..00eae35 100644
--- a/pw_protobuf/BUILD.gn
+++ b/pw_protobuf/BUILD.gn
@@ -59,20 +59,6 @@
   ]
 }
 
-# Entrypoint for pw_protobuf's protoc plugin.
-pw_input_group("codegen_protoc_plugin") {
-  inputs = [ "py/pw_protobuf/plugin.py" ]
-  deps = [ ":codegen_protoc_lib" ]
-}
-
-# Source files for pw_protobuf's protoc plugin.
-pw_input_group("codegen_protoc_lib") {
-  inputs = [
-    "py/pw_protobuf/codegen_pwpb.py",
-    "py/pw_protobuf/proto_tree.py",
-  ]
-}
-
 pw_test_group("tests") {
   tests = [
     ":codegen_test",
diff --git a/pw_protobuf_compiler/proto.cmake b/pw_protobuf_compiler/proto.cmake
index 1966fc7..bd8584e 100644
--- a/pw_protobuf_compiler/proto.cmake
+++ b/pw_protobuf_compiler/proto.cmake
@@ -64,13 +64,32 @@
 
 # Internal function that invokes protoc through generate_protos.py.
 function(_pw_generate_protos
-      TARGET LANGUAGE INCLUDE_FILE OUT_DIR SOURCES OUTPUTS DEPS)
+      TARGET LANGUAGE PLUGIN OUTPUT_EXTS INCLUDE_FILE OUT_DIR SOURCES DEPS)
+  # Determine the names of the output files.
+  foreach(extension IN LISTS OUTPUT_EXTS)
+    foreach(source_file IN LISTS SOURCES)
+      get_filename_component(dir "${source_file}" DIRECTORY)
+      get_filename_component(name "${source_file}" NAME_WE)
+      list(APPEND outputs "${OUT_DIR}/${dir}/${name}${extension}")
+    endforeach()
+  endforeach()
+
+  # Export the output files to the caller's scope so it can use them if needed.
+  set(generated_outputs "${outputs}" PARENT_SCOPE)
+
+  if("${CMAKE_HOST_SYSTEM_NAME}" STREQUAL "Windows")
+      get_filename_component(dir "${source_file}" DIRECTORY)
+      get_filename_component(name "${source_file}" NAME_WE)
+      set(PLUGIN "${dir}/${name}.bat")
+  endif()
+
   set(script "$ENV{PW_ROOT}/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py")
   add_custom_command(
     COMMAND
       python
       "${script}"
       --language "${LANGUAGE}"
+      --plugin-path "${PLUGIN}"
       --module-path "${CMAKE_CURRENT_SOURCE_DIR}"
       --include-file "${INCLUDE_FILE}"
       --out-dir "${OUT_DIR}"
@@ -88,22 +107,15 @@
 
 # Internal function that creates a pwpb proto library.
 function(_pw_pwpb_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
-  # Determine the names of the output files.
-  set(outputs "${SOURCES}")
-  list(TRANSFORM outputs REPLACE "\.proto$" ".pwpb.h")
-  list(TRANSFORM outputs PREPEND "${OUT_DIR}/")
-
-  # Make the source paths absolute since they are passed to a script.
-  list(TRANSFORM SOURCES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
-
   list(TRANSFORM DEPS APPEND .pwpb)
 
   _pw_generate_protos("${NAME}.generate.pwpb"
-      cc
+      pwpb
+      "$ENV{PW_ROOT}/pw_protobuf/py/pw_protobuf/plugin.py"
+      ".pwpb.h"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
-      "${outputs}"
       "${DEPS}"
   )
 
@@ -116,22 +128,15 @@
 
 # Internal function that creates a raw_rpc proto library.
 function(_pw_raw_rpc_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
-  # Determine the names of the output files.
-  set(outputs "${SOURCES}")
-  list(TRANSFORM outputs REPLACE "\.proto$" ".raw_rpc.pb.h")
-  list(TRANSFORM outputs PREPEND "${OUT_DIR}/")
-
-  # Make the source paths absolute since they are passed to a script.
-  list(TRANSFORM SOURCES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
-
   list(TRANSFORM DEPS APPEND .raw_rpc)
 
   _pw_generate_protos("${NAME}.generate.raw_rpc"
       raw_rpc
+      "$ENV{PW_ROOT}/pw_rpc/py/pw_rpc/plugin_raw.py"
+      ".raw_rpc.pb.h"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
-      "${outputs}"
       "${DEPS}"
   )
 
@@ -149,42 +154,25 @@
 
 # Internal function that creates a nanopb proto library.
 function(_pw_nanopb_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
-  # Determine the names of the output files.
-  set(outputs_h "${SOURCES}")
-  list(TRANSFORM outputs_h REPLACE "\.proto$" ".pb.h")
-  list(TRANSFORM outputs_h PREPEND "${OUT_DIR}/")
-
-  set(outputs_c "${SOURCES}")
-  list(TRANSFORM outputs_c REPLACE "\.proto$" ".pb.c")
-  list(TRANSFORM outputs_c PREPEND "${OUT_DIR}/")
-
-  set(outputs ${outputs_c} ${outputs_h})
-
-  # Make the source paths absolute since they are passed to a script.
-  list(TRANSFORM SOURCES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
-
   list(TRANSFORM DEPS APPEND .nanopb)
 
   set(nanopb_dir "$<TARGET_PROPERTY:$<IF:$<TARGET_EXISTS:protobuf-nanopb-static>,protobuf-nanopb-static,pw_build.empty>,SOURCE_DIR>")
   set(nanopb_plugin
       "$<IF:$<TARGET_EXISTS:protobuf-nanopb-static>,${nanopb_dir}/generator/protoc-gen-nanopb,COULD_NOT_FIND_protobuf-nanopb-static_TARGET_PLEASE_SET_UP_NANOPB>")
-  if(WIN32)
-    set(nanopb_plugin "${nanopb_plugin}.bat")
-  endif()
 
   _pw_generate_protos("${NAME}.generate.nanopb"
       nanopb
+      "${nanopb_plugin}"
+      ".pb.h;.pb.c"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
-      "${outputs}"
       "${DEPS}"
-      --custom-plugin "${nanopb_plugin}"
       --include-paths "${nanopb_dir}/generator/proto"
   )
 
   # Create the library with the generated source files.
-  add_library("${NAME}.nanopb" EXCLUDE_FROM_ALL ${outputs})
+  add_library("${NAME}.nanopb" EXCLUDE_FROM_ALL ${generated_outputs})
   target_include_directories("${NAME}.nanopb" PUBLIC "${OUT_DIR}")
   target_link_libraries("${NAME}.nanopb" PUBLIC pw_third_party.nanopb ${DEPS})
   add_dependencies("${NAME}.nanopb" "${NAME}.generate.nanopb")
@@ -193,21 +181,15 @@
 # Internal function that creates a nanopb_rpc library.
 function(_pw_nanopb_rpc_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
   # Determine the names of the output files.
-  set(outputs "${SOURCES}")
-  list(TRANSFORM outputs REPLACE "\.proto$" ".rpc.pb.h")
-  list(TRANSFORM outputs PREPEND "${OUT_DIR}/")
-
-  # Make the source paths absolute since they are passed to a script.
-  list(TRANSFORM SOURCES PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/")
-
   list(TRANSFORM DEPS APPEND .nanopb_rpc)
 
   _pw_generate_protos("${NAME}.generate.nanopb_rpc"
       nanopb_rpc
+      "$ENV{PW_ROOT}/pw_rpc/py/pw_rpc/plugin_nanopb.py"
+      ".rpc.pb.h"
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
-      "${outputs}"
       "${DEPS}"
   )
 
diff --git a/pw_protobuf_compiler/proto.gni b/pw_protobuf_compiler/proto.gni
index ad9f175..f7b1a28 100644
--- a/pw_protobuf_compiler/proto.gni
+++ b/pw_protobuf_compiler/proto.gni
@@ -20,67 +20,92 @@
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_third_party/nanopb/nanopb.gni")
 
-# Python script that invokes protoc.
-_gen_script_path =
-    "$dir_pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py"
-
+# Variables forwarded from the public pw_proto_library template to the final
+# pw_source_set.
 _forwarded_vars = [
   "testonly",
   "visibility",
 ]
 
+# Internal template that invokes protoc with a pw_python_action. This should not
+# be used outside of this file; use pw_proto_library instead.
+#
+# This creates the internal GN target $target_name.$language._gen that compiles
+# proto files with protoc.
+template("_pw_invoke_protoc") {
+  _output = rebase_path(get_target_outputs(":${invoker.base_target}._metadata"))
+
+  pw_python_action("$target_name._gen") {
+    forward_variables_from(invoker, [ "metadata" ])
+    script =
+        "$dir_pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py"
+
+    deps = [
+             ":${invoker.base_target}._metadata",
+             ":${invoker.base_target}._inputs",
+           ] + invoker.deps
+
+    args = [
+             "--language",
+             invoker.language,
+             "--module-path",
+             rebase_path("."),
+             "--include-file",
+             _output[0],
+             "--out-dir",
+             rebase_path(invoker.gen_dir),
+           ] + rebase_path(invoker.sources)
+
+    inputs = invoker.sources
+
+    if (defined(invoker.plugin)) {
+      inputs += [ invoker.plugin ]
+      args += [ "--plugin-path=" + rebase_path(invoker.plugin) ]
+    }
+
+    if (defined(invoker.include_paths)) {
+      args += [
+        "--include-paths",
+        string_join(";", rebase_path(invoker.include_paths)),
+      ]
+    }
+
+    outputs = []
+    foreach(extension, invoker.output_extensions) {
+      foreach(proto,
+              rebase_path(invoker.sources, get_path_info(".", "abspath"))) {
+        _output = string_replace(proto, ".proto", extension)
+        outputs += [ "${invoker.gen_dir}/$_output" ]
+      }
+    }
+
+    if (outputs == []) {
+      stamp = true
+    }
+
+    visibility = [ ":*" ]
+  }
+}
+
 # Generates pw_protobuf C++ code for proto files, creating a source_set of the
 # generated files. This is internal and should not be used outside of this file.
 # Use pw_proto_library instead.
-#
-# Args:
-#  protos: List of input .proto files.
 template("_pw_pwpb_proto_library") {
-  _proto_gen_dir = "$root_gen_dir/protos"
-  _module_path = get_path_info(".", "abspath")
-  _relative_proto_paths = rebase_path(invoker.protos, _module_path)
-
-  _outputs = []
-  foreach(_proto, _relative_proto_paths) {
-    _output = string_replace(_proto, ".proto", ".pwpb.h")
-    _outputs += [ "$_proto_gen_dir/$_output" ]
-  }
-
-  _gen_target = "${target_name}_gen"
-  pw_python_action(_gen_target) {
-    forward_variables_from(invoker, _forwarded_vars)
-    script = _gen_script_path
-    args = [
-             "--language",
-             "cc",
-             "--module-path",
-             rebase_path(_module_path),
-             "--include-file",
-             rebase_path(invoker.include_file),
-             "--out-dir",
-             rebase_path(_proto_gen_dir),
-           ] + rebase_path(invoker.protos)
-    inputs = invoker.protos
-    outputs = _outputs
-    deps = invoker.deps
-    if (defined(invoker.protoc_deps)) {
-      deps += invoker.protoc_deps
-    }
-  }
-
-  # For C++ proto files, the generated proto directory is added as an include
-  # path for the code.
-  _include_config_target = "${target_name}_includes"
-  config(_include_config_target) {
-    include_dirs = [ "$_proto_gen_dir" ]
+  _pw_invoke_protoc(target_name) {
+    forward_variables_from(invoker, "*", _forwarded_vars)
+    language = "pwpb"
+    plugin = "$dir_pw_protobuf/py/pw_protobuf/plugin.py"
+    deps += [ "$dir_pw_protobuf/py" ]
+    output_extensions = [ ".pwpb.h" ]
   }
 
   # Create a library with the generated source files.
   pw_source_set(target_name) {
-    public_configs = [ ":$_include_config_target" ]
-    deps = [ ":$_gen_target" ]
-    public_deps = [ dir_pw_protobuf ] + invoker.gen_deps
-    sources = get_target_outputs(":$_gen_target")
+    forward_variables_from(invoker, _forwarded_vars)
+    public_configs = [ ":${invoker.base_target}._include_path" ]
+    deps = [ ":$target_name._gen" ]
+    public_deps = [ dir_pw_protobuf ] + invoker.deps
+    sources = get_target_outputs(":$target_name._gen")
     public = filter_include(sources, [ "*.pwpb.h" ])
   }
 }
@@ -88,148 +113,57 @@
 # Generates nanopb RPC code for proto files, creating a source_set of the
 # generated files. This is internal and should not be used outside of this file.
 # Use pw_proto_library instead.
-#
-# Args:
-#  protos: List of input .proto files.
-#
 template("_pw_nanopb_rpc_proto_library") {
-  _proto_gen_dir = "$root_gen_dir/protos"
-  _module_path = get_path_info(".", "abspath")
-  _relative_proto_paths = rebase_path(invoker.protos, _module_path)
-
-  _outputs = []
-  foreach(_proto, _relative_proto_paths) {
-    _output_h = string_replace(_proto, ".proto", ".rpc.pb.h")
-    _outputs += [ "$_proto_gen_dir/$_output_h" ]
-  }
-
   # Create a target which runs protoc configured with the nanopb_rpc plugin to
   # generate the C++ proto RPC headers.
-  _gen_target = "${target_name}_gen"
-  pw_python_action(_gen_target) {
-    forward_variables_from(invoker, _forwarded_vars)
-    script = _gen_script_path
-    args = [
-             "--language",
-             "nanopb_rpc",
-             "--module-path",
-             rebase_path(_module_path),
-             "--include-paths",
-             rebase_path("$dir_pw_third_party_nanopb/generator/proto"),
-             "--include-file",
-             rebase_path(invoker.include_file),
-             "--out-dir",
-             rebase_path(_proto_gen_dir),
-           ] + rebase_path(invoker.protos)
-    inputs = invoker.protos
-    outputs = _outputs
-
-    deps = invoker.deps
-    if (defined(invoker.protoc_deps)) {
-      deps += invoker.protoc_deps
-    }
-  }
-
-  # For C++ proto files, the generated proto directory is added as an include
-  # path for the code.
-  _include_root = rebase_path(get_path_info(".", "abspath"), "//")
-  _include_config_target = "${target_name}_includes"
-  config(_include_config_target) {
-    include_dirs = [
-      "$_proto_gen_dir",
-      "$_proto_gen_dir/$_include_root",
-    ]
+  _pw_invoke_protoc(target_name) {
+    forward_variables_from(invoker, "*", _forwarded_vars)
+    language = "nanopb_rpc"
+    plugin = "$dir_pw_rpc/py/pw_rpc/plugin_nanopb.py"
+    deps += [ "$dir_pw_rpc/py" ]
+    include_paths = [ "$dir_pw_third_party_nanopb/generator/proto" ]
+    output_extensions = [ ".rpc.pb.h" ]
   }
 
   # Create a library with the generated source files.
   pw_source_set(target_name) {
-    public_configs = [ ":$_include_config_target" ]
-    deps = [ ":$_gen_target" ]
+    forward_variables_from(invoker, _forwarded_vars)
+    public_configs = [ ":${invoker.base_target}._include_path" ]
+    deps = [ ":$target_name._gen" ]
     public_deps = [
+                    ":${invoker.base_target}.nanopb",
                     "$dir_pw_rpc:server",
                     "$dir_pw_rpc/nanopb:method_union",
                     "$dir_pw_third_party/nanopb",
-                  ] + invoker.gen_deps
-    public = get_target_outputs(":$_gen_target")
+                  ] + invoker.deps
+    public = get_target_outputs(":$target_name._gen")
   }
 }
 
 # Generates nanopb code for proto files, creating a source_set of the generated
 # files. This is internal and should not be used outside of this file. Use
 # pw_proto_library instead.
-#
-# Args:
-#  protos: List of input .proto files.
 template("_pw_nanopb_proto_library") {
-  _proto_gen_dir = "$root_gen_dir/protos"
-  _module_path = get_path_info(".", "abspath")
-  _relative_proto_paths = rebase_path(invoker.protos, _module_path)
-
-  _outputs = []
-  foreach(_proto, _relative_proto_paths) {
-    _output_h = string_replace(_proto, ".proto", ".pb.h")
-    _output_c = string_replace(_proto, ".proto", ".pb.c")
-    _outputs += [
-      "$_proto_gen_dir/$_output_h",
-      "$_proto_gen_dir/$_output_c",
-    ]
-  }
-
-  _nanopb_plugin = "$dir_pw_third_party_nanopb/generator/protoc-gen-nanopb"
-  if (host_os == "win") {
-    _nanopb_plugin += ".bat"
-  }
-
   # Create a target which runs protoc configured with the nanopb plugin to
   # generate the C proto sources.
-  _gen_target = "${target_name}_gen"
-  pw_python_action(_gen_target) {
-    forward_variables_from(invoker, _forwarded_vars)
-    script = _gen_script_path
-    args = [
-             "--language",
-             "nanopb",
-             "--module-path",
-             rebase_path(_module_path),
-             "--include-paths",
-             rebase_path("$dir_pw_third_party_nanopb/generator/proto"),
-             "--include-file",
-             rebase_path(invoker.include_file),
-             "--out-dir",
-             rebase_path(_proto_gen_dir),
-             "--custom-plugin",
-             rebase_path(_nanopb_plugin),
-           ] + rebase_path(invoker.protos)
-
-    inputs = invoker.protos
-    outputs = _outputs
-
-    deps = invoker.deps
-    if (defined(invoker.protoc_deps)) {
-      deps += invoker.protoc_deps
-    }
-  }
-
-  # For C++ proto files, the generated proto directory is added as an include
-  # path for the code.
-  _include_root = rebase_path(get_path_info(".", "abspath"), "//")
-  _include_config_target = "${target_name}_includes"
-  config(_include_config_target) {
-    include_dirs = [
-      "$_proto_gen_dir",
-      "$_proto_gen_dir/$_include_root",
+  _pw_invoke_protoc(target_name) {
+    forward_variables_from(invoker, "*", _forwarded_vars)
+    language = "nanopb"
+    plugin = "$dir_pw_third_party_nanopb/generator/protoc-gen-nanopb"
+    include_paths = [ "$dir_pw_third_party_nanopb/generator/proto" ]
+    output_extensions = [
+      ".pb.h",
+      ".pb.c",
     ]
-
-    # Nanopb uses __cplusplus with the implicit default of 0.
-    cflags = [ "-Wno-undef" ]
   }
 
   # Create a library with the generated source files.
   pw_source_set(target_name) {
-    public_configs = [ ":$_include_config_target" ]
-    deps = [ ":$_gen_target" ]
-    public_deps = [ "$dir_pw_third_party/nanopb" ] + invoker.gen_deps
-    sources = get_target_outputs(":$_gen_target")
+    forward_variables_from(invoker, _forwarded_vars)
+    public_configs = [ ":${invoker.base_target}._include_path" ]
+    deps = [ ":$target_name._gen" ]
+    public_deps = [ "$dir_pw_third_party/nanopb" ] + invoker.deps
+    sources = get_target_outputs(":$target_name._gen")
     public = filter_include(sources, [ "*.pb.h" ])
   }
 }
@@ -237,102 +171,51 @@
 # Generates raw RPC code for proto files, creating a source_set of the generated
 # files. This is internal and should not be used outside of this file. Use
 # pw_proto_library instead.
-#
-# Args:
-#  protos: List of input .proto files.
-#
 template("_pw_raw_rpc_proto_library") {
-  _proto_gen_dir = "$root_gen_dir/protos"
-  _module_path = get_path_info(".", "abspath")
-  _relative_proto_paths = rebase_path(invoker.protos, _module_path)
-
-  _outputs = []
-  foreach(_proto, _relative_proto_paths) {
-    _output_h = string_replace(_proto, ".proto", ".raw_rpc.pb.h")
-    _outputs += [ "$_proto_gen_dir/$_output_h" ]
-  }
-
   # Create a target which runs protoc configured with the nanopb_rpc plugin to
   # generate the C++ proto RPC headers.
-  _gen_target = "${target_name}_gen"
-  pw_python_action(_gen_target) {
-    forward_variables_from(invoker, _forwarded_vars)
-    script = _gen_script_path
-    args = [
-             "--language",
-             "raw_rpc",
-             "--module-path",
-             rebase_path(_module_path),
-             "--include-file",
-             rebase_path(invoker.include_file),
-             "--out-dir",
-             rebase_path(_proto_gen_dir),
-           ] + rebase_path(invoker.protos)
-    inputs = invoker.protos
-    outputs = _outputs
-
-    deps = invoker.deps
-    if (defined(invoker.protoc_deps)) {
-      deps += invoker.protoc_deps
-    }
-  }
-
-  # For C++ proto files, the generated proto directory is added as an include
-  # path for the code.
-  _include_root = rebase_path(get_path_info(".", "abspath"), "//")
-  _include_config_target = "${target_name}_includes"
-  config(_include_config_target) {
-    include_dirs = [
-      "$_proto_gen_dir",
-      "$_proto_gen_dir/$_include_root",
-    ]
+  _pw_invoke_protoc(target_name) {
+    forward_variables_from(invoker, "*", _forwarded_vars)
+    language = "raw_rpc"
+    plugin = "$dir_pw_rpc/py/pw_rpc/plugin_raw.py"
+    deps += [ "$dir_pw_rpc/py" ]
+    output_extensions = [ ".raw_rpc.pb.h" ]
   }
 
   # Create a library with the generated source files.
   pw_source_set(target_name) {
-    public_configs = [ ":$_include_config_target" ]
-    deps = [ ":$_gen_target" ]
+    forward_variables_from(invoker, _forwarded_vars)
+    public_configs = [ ":${invoker.base_target}._include_path" ]
+    deps = [ ":$target_name._gen" ]
     public_deps = [
                     "$dir_pw_rpc:server",
                     "$dir_pw_rpc/raw:method_union",
-                  ] + invoker.gen_deps
-    public = get_target_outputs(":$_gen_target")
+                  ] + invoker.deps
+    public = get_target_outputs(":$target_name._gen")
   }
 }
 
 # Generates Go code for proto files, listing the proto output directory in the
 # metadata variable GOPATH. Internal use only.
-#
-# Args:
-#  protos: List of input .proto files.
 template("_pw_go_proto_library") {
   _proto_gopath = "$root_gen_dir/go"
-  _proto_gen_dir = "$_proto_gopath/src"
-  _rebased_gopath = rebase_path(_proto_gopath)
 
-  pw_python_action(target_name) {
-    forward_variables_from(invoker, _forwarded_vars)
+  _pw_invoke_protoc(target_name) {
+    forward_variables_from(invoker, "*")
+    language = "go"
     metadata = {
-      gopath = [ "GOPATH+=$_rebased_gopath" ]
+      gopath = [ "GOPATH+=" + rebase_path(_proto_gopath) ]
       external_deps = [
         "github.com/golang/protobuf/proto",
         "google.golang.org/grpc",
       ]
     }
-    script = _gen_script_path
-    args = [
-             "--language",
-             "go",
-             "--module-path",
-             rebase_path("//"),
-             "--include-file",
-             rebase_path(invoker.include_file),
-             "--out-dir",
-             rebase_path(_proto_gen_dir),
-           ] + rebase_path(invoker.protos)
-    inputs = invoker.protos
-    deps = invoker.deps + invoker.gen_deps
-    stamp = true
+    output_extensions = []  # Don't enumerate the generated .go files.
+    gen_dir = "$_proto_gopath/src"
+  }
+
+  group(target_name) {
+    deps = [ ":$target_name._gen" ]
   }
 }
 
@@ -350,19 +233,26 @@
   assert(defined(invoker.sources) && invoker.sources != [],
          "pw_proto_library requires .proto source files")
 
+  _common = {
+    base_target = target_name
+    gen_dir = "$target_gen_dir/protos"
+    sources = invoker.sources
+  }
+
+  if (defined(invoker.deps)) {
+    _deps = invoker.deps
+  } else {
+    _deps = []
+  }
+
   # For each proto target, create a file which collects the base directories of
   # all of its dependencies to list as include paths to protoc.
-  _include_metadata_target = "${target_name}_include_paths"
-  _include_metadata_file = "${target_gen_dir}/${target_name}_includes.txt"
-  generated_file(_include_metadata_target) {
-    if (defined(invoker.deps)) {
-      # Collect metadata from the include path files of each dependency.
-      deps = process_file_template(invoker.deps, "{{source}}_include_paths")
-    } else {
-      deps = []
-    }
+  generated_file("$target_name._metadata") {
+    # Collect metadata from the include path files of each dependency.
+    deps = process_file_template(_deps, "{{source}}._metadata")
+
     data_keys = [ "protoc_includes" ]
-    outputs = [ _include_metadata_file ]
+    outputs = [ "$target_gen_dir/${target_name}_includes.txt" ]
 
     # Indicate this library's base directory for its dependents.
     metadata = {
@@ -370,81 +260,66 @@
     }
   }
 
-  _deps = [ ":$_include_metadata_target" ]
-
+  # Toss any additional inputs into an input group dependency.
   if (defined(invoker.inputs)) {
-    # Toss any additional inputs into an input group dependency.
-    _input_target_name = "${target_name}_inputs"
-    pw_input_group(_input_target_name) {
+    pw_input_group("$target_name._inputs") {
       inputs = invoker.inputs
+      visibility = [ ":*" ]
     }
-    _deps += [ ":$_input_target_name" ]
+  } else {
+    group("$target_name._inputs") {
+      visibility = [ ":*" ]
+    }
   }
 
-  _base_target = target_name
-
-  if (defined(invoker.deps)) {
-    _invoker_deps = invoker.deps
-  } else {
-    _invoker_deps = []
+  # Create a config with the generated proto directory, which is used for C++.
+  config("$target_name._include_path") {
+    include_dirs = [ _common.gen_dir ]
+    visibility = [ ":*" ]
   }
 
   # Enumerate all of the protobuf generator targets.
 
-  _pw_pwpb_proto_library("${_base_target}.pwpb") {
+  _pw_pwpb_proto_library("$target_name.pwpb") {
     forward_variables_from(invoker, _forwarded_vars)
-    protos = invoker.sources
-    deps = _deps
-    include_file = _include_metadata_file
-    gen_deps = process_file_template(_invoker_deps, "{{source}}.pwpb")
-    protoc_deps = [ "$dir_pw_protobuf/py" ]
+    forward_variables_from(_common, "*")
+    deps = process_file_template(_deps, "{{source}}.pwpb")
   }
 
   if (dir_pw_third_party_nanopb != "") {
-    _pw_nanopb_rpc_proto_library("${_base_target}.nanopb_rpc") {
+    _pw_nanopb_rpc_proto_library("$target_name.nanopb_rpc") {
       forward_variables_from(invoker, _forwarded_vars)
-      protos = invoker.sources
-      deps = _deps
-      include_file = _include_metadata_file
-      gen_deps = process_file_template(_invoker_deps, "{{source}}.nanopb") +
-                 [ ":${_base_target}.nanopb" ]
-      protoc_deps = [ "$dir_pw_rpc/py" ]
+      forward_variables_from(_common, "*")
+      deps = process_file_template(_deps, "{{source}}.nanopb_rpc")
     }
 
-    _pw_nanopb_proto_library("${target_name}.nanopb") {
+    _pw_nanopb_proto_library("$target_name.nanopb") {
       forward_variables_from(invoker, _forwarded_vars)
-      protos = invoker.sources
-      deps = _deps
-      include_file = _include_metadata_file
-      gen_deps = process_file_template(_invoker_deps, "{{source}}.nanopb")
+      forward_variables_from(_common, "*")
+      deps = process_file_template(_deps, "{{source}}.nanopb")
     }
   } else {
-    pw_error("${_base_target}.nanopb_rpc") {
+    pw_error("$target_name.nanopb_rpc") {
       message =
           "\$dir_pw_third_party_nanopb must be set to generate nanopb RPC code."
     }
 
-    pw_error("${_base_target}.nanopb") {
+    pw_error("$target_name.nanopb") {
       message =
           "\$dir_pw_third_party_nanopb must be set to compile nanopb protobufs."
     }
   }
 
-  _pw_raw_rpc_proto_library("${target_name}.raw_rpc") {
+  _pw_raw_rpc_proto_library("$target_name.raw_rpc") {
     forward_variables_from(invoker, _forwarded_vars)
-    protos = invoker.sources
-    deps = _deps
-    include_file = _include_metadata_file
-    gen_deps = []
-    protoc_deps = [ "$dir_pw_rpc/py" ]
+    forward_variables_from(_common, "*", [ "deps" ])
+    deps = process_file_template(_deps, "{{source}}.raw_rpc")
   }
 
-  _pw_go_proto_library("${target_name}.go") {
-    forward_variables_from(invoker, _forwarded_vars)
-    protos = invoker.sources
-    deps = _deps
-    include_file = _include_metadata_file
-    gen_deps = process_file_template(_invoker_deps, "{{source}}.go")
+  _pw_go_proto_library("$target_name.go") {
+    sources = invoker.sources
+    deps = process_file_template(_deps, "{{source}}.go")
+    base_target = _common.base_target
   }
 
   # All supported pw_protobuf generators.
diff --git a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
index 9ec8555..7ee524f 100644
--- a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
+++ b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
@@ -16,8 +16,9 @@
 import argparse
 import logging
 import os
-import shutil
+from pathlib import Path
 import sys
+import tempfile
 
 from typing import Callable, Dict, List, Optional
 
@@ -35,8 +36,13 @@
     if parser is None:
         parser = argparse.ArgumentParser(description=__doc__)
 
-    parser.add_argument('--language', default='cc', help='Output language')
-    parser.add_argument('--custom-plugin', help='Custom protoc plugin')
+    parser.add_argument('--language',
+                        required=True,
+                        choices=DEFAULT_PROTOC_ARGS,
+                        help='Output language')
+    parser.add_argument('--plugin-path',
+                        type=Path,
+                        help='Path to the protoc plugin')
     parser.add_argument('--module-path',
                         required=True,
                         help='Path to the module containing the .proto files')
@@ -60,8 +66,10 @@
 
 def protoc_cc_args(args: argparse.Namespace) -> List[str]:
     return [
-        '--plugin', f'protoc-gen-custom={shutil.which("pw_protobuf_codegen")}',
-        '--custom_out', args.out_dir
+        '--plugin',
+        f'protoc-gen-custom={args.plugin_path}',
+        '--custom_out',
+        args.out_dir,
     ]
 
 
@@ -73,33 +81,36 @@
     # nanopb needs to know of the include path to parse *.options files
     return [
         '--plugin',
-        f'protoc-gen-nanopb={args.custom_plugin}',
+        f'protoc-gen-nanopb={args.plugin_path}',
         # nanopb_opt provides the flags to use for nanopb_out. Windows doesn't
         # like when you merge the two using the `flag,...:out` syntax.
         f'--nanopb_opt=-I{args.module_path}',
-        f'--nanopb_out={args.out_dir}'
+        f'--nanopb_out={args.out_dir}',
     ]
 
 
 def protoc_nanopb_rpc_args(args: argparse.Namespace) -> List[str]:
     return [
         '--plugin',
-        f'protoc-gen-custom={shutil.which("pw_rpc_codegen_nanopb")}',
-        '--custom_out', args.out_dir
+        f'protoc-gen-custom={args.plugin_path}',
+        '--custom_out',
+        args.out_dir,
     ]
 
 
 def protoc_raw_rpc_args(args: argparse.Namespace) -> List[str]:
     return [
-        '--plugin', f'protoc-gen-custom={shutil.which("pw_rpc_codegen_raw")}',
-        '--custom_out', args.out_dir
+        '--plugin',
+        f'protoc-gen-custom={args.plugin_path}',
+        '--custom_out',
+        args.out_dir,
     ]
 
 
 # Default additional protoc arguments for each supported language.
 # TODO(frolv): Make these overridable with a command-line argument.
 DEFAULT_PROTOC_ARGS: Dict[str, Callable[[argparse.Namespace], List[str]]] = {
-    'cc': protoc_cc_args,
+    'pwpb': protoc_cc_args,
     'go': protoc_go_args,
     'nanopb': protoc_nanopb_args,
     'nanopb_rpc': protoc_nanopb_rpc_args,
@@ -110,26 +121,45 @@
 def main() -> int:
     """Runs protoc as configured by command-line arguments."""
 
-    args = argument_parser().parse_args()
-    os.makedirs(args.out_dir, exist_ok=True)
+    parser = argument_parser()
+    args = parser.parse_args()
 
-    try:
-        lang_args = DEFAULT_PROTOC_ARGS[args.language](args)
-    except KeyError:
-        _LOG.error('Unsupported language: %s', args.language)
-        return 1
+    if args.plugin_path is None and args.language != 'go':
+        parser.error(
+            f'--plugin-path is required for --language {args.language}')
+
+    os.makedirs(args.out_dir, exist_ok=True)
 
     include_paths = [f'-I{path}' for path in args.include_paths]
     include_paths += [f'-I{line.strip()}' for line in args.include_file]
 
-    process = pw_cli.process.run(
-        'protoc',
-        f'-I{args.module_path}',
-        f'-I{args.out_dir}',
-        *include_paths,
-        *lang_args,
-        *args.protos,
-    )
+    wrapper_script: Optional[Path] = None
+
+    # On Windows, use a .bat version of the plugin if it exists or create a .bat
+    # wrapper to use if none exists.
+    if os.name == 'nt' and args.plugin_path:
+        if args.plugin_path.with_suffix('.bat').exists():
+            args.plugin_path = args.plugin_path.with_suffix('.bat')
+            _LOG.debug('Using Batch plugin %s', args.plugin_path)
+        else:
+            with tempfile.NamedTemporaryFile('w', suffix='.bat',
+                                             delete=False) as file:
+                file.write(f'@echo off\npython {args.plugin_path.resolve()}\n')
+
+            args.plugin_path = wrapper_script = Path(file.name)
+            _LOG.debug('Using generated plugin wrapper %s', args.plugin_path)
+
+    try:
+        process = pw_cli.process.run(
+            'protoc',
+            f'-I{args.module_path}',
+            *include_paths,
+            *DEFAULT_PROTOC_ARGS[args.language](args),
+            *args.protos,
+        )
+    finally:
+        if wrapper_script:
+            wrapper_script.unlink()
 
     if process.returncode != 0:
         print(process.output.decode(), file=sys.stderr)
diff --git a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h
index 7c5dd9a..78b798c 100644
--- a/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h
+++ b/pw_rpc/nanopb/public/pw_rpc/internal/nanopb_method.h
@@ -136,18 +136,18 @@
     //
     // In optimized builds, the compiler inlines the user-defined function into
     // this wrapper, elminating any overhead.
-    return NanopbMethod(id,
-                        UnaryInvoker<AllocateSpaceFor<Request<method>>(),
-                                     AllocateSpaceFor<Response<method>>()>,
-                        {.unary =
-                             [](ServerCall& call, const void* req, void* resp) {
-                               return method(
-                                   call,
-                                   *static_cast<const Request<method>*>(req),
-                                   *static_cast<Response<method>*>(resp));
-                             }},
-                        request,
-                        response);
+    return NanopbMethod(
+        id,
+        UnaryInvoker<AllocateSpaceFor<Request<method>>(),
+                     AllocateSpaceFor<Response<method>>()>,
+        Function{.unary =
+                     [](ServerCall& call, const void* req, void* resp) {
+                       return method(call,
+                                     *static_cast<const Request<method>*>(req),
+                                     *static_cast<Response<method>*>(resp));
+                     }},
+        request,
+        response);
   }
 
   // Creates a NanopbMethod for a server-streaming RPC.
@@ -163,12 +163,15 @@
     return NanopbMethod(
         id,
         ServerStreamingInvoker<AllocateSpaceFor<Request<method>>()>,
-        {.server_streaming =
-             [](ServerCall& call, const void* req, BaseServerWriter& writer) {
-               method(call,
-                      *static_cast<const Request<method>*>(req),
-                      static_cast<ServerWriter<Response<method>>&>(writer));
-             }},
+        Function{.server_streaming =
+                     [](ServerCall& call,
+                        const void* req,
+                        BaseServerWriter& writer) {
+                       method(call,
+                              *static_cast<const Request<method>*>(req),
+                              static_cast<ServerWriter<Response<method>>&>(
+                                  writer));
+                     }},
         request,
         response);
   }
diff --git a/pw_rpc/py/pw_rpc/plugin_nanopb.py b/pw_rpc/py/pw_rpc/plugin_nanopb.py
old mode 100644
new mode 100755
index 563f3c8..2dfcf2d
--- a/pw_rpc/py/pw_rpc/plugin_nanopb.py
+++ b/pw_rpc/py/pw_rpc/plugin_nanopb.py
@@ -1,3 +1,4 @@
+#!/usr/bin/env python3
 # Copyright 2020 The Pigweed Authors
 #
 # Licensed under the Apache License, Version 2.0 (the "License"); you may not
diff --git a/pw_rpc/py/pw_rpc/plugin_raw.py b/pw_rpc/py/pw_rpc/plugin_raw.py
old mode 100644
new mode 100755
index cb3a7cc..8c2235915
--- a/pw_rpc/py/pw_rpc/plugin_raw.py
+++ b/pw_rpc/py/pw_rpc/plugin_raw.py
@@ -1,3 +1,4 @@
+#!/usr/bin/env python3
 # Copyright 2020 The Pigweed Authors
 #
 # Licensed under the Apache License, Version 2.0 (the "License"); you may not