~bigbes/tarantool

tarantool-protobuf

ref: b1d9c7581bcc6b4f299155746dc04acc2330e78c tarantool-protobuf/runtime/pb/text.lua -rw-r--r-- 13.5 KiB
b1d9c758 — Eugene Blikh test: pin unknown-fields text-format conformance regressions 3 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
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