diff --git a/README.md b/README.md index 789215b..b984fbe 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,28 @@ val usdAmount: Double = Raise.recoverable { The strategy lets us define how to recover from a typed error in a dedicated place, leaving the happy logic in the main block. +### Tracing + +When a logical error is raised, the library will not print any information about the error by default. Sometimes, it's useful for debugging purposes to have a trace of the error. The library allows you to define a strategy called `TraceWith` that processes the error together with a stack trace that identifies where the error was raised. + +To enable tracing for a specific code block, wrap it inside the `trace` DSL and provide a `TraceWith` strategy: + +```scala 3 +import in.rcard.raise4s.Raise.{raise, traced} +import in.rcard.raise4s.Strategies.TraceWith + +given TraceWith[String] = (trace: Traced) => { + trace.printStackTrace() +} +val lambda: Int raises String = traced { + raise("Oops!") +} +val actual: String | Int = Raise.run(lambda) +actual shouldBe "Oops!" +``` + +The `Traced` exception the tracing engine uses contains the original typed error. + ## Contributing If you want to contribute to the project, please do it! Any help is welcome. diff --git a/core/src/main/scala/in/rcard/raise4s/Raise.scala b/core/src/main/scala/in/rcard/raise4s/Raise.scala index 0d3e10d..a898dd5 100644 --- a/core/src/main/scala/in/rcard/raise4s/Raise.scala +++ b/core/src/main/scala/in/rcard/raise4s/Raise.scala @@ -1,7 +1,7 @@ package in.rcard.raise4s import in.rcard.raise4s -import in.rcard.raise4s.Strategies.RecoverWith +import in.rcard.raise4s.Strategies.{RecoverWith, TraceWith, Traced, TracedRaise} import scala.annotation.targetName import scala.util.Try @@ -1768,4 +1768,44 @@ object Raise { */ inline def withDefault[Error, A](default: A)(inline block: Raise[Error] ?=> A): A = Raise.recover(block)(error => default) + + /** Add tracing to a block of code that can raise a logical error. In detail, the logical error is + * wrapped inside a [[Traced]] exception and processed by the [[TraceWith]] strategy instance. + * Please, be aware that adding tracing to an error can have a performance impact since a fat + * exception with a full stack trace is created. + * + *

Example

+ * {{{ + * given TraceWith[String] = trace => { + * trace.printStackTrace() + * } + * val lambda: Int raises String = traced { + * raise("Oops!") + * } + * val actual: String | Int = Raise.run(lambda) + * actual shouldBe "Oops!" + * }}} + * + * @param block + * The block of code to execute that can raise an error + * @param tracing + * The strategy to process the traced error + * @tparam Error + * The type of the logical error that can be raised by the `block` lambda + * @tparam A + * The type of the result of the execution of `block` lambda + * @return + * The original block wrapped into a traced block + */ + inline def traced[Error, A]( + inline block: Raise[Error] ?=> A + )(using inline tracing: TraceWith[Error]): Raise[Error] ?=> A = { + try { + given tracedRaise: Raise[Error] = new TracedRaise + block + } catch + case traced: Traced[Error] => + tracing.trace(traced) + Raise.raise(traced.original) + } } diff --git a/core/src/main/scala/in/rcard/raise4s/Strategies.scala b/core/src/main/scala/in/rcard/raise4s/Strategies.scala index 36eecb4..2586ce5 100644 --- a/core/src/main/scala/in/rcard/raise4s/Strategies.scala +++ b/core/src/main/scala/in/rcard/raise4s/Strategies.scala @@ -61,12 +61,53 @@ object Strategies { * actual should be(43) * }}} * - * @tparam Error The type of the error to recover from - * @tparam A The type of the value to return if the recovery is successful - * - * @see [[Raise.recoverable]] + * @tparam Error + * The type of the error to recover from + * @tparam A + * The type of the value to return if the recovery is successful + * + * @see + * [[Raise.recoverable]] */ trait RecoverWith[Error, A] { def recover(error: Error): A } + + /** Implement the `raise` method to throw a [[Traced]] exception with the error to trace instead + * of a [[Raised]] exception. + */ + private[raise4s] class TracedRaise extends Raise[Any]: + def raise(e: Any): Nothing = throw Traced(e) + + /** The exception that wraps the original error in case of tracing. The difference with the [[Raised]] exception is that + * the exception contains a full stack trace. + * @param original + * The original error to trace + * @tparam Error + * The type of the error to trace + */ + case class Traced[Error](original: Error) extends Exception + + /** A strategy that allows to trace an error and return it. The [[trace]] method represent the + * behavior to trace the error. As a strategy, it should be used as a `given` instance. Use the + * type class instance with the [[Raise.traced]] DSL. + * + *

Example

+ * {{{ + * given TraceWith[String] = trace => { + * trace.printStackTrace() + * } + * val lambda: Int raises String = traced { + * raise("Oops!") + * } + * val actual: String | Int = Raise.run(lambda) + * actual shouldBe "Oops!" + * }}} + * + * @tparam Error + * The type of the error to trace + */ + trait TraceWith[Error] { + def trace(traced: Traced[Error]): Unit + } } diff --git a/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala b/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala index bfde237..ee1cf34 100644 --- a/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala +++ b/core/src/test/scala/in/rcard/raise4s/StrategiesSpec.scala @@ -1,7 +1,7 @@ package in.rcard.raise4s -import in.rcard.raise4s.Raise.raise -import in.rcard.raise4s.Strategies.{MapError, anyRaised} +import in.rcard.raise4s.Raise.{raise, traced} +import in.rcard.raise4s.Strategies.{MapError, TraceWith, anyRaised} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers @@ -39,4 +39,38 @@ class StrategiesSpec extends AnyFlatSpec with Matchers { val result: Int | String = Raise.run(finalLambda) result shouldBe "Hello" } + + "TraceWith" should "allow defining a strategy that trace the error and return it" in { + val queue = collection.mutable.ListBuffer.empty[String] + given TraceWith[String] = trace => { + queue += trace.original + trace.printStackTrace() + } + + val lambda: Int raises String = traced { + raise("Oops!") + } + + val actual: String | Int = Raise.run(lambda) + + actual shouldBe "Oops!" + queue should contain("Oops!") + } + + it should "return the happy path value if no error is raised" in { + val queue = collection.mutable.ListBuffer.empty[String] + given TraceWith[String] = trace => { + queue += trace.original + trace.printStackTrace() + } + + val lambda: Int raises String = traced { + 42 + } + + val actual: Int | String = Raise.run(lambda) + + actual shouldBe 42 + queue shouldBe empty + } }