Skip to content

Preserve user-defined closure parameter names under CC#25999

Open
bracevac wants to merge 1 commit into
scala:mainfrom
dotty-staging:ob/cc-closure-param-names
Open

Preserve user-defined closure parameter names under CC#25999
bracevac wants to merge 1 commit into
scala:mainfrom
dotty-staging:ob/cc-closure-param-names

Conversation

@bracevac
Copy link
Copy Markdown
Contributor

@bracevac bracevac commented May 7, 2026

Fixes #25971.

Records user-written closure parameter names against their inferred plain FunctionN AppliedType at typer time, propagates that registration through type rebuilds (PostTyper's @caps.declared insertion, capture-set substitution), and reads it back in cc.Setup.normalizeFunctions when the type is expanded into a dependent function. This replaces the synthetic x$N names that the expansion otherwise generates.

Design

The mechanism is encapsulated in cc.ClosureParamNames — three operations over a per-Run EqHashMap:

When Where Op
Closure typing TypeAssigner.assignType(Closure, ...) record(funType, methodicType)
AppliedType rebuilds Types.AppliedType.derivedAppliedType propagate(this, newTp)
Plain FunctionN → dep function cc.Setup.normalizeFunctions of(original) as paramNames

Off the CC code path the table stays empty, and propagate short-circuits on Feature.ccEnabledSomewhere, so the hot-path call from core.Types is essentially free for non-CC compilation.

Why a side table

An earlier walker-based attempt (#25982) recovered names by aligning the val/result type spine with the rhs closure tree. It works but is invasive (~140 lines of tree-type alignment, with peelFormal, collectArgsThroughFormal, etc.) and doesn't extend cleanly to all the type shapes CC produces. The side table records once per closure and follows the type wherever it goes.

A typer-level alternative — always emitting RefinedType(FunctionN, apply, MT) for closures — would make every closure structurally a dependent function, which we don't want. The annotation alternative would couple FunctionOrMethod extractors to a CC-specific annotation, with similarly broad fallout. The side table localizes the CC concern: cc/ClosureParamNames.scala owns the policy, and call sites are one-liners.

Test impact

Previously-synthetic x$N names in error messages now resolve to user-given names where applicable. Updated check files:

  • tests/neg-custom-args/captures/heal-tparam-cs.checkc1 instead of x$0
  • tests/neg-custom-args/captures/i15923.check, i15923b.checkcap, lcap
  • tests/neg-custom-args/captures/scope-extrusions.checkx instead of x$0
  • tests/neg-custom-args/captures/sep-curried-par.checkp1 instead of x$0
  • tests/neg-custom-args/captures/use-capset.checkxs instead of unnamed

New printing test tests/printing/cc/i25971.scala covers three closure shapes:

  1. Polymorphic closure ([C^] => (xs) => (ys) => (zs) => …)
  2. Plain nested closures ((ys) => (zs) => …)
  3. Closure passed as constructor arg (new Box(closure)) — the substitution case that breaks naive identity-based recovery.

Full CompilationTests suite passes (73/73).

How much have you relied on LLM-based tools in this contribution?

Extensively, while I specified how I expect the solution to look like/work.

How was the solution tested?

New automated tests (tests/printing/cc/i25971.scala, plus updated check files for affected neg tests).

Fixes scala#25971 by recording user-written closure parameter names against
their inferred plain `FunctionN` type at typer time, propagating that
registration when `AppliedType.derivedAppliedType` rebuilds the type
(PostTyper's `@caps.declared` insertion, capture-set substitution, etc.),
and reading it back in `cc.Setup.normalizeFunctions` when the type is
expanded into a dependent function. This replaces the synthetic `x$N`
names that were previously generated by that expansion.

The mechanism is encapsulated in `cc.ClosureParamNames`, which exposes
three operations (`record`, `propagate`, `of`) over a per-Run
`EqHashMap` table. Off the CC code path the table stays empty, so the
hot-path call from `core.Types.AppliedType.derivedAppliedType` is a
no-op gated on `Feature.ccEnabledSomewhere`.
bracevac added a commit to dotty-staging/dotty that referenced this pull request May 7, 2026
Tries the alternative to scala#25999: when capture checking is enabled, force
`Closure.toFunctionType(alwaysDependent = true)`, so the inferred closure
type is `RefinedType(FunctionN, apply, MethodType)` from the start. The
user-given parameter names live structurally in the MethodType refinement
and survive every type rebuild trivially.

Two iterations of effort:

  1. Bare `alwaysDependent = true`: 201 captures tests fail.
     Setup's `apply` short-circuits for `RefinedFunctionOf` and never
     wraps with a capture-set variable, so the closure's body captures
     have nowhere to be written; subsequent CC checks see the un-captured
     frozen type and reject.

  2. Wrap eager dep-fun in `@caps.internal.inferred` so Setup's `mapInferred`
     matches the AnnotatedType branch, drops the marker, and routes through
     `addVar` (which gives the expected `CapturingType(RefinedType, {var})`):
     down to 49 failures. After `--update-checkfiles`, 23 still fail.

The remaining 23 are not format changes — they're semantic regressions
where the eager dep-fun shape causes type inference to take a different
path. Example: `tests/neg-custom-args/captures/vars.scala` line 39 (the
bare reference `g`) now errors when it shouldn't, because the dep-fun
shape leaks `g`'s body captures into a context that previously left them
abstract.

Conclusion: making this approach correct would require rethinking how
CC's pipeline carries captures from typer into Setup — well beyond the
scope of recovering closure parameter names. The side-table approach in
scala#25999 sidesteps all of this by leaving structural types alone and only
annotating them for naming.

Includes:
- TypeAssigner.assignType(Closure): force `alwaysDependent` under CC,
  guard with `\!ctx.erasedTypes`, wrap result in `@InferredAnnot`.
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.

CC & REPL: Can we keep the same parameter names in printed output?

1 participant