diff --git a/annotation/src/main/scala/com/softwaremill/stringmask/annotation/mask.scala b/annotation/src/main/scala/com/softwaremill/stringmask/annotation/mask.scala
new file mode 100644
index 0000000..fdcdf15
--- /dev/null
+++ b/annotation/src/main/scala/com/softwaremill/stringmask/annotation/mask.scala
@@ -0,0 +1,5 @@
+package com.softwaremill.stringmask.annotation
+
+import scala.annotation.StaticAnnotation
+
+class mask extends StaticAnnotation
\ No newline at end of file
diff --git a/build.sbt b/build.sbt
index 98ee076..fdb3077 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,42 +1,75 @@
-organization := "com.softwaremill.stringmask"
-name := "stringmask"
+import sbt.Keys._
-version := "1.1.0-SNAPSHOT"
+import scalariform.formatter.preferences._
+
+lazy val commonSettings = scalariformSettings ++ Seq(
+ organization := "com.softwaremill.stringmask",
+ version := "2.0.0-SNAPSHOT",
+ crossScalaVersions := Seq("2.10.6", "2.11.8"),
+ scalaVersion := "2.11.8",
+ ScalariformKeys.preferences in ThisBuild := ScalariformKeys.preferences.value
+ .setPreference(DoubleIndentClassDeclaration, true)
+ .setPreference(PreserveSpaceBeforeArguments, true)
+ .setPreference(CompactControlReadability, true)
+ .setPreference(SpacesAroundMultiImports, false)
+)
+
+lazy val root = (project in file("."))
+ .aggregate(annotation, scalacPlugin, tests)
+ .settings(commonSettings)
+ .settings(name := "scalac-stringmask-plugin")
-crossScalaVersions := Seq("2.10.6", "2.11.8")
-scalaVersion := "2.11.8"
+lazy val annotation = (project in file("annotation"))
+ .settings(commonSettings)
+ .settings(name := "stringmask-annotation")
+ .settings(publishArtifact := true)
-libraryDependencies ++= Seq(
- "org.scala-lang" % "scala-reflect" % scalaVersion.value,
- "org.scala-lang" % "scala-compiler" % scalaVersion.value,
- "org.typelevel" %% "macro-compat" % "1.1.1",
- "org.scalatest" %% "scalatest" % "2.2.6" % "test"
+lazy val scalacPlugin = (project in file("scalacPlugin"))
+ .dependsOn(annotation)
+ .settings(commonSettings)
+ .settings(
+ name := "stringmask-scalac-plugin",
+ exportJars := true
+ )
+ .settings(
+ libraryDependencies ++= Seq(
+ "org.scala-lang" % "scala-compiler" % scalaVersion.value
+ )
+ )
+
+lazy val tests = (project in file("tests"))
+ .dependsOn(scalacPlugin)
+ .settings(commonSettings)
+ .settings(
+ scalacOptions <+= (artifactPath in(scalacPlugin, Compile, packageBin)).map { file =>
+ s"-Xplugin:${file.getAbsolutePath}"
+ }
+ ).settings(
+ libraryDependencies ++= Seq(
+ "org.scalatest" %% "scalatest" % "2.2.6"
+ )
)
-publishTo := {
- val nexus = "https://oss.sonatype.org/"
- if (version.value.trim.endsWith("SNAPSHOT"))
- Some("snapshots" at nexus + "content/repositories/snapshots")
- else
- Some("releases" at nexus + "service/local/staging/deploy/maven2")
-}
-credentials += Credentials(Path.userHome / ".ivy2" / ".credentials")
-publishMavenStyle := true
-publishArtifact in Test := false
+publishMavenStyle in ThisBuild := true
+publishArtifact in ThisBuild := true
+publishArtifact in Test in ThisBuild := false
pomIncludeRepository := { _ => false }
-pomExtra :=
-
- git@github.com:softwaremill/stringmask.git
- scm:git:git@github.com:softwaremill/stringmask.git
-
-
-
- kciesielski
- Krzysztof Ciesielski
-
-
-licenses := ("Apache2", new java.net.URL("http://www.apache.org/licenses/LICENSE-2.0.txt")) :: Nil
-homepage := Some(new java.net.URL("http://www.softwaremill.com"))
-
-addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full)
+pomExtra in ThisBuild :=
+
+ git@github.com:softwaremill/stringmask.git
+ scm:git:git@github.com:softwaremill/stringmask.git
+
+
+
+ kciesielski
+ Krzysztof Ciesielski
+
+
+ mkubala
+ Marcin Kubala
+
+
+
+licenses in ThisBuild := ("Apache2", new java.net.URL("http://www.apache.org/licenses/LICENSE-2.0.txt")) :: Nil
+homepage in ThisBuild := Some(new java.net.URL("http://www.softwaremill.com"))
diff --git a/project/plugins.sbt b/project/plugins.sbt
new file mode 100644
index 0000000..8f6f9b4
--- /dev/null
+++ b/project/plugins.sbt
@@ -0,0 +1 @@
+addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.4.0")
diff --git a/scalacPlugin/src/main/resources/scalac-plugin.xml b/scalacPlugin/src/main/resources/scalac-plugin.xml
new file mode 100644
index 0000000..0263015
--- /dev/null
+++ b/scalacPlugin/src/main/resources/scalac-plugin.xml
@@ -0,0 +1,4 @@
+
+ stringmask
+ com.softwaremill.stringmask.StringMaskPlugin
+
\ No newline at end of file
diff --git a/scalacPlugin/src/main/scala/com/softwaremill/stringmask/StringMaskPlugin.scala b/scalacPlugin/src/main/scala/com/softwaremill/stringmask/StringMaskPlugin.scala
new file mode 100644
index 0000000..70acc9f
--- /dev/null
+++ b/scalacPlugin/src/main/scala/com/softwaremill/stringmask/StringMaskPlugin.scala
@@ -0,0 +1,13 @@
+package com.softwaremill.stringmask
+
+import com.softwaremill.stringmask.components.StringMaskComponent
+
+import scala.tools.nsc.Global
+import scala.tools.nsc.plugins.{ Plugin, PluginComponent }
+
+class StringMaskPlugin(val global: Global) extends Plugin {
+
+ override val name: String = "stringmask"
+ override val description: String = "StringMask compiler plugin"
+ override val components: List[PluginComponent] = List(new StringMaskComponent(global))
+}
diff --git a/scalacPlugin/src/main/scala/com/softwaremill/stringmask/components/StringMaskComponent.scala b/scalacPlugin/src/main/scala/com/softwaremill/stringmask/components/StringMaskComponent.scala
new file mode 100644
index 0000000..292ad50
--- /dev/null
+++ b/scalacPlugin/src/main/scala/com/softwaremill/stringmask/components/StringMaskComponent.scala
@@ -0,0 +1,83 @@
+package com.softwaremill.stringmask.components
+
+import scala.tools.nsc.Global
+import scala.tools.nsc.plugins.PluginComponent
+import scala.tools.nsc.transform.Transform
+
+class StringMaskComponent(val global: Global) extends PluginComponent with Transform {
+
+ override val phaseName: String = "stringmask"
+
+ override val runsAfter: List[String] = List("parser")
+
+ // When runs after typer phase, might have problems with accessing ValDefs' annotations.
+ override val runsRightAfter: Option[String] = Some("parser")
+
+ override protected def newTransformer(unit: global.CompilationUnit): global.Transformer = ToStringMaskerTransformer
+
+ import global._
+
+ object ToStringMaskerTransformer extends Transformer {
+
+ override def transform(tree: global.Tree): global.Tree = {
+ val transformedTree = super.transform(tree)
+ transformedTree match {
+ case classDef: ClassDef if isAnnotatedCaseClass(classDef) =>
+ extractParamsAnnotatedWithMask(classDef)
+ .map(buildNewToStringTree(classDef.name))
+ .map(overrideToStringDef(classDef))
+ .getOrElse(transformedTree)
+ case oth => transformedTree
+ }
+ }
+
+ private def isAnnotatedCaseClass(classDef: ClassDef): Boolean =
+ classDef.mods.isCase && !containsCustomToStringDef(classDef)
+
+ private def containsCustomToStringDef(classDef: global.ClassDef): Boolean =
+ classDef.impl.body.exists {
+ case d: DefDef => d.name.decode == "toString"
+ case _ => false
+ }
+
+ private def extractParamsAnnotatedWithMask(classDef: ClassDef): Option[List[Tree]] =
+ classDef.impl.body.collectFirst {
+ case d: DefDef if d.name.decode == "" && d.vparamss.headOption.exists(containsMaskedParams) =>
+ d.vparamss.headOption.map { firstParamsGroup =>
+ firstParamsGroup.foldLeft(List.empty[Tree]) {
+ case (accList, fieldTree) =>
+ val newFieldTree = if (hasMaskAnnotation(fieldTree)) {
+ Literal(Constant("***"))
+ } else {
+ Apply(Select(Ident(fieldTree.name), "toString"), Nil)
+ }
+ accList :+ newFieldTree
+ }
+ }.getOrElse(Nil)
+ }
+
+ private def containsMaskedParams(params: List[ValDef]): Boolean =
+ params.exists(hasMaskAnnotation)
+
+ private def hasMaskAnnotation(param: ValDef): Boolean =
+ param.mods.hasAnnotationNamed("mask")
+
+ private def overrideToStringDef(classDef: global.ClassDef)(newToStringImpl: Tree): Tree = {
+ val className = classDef.name
+ global.inform(s"overriding $className.toString")
+ val newBody = newToStringImpl :: classDef.impl.body
+ val newImpl = Template(classDef.impl.parents, classDef.impl.self, newBody)
+ ClassDef(classDef.mods, className, classDef.tparams, newImpl)
+ }
+
+ private def buildNewToStringTree(className: TypeName)(fields: List[Tree]): Tree = {
+ val treesAsTuple = Apply(Select(Ident("scala"), "Tuple" + fields.length), fields)
+ val typeNameStrTree = Literal(Constant(className.toString))
+
+ DefDef(Modifiers(Flag.OVERRIDE), "toString": TermName, List(), List(), TypeTree(),
+ Apply(Select(typeNameStrTree, "$plus": TermName), List(treesAsTuple)))
+ }
+
+ }
+
+}
diff --git a/src/main/scala/com/softwaremill/macros/customize/CustomizeImpl.scala b/src/main/scala/com/softwaremill/macros/customize/CustomizeImpl.scala
deleted file mode 100644
index bf1c2c6..0000000
--- a/src/main/scala/com/softwaremill/macros/customize/CustomizeImpl.scala
+++ /dev/null
@@ -1,71 +0,0 @@
-package com.softwaremill.macros.customize
-
-import scala.annotation.StaticAnnotation
-import scala.language.experimental.macros
-import scala.reflect.macros.whitebox
-import macrocompat.bundle
-
-class customize extends StaticAnnotation {
- def macroTransform(annottees: Any*): Any = macro CustomizeImpl.impl
-}
-
-class mask extends StaticAnnotation
-
-@bundle
-class CustomizeImpl(val c: whitebox.Context) {
-
- def impl(annottees: c.Expr[Any]*): c.Expr[Any] = {
- import c.universe._
-
- def extractCaseClassesParts(classDecl: ClassDef) = classDecl match {
- case q"""case class $className(..$fields) extends ..$parents { ..$body }""" =>
- (className, fields, parents, body)
- }
-
- def extractNewToString(typeName: TypeName, allFields: List[Tree]) = {
- val fieldListTree = allFields.foldLeft(List.empty[Tree]) { case (accList, fieldTree) =>
- fieldTree match {
- case q"$m val $field: $fieldType = $sth" =>
- m.annotations match {
- case List(q"new mask()") =>
- accList :+ Literal(Constant("***"))
- case _ =>
- accList :+ q"$field.toString"
- }
- case _ => c.abort(c.enclosingPosition, s"Cannot call .toString on field.")
- }
- }
- val treesAsTuple = Apply(Select(Ident(TermName("scala")), TermName("Tuple" + fieldListTree.length)), fieldListTree)
- val typeNameStrTree = Literal(Constant(typeName.toString))
- q"""
- override def toString = {
- $typeNameStrTree + $treesAsTuple
- }
- """
- }
-
- def modifiedDeclaration(classDecl: ClassDef, tail: List[Tree] = List.empty) = {
- val (className, fields, parents, body) = extractCaseClassesParts(classDecl)
- val newToString = extractNewToString(className, fields)
- val params = fields.asInstanceOf[List[ValDef]] map { p => p.duplicate}
- val e = q"""
- case class $className ( ..$params ) extends ..$parents {
- $newToString
- ..$body
- }
- """
- val blockItems = e +: tail
- c.Expr[Any](q"{..$blockItems}")
- }
-
- annottees map (_.tree) toList match {
- case (classDecl: ClassDef) :: Nil =>
- modifiedDeclaration(classDecl)
- case (classDecl: ClassDef) :: tail =>
- modifiedDeclaration(classDecl, tail)
- case other =>
- c.abort(c.enclosingPosition, "Invalid annottee, expected case class.")
- }
-
- }
-}
\ No newline at end of file
diff --git a/src/test/scala/ToStringMaskTest.scala b/src/test/scala/ToStringMaskTest.scala
deleted file mode 100644
index 8167bb6..0000000
--- a/src/test/scala/ToStringMaskTest.scala
+++ /dev/null
@@ -1,36 +0,0 @@
-import java.time.ZonedDateTime
-import java.util.Date
-
-import com.softwaremill.macros.customize.{customize, mask}
-import org.scalatest.{FlatSpec, Matchers}
-
-class ToStringMaskTest extends FlatSpec with Matchers {
-
- behavior of "masking"
-
- @customize
- case class User20(id: Int, @mask name: String, @mask email: String, something: Long, @mask secretDob: ZonedDateTime)
-
- it should "mask fields" in {
- // given
- val u = User20(1, "Secret Person", "secret@email.com", 15, ZonedDateTime.now())
-
- // then
- u.toString should be("User20(1,***,***,15,***)")
- }
-
- it should "work on case classes which have custom companion objects" in {
- // given
- val u = User30(2)
-
- // then
- u.toString should be("User30(2,***)")
- }
-}
-
-@customize
-case class User30(id: Int, @mask email: String)
-
-object User30 {
- def apply(id: Int) = new User30(id, "default@email.com")
-}
\ No newline at end of file
diff --git a/tests/src/test/scala/com/softwaremill/stringmask/testing/BasicCasesSpec.scala b/tests/src/test/scala/com/softwaremill/stringmask/testing/BasicCasesSpec.scala
new file mode 100644
index 0000000..5d59ae0
--- /dev/null
+++ b/tests/src/test/scala/com/softwaremill/stringmask/testing/BasicCasesSpec.scala
@@ -0,0 +1,51 @@
+package com.softwaremill.stringmask.testing
+
+import com.softwaremill.stringmask.annotation.mask
+import org.scalatest.{ Matchers, WordSpec }
+
+class BasicCasesSpec extends WordSpec with Matchers {
+ "StringMask" should {
+ "not override toString" when {
+ "none of the class parameters is annotated with @mask" in {
+ case class Planet(name: String)
+
+ Planet("earth").toString should equal("Planet(earth)")
+ }
+
+ "@mask annotation is applied, but not in the first parameters list" in {
+ case class TrickyCurrying(firstArg: String)(@mask secretKey: String)
+
+ TrickyCurrying("red")("tomato").toString should equal("TrickyCurrying(red)")
+ }
+
+ "class is implementing a custom toString method" in {
+ case class UserWithCustomToString(name: String, @mask password: String) {
+ override def toString: String = s"I love potatoes"
+ }
+
+ UserWithCustomToString("James", "secretPass").toString should equal("I love potatoes")
+ }
+
+ "applied to regular class" in {
+ new TestClasses.FavouriteMug("white", 0.4f, "yerba mate").toString should startWith("com.softwaremill.stringmask.testing.TestClasses$FavouriteMug@")
+ }
+
+ }
+
+ "mask confidential fields" when {
+
+ "applied to case classes" in {
+ case class CasualUser(name: String, @mask password: String)
+
+ CasualUser("James", "secretPass").toString should equal("CasualUser(James,***)")
+ }
+
+ }
+ }
+}
+
+object TestClasses {
+
+ class FavouriteMug(color: String, volume: Float, @mask content: String)
+
+}