M cmd/protoc-gen-tarantool/internal/gen/emmylua.go => cmd/protoc-gen-tarantool/internal/gen/emmylua.go +5 -0
@@ 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
)
M cmd/protoc-gen-tarantool/internal/gen/gen.go => cmd/protoc-gen-tarantool/internal/gen/gen.go +2 -0
@@ 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("")
}
M cmd/protoc-gen-tarantool/internal/gen/inline.go => cmd/protoc-gen-tarantool/internal/gen/inline.go +2 -0
@@ 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("")
}
M examples/expected/full/conformance/conformance_pb.lua => examples/expected/full/conformance/conformance_pb.lua +20 -0
@@ 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
M examples/expected/full/hello/hello_pb.lua => examples/expected/full/hello/hello_pb.lua +24 -0
@@ 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 = {
M examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua => examples/expected/full/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +20 -0
@@ 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
M examples/expected/runtime/conformance/conformance_pb.lua => examples/expected/runtime/conformance/conformance_pb.lua +20 -0
@@ 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
M examples/expected/runtime/hello/hello_pb.lua => examples/expected/runtime/hello/hello_pb.lua +24 -0
@@ 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 = {
M examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua => examples/expected/runtime/protobuf_test_messages/proto3/test_messages_proto3_pb.lua +20 -0
@@ 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
M runtime/pb/init.lua => runtime/pb/init.lua +5 -0
@@ 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,
A runtime/pb/text.lua => runtime/pb/text.lua +409 -0
@@ 0,0 1,409 @@
+-- Protobuf text-format encoder.
+--
+-- Output matches the form produced by `protoc --decode=<msg>`:
+-- * 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<K,V> 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: <token>` 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
A test/text_test.lua => test/text_test.lua +274 -0
@@ 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