PRE-SIP: requireCompilerPlugin annotation

SIP-XXX: requireCompilerPlugin annotation.

Summary:

Problem: compiler-plugin can introduce binary incompatibility between code generated with and without plugin. To check such cases in the compile-time we can introduce method annotation “scala.annotation.requireCompilerPlugin(name, version),” which checks that the annotated method should be called only if the compiler plugin with the given name is enabled.

Motivation

Scala3 allows developers to write standalone compiler plugins, which are used for different purposes: generation of hosted code, simplified representation of computations, generation of boilerplate integration API, etc.

Usually, libraries are built with plugins distributed similarly, and any client can freely mix code built with and without compiler plugins. But often, compiler plugins change the binary call interface for some subset of processed code or enforce some constraints about the call interface. For example, scalus (GitHub - nau/scalus: Scalus - Scala implementation of Cardano Plutus) assumes that the caller of the compile function should use a compiler plugin; dotty-cps-async (GitHub - rssh/dotty-cps-async: experimental CPS transformer for dotty ) changes the binary representation of the functions which use direct context encoding; scout (GitHub - xebia-functional/scourt: Scala Coroutines Compiler Plugin ) change the binary representation of the coroutines, etc.

Application developers can call in the library method, which some compiler plugins change the binary interface, and the Scala compiler will accept this method. The error will be in runtime during the linking or calling of changed methods.

Proposed Solution:

Add a static annotation “scala.annotation.requireCompilerPlugin” to the standard library, which should be put on methods or objects whose signature is changed after erasure by a compiler plugin.

   final class requireCompilerPlugin(name: String, version: String) extends StaticAnnotation

Let’s define ‘method is required compiler plugin’’ if

  • the method is annotated by requireCompilerPlugin [or]

  • the template in which the method is defined is annotated by requireCompilerPlugin [or]

  • some of the types of parameters are annotated by requireCompilerPlugin [or]

  • method are annotated with annotations, which annotated by requireCompilerPlugin

    Add to the compiler check that compiler plugins are loaded with versions, compatible in terms of semantic versioning or report an appropriate error for all method invocations required.

Alternatives:

  1. Allow changing the tasty representation of the compiled code between typing and pickling for compiler plugins. In the current compiler code, the changes of the symbol signatures after typing are possible only after erasure. (see comment in compiler/src/dotty/tools/dotc/Compiler.scala ).
  • Pro:
    • In the long-term, the ability to transform a typed tree into another typed tree and write a transformed type tree into tasty is worth having. This direction is superior to the requireCompilerPlugin annotation in most cases.
  • Cons:
    • The compiler has relatively big changes (change the search of the overloaded terms).
    • This can require additional implementation effort from plugin authors.
    • Some analog of requireComplierPlugin is still needed when the compiler plugin requires interaction with some custom infrastructure at compile-time.
  1. Disallow changing the signatures by plugins.
  • Pro:

    • Simple
  • Cons:

    • Probably, this will kill all untrivial compiler plugins.
1 Like

I think this is a too fine-grained approach. If a library is dependent on a compiler plugin then everything that is dependent on that library should also implicitly be dependent on that compiler plugin. This will also simplify the build files of applications that are dependent on a plugin-dependent library. They just need to include the library and the plugin will be implicitly included and turned on.
So I propose adding some kind of “requirePlugin” flag to the build process to initiate this instead.

The coarse-grained approach will make the requireCompilerPlugin property transitive, which I want to avoid: this means that developers will be not able to encapsulate the functionality of custom plugins in their code and export API which is usable without plugins.

(to include in the next version: also, we have some notion of platform, and plugins can be needed only for some platforms. Example: JUnit)

Why not indicate whether the dependency is encapsulated or inherited when you express it?

Or better, let the plugin declare it.

As an implementation using existing tools, could we make those annotations compileTimeOnly with a nice error message, and rely on the compiler plugin to strip them if correctly enabled? That way the annotations will error out if the compiler plugin is not present, which seems like it might satisfy this use case without a change in the language

Why not indicate whether the dependency is encapsulated or inherited when you express it?
Or better, let the plugin declare it.

Yes, during implementation I start with the next simple schema: If a plugin changes the binary interface of a method, it annotates one.

The problem is that most plugins work after the ‘pickling’ stage (i.e., before saving tasty files). If we want the plugin to annotate files and be visible, this means that we should have an annotation phase before pickling in each plugin. This will double the number of required phases for plugins. Note that currently, in Dotty, the maximum number of phases is 32, and five phases already overload the compiler.

The ‘redistribute’ of the @requirePlugin annotation attempts to have one mechanism in the compiler instead of a phase in the each plugin.

As an implementation using existing tools, could we make those annotations compileTimeOnly with a nice error message, and rely on the compiler plugin to strip them if correctly enabled?

Interesting approach. (I have been thinking about @compileTimeOnly as method annotations, but if it can be bonded to any type, …)

Problematic cases:

  • plugins that work after the erasure phase. (@compileTimeOnly is erased in erasure, so if the plugin works after erasure, then the error condition will be triggered before.) Plugins will need to do an extra phase only for erasing annotations.
    ( Or maybe it is better to refactor the compiler and move checking @compileTimeOnlyCheck to its own stage after erasure.)

  • opaque types, which are changed to the value of the aliases in the separate stage before erasure (so, if opaque type is annotated by @compileTimeOnly, it will not be triggered). The plugin can avoid using opaque types in API and instead provide its own pseudotype and substitute one in the plugin, but this is extra work for plugin authors. Or we can add check @compileTimeOnly there

  • annotation marks. I.e. situation, when we generate some data for code, marked by annotations. (for example - generate plutus bytecode for annotated methods). Since annotations are already compile-time only, we have no place to insert @compileTimeOnly

So, at first glance, it looks like reusing @compileTimeOnly is not different much from the status quo now. Maybe status-quo is not bad…