diff --git a/.scalafmt.conf b/.scalafmt.conf index 6f8d4bf..5b12f71 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,6 +1,8 @@ version = "3.11.5" 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 ba82fe1..47c4330 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3Version = "3.3.8" +val scala3Version = "3.9.0" val scalaVersions = Seq(scala3Version) // dependencies for tests and benchmarks @@ -59,9 +59,12 @@ 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").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( @@ -78,11 +81,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", + "-Wunused:imports,params,privates,implicits,explicits", "-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 9340801..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] { +trait Abort[-E] extends scala.caps.Control { /** * Aborts the computation with the given error. @@ -60,8 +60,8 @@ object Abort { /** * Default `Abort[Nothing]` instance that can never fail. */ - 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 } /** @@ -122,17 +122,18 @@ 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 = + 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) /** * Like `recoverSome`, but keeps the writes from the matched block instead of rolling them back. Unmatched errors are * re-raised without rollback (same as `recoverSome`). */ - def recoverSomeKeepLog[W, S, E, A](f: Abort[E] ?=> A)(handler: PartialFunction[E, A])(using Writer[W], State[S], Abort[E]): A = + def recoverSomeKeepLog[W, S, E, A](f: Abort[E] ?=> A)(handler: PartialFunction[E, A]^)(using Writer[W], State[S], Abort[E]): A = doRecoverSome(resetLog = false)(f)(handler) private def doRecover[W, S, E, A](resetLog: Boolean)(f: Abort[E] ?=> A)(handler: E => A)(using w: Writer[W], s: State[S]): A = { @@ -157,15 +158,13 @@ object Abort { private def doRecoverSome[W, S, E, A]( resetLog: Boolean - )(f: Abort[E] ?=> A)(handler: PartialFunction[E, A])(using w: Writer[W], s: State[S], abort: Abort[E]): A = { + )(f: Abort[E] ?=> A)(handler: PartialFunction[E, A]^)(using w: Writer[W], s: State[S], abort: Abort[E]): A = { val stateSnapshot = s.get val logSnapshot = w.snapshot 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 1420c8d..3cc075a 100644 --- a/core/src/main/scala/purelogic/EventSourcing.scala +++ b/core/src/main/scala/purelogic/EventSourcing.scala @@ -13,7 +13,7 @@ import EventSourcing.Transition * @tparam Err * the type of errors that transitions can raise */ -trait EventSourcing[Ev, S, Err] { +trait EventSourcing[Ev, S, Err] extends scala.caps.ExclusiveCapability { protected given state: State[S] protected given writer: Writer[Ev] @@ -30,7 +30,7 @@ trait EventSourcing[Ev, S, Err] { * 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) } @@ -51,7 +51,7 @@ object EventSourcing { /** * Applies the event to the current state. */ - def run(ev: Ev): (State[S], Abort[Err]) ?=> Unit + def run(ev: Ev): (State[S], Abort[Err]) ?-> Unit } /** @@ -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 1d714eb..001976c 100644 --- a/core/src/main/scala/purelogic/Reader.scala +++ b/core/src/main/scala/purelogic/Reader.scala @@ -8,7 +8,7 @@ package purelogic * @tparam R * the type of the value to read */ -trait Reader[+R] { +trait Reader[+R] extends scala.caps.SharedCapability { /** * Returns the current reader value. @@ -46,8 +46,8 @@ object Reader { /** * Default `Reader[Unit]` instance that always returns `()`. */ - 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 9d63b6d..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] { +trait StateReader[+S] extends scala.caps.ExclusiveCapability { /** * Returns the current state. @@ -29,7 +29,7 @@ trait StateReader[+S] { * @tparam S * the type of the state */ -trait StateWriter[-S] { +trait StateWriter[-S] extends scala.caps.ExclusiveCapability { /** * Replaces the state with a new value. @@ -87,7 +87,7 @@ 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 = { + 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) @@ -116,9 +116,9 @@ object State { /** * Default `State[Unit]` instance that does nothing. */ - 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 = () } /** @@ -170,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 b6ca62a..58351d4 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] { +trait Writer[-W] extends scala.caps.ExclusiveCapability { /** * Appends a single value to the log. @@ -21,7 +21,7 @@ trait Writer[-W] { /** * 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 = { @@ -83,10 +83,10 @@ object Writer { /** * Default `Writer[Nothing]` instance that discards all writes. */ - given Writer[Nothing] = new Writer[Nothing] { - def write(w: Nothing): Unit = () - def writeAll(elems: IterableOnce[Nothing]): Unit = () - def clear: Unit = () + given [W <: Nothing]: Writer[W] = new Writer[W] { + 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..a4c70f7 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..9602d82 --- /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" +``` + +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..dab32ad 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -152,4 +152,6 @@ 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 \ No newline at end of file +- [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. diff --git a/examples/src/main/scala/examples/account/AccountExample.scala b/examples/src/main/scala/examples/account/AccountExample.scala index bdb6edb..1d8218a 100644 --- a/examples/src/main/scala/examples/account/AccountExample.scala +++ b/examples/src/main/scala/examples/account/AccountExample.scala @@ -17,7 +17,7 @@ type Program[A] = EventSourcingLogic[Config, AccountEvent, Account, String, A] // --- Transition --- 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))