~bigbes/tarantool

tarantool-protobuf

14e1981920edc18d473b8ae1034c31ee8d4e72a1 — Eugene Blikh 3 months ago 00b93fd
codegen: bracket-quote Lua-keyword field names

The plugin emitted bare-identifier field names in three positions:
the `M.<Type>_fields` table key, the inline encoder's `v = t.<name>`
load, and the inline decoder's `result.<name> = ...` store. When a
proto field name collided with a Lua reserved word the generated
`*_pb.lua` failed to load with `'(' expected near '<keyword>'`.

The real-world hit is pprof's profile.proto, which declares
`repeated Function function = 5` — that breaks all three emit sites.

Route every user-named identifier through new luaTableKey /
luaFieldAccess helpers that bracket-quote reserved words. Covers
field_names + oneof descriptors, oneof presence pre-pass, inline
encode/decode field bodies (including repeated and map paths), and
optional has/clear accessors.
M cmd/protoc-gen-tarantool/internal/gen/gen.go => cmd/protoc-gen-tarantool/internal/gen/gen.go +6 -5
@@ 263,7 263,7 @@ func emitFieldNamesTable(w *writer, name string, m *protogen.Message) {
	w.line("M.%s_fields = pb.field_names({", name)
	for _, f := range m.Fields {
		fn := string(f.Desc.Name())
		w.line("    %s = %q,", fn, fn)
		w.line("    %s = %q,", luaTableKey(fn), fn)
	}
	w.line("})")
}


@@ 284,7 284,7 @@ func emitOneofNamesTable(w *writer, name string, m *protogen.Message) {
	}
	w.line("M.%s_oneofs = pb.field_names({", name)
	for _, on := range names {
		w.line("    %s = %q,", on, on)
		w.line("    %s = %q,", luaTableKey(on), on)
	}
	w.line("})")
}


@@ 318,7 318,7 @@ func emitOneofTable(w *writer, name string, m *protogen.Message) {
		for _, fn := range row.members {
			quoted = append(quoted, fmt.Sprintf("%q", fn))
		}
		w.line("    %s = {%s},", row.name, strings.Join(quoted, ", "))
		w.line("    %s = {%s},", luaTableKey(row.name), strings.Join(quoted, ", "))
	}
	w.line("}")
}


@@ 469,10 469,11 @@ func emitOptionalAccessors(w *writer, name string, m *protogen.Message, fullName
			continue
		}
		fname := string(f.Desc.Name())
		access := luaFieldAccess("t", fname)
		emitEmmyWrapperAnnotations(w, name, fullName, wrapperHas)
		w.line("function M.%s_has_%s(t) return t.%s ~= nil end", name, fname, fname)
		w.line("function M.%s_has_%s(t) return %s ~= nil end", name, fname, access)
		emitEmmyWrapperAnnotations(w, name, fullName, wrapperClear)
		w.line("function M.%s_clear_%s(t) t.%s = nil end", name, fname, fname)
		w.line("function M.%s_clear_%s(t) %s = nil end", name, fname, access)
	}
}


M cmd/protoc-gen-tarantool/internal/gen/inline.go => cmd/protoc-gen-tarantool/internal/gen/inline.go +17 -13
@@ 43,8 43,8 @@ func emitInlineEncode(w *writer, name string, m *protogen.Message, file *protoge
	for _, oo := range realOneofs(m) {
		w.line("    local %s", oneofVar(string(oo.Desc.Name())))
		for _, f := range oo.Fields {
			w.line("    if t.%s ~= nil then %s = %q end",
				string(f.Desc.Name()),
			w.line("    if %s ~= nil then %s = %q end",
				luaFieldAccess("t", string(f.Desc.Name())),
				oneofVar(string(oo.Desc.Name())),
				string(f.Desc.Name()))
		}


@@ 92,7 92,7 @@ func emitInlineEncodeField(w *writer, f *protogen.Field, file *protogen.File, se
	fname := string(f.Desc.Name())

	w.line("    -- field %d: %s", id, fname)
	w.line("    v = t.%s", fname)
	w.line("    v = %s", luaFieldAccess("t", fname))

	oneof := fieldRealOneof(f)
	// Default presence gate: a regular nil check. For message fields we


@@ 290,20 290,21 @@ func emitInlineDecodeFieldBody(w *writer, f *protogen.Field, file *protogen.File
		emitInlineDecodeRepeated(w, f, fname, file, selfPath, imports, prefix)
	case f.Message != nil:
		ref := typeRef(file, f.Message.Desc, selfPath, imports, "_decode", prefix)
		dst := luaFieldAccess("result", fname)
		w.line("            local payload")
		w.line("            payload, pos = wire.decode_len(buf, pos)")
		if isWellKnownTypeFile(f.Message.Desc.ParentFile()) {
			// WKT decoders return unwrapped values (datetime, number, string),
			// not Lua tables — there is nothing to merge into. Replace.
			w.line("            result.%s = %s(payload)", fname, ref)
			w.line("            %s = %s(payload)", dst, ref)
		} else {
			// Per proto3 spec, repeated occurrences of a singular message
			// field merge recursively. This holds for oneof branches too;
			// sibling clearing below enforces oneof exclusivity.
			descRef := typeRef(file, f.Message.Desc, selfPath, imports, "_descriptor", prefix)
			w.line("            local prev = result.%s", fname)
			w.line("            local prev = %s", dst)
			w.line("            if prev == nil then")
			w.line("                result.%s = %s(payload)", fname, ref)
			w.line("                %s = %s(payload)", dst, ref)
			w.line("            else")
			w.line("                pb.codec.merge_message(%s, prev, %s(payload))",
				descRef, ref)


@@ 312,12 313,12 @@ func emitInlineDecodeFieldBody(w *writer, f *protogen.Field, file *protogen.File
	case f.Enum != nil:
		w.line("            local u")
		w.line("            u, pos = wire.decode_varint(buf, pos)")
		w.line("            result.%s = wire.varint_to_int32(u)", fname)
		w.line("            %s = wire.varint_to_int32(u)", luaFieldAccess("result", fname))
	default:
		st := scalarName(f.Desc.Kind())
		w.line("            local val")
		w.line("            val, pos = wire.decode_%s(buf, pos)", st)
		w.line("            result.%s = val", fname)
		w.line("            %s = val", luaFieldAccess("result", fname))
	}

	// Oneof: clear sibling branches so callers see exactly one set field.


@@ 326,14 327,16 @@ func emitInlineDecodeFieldBody(w *writer, f *protogen.Field, file *protogen.File
			if sib == f {
				continue
			}
			w.line("            result.%s = nil", string(sib.Desc.Name()))
			w.line("            %s = nil",
				luaFieldAccess("result", string(sib.Desc.Name())))
		}
	}
}

func emitInlineDecodeRepeated(w *writer, f *protogen.Field, fname string, file *protogen.File, selfPath string, imports map[string]string, prefix string) {
	w.line("            local list = result.%s", fname)
	w.line("            if list == nil then list = {}; result.%s = list end", fname)
	dst := luaFieldAccess("result", fname)
	w.line("            local list = %s", dst)
	w.line("            if list == nil then list = {}; %s = list end", dst)

	switch {
	case f.Message != nil:


@@ 512,8 515,9 @@ func mapSubFieldWireType(f *protogen.Field) int {
func emitInlineDecodeMap(w *writer, f *protogen.Field, fname string, file *protogen.File, selfPath string, imports map[string]string, prefix string) {
	keyF, valF := f.Message.Fields[0], f.Message.Fields[1]

	w.line("            local map = result.%s", fname)
	w.line("            if map == nil then map = {}; result.%s = map end", fname)
	dst := luaFieldAccess("result", fname)
	w.line("            local map = %s", dst)
	w.line("            if map == nil then map = {}; %s = map end", dst)
	w.line("            local payload")
	w.line("            payload, pos = wire.decode_len(buf, pos)")
	w.line("            local _ep, _elim = 1, #payload")

M cmd/protoc-gen-tarantool/internal/gen/name.go => cmd/protoc-gen-tarantool/internal/gen/name.go +51 -0
@@ 1,12 1,63 @@
package gen

import (
	"fmt"
	"path"
	"strings"

	"google.golang.org/protobuf/reflect/protoreflect"
)

// luaReservedWords is the frozen set of Lua 5.1 / LuaJIT keywords. A proto
// field whose name collides with one of these can't be emitted as a bare
// identifier in generated Lua — table-key form and bracket-index form
// must be used instead. See luaTableKey / luaFieldAccess.
var luaReservedWords = map[string]bool{
	"and": true, "break": true, "do": true, "else": true,
	"elseif": true, "end": true, "false": true, "for": true,
	"function": true, "goto": true, "if": true, "in": true,
	"local": true, "nil": true, "not": true, "or": true,
	"repeat": true, "return": true, "then": true, "true": true,
	"until": true, "while": true,
}

func isLuaReservedWord(name string) bool { return luaReservedWords[name] }

func isValidLuaIdentifier(name string) bool {
	if name == "" {
		return false
	}
	for i, r := range name {
		switch {
		case r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
		case i > 0 && r >= '0' && r <= '9':
		default:
			return false
		}
	}
	return true
}

// luaTableKey returns a representation of `name` valid as a Lua table key.
// Bare identifier when the name is a valid Lua identifier and not reserved;
// bracket-quoted form otherwise.
func luaTableKey(name string) string {
	if isLuaReservedWord(name) || !isValidLuaIdentifier(name) {
		return fmt.Sprintf("[%q]", name)
	}
	return name
}

// luaFieldAccess returns Lua source for `<receiver>.<name>`, falling back
// to `<receiver>[<name:q>]` when `name` is a Lua keyword or otherwise
// invalid as a bare identifier.
func luaFieldAccess(receiver, name string) string {
	if isLuaReservedWord(name) || !isValidLuaIdentifier(name) {
		return fmt.Sprintf("%s[%q]", receiver, name)
	}
	return fmt.Sprintf("%s.%s", receiver, name)
}

// luaPackagePath returns the dotted Lua require path for a generated file.
//
// Resolution order:

A test/codegen_lua_keywords_test.lua => test/codegen_lua_keywords_test.lua +151 -0
@@ 0,0 1,151 @@
-- Regression test for Lua-keyword proto field names.
--
-- protoc-gen-tarantool used to emit bare-identifier table keys and
-- `t.<field>` / `result.<field>` accesses for every field. When a field
-- name happened to be a Lua reserved word (the in-the-wild hit is
-- pprof's `repeated Function function = 5`) the generated `*_pb.lua`
-- failed to load with `'(' expected near '<keyword>'`.
--
-- Coverage: emit a .proto where every Lua keyword is used as a field
-- name, run the plugin in both codegen modes, and assert:
--   * The generated module loads without error.
--   * Each `M.<Type>_fields[<keyword>]` resolves to the same name string
--     (covers the field_names table emit site).
--   * Encoding `{[<keyword>] = ...}` and decoding the bytes round-trips
--     (covers the inline-encoder `v = t.<name>` and inline-decoder
--     `result.<name> =` sites).

local t = require('luatest')
local fio = require('fio')

local g = t.group('codegen_lua_keywords')

local REPO_ROOT = fio.abspath(fio.pathjoin(
    fio.dirname(debug.getinfo(1, 'S').source:sub(2)), '..'))
local OPTIONS_DIR = fio.pathjoin(REPO_ROOT, 'options')
local PLUGIN = fio.pathjoin(REPO_ROOT, 'protoc-gen-tarantool')

local LUA_KEYWORDS = {
    'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for',
    'function', 'goto', 'if', 'in', 'local', 'nil', 'not', 'or',
    'repeat', 'return', 'then', 'true', 'until', 'while',
}

local function spit(path, content)
    local f = assert(io.open(path, 'wb'))
    f:write(content)
    f:close()
end

local function ensure_plugin()
    if fio.path.exists(PLUGIN) then return end
    local cmd = string.format('cd %q && go build -o %s ./cmd/protoc-gen-tarantool',
                              REPO_ROOT, fio.basename(PLUGIN))
    assert(os.execute(cmd) == 0 or os.execute(cmd) == true,
           'failed to build plugin: ' .. cmd)
end

-- Build a .proto whose every field name is a Lua keyword.
local function build_proto()
    local lines = {
        'syntax = "proto3";',
        'package keywords_test;',
        'message Inner { int32 x = 1; }',
        'message Outer {',
    }
    -- Field id 1: nested message named `function` (the pprof shape).
    table.insert(lines, '  Inner function = 1;')
    -- Subsequent ids: int32 scalars named after every other Lua keyword.
    local id = 2
    for _, kw in ipairs(LUA_KEYWORDS) do
        if kw ~= 'function' then
            table.insert(lines, string.format('  int32 %s = %d;', kw, id))
            id = id + 1
        end
    end
    table.insert(lines, '}')
    return table.concat(lines, '\n') .. '\n'
end

local function run_plugin(mode)
    local tmp = fio.tempdir()
    local proto_dir = fio.pathjoin(tmp, 'proto')
    local out_dir = fio.pathjoin(tmp, 'out')
    assert(fio.mkdir(proto_dir))
    assert(fio.mkdir(out_dir))
    spit(fio.pathjoin(proto_dir, 'kw.proto'), build_proto())

    local cmd = string.format(
        'protoc --plugin=%q --tarantool_out=%q '
        ..'--tarantool_opt=mode=%s,prefix=kw_%s '
        ..'-I %q -I %q %q',
        PLUGIN, out_dir, mode, mode, proto_dir, OPTIONS_DIR,
        fio.pathjoin(proto_dir, 'kw.proto'))
    local ok = os.execute(cmd)
    assert(ok == 0 or ok == true, 'plugin failed: ' .. cmd)
    return out_dir, ('kw_%s.keywords_test.kw_pb'):format(mode)
end

g.before_all(function()
    ensure_plugin()
end)

for _, mode in ipairs({'full', 'runtime'}) do
    g['test_module_loads_with_keyword_fields_'..mode] = function()
        local out, modname = run_plugin(mode)
        local prev = package.path
        package.path = fio.pathjoin(out, '?.lua') .. ';'
                    .. fio.pathjoin(out, '?/init.lua') .. ';' .. prev
        package.loaded[modname] = nil
        local ok, mod = pcall(require, modname)
        package.path = prev
        t.assert(ok, 'module failed to load: ' .. tostring(mod))
        t.assert_type(mod.Outer_encode, 'function')
        t.assert_type(mod.Outer_decode, 'function')
        t.assert_type(mod.Outer_fields, 'table')
    end

    g['test_field_names_table_carries_keyword_keys_'..mode] = function()
        local out, modname = run_plugin(mode)
        local prev = package.path
        package.path = fio.pathjoin(out, '?.lua') .. ';'
                    .. fio.pathjoin(out, '?/init.lua') .. ';' .. prev
        package.loaded[modname] = nil
        local mod = require(modname)
        package.path = prev
        for _, kw in ipairs(LUA_KEYWORDS) do
            t.assert_equals(mod.Outer_fields[kw], kw,
                'M.Outer_fields["' .. kw .. '"] must round-trip the keyword')
        end
    end

    g['test_round_trip_keyword_fields_'..mode] = function()
        local out, modname = run_plugin(mode)
        local prev = package.path
        package.path = fio.pathjoin(out, '?.lua') .. ';'
                    .. fio.pathjoin(out, '?/init.lua') .. ';' .. prev
        package.loaded[modname] = nil
        local mod = require(modname)
        package.path = prev

        -- Populate every keyword-named field.
        local input = { [ 'function' ] = { x = 42 } }
        local n = 1
        for _, kw in ipairs(LUA_KEYWORDS) do
            if kw ~= 'function' then
                input[kw] = n
                n = n + 1
            end
        end

        local bytes = mod.Outer_encode(input)
        local decoded = mod.Outer_decode(bytes)
        t.assert_equals(decoded['function'].x, 42)
        for k, v in pairs(input) do
            if k ~= 'function' then
                t.assert_equals(decoded[k], v,
                    'round-trip failed for keyword field "' .. k .. '"')
            end
        end
    end
end