Skip to content

Commit c480068

Browse files
authored
Add Scala 3 stubbing macros (WhenMacro, DoSomethingMacro) (#639)
1 parent 775568c commit c480068

5 files changed

Lines changed: 973 additions & 8 deletions

File tree

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package org.mockito
22

3-
/**
4-
* Stub so that the shared WhenMacroRuntime (scala/) compiles under Scala 3. WhenMacroRuntime references Called.type in RealMethod.willBe. The full implementation with the by[T]
5-
* macro will be added in the upcoming commits.
6-
*/
7-
object Called
3+
object Called {
4+
inline def by[T](inline stubbing: T): T = ${ DoSomethingMacro.calledByImpl[T]('stubbing) }
5+
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package org.mockito
2+
3+
import org.mockito.Utils.*
4+
import org.mockito.stubbing.{ ScalaAnswer, Stubber }
5+
import scala.collection.mutable
6+
import scala.quoted.*
7+
8+
/**
9+
* Scala 3 macro implementations for "do something by" DSL (e.g., `Returned(value) by mock.method(args)`).
10+
*
11+
* High-level flow:
12+
* 1. Take a user DSL form (`doesNothing`, `returnedBy`, `answeredBy`, `thrownBy`, `calledBy`).
13+
* 2. Normalize invocation arguments through shared tree transformers from [[Utils]].
14+
* 3. Emit the corresponding Mockito runtime action (`doNothing`, `doReturn`, `doAnswer`, `doCallRealMethod`).
15+
*/
16+
object DoSomethingMacro {
17+
18+
/**
19+
* Generic macro: takes a pre-built Stubber and an invocation, transforms args. Used by cats/scalaz modules to implement returnedF/answeredF/raised etc.
20+
*/
21+
inline def doSomethingBy[T](inline action: Stubber, inline stubbing: T): T =
22+
${ doSomethingByImpl[T]('action, 'stubbing) }
23+
24+
def doSomethingByImpl[T: Type](action: Expr[Stubber], stubbing: Expr[T])(using Quotes): Expr[T] = {
25+
import quotes.reflect.*
26+
doTransformInvocation[T](stubbing, action.asTerm)
27+
}
28+
29+
/**
30+
* Macro for: mock.method(args) doesNothing
31+
*/
32+
inline def doesNothing[T](inline stubbing: => T): T =
33+
${ doesNothingImpl[T]('stubbing) }
34+
35+
def doesNothingImpl[T: Type](stubbing: Expr[T])(using Quotes): Expr[T] = {
36+
import quotes.reflect.*
37+
38+
// Use quoted expression to call Mockito.doNothing()
39+
val doNothingCall = '{ Mockito.doNothing() }.asTerm
40+
41+
doTransformInvocation[T](stubbing, doNothingCall)
42+
}
43+
44+
/**
45+
* Macro for: Returned(value) by mock.method(args)
46+
*/
47+
inline def returnedBy[T, S](inline v: T, inline stubbing: S)(using inline $ev: T <:< S): S =
48+
${ returnedByImpl[T, S]('v, 'stubbing) }
49+
50+
// Wrapper for case class usage
51+
inline def returnedByMacro[T, S](inline v: T, inline stubbing: S): S =
52+
${ returnedByImpl[T, S]('v, 'stubbing) }
53+
54+
def returnedByImpl[T: Type, S: Type](v: Expr[T], stubbing: Expr[S])(using Quotes): Expr[S] = {
55+
import quotes.reflect.*
56+
57+
// Type safety check: the return value type T must be assignable to the stubbed method's return type S
58+
if (!(TypeRepr.of[T] <:< TypeRepr.of[S])) report.errorAndAbort(s"Type mismatch: value of type ${TypeRepr.of[T].show} is not a subtype of ${TypeRepr.of[S].show}")
59+
60+
// Explicitly type the doReturn result as Stubber to help type inference in transformInvocation.
61+
// For value classes, extract the underlying value before passing to Mockito.
62+
val doReturnCall = Typed(
63+
'{ org.mockito.Mockito.doReturn(org.mockito.internal.ValueClassExtractor[T].extract($v)) }.asTerm,
64+
TypeTree.of[Stubber]
65+
)
66+
67+
doTransformInvocation[S](stubbing, doReturnCall)
68+
}
69+
70+
/**
71+
* Macro for: Answered(func) by mock.method(args)
72+
*/
73+
inline def answeredBy[T, S](inline v: T, inline stubbing: S)(using inline $ev: T <:< S): S =
74+
${ answeredByImpl[T, S]('v, 'stubbing) }
75+
76+
// Wrapper for case class usage
77+
inline def answeredByMacro[T, S](inline v: T, inline stubbing: S): S =
78+
${ answeredByImpl[T, S]('v, 'stubbing) }
79+
80+
// Thunk-based wrapper: value is deferred via () => T to prevent eager evaluation
81+
inline def answeredByThunkMacro[T, S](inline v: () => T, inline stubbing: S): S =
82+
${ answeredByThunkImpl[T, S]('v, 'stubbing) }
83+
84+
// Thunk variant: calling v() on each mock invocation gives the same generated code as passing the expression
85+
// directly, so we delegate to answeredByImpl with the thunk unwrapped.
86+
def answeredByThunkImpl[T: Type, S: Type](v: Expr[() => T], stubbing: Expr[S])(using Quotes): Expr[S] =
87+
answeredByImpl[T, S]('{ $v() }, stubbing)
88+
89+
def answeredByImpl[T: Type, S: Type](v: Expr[T], stubbing: Expr[S])(using Quotes): Expr[S] = {
90+
import quotes.reflect.*
91+
92+
val tRepr = TypeRepr.of[T].dealias.widen
93+
val sRepr = TypeRepr.of[S].dealias.widen
94+
val functionInfo = extractFunctionInfo(tRepr)
95+
val resultType = functionInfo.map(_._2).getOrElse(tRepr)
96+
if (!(resultType <:< sRepr)) report.errorAndAbort(s"Type mismatch: answer result type ${resultType.show} is not a subtype of ${sRepr.show}")
97+
98+
val doAnswerCall = functionInfo match {
99+
case Some((paramTypes, retType)) =>
100+
// Function type: extract args from InvocationOnMock and apply the function
101+
val answerExpr = buildFunctionAnswer(v, paramTypes, retType)
102+
'{ org.mockito.Mockito.doAnswer($answerExpr) }.asTerm
103+
case None =>
104+
// Plain value: wrap in ScalaAnswer.lift
105+
'{ org.mockito.Mockito.doAnswer(org.mockito.stubbing.ScalaAnswer.lift[Any](_ => $v)) }.asTerm
106+
}
107+
doTransformInvocation[S](stubbing, doAnswerCall)
108+
}
109+
110+
/** Build a ScalaAnswer that applies a function to extracted InvocationOnMock args with value class support */
111+
private def buildFunctionAnswer[T: Type](using
112+
Quotes
113+
)(
114+
fn: Expr[T],
115+
paramTypes: List[quotes.reflect.TypeRepr],
116+
retType: quotes.reflect.TypeRepr
117+
): Expr[ScalaAnswer[Any]] = {
118+
import quotes.reflect.*
119+
120+
retType.asType match {
121+
case '[r] =>
122+
'{
123+
org.mockito.stubbing.ScalaAnswer.lift[Any] { invocation =>
124+
${
125+
val fnTerm = fn.asTerm
126+
val argExprs = paramTypes.zipWithIndex.map { case (pt, i) =>
127+
pt.asType match {
128+
case '[p] =>
129+
val idxExpr = Expr(i)
130+
'{ org.mockito.internal.ValueClassWrapper[p].wrapAs[p](invocation.getArgument($idxExpr)) }.asTerm
131+
}
132+
}
133+
val result = Select.unique(fnTerm, "apply").appliedToArgs(argExprs)
134+
'{ org.mockito.internal.ValueClassExtractor[r].extractAs[r](${ result.asExprOf[r] }) }.asExprOf[Any]
135+
}
136+
}
137+
}
138+
}
139+
}
140+
141+
/**
142+
* Macro for: Thrown(exception) by mock.method(args)
143+
*/
144+
inline def thrownBy[T](inline v: Throwable, inline stubbing: T)(using inline $ev: Throwable): T =
145+
${ thrownByImpl[T]('v, 'stubbing) }
146+
147+
// Wrapper for case class usage
148+
inline def thrownByMacro[T, E](inline v: E, inline stubbing: T): T =
149+
${ thrownByMacroImpl[T, E]('v, 'stubbing) }
150+
151+
private def thrownByMacroImpl[T: Type, E: Type](v: Expr[E], stubbing: Expr[T])(using Quotes): Expr[T] = {
152+
import quotes.reflect.*
153+
154+
// Type safety check: E must be a Throwable
155+
if (!(TypeRepr.of[E] <:< TypeRepr.of[Throwable])) report.errorAndAbort(s"Type mismatch: ${TypeRepr.of[E].show} is not a subtype of Throwable")
156+
157+
thrownByImpl[T]('{ $v.asInstanceOf[Throwable] }, stubbing)
158+
}
159+
160+
def thrownByImpl[T: Type](v: Expr[Throwable], stubbing: Expr[T])(using Quotes): Expr[T] = {
161+
import quotes.reflect.*
162+
163+
// Use doAnswer with ScalaThrowsException to avoid Mockito's checked exception validation
164+
val doThrowCall = '{
165+
org.mockito.Mockito.doAnswer(new org.mockito.internal.stubbing.answers.ScalaThrowsException($v))
166+
}.asTerm
167+
168+
doTransformInvocation[T](stubbing, doThrowCall)
169+
}
170+
171+
/**
172+
* Macro for: Called by mock.method(args)
173+
*/
174+
inline def calledBy[T](inline stubbing: T): T =
175+
${ calledByImpl[T]('stubbing) }
176+
177+
def calledByImpl[T: Type](stubbing: Expr[T])(using Quotes): Expr[T] = {
178+
import quotes.reflect.*
179+
180+
// Use quoted expression to call Mockito.doCallRealMethod()
181+
val doCallRealMethodCall = '{ org.mockito.Mockito.doCallRealMethod() }.asTerm
182+
183+
doTransformInvocation[T](stubbing, doCallRealMethodCall)
184+
}
185+
186+
/** Build `action.when[ObjType](obj)` — the Stubber.when(T) call shared by all pattern cases */
187+
private def makeStubberWhenCall(using Quotes)(action: quotes.reflect.Term, obj: quotes.reflect.Term): quotes.reflect.Term = {
188+
import quotes.reflect.*
189+
val stubberClass = Symbol.requiredClass("org.mockito.stubbing.Stubber")
190+
val whenSymbol = stubberClass.declaredMethod("when").head
191+
TypeApply(
192+
Select(action, whenSymbol),
193+
List(obj.tpe.widen.asType match { case '[t] => TypeTree.of[t] })
194+
).appliedTo(obj)
195+
}
196+
197+
/**
198+
* Transform invocation: obj.method(args) => action.when(obj).method(transformedArgs)
199+
*/
200+
private def transformInvocation(using
201+
Quotes
202+
)(
203+
invocation: quotes.reflect.Term,
204+
action: quotes.reflect.Term,
205+
hoisted: mutable.ListBuffer[quotes.reflect.Statement],
206+
matcherValNames: Set[String] = Set.empty
207+
): quotes.reflect.Term = {
208+
import quotes.reflect.*
209+
210+
invocation match {
211+
// Handle blocks with hoisted named args
212+
case Block(stats, expr) =>
213+
transformBlock(stats, expr, matcherValNames)((e, mvs) => transformInvocation(e, action, hoisted, mvs))
214+
215+
// Handle inlined expressions with bindings
216+
case inlined: Inlined =>
217+
transformInvocation(inlined.body, action, hoisted, matcherValNames)
218+
219+
// Match: obj.method(args)
220+
case Apply(select @ Select(obj, _), args) =>
221+
val whenCall = makeStubberWhenCall(action, obj)
222+
val transformedArgs = transformArgsForApply(select, args, hoisted, matcherValNames)
223+
Apply(Select(whenCall, select.symbol), transformedArgs)
224+
225+
// Match: obj.method[TypeArgs](args)
226+
case Apply(TypeApply(select @ Select(obj, _), targs), args) =>
227+
val whenCall = makeStubberWhenCall(action, obj)
228+
val transformedArgs = transformArgsForApply(TypeApply(select, targs), args, hoisted, matcherValNames)
229+
Apply(TypeApply(Select(whenCall, select.symbol), targs), transformedArgs)
230+
231+
// Match: obj.method (no args)
232+
case select @ Select(obj, _) =>
233+
Select(makeStubberWhenCall(action, obj), select.symbol)
234+
235+
// Match: obj.method[TypeArgs] (no args)
236+
case TypeApply(select @ Select(obj, _), targs) =>
237+
TypeApply(Select(makeStubberWhenCall(action, obj), select.symbol), targs)
238+
239+
// Nested Apply - recurse
240+
case Apply(fun, args) =>
241+
val transformedArgs = transformArgsForApply(fun, args, hoisted, matcherValNames)
242+
Apply(
243+
transformInvocation(fun, action, hoisted, matcherValNames),
244+
transformedArgs
245+
)
246+
247+
case other =>
248+
report.errorAndAbort(s"Could not transform do-something invocation: ${other.show}")
249+
}
250+
}
251+
252+
private def doTransformInvocation[A: Type](using Quotes)(stubbing: Expr[A], action: quotes.reflect.Term): Expr[A] = {
253+
import quotes.reflect.*
254+
val hoisted = mutable.ListBuffer.empty[Statement]
255+
val transformed = transformInvocation(stubbing.asTerm, action, hoisted)
256+
(if (hoisted.nonEmpty) Block(hoisted.toList, transformed) else transformed).asExprOf[A]
257+
}
258+
}

0 commit comments

Comments
 (0)