Scala 编译器插件

语言
此文档页面特定于 Scala 2 中提供的功能,这些功能在 Scala 3 中已被移除或替换为替代方案。除非另有说明,此页面中的所有代码示例都假定您使用的是 Scala 2。

Lex Spoon(2008 年)
Seth Tisue(2018 年)

简介

编译器插件是一个编译器组件,它位于与主编译器分开的 JAR 文件中。然后,编译器可以加载该插件并获得额外的功能。

本教程简要介绍如何为 Scala 编译器编写插件。它没有深入探讨如何使您的插件真正发挥作用,而只是展示了编写插件并将其连接到 Scala 编译器所需的基础知识。

您可以阅读,也可以观看电视

本指南的内容与 Seth Tisue 的演讲“Scala 编译器插件 101”(32 分钟视频)有很大程度的重叠。尽管该演讲来自 2018 年 4 月,但其中几乎所有信息仍然适用(截至 2020 年 11 月)。

何时编写插件

插件允许你修改 Scala 编译器的行为,而无需更改主 Scala 分发。如果你编写了一个包含编译器修改的编译器插件,那么你分发插件给的任何人都可以使用你的修改。

实际上你并不需要经常修改 Scala 编译器,因为 Scala 的轻量级、灵活的语法通常允许你使用一个聪明的库来提供更好的解决方案。

不过,即使对于 Scala,在某些情况下编译器修改也是最佳选择。流行的编译器插件(截至 2018 年)包括

现在可以使用宏来完成某些在非常早期的 Scala 版本中需要编译器插件的任务;请参阅

工作原理

编译器插件包括

  • 一些实现附加编译器阶段的代码。
  • 一些使用编译器插件 API 来指定此新阶段应何时运行的代码。
  • 指定插件接受哪些选项的其他代码。
  • 包含有关插件的元数据的 XML 文件

所有这些都打包在 JAR 文件中。

要使用插件,用户需要将 JAR 文件添加到其编译时类路径中,并通过使用 scalac 调用 -Xplugin:... 来启用它。(某些构建工具为此提供了快捷方式;请参见下文。)

所有这些内容将在下面进行更详细的描述。

一个简单的插件,从头到尾

本节将逐步介绍如何编写一个简单的插件。

假设您希望编写一个插件来检测明显情况下的除零错误。例如,假设有人编译了一个像这样的愚蠢程序

object Test {
  val five = 5
  val amount = five / 0
  def main(args: Array[String]): Unit = {
    println(amount)
  }
}

我们的插件将生成一个类似这样的错误

Test.scala:3: error: definitely division by zero
  val amount = five / 0
                    ^

制作插件有几个步骤。首先,您需要编写并编译插件本身的源代码。以下是其源代码

package localhost

import scala.tools.nsc
import nsc.Global
import nsc.Phase
import nsc.plugins.Plugin
import nsc.plugins.PluginComponent

class DivByZero(val global: Global) extends Plugin {
  import global._

  val name = "divbyzero"
  val description = "checks for division by zero"
  val components = List[PluginComponent](Component)

  private object Component extends PluginComponent {
    val global: DivByZero.this.global.type = DivByZero.this.global
    val runsAfter = List[String]("refchecks")
    val phaseName = DivByZero.this.name
    def newPhase(_prev: Phase) = new DivByZeroPhase(_prev)
    class DivByZeroPhase(prev: Phase) extends StdPhase(prev) {
      override def name = DivByZero.this.name
      def apply(unit: CompilationUnit): Unit = {
        for ( tree @ Apply(Select(rcvr, nme.DIV), List(Literal(Constant(0)))) <- unit.body
             if rcvr.tpe <:< definitions.IntClass.tpe)
          {
            global.reporter.error(tree.pos, "definitely division by zero")
          }
      }
    }
  }
}

即使是这个简单的插件,也有很多事情要做。以下是一些值得注意的方面。

  • 插件由一个顶级类描述,该类继承自 Plugin,将 Global 作为构造函数参数,并将该参数导出为名为 globalval
  • 插件必须定义一个或多个继承自 PluginComponent 的组件对象。在这种情况下,唯一的组件是嵌套的 Component 对象。插件的组件列在 components 字段中。
  • 每个组件都必须定义 newPhase 方法,该方法创建组件的唯一编译器阶段。该阶段将插入在指定的编译器阶段之后,在本例中为 refchecks
  • 每个阶段都必须定义一个方法 apply,该方法对给定的编译单元执行您希望执行的任何操作。通常这涉及检查单元中的树并在树上进行一些转换。
  • apply 主体内的模式匹配显示了检测用户代码中某些树形状的一种方法。(准引号是另一种方法。)Apply 表示方法调用,Select 表示成员的“选择”,例如 a.b。树处理的详细信息超出了本文档的范围,但请参阅“进一步了解”,了解指向更多文档的链接。

runsAfter 方法使插件作者可以控制阶段的执行时间。如上所示,它应该返回一个阶段名称列表。这使得可以指定多个阶段名称以在插件之前执行。还可以指定此阶段应该在之前执行的阶段名称的 runsBefore 约束,但这是可选的。还可以指定 runsRightAfter 约束,以在特定阶段之后立即运行,但这也是可选的。

有关如何控制阶段排序的更多信息,请参阅 编译器阶段和插件初始化 SID。(此文档最后更新于 2009 年,因此某些细节可能已过时。)

指定顺序的最简单方法是实现 runsRightAfter

这是插件本身。接下来你需要做的是为其编写一个插件描述符。插件描述符是一个小型 XML 文件,提供插件的名称和入口点。在这种情况下,它应如下所示

<plugin>
  <name>divbyzero</name>
  <classname>localhost.DivByZero</classname>
</plugin>

插件的名称应与在 Plugin 子类中指定的内容相匹配,而插件的 classnamePlugin 子类的名称。有关插件的所有其他信息都在 Plugin 子类中。

将此 XML 放入名为 scalac-plugin.xml 的文件中,然后使用该文件加上已编译代码创建一个 jar

mkdir classes
scalac -d classes ExPlugin.scala
cp scalac-plugin.xml classes
(cd classes; jar cf ../divbyzero.jar .)

这就是它在没有构建工具的情况下工作的方式。如果你使用 sbt 构建插件,则 XML 文件将进入 src/main/resources

在 scalac 中使用插件

现在,你可以通过添加 -Xplugin: 选项来使用 scalac 中的插件

$ scalac -Xplugin:divbyzero.jar Test.scala
Test.scala:3: error: definitely division by zero
  val amount = five / 0
                    ^
one error found

发布你的插件

当你对插件的行为感到满意时,你可能希望将 JAR 发布到 Maven 或 Ivy 存储库,以便构建工具可以解析它。(出于测试目的,你也可以仅将其发布到本地计算机。在 sbt 中,这是通过 publishLocal 完成的。)

在大多数方面,编译器插件都是普通的 Scala 库,因此发布插件就像发布任何库一样。请参阅 库作者指南 和/或构建工具有关发布的文档。

从 sbt 使用插件

为了方便最终用户在你发布插件后使用它,sbt 提供了一个 addCompilerPlugin 方法,你可以在构建定义中调用它,例如

addCompilerPlugin("org.divbyzero" %% "divbyzero" % "1.0")

addCompilerPlugin 执行多个操作。它通过 libraryDependencies 将 JAR 添加到类路径(仅编译类路径,不包括运行时类路径),并使用 -Xplugin 自定义 scalacOptions 以启用插件。

有关更多详细信息,请参阅 sbt 手册中的 编译器插件支持

在 Mill 中使用插件

要在 Mill 项目中使用 scalac 编译器插件,可以覆盖 scalacPluginIvyDeps 目标以添加插件依赖项坐标。

插件选项可以在 scalacOptions 中指定。

示例

// build.sc
import mill._, mill.scalalib._

object foo extends ScalaModule {
  // Add the compiler plugin divbyzero in version 1.0
  def scalacPluginIvyDeps = Agg(ivy"org.divbyzero:::divbyzero:1.0")
  // Enable the `verbose` option of the divbyzero plugin
  def scalacOptions = Seq("-P:divbyzero:verbose:true")
  // other settings
  // ...
}

请注意,编译器插件通常绑定到编译器的完整版本,因此必须在组织和工件名称之间使用 :::(而不是正常的 ::)来声明依赖项。

有关在 Mill 中使用插件的更多信息,请参阅 Mill 文档

使用 IDE 开发编译器插件

在内部,Scala 编译器中路径相关类型的使用可能会混淆某些 IDE,例如 IntelliJ。正确的插件代码有时可能会被标记为错误。在这种情况下,IDE 通常仍然有用,但请记住要谨慎对待其反馈。如果错误突出显示令人分心,IDE 可能有一个设置,你可以禁用它。

有用的编译器选项

上一节介绍了编写、使用和安装编译器插件的基础知识。有几个与插件相关的编译器选项,你应该了解。

  • -Xshow-phases—显示所有编译器阶段的列表,包括来自插件的阶段。
  • -Xplugin-list—显示所有已加载插件的列表。
  • -Xplugin-disable:...—禁用插件。每当编译器遇到指定插件的插件描述符时,它都会跳过它,甚至不会加载关联的 Plugin 子类。
  • -Xplugin-require:...—要求加载插件,否则中止。这在构建脚本中非常有用。
  • -Xpluginsdir—指定编译器将扫描以加载插件的目录。同样,这在构建脚本中非常有用。

以下选项不特定于编写插件,但经常被插件编写者使用

  • -Xprint:—在指定阶段运行后立即打印编译器树。
  • -Ybrowse:—类似于 -Xprint:,但不是打印树,而是打开一个基于 Swing 的 GUI 来浏览树。

添加您自己的选项

编译器插件可以向用户提供命令行选项。所有此类选项都以 -P: 开头,后跟插件的名称。例如,-P:foo:bar 将选项 bar 传递给插件 foo

要向您自己的插件添加选项,您必须做两件事。首先,向您的 Plugin 子类添加一个 processOptions 方法,其类型签名如下

override def processOptions(
    options: List[String],
    error: String => Unit)

编译器将使用用户为您的插件指定的所有选项调用此方法。为方便起见,-P: 后跟您的插件名称的公共前缀将从传入的所有选项中剥离。

您应该做的第二件事是为您的插件选项添加一个帮助消息。您需要做的就是覆盖名为 optionsHelpval。您指定的字符串将作为编译器的 -help 输出的一部分打印出来。按照惯例,每个选项都打印在一行上。选项本身从第 3 列开始打印,选项的描述从第 31 列开始打印。键入 scalac -help 以确保帮助字符串看起来正确。

这是一个具有选项的完整插件。此插件没有任何行为,只是打印其选项。

package localhost

import scala.tools.nsc
import nsc.Global
import nsc.Phase
import nsc.plugins.Plugin
import nsc.plugins.PluginComponent

class Silly(val global: Global) extends Plugin {
  import global._

  val name = "silly"
  val description = "goose"
  val components = List[PluginComponent](Component)

  var level = 1000000

  override def processOptions(options: List[String], error: String => Unit): Unit = {
    for (option <- options) {
      if (option.startsWith("level:")) {
        level = option.substring("level:".length).toInt
      } else {
        error("Option not understood: "+option)
      }
    }
  }

  override val optionsHelp: Option[String] = Some(
    "  -P:silly:level:n             set the silliness to level n")

  private object Component extends PluginComponent {
    val global: Silly.this.global.type = Silly.this.global
    val runsAfter = List[String]("refchecks");
    val phaseName = Silly.this.name
    def newPhase(_prev: Phase) = new SillyPhase(_prev)

    class SillyPhase(prev: Phase) extends StdPhase(prev) {
      override def name = Silly.this.name
      def apply(unit: CompilationUnit): Unit = {
        println("Silliness level: " + level)
      }
    }
  }
}

更进一步

有关如何使您的插件完成某些任务的详细信息,您必须查阅有关编译器内部的其他文档。相关文档包括

  • 符号、树和类型是关于编译器内部使用的数据结构最重要的参考。
  • 准引号可用于对 AST 进行模式匹配。
    • 准引号指南中的 语法摘要是用户级语法和 AST 节点类型之间有用的对应关系。

查看其他插件并在编译器源代码中研究现有阶段也很有用。

此页面的贡献者