此文档页面特定于 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.js、Scala Native 和 Fortify SCA for Scala。
- 诸如 Wartremover 和 Scapegoat 之类的 linter。
- 诸如 kind-projector 之类的修改 Scala 语法的插件。
- 诸如 silencer、splain 和 clippy 之类的修改 Scala 在错误和警告方面的行为的插件。
- 诸如 Sculpt、acyclic 和 graph-buddy 之类的分析源代码结构的插件。
- 诸如代码覆盖率工具 scoverage 之类的对用户代码进行检测以收集信息的插件。
- 启用工具的插件。一个这样的插件是 semanticdb,它使 scalafix(一个著名的重构和 linting 工具)能够完成其工作。另一个是 Macro Paradise(仅适用于 Scala 2.12)。
- 诸如 better-monadic-for 和 better-tostring 之类的修改用户代码中现有 Scala 构造的插件。
- 诸如 scala-continuations 之类的通过重构用户代码向 Scala 添加全新构造的插件。
现在可以使用宏来完成某些在非常早期的 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
作为构造函数参数,并将该参数导出为名为global
的val
。 - 插件必须定义一个或多个继承自
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
子类中指定的内容相匹配,而插件的 classname
是 Plugin
子类的名称。有关插件的所有其他信息都在 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:
后跟您的插件名称的公共前缀将从传入的所有选项中剥离。
您应该做的第二件事是为您的插件选项添加一个帮助消息。您需要做的就是覆盖名为 optionsHelp
的 val
。您指定的字符串将作为编译器的 -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)
}
}
}
}
更进一步
有关如何使您的插件完成某些任务的详细信息,您必须查阅有关编译器内部的其他文档。相关文档包括
查看其他插件并在编译器源代码中研究现有阶段也很有用。