entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Scalafix (Linter) のルール開発をした話

はじめましての方ははじめまして。新卒期間も終了し、晴れて一般エンジニアになりました Javakky です。 今回は、 Scala の Linter であり自動リファクタリングツールでもある Scalafix について、ルール開発の流れについて話していこうと思います。

github.com

Scalafix とは?

Scalafix は Scala の Linter として有名なツールの一つです。 このツールの大きな特徴として、発見した修正箇所を自動で書き換えることができる。というものになっています。 イメージとしては、 IntelliJ のコードインスペクションのクイックフィックスを利用するような感じです。 もう一つの特徴として、 SyntacticRule, SemanticRule の2種類のルールの作り方が存在することが挙げられます。

scalacenter.github.io

SyntacticRule

まず、 SyntacticRule はいわゆるフォーマッタなどと同じような作りになっていて、対象のコードを字句解析した後に得られる文字列配列を操作することで校正や書き換えを行うことができるようになっています。 擬似コードとして val x = 3 というコードから得られるトークンを以下に示します。

[
  Token$KwVal("val"), 
  Token$Space(" "),
  Token$Ident("x"),
  Token$Space(" "),
  Token$Equals("="),
  Token$Space(" "),
  Token$Constant$Int("3")
]

詳細は後述しますが、自作のルールでは不要なセミコロンの削除のために SyntacticRule を利用しました。

github.com

SyntacticRule の強みとして、コードの構文木の生成や型情報の取得といった重たい処理を挟まないため、高速に動作させることができるというものがあります。

SemanticRule

二つ目が SemanticRule です。こちらは実際にコードを構文解析し、その結果得られる木を操作しながら校正を行います。 例えば、 val x = 3 というコードを collect で操作すると、以下のような順番で値がヒットします。 ※疑似コードです。実際にはリストではないのですが、わかりやすさのためにそのように表記しています。

[
  Source("val x = 3"),
  Defn$Val("val x = 3"),
  Pat$Var("x"),
  Term$Name("x"),
  Lit$Int("3")
]

下図のように、 Tree を継承した複数のインターフェースやクラスが定義されていて、それぞれのインスタンスが入れ子になっています。 例えば Lit にマッチさせることで、あらゆるリテラル表記にマッチさせることができたりします。

次にこの中の Defn$Val の定義を見てみましょう。 pats には左辺の変数一覧が、 rhs には右辺の式が格納されていて、一度 Defn$Val でマッチさせてから中身を参照することもできるようになっています。

class Val(
  mods: List[Mod],
  pats: List[Pat] @nonEmpty,
  decltpe: Option[scala.meta.Type],
  rhs: Term
) extends Defn

Treeの各クラスの定義は、 Tree.scala から見ることができます。

Patch

作成する各ルールは、 Patch と呼ばれる書き換え内容を表現するクラスのインスタンスを返すことで書き換え (または Lint) を行うことができます。 Patch には以下のようなメソッドが存在し、その返り値を返却することでルールを実装していきます。

Patch.scala:

github.com

  • empty : 何も書き換えを行わない
  • replaceToken(token: Token, toReplace: String) : トークンのあった位置を文字列で置換する
  • addAround(tok: Token, left: String, right: String) : トークンの左右に文字列を追加する
  • lint(msg: Diagnostic) : コードには変更を加えずに警告を出力する

Scalafixのカスタムルールの開発

チュートリアルのセットアップページ にある通り、 sbt new scalacenter/scalafix.g8 コマンドを用いて sbt プロジェクトを作成します。

build.sbtinThisBuild \ organization では後述する Maven Central Repository で取得したネームスペースを設定しましょう。 またサブプロジェクトの rulessettings にモジュール名 (と後で削除しますがバージョン) も設定するとよいでしょう。

lazy val rules = projectMatrix
  .settings(
    moduleName := ${ルール名},
    version := “0.0.1-SNAPSHOT”
    libraryDependencies ++= Seq(
      "ch.epfl.scala" %% "scalafix-core" % V.scalafixVersion
    )
  )
  .defaultAxes(VirtualAxis.jvm)
  .jvmPlatform(rulesCrossVersions)

今回、私たちが作成したルールの完全な設定は こちら にあります。ご参考までにどうぞ。

Scalafix

もちろんこのリポジトリにも Scalafix を導入してコードの品質管理をしたいですよね! しかし、Scalafix は projectMatrix に対応していなさそうだったため、 rules と同じディレクトリを指すサブプロジェクトを作成しました。 sbt “src/scalafixAll” で Scalafix が利用できます。

lazy val src = (project in file("rules"))
  .settings(
    libraryDependencies ++= Seq(
      "ch.epfl.scala" %% "scalafix-core" % V.scalafixVersion
    ),
    scalacOptions ++= Seq(
      "-deprecation",
      "-feature",
      "-Ywarn-unused:imports,locals,patvars"
    ),
    semanticdbEnabled := true,
    semanticdbVersion := scalafixSemanticdb.revision,
    publish / skip := true
  )

Scala Test

ルールを実装する中で、汎用的な処理を持つクラスを作成する必要が出てきました。このクラスをテストするため、 ScalaTest を導入しました。

www.scalatest.org

導入方法としては rules, src サブプロジェクトの Dependencies に追加するだけです。

libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.11" % "test"

また、テストコードについては rules/src/test/scala 以下に設置します。 これで、 sbt test を実行した際に src/test にあるテストコードを実行してくれるようになります。

sbt-license-report

このルールをOSSとして公開することに決めたので、依存関係を全て NOTICE.md に吐き出したくなりました。 こんな時には sbt-license-report が役に立ちます。

github.com

設定は plugins.sbt ファイルに以下の行を追加して、

addSbtPlugin("com.typesafe.sbt" % "sbt-license-report" % "1.2.0")

src サブプロジェクトに以下の設定を追加するだけで完了します!

# 出力するファイルの名前
licenseReportTitle := "NOTICE",
# 出力するディレクトリ
licenseReportDir := `<プロジェクト名>`.base,
# 形式 (Markdown, Html, Csv)
licenseReportTypes := Seq(MarkDown)

sbt "src/dumpLicenseReport" コマンドで NOTICE.md はあなたのものです。 公開前には LICENCE ファイルを手動で追加することも忘れないようにしましょう。

sbt-ci-release

作成したルールを公開するにあたって、毎回手動で publish するのも面倒だな〜と思う方は多いのではないでしょうか? sbt-ci-release の README 通りに GitHub Actions と plugins.sbt の設定をするだけで、git のタグを打つのに反応して自動でリリース作業を行ってくれるようになります。

github.com

ローカルでの実行

いくらテストを書いたとはいえ、 Scalafix のルールをリリースする前に別のリポジトリで動作を試したい!なんてことはありませんか? そんな時は publishLocal コマンドがおすすめです。Scalafix ルール側のリポジトリで sbt publishLocal を行うと、 jar ファイルが ~/.ivy2/local/ に保存され、まるで Maven Central Repository にあるかのように利用することができます。デプロイ前の動作確認にぜひご利用ください。

作成したルール

UnnecessarySemicolon

こちらは行末の不要なセミコロンを削除するルールです。 このルールでは、 (技術的には可能ですが) 連続する処理の中間に記述されているセミコロンは分割していません。

UnnecessarySemicolon は SyntacticRule として実装されていて、トークンを collect で走査した後、; にマッチし、かつ、直後が行末である場合にヒットするように実装されています。

class UnnecessarySemicolon extends SyntacticRule("UnnecessarySemicolon") {
  override def fix(implicit doc: SyntacticDocument): Patch = {
    doc.tokens.zipWithIndex.collect { case (semicolon: Token.Semicolon, i) =>
      doc.tokens(i + 1) match {
        case _ @(Token.CR() | Token.LF() | Token.EOF()) =>
          Patch.replaceToken(semicolon, "")
        case _ => Patch.empty
      }
    }.asPatch
  }
}

ZeroIndexToHead

これはあるシンボルに対して (0) を適用している場合に head というメソッドの呼び出しに置換するルールです。 ここでは Term$Apply (関数適用) の構文にマッチした場合に、() の中身がリテラルの 0 だったら .head 呼び出しに置き換える。という処理を行っています。 Patch#replaceTree の第二引数は文字列なため、一旦 head メソッド呼び出し (Select) の Tree を一旦組んでから toString した結果を渡すことで変換後の文字列を生成しています。

class ZeroIndexToHead extends SemanticRule("ZeroIndexToHead") {
  override def fix(implicit doc: SemanticDocument): Patch = {
    doc.tree.collect {
      case t @ Term.Apply(x1, List(Lit.Int(0))) =>
        Patch.replaceTree(
          t,
          Term.Select(x1, Term.Name("head")).toString
        )
    }.asPatch
  }
}

しかし、これでは x1(0) が本当に x1.head に置換しても問題ない確証が持てません。なので、 x1 が想定される型 (ここでは、 Seq かつ IndexedSeq ではないクラス) かどうかをチェックしたいと思います。

Scalafix でシンボルの型を取り出す

今回は Symbol が表す文字列から完全修飾クラス名を類推するというアプローチをとりました。 SemanticType を Class[_] に変換できるようにする #6 Scalafix の SemanticRule では、裏で SemanticDB という Scala コードの構文情報を生成するライブラリを利用しています。

github.com

ここで、 Scala の Symbol は以下のように表されます 1. Package : 各記号名を / で連結する 2. Class または Trait : パッケージ名 + シンボル名 + # 3. Object : パッケージ名 + シンボル名 + . 4. Type: 所有者名 + シンボル名 + #

scalameta.org

まず、 2. のパターンでは、完全修飾クラス名に変換することが容易なため、リフレクションによって Class[_] に変換を行います。

Class.forName(str.replace('/', '.').init)

ここで、 3. のパターンを考えると単純な末尾切り落としではなく、判定を行うべきです。 しかし、(少なくとも私たちのリポジトリで) 3. が返されるパターンは apply 呼び出しの左項であることがほとんどでした。そこで、 3. のパターンではまず対象のオブジェクトがコンパニオンオブジェクト、かつ、シンボルが apply 呼び出しであると仮定して変換を行うことにしました。これであれば、 init のままで問題ありません。

これで多くの型を Class[_] に変換することができました。しかし、少し厄介な問題があります。それは、 Java 標準APIにあるクラスが指定されている場合です。 例えば、 java.lang.String が使われている場合、 scala.Predef オブジェクトに定義された type としてシンボルが返されてしまいます。 そこで、上記コードで変換に失敗した場合には、あるオブジェクト内で再定義された type であると仮定してより高度なリフレクションにより型を取得することにしました。

val str = str.replace('/', '.').init
val lastDot = str.lastIndexOf('.')
// 最後の `.` までの文字列をオブジェクトに変換する
val objCls = Class.forName(str.take(lastDot) + "$")
import scala.reflect.runtime.universe.{TypeName, runtimeMirror}
val mirror = runtimeMirror(getClass.getClassLoader)
// 前項で生成したオブジェクトから `type` を取り出す
mirror.runtimeClass(mirror.classSymbol(objCls).toType.decl(
  TypeName(str.drop(lastDot + 1))
).typeSignature.dealias.typeSymbol.asClass)

CheckIsEmpty

こちらは Seq (Scala にあるリストのような構造) の空チェックに .isEmpty を利用するように書き換えを行うルールです。

そして以下が fixメソッドのすべてです。ここでは具体的にどういうパターンがヒットするかは記載されていません。

class CheckIsEmpty extends SemanticRule("CheckIsEmpty") {
  override def fix(implicit doc: SemanticDocument): Patch = {
    doc.tree.collect {
      // IsDefined かつ書き換えが必要なパターンであれば
      case t @ IsDefined(x1, rewrite) if rewrite && isType(x1, classOf[Option[Any]]) =>
        Patch.replaceTree(t, Term.Select(x1, Term.Name("isDefined")).toString())
      // NonEmpty かつ書き換えが必要なパターンであれば
      case t @ NonEmpty(x1, rewrite) if rewrite && CheckIsEmpty.isTypeHasIsEmpty(x1) =>
        Patch.replaceTree(t, Term.Select(x1, Term.Name("nonEmpty")).toString())
      // IsEmpty かつ書き換えが必要なパターンであれば
      case t @ IsEmpty(x1, rewrite) if rewrite && CheckIsEmpty.isTypeHasIsEmpty(x1) =>
        Patch.replaceTree(t, Term.Select(x1, Term.Name("isEmpty")).toString())
    }.asPatch
  }
}

このように Scalafix のルール作成では 抽出子 を利用することでルールをシンプルに記述することができます。 抽出子はパターンマッチ時にインスタンスを複数の別のインスタンスに変換することができるため、複数のパターンをまとめて扱うことができるのです。

では実際に、 IsEmpty オブジェクトの実装を見てみましょう。 unapply メソッドを定義し、受け取った Tree インスタンスをマッチさせることで、 .isEmpty を呼び出すべき Term を返却しています。また、 !seq.IsEmpty のように否定が連続しているものを変換するため、同時に直接変換すべき項であるかの情報も返しています。

private object IsEmpty {
  def unapply(tree: Tree)(implicit doc: SemanticDocument): Option[(Term, Boolean)] = {
    tree match {
      // `seq.isEmpty` は変換不要だが IsEmpty ではある
      case _ @Term.Select(x1: Term, _ @Term.Name("isEmpty")) => Some(x1, false)
      // `seq.size == 0`
      case _ @Term.ApplyInfix(
            Term.Select(x1: Term, _ @(Term.Name("size") | Term.Name("length"))),
            Term.Name("=="),
            Nil,
            List(Lit.Int(0))
          ) => Some((x1, true))
      case _ @Term.ApplyUnary(Term.Name("!"), NonEmpty(x1, _)) => Some((x1, true))
      case _ @Term.ApplyUnary(Term.Name("!"), IsDefined(x1, _)) => Some((x1, true))
      // option == None
      case _ @Term.ApplyInfix(x1: Term, _ @Term.Name("=="), Nil, List(Term.Name("None")))
          if isType(x1, classOf[Option[Any]]) => Some((x1, true))
      // None == option
      case _ @Term.ApplyInfix(Term.Name("None"), _ @Term.Name("=="), Nil, List(x1: Term))
          if isType(x1, classOf[Option[Any]]) => Some((x1, true))
      case _ => None
    }
  }
}

コンフィグの利用

CheckIsEmpty ルールを実装したところで、1つのアイデアが浮かびました。 実は OptionnonEmptyisDefined は同じ処理を持つメソッドなので、そこも統一できると嬉しい!というものです。 しかし、IntelliJ などの変換規則ではこれは規定されていない書き換えルールのため、この好みが別れるルールだと考えました。そこで、 OptionnonEmpty を変換するかどうかは .scalafix.conf で設定ができるようにすることにしました。 今回はコードの例示を省略しますが、これはユーザーガイドの Tutorial にて解説があります。

Use withConfiguration to make a rule configurable

NonCaseException

このルールは弊社独自のコーディング規約に基づいたルールで、 Exception を継承した独自例外を case class として実装している場合にエラーを出すというものです。 もしこのルールを書き換えルールとして定義した場合、対象の Exception を生成している箇所全てを new を用いたインスタンス化に置き換える必要が出てしまいます。それでは工数がかかりすぎるため、以下のチュートリアルを参考に Lint ルールとして実装しました。

Use Diagnostic to report linter errors

今後の展望

実は先日行われた Scala Matsuri 2022 Day2 にて発表を行ったり、交流会でお話ししてきたりしました。 その中で、Scalafix によるリッチなルール作成について以下のようなご意見を頂きました。

  • Semantic DB を生成する段階で情報が単純化されているので型情報を上手く扱うことが難しい (@tanishiking さんより)
  • Scalafix の SemanticRule は非常に重いのであまり多用したくない
  • Wartremover を利用するのもそれはそれで課題がいくつかある
  • TASTy を利用した Linter を作成する or 既存の Linter で TASTy を利用するといい感じかもしれない?
    • しかし TASTy が利用できるのは Scala 3 以降のみ
    • Scalafix で TASTy を利用するにはある程度大きく作り替える必要がありそう

上記を勘案した結果、ひとまずは scalafix-pixiv-rule の開発を続けつつ、 Scala 3 移行後に方針を再考していくことにしました。 ルールなども随時追加していく予定ですので、続報をお待ちください!

icon
javakky
決済周りの改善を中心に働いている2021年入社エンジニア。その名の通りJavaが好きなことで有名(?)で、最近はScalaを使える部署へ入ったらしい。