From 48ff3df5beda8b701c56e6be10e53178ac692105 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Wed, 11 Mar 2026 13:58:03 +0900 Subject: [PATCH 01/11] Capture Checking --- build.sbt | 7 ++++--- core/src/main/scala/purelogic/Abort.scala | 3 ++- core/src/main/scala/purelogic/Reader.scala | 4 +++- core/src/main/scala/purelogic/State.scala | 6 ++++-- core/src/main/scala/purelogic/Writer.scala | 3 ++- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index 45aac39..92c49e9 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3Version = "3.3.7" +val scala3Version = "3.8.2" // dependencies for tests and benchmarks val catsVersion = "2.13.0" @@ -76,11 +76,12 @@ lazy val benchmarks = project lazy val commonSettings = Def.settings( scalacOptions ++= Seq( "-deprecation", - "-Xfatal-warnings", + "-Werror", "-no-indent", "-Wunused:imports,params,privates,implicits,explicits,nowarn", "-Wvalue-discard", - "-Ykind-projector" + "-Xkind-projector", + "-language:experimental.captureChecking" ), Test / fork := true ) diff --git a/core/src/main/scala/purelogic/Abort.scala b/core/src/main/scala/purelogic/Abort.scala index 646e720..412557a 100644 --- a/core/src/main/scala/purelogic/Abort.scala +++ b/core/src/main/scala/purelogic/Abort.scala @@ -1,5 +1,6 @@ package purelogic +import scala.caps.SharedCapability import scala.util.boundary import scala.util.boundary.break @@ -11,7 +12,7 @@ import scala.util.boundary.break * @tparam E * the type of error */ -trait Abort[-E] { +trait Abort[-E] extends SharedCapability { /** * Aborts the computation with the given error. diff --git a/core/src/main/scala/purelogic/Reader.scala b/core/src/main/scala/purelogic/Reader.scala index 1d714eb..561cf9b 100644 --- a/core/src/main/scala/purelogic/Reader.scala +++ b/core/src/main/scala/purelogic/Reader.scala @@ -1,5 +1,7 @@ package purelogic +import scala.caps.SharedCapability + /** * Read-only access to a value of type `R`. * @@ -8,7 +10,7 @@ package purelogic * @tparam R * the type of the value to read */ -trait Reader[+R] { +trait Reader[+R] extends SharedCapability { /** * Returns the current reader value. diff --git a/core/src/main/scala/purelogic/State.scala b/core/src/main/scala/purelogic/State.scala index 9d63b6d..ffc4f77 100644 --- a/core/src/main/scala/purelogic/State.scala +++ b/core/src/main/scala/purelogic/State.scala @@ -1,5 +1,7 @@ package purelogic +import scala.caps.SharedCapability + /** * Read-only access to a mutable state of type `S`. * @@ -8,7 +10,7 @@ package purelogic * @tparam S * the type of the state */ -trait StateReader[+S] { +trait StateReader[+S] extends SharedCapability { /** * Returns the current state. @@ -29,7 +31,7 @@ trait StateReader[+S] { * @tparam S * the type of the state */ -trait StateWriter[-S] { +trait StateWriter[-S] extends SharedCapability { /** * Replaces the state with a new value. diff --git a/core/src/main/scala/purelogic/Writer.scala b/core/src/main/scala/purelogic/Writer.scala index ce36e39..60f3fb9 100644 --- a/core/src/main/scala/purelogic/Writer.scala +++ b/core/src/main/scala/purelogic/Writer.scala @@ -1,6 +1,7 @@ package purelogic import scala.annotation.unchecked.uncheckedVariance +import scala.caps.SharedCapability import scala.collection.mutable.ArrayBuffer /** @@ -11,7 +12,7 @@ import scala.collection.mutable.ArrayBuffer * @tparam W * the type of values to accumulate */ -trait Writer[-W] { +trait Writer[-W] extends SharedCapability { /** * Appends a single value to the log. From 0697af9d90c9077c55bfe34bea62e81e8eb2aadd Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 19 Mar 2026 11:40:30 +0900 Subject: [PATCH 02/11] Fix capture checking compatibility after rebase Restructure methods to satisfy CC constraints, switch tests from zio-test to munit (incompatible with CC macros), and disable CC for benchmarks project. Co-Authored-By: Claude Opus 4.6 (1M context) --- build.sbt | 8 +- core/src/main/scala/purelogic/Abort.scala | 33 +- core/src/main/scala/purelogic/Reader.scala | 5 +- core/src/main/scala/purelogic/State.scala | 17 +- core/src/main/scala/purelogic/Writer.scala | 4 +- .../test/scala/purelogic/PureLogicSpec.scala | 958 +++++++++--------- 6 files changed, 525 insertions(+), 500 deletions(-) diff --git a/build.sbt b/build.sbt index 92c49e9..6385c45 100644 --- a/build.sbt +++ b/build.sbt @@ -37,8 +37,7 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) .settings(commonSettings) .settings( libraryDependencies ++= Seq( - "dev.zio" %%% "zio-test" % zioVersion % Test, - "dev.zio" %%% "zio-test-sbt" % zioVersion % Test + "org.scalameta" %%% "munit" % "1.2.4" % Test ) ) .jsSettings(Test / fork := false) @@ -59,7 +58,10 @@ lazy val benchmarks = project .settings(commonSettings) .settings(scalaVersion := "3.8.2") .settings( - scalacOptions := scalacOptions.value.filterNot(_ == "-Ykind-projector").filterNot(_ == "-Xfatal-warnings") :+ "-Xkind-projector" + scalacOptions := scalacOptions.value + .filterNot(_ == "-Ykind-projector") + .filterNot(_ == "-Xfatal-warnings") + .filterNot(_ == "-language:experimental.captureChecking") :+ "-Xkind-projector" ) .settings(publish / skip := true) .settings( diff --git a/core/src/main/scala/purelogic/Abort.scala b/core/src/main/scala/purelogic/Abort.scala index 412557a..a5d9a43 100644 --- a/core/src/main/scala/purelogic/Abort.scala +++ b/core/src/main/scala/purelogic/Abort.scala @@ -1,6 +1,5 @@ package purelogic -import scala.caps.SharedCapability import scala.util.boundary import scala.util.boundary.break @@ -12,7 +11,7 @@ import scala.util.boundary.break * @tparam E * the type of error */ -trait Abort[-E] extends SharedCapability { +trait Abort[-E] extends scala.caps.SharedCapability { /** * Aborts the computation with the given error. @@ -60,6 +59,7 @@ object Abort { /** * Default `Abort[Nothing]` instance that can never fail. */ + @scala.annotation.nowarn given Abort[Nothing] = new Abort[Nothing] { def fail(e: Nothing): Nothing = e } @@ -132,15 +132,20 @@ object Abort { val stateSnapshot = s.get val logSnapshot = w.snapshot - boundary[A] { + val result = boundary[Either[E, A]] { val abort = new Abort[E] { def fail(e: E): Nothing = { s.set(stateSnapshot) if (resetLog) w.rollback(logSnapshot) - break(handler(e)) + break(Left(e)) } } - f(using abort) + Right(f(using abort)) + } + + result match { + case Right(value) => value + case Left(e) => handler(e) } } @@ -150,18 +155,24 @@ object Abort { val stateSnapshot = s.get val logSnapshot = w.snapshot - boundary[A] { + val result = boundary[Either[E, A]] { val a = new Abort[E] { def fail(e: E): Nothing = { s.set(stateSnapshot) if (resetLog) w.rollback(logSnapshot) - handler.lift(e) match { - case Some(value) => break(value) - case None => abort.fail(e) - } + break(Left(e)) } } - f(using a) + Right(f(using a)) + } + + result match { + case Right(value) => value + case Left(e) => + handler.lift(e) match { + case Some(value) => value + case None => abort.fail(e) + } } } } diff --git a/core/src/main/scala/purelogic/Reader.scala b/core/src/main/scala/purelogic/Reader.scala index 561cf9b..cfe4a89 100644 --- a/core/src/main/scala/purelogic/Reader.scala +++ b/core/src/main/scala/purelogic/Reader.scala @@ -1,7 +1,5 @@ package purelogic -import scala.caps.SharedCapability - /** * Read-only access to a value of type `R`. * @@ -10,7 +8,7 @@ import scala.caps.SharedCapability * @tparam R * the type of the value to read */ -trait Reader[+R] extends SharedCapability { +trait Reader[+R] extends scala.caps.SharedCapability { /** * Returns the current reader value. @@ -48,6 +46,7 @@ object Reader { /** * Default `Reader[Unit]` instance that always returns `()`. */ + @scala.annotation.nowarn given Reader[Unit] = new Reader[Unit] { def read: Unit = () } diff --git a/core/src/main/scala/purelogic/State.scala b/core/src/main/scala/purelogic/State.scala index ffc4f77..4ef62ca 100644 --- a/core/src/main/scala/purelogic/State.scala +++ b/core/src/main/scala/purelogic/State.scala @@ -1,7 +1,5 @@ package purelogic -import scala.caps.SharedCapability - /** * Read-only access to a mutable state of type `S`. * @@ -10,7 +8,7 @@ import scala.caps.SharedCapability * @tparam S * the type of the state */ -trait StateReader[+S] extends SharedCapability { +trait StateReader[+S] extends scala.caps.SharedCapability { /** * Returns the current state. @@ -31,7 +29,7 @@ trait StateReader[+S] extends SharedCapability { * @tparam S * the type of the state */ -trait StateWriter[-S] extends SharedCapability { +trait StateWriter[-S] extends scala.caps.SharedCapability { /** * Replaces the state with a new value. @@ -90,10 +88,12 @@ trait State[S] extends StateReader[S] with StateWriter[S] { * outer state. */ def focus[A, B](getFocus: S => A)(setFocus: (S, A) => S)(body: State[A] ?=> B): B = { - val outer = this - val state = new State[A] { - def get: A = getFocus(outer.get) - def set(a: A): Unit = outer.set(setFocus(outer.get, a)) + val outer = this + var cachedGet = getFocus + var cachedSet = setFocus + val state = new State[A] { + def get: A = cachedGet(outer.get) + def set(a: A): Unit = outer.set(cachedSet(outer.get, a)) } body(using state) } @@ -118,6 +118,7 @@ object State { /** * Default `State[Unit]` instance that does nothing. */ + @scala.annotation.nowarn given State[Unit] = new State[Unit] { def get: Unit = () def set(s: Unit): Unit = () diff --git a/core/src/main/scala/purelogic/Writer.scala b/core/src/main/scala/purelogic/Writer.scala index 60f3fb9..17e76cb 100644 --- a/core/src/main/scala/purelogic/Writer.scala +++ b/core/src/main/scala/purelogic/Writer.scala @@ -1,7 +1,6 @@ package purelogic import scala.annotation.unchecked.uncheckedVariance -import scala.caps.SharedCapability import scala.collection.mutable.ArrayBuffer /** @@ -12,7 +11,7 @@ import scala.collection.mutable.ArrayBuffer * @tparam W * the type of values to accumulate */ -trait Writer[-W] extends SharedCapability { +trait Writer[-W] extends scala.caps.SharedCapability { /** * Appends a single value to the log. @@ -65,6 +64,7 @@ object Writer { /** * Default `Writer[Nothing]` instance that discards all writes. */ + @scala.annotation.nowarn given Writer[Nothing] = new Writer[Nothing] { def write(w: Nothing): Unit = () def writeAll(elems: IterableOnce[Nothing]): Unit = () diff --git a/core/src/test/scala/purelogic/PureLogicSpec.scala b/core/src/test/scala/purelogic/PureLogicSpec.scala index fc4b757..74ba2ad 100644 --- a/core/src/test/scala/purelogic/PureLogicSpec.scala +++ b/core/src/test/scala/purelogic/PureLogicSpec.scala @@ -2,11 +2,9 @@ package purelogic import scala.util.{Failure, Success} -import zio.test.* - import purelogic.syntax.* -object PureLogicSpec extends ZIOSpecDefault { +class PureLogicSpec extends munit.FunSuite { case class Config(discount: Double) case class AppState(items: List[String]) @@ -25,489 +23,503 @@ object PureLogicSpec extends ZIOSpecDefault { extractOption(items.lift(idx), AppError.NotFound(s"index $idx")) } - def spec = suite("PureLogic")( - // --------------------------------------------------------------------------- - // Reader - // --------------------------------------------------------------------------- - suite("Reader")( - test("read returns the environment") { - val result = Reader(42)(read) - assertTrue(result == 42) - }, - test("read projects the environment") { - val result = Reader("hello")(read(_.length)) - assertTrue(result == 5) - }, - test("local provides a modified environment for the block") { - val (inner, outer) = Reader(10) { - val inner = local(_ + 5) { - read - } - val outer = read - (inner, outer) - } - assertTrue(inner == 15, outer == 10) - }, - test("focus narrows the environment for the block") { - val (inner, outer) = Reader(("hello", 42)) { - val inner = focus(_._2) { - read - } - val outer = read - (inner, outer) - } - assertTrue(inner == 42, outer == ("hello", 42)) + // --------------------------------------------------------------------------- + // Reader + // --------------------------------------------------------------------------- + test("Reader: read returns the environment") { + val result = Reader(42)(read) + assertEquals(result, 42) + } + + test("Reader: read projects the environment") { + val result = Reader("hello")(read(_.length)) + assertEquals(result, 5) + } + + test("Reader: local provides a modified environment for the block") { + val (inner, outer) = Reader(10) { + val inner = local(_ + 5) { + read + } + val outer = read + (inner, outer) + } + assertEquals(inner, 15) + assertEquals(outer, 10) + } + + test("Reader: focus narrows the environment for the block") { + val (inner, outer) = Reader(("hello", 42)) { + val inner = focus(_._2) { + read } - ), - // --------------------------------------------------------------------------- - // StateReader - // --------------------------------------------------------------------------- - suite("StateReader")( - test("StateReader is covariant") { - val (_, result) = State(List(1, 2, 3)) { - val reader: StateReader[Iterable[Int]] = summon[State[List[Int]]] - reader.get(_.size) + val outer = read + (inner, outer) + } + assertEquals(inner, 42) + assertEquals(outer, ("hello", 42)) + } + + // --------------------------------------------------------------------------- + // StateReader + // --------------------------------------------------------------------------- + test("StateReader: StateReader is covariant") { + val (_, result) = State(List(1, 2, 3)) { + val reader: StateReader[Iterable[Int]] = summon[State[List[Int]]] + reader.get(_.size) + } + assertEquals(result, 3) + } + + test("StateReader: function requiring only StateReader works with State") { + def readOnly(using StateReader[Int]): Int = get + 1 + val (finalState, result) = State(10)(readOnly) + assertEquals(result, 11) + assertEquals(finalState, 10) + } + + // --------------------------------------------------------------------------- + // StateWriter + // --------------------------------------------------------------------------- + test("StateWriter: StateWriter is contravariant") { + def acceptsWriter(w: StateWriter[Int]): Unit = w.set(42) + val (finalState, _) = State(0) { + val state: State[Int] = summon[State[Int]] + acceptsWriter(state) + } + assertEquals(finalState, 42) + } + + test("StateWriter: function requiring only StateWriter works with State") { + def writeOnly(using StateWriter[Int]): Unit = set(99) + val (finalState, _) = State(0)(writeOnly) + assertEquals(finalState, 99) + } + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + test("State: get returns the current state") { + val (_, result) = State(10)(get) + assertEquals(result, 10) + } + + test("State: get projects the state") { + val (_, result) = State(List(1, 2, 3))(get(_.size)) + assertEquals(result, 3) + } + + test("State: set replaces the state") { + val (finalState, _) = State(0)(set(42)) + assertEquals(finalState, 42) + } + + test("State: update transforms the state") { + val (finalState, _) = State(10)(update(_ + 5)) + assertEquals(finalState, 15) + } + + test("State: modify transforms state and returns a value") { + val (finalState, result) = State(10)(modify(s => (s * 2, s + 1))) + assertEquals(result, 20) + assertEquals(finalState, 11) + } + + test("State: updateAndGet transforms and returns new state") { + val (_, result) = State(10)(updateAndGet(_ + 5)) + assertEquals(result, 15) + } + + test("State: getAndSet returns old state and replaces") { + val (finalState, old) = State(10)(getAndSet(42)) + assertEquals(old, 10) + assertEquals(finalState, 42) + } + + test("State: getAndUpdate returns old state and transforms") { + val (finalState, old) = State(10)(getAndUpdate(_ + 5)) + assertEquals(old, 10) + assertEquals(finalState, 15) + } + + test("State: local provides a modified state and restores it after the block") { + val (finalState, (inner, outer)) = + State(10) { + val inner = localState(_ + 5) { + get } - assertTrue(result == 3) - }, - test("function requiring only StateReader works with State") { - def readOnly(using StateReader[Int]): Int = get + 1 - val (finalState, result) = State(10)(readOnly) - assertTrue(result == 11, finalState == 10) + val outer = get + (inner, outer) } - ), - // --------------------------------------------------------------------------- - // StateWriter - // --------------------------------------------------------------------------- - suite("StateWriter")( - test("StateWriter is contravariant") { - def acceptsWriter(w: StateWriter[Int]): Unit = w.set(42) - val (finalState, _) = State(0) { - val state: State[Int] = summon[State[Int]] - acceptsWriter(state) // State[Int] <: StateWriter[Int] + assertEquals(inner, 15) + assertEquals(outer, 10) + assertEquals(finalState, 10) + } + + test("State: focus updates a sub-state") { + case class AppState(counter: Int, name: String) + val (finalState, inner) = + State(AppState(1, "initial")) { + focusState(_.counter)((s, v) => s.copy(counter = v)) { + update(_ + 5) + get } - assertTrue(finalState == 42) - }, - test("function requiring only StateWriter works with State") { - def writeOnly(using StateWriter[Int]): Unit = set(99) - val (finalState, _) = State(0)(writeOnly) - assertTrue(finalState == 99) } - ), - // --------------------------------------------------------------------------- - // State - // --------------------------------------------------------------------------- - suite("State")( - test("get returns the current state") { - val (_, result) = State(10)(get) - assertTrue(result == 10) - }, - test("get projects the state") { - val (_, result) = State(List(1, 2, 3))(get(_.size)) - assertTrue(result == 3) - }, - test("set replaces the state") { - val (finalState, _) = State(0)(set(42)) - assertTrue(finalState == 42) - }, - test("update transforms the state") { - val (finalState, _) = State(10)(update(_ + 5)) - assertTrue(finalState == 15) - }, - test("modify transforms state and returns a value") { - val (finalState, result) = State(10)(modify(s => (s * 2, s + 1))) - assertTrue(result == 20, finalState == 11) - }, - test("updateAndGet transforms and returns new state") { - val (_, result) = State(10)(updateAndGet(_ + 5)) - assertTrue(result == 15) - }, - test("getAndSet returns old state and replaces") { - val (finalState, old) = State(10)(getAndSet(42)) - assertTrue(old == 10, finalState == 42) - }, - test("getAndUpdate returns old state and transforms") { - val (finalState, old) = State(10)(getAndUpdate(_ + 5)) - assertTrue(old == 10, finalState == 15) - }, - test("local provides a modified state and restores it after the block") { - val (finalState, (inner, outer)) = - State(10) { - val inner = localState(_ + 5) { - get - } - val outer = get - (inner, outer) - } - assertTrue(inner == 15, outer == 10, finalState == 10) - }, - test("focus updates a sub-state") { - case class AppState(counter: Int, name: String) - val (finalState, inner) = - State(AppState(1, "initial")) { - focusState(_.counter)((s, v) => s.copy(counter = v)) { - update(_ + 5) - get - } - } - assertTrue(inner == 6, finalState == AppState(6, "initial")) + assertEquals(inner, 6) + assertEquals(finalState, AppState(6, "initial")) + } + + // --------------------------------------------------------------------------- + // Writer + // --------------------------------------------------------------------------- + test("Writer: write appends a single entry") { + val (logs, _) = Writer(write("hello")) + assertEquals(logs, Vector("hello")) + } + + test("Writer: writeAll appends multiple entries") { + val (logs, _) = Writer(writeAll(List("a", "b", "c"))) + assertEquals(logs, Vector("a", "b", "c")) + } + + test("Writer: clear removes all entries") { + val (logs, _) = Writer { + write("before") + clear + write("after") + } + assertEquals(logs, Vector("after")) + } + + test("Writer: capture returns the logs from a block and keeps them") { + val (logs, result) = Writer { + val (inner, value) = capture { + write("a") + write("b") + 42 } - ), - // --------------------------------------------------------------------------- - // Writer - // --------------------------------------------------------------------------- - suite("Writer")( - test("write appends a single entry") { - val (logs, _) = Writer(write("hello")) - assertTrue(logs == Vector("hello")) - }, - test("writeAll appends multiple entries") { - val (logs, _) = Writer(writeAll(List("a", "b", "c"))) - assertTrue(logs == Vector("a", "b", "c")) - }, - test("clear removes all entries") { - val (logs, _) = Writer { - write("before") - clear - write("after") - } - assertTrue(logs == Vector("after")) - }, - test("capture returns the logs from a block and keeps them") { - val (logs, result) = Writer { - val (inner, value) = capture { - write("a") - write("b") - 42 - } - write("c") - (inner, value) - } - assertTrue( - logs == Vector("a", "b", "c"), - result == (Vector("a", "b"), 42) - ) + write("c") + (inner, value) + } + assertEquals(logs, Vector("a", "b", "c")) + assertEquals(result, (Vector("a", "b"), 42)) + } + + // --------------------------------------------------------------------------- + // Abort + // --------------------------------------------------------------------------- + test("Abort: fail short-circuits with an error") { + val result: Either[String, Nothing] = Abort(Abort.fail("boom")) + assertEquals(result, Left("boom")) + } + + test("Abort: ensure passes when condition is true") { + val result = Abort(ensure(true, "fail")) + assertEquals(result, Right(())) + } + + test("Abort: ensure fails when condition is false") { + val result = Abort(ensure(false, "fail")) + assertEquals(result, Left("fail")) + } + + test("Abort: ensureNot passes when condition is false") { + val result = Abort(ensureNot(false, "fail")) + assertEquals(result, Right(())) + } + + test("Abort: ensureNot fails when condition is true") { + val result = Abort(ensureNot(true, "fail")) + assertEquals(result, Left("fail")) + } + + test("Abort: extractOption extracts Some") { + val result = Abort(extractOption(Some(42), "missing")) + assertEquals(result, Right(42)) + } + + test("Abort: extractOption fails on None") { + val result = Abort(extractOption(None, "missing")) + assertEquals(result, Left("missing")) + } + + test("Abort: extractEither extracts Right") { + val result = Abort(extractEither(Right(42))) + assertEquals(result, Right(42)) + } + + test("Abort: extractEither fails on Left") { + val result = Abort(extractEither(Left("err"))) + assertEquals(result, Left("err")) + } + + test("Abort: extractTry extracts Success") { + val result = Abort(extractTry(Success(42))) + assertEquals(result, Right(42)) + } + + test("Abort: extractTry fails on Failure") { + val ex = new RuntimeException("boom") + val result = Abort(extractTry(Failure(ex))) + assertEquals(result, Left(ex)) + } + + test("Abort: attempt catches exceptions") { + val result = Abort(attempt(throw new RuntimeException("boom"))) + assert(result.isLeft) + } + + test("Abort: attempt passes through normal values") { + val result = Abort(attempt(42)) + assertEquals(result, Right(42)) + } + + // --------------------------------------------------------------------------- + // orFail extensions + // --------------------------------------------------------------------------- + test("orFail: Some.orFail returns the value") { + val result = Abort(Some(42).orFail("missing")) + assertEquals(result, Right(42)) + } + + test("orFail: None.orFail fails with the error") { + val result = Abort(None.orFail("missing")) + assertEquals(result, Left("missing")) + } + + test("orFail: Right.orFail returns the value") { + val result = Abort(Right(42).orFail) + assertEquals(result, Right(42)) + } + + test("orFail: Left.orFail fails with the error") { + val result = Abort(Left("err").orFail) + assertEquals(result, Left("err")) + } + + test("orFail: Success.orFail returns the value") { + val result = Abort(Success(42).orFail) + assertEquals(result, Right(42)) + } + + test("orFail: Failure.orFail fails with the exception") { + val ex = new RuntimeException("boom") + val result = Abort(Failure(ex).orFail) + assertEquals(result, Left(ex)) + } + + // --------------------------------------------------------------------------- + // Recovery + // --------------------------------------------------------------------------- + test("Recovery: recover rolls back state and logs on error") { + val (logs, result) = + Logic.run(0, ()) { + set(10) + write("before") + recover { + set(99) + write("inside") + Abort.fail("oops") + }(_ => -1) } - ), - // --------------------------------------------------------------------------- - // Abort - // --------------------------------------------------------------------------- - suite("Abort")( - test("fail short-circuits with an error") { - val result = Abort(fail("boom")) - assertTrue(result == Left("boom")) - }, - test("ensure passes when condition is true") { - val result = Abort(ensure(true, "fail")) - assertTrue(result == Right(())) - }, - test("ensure fails when condition is false") { - val result = Abort(ensure(false, "fail")) - assertTrue(result == Left("fail")) - }, - test("ensureNot passes when condition is false") { - val result = Abort(ensureNot(false, "fail")) - assertTrue(result == Right(())) - }, - test("ensureNot fails when condition is true") { - val result = Abort(ensureNot(true, "fail")) - assertTrue(result == Left("fail")) - }, - test("extractOption extracts Some") { - val result = Abort(extractOption(Some(42), "missing")) - assertTrue(result == Right(42)) - }, - test("extractOption fails on None") { - val result = Abort(extractOption(None, "missing")) - assertTrue(result == Left("missing")) - }, - test("extractEither extracts Right") { - val result = Abort(extractEither(Right(42))) - assertTrue(result == Right(42)) - }, - test("extractEither fails on Left") { - val result = Abort(extractEither(Left("err"))) - assertTrue(result == Left("err")) - }, - test("extractTry extracts Success") { - val result = Abort(extractTry(Success(42))) - assertTrue(result == Right(42)) - }, - test("extractTry fails on Failure") { - val ex = new RuntimeException("boom") - val result = Abort(extractTry(Failure(ex))) - assertTrue(result == Left(ex)) - }, - test("attempt catches exceptions") { - val result = Abort(attempt(throw new RuntimeException("boom"))) - assertTrue(result.isLeft) - }, - test("attempt passes through normal values") { - val result = Abort(attempt(42)) - assertTrue(result == Right(42)) + assertEquals(result, Right((10, -1))) + assertEquals(logs, Vector("before")) + } + + test("Recovery: recover keeps state and logs when no error") { + val (logs, result) = + Logic.run(0, ()) { + recover { + set(42) + write("kept") + 100 + }(_ => -1) } - ), - // --------------------------------------------------------------------------- - // orFail extensions - // --------------------------------------------------------------------------- - suite("orFail")( - test("Some.orFail returns the value") { - val result = Abort(Some(42).orFail("missing")) - assertTrue(result == Right(42)) - }, - test("None.orFail fails with the error") { - val result = Abort(None.orFail("missing")) - assertTrue(result == Left("missing")) - }, - test("Right.orFail returns the value") { - val result = Abort(Right(42).orFail) - assertTrue(result == Right(42)) - }, - test("Left.orFail fails with the error") { - val result = Abort(Left("err").orFail) - assertTrue(result == Left("err")) - }, - test("Success.orFail returns the value") { - val result = Abort(Success(42).orFail) - assertTrue(result == Right(42)) - }, - test("Failure.orFail fails with the exception") { - val ex = new RuntimeException("boom") - val result = Abort(Failure(ex).orFail) - assertTrue(result == Left(ex)) + assertEquals(result, Right((42, 100))) + assertEquals(logs, Vector("kept")) + } + + test("Recovery: recoverKeepLog rolls back state but keeps logs") { + val (logs, result) = + Logic.run(0, ()) { + set(10) + write("before") + recoverKeepLog { + set(99) + write("inside") + Abort.fail("oops") + }(_ => -1) } - ), - // --------------------------------------------------------------------------- - // Recovery - // --------------------------------------------------------------------------- - suite("Recovery")( - test("recover rolls back state and logs on error") { - val (logs, result) = - Logic.run(0, ()) { - set(10) - write("before") - recover { - set(99) - write("inside") - fail("oops") - }(_ => -1) - } - assertTrue( - result == Right((10, -1)), - logs == Vector("before") - ) - }, - test("recover keeps state and logs when no error") { - val (logs, result) = - Logic.run(0, ()) { - recover { - set(42) - write("kept") - 100 - }(_ => -1) - } - assertTrue( - result == Right((42, 100)), - logs == Vector("kept") - ) - }, - test("recoverKeepLog rolls back state but keeps logs") { - val (logs, result) = - Logic.run(0, ()) { - set(10) - write("before") - recoverKeepLog { - set(99) - write("inside") - fail("oops") - }(_ => -1) - } - assertTrue( - result == Right((10, -1)), - logs == Vector("before", "inside") - ) - }, - test("recover can be used without State or Writer") { - val result = Abort { - recover { - fail("boom") - }(_ => "recovered") - } - assertTrue(result == Right("recovered")) - }, - test("recoverSome handles matching errors and rolls back state and logs") { - val (logs, result) = - Logic.run(0, ()) { - set(10) - write("before") - recoverSome { - set(99) - write("inside") - fail("handled") - } { case "handled" => -1 } - } - assertTrue( - result == Right((10, -1)), - logs == Vector("before") - ) - }, - test("recoverSome re-fails when the error is not handled") { - val (logs, result) = - Logic.run(0, ()) { - set(10) - write("before") - recoverSome { - set(99) - write("inside") - fail("unhandled") - } { case "handled" => -1 } - } - assertTrue( - result == Left("unhandled"), - logs == Vector("before") - ) - }, - test("recoverSomeKeepLog handles matching errors and keeps logs") { - val (logs, result) = - Logic.run(0, ()) { - set(10) - write("before") - recoverSomeKeepLog { - set(99) - write("inside") - fail("handled") - } { case "handled" => -1 } - } - assertTrue( - result == Right((10, -1)), - logs == Vector("before", "inside") - ) - }, - test("recoverSomeKeepLog re-fails when the error is not handled and keeps logs") { - val (logs, result) = - Logic.run(0, ()) { - set(10) - write("before") - recoverSomeKeepLog { - set(99) - write("inside") - fail("unhandled") - } { case "handled" => -1 } - } - assertTrue( - result == Left("unhandled"), - logs == Vector("before", "inside") - ) + assertEquals(result, Right((10, -1))) + assertEquals(logs, Vector("before", "inside")) + } + + test("Recovery: recover can be used without State or Writer") { + val result = Abort { + recover { + Abort.fail("boom") + }(_ => "recovered") + } + assertEquals(result, Right("recovered")) + } + + test("Recovery: recoverSome handles matching errors and rolls back state and logs") { + val (logs, result) = + Logic.run(0, ()) { + set(10) + write("before") + recoverSome { + set(99) + write("inside") + Abort.fail("handled") + } { case "handled" => -1 } } - ), - // --------------------------------------------------------------------------- - // Logic.run - // --------------------------------------------------------------------------- - suite("Logic.run")( - test("happy path threads state, logs, and reader") { - val (logs, result) = - Logic.run(AppState(Nil), Config(0.1)) { - addItem("widget") - addItem("gadget") - lookupItem(0) - } - assertTrue( - result == Right((AppState(List("widget", "gadget")), "widget")), - logs.size == 2, - logs.head.msg.contains("widget") - ) - }, - test("error short-circuits and preserves logs up to the failure") { - val (logs, result) = - Logic.run(AppState(Nil), Config(0.1)) { - addItem("widget") - addItem("") - } - assertTrue( - result == Left(AppError.Invalid), - logs.size == 1 - ) - }, - test("runInfallible works without Abort") { - val (logs, finalState, result) = - Logic.runInfallible(AppState(Nil), Config(0.1)) { - update(s => s.copy(items = s.items :+ "item")) - write(Log("done")) - get(_.items.size) - } - assertTrue( - result == 1, - finalState == AppState(List("item")), - logs.size == 1 - ) - }, - test("simulateWith re-raises errors from inner run") { - val result = Abort { - Logic.simulateWith(AppState(Nil), Config(0.1)) { - addItem("") - } - } - assertTrue(result == Left(AppError.Invalid)) - }, - test("simulateWith does not leak state or logs to outer context") { - val (logs, result) = - Logic.run(AppState(List("outer")), Config(0.1)) { - write(Log("outer-log")) - Logic.simulateWith(AppState(Nil), Config(0.2)) { - addItem("inner") - } - val outerState = get - outerState - } - assertTrue( - result == Right((AppState(List("outer")), AppState(List("outer")))), - logs == Vector(Log("outer-log")) - ) + assertEquals(result, Right((10, -1))) + assertEquals(logs, Vector("before")) + } + + test("Recovery: recoverSome re-fails when the error is not handled") { + val (logs, result) = + Logic.run(0, ()) { + set(10) + write("before") + recoverSome { + set(99) + write("inside") + Abort.fail("unhandled") + } { case "handled" => -1 } } - ), - // --------------------------------------------------------------------------- - // Composition - // --------------------------------------------------------------------------- - suite("Composition")( - test("functions compose naturally through context propagation") { - def step1(using State[Int], Writer[String]) = { - update(_ + 1) - write("step1") - } + assertEquals(result, Left("unhandled")) + assertEquals(logs, Vector("before")) + } + + test("Recovery: recoverSomeKeepLog handles matching errors and keeps logs") { + val (logs, result) = + Logic.run(0, ()) { + set(10) + write("before") + recoverSomeKeepLog { + set(99) + write("inside") + Abort.fail("handled") + } { case "handled" => -1 } + } + assertEquals(result, Right((10, -1))) + assertEquals(logs, Vector("before", "inside")) + } + + test("Recovery: recoverSomeKeepLog re-fails when the error is not handled and keeps logs") { + val (logs, result) = + Logic.run(0, ()) { + set(10) + write("before") + recoverSomeKeepLog { + set(99) + write("inside") + Abort.fail("unhandled") + } { case "handled" => -1 } + } + assertEquals(result, Left("unhandled")) + assertEquals(logs, Vector("before", "inside")) + } + + // --------------------------------------------------------------------------- + // Logic.run + // --------------------------------------------------------------------------- + test("Logic.run: happy path threads state, logs, and reader") { + val (logs, result) = + Logic.run(AppState(Nil), Config(0.1)) { + addItem("widget") + addItem("gadget") + lookupItem(0) + } + assertEquals(result, Right((AppState(List("widget", "gadget")), "widget"))) + assertEquals(logs.size, 2) + assert(logs.head.msg.contains("widget")) + } + + test("Logic.run: error short-circuits and preserves logs up to the failure") { + val (logs, result) = + Logic.run(AppState(Nil), Config(0.1)) { + addItem("widget") + addItem("") + } + assertEquals(result, Left(AppError.Invalid)) + assertEquals(logs.size, 1) + } + + test("Logic.run: runInfallible works without Abort") { + val (logs, finalState, result) = + Logic.runInfallible(AppState(Nil), Config(0.1)) { + update(s => s.copy(items = s.items :+ "item")) + write(Log("done")) + get(_.items.size) + } + assertEquals(result, 1) + assertEquals(finalState, AppState(List("item"))) + assertEquals(logs.size, 1) + } + + test("Logic.run: simulateWith re-raises errors from inner run") { + val result = Abort { + Logic.simulateWith(AppState(Nil), Config(0.1)) { + addItem("") + } + } + assertEquals(result, Left(AppError.Invalid)) + } - def step2(using State[Int], Writer[String], Abort[String]) = { - val v = get - ensure(v > 0, "must be positive") - write("step2") - v * 10 + test("Logic.run: simulateWith does not leak state or logs to outer context") { + val (logs, result) = + Logic.run(AppState(List("outer")), Config(0.1)) { + write(Log("outer-log")) + Logic.simulateWith(AppState(Nil), Config(0.2)) { + addItem("inner") } + val outerState = get + outerState + } + assertEquals(result, Right((AppState(List("outer")), AppState(List("outer"))))) + assertEquals(logs, Vector(Log("outer-log"))) + } + + // --------------------------------------------------------------------------- + // Composition + // --------------------------------------------------------------------------- + test("Composition: functions compose naturally through context propagation") { + def step1(using State[Int], Writer[String]) = { + update(_ + 1) + write("step1") + } + + def step2(using State[Int], Writer[String], Abort[String]) = { + val v = get + ensure(v > 0, "must be positive") + write("step2") + v * 10 + } - val (logs, result) = - Logic.run(0, ()) { - step1 - step2 - } - assertTrue( - result == Right((1, 10)), - logs == Vector("step1", "step2") - ) - }, - test("nested recover isolates inner failures") { - val (logs, result) = - Logic.run(0, ()) { - write("outer") - val inner = recover { - write("inner") - fail("inner-err") - }(e => s"recovered: $e") - write(inner) - 42 - } - assertTrue( - result == Right((0, 42)), - logs == Vector("outer", "recovered: inner-err") - ) + val (logs, result) = + Logic.run(0, ()) { + step1 + step2 } - ) - ) + assertEquals(result, Right((1, 10))) + assertEquals(logs, Vector("step1", "step2")) + } + + test("Composition: nested recover isolates inner failures") { + val (logs, result) = + Logic.run(0, ()) { + write("outer") + val inner = recover { + write("inner") + Abort.fail("inner-err") + }(e => s"recovered: $e") + write(inner) + 42 + } + assertEquals(result, Right((0, 42))) + assertEquals(logs, Vector("outer", "recovered: inner-err")) + } } From faff164c000703e58d043d30e73c4dcb5cb9dce2 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 19 Mar 2026 14:46:42 +0900 Subject: [PATCH 03/11] Use more specific classifiers --- core/src/main/scala/purelogic/Abort.scala | 2 +- core/src/main/scala/purelogic/State.scala | 2 +- core/src/main/scala/purelogic/Writer.scala | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/scala/purelogic/Abort.scala b/core/src/main/scala/purelogic/Abort.scala index a5d9a43..639017b 100644 --- a/core/src/main/scala/purelogic/Abort.scala +++ b/core/src/main/scala/purelogic/Abort.scala @@ -11,7 +11,7 @@ import scala.util.boundary.break * @tparam E * the type of error */ -trait Abort[-E] extends scala.caps.SharedCapability { +trait Abort[-E] extends scala.caps.ExclusiveCapability { /** * Aborts the computation with the given error. diff --git a/core/src/main/scala/purelogic/State.scala b/core/src/main/scala/purelogic/State.scala index 4ef62ca..a510b1a 100644 --- a/core/src/main/scala/purelogic/State.scala +++ b/core/src/main/scala/purelogic/State.scala @@ -29,7 +29,7 @@ trait StateReader[+S] extends scala.caps.SharedCapability { * @tparam S * the type of the state */ -trait StateWriter[-S] extends scala.caps.SharedCapability { +trait StateWriter[-S] extends scala.caps.ExclusiveCapability { /** * Replaces the state with a new value. diff --git a/core/src/main/scala/purelogic/Writer.scala b/core/src/main/scala/purelogic/Writer.scala index 17e76cb..7672740 100644 --- a/core/src/main/scala/purelogic/Writer.scala +++ b/core/src/main/scala/purelogic/Writer.scala @@ -11,7 +11,7 @@ import scala.collection.mutable.ArrayBuffer * @tparam W * the type of values to accumulate */ -trait Writer[-W] extends scala.caps.SharedCapability { +trait Writer[-W] extends scala.caps.ExclusiveCapability { /** * Appends a single value to the log. From 788f6dd8fc0444029c03932923f8ab2b5fe96a19 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 19 Mar 2026 16:26:25 +0900 Subject: [PATCH 04/11] Use 3.8.3-RC2 --- build.sbt | 4 ++-- core/src/main/scala/purelogic/Abort.scala | 5 ++--- core/src/main/scala/purelogic/Reader.scala | 5 ++--- core/src/main/scala/purelogic/State.scala | 21 +++++++++------------ core/src/main/scala/purelogic/Writer.scala | 13 ++++++------- 5 files changed, 21 insertions(+), 27 deletions(-) diff --git a/build.sbt b/build.sbt index 6385c45..5acd981 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3Version = "3.8.2" +val scala3Version = "3.8.3-RC2" // dependencies for tests and benchmarks val catsVersion = "2.13.0" @@ -56,7 +56,7 @@ lazy val benchmarks = project .enablePlugins(JmhPlugin) .settings(name := "purelogic-benchmarks") .settings(commonSettings) - .settings(scalaVersion := "3.8.2") + .settings(scalaVersion := "3.8.3-RC2") .settings( scalacOptions := scalacOptions.value .filterNot(_ == "-Ykind-projector") diff --git a/core/src/main/scala/purelogic/Abort.scala b/core/src/main/scala/purelogic/Abort.scala index 639017b..49e255e 100644 --- a/core/src/main/scala/purelogic/Abort.scala +++ b/core/src/main/scala/purelogic/Abort.scala @@ -59,9 +59,8 @@ object Abort { /** * Default `Abort[Nothing]` instance that can never fail. */ - @scala.annotation.nowarn - given Abort[Nothing] = new Abort[Nothing] { - def fail(e: Nothing): Nothing = e + given [E <: Nothing]: Abort[E] = new Abort[E] { + def fail(e: E): Nothing = e } /** diff --git a/core/src/main/scala/purelogic/Reader.scala b/core/src/main/scala/purelogic/Reader.scala index cfe4a89..001976c 100644 --- a/core/src/main/scala/purelogic/Reader.scala +++ b/core/src/main/scala/purelogic/Reader.scala @@ -46,9 +46,8 @@ object Reader { /** * Default `Reader[Unit]` instance that always returns `()`. */ - @scala.annotation.nowarn - given Reader[Unit] = new Reader[Unit] { - def read: Unit = () + given [R <: Unit]: Reader[R] = new Reader[R] { + def read: R = ().asInstanceOf[R] } /** diff --git a/core/src/main/scala/purelogic/State.scala b/core/src/main/scala/purelogic/State.scala index a510b1a..cebbfb0 100644 --- a/core/src/main/scala/purelogic/State.scala +++ b/core/src/main/scala/purelogic/State.scala @@ -87,13 +87,11 @@ trait State[S] extends StateReader[S] with StateWriter[S] { * Runs a block that operates on a subset of the state. Reads and writes to the focused state are reflected in the * outer state. */ - def focus[A, B](getFocus: S => A)(setFocus: (S, A) => S)(body: State[A] ?=> B): B = { - val outer = this - var cachedGet = getFocus - var cachedSet = setFocus - val state = new State[A] { - def get: A = cachedGet(outer.get) - def set(a: A): Unit = outer.set(cachedSet(outer.get, a)) + def focus[A, B](getFocus: S -> A)(setFocus: (S, A) -> S)(body: State[A] ?=> B): B = { + val outer = this + val state = new State[A] { + def get: A = getFocus(outer.get) + def set(a: A): Unit = outer.set(setFocus(outer.get, a)) } body(using state) } @@ -118,10 +116,9 @@ object State { /** * Default `State[Unit]` instance that does nothing. */ - @scala.annotation.nowarn - given State[Unit] = new State[Unit] { - def get: Unit = () - def set(s: Unit): Unit = () + given [S <: Unit]: State[S] = new State[S] { + def get: S = ().asInstanceOf[S] + def set(s: S): Unit = () } /** @@ -173,6 +170,6 @@ object State { * Runs a block that operates on a subset of the state. Reads and writes to the focused state are reflected in the * outer state. */ - inline def focusState[S, A, B](using s: State[S])(getFocus: S => A)(setFocus: (S, A) => S)(body: State[A] ?=> B): B = + inline def focusState[S, A, B](using s: State[S])(getFocus: S -> A)(setFocus: (S, A) -> S)(body: State[A] ?=> B): B = s.focus(getFocus)(setFocus)(body) } diff --git a/core/src/main/scala/purelogic/Writer.scala b/core/src/main/scala/purelogic/Writer.scala index 7672740..dd0b687 100644 --- a/core/src/main/scala/purelogic/Writer.scala +++ b/core/src/main/scala/purelogic/Writer.scala @@ -64,13 +64,12 @@ object Writer { /** * Default `Writer[Nothing]` instance that discards all writes. */ - @scala.annotation.nowarn - given Writer[Nothing] = new Writer[Nothing] { - def write(w: Nothing): Unit = () - def writeAll(elems: IterableOnce[Nothing]): Unit = () - def clear: Unit = () - private[purelogic] def snapshot: Int = 0 - private[purelogic] def rollback(to: Int): Unit = () + given [W <: Nothing]: Writer[W] = new Writer[W] { + def write(w: W): Unit = () + def writeAll(elems: IterableOnce[W]): Unit = () + def clear: Unit = () + private[purelogic] def snapshot: Int = 0 + private[purelogic] def rollback(to: Int): Unit = () } /** From 0e55e7cb5d690472c6a499357a9fa950a1dccd63 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 19 Mar 2026 17:56:05 +0900 Subject: [PATCH 05/11] Use pure functions --- core/src/main/scala/purelogic/Reader.scala | 12 +++++------ core/src/main/scala/purelogic/State.scala | 24 +++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/core/src/main/scala/purelogic/Reader.scala b/core/src/main/scala/purelogic/Reader.scala index 001976c..265ee9e 100644 --- a/core/src/main/scala/purelogic/Reader.scala +++ b/core/src/main/scala/purelogic/Reader.scala @@ -18,17 +18,17 @@ trait Reader[+R] extends scala.caps.SharedCapability { /** * Applies a projection function to the reader value and returns the result. */ - def read[A](f: R => A): A = f(read) + def read[A](f: R -> A): A = f(read) /** * Runs a block with a modified reader value. The original value is restored after the block completes. */ - def local[A, R1 >: R](f: R1 => R1)(body: Reader[R1] ?=> A): A = Reader(f(read))(body) + def local[A, R1 >: R](f: R1 -> R1)(body: Reader[R1] ?=> A): A = Reader(f(read))(body) /** * Runs a block with a narrowed reader derived by applying `f` to the current value. */ - def focus[A, B, R1 >: R](f: R1 => A)(body: Reader[A] ?=> B): B = Reader(f(read))(body) + def focus[A, B, R1 >: R](f: R1 -> A)(body: Reader[A] ?=> B): B = Reader(f(read))(body) } object Reader { @@ -58,15 +58,15 @@ object Reader { /** * Applies a projection function to the reader value and returns the result. */ - inline def read[R, A](using r: Reader[R])(f: R => A): A = r.read(f) + inline def read[R, A](using r: Reader[R])(f: R -> A): A = r.read(f) /** * Runs a block with a modified reader value. The original value is restored after the block completes. */ - inline def local[R, A](using r: Reader[R])(f: R => R)(body: Reader[R] ?=> A): A = r.local(f)(body) + inline def local[R, A](using r: Reader[R])(f: R -> R)(body: Reader[R] ?=> A): A = r.local(f)(body) /** * Runs a block with a narrowed reader derived by applying `f` to the current value. */ - inline def focus[R, A, B](using r: Reader[R])(f: R => A)(body: Reader[A] ?=> B): B = r.focus(f)(body) + inline def focus[R, A, B](using r: Reader[R])(f: R -> A)(body: Reader[A] ?=> B): B = r.focus(f)(body) } diff --git a/core/src/main/scala/purelogic/State.scala b/core/src/main/scala/purelogic/State.scala index cebbfb0..5f9ffdc 100644 --- a/core/src/main/scala/purelogic/State.scala +++ b/core/src/main/scala/purelogic/State.scala @@ -18,7 +18,7 @@ trait StateReader[+S] extends scala.caps.SharedCapability { /** * Applies a projection function to the state and returns the result. */ - def get[A](f: S => A): A = f(get) + def get[A](f: S -> A): A = f(get) } /** @@ -50,17 +50,17 @@ trait State[S] extends StateReader[S] with StateWriter[S] { /** * Modifies the state using a function. */ - def update(f: S => S): Unit = set(f(get)) + def update(f: S -> S): Unit = set(f(get)) /** * Updates the state using a function and returns the new state. */ - def updateAndGet(f: S => S): S = { update(f); get } + def updateAndGet(f: S -> S): S = { update(f); get } /** * Computes a return value and a new state from the current state in one step. */ - def modify[A](f: S => (A, S)): A = { val (a, newState) = f(get); set(newState); a } + def modify[A](f: S -> (A, S)): A = { val (a, newState) = f(get); set(newState); a } /** * Returns the old state and replaces it with the given value. @@ -70,12 +70,12 @@ trait State[S] extends StateReader[S] with StateWriter[S] { /** * Returns the old state and modifies it using a function. */ - def getAndUpdate(f: S => S): S = { val old = get; update(f); old } + def getAndUpdate(f: S -> S): S = { val old = get; update(f); old } /** * Runs a block with a modified state. The original state is restored after the block completes. */ - def local[A](f: S => S)(body: State[S] ?=> A): A = { + def local[A](f: S -> S)(body: State[S] ?=> A): A = { val state = this val previous = get set(f(previous)) @@ -129,7 +129,7 @@ object State { /** * Applies a projection function to the state and returns the result. */ - inline def get[S, A](using s: StateReader[S])(f: S => A): A = s.get(f) + inline def get[S, A](using s: StateReader[S])(f: S -> A): A = s.get(f) /** * Replaces the state with a new value. @@ -139,17 +139,17 @@ object State { /** * Modifies the state using a function. */ - inline def update[S](using s: State[S])(f: S => S): Unit = s.update(f) + inline def update[S](using s: State[S])(f: S -> S): Unit = s.update(f) /** * Updates the state using a function and returns the new state. */ - inline def updateAndGet[S](using s: State[S])(f: S => S): S = s.updateAndGet(f) + inline def updateAndGet[S](using s: State[S])(f: S -> S): S = s.updateAndGet(f) /** * Computes a return value and a new state from the current state in one step. */ - inline def modify[S, A](using s: State[S])(f: S => (A, S)): A = s.modify(f) + inline def modify[S, A](using s: State[S])(f: S -> (A, S)): A = s.modify(f) /** * Returns the old state and replaces it with the given value. @@ -159,12 +159,12 @@ object State { /** * Returns the old state and modifies it using a function. */ - inline def getAndUpdate[S](using s: State[S])(f: S => S): S = s.getAndUpdate(f) + inline def getAndUpdate[S](using s: State[S])(f: S -> S): S = s.getAndUpdate(f) /** * Runs a block with a modified state. The original state is restored after the block completes. */ - inline def localState[S, A](using s: State[S])(f: S => S)(body: State[S] ?=> A): A = s.local(f)(body) + inline def localState[S, A](using s: State[S])(f: S -> S)(body: State[S] ?=> A): A = s.local(f)(body) /** * Runs a block that operates on a subset of the state. Reads and writes to the focused state are reflected in the From 460912d0709caf7a81d1f52b894301daa5b4f14a Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Sun, 24 May 2026 18:36:06 +0900 Subject: [PATCH 06/11] fmt --- .scalafmt.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 91fb04c..0009030 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,6 +1,6 @@ version = "3.11.1" -runner.dialect = scala3 +runner.dialect = scala3future runner.dialectOverride.allowSignificantIndentation = false maxColumn = 150 From eca9640f5aa5555e1e0ca407996e1cd53ef6acb7 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 25 Jun 2026 17:12:08 +0900 Subject: [PATCH 07/11] Upgrade Scala --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 828e92f..3c358f0 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3Version = "3.8.3" +val scala3Version = "3.8.4" // dependencies for tests and benchmarks val catsVersion = "2.13.0" From 4e26fa40ff259a0b21199696a7635532ee0199dc Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 25 Jun 2026 17:29:25 +0900 Subject: [PATCH 08/11] Fix --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 3c358f0..ef3f6df 100644 --- a/build.sbt +++ b/build.sbt @@ -81,7 +81,7 @@ lazy val commonSettings = Def.settings( "-deprecation", "-Werror", "-no-indent", - "-Wunused:imports,params,privates,implicits,explicits,nowarn", + "-Wunused:imports,params,privates,implicits,explicits", "-Wvalue-discard", "-Xkind-projector", "-language:experimental.captureChecking" From 76aa732c247e3cef397c8d0ac8f8c22ff7f19e73 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Fri, 4 Sep 2026 09:26:38 +0900 Subject: [PATCH 09/11] Upgrade Scala to 3.9.0 --- .scalafmt.conf | 6 ++++-- benchmarks/src/main/scala/purelogic/bench/RwstBench.scala | 2 +- build.sbt | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 989377f..5b12f71 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,6 +1,8 @@ version = "3.11.5" -runner.dialect = scala3future +runner.dialect = scala3 +runner.dialectOverride.allowCaptureChecking = true +runner.dialectOverride.allowPureFunctions = true runner.dialectOverride.allowSignificantIndentation = false maxColumn = 150 @@ -27,4 +29,4 @@ rewrite.imports.groups = [ ["purelogic\\..*"], ] rewrite.imports.sort = scalastyle -rewrite.redundantBraces.stringInterpolation = true \ No newline at end of file +rewrite.redundantBraces.stringInterpolation = true diff --git a/benchmarks/src/main/scala/purelogic/bench/RwstBench.scala b/benchmarks/src/main/scala/purelogic/bench/RwstBench.scala index 048df0b..4e3fa5e 100644 --- a/benchmarks/src/main/scala/purelogic/bench/RwstBench.scala +++ b/benchmarks/src/main/scala/purelogic/bench/RwstBench.scala @@ -180,7 +180,7 @@ class RwstBench { .handleWith(TL.W.handlers.local) .handleWith(TL.S.handlers.local(initialState)) .handleWith(TL.R.handlers.default(env)) - .handleWith(TL.E.handlers.first) + .handleWith(TL.E.handlers.firstSeq) .run result match { case Right((_, state)) => state diff --git a/build.sbt b/build.sbt index bb6cd02..47c4330 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3Version = "3.8.4" +val scala3Version = "3.9.0" val scalaVersions = Seq(scala3Version) // dependencies for tests and benchmarks @@ -59,7 +59,7 @@ lazy val benchmarks = project .enablePlugins(JmhPlugin) .settings(name := "purelogic-benchmarks") .settings(commonSettings) - .settings(scalaVersion := "3.8.4") + .settings(scalaVersion := "3.9.0") .settings( scalacOptions := scalacOptions.value .filterNot(_ == "-Ykind-projector") From 47288ca3de50625bd45639388c5440216f81918d Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Fri, 4 Sep 2026 10:17:06 +0900 Subject: [PATCH 10/11] Improve capture checking --- core/src/main/scala/purelogic/Abort.scala | 22 ++++-- .../main/scala/purelogic/EventSourcing.scala | 4 +- core/src/main/scala/purelogic/Reader.scala | 12 ++-- core/src/main/scala/purelogic/State.scala | 26 +++---- core/src/main/scala/purelogic/Writer.scala | 16 ++--- .../test/scala/purelogic/PureLogicSpec.scala | 72 +++++++++++++++++++ docs/.vitepress/config.mts | 3 +- docs/abort.md | 2 +- docs/capture-checking.md | 60 ++++++++++++++++ docs/event-sourcing.md | 8 ++- docs/faq.md | 6 -- docs/getting-started.md | 4 +- 12 files changed, 187 insertions(+), 48 deletions(-) create mode 100644 docs/capture-checking.md diff --git a/core/src/main/scala/purelogic/Abort.scala b/core/src/main/scala/purelogic/Abort.scala index b9ef0ee..29e9649 100644 --- a/core/src/main/scala/purelogic/Abort.scala +++ b/core/src/main/scala/purelogic/Abort.scala @@ -12,7 +12,7 @@ import scala.util.control.NonFatal * @tparam E * the type of error */ -trait Abort[-E] extends scala.caps.ExclusiveCapability { +trait Abort[-E] extends scala.caps.Control { /** * Aborts the computation with the given error. @@ -122,8 +122,9 @@ object Abort { /** * Catches only errors matched by a partial function. When an error is matched, rolls back state and writes to the - * point before the failed block. Unmatched errors are re-raised without rollback, leaving state and writes intact - * for the outer scope to observe or handle. + * point before the failed block. The handler, including any pattern guards, is evaluated once, on the restored state + * and writes. Unmatched errors are re-raised without rollback, leaving state and writes intact for the outer scope to + * observe or handle. */ def recoverSome[W, S, E, A](f: Abort[E] ?=> A)(handler: PartialFunction[E, A]^)(using Writer[W], State[S], Abort[E]): A = doRecoverSome(resetLog = true)(f)(handler) @@ -163,9 +164,7 @@ object Abort { val result = boundary[Either[E, A]] { val a = new Abort[E] { - def fail(e: E): Nothing = - if (handler.isDefinedAt(e)) break(Left(e)) - else abort.fail(e) + def fail(e: E): Nothing = break(Left(e)) } Right(f(using a)) } @@ -173,9 +172,18 @@ object Abort { result match { case Right(value) => value case Left(e) => + val failedState = s.get + val failedLog = w.snapshot s.set(stateSnapshot) if (resetLog) w.rollback(logSnapshot) - handler(e) + handler.applyOrElse( + e, + (unmatched: E) => { + s.set(failedState) + w.rollback(failedLog) + abort.fail(unmatched) + } + ) } } } diff --git a/core/src/main/scala/purelogic/EventSourcing.scala b/core/src/main/scala/purelogic/EventSourcing.scala index 759358b..3cc075a 100644 --- a/core/src/main/scala/purelogic/EventSourcing.scala +++ b/core/src/main/scala/purelogic/EventSourcing.scala @@ -30,7 +30,7 @@ trait EventSourcing[Ev, S, Err] extends scala.caps.ExclusiveCapability { * Replays a sequence of events by applying their transitions to rebuild the state. Events are not recorded in the * writer, making this suitable for rehydrating state from a persisted event log. */ - def replayEvents(events: Iterable[Ev])(using transition: Transition[Ev, S, Err], abort: Abort[Err]): Unit = + def replayEvents(events: Iterable[Ev]^)(using transition: Transition[Ev, S, Err], abort: Abort[Err]): Unit = events.foreach(transition.run) } @@ -80,7 +80,7 @@ object EventSourcing { * Replays a sequence of events by applying their transitions to rebuild the state. Events are not recorded in the * writer, making this suitable for rehydrating state from a persisted event log. */ - inline def replayEvents[Ev, S, Err](events: Iterable[Ev])(using transition: Transition[Ev, S, Err])( + inline def replayEvents[Ev, S, Err](events: Iterable[Ev]^)(using transition: Transition[Ev, S, Err])( using eventSourcing: EventSourcing[Ev, S, Err], abort: Abort[Err] ): Unit = diff --git a/core/src/main/scala/purelogic/Reader.scala b/core/src/main/scala/purelogic/Reader.scala index 265ee9e..001976c 100644 --- a/core/src/main/scala/purelogic/Reader.scala +++ b/core/src/main/scala/purelogic/Reader.scala @@ -18,17 +18,17 @@ trait Reader[+R] extends scala.caps.SharedCapability { /** * Applies a projection function to the reader value and returns the result. */ - def read[A](f: R -> A): A = f(read) + def read[A](f: R => A): A = f(read) /** * Runs a block with a modified reader value. The original value is restored after the block completes. */ - def local[A, R1 >: R](f: R1 -> R1)(body: Reader[R1] ?=> A): A = Reader(f(read))(body) + def local[A, R1 >: R](f: R1 => R1)(body: Reader[R1] ?=> A): A = Reader(f(read))(body) /** * Runs a block with a narrowed reader derived by applying `f` to the current value. */ - def focus[A, B, R1 >: R](f: R1 -> A)(body: Reader[A] ?=> B): B = Reader(f(read))(body) + def focus[A, B, R1 >: R](f: R1 => A)(body: Reader[A] ?=> B): B = Reader(f(read))(body) } object Reader { @@ -58,15 +58,15 @@ object Reader { /** * Applies a projection function to the reader value and returns the result. */ - inline def read[R, A](using r: Reader[R])(f: R -> A): A = r.read(f) + inline def read[R, A](using r: Reader[R])(f: R => A): A = r.read(f) /** * Runs a block with a modified reader value. The original value is restored after the block completes. */ - inline def local[R, A](using r: Reader[R])(f: R -> R)(body: Reader[R] ?=> A): A = r.local(f)(body) + inline def local[R, A](using r: Reader[R])(f: R => R)(body: Reader[R] ?=> A): A = r.local(f)(body) /** * Runs a block with a narrowed reader derived by applying `f` to the current value. */ - inline def focus[R, A, B](using r: Reader[R])(f: R -> A)(body: Reader[A] ?=> B): B = r.focus(f)(body) + inline def focus[R, A, B](using r: Reader[R])(f: R => A)(body: Reader[A] ?=> B): B = r.focus(f)(body) } diff --git a/core/src/main/scala/purelogic/State.scala b/core/src/main/scala/purelogic/State.scala index 5f9ffdc..da1c294 100644 --- a/core/src/main/scala/purelogic/State.scala +++ b/core/src/main/scala/purelogic/State.scala @@ -8,7 +8,7 @@ package purelogic * @tparam S * the type of the state */ -trait StateReader[+S] extends scala.caps.SharedCapability { +trait StateReader[+S] extends scala.caps.ExclusiveCapability { /** * Returns the current state. @@ -18,7 +18,7 @@ trait StateReader[+S] extends scala.caps.SharedCapability { /** * Applies a projection function to the state and returns the result. */ - def get[A](f: S -> A): A = f(get) + def get[A](f: S => A): A = f(get) } /** @@ -50,17 +50,17 @@ trait State[S] extends StateReader[S] with StateWriter[S] { /** * Modifies the state using a function. */ - def update(f: S -> S): Unit = set(f(get)) + def update(f: S => S): Unit = set(f(get)) /** * Updates the state using a function and returns the new state. */ - def updateAndGet(f: S -> S): S = { update(f); get } + def updateAndGet(f: S => S): S = { update(f); get } /** * Computes a return value and a new state from the current state in one step. */ - def modify[A](f: S -> (A, S)): A = { val (a, newState) = f(get); set(newState); a } + def modify[A](f: S => (A, S)): A = { val (a, newState) = f(get); set(newState); a } /** * Returns the old state and replaces it with the given value. @@ -70,12 +70,12 @@ trait State[S] extends StateReader[S] with StateWriter[S] { /** * Returns the old state and modifies it using a function. */ - def getAndUpdate(f: S -> S): S = { val old = get; update(f); old } + def getAndUpdate(f: S => S): S = { val old = get; update(f); old } /** * Runs a block with a modified state. The original state is restored after the block completes. */ - def local[A](f: S -> S)(body: State[S] ?=> A): A = { + def local[A](f: S => S)(body: State[S] ?=> A): A = { val state = this val previous = get set(f(previous)) @@ -129,7 +129,7 @@ object State { /** * Applies a projection function to the state and returns the result. */ - inline def get[S, A](using s: StateReader[S])(f: S -> A): A = s.get(f) + inline def get[S, A](using s: StateReader[S])(f: S => A): A = s.get(f) /** * Replaces the state with a new value. @@ -139,17 +139,17 @@ object State { /** * Modifies the state using a function. */ - inline def update[S](using s: State[S])(f: S -> S): Unit = s.update(f) + inline def update[S](using s: State[S])(f: S => S): Unit = s.update(f) /** * Updates the state using a function and returns the new state. */ - inline def updateAndGet[S](using s: State[S])(f: S -> S): S = s.updateAndGet(f) + inline def updateAndGet[S](using s: State[S])(f: S => S): S = s.updateAndGet(f) /** * Computes a return value and a new state from the current state in one step. */ - inline def modify[S, A](using s: State[S])(f: S -> (A, S)): A = s.modify(f) + inline def modify[S, A](using s: State[S])(f: S => (A, S)): A = s.modify(f) /** * Returns the old state and replaces it with the given value. @@ -159,12 +159,12 @@ object State { /** * Returns the old state and modifies it using a function. */ - inline def getAndUpdate[S](using s: State[S])(f: S -> S): S = s.getAndUpdate(f) + inline def getAndUpdate[S](using s: State[S])(f: S => S): S = s.getAndUpdate(f) /** * Runs a block with a modified state. The original state is restored after the block completes. */ - inline def localState[S, A](using s: State[S])(f: S -> S)(body: State[S] ?=> A): A = s.local(f)(body) + inline def localState[S, A](using s: State[S])(f: S => S)(body: State[S] ?=> A): A = s.local(f)(body) /** * Runs a block that operates on a subset of the state. Reads and writes to the focused state are reflected in the diff --git a/core/src/main/scala/purelogic/Writer.scala b/core/src/main/scala/purelogic/Writer.scala index 34b3402..58351d4 100644 --- a/core/src/main/scala/purelogic/Writer.scala +++ b/core/src/main/scala/purelogic/Writer.scala @@ -21,7 +21,7 @@ trait Writer[-W] extends scala.caps.ExclusiveCapability { /** * Appends multiple values to the log at once. */ - def writeAll(elems: IterableOnce[W]): Unit + def writeAll(elems: IterableOnce[W]^): Unit /** * Clears all accumulated values. @@ -66,9 +66,9 @@ object Writer { def apply[W, A](body: Writer[W] ?=> A): (Vector[W], A) = { val buffer = ArrayBuffer[W]() val writer = new Writer[W] { - def write(w: W): Unit = buffer.addOne(w) - def writeAll(elems: IterableOnce[W]): Unit = buffer.addAll(elems) - def clear: Unit = buffer.clear() + def write(w: W): Unit = buffer.addOne(w) + def writeAll(elems: IterableOnce[W]^): Unit = buffer.addAll(elems) + def clear: Unit = buffer.clear() type Snapshot = Vector[W] def snapshot: Vector[W] = buffer.toVector def rollback(to: Vector[W]): Unit = { @@ -84,9 +84,9 @@ object Writer { * Default `Writer[Nothing]` instance that discards all writes. */ given [W <: Nothing]: Writer[W] = new Writer[W] { - def write(w: W): Unit = () - def writeAll(elems: IterableOnce[W]): Unit = () - def clear: Unit = () + def write(w: W): Unit = () + def writeAll(elems: IterableOnce[W]^): Unit = () + def clear: Unit = () type Snapshot = Unit def snapshot: Unit = () def rollback(to: Unit): Unit = () @@ -100,7 +100,7 @@ object Writer { /** * Appends multiple values to the log at once. */ - inline def writeAll[W](elems: IterableOnce[W])(using writer: Writer[W]): Unit = writer.writeAll(elems) + inline def writeAll[W](elems: IterableOnce[W]^)(using writer: Writer[W]): Unit = writer.writeAll(elems) /** * Clears all accumulated values. diff --git a/core/src/test/scala/purelogic/PureLogicSpec.scala b/core/src/test/scala/purelogic/PureLogicSpec.scala index ed980bc..2731a93 100644 --- a/core/src/test/scala/purelogic/PureLogicSpec.scala +++ b/core/src/test/scala/purelogic/PureLogicSpec.scala @@ -467,6 +467,78 @@ class PureLogicSpec extends munit.FunSuite { assertEquals(logs, Vector("before", "inside")) } + test("Recovery: recoverSome guards are evaluated once, on the restored state") { + val (logs, result) = + Logic.run(0, ()) { + recoverSome[String, Int, String, Int] { + try fail("boom") + finally set(1) + } { + case "boom" if get[Int] == 0 => 42 + } + } + assertEquals(result, Right((0, 42))) + assertEquals(logs, Vector.empty) + } + + test("Recovery: recoverSome guard that does not match leaves state and writes as they were at the failure") { + val (logs, result) = + Logic.run(0, ()) { + val inner = Abort[String, Int] { + recoverSome[String, Int, String, Int] { + set(5) + write("inside") + fail("boom") + } { + case "boom" if get[Int] == 99 => 42 + } + } + (inner, get) + } + assertEquals(result, Right((5, (Left("boom"), 5)))) + assertEquals(logs, Vector("inside")) + } + + test("Recovery: recoverSomeKeepLog discards state and writes made by a guard that does not match") { + val (logs, result) = + Logic.run(0, ()) { + val inner = Abort[String, Int] { + recoverSomeKeepLog[String, Int, String, Int] { + set(5) + write("body") + fail("boom") + } { + case "boom" if { set(99); write("guard"); false } => 42 + } + } + (inner, get) + } + assertEquals(result, Right((5, (Left("boom"), 5)))) + assertEquals(logs, Vector("body")) + } + + test("Capture checking: lambdas passed to update, modify and read may use other capabilities") { + val (logs, result) = + Logic.run(1, 10) { + update(_ + read) + val doubled = modify[Int, Int](s => (s * 2, s)) + write(read(_ + doubled)) + get + } + assertEquals(result, Right((11, 11))) + assertEquals(logs, Vector(32)) + } + + test("Capture checking: writeAll accepts an iterator that captures a capability") { + val (logs, result) = + Logic.run((), 5) { + writeAll(Iterator.tabulate(2)(i => i + read)) + writeAll(List(0, 1).iterator.map(_ * read)) + } + assertEquals(result, Right(((), ()))) + assertEquals(logs, Vector(5, 6, 0, 5)) + } + test("Writer: rollback restores the log to an earlier snapshot, keeping pre-snapshot writes") { val (logs, _) = Logic.run((), ()) { diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 60f0a07..59f830f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -33,7 +33,8 @@ export default defineConfig({ { text: 'Abort', link: '/abort' }, { text: 'Event Sourcing', link: '/event-sourcing' }, ] - } + }, + { text: 'Capture checking', link: '/capture-checking' } ] }, { diff --git a/docs/abort.md b/docs/abort.md index ec0990b..b4d8aa1 100644 --- a/docs/abort.md +++ b/docs/abort.md @@ -100,7 +100,7 @@ Like `recover`, but **keeps the writes** from the failed block instead of rollin ### `recoverSome` -Catches only errors matched by a **partial function**. Matched errors trigger the usual rollback; unmatched errors are re-raised without rollback, leaving state and writes intact for the outer scope to observe or handle: +Catches only errors matched by a **partial function**. Matched errors trigger the usual rollback. The handler, including any pattern guards, runs once, on the restored state and writes. Unmatched errors are re-raised without rollback, leaving state and writes intact for the outer scope to observe or handle: ```scala val result = Abort.recoverSome { diff --git a/docs/capture-checking.md b/docs/capture-checking.md new file mode 100644 index 0000000..682104a --- /dev/null +++ b/docs/capture-checking.md @@ -0,0 +1,60 @@ +# Capture checking + +PureLogic supports Scala's experimental capture checking. When you enable it in your application, the compiler checks that a capability cannot escape the scope that provides it. For example, code cannot return a closure that keeps modifying a `State` after `State.apply` or `Logic.run` has finished. + +PureLogic itself is compiled with capture checking enabled. You can use the library without enabling it in your application; the additional compiler checks are optional. + +## Enabling capture checking + +Add the following option to your application's `build.sbt`: + +```scala +scalacOptions += "-language:experimental.captureChecking" +``` + +The examples below were verified with Scala 3.9.0. Capture checking is still an experimental Scala feature, so its syntax and diagnostics can change between compiler versions. See Scala's [capture checking documentation](https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/index.html) for more detail. + +## Keeping capabilities in scope + +A `State` capability gives code access to the state of one computation. Returning a function that captures it would let callers keep changing that state after the computation has returned: + +```scala +import purelogic.State + +val (finalState, incrementLater) = State(0) { + val state = summon[State[Int]] + () => { + state.update(_ + 1) + state.get + } +} +``` + +Without capture checking, this compiles: `finalState` is `0`, but calls to `incrementLater()` return `1`, then `2`. With capture checking enabled, the compiler rejects the closure because the captured capability outlives its scope. + +Returning an ordinary value is allowed. A function can also capture a value read from the state, without retaining the capability itself: + +```scala +val (finalState, readLater) = State(0) { + val value = summon[State[Int]].get + () => value +} + +readLater() // 0 +``` + +The same scope checks apply to `Reader`, `Writer`, `Abort`, and `EventSourcing`. Nested computations can still use capabilities supplied by an enclosing scope. + +## Event-sourcing transitions + +An [event-sourcing transition](event-sourcing.md#defining-a-transition) returns `(State[S], Abort[Err]) ?-> Unit`. The `?->` arrow denotes a pure context function: it can use the State and Abort parameters it receives, but cannot capture additional tracked capabilities such as an enclosing Reader or Writer. + +Leave the result type of `Transition.run` inferred, as in the event-sourcing examples. If you write the type explicitly with capture checking enabled, use `?->` rather than `?=>`. + +This restriction is not a proof of determinism. Calls to clocks, random-number generators, and other untracked effects still compile. Transitions must avoid those effects for replay to reproduce the same state. + +## Separation checking + +Scala's separate `-language:experimental.separationChecking` option is not supported by PureLogic yet. State, Writer, and EventSourcing are declared exclusive, but nested operations such as `recover` pass the same capability both as a context parameter and inside the body. Separation checking rejects this overlap. + +Capture checking alone does not make State or Writer safe to share across threads. Keep each computation's mutable capabilities within that computation, as described in the [thread-safety FAQ](faq.md#is-purelogic-thread-safe). diff --git a/docs/event-sourcing.md b/docs/event-sourcing.md index 0fe5b69..d58c730 100644 --- a/docs/event-sourcing.md +++ b/docs/event-sourcing.md @@ -23,7 +23,7 @@ enum AccountEvent { } given EventSourcing.Transition[AccountEvent, Account, String] with { - def run(ev: AccountEvent): (State[Account], Abort[String]) ?=> Unit = + def run(ev: AccountEvent) = ev match { case AccountEvent.Deposited(amount) => update(a => Account(a.balance + amount)) @@ -34,6 +34,8 @@ given EventSourcing.Transition[AccountEvent, Account, String] with { } ``` +For reliable replay, base each transition on the event and current state. Read clocks or generate random values before recording an event, and include any values the transition needs in that event. Optional compiler checks for transition dependencies are explained in [Capture checking](capture-checking.md#event-sourcing-transitions). + ## Writing events `writeEvent` applies the transition to update the state, then records the event. If the transition fails via `Abort`, the event is **not** recorded: @@ -118,12 +120,12 @@ object AccountEvent { } given EventSourcing.Transition[AccountEvent.Deposited, Account, String] with { - def run(ev: AccountEvent.Deposited): (State[Account], Abort[String]) ?=> Unit = + def run(ev: AccountEvent.Deposited) = update(a => Account(a.balance + ev.amount)) } given EventSourcing.Transition[AccountEvent.Withdrawn, Account, String] with { - def run(ev: AccountEvent.Withdrawn): (State[Account], Abort[String]) ?=> Unit = + def run(ev: AccountEvent.Withdrawn) = ensure(get.balance >= ev.amount, "Insufficient balance") update(a => Account(a.balance - ev.amount)) } diff --git a/docs/faq.md b/docs/faq.md index 5f82b67..48022cc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,9 +29,3 @@ It depends on the **nesting order**. With the default `Logic.run` order, `Writer ## Is `State` really pure? Yes. While `State` uses **mutable variables internally**, this mutation is completely **scoped** within the `State.apply` or `Logic.run` call. From the outside, the function is pure: same inputs always produce same outputs, with no observable side effects. This is the same approach used by Haskell's `ST` monad. - -## What about capture checking? - -PureLogic's design based on **context functions and capabilities** is a natural fit for Scala's upcoming **capture checking** feature. Capture checking will allow the compiler to verify that capabilities don't escape their scope — for example, ensuring that a `State` reference isn't leaked outside of `Logic.run`. - -PureLogic is **ready** for capture checking and it will be added when capture checking becomes more stable in future versions of Scala. diff --git a/docs/getting-started.md b/docs/getting-started.md index 8321953..f818f88 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -146,10 +146,12 @@ For more details about each of the capabilities, check out the [Capabilities](ca ## Next steps +Read [Capture checking](capture-checking.md) to enable optional compiler checks that keep capabilities within their scope. + Make sure to read the [Why PureLogic?](why-purelogic.md) page to understand when this library is useful and see a comparison with traditional monadic approaches. Check out the [examples](https://github.com/ghostdogpr/purelogic/tree/master/examples/src/main/scala/examples) in GitHub to see how to use the library in practice. If you want to know more about direct-style effects using capabilities, I recommend these two blog posts: - [Effects as Capabilities](https://nrinaudo.github.io/articles/capabilities.html) by Nicolas Rinaudo -- [The Effect Pattern and Effect Systems in Scala](https://rockthejvm.com/articles/the-effect-pattern) by Riccardo Cardin \ No newline at end of file +- [The Effect Pattern and Effect Systems in Scala](https://rockthejvm.com/articles/the-effect-pattern) by Riccardo Cardin From 33a6199687b52e2ba71f76ece7df821b66ea0202 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Fri, 4 Sep 2026 10:25:46 +0900 Subject: [PATCH 11/11] Polish docs --- docs/.vitepress/config.mts | 2 +- docs/capture-checking.md | 8 ++++---- docs/getting-started.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 59f830f..a4c70f7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -34,7 +34,7 @@ export default defineConfig({ { text: 'Event Sourcing', link: '/event-sourcing' }, ] }, - { text: 'Capture checking', link: '/capture-checking' } + { text: 'Capture Checking', link: '/capture-checking' } ] }, { diff --git a/docs/capture-checking.md b/docs/capture-checking.md index 682104a..9602d82 100644 --- a/docs/capture-checking.md +++ b/docs/capture-checking.md @@ -12,7 +12,7 @@ Add the following option to your application's `build.sbt`: scalacOptions += "-language:experimental.captureChecking" ``` -The examples below were verified with Scala 3.9.0. Capture checking is still an experimental Scala feature, so its syntax and diagnostics can change between compiler versions. See Scala's [capture checking documentation](https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/index.html) for more detail. +Capture checking is still an experimental Scala feature, so its syntax and diagnostics can change between compiler versions. See Scala's [capture checking documentation](https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/index.html) for more detail. ## Keeping capabilities in scope @@ -47,7 +47,7 @@ The same scope checks apply to `Reader`, `Writer`, `Abort`, and `EventSourcing`. ## Event-sourcing transitions -An [event-sourcing transition](event-sourcing.md#defining-a-transition) returns `(State[S], Abort[Err]) ?-> Unit`. The `?->` arrow denotes a pure context function: it can use the State and Abort parameters it receives, but cannot capture additional tracked capabilities such as an enclosing Reader or Writer. +An [event-sourcing transition](event-sourcing.md#defining-a-transition) returns `(State[S], Abort[Err]) ?-> Unit`. The `?->` arrow denotes a pure context function: it can use the `State` and `Abort` parameters it receives, but cannot capture additional tracked capabilities such as an enclosing `Reader` or `Writer`. Leave the result type of `Transition.run` inferred, as in the event-sourcing examples. If you write the type explicitly with capture checking enabled, use `?->` rather than `?=>`. @@ -55,6 +55,6 @@ This restriction is not a proof of determinism. Calls to clocks, random-number g ## Separation checking -Scala's separate `-language:experimental.separationChecking` option is not supported by PureLogic yet. State, Writer, and EventSourcing are declared exclusive, but nested operations such as `recover` pass the same capability both as a context parameter and inside the body. Separation checking rejects this overlap. +Scala's separate `-language:experimental.separationChecking` option is not supported by PureLogic yet. `State`, `Writer`, and `EventSourcing` are declared exclusive, but nested operations such as `recover` pass the same capability both as a context parameter and inside the body. Separation checking rejects this overlap. -Capture checking alone does not make State or Writer safe to share across threads. Keep each computation's mutable capabilities within that computation, as described in the [thread-safety FAQ](faq.md#is-purelogic-thread-safe). +Capture checking alone does not make `State` or `Writer` safe to share across threads. Keep each computation's mutable capabilities within that computation, as described in the [thread-safety FAQ](faq.md#is-purelogic-thread-safe). diff --git a/docs/getting-started.md b/docs/getting-started.md index f818f88..dab32ad 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -146,8 +146,6 @@ For more details about each of the capabilities, check out the [Capabilities](ca ## Next steps -Read [Capture checking](capture-checking.md) to enable optional compiler checks that keep capabilities within their scope. - Make sure to read the [Why PureLogic?](why-purelogic.md) page to understand when this library is useful and see a comparison with traditional monadic approaches. Check out the [examples](https://github.com/ghostdogpr/purelogic/tree/master/examples/src/main/scala/examples) in GitHub to see how to use the library in practice. @@ -155,3 +153,5 @@ Check out the [examples](https://github.com/ghostdogpr/purelogic/tree/master/exa If you want to know more about direct-style effects using capabilities, I recommend these two blog posts: - [Effects as Capabilities](https://nrinaudo.github.io/articles/capabilities.html) by Nicolas Rinaudo - [The Effect Pattern and Effect Systems in Scala](https://rockthejvm.com/articles/the-effect-pattern) by Riccardo Cardin + +Read [Capture checking](capture-checking.md) to enable optional compiler checks that keep capabilities within their scope.