Skip to content

refactor(codegen): collapse standalone block writers into mixin methods (#206 follow-up) - #211

Merged
apotema merged 1 commit into
mainfrom
refactor/mixin-only-block-writers
May 26, 2026
Merged

refactor(codegen): collapse standalone block writers into mixin methods (#206 follow-up)#211
apotema merged 1 commit into
mainfrom
refactor/mixin-only-block-writers

Conversation

@apotema

@apotema apotema commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Mixin-only end-state: PR refactor(codegen): convert block writers + orchestrator to mixin pattern (closes #183) #206's leftover standalone pub fn writeXxx(...) declarations are collapsed into the matching Mixin(Self).writeXxx(self, ...) methods. Every external caller (orchestrator + tests + root.zig:generateGameShim) now dispatches through a Codegen context, matching how main_template.zig already worked.
  • 12 standalone fns inlined into their mixin methods across blocks/plugin_registries.zig (5), blocks/scene_manifests.zig (2), lifecycle/loop.zig (2), lifecycle/callback.zig (3). Bodies now read state off self.* instead of taking explicit args.
  • main_zig.zig re-export shim trimmed from 111 → 95 lines: dropped all per-fn re-exports, kept submodule namespaces + Codegen alias + scan/preview type pass-throughs + LoadStyle (still passed as a value by lifecycle siblings).
  • blocks/asset_wiring.zig and blocks/resource_loader.zig keep their standalone pub fn forms — they're consumed by lifecycle/{loop,callback}.zig as sibling-module utilities, not part of the external surface. The Mixin methods still delegate uniformly so the orchestrator can call ctx.writeXxx(...) consistently.

Test plan

  • zig build test --summary all555/559 tests passed (4 skipped), same baseline as today's main.
  • scripts/gen_all_examples.sh — 8/9 bundled examples bit-identical (main.zig, game.zig, build.zig, build.zig.zon) across baseline + 2 follow-up runs. sokol_imgui has a pre-existing setup failure (missing local labelle-imgui dep) and is unaffected by this PR — it failed identically on origin/main.
  • Hold off merging — structural diff, prefer review.

Files

  • src/codegen/blocks/{plugin_registries,scene_manifests}.zig — inlined into Mixin
  • src/codegen/lifecycle/{loop,callback}.zig — inlined into Mixin
  • src/codegen/context.zig — file header updated to document mixin-only surface
  • src/main_zig.zig — re-export shim trimmed
  • src/root.ziggenerateGameShim dispatches writePluginEventsBlock via a Codegen context
  • test/{backend_wiring_tests,engine_events_discovery_test}.zig and test/flow_scanner/{coercions_tests,flow_decls_tests}.zig — refactored to build a Codegen + dispatch through it
  • test/helpers.zig — new emptyCodegen(allocator) helper for the zero-state mixin tests

…ds (#206 follow-up)

PR #206 left the standalone `pub fn writeXxx(...)` declarations
alongside the new `Mixin(Self).writeXxx(self, ...)` methods because
the test surface + a couple of external call sites depended on the
explicit-arg form. Migrating those callers to the Codegen-context
shape removes the duplication: each concern lives in exactly one
place (the mixin method), driven by Codegen's accumulated state.

- 4 test files refactored to construct a Codegen + dispatch through
  it (engine_events_discovery_test, backend_wiring_tests,
  flow_scanner/coercions_tests, flow_scanner/flow_decls_tests).
  Added `emptyCodegen(allocator)` to test/helpers.zig for the
  zero-state case.
- root.zig's `generateGameShim` migrated to dispatch through a
  Codegen context for `writePluginEventsBlock`.
- main_zig.zig's re-export shim trimmed: standalone block-writer
  + lifecycle-builder re-exports dropped. What remains: submodule
  namespaces, the Codegen alias, scan/preview pass-throughs, and
  LoadStyle (kept because lifecycle siblings consume it as a value).
- 12 standalone fns inlined into their mixin methods (5 in
  plugin_registries, 2 in scene_manifests, 2 in lifecycle/loop, 3
  in lifecycle/callback). Signatures now read off `self.*` instead
  of the explicit arg list; doc comments + bit-exact emission shape
  preserved verbatim.

The 3 standalone helpers in `blocks/asset_wiring.zig` and the one
in `blocks/resource_loader.zig` stay `pub` — they're consumed by
`lifecycle/{loop,callback}.zig` as sibling-module utilities, not
part of the external surface. The Mixin in each of those files
still delegates (the mixin reads no state from self in either case,
the methods only exist for orchestrator-side dispatch uniformity).

Bit-identical generated output across all 8 reachable bundled
examples (scripts/gen_all_examples.sh, 3 runs). sokol_imgui has a
pre-existing setup failure (local labelle-imgui dep missing) and
is unaffected by this change. zig build test green (555/559 — same
baseline as today's main).
@cursor

cursor Bot commented May 26, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the full main.zig codegen path and public re-export surface, but behavior is meant to be unchanged; regression risk is structural/API breakage or accidental emit drift, mitigated by existing bit-identical example harness and tests.

Overview
Completes the mixin-only codegen surface from #206: twelve former standalone pub fn writeXxx / buildXxx helpers are removed and their bodies live directly on Codegen mixin methods in plugin_registries, scene_manifests, and lifecycle/{loop,callback}.zig. Methods read scan/config state from self (with writePluginPinStylesBlock still taking the orchestrator-owned deduped slice as an explicit arg).

Public API: main_zig.zig no longer re-exports those writers or lifecycle builders—only submodule namespaces, Codegen, scan/preview types, and LoadStyle. root.zig drops wiring re-exports; generateGameShim emits plugin events via a minimal Codegen like the orchestrator. Tests gain emptyCodegen / slice-populated contexts and call ctx.writePluginXxxBlock instead of standalone functions.

Unchanged externally: asset_wiring and resource_loader stay as sibling standalone utilities invoked from inside mixin bodies. Emitted main.zig string literals are intended to stay bit-identical (same bodies, different dispatch path).

Reviewed by Cursor Bugbot for commit 822ecc2. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the codegen architecture by collapsing standalone block-writer and lifecycle-builder functions into mixin methods on the Codegen context, establishing a mixin-only surface. Corresponding updates were made to the orchestrator, tests, and shims to dispatch through the Codegen context. The review feedback identifies a potential issue in the resolve functions of both PluginFlowNodes and PluginCoercions where module names starting with a digit are not correctly handled, which would cause resolution to fail since generated identifiers are prefixed with an underscore.

Comment on lines +240 to +248
try bw.writeAll(" pub fn resolve(comptime dotted: []const u8) ?[]const u8 {\n");
try bw.writeAll(" const dot = std.mem.indexOfScalar(u8, dotted, '.') orelse return null;\n");
try bw.writeAll(" const module = dotted[0..dot];\n");
try bw.writeAll(" const node = dotted[dot + 1 ..];\n");
try bw.writeAll(" if (node.len == 0) return null;\n");
try bw.writeAll(" const qualified = module ++ \"__\" ++ node;\n");
try bw.writeAll(" if (!@hasDecl(@This(), qualified)) return null;\n");
try bw.writeAll(" return qualified;\n");
try bw.writeAll(" }\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a plugin or module name starts with a digit (e.g., 3d_physics), the generated identifier is prefixed with an underscore to be a valid Zig identifier. However, the resolve function currently joins the module and node names directly without checking if the module name starts with a digit. This will cause resolution to fail for such modules.

Please update the resolve function to prepend an underscore if the module name starts with a digit, ensuring consistency with the generated identifiers.

            try bw.writeAll("    pub fn resolve(comptime dotted: []const u8) ?[]const u8 {\n");
            try bw.writeAll("        const dot = std.mem.indexOfScalar(u8, dotted, '.') orelse return null;\n");
            try bw.writeAll("        const module = dotted[0..dot];\n");
            try bw.writeAll("        const node = dotted[dot + 1 ..];\n");
            try bw.writeAll("        if (node.len == 0) return null;\n");
            try bw.writeAll("        const qualified = if (module.len > 0 and module[0] >= '0' and module[0] <= '9')\n");
            try bw.writeAll("            \"_\" ++ module ++ \"__\" ++ node\n");
            try bw.writeAll("        else\n");
            try bw.writeAll("            module ++ \"__\" ++ node;\n");
            try bw.writeAll("        if (!@hasDecl(@This(), qualified)) return null;\n");
            try bw.writeAll("        return qualified;\n");
            try bw.writeAll("    }\n");
References
  1. Zig identifiers must not start with a digit. When converting strings (such as file paths) to Zig identifiers, prefix the result with an underscore if the input starts with a digit to ensure the generated code is valid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the finding is correct — sanitizePluginIdent (scan.zig:819) does prepend _ for digit-leading module names, so the generated resolve body's module ++ "__" ++ name join fails @hasDecl for those modules. But the bug pre-dates this PR (same string lives on main), and this PR's contract is bit-identical generated output — applying the fix here would force regenerating every example baseline and entangle a correctness change with the refactor.

Filed as #212 to fix in a follow-up; the suggested two-line change there mirrors what you proposed.

Comment on lines +355 to +363
try bw.writeAll(" pub fn resolve(comptime dotted: []const u8) ?[]const u8 {\n");
try bw.writeAll(" const dot = std.mem.indexOfScalar(u8, dotted, '.') orelse return null;\n");
try bw.writeAll(" const module = dotted[0..dot];\n");
try bw.writeAll(" const name = dotted[dot + 1 ..];\n");
try bw.writeAll(" if (name.len == 0) return null;\n");
try bw.writeAll(" const qualified = module ++ \"__\" ++ name;\n");
try bw.writeAll(" if (!@hasDecl(@This(), qualified)) return null;\n");
try bw.writeAll(" return qualified;\n");
try bw.writeAll(" }\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a plugin or module name starts with a digit (e.g., 3d_physics), the generated identifier is prefixed with an underscore to be a valid Zig identifier. However, the resolve function currently joins the module and name directly without checking if the module name starts with a digit. This will cause resolution to fail for such modules.

Please update the resolve function to prepend an underscore if the module name starts with a digit, ensuring consistency with the generated identifiers.

            try bw.writeAll("    pub fn resolve(comptime dotted: []const u8) ?[]const u8 {\n");
            try bw.writeAll("        const dot = std.mem.indexOfScalar(u8, dotted, '.') orelse return null;\n");
            try bw.writeAll("        const module = dotted[0..dot];\n");
            try bw.writeAll("        const name = dotted[dot + 1 ..];\n");
            try bw.writeAll("        if (name.len == 0) return null;\n");
            try bw.writeAll("        const qualified = if (module.len > 0 and module[0] >= '0' and module[0] <= '9')\n");
            try bw.writeAll("            \"_\" ++ module ++ \"__\" ++ name\n");
            try bw.writeAll("        else\n");
            try bw.writeAll("            module ++ \"__\" ++ name;\n");
            try bw.writeAll("        if (!@hasDecl(@This(), qualified)) return null;\n");
            try bw.writeAll("        return qualified;\n");
            try bw.writeAll("    }\n");
References
  1. Zig identifiers must not start with a digit. When converting strings (such as file paths) to Zig identifiers, prefix the result with an underscore if the input starts with a digit to ensure the generated code is valid

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the finding is correct — sanitizePluginIdent (scan.zig:819) does prepend _ for digit-leading module names, so the generated resolve body's module ++ "__" ++ name join fails @hasDecl for those modules. But the bug pre-dates this PR (same string lives on main), and this PR's contract is bit-identical generated output — applying the fix here would force regenerating every example baseline and entangle a correctness change with the refactor.

Filed as #212 to fix in a follow-up; the suggested two-line change there mirrors what you proposed.

@apotema

apotema commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

@copilot review

Copilot AI commented May 26, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Reviewed this PR’s current diff at 822ecc2; I don’t see any additional actionable changes to make for this thread.

@apotema
apotema merged commit 8e3ebc4 into main May 26, 2026
2 checks passed
@apotema
apotema deleted the refactor/mixin-only-block-writers branch May 26, 2026 13:19
apotema added a commit that referenced this pull request May 26, 2026
…216)

The assembler's `sanitizePluginIdent` prefixes a `_` to plugin module
names that start with a digit (Zig identifier rule), so a plugin
`3d_renderer` ships its FlowNodes / Coercions decls under
`_3d_renderer__<name>`. But the generated `resolve()` body computed
`qualified = module ++ "__" ++ node` straight off the user-supplied
dotted name, so `resolve("3d_renderer.foo")` produced
`3d_renderer__foo` — silently missing the `_`-prefixed decl and
returning `null` for every flow / coercion reference into a
digit-leading plugin.

Mirror the digit-prefix + non-identifier collapse in the emitted
resolver via a new comptime `sanitizeModuleIdent` helper inside the
generated `PluginFlowNodes` / `PluginCoercions` structs. Pure
comptime, zero runtime cost. Adds a regression test that asserts the
new helper + sanitized join lands on the resolver shape; pre-fix the
test fails because the emitted body still says
`module ++ "__" ++ node`.

Caught by gemini-code-assist on #211 but kept out of that refactor's
bit-identical-output scope; filed as #212.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants