diff --git a/MODULE.bazel b/MODULE.bazel index 245b79f9..dfbaa150 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -10,6 +10,7 @@ bazel_dep(name = "libyaml", version = "0.2.5") bazel_dep(name = "lz4", version = "1.10.0.bcr.1") bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1") bazel_dep(name = "platforms", version = "1.1.0") +bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf") bazel_dep(name = "pybind11_bazel", version = "3.0.0") bazel_dep(name = "readerwriterqueue", version = "1.0.6") bazel_dep(name = "rules_cc", version = "0.2.16") diff --git a/repositories/deps.bzl b/repositories/deps.bzl index bdc684cb..c696dd05 100644 --- a/repositories/deps.bzl +++ b/repositories/deps.bzl @@ -3,6 +3,7 @@ load("@bazel_features//:deps.bzl", "bazel_features_deps") load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace") +load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps") load("@googletest//:googletest_deps.bzl", "googletest_deps") load("@rules_foreign_cc//foreign_cc:repositories.bzl", "rules_foreign_cc_dependencies") load("@rules_shell//shell:repositories.bzl", "rules_shell_dependencies", "rules_shell_toolchains") @@ -16,3 +17,4 @@ def ros2_deps(): rules_shell_toolchains() rules_foreign_cc_dependencies() googletest_deps() + protobuf_deps() diff --git a/repositories/repositories.bzl b/repositories/repositories.bzl index eb7aee71..acf3aa58 100644 --- a/repositories/repositories.bzl +++ b/repositories/repositories.bzl @@ -234,6 +234,14 @@ def ros2_workspace_repositories(): ], ) + maybe( + http_archive, + name = "com_google_protobuf", + sha256 = "440848dffa209beb8a04e41cc352762e44f8e91342b2a43aab6af9b30713c2f6", + strip_prefix = "protobuf-33.5", + urls = ["https://github.com/protocolbuffers/protobuf/archive/refs/tags/v33.5.tar.gz"], + ) + def ros2_repositories(): """Import ROS 2 repositories.""" diff --git a/ros2/BUILD.bazel b/ros2/BUILD.bazel index 437c9926..2ff9dc9e 100644 --- a/ros2/BUILD.bazel +++ b/ros2/BUILD.bazel @@ -5,6 +5,7 @@ load("@bazel_skylib//rules:common_settings.bzl", "string_flag") load("@rules_cc//cc:defs.bzl", "cc_library") load("@rules_python//python:defs.bzl", "py_binary", "py_library") load("@rules_python//python:pip.bzl", "whl_filegroup") +load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") exports_files([ "action.bzl", diff --git a/ros2/interfaces.bzl b/ros2/interfaces.bzl index fa8af1f4..e11edbd6 100644 --- a/ros2/interfaces.bzl +++ b/ros2/interfaces.bzl @@ -16,6 +16,7 @@ load("@bazel_skylib//lib:paths.bzl", "paths") load("@com_github_mvukov_rules_ros2//ros2:cc_opts.bzl", "C_COPTS") +load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") load("@rules_cc//cc:defs.bzl", "CcInfo", "cc_common") load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") load("@rules_python//python:defs.bzl", "PyInfo", "py_library") @@ -26,6 +27,7 @@ Ros2InterfaceInfo = provider( fields = [ "info", "deps", + "ros_package_name", ], ) @@ -43,6 +45,7 @@ def _ros2_interface_library_impl(ctx): for dep in ctx.attr.deps ], ), + ros_package_name = ctx.label.name, ), ] @@ -141,7 +144,7 @@ IdlAdapterAspectInfo = provider("TBD", fields = [ ]) def _idl_adapter_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs idl_files, idl_tuples = _run_adapter(ctx, package_name, srcs) return [ @@ -154,7 +157,6 @@ def _idl_adapter_aspect_impl(target, ctx): idl_adapter_aspect = aspect( implementation = _idl_adapter_aspect_impl, attr_aspects = ["deps"], - required_providers = [Ros2InterfaceInfo], attrs = { "_adapter": attr.label( default = Label("@ros2_rosidl//:rosidl_adapter_app"), @@ -162,6 +164,8 @@ idl_adapter_aspect = aspect( cfg = "exec", ), }, + required_providers = [[ProtoInfo], [Ros2InterfaceInfo]], + required_aspect_providers = [Ros2InterfaceInfo], provides = [IdlAdapterAspectInfo], ) @@ -291,13 +295,13 @@ def _get_compilation_contexts_from_deps(deps): return [dep[CcInfo].compilation_context for dep in deps] def _get_compilation_contexts_from_aspect_info_deps(deps, aspect_info): - return [dep[aspect_info].cc_info.compilation_context for dep in deps] + return [dep[aspect_info].cc_info.compilation_context for dep in deps if aspect_info in dep] def _get_linking_contexts_from_deps(deps): return [dep[CcInfo].linking_context for dep in deps] def _get_linking_contexts_from_aspect_info_deps(deps, aspect_info): - return [dep[aspect_info].cc_info.linking_context for dep in deps] + return [dep[aspect_info].cc_info.linking_context for dep in deps if aspect_info in dep] def _compile_cc_generated_code( ctx, @@ -367,7 +371,7 @@ def _compile_cc_generated_code( ) def _c_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] @@ -492,14 +496,14 @@ c_generator_aspect = aspect( fragments = ["cpp"], ) -def _cc_generator_impl(ctx, aspect_info): +def cc_generator_impl(ctx, aspect_info): cc_info = cc_common.merge_cc_infos( direct_cc_infos = [dep[aspect_info].cc_info for dep in ctx.attr.deps], ) return [cc_info] def _c_ros2_interface_library_impl(ctx): - return _cc_generator_impl(ctx, CGeneratorAspectInfo) + return cc_generator_impl(ctx, CGeneratorAspectInfo) c_ros2_interface_library = rule( attrs = { @@ -535,7 +539,7 @@ _TYPESUPPORT_INTROSPECION_GENERATOR_CPP_OUTPUT_MAPPING = [ ] def _cpp_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] @@ -648,15 +652,15 @@ cpp_generator_aspect = aspect( default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), ), }, - required_providers = [Ros2InterfaceInfo], - required_aspect_providers = [IdlAdapterAspectInfo], + required_providers = [[ProtoInfo], [Ros2InterfaceInfo]], + required_aspect_providers = [[Ros2InterfaceInfo], [IdlAdapterAspectInfo]], provides = [CppGeneratorAspectInfo], toolchains = ["@bazel_tools//tools/cpp:toolchain_type"], fragments = ["cpp"], ) def _cpp_ros2_interface_library_impl(ctx): - return _cc_generator_impl(ctx, CppGeneratorAspectInfo) + return cc_generator_impl(ctx, CppGeneratorAspectInfo) cpp_ros2_interface_library = rule( attrs = { @@ -686,7 +690,7 @@ def _get_py_srcs(files): return [f for f in files if f.path.endswith(".py")] def _py_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] @@ -779,32 +783,33 @@ def _py_generator_aspect_impl(target, ctx): *relative_path_parts[0:] ) + py_deps = [dep for dep in ctx.rule.attr.deps if PyGeneratorAspectInfo in dep] py_info = PyGeneratorAspectInfo( cc_info = cc_common.merge_cc_infos( direct_cc_infos = [compilation_info.cc_info] + [ dep[PyGeneratorAspectInfo].cc_info - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), dynamic_libraries = depset( direct = [dynamic_library], transitive = [ dep[PyGeneratorAspectInfo].dynamic_libraries - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), transitive_sources = depset( direct = _get_py_srcs(all_outputs), transitive = [ dep[PyGeneratorAspectInfo].transitive_sources - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), imports = depset( direct = [py_import_path], transitive = [ dep[PyGeneratorAspectInfo].imports - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), linker_inputs = compilation_info.cc_info.linking_context.linker_inputs, diff --git a/ros2/plugin_aspects.bzl b/ros2/plugin_aspects.bzl index 53752b43..3db5e482 100644 --- a/ros2/plugin_aspects.bzl +++ b/ros2/plugin_aspects.bzl @@ -98,7 +98,7 @@ Ros2InterfaceCollectorAspectInfo = provider( def create_interface_struct(target): return struct( - package_name = target.label.name, + package_name = target[Ros2InterfaceInfo].ros_package_name, srcs = target[Ros2InterfaceInfo].info.srcs, ) @@ -155,7 +155,7 @@ def create_dynamic_library(ctx, **kwargs): return dynamic_library def _ros2_idl_plugin_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name cc_info = target[CppGeneratorAspectInfo].cc_info dynamic_library = create_dynamic_library( ctx, diff --git a/ros2/protobuf/BUILD.bazel b/ros2/protobuf/BUILD.bazel new file mode 100644 index 00000000..9df4ddee --- /dev/null +++ b/ros2/protobuf/BUILD.bazel @@ -0,0 +1,36 @@ +load("@com_github_mvukov_rules_ros2//ros2:cc_defs.bzl", "ros2_cpp_library") +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") + +exports_files([ + "protobuf.bzl", +]) + +py_library( + name = "proto_to_ros2", + srcs = ["proto_to_ros2.py"], + deps = [ + "@com_google_protobuf//:protobuf_python", + ], +) + +py_binary( + name = "proto_to_ros2_msg", + srcs = ["proto_to_ros2_msg.py"], + visibility = ["//visibility:public"], + deps = [ + ":proto_to_ros2", + "@com_google_protobuf//:protobuf_python", + ], +) + +py_binary( + name = "proto_to_ros2_converter", + srcs = ["proto_to_ros2_converter.py"], + visibility = ["//visibility:public"], + deps = [ + ":proto_to_ros2", + "@com_google_protobuf//:protobuf_python", + requirement("empy"), + ], +) diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md new file mode 100644 index 00000000..c80bd801 --- /dev/null +++ b/ros2/protobuf/README.md @@ -0,0 +1,139 @@ +# Proto → ROS 2 interface rules + +This package provides Bazel rules for converting [Protocol Buffer](https://protobuf.dev/) +message definitions into ROS 2 interface types and optional C++ conversion +helpers. + +## Rules + +### `proto_ros2_interface_library` + +Generates one ROS 2 `.msg` file for each proto source in a single +`proto_library` dep. Use this rule when you +need the generated `.msg` files as input to other interface rules +(`cpp_ros2_interface_library`, `py_ros2_interface_library`, etc.). + +```python +load("//ros2/protobuf:defs.bzl", "proto_ros2_interface_library") + +proto_ros2_interface_library( + name = "point_msgs", + dep = ":point_proto", +) +``` + +### `cpp_proto_ros2_interface_library` + +Runs the full pipeline (proto → `.msg` → ... → C++ code → compilation). Use this rule when you need to +include the generated message headers in your C++ code. + +```python +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_interface_library") + +cpp_proto_ros2_interface_library( + name = "cpp_point_msgs", + deps = [":point_proto"], +) +``` + +Include path: `/msg/.hpp` +(e.g. `src_foo_point_proto_ros_msgs/msg/Point.hpp` for `//src/foo:point_proto`). + +### `cpp_proto_ros2_converter_library` + +Generates C++ libraries with bidirectional conversion functions between the +proto C++ types and the corresponding ROS 2 message type. The generated functions live in +namespace `::proto_converters`: + +```cpp +void ToRos(const MyProtoType& proto, MyRosType* ros); +void ToProto(const MyRosType& ros, MyProtoType* proto); +``` + +```python +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library") + +cpp_proto_ros2_converter_library( + name = "point_converters", + deps = [":point_proto"], +) +``` + +## Naming conventions + +The message name must be the PascalCase form of the filename stem. + +The ROS package name is derived from the effective proto import path of the +first source file (respecting `strip_import_prefix`) and the target name: + +``` +__ros_msgs +``` + +where `` is the directory part of the proto file's import path +after `strip_import_prefix` is applied, with `/` and `-` replaced by `_`. +When the import path has no directory component the prefix is omitted. + +For ordinary local targets (no `strip_import_prefix`) the import path +equals the Bazel package path, so the result is equivalent to: + +``` +__ros_msgs +``` + +| Label | `strip_import_prefix` | Generated ROS package | +| ---------------------------------------- | --------------------- | ------------------------------------------ | +| `//src/foo:perf_proto` | _(none)_ | `src_foo_perf_proto_ros_msgs` | +| `//src/bar:my_proto` | _(none)_ | `src_bar_my_proto_ros_msgs` | +| `//:root_proto` | _(none)_ | `root_proto_ros_msgs` | +| `@com_google_protobuf//:timestamp_proto` | `/src` | `google_protobuf_timestamp_proto_ros_msgs` | + +Targets at the repository root with no `strip_import_prefix` (empty import +directory) have no prefix. + +Rationale: In a monorepo, multiple Bazel packages can independently define a +`proto_library` with the same short target name (e.g. `//src/foo:perf_proto` +and `//src/bar:perf_proto`). Using only the target name for the ROS package +would produce the same string for both, causing output file collisions when +both are consumed by the same build target. Basing the name on the import path +rather than the raw Bazel package path also ensures that `strip_import_prefix` +is respected: e.g. Google's well-known protos strip their `src/` prefix, so +the generated name starts with `google_protobuf_` rather than +`src_google_protobuf_`. This makes the ROS package name match the proto import +namespace that users actually write in their `.proto` files. + +## Type mapping + +| Proto type | ROS 2 type | +| ----------------------------- | ------------------------------ | +| `double` | `float64` | +| `float` | `float32` | +| `int32`, `sint32`, `sfixed32` | `int32` | +| `int64`, `sint64`, `sfixed64` | `int64` | +| `uint32`, `fixed32` | `uint32` | +| `uint64`, `fixed64` | `uint64` | +| `bool` | `bool` | +| `string` | `string` | +| `bytes` | `uint8[]` | +| `enum` | `int32` (with named constants) | +| `message` | `/MsgName` | +| `repeated T` | `T[]` | + +## Limitations and constraints + +- **One message per file.** Each `.proto` source must define exactly one + top-level message, and its name must be the PascalCase form of the filename + stem (e.g. `point.proto` → `Point`). +- **No services.** Service definitions in a proto file cause a build error. +- **Nested message types** are not supported. All message types used as fields + must be defined at the top level of their own proto file. +- **Enums.** Only enums defined **inside the message** (nested enum types) are + supported. File-level enum definitions cause a build error. Cross-file enum + references are not supported. +- **`oneof` fields** are not supported. +- **`map` fields** are not supported. +- **Group fields** are not supported. +- **`repeated bytes`** is not supported (`bytes` already maps to `uint8[]`; + `repeated bytes` would require `uint8[][]`, which is not a valid ROS 2 type). +- **Deprecated fields.** Fields marked `deprecated = true` are silently skipped + and do not appear in the generated `.msg` file. diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl new file mode 100644 index 00000000..7c39d357 --- /dev/null +++ b/ros2/protobuf/defs.bzl @@ -0,0 +1,338 @@ +# Copyright 2026 Milan Vukov +# +# Licensed 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. +"""Protobuf ROS 2 utilities. + +Limitations: +- One proto file must correspond to exactly one message definition. +- Service definitions in a proto file cause a build error. +- Message-type fields are supported as cross-package ROS2 references (e.g. + `pkg/Type`). Each proto dep must have a corresponding + proto_ros2_interface_library so the package name can be resolved. +- Enum fields are supported (mapped to int32 with named constants). + Only enums defined in the same proto file are supported. +- Group fields are not supported. +- Repeated scalar and message fields map to dynamic ROS2 arrays. +- Proto `bytes` fields map to `uint8[]` in ROS2. +""" + +load("@bazel_skylib//lib:paths.bzl", "paths") +load( + "@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", + "CppGeneratorAspectInfo", + "Ros2InterfaceInfo", + "cc_generator_impl", + "cpp_generator_aspect", + "idl_adapter_aspect", +) +load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") +load("@com_google_protobuf//bazel/private:cc_proto_aspect.bzl", "cc_proto_aspect") +load("@rules_cc//cc:defs.bzl", "CcInfo", "cc_common") +load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") + +CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) + +def _get_ros_package_name(proto_info, label_name): + """Derives the ROS package name from a ProtoInfo and the target label name. + + Uses proto_source_root so that strip_import_prefix is respected (e.g. + google/protobuf/timestamp.proto gets google_protobuf_* not src_google_*). + When there are no direct sources the directory prefix is empty. + """ + sources = proto_info.direct_sources + if sources: + root = proto_info.proto_source_root + src0 = sources[0].path + if root != "." and not paths.starts_with(src0, root): + fail("proto_source_root must be an ancestor of the source path") + logical_dir = paths.dirname(paths.relativize(src0, root)) + prefix = logical_dir.replace("/", "_").replace("-", "_") + else: + prefix = "" + return (prefix + "_" if prefix else "") + label_name + "_ros_msgs" + +def _collect_dep_proto_args(deps): + """Returns (dep_extra_args, dep_descriptor_sets) for proto deps.""" + dep_extra_args = [] + dep_descriptor_sets = [] + for dep in deps: + dep_descriptor_set = dep[ProtoInfo].direct_descriptor_set + dep_descriptor_sets.append(dep_descriptor_set) + dep_extra_args += ["--dep_descriptor_set", dep_descriptor_set.path] + dep_ros2_package = dep[Ros2InterfaceInfo].ros_package_name + for src in dep[ProtoInfo].direct_sources: + dep_extra_args += [ + "--dep_mapping", + "{}:{}".format(src.short_path, dep_ros2_package), + ] + return dep_extra_args, dep_descriptor_sets + +def _proto_to_ros2_msg_aspect_impl(target, ctx): + proto_info = target[ProtoInfo] + + msg_files = [] + + ros_package_name = _get_ros_package_name(proto_info, target.label.name) + + dep_extra_args, dep_descriptor_sets = _collect_dep_proto_args(ctx.rule.attr.deps) + + for src in proto_info.direct_sources: + if not src.basename.endswith(".proto"): + fail("Expected a .proto source file, got: {}".format(src.basename)) + stem = "".join([w.capitalize() for w in src.basename[:-len(".proto")].split("_")]) + msg_file = ctx.actions.declare_file( + "{}/{}.msg".format(ros_package_name, stem), + ) + msg_files.append(msg_file) + + ctx.actions.run( + executable = ctx.executable._proto_to_ros2_msg, + inputs = [proto_info.direct_descriptor_set] + dep_descriptor_sets, + outputs = [msg_file], + arguments = [ + "--descriptor_set", + proto_info.direct_descriptor_set.path, + "--proto_source", + src.short_path, + "--output", + msg_file.path, + ] + dep_extra_args, + mnemonic = "ProtoToRos2Msg", + progress_message = "Converting proto to ROS 2 messages for %{label}", + ) + + return [ + Ros2InterfaceInfo( + info = struct(srcs = msg_files), + deps = depset( + direct = [dep[Ros2InterfaceInfo].info for dep in ctx.rule.attr.deps], + transitive = [ + dep[Ros2InterfaceInfo].deps + for dep in ctx.rule.attr.deps + ], + ), + ros_package_name = ros_package_name, + ), + ] + +proto_to_ros2_msg_aspect = aspect( + implementation = _proto_to_ros2_msg_aspect_impl, + attr_aspects = ["deps"], + attrs = { + "_proto_to_ros2_msg": attr.label( + default = Label("@com_github_mvukov_rules_ros2//ros2/protobuf:proto_to_ros2_msg"), + executable = True, + cfg = "exec", + ), + }, + required_providers = [ProtoInfo], + provides = [Ros2InterfaceInfo], +) + +def _proto_ros2_interface_library_impl(ctx): + dep_info = ctx.attr.dep[Ros2InterfaceInfo] + return [ + DefaultInfo(files = depset(dep_info.info.srcs)), + dep_info, + ] + +proto_ros2_interface_library = rule( + doc = """Generates one ROS 2 .msg file per proto source in a proto_library dep. + +Downstream interface rules (cpp_ros2_interface_library, py_ros2_interface_library, etc.) +can consume the generated messages. +""", + attrs = { + "dep": attr.label( + mandatory = True, + aspects = [proto_to_ros2_msg_aspect], + providers = [ProtoInfo], + ), + }, + provides = [Ros2InterfaceInfo], + implementation = _proto_ros2_interface_library_impl, +) + +def _cpp_proto_ros2_interface_library_impl(ctx): + return cc_generator_impl(ctx, CppGeneratorAspectInfo) + +cpp_proto_ros2_interface_library = rule( + doc = """Generates a C++ ROS 2 interface library from proto_library deps. + +Each proto file in deps must be named in snake_case (e.g. my_message.proto) +or PascalCase (e.g. MyMessage.proto), and must define exactly one message +whose name matches the PascalCase form of the filename stem. +""", + attrs = { + "deps": attr.label_list( + mandatory = True, + aspects = [proto_to_ros2_msg_aspect, idl_adapter_aspect, cpp_generator_aspect], + providers = [ProtoInfo], + ), + }, + implementation = _cpp_proto_ros2_interface_library_impl, +) + +def _cpp_proto_ros2_converter_aspect_impl(target, ctx): + proto_info = target[ProtoInfo] + + ros_package_name = target[Ros2InterfaceInfo].ros_package_name + + # Collect dep information: descriptor sets and proto→ros_package mappings. + dep_extra_args, dep_descriptor_sets = _collect_dep_proto_args(ctx.rule.attr.deps) + dep_converter_cc_infos = [ + dep[CppProtoConverterAspectInfo].cc_info + for dep in ctx.rule.attr.deps + if CppProtoConverterAspectInfo in dep + ] + + # Declare output files. + header = ctx.actions.declare_file( + "{}/proto_converters.h".format(ros_package_name), + ) + source = ctx.actions.declare_file( + "{}/proto_converters.cc".format(ros_package_name), + ) + + proto_source_args = [] + for src in proto_info.direct_sources: + proto_source_args += ["--proto_source", src.short_path] + + ctx.actions.run( + executable = ctx.executable._proto_to_ros2_converter, + inputs = [proto_info.direct_descriptor_set] + dep_descriptor_sets, + outputs = [header, source], + arguments = [ + "--descriptor_set", + proto_info.direct_descriptor_set.path, + "--ros_package_name", + ros_package_name, + "--output_header", + header.path, + "--output_source", + source.path, + ] + proto_source_args + dep_extra_args, + mnemonic = "ProtoToRos2Converter", + progress_message = "Generating proto/ROS 2 converters for %{label}", + ) + + # The include root is the parent directory of the ros_package_name folder. + cc_include_dir = "/".join(header.dirname.split("/")[:-1]) + + cc_toolchain = find_cpp_toolchain(ctx) + feature_configuration = cc_common.configure_features( + ctx = ctx, + cc_toolchain = cc_toolchain, + requested_features = ctx.features, + unsupported_features = ctx.disabled_features, + ) + + compilation_contexts = ( + [ + target[CcInfo].compilation_context, + target[CppGeneratorAspectInfo].cc_info.compilation_context, + ] + + [ci.compilation_context for ci in dep_converter_cc_infos] + ) + compilation_context, compilation_outputs = cc_common.compile( + actions = ctx.actions, + name = ros_package_name + "_converter", + cc_toolchain = cc_toolchain, + feature_configuration = feature_configuration, + system_includes = [cc_include_dir], + srcs = [source], + public_hdrs = [header], + compilation_contexts = compilation_contexts, + ) + + linking_contexts = ( + [ + target[CcInfo].linking_context, + target[CppGeneratorAspectInfo].cc_info.linking_context, + ] + + [ci.linking_context for ci in dep_converter_cc_infos] + ) + linking_context, _ = cc_common.create_linking_context_from_compilation_outputs( + actions = ctx.actions, + name = ros_package_name + "_converter", + compilation_outputs = compilation_outputs, + cc_toolchain = cc_toolchain, + feature_configuration = feature_configuration, + linking_contexts = linking_contexts, + ) + + return [ + CppProtoConverterAspectInfo( + cc_info = CcInfo( + compilation_context = compilation_context, + linking_context = linking_context, + ), + ), + ] + +cpp_proto_ros2_converter_aspect = aspect( + implementation = _cpp_proto_ros2_converter_aspect_impl, + attr_aspects = ["deps"], + attrs = { + "_proto_to_ros2_converter": attr.label( + default = Label("@com_github_mvukov_rules_ros2//ros2/protobuf:proto_to_ros2_converter"), + executable = True, + cfg = "exec", + ), + "_cc_toolchain": attr.label( + default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), + ), + }, + required_providers = [ProtoInfo], + required_aspect_providers = [ + [Ros2InterfaceInfo], + [CppGeneratorAspectInfo], + # cc_proto_aspect returns CcInfo. + [CcInfo], + ], + provides = [CppProtoConverterAspectInfo], + toolchains = ["@bazel_tools//tools/cpp:toolchain_type"], + fragments = ["cpp"], +) + +def _cpp_proto_ros2_converter_library_impl(ctx): + cc_info = cc_common.merge_cc_infos( + direct_cc_infos = [ + dep[CppProtoConverterAspectInfo].cc_info + for dep in ctx.attr.deps + ], + ) + return [cc_info] + +cpp_proto_ros2_converter_library = rule( + doc = """Generates a C++ proto<->ROS 2 converter library from proto_library deps. + +Each proto file in deps must be named in snake_case (e.g. my_message.proto) +or PascalCase (e.g. MyMessage.proto), and must define exactly one message +whose name matches the PascalCase form of the filename stem. +""", + attrs = { + "deps": attr.label_list( + mandatory = True, + aspects = [ + proto_to_ros2_msg_aspect, + idl_adapter_aspect, + cpp_generator_aspect, + cc_proto_aspect, + cpp_proto_ros2_converter_aspect, + ], + providers = [ProtoInfo], + ), + }, + implementation = _cpp_proto_ros2_converter_library_impl, +) diff --git a/ros2/protobuf/proto_to_ros2.py b/ros2/protobuf/proto_to_ros2.py new file mode 100644 index 00000000..b6c8c0af --- /dev/null +++ b/ros2/protobuf/proto_to_ros2.py @@ -0,0 +1,48 @@ +# Copyright 2026 Milan Vukov +# +# Licensed 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. +"""Shared utilities for proto-to-ROS 2 code generation tools.""" +from google.protobuf import descriptor_pb2 +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +# Non-scalar proto field types that are explicitly unsupported. +UNSUPPORTED_TYPES = { + FieldDescriptorProto.TYPE_GROUP: 'group', +} + + +def load_descriptor_set(path): + """Loads and parses a binary FileDescriptorSet from a file path.""" + with open(path, 'rb') as f: + proto_set = descriptor_pb2.FileDescriptorSet() + proto_set.ParseFromString(f.read()) + return proto_set + + +def find_file_descriptor(proto_set, proto_source): + """Finds a FileDescriptorProto by name, with fallback to basename + matching. + """ + for file_proto in proto_set.file: + if file_proto.name == proto_source: + return file_proto + source_basename = proto_source.split('/')[-1] + for file_proto in proto_set.file: + if file_proto.name.split('/')[-1] == source_basename: + return file_proto + return None + + +def parse_dep_mapping(dep_mapping): + """Parses a list of 'proto_path:ros_package' strings into a dict.""" + return dict(entry.split(':', 1) for entry in dep_mapping) diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py new file mode 100644 index 00000000..92260f0a --- /dev/null +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -0,0 +1,458 @@ +# Copyright 2026 Milan Vukov +# +# Licensed 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. +"""Generates C++ proto<->ROS converter header and source from a proto file. + +For each proto message the tool emits two free functions in namespace +``::proto_converters``: + + void ToRos(const & proto, * ros); + void ToProto(const & ros, * proto); + +Limitations mirror those of proto_to_ros2_msg.py: +- Exactly one message definition per proto file. +- Service definitions are not supported. +- Message-type fields require a --dep_mapping entry so the ROS package can + be resolved. +- Enum fields are supported (cast to/from int32_t). Only enums nested + inside the message are supported; file-level enums cause a build error. +- Fields marked `deprecated = true` are silently skipped (not emitted). +- Group fields are not supported. +- Repeated bytes fields are not supported. +""" +import argparse +import io +import re +import sys + +import em +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +from ros2.protobuf import proto_to_ros2 + +# --------------------------------------------------------------------------- +# Field-type tables +# --------------------------------------------------------------------------- + +# Proto scalar types → C++ type used in ROS structs. +_SCALAR_CPP_TYPE = { + FieldDescriptorProto.TYPE_DOUBLE: 'double', + FieldDescriptorProto.TYPE_FLOAT: 'float', + FieldDescriptorProto.TYPE_INT32: 'int32_t', + FieldDescriptorProto.TYPE_INT64: 'int64_t', + FieldDescriptorProto.TYPE_UINT32: 'uint32_t', + FieldDescriptorProto.TYPE_UINT64: 'uint64_t', + FieldDescriptorProto.TYPE_SINT32: 'int32_t', + FieldDescriptorProto.TYPE_SINT64: 'int64_t', + FieldDescriptorProto.TYPE_FIXED32: 'uint32_t', + FieldDescriptorProto.TYPE_FIXED64: 'uint64_t', + FieldDescriptorProto.TYPE_SFIXED32: 'int32_t', + FieldDescriptorProto.TYPE_SFIXED64: 'int64_t', + FieldDescriptorProto.TYPE_BOOL: 'bool', +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _to_snake_case(name): + """Converts CamelCase to snake_case (mirrors rosidl_cmake convention).""" + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + return s2.lower() + + +def _proto_package_to_ns(package): + """Converts 'a.b.c' → 'a::b::c'.""" + return '::'.join(package.split('.')) if package else '' + + +def _get_ros2_pkg(path_to_pkg, path): + for actual_path, ros2_pkg in path_to_pkg.items(): + if actual_path.endswith(path): + return ros2_pkg + raise ValueError(f'Failed to find ROS package for {path} proto file') + + +def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, + main_proto_set, ros_package_name): + """Build {'.pkg.MsgName': 'dep_ros_package_name'} from dep descriptor sets. + + Also scans the main descriptor set for sibling files (other sources in the + same proto_library target) and maps their message types to ros_package_name. + """ + path_to_ros_pkg = proto_to_ros2.parse_dep_mapping(dep_mapping) + proto_types_to_ros_pkgs = {} + + # Scan sibling files in the main descriptor set (same proto_library target). + for file_proto in main_proto_set.file: + if file_proto.name in path_to_ros_pkg: + continue # Already covered by a dep_mapping. + # This is a sibling file; it belongs to the same ROS package. + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + proto_types_to_ros_pkgs[ + f'{pkg_prefix}.{msg.name}'] = ros_package_name + + for ds_path in dep_descriptor_set_paths: + dep_set = proto_to_ros2.load_descriptor_set(ds_path) + for file_proto in dep_set.file: + ros_pkg = _get_ros2_pkg(path_to_ros_pkg, file_proto.name) + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + proto_types_to_ros_pkgs[f'{pkg_prefix}.{msg.name}'] = ros_pkg + return proto_types_to_ros_pkgs + + +# --------------------------------------------------------------------------- +# Per-field conversion code generation +# --------------------------------------------------------------------------- + + +def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): + """Return (to_ros_lines, from_ros_lines, dep_pkgs). + + Each entry in to_ros_lines / from_ros_lines is a C++ statement string + (already indented with two spaces). dep_pkgs is the set of dep + ros_package_names whose converters are called. + """ + to_ros = [] + from_ros = [] + dep_pkgs = set() + + for field in message.field: + if field.options.deprecated: + continue + name = field.name + is_repeated = field.label == FieldDescriptorProto.LABEL_REPEATED + ftype = field.type + + if ftype in proto_to_ros2.UNSUPPORTED_TYPES: + sys.exit(f'Error: {proto_source}: field "{name}": ' + f'{proto_to_ros2.UNSUPPORTED_TYPES[ftype]} ' + 'fields are not supported.') + + # ---- enum ----------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_ENUM: + # Proto3 enums are backed by int32; cast explicitly. + enum_cpp_type = field.type_name.lstrip('.').replace('.', '::') + if is_repeated: + to_ros.append(f' for (const auto v : proto.{name}()) {{') + to_ros.append( + f' ros->{name}.push_back(static_cast(v));') + to_ros.append(' }') + from_ros.append(f' for (const auto v : ros.{name}) {{') + from_ros.append(f' proto->add_{name}' + f'(static_cast<{enum_cpp_type}>(v));') + from_ros.append(' }') + else: + to_ros.append( + f' ros->{name} = static_cast(proto.{name}());') + from_ros.append(f' proto->set_{name}' + f'(static_cast<{enum_cpp_type}>(ros.{name}));') + continue + + # ---- bytes ---------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_BYTES: + if is_repeated: + sys.exit(f'Error: {proto_source}: field "{name}": ' + f'repeated bytes is not supported.') + to_ros.append(f' ros->{name} = std::vector' + f'(proto.{name}().begin(), proto.{name}().end());') + from_ros.append( + f' proto->set_{name}' + f'(std::string(ros.{name}.begin(), ros.{name}.end()));') + continue + + # ---- string --------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_STRING: + if is_repeated: + to_ros.append( + f' ros->{name} = std::vector' + f'(proto.{name}().begin(), proto.{name}().end());') + from_ros.append(f' for (const auto& s : ros.{name}) {{') + from_ros.append(f' proto->add_{name}(s);') + from_ros.append(' }') + else: + to_ros.append(f' ros->{name} = proto.{name}();') + from_ros.append(f' proto->set_{name}(ros.{name});') + continue + + # ---- message -------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_MESSAGE: + dep_pkg = proto_types_to_ros_pkgs.get(field.type_name) + if dep_pkg is None: + sys.exit( + f'Error: {proto_source}: field "{name}" references ' + f'message type "{field.type_name}" with no dep_mapping ' + f'entry. Add a --dep_mapping for the proto file that ' + f'defines it.') + dep_pkgs.add(dep_pkg) + conv = f'{dep_pkg}::proto_converters' + + if is_repeated: + to_ros.append(f' for (const auto& item : proto.{name}()) {{') + to_ros.append( + f' {conv}::ToRos(item, &ros->{name}.emplace_back());') + to_ros.append(' }') + from_ros.append(f' for (const auto& item : ros.{name}) {{') + from_ros.append( + f' {conv}::ToProto(item, proto->add_{name}());') + from_ros.append(' }') + else: + to_ros.append(f' {conv}::ToRos(proto.{name}(), &ros->{name});') + from_ros.append( + f' {conv}::ToProto(ros.{name}, proto->mutable_{name}());') + continue + + # ---- scalar (all remaining types) ----------------------------------- + if ftype not in _SCALAR_CPP_TYPE: + sys.exit(f'Error: {proto_source}: field "{name}" has unknown ' + f'field type value {ftype}.') + + if is_repeated: + to_ros.append(f' ros->{name}.assign' + f'(proto.{name}().begin(), proto.{name}().end());') + from_ros.append(f' proto->mutable_{name}()->Assign' + f'(ros.{name}.begin(), ros.{name}.end());') + else: + to_ros.append(f' ros->{name} = proto.{name}();') + from_ros.append(f' proto->set_{name}(ros.{name});') + + return to_ros, from_ros, dep_pkgs + + +# --------------------------------------------------------------------------- +# Empy templates +# --------------------------------------------------------------------------- + +_HEADER_TEMPLATE = """\ +// Generated by proto_to_ros2_converter. Do not edit. +#pragma once + +@(ctx['includes_section']) + +namespace @(ctx['ros_package_name'])::proto_converters { + +@[for msg in ctx['messages']] +void ToRos(const @(msg['proto_type'])& proto, @(msg['ros_type'])* ros); + +void ToProto(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto); +@[end for] + +} // namespace @(ctx['ros_package_name'])::proto_converters +""" + +_SOURCE_TEMPLATE = """\ +// Generated by proto_to_ros2_converter. Do not edit. +#include "@(ctx['ros_package_name'])/proto_converters.h" +@[for inc in ctx['dep_converter_includes']] +#include "@(inc)" +@[end for] + +namespace @(ctx['ros_package_name'])::proto_converters { + +@[for msg in ctx['messages']] +void ToRos(const @(msg['proto_type'])& proto, @(msg['ros_type'])* ros) { +@(msg['to_ros_body']) +} + +void ToProto(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto) { +@(msg['from_ros_body']) +} +@[end for] + +} // namespace @(ctx['ros_package_name'])::proto_converters +""" + + +def _render(template, context): + """Render an empy 3.3 template string with a 'ctx' variable.""" + output = io.StringIO() + interp = em.Interpreter(output=output, globals={'ctx': context}) + try: + interp.string(template) + return output.getvalue() + finally: + interp.shutdown() + + +# --------------------------------------------------------------------------- +# Main conversion logic +# --------------------------------------------------------------------------- + + +def _convert(descriptor_set, proto_sources, ros_package_name, + proto_types_to_ros_pkgs, output_header, output_source): + """Generate converter .h and .cc for all proto sources in the descriptor.""" + messages = [] + dep_pkgs_all = set() + + proto_includes = [] + ros_includes = [] + + for proto_source in proto_sources: + file_proto = proto_to_ros2.find_file_descriptor(descriptor_set, + proto_source) + if file_proto is None: + sys.exit(f'Error: could not find proto source "{proto_source}" in ' + f'the descriptor set.') + + if file_proto.service: + sys.exit(f'Error: {proto_source}: services are not supported.') + + num_msgs = len(file_proto.message_type) + if num_msgs != 1: + sys.exit(f'Error: {proto_source}: expected exactly 1 message ' + f'definition, got {num_msgs}.') + + message = file_proto.message_type[0] + msg_name = message.name + + proto_ns = _proto_package_to_ns(file_proto.package) + proto_type = f'{proto_ns}::{msg_name}' if proto_ns else msg_name + ros_type = f'{ros_package_name}::msg::{msg_name}' + + # Proto C++ include: replace .proto suffix with .pb.h + proto_include = file_proto.name[:-len('.proto')] + '.pb.h' + # ROS msg include: snake_case name with .hpp extension + ros_include = (f'{ros_package_name}/msg/' + f'{_to_snake_case(msg_name)}.hpp') + + to_ros_lines, from_ros_lines, dep_pkgs = _field_conversions( + message, proto_source, proto_types_to_ros_pkgs) + dep_pkgs_all.update(dep_pkgs) + + to_ros_body = '\n'.join(to_ros_lines) + from_ros_body = '\n'.join(from_ros_lines) + + messages.append({ + 'msg_name': msg_name, + 'proto_type': proto_type, + 'ros_type': ros_type, + 'to_ros_body': to_ros_body, + 'from_ros_body': from_ros_body, + }) + + proto_includes.append(proto_include) + ros_includes.append(ros_include) + + # Build a sorted, deduplicated dep-converter include list, excluding self. + dep_converter_includes = sorted( + f'{pkg}/proto_converters.h' for pkg in dep_pkgs_all + if pkg != ros_package_name) + + # Header includes: only the current package's proto and ROS msg types. + # Dep converter includes go into the .cc file, not the header. + include_lines = [] + for inc in proto_includes: + include_lines.append(f'#include "{inc}"') + include_lines.append('') + for inc in ros_includes: + include_lines.append(f'#include "{inc}"') + includes_section = '\n'.join(include_lines) + + header_context = { + 'ros_package_name': ros_package_name, + 'includes_section': includes_section, + 'messages': messages, + } + source_context = { + 'ros_package_name': ros_package_name, + 'dep_converter_includes': dep_converter_includes, + 'messages': messages, + } + + with open(output_header, 'w') as f: + f.write(_render(_HEADER_TEMPLATE, header_context)) + with open(output_source, 'w') as f: + f.write(_render(_SOURCE_TEMPLATE, source_context)) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description='Generate C++ proto<->ROS converter code.') + parser.add_argument( + '--descriptor_set', + required=True, + help='Path to the binary FileDescriptorSet file.', + ) + parser.add_argument( + '--ros_package_name', + required=True, + help='ROS package name (e.g. point_proto_ros_msgs).', + ) + parser.add_argument( + '--output_header', + required=True, + help='Path of the output .h file to write.', + ) + parser.add_argument( + '--output_source', + required=True, + help='Path of the output .cc file to write.', + ) + parser.add_argument( + '--dep_mapping', + action='append', + default=[], + metavar='PROTO_PATH:ROS_PACKAGE', + help='Mapping from a dep proto file path to its ROS package name. ' + 'May be repeated.', + ) + parser.add_argument( + '--dep_descriptor_set', + action='append', + default=[], + metavar='PATH', + help='Path to a dep binary FileDescriptorSet file. May be repeated.', + ) + parser.add_argument( + '--proto_source', + action='append', + default=[], + metavar='PROTO_PATH', + help='Relative path of a direct proto source to convert. ', + ) + args = parser.parse_args() + + proto_set = proto_to_ros2.load_descriptor_set(args.descriptor_set) + + proto_types_to_ros_pkgs = _build_proto_types_to_ros_pkgs( + args.dep_descriptor_set, args.dep_mapping, proto_set, + args.ros_package_name) + + proto_sources = args.proto_source + if not proto_sources: + open(args.output_header, 'w').close() + open(args.output_source, 'w').close() + return + + _convert( + descriptor_set=proto_set, + proto_sources=proto_sources, + ros_package_name=args.ros_package_name, + proto_types_to_ros_pkgs=proto_types_to_ros_pkgs, + output_header=args.output_header, + output_source=args.output_source, + ) + + +if __name__ == '__main__': + main() diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py new file mode 100644 index 00000000..a0b246ce --- /dev/null +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -0,0 +1,300 @@ +# Copyright 2026 Milan Vukov +# +# Licensed 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. +"""Converts a proto file to a ROS .msg file. + +Limitations: +- Exactly one message definition per proto file is required. +- Service definitions are not supported. +- Message-type fields are supported as cross-package ROS references (e.g. + `pkg/Type`). The caller must supply --dep_mapping for each imported proto. +- Enum fields are supported (mapped to int32 with named constants). + Only enums nested inside the message are supported; file-level enum + definitions cause a build error. +- Fields marked `deprecated = true` are silently skipped (not emitted). +- oneof and map fields are not supported. +- Group fields are not supported. +- Repeated scalar and message fields are supported and map to dynamic arrays + (e.g. `int32[] values`, `pkg/msg/Point[] points`). +- proto `bytes` fields map to `uint8[]` in ROS. +""" +import argparse +import os +import pathlib +import sys + +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +from ros2.protobuf import proto_to_ros2 + +# Mapping from proto3 scalar FieldDescriptorProto.Type to ROS .msg type. +# Types that map to arrays (like bytes) use a special sentinel handled below. +_PROTO_TO_ROS_TYPE = { + FieldDescriptorProto.TYPE_DOUBLE: + 'float64', + FieldDescriptorProto.TYPE_FLOAT: + 'float32', + FieldDescriptorProto.TYPE_INT32: + 'int32', + FieldDescriptorProto.TYPE_INT64: + 'int64', + FieldDescriptorProto.TYPE_UINT32: + 'uint32', + FieldDescriptorProto.TYPE_UINT64: + 'uint64', + FieldDescriptorProto.TYPE_SINT32: + 'int32', + FieldDescriptorProto.TYPE_SINT64: + 'int64', + FieldDescriptorProto.TYPE_FIXED32: + 'uint32', + FieldDescriptorProto.TYPE_FIXED64: + 'uint64', + FieldDescriptorProto.TYPE_SFIXED32: + 'int32', + FieldDescriptorProto.TYPE_SFIXED64: + 'int64', + FieldDescriptorProto.TYPE_BOOL: + 'bool', + FieldDescriptorProto.TYPE_STRING: + 'string', + # bytes in proto3 → dynamic byte array in ROS + FieldDescriptorProto.TYPE_BYTES: + 'uint8[]', +} + + +def _build_enum_map(file_proto, message): + """Build {'.pkg.Message.EnumName': EnumDescriptorProto} for enums nested + directly inside the message. + """ + enum_map = {} + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + msg_prefix = f'{pkg_prefix}.{message.name}' + for enum_type in message.enum_type: + enum_map[f'{msg_prefix}.{enum_type.name}'] = enum_type + return enum_map + + +def _get_ros2_pkg(path_to_pkg, path): + for actual_path, ros2_pkg in path_to_pkg.items(): + if actual_path.endswith(path): + return ros2_pkg + raise ValueError(f'Failed to find ROS package for {path} proto file') + + +def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, + main_descriptor_set_path, proto_source, + self_ros_package): + """Build {'.pkg.MsgName': 'ros2_package/MsgName'} from dep descriptor sets. + + Also scans the main descriptor set for sibling files (other sources in the + same proto_library target) and maps their message types to self_ros_package. + """ + path_to_pkg = proto_to_ros2.parse_dep_mapping(dep_mapping) + msg_type_map = {} + + # Scan sibling files in the main descriptor set (same proto_library target). + main_set = proto_to_ros2.load_descriptor_set(main_descriptor_set_path) + for file_proto in main_set.file: + if file_proto.name == proto_source: + continue # Skip the file being converted. + if file_proto.name in path_to_pkg: + continue # Already covered by a dep_mapping. + # This is a sibling file; it belongs to the same ROS 2 package. + # TODO(mvukov) Make package mandatory! + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + fq = f'{pkg_prefix}.{msg.name}' + msg_type_map[fq] = f'{self_ros_package}/{msg.name}' + + for ds_path in dep_descriptor_set_paths: + dep_set = proto_to_ros2.load_descriptor_set(ds_path) + for file_proto in dep_set.file: + ros2_pkg = _get_ros2_pkg(path_to_pkg, file_proto.name) + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + fq = f'{pkg_prefix}.{msg.name}' + msg_type_map[fq] = f'{ros2_pkg}/{msg.name}' + return msg_type_map + + +def _convert(file_proto, output_path, proto_source, msg_type_map): + """Validate and convert a FileDescriptorProto to a ROS .msg file.""" + if file_proto.service: + sys.exit(f'Error: {proto_source}: services are not supported ' + f'(found {len(file_proto.service)} service(s)).') + + num_messages = len(file_proto.message_type) + if num_messages != 1: + sys.exit( + f'Error: {proto_source}: expected exactly 1 message definition, ' + f'got {num_messages}.') + + message = file_proto.message_type[0] + stem = pathlib.Path(proto_source).stem + expected_name = ''.join(w.capitalize() for w in stem.split('_')) + if message.name != expected_name: + sys.exit( + f'Error: {proto_source}: message must be named "{expected_name}" ' + f'to match the proto filename, got "{message.name}".') + + if file_proto.enum_type: + names = ', '.join(e.name for e in file_proto.enum_type) + sys.exit(f'Error: {proto_source}: file-level enum definitions are not ' + f'supported ({names}); define enums inside the message.') + + lines = [f'# Generated from proto source: {proto_source}', ''] + + # Emit enum constants before fields (deduplicated per enum type). + enum_map = _build_enum_map(file_proto, message) + emitted_enums = set() + for field in message.field: + if field.options.deprecated: + continue + if field.type == FieldDescriptorProto.TYPE_ENUM: + if field.type_name not in emitted_enums: + enum_desc = enum_map.get(field.type_name) + if enum_desc is None: + sys.exit(f'Error: {proto_source}: enum "{field.type_name}" ' + f'not found in the current proto file. Cross-file ' + f'enums are not yet supported.') + lines.append(f'# {enum_desc.name} constants') + for ev in enum_desc.value: + lines.append(f'int32 {ev.name}={ev.number}') + lines.append('') + emitted_enums.add(field.type_name) + + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + map_entry_fq_names = set() + for nested in message.nested_type: + if nested.options.map_entry: + map_entry_fq_names.add(f'{pkg_prefix}.{message.name}.{nested.name}') + else: + sys.exit( + f'Error: {proto_source}: message "{message.name}" defines a ' + f'nested message type "{nested.name}", which is not supported.') + + for field in message.field: + if field.options.deprecated: + continue + field_type_value = field.type + is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) + + if field.HasField('oneof_index'): + sys.exit( + f'Error: {proto_source}: field "{field.name}" is part of a ' + f'oneof, which is not supported.') + + if field.type_name in map_entry_fq_names: + sys.exit( + f'Error: {proto_source}: field "{field.name}" is a map field, ' + f'which is not supported.') + + if field_type_value == FieldDescriptorProto.TYPE_MESSAGE: + ros2_type = msg_type_map.get(field.type_name) + if ros2_type is None: + sys.exit( + f'Error: {proto_source}: field "{field.name}" references ' + f'message type "{field.type_name}" with no dep_mapping ' + f'entry. Add a --dep_mapping for the proto file that ' + f'defines it.') + if is_repeated: + ros2_type = ros2_type + '[]' + lines.append(f'{ros2_type} {field.name}') + continue + + if field_type_value == FieldDescriptorProto.TYPE_ENUM: + ros2_type = 'int32[]' if is_repeated else 'int32' + lines.append(f'{ros2_type} {field.name}') + continue + + if field_type_value in proto_to_ros2.UNSUPPORTED_TYPES: + type_name = proto_to_ros2.UNSUPPORTED_TYPES[field_type_value] + sys.exit( + f'Error: {proto_source}: field "{field.name}" has unsupported ' + f'type "{type_name}".') + + if field_type_value not in _PROTO_TO_ROS_TYPE: + sys.exit(f'Error: {proto_source}: field "{field.name}" has unknown ' + f'type value {field_type_value}.') + + if is_repeated and field_type_value == FieldDescriptorProto.TYPE_BYTES: + sys.exit(f'Error: {proto_source}: field "{field.name}" is ' + f'"repeated bytes", which would require uint8[][] — not ' + f'supported in ROS 2 msg types.') + + ros2_type = _PROTO_TO_ROS_TYPE[field_type_value] + + # Proto `bytes` already becomes `uint8[]`; avoid double `[]`. + if is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: + ros2_type = ros2_type + '[]' + + lines.append(f'{ros2_type} {field.name}') + + with open(output_path, 'w') as f: + f.write('\n'.join(lines) + '\n') + + +def main(): + parser = argparse.ArgumentParser( + description='Convert a proto file to a ROS .msg file.') + parser.add_argument( + '--descriptor_set', + required=True, + help='Path to the binary FileDescriptorSet file.', + ) + parser.add_argument( + '--proto_source', + required=True, + help='Relative path of the proto source as stored in the descriptor.', + ) + parser.add_argument( + '--output', + required=True, + help='Path of the output .msg file to write.', + ) + parser.add_argument( + '--dep_mapping', + action='append', + default=[], + metavar='PROTO_PATH:ROS_PACKAGE', + help='Mapping from a dep proto file path to its ROS package name. ' + 'May be repeated.', + ) + parser.add_argument( + '--dep_descriptor_set', + action='append', + default=[], + metavar='PATH', + help='Path to a dep binary FileDescriptorSet file. May be repeated.', + ) + args = parser.parse_args() + + proto_set = proto_to_ros2.load_descriptor_set(args.descriptor_set) + + file_proto = proto_to_ros2.find_file_descriptor(proto_set, + args.proto_source) + if file_proto is None: + sys.exit(f'Error: could not find proto source "{args.proto_source}" in ' + f'descriptor set "{args.descriptor_set}".') + + self_ros_package = os.path.basename(os.path.dirname(args.output)) + msg_type_map = _build_msg_type_map(args.dep_descriptor_set, + args.dep_mapping, args.descriptor_set, + args.proto_source, self_ros_package) + _convert(file_proto, args.output, args.proto_source, msg_type_map) + + +if __name__ == '__main__': + main() diff --git a/ros2/protobuf/sized_arrays.md b/ros2/protobuf/sized_arrays.md new file mode 100644 index 00000000..39ab857f --- /dev/null +++ b/ros2/protobuf/sized_arrays.md @@ -0,0 +1,232 @@ +# Plan: Sized arrays via `ros2_field_options.proto` + +## Context + +`proto_to_ros2_msg.py` always emits dynamic arrays (`T[]`) for `bytes` fields and +`repeated T` fields. Users need a way to annotate proto fields to emit **fixed-size +arrays** (`T[N]`) in ROS 2 msg format. Fixed-size arrays avoid heap allocation in +the ROS 2 serialization layer and are important for performance-critical types +(hashes, fixed-length point clouds, pose arrays, etc.). + +The mechanism: a new custom proto field option `(ros2.msg.array_size) = N` defined in +`ros2/protobuf/ros2_field_options.proto`. protoc serialises this as extension field +bytes inside `FieldOptions` in the descriptor set. The Python tool reads it via +`field.options.UnknownFields()` — no `py_proto_library` Bazel target is needed. + +--- + +## New file: `ros2/protobuf/ros2_field_options.proto` + +```protobuf +syntax = "proto3"; +package ros2.msg; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + // Fixed array size for the generated ROS .msg field. + // Valid on: singular bytes fields and repeated fields of any type. + // When N > 0 the generator emits T[N] instead of T[]. + // Value 0 (the proto3 default) means dynamic array (no change). + uint32 array_size = 50000; +} +``` + +Field number 50000 is in the open custom-extension range. + +--- + +## Changes + +### 1. `ros2/protobuf/BUILD.bazel` + +Add a `proto_library` for the new options file (public so downstream `proto_library` +targets can depend on it): + +```python +proto_library( + name = "ros2_field_options_proto", + srcs = ["ros2_field_options.proto"], + deps = ["@com_google_protobuf//:descriptor_proto"], + visibility = ["//visibility:public"], +) +``` + +No changes to the `proto_to_ros2_msg` `py_binary` deps — the tool reads the option +via `UnknownFields()` at runtime without importing a generated Python extension module. + +### 2. `ros2/protobuf/proto_to_ros2_msg.py` + +**Add constant and helper** near the top of the file (after imports): + +```python +# Field number for the ros2.msg.array_size FieldOptions extension. +_ARRAY_SIZE_FIELD_NUMBER = 50000 + + +def _get_array_size(field_options): + """Returns the (ros2.msg.array_size) value from field options, or 0.""" + for uf in field_options.UnknownFields(): + if uf.field_number == _ARRAY_SIZE_FIELD_NUMBER: + return uf.data # wire type 0 (varint) → Python int + return 0 +``` + +**Modify the field-emission loop** in `_convert()`: + +After the `deprecated` guard, read `array_size` once per field and validate: + +```python +array_size = _get_array_size(field.options) +is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) + +if array_size and not is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: + sys.exit( + f'Error: {proto_source}: field "{field.name}" has array_size={array_size} ' + f'but is not a bytes field or a repeated field.') +``` + +Then change each array-suffix site (three places): + +| Field kind | Before | After | +| ------------------------------- | --------------------------------------- | ----------------------------------------------------------------------- | +| `TYPE_MESSAGE` (repeated) | `ros2_type + '[]'` | `ros2_type + f'[{array_size}]'` if `array_size` else `ros2_type + '[]'` | +| `TYPE_ENUM` | `'int32[]' if is_repeated else 'int32'` | `f'int32[{array_size}]'` if `array_size and is_repeated` else original | +| scalar / bytes (bottom of loop) | `ros2_type + '[]'` | sized suffix for repeated and for singular bytes | + +Concretely, the last block becomes: + +```python +ros2_type = _PROTO_TO_ROS_TYPE[field_type_value] + +if is_repeated and field_type_value == FieldDescriptorProto.TYPE_BYTES: + sys.exit(...) # unchanged + +suffix = f'[{array_size}]' if array_size else '[]' + +if field_type_value == FieldDescriptorProto.TYPE_BYTES: + # bytes already maps to 'uint8[]'; replace the trailing [] with the suffix. + ros2_type = 'uint8' + suffix +elif is_repeated: + ros2_type = ros2_type + suffix +# singular non-bytes: ros2_type unchanged (no suffix) + +lines.append(f'{ros2_type} {field.name}') +``` + +### 3. Test fixtures — `ros2/test/protobuf/` (new files) + +**`sized_bytes.proto`** — singular `bytes` with and without a size: + +```protobuf +syntax = "proto3"; +package ros2.test.protobuf; +import "ros2/protobuf/ros2_field_options.proto"; +message SizedBytes { + bytes hash = 1 [(ros2.msg.array_size) = 32]; + bytes raw = 2; +} +``` + +Expected: `uint8[32] hash`, `uint8[] raw`. + +**`sized_array.proto`** — repeated scalar and repeated message with fixed sizes: + +```protobuf +syntax = "proto3"; +package ros2.test.protobuf; +import "ros2/protobuf/ros2_field_options.proto"; +import "ros2/test/protobuf/point.proto"; +message SizedArray { + repeated float coords = 1 [(ros2.msg.array_size) = 3]; + repeated float dynamic = 2; + repeated ros2.test.protobuf.Point pts = 3 [(ros2.msg.array_size) = 4]; +} +``` + +Expected: `float32[3] coords`, `float32[] dynamic`, +`ros2_test_protobuf_point_proto_ros_msgs/Point[4] pts`. + +**`sized_bad.proto`** — singular non-bytes scalar with `array_size` (error test): + +```protobuf +syntax = "proto3"; +package ros2.test.protobuf; +import "ros2/protobuf/ros2_field_options.proto"; +message SizedBad { + int32 x = 1 [(ros2.msg.array_size) = 3]; +} +``` + +### 4. `ros2/test/protobuf/BUILD.bazel` + +Add three `proto_library` targets (the `sized_array_proto` depends on `:point_proto`): + +```python +proto_library( + name = "sized_bytes_proto", + srcs = ["sized_bytes.proto"], + deps = ["//ros2/protobuf:ros2_field_options_proto"], +) + +proto_library( + name = "sized_array_proto", + srcs = ["sized_array.proto"], + deps = [ + ":point_proto", + "//ros2/protobuf:ros2_field_options_proto", + ], +) + +proto_library( + name = "sized_bad_proto", + srcs = ["sized_bad.proto"], + deps = ["//ros2/protobuf:ros2_field_options_proto"], +) +``` + +Add all three to the `data` list of `proto_to_ros2_msg_tests`. + +### 5. `ros2/test/protobuf/tests.py` + +Add a new class **`SizedArrayTests`**: + +```python +class SizedArrayTests: + def test_bytes_with_array_size(self, tmp_path): + ... # assert 'uint8[32] hash' and 'uint8[] raw' in output + + def test_repeated_scalar_with_array_size(self, tmp_path): + ... # assert 'float32[3] coords' and 'float32[] dynamic' in output + + def test_repeated_message_with_array_size(self, tmp_path): + ... # assert '/Point[4] pts' in output + + def test_array_size_on_singular_scalar_is_error(self, tmp_path): + ... # assert SystemExit with 'array_size' in message +``` + +The `test_repeated_message_with_array_size` test must supply the msg_type_map for +`.ros2.test.protobuf.Point` (same pattern as `test_message_dep_field`). + +### 6. `ros2/protobuf/README.md` + +Add a **Fixed-size arrays** entry to the Limitations section and a prose paragraph +documenting the option and import path. + +--- + +## Out of scope + +`proto_to_ros2_converter.py` is not changed here. Fixed-size ROS arrays require +different C++ copy logic (`std::copy` / `memcpy` vs. `assign`); that is a follow-up. + +--- + +## Verification + +``` +bazel test //ros2/test/protobuf:proto_to_ros2_msg_tests --test_output=streamed +``` + +All tests in `SizedArrayTests` must pass; all pre-existing tests must continue to pass. diff --git a/ros2/rust_interfaces.bzl b/ros2/rust_interfaces.bzl index 18139042..bd5ac8e8 100644 --- a/ros2/rust_interfaces.bzl +++ b/ros2/rust_interfaces.bzl @@ -131,7 +131,7 @@ def _compile_rust_code(ctx, label, crate_name, srcs, deps): ) def _rust_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel new file mode 100644 index 00000000..bc22ac2e --- /dev/null +++ b/ros2/test/protobuf/BUILD.bazel @@ -0,0 +1,188 @@ +"""Tests for proto -> ROS2 interface conversion.""" + +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_python//python:defs.bzl", "py_test") +load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") +load("//ros2:cc_defs.bzl", "ros2_cpp_test") +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library", "proto_ros2_interface_library") + +proto_library( + name = "point_proto", + srcs = [ + "dummy_one.proto", + "event.proto", + "point.proto", + ], + deps = [ + "@com_google_protobuf//:duration_proto", + "@com_google_protobuf//:timestamp_proto", + ], +) + +proto_library( + name = "transform_proto", + srcs = ["transform.proto"], + deps = [":point_proto"], +) + +proto_ros2_interface_library( + name = "point_proto_ros_msgs", + dep = ":point_proto", +) + +cpp_proto_ros2_interface_library( + name = "cpp_proto_ros_msgs", + deps = [":transform_proto"], +) + +ros2_cpp_test( + name = "tests", + srcs = ["tests.cc"], + deps = [ + ":cpp_proto_ros_msgs", + "@googletest//:gtest_main", + ], +) + +cpp_proto_ros2_converter_library( + name = "cpp_proto_ros2_converters", + deps = [":transform_proto"], +) + +ros2_cpp_test( + name = "converter_tests", + srcs = ["converter_tests.cc"], + deps = [ + ":cpp_proto_ros2_converters", + ":deprecated_field_converters", + "@googletest//:gtest_main", + ], +) + +proto_library( + name = "scalars_proto", + srcs = ["scalars.proto"], +) + +proto_library( + name = "repeated_fields_proto", + srcs = ["repeated_fields.proto"], + deps = [":point_proto"], +) + +proto_library( + name = "file_enum_proto", + srcs = ["file_enum.proto"], +) + +proto_library( + name = "msg_enum_proto", + srcs = ["msg_enum.proto"], +) + +proto_library( + name = "no_message_proto", + srcs = ["no_message.proto"], +) + +proto_library( + name = "two_messages_proto", + srcs = ["two_messages.proto"], +) + +proto_library( + name = "with_service_proto", + srcs = ["with_service.proto"], +) + +proto_library( + name = "wrong_message_name_proto", + srcs = ["wrong_message_name.proto"], +) + +proto_library( + name = "nested_message_proto", + srcs = ["nested_message.proto"], +) + +proto_library( + name = "oneof_field_proto", + srcs = ["oneof_field.proto"], +) + +proto_library( + name = "map_field_proto", + srcs = ["map_field.proto"], +) + +proto_library( + name = "repeated_bytes_proto", + srcs = ["repeated_bytes.proto"], +) + +proto_library( + name = "foreign_enum_proto", + srcs = [ + "foreign_enum_source.proto", + "foreign_enum_usage.proto", + ], +) + +proto_library( + name = "sibling_proto", + srcs = [ + "sibling_base.proto", + "sibling_ref.proto", + ], +) + +proto_library( + name = "deprecated_field_proto", + srcs = ["deprecated_field.proto"], +) + +cpp_proto_ros2_converter_library( + name = "deprecated_field_converters", + deps = [":deprecated_field_proto"], +) + +proto_library( + name = "deprecated_map_proto", + srcs = ["deprecated_map.proto"], +) + +py_test( + name = "proto_to_ros2_msg_tests", + size = "small", + srcs = ["tests.py"], + args = [ + "-o", + "python_classes=*Tests", + ], + data = [ + ":deprecated_field_proto", + ":deprecated_map_proto", + ":file_enum_proto", + ":foreign_enum_proto", + ":map_field_proto", + ":msg_enum_proto", + ":nested_message_proto", + ":no_message_proto", + ":oneof_field_proto", + ":point_proto", + ":repeated_bytes_proto", + ":repeated_fields_proto", + ":scalars_proto", + ":sibling_proto", + ":transform_proto", + ":two_messages_proto", + ":with_service_proto", + ":wrong_message_name_proto", + ], + main = "tests.py", + deps = [ + "//ros2/protobuf:proto_to_ros2_converter", + "//ros2/protobuf:proto_to_ros2_msg", + requirement("pytest"), + ], +) diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc new file mode 100644 index 00000000..93cf1d9a --- /dev/null +++ b/ros2/test/protobuf/converter_tests.cc @@ -0,0 +1,263 @@ +// Copyright 2026 Milan Vukov +// +// Licensed 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. +#include "gtest/gtest.h" + +#include "ros2_test_protobuf_deprecated_field_proto_ros_msgs/proto_converters.h" +#include "ros2_test_protobuf_point_proto_ros_msgs/proto_converters.h" +#include "ros2_test_protobuf_transform_proto_ros_msgs/proto_converters.h" + +namespace { + +// --------------------------------------------------------------------------- +// Point converter tests +// --------------------------------------------------------------------------- + +TEST(PointConverterTest, ToRos) { + ros2::test::protobuf::Point proto; + proto.set_x(1.0); + proto.set_y(2.0); + proto.set_z(3.0); + proto.set_label("hello"); + proto.set_id(42); + proto.set_valid(true); + proto.add_values(1.5f); + proto.add_values(2.5f); + + ros2_test_protobuf_point_proto_ros_msgs::msg::Point ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + + EXPECT_DOUBLE_EQ(ros.x, 1.0); + EXPECT_DOUBLE_EQ(ros.y, 2.0); + EXPECT_DOUBLE_EQ(ros.z, 3.0); + EXPECT_EQ(ros.label, "hello"); + EXPECT_EQ(ros.id, 42); + EXPECT_EQ(ros.valid, true); + ASSERT_EQ(ros.values.size(), 2u); + EXPECT_FLOAT_EQ(ros.values[0], 1.5f); + EXPECT_FLOAT_EQ(ros.values[1], 2.5f); +} + +TEST(PointConverterTest, ToProto) { + ros2_test_protobuf_point_proto_ros_msgs::msg::Point ros; + ros.x = 4.0; + ros.y = 5.0; + ros.z = 6.0; + ros.label = "world"; + ros.id = -7; + ros.valid = false; + ros.values = {3.0f, 4.0f, 5.0f}; + + ros2::test::protobuf::Point proto; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, + &proto); + + EXPECT_DOUBLE_EQ(proto.x(), 4.0); + EXPECT_DOUBLE_EQ(proto.y(), 5.0); + EXPECT_DOUBLE_EQ(proto.z(), 6.0); + EXPECT_EQ(proto.label(), "world"); + EXPECT_EQ(proto.id(), -7); + EXPECT_EQ(proto.valid(), false); + ASSERT_EQ(proto.values_size(), 3); + EXPECT_FLOAT_EQ(proto.values(0), 3.0f); + EXPECT_FLOAT_EQ(proto.values(1), 4.0f); + EXPECT_FLOAT_EQ(proto.values(2), 5.0f); +} + +TEST(PointConverterTest, RoundTrip) { + ros2::test::protobuf::Point original; + original.set_x(10.0); + original.set_y(20.0); + original.set_z(30.0); + original.set_label("round"); + original.set_id(99); + original.set_valid(true); + original.add_values(0.1f); + original.add_values(0.2f); + + ros2_test_protobuf_point_proto_ros_msgs::msg::Point ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, + &ros); + ros2::test::protobuf::Point recovered; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +// --------------------------------------------------------------------------- +// Transform converter tests +// --------------------------------------------------------------------------- + +TEST(TransformConverterTest, ToRos) { + ros2::test::protobuf::Transform proto; + proto.mutable_point()->set_x(7.0); + proto.mutable_point()->set_y(8.0); + proto.mutable_point()->set_z(9.0); + proto.mutable_point()->set_label("pt"); + proto.mutable_point()->set_id(1); + proto.mutable_point()->set_valid(true); + + ros2_test_protobuf_transform_proto_ros_msgs::msg::Transform ros; + ros2_test_protobuf_transform_proto_ros_msgs::proto_converters::ToRos(proto, + &ros); + + EXPECT_DOUBLE_EQ(ros.point.x, 7.0); + EXPECT_DOUBLE_EQ(ros.point.y, 8.0); + EXPECT_DOUBLE_EQ(ros.point.z, 9.0); + EXPECT_EQ(ros.point.label, "pt"); + EXPECT_EQ(ros.point.id, 1); + EXPECT_EQ(ros.point.valid, true); +} + +TEST(TransformConverterTest, RoundTrip) { + ros2::test::protobuf::Transform original; + original.mutable_point()->set_x(1.1); + original.mutable_point()->set_y(2.2); + original.mutable_point()->set_z(3.3); + original.mutable_point()->set_label("trip"); + original.mutable_point()->set_id(5); + original.mutable_point()->set_valid(false); + original.mutable_point()->add_values(0.5f); + + ros2_test_protobuf_transform_proto_ros_msgs::msg::Transform ros; + ros2_test_protobuf_transform_proto_ros_msgs::proto_converters::ToRos(original, + &ros); + ros2::test::protobuf::Transform recovered; + ros2_test_protobuf_transform_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +// --------------------------------------------------------------------------- +// DummyOne converter tests (exercises enum field conversion) +// --------------------------------------------------------------------------- + +TEST(DummyOneConverterTest, ToRos) { + ros2::test::protobuf::DummyOne proto; + proto.set_color(ros2::test::protobuf::DummyOne::COLOR_RED); + + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + + EXPECT_EQ(ros.color, + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne::COLOR_RED); +} + +TEST(DummyOneConverterTest, ToProto) { + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; + ros.color = + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne::COLOR_GREEN; + + ros2::test::protobuf::DummyOne proto; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, + &proto); + + EXPECT_EQ(proto.color(), ros2::test::protobuf::DummyOne::COLOR_GREEN); +} + +TEST(DummyOneConverterTest, RoundTrip) { + ros2::test::protobuf::DummyOne original; + original.set_color(ros2::test::protobuf::DummyOne::COLOR_BLUE); + + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, + &ros); + ros2::test::protobuf::DummyOne recovered; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +// --------------------------------------------------------------------------- +// Event converter tests (exercises Timestamp and Duration conversion) +// --------------------------------------------------------------------------- + +TEST(EventConverterTest, ToRos) { + ros2::test::protobuf::Event proto; + proto.mutable_stamp()->set_seconds(1234567890); + proto.mutable_stamp()->set_nanos(500000000); + proto.set_name("test_event"); + proto.mutable_duration()->set_seconds(60); + proto.mutable_duration()->set_nanos(250000000); + + ros2_test_protobuf_point_proto_ros_msgs::msg::Event ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + + EXPECT_EQ(ros.stamp.seconds, 1234567890); + EXPECT_EQ(ros.stamp.nanos, 500000000); + EXPECT_EQ(ros.name, "test_event"); + EXPECT_EQ(ros.duration.seconds, 60); + EXPECT_EQ(ros.duration.nanos, 250000000u); +} + +TEST(EventConverterTest, ToProto) { + ros2_test_protobuf_point_proto_ros_msgs::msg::Event ros; + ros.stamp.seconds = 987654321; + ros.stamp.nanos = 123456789; + ros.name = "from_ros_event"; + ros.duration.seconds = 5; + ros.duration.nanos = 0u; + + ros2::test::protobuf::Event proto; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, + &proto); + + EXPECT_EQ(proto.stamp().seconds(), 987654321); + EXPECT_EQ(proto.stamp().nanos(), 123456789); + EXPECT_EQ(proto.name(), "from_ros_event"); + EXPECT_EQ(proto.duration().seconds(), 5); + EXPECT_EQ(proto.duration().nanos(), 0); +} + +TEST(EventConverterTest, RoundTrip) { + ros2::test::protobuf::Event original; + original.mutable_stamp()->set_seconds(1000000000); + original.mutable_stamp()->set_nanos(999999999); + original.set_name("round_trip"); + original.mutable_duration()->set_seconds(3600); + original.mutable_duration()->set_nanos(1); + + ros2_test_protobuf_point_proto_ros_msgs::msg::Event ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, + &ros); + ros2::test::protobuf::Event recovered; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +// --------------------------------------------------------------------------- +// DeprecatedField converter tests (verifies deprecated fields are excluded) +// --------------------------------------------------------------------------- + +TEST(DeprecatedFieldConverterTest, RoundTrip) { + ros2::test::protobuf::DeprecatedField original; + original.set_active("round"); + original.set_keep_me(true); + // legacy is left at its zero default; it is not touched by the converter. + + ros2_test_protobuf_deprecated_field_proto_ros_msgs::msg::DeprecatedField ros; + ros2_test_protobuf_deprecated_field_proto_ros_msgs::proto_converters::ToRos( + original, &ros); + ros2::test::protobuf::DeprecatedField recovered; + ros2_test_protobuf_deprecated_field_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +} // namespace diff --git a/ros2/test/protobuf/deprecated_field.proto b/ros2/test/protobuf/deprecated_field.proto new file mode 100644 index 00000000..a6b713ab --- /dev/null +++ b/ros2/test/protobuf/deprecated_field.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message DeprecatedField { + string active = 1; + int32 legacy = 2 [deprecated = true]; + bool keep_me = 3; +} diff --git a/ros2/test/protobuf/deprecated_map.proto b/ros2/test/protobuf/deprecated_map.proto new file mode 100644 index 00000000..6ad722f6 --- /dev/null +++ b/ros2/test/protobuf/deprecated_map.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message DeprecatedMap { + string label = 1; + map old_items = 2 [deprecated = true]; +} diff --git a/ros2/test/protobuf/dummy_one.proto b/ros2/test/protobuf/dummy_one.proto new file mode 100644 index 00000000..f94d092a --- /dev/null +++ b/ros2/test/protobuf/dummy_one.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/point.proto"; + +message DummyOne { + enum Color { + COLOR_UNKNOWN = 0; + COLOR_RED = 1; + COLOR_GREEN = 2; + COLOR_BLUE = 3; + } + repeated Point points = 1; + Color color = 2; +} diff --git a/ros2/test/protobuf/event.proto b/ros2/test/protobuf/event.proto new file mode 100644 index 00000000..c8a15afa --- /dev/null +++ b/ros2/test/protobuf/event.proto @@ -0,0 +1,25 @@ +// Copyright 2026 Milan Vukov +// +// Licensed 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. +syntax = "proto3"; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +package ros2.test.protobuf; + +message Event { + google.protobuf.Timestamp stamp = 1; + string name = 2; + google.protobuf.Duration duration = 3; +} diff --git a/ros2/test/protobuf/file_enum.proto b/ros2/test/protobuf/file_enum.proto new file mode 100644 index 00000000..38697988 --- /dev/null +++ b/ros2/test/protobuf/file_enum.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +enum Status { + STATUS_UNKNOWN = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; +} + +message FileEnum { + Status status = 1; + repeated Status tags = 2; +} diff --git a/ros2/test/protobuf/foreign_enum_source.proto b/ros2/test/protobuf/foreign_enum_source.proto new file mode 100644 index 00000000..105f12bf --- /dev/null +++ b/ros2/test/protobuf/foreign_enum_source.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +enum ForeignColor { + FOREIGN_COLOR_UNKNOWN = 0; + FOREIGN_COLOR_RED = 1; + FOREIGN_COLOR_GREEN = 2; +} diff --git a/ros2/test/protobuf/foreign_enum_usage.proto b/ros2/test/protobuf/foreign_enum_usage.proto new file mode 100644 index 00000000..b610d5ac --- /dev/null +++ b/ros2/test/protobuf/foreign_enum_usage.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/foreign_enum_source.proto"; + +message ForeignEnumUsage { + ForeignColor color = 1; +} diff --git a/ros2/test/protobuf/map_field.proto b/ros2/test/protobuf/map_field.proto new file mode 100644 index 00000000..cad1a313 --- /dev/null +++ b/ros2/test/protobuf/map_field.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message MapField { + map items = 1; +} diff --git a/ros2/test/protobuf/msg_enum.proto b/ros2/test/protobuf/msg_enum.proto new file mode 100644 index 00000000..a48b8874 --- /dev/null +++ b/ros2/test/protobuf/msg_enum.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message MsgEnum { + enum Kind { + KIND_NONE = 0; + KIND_TYPE_A = 1; + KIND_TYPE_B = 2; + } + + Kind kind = 1; + repeated Kind extra_kinds = 2; +} diff --git a/ros2/test/protobuf/nested_message.proto b/ros2/test/protobuf/nested_message.proto new file mode 100644 index 00000000..1ce26e3c --- /dev/null +++ b/ros2/test/protobuf/nested_message.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message NestedMessage { + message InnerType { + bool flag = 1; + } + + InnerType inner = 1; +} diff --git a/ros2/test/protobuf/no_message.proto b/ros2/test/protobuf/no_message.proto new file mode 100644 index 00000000..301c5e97 --- /dev/null +++ b/ros2/test/protobuf/no_message.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +enum Placeholder { + PLACEHOLDER_NONE = 0; +} diff --git a/ros2/test/protobuf/oneof_field.proto b/ros2/test/protobuf/oneof_field.proto new file mode 100644 index 00000000..49107928 --- /dev/null +++ b/ros2/test/protobuf/oneof_field.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message OneofField { + oneof value { + int32 num = 1; + string str = 2; + } +} diff --git a/ros2/test/protobuf/point.proto b/ros2/test/protobuf/point.proto new file mode 100644 index 00000000..0eaa5a1e --- /dev/null +++ b/ros2/test/protobuf/point.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +// A simple 3D point with some extra scalar fields to exercise type conversion. +message Point { + double x = 1; + double y = 2; + double z = 3; + string label = 4; + int32 id = 5; + bool valid = 6; + repeated float values = 7; +} diff --git a/ros2/test/protobuf/repeated_bytes.proto b/ros2/test/protobuf/repeated_bytes.proto new file mode 100644 index 00000000..5ebb2653 --- /dev/null +++ b/ros2/test/protobuf/repeated_bytes.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message RepeatedBytes { + repeated bytes chunks = 1; +} diff --git a/ros2/test/protobuf/repeated_fields.proto b/ros2/test/protobuf/repeated_fields.proto new file mode 100644 index 00000000..ea833fed --- /dev/null +++ b/ros2/test/protobuf/repeated_fields.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/point.proto"; + +message RepeatedFields { + repeated int32 values = 1; + repeated float scores = 2; + repeated string labels = 3; + repeated Point points = 4; +} diff --git a/ros2/test/protobuf/scalars.proto b/ros2/test/protobuf/scalars.proto new file mode 100644 index 00000000..574fd795 --- /dev/null +++ b/ros2/test/protobuf/scalars.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message Scalars { + double f_double = 1; + float f_float = 2; + int32 f_int32 = 3; + int64 f_int64 = 4; + uint32 f_uint32 = 5; + uint64 f_uint64 = 6; + sint32 f_sint32 = 7; + sint64 f_sint64 = 8; + fixed32 f_fixed32 = 9; + fixed64 f_fixed64 = 10; + sfixed32 f_sfixed32 = 11; + sfixed64 f_sfixed64 = 12; + bool f_bool = 13; + string f_string = 14; + bytes f_bytes = 15; +} diff --git a/ros2/test/protobuf/sibling_base.proto b/ros2/test/protobuf/sibling_base.proto new file mode 100644 index 00000000..f7284ed3 --- /dev/null +++ b/ros2/test/protobuf/sibling_base.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message SiblingBase { + bool flag = 1; +} diff --git a/ros2/test/protobuf/sibling_ref.proto b/ros2/test/protobuf/sibling_ref.proto new file mode 100644 index 00000000..e2110d61 --- /dev/null +++ b/ros2/test/protobuf/sibling_ref.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/sibling_base.proto"; + +message SiblingRef { + SiblingBase base = 1; + repeated SiblingBase items = 2; +} diff --git a/ros2/test/protobuf/tests.cc b/ros2/test/protobuf/tests.cc new file mode 100644 index 00000000..225a62c9 --- /dev/null +++ b/ros2/test/protobuf/tests.cc @@ -0,0 +1,30 @@ +// Copyright 2026 Milan Vukov +// +// Licensed 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. +#include "gtest/gtest.h" + +#include "ros2_test_protobuf_point_proto_ros_msgs/msg/dummy_one.hpp" +#include "ros2_test_protobuf_point_proto_ros_msgs/msg/point.hpp" +#include "ros2_test_protobuf_transform_proto_ros_msgs/msg/transform.hpp" + +namespace { + +TEST(DummyOneTest, EnumConstants) { + using DummyOne = ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne; + EXPECT_EQ(DummyOne::COLOR_UNKNOWN, 0); + EXPECT_EQ(DummyOne::COLOR_RED, 1); + EXPECT_EQ(DummyOne::COLOR_GREEN, 2); + EXPECT_EQ(DummyOne::COLOR_BLUE, 3); +} + +} // namespace diff --git a/ros2/test/protobuf/tests.py b/ros2/test/protobuf/tests.py new file mode 100644 index 00000000..2b32f449 --- /dev/null +++ b/ros2/test/protobuf/tests.py @@ -0,0 +1,243 @@ +# Copyright 2026 Milan Vukov +# +# Licensed 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_to_ros2_msg.py and proto_to_ros2_converter.py.""" +import sys + +import pytest + +from ros2.protobuf import proto_to_ros2 +from ros2.protobuf import proto_to_ros2_converter +from ros2.protobuf import proto_to_ros2_msg + +_PKG = 'ros2/test/protobuf' + + +def _ds(target: str) -> str: + """Returns the path to a proto_library's descriptor set in Bazel runfiles. + """ + return f'{_PKG}/{target}-descriptor-set.proto.bin' + + +def _load(target: str, proto_file: str): + """Loads a FileDescriptorProto from a proto_library's descriptor set. + """ + proto_set = proto_to_ros2.load_descriptor_set(_ds(target)) + fp = proto_to_ros2.find_file_descriptor(proto_set, f'{_PKG}/{proto_file}') + assert fp is not None, ( + f'Proto file {proto_file!r} not found in descriptor set for {target!r}.' + f' Files present: {[f.name for f in proto_set.file]}') + return fp + + +class ConvertFieldsTests: + """Tests that the converter correctly maps proto field types to ROS 2 types. + """ + + def test_scalar_types(self, tmp_path): + fp = _load('scalars_proto', 'scalars.proto') + out = tmp_path / 'Scalars.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/scalars.proto', {}) + content = out.read_text() + assert 'float64 f_double' in content + assert 'float32 f_float' in content + assert 'int32 f_int32' in content + assert 'int64 f_int64' in content + assert 'uint32 f_uint32' in content + assert 'uint64 f_uint64' in content + assert 'int32 f_sint32' in content + assert 'int64 f_sint64' in content + assert 'uint32 f_fixed32' in content + assert 'uint64 f_fixed64' in content + assert 'int32 f_sfixed32' in content + assert 'int64 f_sfixed64' in content + assert 'bool f_bool' in content + assert 'string f_string' in content + assert 'uint8[] f_bytes' in content + + def test_repeated_scalar_fields(self, tmp_path): + fp = _load('repeated_fields_proto', 'repeated_fields.proto') + msg_type_map = { + '.ros2.test.protobuf.Point': + 'ros2_test_protobuf_point_proto_ros_msgs/Point', + } + out = tmp_path / 'RepeatedFields.msg' + proto_to_ros2_msg._convert(fp, str(out), + f'{_PKG}/repeated_fields.proto', + msg_type_map) + content = out.read_text() + assert 'int32[] values' in content + assert 'float32[] scores' in content + assert 'string[] labels' in content + + def test_repeated_message_field(self, tmp_path): + fp = _load('repeated_fields_proto', 'repeated_fields.proto') + msg_type_map = { + '.ros2.test.protobuf.Point': + 'ros2_test_protobuf_point_proto_ros_msgs/Point', + } + out = tmp_path / 'RepeatedFields.msg' + proto_to_ros2_msg._convert(fp, str(out), + f'{_PKG}/repeated_fields.proto', + msg_type_map) + content = out.read_text() + assert 'ros2_test_protobuf_point_proto_ros_msgs/Point[] points' in content # noqa + + def test_message_dep_field(self, tmp_path): + fp = _load('transform_proto', 'transform.proto') + msg_type_map = { + '.ros2.test.protobuf.Point': + 'ros2_test_protobuf_point_proto_ros_msgs/Point', + } + out = tmp_path / 'Transform.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/transform.proto', + msg_type_map) + content = out.read_text() + assert 'ros2_test_protobuf_point_proto_ros_msgs/Point point' in content + + def test_sibling_proto_same_target(self, tmp_path): + """SiblingRef references SiblingBase; both in the same proto_library.""" + fp = _load('sibling_proto', 'sibling_ref.proto') + pkg = 'ros2_test_protobuf_sibling_proto_ros_msgs' + msg_type_map = proto_to_ros2_msg._build_msg_type_map( + dep_descriptor_set_paths=[], + dep_mapping=[], + main_descriptor_set_path=_ds('sibling_proto'), + proto_source=f'{_PKG}/sibling_ref.proto', + self_ros_package=pkg, + ) + out = tmp_path / 'SiblingRef.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/sibling_ref.proto', + msg_type_map) + content = out.read_text() + assert f'{pkg}/SiblingBase base' in content + assert f'{pkg}/SiblingBase[] items' in content + + def test_message_level_enum(self, tmp_path): + fp = _load('msg_enum_proto', 'msg_enum.proto') + out = tmp_path / 'MsgEnum.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/msg_enum.proto', {}) + content = out.read_text() + assert 'int32 KIND_NONE=0' in content + assert 'int32 KIND_TYPE_A=1' in content + assert 'int32 kind' in content + assert 'int32[] extra_kinds' in content + + def test_deprecated_field_is_skipped(self, tmp_path): + fp = _load('deprecated_field_proto', 'deprecated_field.proto') + out = tmp_path / 'DeprecatedField.msg' + proto_to_ros2_msg._convert(fp, str(out), + f'{_PKG}/deprecated_field.proto', {}) + content = out.read_text() + assert 'string active' in content + assert 'bool keep_me' in content + assert 'legacy' not in content + + def test_deprecated_unsupported_type_is_skipped(self, tmp_path): + """Deprecated fields are skipped before constraint validation.""" + fp = _load('deprecated_map_proto', 'deprecated_map.proto') + out = tmp_path / 'DeprecatedMap.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/deprecated_map.proto', + {}) + content = out.read_text() + assert 'string label' in content + assert 'old_items' not in content + + +class RejectInvalidProtoTests: + """Tests that the converter rejects each documented constraint violation.""" + + def _assert_error(self, fp, proto_file, msg_type_map, fragment, tmp_path): + with pytest.raises(SystemExit) as exc: + proto_to_ros2_msg._convert(fp, str(tmp_path / 'out.msg'), + f'{_PKG}/{proto_file}', msg_type_map) + assert fragment in str(exc.value.code) + + def test_rejects_zero_messages(self, tmp_path): + fp = _load('no_message_proto', 'no_message.proto') + self._assert_error(fp, 'no_message.proto', {}, + 'expected exactly 1 message definition, got 0', + tmp_path) + + def test_rejects_multiple_messages(self, tmp_path): + fp = _load('two_messages_proto', 'two_messages.proto') + self._assert_error(fp, 'two_messages.proto', {}, + 'expected exactly 1 message definition, got 2', + tmp_path) + + def test_rejects_service_definition(self, tmp_path): + fp = _load('with_service_proto', 'with_service.proto') + self._assert_error(fp, 'with_service.proto', {}, + 'services are not supported', tmp_path) + + def test_rejects_wrong_message_name(self, tmp_path): + fp = _load('wrong_message_name_proto', 'wrong_message_name.proto') + self._assert_error(fp, 'wrong_message_name.proto', {}, + 'message must be named "WrongMessageName"', tmp_path) + + def test_rejects_nested_message_type(self, tmp_path): + fp = _load('nested_message_proto', 'nested_message.proto') + self._assert_error(fp, 'nested_message.proto', {}, + 'nested message type', tmp_path) + + def test_rejects_oneof_field(self, tmp_path): + fp = _load('oneof_field_proto', 'oneof_field.proto') + self._assert_error(fp, 'oneof_field.proto', {}, 'oneof', tmp_path) + + def test_rejects_map_field(self, tmp_path): + fp = _load('map_field_proto', 'map_field.proto') + self._assert_error(fp, 'map_field.proto', {}, 'map field', tmp_path) + + def test_rejects_repeated_bytes(self, tmp_path): + fp = _load('repeated_bytes_proto', 'repeated_bytes.proto') + self._assert_error(fp, 'repeated_bytes.proto', {}, 'repeated bytes', + tmp_path) + + def test_rejects_cross_file_enum(self, tmp_path): + fp = _load('foreign_enum_proto', 'foreign_enum_usage.proto') + self._assert_error(fp, 'foreign_enum_usage.proto', {}, + 'not found in the current proto file', tmp_path) + + def test_rejects_file_level_enum(self, tmp_path): + fp = _load('file_enum_proto', 'file_enum.proto') + self._assert_error(fp, 'file_enum.proto', {}, + 'file-level enum definitions are not supported', + tmp_path) + + def test_rejects_missing_dep_mapping(self, tmp_path): + fp = _load('transform_proto', 'transform.proto') + self._assert_error(fp, 'transform.proto', {}, 'no dep_mapping entry', + tmp_path) + + +class ConverterSkipsDeprecatedTests: + """Tests that the C++ converter generator skips deprecated fields.""" + + def _field_lines(self, target, proto_file): + fp = _load(target, proto_file) + message = fp.message_type[0] + to_ros, from_ros, _ = proto_to_ros2_converter._field_conversions( + message, f'{_PKG}/{proto_file}', {}) + return to_ros, from_ros + + def test_deprecated_scalar_not_in_generated_code(self): + to_ros, from_ros = self._field_lines('deprecated_field_proto', + 'deprecated_field.proto') + all_lines = to_ros + from_ros + assert any('active' in line for line in all_lines) + assert any('keep_me' in line for line in all_lines) + assert not any('legacy' in line for line in all_lines) + + +if __name__ == '__main__': + sys.exit(pytest.main([__file__, '-v', *sys.argv[1:]])) diff --git a/ros2/test/protobuf/transform.proto b/ros2/test/protobuf/transform.proto new file mode 100644 index 00000000..cc9ef5dc --- /dev/null +++ b/ros2/test/protobuf/transform.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/point.proto"; + +message Transform { + Point point = 1; +} diff --git a/ros2/test/protobuf/two_messages.proto b/ros2/test/protobuf/two_messages.proto new file mode 100644 index 00000000..139fb9ca --- /dev/null +++ b/ros2/test/protobuf/two_messages.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message MessageOne { + bool x = 1; +} + +message MessageTwo { + bool y = 1; +} diff --git a/ros2/test/protobuf/with_service.proto b/ros2/test/protobuf/with_service.proto new file mode 100644 index 00000000..48b4e9b0 --- /dev/null +++ b/ros2/test/protobuf/with_service.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message WithServiceRequest { + bool flag = 1; +} + +message WithServiceResponse { + bool result = 1; +} + +service WithService { + rpc Call(WithServiceRequest) returns (WithServiceResponse); +} diff --git a/ros2/test/protobuf/wrong_message_name.proto b/ros2/test/protobuf/wrong_message_name.proto new file mode 100644 index 00000000..e4b16b6f --- /dev/null +++ b/ros2/test/protobuf/wrong_message_name.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message NotTheRightName { + bool x = 1; +}