diff --git a/src/main/scala/steps/annotation/toString.scala b/src/main/scala/steps/annotation/toString.scala new file mode 100644 index 0000000..5f961cc --- /dev/null +++ b/src/main/scala/steps/annotation/toString.scala @@ -0,0 +1,92 @@ +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") + tree match + case _: ClassDef if toStringSym.overridingSymbol(tree.symbol).exists => + 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 on traits") + List(tree) + case cls: ClassDef if cls.symbol.flags.is(Flags.Module) => + report.error(s"@toString is not supported on objects") + 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, toStringSym.name, 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 on 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 + "(") + '{ $fieldsSeq.mkString($prefix, ", ", ")") } + end toStringExpr + +end toString \ No newline at end of file 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