From 4fbdb65d00feb310ffdbc56f4acd9204746cb9d5 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 17 May 2026 08:06:23 +0300 Subject: [PATCH] =?UTF-8?q?codegen:=20proto2=20baseline=20=E2=80=94=20requ?= =?UTF-8?q?ired,=20optional,=20custom=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Justfile | 21 +- cmd/protoc-gen-tarantool/internal/gen/gen.go | 59 +- .../internal/gen/inline.go | 53 +- .../full/proto2_basic/proto2_basic_pb.lua | 611 ++++++++++++++++++ .../runtime/proto2_basic/proto2_basic_pb.lua | 242 +++++++ runtime/pb/codec.lua | 73 ++- test/proto/proto2_basic.proto | 38 ++ test/proto2_test.lua | 187 ++++++ 8 files changed, 1276 insertions(+), 8 deletions(-) create mode 100644 examples/expected/full/proto2_basic/proto2_basic_pb.lua create mode 100644 examples/expected/runtime/proto2_basic/proto2_basic_pb.lua create mode 100644 test/proto/proto2_basic.proto create mode 100644 test/proto2_test.lua diff --git a/Justfile b/Justfile index c5ad99009a4e92c05939c9233d7be19639275343..6da87e01c048cf54e8e8f2d4ddc65623397d8522 100644 --- a/Justfile +++ b/Justfile @@ -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}} diff --git a/cmd/protoc-gen-tarantool/internal/gen/gen.go b/cmd/protoc-gen-tarantool/internal/gen/gen.go index 314655ed67584ad28bd2d7b0b1f3e06101ee25e5..336f033c6bb66e8f37e1498ea07d8b88df9b9b3f 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/gen.go +++ b/cmd/protoc-gen-tarantool/internal/gen/gen.go @@ -55,9 +55,10 @@ type Config struct { // GenerateFile emits one `.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). diff --git a/cmd/protoc-gen-tarantool/internal/gen/inline.go b/cmd/protoc-gen-tarantool/internal/gen/inline.go index eff5acae95811ef773838ce2f2e703dbf499c46a..3a4c69efcba0f58b49ccf03539e4fd9b9e5fa4c2 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/inline.go +++ b/cmd/protoc-gen-tarantool/internal/gen/inline.go @@ -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: diff --git a/examples/expected/full/proto2_basic/proto2_basic_pb.lua b/examples/expected/full/proto2_basic/proto2_basic_pb.lua new file mode 100644 index 0000000000000000000000000000000000000000..2f2aa63564355128d72f2456165fb1682e520a80 --- /dev/null +++ b/examples/expected/full/proto2_basic/proto2_basic_pb.lua @@ -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 diff --git a/examples/expected/runtime/proto2_basic/proto2_basic_pb.lua b/examples/expected/runtime/proto2_basic/proto2_basic_pb.lua new file mode 100644 index 0000000000000000000000000000000000000000..c776efc6ee61dc6420fa50303385950f9805a634 --- /dev/null +++ b/examples/expected/runtime/proto2_basic/proto2_basic_pb.lua @@ -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 diff --git a/runtime/pb/codec.lua b/runtime/pb/codec.lua index b94d6facf2859f0e3a610486b193feee56328608..775efb9b5cc8fe5643f5d70475ac4573f7106c5f 100644 --- a/runtime/pb/codec.lua +++ b/runtime/pb/codec.lua @@ -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 diff --git a/test/proto/proto2_basic.proto b/test/proto/proto2_basic.proto new file mode 100644 index 0000000000000000000000000000000000000000..60780345697c58de059327c42ce658551865cab5 --- /dev/null +++ b/test/proto/proto2_basic.proto @@ -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; + } +} diff --git a/test/proto2_test.lua b/test/proto2_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..ff6558fa60354b5b0e47eaf062e23306e1ceb081 --- /dev/null +++ b/test/proto2_test.lua @@ -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