M .beads/interactions.jsonl => .beads/interactions.jsonl +1 -0
@@ 18,3 18,4 @@
{"id":"int-ba4ac242","kind":"field_change","created_at":"2026-05-19T04:23:56.245983Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-47e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Compatibility contract formalized in docs/specs/c_accel_compat.md. Spec covers: PB_ENABLE_C=1 single activation switch, silent fallback, full public surface preservation (pb.encode/decode/decode_lazy/parse/from_pb/json/text/grpc), generated module wrappers, 64-bit cdata invariant, WKT shapes, unknown fields, extensions, error compatibility, install paths (pure-Lua and C-enabled), ABI versioning, conformance via existing-suite re-run, explicit out-of-scope list. Lazy view stays Lua-only per spike rationale. Downstream tasks (mq7, ra6, 43t, c0i) reference this spec for compat boundaries."}}
{"id":"int-951d03f7","kind":"field_change","created_at":"2026-05-19T04:24:12.141983Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-z7x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side strategy formalized in docs/specs/c_accel_strategy.md. Spec covers: pb_plan / pb_plan_field struct layout, field-name luaL_ref caching (spike-validated), 4 KB stack-backed pb_buf with malloc promotion, lua_createtable(0, n_fields) result pre-sizing, cached per-field stack indices for repeated/packed (spike Phase B: 2x speedup at 100 KB), 64-bit cdata via luaT_pushint64/checkint64, sub-buffer (chosen) vs backpatching (deferred) for sub-messages, two-pass packed encode, map iteration via lua_next, oneof grouping, unknown field passthrough, deferred micro-opts (manual varint inlining, SIMD scan), C99/-O2 build environment, deferred PB_C_TRACE/PB_C_CHECK_PARITY env knobs. ra6/mq7 references this spec for low-level decisions."}}
{"id":"int-7ce7c344","kind":"field_change","created_at":"2026-05-19T04:24:13.236096Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-wky","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Build and packaging scaffolding landed: docs/specs/c_accel_build_packaging.md spec, runtime/pb/init.lua PB_ENABLE_C=1 opt-in pcall hook (exposed as pb.c_runtime field), Justfile build-c/clean-c recipes (stub erroring cleanly until bd-ra6 lands runtime/pb/c/), Justfile lua_cpath constant + LUA_CPATH on test/test-one, .gitignore for compiled .so/.dylib, sourcehut .builds/{pure-lua,c-enabled}.yml manifests (ubuntu/noble, parallel jobs via separate files since sourcehut has no matrix), .sourcehut/conformance.yml for manual conformance triggers. Rockspec changes deferred to first C-source landing (described in spec). All 745 tests pass with PB_ENABLE_C unset and PB_ENABLE_C=1 (silent fallback verified)."}}
+{"id":"int-b6e91276","kind":"field_change","created_at":"2026-05-19T04:46:39.175032Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-mq7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Descriptor -> C plan compiler landed in runtime/pb/c/c_runtime.c. Compiles a finalized Lua descriptor into an opaque pb_plan userdata with: per-field records (field_number, wire_type, kind, repeated/packed/optional, pre-encoded tag bytes, sub_plan_idx, oneof_idx, enum_ref), oneof grouping (parallel array with member indices + field oneof_idx back-pointers), WKT override pointers (has_override + override_encode_ref/decode_ref skipping field-walk), extension range hooks (proto2 scaffolding), field-name luaL_ref cache, sub-plan table for cycle-safe recursion. Self-references (Person.friends -> Person) handled by stashing c_plan on desc BEFORE recursing, so resolve_sub_plan returns the same userdata. Idempotent: re-compiling returns cached plan. Build via 'just build-c'. Smoke test at test/c_runtime_plan_test.lua: 26 assertions across both codegen modes (full + runtime) verify module surface, scalar/enum/message/map/repeated/packed shapes, sub-plan resolution, self-reference cycle break, oneof grouping (Result.outcome), idempotency, WKT override detection (Timestamp). Full suite: 771/771 with PB_ENABLE_C=1, 745+26 skipped without (silent fallback verified). Unblocks 3b/3c/3l/3k (y1n, mz6, awv, rmf)."}}
M .beads/issues.jsonl => .beads/issues.jsonl +2 -2
@@ 4,7 4,7 @@
{"_type":"issue","id":"tarantool-protobuf-awv","title":"ra6 3l: 64-bit cdata fidelity","description":"int64/uint64/fixed64/sfixed64 must round-trip as LuaJIT cdata in both encode and decode — never narrowed to a Lua double. Same convention as msgpackffi, net.box, box.tuple, the built-in protobuf. Use luaT_pushuint64/luaT_pushint64 from Tarantool's module.h for the C side. Independent transverse concern — can land in parallel with 3b/3c since it's a per-kind fix rather than a new pipeline stage. Acceptance: a value \u003e 2^53 round-trips byte-equal and remains cdata after decode; luatest test/protobuf_test.lua int64-cdata cases pass with PB_ENABLE_C=1.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:10Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:10Z","dependencies":[{"issue_id":"tarantool-protobuf-awv","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:55Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:06Z","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-y1n","title":"ra6 3b: encode scalars (varint, fixed32/64, double, bool, enum, sint)","description":"C-side encode for scalar wire types into the message's output buffer. Reads field values via cached luaL_ref + lua_rawgeti, dispatches on plan field kind, writes varint/fixed32/fixed64/double/bool/enum/sint into the 4KB stack-backed buffer with malloc-promotion on overflow. Wire-byte output must be byte-equal to mode=full Lua output. Depends on 3a (needs the plan). Acceptance: Person encode round-trip works for {name='x', age=42, balance=-7, user_id=0xDEADBEEFCAFEBABEULL, weight_kg=3.14} with byte-equal output to mode=full pure Lua.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:03Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:03Z","dependencies":[{"issue_id":"tarantool-protobuf-y1n","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
-{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:58Z","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0}
+{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:26:53Z","started_at":"2026-05-19T04:26:53Z","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-1eq","title":"C accel spike: generic one-call C codec variant (strategy 3)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 3: a generic C codec that gets a descriptor + Lua table once per top-level encode/decode call and owns the inner loop. Internally dispatches per-field on descriptor kind (the work pf6 / ra6 ends up shipping in production). Measure with bench/c_accel/spike_bench.lua. Acceptance: numbers in bench/c_accel/README.md showing the gap to strategy 4 (hand-written, no dispatch).","notes":"Phase A measured strategy 4 ceiling at 3-10x over pure Lua. The 4 vs 3 gap is the key signal for pf6 architecture: \u003c20-40% gap means generic C runtime (ra6) alone is sufficient; \u003e40% gap means we need codegen-emitted C (c0i). For the spike, dispatch can be simple (switch on protoreflect.Kind); no need to design the compiled-plan format here.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:08Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-1eq","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-80t","title":"C accel spike: per-primitive FFI variant (strategy 2)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 2 of the C-acceleration benchmark: replace wire.lua's encode_varint / decode_varint / encode_string / decode_string / fixed32/64 / double with ffi.C.\u003cfn\u003e calls into a small C shim. The Lua dispatch loop stays; only the primitives cross. Measure with bench/c_accel/spike_bench.lua across the same 5 sizes. Acceptance: numbers added to bench/c_accel/README.md results table.","notes":"Phase A landed strategies 1 + 4. Strategy 4 (hand-written Person C codec) sets the upper bound at 3-10x over pure Lua. Strategy 2 must clear ~3x at every size to be competitive with 4; otherwise FFI per-primitive boundary cost is the wrong place to spend complexity. See bench/c_accel/README.md for full Phase A results.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:02Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-80t","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-04c","title":"C accel: benchmark spike for Lua C module vs FFI boundaries","description":"Build a small benchmark spike before committing to a full backend. Compare: pure Lua current path, per-primitive FFI calls, one Lua C module call per top-level encode/decode, and a hand-written C codec for hello.Person. Measure 10B/100B/1KB/10KB/100KB Person and proto2 BenchPayload. Acceptance: numbers show which boundary wins and quantify C-call overhead for tiny messages where current Lua decode already beats starwing.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:31Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:43:59Z","started_at":"2026-05-18T19:21:27Z","closed_at":"2026-05-18T19:43:59Z","close_reason":"Spike complete across all 4 boundaries (Lua / S2 FFI prims / S3 generic C / S4 hand C) at 10B-100KB. Bench: bench/c_accel/. Decisive results. S3 ≈ S4 within 15% (S3 often faster on encode at scale). S2 LOSES vs pure-Lua at every size 1KB+ on encode (0.60-0.99x) and at every size on decode (0.29-0.39x). FFI cost decomposition (bench/c_accel/ffi_probe.lua, memory luajit-ffi-boundary-cost-2026-05-18): bare FFI = 33ns; pointer-return FFI = 73ns; ffi.cast on string = 156ns; libc memcmp = 60ns; pure-Lua varint decode = 75ns. Per-primitive boundary cost equals pure-Lua dispatch cost — only message-level C crossing wins. ARCHITECTURE for pf6: ship ra6 (generic C runtime, descriptor-walking, one C call per message); drop c0i (≤15% headroom over ra6); drop per-primitive FFI. Required ra6 impl pattern: cache per-field stack indices for repeated/packed arrays during decode_message (naive lazy-getfield 2x slower at 100KB). Full details in bench/c_accel/README.md and bd memory c-accel-spike-04c-final-2026-05-18 + luajit-ffi-boundary-cost-2026-05-18.","dependency_count":0,"dependent_count":3,"comment_count":0}
@@ 32,7 32,7 @@
{"_type":"issue","id":"tarantool-protobuf-43t","title":"C accel: conformance and parity gate for C paths","description":"Extend tests/benches so C runtime and generated-C modes must pass the same luatest, interop, JSON/text, proto2, lazy passthrough where applicable, and Google conformance suites as Lua modes. Add bench output columns for lua-full, lua-runtime, c-runtime, c-generated, starwing. C acceleration must remain byte-equal with current encoders and preserve unknown fields/extensions/groups.","notes":"Architecture finalized in docs/c-accel.md. Parity strategy: REUSE existing test suites — no separate Lua-vs-C diff harness. (1) just test (luatest, 639 tests already parameterized over full/runtime modes) runs once with PB_ENABLE_C unset and once with PB_ENABLE_C=1. Both must pass. (2) just conformance (Google proto3 + proto2) runs both modes. (3) Interop fixtures (test/interop/fixtures/*.bin) cover both modes via the existing parity.full_vs_runtime tests; extend the matrix to also cover c-runtime. Logic: every assert is against a reference output (golden bytes, txtpb, conformance result). If Lua passes and C passes, both equal the reference, so Lua == C by transitivity. No new test infrastructure required. bench/bench.lua gains a c-runtime column.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:53Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:36Z","dependencies":[{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-c0i","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-ra6","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-wky","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-z7x","title":"C accel: C table and string handling strategy","description":"Design and implement the low-level C helpers for Lua table access and string/buffer handling. Cache field-name strings/registry refs in descriptor plans, pre-size result tables where possible, build output strings with luaL_Buffer or exact-size allocation, avoid per-field Lua stack churn, and handle 64-bit cdata consistently with the current Lua API.","notes":"Architecture finalized in docs/c-accel.md. Strategy decisions validated by bench/c_accel/ spike: (1) Field-name strings cached as luaL_ref slots at plan-compile time; each decode does lua_rawgeti from the cached ref instead of re-interning. (2) Output buffer: 4KB stack-backed cap with malloc promotion on overflow (pattern in bench/c_accel/person_codec.c — validates against tiny-msg overhead and big-msg correctness). (3) Result tables pre-sized via lua_createtable(0, n_fields) from descriptor stats. (4) Per-field stack indices cached for repeated/packed array fields for the duration of decode_message — naive lazy-getfield was 2x slower at 100KB (spike Phase B). (5) 64-bit cdata via luaT_pushuint64/luaT_pushint64 (Tarantool extensions in module.h). (6) Sub-messages use separate buf with stack-allocated header; backpatching avoided (the bench showed sub-buf approach is fast enough).","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:53Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:24:12Z","started_at":"2026-05-19T04:10:45Z","closed_at":"2026-05-19T04:24:12Z","close_reason":"C-side strategy formalized in docs/specs/c_accel_strategy.md. Spec covers: pb_plan / pb_plan_field struct layout, field-name luaL_ref caching (spike-validated), 4 KB stack-backed pb_buf with malloc promotion, lua_createtable(0, n_fields) result pre-sizing, cached per-field stack indices for repeated/packed (spike Phase B: 2x speedup at 100 KB), 64-bit cdata via luaT_pushint64/checkint64, sub-buffer (chosen) vs backpatching (deferred) for sub-messages, two-pass packed encode, map iteration via lua_next, oneof grouping, unknown field passthrough, deferred micro-opts (manual varint inlining, SIMD scan), C99/-O2 build environment, deferred PB_C_TRACE/PB_C_CHECK_PARITY env knobs. ra6/mq7 references this spec for low-level decisions.","dependency_count":0,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-47e","title":"C accel: preserve pure-Lua fallback and public API compatibility","description":"Define compatibility boundaries for C acceleration. Existing generated Lua modules and runtime pb APIs must keep working without a compiler or C module. New generated-C mode should be opt-in initially. Errors, 64-bit cdata behavior, WKT shapes, unknown fields, extension representation, lazy decode API, and text/JSON integration must remain compatible.","notes":"Architecture finalized in docs/c-accel.md. Concrete contract: (1) PB_ENABLE_C=1 env var is the only activation switch, default off. (2) require('pb') returns the same Lua surface in both modes — no API change. (3) 64-bit ints stay LuaJIT cdata (int64_t/uint64_t) in both modes. (4) WKT shapes unchanged. (5) Unknown fields round-trip identically. (6) Errors land as the same Lua error types. (7) Pure-Lua install must work without a C compiler — rockspec opt-builds the C module, install never fails on a host without cc. (8) When C module fails to load or PB_ENABLE_C is unset, runtime/pb/init.lua transparently uses the pure-Lua path.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:23:56Z","started_at":"2026-05-19T04:10:44Z","closed_at":"2026-05-19T04:23:56Z","close_reason":"Compatibility contract formalized in docs/specs/c_accel_compat.md. Spec covers: PB_ENABLE_C=1 single activation switch, silent fallback, full public surface preservation (pb.encode/decode/decode_lazy/parse/from_pb/json/text/grpc), generated module wrappers, 64-bit cdata invariant, WKT shapes, unknown fields, extensions, error compatibility, install paths (pure-Lua and C-enabled), ABI versioning, conformance via existing-suite re-run, explicit out-of-scope list. Lazy view stays Lua-only per spike rationale. Downstream tasks (mq7, ra6, 43t, c0i) reference this spec for compat boundaries.","dependency_count":0,"dependent_count":3,"comment_count":0}
-{"_type":"issue","id":"tarantool-protobuf-wky","title":"C accel: build and packaging support for C modules","description":"Add build-system and packaging support for C acceleration. Needs Justfile targets, rockspec support for compiled modules, platform naming, local dev build, CI matrix integration, and fallback when the C module is absent. Generated C backend must not make pure-Lua install impossible unless explicitly selected.","notes":"Architecture finalized in docs/c-accel.md. Concrete deliverables: (1) Justfile recipe 'just build-c' that builds the C runtime module (mirror bench/c_accel/Makefile auto-detection of TT_INC). (2) Rockspec optionally builds the C module — install on host without cc must succeed and produce a pure-Lua install. (3) runtime/pb/init.lua does pcall(require, 'pb.c_runtime') only when os.getenv('PB_ENABLE_C') == '1'. (4) Sourcehut CI build manifest gains one extra job that runs the full test+conformance suite with PB_ENABLE_C=1 set — single .build.yml, both modes covered on every push to master. (5) CI also runs one variant without the C module compiled to confirm pure-Lua install works.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:10:45Z","started_at":"2026-05-19T04:10:45Z","dependencies":[{"issue_id":"tarantool-protobuf-wky","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:11Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
+{"_type":"issue","id":"tarantool-protobuf-wky","title":"C accel: build and packaging support for C modules","description":"Add build-system and packaging support for C acceleration. Needs Justfile targets, rockspec support for compiled modules, platform naming, local dev build, CI matrix integration, and fallback when the C module is absent. Generated C backend must not make pure-Lua install impossible unless explicitly selected.","notes":"Architecture finalized in docs/c-accel.md. Concrete deliverables: (1) Justfile recipe 'just build-c' that builds the C runtime module (mirror bench/c_accel/Makefile auto-detection of TT_INC). (2) Rockspec optionally builds the C module — install on host without cc must succeed and produce a pure-Lua install. (3) runtime/pb/init.lua does pcall(require, 'pb.c_runtime') only when os.getenv('PB_ENABLE_C') == '1'. (4) Sourcehut CI build manifest gains one extra job that runs the full test+conformance suite with PB_ENABLE_C=1 set — single .build.yml, both modes covered on every push to master. (5) CI also runs one variant without the C module compiled to confirm pure-Lua install works.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:24:13Z","started_at":"2026-05-19T04:10:45Z","closed_at":"2026-05-19T04:24:13Z","close_reason":"Build and packaging scaffolding landed: docs/specs/c_accel_build_packaging.md spec, runtime/pb/init.lua PB_ENABLE_C=1 opt-in pcall hook (exposed as pb.c_runtime field), Justfile build-c/clean-c recipes (stub erroring cleanly until bd-ra6 lands runtime/pb/c/), Justfile lua_cpath constant + LUA_CPATH on test/test-one, .gitignore for compiled .so/.dylib, sourcehut .builds/{pure-lua,c-enabled}.yml manifests (ubuntu/noble, parallel jobs via separate files since sourcehut has no matrix), .sourcehut/conformance.yml for manual conformance triggers. Rockspec changes deferred to first C-source landing (described in spec). All 745 tests pass with PB_ENABLE_C unset and PB_ENABLE_C=1 (silent fallback verified).","dependencies":[{"issue_id":"tarantool-protobuf-wky","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:11Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-801","title":"Release: tagged releases + rockspec + Go plugin binaries","description":"Once the CI pipeline is green, ship tagged releases. Rockspec for the Lua runtime (publishable via tt rocks). Pre-built protoc-gen-tarantool binaries for darwin/arm64, darwin/amd64, linux/amd64, linux/arm64. Sourcecraft has 'sc release' tooling (see sc-release skill) for the release workflow itself.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:16Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:16Z","labels":["release"],"dependencies":[{"issue_id":"tarantool-protobuf-801","depends_on_id":"tarantool-protobuf-1eu","type":"blocks","created_at":"2026-05-17T18:47:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-1eu","title":"Release: Sourcecraft.dev project + CI pipeline","description":"Set up the canonical sourcecraft.dev project for the repo and a CI pipeline. Matrix: Tarantool 2.11 (CE+EE) and 3.x (CE+EE), Linux + macOS. Targets to run: just gen, just test, just bench-compare (alloc regression gate), just conformance (gated on the cached Docker image — see related CI wire-up bead). The repo lives under ~/data/home which by convention publishes to sourcecraft.dev (not github).","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:15Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:15Z","labels":["ci","release"],"dependencies":[{"issue_id":"tarantool-protobuf-1eu","depends_on_id":"tarantool-protobuf-7lf","type":"blocks","created_at":"2026-05-17T18:47:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-7lf","title":"CI: wire up conformance suite with cached Docker image","description":"The Docker image build (docker/conformance.Dockerfile) is the long pole at ~10-15 min on a clean cache. A registry push from a scheduled job would let CI runs reuse a warm image. Today the conformance suite runs locally via 'just conformance' but isn't gated on pushes. Goal: every push to master runs the binary+JSON and text-format suites; PRs run the same. Pre-requisite for M8 sourcecraft setup (this defines what the CI pipeline runs).","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:15Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:15Z","labels":["ci","conformance"],"dependency_count":0,"dependent_count":1,"comment_count":0}
M Justfile => Justfile +2 -2
@@ 74,12 74,12 @@ build-c:
echo " See docs/specs/c_accel_build_packaging.md."; \
exit 1; \
fi
- $(MAKE) -C runtime/pb/c
+ make -C runtime/pb/c
# Remove built C-runtime artifacts.
clean-c:
rm -f runtime/pb/c_runtime.so runtime/pb/c_runtime.dylib
- @if [ -d runtime/pb/c ]; then $(MAKE) -C runtime/pb/c clean; fi
+ @if [ -d runtime/pb/c ]; then make -C runtime/pb/c clean; fi
# ---------------------------------------------------------------------------
# Codegen
A runtime/pb/c/Makefile => runtime/pb/c/Makefile +55 -0
@@ 0,0 1,55 @@
+# Makefile for pb.c_runtime — the optional C-acceleration runtime.
+#
+# Output sits at ../c_runtime.{so,dylib} so that
+# require('pb.c_runtime')
+# resolves it via the LUA_CPATH the Justfile sets for `just test`
+# (see Justfile lua_cpath: "./runtime/?.so;./runtime/?.dylib;...").
+#
+# Built only when PB_ENABLE_C=1 is part of the user's workflow.
+# Pure-Lua install ignores this directory entirely.
+
+# Locate <module.h>. Order: env override, brew prefix, common system dirs.
+# Mirrors bench/c_accel/Makefile.
+TT_INC ?= $(shell \
+ if [ -n "$$TARANTOOL_INCLUDE" ] && [ -f "$$TARANTOOL_INCLUDE/module.h" ]; then \
+ echo "$$TARANTOOL_INCLUDE"; exit 0; \
+ fi; \
+ for d in \
+ $$(brew --prefix tarantool 2>/dev/null)/include/tarantool \
+ /opt/homebrew/include/tarantool \
+ /usr/local/include/tarantool \
+ /usr/include/tarantool; do \
+ if [ -f "$$d/module.h" ]; then echo "$$d"; exit 0; fi; \
+ done)
+
+ifeq ($(TT_INC),)
+$(error Cannot find <module.h>; set TARANTOOL_INCLUDE=/path/to/include/tarantool or install tarantool-dev)
+endif
+
+UNAME_S := $(shell uname -s)
+
+CFLAGS := -O2 -fPIC -Wall -Wextra -std=c99 -I$(TT_INC)
+ifeq ($(UNAME_S),Darwin)
+ LIB_EXT := dylib
+ LDFLAGS := -bundle -undefined dynamic_lookup
+else
+ LIB_EXT := so
+ LDFLAGS := -shared
+endif
+
+SRCS := c_runtime.c
+OBJS := $(SRCS:.c=.o)
+OUTPUT := ../c_runtime.$(LIB_EXT)
+
+all: $(OUTPUT)
+
+$(OUTPUT): $(OBJS)
+ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS)
+
+%.o: %.c
+ $(CC) $(CFLAGS) -c -o $@ $<
+
+clean:
+ rm -f *.o $(OUTPUT)
+
+.PHONY: all clean
A runtime/pb/c/c_runtime.c => runtime/pb/c/c_runtime.c +840 -0
@@ 0,0 1,840 @@
+/*
+ * c_runtime.c -- C-acceleration runtime for tarantool-protobuf.
+ *
+ * Phase 1 (bd-mq7): descriptor -> plan compiler. Walks a finalized
+ * Lua descriptor table and produces an opaque `pb_plan` userdata.
+ * Encode/decode entry points arrive with bd-3b/3c/etc.; this file
+ * only carries the plan compilation surface and enough introspection
+ * to make a smoke test possible.
+ *
+ * Conventions follow docs/specs/c_accel_strategy.md.
+ */
+
+#include <module.h>
+#include <lauxlib.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define PB_PLAN_MT "pb.plan"
+#define PB_ABI_VERSION "1"
+
+/* ---------------------------------------------------------------- *
+ * Kind / wire-type taxonomy. *
+ * *
+ * Mirrors runtime/pb/wire.lua's TYPE_INFO. The numbering is *
+ * internal — only the C runtime needs to agree with itself. *
+ * ---------------------------------------------------------------- */
+
+enum {
+ PB_KIND_NONE = 0,
+ PB_KIND_INT32,
+ PB_KIND_INT64,
+ PB_KIND_UINT32,
+ PB_KIND_UINT64,
+ PB_KIND_SINT32,
+ PB_KIND_SINT64,
+ PB_KIND_FIXED32,
+ PB_KIND_FIXED64,
+ PB_KIND_SFIXED32,
+ PB_KIND_SFIXED64,
+ PB_KIND_FLOAT,
+ PB_KIND_DOUBLE,
+ PB_KIND_BOOL,
+ PB_KIND_STRING,
+ PB_KIND_BYTES,
+ PB_KIND_ENUM,
+ PB_KIND_MESSAGE,
+ PB_KIND_MAP,
+};
+
+/* Wire types per proto3 spec. */
+enum {
+ PB_WIRE_VARINT = 0,
+ PB_WIRE_I64 = 1,
+ PB_WIRE_LEN = 2,
+ PB_WIRE_I32 = 5,
+};
+
+struct kind_info {
+ const char *proto_type;
+ uint8_t kind;
+ uint8_t wire_type;
+};
+
+/* Lookup table keyed by proto3 field type string. Linear scan is fine —
+ * a finalized descriptor calls this once per field at plan-compile time,
+ * not per encode/decode. */
+static const struct kind_info kind_table[] = {
+ {"int32", PB_KIND_INT32, PB_WIRE_VARINT},
+ {"int64", PB_KIND_INT64, PB_WIRE_VARINT},
+ {"uint32", PB_KIND_UINT32, PB_WIRE_VARINT},
+ {"uint64", PB_KIND_UINT64, PB_WIRE_VARINT},
+ {"sint32", PB_KIND_SINT32, PB_WIRE_VARINT},
+ {"sint64", PB_KIND_SINT64, PB_WIRE_VARINT},
+ {"bool", PB_KIND_BOOL, PB_WIRE_VARINT},
+ {"fixed32", PB_KIND_FIXED32, PB_WIRE_I32},
+ {"sfixed32", PB_KIND_SFIXED32, PB_WIRE_I32},
+ {"float", PB_KIND_FLOAT, PB_WIRE_I32},
+ {"fixed64", PB_KIND_FIXED64, PB_WIRE_I64},
+ {"sfixed64", PB_KIND_SFIXED64, PB_WIRE_I64},
+ {"double", PB_KIND_DOUBLE, PB_WIRE_I64},
+ {"string", PB_KIND_STRING, PB_WIRE_LEN},
+ {"bytes", PB_KIND_BYTES, PB_WIRE_LEN},
+};
+
+static const struct kind_info *
+lookup_kind(const char *proto_type)
+{
+ if (proto_type == NULL)
+ return NULL;
+ for (size_t i = 0; i < sizeof(kind_table) / sizeof(kind_table[0]); i++) {
+ if (strcmp(kind_table[i].proto_type, proto_type) == 0)
+ return &kind_table[i];
+ }
+ return NULL;
+}
+
+/* ---------------------------------------------------------------- *
+ * Plan struct layout. *
+ * *
+ * Spec: docs/specs/c_accel_strategy.md § The plan userdata. *
+ * Simplifications from the spec for mq7: *
+ * - oneofs[] populated but member dispatch lives in 3i *
+ * - extension_range_* populated but dispatch lives in 3i *
+ * - sub_plan_idx points into `sub_plans_ref` table (1-based) *
+ * - Field name strings live in a Lua table keyed by 1..n; *
+ * lookup via `lua_rawgeti(L, names, i+1)` per spec. *
+ * ---------------------------------------------------------------- */
+
+typedef struct pb_plan_field {
+ uint32_t field_number;
+ uint8_t wire_type;
+ uint8_t kind;
+ uint8_t packed;
+ uint8_t repeated;
+ uint8_t optional;
+ uint8_t tag_len;
+ uint8_t tag_bytes[5];
+ int sub_plan_idx; /* 1-based into sub_plans table; 0 if none */
+ uint8_t map_key_kind;
+ uint8_t map_value_kind;
+ int map_value_sub_plan_idx; /* 1-based; 0 if value is scalar */
+ int oneof_idx; /* 0-based into plan->oneofs; -1 if none */
+ int enum_ref; /* LUA_REGISTRYINDEX ref for enum desc; LUA_NOREF if none */
+} pb_plan_field;
+
+typedef struct pb_plan_oneof {
+ char *name; /* malloc'd */
+ int n_members;
+ int *member_indices; /* indices into plan->fields */
+} pb_plan_oneof;
+
+typedef struct pb_plan {
+ char *name; /* malloc'd descriptor name */
+ int n_fields;
+ pb_plan_field *fields;
+ int n_oneofs;
+ pb_plan_oneof *oneofs;
+ int extension_range_start;
+ int extension_range_end;
+ uint8_t has_override;
+ int override_encode_ref; /* LUA_NOREF if absent */
+ int override_decode_ref;
+ int field_names_ref; /* table { [1]=name1, ... } */
+ int sub_plans_ref; /* table { [1]=plan_userdata, ... } */
+} pb_plan;
+
+/* ---------------------------------------------------------------- *
+ * Tag encoding. *
+ * *
+ * Pre-encodes the (field_number << 3) | wire_type varint so the *
+ * hot encode path emits a fixed memcpy instead of recomputing. *
+ * Up to 5 bytes for any legal field number (2^29 - 1 max). *
+ * ---------------------------------------------------------------- */
+
+static void
+encode_tag(uint32_t field_number, uint8_t wire_type,
+ uint8_t *out, uint8_t *out_len)
+{
+ uint64_t v = ((uint64_t)field_number << 3) | wire_type;
+ uint8_t i = 0;
+ while (v >= 0x80) {
+ out[i++] = (uint8_t)(v | 0x80);
+ v >>= 7;
+ }
+ out[i++] = (uint8_t)v;
+ *out_len = i;
+}
+
+/* ---------------------------------------------------------------- *
+ * Plan lifecycle: alloc / free. *
+ * ---------------------------------------------------------------- */
+
+static void
+plan_free(lua_State *L, pb_plan *p)
+{
+ if (p->name != NULL)
+ free(p->name);
+ if (p->fields != NULL) {
+ for (int i = 0; i < p->n_fields; i++) {
+ if (p->fields[i].enum_ref != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, p->fields[i].enum_ref);
+ }
+ free(p->fields);
+ }
+ if (p->oneofs != NULL) {
+ for (int i = 0; i < p->n_oneofs; i++) {
+ free(p->oneofs[i].name);
+ free(p->oneofs[i].member_indices);
+ }
+ free(p->oneofs);
+ }
+ if (p->override_encode_ref != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, p->override_encode_ref);
+ if (p->override_decode_ref != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, p->override_decode_ref);
+ if (p->field_names_ref != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, p->field_names_ref);
+ if (p->sub_plans_ref != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, p->sub_plans_ref);
+
+ memset(p, 0, sizeof(*p));
+ p->override_encode_ref = LUA_NOREF;
+ p->override_decode_ref = LUA_NOREF;
+ p->field_names_ref = LUA_NOREF;
+ p->sub_plans_ref = LUA_NOREF;
+}
+
+static int
+plan_gc(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ plan_free(L, p);
+ return 0;
+}
+
+static int
+plan_tostring(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ lua_pushfstring(L, "pb.plan: %s (n_fields=%d)",
+ p->name != NULL ? p->name : "(unnamed)",
+ p->n_fields);
+ return 1;
+}
+
+/* ---------------------------------------------------------------- *
+ * Field compilation. *
+ * *
+ * Reads one descriptor-field table (at the top of the stack) and *
+ * fills the corresponding pb_plan_field. Recurses into sub-message *
+ * plans by calling compile_plan_impl. *
+ * *
+ * Stack expectations on entry: *
+ * -1: field-descriptor table (e.g. {name="foo", id=1, kind=...})*
+ * sub_plans_stack_idx: the sub-plans table being filled *
+ * *
+ * Stack on exit: same (we pop everything we push). *
+ * ---------------------------------------------------------------- */
+
+static int compile_plan_impl(lua_State *L, int desc_idx);
+
+/* Append a sub-plan userdata (at -1) to the sub-plans table at
+ * sub_plans_idx; return its 1-based index. Pops the userdata. */
+static int
+push_sub_plan(lua_State *L, int sub_plans_idx)
+{
+ int n = (int)lua_objlen(L, sub_plans_idx);
+ lua_rawseti(L, sub_plans_idx, n + 1);
+ return n + 1;
+}
+
+/* Resolve a sub-message descriptor (at stack idx `sub_desc_idx`) into a
+ * 1-based index into the sub-plans table. Stack-neutral: pushes/pops the
+ * intermediate desc copy and plan userdata internally. */
+static int
+resolve_sub_plan(lua_State *L, int sub_desc_idx, int sub_plans_idx)
+{
+ int saved_top = lua_gettop(L);
+ /* compile_plan_impl is idempotent — if desc.c_plan exists it just
+ * leaves the cached userdata on top. */
+ lua_pushvalue(L, sub_desc_idx);
+ int dup_idx = lua_gettop(L);
+ compile_plan_impl(L, dup_idx);
+ if (!lua_isuserdata(L, -1))
+ luaL_error(L, "compile_plan_impl did not return a userdata");
+ int idx = push_sub_plan(L, sub_plans_idx); /* pops plan userdata */
+ lua_settop(L, saved_top); /* drop sub_desc copy */
+ return idx;
+}
+
+/* Compile one field. `f_desc_idx` is the absolute stack index of the
+ * field descriptor table. `field` points at the pb_plan_field slot to
+ * fill. `field_names_idx`, `sub_plans_idx` are absolute indices of the
+ * field-names and sub-plans tables being populated. `field_idx_1based`
+ * is the 1-based position used for the field-names lookup table. */
+static void
+compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
+ int field_names_idx, int sub_plans_idx, int field_idx_1based)
+{
+ memset(field, 0, sizeof(*field));
+ field->enum_ref = LUA_NOREF;
+ field->oneof_idx = -1;
+
+ /* field number */
+ lua_getfield(L, f_desc_idx, "id");
+ if (!lua_isnumber(L, -1))
+ luaL_error(L, "field descriptor missing 'id'");
+ field->field_number = (uint32_t)lua_tointeger(L, -1);
+ lua_pop(L, 1);
+
+ /* name (interned in field_names_ref under field_idx_1based) */
+ lua_getfield(L, f_desc_idx, "name");
+ if (!lua_isstring(L, -1))
+ luaL_error(L, "field descriptor missing 'name'");
+ lua_pushvalue(L, -1); /* dup */
+ lua_rawseti(L, field_names_idx, field_idx_1based);
+ lua_pop(L, 1);
+
+ /* repeated / packed / optional */
+ lua_getfield(L, f_desc_idx, "repeated");
+ field->repeated = lua_toboolean(L, -1) ? 1 : 0;
+ lua_pop(L, 1);
+ lua_getfield(L, f_desc_idx, "packed");
+ field->packed = lua_toboolean(L, -1) ? 1 : 0;
+ lua_pop(L, 1);
+ lua_getfield(L, f_desc_idx, "optional");
+ field->optional = lua_toboolean(L, -1) ? 1 : 0;
+ lua_pop(L, 1);
+
+ /* kind dispatch on desc.kind */
+ lua_getfield(L, f_desc_idx, "kind");
+ const char *kind_str = lua_tostring(L, -1);
+ if (kind_str == NULL)
+ luaL_error(L, "field descriptor missing 'kind'");
+
+ uint8_t element_wire_type;
+
+ if (strcmp(kind_str, "scalar") == 0) {
+ lua_pop(L, 1); /* pop kind */
+ lua_getfield(L, f_desc_idx, "proto_type");
+ const char *pt = lua_tostring(L, -1);
+ const struct kind_info *ki = lookup_kind(pt);
+ if (ki == NULL)
+ luaL_error(L, "unknown scalar proto_type: %s",
+ pt != NULL ? pt : "(nil)");
+ field->kind = ki->kind;
+ element_wire_type = ki->wire_type;
+ lua_pop(L, 1);
+ } else if (strcmp(kind_str, "enum") == 0) {
+ lua_pop(L, 1);
+ field->kind = PB_KIND_ENUM;
+ element_wire_type = PB_WIRE_VARINT;
+ lua_getfield(L, f_desc_idx, "enum");
+ if (lua_istable(L, -1)) {
+ field->enum_ref = luaL_ref(L, LUA_REGISTRYINDEX);
+ } else {
+ lua_pop(L, 1);
+ }
+ } else if (strcmp(kind_str, "message") == 0) {
+ lua_pop(L, 1);
+ field->kind = PB_KIND_MESSAGE;
+ element_wire_type = PB_WIRE_LEN;
+ lua_getfield(L, f_desc_idx, "message");
+ if (!lua_istable(L, -1))
+ luaL_error(L, "message field '%s' missing 'message' descriptor",
+ "?");
+ int sub_desc = lua_gettop(L);
+ field->sub_plan_idx = resolve_sub_plan(L, sub_desc, sub_plans_idx);
+ lua_pop(L, 1); /* sub-desc table */
+ } else if (strcmp(kind_str, "map") == 0) {
+ lua_pop(L, 1);
+ field->kind = PB_KIND_MAP;
+ element_wire_type = PB_WIRE_LEN;
+
+ /* key */
+ lua_getfield(L, f_desc_idx, "key");
+ if (!lua_istable(L, -1))
+ luaL_error(L, "map field missing 'key' descriptor");
+ lua_getfield(L, -1, "proto_type");
+ const struct kind_info *ki = lookup_kind(lua_tostring(L, -1));
+ if (ki == NULL)
+ luaL_error(L, "map key has unknown proto_type");
+ field->map_key_kind = ki->kind;
+ lua_pop(L, 2); /* proto_type + key */
+
+ /* value */
+ lua_getfield(L, f_desc_idx, "value");
+ if (!lua_istable(L, -1))
+ luaL_error(L, "map field missing 'value' descriptor");
+ lua_getfield(L, -1, "kind");
+ const char *vk = lua_tostring(L, -1);
+ lua_pop(L, 1);
+ if (vk != NULL && strcmp(vk, "scalar") == 0) {
+ lua_getfield(L, -1, "proto_type");
+ const struct kind_info *vki = lookup_kind(lua_tostring(L, -1));
+ if (vki == NULL)
+ luaL_error(L, "map value has unknown scalar proto_type");
+ field->map_value_kind = vki->kind;
+ lua_pop(L, 1);
+ } else if (vk != NULL && strcmp(vk, "message") == 0) {
+ field->map_value_kind = PB_KIND_MESSAGE;
+ lua_getfield(L, -1, "message");
+ if (lua_istable(L, -1)) {
+ int sub_desc = lua_gettop(L);
+ field->map_value_sub_plan_idx =
+ resolve_sub_plan(L, sub_desc, sub_plans_idx);
+ }
+ lua_pop(L, 1);
+ } else if (vk != NULL && strcmp(vk, "enum") == 0) {
+ field->map_value_kind = PB_KIND_ENUM;
+ } else {
+ luaL_error(L, "map value has unknown kind: %s",
+ vk != NULL ? vk : "(nil)");
+ }
+ lua_pop(L, 1); /* value table */
+ } else {
+ luaL_error(L, "unknown field kind: %s", kind_str);
+ return; /* unreachable */
+ }
+
+ /* Wire type: repeated+packed → LEN regardless of element type;
+ * repeated unpacked → element type per tag; singular → element. */
+ if (field->repeated && field->packed) {
+ field->wire_type = PB_WIRE_LEN;
+ } else {
+ field->wire_type = element_wire_type;
+ }
+
+ encode_tag(field->field_number, field->wire_type,
+ field->tag_bytes, &field->tag_len);
+}
+
+/* ---------------------------------------------------------------- *
+ * Oneof compilation. *
+ * *
+ * desc.oneofs is the hash table {[name]=members}. We walk it and *
+ * build pb_plan_oneof[]. For each member field, set its oneof_idx *
+ * to point back at the plan's oneof entry. *
+ * ---------------------------------------------------------------- */
+
+static int
+find_field_by_name(pb_plan *p, lua_State *L, int field_names_idx,
+ const char *name)
+{
+ for (int i = 0; i < p->n_fields; i++) {
+ lua_rawgeti(L, field_names_idx, i + 1);
+ const char *fn = lua_tostring(L, -1);
+ int match = (fn != NULL && strcmp(fn, name) == 0);
+ lua_pop(L, 1);
+ if (match) return i;
+ }
+ return -1;
+}
+
+static void
+compile_oneofs(lua_State *L, pb_plan *p, int desc_idx, int field_names_idx)
+{
+ lua_getfield(L, desc_idx, "oneofs");
+ if (!lua_istable(L, -1)) {
+ lua_pop(L, 1);
+ return;
+ }
+
+ /* First pass: count. */
+ int count = 0;
+ lua_pushnil(L);
+ while (lua_next(L, -2) != 0) {
+ count++;
+ lua_pop(L, 1);
+ }
+ if (count == 0) {
+ lua_pop(L, 1);
+ return;
+ }
+
+ p->oneofs = (pb_plan_oneof *)calloc(count, sizeof(pb_plan_oneof));
+ p->n_oneofs = count;
+
+ int idx = 0;
+ lua_pushnil(L);
+ while (lua_next(L, -2) != 0) {
+ /* key at -2 (oneof name), value at -1 (members array) */
+ const char *oname = lua_tostring(L, -2);
+ p->oneofs[idx].name = strdup(oname != NULL ? oname : "");
+
+ int n_members = (int)lua_objlen(L, -1);
+ p->oneofs[idx].n_members = n_members;
+ p->oneofs[idx].member_indices = (int *)calloc(n_members, sizeof(int));
+
+ for (int j = 0; j < n_members; j++) {
+ lua_rawgeti(L, -1, j + 1);
+ const char *mname = lua_tostring(L, -1);
+ int fi = find_field_by_name(p, L, field_names_idx,
+ mname != NULL ? mname : "");
+ lua_pop(L, 1);
+ p->oneofs[idx].member_indices[j] = fi;
+ if (fi >= 0)
+ p->fields[fi].oneof_idx = idx;
+ }
+
+ lua_pop(L, 1); /* value */
+ idx++;
+ }
+ lua_pop(L, 1); /* oneofs table */
+}
+
+/* ---------------------------------------------------------------- *
+ * Main compile entry. *
+ * *
+ * Idempotent. If desc.c_plan exists and is a pb.plan userdata, *
+ * returns it. Otherwise allocates a new one, stashes it on *
+ * desc.c_plan BEFORE recursing into sub-plans (breaks cycles for *
+ * self-referencing messages like Person.friends → Person), then *
+ * walks fields and oneofs. *
+ * ---------------------------------------------------------------- */
+
+/* Lua 5.1 lacks lua_absindex; LuaJIT's compatibility layer too. */
+static inline int
+abs_idx(lua_State *L, int idx)
+{
+ if (idx < 0 && idx > LUA_REGISTRYINDEX)
+ return lua_gettop(L) + idx + 1;
+ return idx;
+}
+
+static int
+compile_plan_impl(lua_State *L, int desc_idx)
+{
+ desc_idx = abs_idx(L, desc_idx);
+
+ /* Idempotency check. */
+ lua_getfield(L, desc_idx, "c_plan");
+ if (lua_isuserdata(L, -1)) {
+ /* Already compiled; leave on top of stack and pop the original
+ * desc-table push pattern wasn't done — caller still owns. */
+ return 1;
+ }
+ lua_pop(L, 1);
+
+ /* Allocate the plan userdata and stash it immediately. */
+ pb_plan *p = (pb_plan *)lua_newuserdata(L, sizeof(pb_plan));
+ memset(p, 0, sizeof(*p));
+ p->override_encode_ref = LUA_NOREF;
+ p->override_decode_ref = LUA_NOREF;
+ p->field_names_ref = LUA_NOREF;
+ p->sub_plans_ref = LUA_NOREF;
+
+ luaL_getmetatable(L, PB_PLAN_MT);
+ lua_setmetatable(L, -2);
+ int plan_idx = lua_gettop(L);
+
+ /* Stash on desc.c_plan first to break sub-message cycles. */
+ lua_pushvalue(L, plan_idx);
+ lua_setfield(L, desc_idx, "c_plan");
+
+ /* desc.name */
+ lua_getfield(L, desc_idx, "name");
+ const char *dname = lua_tostring(L, -1);
+ p->name = strdup(dname != NULL ? dname : "");
+ lua_pop(L, 1);
+
+ /* WKT override pointers — desc.encode / desc.decode. */
+ lua_getfield(L, desc_idx, "encode");
+ if (lua_isfunction(L, -1)) {
+ p->has_override = 1;
+ p->override_encode_ref = luaL_ref(L, LUA_REGISTRYINDEX);
+ } else {
+ lua_pop(L, 1);
+ }
+ lua_getfield(L, desc_idx, "decode");
+ if (lua_isfunction(L, -1)) {
+ p->has_override = 1;
+ p->override_decode_ref = luaL_ref(L, LUA_REGISTRYINDEX);
+ } else {
+ lua_pop(L, 1);
+ }
+
+ /* Extension ranges (proto2). desc.extension_ranges = {{start, end}, ...} */
+ lua_getfield(L, desc_idx, "extension_ranges");
+ if (lua_istable(L, -1) && lua_objlen(L, -1) >= 1) {
+ lua_rawgeti(L, -1, 1);
+ if (lua_istable(L, -1)) {
+ lua_rawgeti(L, -1, 1);
+ p->extension_range_start = (int)lua_tointeger(L, -1);
+ lua_pop(L, 1);
+ lua_rawgeti(L, -1, 2);
+ p->extension_range_end = (int)lua_tointeger(L, -1);
+ lua_pop(L, 1);
+ }
+ lua_pop(L, 1);
+ }
+ lua_pop(L, 1);
+
+ /* If this descriptor has an override, we skip field-walk entirely —
+ * the override owns encode/decode and the field array is unused. */
+ if (p->has_override) {
+ return 1;
+ }
+
+ /* Create the field-names and sub-plans tables. */
+ lua_newtable(L);
+ int field_names_idx = lua_gettop(L);
+ lua_newtable(L);
+ int sub_plans_idx = lua_gettop(L);
+
+ /* Walk desc.fields. */
+ lua_getfield(L, desc_idx, "fields");
+ if (!lua_istable(L, -1))
+ luaL_error(L, "descriptor '%s' has no 'fields'",
+ p->name != NULL ? p->name : "?");
+ int n_fields = (int)lua_objlen(L, -1);
+ int fields_table_idx = lua_gettop(L);
+
+ p->n_fields = n_fields;
+ p->fields = (pb_plan_field *)calloc(
+ n_fields > 0 ? n_fields : 1, sizeof(pb_plan_field));
+
+ for (int i = 0; i < n_fields; i++) {
+ lua_rawgeti(L, fields_table_idx, i + 1);
+ int f_desc_idx = lua_gettop(L);
+ compile_field(L, f_desc_idx, &p->fields[i],
+ field_names_idx, sub_plans_idx, i + 1);
+ lua_pop(L, 1);
+ }
+
+ lua_pop(L, 1); /* fields table */
+
+ /* Compile oneofs after fields so oneof_idx back-pointers can be set. */
+ compile_oneofs(L, p, desc_idx, field_names_idx);
+
+ /* Stash the field-names + sub-plans tables in the registry. */
+ lua_pushvalue(L, sub_plans_idx);
+ p->sub_plans_ref = luaL_ref(L, LUA_REGISTRYINDEX);
+ lua_pop(L, 1); /* sub_plans_idx (now unreferenced from stack) */
+
+ lua_pushvalue(L, field_names_idx);
+ p->field_names_ref = luaL_ref(L, LUA_REGISTRYINDEX);
+ lua_pop(L, 1);
+
+ /* Stack now has just the plan userdata on top. */
+ return 1;
+}
+
+static int
+compile_plan_lua(lua_State *L)
+{
+ luaL_checktype(L, 1, LUA_TTABLE);
+ lua_settop(L, 1);
+ return compile_plan_impl(L, 1);
+}
+
+/* ---------------------------------------------------------------- *
+ * Introspection (for the smoke test and future debugging). *
+ * *
+ * These are intentionally lean — enough to let a Lua test assert *
+ * the plan-build produced sensible values without a C-side test *
+ * harness. *
+ * ---------------------------------------------------------------- */
+
+static int
+plan_n_fields(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ lua_pushinteger(L, p->n_fields);
+ return 1;
+}
+
+static int
+plan_name(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ lua_pushstring(L, p->name != NULL ? p->name : "");
+ return 1;
+}
+
+static int
+plan_field_info(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ int i = luaL_checkint(L, 2);
+ if (i < 1 || i > p->n_fields)
+ return luaL_error(L, "field index %d out of range [1, %d]",
+ i, p->n_fields);
+ pb_plan_field *f = &p->fields[i - 1];
+
+ lua_createtable(L, 0, 9);
+
+ lua_pushinteger(L, f->field_number);
+ lua_setfield(L, -2, "field_number");
+
+ lua_pushinteger(L, f->wire_type);
+ lua_setfield(L, -2, "wire_type");
+
+ lua_pushinteger(L, f->kind);
+ lua_setfield(L, -2, "kind");
+
+ lua_pushboolean(L, f->repeated);
+ lua_setfield(L, -2, "repeated");
+
+ lua_pushboolean(L, f->packed);
+ lua_setfield(L, -2, "packed");
+
+ lua_pushboolean(L, f->optional);
+ lua_setfield(L, -2, "optional");
+
+ lua_pushinteger(L, f->sub_plan_idx);
+ lua_setfield(L, -2, "sub_plan_idx");
+
+ lua_pushinteger(L, f->oneof_idx);
+ lua_setfield(L, -2, "oneof_idx");
+
+ lua_pushlstring(L, (const char *)f->tag_bytes, f->tag_len);
+ lua_setfield(L, -2, "tag_bytes");
+
+ /* Field name from cached table. */
+ lua_rawgeti(L, LUA_REGISTRYINDEX, p->field_names_ref);
+ lua_rawgeti(L, -1, i);
+ lua_remove(L, -2);
+ lua_setfield(L, -2, "name");
+
+ /* Map kinds, if any. */
+ if (f->kind == PB_KIND_MAP) {
+ lua_pushinteger(L, f->map_key_kind);
+ lua_setfield(L, -2, "map_key_kind");
+ lua_pushinteger(L, f->map_value_kind);
+ lua_setfield(L, -2, "map_value_kind");
+ lua_pushinteger(L, f->map_value_sub_plan_idx);
+ lua_setfield(L, -2, "map_value_sub_plan_idx");
+ }
+
+ return 1;
+}
+
+static int
+plan_n_oneofs(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ lua_pushinteger(L, p->n_oneofs);
+ return 1;
+}
+
+static int
+plan_oneof_info(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ int i = luaL_checkint(L, 2);
+ if (i < 1 || i > p->n_oneofs)
+ return luaL_error(L, "oneof index %d out of range [1, %d]",
+ i, p->n_oneofs);
+ pb_plan_oneof *o = &p->oneofs[i - 1];
+
+ lua_createtable(L, 0, 2);
+ lua_pushstring(L, o->name != NULL ? o->name : "");
+ lua_setfield(L, -2, "name");
+
+ lua_createtable(L, o->n_members, 0);
+ for (int j = 0; j < o->n_members; j++) {
+ lua_pushinteger(L, o->member_indices[j]);
+ lua_rawseti(L, -2, j + 1);
+ }
+ lua_setfield(L, -2, "member_indices");
+
+ return 1;
+}
+
+static int
+plan_has_override(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ lua_pushboolean(L, p->has_override);
+ return 1;
+}
+
+static int
+plan_sub_plan(lua_State *L)
+{
+ pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ int i = luaL_checkint(L, 2);
+ if (p->sub_plans_ref == LUA_NOREF || i < 1) {
+ lua_pushnil(L);
+ return 1;
+ }
+ lua_rawgeti(L, LUA_REGISTRYINDEX, p->sub_plans_ref);
+ lua_rawgeti(L, -1, i);
+ lua_remove(L, -2);
+ return 1;
+}
+
+/* ---------------------------------------------------------------- *
+ * Module entry. *
+ * ---------------------------------------------------------------- */
+
+static const struct luaL_Reg c_runtime_methods[] = {
+ {"compile_plan", compile_plan_lua},
+ {"plan_n_fields", plan_n_fields},
+ {"plan_name", plan_name},
+ {"plan_field_info", plan_field_info},
+ {"plan_n_oneofs", plan_n_oneofs},
+ {"plan_oneof_info", plan_oneof_info},
+ {"plan_has_override", plan_has_override},
+ {"plan_sub_plan", plan_sub_plan},
+ {NULL, NULL},
+};
+
+static const struct luaL_Reg plan_mt_methods[] = {
+ {"__gc", plan_gc},
+ {"__tostring", plan_tostring},
+ {NULL, NULL},
+};
+
+LUA_API int
+luaopen_pb_c_runtime(lua_State *L)
+{
+ /* Register the plan metatable. */
+ luaL_newmetatable(L, PB_PLAN_MT);
+ luaL_register(L, NULL, plan_mt_methods);
+ lua_pop(L, 1);
+
+ /* Build the module table. */
+ lua_newtable(L);
+ luaL_register(L, NULL, c_runtime_methods);
+
+ lua_pushliteral(L, PB_ABI_VERSION);
+ lua_setfield(L, -2, "_abi_version");
+
+ /* Kind constants — exported so Lua tests can compare without
+ * duplicating the enum. */
+ lua_createtable(L, 0, 19);
+ lua_pushinteger(L, PB_KIND_NONE); lua_setfield(L, -2, "NONE");
+ lua_pushinteger(L, PB_KIND_INT32); lua_setfield(L, -2, "INT32");
+ lua_pushinteger(L, PB_KIND_INT64); lua_setfield(L, -2, "INT64");
+ lua_pushinteger(L, PB_KIND_UINT32); lua_setfield(L, -2, "UINT32");
+ lua_pushinteger(L, PB_KIND_UINT64); lua_setfield(L, -2, "UINT64");
+ lua_pushinteger(L, PB_KIND_SINT32); lua_setfield(L, -2, "SINT32");
+ lua_pushinteger(L, PB_KIND_SINT64); lua_setfield(L, -2, "SINT64");
+ lua_pushinteger(L, PB_KIND_FIXED32); lua_setfield(L, -2, "FIXED32");
+ lua_pushinteger(L, PB_KIND_FIXED64); lua_setfield(L, -2, "FIXED64");
+ lua_pushinteger(L, PB_KIND_SFIXED32); lua_setfield(L, -2, "SFIXED32");
+ lua_pushinteger(L, PB_KIND_SFIXED64); lua_setfield(L, -2, "SFIXED64");
+ lua_pushinteger(L, PB_KIND_FLOAT); lua_setfield(L, -2, "FLOAT");
+ lua_pushinteger(L, PB_KIND_DOUBLE); lua_setfield(L, -2, "DOUBLE");
+ lua_pushinteger(L, PB_KIND_BOOL); lua_setfield(L, -2, "BOOL");
+ lua_pushinteger(L, PB_KIND_STRING); lua_setfield(L, -2, "STRING");
+ lua_pushinteger(L, PB_KIND_BYTES); lua_setfield(L, -2, "BYTES");
+ lua_pushinteger(L, PB_KIND_ENUM); lua_setfield(L, -2, "ENUM");
+ lua_pushinteger(L, PB_KIND_MESSAGE); lua_setfield(L, -2, "MESSAGE");
+ lua_pushinteger(L, PB_KIND_MAP); lua_setfield(L, -2, "MAP");
+ lua_setfield(L, -2, "KIND");
+
+ lua_createtable(L, 0, 4);
+ lua_pushinteger(L, PB_WIRE_VARINT); lua_setfield(L, -2, "VARINT");
+ lua_pushinteger(L, PB_WIRE_I64); lua_setfield(L, -2, "I64");
+ lua_pushinteger(L, PB_WIRE_LEN); lua_setfield(L, -2, "LEN");
+ lua_pushinteger(L, PB_WIRE_I32); lua_setfield(L, -2, "I32");
+ lua_setfield(L, -2, "WIRE");
+
+ return 1;
+}
A test/c_runtime_plan_test.lua => test/c_runtime_plan_test.lua +210 -0
@@ 0,0 1,210 @@
+-- Smoke test for bd-mq7: descriptor → C plan compiler.
+--
+-- Only runs when PB_ENABLE_C=1 is set in the environment AND the C
+-- runtime module is loadable. Otherwise the group is skipped, which
+-- keeps `just test` green on hosts without the C module built.
+--
+-- Acceptance per bd-mq7:
+-- (1) pb.c_runtime.compile_plan(desc) returns a userdata
+-- (2) plan->n_fields and plan->fields[i].tag are readable
+-- (3) Plans for hello.Person and hello.Address build without error
+
+local t = require('luatest')
+
+local pb = require('pb')
+local c_runtime = pb.c_runtime
+
+local function skip_if_no_c()
+ if c_runtime == nil then
+ t.skip('PB_ENABLE_C not set or pb.c_runtime not available')
+ end
+end
+
+-- Run each test against both codegen modes — c_plan is attached to the
+-- descriptor regardless of mode, so both pick up the same compile path.
+for _, mode in ipairs({'full', 'runtime'}) do
+ local g = t.group('c_runtime_plan.' .. mode)
+ local hello
+
+ g.before_all(function()
+ skip_if_no_c()
+ hello = require(mode .. '.hello.hello_pb')
+ end)
+
+ g.before_each(skip_if_no_c)
+
+ function g.test_module_surface()
+ t.assert_equals(type(c_runtime.compile_plan), 'function')
+ t.assert_equals(type(c_runtime._abi_version), 'string')
+ t.assert(c_runtime.KIND ~= nil, 'KIND table exposed')
+ t.assert(c_runtime.WIRE ~= nil, 'WIRE table exposed')
+ t.assert_equals(c_runtime.WIRE.LEN, 2)
+ t.assert_equals(c_runtime.WIRE.VARINT, 0)
+ end
+
+ function g.test_compile_address_returns_userdata()
+ local plan = c_runtime.compile_plan(hello.Address_descriptor)
+ t.assert_equals(type(plan), 'userdata')
+ t.assert_equals(c_runtime.plan_name(plan), 'hello.Address')
+ t.assert_equals(c_runtime.plan_n_fields(plan), 4)
+ end
+
+ function g.test_address_field_shapes()
+ local plan = c_runtime.compile_plan(hello.Address_descriptor)
+ -- {name="street", id=1, kind=scalar/string}
+ local f1 = c_runtime.plan_field_info(plan, 1)
+ t.assert_equals(f1.name, 'street')
+ t.assert_equals(f1.field_number, 1)
+ t.assert_equals(f1.wire_type, c_runtime.WIRE.LEN)
+ t.assert_equals(f1.kind, c_runtime.KIND.STRING)
+ t.assert_equals(f1.tag_bytes:byte(1, 1), 0x0A) -- (1<<3)|2
+ -- {name="apartment", id=4, kind=scalar/string, optional=true}
+ local f4 = c_runtime.plan_field_info(plan, 4)
+ t.assert_equals(f4.name, 'apartment')
+ t.assert_equals(f4.optional, true)
+ t.assert_equals(f4.field_number, 4)
+ end
+
+ function g.test_compile_person_returns_userdata()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ t.assert_equals(type(plan), 'userdata')
+ t.assert_equals(c_runtime.plan_name(plan), 'hello.Person')
+ -- Person has 14 fields per examples/proto/hello.proto
+ t.assert_equals(c_runtime.plan_n_fields(plan), 14)
+ end
+
+ function g.test_person_scalar_fields()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ -- name = string @1
+ local f = c_runtime.plan_field_info(plan, 1)
+ t.assert_equals(f.name, 'name')
+ t.assert_equals(f.kind, c_runtime.KIND.STRING)
+ t.assert_equals(f.wire_type, c_runtime.WIRE.LEN)
+ -- age = int32 @2
+ f = c_runtime.plan_field_info(plan, 2)
+ t.assert_equals(f.name, 'age')
+ t.assert_equals(f.kind, c_runtime.KIND.INT32)
+ t.assert_equals(f.wire_type, c_runtime.WIRE.VARINT)
+ -- user_id = fixed64 @9
+ f = c_runtime.plan_field_info(plan, 9)
+ t.assert_equals(f.name, 'user_id')
+ t.assert_equals(f.kind, c_runtime.KIND.FIXED64)
+ t.assert_equals(f.wire_type, c_runtime.WIRE.I64)
+ -- balance = sint32 @10
+ f = c_runtime.plan_field_info(plan, 10)
+ t.assert_equals(f.kind, c_runtime.KIND.SINT32)
+ -- weight_kg = double @11
+ f = c_runtime.plan_field_info(plan, 11)
+ t.assert_equals(f.kind, c_runtime.KIND.DOUBLE)
+ t.assert_equals(f.wire_type, c_runtime.WIRE.I64)
+ end
+
+ function g.test_person_repeated_packed()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ -- emails = repeated string @3 (not packed)
+ local f = c_runtime.plan_field_info(plan, 3)
+ t.assert_equals(f.name, 'emails')
+ t.assert_equals(f.repeated, true)
+ t.assert_equals(f.packed, false)
+ t.assert_equals(f.wire_type, c_runtime.WIRE.LEN)
+ -- lucky_numbers = repeated int32, packed @7
+ f = c_runtime.plan_field_info(plan, 7)
+ t.assert_equals(f.name, 'lucky_numbers')
+ t.assert_equals(f.repeated, true)
+ t.assert_equals(f.packed, true)
+ -- packed flips wire type to LEN regardless of element type
+ t.assert_equals(f.wire_type, c_runtime.WIRE.LEN)
+ t.assert_equals(f.kind, c_runtime.KIND.INT32)
+ end
+
+ function g.test_person_enum_field()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ -- status = enum @4
+ local f = c_runtime.plan_field_info(plan, 4)
+ t.assert_equals(f.name, 'status')
+ t.assert_equals(f.kind, c_runtime.KIND.ENUM)
+ t.assert_equals(f.wire_type, c_runtime.WIRE.VARINT)
+ end
+
+ function g.test_person_message_field_resolves_sub_plan()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ -- address = message Address @5
+ local f = c_runtime.plan_field_info(plan, 5)
+ t.assert_equals(f.name, 'address')
+ t.assert_equals(f.kind, c_runtime.KIND.MESSAGE)
+ t.assert(f.sub_plan_idx > 0, 'sub_plan_idx populated')
+ local sub = c_runtime.plan_sub_plan(plan, f.sub_plan_idx)
+ t.assert_equals(type(sub), 'userdata')
+ t.assert_equals(c_runtime.plan_name(sub), 'hello.Address')
+ end
+
+ function g.test_self_reference_cycle()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ -- friends = repeated Person @6 (self-reference)
+ local f = c_runtime.plan_field_info(plan, 6)
+ t.assert_equals(f.name, 'friends')
+ t.assert_equals(f.kind, c_runtime.KIND.MESSAGE)
+ t.assert_equals(f.repeated, true)
+ t.assert(f.sub_plan_idx > 0)
+ local sub = c_runtime.plan_sub_plan(plan, f.sub_plan_idx)
+ t.assert_equals(c_runtime.plan_name(sub), 'hello.Person')
+ -- Cycle resolves to the same userdata, not a fresh one
+ t.assert(sub == plan,
+ 'self-reference returns the same plan userdata (cycle broken)')
+ end
+
+ function g.test_person_map_field()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ -- ages_by_nickname: map<string, int32> @13
+ local f = c_runtime.plan_field_info(plan, 12) -- 12th field
+ t.assert_equals(f.name, 'ages_by_nickname')
+ t.assert_equals(f.kind, c_runtime.KIND.MAP)
+ t.assert_equals(f.map_key_kind, c_runtime.KIND.STRING)
+ t.assert_equals(f.map_value_kind, c_runtime.KIND.INT32)
+ -- addresses_by_label: map<string, Address> @15
+ f = c_runtime.plan_field_info(plan, 14)
+ t.assert_equals(f.name, 'addresses_by_label')
+ t.assert_equals(f.map_value_kind, c_runtime.KIND.MESSAGE)
+ t.assert(f.map_value_sub_plan_idx > 0)
+ local sub = c_runtime.plan_sub_plan(plan, f.map_value_sub_plan_idx)
+ t.assert_equals(c_runtime.plan_name(sub), 'hello.Address')
+ end
+
+ function g.test_idempotent_compile()
+ local p1 = c_runtime.compile_plan(hello.Person_descriptor)
+ local p2 = c_runtime.compile_plan(hello.Person_descriptor)
+ t.assert(p1 == p2, 'second compile returns cached plan')
+ end
+
+ function g.test_result_oneof()
+ local plan = c_runtime.compile_plan(hello.Result_descriptor)
+ t.assert_equals(c_runtime.plan_n_fields(plan), 4)
+ t.assert_equals(c_runtime.plan_n_oneofs(plan), 1)
+ local o = c_runtime.plan_oneof_info(plan, 1)
+ t.assert_equals(o.name, 'outcome')
+ t.assert_equals(#o.member_indices, 3)
+ -- Members are 0-based indices into plan->fields[]; text/code/details
+ -- are fields 2, 3, 4 in Result (id-ordered) → indices 1, 2, 3.
+ local idxs = {}
+ for _, i in ipairs(o.member_indices) do idxs[i] = true end
+ t.assert(idxs[1] and idxs[2] and idxs[3],
+ 'oneof members map to text/code/details indices')
+ -- Each member field gets oneof_idx = 0 (the only oneof in Result).
+ for _, fi in ipairs({2, 3, 4}) do
+ local f = c_runtime.plan_field_info(plan, fi)
+ t.assert_equals(f.oneof_idx, 0,
+ ('field %d oneof_idx'):format(fi))
+ end
+ -- id (field 1) is NOT in a oneof.
+ local f1 = c_runtime.plan_field_info(plan, 1)
+ t.assert_equals(f1.oneof_idx, -1)
+ end
+
+ function g.test_wkt_override_detection()
+ local wkt = pb.wkt
+ local plan = c_runtime.compile_plan(wkt.Timestamp_descriptor)
+ t.assert_equals(c_runtime.plan_has_override(plan), true)
+ -- has_override means the plan does not walk fields; field count is 0.
+ t.assert_equals(c_runtime.plan_n_fields(plan), 0)
+ end
+end