~bigbes/tarantool

tarantool-protobuf

4fbdb65d00feb310ffdbc56f4acd9204746cb9d5 — Eugene Blikh 3 months ago 7676fdc
codegen: proto2 baseline — required, optional, custom defaults

Lifts the proto3-only syntax gate in the plugin and threads three
new field-descriptor attributes through codegen and the codec:

  * required=true   — fields declared with the proto2 `required` keyword.
                      Inline codegen and the runtime codec both error when
                      a required field is missing on encode (vs the silent
                      elide that proto3 implicit-presence fields get).
  * optional=true   — already wired for proto3 explicit `optional`; in
                      proto2 every singular field carries it via the
                      existing HasOptionalKeyword() check, giving presence
                      semantics without a separate emission path.
  * default_value=… — proto2 [default = X] from the field descriptor,
                      rendered as a Lua literal (cdata for 64-bit ints,
                      symbolic name for enums) so consumers can surface
                      it; the codec itself does not auto-materialize
                      defaults on decode, matching how proto3 absent
                      fields stay nil.

Packed-by-default already flips correctly because we ask
protoreflect's `IsPacked()`, which is syntax-aware.

Adds test/proto/proto2_basic.proto with 33 luatest cases covering
required validation, optional presence, custom defaults, the proto2
unpacked-by-default repeated rule, nested-required messages, and
full-vs-runtime mode parity. `just gen-proto2-tests` regenerates the
fixture into examples/expected/{full,runtime}/.

Out of scope: extensions, extend, group; conformance harness still
skips TestAllTypesProto2.
M Justfile => Justfile +20 -1
@@ 26,6 26,7 @@ gen_dir           := "examples/expected"
docs_dir          := "examples/docs"
proto_dir         := "examples/proto"
conformance_proto := "test/conformance/proto"
proto2_test_dir   := "test/proto"
luatest           := ".rocks/bin/luatest"
image             := "tarantool-protobuf-conformance:latest"



@@ 61,7 62,7 @@ build-doc:
# ---------------------------------------------------------------------------

# Regenerate examples/expected/{full,runtime}/* + conformance protos.
gen: gen-full gen-runtime gen-conformance
gen: gen-full gen-runtime gen-conformance gen-proto2-tests

# Generate full-mode Lua (inline encode/decode bodies).
gen-full: build


@@ 99,6 100,24 @@ gen-conformance: build
        -I {{conformance_proto}} -I options \
        {{conformance_proto}}/*.proto

# Generate the proto2 test fixtures (test/proto/*.proto) in both modes.
# Lives outside examples/ because proto2 is exercised through the luatest
# suite, not the per-example runners.
gen-proto2-tests: build
    mkdir -p {{gen_dir}}
    protoc \
        --plugin=./{{plugin}} \
        --tarantool_out={{gen_dir}} \
        --tarantool_opt=mode=full,prefix=full \
        -I {{proto2_test_dir}} -I options \
        {{proto2_test_dir}}/*.proto
    protoc \
        --plugin=./{{plugin}} \
        --tarantool_out={{gen_dir}} \
        --tarantool_opt=mode=runtime,prefix=runtime \
        -I {{proto2_test_dir}} -I options \
        {{proto2_test_dir}}/*.proto

# Regenerate Markdown reference docs (examples/docs/*.md) — committed output.
gen-docs: build-doc
    mkdir -p {{docs_dir}}

M cmd/protoc-gen-tarantool/internal/gen/gen.go => cmd/protoc-gen-tarantool/internal/gen/gen.go +55 -4
@@ 55,9 55,10 @@ type Config struct {

// GenerateFile emits one `<lua_pkg>.lua` file per input `.proto`.
func GenerateFile(plug *protogen.Plugin, file *protogen.File, cfg Config) error {
	if file.Desc.Syntax() != protoreflect.Proto3 {
		return fmt.Errorf("%s: only proto3 is supported, got %s",
			file.Desc.Path(), file.Desc.Syntax())
	syntax := file.Desc.Syntax()
	if syntax != protoreflect.Proto3 && syntax != protoreflect.Proto2 {
		return fmt.Errorf("%s: only proto2 and proto3 are supported, got %s",
			file.Desc.Path(), syntax)
	}

	allMsgs := flattenMessagesSkippingMapEntries(file.Messages, nil)


@@ 473,11 474,25 @@ func renderFieldEntry(w *writer, file *protogen.File, f *protogen.Field, selfPat
		parts = append(parts, fmt.Sprintf("oneof=%q", string(f.Oneof.Desc.Name())))
	}

	// Proto3 explicit optional (field presence).
	// Presence-tracked singular field. Proto3: explicit `optional` keyword
	// (synthetic-oneof wrapped). Proto2: every singular field declared
	// `optional` (and `required` — required fields also have presence).
	// Repeated/map already short-circuit above; messages don't need the
	// flag because the codec's message writer is presence-aware anyway.
	if f.Desc.HasOptionalKeyword() {
		parts = append(parts, "optional=true")
	}

	// Proto2 required cardinality. Codec validates on encode.
	if f.Desc.Cardinality() == protoreflect.Required {
		parts = append(parts, "required=true")
	}

	// Explicit `[default = X]` (proto2 only — proto3 has no custom defaults).
	if f.Desc.HasDefault() {
		parts = append(parts, "default_value="+renderDefaultValueLiteral(f))
	}

	if opts := w.renderOpts(f.Desc.Options()); opts != "" {
		parts = append(parts, "options="+opts)
	}


@@ 485,6 500,42 @@ func renderFieldEntry(w *writer, file *protogen.File, f *protogen.Field, selfPat
	return "{" + strings.Join(parts, ", ") + "}"
}

// renderDefaultValueLiteral converts a field's proto2 default value to the
// Lua expression that materializes it. Matches the runtime convention:
// strings/bytes are quoted, 64-bit integers use LuaJIT cdata literals,
// enums use the symbolic name so codec lookups stay readable.
func renderDefaultValueLiteral(f *protogen.Field) string {
	v := f.Desc.Default()
	switch f.Desc.Kind() {
	case protoreflect.BoolKind:
		if v.Bool() {
			return "true"
		}
		return "false"
	case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind:
		return strconv.FormatInt(int64(int32(v.Int())), 10)
	case protoreflect.Uint32Kind, protoreflect.Fixed32Kind:
		return strconv.FormatUint(uint64(uint32(v.Uint())), 10)
	case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
		return strconv.FormatInt(v.Int(), 10) + "LL"
	case protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
		return strconv.FormatUint(v.Uint(), 10) + "ULL"
	case protoreflect.FloatKind, protoreflect.DoubleKind:
		return formatLuaFloat(v.Float())
	case protoreflect.StringKind:
		return strconv.Quote(v.String())
	case protoreflect.BytesKind:
		return luaByteString(v.Bytes())
	case protoreflect.EnumKind:
		ev := f.Enum.Desc.Values().ByNumber(v.Enum())
		if ev != nil {
			return strconv.Quote(string(ev.Name()))
		}
		return strconv.FormatInt(int64(v.Enum()), 10)
	}
	panic("renderDefaultValueLiteral: unhandled kind " + f.Desc.Kind().String())
}

// renderMapEntry renders a sub-field descriptor for a map's key or value.
// It mirrors renderFieldEntry but always for a singular non-map value, and
// emits without the `name`/`id` (caller knows: id 1 = key, id 2 = value).

M cmd/protoc-gen-tarantool/internal/gen/inline.go => cmd/protoc-gen-tarantool/internal/gen/inline.go +52 -1
@@ 94,6 94,14 @@ func emitInlineEncodeField(w *writer, f *protogen.Field, file *protogen.File, se
	w.line("    -- field %d: %s", id, fname)
	w.line("    v = %s", luaFieldAccess("t", fname))

	// Proto2 `required`: error on encode if missing, never elide. Mutually
	// exclusive with oneof and repeated, so the rest of the branching below
	// stays unchanged for non-required fields.
	if f.Desc.Cardinality() == protoreflect.Required {
		emitInlineEncodeRequiredField(w, f, tag, file, selfPath, imports, prefix)
		return
	}

	oneof := fieldRealOneof(f)
	// Default presence gate: a regular nil check. For message fields we
	// also need to accept box.NULL (which == nil via Tarantool's cdata


@@ 105,7 113,8 @@ func emitInlineEncodeField(w *writer, f *protogen.Field, file *protogen.File, se
	} else if f.Message != nil {
		gate = "v ~= nil or type(v) == 'cdata'"
	}
	// Explicit-optional fields: presence is meaningful, no default elision.
	// Presence semantics: oneof branches, proto3 explicit `optional`, and
	// every proto2 singular field (`optional` keyword). No default elision.
	hasPresence := oneof != "" || f.Desc.HasOptionalKeyword()

	switch {


@@ 173,6 182,48 @@ func emitInlineEncodeField(w *writer, f *protogen.Field, file *protogen.File, se
	}
}

// emitInlineEncodeRequiredField generates the encode body for a proto2
// `required` field: error if missing, always emit (no default elision).
func emitInlineEncodeRequiredField(w *writer, f *protogen.Field, tag string, file *protogen.File, selfPath string, imports map[string]string, prefix string) {
	fullName := string(f.Desc.FullName())
	w.line("    if v == nil then")
	w.line("        error(%q, 0)", "required field missing on encode: "+fullName)
	w.line("    end")
	switch {
	case f.Message != nil:
		ref := typeRef(file, f.Message.Desc, selfPath, imports, "_encode", prefix)
		w.line("    local _b = %s(v)", ref)
		w.line("    n = n + 1; out[n] = %s", tag)
		w.line("    n = n + 1; out[n] = wire.encode_varint(#_b)")
		w.line("    n = n + 1; out[n] = _b")
	case f.Enum != nil:
		enumLocal := typeRef(file, f.Enum.Desc, selfPath, imports, "", prefix)
		enumFull := string(f.Enum.Desc.FullName())
		w.line("    do")
		w.line("        local nv = v")
		w.line("        if type(v) == 'string' then")
		w.line("            nv = %s[v]", enumLocal)
		w.line("            if nv == nil then error(\"unknown enum value '\" .. v .. \"' for %s\", 0) end", enumFull)
		w.line("        end")
		w.line("        n = n + 1; out[n] = %s", tag)
		w.line("        n = n + 1; out[n] = wire.encode_int32(nv)")
		w.line("    end")
	default:
		st := scalarName(f.Desc.Kind())
		if st == "" {
			panic("unhandled scalar kind: " + f.Desc.Kind().String())
		}
		if st == "string" || st == "bytes" {
			w.line("    n = n + 1; out[n] = %s", tag)
			w.line("    n = n + 1; out[n] = wire.encode_varint(#v)")
			w.line("    n = n + 1; out[n] = v")
		} else {
			w.line("    n = n + 1; out[n] = %s", tag)
			w.line("    n = n + 1; out[n] = wire.encode_%s(v)", st)
		}
	}
}

func emitInlineEncodeRepeated(w *writer, f *protogen.Field, tag, fname string, file *protogen.File, selfPath string, imports map[string]string, prefix string) {
	switch {
	case f.Message != nil:

A examples/expected/full/proto2_basic/proto2_basic_pb.lua => examples/expected/full/proto2_basic/proto2_basic_pb.lua +611 -0
@@ 0,0 1,611 @@
-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
-- source: proto2_basic.proto
-- syntax: proto2
-- package: proto2_basic

local pb = require("pb")
local wire = pb.wire

local M = {}

M.options = {go_package = "tarantoolpb_synthetic/proto2_basic"}

-- Enum: proto2_basic.Defaults.Color
M.Defaults_Color_descriptor = pb.enum("proto2_basic.Defaults.Color", {
    RED = 0,
    GREEN = 1,
    BLUE = 2,
})
M.Defaults_Color = M.Defaults_Color_descriptor.by_name

-- Pre-declare message descriptors so cross-references resolve.
M.Defaults_descriptor = {name = "proto2_basic.Defaults"}
M.Cardinality_descriptor = {name = "proto2_basic.Cardinality"}
M.Nested_descriptor = {name = "proto2_basic.Nested"}
M.Nested_Inner_descriptor = {name = "proto2_basic.Nested.Inner"}

-- Message: proto2_basic.Defaults
M.Defaults_descriptor.fields = {
    {name="i", id=1, kind='scalar', proto_type="int32", optional=true, default_value=17},
    {name="s", id=2, kind='scalar', proto_type="string", optional=true, default_value="hello"},
    {name="b", id=3, kind='scalar', proto_type="bool", optional=true, default_value=true},
    {name="f", id=4, kind='scalar', proto_type="float", optional=true, default_value=3.5},
    {name="d", id=5, kind='scalar', proto_type="double", optional=true, default_value=1.5},
    {name="i64", id=6, kind='scalar', proto_type="int64", optional=true, default_value=1234567890123LL},
    {name="u64", id=7, kind='scalar', proto_type="uint64", optional=true, default_value=17ULL},
    {name="by", id=8, kind='scalar', proto_type="bytes", optional=true, default_value="\x00\xff"},
    {name="color", id=9, kind='enum', enum=M.Defaults_Color_descriptor, optional=true, default_value="GREEN"},
}
pb.finalize_message(M.Defaults_descriptor)
M.Defaults_fields = pb.field_names({
    i = "i",
    s = "s",
    b = "b",
    f = "f",
    d = "d",
    i64 = "i64",
    u64 = "u64",
    by = "by",
    color = "color",
})

-- Message: proto2_basic.Cardinality
M.Cardinality_descriptor.fields = {
    {name="r", id=1, kind='scalar', proto_type="int32", required=true},
    {name="o", id=2, kind='scalar', proto_type="int32", optional=true},
    {name="packed_default", id=3, kind='scalar', proto_type="int32", repeated=true, packed=false},
    {name="explicitly_packed", id=4, kind='scalar', proto_type="int32", repeated=true, packed=true, options={packed = true}},
    {name="explicitly_unpacked", id=5, kind='scalar', proto_type="int32", repeated=true, packed=false, options={packed = false}},
}
pb.finalize_message(M.Cardinality_descriptor)
M.Cardinality_fields = pb.field_names({
    r = "r",
    o = "o",
    packed_default = "packed_default",
    explicitly_packed = "explicitly_packed",
    explicitly_unpacked = "explicitly_unpacked",
})

-- Message: proto2_basic.Nested
M.Nested_descriptor.fields = {
    {name="inner", id=1, kind='message', message=M.Nested_Inner_descriptor, required=true},
    {name="inner_opt", id=2, kind='message', message=M.Nested_Inner_descriptor, optional=true},
}
pb.finalize_message(M.Nested_descriptor)
M.Nested_fields = pb.field_names({
    inner = "inner",
    inner_opt = "inner_opt",
})

-- Message: proto2_basic.Nested.Inner
M.Nested_Inner_descriptor.fields = {
    {name="x", id=1, kind='scalar', proto_type="int32", required=true},
}
pb.finalize_message(M.Nested_Inner_descriptor)
M.Nested_Inner_fields = pb.field_names({
    x = "x",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors
-- autocomplete and type-checking for the generated wrappers.
---@alias proto2_basic.Defaults.Color integer

---@class proto2_basic.Defaults
---@field i? integer
---@field s? string
---@field b? boolean
---@field f? number
---@field d? number
---@field i64? integer
---@field u64? integer
---@field by? string
---@field color? proto2_basic.Defaults.Color

---@class proto2_basic.Cardinality
---@field r integer
---@field o? integer
---@field packed_default integer[]
---@field explicitly_packed integer[]
---@field explicitly_unpacked integer[]

---@class proto2_basic.Nested
---@field inner proto2_basic.Nested.Inner
---@field inner_opt? proto2_basic.Nested.Inner

---@class proto2_basic.Nested.Inner
---@field x integer

---@param t? proto2_basic.Defaults
---@return proto2_basic.Defaults
function M.Defaults_new(t) return t or {} end

---@param t proto2_basic.Defaults
---@return string
function M.Defaults_encode(t)
    if type(t) ~= 'table' then
        error("expected table for proto2_basic.Defaults, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: i
    v = t.i
    if v ~= nil then
        n = n + 1; out[n] = "\x08"
        n = n + 1; out[n] = wire.encode_int32(v)
    end
    -- field 2: s
    v = t.s
    if v ~= nil then
        n = n + 1; out[n] = "\x12"
        n = n + 1; out[n] = wire.encode_varint(#v)
        n = n + 1; out[n] = v
    end
    -- field 3: b
    v = t.b
    if v ~= nil then
        n = n + 1; out[n] = "\x18"
        n = n + 1; out[n] = wire.encode_bool(v)
    end
    -- field 4: f
    v = t.f
    if v ~= nil then
        n = n + 1; out[n] = "\x25"
        n = n + 1; out[n] = wire.encode_float(v)
    end
    -- field 5: d
    v = t.d
    if v ~= nil then
        n = n + 1; out[n] = "\x29"
        n = n + 1; out[n] = wire.encode_double(v)
    end
    -- field 6: i64
    v = t.i64
    if v ~= nil then
        n = n + 1; out[n] = "\x30"
        n = n + 1; out[n] = wire.encode_int64(v)
    end
    -- field 7: u64
    v = t.u64
    if v ~= nil then
        n = n + 1; out[n] = "\x38"
        n = n + 1; out[n] = wire.encode_uint64(v)
    end
    -- field 8: by
    v = t.by
    if v ~= nil then
        n = n + 1; out[n] = "\x42"
        n = n + 1; out[n] = wire.encode_varint(#v)
        n = n + 1; out[n] = v
    end
    -- field 9: color
    v = t.color
    if v ~= nil then
        local nv = v
        if type(v) == 'string' then
            nv = M.Defaults_Color[v]
            if nv == nil then error("unknown enum value '" .. v .. "' for proto2_basic.Defaults.Color", 0) end
        end
        n = n + 1; out[n] = "\x48"
        n = n + 1; out[n] = wire.encode_int32(nv)
    end
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return proto2_basic.Defaults
function M.Defaults_decode(buf)
    if type(buf) ~= 'string' then
        error("expected string for proto2_basic.Defaults decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        id, wt, pos = wire.decode_tag(buf, pos)
        if id == 1 then
            local val
            val, pos = wire.decode_int32(buf, pos)
            result.i = val
        elseif id == 2 then
            local val
            val, pos = wire.decode_string(buf, pos)
            result.s = val
        elseif id == 3 then
            local val
            val, pos = wire.decode_bool(buf, pos)
            result.b = val
        elseif id == 4 then
            local val
            val, pos = wire.decode_float(buf, pos)
            result.f = val
        elseif id == 5 then
            local val
            val, pos = wire.decode_double(buf, pos)
            result.d = val
        elseif id == 6 then
            local val
            val, pos = wire.decode_int64(buf, pos)
            result.i64 = val
        elseif id == 7 then
            local val
            val, pos = wire.decode_uint64(buf, pos)
            result.u64 = val
        elseif id == 8 then
            local val
            val, pos = wire.decode_bytes(buf, pos)
            result.by = val
        elseif id == 9 then
            local u
            u, pos = wire.decode_varint(buf, pos)
            result.color = wire.varint_to_int32(u)
        else
            pos = wire.skip_field(buf, pos, wt, id)
            if _uf == nil then _uf = {} end
            _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Defaults_decode_lazy(b) return pb.decode_lazy(M.Defaults_descriptor, b) end
---@param t proto2_basic.Defaults
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Defaults_text(t, opts) return pb.text.encode(M.Defaults_descriptor, t, opts) end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_i(t) return t.i ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_i(t) t.i = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_s(t) return t.s ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_s(t) t.s = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_b(t) return t.b ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_b(t) t.b = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_f(t) return t.f ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_f(t) t.f = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_d(t) return t.d ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_d(t) t.d = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_i64(t) return t.i64 ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_i64(t) t.i64 = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_u64(t) return t.u64 ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_u64(t) t.u64 = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_by(t) return t.by ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_by(t) t.by = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_color(t) return t.color ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_color(t) t.color = nil end

---@param t? proto2_basic.Cardinality
---@return proto2_basic.Cardinality
function M.Cardinality_new(t) return t or {} end

---@param t proto2_basic.Cardinality
---@return string
function M.Cardinality_encode(t)
    if type(t) ~= 'table' then
        error("expected table for proto2_basic.Cardinality, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: r
    v = t.r
    if v == nil then
        error("required field missing on encode: proto2_basic.Cardinality.r", 0)
    end
    n = n + 1; out[n] = "\x08"
    n = n + 1; out[n] = wire.encode_int32(v)
    -- field 2: o
    v = t.o
    if v ~= nil then
        n = n + 1; out[n] = "\x10"
        n = n + 1; out[n] = wire.encode_int32(v)
    end
    -- field 3: packed_default
    v = t.packed_default
    if v ~= nil and #v > 0 then
        local _tag = "\x18"
        for _i = 1, #v do
            n = n + 1; out[n] = _tag
            n = n + 1; out[n] = wire.encode_int32(v[_i])
        end
    end
    -- field 4: explicitly_packed
    v = t.explicitly_packed
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_int32(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x22"
        n = n + 1; out[n] = wire.encode_varint(#_b)
        n = n + 1; out[n] = _b
    end
    -- field 5: explicitly_unpacked
    v = t.explicitly_unpacked
    if v ~= nil and #v > 0 then
        local _tag = "\x28"
        for _i = 1, #v do
            n = n + 1; out[n] = _tag
            n = n + 1; out[n] = wire.encode_int32(v[_i])
        end
    end
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return proto2_basic.Cardinality
function M.Cardinality_decode(buf)
    if type(buf) ~= 'string' then
        error("expected string for proto2_basic.Cardinality decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        id, wt, pos = wire.decode_tag(buf, pos)
        if id == 1 then
            local val
            val, pos = wire.decode_int32(buf, pos)
            result.r = val
        elseif id == 2 then
            local val
            val, pos = wire.decode_int32(buf, pos)
            result.o = val
        elseif id == 3 then
            local list = result.packed_default
            if list == nil then list = {}; result.packed_default = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_int32(payload, p2)
                    list[#list + 1] = val
                end
            else
                local val
                val, pos = wire.decode_int32(buf, pos)
                list[#list + 1] = val
            end
        elseif id == 4 then
            local list = result.explicitly_packed
            if list == nil then list = {}; result.explicitly_packed = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_int32(payload, p2)
                    list[#list + 1] = val
                end
            else
                local val
                val, pos = wire.decode_int32(buf, pos)
                list[#list + 1] = val
            end
        elseif id == 5 then
            local list = result.explicitly_unpacked
            if list == nil then list = {}; result.explicitly_unpacked = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_int32(payload, p2)
                    list[#list + 1] = val
                end
            else
                local val
                val, pos = wire.decode_int32(buf, pos)
                list[#list + 1] = val
            end
        else
            pos = wire.skip_field(buf, pos, wt, id)
            if _uf == nil then _uf = {} end
            _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Cardinality_decode_lazy(b) return pb.decode_lazy(M.Cardinality_descriptor, b) end
---@param t proto2_basic.Cardinality
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Cardinality_text(t, opts) return pb.text.encode(M.Cardinality_descriptor, t, opts) end
---@param t proto2_basic.Cardinality
---@return boolean
function M.Cardinality_has_o(t) return t.o ~= nil end
---@param t proto2_basic.Cardinality
function M.Cardinality_clear_o(t) t.o = nil end

---@param t? proto2_basic.Nested
---@return proto2_basic.Nested
function M.Nested_new(t) return t or {} end

---@param t proto2_basic.Nested
---@return string
function M.Nested_encode(t)
    if type(t) ~= 'table' then
        error("expected table for proto2_basic.Nested, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: inner
    v = t.inner
    if v == nil then
        error("required field missing on encode: proto2_basic.Nested.inner", 0)
    end
    local _b = M.Nested_Inner_encode(v)
    n = n + 1; out[n] = "\x0a"
    n = n + 1; out[n] = wire.encode_varint(#_b)
    n = n + 1; out[n] = _b
    -- field 2: inner_opt
    v = t.inner_opt
    if v ~= nil or type(v) == 'cdata' then
        local _b = M.Nested_Inner_encode(v)
        n = n + 1; out[n] = "\x12"
        n = n + 1; out[n] = wire.encode_varint(#_b)
        n = n + 1; out[n] = _b
    end
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return proto2_basic.Nested
function M.Nested_decode(buf)
    if type(buf) ~= 'string' then
        error("expected string for proto2_basic.Nested decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        id, wt, pos = wire.decode_tag(buf, pos)
        if id == 1 then
            local payload
            payload, pos = wire.decode_len(buf, pos)
            local prev = result.inner
            if prev == nil then
                result.inner = M.Nested_Inner_decode(payload)
            else
                pb.codec.merge_message(M.Nested_Inner_descriptor, prev, M.Nested_Inner_decode(payload))
            end
        elseif id == 2 then
            local payload
            payload, pos = wire.decode_len(buf, pos)
            local prev = result.inner_opt
            if prev == nil then
                result.inner_opt = M.Nested_Inner_decode(payload)
            else
                pb.codec.merge_message(M.Nested_Inner_descriptor, prev, M.Nested_Inner_decode(payload))
            end
        else
            pos = wire.skip_field(buf, pos, wt, id)
            if _uf == nil then _uf = {} end
            _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Nested_decode_lazy(b) return pb.decode_lazy(M.Nested_descriptor, b) end
---@param t proto2_basic.Nested
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Nested_text(t, opts) return pb.text.encode(M.Nested_descriptor, t, opts) end
---@param t proto2_basic.Nested
---@return boolean
function M.Nested_has_inner_opt(t) return t.inner_opt ~= nil end
---@param t proto2_basic.Nested
function M.Nested_clear_inner_opt(t) t.inner_opt = nil end

---@param t? proto2_basic.Nested.Inner
---@return proto2_basic.Nested.Inner
function M.Nested_Inner_new(t) return t or {} end

---@param t proto2_basic.Nested.Inner
---@return string
function M.Nested_Inner_encode(t)
    if type(t) ~= 'table' then
        error("expected table for proto2_basic.Nested.Inner, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: x
    v = t.x
    if v == nil then
        error("required field missing on encode: proto2_basic.Nested.Inner.x", 0)
    end
    n = n + 1; out[n] = "\x08"
    n = n + 1; out[n] = wire.encode_int32(v)
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return proto2_basic.Nested.Inner
function M.Nested_Inner_decode(buf)
    if type(buf) ~= 'string' then
        error("expected string for proto2_basic.Nested.Inner decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        id, wt, pos = wire.decode_tag(buf, pos)
        if id == 1 then
            local val
            val, pos = wire.decode_int32(buf, pos)
            result.x = val
        else
            pos = wire.skip_field(buf, pos, wt, id)
            if _uf == nil then _uf = {} end
            _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Nested_Inner_decode_lazy(b) return pb.decode_lazy(M.Nested_Inner_descriptor, b) end
---@param t proto2_basic.Nested.Inner
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Nested_Inner_text(t, opts) return pb.text.encode(M.Nested_Inner_descriptor, t, opts) end

return M

A examples/expected/runtime/proto2_basic/proto2_basic_pb.lua => examples/expected/runtime/proto2_basic/proto2_basic_pb.lua +242 -0
@@ 0,0 1,242 @@
-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
-- source: proto2_basic.proto
-- syntax: proto2
-- package: proto2_basic

local pb = require("pb")
local wire = pb.wire

local M = {}

M.options = {go_package = "tarantoolpb_synthetic/proto2_basic"}

-- Enum: proto2_basic.Defaults.Color
M.Defaults_Color_descriptor = pb.enum("proto2_basic.Defaults.Color", {
    RED = 0,
    GREEN = 1,
    BLUE = 2,
})
M.Defaults_Color = M.Defaults_Color_descriptor.by_name

-- Pre-declare message descriptors so cross-references resolve.
M.Defaults_descriptor = {name = "proto2_basic.Defaults"}
M.Cardinality_descriptor = {name = "proto2_basic.Cardinality"}
M.Nested_descriptor = {name = "proto2_basic.Nested"}
M.Nested_Inner_descriptor = {name = "proto2_basic.Nested.Inner"}

-- Message: proto2_basic.Defaults
M.Defaults_descriptor.fields = {
    {name="i", id=1, kind='scalar', proto_type="int32", optional=true, default_value=17},
    {name="s", id=2, kind='scalar', proto_type="string", optional=true, default_value="hello"},
    {name="b", id=3, kind='scalar', proto_type="bool", optional=true, default_value=true},
    {name="f", id=4, kind='scalar', proto_type="float", optional=true, default_value=3.5},
    {name="d", id=5, kind='scalar', proto_type="double", optional=true, default_value=1.5},
    {name="i64", id=6, kind='scalar', proto_type="int64", optional=true, default_value=1234567890123LL},
    {name="u64", id=7, kind='scalar', proto_type="uint64", optional=true, default_value=17ULL},
    {name="by", id=8, kind='scalar', proto_type="bytes", optional=true, default_value="\x00\xff"},
    {name="color", id=9, kind='enum', enum=M.Defaults_Color_descriptor, optional=true, default_value="GREEN"},
}
pb.finalize_message(M.Defaults_descriptor)
M.Defaults_fields = pb.field_names({
    i = "i",
    s = "s",
    b = "b",
    f = "f",
    d = "d",
    i64 = "i64",
    u64 = "u64",
    by = "by",
    color = "color",
})

-- Message: proto2_basic.Cardinality
M.Cardinality_descriptor.fields = {
    {name="r", id=1, kind='scalar', proto_type="int32", required=true},
    {name="o", id=2, kind='scalar', proto_type="int32", optional=true},
    {name="packed_default", id=3, kind='scalar', proto_type="int32", repeated=true, packed=false},
    {name="explicitly_packed", id=4, kind='scalar', proto_type="int32", repeated=true, packed=true, options={packed = true}},
    {name="explicitly_unpacked", id=5, kind='scalar', proto_type="int32", repeated=true, packed=false, options={packed = false}},
}
pb.finalize_message(M.Cardinality_descriptor)
M.Cardinality_fields = pb.field_names({
    r = "r",
    o = "o",
    packed_default = "packed_default",
    explicitly_packed = "explicitly_packed",
    explicitly_unpacked = "explicitly_unpacked",
})

-- Message: proto2_basic.Nested
M.Nested_descriptor.fields = {
    {name="inner", id=1, kind='message', message=M.Nested_Inner_descriptor, required=true},
    {name="inner_opt", id=2, kind='message', message=M.Nested_Inner_descriptor, optional=true},
}
pb.finalize_message(M.Nested_descriptor)
M.Nested_fields = pb.field_names({
    inner = "inner",
    inner_opt = "inner_opt",
})

-- Message: proto2_basic.Nested.Inner
M.Nested_Inner_descriptor.fields = {
    {name="x", id=1, kind='scalar', proto_type="int32", required=true},
}
pb.finalize_message(M.Nested_Inner_descriptor)
M.Nested_Inner_fields = pb.field_names({
    x = "x",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors
-- autocomplete and type-checking for the generated wrappers.
---@alias proto2_basic.Defaults.Color integer

---@class proto2_basic.Defaults
---@field i? integer
---@field s? string
---@field b? boolean
---@field f? number
---@field d? number
---@field i64? integer
---@field u64? integer
---@field by? string
---@field color? proto2_basic.Defaults.Color

---@class proto2_basic.Cardinality
---@field r integer
---@field o? integer
---@field packed_default integer[]
---@field explicitly_packed integer[]
---@field explicitly_unpacked integer[]

---@class proto2_basic.Nested
---@field inner proto2_basic.Nested.Inner
---@field inner_opt? proto2_basic.Nested.Inner

---@class proto2_basic.Nested.Inner
---@field x integer

---@param t? proto2_basic.Defaults
---@return proto2_basic.Defaults
function M.Defaults_new(t) return t or {} end
---@param t proto2_basic.Defaults
---@return string
function M.Defaults_encode(t) return pb.encode(M.Defaults_descriptor, t) end
---@param b string
---@return proto2_basic.Defaults
function M.Defaults_decode(b) return pb.decode(M.Defaults_descriptor, b) end
---@param b string
---@return pb.MessageView
function M.Defaults_decode_lazy(b) return pb.decode_lazy(M.Defaults_descriptor, b) end
---@param t proto2_basic.Defaults
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Defaults_text(t, opts) return pb.text.encode(M.Defaults_descriptor, t, opts) end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_i(t) return t.i ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_i(t) t.i = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_s(t) return t.s ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_s(t) t.s = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_b(t) return t.b ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_b(t) t.b = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_f(t) return t.f ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_f(t) t.f = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_d(t) return t.d ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_d(t) t.d = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_i64(t) return t.i64 ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_i64(t) t.i64 = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_u64(t) return t.u64 ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_u64(t) t.u64 = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_by(t) return t.by ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_by(t) t.by = nil end
---@param t proto2_basic.Defaults
---@return boolean
function M.Defaults_has_color(t) return t.color ~= nil end
---@param t proto2_basic.Defaults
function M.Defaults_clear_color(t) t.color = nil end

---@param t? proto2_basic.Cardinality
---@return proto2_basic.Cardinality
function M.Cardinality_new(t) return t or {} end
---@param t proto2_basic.Cardinality
---@return string
function M.Cardinality_encode(t) return pb.encode(M.Cardinality_descriptor, t) end
---@param b string
---@return proto2_basic.Cardinality
function M.Cardinality_decode(b) return pb.decode(M.Cardinality_descriptor, b) end
---@param b string
---@return pb.MessageView
function M.Cardinality_decode_lazy(b) return pb.decode_lazy(M.Cardinality_descriptor, b) end
---@param t proto2_basic.Cardinality
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Cardinality_text(t, opts) return pb.text.encode(M.Cardinality_descriptor, t, opts) end
---@param t proto2_basic.Cardinality
---@return boolean
function M.Cardinality_has_o(t) return t.o ~= nil end
---@param t proto2_basic.Cardinality
function M.Cardinality_clear_o(t) t.o = nil end

---@param t? proto2_basic.Nested
---@return proto2_basic.Nested
function M.Nested_new(t) return t or {} end
---@param t proto2_basic.Nested
---@return string
function M.Nested_encode(t) return pb.encode(M.Nested_descriptor, t) end
---@param b string
---@return proto2_basic.Nested
function M.Nested_decode(b) return pb.decode(M.Nested_descriptor, b) end
---@param b string
---@return pb.MessageView
function M.Nested_decode_lazy(b) return pb.decode_lazy(M.Nested_descriptor, b) end
---@param t proto2_basic.Nested
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Nested_text(t, opts) return pb.text.encode(M.Nested_descriptor, t, opts) end
---@param t proto2_basic.Nested
---@return boolean
function M.Nested_has_inner_opt(t) return t.inner_opt ~= nil end
---@param t proto2_basic.Nested
function M.Nested_clear_inner_opt(t) t.inner_opt = nil end

---@param t? proto2_basic.Nested.Inner
---@return proto2_basic.Nested.Inner
function M.Nested_Inner_new(t) return t or {} end
---@param t proto2_basic.Nested.Inner
---@return string
function M.Nested_Inner_encode(t) return pb.encode(M.Nested_Inner_descriptor, t) end
---@param b string
---@return proto2_basic.Nested.Inner
function M.Nested_Inner_decode(b) return pb.decode(M.Nested_Inner_descriptor, b) end
---@param b string
---@return pb.MessageView
function M.Nested_Inner_decode_lazy(b) return pb.decode_lazy(M.Nested_Inner_descriptor, b) end
---@param t proto2_basic.Nested.Inner
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Nested_Inner_text(t, opts) return pb.text.encode(M.Nested_Inner_descriptor, t, opts) end

return M

M runtime/pb/codec.lua => runtime/pb/codec.lua +71 -2
@@ 389,10 389,79 @@ local function build_repeated_writer(f)
    return nil
end

local function build_writer(f)
-- build_required_writer specializes for proto2 `required` singular fields:
-- error if the value is missing, never elide (defaults are not skipped).
-- Required is mutually exclusive with repeated/map/oneof, so this only
-- handles singular scalar/enum/message. `owner_name` is the parent
-- message's full name; threaded through compile_writers so the missing-
-- field error tells the caller which message they were encoding.
local function build_required_writer(f, owner_name)
    local fname = f.name
    local kind  = f.kind
    local missing_msg = "required field missing on encode: "
        .. tostring(owner_name) .. "." .. tostring(f.name)

    if kind == 'scalar' then
        local handler = scalar[f.proto_type]
        if not handler then return nil end
        local tag_bytes    = wire.encode_tag(f.id, handler.wire)
        local encode_value = handler.encode
        local proto_type   = f.proto_type
        if proto_type == 'string' or proto_type == 'bytes' then
            return function(data, out)
                local v = data[fname]
                if v == nil then error(missing_msg, 0) end
                local n = #out
                out[n + 1] = tag_bytes
                out[n + 2] = wire.encode_varint(#v)
                out[n + 3] = v
            end
        end
        return function(data, out)
            local v = data[fname]
            if v == nil then error(missing_msg, 0) end
            local n = #out
            out[n + 1] = tag_bytes
            out[n + 2] = encode_value(v)
        end
    end

    if kind == 'enum' then
        local enum_desc = f.enum
        local tag_bytes = wire.encode_tag(f.id, wire.WIRE_VARINT)
        return function(data, out)
            local v = data[fname]
            if v == nil then error(missing_msg, 0) end
            local n_enum = encode_enum_value(enum_desc, v)
            local n = #out
            out[n + 1] = tag_bytes
            out[n + 2] = wire.encode_varint(n_enum)
        end
    end

    if kind == 'message' then
        local sub_desc  = f.message
        local tag_bytes = wire.encode_tag(f.id, wire.WIRE_LEN)
        return function(data, out)
            local v = data[fname]
            if v == nil and type(v) ~= 'cdata' then error(missing_msg, 0) end
            local body = encode_msg(sub_desc, v)
            local n = #out
            out[n + 1] = tag_bytes
            out[n + 2] = wire.encode_varint(#body)
            out[n + 3] = body
        end
    end

    return nil
end

local function build_writer(f, owner_name)
    -- Maps and oneof branches keep going through encode_field.
    if f.kind == 'map' or f.oneof then return nil end

    if f.required then return build_required_writer(f, owner_name) end

    if f.repeated then return build_repeated_writer(f) end

    local fname  = f.name


@@ 515,7 584,7 @@ end
-- specialized. Called from pb.finalize_message after the oneof flatten.
function M.compile_writers(desc)
    for _, f in ipairs(desc.fields) do
        f._writer = build_writer(f)
        f._writer = build_writer(f, desc.name)
    end
end


A test/proto/proto2_basic.proto => test/proto/proto2_basic.proto +38 -0
@@ 0,0 1,38 @@
syntax = "proto2";

package proto2_basic;

message Defaults {
  optional int32 i = 1 [default = 17];
  optional string s = 2 [default = "hello"];
  optional bool b = 3 [default = true];
  optional float f = 4 [default = 3.5];
  optional double d = 5 [default = 1.5];
  optional int64 i64 = 6 [default = 1234567890123];
  optional uint64 u64 = 7 [default = 17];
  optional bytes by = 8 [default = "\x00\xff"];
  optional Color color = 9 [default = GREEN];

  enum Color {
    RED = 0;
    GREEN = 1;
    BLUE = 2;
  }
}

message Cardinality {
  required int32 r = 1;
  optional int32 o = 2;
  repeated int32 packed_default = 3;
  repeated int32 explicitly_packed = 4 [packed = true];
  repeated int32 explicitly_unpacked = 5 [packed = false];
}

message Nested {
  required Inner inner = 1;
  optional Inner inner_opt = 2;

  message Inner {
    required int32 x = 1;
  }
}

A test/proto2_test.lua => test/proto2_test.lua +187 -0
@@ 0,0 1,187 @@
-- Proto2 round-trip + required-field validation, parametrized over both
-- codegen modes. The fixture is generated from test/proto/proto2_basic.proto
-- by `just gen-proto2-tests`.
local t   = require('luatest')
local ffi = require('ffi')

local function hex(s)
    local out = {}
    for i = 1, #s do out[i] = string.format('%02x', s:byte(i)) end
    return table.concat(out)
end

local MODES = {'full', 'runtime'}

for _, mode in ipairs(MODES) do
    local g  = t.group('proto2_basic.' .. mode)
    local pb = require(mode .. '.proto2_basic.proto2_basic_pb')

    -- ----- Defaults: explicit-optional fields with [default = X] -----

    g.test_defaults_descriptor_carries_default_value = function()
        local d = pb.Defaults_descriptor.field_by_name
        t.assert_equals(d.i.default_value, 17)
        t.assert_equals(d.s.default_value, 'hello')
        t.assert_equals(d.b.default_value, true)
        t.assert_equals(d.f.default_value, 3.5)
        t.assert_equals(d.d.default_value, 1.5)
        t.assert(d.i64.default_value == ffi.cast('int64_t', 1234567890123),
            'int64 default cdata equality')
        t.assert(d.u64.default_value == ffi.cast('uint64_t', 17),
            'uint64 default cdata equality')
        t.assert_equals(d.by.default_value, '\x00\xff')
        t.assert_equals(d.color.default_value, 'GREEN')
    end

    g.test_empty_message_round_trips_to_empty_bytes = function()
        -- All proto2 fields are presence-tracked. An empty Lua table has no
        -- fields set, so encode produces zero bytes (no defaults serialized).
        local enc = pb.Defaults_encode({})
        t.assert_equals(enc, '', 'no defaults on the wire')
        t.assert_equals(pb.Defaults_decode(enc), {})
    end

    g.test_set_value_round_trip = function()
        local val = {i = 42, s = 'world', b = false, f = -1.5}
        local dec = pb.Defaults_decode(pb.Defaults_encode(val))
        t.assert_equals(dec.i, 42)
        t.assert_equals(dec.s, 'world')
        t.assert_equals(dec.b, false)
        t.assert_equals(dec.f, -1.5)
        -- d/i64/u64/by/color stay absent.
        t.assert_equals(dec.d, nil)
        t.assert_equals(dec.color, nil)
    end

    g.test_set_to_proto_default_still_serializes = function()
        -- proto2 presence means setting a field to its declared default
        -- still emits it on the wire (no proto3-style elision).
        local enc = pb.Defaults_encode({i = 17})
        t.assert_not_equals(enc, '', 'presence-tracked field at default must serialize')
        t.assert_equals(pb.Defaults_decode(enc).i, 17)
    end

    -- ----- Cardinality: required vs optional vs repeated -----

    g.test_required_missing_errors = function()
        local ok, err = pcall(pb.Cardinality_encode, {})
        t.assert_equals(ok, false)
        t.assert_str_contains(err, 'required field missing on encode')
        t.assert_str_contains(err, 'proto2_basic.Cardinality.r')
    end

    g.test_required_zero_emitted = function()
        -- Required field at proto2-default zero must still be on the wire.
        local enc = pb.Cardinality_encode({r = 0})
        t.assert_equals(hex(enc), '0800',
            'required int32=0: tag 1/VARINT + varint 0')
    end

    g.test_required_set = function()
        local enc = pb.Cardinality_encode({r = 7})
        local dec = pb.Cardinality_decode(enc)
        t.assert_equals(dec.r, 7)
    end

    g.test_repeated_unpacked_by_default = function()
        -- Proto2 default for repeated scalars is NOT packed.
        local enc = pb.Cardinality_encode({r = 0, packed_default = {1, 2, 3}})
        -- field 3, wire VARINT (0x18) repeated three times.
        t.assert_equals(hex(enc), '0800' .. '180118021803')
    end

    g.test_repeated_explicit_packed = function()
        local enc = pb.Cardinality_encode({r = 0, explicitly_packed = {1, 2, 3}})
        -- field 4, wire LEN (0x22), len=3, varints 1,2,3.
        t.assert_equals(hex(enc), '0800' .. '2203' .. '010203')
    end

    g.test_repeated_explicit_unpacked = function()
        local enc = pb.Cardinality_encode({r = 0, explicitly_unpacked = {1, 2, 3}})
        -- field 5, wire VARINT (0x28) repeated three times.
        t.assert_equals(hex(enc), '0800' .. '280128022803')
    end

    g.test_repeated_decode_accepts_both_packed_and_unpacked = function()
        -- A wire stream with packed_default encoded as packed (legal — proto
        -- consumers must accept either form) decodes the same way as unpacked.
        local packed   = '\x08\x00\x1a\x03\x01\x02\x03'  -- field 3 with LEN
        local unpacked = '\x08\x00\x18\x01\x18\x02\x18\x03'
        local a = pb.Cardinality_decode(packed)
        local b = pb.Cardinality_decode(unpacked)
        t.assert_equals(a.packed_default, {1, 2, 3})
        t.assert_equals(b.packed_default, {1, 2, 3})
    end

    -- ----- Nested required message -----

    g.test_nested_required_message_missing_errors = function()
        local ok, err = pcall(pb.Nested_encode, {})
        t.assert_equals(ok, false)
        t.assert_str_contains(err, 'required field missing on encode')
        t.assert_str_contains(err, 'proto2_basic.Nested.inner')
    end

    g.test_nested_required_inner_required = function()
        -- Inner message also has its own required field (x). When Inner
        -- itself is required on the outer message, missing it errors on the
        -- inner encode call.
        local ok, err = pcall(pb.Nested_encode, {inner = {}})
        t.assert_equals(ok, false)
        t.assert_str_contains(err, 'proto2_basic.Nested.Inner.x')
    end

    g.test_nested_required_filled_round_trips = function()
        local val = {inner = {x = 5}, inner_opt = {x = 9}}
        local dec = pb.Nested_decode(pb.Nested_encode(val))
        t.assert_equals(dec.inner.x, 5)
        t.assert_equals(dec.inner_opt.x, 9)
    end

    -- ----- Enum default surfaced in descriptor -----

    g.test_enum_default_descriptor = function()
        local f = pb.Defaults_descriptor.field_by_name.color
        t.assert_equals(f.default_value, 'GREEN')
        -- The enum descriptor itself round-trips the symbolic name.
        t.assert_equals(pb.Defaults_Color_descriptor.by_name['GREEN'], 1)
    end
end

-- Parity: full and runtime modes must produce byte-identical output for
-- the same input. Equivalent to the existing parity.full_vs_runtime group.
do
    local g = t.group('proto2_basic.parity')
    local full    = require('full.proto2_basic.proto2_basic_pb')
    local runtime = require('runtime.proto2_basic.proto2_basic_pb')

    local function check_parity(msg, encode_full, encode_runtime, value)
        local bf, br = encode_full(value), encode_runtime(value)
        t.assert_equals(hex(bf), hex(br),
            msg .. ': full and runtime modes must produce identical bytes')
    end

    g.test_defaults_round_trip_parity = function()
        check_parity('Defaults set values', full.Defaults_encode,
            runtime.Defaults_encode,
            {i = 42, s = 'world', b = true, f = 0.5, d = -1.5})
    end

    g.test_cardinality_required_parity = function()
        check_parity('Cardinality.r=0', full.Cardinality_encode,
            runtime.Cardinality_encode, {r = 0})
        check_parity('Cardinality full', full.Cardinality_encode,
            runtime.Cardinality_encode, {
                r = 1,
                o = 2,
                packed_default      = {3, 4, 5},
                explicitly_packed   = {6, 7, 8},
                explicitly_unpacked = {9, 10},
            })
    end

    g.test_nested_parity = function()
        check_parity('Nested', full.Nested_encode, runtime.Nested_encode,
            {inner = {x = 5}, inner_opt = {x = 9}})
    end
end