Skip to content

fix(hy-emit): resolve and emit a parenthesized receiver in its own type (fixes #618) - #619

Closed
Leimiu-G wants to merge 1 commit into
limitlessprojects:mainfrom
Leimiu-G:fix/emit-receiver-paren
Closed

Leimiu-G wants to merge 1 commit into
limitlessprojects:mainfrom
Leimiu-G:fix/emit-receiver-paren

Conversation

@Leimiu-G

Copy link
Copy Markdown
Contributor

Fixes #618.

What

Parentheses around a postfix receiver were honoured by the emitter (kept in the Go text) but not by
any of the resolution paths, so the member was never normalized:

fn probe(hits *atomic.int64) {
    (hits).add(1)
    fmt.println("hits: {hits.load()}")
}

hyb check → OK (0 diagnostics); hyb build →

out.go:10:9: (hits).add undefined (type "sync/atomic".Int64 has no field or method add,
              but does have method Add)

Same failure through all three normalization routes:

route source emitted Go error
shape table (method) (hits).add(1) (hits).add(1) has no field or method add, but does have method Add
user struct → shape table (stats).mu.lock() (stats).mu.lock() "sync".Mutex has no field or method lock, but does have method Lock
foreign handle (mux).handle("/p", …) (mux).handle(…) *http.ServeMux has no field or method handle

Go's error names the correct type in every case — the type was resolvable, the emitter just never
asked, because the dispatch keys on the receiver's shape and Paren matched nothing.

Fix

Two halves, both needed:

  1. Resolve through the parens (expr.rs + stmt.rs): type_of_expr at entry and at its
    user-struct root lookup, member_name, and the four if let Expr::Field { base, .. } dispatch
    branches in emit_call. peel_parens — already in stmt.rs from the O8 work — becomes
    pub(crate) so expr.rs can use it.
  2. Emit the receiver peeled: a postfix receiver never needs parentheses, so (x).f emits
    exactly x.f. That is what makes this checkable byte-for-byte rather than "it builds now" — the
    parenthesized spelling and the plain one now produce identical Go.

No lowering changes.

Tests

Three tests, all failing without the patch:

test asserts
paren_receiver_keeps_atomic_method_mapping hits.Add(1), hits.Load(), no (hits)
paren_receiver_keeps_struct_field_method_mapping stats.mu.Lock() / stats.mu.Unlock(), no (stats)
paren_receiver_drops_the_parens_on_a_foreign_handle mux.Handle(, no .handle(, no (mux)

Reverting expr.rs + stmt.rs to 0706813f with only tests.rs applied:

test result: FAILED. 170 passed; 3 failed

With the patch: test result: ok. 173 passed; 0 failed.

Verification (main @ 0706813f)

check result
cargo test -p hy-emit --lib 173 passed, 0 failed (170 before, +3)
cargo test -p hy-check -p hy-syntax --lib 160 / 107 passed, unchanged
corpus verdicts, 78 fixtures 23 ACCEPT / 55 REJECT, zero mismatch
end to end both repros build; hits.Add(1) / mux.Handle(...) — identical to the plain spelling

Fourth locus of the same class

#575 P0-04 (as_slot_loan_operand), #607 (binding_kind_of / hint_from_values), #469 (?
hoisting) and this one are independent sites of one defect: parens are semantically transparent, so
every shape-matching site must remember to peel, and each one that forgets fails silently — the
checker accepts and the wrong name reaches Go. None of the four fixes subsumes another. #585 argues
for eliminating the class once instead of paying per site; this PR is the per-site fix.

The three tests use a parameter receiver, so they exercise this defect without depending on
#611 (a line-leading ( used to be folded into the previous expression, which made the shape
unwritable outside a block). Independent fixes, either order.

…pe (limitlessprojects#618)

A postfix receiver never needs parentheses, but the emitter both KEPT them
in the Go text and dropped them for every resolution decision — so the
member was never normalized:

    fn probe(hits *atomic.int64) {
        (hits).add(1)
        fmt.println("hits: {hits.load()}")
    }

    $ hyb check r2.hy   ->  OK (0 diagnostics)
    $ hyb build r2.hy   ->  emitted, then:
        out.go:10:9: (hits).add undefined (type "sync/atomic".Int64 has no
                      field or method add, but does have method Add)

Go names the right type in the error: the type was resolvable all along.
The dispatch that decides how a member is spelled keys on the receiver's
SHAPE, and `Expr::Paren` matched none of the sites:

  * `type_of_expr` (expr.rs) at entry, and at its user-struct root lookup
  * `member_name` (stmt.rs) for the foreign / package-root decision
  * `emit_call`'s four `if let Expr::Field { base, .. } = callee` branches

Each fell through to "emit the name verbatim", so `add` / `lock` / `handle`
survived as lowercase Go methods that do not exist (`has no field or method
add` / `lock` / `handle`).

The fix has two halves:
  1. resolve through the parens at those entry points (`peel_parens`, which
     already lived in stmt.rs for the O8 work, becomes pub(crate));
  2. emit the receiver peeled — a postfix receiver never needs parens, so
     `(x).f` now emits exactly `x.f`, which makes the equivalence checkable
     byte-for-byte instead of "it builds now".

No lowering changes.

Tests: `paren_receiver_keeps_atomic_method_mapping`,
`paren_receiver_keeps_struct_field_method_mapping`,
`paren_receiver_drops_the_parens_on_a_foreign_handle` — all three fail with
expr.rs + stmt.rs reverted to main (170 passed / 3 failed) and pass here
(173 passed / 0 failed). They use a PARAMETER receiver, so they exercise
this defect without depending on limitlessprojects#611.

Verified on 0706813: hy-check 160 / hy-syntax 107 passed, unchanged; all
78 corpus fixtures still agree with their EXPECTED-VERDICT headers
(23 ACCEPT / 55 REJECT, zero mismatches).

This is the fourth independent locus of one class (limitlessprojects#575 P0-04, limitlessprojects#607, limitlessprojects#469,
this); see limitlessprojects#585 for the argument that it should be one normalization point
rather than six.
limitlessprojects pushed a commit that referenced this pull request Oct 18, 2026
`emit_expr_exp` is the only site that gives a type-elided composite
literal its head from the surrounding type, and it matched the
expression directly. A redundant parenthesis therefore put the literal
out of reach of the `ty: None` / `elem: None` arms, the expected type
was discarded, and the emit fell through to the "spell the type"
refusal - on a program `hyb check` accepts with 0 diagnostics:

    struct svc { replicas int
                 tag      string }

    fn main() {
        m := map[string]svc{"a": ({replicas: 2, tag: "x"})}
        fmt.println("{m}")
    }

    $ hyb check elided.hy   ->  OK (0 diagnostics)
    $ hyb build elided.hy   ->  error [emit]: not yet supported:
                                type-elided struct literal - spell the type

Without the parens the same program builds, runs, and prints
`map[a:{2 x}]`; with the peel it prints the same thing.

`Paren` is documented as span-only and semantically transparent
(`hy-syntax/src/ast.rs`: "kept for faithful spans"), so the elision has
to apply identically through it. Dropping the parens from output is safe
at every call site: `emit_expr_exp` is only reached from the element
slots of composite literals, where Go gives parentheses no meaning.

The peel stays local to `emit_expr_exp` on purpose - `Self::peel_parens`
is private to `stmt.rs` today and #619 promotes it separately, so this
change is one hunk in one file and does not touch that helper.

Tests: `diff_elided_map_value_struct_literal_under_parens` and
`diff_elided_slice_elem_struct_literal_under_parens` (the latter also
covers double parens) fail without the peel; `diff_elided_map_value_
struct_literal` pins the unparenthesised spelling unchanged.

Verified on 28754ab: `cargo test -p hy-emit --lib` 173 passed / 0 failed
(171 before, +2); with expr.rs reverted and the tests kept, exactly the
two new ones fail and the other 171 pass; `cargo test --workspace` all
suites ok; `tools/differential_loop.py` accept-side 21/23, reject-side
56/56 - the two misses are the local non-ASCII %TEMP% cgo fixtures
(`a7-borrow-by-default`, `a7-own-transfer-at-return`), which fail the
same way on unpatched main.
@Leimiu-G

Copy link
Copy Markdown
Contributor Author

Closing — my own fix is unsound and #741 is the correct take.

While re-auditing this branch against a newer main, I found that my peel is unconditional, and that
turns a paren that was load-bearing into a semantic change:

struct inner { replicas int }

fn main() {
    e := inner{replicas: 3}
    v := (&e).replicas        // the parens are NOT redundant: & binds looser than .
    fmt.println("sum: {v + 1}")
}
$ hyb build on this branch --emit-go
v := &e.replicas              // = &(e.replicas), type *int
$ go build
invalid operation: v + 1 (mismatched types *int and untyped int)

Without this branch the same program emits (&e).replicas and prints sum: 4. So #619 fixes three
shapes and breaks a fourth — a check-accepts/emit-rejects regression of exactly the family it was
meant to close. That is worse than the bug, so it should not land.

@airdropia's #741 avoids this with the right rule, which I verified on f936449 (tarball build,
HYB_ALLOW_TOOLCHAIN_DRIFT=1) rather than taking on trust:

shape emitted runs
(hits).add(1) hits.Add(1) hits: 1
(stats).mu.lock() stats.mu.Lock() hits: 2
(&e).replicas (&e).replicas — parens kept sum: 4

selector_base peeling only when the inner expression is a primary (Ident / Field / Index) is
the same whitelist the .pop() lowering already uses, so it is one rule rather than a second special
case — that is the part I got wrong and #741 got right.

Disclosure for the record: this branch was written before that whitelist existed, and CI was green on
it. Green CI could not have caught this, because no test in it exercises an AddrOf receiver — the
three tests I added all use a bare Ident or a foreign handle. The gap was in my test matrix, not in
the build.

#618 itself stays valid as the report; the finding and the three reproductions stand, and #741 is the
fix for them. Nothing else needed from me here.

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.

[soundness] P0 (hy-emit): a parenthesized receiver loses its type — (hits).add(1) / (stats).mu.lock() emit Go that cannot build

1 participant