Skip to content

Commit eb829ea

Browse files
Copilotlloydmeta
andauthored
[Scala 3] Fix type-parametrized intermediate sealed traits not discovered by findValues (#459)
* Add failing test for type-parametrized intermediate enum hierarchies Co-authored-by: lloydmeta <914805+lloydmeta@users.noreply.github.com> * Fix: Handle type-parametrized intermediate traits in Scala 3 macros Modified the enclosedSubClasses function to check type symbol base classes rather than exact type conformance, allowing intermediate traits with type parameters (e.g., Bar[T] extends Foo[T]) to be properly traversed even when the enum is Enum[Foo[Unit]]. Also fixed isObject to check against defining module rather than type companion, preventing incorrect warnings when intermediate traits are defined outside the enum companion. Co-authored-by: lloydmeta <914805+lloydmeta@users.noreply.github.com> * docs: Add documentation for isSubclassOfTarget helper function Improved code documentation based on code review feedback Co-authored-by: lloydmeta <914805+lloydmeta@users.noreply.github.com> * Fix: Tighten isObject check to avoid false negatives with name prefixes - Changed startsWith check to require exact match or proper dot-separated prefix - Added regression test to verify type parameter filtering works correctly - Prevents false positives when enum companion name is a prefix of another object name Co-authored-by: lloydmeta <914805+lloydmeta@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lloydmeta <914805+lloydmeta@users.noreply.github.com>
1 parent a50d34e commit eb829ea

2 files changed

Lines changed: 135 additions & 33 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package enumeratum
2+
3+
import org.scalatest.funspec.AnyFunSpec
4+
import org.scalatest.matchers.should.Matchers
5+
6+
class TypeParameterizedIntermediateSpec extends AnyFunSpec with Matchers {
7+
8+
describe("Type-parametrized enums with intermediate hierarchies") {
9+
it("should find all values when intermediate trait has type parameters") {
10+
sealed trait Foo2[T] extends EnumEntry with Serializable
11+
12+
object Foo2 extends Enum[Foo2[Unit]] {
13+
sealed trait Bar[T] extends Foo2[T]
14+
15+
case object A extends Bar[Unit]
16+
case object B extends Foo2[Unit]
17+
lazy val values: IndexedSeq[Foo2[Unit]] = findValues
18+
}
19+
20+
Foo2.values should contain theSameElementsAs Seq(Foo2.A, Foo2.B)
21+
}
22+
23+
it("should exclude case objects with different type parameters") {
24+
sealed trait TypedEnum[T] extends EnumEntry
25+
26+
object TypedEnum {
27+
sealed trait IntermediateTrait[T] extends TypedEnum[T]
28+
}
29+
30+
object UnitEnum extends Enum[TypedEnum[Unit]] {
31+
lazy val values: IndexedSeq[TypedEnum[Unit]] = findValues
32+
33+
case object UnitValue1 extends TypedEnum[Unit]
34+
case object UnitValue2 extends TypedEnum.IntermediateTrait[Unit]
35+
// These should NOT be included - different type parameter
36+
case object IntValue extends TypedEnum[Int]
37+
case object StringValue extends TypedEnum.IntermediateTrait[String]
38+
}
39+
40+
// Should only find Unit-typed values
41+
UnitEnum.values should contain theSameElementsAs Seq(
42+
UnitEnum.UnitValue1,
43+
UnitEnum.UnitValue2
44+
)
45+
UnitEnum.values should not contain UnitEnum.IntValue
46+
UnitEnum.values should not contain UnitEnum.StringValue
47+
}
48+
49+
it("should find all values in complex hierarchy with type parameters") {
50+
sealed trait Account[A] extends EnumEntry
51+
object Account {
52+
sealed trait Asset[A] extends Account[A]
53+
sealed trait Liability[A] extends Account[A]
54+
}
55+
56+
case class User()
57+
case class Company()
58+
59+
object UserAccounts extends Enum[Account[User]] {
60+
lazy val values: IndexedSeq[Account[User]] = findValues
61+
62+
case object Savings extends Account.Asset[User]
63+
case object Checking extends Account.Asset[User]
64+
case object CreditCard extends Account.Liability[User]
65+
}
66+
67+
UserAccounts.values should contain theSameElementsAs Seq(
68+
UserAccounts.Savings,
69+
UserAccounts.Checking,
70+
UserAccounts.CreditCard
71+
)
72+
}
73+
}
74+
}

macros/src/main/scala-3/enumeratum/EnumMacros.scala

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ object EnumMacros:
5050
}
5151

5252
val repr = validateType[A]
53-
val subclasses = enclosedSubClasses[A](q)(repr)
53+
val subclasses = enclosedSubClasses[A](q)(repr, definingTpeSym)
5454

5555
buildSeqExpr[A](q)(subclasses)
5656
}
@@ -124,9 +124,12 @@ object EnumMacros:
124124
* the `Enum` type
125125
* @param tpr
126126
* the representation of type `T` (also specified by `tpe`)
127+
* @param definingModule
128+
* the symbol of the module (object) where findValues is called
127129
*/
128130
private[enumeratum] def enclosedSubClasses[T](q: Quotes)(
129-
tpr: q.reflect.TypeRepr
131+
tpr: q.reflect.TypeRepr,
132+
definingModule: q.reflect.Symbol
130133
)(using tpe: Type[T]): List[q.reflect.TypeRepr] = {
131134
import q.reflect.*
132135

@@ -142,7 +145,9 @@ object EnumMacros:
142145
if (!owner.exists || owner == defn.RootClass || owner.isTerm) {
143146
if (
144147
sym.flags.is(Flags.Module) && !sym.flags.is(Flags.Package) &&
145-
!sym.fullName.startsWith(tpr.typeSymbol.companionModule.fullName)
148+
!(sym.fullName == definingModule.fullName || sym.fullName.startsWith(
149+
definingModule.fullName + "."
150+
))
146151
) {
147152
// See EnumSpec#'should return -1 for elements that do not exist'
148153

@@ -162,6 +167,21 @@ object EnumMacros:
162167

163168
type IsEntry[E <: T] = E
164169

170+
/** Check if a type symbol is a subclass of the target type symbol, ignoring type parameters.
171+
*
172+
* This allows intermediate traits with type parameters (e.g., Bar[T] extends Foo[T]) to be
173+
* recognized as subclasses even when we're looking for a specific parameterized type (e.g.,
174+
* Foo[Unit]). The check is based on the type symbol hierarchy, not exact type conformance.
175+
*
176+
* @param childTypeSym
177+
* the type symbol to check
178+
* @return
179+
* true if childTypeSym is the target or has the target in its base class hierarchy
180+
*/
181+
def isSubclassOfTarget(childTypeSym: Symbol): Boolean = {
182+
childTypeSym == tpr.typeSymbol || childTypeSym.typeRef.baseClasses.contains(tpr.typeSymbol)
183+
}
184+
165185
@annotation.tailrec
166186
def subclasses(
167187
children: List[Tree],
@@ -184,38 +204,46 @@ object EnumMacros:
184204

185205
childTpr match {
186206
case Some(child) => {
187-
child.asType match {
188-
case ct @ '[IsEntry[t]] => {
189-
val tpeSym = child.typeSymbol
190-
191-
if (!isObject(tpeSym)) {
192-
// This is an intermediate type (trait or abstract class), not a case object
193-
// However, if it's a Module (object), it means isObject returned false because
194-
// the object is in the wrong place (already warned about). Don't double-warn.
195-
if (!tpeSym.flags.is(Flags.Module) && !tpeSym.flags.is(Flags.Sealed)) {
196-
// Only warn about unsealed intermediate types, not misplaced objects
197-
report.warning(
198-
s"""Intermediate enum type '${tpeSym.fullName}' must be sealed.
199-
|
200-
|All intermediate parent types between the enum base type and the case objects must be sealed.
201-
|This is a known limitation in Scala 3's macro system.
202-
|
203-
|To fix this, add the 'sealed' modifier to '${tpeSym.name}':
204-
| sealed trait ${tpeSym.name} extends ...
205-
| sealed abstract class ${tpeSym.name} extends ...
206-
|
207-
|See: https://github.com/lloydmeta/enumeratum/blob/master/README.md
208-
|""".stripMargin
209-
)
210-
}
211-
subclasses(tpeSym.children.map(_.tree) ::: children.tail, out)
212-
} else {
213-
subclasses(children.tail, child :: out)
207+
val tpeSym = child.typeSymbol
208+
209+
// First check: is this type symbol related to our target type (ignoring type parameters)?
210+
// This allows intermediate traits like Bar[T] extends Foo[T] to be recognized
211+
// even when we're looking for Foo[Unit]
212+
if (isSubclassOfTarget(tpeSym)) {
213+
if (!isObject(tpeSym)) {
214+
// This is an intermediate type (trait or abstract class), not a case object
215+
// However, if it's a Module (object), it means isObject returned false because
216+
// the object is in the wrong place (already warned about). Don't double-warn.
217+
if (!tpeSym.flags.is(Flags.Module) && !tpeSym.flags.is(Flags.Sealed)) {
218+
// Only warn about unsealed intermediate types, not misplaced objects
219+
report.warning(
220+
s"""Intermediate enum type '${tpeSym.fullName}' must be sealed.
221+
|
222+
|All intermediate parent types between the enum base type and the case objects must be sealed.
223+
|This is a known limitation in Scala 3's macro system.
224+
|
225+
|To fix this, add the 'sealed' modifier to '${tpeSym.name}':
226+
| sealed trait ${tpeSym.name} extends ...
227+
| sealed abstract class ${tpeSym.name} extends ...
228+
|
229+
|See: https://github.com/lloydmeta/enumeratum/blob/master/README.md
230+
|""".stripMargin
231+
)
232+
}
233+
subclasses(tpeSym.children.map(_.tree) ::: children.tail, out)
234+
} else {
235+
// This is a case object - also check that it matches the exact target type with parameters
236+
child.asType match {
237+
case ct @ '[IsEntry[t]] =>
238+
subclasses(children.tail, child :: out)
239+
case _ =>
240+
// Type symbol matches but exact type doesn't - skip it
241+
subclasses(children.tail, out)
214242
}
215243
}
216-
217-
case _ =>
218-
subclasses(children.tail, out)
244+
} else {
245+
// Not related to our target type at all - skip it
246+
subclasses(children.tail, out)
219247
}
220248
}
221249

0 commit comments

Comments
 (0)