From 104f3ac0c9ca9e21142c61bb596a2c718fe04061 Mon Sep 17 00:00:00 2001 From: Leimiu-G Date: Mon, 19 Oct 2026 08:39:09 +0800 Subject: [PATCH] fix(hy-emit): register a struct binding's type through redundant parens (#607) `stats := (pool_stats{hits: 1})` binds exactly what `stats := pool_stats{hits: 1}` binds, but `register_var_ty` matched `values.first()` directly, so the struct type never reached `var_struct`. With no type the shape table cannot resolve `stats.mu` to `sync.mutex`, and the field call emits Go's lowercase spelling: $ hyb check p.hy -> OK (0 diagnostics) $ hyb build p.hy -> emitted stats.mu.lock() $ go build stats.mu.lock undefined (type "sync".Mutex has no field or method lock, but does have method Lock) An accepted program that does not build (rule BUILDABLE). This is the remaining leg of #607. The other leg - `mux := (http.new_serve_mux())` losing its foreign binding kind - is already fixed on main: `binding_kind_of` now peels at entry, and that reproducer emits `mux.Handle(...)` today. So this revision carries only the `register_var_ty` peel; the earlier `binding_kind_of` hunk is dropped rather than re-proposed, and the corresponding test stays as a pin. Verified on aa4a9d9: `cargo test -p hy-emit` 186 passed / 0 failed (184 before, +2). With the `stmt.rs` hunk reverted and only the tests kept, exactly `struct_binding_type_registers_through_redundant_parentheses` fails and the other 185 pass; `foreign_binding_kind_survives_redundant_ parentheses` is green either way, which is why it is labelled a pin. End-to-end on the Go toolchain: the reproducer emits `stats.mu.Lock()` / `stats.mu.Unlock()`, builds, and prints `hits: 2`. Known local-only failure, not from this change: `hyb --bin hyb tests::generate_one_ignores_stale_generated_hy_go_siblings_on_package_ rename` fails identically on an untouched tree in this environment (non-ASCII %TEMP% / path handling in `hyb generate`). --- compiler/crates/hy-emit/src/stmt.rs | 9 ++++- compiler/crates/hy-emit/src/tests.rs | 52 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/compiler/crates/hy-emit/src/stmt.rs b/compiler/crates/hy-emit/src/stmt.rs index e45d1aa3..36b2661e 100644 --- a/compiler/crates/hy-emit/src/stmt.rs +++ b/compiler/crates/hy-emit/src/stmt.rs @@ -1034,7 +1034,14 @@ impl Emitter { if kind != BindingKind::Plain { self.bindings.insert(names[0].clone(), kind); } - if let Some(Expr::StructLit { ty: Some(t), .. }) = values.first() { + // `Parenthesised` is semantically transparent, so `x := (T{…})` + // registers exactly the type `x := T{…}` registers. Without the + // peel the struct type never reaches `var_struct`, `stats.mu` + // then has no shape, and the field call emits Go's lowercase + // spelling (`stats.mu.lock()`) — accepted by `hyb check`, refused + // by `go build` (rule BUILDABLE). + if let Some(Expr::StructLit { ty: Some(t), .. }) = values.first().map(Self::peel_parens) + { self.register_var_ty(&names[0], t); } } diff --git a/compiler/crates/hy-emit/src/tests.rs b/compiler/crates/hy-emit/src/tests.rs index 002271c4..4de1dacf 100644 --- a/compiler/crates/hy-emit/src/tests.rs +++ b/compiler/crates/hy-emit/src/tests.rs @@ -1023,6 +1023,58 @@ fn assign_slot_loan_copies_and_stores_back() { ); } +#[test] +fn struct_binding_type_registers_through_redundant_parentheses() { + // #607: `stats := (pool_stats{hits: 1})` is the same binding as + // `stats := pool_stats{hits: 1}`. The struct type is what lets the shape + // table resolve `stats.mu` to `sync.mutex` and emit `stats.mu.Lock()`; + // when the parens stopped `register_var_ty`, `mu` had no shape and the + // field call emitted Go's lowercase spelling (`stats.mu.lock()`) — an + // accepted program that `go build` rejects (rule BUILDABLE). + for init in ["pool_stats{hits: 1}", "(pool_stats{hits: 1})"] { + let go = emit(&format!( + "import \"sync\"\n\nstruct pool_stats {{\n mu sync.mutex\n hits int\n}}\n\nfn main() {{\n stats := {}\n stats.mu.lock()\n stats.hits = 2\n stats.mu.unlock()\n fmt.println(\"hits: {{stats.hits}}\")\n}}\n", + init + )); + assert!( + go.contains("stats.mu.Lock()") && go.contains("stats.mu.Unlock()"), + "`stats := {}` must still map the field call to Go's exported name:\n{}", + init, + go + ); + assert!( + !go.contains(".lock()") && !go.contains(".unlock()"), + "`stats := {}` must not emit an unexported Go method:\n{}", + init, + go + ); + } +} + +#[test] +fn foreign_binding_kind_survives_redundant_parentheses() { + // #607 reproducer 1 — already green on main once `binding_kind_of` peeled + // at entry; pinned here so the two legs of the same rule stay covered. + for init in ["http.new_serve_mux()", "(http.new_serve_mux())"] { + let go = emit(&format!( + "import \"net/http\"\n\nfn main() {{\n mux := {}\n mux.handle(\"/ping\", http.handler(fn(w http.response_writer, r *http.request) {{\n fmt.fprintln(w, \"pong\")\n }}))\n}}\n", + init + )); + assert!( + go.contains("mux.Handle("), + "`mux := {}` must still map `handle` -> `Handle`:\n{}", + init, + go + ); + assert!( + !go.contains(".handle("), + "`mux := {}` must not emit an unexported Go method:\n{}", + init, + go + ); + } +} + #[test] fn assign_slot_loan_into_pointer_var_points_at_copy() { // A pointer-typed target cannot hold the copied element itself