Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions resources/scala-3/implicit-breaking-change.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import upickle.default.{ReadWriter, Pickler}

final case class User(
id: Int,
name: String,
email: String
)

implicit val rw: ReadWriter[User] = Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-breaking-change.scala_test.prev
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class User(
id: Int,
name: String
)

implicit val rw: ReadWriter[User] = Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-default-added.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitAccount(
id: Int,
plan: String = "basic"
)

implicit val implicitAccountRW: ReadWriter[ImplicitAccount] = Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-default-added.scala_test.prev
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitAccount(
id: Int,
plan: String
)

implicit val implicitAccountRW: ReadWriter[ImplicitAccount] = Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-default-removed.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitSettings(
id: Int,
theme: String
)

implicit val implicitSettingsRW: ReadWriter[ImplicitSettings] = Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-default-removed.scala_test.prev
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitSettings(
id: Int,
theme: String = "dark"
)

implicit val implicitSettingsRW: ReadWriter[ImplicitSettings] = Pickler.macroRW
9 changes: 9 additions & 0 deletions resources/scala-3/implicit-non-breaking-change.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitUser(
id: Int,
name: String,
role: String = "user"
)

implicit val implicitUserRW: ReadWriter[ImplicitUser] = Pickler.macroRW
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitUser(
id: Int,
name: String
)

implicit val implicitUserRW: ReadWriter[ImplicitUser] = Pickler.macroRW
5 changes: 5 additions & 0 deletions resources/scala-3/implicit-qualified-type.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
final case class TestClass(
field1: String
)

implicit val rw: upickle.default.ReadWriter[TestClass] = upickle.default.Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-removed-field.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitContact(
id: Int,
name: String
)

implicit val implicitContactRW: ReadWriter[ImplicitContact] = Pickler.macroRW
9 changes: 9 additions & 0 deletions resources/scala-3/implicit-removed-field.scala_test.prev
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitContact(
id: Int,
name: String,
email: String
)

implicit val implicitContactRW: ReadWriter[ImplicitContact] = Pickler.macroRW
8 changes: 8 additions & 0 deletions resources/scala-3/implicit-serialization-added.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitProfile(
id: Int,
name: String
)

implicit val implicitProfileRW: ReadWriter[ImplicitProfile] = Pickler.macroRW
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
final case class ImplicitProfile(
id: Int,
name: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{Writer, Pickler}

final case class ImplicitOrder(
id: String,
total: BigDecimal
)

implicit val implicitOrderWriter: upickle.default.Writer[ImplicitOrder] = Pickler.macroW
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import upickle.default.{ReadWriter, Pickler}

final case class ImplicitOrder(
id: String,
total: BigDecimal
)

implicit val implicitOrderRW: ReadWriter[ImplicitOrder] = Pickler.macroRW
36 changes: 36 additions & 0 deletions resources/scala-3/implicit-serialization.scala_test
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import upickle.default.{ReadWriter, Pickler}

final case class AchOriginationJobArgs(
cutoff: java.time.LocalTime,
timezone: java.time.ZoneId,
companyName: Option[String] = None
)

implicit val rw: ReadWriter[AchOriginationJobArgs] = Pickler.macroRW

case class UserWithWriter(
id: Int,
name: String
)

implicit val writer: upickle.default.Writer[UserWithWriter] = Pickler.macroW

case class ProductWithReader(
sku: String,
price: Double
)

implicit val reader: upickle.default.Reader[ProductWithReader] = Pickler.macroR

case class OrderWithReadWriter(
orderId: String,
total: BigDecimal,
status: String = "pending"
)

implicit val orderRW: ReadWriter[OrderWithReadWriter] = Pickler.macroRW

case class NotSerializable(
field1: String,
field2: Int
)
89 changes: 69 additions & 20 deletions src/BreakingChangeDetector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ object BreakingChangeDetector {
newFile: ScalaFile
): List[CompareSummary] =
newFile.classes
.filter(checkIfClassHasDerivingAnnotation(_, serializableClasses))
.filter(checkIfClassHasSerialization(_, serializableClasses))
.map(newClass =>
oldFile.classes
.find(_.name == newClass.name)
Expand All @@ -39,7 +39,7 @@ object BreakingChangeDetector {
oldClass.name,
listOfRemovedFields(oldClass, newClass),
listOfAddedFieldsWithoutDefaultValue(oldClass, newClass),
checkIfDerivingAnnotationWasChanged(oldClass, newClass),
checkIfSerializationWasChanged(oldClass, newClass),
listOfFieldsThatDefaultValueWasRemoved(oldClass, newClass),
listOfFieldsThatDefaultValueWasAdded(oldClass, newClass)
)
Expand All @@ -64,13 +64,37 @@ object BreakingChangeDetector {
.filter(_.default.isEmpty)
.map(_.name)

/**
* Check if class has serialization via @deriving or derives (old way)
*/
private def checkIfClassHasDerivingAnnotation(
classInfo: ClassInfo,
initArgs: List[String]
): Boolean =
classInfo.annotations.exists(x =>
(x.name == "deriving" || x.name == "derives") && x.args.exists(initArgs.contains)
)

/**
* Check if class has serialization via implicit val instances (new way)
*/
private def checkIfClassHasImplicitSerialization(
classInfo: ClassInfo,
serializationTypes: List[String]
): Boolean =
classInfo.annotations.exists(x =>
x.name == "implicit" && x.args.exists(serializationTypes.contains)
)

/**
* Check if class has serialization via either old or new way
*/
private def checkIfClassHasSerialization(
classInfo: ClassInfo,
serializationTypes: List[String]
): Boolean =
checkIfClassHasDerivingAnnotation(classInfo, serializationTypes) ||
checkIfClassHasImplicitSerialization(classInfo, serializationTypes)
private def listOfFieldsThatDefaultValueWasAdded(
oldClass: ClassInfo,
newClass: ClassInfo
Expand All @@ -93,27 +117,52 @@ object BreakingChangeDetector {
)
.map(_.name)

private def checkIfDerivingAnnotationWasChanged(
oldClass: ClassInfo,
newClass: ClassInfo
): Boolean = {
val isOldClassContainsSerializable = !(oldClass.annotations
/**
* Extract serialization types from annotations (old way: deriving/derives)
*/
private def extractDerivingSerializationTypes(classInfo: ClassInfo): Set[String] = {
classInfo.annotations
.filter(x => x.name == "deriving" || x.name == "derives")
.flatMap(_.args)
.filter(x => serializableClasses.contains(x))
.length == 0)
.filter(serializableClasses.contains)
.toSet
}

val oldClassDerivingAnnotations =
oldClass.annotations.filter(x => x.name == "deriving" || x.name == "derives")
val newClassDerivingAnnotations =
newClass.annotations.filter(x => x.name == "deriving" || x.name == "derives")
/**
* Extract serialization types from implicit annotations (new way)
*/
private def extractImplicitSerializationTypes(classInfo: ClassInfo): Set[String] = {
classInfo.annotations
.filter(_.name == "implicit")
.flatMap(_.args)
.filter(serializableClasses.contains)
.toSet
}

isOldClassContainsSerializable &&
!oldClassDerivingAnnotations
.forall(oldAnnotation =>
newClassDerivingAnnotations.exists(newAnnotation => {
oldAnnotation.args.toSet.subsetOf(newAnnotation.args.toSet)
})
)
/**
* Get all serialization types (both old and new ways)
*/
private def getAllSerializationTypes(classInfo: ClassInfo): Set[String] = {
extractDerivingSerializationTypes(classInfo) ++ extractImplicitSerializationTypes(classInfo)
}

/**
* Check if serialization was changed (handles both old and new ways, and migration between them)
*/
private def checkIfSerializationWasChanged(
oldClass: ClassInfo,
newClass: ClassInfo
): Boolean = {
val oldSerializationTypes = getAllSerializationTypes(oldClass)
val newSerializationTypes = getAllSerializationTypes(newClass)

// If old class had serialization, check if it changed
if (oldSerializationTypes.nonEmpty) {
// Serialization types changed (e.g., ReadWriter -> Writer, or deriving -> implicit)
oldSerializationTypes != newSerializationTypes
} else {
// Old class didn't have serialization, so this is not a breaking change
false
}
}
}
68 changes: 59 additions & 9 deletions src/FileParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ object FileParser {
}.flatten
}

/**
* Parse implicit val declarations from the AST.
* Returns a map from class name to serialization type (ReadWriter, Writer, Reader, etc.)
*/
private def parseImplicitSerializationInstances(tree: Tree): Map[String, String] = {
def extractFromType(tpe: Type): Option[(String, String)] = tpe match {
case Type.Apply(Type.Name(serializationType), List(Type.Name(className))) =>
Some((className, serializationType))
case Type.Apply(Type.Select(_, Type.Name(serializationType)), List(Type.Name(className))) =>
Some((className, serializationType))
case _ => None
}

def collectImplicitVals(tree: Tree): List[(String, String)] = {
tree.children.flatMap {
case v: Defn.Val if v.mods.exists(_.isInstanceOf[Mod.Implicit]) =>
v.decltpe.flatMap(extractFromType).toList
case x => collectImplicitVals(x)
}
}

collectImplicitVals(tree).toMap
}

def addSuffixToDuplicates(list: List[ClassInfo]): List[ClassInfo] = {
var countMap = collection.mutable.Map[String, Int]().withDefaultValue(0)
list.map { c =>
Expand All @@ -45,24 +69,50 @@ object FileParser {
}
}

/**
* Extract annotations from @deriving or derives syntax (old way)
*/
private def extractDerivingAnnotations(classDef: Defn.Class): List[Annotation] = {
val derivingFromAnnotation =
classDef.mods
.flatMap(_.children)
.collect { case Init(tpe, _, args) =>
Annotation(tpe.toString, args.flatten.map(_.toString))
}
val derivingFromDerives =
classDef.templ.derives.map(d => Annotation("derives", List(d.toString)))
derivingFromAnnotation ++ derivingFromDerives
}

/**
* Extract annotations from implicit val instances (new way)
*/
private def extractImplicitSerializationAnnotations(
className: String,
implicitInstances: Map[String, String]
): List[Annotation] = {
implicitInstances
.get(className)
.map(serializationType => Annotation("implicit", List(serializationType)))
.toList
}

def parse(
content: String,
dialect: Dialect = dialects.Scala213Source3
): ScalaFile = {
val input = Input.String(content)
val exampleTree: Source = dialect(input).parse[Source].get

// Parse implicit serialization instances (new way)
val implicitInstances = parseImplicitSerializationInstances(exampleTree)

// Parse classes with their annotations (old way)
val tree =
parseTreeClasses(exampleTree)
.map(c => {
val derivingFromAnnotation =
c.mods
.flatMap(_.children)
.collect { case Init(tpe, _, args) =>
Annotation(tpe.toString, args.flatten.map(_.toString))
}
val derivingFromDerives =
c.templ.derives.map(d => Annotation("derives", List(d.toString)))
val derivingAnnotations = extractDerivingAnnotations(c)
val implicitAnnotations = extractImplicitSerializationAnnotations(c.name.value, implicitInstances)
ClassInfo(
c.name.value,
c.ctor.paramss.headOption.getOrElse(Nil).map(p =>
Expand All @@ -72,7 +122,7 @@ object FileParser {
p.default.map(_.toString)
)
),
derivingFromAnnotation ++ derivingFromDerives
derivingAnnotations ++ implicitAnnotations
)
})
ScalaFile(
Expand Down
Loading