From 311c1c369d3190f7e28ca252c48cc8e584dac201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Sun, 26 Oct 2025 14:26:52 +0100 Subject: [PATCH 1/4] Initial lazy vals support --- .../dotty/tools/dotc/cc/CheckCaptures.scala | 26 +++++++-- tests/neg-custom-args/captures/lazyvals.scala | 20 +++++++ .../neg-custom-args/captures/lazyvals2.scala | 19 +++++++ .../neg-custom-args/captures/lazyvals3.scala | 55 +++++++++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 tests/neg-custom-args/captures/lazyvals.scala create mode 100644 tests/neg-custom-args/captures/lazyvals2.scala create mode 100644 tests/neg-custom-args/captures/lazyvals3.scala diff --git a/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala b/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala index 9d17f66f1dd8..61481f361dda 100644 --- a/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala +++ b/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala @@ -81,7 +81,7 @@ object CheckCaptures: end Env def definesEnv(sym: Symbol)(using Context): Boolean = - sym.is(Method) || sym.isClass + sym.is(Method) || sym.isClass || sym.is(Lazy) /** Similar normal substParams, but this is an approximating type map that * maps parameters in contravariant capture sets to the empty set. @@ -225,7 +225,7 @@ object CheckCaptures: def needsSepCheck: Boolean /** If a tree is an argument for which needsSepCheck is true, - * the type of the formal paremeter corresponding to the argument. + * the type of the formal parameter corresponding to the argument. */ def formalType: Type @@ -441,7 +441,7 @@ class CheckCaptures extends Recheck, SymTransformer: */ def capturedVars(sym: Symbol)(using Context): CaptureSet = myCapturedVars.getOrElseUpdate(sym, - if sym.isTerm || !sym.owner.isStaticOwner + if sym.isTerm || !sym.owner.isStaticOwner || sym.is(Lazy) // FIXME: are lazy vals in static owners a thing? then CaptureSet.Var(sym, nestedOK = false) else CaptureSet.empty) @@ -655,8 +655,10 @@ class CheckCaptures extends Recheck, SymTransformer: */ override def recheckIdent(tree: Ident, pt: Type)(using Context): Type = val sym = tree.symbol - if sym.is(Method) then - // If ident refers to a parameterless method, charge its cv to the environment + if sym.is(Method) || sym.is(Lazy) then + // If ident refers to a parameterless method or lazy val, charge its cv to the environment. + // Lazy vals are like parameterless methods: accessing them may trigger initialization + // that uses captured references. includeCallCaptures(sym, sym.info, tree) else if sym.exists && !sym.isStatic then markPathFree(sym.termRef, pt, tree) @@ -1083,6 +1085,7 @@ class CheckCaptures extends Recheck, SymTransformer: * - for externally visible definitions: check that their inferred type * does not refine what was known before capture checking. * - Interpolate contravariant capture set variables in result type. + * - for lazy vals: create a nested environment to track captures (similar to methods) */ override def recheckValDef(tree: ValDef, sym: Symbol)(using Context): Type = val savedEnv = curEnv @@ -1105,8 +1108,16 @@ class CheckCaptures extends Recheck, SymTransformer: "" disallowBadRootsIn( tree.tpt.nuType, NoSymbol, i"Mutable $sym", "have type", addendum, sym.srcPos) - if runInConstructor then + + // Lazy vals need their own environment to track captures from their RHS, + // similar to how methods work + if sym.is(Lazy) then + val localSet = capturedVars(sym) + if localSet ne CaptureSet.empty then + curEnv = Env(sym, EnvKind.Regular, localSet, curEnv, nestedClosure = NoSymbol) + else if runInConstructor then pushConstructorEnv() + checkInferredResult(super.recheckValDef(tree, sym), tree) finally if !sym.is(Param) then @@ -1120,6 +1131,9 @@ class CheckCaptures extends Recheck, SymTransformer: if runInConstructor && savedEnv.owner.isClass then curEnv = savedEnv markFree(declaredCaptures, tree, addUseInfo = false) + else if sym.is(Lazy) then + // Restore environment after checking lazy val + curEnv = savedEnv if sym.owner.isStaticOwner && !declaredCaptures.elems.isEmpty && sym != defn.captureRoot then def where = diff --git a/tests/neg-custom-args/captures/lazyvals.scala b/tests/neg-custom-args/captures/lazyvals.scala new file mode 100644 index 000000000000..5a26b4ce60d5 --- /dev/null +++ b/tests/neg-custom-args/captures/lazyvals.scala @@ -0,0 +1,20 @@ +import language.experimental.captureChecking +import caps.* + +class Console extends SharedCapability: + def println(msg: String): Unit = Predef.println("CONSOLE: " + msg) + +@main def run = + val console: Console^ = Console() + lazy val x: () -> String = { + console.println("Computing x") + () => "Hello, World!" + } + + val fun: () ->{console} String = () => x() // ok + val fun2: () -> String = () => x() // error + val fun3: () ->{x} String = () => x() // error // error + + println("Before accessing x") + println(s"x = ${x()}") + println(s"x again = ${x()}") diff --git a/tests/neg-custom-args/captures/lazyvals2.scala b/tests/neg-custom-args/captures/lazyvals2.scala new file mode 100644 index 000000000000..7733f5a00f01 --- /dev/null +++ b/tests/neg-custom-args/captures/lazyvals2.scala @@ -0,0 +1,19 @@ +import language.experimental.captureChecking +import caps.* + +class Console extends SharedCapability: + def println(msg: String): Unit = Predef.println("CONSOLE: " + msg) + +class IO extends SharedCapability: + def readLine(): String = scala.io.StdIn.readLine() + +@main def run = + val console: Console^ = Console() + val io: IO^ = IO() + lazy val x: () ->{io} String = { + console.println("Computing x") + () => io.readLine() + } + + val fun: () ->{console,io} String = () => x() // ok + val fun2: () ->{io} String = () => x() // error diff --git a/tests/neg-custom-args/captures/lazyvals3.scala b/tests/neg-custom-args/captures/lazyvals3.scala new file mode 100644 index 000000000000..1d1f0b4fe142 --- /dev/null +++ b/tests/neg-custom-args/captures/lazyvals3.scala @@ -0,0 +1,55 @@ +import language.experimental.captureChecking +import caps.* + +class C +type Cap = C^ + +class Console extends SharedCapability: + def println(msg: String): Unit = Predef.println("CONSOLE: " + msg) + +class IO extends SharedCapability: + def readLine(): String = scala.io.StdIn.readLine() + +def test(c: Cap, console: Console^, io: IO^): Unit = + lazy val ev: (Int -> Boolean) = (n: Int) => + lazy val od: (Int -> Boolean) = (n: Int) => + if n == 1 then true else ev(n - 1) + if n == 0 then true else od(n - 1) + + // In a mutually recursive lazy val, the result types accumulate the captures of both the initializers and results themselves. + // So, this is not ok: + lazy val ev1: (Int ->{io,console} Boolean) = + println(c) + (n: Int) => + lazy val od1: (Int ->{ev1,console} Boolean) = (n: Int) => // error + if n == 1 then + console.println("CONSOLE: 1") + true + else + ev1(n - 1) + if n == 0 then + io.readLine() // just to capture io + true + else + od1(n - 1) + + // But this is ok: + lazy val ev2: (Int ->{c,io,console} Boolean) = + println(c) + (n: Int) => + lazy val od2: (Int ->{c,io,console} Boolean) = (n: Int) => + if n == 1 then + console.println("CONSOLE: 1") + true + else + ev2(n - 1) + if n == 0 then + io.readLine() // just to capture io + true + else + od2(n - 1) + + val even: Int -> Boolean = (n: Int) => ev(n) // ok + val even2: Int ->{io,console,c} Boolean = (n: Int) => ev1(n) // ok + + () \ No newline at end of file From 80a0b5ea75ea02348936aa06e6d7a3856c50c546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Wed, 29 Oct 2025 21:52:16 +0100 Subject: [PATCH 2/4] Support lazy val members --- .../dotty/tools/dotc/cc/CheckCaptures.scala | 8 +++++ .../neg-custom-args/captures/lazyvals4.scala | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/neg-custom-args/captures/lazyvals4.scala diff --git a/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala b/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala index 61481f361dda..741af7e56689 100644 --- a/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala +++ b/compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala @@ -736,6 +736,14 @@ class CheckCaptures extends Recheck, SymTransformer: checkUpdate(qualType, tree.srcPos): i"Cannot call update ${tree.symbol} of ${qualType.showRef}" + // If selecting a lazy val member, charge the qualifier since accessing + // the lazy val can trigger initialization that uses the qualifier's capabilities + if tree.symbol.is(Lazy) then + qualType match + case tr: (TermRef | ThisType) => + markPathFree(tr, pt, tree) + case _ => + val origSelType = recheckSelection(tree, qualType, name, disambiguate) val selType = mapResultRoots(origSelType, tree.symbol) val selWiden = selType.widen diff --git a/tests/neg-custom-args/captures/lazyvals4.scala b/tests/neg-custom-args/captures/lazyvals4.scala new file mode 100644 index 000000000000..a062dee25de1 --- /dev/null +++ b/tests/neg-custom-args/captures/lazyvals4.scala @@ -0,0 +1,29 @@ +import language.experimental.captureChecking +import caps.* + +class Console extends SharedCapability: + def println(msg: String): Unit = Predef.println("CONSOLE: " + msg) + +class IO extends SharedCapability: + def readLine(): String = scala.io.StdIn.readLine() + +class Clazz(val console: Console^): + lazy val memberLazy: () -> String = { + console.println("Computing memberLazy") + () => "Member Lazy Value" + } + +trait Trait: + lazy val memberLazy: () -> String + def memberMethod(): String + +def client(t: Trait^, c: Clazz^): Unit = + val v0: () -> () -> String = () => t.memberLazy // error + val v0_1: () ->{t} () -> String = () => t.memberLazy // ok + val v1: () -> String = () => t.memberLazy() // error + val v2: (() -> String)^{t} = () => t.memberLazy() // ok + val v3: (() -> String)^{c.console} = () => c.memberLazy() // error (but should this be allowed?) + val v4: () -> String = () => t.memberMethod() // error + val v5: (() -> String)^{t} = () => t.memberMethod() // ok + + () \ No newline at end of file From f48006463d863ec8272adf0abb55dc74c610580e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Thu, 30 Oct 2025 14:17:46 +0100 Subject: [PATCH 3/4] Fix ReverseSorted in SeqView --- library/src/scala/collection/SeqView.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/scala/collection/SeqView.scala b/library/src/scala/collection/SeqView.scala index 63ffd17fc109..0d5fb1d9a5e5 100644 --- a/library/src/scala/collection/SeqView.scala +++ b/library/src/scala/collection/SeqView.scala @@ -151,7 +151,7 @@ object SeqView { def apply(i: Int): A = _reversed.apply(i) def length: Int = len - def iterator: Iterator[A] = Iterator.empty ++ _reversed.iterator // very lazy + def iterator: Iterator[A]^{this} = Iterator.empty ++ _reversed.iterator // very lazy override def knownSize: Int = len override def isEmpty: Boolean = len == 0 override def to[C1](factory: Factory[A, C1]): C1 = _reversed.to(factory) From f8e6598eddb8cae3525f32b397c063fc46084816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Bra=C4=8Devac?= Date: Fri, 31 Oct 2025 17:41:47 +0100 Subject: [PATCH 4/4] More tests --- .../neg-custom-args/captures/lazyvals5.scala | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/neg-custom-args/captures/lazyvals5.scala diff --git a/tests/neg-custom-args/captures/lazyvals5.scala b/tests/neg-custom-args/captures/lazyvals5.scala new file mode 100644 index 000000000000..50e32aed58ec --- /dev/null +++ b/tests/neg-custom-args/captures/lazyvals5.scala @@ -0,0 +1,63 @@ +import language.experimental.captureChecking +import caps.* + +class Console extends SharedCapability: + def println(msg: String): Unit = Predef.println("CONSOLE: " + msg) + +class IO extends SharedCapability: + def readLine(): String = scala.io.StdIn.readLine() + +class Clazz(val console: Console^): + val io: IO^ = IO() + lazy val memberLazy: () ->{io} String = { + console.println("Computing memberLazy") + () => "Member Lazy Value" + io.readLine() + } + +def client(c: Clazz^): Unit = + val io: IO^ = IO() + trait Trait: + lazy val memberLazy: () ->{io} String + def memberMethod(): String + + val t: Trait^ = ??? + + lazy val funky = t.memberLazy() + c.memberLazy() + + lazy val anotherFunky = + c.console.println("Computing anotherFunky") + t.memberLazy + + val v0: () -> () ->{io} String = () => t.memberLazy // error + val v0_1: () ->{t} () ->{io} String = () => t.memberLazy // ok + val v1: () -> String = () => t.memberLazy() // error + val v2: (() -> String)^{t} = () => t.memberLazy() // ok + val v3: (() -> String)^{c.console} = () => c.memberLazy() // error (but should this be allowed?) + val v4: () -> String = () => t.memberMethod() // error + val v5: (() -> String)^{t} = () => t.memberMethod() // ok + + val v6: () ->{c} String = () => funky // error + val v6_1: () ->{t} String = () => funky // error + val v7: () ->{c, t} String = () => funky // ok + + val v8: () ->{t, c.console} String = () => anotherFunky() // ok + + class Clazz2(val console: Console^): + val io: IO^ = IO() + final lazy val memberLazy: () ->{io} String = { + console.println("Computing memberLazy") + () => "Member Lazy Value" + io.readLine() + } + + trait Trait2: + final lazy val memberLazy : () ->{io} String = () => io.readLine() + + val c2: Clazz2^ = ??? + val t2: Trait2^ = ??? + + lazy val funky2 = t2.memberLazy() + c2.memberLazy() + + val v9: () ->{c2.memberLazy, t2.memberLazy} String = () => funky2 // error (but should this be allowed?) + val v10: () ->{t2, c2} String = () => funky2 // ok + + () \ No newline at end of file