From 994c99546331541253d761284bd7a2058deffed0 Mon Sep 17 00:00:00 2001 From: Hamza Remmal <hamza@remmal.dev> Date: Tue, 15 Oct 2024 11:43:07 +0200 Subject: [PATCH 1/6] First draft of the @toString annotation --- .../scala/steps/annotation/toString.scala | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/scala/steps/annotation/toString.scala diff --git a/src/main/scala/steps/annotation/toString.scala b/src/main/scala/steps/annotation/toString.scala new file mode 100644 index 0000000..39668ce --- /dev/null +++ b/src/main/scala/steps/annotation/toString.scala @@ -0,0 +1,41 @@ +package steps.annotation + +import scala.annotation.{experimental, MacroAnnotation} +import scala.quoted.* + +@experimental +final class toString extends MacroAnnotation: + override def transform(using Quotes)(tree: quotes.reflect.Definition): List[quotes.reflect.Definition] = + import quotes.reflect.* + val toStringSym = Symbol.requiredMethod("java.lang.Object.toString") + tree match + case _: ClassDef if toStringSym.overridingSymbol(tree.symbol).exists => + report.warning(s"@toString is not necessary since hashcode is defined in ${tree.symbol}") + List(tree) + case ClassDef(className, ctr, parents, self, body) => + val cls = tree.symbol + + val fields = body.collect { + case vdef: ValDef if vdef.symbol.flags.is(Flags.ParamAccessor) => + Select(This(cls), vdef.symbol).asExpr + } + + val toStringOverrideSym = Symbol.newMethod(cls, "toString", toStringSym.info, Flags.Override, Symbol.noSymbol) + + def toStringOverrideDefBody(argss: List[List[Tree]]): Option[Term] = + given Quotes = toStringOverrideSym.asQuotes + Some(toStringExpr(className, fields).asTerm) + + val toStringDef = DefDef(toStringOverrideSym, toStringOverrideDefBody) + List(ClassDef.copy(tree)(className, ctr, parents, self, toStringDef :: body)) + case _ => + report.errorAndAbort("@toString is only supported in class/object/trait") + end transform + + private def toStringExpr(className: String, thisFields: List[Expr[Any]])(using Quotes): Expr[String] = + val fieldsSeq = Expr.ofSeq(thisFields) + val prefix = Expr(className + "(") + '{ $fieldsSeq.mkString($prefix, ", ", ")") } + end toStringExpr + +end toString \ No newline at end of file From 2f0862df026ef6694eba6d931c740a8a261210fa Mon Sep 17 00:00:00 2001 From: Hamza Remmal <hamza@remmal.dev> Date: Tue, 15 Oct 2024 13:00:31 +0200 Subject: [PATCH 2/6] Restrict toString to classes only --- src/main/scala/steps/annotation/toString.scala | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/scala/steps/annotation/toString.scala b/src/main/scala/steps/annotation/toString.scala index 39668ce..ce72ab0 100644 --- a/src/main/scala/steps/annotation/toString.scala +++ b/src/main/scala/steps/annotation/toString.scala @@ -12,6 +12,12 @@ final class toString extends MacroAnnotation: case _: ClassDef if toStringSym.overridingSymbol(tree.symbol).exists => report.warning(s"@toString is not necessary since hashcode is defined in ${tree.symbol}") List(tree) + case cls: ClassDef if cls.symbol.flags.is(Flags.Trait) => + report.error(s"@toString is not supported in traits") + List(tree) + case cls: ClassDef if cls.symbol.flags.is(Flags.Module) => + report.error(s"@toString is not supported in object") + List(tree) case ClassDef(className, ctr, parents, self, body) => val cls = tree.symbol @@ -29,7 +35,7 @@ final class toString extends MacroAnnotation: val toStringDef = DefDef(toStringOverrideSym, toStringOverrideDefBody) List(ClassDef.copy(tree)(className, ctr, parents, self, toStringDef :: body)) case _ => - report.errorAndAbort("@toString is only supported in class/object/trait") + report.errorAndAbort("@toString is only supported in class") end transform private def toStringExpr(className: String, thisFields: List[Expr[Any]])(using Quotes): Expr[String] = From 8bbb7e5cc3cc1bf070d753c57aa042cf55c27911 Mon Sep 17 00:00:00 2001 From: Hamza Remmal <hamza@remmal.dev> Date: Tue, 15 Oct 2024 13:01:40 +0200 Subject: [PATCH 3/6] Add scaladoc for steps.annotation.toString --- .../scala/steps/annotation/toString.scala | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/main/scala/steps/annotation/toString.scala b/src/main/scala/steps/annotation/toString.scala index ce72ab0..07d5cf4 100644 --- a/src/main/scala/steps/annotation/toString.scala +++ b/src/main/scala/steps/annotation/toString.scala @@ -3,8 +3,44 @@ package steps.annotation import scala.annotation.{experimental, MacroAnnotation} import scala.quoted.* + +/** + * A macro annotation that automatically generates a custom `toString` method for a class. + * + * The `@toString` annotation can be applied to a class, object, or trait. When applied, it overrides the `toString` + * method to include the class name and the values of all fields marked as `ParamAccessor`. + * + * If the class already defines or overrides the `toString` method, the annotation will emit a warning indicating that + * the annotation is not necessary. The existing `toString` method will remain unchanged. + * + * Example usage: + * {{{ + * @toString + * class MyClass(val a: Int, val b: String) + * + * val instance = MyClass(1, "hello") + * println(instance.toString) // Output: MyClass(1, hello) + * }}} + * + * The generated `toString` method produces output in the format: `ClassName(field1, field2, ...)`. + * + * @note This annotation requires Scala's `experimental` flag to be enabled, or it can be used within a scope + * marked as experimental (i.e., using `import scala.annotation.experimental`). This is necessary because it + * relies on experimental macro annotation features. + */ @experimental final class toString extends MacroAnnotation: + + /** + * Transforms the annotated class to add a custom `toString` method. + * + * If the class already overrides `toString`, a warning is emitted and no changes are made. + * Otherwise, this annotation adds a new `toString` method that returns a string representation of + * the class name and its fields. + * + * @param tree The abstract syntax tree (AST) of the annotated class, object, or trait. + * @return The transformed class definition, with the generated `toString` method if applicable. + */ override def transform(using Quotes)(tree: quotes.reflect.Definition): List[quotes.reflect.Definition] = import quotes.reflect.* val toStringSym = Symbol.requiredMethod("java.lang.Object.toString") @@ -38,6 +74,15 @@ final class toString extends MacroAnnotation: report.errorAndAbort("@toString is only supported in class") end transform + /** + * Helper method to create the string representation of the class. + * + * Constructs a string in the format: `ClassName(field1, field2, ...)`. + * + * @param className The name of the class. + * @param thisFields The list of expressions representing the class fields. + * @return A quoted expression representing the final string. + */ private def toStringExpr(className: String, thisFields: List[Expr[Any]])(using Quotes): Expr[String] = val fieldsSeq = Expr.ofSeq(thisFields) val prefix = Expr(className + "(") From a9cc7833e2bd4009c2e2a323d7e10298508ce3c7 Mon Sep 17 00:00:00 2001 From: Hamza Remmal <hamza@remmal.dev> Date: Tue, 15 Oct 2024 13:02:03 +0200 Subject: [PATCH 4/6] Add tests for steps.annotation.toString --- .../scala/steps/annotation/toString.scala | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/test/scala/steps/annotation/toString.scala diff --git a/src/test/scala/steps/annotation/toString.scala b/src/test/scala/steps/annotation/toString.scala new file mode 100644 index 0000000..d4dd521 --- /dev/null +++ b/src/test/scala/steps/annotation/toString.scala @@ -0,0 +1,31 @@ +package steps.annotation + +import scala.annotation.experimental +import scala.language.experimental.clauseInterleaving + +@toString +@experimental +class Foo1(val a: Int, val b: String) + +@toString +@experimental +class Foo2(a: Int, b: String) + +@toString +@experimental +class Foo3(var a: Int, var b: String) + +@toString +@experimental +class Foo4(a: Int, b: String)(c: Int) + +@experimental +class AssertToStringBehaviour extends munit.FunSuite: + + test("@toString works with all kinds of classes"): + assertEquals(Foo1(1, "hello").toString(), "Foo1(1, hello)") + assertEquals(Foo2(1, "hello").toString(), "Foo2(1, hello)") + assertEquals(Foo3(1, "hello").toString(), "Foo3(1, hello)") + assertEquals(Foo4(1, "hello")(2).toString(), "Foo4(1, hello, 2)") + +end AssertToStringBehaviour \ No newline at end of file From 9eb4b45baa99a1fe9efaeb0dcc611d3511bf2d2a Mon Sep 17 00:00:00 2001 From: Hamza Remmal <hamza@remmal.net> Date: Tue, 15 Oct 2024 14:18:29 +0200 Subject: [PATCH 5/6] Fix typo in warning messages Co-authored-by: Natsu Kagami <natsukagami@gmail.com> --- src/main/scala/steps/annotation/toString.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/steps/annotation/toString.scala b/src/main/scala/steps/annotation/toString.scala index 07d5cf4..6928341 100644 --- a/src/main/scala/steps/annotation/toString.scala +++ b/src/main/scala/steps/annotation/toString.scala @@ -46,7 +46,7 @@ final class toString extends MacroAnnotation: val toStringSym = Symbol.requiredMethod("java.lang.Object.toString") tree match case _: ClassDef if toStringSym.overridingSymbol(tree.symbol).exists => - report.warning(s"@toString is not necessary since hashcode is defined in ${tree.symbol}") + report.warning(s"@toString is not necessary since toString is defined in ${tree.symbol}") List(tree) case cls: ClassDef if cls.symbol.flags.is(Flags.Trait) => report.error(s"@toString is not supported in traits") From 99e550556fe4343987d45c2ec8a3f48b3aba9577 Mon Sep 17 00:00:00 2001 From: Hamza Remmal <hamza@remmal.net> Date: Tue, 15 Oct 2024 14:21:53 +0200 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Natsu Kagami <natsukagami@gmail.com> --- src/main/scala/steps/annotation/toString.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/scala/steps/annotation/toString.scala b/src/main/scala/steps/annotation/toString.scala index 6928341..5f961cc 100644 --- a/src/main/scala/steps/annotation/toString.scala +++ b/src/main/scala/steps/annotation/toString.scala @@ -49,10 +49,10 @@ final class toString extends MacroAnnotation: report.warning(s"@toString is not necessary since toString is defined in ${tree.symbol}") List(tree) case cls: ClassDef if cls.symbol.flags.is(Flags.Trait) => - report.error(s"@toString is not supported in traits") + report.error(s"@toString is not supported on traits") List(tree) case cls: ClassDef if cls.symbol.flags.is(Flags.Module) => - report.error(s"@toString is not supported in object") + report.error(s"@toString is not supported on objects") List(tree) case ClassDef(className, ctr, parents, self, body) => val cls = tree.symbol @@ -62,7 +62,7 @@ final class toString extends MacroAnnotation: Select(This(cls), vdef.symbol).asExpr } - val toStringOverrideSym = Symbol.newMethod(cls, "toString", toStringSym.info, Flags.Override, Symbol.noSymbol) + val toStringOverrideSym = Symbol.newMethod(cls, toStringSym.name, toStringSym.info, Flags.Override, Symbol.noSymbol) def toStringOverrideDefBody(argss: List[List[Tree]]): Option[Term] = given Quotes = toStringOverrideSym.asQuotes @@ -71,7 +71,7 @@ final class toString extends MacroAnnotation: val toStringDef = DefDef(toStringOverrideSym, toStringOverrideDefBody) List(ClassDef.copy(tree)(className, ctr, parents, self, toStringDef :: body)) case _ => - report.errorAndAbort("@toString is only supported in class") + report.errorAndAbort("@toString is only supported on class") end transform /**