~bigbes/tarantool

tarantool-protobuf

0044b1632684703ed499d1d7d6742536ff09c18e — Eugene Blikh 3 months ago b75b879
codegen: text-format printer (pb.text.encode + <Msg>_text wrappers)

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.<Type>_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.
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