scalamacrosimplicitscala-macrosscala-3

Can a scala3 macro introduce an implicit value around an existing block?


I am working on a library for use in tests, where I want to be able to have variables that can be redefined in a particular scope. This is inspired by let in rspec.

I have something working, by consistently shadowing a variable called scope (of type Scope). The Scope type has

A dumbed-down example which works:

object MyTest extends Scopes with FunSuite {
    val env = Scoped[String]("production")
    val input = Scoped[Int]()

    scope("when the input is zero") { scope =>
        scope.let(input, 0)

        test("the value equals zero") {
            expect(scope.get(input) == 0)
        }
        
        scope("and the app is running in staging") { scope =>
            scope.let(env, "staging")

            test("the value still equals zero") {
                expect(scope.get(input) == 0)
            }
        }
    }
}

That works because the Scopes trait provides val scope = Scope.root, and as long as you consistently shadow the scope variable every time you create a child scope, tests can refer to scope and it'll be based on the lexical scope where they're defined.

Implicit scope

The API would be less verbose if I methods could accept an implicit Scope, and that does work (in scala 3) if I consistently write each nested scope as:

scope("nested scope") { implicit scope =>
  // ...
}

But that's more verbose at the scope definition, even though it can improve brevity when accessing or redefining scoped variables.

Adding an implicit via macro

I was hoping I could cut down on the boilerplate via macros. Essentially I want to automatically add the above implicit activeScope => boilerplate on each call to scope. Then the user's code would look like:

scope("nested scope") {
    // I can call a method taking (implicit scope: Scope)
}

I got this to typecheck by having an implicit val rootScope: Scope = ??? in the Scopes trait, and then having a scope macro which automatically introduces an implicit at the beginning of the block passed to it. The code generation part of the macro looks like this:

    def contextCode[T](scope: Expr[Scope], s: Expr[String], block: Expr[T])(using Quotes, Type[T]): Expr[T] = {
        '{
            ${scope}(${s}) { implicit activeScope =>
                (${block})
            }
        }
    }

This compiles, but it doesn't use the introduced implicit. I suspect that since typechecking happens prior to macro expansion, so does implicit resolution (so my new implicits are ignored).

Is there any way I can use macros to affect which Scope value is selected based on the lexical scope? I'm willing to resort to some cunning tricks as long as they're reliable.

In terms of constraints, the outer scope definition calls are all evaluated at object creation time, so I'd be OK with using some mutable state to track "the active scope" at definition time. The problem is that the code within test blocks needs to access the same scope, and that code runs much later (potentially in parallel), so the "active" scope in test bodies must be based on its lexical scope, not runtime state.

Oh, and I don't have any control over the test framework itself. That is, I can't (for example) modify the test function to do anything special before running the test body.


Solution

  • You don't need macros but context function and some utilities (with inline):

    final case class Scoped private (
        private val nesting: List[String] = Nil,
        private var ctx: Map[String, String] = Map.empty
    ) {
    
      // magic happens here ````````````````\/
      def apply(name: String)(block: Scoped ?=> Any): Unit =
        block(using this.copy(name :: nesting))
    
      def let(input: String, value: String): Unit =
        ctx = ctx + (input -> value)
    
      def get(input: String): String = ctx.getOrElse(input, "")
    }
    object Scoped {
    
      given Scoped = Scoped()
    }
    
    inline def scope(using s: Scoped): Scoped = s
    
    println(s"$scope")
    
    scope("foo") { // no need for `implicit scope =>`
    
      scope.let("foo", "foo1")
      println(s"  ${scope.get("foo")}")
      println(s"  $scope")
    
      scope("bar") { // overshadowing of given Scoped OOTB
    
        scope.let("boo", "boo1")
        println(s"    ${scope.get("boo")}")
        println(s"    $scope")
      }
    
      println(s"  $scope")
    }
    
    println(s"$scope")
    
    

    (scastie)

    Every time you use nested context function (?=>) the given inside overshadows the given outside, so you can safely and predictably create a copy of given value, use it in nesting, and it won't affect the scope outside - which sounds like what you are trying to do.

    I didn't bother making my Scoped example generic, but it should give you some understanding how to overshadow givens with in nested context functions.