From 0dab73f67f41e72a654404c4c14a006256ec1fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Mon, 20 Apr 2026 17:24:05 +0200 Subject: [PATCH 1/5] Remove CleanupRetains invocation from inlineCall Originally added in #24560 to strip inferred retains from inlined call trees before they reach the pickler. The cc setup pipeline now handles retains correctly without this preemptive strip, and removing it leaves scala3-bootstrapped/testCompilation green. --- .../dotty/tools/dotc/inlines/Inlines.scala | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/inlines/Inlines.scala b/compiler/src/dotty/tools/dotc/inlines/Inlines.scala index 859648a47c6a..bf77a3a2d566 100644 --- a/compiler/src/dotty/tools/dotc/inlines/Inlines.scala +++ b/compiler/src/dotty/tools/dotc/inlines/Inlines.scala @@ -18,7 +18,6 @@ import dotty.tools.dotc.transform.MegaPhase.MiniPhase import parsing.Parsers.Parser import transform.{PostTyper, Inlining, CrossVersionChecks} import staging.StagingLevel -import cc.CleanupRetains import collection.mutable import reporting.{NotConstant, trace} @@ -102,32 +101,18 @@ object Inlines: */ def inlineCall(tree: Tree)(using Context): Tree = ctx.profiler.onInlineCall(tree.symbol): - /** Strip @retains annotations from inferred types in the call tree */ - val stripRetains = CleanupRetains() - val stripper = new TreeTypeMap( - treeMap = { - case tree: InferredTypeTree => - val stripped = stripRetains(tree.tpe) - if stripped ne tree.tpe then tree.withType(stripped) - else tree - case tree => tree - } - ) - - val tree0 = stripper.transform(tree) - - if tree0.symbol.denot.exists - && tree0.symbol.effectiveOwner == defn.CompiletimeTestingPackage.moduleClass + if tree.symbol.denot.exists + && tree.symbol.effectiveOwner == defn.CompiletimeTestingPackage.moduleClass then - if (tree0.symbol == defn.CompiletimeTesting_typeChecks) return Intrinsics.typeChecks(tree0) - if (tree0.symbol == defn.CompiletimeTesting_typeCheckErrors) return Intrinsics.typeCheckErrors(tree0) + if (tree.symbol == defn.CompiletimeTesting_typeChecks) return Intrinsics.typeChecks(tree) + if (tree.symbol == defn.CompiletimeTesting_typeCheckErrors) return Intrinsics.typeCheckErrors(tree) if ctx.isAfterTyper then // During typer we wait with cross version checks until PostTyper, in order // not to provoke cyclic references. See i16116 for a test case. - CrossVersionChecks.checkRef(tree0.symbol, tree0.srcPos) + CrossVersionChecks.checkRef(tree.symbol, tree.srcPos) - if tree0.symbol.isConstructor then return tree // error already reported for the inline constructor definition + if tree.symbol.isConstructor then return tree // error already reported for the inline constructor definition /** Set the position of all trees logically contained in the expansion of * inlined call `call` to the position of `call`. This transform is necessary @@ -175,17 +160,17 @@ object Inlines: tree } - // assertAllPositioned(tree0) // debug - val tree1 = liftBindings(tree0, identity) + // assertAllPositioned(tree) // debug + val tree1 = liftBindings(tree, identity) val tree2 = if bindings.nonEmpty then - cpy.Block(tree0)(bindings.toList, inlineCall(tree1)) + cpy.Block(tree)(bindings.toList, inlineCall(tree1)) else if enclosingInlineds.length < ctx.settings.XmaxInlines.value && !reachedInlinedTreesLimit then val body = - try bodyToInline(tree0.symbol) // can typecheck the tree and thereby produce errors + try bodyToInline(tree.symbol) // can typecheck the tree and thereby produce errors catch case _: MissingInlineInfo => throw CyclicReference(ctx.owner) - new InlineCall(tree0).expand(body) + new InlineCall(tree).expand(body) else ctx.base.stopInlining = true val (reason, setting) = From d39d37255d2f27308be6d61c1d9e9b5a67723898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Mon, 27 Apr 2026 15:13:30 +0200 Subject: [PATCH 2/5] Fix and restrict inferred capture-polymorphic function literals Fixes #25830. For `val f = { [C^] => (xs: List[File^{C}]) => xs }`, the typer now produces a stable inferred type whose `{C}` retains survive PostTyper, so callers like `f[{x}](files)` work as expected. Implementation: * `cc/CaptureOps.scala`: rewrote `CleanupRetains` as a `TypeMap` that preserves a retain element only when it's (a) a capset `TypeParamRef` of an in-scope TypeLambda, (b) a capset `TypeRef` aliasing such a binder (synthetic poly-fn class type members surfaced when curried literals are visited as inner fragments), or (c) `caps.any` inside a capset bound. Anything else is erased to `Nothing` as before. * `transform/PostTyper.scala`: added `IsCapsetPolyFunLiteralTpt` sticky attachment plus an `isCapsetPolyFunLiteralRhs` helper based on `tpd.closureDef`. PostTyper attaches the marker to the inferred tpt of any val/def whose RHS is a capset poly-fn literal. CleanupRetains reads it to enable an implementation-restriction error precisely on user-written literals (not on inferred poly-fn types from method results or aliases). * `cc/Setup.scala`: accept capset `TypeRef` alongside `ParamRef` in `isRetainedParamRef` so curried literals' synthetic class type members flow through Setup correctly. Rejected shapes (with a clear restriction error): * term refs in retains (value-position or capset-bound), * `caps.any` in non-bound positions, * nested capset binders like `[C^] => x => [D^] => y` (which would otherwise crash later in cc's `SubstBindingMap`). Tests: * tests/pos-custom-args/captures/i25830.scala: plain, curried, def-bound, and anonymous-function-context literals. * tests/pos-custom-args/captures/i25830-bounded.scala: bounded capset binders, curried-bounded. * tests/pos-custom-args/captures/i25830-apply-workaround.scala: the class-with-polymorphic-apply workaround for shapes outside the supported form. * tests/neg-custom-args/captures/i25830-unsupported.scala: each rejected shape, with `// error` annotations. * Removed tests/pos-custom-args/captures/cap-paramlists{3,6,7,8}.scala: these were parser-only tests using outer-term refs in capset bounds. Equivalent parser coverage remains in cap-paramlists.scala (def-based) and cap-paramlist8-desugared.scala (explicit ascription). --- .../src/dotty/tools/dotc/cc/CaptureOps.scala | 107 +++++++++++++++++- compiler/src/dotty/tools/dotc/cc/Setup.scala | 36 +++++- .../tools/dotc/transform/PostTyper.scala | 37 +++++- .../captures/i25830-unsupported.scala | 51 +++++++++ .../captures/cap-paramlists3.scala | 10 -- .../captures/cap-paramlists6.scala | 8 -- .../captures/cap-paramlists7.scala | 8 -- .../captures/cap-paramlists8.scala | 8 -- .../captures/i25830-apply-workaround.scala | 18 +++ .../captures/i25830-bounded.scala | 27 +++++ tests/pos-custom-args/captures/i25830.scala | 27 +++++ 11 files changed, 293 insertions(+), 44 deletions(-) create mode 100644 tests/neg-custom-args/captures/i25830-unsupported.scala delete mode 100644 tests/pos-custom-args/captures/cap-paramlists3.scala delete mode 100644 tests/pos-custom-args/captures/cap-paramlists6.scala delete mode 100644 tests/pos-custom-args/captures/cap-paramlists7.scala delete mode 100644 tests/pos-custom-args/captures/cap-paramlists8.scala create mode 100644 tests/pos-custom-args/captures/i25830-apply-workaround.scala create mode 100644 tests/pos-custom-args/captures/i25830-bounded.scala create mode 100644 tests/pos-custom-args/captures/i25830.scala diff --git a/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala b/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala index f6ad067168a1..1ab45b71f27c 100644 --- a/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala +++ b/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala @@ -8,6 +8,7 @@ import Names.{Name, TermName} import ast.{tpd, untpd} import Decorators.*, NameOps.* import config.Printers.capt +import util.{NoSourcePosition, SrcPos} import util.Property.Key import tpd.* import Annotations.Annotation @@ -898,17 +899,112 @@ extension (tp: AnnotatedType) { /** A prototype that indicates selection */ class PathSelectionProto(val selector: Symbol, val pt: Type, val tree: Tree) extends typer.ProtoTypes.WildcardSelectionProto -/** Drop retains annotations in the inferred type if CC is not enabled - * or transform them into retains annotations with Nothing (i.e. empty set) as - * argument if CC is enabled (we need to do that to keep by-name status). +/** A TypeMap that strips `@retains` annotations from inferred types, + * preserving only those whose elements describe a stable, capture-polymorphic + * interface (refs to capset binders in scope). Inferred types reach the + * pickler with retains that the user never wrote — this cleanup makes them + * consistent with what the user can actually rely on. + * + * When applied to the inferred type of a capture-polymorphic poly-function + * literal, an unsupported retain is reported as an implementation + * restriction. The supported shape is documented at the report site. + * + * @param tree the inferred TypeTree being cleaned. Only used to source the + * error position. Defaults to `EmptyTree` for callers that + * don't need to report (UnApply / Bind cleanups in PostTyper). */ -class CleanupRetains(using Context) extends TypeMap: +class CleanupRetains(tree: Tree = EmptyTree)(using Context) extends TypeMap: + // Capset-binding TypeLambdas in scope. Used to recognize valid retain + // elements during descent and to detect a capture-polymorphic context. + // + // A poly-fn literal `{ [C^] => x => body }` desugars to a `DefDef $anonfun` + // carrying the [C^] PolyType on its symbol. PostTyper visits two inferred + // TypeTrees per literal: the outer val/def tpt sees the whole type so [C^] + // is reached by descent; the inner $anonfun return tpt sees only a fragment + // and never crosses [C^]. Owner walking covers that fragment case by + // seeding the binder from `ctx.owner.info` — gated on anon-fn owners so we + // don't pull enclosing PolyTypes into the scope of an unrelated regular + // val (e.g. one inside `extension [T, C^]`). + private var capSetBinders: List[TypeLambda] = + if Feature.ccEnabled && ctx.owner.isAnonymousFunction then + def enclosing(sym: Symbol): List[TypeLambda] = + if !sym.exists || sym.is(Package) then Nil + else sym.info match + case lt: TypeLambda if lt.paramRefs.exists(_.derivesFromCapSet) => + lt :: enclosing(sym.owner) + case _ => enclosing(sym.owner) + enclosing(ctx.owner) + else Nil + + // At most one implementation-restriction error per type tree. + private var reported = false + + // Source position for the implementation-restriction error. Set when + // `tree` carries the IsCapsetPolyFunLiteralTpt attachment, which PostTyper + // attaches to the inferred tpt of any val/def whose RHS is a poly-fn + // literal — including nested cases like `(i: Int) => { [C^] => ... }`. + private val reportPos: SrcPos = + if Feature.ccEnabled && tree.hasAttachment(transform.PostTyper.IsCapsetPolyFunLiteralTpt) then tree.srcPos + else NoSourcePosition + + // Whether to keep a retain element. `parent` is the annotated type. + private def isPreserved(elem: Type, parent: Type): Boolean = elem match + case ref: TypeParamRef => + // Capset param of an in-scope TypeLambda. + ref.derivesFromCapSet && capSetBinders.exists(_ eq ref.binder) + case ref: TypeRef => + // Capset typedef aliasing an in-scope binder — for curried literals. + ref.derivesFromCapSet && capSetBinders.exists(_.paramRefs.exists(_ =:= ref)) + case ref: TermRef => + // caps.any only in capset bounds (synthesized `<: CapSet^{any}` for `[C^]`). + ref.isCapsAnyRef && parent.derivesFromCapSet + case _ => false + def apply(tp: Type): Type = tp match case tp @ AnnotatedType(parent, annot: RetainingAnnotation) => if Feature.ccEnabled then if annot.symbol == defn.RetainsCapAnnot then tp - else AnnotatedType(this(parent), RetainingAnnotation(annot.symbol.asClass, defn.NothingType)) + else + val elems = annot.retainedType.retainedElementsRaw + val keep = elems.nonEmpty && elems.forall(isPreserved(_, parent)) + // Suppress only the narrow case where the entire retain is a single + // synthesized TypeBounds — `nonDependentResultApprox` (Types.scala) + // produces these when collapsing a dependent retain into a `range(...)` + // and an invariant position turns it back into bounds. Anything else + // gets reported, even if some bad element is a TypeBounds. + val isSyntheticOnly = elems match + case (_: TypeBounds) :: Nil => true + case _ => false + if !keep && capSetBinders.nonEmpty && !reported && reportPos.span.exists && !isSyntheticOnly then + elems.find(e => !isPreserved(e, parent)) match + case Some(bad) => + reported = true + report.restrictionError( + em"""inferred capture-polymorphic function literals can only retain capture-set parameters from enclosing type lambdas; found $bad. + |Define a class with a polymorphic apply method to retain arbitrary capture sets.""", + reportPos) + case None => + AnnotatedType(this(parent), + if keep then annot + else RetainingAnnotation(annot.symbol.asClass, defn.NothingType)) else this(parent) + case tp: TypeLambda if tp.paramRefs.exists(_.derivesFromCapSet) => + // A second capset TypeLambda nested inside another would crash cc later + // (Setup's lambda recursion + SubstBindingMap on a Var holding outer + // binder refs hits an invariant in CaptureSet.map). Reject it here. + // Descend with empty `capSetBinders` so retains in the inner lambda + // erase to Nothing — this avoids the Const{C, D}-with-rebound-C shape + // that triggers the downstream crash. + val saved = capSetBinders + val nested = capSetBinders.nonEmpty + if nested && !reported && reportPos.span.exists then + reported = true + report.restrictionError( + em"""nested capture-set type-lambda binders aren't supported in inferred capture-polymorphic function literals. + |Combine binders into a single section like `[C^, D^, ...]`, or define a class with a polymorphic apply method.""", + reportPos) + capSetBinders = if nested then Nil else tp :: capSetBinders + try mapOver(tp) finally capSetBinders = saved case _ => mapOver(tp) /** A base class for extractors that match annotated types with a specific @@ -1016,4 +1112,3 @@ abstract class DeepTypeAccumulator[T](using Context) extends TypeAccumulator[T]: case _ => foldOver(acc, t) end DeepTypeAccumulator - diff --git a/compiler/src/dotty/tools/dotc/cc/Setup.scala b/compiler/src/dotty/tools/dotc/cc/Setup.scala index ad96444917b1..fe504f5a8b8a 100644 --- a/compiler/src/dotty/tools/dotc/cc/Setup.scala +++ b/compiler/src/dotty/tools/dotc/cc/Setup.scala @@ -338,14 +338,38 @@ class Setup extends PreRecheck, SymTransformer, SetupAPI: def innerApply(tp: Type) = val tp1 = tp match + case AnnotatedType(parent, annot: RetainingAnnotation) => + val elems = annot.retainedType.retainedElementsRaw + def isRetainedParamRef(elem: Type): Boolean = elem match + case _: ParamRef => true + // CleanupRetains may also preserve capset TypeRefs (e.g. to a + // type member of a synthetic poly-function class that aliases an + // outer apply method's type parameter). + case ref: TypeRef => ref.derivesFromCapSet + case _ => false + def promote(caps: List[Capability]) = + // Refs preserved by CleanupRetains are part of the inferred + // polymorphic function interface. Keep them as a Const + // CapturingType and strip the empty Var that `apply(parent)` + // would have layered underneath. + val parent1 = apply(parent) match + case CapturingType(p, refs) if refs.elems.isEmpty && !refs.isConst => p + case other => other + CapturingType(parent1, CaptureSet(caps*)) + try + if elems.nonEmpty && elems.forall(isRetainedParamRef) then + promote(elems.map(_.toCapability)) + else apply(parent) + catch case _: IllegalCaptureRef => + apply(parent) case AnnotatedType(parent, annot) if annot.symbol.isRetains || annot.symbol == defn.InferredAnnot => - // Drop explicit retains and @inferred annotations + // Drop non-RetainingAnnotation retains (e.g. pickle-read) and @inferred. apply(parent) case tp: TypeLambda => - // Don't recurse into parameter bounds, just cleanup any stray retains annotations + // Leave parameter bounds alone; CleanupRetains already filtered them. tp.derivedLambdaType( - paramInfos = tp.paramInfos.mapConserve(_.dropAllRetains.bounds), + paramInfos = tp.paramInfos, resType = this(tp.resType)) case tp @ RefinedType(parent, rname, rinfo) => val saved = refiningNames @@ -879,6 +903,9 @@ class Setup extends PreRecheck, SymTransformer, SetupAPI: * - If type is a capturing type that has already a capture set variable or has * the universal capture set, it does not need a variable. */ + private def isNonEmptyParamRefSet(refs: CaptureSet)(using Context): Boolean = + !refs.elems.isEmpty && refs.elems.forall(_.core.isInstanceOf[ParamRef]) + def needsVariable(tp: Type)(using Context): Boolean = tp.typeParams.isEmpty && tp.match case tp: (TypeRef | AppliedType) => @@ -899,6 +926,9 @@ class Setup extends PreRecheck, SymTransformer, SetupAPI: needsVariable(parent) && refs.isConst // if refs is a variable, no need to add another && !refs.isUniversal // if refs is {caps.any}, an added variable would not change anything + // Non-empty Const sets that contain only parameter refs must stay Const: + // a Var's elements don't rewrite under SubstParamsMap. See i25830. + && !isNonEmptyParamRefSet(refs) case AnnotatedType(parent, _) => needsVariable(parent) case _ => diff --git a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala index 08409b2c1122..4dcf02c034d4 100644 --- a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala +++ b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala @@ -30,6 +30,26 @@ import ast.TreeInfo object PostTyper { val name: String = "posttyper" val description: String = "additional checks and cleanups after type checking" + + /** Sticky attachment placed by PostTyper on the inferred tpt of a val/def + * whose RHS is a capture-polymorphic poly-function literal. Read by + * `cc.CleanupRetains` to decide whether to enable the implementation- + * restriction error: it should fire only on user-written literals, not on + * inferred poly-fn types that come from method calls or aliases. + */ + val IsCapsetPolyFunLiteralTpt: util.Property.StickyKey[Unit] = util.Property.StickyKey() + + /** Detect whether `rhs` is a capture-polymorphic poly-fn literal — i.e. + * a closure (after desugaring: `Block(DefDef($anonfun, ...), Closure(...))`, + * possibly wrapped in `Block(Nil, _)` from outer braces) whose synthesized + * `$anonfun` carries a `PolyType` with at least one capset binder. + */ + def isCapsetPolyFunLiteralRhs(rhs: tpd.Tree)(using Context): Boolean = rhs match + case tpd.closureDef(dd) => + dd.symbol.info match + case pt: PolyType => pt.paramRefs.exists(_.derivesFromCapSet) + case _ => false + case _ => false } /** A macro transform that runs immediately after typer and that performs the following functions: @@ -585,12 +605,27 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => val tree1 = cpy.ValDef(tree)(tpt = makeOverrideTypeDeclared(tree.symbol, tree.tpt)) if tree1.removeAttachment(desugar.UntupledParam).isDefined then checkStableSelection(tree.rhs) + tree1.tpt match + case tpt: TypeTree if tpt.isInferred && PostTyper.isCapsetPolyFunLiteralRhs(tree.rhs) => + tpt.putAttachment(PostTyper.IsCapsetPolyFunLiteralTpt, ()) + case _ => processValOrDefDef(super.transform(tree1)) case tree: DefDef => registerIfHasMacroAnnotations(tree) Checking.checkPolyFunctionType(tree.tpt) annotateContextResults(tree) val tree1 = cpy.DefDef(tree)(tpt = makeOverrideTypeDeclared(tree.symbol, tree.tpt)) + // Only attach for user-named defs. Synthetic anon-funs (closure + // bodies) shouldn't carry the marker — that would fire restriction + // errors for literals nested inside Function1s even when the + // enclosing val has an explicit type ascription. + tree1.tpt match + case tpt: TypeTree + if !tree.symbol.isAnonymousFunction + && tpt.isInferred + && PostTyper.isCapsetPolyFunLiteralRhs(tree.rhs) => + tpt.putAttachment(PostTyper.IsCapsetPolyFunLiteralTpt, ()) + case _ => processValOrDefDef(superAcc.wrapDefDef(tree1)(super.transform(tree1).asInstanceOf[DefDef])) case tree: TypeDef => registerIfHasMacroAnnotations(tree) @@ -677,7 +712,7 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => report.error(em"type ${alias.tpe} outside bounds $bounds", tree.srcPos) super.transform(tree) case tree: TypeTree => - val tpe = if tree.isInferred then CleanupRetains()(tree.tpe) else tree.tpe + val tpe = if tree.isInferred then CleanupRetains(tree)(tree.tpe) else tree.tpe tree.withType(transformAnnotsIn(tpe)) case Typed(Ident(nme.WILDCARD), _) => withMode(Mode.Pattern)(super.transform(tree)) diff --git a/tests/neg-custom-args/captures/i25830-unsupported.scala b/tests/neg-custom-args/captures/i25830-unsupported.scala new file mode 100644 index 000000000000..d5f92f755bce --- /dev/null +++ b/tests/neg-custom-args/captures/i25830-unsupported.scala @@ -0,0 +1,51 @@ +import language.experimental.captureChecking +import caps.* + +class File extends SharedCapability + +// TODO these cases are currently restricted by the implementation, but we should consider allowing some of them in the future. See i25830 for details. +// The general case for capture-polymorphic lambdas is non-trivial to implement and we want to get the implementation right before allowing more cases. +// On the other hand, they are currently rarely needed in practice and the workaround is not too bad, so we can live with the restriction for now. + +def mixedExternal() = + val external = File() + val f = // error + { [C^] => (xs: List[File^{C, external}]) => xs } + +def externalOnly() = + val external = File() + val f = // error + { [C^] => (xs: List[File^{external}]) => xs } + +def mixedExternalWithLowerBoundedParam() = + val external = File() + val f = // error + { [C^, D^ >: {C}] => (xs: List[File^{D, external}]) => xs } + +def mixedExternalInLaterParamList() = + val external = File() + val f = // error + { [C^] => (xs: List[File^{C}]) => (ys: List[File^{C, external}]) => ys } + +def enclosingParam(external: File^) = + val f = // error + { [C^] => (xs: List[File^{C}]) => (ys: List[File^{external}]) => ys } + +def unsupportedDef() = + val external = File() + def f = // error + { [C^] => (xs: List[File^{C, external}]) => xs } + +def unsupportedInsideAnonymousFunction() = + List(File()).map: external => + val f = // error + { [C^] => (xs: List[File^{C}]) => (ys: List[File^{external}]) => ys } + +def externalInBound() = + val external = File() + val f = // error + { [C^, D^ <: {C, external}] => (xs: List[File^{D}]) => xs } + +def nestedCapsetBinders() = + val f = // error + { [C^] => (xs: List[File^{C}]) => [D^] => (ys: List[File^{C, D}]) => ys } diff --git a/tests/pos-custom-args/captures/cap-paramlists3.scala b/tests/pos-custom-args/captures/cap-paramlists3.scala deleted file mode 100644 index 29ae98cb98d6..000000000000 --- a/tests/pos-custom-args/captures/cap-paramlists3.scala +++ /dev/null @@ -1,10 +0,0 @@ -import language.experimental.captureChecking - -trait Ctx[T] - -def test = - val x: Any^ = ??? - val y: Any^ = ??? - object O: - val z: Any^ = ??? - val bar = [C^, D <: {C}, E^ <: {C,x}, F >: {x,y} <: {C,E}] => (x: Int) => 1 \ No newline at end of file diff --git a/tests/pos-custom-args/captures/cap-paramlists6.scala b/tests/pos-custom-args/captures/cap-paramlists6.scala deleted file mode 100644 index d0567089556c..000000000000 --- a/tests/pos-custom-args/captures/cap-paramlists6.scala +++ /dev/null @@ -1,8 +0,0 @@ -import language.experimental.captureChecking - -def test = - val x: Any^ = ??? - val y: Any^ = ??? - object O: - val z: Any^ = ??? - val baz = () => [C^, D^ <: {C}, E^ <: {C,x}, F^ >: {x,y} <: {C,E}] => (x: Int) => 1 \ No newline at end of file diff --git a/tests/pos-custom-args/captures/cap-paramlists7.scala b/tests/pos-custom-args/captures/cap-paramlists7.scala deleted file mode 100644 index abe59edd6835..000000000000 --- a/tests/pos-custom-args/captures/cap-paramlists7.scala +++ /dev/null @@ -1,8 +0,0 @@ -import language.experimental.captureChecking - -def test = - val x: Any^ = ??? - val y: Any^ = ??? - object O: - val z: Any^ = ??? - val baz2 = (i: Int) => [C^, D^ <: {C}, E^ <: {C,x}, F^ >: {x,y} <: {C,E}] => (x: Int) => 1 \ No newline at end of file diff --git a/tests/pos-custom-args/captures/cap-paramlists8.scala b/tests/pos-custom-args/captures/cap-paramlists8.scala deleted file mode 100644 index f300e857282a..000000000000 --- a/tests/pos-custom-args/captures/cap-paramlists8.scala +++ /dev/null @@ -1,8 +0,0 @@ -import language.experimental.captureChecking - -def test = - val x: Any^ = ??? - val y: Any^ = ??? - object O: - val z: Any^ = ??? - val baz3 = (i: Int) => [C^, D^ <: {C}, E^ <: {C,x}] => () => [F^ >: {x,y} <: {C,E}] => (x: Int) => 1 \ No newline at end of file diff --git a/tests/pos-custom-args/captures/i25830-apply-workaround.scala b/tests/pos-custom-args/captures/i25830-apply-workaround.scala new file mode 100644 index 000000000000..745fd5674c5f --- /dev/null +++ b/tests/pos-custom-args/captures/i25830-apply-workaround.scala @@ -0,0 +1,18 @@ +import language.experimental.captureChecking +import caps.* + +class File extends SharedCapability + +def test() = + val external = File() + class Convert: + def apply[C^, D^ <: {C}, E^ >: {C} <: {C, external}]( + xs: List[File^{C, external}], + ys: List[File^{D, external}])( + zs: List[File^{E, external}]): List[File^{E, external}] = zs + val x = File() + val files1: List[File^{x, external}] = List(x) + val files2: List[File^{x, external}] = List(x) + val files3: List[File^{x, external}] = List(x) + val _ : List[File^{x, external}] = + Convert()[{x}, {x}, {x, external}](files1, files2)(files3) diff --git a/tests/pos-custom-args/captures/i25830-bounded.scala b/tests/pos-custom-args/captures/i25830-bounded.scala new file mode 100644 index 000000000000..374f38e8c306 --- /dev/null +++ b/tests/pos-custom-args/captures/i25830-bounded.scala @@ -0,0 +1,27 @@ +import language.experimental.captureChecking +import caps.* + +class File extends SharedCapability + +def testFlat() = + val f = { [C^, D^ <: {C}] => (xs: List[File^{D}]) => xs } + val a = File() + val _ : List[File^{a}] = f[{a}, {a}](List[File^{a}](a)) + +def testLowerBound() = + val f = { [C^, D^ >: {C}] => (xs: List[File^{D}]) => xs } + val a = File() + val _ : List[File^{a}] = f[{a}, {a}](List[File^{a}](a)) + +def testCurriedBounded() = + val f = + { [C^, D^ <: {C}, E^ >: {C} <: {C, D}] => + (xs: List[File^{D}], ys: List[File^{C}]) => + (zs: List[File^{E}], ws: List[File^{C, D}]) => () + } + val a = File() + val xs: List[File^{a}] = List(a) + val ys: List[File^{a}] = List(a) + val zs: List[File^{a}] = List(a) + val ws: List[File^{a}] = List(a) + val _ : Unit = f[{a}, {a}, {a}](xs, ys)(zs, ws) diff --git a/tests/pos-custom-args/captures/i25830.scala b/tests/pos-custom-args/captures/i25830.scala new file mode 100644 index 000000000000..b7175312cca7 --- /dev/null +++ b/tests/pos-custom-args/captures/i25830.scala @@ -0,0 +1,27 @@ +import language.experimental.captureChecking +import caps.* + +class File extends SharedCapability + +@main def test = + val convert = { [C^] => (xs: List[File^{C}]) => xs.map(_ => ()) } + val x = File() + val files: List[File^{x}] = List(x) + val result = convert[{x}](files) + + val convertCurried = + { [C^] => (xs: List[File^{C}]) => (ys: List[File^{C}]) => + xs.map(_ => ()) ++ ys.map(_ => ()) + } + val resultCurried = convertCurried[{x}](files)(files) + + def convertDef = + { [C^] => (xs: List[File^{C}]) => xs.map(_ => ()) } + val resultDef = convertDef[{x}](files) + + val resultInAnonymousFunction = + files.map: file => + val localFiles: List[File^{file}] = List(file) + val localConvert = + { [C^] => (xs: List[File^{C}]) => xs } + localConvert[{file}](localFiles) From 3198b7382cfd92245e52e9184e34df6b84483471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Mon, 27 Apr 2026 16:17:02 +0200 Subject: [PATCH 3/5] Detect poly-fn literals nested in Function1s Recurse `isCapsetPolyFunLiteralRhs` through closure bodies whose synthesized `$anonfun` is non-polymorphic, so a literal nested inside a Function1 like `(i: Int) => { [C^] => ... }` is recognized as a literal RHS. Previously such cases silently erased their retains; now the implementation restriction fires when retains reference non-binder refs. Add positive (clean nested literal) and negative (nested literal with external dep) tests. --- .../src/dotty/tools/dotc/transform/PostTyper.scala | 11 ++++++----- .../neg-custom-args/captures/i25830-unsupported.scala | 5 +++++ tests/pos-custom-args/captures/i25830.scala | 5 +++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala index 4dcf02c034d4..b75cf2535ad1 100644 --- a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala +++ b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala @@ -39,16 +39,17 @@ object PostTyper { */ val IsCapsetPolyFunLiteralTpt: util.Property.StickyKey[Unit] = util.Property.StickyKey() - /** Detect whether `rhs` is a capture-polymorphic poly-fn literal — i.e. - * a closure (after desugaring: `Block(DefDef($anonfun, ...), Closure(...))`, - * possibly wrapped in `Block(Nil, _)` from outer braces) whose synthesized - * `$anonfun` carries a `PolyType` with at least one capset binder. + /** Detect whether `rhs` (or, transitively, any closure body it nests into) + * is a capture-polymorphic poly-fn literal — i.e. a closure whose + * synthesized `$anonfun` carries a `PolyType` with at least one capset + * binder. The recursion catches cases like `(i: Int) => { [C^] => ... }`, + * where the user-written literal is the body of an outer Function1. */ def isCapsetPolyFunLiteralRhs(rhs: tpd.Tree)(using Context): Boolean = rhs match case tpd.closureDef(dd) => dd.symbol.info match case pt: PolyType => pt.paramRefs.exists(_.derivesFromCapSet) - case _ => false + case _ => isCapsetPolyFunLiteralRhs(dd.rhs) case _ => false } diff --git a/tests/neg-custom-args/captures/i25830-unsupported.scala b/tests/neg-custom-args/captures/i25830-unsupported.scala index d5f92f755bce..6cf87aa63908 100644 --- a/tests/neg-custom-args/captures/i25830-unsupported.scala +++ b/tests/neg-custom-args/captures/i25830-unsupported.scala @@ -49,3 +49,8 @@ def externalInBound() = def nestedCapsetBinders() = val f = // error { [C^] => (xs: List[File^{C}]) => [D^] => (ys: List[File^{C, D}]) => ys } + +def literalNestedInFunction1() = + val external = File() + val f = // error + (i: Int) => { [C^] => (xs: List[File^{C, external}]) => xs } diff --git a/tests/pos-custom-args/captures/i25830.scala b/tests/pos-custom-args/captures/i25830.scala index b7175312cca7..31cbe2e0ee6c 100644 --- a/tests/pos-custom-args/captures/i25830.scala +++ b/tests/pos-custom-args/captures/i25830.scala @@ -25,3 +25,8 @@ class File extends SharedCapability val localConvert = { [C^] => (xs: List[File^{C}]) => xs } localConvert[{file}](localFiles) + + // Poly-fn literal nested inside a Function1: fine as long as retains + // only mention the literal's own capset binders. + val nestedInFunction1 = (i: Int) => { [C^] => (xs: List[File^{C}]) => xs } + val resultNested = nestedInFunction1(0)[{x}](files) From 856f147c08b946a575496a51949dc5560c8dd86f Mon Sep 17 00:00:00 2001 From: odersky Date: Mon, 27 Apr 2026 18:30:30 +0200 Subject: [PATCH 4/5] Don't use tree attachment to guide CleanupRetains --- .../src/dotty/tools/dotc/cc/CaptureOps.scala | 16 +++-------- .../tools/dotc/transform/PostTyper.scala | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala b/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala index 1ab45b71f27c..7ba517b93eb3 100644 --- a/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala +++ b/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala @@ -909,11 +909,11 @@ class PathSelectionProto(val selector: Symbol, val pt: Type, val tree: Tree) ext * literal, an unsupported retain is reported as an implementation * restriction. The supported shape is documented at the report site. * - * @param tree the inferred TypeTree being cleaned. Only used to source the - * error position. Defaults to `EmptyTree` for callers that - * don't need to report (UnApply / Bind cleanups in PostTyper). + * @param reportPos The source position to use in case of illegal signatures. + * Defaults to `NoSourcePosition` for callers that don't need + * to report (UnApply / Bind cleanups in PostTyper). */ -class CleanupRetains(tree: Tree = EmptyTree)(using Context) extends TypeMap: +class CleanupRetains(reportPos: SrcPos = NoSourcePosition)(using Context) extends TypeMap: // Capset-binding TypeLambdas in scope. Used to recognize valid retain // elements during descent and to detect a capture-polymorphic context. // @@ -939,14 +939,6 @@ class CleanupRetains(tree: Tree = EmptyTree)(using Context) extends TypeMap: // At most one implementation-restriction error per type tree. private var reported = false - // Source position for the implementation-restriction error. Set when - // `tree` carries the IsCapsetPolyFunLiteralTpt attachment, which PostTyper - // attaches to the inferred tpt of any val/def whose RHS is a poly-fn - // literal — including nested cases like `(i: Int) => { [C^] => ... }`. - private val reportPos: SrcPos = - if Feature.ccEnabled && tree.hasAttachment(transform.PostTyper.IsCapsetPolyFunLiteralTpt) then tree.srcPos - else NoSourcePosition - // Whether to keep a retain element. `parent` is the annotated type. private def isPreserved(elem: Type, parent: Type): Boolean = elem match case ref: TypeParamRef => diff --git a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala index b75cf2535ad1..675ddb9f9d53 100644 --- a/compiler/src/dotty/tools/dotc/transform/PostTyper.scala +++ b/compiler/src/dotty/tools/dotc/transform/PostTyper.scala @@ -16,7 +16,7 @@ import Symbols.*, NameOps.* import ContextFunctionResults.annotateContextResults import config.Printers.typr import config.Feature -import util.{SrcPos, Stats} +import util.{SrcPos, Stats, NoSourcePosition} import reporting.* import NameKinds.{WildcardParamName, TempResultName} import typer.Applications.{spread, HasSpreads} @@ -31,14 +31,6 @@ object PostTyper { val name: String = "posttyper" val description: String = "additional checks and cleanups after type checking" - /** Sticky attachment placed by PostTyper on the inferred tpt of a val/def - * whose RHS is a capture-polymorphic poly-function literal. Read by - * `cc.CleanupRetains` to decide whether to enable the implementation- - * restriction error: it should fire only on user-written literals, not on - * inferred poly-fn types that come from method calls or aliases. - */ - val IsCapsetPolyFunLiteralTpt: util.Property.StickyKey[Unit] = util.Property.StickyKey() - /** Detect whether `rhs` (or, transitively, any closure body it nests into) * is a capture-polymorphic poly-fn literal — i.e. a closure whose * synthesized `$anonfun` carries a `PolyType` with at least one capset @@ -128,6 +120,14 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => initContextCalled = true compilingScala2StdLib = Feature.shouldBehaveAsScala2(using ctx) + /** The set of as-yet untransformed TypeTrees that are the inferred tpt of a val/def + * whose RHS is a capture-polymorphic poly-function literal. Used to direct + * `cc.CleanupRetains` whether to enable the implementation-restriction error: + * it should fire only on user-written literals, not on inferred poly-fn types + * that come from method calls or aliases. + */ + private val hasCapsetPolyFunLiteralRhs = mutable.Set[Tree]() + val superAcc: SuperAccessors = new SuperAccessors(thisPhase) val synthMbr: SyntheticMembers = new SyntheticMembers(thisPhase) val beanProps: BeanProperties = new BeanProperties(thisPhase) @@ -608,7 +608,7 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => checkStableSelection(tree.rhs) tree1.tpt match case tpt: TypeTree if tpt.isInferred && PostTyper.isCapsetPolyFunLiteralRhs(tree.rhs) => - tpt.putAttachment(PostTyper.IsCapsetPolyFunLiteralTpt, ()) + hasCapsetPolyFunLiteralRhs += tpt case _ => processValOrDefDef(super.transform(tree1)) case tree: DefDef => @@ -625,7 +625,7 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => if !tree.symbol.isAnonymousFunction && tpt.isInferred && PostTyper.isCapsetPolyFunLiteralRhs(tree.rhs) => - tpt.putAttachment(PostTyper.IsCapsetPolyFunLiteralTpt, ()) + hasCapsetPolyFunLiteralRhs += tpt case _ => processValOrDefDef(superAcc.wrapDefDef(tree1)(super.transform(tree1).asInstanceOf[DefDef])) case tree: TypeDef => @@ -713,7 +713,11 @@ class PostTyper extends MacroTransform with InfoTransformer { thisPhase => report.error(em"type ${alias.tpe} outside bounds $bounds", tree.srcPos) super.transform(tree) case tree: TypeTree => - val tpe = if tree.isInferred then CleanupRetains(tree)(tree.tpe) else tree.tpe + def reportPos: SrcPos = + if hasCapsetPolyFunLiteralRhs.remove(tree) && Feature.ccEnabled + then tree.srcPos + else NoSourcePosition + val tpe = if tree.isInferred then CleanupRetains(reportPos)(tree.tpe) else tree.tpe tree.withType(transformAnnotsIn(tpe)) case Typed(Ident(nme.WILDCARD), _) => withMode(Mode.Pattern)(super.transform(tree)) From d960d08c5173a2b31c93fcbb1176504555dec70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Mon, 27 Apr 2026 19:13:23 +0200 Subject: [PATCH 5/5] Address review comments --- .../src/dotty/tools/dotc/cc/CaptureOps.scala | 17 +++++++--- .../dotty/tools/dotc/inlines/Inlines.scala | 31 +++++++++++++++---- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala b/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala index 7ba517b93eb3..cbaee6f27342 100644 --- a/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala +++ b/compiler/src/dotty/tools/dotc/cc/CaptureOps.scala @@ -913,7 +913,7 @@ class PathSelectionProto(val selector: Symbol, val pt: Type, val tree: Tree) ext * Defaults to `NoSourcePosition` for callers that don't need * to report (UnApply / Bind cleanups in PostTyper). */ -class CleanupRetains(reportPos: SrcPos = NoSourcePosition)(using Context) extends TypeMap: +class CleanupRetains(reportPos: SrcPos = NoSourcePosition, ownerWalk: Boolean = true)(using Context) extends TypeMap: // Capset-binding TypeLambdas in scope. Used to recognize valid retain // elements during descent and to detect a capture-polymorphic context. // @@ -925,8 +925,11 @@ class CleanupRetains(reportPos: SrcPos = NoSourcePosition)(using Context) extend // seeding the binder from `ctx.owner.info` — gated on anon-fn owners so we // don't pull enclosing PolyTypes into the scope of an unrelated regular // val (e.g. one inside `extension [T, C^]`). + // + // `ownerWalk = false` is for callers that just want plain erasure + // (e.g. inlining) and shouldn't interact with the literal-tpt machinery. private var capSetBinders: List[TypeLambda] = - if Feature.ccEnabled && ctx.owner.isAnonymousFunction then + if ownerWalk && Feature.ccEnabled && ctx.owner.isAnonymousFunction then def enclosing(sym: Symbol): List[TypeLambda] = if !sym.exists || sym.is(Package) then Nil else sym.info match @@ -945,8 +948,12 @@ class CleanupRetains(reportPos: SrcPos = NoSourcePosition)(using Context) extend // Capset param of an in-scope TypeLambda. ref.derivesFromCapSet && capSetBinders.exists(_ eq ref.binder) case ref: TypeRef => - // Capset typedef aliasing an in-scope binder — for curried literals. - ref.derivesFromCapSet && capSetBinders.exists(_.paramRefs.exists(_ =:= ref)) + // Capset typedef aliasing an in-scope binder — required for curried + // literals where inner-fragment visits see the outer binder via a + // TypeRef (e.g. `TypeRef(NoPrefix, type C)`) rather than as a + // TypeParamRef. `frozen_=:=` matches it against the binder's paramRef + // without leaking constraints into type inference. + ref.derivesFromCapSet && capSetBinders.exists(_.paramRefs.exists(_ frozen_=:= ref)) case ref: TermRef => // caps.any only in capset bounds (synthesized `<: CapSet^{any}` for `[C^]`). ref.isCapsAnyRef && parent.derivesFromCapSet @@ -959,7 +966,7 @@ class CleanupRetains(reportPos: SrcPos = NoSourcePosition)(using Context) extend else val elems = annot.retainedType.retainedElementsRaw val keep = elems.nonEmpty && elems.forall(isPreserved(_, parent)) - // Suppress only the narrow case where the entire retain is a single + // Suppress only the narrow case where the entire retains is a single // synthesized TypeBounds — `nonDependentResultApprox` (Types.scala) // produces these when collapsing a dependent retain into a `range(...)` // and an invariant position turns it back into bounds. Anything else diff --git a/compiler/src/dotty/tools/dotc/inlines/Inlines.scala b/compiler/src/dotty/tools/dotc/inlines/Inlines.scala index bf77a3a2d566..2a2af8aa2704 100644 --- a/compiler/src/dotty/tools/dotc/inlines/Inlines.scala +++ b/compiler/src/dotty/tools/dotc/inlines/Inlines.scala @@ -18,6 +18,7 @@ import dotty.tools.dotc.transform.MegaPhase.MiniPhase import parsing.Parsers.Parser import transform.{PostTyper, Inlining, CrossVersionChecks} import staging.StagingLevel +import cc.CleanupRetains import collection.mutable import reporting.{NotConstant, trace} @@ -101,18 +102,36 @@ object Inlines: */ def inlineCall(tree: Tree)(using Context): Tree = ctx.profiler.onInlineCall(tree.symbol): - if tree.symbol.denot.exists - && tree.symbol.effectiveOwner == defn.CompiletimeTestingPackage.moduleClass + /** Strip @retains annotations from inferred types in the call tree. + * Setup expects retains in inferred types to have been cleaned; types + * arriving here from inlining bypass PostTyper's CleanupRetains, so we + * apply a plain (non-poly-literal-aware) cleanup pass here. + */ + val stripRetains = CleanupRetains(ownerWalk = false) + val stripper = new TreeTypeMap( + treeMap = { + case tree: InferredTypeTree => + val stripped = stripRetains(tree.tpe) + if stripped ne tree.tpe then tree.withType(stripped) + else tree + case tree => tree + } + ) + + val tree0 = stripper.transform(tree) + + if tree0.symbol.denot.exists + && tree0.symbol.effectiveOwner == defn.CompiletimeTestingPackage.moduleClass then - if (tree.symbol == defn.CompiletimeTesting_typeChecks) return Intrinsics.typeChecks(tree) - if (tree.symbol == defn.CompiletimeTesting_typeCheckErrors) return Intrinsics.typeCheckErrors(tree) + if (tree0.symbol == defn.CompiletimeTesting_typeChecks) return Intrinsics.typeChecks(tree0) + if (tree0.symbol == defn.CompiletimeTesting_typeCheckErrors) return Intrinsics.typeCheckErrors(tree0) if ctx.isAfterTyper then // During typer we wait with cross version checks until PostTyper, in order // not to provoke cyclic references. See i16116 for a test case. - CrossVersionChecks.checkRef(tree.symbol, tree.srcPos) + CrossVersionChecks.checkRef(tree0.symbol, tree0.srcPos) - if tree.symbol.isConstructor then return tree // error already reported for the inline constructor definition + if tree0.symbol.isConstructor then return tree0 // error already reported for the inline constructor definition /** Set the position of all trees logically contained in the expansion of * inlined call `call` to the position of `call`. This transform is necessary