1

After the parser phase of the Scalac process, the following case class

case class ExampleCaseClass(var s:String, var i:Int) extends ContextuallyMutable

takes the intermediate form:

Clazz(case class ExampleCaseClass extends ContextuallyMutable with scala.Product with scala.Serializable {
  <caseaccessor> <paramaccessor> var s: String = _;
  <caseaccessor> <paramaccessor> var i: Int = _;
  def <init>(s: String, i: Int) = {
    super.<init>();
    ()
  }
})

However, a run time reflection call:

ExampleCaseClass("Can a Scala compiler plugin transform the autogenerated accessor methods of scala case classes?", 42).getClass.getMethods.foreach(println(_))

reveals many more public methods:

public boolean ExampleCaseClass.equals(java.lang.Object)
public java.lang.String ExampleCaseClass.toString()
public int ExampleCaseClass.hashCode()
public static ExampleCaseClass ExampleCaseClass.apply(java.lang.String,int)
public int ExampleCaseClass.i()
public java.lang.String ExampleCaseClass.s()
public ExampleCaseClass ExampleCaseClass.copy(java.lang.String,int)
public void ExampleCaseClass.i_$eq(int)
public scala.collection.Iterator ExampleCaseClass.productElementNames()
public java.lang.String ExampleCaseClass.productElementName(int)
public void ExampleCaseClass.s_$eq(java.lang.String)
public int ExampleCaseClass.copy$default$2()
public boolean ExampleCaseClass.canEqual(java.lang.Object)
public java.lang.String ExampleCaseClass.productPrefix()
public int ExampleCaseClass.productArity()
public java.lang.Object ExampleCaseClass.productElement(int)
public scala.collection.Iterator ExampleCaseClass.productIterator()
public java.lang.String ExampleCaseClass.copy$default$1()
public static scala.Function1 ExampleCaseClass.tupled()
public static scala.Option ExampleCaseClass.unapply(ExampleCaseClass)
public static scala.Function1 ExampleCaseClass.curried()
public final void java.lang.Object.wait(long,int) throws java.lang.InterruptedException
public final void java.lang.Object.wait() throws java.lang.InterruptedException
public final native void java.lang.Object.wait(long) throws java.lang.InterruptedException
public final native java.lang.Class java.lang.Object.getClass()
public final native void java.lang.Object.notify()
public final native void java.lang.Object.notifyAll()

Clearly some subsequent compiler phase creates the property accessor methods:

public int ExampleCaseClass.i()
public java.lang.String ExampleCaseClass.s()
public void ExampleCaseClass.i_$eq(int)
public void ExampleCaseClass.s_$eq(java.lang.String)

Which compilation phase generates these accessor methods and what manner of compiler plugin (or other means) might prevent or transform them?

The enquirer has already run numerous experiments removing or reshaping the:

  <caseaccessor> <paramaccessor> var s: String = _;
  <caseaccessor> <paramaccessor> var i: Int = _;

portions of the case class, and also with injecting the desired accessor methods in advance but no combination has met the desired outcome. They either fail to compile because of naming conflicts that arise in subsequent compilation phases, or they alter the parameter names in constructor, apply, and accessor methods.

Can a scala compiler plugin transform synthetic accessors at all? Does the Java Compiler introduce these methods? If so, should the enquirer look to Javac plugins and what analogues might serve the Scala.js and Scala native compilation targets?

Thank you for any consideration.

Ben McKenneby
  • 481
  • 4
  • 15
  • 3
    Btw Using var in a case class is probably a bad idea – cchantep Nov 23 '21 at 00:28
  • An idea so bad that it leaves one longing for a compiler plugin that intervenes in such situations. :) – Ben McKenneby Nov 23 '21 at 00:44
  • Scala 2 has no real "instance variables" as it always generates accessor methods for val and var, in order for many "Scala magics" to happen, except for `private[this]`. I am not aware of any compiler plugins that does anything about it. – SwiftMango Nov 23 '21 at 00:57
  • Yes to all of that. I'm writing the compiler plugin in question. – Ben McKenneby Nov 23 '21 at 01:13
  • 1
    I mean if you just wanna rid those method, just use define them as `private[this]`, then they will not be generate. Then you can write your own accessors. Writing a plugin seems like an overkill. But anyway your choice! – SwiftMango Nov 23 '21 at 01:16
  • `var`s can be simply prevented with something like wartremover or scalafix. – Mateusz Kubuszok Nov 23 '21 at 09:30
  • Thank you, Mateusz, The overall goal is not to 'prevent them', so much as augment their functionality. – Ben McKenneby Nov 23 '21 at 15:10

2 Answers2

1

case class expansion happens in more than one place, see another question.

Instead of writing a new plugin just to disallow using var it would be much better to add a new rule to Wartremover or ScalaFix. As a matter of the fact, these rules already exist:

If you want to add more elaborate rule... it would still be easier just to write your own Wartremover/ScalaFix rule (the latter might be preferred as it is already supported in Scala 3).

And if you really need a custom compiler plugin to mess with code generated by compiler... take a look at better-toString plugin. It adds its own phase after "parser" phase. But I wouldn't hope for removing the autogenerated implementations. At best you can override them manually where specs allows you to.

Mateusz Kubuszok
  • 24,995
  • 4
  • 42
  • 64
  • Good! Experiments with better-tostring prompted this question! :) Following that plugin's example and injecting the s, s_$eq, i, and i_$eq methods in this early phase introduced naming conflicts with the automatically generated accessors that arise in later phases. This kind of intervention could only work if invoked after the compilation phase that injects the autogenerated accessors. A phase called ____? Alternatively, it could work in conjunction with a later intervention that blocks the auto generated accessors at that phase. – Ben McKenneby Nov 23 '21 at 16:05
  • Thank you for mentioning Wartremover and ScalaFix. The task seems beyond the scope of linters; ScalaFix might offer a solution, but it seems more focused on code analysis than transformation. Thoughts? – Ben McKenneby Nov 23 '21 at 16:15
  • 1
    better toString did it the way it did because you most likely cannot edit existing phases of the compiler - you can only add hook with your own phase. You are allowed to `override def toString` in a case class - so better toString just checks if you did it and modifies AST by adding the overriding code if you didn't. So it doesn't change the existing code, it just changes ADT right after it's created (before compile synthesises methods). So you would have to check how would you edit existing code and apply these edits right after parsing. – Mateusz Kubuszok Nov 23 '21 at 16:49
  • 1
    I am guessing that the only possible way would be to edit case class code to make all `val`s `private`, rename them and then create `def`s acting like accessors. But this would be so much different than specified behavior that personally I wouldn't find it to be a safe solution in any project I can think of. I would rather stop at adding rule preventing bad practices without having existing code diverging in behavior from specification. Any new developer would trip over it sooner or later. – Mateusz Kubuszok Nov 23 '21 at 16:53
  • "... make all vals private, rename them and then create defs acting like accessors ..." I tried that, too. It requires rewriting all of the def trees to reflect the variable name changes. Agreed, this seems too heavy handed to accomplish little more than a method override. Is it impossible for compiler plugins to apply transforms in later phases? The method signatures get generated later, exactly as needed, only with the wrong method bodies. – Ben McKenneby Nov 23 '21 at 17:14
  • 1
    You can add transformation at (almost) any phase but you cannot remove existing ones. And existing ones when they'll see `case` flag/modifier on a `class` will start adding synthetic methods. They will skip it ONLY if spec allows this (e.g. skip toString if you created it yourself). There is a limit what plugin (or macro) can do and taking away behavior guaranteed by the spec is surely on the forbidden list. – Mateusz Kubuszok Nov 23 '21 at 17:33
  • That's great news. Should I edit the original question to: "What compiler phase generates case accessor methods, and how can I write a compiler plugin to transform those autogenerated accessors?" Do you happen to know? – Ben McKenneby Nov 23 '21 at 17:39
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/239508/discussion-between-ben-mckenneby-and-mateusz-kubuszok). – Ben McKenneby Nov 23 '21 at 19:27
1

The enquirer found a Scala 3 solution with persistence and help from examples:

  1. better-tostring a plugin that demonstrates conditional method insertion by Polyvariant.
  2. Compiler Plugin Development in Scala 3 Example and Tutorial by Scala Center dev: Fengyun Liu. The video provided insights into compiler phases and the example shed light on method body generation syntax. In particular, available documentation doesn't readily clarify how to call println from a method body generated as a Tree by a compiler plugin, but Liu's example plugin demonstrated requireModule and requiredMethod.
  3. Scala 3 Compiler Plugin Documentation offers a very nice template for how to start writing a plugin. The final solution looked very similar.

As of 26 Nov 2021, no solution exists for Scala 2.13, but maybe that just means it is time to upgrade.

final class BlockMutatorPlugin extends StandardPlugin {
  override val name: String = "BlockMutatorPlugin"
  override val description: String = "Scala Compiler Plugin for blocking ContextuallyMutable setter methods."
  override def init(options: List[String]): List[PluginPhase] = List(new BlockContextuallyMutableSetters)
}

class BlockContextuallyMutableSetters extends PluginPhase {

  val phaseName = "blockGetter"

  /* Running this plugin after phases before ElimErasedValueType
resulted in the replacement of the generated setter methods by the synthetic
default versions.  By the time that this ElimErasedValueType phase ends, the 
defaults already existed, so this plugin could augment them safely.  */

  override val runsAfter = Set(ElimErasedValueType.name)

  private var printBlocked: Tree = _

  override def prepareForTemplate(tree: tpd.Template)(using ctx: Context): Context = {
    val cnsl = requiredModule("scala.Predef")
    val prntln: PreName = "println".toTermName
    val say = cnsl.requiredMethod(prntln, List[Types.Type](ctx.definitions.ObjectType))
    printBlocked = ref(say).appliedTo(Literal(Constant("Blocked!")))
    ctx
  }

  override def transformTemplate(tree: Template)(using ctx: Context): Tree = {
    if (tree.parents.filter(_.symbol.name.toString.equals("ContextuallyMutable")).nonEmpty) {
      cpy.Template(tree)(
        body = tree.body.collect {
          case dd: DefDef if dd.name.isSetterName => DefDef(
            dd.symbol.asInstanceOf[Symbols.TermSymbol],
            printBlocked
          )
          case x => x
        }
      ).asInstanceOf[Tree]
    } else tree
  }
}

The most important part of this effort involved discovering which compiler phase this plugin should follow. Similar efforts in Scala 2.13.6 have failed so far; the only remaining impediment to the Scala 2 solution sought by this original Stack Overflow question. As such, the enquirer will not mark his own answer as the accepted solution unless future edits avail Scala 2. Until that time, your response may claim that designation.

For any inclined to try compile this example, the code above requires the following import statements:

import dotty.tools.dotc.ast.tpd
import tpd.*
import dotty.tools.dotc.core.*
import Names.PreName
import Symbols.{ClassSymbol, requiredMethod, requiredModule}
import Decorators.*
import NameOps.*
import Contexts.Context
import Constants.Constant
import dotty.tools.dotc.plugins.*
import dotty.tools.dotc.transform.ElimErasedValueType
Ben McKenneby
  • 481
  • 4
  • 15