~bigbes/tarantool

tarantool-protobuf

7ec8f2b488e27723ebff9b5eea9587d3a854414b — Eugene Blikh 3 months ago bd0709d
text: add pb.text.decode and wire it into conformance dispatch

Hand-written recursive-descent parser for the textproto grammar
covering every bucket the proto3 conformance suite exercises:
decimal/hex/octal integer literals with full 32/64-bit range checks,
float specials (inf/infinity/nan any case, oversize exponents
saturating to ±inf, underflows to ±0), C-style + \u/\U string escapes
with adjacent-literal concat and surrogate rejection, aggregate {} /
<> bodies, repeated short-form `[a, b, c]`, `key: K value: V` map
entries, the `[type.googleapis.com/...]` inline Any form alongside
the direct `type_url:`/`value:` form, enum-by-name-or-number,
reserved-name silent drop, numeric-field-ID tolerance, and
duplicate-singular-field rejection.

Plugin gains a small reserved_names emitter so the parser can match
mainline TextFormat::Parser's "silently drop reserved" rule. The Any
WKT descriptor advertises its real fields (type_url + string,
value + bytes) so the generic body walker can populate it directly
when the input doesn't use the inline-URL form.

cmd/conformance/core.lua stops short-circuiting text_payload to
`skipped` and runs it through pb.text.decode. The proto3 TextFormat
input suite climbs from 8 ✓ / 426 skipped to 406 ✓ / 18 skipped / 10
expected failures. The 10 surviving failures all share one cause
(proto3 -0.0 elision in the codec, not a parser bug — documented in
test/conformance/known_failures_text.txt). Binary+JSON conformance
holds at 1478 ✓. 591 unit tests pass across both codegen modes.

Closes the text-conformance-output branch.
M PLAN.md => PLAN.md +16 -1
@@ 270,7 270,22 @@ fiber and bridges client ↔ handler via `fiber.channel`. All four flavors
      or `{seconds,nanos}`, wrappers print their unwrapped scalar as
      `value: ...`, `Struct`/`Value`/`ListValue` walk the tagged-table
      form, `FieldMask` prints `paths: ...` per entry, `Any` stays opaque.
      Encode-only; the matching parser is deferred.
- [x] Text-format parser. `pb.text.decode(desc, text, opts)` is the
      recursive-descent counterpart: handles every grammar bucket the
      proto3 conformance suite exercises — decimal/hex/octal int literals,
      float specials (`inf`/`infinity`/`nan` any case, oversize exponents
      saturating to ±inf, underflow to ±0), C-style + `\u`/`\U` string
      escapes with adjacent-literal concat and surrogate rejection,
      aggregate `{}` / `<>` bodies, repeated short-form `[a, b, c]`,
      `key: K value: V` map entries, the `[type.googleapis.com/...]`
      inline Any form, enum-by-name-or-number, reserved-name drop, and
      numeric-field-ID tolerance. Range-checks 32/64-bit ints, rejects
      duplicate singular fields, and threads through the conformance
      runner — `cmd/conformance/core.lua` no longer skips `text_payload`.
      Proto3 text-format conformance suite: **8 ✓ / 426 skipped → 406 ✓
      / 18 skipped / 10 expected failures** (the 10 are `-0` float/double
      preservation; their root cause is in the codec, not the parser —
      see `test/conformance/known_failures_text.txt`).
- [x] `protoc-gen-tarantool-doc`: sibling Go plugin under
      `cmd/protoc-gen-tarantool-doc/` that emits one Markdown file per
      input `.proto`. Sections: header (package + imports), messages

M cmd/conformance/core.lua => cmd/conformance/core.lua +5 -2
@@ 60,8 60,11 @@ local function dispatch(req)
    elseif req.jspb_payload ~= nil then
        return {skipped = 'jspb input not supported'}
    elseif req.text_payload ~= nil then
        -- pb.text is encode-only; text-format input parsing is deferred.
        return {skipped = 'text-format input not supported'}
        local ok, decoded = pcall(pb.text.decode, desc, req.text_payload)
        if not ok then
            return {parse_error = 'text decode failed: ' .. tostring(decoded)}
        end
        msg = decoded
    else
        return {runtime_error = 'no payload set in ConformanceRequest'}
    end

M cmd/protoc-gen-tarantool/internal/gen/gen.go => cmd/protoc-gen-tarantool/internal/gen/gen.go +18 -0
@@ 237,6 237,7 @@ func emitMessageFields(w *writer, file *protogen.File, m *protogen.Message, impo
	}
	w.line("}")
	emitOneofTable(w, name, m)
	emitReservedNames(w, name, m)
	w.line("pb.finalize_message(M.%s_descriptor)", name)
	w.line("")
}


@@ 275,6 276,23 @@ func emitOneofTable(w *writer, name string, m *protogen.Message) {
	w.line("}")
}

// emitReservedNames emits `M.<Name>_descriptor.reserved_names = { ["x"] = true }`
// when the message declares any reserved field names. The text-format decoder
// uses this to silently drop fields named in `reserved "..."` declarations.
// Reserved field numbers are not emitted: unknown numeric IDs fall through the
// same drop path as truly unknown fields.
func emitReservedNames(w *writer, name string, m *protogen.Message) {
	rn := m.Desc.ReservedNames()
	if rn.Len() == 0 {
		return
	}
	w.line("M.%s_descriptor.reserved_names = {", name)
	for i := 0; i < rn.Len(); i++ {
		w.line("    [%q] = true,", string(rn.Get(i)))
	}
	w.line("}")
}

// renderFieldEntry produces the Lua table literal for a single field descriptor.
func renderFieldEntry(file *protogen.File, f *protogen.Field, selfPath string, imports map[string]string, prefix string) string {
	parts := []string{

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 +3 -0
@@ 209,6 209,9 @@ M.TestAllTypesProto3_descriptor.fields = {
M.TestAllTypesProto3_descriptor.oneofs = {
    oneof_field = {"oneof_uint32", "oneof_nested_message", "oneof_string", "oneof_bytes", "oneof_bool", "oneof_uint64", "oneof_float", "oneof_double", "oneof_enum", "oneof_null_value"},
}
M.TestAllTypesProto3_descriptor.reserved_names = {
    ["reserved_field"] = true,
}
pb.finalize_message(M.TestAllTypesProto3_descriptor)

-- Message: protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage

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 +3 -0
@@ 209,6 209,9 @@ M.TestAllTypesProto3_descriptor.fields = {
M.TestAllTypesProto3_descriptor.oneofs = {
    oneof_field = {"oneof_uint32", "oneof_nested_message", "oneof_string", "oneof_bytes", "oneof_bool", "oneof_uint64", "oneof_float", "oneof_double", "oneof_enum", "oneof_null_value"},
}
M.TestAllTypesProto3_descriptor.reserved_names = {
    ["reserved_field"] = true,
}
pb.finalize_message(M.TestAllTypesProto3_descriptor)

-- Message: protobuf_test_messages.proto3.TestAllTypesProto3.NestedMessage

M runtime/pb/text.lua => runtime/pb/text.lua +965 -1
@@ 16,7 16,10 @@
-- 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.
-- Decoder lives at the bottom of the file (see "Text-format parser"). It
-- shares the WKT extension hook: a descriptor with `desc.text_decode(text,
-- opts)` takes over body parsing, mirroring the encode-side `desc.text`.
local bit      = require('bit')
local ffi      = require('ffi')
local datetime = require('datetime')
local pbwkt    = require('pb.wkt')


@@ 26,6 29,9 @@ 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 UINT64       = ffi.typeof('uint64_t')
local INT64        = ffi.typeof('int64_t')
local FLOAT32      = ffi.typeof('float[1]')
local INT64_ZERO   = ffi.cast('int64_t', 0)
local UINT64_ZERO  = ffi.cast('uint64_t', 0)



@@ 507,4 513,962 @@ M.emit_block    = emit_block
M.emit_message  = emit_message
M.emit_one      = emit_one

-- ===========================================================================
-- Text-format parser (decode side).
--
-- Recursive descent over a single string. Mirrors the encoder's grammar
-- buckets (numbers in all radixes, float specials, string/bytes literals
-- with escapes + adjacent concat, aggregate `{}` / `<>` bodies, repeated
-- short-form lists, map entries, Any inline `[type.url] { ... }`, and
-- enum-by-name-or-number). Silently drops `reserved` field names and
-- numeric field IDs that don't resolve in the schema, mirroring mainline
-- protoc's `AllowFieldNumber` behavior under the conformance harness.
--
-- The output is the same Lua-table shape `pb.decode` produces, so
-- downstream encode / JSON / text passes work without conversion.
-- ===========================================================================

local DEFAULT_DEPTH_LIMIT = 100

-- ---- lexer / cursor -------------------------------------------------------

local function err(S, msg)
    -- 0 disables the file:line prefix from error() so the message lands
    -- intact in the conformance harness's parse_error response body.
    error(('text.decode: %s at offset %d'):format(msg, S.pos), 0)
end

local function skip_ws(S)
    local src, pos, len = S.src, S.pos, S.len
    while pos <= len do
        local c = src:byte(pos)
        if c == 0x20 or c == 0x09 or c == 0x0a or c == 0x0d then
            pos = pos + 1
        elseif c == 0x23 then  -- '#' line comment
            local nl = src:find('\n', pos + 1, true)
            pos = nl and (nl + 1) or (len + 1)
        else
            break
        end
    end
    S.pos = pos
end

-- Token kinds populated by advance():
--   'eof'    value=nil
--   'punct'  value=single-char string from { : { } < > [ ] , ; - + / }
--   'ident'  value=identifier (with dots allowed, for fully-qualified names)
--   'number' value=raw lexeme (digits, possibly 0x/0..7 prefixes, decimal,
--                              exponent, trailing f/F). NO sign — `-` and
--                              `+` come through as separate punct tokens.
--   'string' value=already-decoded string body (escapes applied, adjacent
--                  literals concatenated)
local advance  -- forward

local function is_ident_start(c)
    return (c >= 0x41 and c <= 0x5a) or (c >= 0x61 and c <= 0x7a) or c == 0x5f
end
local function is_ident_cont(c)
    return is_ident_start(c) or (c >= 0x30 and c <= 0x39) or c == 0x2e
end
local function is_digit(c) return c ~= nil and c >= 0x30 and c <= 0x39 end

local function read_string_literal(S)
    -- Consumes one "..." or '...' literal, applying C-style escapes.
    -- Adjacent string literal concatenation is handled by the caller via
    -- a loop in advance().
    local src, len = S.src, S.len
    local quote = src:byte(S.pos)
    local p = S.pos + 1
    local out, n = {}, 0
    while p <= len do
        local c = src:byte(p)
        if c == quote then
            S.pos = p + 1
            return table.concat(out, '', 1, n)
        end
        if c == 0x0a or c == 0x0d then
            S.pos = p; err(S, 'unescaped newline in string literal')
        end
        if c ~= 0x5c then  -- '\\'
            n = n + 1; out[n] = string.char(c)
            p = p + 1
        else
            -- escape: consume backslash, then look at next byte
            p = p + 1
            if p > len then S.pos = p; err(S, 'unterminated escape') end
            local e = src:byte(p)
            if     e == 0x61 then n = n + 1; out[n] = '\a'; p = p + 1
            elseif e == 0x62 then n = n + 1; out[n] = '\b'; p = p + 1
            elseif e == 0x66 then n = n + 1; out[n] = '\f'; p = p + 1
            elseif e == 0x6e then n = n + 1; out[n] = '\n'; p = p + 1
            elseif e == 0x72 then n = n + 1; out[n] = '\r'; p = p + 1
            elseif e == 0x74 then n = n + 1; out[n] = '\t'; p = p + 1
            elseif e == 0x76 then n = n + 1; out[n] = '\v'; p = p + 1
            elseif e == 0x3f then n = n + 1; out[n] = '?';  p = p + 1
            elseif e == 0x27 or e == 0x22 or e == 0x5c then
                n = n + 1; out[n] = string.char(e); p = p + 1
            elseif e == 0x78 or e == 0x58 then        -- \xHH (1..2 hex)
                p = p + 1
                local h1 = src:byte(p)
                if h1 == nil or not (
                    (h1 >= 0x30 and h1 <= 0x39) or
                    (h1 >= 0x41 and h1 <= 0x46) or
                    (h1 >= 0x61 and h1 <= 0x66)) then
                    S.pos = p; err(S, "bad \\x escape")
                end
                local val = (h1 <= 0x39 and (h1 - 0x30)
                          or (h1 >= 0x61 and (h1 - 0x57) or (h1 - 0x37)))
                p = p + 1
                local h2 = src:byte(p)
                if h2 ~= nil and (
                        (h2 >= 0x30 and h2 <= 0x39) or
                        (h2 >= 0x41 and h2 <= 0x46) or
                        (h2 >= 0x61 and h2 <= 0x66)) then
                    val = val * 16 + (h2 <= 0x39 and (h2 - 0x30)
                                   or (h2 >= 0x61 and (h2 - 0x57) or (h2 - 0x37)))
                    p = p + 1
                end
                n = n + 1; out[n] = string.char(val)
            elseif e >= 0x30 and e <= 0x37 then       -- \NNN octal (1..3)
                local val = e - 0x30
                p = p + 1
                local d2 = src:byte(p)
                if d2 ~= nil and d2 >= 0x30 and d2 <= 0x37 then
                    val = val * 8 + (d2 - 0x30); p = p + 1
                    local d3 = src:byte(p)
                    if d3 ~= nil and d3 >= 0x30 and d3 <= 0x37
                            and val * 8 + (d3 - 0x30) < 0x100 then
                        val = val * 8 + (d3 - 0x30); p = p + 1
                    end
                end
                n = n + 1; out[n] = string.char(val)
            elseif e == 0x75 or e == 0x55 then        -- \uHHHH or \UHHHHHHHH
                local digits = (e == 0x75) and 4 or 8
                p = p + 1
                if p + digits - 1 > len then
                    S.pos = p; err(S, "bad \\u/\\U escape")
                end
                local cp = 0
                for i = 0, digits - 1 do
                    local h = src:byte(p + i)
                    local d
                    if     h >= 0x30 and h <= 0x39 then d = h - 0x30
                    elseif h >= 0x41 and h <= 0x46 then d = h - 0x37
                    elseif h >= 0x61 and h <= 0x66 then d = h - 0x57
                    else S.pos = p; err(S, "bad \\u/\\U hex digit")
                    end
                    cp = cp * 16 + d
                end
                p = p + digits
                -- Surrogate code points are invalid in textproto \u/\U
                -- escapes regardless of target field type (string vs
                -- bytes). Mainline's TextFormat parser rejects them as
                -- "Invalid escape sequence: <pair>".
                if cp >= 0xd800 and cp <= 0xdfff then
                    S.pos = p; err(S, 'surrogate code point in unicode escape')
                end
                -- Encode as UTF-8.
                if cp < 0x80 then
                    n = n + 1; out[n] = string.char(cp)
                elseif cp < 0x800 then
                    n = n + 1; out[n] = string.char(
                        0xc0 + bit.rshift(cp, 6),
                        0x80 + bit.band(cp, 0x3f))
                elseif cp < 0x10000 then
                    n = n + 1; out[n] = string.char(
                        0xe0 + bit.rshift(cp, 12),
                        0x80 + bit.band(bit.rshift(cp, 6), 0x3f),
                        0x80 + bit.band(cp, 0x3f))
                elseif cp <= 0x10ffff then
                    n = n + 1; out[n] = string.char(
                        0xf0 + bit.rshift(cp, 18),
                        0x80 + bit.band(bit.rshift(cp, 12), 0x3f),
                        0x80 + bit.band(bit.rshift(cp, 6), 0x3f),
                        0x80 + bit.band(cp, 0x3f))
                else
                    S.pos = p; err(S, "code point out of range")
                end
            else
                S.pos = p; err(S, 'unknown escape \\' .. string.char(e))
            end
        end
    end
    S.pos = p
    err(S, 'unterminated string literal')
end

advance = function(S)
    skip_ws(S)
    local src, pos, len = S.src, S.pos, S.len
    if pos > len then
        S.tok_kind, S.tok_value = 'eof', nil
        return
    end
    local c = src:byte(pos)
    -- single-char punct
    if c == 0x3a or c == 0x7b or c == 0x7d or c == 0x3c or c == 0x3e
            or c == 0x5b or c == 0x5d or c == 0x2c or c == 0x3b
            or c == 0x2d or c == 0x2b or c == 0x2f then
        S.tok_kind, S.tok_value = 'punct', string.char(c)
        S.pos = pos + 1
        return
    end
    -- string literal (handles adjacent concat)
    if c == 0x22 or c == 0x27 then
        local s = read_string_literal(S)
        -- Concatenate adjacent string literals: `"a" "b"` -> "ab".
        while true do
            skip_ws(S)
            if S.pos > S.len then break end
            local b = S.src:byte(S.pos)
            if b ~= 0x22 and b ~= 0x27 then break end
            s = s .. read_string_literal(S)
        end
        S.tok_kind, S.tok_value = 'string', s
        return
    end
    -- number (no sign — sign is a separate punct token)
    if is_digit(c) or c == 0x2e then
        local start = pos
        -- Hex: 0x...
        if c == 0x30 and pos + 1 <= len then
            local n2 = src:byte(pos + 1)
            if n2 == 0x78 or n2 == 0x58 then
                pos = pos + 2
                while pos <= len do
                    local b = src:byte(pos)
                    if (b >= 0x30 and b <= 0x39) or
                       (b >= 0x41 and b <= 0x46) or
                       (b >= 0x61 and b <= 0x66) then
                        pos = pos + 1
                    else break end
                end
                S.tok_kind, S.tok_value = 'number', src:sub(start, pos - 1)
                S.pos = pos
                return
            end
        end
        -- Decimal / float / octal: digits[.digits][eE±digits][fF]
        while pos <= len and is_digit(src:byte(pos)) do pos = pos + 1 end
        if pos <= len and src:byte(pos) == 0x2e then         -- '.'
            pos = pos + 1
            while pos <= len and is_digit(src:byte(pos)) do pos = pos + 1 end
        end
        if pos <= len then
            local b = src:byte(pos)
            if b == 0x65 or b == 0x45 then                   -- 'e'/'E'
                pos = pos + 1
                if pos <= len then
                    local s = src:byte(pos)
                    if s == 0x2b or s == 0x2d then pos = pos + 1 end
                end
                while pos <= len and is_digit(src:byte(pos)) do pos = pos + 1 end
            end
        end
        if pos <= len then
            local b = src:byte(pos)
            if b == 0x66 or b == 0x46 then pos = pos + 1 end   -- 'f'/'F'
        end
        S.tok_kind, S.tok_value = 'number', src:sub(start, pos - 1)
        S.pos = pos
        return
    end
    if is_ident_start(c) then
        local p = pos + 1
        while p <= len and is_ident_cont(src:byte(p)) do p = p + 1 end
        S.tok_kind, S.tok_value = 'ident', src:sub(pos, p - 1)
        S.pos = p
        return
    end
    err(S, ('unexpected character %q'):format(string.char(c)))
end

local function expect_punct(S, ch)
    if S.tok_kind ~= 'punct' or S.tok_value ~= ch then
        err(S, ('expected %q, got %s %q'):format(
            ch, S.tok_kind, tostring(S.tok_value)))
    end
    advance(S)
end

local function accept_punct(S, ch)
    if S.tok_kind == 'punct' and S.tok_value == ch then
        advance(S); return true
    end
    return false
end

-- ---- numeric literal parsing ----------------------------------------------

-- digits_to_u64 returns (u64, overflowed?). Overflow is detected via the
-- LuaJIT uint64 wrap rule: `u*b + d` wraps modulo 2^64, so a multiplication
-- that decreases the value or whose round-trip through division loses
-- precision is conclusive. Cheap enough for 20-ish digits per integer.
local function digits_to_u64(s, base)
    local u = UINT64_ZERO
    local b = UINT64(base)
    for i = 1, #s do
        local c = s:byte(i)
        local d
        if c >= 0x30 and c <= 0x39 then d = c - 0x30
        elseif c >= 0x41 and c <= 0x46 then d = c - 0x37
        elseif c >= 0x61 and c <= 0x66 then d = c - 0x57
        else return nil
        end
        if d >= base then return nil end
        local nu = u * b
        if u ~= UINT64_ZERO and nu / b ~= u then return nil, true end
        local r = nu + UINT64(d)
        if r < nu then return nil, true end
        u = r
    end
    return u, false
end

-- Parse a numeric lexeme into a uint64 cdata representing the magnitude.
-- Returns (u64, status) where status is nil on success, 'invalid' when the
-- lexeme isn't an integer-shape (caller routes to float path), or
-- 'overflow' when the magnitude exceeds 2^64-1.
local function parse_int_lexeme(lex)
    -- Hex
    if lex:sub(1, 2) == '0x' or lex:sub(1, 2) == '0X' then
        local digits = lex:sub(3)
        if #digits == 0 then return nil, 'invalid' end
        local u, ov = digits_to_u64(digits, 16)
        if ov then return nil, 'overflow' end
        return u
    end
    -- Floaty?
    if lex:find('[.eEfF]') then return nil, 'invalid' end
    -- Octal: leading 0 with more digits, and all digits in 0..7.
    if #lex >= 2 and lex:byte(1) == 0x30 then
        local digits = lex:sub(2)
        if digits:find('[^0-7]') then return nil, 'invalid' end
        local u, ov = digits_to_u64(digits, 8)
        if ov then return nil, 'overflow' end
        return u
    end
    -- Decimal
    if lex:find('[^0-9]') then return nil, 'invalid' end
    local u, ov = digits_to_u64(lex, 10)
    if ov then return nil, 'overflow' end
    return u
end

-- inf / infinity / nan, any case.
local function classify_inf_nan(ident)
    local low = ident:lower()
    if low == 'inf' or low == 'infinity' then return 'inf' end
    if low == 'nan' then return 'nan' end
    return nil
end

local function parse_float_lexeme(lex)
    -- Strip trailing f/F (C-style) — tonumber doesn't accept it.
    if lex:byte(-1) == 0x66 or lex:byte(-1) == 0x46 then
        lex = lex:sub(1, -2)
    end
    -- Hex and octal int literals are NOT valid in float fields. Reject
    -- (mainline's FloatField{No,NoNegative}{Hex,Octal} tests pin this).
    if lex:sub(1, 2) == '0x' or lex:sub(1, 2) == '0X' then return nil, 'hex' end
    if #lex >= 2 and lex:byte(1) == 0x30
            and not lex:find('[.eE]') then
        return nil, 'octal'
    end
    local v = tonumber(lex)
    if v ~= nil then return v end
    -- tonumber returns nil for exponents so huge they fall outside its
    -- exponent parser's range (typically ~1e308 for doubles). Saturate
    -- to ±inf if the exponent is positive, 0 if negative — matches
    -- mainline's Float/DoubleField{Overflow,LargeNegativeExp} cases.
    local _, expsign = lex:find('[eE]([+-]?)')
    -- Lua's :find returns positions; use :match to capture.
    local sign_chr = lex:match('[eE]([+-]?)')
    if sign_chr == nil then return nil, 'invalid' end
    if sign_chr == '-' then return 0.0 end
    return math.huge
end

-- ---- value parsers --------------------------------------------------------

local SCALAR_NUMERIC = {
    int32=true, int64=true, uint32=true, uint64=true,
    sint32=true, sint64=true, fixed32=true, fixed64=true,
    sfixed32=true, sfixed64=true,
    float=true, double=true,
}

local INT_TYPES = {
    int32=true, int64=true, uint32=true, uint64=true,
    sint32=true, sint64=true, fixed32=true, fixed64=true,
    sfixed32=true, sfixed64=true,
}

-- Magnitude limits (positive sign) for 32-bit ints. Range-check is done on
-- the parsed uint64 magnitude before applying the sign so negative values
-- can use a different limit (e.g. int32 magnitude is 0x80000000 negative
-- but only 0x7fffffff positive).
local INT32_MAX_U    = UINT64(0x7fffffff)
local INT32_MIN_MAG  = UINT64(0x80000000)
local UINT32_MAX_U   = UINT64(0xffffffff)
local INT64_MAX_U    = 0x7fffffffffffffffULL
local INT64_MIN_MAG  = 0x8000000000000000ULL

-- Consume an optional sign punct. Returns true if value should be negated.
local function consume_sign(S)
    if S.tok_kind == 'punct' then
        if S.tok_value == '-' then advance(S); return true end
        if S.tok_value == '+' then advance(S); return false end
    end
    return false
end

local function parse_int_value(S, proto_type)
    local neg = consume_sign(S)
    if S.tok_kind ~= 'number' then
        err(S, ('expected integer for %s, got %s %q'):format(
            proto_type, S.tok_kind, tostring(S.tok_value)))
    end
    local lex = S.tok_value
    advance(S)
    local u, status = parse_int_lexeme(lex)
    if u == nil then
        if status == 'overflow' then
            err(S, ('integer literal %q exceeds 64-bit range'):format(lex))
        end
        err(S, ('invalid integer literal %q for %s'):format(lex, proto_type))
    end
    if proto_type == 'int64' or proto_type == 'sint64' or proto_type == 'sfixed64' then
        if neg then
            if u > INT64_MIN_MAG then
                err(S, ('integer %s out of range for %s'):format('-' .. lex, proto_type))
            end
            -- u == INT64_MIN_MAG: -u wraps back to INT64_MIN, which is the
            -- exact value we want (the only valid representation).
            return -INT64(u)
        end
        if u > INT64_MAX_U then
            err(S, ('integer %s out of range for %s'):format(lex, proto_type))
        end
        return INT64(u)
    end
    if proto_type == 'uint64' or proto_type == 'fixed64' then
        if neg and u ~= UINT64_ZERO then
            err(S, ('negative value %s for %s'):format('-' .. lex, proto_type))
        end
        return u  -- range already verified by parse_int_lexeme (≤ 2^64-1)
    end
    -- 32-bit Lua-number return.
    if proto_type == 'int32' or proto_type == 'sint32' or proto_type == 'sfixed32' then
        if neg then
            if u > INT32_MIN_MAG then
                err(S, ('integer %s out of range for %s'):format('-' .. lex, proto_type))
            end
            if u == INT32_MIN_MAG then return -0x80000000 end
            return -tonumber(u)
        end
        if u > INT32_MAX_U then
            err(S, ('integer %s out of range for %s'):format(lex, proto_type))
        end
        return tonumber(u)
    end
    -- uint32 / fixed32
    if neg and u ~= UINT64_ZERO then
        err(S, ('negative value %s for %s'):format('-' .. lex, proto_type))
    end
    if u > UINT32_MAX_U then
        err(S, ('integer %s out of range for %s'):format(lex, proto_type))
    end
    return tonumber(u)
end

local function parse_float_value(S, proto_type)
    local neg = consume_sign(S)
    local raw
    if S.tok_kind == 'ident' then
        local cls = classify_inf_nan(S.tok_value)
        if cls == 'inf' then
            advance(S)
            return neg and -math.huge or math.huge
        end
        if cls == 'nan' then advance(S); return 0/0 end
        err(S, ('expected number for %s, got ident %q'):format(
            proto_type, S.tok_value))
    end
    if S.tok_kind ~= 'number' then
        err(S, ('expected number for %s, got %s'):format(proto_type, S.tok_kind))
    end
    raw = S.tok_value
    advance(S)
    local v, why = parse_float_lexeme(raw)
    if v == nil then
        if why == 'hex' or why == 'octal' then
            err(S, ('%s integer literal not allowed in float field'):format(why))
        end
        err(S, ('invalid float literal %q'):format(raw))
    end
    if neg then v = -v end
    -- float (32-bit) rounds through IEEE 754 single. Lets `-1e-50` underflow
    -- to `-0.0` and oversize values saturate to ±inf per the spec.
    if proto_type == 'float' then
        local buf = FLOAT32(v)
        v = tonumber(buf[0])
    end
    return v
end

local BOOL_TRUE  = {['true']=true, True=true, ['t']=true, ['1']=true}
local BOOL_FALSE = {['false']=true, False=true, ['f']=true, ['0']=true}

local function parse_bool_value(S)
    if S.tok_kind == 'ident' then
        local v = S.tok_value
        if BOOL_TRUE[v]  then advance(S); return true  end
        if BOOL_FALSE[v] then advance(S); return false end
        err(S, ('expected bool, got ident %q'):format(v))
    elseif S.tok_kind == 'number' then
        local v = S.tok_value
        if v == '1' then advance(S); return true  end
        if v == '0' then advance(S); return false end
        err(S, ('expected bool, got number %q'):format(v))
    end
    err(S, ('expected bool, got %s'):format(S.tok_kind))
end

local function parse_string_value(S, validate_utf8)
    if S.tok_kind ~= 'string' then
        err(S, ('expected string, got %s'):format(S.tok_kind))
    end
    local v = S.tok_value
    advance(S)
    if validate_utf8 and not wire.is_valid_utf8(v) then
        err(S, 'invalid UTF-8 in string field')
    end
    return v
end

local function parse_enum_value(S, enum_desc)
    if S.tok_kind == 'ident' then
        local name = S.tok_value
        local num = enum_desc.by_name[name]
        if num == nil then
            err(S, ('unknown enum name %q for %s'):format(name, enum_desc.name))
        end
        advance(S)
        return num
    end
    -- Numeric enum value: signed int32-shape.
    return parse_int_value(S, 'int32')
end

local function parse_scalar_value(S, proto_type)
    if proto_type == 'bool'   then return parse_bool_value(S) end
    if proto_type == 'string' then return parse_string_value(S, true) end
    if proto_type == 'bytes'  then return parse_string_value(S, false) end
    if proto_type == 'float' or proto_type == 'double' then
        return parse_float_value(S, proto_type)
    end
    if INT_TYPES[proto_type] then return parse_int_value(S, proto_type) end
    err(S, 'unhandled scalar proto_type: ' .. tostring(proto_type))
end

-- Forward decls so message/list/map parsers can call into one another.
local parse_message_body
local skip_value
local skip_field_entry

-- Skip the value following a `:` (or message body) for an unknown / reserved
-- field. Tolerates every shape the grammar emits: scalar (token), aggregate,
-- and list-shorthand. Used to silently drop unknown numeric IDs and
-- `reserved "..."` field names. Mirrors the value-position grammar so a
-- well-formed input is still parsed cleanly.
skip_value = function(S, depth)
    if S.tok_kind == 'punct' then
        local v = S.tok_value
        if v == '{' or v == '<' then
            local closer = (v == '{') and '}' or '>'
            advance(S)
            while not (S.tok_kind == 'punct' and S.tok_value == closer) do
                if S.tok_kind == 'eof' then
                    err(S, 'unterminated unknown message body')
                end
                skip_field_entry(S, depth + 1, nil, nil)
            end
            advance(S)
            return
        end
        if v == '[' then
            advance(S)
            if not accept_punct(S, ']') then
                while true do
                    skip_value(S, depth)
                    if accept_punct(S, ']') then break end
                    expect_punct(S, ',')
                end
            end
            return
        end
        if v == '-' or v == '+' then
            advance(S)
            -- expect a number/ident next (caught by recursive call)
            skip_value(S, depth); return
        end
    end
    if S.tok_kind == 'number' or S.tok_kind == 'string'
            or S.tok_kind == 'ident' then
        advance(S); return
    end
    err(S, ('unexpected token in skipped value: %s'):format(S.tok_kind))
end

-- ---- message body ---------------------------------------------------------

-- Append a value to a repeated-field list on the result table.
local function repeated_append(result, fname, v)
    local list = result[fname]
    if list == nil then list = {}; result[fname] = list end
    list[#list + 1] = v
end

-- Clear oneof siblings when a oneof field is set (last-set-wins).
local function clear_oneof_siblings(result, f)
    local sibs = f.oneof_siblings
    if sibs == nil then return end
    for i = 1, #sibs do result[sibs[i]] = nil end
end

local parse_value_for_field  -- forward

-- Parse the body of a map entry (key + value sub-fields) into a synthetic
-- result table {key=K, value=V}. Map "fields" in text-format are spelled
-- as nested `key:` / `value:` aggregates.
local function parse_map_entry(S, f, depth)
    local entry = {}
    -- Synthesize per-direction descriptors so the inner field loop can
    -- reuse parse_value_for_field without re-deriving kind/proto_type.
    local key_f = {name='key',   kind=f.key.kind,
                   proto_type=f.key.proto_type, enum=f.key.enum,
                   message=f.key.message}
    local val_f = {name='value', kind=f.value.kind,
                   proto_type=f.value.proto_type, enum=f.value.enum,
                   message=f.value.message}
    while S.tok_kind ~= 'eof' do
        if S.tok_kind == 'punct' and (S.tok_value == '}' or S.tok_value == '>') then
            break
        end
        if S.tok_kind ~= 'ident' then
            err(S, 'expected key/value in map entry')
        end
        local name = S.tok_value
        advance(S)
        accept_punct(S, ':')
        if name == 'key' then
            entry.key = parse_value_for_field(S, key_f, depth + 1)
        elseif name == 'value' then
            entry.value = parse_value_for_field(S, val_f, depth + 1)
        else
            err(S, ('unknown field %q in map entry'):format(name))
        end
        if not accept_punct(S, ',') then accept_punct(S, ';') end
    end
    return entry
end

-- Resolve a message-typed field's value. Used both for singular and inside
-- list-shorthand. Handles WKT desc.text_decode overrides + Any inline form.
local parse_message_field_value  -- forward

-- Parse one value into the field-appropriate Lua shape. Does NOT handle
-- list-shorthand or repeated bookkeeping; the caller decides.
parse_value_for_field = function(S, f, depth)
    if f.kind == 'scalar' then
        return parse_scalar_value(S, f.proto_type)
    end
    if f.kind == 'enum' then
        return parse_enum_value(S, f.enum)
    end
    if f.kind == 'message' then
        return parse_message_field_value(S, f.message, depth)
    end
    if f.kind == 'map' then
        local opener = S.tok_value
        if S.tok_kind ~= 'punct' or (opener ~= '{' and opener ~= '<') then
            err(S, 'expected { for map entry')
        end
        advance(S)
        local entry = parse_map_entry(S, f, depth)
        expect_punct(S, opener == '{' and '}' or '>')
        return entry
    end
    err(S, 'unhandled field kind: ' .. tostring(f.kind))
end

-- ---- Any inline form ------------------------------------------------------

local function parse_any_url_brackets(S)
    -- assumes current token is `[`
    expect_punct(S, '[')
    -- Concatenate identifier and `/` segments into the full URL.
    local parts, n = {}, 0
    while not (S.tok_kind == 'punct' and S.tok_value == ']') do
        if S.tok_kind == 'ident' then
            n = n + 1; parts[n] = S.tok_value
            advance(S)
        elseif S.tok_kind == 'punct' and S.tok_value == '/' then
            n = n + 1; parts[n] = '/'
            advance(S)
        else
            err(S, 'unexpected token inside [type.url]')
        end
    end
    expect_punct(S, ']')
    return table.concat(parts)
end

-- ---- message body parser --------------------------------------------------

parse_message_body = function(S, desc, result, depth)
    if depth > DEFAULT_DEPTH_LIMIT then
        err(S, 'nesting depth limit exceeded')
    end
    -- `seen` tracks singular scalar/enum field occurrences within this
    -- message body so we can reject `field: A; field: B` per spec. Each
    -- message body gets its own `seen` — nested messages don't inherit.
    local seen = {}
    while S.tok_kind ~= 'eof' do
        if S.tok_kind == 'punct' and (S.tok_value == '}' or S.tok_value == '>') then
            return
        end
        skip_field_entry(S, depth, desc, result, seen)
    end
end

skip_field_entry = function(S, depth, desc, result, seen)
    -- desc/result may be nil when skipping inside an unknown sub-message body.
    if S.tok_kind == 'punct' and S.tok_value == '[' then
        -- Any inline form: `[type.url] { ... }` — only valid when the
        -- current message is google.protobuf.Any. Anywhere else we treat
        -- it as an extension/unknown and skip the URL plus value.
        local url = parse_any_url_brackets(S)
        local is_any_target = desc ~= nil and desc.name == 'google.protobuf.Any'
        accept_punct(S, ':')
        if is_any_target then
            -- Resolve the inner type from the registry and serialize.
            local inner_desc = pbwkt.lookup(url)
            if inner_desc == nil then
                err(S, ('no descriptor for Any type %q'):format(url))
            end
            local opener = S.tok_value
            if S.tok_kind ~= 'punct' or (opener ~= '{' and opener ~= '<') then
                err(S, 'expected { for Any body')
            end
            advance(S)
            local inner = {}
            if inner_desc.text_decode ~= nil then
                -- WKT overrides: re-tokenize would be expensive; instead
                -- parse the body fields into a temp table via the codec
                -- descriptor view if available. Fall back to body parse.
                parse_message_body(S, inner_desc, inner, depth + 1)
            else
                parse_message_body(S, inner_desc, inner, depth + 1)
            end
            expect_punct(S, opener == '{' and '}' or '>')
            local enc = inner_desc.encode and inner_desc.encode(inner)
                      or require('pb.codec').encode(inner_desc, inner)
            result.type_url = url
            result.value    = enc
        else
            skip_value(S, depth)
        end
        if not accept_punct(S, ',') then accept_punct(S, ';') end
        return
    end

    -- Field name (ident) or numeric ID (number).
    local field_name, numeric_id
    if S.tok_kind == 'ident' then
        field_name = S.tok_value
        advance(S)
    elseif S.tok_kind == 'number' then
        local u = parse_int_lexeme(S.tok_value)
        if u == nil then err(S, 'expected field name or number') end
        numeric_id = tonumber(u)
        advance(S)
    else
        err(S, ('unexpected token at field-entry start: %s %q'):format(
            S.tok_kind, tostring(S.tok_value)))
    end

    local field = nil
    if desc ~= nil then
        if field_name ~= nil then
            field = desc.field_by_name and desc.field_by_name[field_name]
        elseif numeric_id ~= nil then
            field = desc.field_by_id and desc.field_by_id[numeric_id]
        end
    end

    -- Unknown / reserved handling.
    if field == nil then
        local is_reserved = desc ~= nil and field_name ~= nil
            and desc.reserved_names ~= nil
            and desc.reserved_names[field_name]
        if field_name ~= nil and not is_reserved and numeric_id == nil
                and not (S.opts and S.opts.allow_unknown_fields)
                and desc ~= nil then
            -- Unknown text field name in a known message: error per spec
            -- (mainline protoc rejects). Numeric IDs are tolerated since
            -- the harness emits round-tripped unknown-field text in that form.
            err(S, ('unknown field %q in %s'):format(field_name, desc.name))
        end
        -- Skip the value (optionally preceded by `:`).
        accept_punct(S, ':')
        skip_value(S, depth)
        if not accept_punct(S, ',') then accept_punct(S, ';') end
        return
    end

    -- Map / message / list / scalar handling.
    local kind = field.kind
    -- `:` is required before scalar/enum, optional before message/map.
    if kind == 'message' or kind == 'map' then
        accept_punct(S, ':')
    else
        expect_punct(S, ':')
    end

    -- List shorthand: `field: [a, b, c]`. Each entry is appended to the
    -- repeated list. Disallowed for non-repeated / non-map fields.
    if S.tok_kind == 'punct' and S.tok_value == '[' then
        if not field.repeated and kind ~= 'map' then
            err(S, ('list shorthand not allowed on non-repeated field %q'):
                format(field.name))
        end
        advance(S)
        -- Empty list still materializes the field as `{}` (mainline
        -- protoc treats `field: []` as "set to empty list", not "absent").
        if result[field.name] == nil then result[field.name] = {} end
        if not accept_punct(S, ']') then
            while true do
                local v = parse_value_for_field(S, field, depth)
                repeated_append(result, field.name,
                    kind == 'map' and v or v)
                if accept_punct(S, ']') then break end
                expect_punct(S, ',')
            end
        end
        -- map<K,V> elements parsed via list-shorthand: rebuild the hash.
        if kind == 'map' then
            local list = result[field.name]
            local m = {}
            for i = 1, #list do m[list[i].key] = list[i].value end
            result[field.name] = m
        end
        if not accept_punct(S, ',') then accept_punct(S, ';') end
        return
    end

    if kind == 'map' then
        local entry = parse_value_for_field(S, field, depth)
        local m = result[field.name]
        if m == nil then m = {}; result[field.name] = m end
        m[entry.key] = entry.value
    elseif field.repeated then
        local v = parse_value_for_field(S, field, depth)
        repeated_append(result, field.name, v)
    else
        -- Singular scalar / enum: duplicate occurrence is a parse error
        -- per the text-format spec ("non-repeated field set more than
        -- once"). Singular sub-messages instead MERGE (their fields are
        -- shallow-merged into the existing value).
        if seen ~= nil and (kind == 'scalar' or kind == 'enum')
                and seen[field.name] then
            err(S, ('non-repeated field %q set more than once'):
                format(field.name))
        end
        local v = parse_value_for_field(S, field, depth)
        clear_oneof_siblings(result, field)
        if kind == 'message' and result[field.name] ~= nil then
            -- text-format spec: repeated singular sub-messages merge. We
            -- approximate by shallow-merging fields; sufficient for the
            -- conformance corpus shapes.
            local prev = result[field.name]
            for k, nv in pairs(v) do prev[k] = nv end
        else
            result[field.name] = v
        end
        if seen ~= nil then seen[field.name] = true end
    end
    if not accept_punct(S, ',') then accept_punct(S, ';') end
end

parse_message_field_value = function(S, msg_desc, depth)
    local opener
    if S.tok_kind ~= 'punct' or (S.tok_value ~= '{' and S.tok_value ~= '<') then
        err(S, ('expected { for message field, got %s %q'):format(
            S.tok_kind, tostring(S.tok_value)))
    end
    opener = S.tok_value
    advance(S)
    local closer = (opener == '{') and '}' or '>'
    -- WKT decode override: gives the WKT type total control over body parse.
    if msg_desc.text_decode ~= nil then
        local v = msg_desc.text_decode(S, depth + 1)
        expect_punct(S, closer)
        return v
    end
    local inner = {}
    parse_message_body(S, msg_desc, inner, depth + 1)
    expect_punct(S, closer)
    return inner
end

-- ---- WKT decode overrides -------------------------------------------------
--
-- Most WKTs naturally parse via the generic body walker: Empty, FieldMask,
-- Timestamp/Duration (when given as `seconds: N nanos: M`), wrappers
-- (when given as `value: V`). The cases that need an override are the
-- ones where the text shape differs from the message field shape:
--
--   * Struct / ListValue / Value — these have heavy generated descriptors
--     in pb.wkt that the text printer bypasses. We let the body walker
--     populate the standard message shape (fields: list of {key,value}
--     entries for Struct, etc.), then mainline encoders pick it up.
--
-- The conformance harness's Struct/Value text inputs use the *generated*
-- message shape (e.g. `fields { key: "k" value { string_value: "v" } }`)
-- rather than the inline JSON-ish form, so our generic walker works as-is.
-- We do NOT register WKT-specific text_decode overrides; the WKT
-- descriptors in pb.wkt carry `desc.encode`/`desc.decode` and the existing
-- field-by-name lookup against descriptor.proto handles parsing.

-- ---- public API -----------------------------------------------------------

function M.decode(desc, text, opts)
    if type(text) ~= 'string' then
        error('pb.text.decode: text must be a string', 0)
    end
    opts = opts or {}
    if opts.allow_unknown_fields == nil then
        opts.allow_unknown_fields = false
    end
    local S = {
        src = text, pos = 1, len = #text, opts = opts,
        tok_kind = nil, tok_value = nil,
    }
    advance(S)
    local result = {}
    -- WKT-level override (currently unused — see comment above).
    if desc.text_decode ~= nil then
        result = desc.text_decode(S, 0) or result
    else
        parse_message_body(S, desc, result, 0)
    end
    if S.tok_kind ~= 'eof' then
        err(S, ('trailing data at end of input (%s)'):format(S.tok_kind))
    end
    return result
end

return M

M runtime/pb/wkt.lua => runtime/pb/wkt.lua +21 -1
@@ 513,7 513,27 @@ end

M.Any_encode     = any_encode
M.Any_decode     = any_decode
M.Any_descriptor = {name='google.protobuf.Any', encode=any_encode, decode=any_decode}
-- The Any descriptor advertises its two real fields (type_url + value) so
-- the text-format parser can fall through to the generic body walker
-- when an Any is written in direct form (`{ type_url: "..." value: "..." }`)
-- instead of the inline `[type.url] { ... }` form. encode/decode still
-- intercept the wire path.
M.Any_descriptor = {
    name = 'google.protobuf.Any',
    encode = any_encode,
    decode = any_decode,
    fields = {
        {name='type_url', id=1, kind='scalar', proto_type='string'},
        {name='value',    id=2, kind='scalar', proto_type='bytes'},
    },
    field_by_name = {
        type_url = {name='type_url', id=1, kind='scalar', proto_type='string'},
        value    = {name='value',    id=2, kind='scalar', proto_type='bytes'},
    },
    field_by_id = {},  -- filled below
}
M.Any_descriptor.field_by_id[1] = M.Any_descriptor.field_by_name.type_url
M.Any_descriptor.field_by_id[2] = M.Any_descriptor.field_by_name.value

-- Per-process registry mapping type_url (or bare full name) to a message
-- descriptor. `pb.register(desc)` adds entries; pack/unpack look them up.

M test/conformance/known_failures_text.txt => test/conformance/known_failures_text.txt +27 -7
@@ 1,11 1,31 @@
# conformance_test_runner --text_format_failure_list
#
# Text-format OUTPUT runs through pb.text.encode (protobuf/JSON input →
# text output). Group/Repeated unknown-field decode is supported by the
# SGROUP-recursive `wire.skip_field`, and unknown bytes are rendered in
# numeric field-ID form when `print_unknown_fields=true`.
# After the pb.text.decode slice landed, the text-format INPUT path is
# wired through pb.text.decode in cmd/conformance/core.lua. The proto3
# TextFormatInput suite climbed from 8 ✓ / 426 skipped to 406 ✓ / 18
# skipped (the residual 18 are the proto2 message-type bucket — those
# round-trip through TestAllTypesProto2 which we don't generate Lua for).
#
# Text-format INPUT is still deferred — pb.text remains encode-only —
# so any test whose payload is `text_payload` returns `skipped`.
# The entries below are the 10 known failures that survive. All ten
# share the same root cause: proto3's "default value is implicit-absence"
# rule, applied uniformly by the codec, drops a singular float/double
# field whose value is *negative zero* — `-0.0 == 0.0` in IEEE, so the
# `if v ~= 0 then emit` guard elides it. Mainline's reference output
# keeps the field, producing a `optional_float: -0` line we don't emit.
#
# No expected failures in the proto3 text suite as of the last refresh.
# This is a wire-codec quirk, not a text-format-decoder bug — the
# decoder correctly parses `-0` and `-1e-50` to a float with the sign
# bit set (verified by inspecting the FFI uint32 reinterpretation). The
# fix lives in `runtime/pb/codec.lua` (and the inline-mode codegen) and
# needs a "sign bit set" test for floats/doubles in addition to the
# `v ~= 0` check. Out of scope for the pb.text.decode work.
Required.Proto3.TextFormatInput.FloatFieldNegativeZero.ProtobufOutput
Required.Proto3.TextFormatInput.FloatFieldNegativeZero.TextFormatOutput
Required.Proto3.TextFormatInput.FloatFieldNegativeZero_F.ProtobufOutput
Required.Proto3.TextFormatInput.FloatFieldNegativeZero_F.TextFormatOutput
Required.Proto3.TextFormatInput.FloatFieldNegativeZero_f.ProtobufOutput
Required.Proto3.TextFormatInput.FloatFieldNegativeZero_f.TextFormatOutput
Required.Proto3.TextFormatInput.NegDoubleFieldLargeNegativeExponentParsesAsNegZero.ProtobufOutput
Required.Proto3.TextFormatInput.NegDoubleFieldLargeNegativeExponentParsesAsNegZero.TextFormatOutput
Required.Proto3.TextFormatInput.NegFloatFieldLargeNegativeExponentParsesAsNegZero.ProtobufOutput
Required.Proto3.TextFormatInput.NegFloatFieldLargeNegativeExponentParsesAsNegZero.TextFormatOutput

M test/conformance_test.lua => test/conformance_test.lua +135 -4
@@ 24,6 24,8 @@ local JSON     = conformance.WireFormat.JSON
local TEXT     = conformance.WireFormat.TEXT_FORMAT

local PROTO3_NAME = 'protobuf_test_messages.proto3.TestAllTypesProto3'
local PROTO3      = proto3.TestAllTypesProto3_descriptor
local pb          = require('pb')

local function encode_req(t_)
    return conformance.ConformanceRequest_encode(t_)


@@ 178,14 180,143 @@ core_g.test_empty_message_to_text = function()
    t.assert_equals(resp.text_payload, '')
end

core_g.test_text_input_skipped = function()
    -- Text-format input parsing is still deferred (pb.text is encode-only).
-- ---- text-format input -------------------------------------------------
-- Each scenario bucket is pinned with a payload cribbed from the upstream
-- conformance corpus so the inner dev loop catches regressions without
-- running Docker.

local function decode_pb(text)
    local resp = decode_resp(core.handle_request(encode_req({
        text_payload = text,
        requested_output_format = PROTOBUF,
        message_type = PROTO3_NAME,
    })))
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not(resp.serialize_error, resp.serialize_error)
    return resp
end

core_g.test_text_input_basic_scalar = function()
    local r = decode_pb('optional_int32: 12345\n')
    -- tag 0xE8 0x07 (field 125, varint) → bytes 0xe8 0x06 ... let pb decode it.
    local m = pb.decode(PROTO3, r.protobuf_payload)
    t.assert_equals(m.optional_int32, 12345)
end

core_g.test_text_input_number_radixes = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_int32: 0x7fffffff\noptional_uint32: 037777777777\n').protobuf_payload)
    t.assert_equals(m.optional_int32, 0x7fffffff)
    t.assert_equals(m.optional_uint32, 0xffffffff)
end

core_g.test_text_input_float_specials = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_double: Infinity\noptional_float: -inf\n').protobuf_payload)
    t.assert_equals(m.optional_double, math.huge)
    t.assert_equals(m.optional_float, -math.huge)
end

core_g.test_text_input_string_escapes = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_string: "a\\tb\\n\\xc3\\x9f"\n').protobuf_payload)
    t.assert_equals(m.optional_string, 'a\tb\n\xc3\x9f')
end

core_g.test_text_input_adjacent_string_literals = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_string: "foo" "bar"\n').protobuf_payload)
    t.assert_equals(m.optional_string, 'foobar')
end

core_g.test_text_input_angle_brackets = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_nested_message < a: 7 >\n').protobuf_payload)
    t.assert_equals(m.optional_nested_message.a, 7)
end

core_g.test_text_input_separators_comma_and_semi = function()
    -- Both `,` and `;` are valid single separators between fields.
    local m = pb.decode(PROTO3, decode_pb(
        'optional_int32: 1,\noptional_int64: 2;\n').protobuf_payload)
    t.assert_equals(m.optional_int32, 1)
    t.assert_equals(tonumber(m.optional_int64), 2)
end

core_g.test_text_input_double_semicolon_rejected = function()
    -- `;;` is two separators; the empty entry between them is rejected.
    -- Pins FieldSeparatorSemi*.
    local resp = decode_resp(core.handle_request(encode_req({
        text_payload = 'optional_int32: 1\n',
        text_payload = 'optional_int32: 1;;\n',
        requested_output_format = PROTOBUF,
        message_type = PROTO3_NAME,
    })))
    t.assert_str_contains(resp.skipped or '', 'text-format input')
    t.assert_str_contains(resp.parse_error or '', 'field-entry start')
end

core_g.test_text_input_list_shorthand = function()
    local m = pb.decode(PROTO3, decode_pb(
        'repeated_int32: [1, 2, 3]\n').protobuf_payload)
    t.assert_equals(m.repeated_int32, {1, 2, 3})
end

core_g.test_text_input_list_shorthand_separate_appends = function()
    -- `field: [1]` followed by `field: [2]` -> two-element list (don't
    -- collapse). Pins ListSeparatorMissingIsOneValue_*.
    local m = pb.decode(PROTO3, decode_pb(
        'repeated_int32: [1] repeated_int32: [2]\n').protobuf_payload)
    t.assert_equals(m.repeated_int32, {1, 2})
end

core_g.test_text_input_reserved_field_name = function()
    -- `reserved "reserved_field"` declared on TestAllTypesProto3; must be
    -- silently dropped, not error.
    local m = pb.decode(PROTO3, decode_pb(
        'optional_int32: 1\nreserved_field: 999\n').protobuf_payload)
    t.assert_equals(m.optional_int32, 1)
end

core_g.test_text_input_unknown_numeric_id_dropped = function()
    -- Numeric field IDs that don't resolve in the schema are silently
    -- dropped (matches mainline AllowFieldNumber under the harness).
    local m = pb.decode(PROTO3, decode_pb(
        'optional_int32: 1\n9999: 42\n').protobuf_payload)
    t.assert_equals(m.optional_int32, 1)
end

core_g.test_text_input_enum_by_name = function()
    -- BAR (=1) keeps the field present after the proto3 default-elision
    -- pass; FOO (=0) would round-trip to the absence default.
    local m = pb.decode(PROTO3, decode_pb(
        'optional_nested_enum: BAR\n').protobuf_payload)
    t.assert_equals(m.optional_nested_enum, 1)
end

core_g.test_text_input_enum_by_number = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_nested_enum: 2\n').protobuf_payload)
    t.assert_equals(m.optional_nested_enum, 2)
end

core_g.test_text_input_unknown_enum_name_errors = function()
    local resp = decode_resp(core.handle_request(encode_req({
        text_payload = 'optional_nested_enum: BOGUS_NAME\n',
        requested_output_format = PROTOBUF,
        message_type = PROTO3_NAME,
    })))
    t.assert_str_contains(resp.parse_error or '', 'unknown enum')
end

core_g.test_text_input_map_entry = function()
    local m = pb.decode(PROTO3, decode_pb(
        'map_string_string { key: "k" value: "v" }\n').protobuf_payload)
    t.assert_equals(m.map_string_string, {k = 'v'})
end

core_g.test_text_input_uint64_max = function()
    local m = pb.decode(PROTO3, decode_pb(
        'optional_uint64: 0xFFFFFFFFFFFFFFFF\n').protobuf_payload)
    t.assert_equals(m.optional_uint64, require('ffi').cast('uint64_t', -1))
end

core_g.test_jspb_output_skipped = function()

A test/text_decode_test.lua => test/text_decode_test.lua +248 -0
@@ 0,0 1,248 @@
-- Protobuf text-format parser tests.
--
-- Decoder is descriptor-driven, so output is mode-independent; we still
-- run every assertion under both `full` and `runtime` generated modules
-- to confirm parity with the encoder/descriptor codegen.
local t = require('luatest')
local ffi = require('ffi')
local pb = require('pb')

local MODES = {'full', 'runtime'}

for _, mode in ipairs(MODES) do
    local g = t.group('text_decode.' .. mode)
    local hello = require(mode .. '.hello.hello_pb')

    -- ---- scalars -------------------------------------------------------

    g.test_basic_scalar = function()
        local m = pb.text.decode(hello.Address_descriptor,
            'street: "Pushkina 1"\ncity: "Moscow"\nzip: 123456\n')
        t.assert_equals(m, {street = 'Pushkina 1', city = 'Moscow', zip = 123456})
    end

    g.test_empty = function()
        t.assert_equals(pb.text.decode(hello.Address_descriptor, ''), {})
    end

    g.test_int64_lossless = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'user_id: 18369917520866213889\n')
        t.assert_equals(m.user_id, ffi.cast('uint64_t', 18369917520866213889ULL))
    end

    g.test_int_radixes = function()
        local m = pb.text.decode(hello.Address_descriptor,
            'zip: 0xff\n')
        t.assert_equals(m.zip, 255)
        m = pb.text.decode(hello.Address_descriptor, 'zip: 010\n')
        t.assert_equals(m.zip, 8)
    end

    g.test_int_negative = function()
        local m = pb.text.decode(hello.Address_descriptor, 'zip: -7\n')
        t.assert_equals(m.zip, -7)
    end

    g.test_float_format = function()
        local m = pb.text.decode(hello.Person_descriptor, 'weight_kg: 72.5\n')
        t.assert_equals(m.weight_kg, 72.5)
    end

    g.test_float_specials = function()
        local m = pb.text.decode(hello.Person_descriptor, 'weight_kg: inf\n')
        t.assert_equals(m.weight_kg, math.huge)
        m = pb.text.decode(hello.Person_descriptor, 'weight_kg: -INFINITY\n')
        t.assert_equals(m.weight_kg, -math.huge)
        m = pb.text.decode(hello.Person_descriptor, 'weight_kg: NaN\n')
        t.assert_not_equals(m.weight_kg, m.weight_kg)  -- NaN ~= NaN
    end

    g.test_float_trailing_f = function()
        local m = pb.text.decode(hello.Person_descriptor, 'weight_kg: 1.5f\n')
        t.assert_equals(m.weight_kg, 1.5)
    end

    g.test_string_escapes = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'name: "a\\"b\\\\c\\nd\\te"\n')
        t.assert_equals(m.name, 'a"b\\c\nd\te')
    end

    g.test_octal_and_hex_escapes = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'avatar: "\\000\\001\\xff"\n')
        t.assert_equals(m.avatar, '\x00\x01\xff')
    end

    g.test_unicode_escape = function()
        -- ሴ is BMP, \U00010437 is supplementary plane.
        local m = pb.text.decode(hello.Person_descriptor,
            'name: "\\u00e9\\U00010437"\n')
        -- U+00E9 -> 0xC3 0xA9; U+10437 -> 0xF0 0x90 0x90 0xB7.
        t.assert_equals(m.name, '\xc3\xa9\xf0\x90\x90\xb7')
    end

    g.test_adjacent_string_concat = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'name: "ab" "cd" "ef"\n')
        t.assert_equals(m.name, 'abcdef')
    end

    g.test_comment_skipped = function()
        local m = pb.text.decode(hello.Address_descriptor,
            '# leading\nstreet: "S"  # trailing\n')
        t.assert_equals(m.street, 'S')
    end

    g.test_separator_semi_and_comma = function()
        local m = pb.text.decode(hello.Address_descriptor,
            'street: "S",\nzip: 1;\n')
        t.assert_equals(m, {street = 'S', zip = 1})
    end

    g.test_double_semicolon_errors = function()
        -- Mainline TextFormat rejects `;;` between fields (treats the
        -- empty entry as a duplicate). Pins FieldSeparatorSemi*.
        local ok, e = pcall(pb.text.decode, hello.Address_descriptor,
            'street: "S";;\n')
        t.assert_not(ok)
        t.assert_str_contains(e, 'field-entry start')
    end

    -- ---- enums ---------------------------------------------------------

    g.test_enum_by_name = function()
        local m = pb.text.decode(hello.Person_descriptor, 'status: ERROR\n')
        t.assert_equals(m.status, hello.Status.ERROR)
    end

    g.test_enum_by_number = function()
        local m = pb.text.decode(hello.Person_descriptor, 'status: 2\n')
        t.assert_equals(m.status, 2)
    end

    g.test_enum_unknown_name_errors = function()
        local ok, err = pcall(pb.text.decode, hello.Person_descriptor,
            'status: NOPE\n')
        t.assert_not(ok)
        t.assert_str_contains(err, 'unknown enum')
    end

    -- ---- repeated ------------------------------------------------------

    g.test_repeated_long_form = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'emails: "a"\nemails: "b"\nemails: "c"\n')
        t.assert_equals(m.emails, {'a', 'b', 'c'})
    end

    g.test_repeated_short_form = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'lucky_numbers: [1, 2, 3]\n')
        t.assert_equals(m.lucky_numbers, {1, 2, 3})
    end

    g.test_repeated_short_empty = function()
        local m = pb.text.decode(hello.Person_descriptor, 'lucky_numbers: []\n')
        t.assert_equals(m.lucky_numbers, {})
    end

    g.test_repeated_short_separate_lists_append = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'lucky_numbers: [1] lucky_numbers: [2, 3]\n')
        t.assert_equals(m.lucky_numbers, {1, 2, 3})
    end

    -- ---- aggregates ----------------------------------------------------

    g.test_nested_message_curly = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'address { street: "S" zip: 1 }\n')
        t.assert_equals(m.address, {street = 'S', zip = 1})
    end

    g.test_nested_message_angle = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'address < street: "S" zip: 1 >\n')
        t.assert_equals(m.address, {street = 'S', zip = 1})
    end

    g.test_nested_message_with_colon = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'address: { street: "S" }\n')
        t.assert_equals(m.address.street, 'S')
    end

    -- ---- maps ----------------------------------------------------------

    g.test_map_entry_single = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'ages_by_nickname { key: "alice" value: 30 }\n')
        t.assert_equals(m.ages_by_nickname, {alice = 30})
    end

    g.test_map_entry_multi = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'ages_by_nickname { key: "a" value: 1 }\n' ..
            'ages_by_nickname { key: "b" value: 2 }\n')
        t.assert_equals(m.ages_by_nickname, {a = 1, b = 2})
    end

    g.test_map_message_value = function()
        local m = pb.text.decode(hello.Person_descriptor,
            'addresses_by_label { key: "home" value { street: "S" zip: 1 } }\n')
        t.assert_equals(m.addresses_by_label.home, {street = 'S', zip = 1})
    end

    -- ---- oneof ---------------------------------------------------------

    g.test_oneof_last_wins = function()
        -- Setting two `outcome` members in sequence keeps only the last;
        -- `id` is outside the oneof so it survives untouched.
        local m = pb.text.decode(hello.Result_descriptor,
            'id: 7\ntext: "first"\ncode: 99\n')
        t.assert_equals(m.id, 7)
        t.assert_equals(m.code, 99)
        t.assert_equals(m.text, nil)
    end

    -- ---- error surface -------------------------------------------------

    g.test_unknown_field_errors_by_default = function()
        local ok, err = pcall(pb.text.decode, hello.Address_descriptor,
            'street: "S"\nbogus: 1\n')
        t.assert_not(ok)
        t.assert_str_contains(err, 'unknown field')
    end

    g.test_unknown_field_dropped_when_allowed = function()
        local m = pb.text.decode(hello.Address_descriptor,
            'street: "S"\nbogus: 1\n', {allow_unknown_fields = true})
        t.assert_equals(m, {street = 'S'})
    end

    g.test_unknown_numeric_id_dropped = function()
        local m = pb.text.decode(hello.Address_descriptor,
            'street: "S"\n9999: 42\n')
        t.assert_equals(m, {street = 'S'})
    end

    -- ---- round-trip with the encoder ----------------------------------

    g.test_round_trip_encode_decode = function()
        local src = {
            name = 'Alice',
            user_id = ffi.cast('uint64_t', 42),
            emails = {'a@x', 'b@x'},
            address = {street = 'Main', city = 'NYC', zip = 10001},
            status = hello.Status.ACTIVE,
            ages_by_nickname = {al = 30},
        }
        local text = pb.text.encode(hello.Person_descriptor, src)
        local decoded = pb.text.decode(hello.Person_descriptor, text)
        -- Compare via re-encode for cdata equality stability.
        t.assert_equals(pb.encode(hello.Person_descriptor, decoded),
                        pb.encode(hello.Person_descriptor, src))
    end
end