Skip to content

Commit

Permalink
Initial commit: Add most of circe-generic-extras rewrites allowing fo…
Browse files Browse the repository at this point in the history
…r migration from Scala 2 to Scala 3
  • Loading branch information
WojciechMazur committed Mar 27, 2024
1 parent f05daa2 commit 3225649
Show file tree
Hide file tree
Showing 30 changed files with 2,103 additions and 4 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Tests
on:
pull_request:
push:
branches:
- main

jobs:
tests:
name: Test scalafix rules
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: coursier/cache-action@v6
- uses: coursier/setup-action@v1
with:
jvm: adopt:17

- name: Test rules
run: sbt "test"
19 changes: 15 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
*.class
*.log
# sbt specific
**/target/
project/plugins/project/

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
# VS Code
.vscode/
# Metals
**/.bloop/
**/.metals/
**/metals.sbt

# Bloop
**/.bsp

# scala-cli
**/.scala-build
8 changes: 8 additions & 0 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version = "3.7.15"
runner.dialect = scala213
maxColumn = 120
fileOverride {
"glob:**/output/src/main/scala/**" {
runner.dialect = scala3
}
}
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Scalafix rules for circe-generic-extra migration

This project contains set of rules allowing to migrate Scala 2 macro-annotation based codecs into Scala 3 built-in derivation.

## Available rules:

### `CirceGenericExtrasMigration`
Main rule, detects usages of `JsonCodec`, `ConfiguredJsonCodec` macro-annotations and replaces them with derived `Codec.AsObject` or `ConfiguredCodec` instances.
Supports `@JsonKey` annotation used to renaem fields defined either in primary constructor or as member fields (renames or member fields are working using best effort principles).
Supoprts `ConfiguredJsonCodec(encodeOnly = true)` and `ConfiguredJsonCodec(decodeOnly = true)` variants, these would be rewritten into `ConfiguredEncoder` or `ConfiguredDecoder` derived instances.
Can be used to migrate usages of both `circe-generic-extras` and `circe-derivation` to Scala 3.

### `CirceLiteralMigration`
Simple rule allowing to adopt to `json` string interpolator changes done in Scala 3. Would rewrite imports `io.circe.literal.JsonStringContext` into `io.circe.literal.json`.

## Usage
For information on how to use this projects refer to [Scalafix user guide](https://scalacenter.github.io/scalafix/docs/users/installation.html)
90 changes: 90 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
lazy val V = _root_.scalafix.sbt.BuildInfo

val Scala2Version = "2.13.13"
val Scala3Version = "3.4.0"

inThisBuild(
List(
organization := "org.virtuslab",
homepage := Some(url("https://github.com/VirtusLabRnD/scalafix-migrate-circe-generic-extras")),
licenses := List(
"Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")
),
developers := List(
Developer("WojciechMazur", "Wojciech Mazur", "[email protected]", url("https://github.com/WojciechMazur"))
),
scalaVersion := Scala2Version,
semanticdbEnabled := true,
semanticdbIncludeInJar := true,
semanticdbVersion := scalafixSemanticdb.revision
)
)

(publish / skip) := true

lazy val rules = project.settings(
moduleName := "scalafix-migrate-circe-generic-extras",
libraryDependencies += "ch.epfl.scala" %% "scalafix-core" % V.scalafixVersion
)

// Dependencies mostly used to check compilation of circe-generic sources
val circeVersion = "0.14.6"
val circeGenericExtrasVersion = "0.14.3"
val circeDerivationVersion = "0.13.0-M5"
val jawnVersion = "1.5.1"
val munitVersion = "0.7.29"
val disciplineMunitVersion = "1.0.9"
val enumeratumVersion = "1.7.3"

lazy val commonTestDependencies = List(
// used in examples
"com.beachape" %% "enumeratum" % enumeratumVersion,
"com.beachape" %% "enumeratum-circe" % enumeratumVersion,
"io.circe" %% "circe-core" % circeVersion,
"io.circe" %% "circe-literal" % circeVersion,
"io.circe" %% "circe-parser" % circeVersion,
// Used by circe-generic-extra tests
"io.circe" %% "circe-testing" % circeVersion,
"org.scalameta" %% "munit" % munitVersion,
"org.scalameta" %% "munit-scalacheck" % munitVersion,
"org.typelevel" %% "discipline-munit" % disciplineMunitVersion,
"org.typelevel" %% "jawn-parser" % jawnVersion
)

lazy val input = project.settings(
(publish / skip) := true,
scalaVersion := Scala2Version,
scalacOptions ++= Seq(
"-Ymacro-annotations"
),
libraryDependencySchemes ++= Seq(
"io.circe" %% "circe-core" % VersionScheme.Always // See https://github.com/circe/circe-derivation/issues/346
),
libraryDependencies ++= commonTestDependencies ++ Seq(
"io.circe" %% "circe-derivation" % circeDerivationVersion,
"io.circe" %% "circe-derivation-annotations" % circeDerivationVersion,
"io.circe" %% "circe-generic-extras" % circeGenericExtrasVersion
)
)

lazy val output = project.settings(
(publish / skip) := true,
scalaVersion := Scala3Version,
libraryDependencies ++= commonTestDependencies ++ Seq(
"io.circe" %% "circe-generic" % circeVersion
)
)

lazy val tests = project
.settings(
(publish / skip) := true,
libraryDependencies += "ch.epfl.scala" % "scalafix-testkit" % V.scalafixVersion % Test cross CrossVersion.full,
scalafixTestkitOutputSourceDirectories :=
(output / Compile / unmanagedSourceDirectories).value,
scalafixTestkitInputSourceDirectories :=
(input / Compile / unmanagedSourceDirectories).value,
scalafixTestkitInputClasspath :=
(input / Compile / fullClasspath).value
)
.dependsOn(rules)
.enablePlugins(ScalafixTestkitPlugin)
55 changes: 55 additions & 0 deletions input/src/main/scala/test/CirceDerivationAnnotations.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
rule = CirceGenericExtrasMigration
*/
import io.circe.derivation.annotations._
import io.circe.{Codec, Encoder, Decoder}

object CirceDerivationAnnotations {
object jsonCodec {
@JsonCodec() case class CClass0()
@JsonCodec() case class CClass1(name: String)
@JsonCodec() case class CClass1Generic1[T](v: T)
@JsonCodec() case class CClass2Generic2[A, B](a: A, b: B)
@JsonCodec() sealed trait T1 { def name: String }
@JsonCodec() case class T1Impl(name: String, count: Int) extends T1

implicitly[Codec[CClass0]]
implicitly[Codec[CClass1]]
implicitly[Codec[CClass1Generic1[Int]]]
implicitly[Codec[CClass2Generic2[String, CClass0]]]
implicitly[Codec[T1]]
implicitly[Codec[T1Impl]]
}

object jsonExplicitCodec {
@JsonCodec(Configuration.default) case class CClass0()
@JsonCodec(Configuration.default) case class CClass1(name: String)
@JsonCodec(Configuration.default) case class CClass1Generic1[T](v: T)
@JsonCodec(Configuration.default) case class CClass2Generic2[A, B](a: A, b: B)
@JsonCodec(Configuration.default) sealed trait T1 { def name: String }
@JsonCodec(Configuration.default) case class T1Impl(name: String, count: Int) extends T1

implicitly[Codec[CClass0]]
implicitly[Encoder[CClass1]]
implicitly[Decoder[CClass1Generic1[Int]]]
implicitly[Codec[CClass2Generic2[String, CClass0]]]
implicitly[Encoder[T1]]
implicitly[Encoder[T1Impl]]
}

object jsonCodecWithJsonKey {
@JsonCodec() case class CClass0()
@JsonCodec() case class CClass1(@JsonKey("id") name: String)
@JsonCodec() case class CClass1Generic1[T](@JsonKey("firstValue")v: T)
@JsonCodec() case class CClass2Generic2[A, B](@JsonKey("first")a: A, @JsonKey("secondValue")b: B)
// @JsonCodec() sealed trait T1 { @JsonKey("memberName") def name: String }
// @JsonCodec() case class T1Impl(name: String, count: Int) extends T1

implicitly[Codec[CClass0]]
implicitly[Encoder[CClass1]]
implicitly[Decoder[CClass1Generic1[Int]]]
implicitly[Codec[CClass2Generic2[String, CClass0]]]
// implicitly[Encoder[T1]]
// implicitly[Encoder[T1Impl]]
}
}
42 changes: 42 additions & 0 deletions input/src/main/scala/test/CirceGenericExtras.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
rule = CirceGenericExtrasMigration
*/
import io.circe.generic.extras._
import io.circe.derivation.annotations.{ JsonCodec }
import io.circe.{Codec, Encoder, Decoder}

object CirceGenericExtras {
implicit val defaultConfig: Configuration = Configuration.default

object configuredJsonCodec {
@ConfiguredJsonCodec() case class CClass0()
@ConfiguredJsonCodec(encodeOnly = true) case class CClass1(name: String)
@ConfiguredJsonCodec(decodeOnly = true) case class CClass1Generic1[T](v: T)
@ConfiguredJsonCodec() case class CClass2Generic2[A, B](a: A, b: B)
@ConfiguredJsonCodec() sealed trait T1 { def name: String }
@ConfiguredJsonCodec() case class T1Impl(name: String, count: Int) extends T1

implicitly[Codec[CClass0]]
implicitly[Encoder[CClass1]]
implicitly[Decoder[CClass1Generic1[Int]]]
// implicitly[Codec[CClass2Generic2[String, CClass0]]]
implicitly[Codec[T1]]
implicitly[Codec[T1Impl]]
}

object jsonCodecWithJsonKey {
@ConfiguredJsonCodec() case class CClass0()
@ConfiguredJsonCodec() case class CClass1(@JsonKey("id") name: String)
@ConfiguredJsonCodec() case class CClass1Generic1[T](@JsonKey("firstValue")v: T)
@ConfiguredJsonCodec() case class CClass2Generic2[A, B](@JsonKey("first")a: A, @JsonKey("secondValue")b: B)
// @ConfiguredJsonCodec() sealed trait T1 { @JsonKey("memberName") def name: String }
// @ConfiguredJsonCodec() case class T1Impl(name: String, count: Int) extends T1

implicitly[Codec[CClass0]]
implicitly[Encoder[CClass1]]
implicitly[Decoder[CClass1Generic1[Int]]]
// implicitly[Codec[CClass2Generic2[String, CClass0]]]
// implicitly[Encoder[T1]]
// implicitly[Encoder[T1Impl]]
}
}
51 changes: 51 additions & 0 deletions input/src/main/scala/test/Example1.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
rules = [CirceGenericExtrasMigration, CirceLiteralMigration]
*/
package test

import enumeratum.EnumEntry.UpperSnakecase
import enumeratum.{CirceEnum, EnumEntry}
import io.circe.Decoder.Result
import io.circe.derivation.annotations.JsonCodec
import io.circe.derivation.annotations.{JsonCodec => JsonCodecAlias}
import io.circe.generic.extras.{ConfiguredJsonCodec, Configuration}
import io.circe.literal.JsonStringContext
import io.circe.syntax.EncoderOps

object Example1 extends App {

// 1. CirceGenericExtrasMigration
@JsonCodec
case class Person(name: String, age: Int)
println("Person: " + Person("Bob", 42).asJson)

@JsonCodecAlias
case class Address(line: String)

implicit val config: Configuration = Configuration.default
@ConfiguredJsonCodec
case class Foo(v: Int)
@ConfiguredJsonCodec(decodeOnly = true)
case class FooRead(foo: Foo)
@ConfiguredJsonCodec(encodeOnly = true)
case class FooWrite(foo: Foo)

// 2. CirceEnumeratumMigration
sealed trait Color extends EnumEntry with UpperSnakecase
object Color extends enumeratum.Enum[Color] with CirceEnum[Color] {
case object Red extends Color
case object Green extends Color
case object Blue extends Color
val values = findValues
}

val color: Color = Color.Red
println("Color: " + color.asJson)

// 3. CirceLiteralMigration
val decodedPerson: Result[Person] = json"""{
"name" : "Sam",
"age" : 70
}""".as[Person]
println("Decoded: " + decodedPerson)
}
55 changes: 55 additions & 0 deletions input/src/main/scala/test/Example2.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
rules = [CirceGenericExtrasMigration, CirceLiteralMigration]
*/
package test

import io.circe.Codec
import io.circe.generic.extras.{Configuration, ConfiguredJsonCodec, JsonKey}
import io.circe.syntax.EncoderOps
import java.time.Instant

object CirceUsage extends App {

// 1. deriveUnwrappedCodec
case class ThingId(value: String) extends AnyVal
object ThingId {
implicit val codec: Codec[ThingId] =
io.circe.generic.extras.semiauto.deriveUnwrappedCodec
}

// 2. @JsonKey annotation
@ConfiguredJsonCodec
case class ExternalServiceRequest(
id: ThingId,
@JsonKey("snake_case_field") snakeCaseField: String,
@JsonKey("acheivedAt") achievedAt: Instant // Do not inherit misspelling
)
object ExternalServiceRequest {
implicit val config: Configuration = Configuration.default
}

println(
"Request example: " + ExternalServiceRequest(
ThingId("abc123"),
"foo",
Instant.now()
).asJson
)

// 3. Sum types with a discriminator
@ConfiguredJsonCodec
sealed trait Fruit
object Fruit {
implicit val configuration: Configuration =
Configuration.default.withScreamingSnakeCaseConstructorNames
.withDiscriminator("fruitType")

case class Banana(curvature: Double) extends Fruit
case class Apple(diameter: Double, variety: String) extends Fruit
case object Grape extends Fruit
}
import Fruit.{Apple, Banana, Grape}

val fruits: List[Fruit] = List(Banana(3.14), Apple(0.07, "Fuji"), Grape)
println("Fruits: " + fruits.asJson)
}
Loading

0 comments on commit 3225649

Please sign in to comment.