From 14e1981920edc18d473b8ae1034c31ee8d4e72a1 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 17 May 2026 07:21:30 +0300 Subject: [PATCH] codegen: bracket-quote Lua-keyword field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin emitted bare-identifier field names in three positions: the `M._fields` table key, the inline encoder's `v = t.` load, and the inline decoder's `result. = ...` store. When a proto field name collided with a Lua reserved word the generated `*_pb.lua` failed to load with `'(' expected near ''`. 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. --- cmd/protoc-gen-tarantool/internal/gen/gen.go | 11 +- .../internal/gen/inline.go | 30 ++-- cmd/protoc-gen-tarantool/internal/gen/name.go | 51 ++++++ test/codegen_lua_keywords_test.lua | 151 ++++++++++++++++++ 4 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 test/codegen_lua_keywords_test.lua diff --git a/cmd/protoc-gen-tarantool/internal/gen/gen.go b/cmd/protoc-gen-tarantool/internal/gen/gen.go index 39e6880cebdffb6fa42f976682d692e302e1b6f6..0a3efdd3bf7d5b4555cb4514cee3ba72725099c7 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/gen.go +++ b/cmd/protoc-gen-tarantool/internal/gen/gen.go @@ -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) } } diff --git a/cmd/protoc-gen-tarantool/internal/gen/inline.go b/cmd/protoc-gen-tarantool/internal/gen/inline.go index f484950fad89474d6ed124eca303331aeb7c1dfe..eff5acae95811ef773838ce2f2e703dbf499c46a 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/inline.go +++ b/cmd/protoc-gen-tarantool/internal/gen/inline.go @@ -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") diff --git a/cmd/protoc-gen-tarantool/internal/gen/name.go b/cmd/protoc-gen-tarantool/internal/gen/name.go index 85be2f2245b538cc336c4697dbfbecb673f598b2..931ac9e11b7eb2f4e94c4a0facd35fec1c7c65ae 100644 --- a/cmd/protoc-gen-tarantool/internal/gen/name.go +++ b/cmd/protoc-gen-tarantool/internal/gen/name.go @@ -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 `.`, falling back +// to `[]` 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: diff --git a/test/codegen_lua_keywords_test.lua b/test/codegen_lua_keywords_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..faa207c690881bc771dbef686e8fa0894a394302 --- /dev/null +++ b/test/codegen_lua_keywords_test.lua @@ -0,0 +1,151 @@ +-- Regression test for Lua-keyword proto field names. +-- +-- protoc-gen-tarantool used to emit bare-identifier table keys and +-- `t.` / `result.` 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 ''`. +-- +-- 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._fields[]` resolves to the same name string +-- (covers the field_names table emit site). +-- * Encoding `{[] = ...}` and decoding the bytes round-trips +-- (covers the inline-encoder `v = t.` and inline-decoder +-- `result. =` 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