Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @toString in steps.annotation #14

Merged
merged 6 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/main/scala/steps/annotation/toString.scala
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions src/test/scala/steps/annotation/toString.scala
Original file line number Diff line number Diff line change
@@ -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
Loading