Skip to content

codegen: fix module redefinition when a message and sub-package share a name (#135)#145

Merged
iainmcgin merged 6 commits into
mainfrom
fix/module-package-collision
May 23, 2026
Merged

codegen: fix module redefinition when a message and sub-package share a name (#135)#145
iainmcgin merged 6 commits into
mainfrom
fix/module-package-collision

Conversation

@iainmcgin
Copy link
Copy Markdown
Collaborator

Summary

Fixes #135 — a message with nested types and a sibling sub-package of the same name produced uncompilable generated code.

Protobuf is case-sensitive, so message Oof and package foo.oof legally coexist. But buffa emits a message's nested types in a snake_case(MessageName) submodule, so both wanted foo::oof and collided — error[E0428]: the name 'oof' is defined multiple times.

Fix

When a message's nested-types module name collides with a sub-package module in the same scope, the nested-types module is deconflicted by appending _ (repeated until unique against the sub-package segments, sibling message modules, and the __buffa sentinel). The message struct and the sub-package keep their natural names:

foo::Oof            // message struct        (unchanged)
foo::oof_::Inner    // nested types          ← deconflicted
foo::oof::Thing     // sub-package foo.oof   (unchanged)

The deconflicted name is computed once at the FQN→Rust-path chokepoint (CodeGenContext) and reused by the emission site, so references and the emitted module always agree. Why trailing-underscore-with-occupied-set rather than a fixed marker: proto package segments and snake_cased message names share the identifier space, so no fixed suffix is collision-proof against a pathological schema — verifying against the actual occupied set is collision-free by construction and reserves no identifier space.

Compatibility

This activates only on a collision that previously failed to compile, so every schema that builds today is byte-for-byte unchanged (verified: regenerating the WKT and bootstrap descriptor types — which include the real google.protobuf + google.protobuf.compiler pair — produces zero diff). Forward note in the CHANGELOG: if you add a sub-package that collides with an existing message's nested-types module, paths to those nested types move from oof to oof_.

Extern-mapped sub-packages (extern_path) are excluded — they emit no local module, so they don't trigger a rename.

Testing

  • codegen-integration assertions (collision → oof_, no-collision → oof)
  • a buffa-test module compiling the full nested layout with generate_json, exercising binary round-trips and the Any-registry paths that bubble through the deconflicted module (super::oof_::__INNER_JSON_ANY), plus a nested message inside the sub-package to confirm deconfliction doesn't leak
  • full cargo test --workspace + clippy -D warnings clean

DESIGN.md is updated (it previously claimed collisions were structurally impossible with no suffix-escalation).

iainmcgin added 2 commits May 22, 2026 18:19
A message with nested types emits its members in a snake_case(Name) submodule
at the package root. Protobuf is case-sensitive, so `message Oof` and a
sibling `package foo.oof` legally coexist, but both map to `mod oof` in Rust —
producing an E0428 "module redefined" error in the stitched output (issue #135).

Deconflict the message-nesting module: when snake_case(Name) collides with a
sub-package segment in the same scope, append `_` until the name is unique
against the occupied set (sub-package segments, sibling message modules, the
`__buffa` sentinel). The message struct (`Oof`) and the sub-package module
(`oof`) keep their natural names; only the nested-types module moves (`oof_`).

The computation lives at the single FQN->path chokepoint (the context type_map)
and is reused by the emission site, so references and the emitted module always
agree. It activates only on a real collision, so all currently-compiling
schemas are byte-for-byte unchanged (verified: the WKT and bootstrap descriptor
regen, incl. google.protobuf + the google.protobuf.compiler sub-package,
produces no diff).

Tests: codegen integration assertions (collision -> oof_, no-collision -> oof)
and a buffa-test module that compiles the nested layout and checks both paths.
… rule

Address review findings on the nested-module deconfliction:

- The owned Any-registry paths (json_any/text_any) bubbled from a nested
  message were prefixed with the raw module name, but a top-level message's
  module is wrapped with the deconflicted name — so a deconflicted message
  built with generate_json/text emitted `super::oof::__INNER_JSON_ANY` while
  the module is `oof_`, an E0432. Prefix with the deconflicted wrap name.
- Skip extern-mapped packages in the collision pre-pass: a sub-package routed
  to another crate via extern_path emits no local module, so it must not
  trigger a spurious rename of a same-named message's nested module.
- Use the deconflicted name in the root re-export occupied set so a future
  re-export candidate can't shadow the renamed module.
- Emit a doc-comment on the deconflicted module explaining the trailing `_`.

Docs: correct DESIGN.md (which claimed collisions were structurally
impossible with no suffix-escalation) and add a CHANGELOG entry.

Tests: build the modcollide regression with generate_json so the Any-registry
path through the deconflicted module is compiled; cover a nested message inside
the sub-package; harden the no-collision assertion against formatter spacing.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

iainmcgin added 3 commits May 22, 2026 18:50
The per-message deconfliction only checked sibling messages' *raw* module
names, so two messages whose deconflicted candidates converge could still
collide: `message Oof` (oof) and `message Oof_` (oof_) alongside sub-packages
`foo.oof` and `foo.oof_` both resolved to `oof__` — a fresh E0428.

Compute a package's nested-module names as a batch with a shared `taken` set
seeded with the sub-package segments, the sentinel, and every message's raw
module name, reserving each deconflicted name as it is assigned. The two
messages above now resolve to `oof__` and `oof___`. The `_`-append loop already
handled the single-message repeated-append case (sub-packages `oof` and `oof_`
-> `oof__`); this closes the multi-message gap. Names are deterministic for a
given declaration order and collision-free regardless of order.

Default output is unchanged (deconfliction still fires only on a real
collision; WKT + bootstrap regen produce no diff).

Tests: unit tests for the pure helper (no-collision, single, repeated-append,
two-message race, sentinel-avoidance, child-segment extraction) and an
end-to-end codegen-integration test for the racing case.
Re-review polish (all Low findings):

- Assign colliding nested-module names in a stable order (sorted by base name),
  so the per-message suffix no longer depends on file/message declaration order
  — reordering inputs can't swap which message gets `oof__` vs `oof___`.
- Broaden the generated module doc-comment (the collision source can be a
  sibling message, not only a sub-package) and fix the stale "snake_case" wording
  in the root re-export occupied-set comment.
- CHANGELOG/DESIGN: note that suffixes can grow past one `_`, and that the
  colliding message and sub-package must be generated in the same compile()
  (deconfliction can't span separate descriptor sets).

Tests: add an order-independence unit test and an end-to-end buffa-test module
(`modrace`) that compiles the multi-message race layout — `Oof`/`Oof_` nested
modules (`oof__`/`oof___`) coexisting with sub-packages `oof`/`oof_`.
@iainmcgin iainmcgin marked this pull request as ready for review May 22, 2026 22:10
@iainmcgin iainmcgin requested a review from rpb-ant May 22, 2026 22:10
@iainmcgin iainmcgin enabled auto-merge (squash) May 22, 2026 22:11
@iainmcgin iainmcgin requested a review from asacamano May 22, 2026 23:09
asacamano
asacamano previously approved these changes May 22, 2026
std::fs::write(
dir.path().join("foo.proto"),
"syntax = \"proto3\";\npackage foo;\n\
message Oof { message Inner { int32 x = 1; } Inner inner = 1; }\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.

Ooof

…llision

# Conflicts:
#	buffa-codegen/tests/codegen_integration.rs
@iainmcgin iainmcgin merged commit 4ff2646 into main May 23, 2026
7 checks passed
@iainmcgin iainmcgin deleted the fix/module-package-collision branch May 23, 2026 00:27
@github-actions github-actions Bot locked and limited conversation to collaborators May 23, 2026
iainmcgin added a commit that referenced this pull request May 24, 2026
Propagates the origin/main merge (idiomatic enum aliases #152, module
collision fix #145) up the stack. Sole conflict in codegen lib.rs:
keep the two-function generate / generate_with_diagnostics split from
view-vtable, but retain owned-vtable's relaxed vtable check (vtable
reflection requires generate_reflection only, not views) and move it
into generate_with_diagnostics. Doc # Errors updated to match.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Module redefinition error when messages and packages share a name

2 participants