@@ -43,6 +43,118 @@ object WhenMacro {
4343 buildActionWrapper[T ](" org.mockito.IdiomaticMockitoBaseRuntime.ReturnActions" , buildScalaFirstStubbing[T ](stubbing))
4444 }
4545
46+ /**
47+ * Value-aware stubbing used by `shouldReturn`/`mustReturn`/`returns`.
48+ *
49+ * Taking the returned value(s) as a separate type parameter `V` (rather than unifying with the stubbed type `T`) lets this macro decide, by inspecting the two types, how to
50+ * build the stubbing:
51+ * - `V` conforms to `T` (normal/sub-typed value): stub at `T`.
52+ * - `V` numerically widens to `T` (e.g. `Long` method stubbed with an `Int`): stub at `T`, coercing the value with `toLong`/`toDouble`/... so the runtime value has the
53+ * method's return type instead of being boxed as the narrower type.
54+ * - `T` is a polymorphic type whose parameter defaulted (e.g. `Either[String, Resp[Any]]`) while `V` is more specific: stub at `V`, so the value's type drives inference.
55+ * - otherwise the stub is a genuine type error and is rejected with a clear message.
56+ */
57+ transparent inline def returnsValue [T , V ](inline stubbing : T , inline value : V , inline values : V * ): ScalaOngoingStubbing [? ] =
58+ $ { returnsValueMacro[T , V ](' stubbing , ' value , ' values ) }
59+
60+ // Scala weak conformance is not a linear order: the main chain is Byte < Short < Int < Long < Float <
61+ // Double, while Char widens directly to Int (and wider) but is unrelated to Byte/Short. Each key maps
62+ // to the set of types it may widen to, so e.g. Byte -> Char is correctly *not* a widening.
63+ private val numericWidening : Map [String , Set [String ]] = {
64+ val chain = List (" scala.Byte" , " scala.Short" , " scala.Int" , " scala.Long" , " scala.Float" , " scala.Double" )
65+ val onChain = chain.tails.collect { case from :: wider => from -> wider.toSet }.toMap
66+ onChain + (" scala.Char" -> Set (" scala.Int" , " scala.Long" , " scala.Float" , " scala.Double" ))
67+ }
68+
69+ def returnsValueMacro [T : Type , V : Type ](stubbing : Expr [T ], value : Expr [V ], values : Expr [Seq [V ]])(using Quotes ): Expr [ScalaOngoingStubbing [? ]] = {
70+ import quotes .reflect .*
71+ val transformed = doTransformInvocation(stubbing)
72+ val tT = TypeRepr .of[T ].widen.dealias
73+ val tV = TypeRepr .of[V ].widen.dealias
74+
75+ val numericWiden = numericWidening.get(tV.typeSymbol.fullName).exists(_.contains(tT.typeSymbol.fullName))
76+
77+ // Numeric literal narrowing (per the spec, mirrored by plain Scala assignment and the Scala 2 DSL):
78+ // an `Int` *constant* whose value is in the target's range may narrow to Byte/Short/Char. `Expr#value`
79+ // returns `Some` exactly for compile-time constants (literals and constant-folded `final val`s), so
80+ // out-of-range literals and non-constant `Int` values are still rejected, just like the compiler.
81+ def intConst (e : Expr [V ]): Option [Int ] = if (tV =:= TypeRepr .of[Int ]) e.asExprOf[Int ].value else None
82+ def inTargetRange (n : Int ): Boolean = tT.typeSymbol.fullName match {
83+ case " scala.Byte" => n >= Byte .MinValue && n <= Byte .MaxValue
84+ case " scala.Short" => n >= Short .MinValue && n <= Short .MaxValue
85+ case " scala.Char" => n >= Char .MinValue && n <= Char .MaxValue
86+ case _ => false
87+ }
88+ val literalNarrow =
89+ intConst(value).exists(inTargetRange) && Varargs .unapply(values).exists(_.forall(intConst(_).exists(inTargetRange)))
90+
91+ // Treats `Any`/`Nothing` positions in the target as wildcards, so a polymorphic method whose
92+ // type parameter defaulted to `Any` accepts a more specific value, while genuinely unrelated
93+ // types (e.g. `Int` vs `String`) are rejected.
94+ def compatible (t : TypeRepr , v : TypeRepr ): Boolean =
95+ (v <:< t) || (t =:= TypeRepr .of[Any ]) || (t =:= TypeRepr .of[Nothing ]) || {
96+ (t, v) match {
97+ case (AppliedType (c1, a1), AppliedType (c2, a2)) if c1 =:= c2 && a1.length == a2.length =>
98+ a1.zip(a2).forall((x, y) => compatible(x, y))
99+ case _ => false
100+ }
101+ }
102+
103+ // A single quote is emitted per expansion (rather than unifying several `if`/`else` branches),
104+ // so `transparent inline` can refine the result to the precise `ScalaOngoingStubbing[targetType]`.
105+ val boxed : Map [String , TypeRepr ] = Map (
106+ " scala.Byte" -> TypeRepr .of[java.lang.Byte ],
107+ " scala.Short" -> TypeRepr .of[java.lang.Short ],
108+ " scala.Char" -> TypeRepr .of[java.lang.Character ],
109+ " scala.Int" -> TypeRepr .of[java.lang.Integer ],
110+ " scala.Long" -> TypeRepr .of[java.lang.Long ],
111+ " scala.Float" -> TypeRepr .of[java.lang.Float ],
112+ " scala.Double" -> TypeRepr .of[java.lang.Double ],
113+ " scala.Boolean" -> TypeRepr .of[java.lang.Boolean ]
114+ )
115+ // `Int` stubbed on a method returning e.g. `java.lang.Long` (typically Java interop): a Scala numeric
116+ // that weakly conforms to the target's primitive is widened and boxed, matching the Scala 2 DSL.
117+ // `boxWidenPrim` is the primitive to widen the value to before boxing (the value's own type when its
118+ // box already conforms, e.g. for a supertype target like `Number`).
119+ val boxWidenPrim : Option [String ] = {
120+ val widenTargets = tV.typeSymbol.fullName :: numericWidening.getOrElse(tV.typeSymbol.fullName, Set .empty).toList
121+ widenTargets.find(p => boxed.get(p).exists(_ <:< tT))
122+ }
123+ val boxWiden = boxWidenPrim.isDefined
124+
125+ val numericCoerce : Option [String ] = Option .when(numericWiden || literalNarrow)(" to" + tT.typeSymbol.name)
126+ // `Any`/`Nothing` (e.g. `returns ???`): the value can't be statically re-typed, so cast at runtime.
127+ val runtimeCast = tV =:= TypeRepr .of[Any ]
128+ val (targetType, castStubbing): (TypeRepr , Boolean ) =
129+ if (tV <:< tT || numericWiden || literalNarrow || boxWiden || runtimeCast) (tT, false )
130+ else if (compatible(tT, tV)) (tV, true )
131+ else report.errorAndAbort(s " Cannot stub a method returning ${tT.show} with a value of type ${tV.show}" )
132+
133+ targetType.asType match {
134+ case ' [tgt] =>
135+ val whenArg : Expr [tgt] = if (castStubbing) ' { $transformed.asInstanceOf [tgt] } else transformed.asExprOf[tgt]
136+ def coerce (e : Expr [V ]): Expr [tgt] =
137+ numericCoerce match {
138+ case Some (n) => Select .unique(e.asTerm, n).asExprOf[tgt]
139+ case _ if boxWiden =>
140+ // Widen to the target's primitive (a no-op when it equals the value's type), then box via `Any`.
141+ val prim = boxWidenPrim.get
142+ val widened : Expr [Any ] =
143+ if (prim == tV.typeSymbol.fullName) e.asExprOf[Any ]
144+ else Select .unique(e.asTerm, " to" + prim.stripPrefix(" scala." )).asExprOf[Any ]
145+ ' { $widened.asInstanceOf [tgt] }
146+ case _ if runtimeCast => ' { $e.asInstanceOf [tgt] }
147+ case _ => e.asExprOf[tgt]
148+ }
149+ val plainPassThrough = numericCoerce.isEmpty && ! boxWiden && ! runtimeCast
150+ val first : Expr [tgt] = coerce(value)
151+ val rest : Expr [Seq [tgt]] =
152+ if (plainPassThrough) values.asExprOf[Seq [tgt]]
153+ else ' { $values.map(x => $ { coerce(' x ) }) }
154+ ' { new ScalaFirstStubbing [tgt](org.mockito.Mockito .when[tgt]($whenArg)).thenReturn($first, $rest* ) }
155+ }
156+ }
157+
46158 inline def shouldThrow [T ](inline stubbing : T ): Any =
47159 $ { shouldThrowMacro[T ](' stubbing ) }
48160
0 commit comments