Skip to content

Commit

Permalink
Merge pull request #9 from estatico/abstract-type
Browse files Browse the repository at this point in the history
Encode newtypes with abstract type aliases
  • Loading branch information
carymrobbins authored Feb 27, 2018
2 parents 5576916 + 1ccaf32 commit 0f587d6
Show file tree
Hide file tree
Showing 8 changed files with 253 additions and 65 deletions.
79 changes: 50 additions & 29 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,39 @@ import ReleaseTransformations._
organization in ThisBuild := "io.estatico"

lazy val root = project.in(file("."))
.aggregate(newtypeJS, newtypeJVM)
.dependsOn(newtypeJS, newtypeJVM)
.settings(
publish := (),
publishLocal := (),
publishArtifact := false
)
.aggregate(newtypeJS, newtypeJVM, catsTestsJVM, catsTestsJS)
.settings(noPublishSettings)

lazy val newtype = crossProject.in(file(".")).settings(
name := "newtype",
lazy val newtype = crossProject.in(file("."))
.settings(defaultSettings)
.settings(releasePublishSettings)
.settings(name := "newtype")

scalacOptions ++= Seq(
"-Xfatal-warnings",
"-unchecked",
"-feature",
"-deprecation",
"-language:higherKinds",
"-language:implicitConversions",
"-language:experimental.macros"
),
lazy val newtypeJVM = newtype.jvm
lazy val newtypeJS = newtype.js

libraryDependencies ++= Seq(
"org.typelevel" %% "macro-compat" % "1.1.1",
scalaOrganization.value % "scala-reflect" % scalaVersion.value % Provided,
scalaOrganization.value % "scala-compiler" % scalaVersion.value % Provided,
"org.scalacheck" %%% "scalacheck" % "1.13.4" % "test",
"org.scalatest" %%% "scalatest" % "3.0.0" % "test"
),
lazy val catsTests = crossProject.in(file("cats-tests"))
.dependsOn(newtype)
.settings(defaultSettings)
.settings(noPublishSettings)
.settings(
name := "newtype-cats-tests",
description := "Test suite for newtype + cats interop",
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-core" % "1.0.1"
)
)

addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full),
lazy val catsTestsJVM = catsTests.jvm
lazy val catsTestsJS = catsTests.js

// Publish settings
lazy val noPublishSettings = Seq(
publish := (),
publishLocal := (),
publishArtifact := false
)

lazy val releasePublishSettings = Seq(
releaseCrossBuild := true,
releasePublishArtifactsAction := PgpKeys.publishSigned.value,
releaseProcess := Seq[ReleaseStep](
Expand Down Expand Up @@ -87,5 +87,26 @@ lazy val newtype = crossProject.in(file(".")).settings(
).toSeq
)

lazy val newtypeJVM = newtype.jvm
lazy val newtypeJS = newtype.js
lazy val defaultSettings = Seq(
defaultScalacOptions,
defaultLibraryDependencies,
addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full)
)

lazy val defaultScalacOptions = scalacOptions ++= Seq(
"-Xfatal-warnings",
"-unchecked",
"-feature",
"-deprecation",
"-language:higherKinds",
"-language:implicitConversions",
"-language:experimental.macros"
)

lazy val defaultLibraryDependencies = libraryDependencies ++= Seq(
"org.typelevel" %% "macro-compat" % "1.1.1",
scalaOrganization.value % "scala-reflect" % scalaVersion.value % Provided,
scalaOrganization.value % "scala-compiler" % scalaVersion.value % Provided,
"org.scalacheck" %%% "scalacheck" % "1.13.4" % "test",
"org.scalatest" %%% "scalatest" % "3.0.0" % "test"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.estatico.newtype

import cats._
import cats.implicits._
import io.estatico.newtype.ops._
import io.estatico.newtype.macros.newtype
import org.scalatest.{FlatSpec, Matchers}

class NewTypeCatsTest extends FlatSpec with Matchers {

import NewTypeCatsTest._

behavior of "Functor[Nel]"

it should "be the same as Functor[List]" in {
Functor[Nel] shouldBe Functor[List]
}

it should "get extension methods" in {
Nel.of(1, 2, 3).map(_ * 2) shouldBe Nel.of(2, 4, 6)
}

behavior of "Monad[Nel]"

it should "be the same as Monad[List]" in {
Monad[Nel] shouldBe Monad[List]
}

it should "get extension methods" in {
1.pure[Nel] shouldBe Nel.of(1)
Nel.of(1, 2, 3).flatMap(x => Nel.of(x, x * 2)) shouldBe
Nel.of(1, 2, 2, 4, 3, 6)
}

it should "work in for comprehensions" in {
val res = for {
x <- Nel.of(1, 2, 3)
y <- Nel.of(x, x * 2)
} yield x + y

res shouldBe Nel.of(2, 3, 4, 6, 6, 9)
}

it should "work in the same scope in which it is defined" in {
testNelTypeAliasExpansion shouldBe testNelTypeAliasExpansionExpectedResult
}
}

object NewTypeCatsTest {

@newtype class Nel[A](val toList: List[A]) {
def head: A = toList.head
def tail: List[A] = toList.tail
def iterator: Iterator[A] = toList.iterator
}
object Nel {
def apply[A](head: A, tail: List[A]): Nel[A] = (head +: tail).coerce
def of[A](head: A, tail: A*): Nel[A] = (head +: tail.toList).coerce
implicit def show[A](implicit A: Show[A]): Show[Nel[A]] = new Show[Nel[A]] {
def show(nel: Nel[A]): String = "Nel(" + nel.iterator.map(A.show).mkString(",") + ")"
}
implicit def monoid[A]: Monoid[Nel[A]] = deriving
implicit val monad: Monad[Nel] = derivingK
}

// See https://github.com/scala/bug/issues/10750
private val testNelTypeAliasExpansion = for {
x <- Nel.of(1, 2, 3)
y <- Nel.of(x, x * 2)
} yield x + y

private val testNelTypeAliasExpansionExpectedResult = Nel.of(2, 3, 4, 6, 6, 9)
}
16 changes: 14 additions & 2 deletions shared/src/main/scala/io/estatico/newtype/BaseNewType.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.estatico.newtype

import scala.reflect.ClassTag

/** Base skeleton for building newtypes. */
trait BaseNewType {
type Base
Expand All @@ -20,13 +22,23 @@ trait BaseNewType {
@inline implicit def cannotUnwrapArrayAmbiguous2: Coercible[Array[Type], Array[Repr]] = Coercible.instance
}

object BaseNewType {
// Scala 2.10 doesn't support abstract type aliases in object definitions, so
// we have to create the abstract type alias Aux in a trait and have the
// BaseNewType object extend from it.
trait BaseNewType$Types {
/** `Type` implementation for all newtypes; see `BaseNewType`. */
type Aux[B, T, R] = B with Meta[T, R]
type Aux[B, T, R] <: B with Meta[T, R]
trait Meta[T, R]
}

object BaseNewType extends BaseNewType$Types {

/** Helper trait to refine Repr via a type parameter. */
trait Of[R] extends BaseNewType {
final type Repr = R
}

// Since Aux is abstract, this is necessary to make Arrays work.
@inline implicit def classTag[B, T, R](implicit base: ClassTag[B]): ClassTag[Aux[B, T, R]] =
ClassTag(base.runtimeClass)
}
2 changes: 1 addition & 1 deletion shared/src/main/scala/io/estatico/newtype/NewType.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ object NewType {
trait NewTypeAutoOps extends BaseNewType {
implicit def toNewTypeOps(
x: Type
): NewTypeOps[Type, Tag, Repr] = new NewTypeOps[Type, Tag, Repr](x)
): NewTypeOps[Base, Tag, Repr] = new NewTypeOps[Base, Tag, Repr](x)
}

trait NewTypeApply extends BaseNewType {
Expand Down
110 changes: 80 additions & 30 deletions shared/src/main/scala/io/estatico/newtype/macros/NewTypeMacros.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.estatico.newtype.macros

import io.estatico.newtype.Coercible
import scala.reflect.ClassTag
import scala.reflect.macros.blackbox

//noinspection TypeAnnotation
Expand All @@ -9,10 +10,15 @@ private[macros] class NewTypeMacros(val c: blackbox.Context) {

import c.universe._

def newtypeAnnotation(annottees: Tree*): Tree = annottees match {
case List(clsDef: ClassDef) => runClass(clsDef)
case List(clsDef: ClassDef, modDef: ModuleDef) => runClassWithObj(clsDef, modDef)
case _ => fail("Unsupported newtype definition")
def newtypeAnnotation(annottees: Tree*): Tree = {
val (name, result) = annottees match {
case List(clsDef: ClassDef) => (clsDef.name, runClass(clsDef))
case List(clsDef: ClassDef, modDef: ModuleDef) => (clsDef.name, runClassWithObj(clsDef, modDef))
case _ => fail("Unsupported newtype definition")
}
if (debug) println(s"Expanded @newtype $name:\n" ++ show(result))
if (debugRaw) println(s"Expanded @newtype $name (raw):\n" + showRaw(result))
result
}

// Support Flag values which are not available in Scala 2.10
Expand All @@ -23,13 +29,32 @@ private[macros] class NewTypeMacros(val c: blackbox.Context) {

val CoercibleCls = typeOf[Coercible[Nothing, Nothing]].typeSymbol
val CoercibleObj = CoercibleCls.companion
val ClassTagCls = typeOf[ClassTag[Nothing]].typeSymbol
val ClassTagObj = ClassTagCls.companion
val ObjectCls = typeOf[Object].typeSymbol

// We need to know if the newtype is defined in an object so we can report
// an error message if methods are defined on it (otherwise, the user will
// get a cryptic error of 'value class may not be a member of another class'
// due to our generated extension methods.
val isDefinedInObject = c.internal.enclosingOwner.isModuleClass

val macroName: Tree = {
c.prefix.tree match {
case Apply(Select(New(name), _), _) => name
case _ => c.abort(c.enclosingPosition, "Unexpected macro application")
}
}

val (debug, debugRaw) = c.prefix.tree match {
case q"new ${`macroName`}(..$args)" =>
(
args.collectFirst { case q"debug = true" => }.isDefined,
args.collectFirst { case q"debugRaw = true" => }.isDefined
)
case _ => (false, false)
}

def fail(msg: String) = c.abort(c.enclosingPosition, msg)

def runClass(clsDef: ClassDef) = {
Expand Down Expand Up @@ -59,45 +84,61 @@ private[macros] class NewTypeMacros(val c: blackbox.Context) {
): Tree = {
val q"object $objName extends { ..$objEarlyDefs } with ..$objParents { $objSelf => ..$objDefs }" = modDef
val typeName = clsDef.name
val clsName = clsDef.name.decodedName
val typesTraitName = TypeName(clsName.toString + '$' + "Types")
val tparams = clsDef.tparams
val baseRefinementName = TypeName(clsDef.name.decodedName + "$newtype")
val baseRefinementName = TypeName(clsName + "$newtype")
val classTagName = TermName(clsName + "$classTag")
val companionExtraDefs =
maybeGenerateApplyMethod(clsDef, valDef, tparamsNoVar, tparamNames) ++
maybeGenerateOpsDef(clsDef, valDef, tparamsNoVar, tparamNames) ++
generateCoercibleInstances(tparamsNoVar, tparamNames, tparamsWild) ++
generateClassTag(classTagName, tparamsNoVar, tparamNames) ::
maybeGenerateApplyMethod(clsDef, valDef, tparamsNoVar, tparamNames) :::
maybeGenerateOpsDef(clsDef, valDef, tparamsNoVar, tparamNames) :::
generateCoercibleInstances(tparamsNoVar, tparamNames, tparamsWild) :::
generateDerivingMethods(tparamsNoVar, tparamNames, tparamsWild)

val newtypeObjParents = objParents :+ tq"$typesTraitName"
val newtypeObjDef = q"""
object $objName extends { ..$objEarlyDefs } with ..$newtypeObjParents { $objSelf =>
..$objDefs
..$companionExtraDefs
}
"""
// Note that we use an abstract type alias
// `type Type <: Base with Tag` and not `type Type = ...` to prevent
// scalac automatically expanding the type alias.
// Also, Scala 2.10 doesn't support objects having abstract type members, so we have to
// use some indirection by defining the abstract type in a trait then having
// the companion object extend the trait.
// See https://github.com/scala/bug/issues/10750
if (tparams.isEmpty) {
q"""
type $typeName = $objName.Type
object $objName extends { ..$objEarlyDefs } with ..$objParents { $objSelf =>
..$objDefs
type Repr = ${valDef.tpt}
type Base = { type $baseRefinementName }
trait Tag
type Type = Base with Tag
..$companionExtraDefs
}
"""
type $typeName = $objName.Type
trait $typesTraitName {
type Repr = ${valDef.tpt}
type Base = { type $baseRefinementName }
trait Tag
type Type <: Base with Tag
}
$newtypeObjDef
"""
} else {
q"""
type $typeName[..$tparams] = ${typeName.toTermName}.Type[..$tparamNames]
object $objName extends { ..$objEarlyDefs } with ..$objParents { $objSelf =>
..$objDefs
type Repr[..$tparams] = ${valDef.tpt}
type Base = { type $baseRefinementName }
trait Tag[..$tparams]
type Type[..$tparams] = Base with Tag[..$tparamNames]
..$companionExtraDefs
}
"""
type $typeName[..$tparams] = ${typeName.toTermName}.Type[..$tparamNames]
trait $typesTraitName {
type Repr[..$tparams] = ${valDef.tpt}
type Base = { type $baseRefinementName }
trait Tag[..$tparams]
type Type[..$tparams] <: Base with Tag[..$tparamNames]
}
$newtypeObjDef
"""
}
}

def maybeGenerateApplyMethod(
clsDef: ClassDef, valDef: ValDef, tparamsNoVar: List[TypeDef], tparamNames: List[TypeName]
): Option[Tree] = {
if (!clsDef.mods.hasFlag(Flag.CASE)) None else Some(
): List[Tree] = {
if (!clsDef.mods.hasFlag(Flag.CASE)) Nil else List(
if (tparamsNoVar.isEmpty) {
q"def apply(${valDef.name}: ${valDef.tpt}): Type = ${valDef.name}.asInstanceOf[Type]"
} else {
Expand Down Expand Up @@ -255,4 +296,13 @@ private[macros] class NewTypeMacros(val c: blackbox.Context) {
fail(s"newtypes do not support inheritance; illegal supertypes: ${unsupported.mkString(", ")}")
}
}

// The erasure of opaque newtypes is always Object.
def generateClassTag(
name: TermName, tparamsNoVar: List[TypeDef], tparamNames: List[TypeName]
): Tree = {
val objectClassTag = q"$ClassTagObj(_root_.scala.Predef.classOf[$ObjectCls])"
if (tparamsNoVar.isEmpty) q"implicit val $name: $ClassTagCls[Type] = $objectClassTag"
else q"implicit def $name[..$tparamsNoVar]: $ClassTagCls[Type[..$tparamNames]] = $objectClassTag"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package io.estatico.newtype.macros

import scala.annotation.StaticAnnotation

class newtype extends StaticAnnotation {
class newtype(
debug: Boolean = false,
debugRaw: Boolean = false
) extends StaticAnnotation {
def macroTransform(annottees: Any*): Any = macro NewTypeMacros.newtypeAnnotation
}
Loading

0 comments on commit 0f587d6

Please sign in to comment.