From 0044b1632684703ed499d1d7d6742536ff09c18e Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 15 May 2026 21:41:24 +0300 Subject: [PATCH] codegen: text-format printer (pb.text.encode + _text wrappers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a descriptor-driven text-format encoder mirroring `protoc --decode` output: one field per line, 2-space indent, octal byte escapes, `nan`/`inf` floats, `opts.single_line=true` for compact one-liners. WKT-aware — Timestamp/Duration accept datetime cdata or {seconds,nanos}, wrappers print their unwrapped scalar, Struct/Value/ListValue walk the tagged-table form, FieldMask flattens to `paths:` lines, Any stays opaque. Both codegen modes emit `M._text(t, opts)`, surfaced as `pb.text` on the public table. Encode-only; matching parser deferred. Tests cover scalars, repeated, maps, oneof, optional presence, all WKT types, single-line mode, and the generated wrapper across both modes (74 cases). 403/403 luatest green, 19/19 jit-trace gate green. --- .../internal/gen/emmylua.go | 5 + cmd/protoc-gen-tarantool/internal/gen/gen.go | 2 + .../internal/gen/inline.go | 2 + .../full/conformance/conformance_pb.lua | 20 + examples/expected/full/hello/hello_pb.lua | 24 + .../proto3/test_messages_proto3_pb.lua | 20 + .../runtime/conformance/conformance_pb.lua | 20 + examples/expected/runtime/hello/hello_pb.lua | 24 + .../proto3/test_messages_proto3_pb.lua | 20 + runtime/pb/init.lua | 5 + runtime/pb/text.lua | 409 ++++++++++++++++++ test/text_test.lua | 274 ++++++++++++ 12 files changed, 825 insertions(+) create mode 100644 runtime/pb/text.lua create mode 100644 test/text_test.lua diff --git a/cmd/protoc-gen-tarantool/internal/gen/emmylua.go b/cmd/protoc-gen-tarantool/internal/gen/emmylua.go index a964766b0111a354464a5c2df98b456b01dd4f88..e18bb97665b2baff7618c4918874f8ab021f4f72 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/emmylua.go +++ b/cmd/protoc-gen-tarantool/internal/gen/emmylua.go @@ -158,6 +158,10 @@ func emitEmmyWrapperAnnotations(w *writer, name string, fullName string, kind em case wrapperDecodeLazy: w.line("---@param b string") w.line("---@return pb.MessageView") + case wrapperText: + w.line("---@param t %s", t) + w.line("---@param opts? {single_line: boolean?, indent: string?}") + w.line("---@return string") case wrapperHas: w.line("---@param t %s", t) w.line("---@return boolean") @@ -173,6 +177,7 @@ const ( wrapperEncode wrapperDecode wrapperDecodeLazy + wrapperText wrapperHas wrapperClear ) diff --git a/cmd/protoc-gen-tarantool/internal/gen/gen.go b/cmd/protoc-gen-tarantool/internal/gen/gen.go index 300c56f76e837ea2cd9dc26022caa0875b2a13f4..22457a182a8449ea7308d09073362186fd7e3bc4 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/gen.go +++ b/cmd/protoc-gen-tarantool/internal/gen/gen.go @@ -390,6 +390,8 @@ func emitMessageWrappers(w *writer, file *protogen.File, m *protogen.Message) { w.line("function M.%s_decode(b) return pb.decode(M.%s_descriptor, b) end", name, name) emitEmmyWrapperAnnotations(w, name, full, wrapperDecodeLazy) w.line("function M.%s_decode_lazy(b) return pb.decode_lazy(M.%s_descriptor, b) end", name, name) + emitEmmyWrapperAnnotations(w, name, full, wrapperText) + w.line("function M.%s_text(t, opts) return pb.text.encode(M.%s_descriptor, t, opts) end", name, name) emitOptionalAccessors(w, name, m, full) w.line("") } diff --git a/cmd/protoc-gen-tarantool/internal/gen/inline.go b/cmd/protoc-gen-tarantool/internal/gen/inline.go index ecb2495d94e6a7a6f33b2a2a9e9360248303e1ec..6cbc720c69d0694a94814dedce3fd5d040479b33 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/inline.go +++ b/cmd/protoc-gen-tarantool/internal/gen/inline.go @@ -24,6 +24,8 @@ func emitInlineMessage(w *writer, file *protogen.File, m *protogen.Message, impo emitInlineDecode(w, name, m, file, selfPath, imports, prefix) emitEmmyWrapperAnnotations(w, name, full, wrapperDecodeLazy) w.line("function M.%s_decode_lazy(b) return pb.decode_lazy(M.%s_descriptor, b) end", name, name) + emitEmmyWrapperAnnotations(w, name, full, wrapperText) + w.line("function M.%s_text(t, opts) return pb.text.encode(M.%s_descriptor, t, opts) end", name, name) emitOptionalAccessors(w, name, m, full) w.line("") } diff --git a/examples/expected/full/conformance/conformance_pb.lua b/examples/expected/full/conformance/conformance_pb.lua index 1795495fc06f761a21bfc638c74072c94d17f5d6..7cc6e97ec59d793746e6d872ef9eded059f7ec1a 100644 --- a/examples/expected/full/conformance/conformance_pb.lua +++ b/examples/expected/full/conformance/conformance_pb.lua @@ -202,6 +202,10 @@ end ---@param b string ---@return pb.MessageView function M.TestStatus_decode_lazy(b) return pb.decode_lazy(M.TestStatus_descriptor, b) end +---@param t conformance.TestStatus +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.TestStatus_text(t, opts) return pb.text.encode(M.TestStatus_descriptor, t, opts) end ---@param t? conformance.FailureSet ---@return conformance.FailureSet @@ -261,6 +265,10 @@ end ---@param b string ---@return pb.MessageView function M.FailureSet_decode_lazy(b) return pb.decode_lazy(M.FailureSet_descriptor, b) end +---@param t conformance.FailureSet +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.FailureSet_text(t, opts) return pb.text.encode(M.FailureSet_descriptor, t, opts) end ---@param t? conformance.ConformanceRequest ---@return conformance.ConformanceRequest @@ -432,6 +440,10 @@ end ---@param b string ---@return pb.MessageView function M.ConformanceRequest_decode_lazy(b) return pb.decode_lazy(M.ConformanceRequest_descriptor, b) end +---@param t conformance.ConformanceRequest +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.ConformanceRequest_text(t, opts) return pb.text.encode(M.ConformanceRequest_descriptor, t, opts) end ---@param t? conformance.ConformanceResponse ---@return conformance.ConformanceResponse @@ -648,6 +660,10 @@ end ---@param b string ---@return pb.MessageView function M.ConformanceResponse_decode_lazy(b) return pb.decode_lazy(M.ConformanceResponse_descriptor, b) end +---@param t conformance.ConformanceResponse +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.ConformanceResponse_text(t, opts) return pb.text.encode(M.ConformanceResponse_descriptor, t, opts) end ---@param t? conformance.JspbEncodingConfig ---@return conformance.JspbEncodingConfig @@ -702,5 +718,9 @@ end ---@param b string ---@return pb.MessageView function M.JspbEncodingConfig_decode_lazy(b) return pb.decode_lazy(M.JspbEncodingConfig_descriptor, b) end +---@param t conformance.JspbEncodingConfig +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.JspbEncodingConfig_text(t, opts) return pb.text.encode(M.JspbEncodingConfig_descriptor, t, opts) end return M diff --git a/examples/expected/full/hello/hello_pb.lua b/examples/expected/full/hello/hello_pb.lua index f30c8ff3db89809cefd45e94ac8aa92781bf015f..db5214c2e37081da33c59ea1a345f53c0b5cc36c 100644 --- a/examples/expected/full/hello/hello_pb.lua +++ b/examples/expected/full/hello/hello_pb.lua @@ -239,6 +239,10 @@ end ---@param b string ---@return pb.MessageView function M.Result_decode_lazy(b) return pb.decode_lazy(M.Result_descriptor, b) end +---@param t hello.Result +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Result_text(t, opts) return pb.text.encode(M.Result_descriptor, t, opts) end ---@param t? hello.HelloRequest ---@return hello.HelloRequest @@ -293,6 +297,10 @@ end ---@param b string ---@return pb.MessageView function M.HelloRequest_decode_lazy(b) return pb.decode_lazy(M.HelloRequest_descriptor, b) end +---@param t hello.HelloRequest +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.HelloRequest_text(t, opts) return pb.text.encode(M.HelloRequest_descriptor, t, opts) end ---@param t? hello.HelloReply ---@return hello.HelloReply @@ -347,6 +355,10 @@ end ---@param b string ---@return pb.MessageView function M.HelloReply_decode_lazy(b) return pb.decode_lazy(M.HelloReply_descriptor, b) end +---@param t hello.HelloReply +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.HelloReply_text(t, opts) return pb.text.encode(M.HelloReply_descriptor, t, opts) end ---@param t? hello.Event ---@return hello.Event @@ -577,6 +589,10 @@ end ---@param b string ---@return pb.MessageView function M.Event_decode_lazy(b) return pb.decode_lazy(M.Event_descriptor, b) end +---@param t hello.Event +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Event_text(t, opts) return pb.text.encode(M.Event_descriptor, t, opts) end ---@param t? hello.Address ---@return hello.Address @@ -662,6 +678,10 @@ end ---@return pb.MessageView function M.Address_decode_lazy(b) return pb.decode_lazy(M.Address_descriptor, b) end ---@param t hello.Address +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Address_text(t, opts) return pb.text.encode(M.Address_descriptor, t, opts) end +---@param t hello.Address ---@return boolean function M.Address_has_apartment(t) return t.apartment ~= nil end ---@param t hello.Address @@ -973,6 +993,10 @@ end ---@param b string ---@return pb.MessageView function M.Person_decode_lazy(b) return pb.decode_lazy(M.Person_descriptor, b) end +---@param t hello.Person +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Person_text(t, opts) return pb.text.encode(M.Person_descriptor, t, opts) end -- Service: hello.Greeter M.Greeter_service = { diff --git a/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua b/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua index 46d6fe4a2def30bc9e0793d61356d8c11724ed8c..62105532c88b247787f02fdc1196becd1404ee04 100644 --- a/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +++ b/examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua @@ -3622,6 +3622,10 @@ end ---@param b string ---@return pb.MessageView function M.TestAllTypesProto3_decode_lazy(b) return pb.decode_lazy(M.TestAllTypesProto3_descriptor, b) end +---@param t protobuf_test_messages.proto3.TestAllTypesProto3 +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.TestAllTypesProto3_text(t, opts) return pb.text.encode(M.TestAllTypesProto3_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage ---@return protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage @@ -3692,6 +3696,10 @@ end ---@param b string ---@return pb.MessageView function M.TestAllTypesProto3_NestedMessage_decode_lazy(b) return pb.decode_lazy(M.TestAllTypesProto3_NestedMessage_descriptor, b) end +---@param t protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.TestAllTypesProto3_NestedMessage_text(t, opts) return pb.text.encode(M.TestAllTypesProto3_NestedMessage_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.ForeignMessage ---@return protobuf_test_messages.proto3.ForeignMessage @@ -3746,6 +3754,10 @@ end ---@param b string ---@return pb.MessageView function M.ForeignMessage_decode_lazy(b) return pb.decode_lazy(M.ForeignMessage_descriptor, b) end +---@param t protobuf_test_messages.proto3.ForeignMessage +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.ForeignMessage_text(t, opts) return pb.text.encode(M.ForeignMessage_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.NullHypothesisProto3 ---@return protobuf_test_messages.proto3.NullHypothesisProto3 @@ -3791,6 +3803,10 @@ end ---@param b string ---@return pb.MessageView function M.NullHypothesisProto3_decode_lazy(b) return pb.decode_lazy(M.NullHypothesisProto3_descriptor, b) end +---@param t protobuf_test_messages.proto3.NullHypothesisProto3 +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.NullHypothesisProto3_text(t, opts) return pb.text.encode(M.NullHypothesisProto3_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.EnumOnlyProto3 ---@return protobuf_test_messages.proto3.EnumOnlyProto3 @@ -3836,5 +3852,9 @@ end ---@param b string ---@return pb.MessageView function M.EnumOnlyProto3_decode_lazy(b) return pb.decode_lazy(M.EnumOnlyProto3_descriptor, b) end +---@param t protobuf_test_messages.proto3.EnumOnlyProto3 +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.EnumOnlyProto3_text(t, opts) return pb.text.encode(M.EnumOnlyProto3_descriptor, t, opts) end return M diff --git a/examples/expected/runtime/conformance/conformance_pb.lua b/examples/expected/runtime/conformance/conformance_pb.lua index 6b729f8069dd4c178a8baeba362d3f2ed2fe55fe..57ab173d6885f72e4ca08d3f5e6c9de7b0c73bad 100644 --- a/examples/expected/runtime/conformance/conformance_pb.lua +++ b/examples/expected/runtime/conformance/conformance_pb.lua @@ -141,6 +141,10 @@ function M.TestStatus_decode(b) return pb.decode(M.TestStatus_descriptor, b) end ---@param b string ---@return pb.MessageView function M.TestStatus_decode_lazy(b) return pb.decode_lazy(M.TestStatus_descriptor, b) end +---@param t conformance.TestStatus +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.TestStatus_text(t, opts) return pb.text.encode(M.TestStatus_descriptor, t, opts) end ---@param t? conformance.FailureSet ---@return conformance.FailureSet @@ -154,6 +158,10 @@ function M.FailureSet_decode(b) return pb.decode(M.FailureSet_descriptor, b) end ---@param b string ---@return pb.MessageView function M.FailureSet_decode_lazy(b) return pb.decode_lazy(M.FailureSet_descriptor, b) end +---@param t conformance.FailureSet +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.FailureSet_text(t, opts) return pb.text.encode(M.FailureSet_descriptor, t, opts) end ---@param t? conformance.ConformanceRequest ---@return conformance.ConformanceRequest @@ -167,6 +175,10 @@ function M.ConformanceRequest_decode(b) return pb.decode(M.ConformanceRequest_de ---@param b string ---@return pb.MessageView function M.ConformanceRequest_decode_lazy(b) return pb.decode_lazy(M.ConformanceRequest_descriptor, b) end +---@param t conformance.ConformanceRequest +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.ConformanceRequest_text(t, opts) return pb.text.encode(M.ConformanceRequest_descriptor, t, opts) end ---@param t? conformance.ConformanceResponse ---@return conformance.ConformanceResponse @@ -180,6 +192,10 @@ function M.ConformanceResponse_decode(b) return pb.decode(M.ConformanceResponse_ ---@param b string ---@return pb.MessageView function M.ConformanceResponse_decode_lazy(b) return pb.decode_lazy(M.ConformanceResponse_descriptor, b) end +---@param t conformance.ConformanceResponse +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.ConformanceResponse_text(t, opts) return pb.text.encode(M.ConformanceResponse_descriptor, t, opts) end ---@param t? conformance.JspbEncodingConfig ---@return conformance.JspbEncodingConfig @@ -193,5 +209,9 @@ function M.JspbEncodingConfig_decode(b) return pb.decode(M.JspbEncodingConfig_de ---@param b string ---@return pb.MessageView function M.JspbEncodingConfig_decode_lazy(b) return pb.decode_lazy(M.JspbEncodingConfig_descriptor, b) end +---@param t conformance.JspbEncodingConfig +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.JspbEncodingConfig_text(t, opts) return pb.text.encode(M.JspbEncodingConfig_descriptor, t, opts) end return M diff --git a/examples/expected/runtime/hello/hello_pb.lua b/examples/expected/runtime/hello/hello_pb.lua index f29a22c59c1442036e57f501157eeb84529e8e33..4f4526ea41bca396ddedf5290f0d88893c8bde75 100644 --- a/examples/expected/runtime/hello/hello_pb.lua +++ b/examples/expected/runtime/hello/hello_pb.lua @@ -158,6 +158,10 @@ function M.Result_decode(b) return pb.decode(M.Result_descriptor, b) end ---@param b string ---@return pb.MessageView function M.Result_decode_lazy(b) return pb.decode_lazy(M.Result_descriptor, b) end +---@param t hello.Result +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Result_text(t, opts) return pb.text.encode(M.Result_descriptor, t, opts) end ---@param t? hello.HelloRequest ---@return hello.HelloRequest @@ -171,6 +175,10 @@ function M.HelloRequest_decode(b) return pb.decode(M.HelloRequest_descriptor, b) ---@param b string ---@return pb.MessageView function M.HelloRequest_decode_lazy(b) return pb.decode_lazy(M.HelloRequest_descriptor, b) end +---@param t hello.HelloRequest +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.HelloRequest_text(t, opts) return pb.text.encode(M.HelloRequest_descriptor, t, opts) end ---@param t? hello.HelloReply ---@return hello.HelloReply @@ -184,6 +192,10 @@ function M.HelloReply_decode(b) return pb.decode(M.HelloReply_descriptor, b) end ---@param b string ---@return pb.MessageView function M.HelloReply_decode_lazy(b) return pb.decode_lazy(M.HelloReply_descriptor, b) end +---@param t hello.HelloReply +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.HelloReply_text(t, opts) return pb.text.encode(M.HelloReply_descriptor, t, opts) end ---@param t? hello.Event ---@return hello.Event @@ -197,6 +209,10 @@ function M.Event_decode(b) return pb.decode(M.Event_descriptor, b) end ---@param b string ---@return pb.MessageView function M.Event_decode_lazy(b) return pb.decode_lazy(M.Event_descriptor, b) end +---@param t hello.Event +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Event_text(t, opts) return pb.text.encode(M.Event_descriptor, t, opts) end ---@param t? hello.Address ---@return hello.Address @@ -211,6 +227,10 @@ function M.Address_decode(b) return pb.decode(M.Address_descriptor, b) end ---@return pb.MessageView function M.Address_decode_lazy(b) return pb.decode_lazy(M.Address_descriptor, b) end ---@param t hello.Address +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Address_text(t, opts) return pb.text.encode(M.Address_descriptor, t, opts) end +---@param t hello.Address ---@return boolean function M.Address_has_apartment(t) return t.apartment ~= nil end ---@param t hello.Address @@ -228,6 +248,10 @@ function M.Person_decode(b) return pb.decode(M.Person_descriptor, b) end ---@param b string ---@return pb.MessageView function M.Person_decode_lazy(b) return pb.decode_lazy(M.Person_descriptor, b) end +---@param t hello.Person +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.Person_text(t, opts) return pb.text.encode(M.Person_descriptor, t, opts) end -- Service: hello.Greeter M.Greeter_service = { diff --git a/examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua b/examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua index 36310c688c95cf9e601c7f588ade2298ca203767..74178c882c405aa2a8b60a0d3863cd19d6094763 100644 --- a/examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +++ b/examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua @@ -420,6 +420,10 @@ function M.TestAllTypesProto3_decode(b) return pb.decode(M.TestAllTypesProto3_de ---@param b string ---@return pb.MessageView function M.TestAllTypesProto3_decode_lazy(b) return pb.decode_lazy(M.TestAllTypesProto3_descriptor, b) end +---@param t protobuf_test_messages.proto3.TestAllTypesProto3 +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.TestAllTypesProto3_text(t, opts) return pb.text.encode(M.TestAllTypesProto3_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage ---@return protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage @@ -433,6 +437,10 @@ function M.TestAllTypesProto3_NestedMessage_decode(b) return pb.decode(M.TestAll ---@param b string ---@return pb.MessageView function M.TestAllTypesProto3_NestedMessage_decode_lazy(b) return pb.decode_lazy(M.TestAllTypesProto3_NestedMessage_descriptor, b) end +---@param t protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.TestAllTypesProto3_NestedMessage_text(t, opts) return pb.text.encode(M.TestAllTypesProto3_NestedMessage_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.ForeignMessage ---@return protobuf_test_messages.proto3.ForeignMessage @@ -446,6 +454,10 @@ function M.ForeignMessage_decode(b) return pb.decode(M.ForeignMessage_descriptor ---@param b string ---@return pb.MessageView function M.ForeignMessage_decode_lazy(b) return pb.decode_lazy(M.ForeignMessage_descriptor, b) end +---@param t protobuf_test_messages.proto3.ForeignMessage +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.ForeignMessage_text(t, opts) return pb.text.encode(M.ForeignMessage_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.NullHypothesisProto3 ---@return protobuf_test_messages.proto3.NullHypothesisProto3 @@ -459,6 +471,10 @@ function M.NullHypothesisProto3_decode(b) return pb.decode(M.NullHypothesisProto ---@param b string ---@return pb.MessageView function M.NullHypothesisProto3_decode_lazy(b) return pb.decode_lazy(M.NullHypothesisProto3_descriptor, b) end +---@param t protobuf_test_messages.proto3.NullHypothesisProto3 +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.NullHypothesisProto3_text(t, opts) return pb.text.encode(M.NullHypothesisProto3_descriptor, t, opts) end ---@param t? protobuf_test_messages.proto3.EnumOnlyProto3 ---@return protobuf_test_messages.proto3.EnumOnlyProto3 @@ -472,5 +488,9 @@ function M.EnumOnlyProto3_decode(b) return pb.decode(M.EnumOnlyProto3_descriptor ---@param b string ---@return pb.MessageView function M.EnumOnlyProto3_decode_lazy(b) return pb.decode_lazy(M.EnumOnlyProto3_descriptor, b) end +---@param t protobuf_test_messages.proto3.EnumOnlyProto3 +---@param opts? {single_line: boolean?, indent: string?} +---@return string +function M.EnumOnlyProto3_text(t, opts) return pb.text.encode(M.EnumOnlyProto3_descriptor, t, opts) end return M diff --git a/runtime/pb/init.lua b/runtime/pb/init.lua index 790556eee16fd43e054ece809641d2654481db29..a26a111ce7802a71bb5736d01ca639e3b54a5cb4 100644 --- a/runtime/pb/init.lua +++ b/runtime/pb/init.lua @@ -15,6 +15,7 @@ local grpc = require('pb.grpc') local parser = require('pb.parser') local dynamic = require('pb.dynamic') local pbjson = require('pb.json') +local pbtext = require('pb.text') local lazy = require('pb.lazy') return { @@ -64,6 +65,10 @@ return { -- pb.json.decode(desc, s) -> table. json = pbjson, + -- Text format printer. pb.text.encode(desc, t, opts) -> string. + -- opts: {single_line=bool, indent=string}. Encode-only. + text = pbtext, + -- Wire type constants WIRE_VARINT = wire.WIRE_VARINT, WIRE_I64 = wire.WIRE_I64, diff --git a/runtime/pb/text.lua b/runtime/pb/text.lua new file mode 100644 index 0000000000000000000000000000000000000000..9a78629d755e4d81925fe19d745f4f19ec62a383 --- /dev/null +++ b/runtime/pb/text.lua @@ -0,0 +1,409 @@ +-- Protobuf text-format encoder. +-- +-- Output matches the form produced by `protoc --decode=`: +-- * one field per line, 2-space indent +-- * `name: value` for scalars / enums +-- * `name { ... }` for sub-messages and map entries (no `:`) +-- * repeated fields emit the field once per element +-- * map emits as repeated synthetic `key:` / `value:` entries +-- * field names use the original snake_case (mainline convention) +-- +-- A single-line variant is available via `opts.single_line = true` — fields +-- are space-separated and message bodies stay on one line. Useful for +-- compact debug logging and inline goldens. +-- +-- WKT extension hook: a descriptor with a `desc.text(t, buf, depth)` +-- function takes over body emission (used by pb.wkt to format Timestamp / +-- Duration / Struct / etc. from their idiomatic Lua shapes). +-- +-- Decoding is not part of M7; this module is encode-only. +local ffi = require('ffi') +local datetime = require('datetime') +local pbwkt = require('pb.wkt') + +local M = {} + +local INT64_FAMILY = {int64=true, uint64=true, sint64=true, fixed64=true, sfixed64=true} +local UINT_FAMILY = {uint32=true, uint64=true, fixed32=true, fixed64=true} +local INT64_ZERO = ffi.cast('int64_t', 0) +local UINT64_ZERO = ffi.cast('uint64_t', 0) + +-- --------------------------------------------------------------------------- +-- Primitives +-- --------------------------------------------------------------------------- + +local function int_to_string(v) + if type(v) == 'cdata' then + return tostring(v):gsub('U?LL$', '') + end + return tostring(v) +end + +local function escape_string(s) + local out = {'"'} + local n = 1 + for i = 1, #s do + local b = s:byte(i) + if b == 0x5c then n = n + 1; out[n] = '\\\\' + elseif b == 0x22 then n = n + 1; out[n] = '\\"' + elseif b == 0x27 then n = n + 1; out[n] = "\\'" + elseif b == 0x0a then n = n + 1; out[n] = '\\n' + elseif b == 0x0d then n = n + 1; out[n] = '\\r' + elseif b == 0x09 then n = n + 1; out[n] = '\\t' + elseif b >= 0x20 and b < 0x7f then + n = n + 1; out[n] = string.char(b) + else + n = n + 1; out[n] = string.format('\\%03o', b) + end + end + n = n + 1; out[n] = '"' + return table.concat(out) +end + +local function format_float(v) + if v ~= v then return 'nan' end + if v == math.huge then return 'inf' end + if v == -math.huge then return '-inf' end + if v == math.floor(v) and math.abs(v) < 1e16 then + return string.format('%.1f', v) + end + return tostring(v) +end + +local function scalar_token(proto_type, v) + if INT64_FAMILY[proto_type] then return int_to_string(v) end + if UINT_FAMILY[proto_type] then return int_to_string(v) end + if proto_type == 'float' or proto_type == 'double' then + if type(v) ~= 'number' then v = tonumber(v) end + return format_float(v) + end + if proto_type == 'bool' then return v and 'true' or 'false' end + if proto_type == 'bytes' or proto_type == 'string' then + return escape_string(v) + end + return tostring(v) -- int32 / sint32 / sfixed32 +end + +local function enum_token(enum_desc, v) + if type(v) == 'string' then return v end + local name = enum_desc.by_value[tonumber(v)] + if name ~= nil then return name end + return tostring(v) +end + +local function is_proto3_default(f, v) + if f.optional or f.oneof then return false end + if f.kind == 'scalar' then + local pt = f.proto_type + if pt == 'string' or pt == 'bytes' then return v == '' end + if pt == 'bool' then return v == false end + if type(v) == 'cdata' then + return v == INT64_ZERO or v == UINT64_ZERO + end + return v == 0 + elseif f.kind == 'enum' then + return v == 0 or v == f.enum.by_value[0] + end + return false +end + +-- --------------------------------------------------------------------------- +-- Buffer + emit primitives +-- --------------------------------------------------------------------------- + +local function new_buf(opts) + return { + chunks = {}, n = 0, + single_line = opts.single_line and true or false, + indent_unit = opts.indent or ' ', + } +end + +local function push(buf, s) + buf.n = buf.n + 1 + buf.chunks[buf.n] = s +end + +-- newline writes a field separator: newline+indent in pretty mode, single +-- space in single-line mode. At the start of an otherwise-empty buffer it +-- writes nothing so the encode output doesn't lead with whitespace. +local function newline(buf, depth) + if buf.n == 0 then return end + if buf.single_line then + push(buf, ' ') + return + end + push(buf, '\n') + if depth > 0 then push(buf, string.rep(buf.indent_unit, depth)) end +end + +local emit_message -- forward +local emit_field -- forward + +-- emit_block writes `prefix {`, then calls body_fn(buf, depth+1) to fill +-- the body. If the body emits nothing, output collapses to `prefix {}`. +local function emit_block(buf, prefix, depth, body_fn) + push(buf, prefix); push(buf, ' {') + local pre_n = buf.n + body_fn(buf, depth + 1) + if buf.n == pre_n then + push(buf, '}') + return + end + newline(buf, depth) + push(buf, '}') +end + +-- --------------------------------------------------------------------------- +-- Field emission +-- --------------------------------------------------------------------------- + +local function emit_one(buf, f, v, depth) + local kind = f.kind + if kind == 'scalar' then + push(buf, f.name); push(buf, ': ') + push(buf, scalar_token(f.proto_type, v)) + elseif kind == 'enum' then + push(buf, f.name); push(buf, ': ') + push(buf, enum_token(f.enum, v)) + elseif kind == 'message' then + emit_block(buf, f.name, depth, function(b, d) + emit_message(b, f.message, v, d) + end) + else + error('text.encode: unknown field kind ' .. tostring(kind), 0) + end +end + +local function emit_map_entry(buf, f, k, v, depth) + local kf, vf = f.key, f.value + emit_block(buf, f.name, depth, function(b, d) + newline(b, d) + emit_one(b, {name='key', kind=kf.kind, proto_type=kf.proto_type, + enum=kf.enum, message=kf.message}, k, d) + newline(b, d) + emit_one(b, {name='value', kind=vf.kind, proto_type=vf.proto_type, + enum=vf.enum, message=vf.message}, v, d) + end) +end + +emit_field = function(buf, f, v, depth) + if f.kind == 'map' then + for k, mv in pairs(v) do + newline(buf, depth) + emit_map_entry(buf, f, k, mv, depth) + end + elseif f.repeated then + for i = 1, #v do + newline(buf, depth) + emit_one(buf, f, v[i], depth) + end + else + if is_proto3_default(f, v) then return end + newline(buf, depth) + emit_one(buf, f, v, depth) + end +end + +local WKT_TEXT -- forward (filled below) + +emit_message = function(buf, desc, t, depth) + -- Use type() rather than == nil so box.NULL (a nil-equal cdata used as + -- the Value WKT's null_value sentinel) survives the guard. + if type(t) == 'nil' then return end + local wkt_fn = WKT_TEXT[desc.name] + if wkt_fn ~= nil then + wkt_fn(buf, t, depth) + return + end + if desc.text ~= nil then + desc.text(t, buf, depth) + return + end + for _, f in ipairs(desc.fields) do + local v = t[f.name] + -- `box.NULL == nil` via cdata __eq metamethod, so a `v ~= nil` + -- guard would silently drop a NULL Value WKT. Compare on type. + if type(v) ~= 'nil' then + emit_field(buf, f, v, depth) + end + end +end + +-- --------------------------------------------------------------------------- +-- Well-known type text emitters +-- --------------------------------------------------------------------------- +-- +-- Each takes (buf, value, depth) and emits the message body — i.e. what +-- would go between `{` and `}` if this WKT appeared as a field value. For +-- the top-level form (`pb.text.encode(M.Timestamp_descriptor, dt)`) this +-- is the entire output. +-- +-- The WKT entries below mirror our Lua representations (datetime cdata for +-- Timestamp, unwrapped scalars for wrappers, hash table for Struct, etc.) +-- so the user can hand a real Lua value to the printer without first +-- converting it back to the proto message shape. + +local function emit_seconds_nanos(buf, t, depth) + local seconds, nanos + if type(t) == 'table' then + seconds = t.seconds or 0 + nanos = t.nanos or 0 + elseif type(t) == 'cdata' and datetime.is_datetime(t) then + seconds = tonumber(t.epoch) + nanos = t.nsec + elseif type(t) == 'number' then + seconds = math.floor(t) + nanos = math.floor((t - seconds) * 1e9 + 0.5) + else + error('Timestamp/Duration text: unsupported value type ' .. type(t), 0) + end + if seconds ~= 0 and seconds ~= ffi.cast('int64_t', 0) then + newline(buf, depth) + push(buf, 'seconds: '); push(buf, int_to_string(seconds)) + end + if nanos ~= 0 then + newline(buf, depth) + push(buf, 'nanos: '); push(buf, tostring(nanos)) + end +end + +local WRAPPER_PROTO = { + DoubleValue = 'double', FloatValue = 'float', + Int64Value = 'int64', UInt64Value = 'uint64', + Int32Value = 'int32', UInt32Value = 'uint32', + BoolValue = 'bool', + StringValue = 'string', BytesValue = 'bytes', +} + +local function make_wrapper_text(proto_type) + return function(buf, v, depth) + -- Wrappers print their unwrapped value as `value: ` so the + -- output mirrors mainline protoc's wrapper rendering. + newline(buf, depth) + push(buf, 'value: ') + push(buf, scalar_token(proto_type, v)) + end +end + +local function emit_value_oneof(buf, v, depth) + local field_name, token + if v == nil or v == pbwkt.NULL then + field_name, token = 'null_value', 'NULL_VALUE' + elseif type(v) == 'boolean' then + field_name, token = 'bool_value', v and 'true' or 'false' + elseif type(v) == 'number' then + field_name, token = 'number_value', format_float(v) + elseif type(v) == 'cdata' then + field_name, token = 'number_value', format_float(tonumber(v)) + elseif type(v) == 'string' then + field_name, token = 'string_value', escape_string(v) + elseif type(v) == 'table' then + local mt = getmetatable(v) + local is_list = (mt and mt.__pb_kind == 'list') or (mt == nil and v[1] ~= nil) + if is_list then + emit_block(buf, 'list_value', depth, function(b, d) + M.emit_list_value(b, v, d) + end) + else + emit_block(buf, 'struct_value', depth, function(b, d) + M.emit_struct(b, v, d) + end) + end + return + else + error('Value text: unsupported Lua type ' .. type(v), 0) + end + newline(buf, depth) + push(buf, field_name); push(buf, ': '); push(buf, token) +end + +local function emit_struct(buf, t, depth) + for k, v in pairs(t) do + newline(buf, depth) + emit_block(buf, 'fields', depth, function(b, d) + newline(b, d) + push(b, 'key: '); push(b, escape_string(tostring(k))) + -- The map value is a Value WKT. Wrap it in `value { ... }`. + newline(b, d) + emit_block(b, 'value', d, function(b2, d2) + emit_value_oneof(b2, v, d2) + end) + end) + end +end + +local function emit_list_value(buf, t, depth) + for i = 1, #t do + newline(buf, depth) + emit_block(buf, 'values', depth, function(b, d) + emit_value_oneof(b, t[i], d) + end) + end +end + +local function emit_fieldmask(buf, t, depth) + if type(t) ~= 'table' then return end + for i = 1, #t do + newline(buf, depth) + push(buf, 'paths: '); push(buf, escape_string(t[i])) + end +end + +local function emit_any(buf, t, depth) + if type(t) ~= 'table' then return end + local type_url = t.type_url + local value = t.value + if type_url ~= nil and type_url ~= '' then + newline(buf, depth) + push(buf, 'type_url: '); push(buf, escape_string(type_url)) + end + if value ~= nil and value ~= '' then + newline(buf, depth) + push(buf, 'value: '); push(buf, escape_string(value)) + end +end + +WKT_TEXT = { + ['google.protobuf.Empty'] = function() end, + ['google.protobuf.Timestamp'] = emit_seconds_nanos, + ['google.protobuf.Duration'] = emit_seconds_nanos, + ['google.protobuf.Struct'] = emit_struct, + ['google.protobuf.ListValue'] = emit_list_value, + ['google.protobuf.Value'] = emit_value_oneof, + ['google.protobuf.FieldMask'] = emit_fieldmask, + ['google.protobuf.Any'] = emit_any, +} +for short, pt in pairs(WRAPPER_PROTO) do + WKT_TEXT['google.protobuf.' .. short] = make_wrapper_text(pt) +end + +-- Exposed so the Value override can recurse through Struct / ListValue +-- without re-resolving the WKT table. +M.emit_struct = emit_struct +M.emit_list_value = emit_list_value + +-- --------------------------------------------------------------------------- +-- Public API +-- --------------------------------------------------------------------------- + +function M.encode(desc, t, opts) + local buf = new_buf(opts or {}) + emit_message(buf, desc, t, 0) + if not buf.single_line and buf.n > 0 then push(buf, '\n') end + return table.concat(buf.chunks, '', 1, buf.n) +end + +-- Exposed for pb.wkt's text overrides. +M.scalar_token = scalar_token +M.enum_token = enum_token +M.escape_string = escape_string +M.int_to_string = int_to_string +M.format_float = format_float +M.push = push +M.newline = newline +M.emit_block = emit_block +M.emit_message = emit_message +M.emit_one = emit_one + +return M diff --git a/test/text_test.lua b/test/text_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..0f1e7493a7a5c28ce2f42e528baab0a6b7d22300 --- /dev/null +++ b/test/text_test.lua @@ -0,0 +1,274 @@ +-- Protobuf text-format printer tests. +local t = require('luatest') +local ffi = require('ffi') +local pb = require('pb') +local datetime = require('datetime') + +-- Both codegen modes share the same descriptors; the printer walks +-- descriptors only, so the output is mode-independent. Exercise both +-- to confirm parity. +local MODES = {'full', 'runtime'} + +for _, mode in ipairs(MODES) do + local g = t.group('text.' .. mode) + local hello = require(mode .. '.hello.hello_pb') + + -- ---- scalars ------------------------------------------------------- + + g.test_empty_message = function() + t.assert_equals(pb.text.encode(hello.Address_descriptor, {}), '') + end + + g.test_basic_scalars = function() + local s = pb.text.encode(hello.Address_descriptor, + {street = 'Pushkina 1', city = 'Moscow', zip = 123456}) + t.assert_equals(s, 'street: "Pushkina 1"\ncity: "Moscow"\nzip: 123456\n') + end + + g.test_proto3_default_elision = function() + -- Defaults on non-optional fields are elided like protoc --decode. + local s = pb.text.encode(hello.Address_descriptor, + {street = '', city = '', zip = 0}) + t.assert_equals(s, '') + end + + g.test_optional_field_emits_default = function() + -- `apartment` is proto3 explicit-optional; explicit empty must + -- round-trip even though it equals the scalar default. + local s = pb.text.encode(hello.Address_descriptor, + {street = 'Main', zip = 1, apartment = ''}) + t.assert_equals(s, 'street: "Main"\nzip: 1\napartment: ""\n') + end + + g.test_int64_lossless = function() + local s = pb.text.encode(hello.Person_descriptor, + {user_id = ffi.cast('uint64_t', 18369917520866213889ULL)}) + t.assert_str_contains(s, 'user_id: 18369917520866213889') + end + + g.test_bytes_octal_escape = function() + local s = pb.text.encode(hello.Person_descriptor, + {avatar = '\x00\x01\x02\xff'}) + t.assert_str_contains(s, 'avatar: "\\000\\001\\002\\377"') + end + + g.test_string_escapes = function() + local s = pb.text.encode(hello.Person_descriptor, + {name = 'a"b\\c\nd\te'}) + t.assert_str_contains(s, 'name: "a\\"b\\\\c\\nd\\te"') + end + + g.test_enum_as_name = function() + local s = pb.text.encode(hello.Person_descriptor, {status = hello.Status.ERROR}) + t.assert_str_contains(s, 'status: ERROR') + end + + g.test_enum_unknown_as_number = function() + local s = pb.text.encode(hello.Person_descriptor, {status = 42}) + t.assert_str_contains(s, 'status: 42') + end + + g.test_float_formats = function() + local s = pb.text.encode(hello.Person_descriptor, {weight_kg = 72.5}) + t.assert_str_contains(s, 'weight_kg: 72.5') + end + + g.test_float_specials = function() + local s = pb.text.encode(hello.Person_descriptor, {weight_kg = 0/0}) + t.assert_str_contains(s, 'weight_kg: nan') + s = pb.text.encode(hello.Person_descriptor, {weight_kg = math.huge}) + t.assert_str_contains(s, 'weight_kg: inf') + s = pb.text.encode(hello.Person_descriptor, {weight_kg = -math.huge}) + t.assert_str_contains(s, 'weight_kg: -inf') + end + + -- ---- repeated ------------------------------------------------------ + + g.test_repeated_scalar = function() + local s = pb.text.encode(hello.Person_descriptor, + {lucky_numbers = {1, 2, 3}}) + t.assert_equals(s, 'lucky_numbers: 1\nlucky_numbers: 2\nlucky_numbers: 3\n') + end + + g.test_repeated_string = function() + local s = pb.text.encode(hello.Person_descriptor, + {emails = {'a@x', 'b@x'}}) + t.assert_equals(s, 'emails: "a@x"\nemails: "b@x"\n') + end + + -- ---- nested message + oneof --------------------------------------- + + g.test_nested_message = function() + local s = pb.text.encode(hello.Person_descriptor, { + name = 'P', + address = {street = 'Main', city = 'X', zip = 1}, + }) + t.assert_equals(s, + 'name: "P"\naddress {\n street: "Main"\n city: "X"\n zip: 1\n}\n') + end + + g.test_oneof_scalar = function() + local s = pb.text.encode(hello.Result_descriptor, {id = 7, text = 'hi'}) + t.assert_equals(s, 'id: 7\ntext: "hi"\n') + end + + g.test_oneof_message = function() + local s = pb.text.encode(hello.Result_descriptor, + {id = 3, details = {street = 'X', zip = 99}}) + t.assert_equals(s, 'id: 3\ndetails {\n street: "X"\n zip: 99\n}\n') + end + + -- ---- map ----------------------------------------------------------- + + g.test_map_single_entry = function() + local s = pb.text.encode(hello.Person_descriptor, + {ages_by_nickname = {alice = 30}}) + t.assert_equals(s, + 'ages_by_nickname {\n key: "alice"\n value: 30\n}\n') + end + + g.test_map_message_value = function() + local s = pb.text.encode(hello.Person_descriptor, + {addresses_by_label = {home = {street = 'Main', zip = 1}}}) + t.assert_str_contains(s, 'addresses_by_label {') + t.assert_str_contains(s, 'key: "home"') + t.assert_str_contains(s, 'value {') + t.assert_str_contains(s, 'street: "Main"') + end + + -- ---- single-line -------------------------------------------------- + + g.test_single_line = function() + local s = pb.text.encode(hello.Address_descriptor, + {street = 'X', zip = 1}, + {single_line = true}) + t.assert_equals(s, 'street: "X" zip: 1') + end + + g.test_single_line_nested = function() + local s = pb.text.encode(hello.Person_descriptor, + {name = 'P', address = {street = 'X', zip = 1}}, + {single_line = true}) + t.assert_equals(s, 'name: "P" address { street: "X" zip: 1 }') + end + + -- ---- WKT ---------------------------------------------------------- + + g.test_wkt_empty = function() + local s = pb.text.encode(hello.Event_descriptor, + {title = 'x', ack = {}}) + t.assert_equals(s, 'title: "x"\nack {}\n') + end + + g.test_wkt_timestamp_from_table = function() + local s = pb.text.encode(hello.Event_descriptor, + {created_at = {seconds = 1700000000, nanos = 123}}) + t.assert_str_contains(s, 'created_at {') + t.assert_str_contains(s, 'seconds: 1700000000') + t.assert_str_contains(s, 'nanos: 123') + end + + g.test_wkt_timestamp_from_datetime = function() + local dt = datetime.new({timestamp = 1700000000}) + local s = pb.text.encode(hello.Event_descriptor, {created_at = dt}) + t.assert_str_contains(s, 'seconds: 1700000000') + end + + g.test_wkt_duration = function() + local s = pb.text.encode(hello.Event_descriptor, + {duration = {seconds = 5}}) + t.assert_str_contains(s, 'duration {') + t.assert_str_contains(s, 'seconds: 5') + end + + g.test_wkt_wrapper_int32 = function() + local s = pb.text.encode(hello.Event_descriptor, {retry_count = 7}) + t.assert_str_contains(s, 'retry_count {') + t.assert_str_contains(s, 'value: 7') + end + + g.test_wkt_wrapper_string = function() + local s = pb.text.encode(hello.Event_descriptor, {note = 'hello'}) + t.assert_str_contains(s, 'note {') + t.assert_str_contains(s, 'value: "hello"') + end + + g.test_wkt_wrapper_bool = function() + local s = pb.text.encode(hello.Event_descriptor, {is_admin = true}) + t.assert_str_contains(s, 'is_admin {') + t.assert_str_contains(s, 'value: true') + end + + g.test_wkt_fieldmask = function() + local s = pb.text.encode(hello.Event_descriptor, + {update_mask = {'title', 'extension'}}) + t.assert_str_contains(s, 'update_mask {') + t.assert_str_contains(s, 'paths: "title"') + t.assert_str_contains(s, 'paths: "extension"') + end + + g.test_wkt_any_opaque = function() + local s = pb.text.encode(hello.Event_descriptor, + {extension = {type_url = 'type.googleapis.com/X', value = '\x01\x02'}}) + t.assert_str_contains(s, 'extension {') + t.assert_str_contains(s, 'type_url: "type.googleapis.com/X"') + t.assert_str_contains(s, 'value: "\\001\\002"') + end + + g.test_wkt_struct = function() + local s = pb.text.encode(hello.Event_descriptor, + {payload = {region = 'us-east-1'}}) + t.assert_str_contains(s, 'payload {') + t.assert_str_contains(s, 'fields {') + t.assert_str_contains(s, 'key: "region"') + t.assert_str_contains(s, 'string_value: "us-east-1"') + end + + g.test_wkt_value_scalar = function() + local s = pb.text.encode(hello.Event_descriptor, {attribute = 'hi'}) + t.assert_str_contains(s, 'attribute {') + t.assert_str_contains(s, 'string_value: "hi"') + end + + g.test_wkt_value_null = function() + local s = pb.text.encode(hello.Event_descriptor, {attribute = pb.NULL}) + t.assert_str_contains(s, 'null_value: NULL_VALUE') + end + + g.test_wkt_listvalue = function() + local s = pb.text.encode(hello.Event_descriptor, + {tags = pb.wkt.list({1, 'two', true})}) + t.assert_str_contains(s, 'tags {') + t.assert_str_contains(s, 'values {') + t.assert_str_contains(s, 'number_value: 1.0') + t.assert_str_contains(s, 'string_value: "two"') + t.assert_str_contains(s, 'bool_value: true') + end + + -- ---- top-level WKT (descriptor itself is a WKT) ------------------- + + g.test_top_level_timestamp = function() + local s = pb.text.encode(pb.wkt.Timestamp_descriptor, + {seconds = 100, nanos = 1}) + t.assert_equals(s, 'seconds: 100\nnanos: 1\n') + end + + g.test_top_level_fieldmask = function() + local s = pb.text.encode(pb.wkt.FieldMask_descriptor, {'a', 'b'}) + t.assert_equals(s, 'paths: "a"\npaths: "b"\n') + end + + g.test_top_level_empty = function() + local s = pb.text.encode(pb.wkt.Empty_descriptor, {}) + t.assert_equals(s, '') + end + + -- ---- generated codegen wrapper ------------------------------------ + + g.test_generated_text_wrapper = function() + local s = hello.Address_text({street = 'X', zip = 1}) + t.assert_equals(s, 'street: "X"\nzip: 1\n') + s = hello.Address_text({street = 'X', zip = 1}, {single_line = true}) + t.assert_equals(s, 'street: "X" zip: 1') + end +end