Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,4 +29,4 @@ rewrite.imports.groups = [
["purelogic\\..*"],
]
rewrite.imports.sort = scalastyle
rewrite.redundantBraces.stringInterpolation = true
rewrite.redundantBraces.stringInterpolation = true
2 changes: 1 addition & 1 deletion benchmarks/src/main/scala/purelogic/bench/RwstBench.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
val scala3Version = "3.3.8"
val scala3Version = "3.9.0"
val scalaVersions = Seq(scala3Version)

// dependencies for tests and benchmarks
Expand Down Expand Up @@ -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(
Expand All @@ -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
)
32 changes: 20 additions & 12 deletions core/src/main/scala/purelogic/Abort.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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 = {
Expand All @@ -157,25 +158,32 @@ 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))
}

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)
}
)
}
}
}
8 changes: 4 additions & 4 deletions core/src/main/scala/purelogic/EventSourcing.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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)
}

Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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 =
Expand Down
6 changes: 3 additions & 3 deletions core/src/main/scala/purelogic/Reader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
}

/**
Expand Down
14 changes: 7 additions & 7 deletions core/src/main/scala/purelogic/State.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ()
}

/**
Expand Down Expand Up @@ -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)
}
20 changes: 10 additions & 10 deletions core/src/main/scala/purelogic/Writer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = ()
Expand All @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions core/src/test/scala/purelogic/PureLogicSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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((), ()) {
Expand Down
3 changes: 2 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export default defineConfig({
{ text: 'Abort', link: '/abort' },
{ text: 'Event Sourcing', link: '/event-sourcing' },
]
}
},
{ text: 'Capture Checking', link: '/capture-checking' }
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/abort.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading