pw_protobuf: Add inline option support

Adds inline field option support for max_size and max_count fields, as
recently discussed in PW Live. This makes a distinction between
protocol-level parameters vs codegen configuration, where specifying
these fields inline should only be used in cases where they affect
protocol validity.

Change-Id: I02ebf55bda21910037ae118a3c14fca960e759f4
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/119114
Reviewed-by: Ted Pudlik <tpudlik@google.com>
Commit-Queue: Wyatt Hepler <hepler@google.com>
diff --git a/pw_protobuf/BUILD.bazel b/pw_protobuf/BUILD.bazel
index 61ed8bb..da1b4b8 100644
--- a/pw_protobuf/BUILD.bazel
+++ b/pw_protobuf/BUILD.bazel
@@ -210,6 +210,27 @@
 )
 
 proto_library(
+    name = "field_options_proto",
+    srcs = [
+        "pw_protobuf_protos/field_options.proto",
+    ],
+    strip_import_prefix = "//pw_protobuf",
+    deps = [
+        "@com_google_protobuf//:descriptor_proto",
+    ],
+)
+
+py_proto_library(
+    name = "field_options_proto_pb2",
+    srcs = [
+        "pw_protobuf_protos/field_options.proto",
+    ],
+    deps = [
+        "@com_google_protobuf//:protobuf_python",
+    ],
+)
+
+proto_library(
     name = "status_proto",
     srcs = [
         "pw_protobuf_protos/status.proto",
diff --git a/pw_protobuf/BUILD.gn b/pw_protobuf/BUILD.gn
index c8a765e..4463f13 100644
--- a/pw_protobuf/BUILD.gn
+++ b/pw_protobuf/BUILD.gn
@@ -218,6 +218,7 @@
 pw_proto_library("common_protos") {
   sources = [
     "pw_protobuf_protos/common.proto",
+    "pw_protobuf_protos/field_options.proto",
     "pw_protobuf_protos/status.proto",
   ]
 }
diff --git a/pw_protobuf/docs.rst b/pw_protobuf/docs.rst
index 7b2cc69..787f150 100644
--- a/pw_protobuf/docs.rst
+++ b/pw_protobuf/docs.rst
@@ -581,6 +581,33 @@
   | 5 bytes           | 4,294,967,295 or < 4GiB (max uint32_t) |
   +-------------------+----------------------------------------+
 
+Field Options
+=============
+``pw_protobuf`` supports the following field options for specifying
+protocol-level limitations, rather than code generation parameters (although
+they do influence code generation):
+
+
+* ``max_count``:
+  Maximum number of entries for repeated fields.
+
+* ``max_size``:
+  Maximum size of `bytes` or `string` fields.
+
+Even though other proto codegen implementations do not respect these field
+options, they can still compile protos which use these options. This is
+especially useful for host builds using upstream protoc code generation, where
+host software can use the reflection API to query for the options and validate
+messages comply with the specified limitations.
+
+.. code::
+
+  import "pw_protobuf_protos/field_options.proto";
+
+  message Demo {
+    string size_limited_string = 1 [(pw.protobuf.pwpb).max_size = 16];
+  };
+
 Options Files
 =============
 Code generation can be configured using a separate ``.options`` file placed
diff --git a/pw_protobuf/pw_protobuf_protos/field_options.proto b/pw_protobuf/pw_protobuf_protos/field_options.proto
new file mode 100644
index 0000000..5d15677
--- /dev/null
+++ b/pw_protobuf/pw_protobuf_protos/field_options.proto
@@ -0,0 +1,37 @@
+// Copyright 2022 The Pigweed Authors
+//
+// 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
+//
+//     https://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/descriptor.proto";
+
+package pw.protobuf;
+
+// Options supported for inline usage in .proto files.
+//
+// These options are for use when there are true limitations intended to be part
+// of the protocol specification and are not specific to any particular protobuf
+// code generator.
+message FieldOptions {
+  // Maximum number of entries for repeated fields.
+  optional int32 max_count = 1;
+
+  // Maximum size for bytes and string fields (excludes null terminator).
+  optional int32 max_size = 2;
+}
+
+extend google.protobuf.FieldOptions {
+  // Extension 1155 is reserved upstream for PW protobuf compiler.
+  // https://github.com/protocolbuffers/protobuf/blob/main/docs/options.md
+  optional FieldOptions pwpb = 1155;
+}
diff --git a/pw_protobuf/py/BUILD.bazel b/pw_protobuf/py/BUILD.bazel
index e273026..c366833 100644
--- a/pw_protobuf/py/BUILD.bazel
+++ b/pw_protobuf/py/BUILD.bazel
@@ -37,6 +37,7 @@
     imports = ["."],
     deps = [
         "//pw_protobuf:codegen_protos_pb2",
+        "//pw_protobuf:field_options_proto_pb2",
         "@com_google_protobuf//:protobuf_python",
     ],
 )
diff --git a/pw_protobuf/py/BUILD.gn b/pw_protobuf/py/BUILD.gn
index 3fa3fe5..c9e4019 100644
--- a/pw_protobuf/py/BUILD.gn
+++ b/pw_protobuf/py/BUILD.gn
@@ -34,6 +34,7 @@
   python_deps = [
     "$dir_pw_cli/py",
     "..:codegen_protos.python",
+    "..:common_protos.python",
   ]
   pylintrc = "$dir_pigweed/.pylintrc"
   mypy_ini = "$dir_pigweed/.mypy.ini"
diff --git a/pw_protobuf/py/pw_protobuf/options.py b/pw_protobuf/py/pw_protobuf/options.py
index 9a3654d..61cb6f1 100644
--- a/pw_protobuf/py/pw_protobuf/options.py
+++ b/pw_protobuf/py/pw_protobuf/options.py
@@ -21,6 +21,7 @@
 from google.protobuf import text_format
 
 from pw_protobuf_codegen_protos.codegen_options_pb2 import CodegenOptions
+from pw_protobuf_protos.field_options_pb2 import FieldOptions
 
 _MULTI_LINE_COMMENT_RE = re.compile(r'/\*.*?\*/', flags=re.MULTILINE)
 _SINGLE_LINE_COMMENT_RE = re.compile(r'//.*?$', flags=re.MULTILINE)
@@ -77,3 +78,37 @@
             matched.MergeFrom(mask_options)
 
     return matched
+
+
+def create_from_field_options(
+    field_options: FieldOptions,
+) -> CodegenOptions:
+    """Create a CodegenOptions from a FieldOptions."""
+    codegen_options = CodegenOptions()
+
+    if field_options.HasField('max_count'):
+        codegen_options.max_count = field_options.max_count
+
+    if field_options.HasField('max_size'):
+        codegen_options.max_size = field_options.max_size
+
+    return codegen_options
+
+
+def merge_field_and_codegen_options(
+    field_options: CodegenOptions, codegen_options: CodegenOptions
+) -> CodegenOptions:
+    """Merge inline field_options and options file codegen_options."""
+    # The field options specify protocol-level requirements. Therefore, any
+    # codegen options should not violate those protocol-level requirements.
+    if field_options.max_count > 0 and codegen_options.max_count > 0:
+        assert field_options.max_count == codegen_options.max_count
+
+    if field_options.max_size > 0 and codegen_options.max_size > 0:
+        assert field_options.max_size == codegen_options.max_size
+
+    merged_options = CodegenOptions()
+    merged_options.CopyFrom(field_options)
+    merged_options.MergeFrom(codegen_options)
+
+    return merged_options
diff --git a/pw_protobuf/py/pw_protobuf/proto_tree.py b/pw_protobuf/py/pw_protobuf/proto_tree.py
index 5617828..44c8804 100644
--- a/pw_protobuf/py/pw_protobuf/proto_tree.py
+++ b/pw_protobuf/py/pw_protobuf/proto_tree.py
@@ -33,6 +33,7 @@
 
 from pw_protobuf import options, symbol_name_mapping
 from pw_protobuf_codegen_protos.codegen_options_pb2 import CodegenOptions
+from pw_protobuf_protos.field_options_pb2 import pwpb as pwpb_field_options
 
 T = TypeVar('T')  # pylint: disable=invalid-name
 
@@ -667,6 +668,7 @@
         repeated = (
             field.label == descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED
         )
+
         codegen_options = (
             options.match_options(
                 '.'.join((message.proto_path(), field.name)), proto_options
@@ -674,6 +676,26 @@
             if proto_options is not None
             else None
         )
+
+        field_options = (
+            options.create_from_field_options(
+                field.options.Extensions[pwpb_field_options]
+            )
+            if field.options.HasExtension(pwpb_field_options)
+            else None
+        )
+
+        merged_options = None
+
+        if field_options and codegen_options:
+            merged_options = options.merge_field_and_codegen_options(
+                field_options, codegen_options
+            )
+        elif field_options:
+            merged_options = field_options
+        elif codegen_options:
+            merged_options = codegen_options
+
         message.add_field(
             ProtoMessageField(
                 field.name,
@@ -682,7 +704,7 @@
                 type_node,
                 optional,
                 repeated,
-                codegen_options,
+                merged_options,
             )
         )
 
diff --git a/pw_protobuf_compiler/BUILD.gn b/pw_protobuf_compiler/BUILD.gn
index 9305af5..22f097c 100644
--- a/pw_protobuf_compiler/BUILD.gn
+++ b/pw_protobuf_compiler/BUILD.gn
@@ -54,6 +54,7 @@
 pw_proto_library("pwpb_test_protos") {
   sources = [ "pw_protobuf_compiler_pwpb_protos/pwpb_test.proto" ]
   inputs = [ "pw_protobuf_compiler_pwpb_protos/pwpb_test.options" ]
+  deps = [ "$dir_pw_protobuf:common_protos" ]
 }
 
 pw_proto_library("test_protos") {
diff --git a/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/BUILD.bazel b/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/BUILD.bazel
index 70c134c..280c01f 100644
--- a/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/BUILD.bazel
+++ b/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/BUILD.bazel
@@ -37,6 +37,9 @@
 proto_library(
     name = "pwpb_test_proto",
     srcs = [":pwpb_test_proto_and_options_files"],
+    deps = [
+        "//pw_protobuf:field_options_proto",
+    ],
 )
 
 pw_proto_library(
diff --git a/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.options b/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.options
index 98bb8da..f7e21b9 100644
--- a/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.options
+++ b/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.options
@@ -17,3 +17,5 @@
 pw.protobuf_compiler.OptionsFileExample.thirty_two_chars max_size:32
 pw.protobuf_compiler.OptionsFileExample.forty_two_chars max_size:42
 // The `max_size` of the string `unspecified_length` is of course unspecified.
+
+pw.protobuf_compiler.InlineOptionsExample.ten_chars_inline fixed_size:True
diff --git a/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.proto b/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.proto
index e176cb0..f93f5ee 100644
--- a/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.proto
+++ b/pw_protobuf_compiler/pw_protobuf_compiler_pwpb_protos/pwpb_test.proto
@@ -15,6 +15,8 @@
 
 package pw.protobuf_compiler;
 
+import "pw_protobuf_protos/field_options.proto";
+
 message Point {
   uint32 x = 1;
   uint32 y = 2;
@@ -29,3 +31,7 @@
   string forty_two_chars = 2;
   string unspecified_length = 3;
 };
+
+message InlineOptionsExample {
+  string ten_chars_inline = 1 [(pw.protobuf.pwpb).max_size = 10];
+}
diff --git a/pw_protobuf_compiler/pwpb_test.cc b/pw_protobuf_compiler/pwpb_test.cc
index a7d2d35..ae5ffa7 100644
--- a/pw_protobuf_compiler/pwpb_test.cc
+++ b/pw_protobuf_compiler/pwpb_test.cc
@@ -48,5 +48,14 @@
       "The field `unspecified_length` should be a `pw::protobuf::Callback`.");
 }
 
+TEST(Pwpb, InlineOptionsAppliedAndOverridden) {
+  pw::protobuf_compiler::InlineOptionsExample::Message inline_options_example;
+
+  static_assert(
+      std::is_same_v<decltype(inline_options_example.ten_chars_inline),
+                     pw::InlineString<10>>,
+      "Field `ten_chars_inline` should be a `pw::InlineString<10>`.");
+}
+
 }  // namespace
 }  // namespace pw::protobuf_compiler