-- 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