~bigbes/tarantool

tarantool-protobuf

ab214a39023a13ef4c1e534589bdc0d20ff59ce4 — Eugene Blikh 3 months ago 428ee1f
codegen: emit strict <Type>_fields / _oneofs constants for lazy view

Lazy-view callers passing a typo'd field name to :get / :has / :set /
:clear / :which got `nil` back, indistinguishable from a legitimately-
absent optional field. Failures surfaced as missing data downstream.

Each generated message now exports a M.<Type>_fields table mapping
each field name to itself (and M.<Type>_oneofs for oneof groups),
wrapped by a new pb.field_names() helper that errors on unknown-key
reads and on any write. Routing field-name arguments through these
tables turns a typo into a load-time error at the read site.

Eager _encode / _decode keep round-tripping plain Lua tables — the
constants table is a lazy-view contract, documented in
docs/api-modes.md. README and lazy_test.lua converted to the new
pattern; three new tests cover typo / read-only / oneof-typo errors.
M README.md => README.md +7 -0
@@ 86,8 86,15 @@ M.Foo_decode_lazy(b)   -- wire bytes -> MessageView (zero-copy view)
M.Foo_text(t, opts)    -- table -> protoc-style text format (debug printer)
M.Foo_has_<field>(t)   -- only emitted for proto3 explicit-`optional` fields
M.Foo_clear_<field>(t) -- same
M.Foo_fields           -- strict {field = "field", ...} for lazy-view callers
M.Foo_oneofs           -- strict {oneof_group = "oneof_group", ...}, when any
```

Lazy-view field-name arguments should be routed through `M.Foo_fields`
/ `M.Foo_oneofs` rather than passed as string literals — typos error at
the read site instead of silently returning `nil`. See
[docs/api-modes.md](docs/api-modes.md#field-name-constants--required).

Text-format **decoding** is exposed on the runtime as `pb.text.decode(desc,
text, opts)` (no per-message wrapper — it's used from a few places, like
the conformance runner, and didn't warrant codegen surface).

M cmd/protoc-gen-tarantool/internal/gen/gen.go => cmd/protoc-gen-tarantool/internal/gen/gen.go +46 -0
@@ 240,9 240,55 @@ func emitMessageFields(w *writer, file *protogen.File, m *protogen.Message, impo
	emitOneofTable(w, name, m)
	emitReservedNames(w, name, m)
	w.line("pb.finalize_message(M.%s_descriptor)", name)
	emitFieldNamesTable(w, name, m)
	emitOneofNamesTable(w, name, m)
	w.line("")
}

// emitFieldNamesTable emits a strict, typo-checked field-name constants
// table per message:
//
//	M.<Name>_fields = pb.field_names({
//	    foo = "foo",
//	    bar = "bar",
//	})
//
// Callers of the lazy view (`view:get(F.foo)`) get a load-time error on
// typos instead of the silent `nil` that a raw `view:get('fooo')` would
// return. See docs/api-modes.md for the documented contract.
func emitFieldNamesTable(w *writer, name string, m *protogen.Message) {
	if len(m.Fields) == 0 {
		return
	}
	w.line("M.%s_fields = pb.field_names({", name)
	for _, f := range m.Fields {
		fn := string(f.Desc.Name())
		w.line("    %s = %q,", fn, fn)
	}
	w.line("})")
}

// emitOneofNamesTable emits a strict, typo-checked oneof-name constants
// table per message that declares non-synthetic oneofs. Symmetric to
// emitFieldNamesTable; used by `view:which(O.outcome)` etc.
func emitOneofNamesTable(w *writer, name string, m *protogen.Message) {
	var names []string
	for _, oo := range m.Oneofs {
		if oo.Fields[0].Desc.HasOptionalKeyword() {
			continue
		}
		names = append(names, string(oo.Desc.Name()))
	}
	if len(names) == 0 {
		return
	}
	w.line("M.%s_oneofs = pb.field_names({", name)
	for _, on := range names {
		w.line("    %s = %q,", on, on)
	}
	w.line("})")
}

// emitOneofTable emits `M.<Name>_descriptor.oneofs = { <name> = {...members...} }`
// when the message has any non-synthetic oneofs.
func emitOneofTable(w *writer, name string, m *protogen.Message) {

M docs/api-modes.md => docs/api-modes.md +50 -11
@@ 151,42 151,81 @@ materialization happens on `:get`, repeated/map fields become

```lua
local view = hello.Person_decode_lazy(bytes)
local F = hello.Person_fields            -- field-name constants
local AF = hello.Address_fields
local R = hello.Result_oneofs            -- oneof-group constants

-- Direct field reads (decoded on demand, then cached on the view).
local name = view:get('name')
local uid  = view:get('user_id')
local name = view:get(F.name)
local uid  = view:get(F.user_id)

-- Repeated field: ArrayView.
local emails = view:get('emails')   -- ArrayView, no per-element table
local emails = view:get(F.emails)        -- ArrayView, no per-element table
for i, e in emails:iter() do
    print(i, e)
end
print('count:', emails:len())

-- Map field: MapView.
local ages = view:get('ages_by_nickname')
-- Map field: MapView. Field name uses the constants table; the map
-- key passed to MapView:get is a raw value, not a field name.
local ages = view:get(F.ages_by_nickname)
print(ages:get('alice'))

-- Sub-message: a nested MessageView.
local addr = view:get('address')
print(addr:get('city'))
-- Sub-message: a nested MessageView. Switch to the sub-message's
-- own constants table for its fields.
local addr = view:get(F.address)
print(addr:get(AF.city))

-- Presence + oneof which-branch checks without forcing a decode.
view:has('user_id')        -- bool
view:which('outcome')      -- string?
view:has(F.user_id)        -- bool
view:which(R.outcome)      -- string?

-- Iterate fields actually present on the wire (skip-aware).
for name, val in view:iter() do print(name, val) end

-- Mutate, then re-encode. Untouched fields are spliced byte-for-byte
-- from the original payload; only dirty fields run through encode.
view:set('user_id', require('ffi').cast('uint64_t', 99))
view:set(F.user_id, require('ffi').cast('uint64_t', 99))
local new_bytes = view:encode()

-- Force a fully-materialized table when you actually need one.
local t = view:totable()
```

### Field-name constants — required

Every generated message exports a `M.<Type>_fields` table mapping each
field name to itself, and (when applicable) `M.<Type>_oneofs` for
oneof group names. Both tables are frozen behind a strict `__index`
that errors on unknown keys and a read-only `__newindex`.

The lazy view API (`:get` / `:has` / `:set` / `:clear` / `:which`)
takes a field name string — route every field-name argument through
the constants table rather than passing a string literal:

```lua
-- right
view:get(hello.Person_fields.user_id)
-- typo here errors at the read site:
-- "unknown field name: 'user_di'"

-- wrong (silently returns nil; indistinguishable from absent optional)
view:get('user_di')
```

Why required: `view:get('user_di')` returns `nil` whether the field
name is wrong *or* the field was legitimately absent on the wire. The
ambiguity tends to surface as missing data far downstream. The
constants table catches the typo where it was written.

This is a lazy-view contract. The eager `_decode` / `_encode` path
round-trips plain Lua tables whose keys are written by the caller's
own code, so the same safety net doesn't apply there — eager users
already see misspelled keys as direct test failures.

Map keys (`MapView:get(k)`, `MapView:has(k)`) and array indices
(`ArrayView:at(i)`) are values, not field names — pass them raw.

### When lazy wins

- **Sparse reads** — payload is large, you only need a few fields.

M examples/expected/full/conformance/conformance_pb.lua => examples/expected/full/conformance/conformance_pb.lua +39 -0
@@ 52,12 52,20 @@ M.TestStatus_descriptor.fields = {
    {name="matched_name", id=3, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.TestStatus_descriptor)
M.TestStatus_fields = pb.field_names({
    name = "name",
    failure_message = "failure_message",
    matched_name = "matched_name",
})

-- Message: conformance.FailureSet
M.FailureSet_descriptor.fields = {
    {name="test", id=2, kind='message', message=M.TestStatus_descriptor, repeated=true},
}
pb.finalize_message(M.FailureSet_descriptor)
M.FailureSet_fields = pb.field_names({
    test = "test",
})

-- Message: conformance.ConformanceRequest
M.ConformanceRequest_descriptor.fields = {


@@ 75,6 83,20 @@ M.ConformanceRequest_descriptor.oneofs = {
    payload = {"protobuf_payload", "json_payload", "jspb_payload", "text_payload"},
}
pb.finalize_message(M.ConformanceRequest_descriptor)
M.ConformanceRequest_fields = pb.field_names({
    protobuf_payload = "protobuf_payload",
    json_payload = "json_payload",
    jspb_payload = "jspb_payload",
    text_payload = "text_payload",
    requested_output_format = "requested_output_format",
    message_type = "message_type",
    test_category = "test_category",
    jspb_encoding_options = "jspb_encoding_options",
    print_unknown_fields = "print_unknown_fields",
})
M.ConformanceRequest_oneofs = pb.field_names({
    payload = "payload",
})

-- Message: conformance.ConformanceResponse
M.ConformanceResponse_descriptor.fields = {


@@ 92,12 114,29 @@ M.ConformanceResponse_descriptor.oneofs = {
    result = {"parse_error", "serialize_error", "timeout_error", "runtime_error", "protobuf_payload", "json_payload", "skipped", "jspb_payload", "text_payload"},
}
pb.finalize_message(M.ConformanceResponse_descriptor)
M.ConformanceResponse_fields = pb.field_names({
    parse_error = "parse_error",
    serialize_error = "serialize_error",
    timeout_error = "timeout_error",
    runtime_error = "runtime_error",
    protobuf_payload = "protobuf_payload",
    json_payload = "json_payload",
    skipped = "skipped",
    jspb_payload = "jspb_payload",
    text_payload = "text_payload",
})
M.ConformanceResponse_oneofs = pb.field_names({
    result = "result",
})

-- Message: conformance.JspbEncodingConfig
M.JspbEncodingConfig_descriptor.fields = {
    {name="use_jspb_array_any_format", id=1, kind='scalar', proto_type="bool"},
}
pb.finalize_message(M.JspbEncodingConfig_descriptor)
M.JspbEncodingConfig_fields = pb.field_names({
    use_jspb_array_any_format = "use_jspb_array_any_format",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors

M examples/expected/full/hello/hello_pb.lua => examples/expected/full/hello/hello_pb.lua +51 -0
@@ 35,18 35,33 @@ M.Result_descriptor.oneofs = {
    outcome = {"text", "code", "details"},
}
pb.finalize_message(M.Result_descriptor)
M.Result_fields = pb.field_names({
    id = "id",
    text = "text",
    code = "code",
    details = "details",
})
M.Result_oneofs = pb.field_names({
    outcome = "outcome",
})

-- Message: hello.HelloRequest
M.HelloRequest_descriptor.fields = {
    {name="name", id=1, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.HelloRequest_descriptor)
M.HelloRequest_fields = pb.field_names({
    name = "name",
})

-- Message: hello.HelloReply
M.HelloReply_descriptor.fields = {
    {name="greeting", id=1, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.HelloReply_descriptor)
M.HelloReply_fields = pb.field_names({
    greeting = "greeting",
})

-- Message: hello.Event
M.Event_descriptor.fields = {


@@ 64,6 79,20 @@ M.Event_descriptor.fields = {
    {name="update_mask", id=12, kind='message', message=pb.wkt.FieldMask_descriptor},
}
pb.finalize_message(M.Event_descriptor)
M.Event_fields = pb.field_names({
    title = "title",
    created_at = "created_at",
    duration = "duration",
    ack = "ack",
    retry_count = "retry_count",
    note = "note",
    is_admin = "is_admin",
    payload = "payload",
    attribute = "attribute",
    tags = "tags",
    extension = "extension",
    update_mask = "update_mask",
})

-- Message: hello.Address
M.Address_descriptor.fields = {


@@ 73,6 102,12 @@ M.Address_descriptor.fields = {
    {name="apartment", id=4, kind='scalar', proto_type="string", optional=true},
}
pb.finalize_message(M.Address_descriptor)
M.Address_fields = pb.field_names({
    street = "street",
    city = "city",
    zip = "zip",
    apartment = "apartment",
})

-- Message: hello.Person
M.Person_descriptor.fields = {


@@ 92,6 127,22 @@ M.Person_descriptor.fields = {
    {name="addresses_by_label", id=15, kind='map', key={kind='scalar', proto_type="string"}, value={kind='message', message=M.Address_descriptor}},
}
pb.finalize_message(M.Person_descriptor)
M.Person_fields = pb.field_names({
    name = "name",
    age = "age",
    emails = "emails",
    status = "status",
    address = "address",
    friends = "friends",
    lucky_numbers = "lucky_numbers",
    avatar = "avatar",
    user_id = "user_id",
    balance = "balance",
    weight_kg = "weight_kg",
    ages_by_nickname = "ages_by_nickname",
    nickname_by_age = "nickname_by_age",
    addresses_by_label = "addresses_by_label",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors

M examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua => examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +165 -0
@@ 213,6 213,164 @@ M.TestAllTypesProto3_descriptor.reserved_names = {
    ["reserved_field"] = true,
}
pb.finalize_message(M.TestAllTypesProto3_descriptor)
M.TestAllTypesProto3_fields = pb.field_names({
    optional_int32 = "optional_int32",
    optional_int64 = "optional_int64",
    optional_uint32 = "optional_uint32",
    optional_uint64 = "optional_uint64",
    optional_sint32 = "optional_sint32",
    optional_sint64 = "optional_sint64",
    optional_fixed32 = "optional_fixed32",
    optional_fixed64 = "optional_fixed64",
    optional_sfixed32 = "optional_sfixed32",
    optional_sfixed64 = "optional_sfixed64",
    optional_float = "optional_float",
    optional_double = "optional_double",
    optional_bool = "optional_bool",
    optional_string = "optional_string",
    optional_bytes = "optional_bytes",
    optional_nested_message = "optional_nested_message",
    optional_foreign_message = "optional_foreign_message",
    optional_nested_enum = "optional_nested_enum",
    optional_foreign_enum = "optional_foreign_enum",
    optional_aliased_enum = "optional_aliased_enum",
    optional_string_piece = "optional_string_piece",
    optional_cord = "optional_cord",
    recursive_message = "recursive_message",
    repeated_int32 = "repeated_int32",
    repeated_int64 = "repeated_int64",
    repeated_uint32 = "repeated_uint32",
    repeated_uint64 = "repeated_uint64",
    repeated_sint32 = "repeated_sint32",
    repeated_sint64 = "repeated_sint64",
    repeated_fixed32 = "repeated_fixed32",
    repeated_fixed64 = "repeated_fixed64",
    repeated_sfixed32 = "repeated_sfixed32",
    repeated_sfixed64 = "repeated_sfixed64",
    repeated_float = "repeated_float",
    repeated_double = "repeated_double",
    repeated_bool = "repeated_bool",
    repeated_string = "repeated_string",
    repeated_bytes = "repeated_bytes",
    repeated_nested_message = "repeated_nested_message",
    repeated_foreign_message = "repeated_foreign_message",
    repeated_nested_enum = "repeated_nested_enum",
    repeated_foreign_enum = "repeated_foreign_enum",
    repeated_string_piece = "repeated_string_piece",
    repeated_cord = "repeated_cord",
    packed_int32 = "packed_int32",
    packed_int64 = "packed_int64",
    packed_uint32 = "packed_uint32",
    packed_uint64 = "packed_uint64",
    packed_sint32 = "packed_sint32",
    packed_sint64 = "packed_sint64",
    packed_fixed32 = "packed_fixed32",
    packed_fixed64 = "packed_fixed64",
    packed_sfixed32 = "packed_sfixed32",
    packed_sfixed64 = "packed_sfixed64",
    packed_float = "packed_float",
    packed_double = "packed_double",
    packed_bool = "packed_bool",
    packed_nested_enum = "packed_nested_enum",
    unpacked_int32 = "unpacked_int32",
    unpacked_int64 = "unpacked_int64",
    unpacked_uint32 = "unpacked_uint32",
    unpacked_uint64 = "unpacked_uint64",
    unpacked_sint32 = "unpacked_sint32",
    unpacked_sint64 = "unpacked_sint64",
    unpacked_fixed32 = "unpacked_fixed32",
    unpacked_fixed64 = "unpacked_fixed64",
    unpacked_sfixed32 = "unpacked_sfixed32",
    unpacked_sfixed64 = "unpacked_sfixed64",
    unpacked_float = "unpacked_float",
    unpacked_double = "unpacked_double",
    unpacked_bool = "unpacked_bool",
    unpacked_nested_enum = "unpacked_nested_enum",
    map_int32_int32 = "map_int32_int32",
    map_int64_int64 = "map_int64_int64",
    map_uint32_uint32 = "map_uint32_uint32",
    map_uint64_uint64 = "map_uint64_uint64",
    map_sint32_sint32 = "map_sint32_sint32",
    map_sint64_sint64 = "map_sint64_sint64",
    map_fixed32_fixed32 = "map_fixed32_fixed32",
    map_fixed64_fixed64 = "map_fixed64_fixed64",
    map_sfixed32_sfixed32 = "map_sfixed32_sfixed32",
    map_sfixed64_sfixed64 = "map_sfixed64_sfixed64",
    map_int32_float = "map_int32_float",
    map_int32_double = "map_int32_double",
    map_bool_bool = "map_bool_bool",
    map_string_string = "map_string_string",
    map_string_bytes = "map_string_bytes",
    map_string_nested_message = "map_string_nested_message",
    map_string_foreign_message = "map_string_foreign_message",
    map_string_nested_enum = "map_string_nested_enum",
    map_string_foreign_enum = "map_string_foreign_enum",
    oneof_uint32 = "oneof_uint32",
    oneof_nested_message = "oneof_nested_message",
    oneof_string = "oneof_string",
    oneof_bytes = "oneof_bytes",
    oneof_bool = "oneof_bool",
    oneof_uint64 = "oneof_uint64",
    oneof_float = "oneof_float",
    oneof_double = "oneof_double",
    oneof_enum = "oneof_enum",
    oneof_null_value = "oneof_null_value",
    optional_bool_wrapper = "optional_bool_wrapper",
    optional_int32_wrapper = "optional_int32_wrapper",
    optional_int64_wrapper = "optional_int64_wrapper",
    optional_uint32_wrapper = "optional_uint32_wrapper",
    optional_uint64_wrapper = "optional_uint64_wrapper",
    optional_float_wrapper = "optional_float_wrapper",
    optional_double_wrapper = "optional_double_wrapper",
    optional_string_wrapper = "optional_string_wrapper",
    optional_bytes_wrapper = "optional_bytes_wrapper",
    repeated_bool_wrapper = "repeated_bool_wrapper",
    repeated_int32_wrapper = "repeated_int32_wrapper",
    repeated_int64_wrapper = "repeated_int64_wrapper",
    repeated_uint32_wrapper = "repeated_uint32_wrapper",
    repeated_uint64_wrapper = "repeated_uint64_wrapper",
    repeated_float_wrapper = "repeated_float_wrapper",
    repeated_double_wrapper = "repeated_double_wrapper",
    repeated_string_wrapper = "repeated_string_wrapper",
    repeated_bytes_wrapper = "repeated_bytes_wrapper",
    optional_duration = "optional_duration",
    optional_timestamp = "optional_timestamp",
    optional_field_mask = "optional_field_mask",
    optional_struct = "optional_struct",
    optional_any = "optional_any",
    optional_value = "optional_value",
    optional_null_value = "optional_null_value",
    optional_empty = "optional_empty",
    repeated_duration = "repeated_duration",
    repeated_timestamp = "repeated_timestamp",
    repeated_fieldmask = "repeated_fieldmask",
    repeated_struct = "repeated_struct",
    repeated_any = "repeated_any",
    repeated_value = "repeated_value",
    repeated_list_value = "repeated_list_value",
    repeated_empty = "repeated_empty",
    fieldname1 = "fieldname1",
    field_name2 = "field_name2",
    _field_name3 = "_field_name3",
    field__name4_ = "field__name4_",
    field0name5 = "field0name5",
    field_0_name6 = "field_0_name6",
    fieldName7 = "fieldName7",
    FieldName8 = "FieldName8",
    field_Name9 = "field_Name9",
    Field_Name10 = "Field_Name10",
    FIELD_NAME11 = "FIELD_NAME11",
    FIELD_name12 = "FIELD_name12",
    __field_name13 = "__field_name13",
    __Field_name14 = "__Field_name14",
    field__name15 = "field__name15",
    field__Name16 = "field__Name16",
    field_name17__ = "field_name17__",
    Field_name18__ = "Field_name18__",
})
M.TestAllTypesProto3_oneofs = pb.field_names({
    oneof_field = "oneof_field",
})

-- Message: protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage
M.TestAllTypesProto3_NestedMessage_descriptor.fields = {


@@ 220,12 378,19 @@ M.TestAllTypesProto3_NestedMessage_descriptor.fields = {
    {name="corecursive", id=2, kind='message', message=M.TestAllTypesProto3_descriptor},
}
pb.finalize_message(M.TestAllTypesProto3_NestedMessage_descriptor)
M.TestAllTypesProto3_NestedMessage_fields = pb.field_names({
    a = "a",
    corecursive = "corecursive",
})

-- Message: protobuf_test_messages.proto3.ForeignMessage
M.ForeignMessage_descriptor.fields = {
    {name="c", id=1, kind='scalar', proto_type="int32"},
}
pb.finalize_message(M.ForeignMessage_descriptor)
M.ForeignMessage_fields = pb.field_names({
    c = "c",
})

-- Message: protobuf_test_messages.proto3.NullHypothesisProto3
M.NullHypothesisProto3_descriptor.fields = {

M examples/expected/runtime/conformance/conformance_pb.lua => examples/expected/runtime/conformance/conformance_pb.lua +39 -0
@@ 52,12 52,20 @@ M.TestStatus_descriptor.fields = {
    {name="matched_name", id=3, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.TestStatus_descriptor)
M.TestStatus_fields = pb.field_names({
    name = "name",
    failure_message = "failure_message",
    matched_name = "matched_name",
})

-- Message: conformance.FailureSet
M.FailureSet_descriptor.fields = {
    {name="test", id=2, kind='message', message=M.TestStatus_descriptor, repeated=true},
}
pb.finalize_message(M.FailureSet_descriptor)
M.FailureSet_fields = pb.field_names({
    test = "test",
})

-- Message: conformance.ConformanceRequest
M.ConformanceRequest_descriptor.fields = {


@@ 75,6 83,20 @@ M.ConformanceRequest_descriptor.oneofs = {
    payload = {"protobuf_payload", "json_payload", "jspb_payload", "text_payload"},
}
pb.finalize_message(M.ConformanceRequest_descriptor)
M.ConformanceRequest_fields = pb.field_names({
    protobuf_payload = "protobuf_payload",
    json_payload = "json_payload",
    jspb_payload = "jspb_payload",
    text_payload = "text_payload",
    requested_output_format = "requested_output_format",
    message_type = "message_type",
    test_category = "test_category",
    jspb_encoding_options = "jspb_encoding_options",
    print_unknown_fields = "print_unknown_fields",
})
M.ConformanceRequest_oneofs = pb.field_names({
    payload = "payload",
})

-- Message: conformance.ConformanceResponse
M.ConformanceResponse_descriptor.fields = {


@@ 92,12 114,29 @@ M.ConformanceResponse_descriptor.oneofs = {
    result = {"parse_error", "serialize_error", "timeout_error", "runtime_error", "protobuf_payload", "json_payload", "skipped", "jspb_payload", "text_payload"},
}
pb.finalize_message(M.ConformanceResponse_descriptor)
M.ConformanceResponse_fields = pb.field_names({
    parse_error = "parse_error",
    serialize_error = "serialize_error",
    timeout_error = "timeout_error",
    runtime_error = "runtime_error",
    protobuf_payload = "protobuf_payload",
    json_payload = "json_payload",
    skipped = "skipped",
    jspb_payload = "jspb_payload",
    text_payload = "text_payload",
})
M.ConformanceResponse_oneofs = pb.field_names({
    result = "result",
})

-- Message: conformance.JspbEncodingConfig
M.JspbEncodingConfig_descriptor.fields = {
    {name="use_jspb_array_any_format", id=1, kind='scalar', proto_type="bool"},
}
pb.finalize_message(M.JspbEncodingConfig_descriptor)
M.JspbEncodingConfig_fields = pb.field_names({
    use_jspb_array_any_format = "use_jspb_array_any_format",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors

M examples/expected/runtime/hello/hello_pb.lua => examples/expected/runtime/hello/hello_pb.lua +51 -0
@@ 35,18 35,33 @@ M.Result_descriptor.oneofs = {
    outcome = {"text", "code", "details"},
}
pb.finalize_message(M.Result_descriptor)
M.Result_fields = pb.field_names({
    id = "id",
    text = "text",
    code = "code",
    details = "details",
})
M.Result_oneofs = pb.field_names({
    outcome = "outcome",
})

-- Message: hello.HelloRequest
M.HelloRequest_descriptor.fields = {
    {name="name", id=1, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.HelloRequest_descriptor)
M.HelloRequest_fields = pb.field_names({
    name = "name",
})

-- Message: hello.HelloReply
M.HelloReply_descriptor.fields = {
    {name="greeting", id=1, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.HelloReply_descriptor)
M.HelloReply_fields = pb.field_names({
    greeting = "greeting",
})

-- Message: hello.Event
M.Event_descriptor.fields = {


@@ 64,6 79,20 @@ M.Event_descriptor.fields = {
    {name="update_mask", id=12, kind='message', message=pb.wkt.FieldMask_descriptor},
}
pb.finalize_message(M.Event_descriptor)
M.Event_fields = pb.field_names({
    title = "title",
    created_at = "created_at",
    duration = "duration",
    ack = "ack",
    retry_count = "retry_count",
    note = "note",
    is_admin = "is_admin",
    payload = "payload",
    attribute = "attribute",
    tags = "tags",
    extension = "extension",
    update_mask = "update_mask",
})

-- Message: hello.Address
M.Address_descriptor.fields = {


@@ 73,6 102,12 @@ M.Address_descriptor.fields = {
    {name="apartment", id=4, kind='scalar', proto_type="string", optional=true},
}
pb.finalize_message(M.Address_descriptor)
M.Address_fields = pb.field_names({
    street = "street",
    city = "city",
    zip = "zip",
    apartment = "apartment",
})

-- Message: hello.Person
M.Person_descriptor.fields = {


@@ 92,6 127,22 @@ M.Person_descriptor.fields = {
    {name="addresses_by_label", id=15, kind='map', key={kind='scalar', proto_type="string"}, value={kind='message', message=M.Address_descriptor}},
}
pb.finalize_message(M.Person_descriptor)
M.Person_fields = pb.field_names({
    name = "name",
    age = "age",
    emails = "emails",
    status = "status",
    address = "address",
    friends = "friends",
    lucky_numbers = "lucky_numbers",
    avatar = "avatar",
    user_id = "user_id",
    balance = "balance",
    weight_kg = "weight_kg",
    ages_by_nickname = "ages_by_nickname",
    nickname_by_age = "nickname_by_age",
    addresses_by_label = "addresses_by_label",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors

M examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua => examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +165 -0
@@ 213,6 213,164 @@ M.TestAllTypesProto3_descriptor.reserved_names = {
    ["reserved_field"] = true,
}
pb.finalize_message(M.TestAllTypesProto3_descriptor)
M.TestAllTypesProto3_fields = pb.field_names({
    optional_int32 = "optional_int32",
    optional_int64 = "optional_int64",
    optional_uint32 = "optional_uint32",
    optional_uint64 = "optional_uint64",
    optional_sint32 = "optional_sint32",
    optional_sint64 = "optional_sint64",
    optional_fixed32 = "optional_fixed32",
    optional_fixed64 = "optional_fixed64",
    optional_sfixed32 = "optional_sfixed32",
    optional_sfixed64 = "optional_sfixed64",
    optional_float = "optional_float",
    optional_double = "optional_double",
    optional_bool = "optional_bool",
    optional_string = "optional_string",
    optional_bytes = "optional_bytes",
    optional_nested_message = "optional_nested_message",
    optional_foreign_message = "optional_foreign_message",
    optional_nested_enum = "optional_nested_enum",
    optional_foreign_enum = "optional_foreign_enum",
    optional_aliased_enum = "optional_aliased_enum",
    optional_string_piece = "optional_string_piece",
    optional_cord = "optional_cord",
    recursive_message = "recursive_message",
    repeated_int32 = "repeated_int32",
    repeated_int64 = "repeated_int64",
    repeated_uint32 = "repeated_uint32",
    repeated_uint64 = "repeated_uint64",
    repeated_sint32 = "repeated_sint32",
    repeated_sint64 = "repeated_sint64",
    repeated_fixed32 = "repeated_fixed32",
    repeated_fixed64 = "repeated_fixed64",
    repeated_sfixed32 = "repeated_sfixed32",
    repeated_sfixed64 = "repeated_sfixed64",
    repeated_float = "repeated_float",
    repeated_double = "repeated_double",
    repeated_bool = "repeated_bool",
    repeated_string = "repeated_string",
    repeated_bytes = "repeated_bytes",
    repeated_nested_message = "repeated_nested_message",
    repeated_foreign_message = "repeated_foreign_message",
    repeated_nested_enum = "repeated_nested_enum",
    repeated_foreign_enum = "repeated_foreign_enum",
    repeated_string_piece = "repeated_string_piece",
    repeated_cord = "repeated_cord",
    packed_int32 = "packed_int32",
    packed_int64 = "packed_int64",
    packed_uint32 = "packed_uint32",
    packed_uint64 = "packed_uint64",
    packed_sint32 = "packed_sint32",
    packed_sint64 = "packed_sint64",
    packed_fixed32 = "packed_fixed32",
    packed_fixed64 = "packed_fixed64",
    packed_sfixed32 = "packed_sfixed32",
    packed_sfixed64 = "packed_sfixed64",
    packed_float = "packed_float",
    packed_double = "packed_double",
    packed_bool = "packed_bool",
    packed_nested_enum = "packed_nested_enum",
    unpacked_int32 = "unpacked_int32",
    unpacked_int64 = "unpacked_int64",
    unpacked_uint32 = "unpacked_uint32",
    unpacked_uint64 = "unpacked_uint64",
    unpacked_sint32 = "unpacked_sint32",
    unpacked_sint64 = "unpacked_sint64",
    unpacked_fixed32 = "unpacked_fixed32",
    unpacked_fixed64 = "unpacked_fixed64",
    unpacked_sfixed32 = "unpacked_sfixed32",
    unpacked_sfixed64 = "unpacked_sfixed64",
    unpacked_float = "unpacked_float",
    unpacked_double = "unpacked_double",
    unpacked_bool = "unpacked_bool",
    unpacked_nested_enum = "unpacked_nested_enum",
    map_int32_int32 = "map_int32_int32",
    map_int64_int64 = "map_int64_int64",
    map_uint32_uint32 = "map_uint32_uint32",
    map_uint64_uint64 = "map_uint64_uint64",
    map_sint32_sint32 = "map_sint32_sint32",
    map_sint64_sint64 = "map_sint64_sint64",
    map_fixed32_fixed32 = "map_fixed32_fixed32",
    map_fixed64_fixed64 = "map_fixed64_fixed64",
    map_sfixed32_sfixed32 = "map_sfixed32_sfixed32",
    map_sfixed64_sfixed64 = "map_sfixed64_sfixed64",
    map_int32_float = "map_int32_float",
    map_int32_double = "map_int32_double",
    map_bool_bool = "map_bool_bool",
    map_string_string = "map_string_string",
    map_string_bytes = "map_string_bytes",
    map_string_nested_message = "map_string_nested_message",
    map_string_foreign_message = "map_string_foreign_message",
    map_string_nested_enum = "map_string_nested_enum",
    map_string_foreign_enum = "map_string_foreign_enum",
    oneof_uint32 = "oneof_uint32",
    oneof_nested_message = "oneof_nested_message",
    oneof_string = "oneof_string",
    oneof_bytes = "oneof_bytes",
    oneof_bool = "oneof_bool",
    oneof_uint64 = "oneof_uint64",
    oneof_float = "oneof_float",
    oneof_double = "oneof_double",
    oneof_enum = "oneof_enum",
    oneof_null_value = "oneof_null_value",
    optional_bool_wrapper = "optional_bool_wrapper",
    optional_int32_wrapper = "optional_int32_wrapper",
    optional_int64_wrapper = "optional_int64_wrapper",
    optional_uint32_wrapper = "optional_uint32_wrapper",
    optional_uint64_wrapper = "optional_uint64_wrapper",
    optional_float_wrapper = "optional_float_wrapper",
    optional_double_wrapper = "optional_double_wrapper",
    optional_string_wrapper = "optional_string_wrapper",
    optional_bytes_wrapper = "optional_bytes_wrapper",
    repeated_bool_wrapper = "repeated_bool_wrapper",
    repeated_int32_wrapper = "repeated_int32_wrapper",
    repeated_int64_wrapper = "repeated_int64_wrapper",
    repeated_uint32_wrapper = "repeated_uint32_wrapper",
    repeated_uint64_wrapper = "repeated_uint64_wrapper",
    repeated_float_wrapper = "repeated_float_wrapper",
    repeated_double_wrapper = "repeated_double_wrapper",
    repeated_string_wrapper = "repeated_string_wrapper",
    repeated_bytes_wrapper = "repeated_bytes_wrapper",
    optional_duration = "optional_duration",
    optional_timestamp = "optional_timestamp",
    optional_field_mask = "optional_field_mask",
    optional_struct = "optional_struct",
    optional_any = "optional_any",
    optional_value = "optional_value",
    optional_null_value = "optional_null_value",
    optional_empty = "optional_empty",
    repeated_duration = "repeated_duration",
    repeated_timestamp = "repeated_timestamp",
    repeated_fieldmask = "repeated_fieldmask",
    repeated_struct = "repeated_struct",
    repeated_any = "repeated_any",
    repeated_value = "repeated_value",
    repeated_list_value = "repeated_list_value",
    repeated_empty = "repeated_empty",
    fieldname1 = "fieldname1",
    field_name2 = "field_name2",
    _field_name3 = "_field_name3",
    field__name4_ = "field__name4_",
    field0name5 = "field0name5",
    field_0_name6 = "field_0_name6",
    fieldName7 = "fieldName7",
    FieldName8 = "FieldName8",
    field_Name9 = "field_Name9",
    Field_Name10 = "Field_Name10",
    FIELD_NAME11 = "FIELD_NAME11",
    FIELD_name12 = "FIELD_name12",
    __field_name13 = "__field_name13",
    __Field_name14 = "__Field_name14",
    field__name15 = "field__name15",
    field__Name16 = "field__Name16",
    field_name17__ = "field_name17__",
    Field_name18__ = "Field_name18__",
})
M.TestAllTypesProto3_oneofs = pb.field_names({
    oneof_field = "oneof_field",
})

-- Message: protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage
M.TestAllTypesProto3_NestedMessage_descriptor.fields = {


@@ 220,12 378,19 @@ M.TestAllTypesProto3_NestedMessage_descriptor.fields = {
    {name="corecursive", id=2, kind='message', message=M.TestAllTypesProto3_descriptor},
}
pb.finalize_message(M.TestAllTypesProto3_NestedMessage_descriptor)
M.TestAllTypesProto3_NestedMessage_fields = pb.field_names({
    a = "a",
    corecursive = "corecursive",
})

-- Message: protobuf_test_messages.proto3.ForeignMessage
M.ForeignMessage_descriptor.fields = {
    {name="c", id=1, kind='scalar', proto_type="int32"},
}
pb.finalize_message(M.ForeignMessage_descriptor)
M.ForeignMessage_fields = pb.field_names({
    c = "c",
})

-- Message: protobuf_test_messages.proto3.NullHypothesisProto3
M.NullHypothesisProto3_descriptor.fields = {

M runtime/pb/init.lua => runtime/pb/init.lua +17 -0
@@ 96,6 96,23 @@ return {
    to_uint64 = wire.to_uint64,
    to_int64  = wire.to_int64,

    -- Strict field-name constants table. Generated code wraps each
    -- message's `M.<Type>_fields = pb.field_names({...})` so callers
    -- pass typo-checked names to the lazy view: `view:get(F.user_id)`
    -- errors at the read site if the field name is wrong, instead of
    -- the silent `nil` that `view:get('user_di')` returns.
    field_names = function(tbl)
        return setmetatable(tbl, {
            __index = function(_, k)
                error(("unknown field name: %q"):format(tostring(k)), 2)
            end,
            __newindex = function(_, k)
                error(("field_names table is read-only: %q"):format(tostring(k)), 2)
            end,
            __metatable = false,
        })
    end,

    -- Helper for building enum descriptors at codegen time.
    enum = function(name, values)
        local by_name, by_value = {}, {}

M test/lazy_test.lua => test/lazy_test.lua +88 -54
@@ 2,6 2,12 @@
-- Parameterized over both codegen modes — the generated _decode_lazy
-- shim is identical in both, but the descriptor shape it consumes
-- comes from each mode's emitted module.
--
-- Field-name arguments to :get / :has / :set / :clear / :which are
-- routed through the codegen-emitted M.<Type>_fields and
-- M.<Type>_oneofs constants tables. See docs/api-modes.md for the
-- documented contract. Map keys and repeated indices stay as raw
-- values — those aren't field names.

local t = require('luatest')
local ffi = require('ffi')


@@ 13,30 19,38 @@ for _, mode in ipairs(MODES) do
    local g = t.group('lazy.' .. mode)
    local hello = require(mode .. '.hello.hello_pb')

    -- Local aliases keep the assertions short without losing the
    -- typo-strict lookup.
    local AF = hello.Address_fields
    local PF = hello.Person_fields
    local RF = hello.Result_fields
    local EF = hello.Event_fields
    local RO = hello.Result_oneofs

    -- ---- Basic scalar access ----

    g.test_singular_scalars_decode_on_demand = function()
        local enc = hello.Address_encode({street = 'Main St', city = 'SF', zip = 42})
        local v = hello.Address_decode_lazy(enc)
        t.assert_equals(v:get('street'), 'Main St')
        t.assert_equals(v:get('city'), 'SF')
        t.assert_equals(v:get('zip'), 42)
        t.assert_equals(v:get('apartment'), nil, 'absent optional')
        t.assert_equals(v:get(AF.street), 'Main St')
        t.assert_equals(v:get(AF.city), 'SF')
        t.assert_equals(v:get(AF.zip), 42)
        t.assert_equals(v:get(AF.apartment), nil, 'absent optional')
    end

    g.test_has_reports_wire_presence = function()
        local enc = hello.Address_encode({street = 'X'})
        local v = hello.Address_decode_lazy(enc)
        t.assert_equals(v:has('street'), true)
        t.assert_equals(v:has('city'), false)
        t.assert_equals(v:has('zip'), false)
        t.assert_equals(v:has(AF.street), true)
        t.assert_equals(v:has(AF.city), false)
        t.assert_equals(v:has(AF.zip), false)
    end

    g.test_get_caches_repeated_calls = function()
        local enc = hello.Address_encode({street = 'Main'})
        local v = hello.Address_decode_lazy(enc)
        local a = v:get('street')
        local b = v:get('street')
        local a = v:get(AF.street)
        local b = v:get(AF.street)
        t.assert_is(a, b, 'string values are interned but cache should hit')
    end



@@ 45,7 59,7 @@ for _, mode in ipairs(MODES) do
    g.test_unpacked_repeated_string = function()
        local enc = hello.Person_encode({emails = {'a@x', 'b@x', 'c@x'}})
        local v = hello.Person_decode_lazy(enc)
        local arr = v:get('emails')
        local arr = v:get(PF.emails)
        t.assert_not_equals(arr, nil)
        t.assert_equals(arr:len(), 3)
        t.assert_equals(arr:at(1), 'a@x')


@@ 59,7 73,7 @@ for _, mode in ipairs(MODES) do
    g.test_packed_repeated_int32 = function()
        local enc = hello.Person_encode({lucky_numbers = {7, 13, 42}})
        local v = hello.Person_decode_lazy(enc)
        local arr = v:get('lucky_numbers')
        local arr = v:get(PF.lucky_numbers)
        t.assert_equals(arr:len(), 3)
        t.assert_equals(arr:at(1), 7)
        t.assert_equals(arr:at(2), 13)


@@ 74,18 88,18 @@ for _, mode in ipairs(MODES) do
            },
        })
        local v = hello.Person_decode_lazy(enc)
        local fr = v:get('friends')
        local fr = v:get(PF.friends)
        t.assert_equals(fr:len(), 2)
        t.assert_equals(fr:at(1):get('name'), 'Bob')
        t.assert_equals(fr:at(1):get('age'), 20)
        t.assert_equals(fr:at(2):get('name'), 'Carol')
        t.assert_equals(fr:at(1):get(PF.name), 'Bob')
        t.assert_equals(fr:at(1):get(PF.age), 20)
        t.assert_equals(fr:at(2):get(PF.name), 'Carol')
    end

    g.test_absent_repeated_is_nil = function()
        local enc = hello.Person_encode({name = 'X'})
        local v = hello.Person_decode_lazy(enc)
        t.assert_equals(v:get('emails'), nil)
        t.assert_equals(v:has('emails'), false)
        t.assert_equals(v:get(PF.emails), nil)
        t.assert_equals(v:has(PF.emails), false)
    end

    -- ---- Nested singular message ----


@@ 96,9 110,9 @@ for _, mode in ipairs(MODES) do
            address = {street = 'Main', city = 'Springfield'},
        })
        local v = hello.Person_decode_lazy(enc)
        local addr = v:get('address')
        t.assert_equals(addr:get('street'), 'Main')
        t.assert_equals(addr:get('city'), 'Springfield')
        local addr = v:get(PF.address)
        t.assert_equals(addr:get(AF.street), 'Main')
        t.assert_equals(addr:get(AF.city), 'Springfield')
    end

    -- ---- Map fields ----


@@ 106,7 120,7 @@ for _, mode in ipairs(MODES) do
    g.test_map_get_and_has = function()
        local enc = hello.Person_encode({ages_by_nickname = {alice = 30, bob = 25}})
        local v = hello.Person_decode_lazy(enc)
        local m = v:get('ages_by_nickname')
        local m = v:get(PF.ages_by_nickname)
        t.assert_equals(m:get('alice'), 30)
        t.assert_equals(m:get('bob'), 25)
        t.assert_equals(m:has('alice'), true)


@@ 117,7 131,7 @@ for _, mode in ipairs(MODES) do
    g.test_map_keys_and_iter = function()
        local enc = hello.Person_encode({ages_by_nickname = {alice = 30}})
        local v = hello.Person_decode_lazy(enc)
        local m = v:get('ages_by_nickname')
        local m = v:get(PF.ages_by_nickname)
        t.assert_equals(m:keys(), {'alice'})
        local seen = {}
        for k, val in m:iter() do seen[k] = val end


@@ 129,10 143,10 @@ for _, mode in ipairs(MODES) do
            addresses_by_label = {home = {street = 'Main', city = 'SF'}},
        })
        local v = hello.Person_decode_lazy(enc)
        local m = v:get('addresses_by_label')
        local m = v:get(PF.addresses_by_label)
        local home = m:get('home')
        t.assert_equals(home:get('street'), 'Main')
        t.assert_equals(home:get('city'), 'SF')
        t.assert_equals(home:get(AF.street), 'Main')
        t.assert_equals(home:get(AF.city), 'SF')
    end

    -- ---- Oneof ----


@@ 140,25 154,25 @@ for _, mode in ipairs(MODES) do
    g.test_oneof_which_text_branch = function()
        local enc = hello.Result_encode({id = 1, text = 'ok'})
        local v = hello.Result_decode_lazy(enc)
        t.assert_equals(v:which('outcome'), 'text')
        t.assert_equals(v:get('text'), 'ok')
        t.assert_equals(v:which(RO.outcome), 'text')
        t.assert_equals(v:get(RF.text), 'ok')
        -- Inactive branches: not on the wire, so :has is false.
        t.assert_equals(v:has('code'), false)
        t.assert_equals(v:has('details'), false)
        t.assert_equals(v:has(RF.code), false)
        t.assert_equals(v:has(RF.details), false)
    end

    g.test_oneof_which_message_branch = function()
        local enc = hello.Result_encode({details = {street = 'Main'}})
        local v = hello.Result_decode_lazy(enc)
        t.assert_equals(v:which('outcome'), 'details')
        local d = v:get('details')
        t.assert_equals(d:get('street'), 'Main')
        t.assert_equals(v:which(RO.outcome), 'details')
        local d = v:get(RF.details)
        t.assert_equals(d:get(AF.street), 'Main')
    end

    g.test_oneof_no_branch_set = function()
        local enc = hello.Result_encode({id = 1})
        local v = hello.Result_decode_lazy(enc)
        t.assert_equals(v:which('outcome'), nil)
        t.assert_equals(v:which(RO.outcome), nil)
    end

    -- ---- iter / names ----


@@ 187,7 201,7 @@ for _, mode in ipairs(MODES) do
        local names = {}
        for name in v:names() do names[#names + 1] = name end
        t.assert_equals(names, {'street'})
        t.assert_equals(v:get('street'), 'X')
        t.assert_equals(v:get(AF.street), 'X')
    end

    -- ---- WKT eager-wrap ----


@@ 197,8 211,8 @@ for _, mode in ipairs(MODES) do
        local dt = datetime.new({timestamp = 1700000000, nsec = 0})
        local enc = hello.Event_encode({title = 'launch', created_at = dt})
        local v = hello.Event_decode_lazy(enc)
        t.assert_equals(v:get('title'), 'launch')
        local ts = v:get('created_at')
        t.assert_equals(v:get(EF.title), 'launch')
        local ts = v:get(EF.created_at)
        -- WKT descriptors carry desc.decode; lazy delegates to it,
        -- producing whatever the eager codec produces — for Timestamp,
        -- a datetime cdata equal to the original.


@@ 211,7 225,7 @@ for _, mode in ipairs(MODES) do
        local big = ffi.cast('uint64_t', 0xdeadbeefcafebabeULL)
        local enc = hello.Person_encode({user_id = big})
        local v = hello.Person_decode_lazy(enc)
        local got = v:get('user_id')
        local got = v:get(PF.user_id)
        t.assert_equals(ffi.cast('uint64_t', got), big)
    end



@@ 230,7 244,7 @@ for _, mode in ipairs(MODES) do
    g.test_set_singular_scalar_round_trips_via_eager = function()
        local orig = hello.Address_encode({street = 'A', city = 'B', zip = 1})
        local v = hello.Address_decode_lazy(orig)
        v:set('city', 'C')
        v:set(AF.city, 'C')
        local out = v:encode()
        local eager = hello.Address_decode(out)
        t.assert_equals(eager.street, 'A')


@@ 241,7 255,7 @@ for _, mode in ipairs(MODES) do
    g.test_set_repeated_replaces_entire_field = function()
        local orig = hello.Person_encode({emails = {'a@x', 'b@x'}})
        local v = hello.Person_decode_lazy(orig)
        v:set('emails', {'new@x'})
        v:set(PF.emails, {'new@x'})
        local eager = hello.Person_decode(v:encode())
        t.assert_equals(eager.emails, {'new@x'})
    end


@@ 253,7 267,7 @@ for _, mode in ipairs(MODES) do
            address = {street = 'Old', city = 'X'},
        })
        local v = hello.Person_decode_lazy(orig)
        v:set('address', {street = 'New', city = 'Y'})
        v:set(PF.address, {street = 'New', city = 'Y'})
        local eager = hello.Person_decode(v:encode())
        t.assert_equals(eager.name, 'Alice')
        t.assert_equals(eager.age, 30)


@@ 266,7 280,7 @@ for _, mode in ipairs(MODES) do
        local known = hello.Address_encode({street = 'X'})
        local extra = string.char(0x68, 0x05)  -- id=13, varint, value=5
        local v = hello.Address_decode_lazy(known .. extra)
        v:set('zip', 99)
        v:set(AF.zip, 99)
        local out = v:encode()
        -- Unknown field bytes should still be present in the output.
        t.assert(out:find(extra, 1, true) ~= nil, 'unknown bytes preserved')


@@ 278,8 292,8 @@ for _, mode in ipairs(MODES) do
    g.test_oneof_set_clears_other_branches = function()
        local orig = hello.Result_encode({id = 1, text = 'hello'})
        local v = hello.Result_decode_lazy(orig)
        v:set('code', 42)
        v:set('text', nil)  -- explicit clear
        v:set(RF.code, 42)
        v:set(RF.text, nil)  -- explicit clear
        local eager = hello.Result_decode(v:encode())
        t.assert_equals(eager.id, 1)
        t.assert_equals(eager.code, 42)


@@ 292,8 306,8 @@ for _, mode in ipairs(MODES) do
            address = {street = 'Old', city = 'X', zip = 1},
        })
        local v = hello.Person_decode_lazy(orig)
        local addr = v:get('address')
        addr:set('street', 'New')
        local addr = v:get(PF.address)
        addr:set(AF.street, 'New')
        local eager = hello.Person_decode(v:encode())
        t.assert_equals(eager.name, 'Alice')
        t.assert_equals(eager.address.street, 'New')


@@ 315,14 329,34 @@ for _, mode in ipairs(MODES) do
        local enc = hello.Person_encode(p)
        local eager = hello.Person_decode(enc)
        local v = hello.Person_decode_lazy(enc)
        t.assert_equals(v:get('name'), eager.name)
        t.assert_equals(v:get('age'), eager.age)
        t.assert_equals(v:get('status'), eager.status)
        t.assert_equals(v:get('emails'):tolist(), eager.emails)
        t.assert_equals(v:get('lucky_numbers'):tolist(), eager.lucky_numbers)
        t.assert_equals(v:get('ages_by_nickname'):totable(), eager.ages_by_nickname)
        local addr = v:get('address')
        t.assert_equals(addr:get('street'), eager.address.street)
        t.assert_equals(addr:get('zip'), eager.address.zip)
        t.assert_equals(v:get(PF.name), eager.name)
        t.assert_equals(v:get(PF.age), eager.age)
        t.assert_equals(v:get(PF.status), eager.status)
        t.assert_equals(v:get(PF.emails):tolist(), eager.emails)
        t.assert_equals(v:get(PF.lucky_numbers):tolist(), eager.lucky_numbers)
        t.assert_equals(v:get(PF.ages_by_nickname):totable(), eager.ages_by_nickname)
        local addr = v:get(PF.address)
        t.assert_equals(addr:get(AF.street), eager.address.street)
        t.assert_equals(addr:get(AF.zip), eager.address.zip)
    end

    -- ---- Field-name constants table contract ----

    g.test_field_names_table_errors_on_typo = function()
        t.assert_error_msg_contains(
            'unknown field name: "steet"',
            function() return hello.Address_fields.steet end)
    end

    g.test_field_names_table_is_read_only = function()
        t.assert_error_msg_contains(
            'field_names table is read-only',
            function() hello.Address_fields.new_key = 'x' end)
    end

    g.test_oneof_names_table_errors_on_typo = function()
        t.assert_error_msg_contains(
            'unknown field name: "outcom"',
            function() return hello.Result_oneofs.outcom end)
    end
end