Skip to content

Commit 8267a98

Browse files
Add ability to use @semi with type classes without a companion object
1 parent 0c5edb7 commit 8267a98

File tree

9 files changed

+157
-55
lines changed

9 files changed

+157
-55
lines changed

Diff for: catnip.sbt

+15-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ lazy val root = project.root
77
.setDescription("Catnip build")
88
.configureRoot
99
.noPublish
10-
.aggregate(catnipJVM, catnipJS, catnipTestsJVM, catnipTestsJS)
10+
.aggregate(catnipJVM, catnipJS, catnipCustomTestsJVM, catnipCustomTestsJS, catnipTestsJVM, catnipTestsJS)
1111

1212
lazy val catnip = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).build.from("catnip")
1313
.setName("catnip")
@@ -21,11 +21,24 @@ lazy val catnipJVM = catnip.jvm
2121
lazy val catnipJS = catnip.js
2222
.settings(Compile / unmanagedResourceDirectories += baseDirectory.value / "../src/main/resources")
2323

24+
lazy val catnipCustomTests = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).build.from("catnip-custom-example")
25+
.setName("catnip-custom-example")
26+
.setDescription("Example for custom derivation")
27+
.setInitialImport("cats.implicits._, alleycats.std.all._")
28+
.dependsOn(catnip)
29+
.configureModule
30+
.noPublish
31+
32+
lazy val catnipCustomTestsJVM = catnipCustomTests.jvm
33+
.settings(Compile / unmanagedResourceDirectories += baseDirectory.value / "../src/main/resources")
34+
lazy val catnipCustomTestsJS = catnipCustomTests.js
35+
.settings(Compile / unmanagedResourceDirectories += baseDirectory.value / "../src/main/resources")
36+
2437
lazy val catnipTests = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).build.from("catnip-tests")
2538
.setName("catnip-tests")
2639
.setDescription("Catnip tests")
2740
.setInitialImport("cats.implicits._, alleycats.std.all._")
28-
.dependsOn(catnip)
41+
.dependsOn(catnip, catnipCustomTests)
2942
.configureModule
3043
.configureTests()
3144
.noPublish
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.scalaland.catnip.CustomDerivation=io.scalaland.catnip.NotACompanion.derive
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.scalaland.catnip.FakeCompanion=io.scalaland.catnip.CustomDerivation
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.scalaland.catnip
2+
3+
import shapeless._
4+
5+
// define a type class (or have a library define it for you)
6+
trait CustomDerivation[A] {
7+
8+
def doSomething(a: A): A
9+
}
10+
11+
object NotACompanion {
12+
13+
trait Derived[A] extends CustomDerivation[A]
14+
15+
private def instance[A](f: A => A): Derived[A] = new Derived[A] {
16+
override def doSomething(a: A) = f(a)
17+
}
18+
19+
// let's pretend this is semi-auto macro or sth
20+
//
21+
// add full.name.to.derive.method into derive.semi.conf e.g.:
22+
// io.scalaland.catnip.CustomDerivation=io.scalaland.catnip.NotACompanion.derive
23+
def derive[A](implicit tc: Derived[A]): CustomDerivation[A] = tc
24+
25+
implicit def hlist[A, ARepr <: HList](implicit gen: Generic.Aux[A, ARepr], reprTC: Derived[ARepr]): Derived[A] =
26+
instance { a =>
27+
gen.from(reprTC.doSomething(gen.to(a)))
28+
}
29+
30+
implicit val hNil: Derived[HNil] = instance { _ =>
31+
HNil
32+
}
33+
34+
implicit def hCons[H, T <: HList](implicit hTC: Derived[H], tTC: Derived[T]): Derived[H :: T] = instance {
35+
case h :: t =>
36+
hTC.doSomething(h) :: tTC.doSomething(t)
37+
}
38+
39+
implicit val string: Derived[String] = instance(s => s)
40+
41+
implicit val int: Derived[Int] = instance(s => s)
42+
}
43+
44+
// if a type class is missing a companion which we could pass as @Semi(TypeClass)
45+
// we could create a fake companion which we could use instead and configure it in derive.stub.conf e.g.
46+
// io.scalaland.catnip.FakeCompanion=io.scalaland.catnip.CustomDerivation
47+
object FakeCompanion
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.scalaland.catnip
2+
3+
import org.specs2.mutable.Specification
4+
5+
@Semi(FakeCompanion) final case class CustomType(a: String, b: Int)
6+
7+
class CustomSpec extends Specification {
8+
9+
"customized @Semi" should {
10+
11+
"handle custom configs" in {
12+
// given
13+
val value = CustomType("test", 5354)
14+
15+
// when
16+
val result = implicitly[CustomDerivation[CustomType]].doSomething(value)
17+
18+
// then
19+
result must_=== value
20+
}
21+
}
22+
}

Diff for: modules/catnip/src/main/scala/io/scalaland/catnip/internals/DerivedImpl.scala

+70-35
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
11
package io.scalaland.catnip.internals
22

3+
import cats.implicits._
4+
import cats.data.{ Validated, ValidatedNel }
5+
import io.scalaland.catnip.internals.DerivedImpl.Config
6+
37
import scala.language.experimental.macros
48
import scala.reflect.macros.whitebox.Context
5-
import scala.util.{ Failure, Success, Try }
9+
import scala.util
610

7-
@SuppressWarnings(Array("org.wartremover.warts.StringPlusAny"))
8-
private[catnip] class DerivedImpl(config: Map[String, (String, List[String])])(val c: Context)(annottees: Seq[Any])
9-
extends Loggers {
11+
private[catnip] class DerivedImpl(mappings: Map[String, Config], stubs: Map[String, Config])(val c: Context)(
12+
annottees: Seq[Any]
13+
) extends Loggers {
1014

1115
import c.universe._
1216

1317
private type TypeClass = TypeName
1418

1519
// TODO: there must be a better way to dealias F[_] type
1620
private def str2TypeConstructor(typeClassName: String): Type =
17-
c.typecheck(c.parse(s"null: $typeClassName[Nothing]")).tpe.dealias.typeConstructor
21+
util
22+
.Try {
23+
// allows using "fake" companion object if a type class is missing one, and use it to redirect to the right type
24+
val key = c.typecheck(c.parse(s"null: ${typeClassName}.type")).tpe.dealias.toString
25+
val stub = stubs.get(key).orElse(stubs.get(key.substring(0, key.length - ".type".length))).get.target
26+
c.typecheck(c.parse(s"null: ${stub}[Nothing]")).tpe.dealias.typeConstructor
27+
}
28+
.orElse(util.Try {
29+
c.typecheck(c.parse(s"null: ${typeClassName}[Nothing]")).tpe.dealias.typeConstructor
30+
})
31+
.get
1832

1933
private def isParametrized(name: TypeName): String => Boolean =
2034
s"""(^|[,\\[])$name([,\\]]|$$)""".r.pattern.asPredicate.test _
@@ -27,7 +41,7 @@ private[catnip] class DerivedImpl(config: Map[String, (String, List[String])])(v
2741
val fType = str2TypeConstructor(typeClassName.toString)
2842
val implName = TermName(s"_derived_${fType.toString.replace('.', '_')}")
2943
lazy val aType = if (params.nonEmpty) TypeName(tq"$name[..${params.map(_.name)}]".toString) else name
30-
val body = c.parse(s"{ ${config(fType.toString)._1}[$aType] }")
44+
val body = c.parse(s"{ ${mappings(fType.toString).target}[$aType] }")
3145
val returnType = tq"$fType[$aType]"
3246
// TODO: figure out, why this doesn't work
3347
// q"""implicit val $implName: $returnType = $body""": ValDef
@@ -38,8 +52,8 @@ private[catnip] class DerivedImpl(config: Map[String, (String, List[String])])(v
3852
with ..$_ { $_ => ..$_ }""" =>
3953
withTraceLog(s"Derivation expanded for $name class") {
4054
val fType = str2TypeConstructor(typeClassName.toString)
41-
val otherReqTCs = config(fType.toString)._2.map(str2TypeConstructor)
42-
val needKind = scala.util.Try(c.typecheck(c.parse(s"null: $fType[List]"))).isSuccess
55+
val otherReqTCs = mappings(fType.toString).arguments.map(str2TypeConstructor)
56+
val needKind = util.Try(c.typecheck(c.parse(s"null: $fType[List]"))).isSuccess
4357
val implName = TermName(s"_derived_${fType.toString.replace('.', '_')}")
4458
lazy val aType = TypeName(if (params.nonEmpty) tq"$name[..${params.map(_.name)}]".toString else name.toString)
4559
lazy val argTypes =
@@ -58,7 +72,7 @@ private[catnip] class DerivedImpl(config: Map[String, (String, List[String])])(v
5872
}
5973
.mkString("")
6074
val tcForType = if (needKind) name else aType
61-
val body = c.parse(s"{ $suppressUnused${config(fType.toString)._1}[$tcForType] }")
75+
val body = c.parse(s"{ $suppressUnused${mappings(fType.toString).target}[$tcForType] }")
6276
val returnType = tq"$fType[$tcForType]"
6377
// TODO: figure out, why this doesn't work
6478
// if (usedParams.isEmpty) q"""implicit val $implName: $returnType = $body""": ValDef
@@ -106,34 +120,55 @@ private[catnip] class DerivedImpl(config: Map[String, (String, List[String])])(v
106120

107121
private[catnip] object DerivedImpl {
108122

109-
private def loadConfig(name: String): Either[String, Map[String, (String, List[String])]] =
110-
Try {
111-
scala.io.Source
112-
.fromURL(getClass.getClassLoader.getResources(name).nextElement)
113-
.getLines
114-
.map(_.trim)
115-
.filterNot(_ startsWith """////""")
116-
.filterNot(_ startsWith """#""")
117-
.filterNot(_.isEmpty)
118-
.map { s =>
119-
val kv = s.split('=')
120-
val typeClass = kv(0)
121-
val generator :: otherRequiredTC = kv(1).split(',').toList
122-
typeClass.trim -> (generator -> otherRequiredTC)
123-
}
124-
.toMap
125-
} match {
126-
case Success(value) => Right(value)
127-
case Failure(_: java.util.NoSuchElementException) =>
128-
Left(s"Unable to load $name using ${getClass.getClassLoader.toString}")
129-
case Failure(err: Throwable) => Left(err.getMessage)
130-
}
123+
final case class Config(target: String, arguments: List[String])
124+
125+
private def loadConfig(name: String): ValidatedNel[String, Map[String, Config]] = {
126+
val configFiles = getClass.getClassLoader.getResources(name)
127+
Iterator
128+
.continually {
129+
if (configFiles.hasMoreElements) Some(configFiles.nextElement())
130+
else None
131+
}
132+
.takeWhile(_.isDefined)
133+
.collect {
134+
case Some(url) =>
135+
val source = scala.io.Source.fromURL(url)
136+
try {
137+
Validated.valid(
138+
source.getLines
139+
.map(_.trim)
140+
.filterNot(_ startsWith raw"""//""")
141+
.filterNot(_ startsWith raw"""#""")
142+
.filterNot(_.isEmpty)
143+
.map { s =>
144+
val kv = s.split('=')
145+
val typeClass = kv(0)
146+
val generator :: otherRequiredTC = kv(1).split(',').toList
147+
typeClass.trim -> (Config(generator, otherRequiredTC))
148+
}
149+
.toMap
150+
)
151+
} catch {
152+
case _: java.util.NoSuchElementException =>
153+
Validated.invalidNel(s"Unable to load $name using ${getClass.getClassLoader.toString} - failed at $url")
154+
case err: Throwable =>
155+
Validated.invalidNel(err.getMessage)
156+
} finally {
157+
source.close()
158+
}
159+
}
160+
.toList
161+
.sequence
162+
.map(_.fold(Map.empty[String, Config])(_ ++ _))
163+
}
131164

132-
private val mappingsE: Either[String, Map[String, (String, List[String])]] = loadConfig("derive.semi.conf")
165+
private val mappingsE: ValidatedNel[String, Map[String, Config]] = loadConfig("derive.semi.conf")
166+
private val stubsE: ValidatedNel[String, Map[String, Config]] = loadConfig("derive.stub.conf")
133167

134168
def impl(c: Context)(annottees: Seq[c.Expr[Any]]): c.Expr[Any] =
135-
mappingsE match {
136-
case Right(mappings) => new DerivedImpl(mappings)(c)(annottees).derive().asInstanceOf[c.Expr[Any]]
137-
case Left(error) => c.abort(c.enclosingPosition, error)
169+
(mappingsE, stubsE).tupled match {
170+
case Validated.Valid((mappings, stubs)) =>
171+
new DerivedImpl(mappings, stubs)(c)(annottees).derive().asInstanceOf[c.Expr[Any]]
172+
case Validated.Invalid(errors) => c.abort(c.enclosingPosition, errors.mkString_("\n"))
138173
}
139174
}

Diff for: modules/catnip/src/main/scala/io/scalaland/catnip/internals/Loggers.scala

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.scalaland.catnip.internals
22

3-
@SuppressWarnings(Array("org.wartremover.warts.StringPlusAny"))
43
private[internals] trait Loggers {
54

65
sealed abstract class Level(val name: String, val ordinal: Int) extends Product with Serializable {

Diff for: project/Settings.scala

+1-16
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import com.lucidchart.sbt.scalafmt.ScalafmtCorePlugin.autoImport._
77
import org.scalastyle.sbt.ScalastylePlugin.autoImport._
88
import sbtcrossproject.CrossProject
99
import scoverage._
10-
import wartremover._
1110

1211
object Settings extends Dependencies {
1312

@@ -131,21 +130,7 @@ object Settings extends Dependencies {
131130

132131
Compile / scalafmtOnCompile := true,
133132

134-
scalastyleFailOnError := true,
135-
136-
Compile / compile / wartremoverWarnings ++= Warts.allBut(
137-
Wart.Any,
138-
Wart.AsInstanceOf,
139-
Wart.DefaultArguments,
140-
Wart.ExplicitImplicitTypes,
141-
Wart.ImplicitConversion,
142-
Wart.ImplicitParameter,
143-
Wart.Overloading,
144-
Wart.PublicInference,
145-
Wart.NonUnitStatements,
146-
Wart.Nothing,
147-
Wart.ToString
148-
)
133+
scalastyleFailOnError := true
149134
) ++ mainDeps ++ Seq(
150135
libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value % "provided",
151136
libraryDependencies += "org.scala-lang" % "scala-compiler" % scalaVersion.value % "provided"

Diff for: project/plugins.sbt

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")
88
addSbtPlugin("org.scala-js" % "sbt-scalajs" % scalaJSVersion)
99
addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0")
1010
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1")
11-
addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.4.2")
1211

1312
addSbtPlugin("com.lihaoyi" % "scalatex-sbt-plugin" % "0.3.11")
1413
addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3")

0 commit comments

Comments
 (0)