-- Protobuf text-format encoder. -- -- Output matches the form produced by `protoc --decode=`: -- * one field per line, 2-space indent -- * `name: value` for scalars / enums -- * `name { ... }` for sub-messages and map entries (no `:`) -- * repeated fields emit the field once per element -- * map emits as repeated synthetic `key:` / `value:` entries -- * field names use the original snake_case (mainline convention) -- -- A single-line variant is available via `opts.single_line = true` — fields -- are space-separated and message bodies stay on one line. Useful for -- compact debug logging and inline goldens. -- -- WKT extension hook: a descriptor with a `desc.text(t, buf, depth)` -- function takes over body emission (used by pb.wkt to format Timestamp / -- Duration / Struct / etc. from their idiomatic Lua shapes). -- -- Decoding is not part of M7; this module is encode-only. local ffi = require('ffi') local datetime = require('datetime') local pbwkt = require('pb.wkt') local M = {} local INT64_FAMILY = {int64=true, uint64=true, sint64=true, fixed64=true, sfixed64=true} local UINT_FAMILY = {uint32=true, uint64=true, fixed32=true, fixed64=true} local INT64_ZERO = ffi.cast('int64_t', 0) local UINT64_ZERO = ffi.cast('uint64_t', 0) -- --------------------------------------------------------------------------- -- Primitives -- --------------------------------------------------------------------------- local function int_to_string(v) if type(v) == 'cdata' then return tostring(v):gsub('U?LL$', '') end return tostring(v) end local function escape_string(s) local out = {'"'} local n = 1 for i = 1, #s do local b = s:byte(i) if b == 0x5c then n = n + 1; out[n] = '\\\\' elseif b == 0x22 then n = n + 1; out[n] = '\\"' elseif b == 0x27 then n = n + 1; out[n] = "\\'" elseif b == 0x0a then n = n + 1; out[n] = '\\n' elseif b == 0x0d then n = n + 1; out[n] = '\\r' elseif b == 0x09 then n = n + 1; out[n] = '\\t' elseif b >= 0x20 and b < 0x7f then n = n + 1; out[n] = string.char(b) else n = n + 1; out[n] = string.format('\\%03o', b) end end n = n + 1; out[n] = '"' return table.concat(out) end local function format_float(v) if v ~= v then return 'nan' end if v == math.huge then return 'inf' end if v == -math.huge then return '-inf' end if v == math.floor(v) and math.abs(v) < 1e16 then return string.format('%.1f', v) end return tostring(v) end local function scalar_token(proto_type, v) if INT64_FAMILY[proto_type] then return int_to_string(v) end if UINT_FAMILY[proto_type] then return int_to_string(v) end if proto_type == 'float' or proto_type == 'double' then if type(v) ~= 'number' then v = tonumber(v) end return format_float(v) end if proto_type == 'bool' then return v and 'true' or 'false' end if proto_type == 'bytes' or proto_type == 'string' then return escape_string(v) end return tostring(v) -- int32 / sint32 / sfixed32 end local function enum_token(enum_desc, v) if type(v) == 'string' then return v end local name = enum_desc.by_value[tonumber(v)] if name ~= nil then return name end return tostring(v) end local function is_proto3_default(f, v) if f.optional or f.oneof then return false end if f.kind == 'scalar' then local pt = f.proto_type if pt == 'string' or pt == 'bytes' then return v == '' end if pt == 'bool' then return v == false end if type(v) == 'cdata' then return v == INT64_ZERO or v == UINT64_ZERO end return v == 0 elseif f.kind == 'enum' then return v == 0 or v == f.enum.by_value[0] end return false end -- --------------------------------------------------------------------------- -- Buffer + emit primitives -- --------------------------------------------------------------------------- local function new_buf(opts) return { chunks = {}, n = 0, single_line = opts.single_line and true or false, indent_unit = opts.indent or ' ', } end local function push(buf, s) buf.n = buf.n + 1 buf.chunks[buf.n] = s end -- newline writes a field separator: newline+indent in pretty mode, single -- space in single-line mode. At the start of an otherwise-empty buffer it -- writes nothing so the encode output doesn't lead with whitespace. local function newline(buf, depth) if buf.n == 0 then return end if buf.single_line then push(buf, ' ') return end push(buf, '\n') if depth > 0 then push(buf, string.rep(buf.indent_unit, depth)) end end local emit_message -- forward local emit_field -- forward -- emit_block writes `prefix {`, then calls body_fn(buf, depth+1) to fill -- the body. If the body emits nothing, output collapses to `prefix {}`. local function emit_block(buf, prefix, depth, body_fn) push(buf, prefix); push(buf, ' {') local pre_n = buf.n body_fn(buf, depth + 1) if buf.n == pre_n then push(buf, '}') return end newline(buf, depth) push(buf, '}') end -- --------------------------------------------------------------------------- -- Field emission -- --------------------------------------------------------------------------- local function emit_one(buf, f, v, depth) local kind = f.kind if kind == 'scalar' then push(buf, f.name); push(buf, ': ') push(buf, scalar_token(f.proto_type, v)) elseif kind == 'enum' then push(buf, f.name); push(buf, ': ') push(buf, enum_token(f.enum, v)) elseif kind == 'message' then emit_block(buf, f.name, depth, function(b, d) emit_message(b, f.message, v, d) end) else error('text.encode: unknown field kind ' .. tostring(kind), 0) end end local function emit_map_entry(buf, f, k, v, depth) local kf, vf = f.key, f.value emit_block(buf, f.name, depth, function(b, d) newline(b, d) emit_one(b, {name='key', kind=kf.kind, proto_type=kf.proto_type, enum=kf.enum, message=kf.message}, k, d) newline(b, d) emit_one(b, {name='value', kind=vf.kind, proto_type=vf.proto_type, enum=vf.enum, message=vf.message}, v, d) end) end emit_field = function(buf, f, v, depth) if f.kind == 'map' then for k, mv in pairs(v) do newline(buf, depth) emit_map_entry(buf, f, k, mv, depth) end elseif f.repeated then for i = 1, #v do newline(buf, depth) emit_one(buf, f, v[i], depth) end else if is_proto3_default(f, v) then return end newline(buf, depth) emit_one(buf, f, v, depth) end end local WKT_TEXT -- forward (filled below) emit_message = function(buf, desc, t, depth) -- Use type() rather than == nil so box.NULL (a nil-equal cdata used as -- the Value WKT's null_value sentinel) survives the guard. if type(t) == 'nil' then return end local wkt_fn = WKT_TEXT[desc.name] if wkt_fn ~= nil then wkt_fn(buf, t, depth) return end if desc.text ~= nil then desc.text(t, buf, depth) return end for _, f in ipairs(desc.fields) do local v = t[f.name] -- `box.NULL == nil` via cdata __eq metamethod, so a `v ~= nil` -- guard would silently drop a NULL Value WKT. Compare on type. if type(v) ~= 'nil' then emit_field(buf, f, v, depth) end end end -- --------------------------------------------------------------------------- -- Well-known type text emitters -- --------------------------------------------------------------------------- -- -- Each takes (buf, value, depth) and emits the message body — i.e. what -- would go between `{` and `}` if this WKT appeared as a field value. For -- the top-level form (`pb.text.encode(M.Timestamp_descriptor, dt)`) this -- is the entire output. -- -- The WKT entries below mirror our Lua representations (datetime cdata for -- Timestamp, unwrapped scalars for wrappers, hash table for Struct, etc.) -- so the user can hand a real Lua value to the printer without first -- converting it back to the proto message shape. local function emit_seconds_nanos(buf, t, depth) local seconds, nanos if type(t) == 'table' then seconds = t.seconds or 0 nanos = t.nanos or 0 elseif type(t) == 'cdata' and datetime.is_datetime(t) then seconds = tonumber(t.epoch) nanos = t.nsec elseif type(t) == 'number' then seconds = math.floor(t) nanos = math.floor((t - seconds) * 1e9 + 0.5) else error('Timestamp/Duration text: unsupported value type ' .. type(t), 0) end if seconds ~= 0 and seconds ~= ffi.cast('int64_t', 0) then newline(buf, depth) push(buf, 'seconds: '); push(buf, int_to_string(seconds)) end if nanos ~= 0 then newline(buf, depth) push(buf, 'nanos: '); push(buf, tostring(nanos)) end end local WRAPPER_PROTO = { DoubleValue = 'double', FloatValue = 'float', Int64Value = 'int64', UInt64Value = 'uint64', Int32Value = 'int32', UInt32Value = 'uint32', BoolValue = 'bool', StringValue = 'string', BytesValue = 'bytes', } local function make_wrapper_text(proto_type) return function(buf, v, depth) -- Wrappers print their unwrapped value as `value: ` so the -- output mirrors mainline protoc's wrapper rendering. newline(buf, depth) push(buf, 'value: ') push(buf, scalar_token(proto_type, v)) end end local function emit_value_oneof(buf, v, depth) local field_name, token if v == nil or v == pbwkt.NULL then field_name, token = 'null_value', 'NULL_VALUE' elseif type(v) == 'boolean' then field_name, token = 'bool_value', v and 'true' or 'false' elseif type(v) == 'number' then field_name, token = 'number_value', format_float(v) elseif type(v) == 'cdata' then field_name, token = 'number_value', format_float(tonumber(v)) elseif type(v) == 'string' then field_name, token = 'string_value', escape_string(v) elseif type(v) == 'table' then local mt = getmetatable(v) local is_list = (mt and mt.__pb_kind == 'list') or (mt == nil and v[1] ~= nil) if is_list then emit_block(buf, 'list_value', depth, function(b, d) M.emit_list_value(b, v, d) end) else emit_block(buf, 'struct_value', depth, function(b, d) M.emit_struct(b, v, d) end) end return else error('Value text: unsupported Lua type ' .. type(v), 0) end newline(buf, depth) push(buf, field_name); push(buf, ': '); push(buf, token) end local function emit_struct(buf, t, depth) for k, v in pairs(t) do newline(buf, depth) emit_block(buf, 'fields', depth, function(b, d) newline(b, d) push(b, 'key: '); push(b, escape_string(tostring(k))) -- The map value is a Value WKT. Wrap it in `value { ... }`. newline(b, d) emit_block(b, 'value', d, function(b2, d2) emit_value_oneof(b2, v, d2) end) end) end end local function emit_list_value(buf, t, depth) for i = 1, #t do newline(buf, depth) emit_block(buf, 'values', depth, function(b, d) emit_value_oneof(b, t[i], d) end) end end local function emit_fieldmask(buf, t, depth) if type(t) ~= 'table' then return end for i = 1, #t do newline(buf, depth) push(buf, 'paths: '); push(buf, escape_string(t[i])) end end local function emit_any(buf, t, depth) if type(t) ~= 'table' then return end local type_url = t.type_url local value = t.value if type_url ~= nil and type_url ~= '' then newline(buf, depth) push(buf, 'type_url: '); push(buf, escape_string(type_url)) end if value ~= nil and value ~= '' then newline(buf, depth) push(buf, 'value: '); push(buf, escape_string(value)) end end WKT_TEXT = { ['google.protobuf.Empty'] = function() end, ['google.protobuf.Timestamp'] = emit_seconds_nanos, ['google.protobuf.Duration'] = emit_seconds_nanos, ['google.protobuf.Struct'] = emit_struct, ['google.protobuf.ListValue'] = emit_list_value, ['google.protobuf.Value'] = emit_value_oneof, ['google.protobuf.FieldMask'] = emit_fieldmask, ['google.protobuf.Any'] = emit_any, } for short, pt in pairs(WRAPPER_PROTO) do WKT_TEXT['google.protobuf.' .. short] = make_wrapper_text(pt) end -- Exposed so the Value override can recurse through Struct / ListValue -- without re-resolving the WKT table. M.emit_struct = emit_struct M.emit_list_value = emit_list_value -- --------------------------------------------------------------------------- -- Public API -- --------------------------------------------------------------------------- function M.encode(desc, t, opts) local buf = new_buf(opts or {}) emit_message(buf, desc, t, 0) if not buf.single_line and buf.n > 0 then push(buf, '\n') end return table.concat(buf.chunks, '', 1, buf.n) end -- Exposed for pb.wkt's text overrides. M.scalar_token = scalar_token M.enum_token = enum_token M.escape_string = escape_string M.int_to_string = int_to_string M.format_float = format_float M.push = push M.newline = newline M.emit_block = emit_block M.emit_message = emit_message M.emit_one = emit_one return M