Scalafixを使って

Scalaプロジェクトの

メンテナンスで楽しよう

2020.3.23 Taisuke Oe

あなた誰?

Taisuke Oe

 

  • フリーランスエンジニア
  • ScalaMatsuri座長
  • (株)セプテーニ・オリジナル技術アドバイザー

Scalafixとは?

  • ソースコードの書き換え(Rewrite)ツール
  • 事前定義したRuleに基づいて、Rewrite・Lintする
  • 主に2つ側面でメンテナンスを楽にする
    • リファクタリング
    • マイグレーション

 

リファクタリングを中心に

見ていきましょう

リファクタリング

  • Lint
  • Rewrite

使い方 初めの一歩

前準備

 

//project/plugins.sbt

addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.12")
//build.sbt

scalacOptions ++= Seq("-Yrangepos", "-Ywarn-unused")
addCompilerPlugin(scalafixSemanticdb)

Scalafixの使い方

.scalafix.conf を利用

// Lint
sbt:myProject> scalafix --check

// Rewrite
sbt:myProject> scalafix

引数でRuleを指定

//Rewrite

sbt:myProject> scalafix RemoveUnused

Scalafixの使い方

.scalafix.conf の例(詳しくは後述)

//.scalafix.conf

rules = [
  DisableSyntax
  ProcedureSyntax
  RemoveUnused
]
DisableSyntax.keywords = [
  return
]

とあるScalaのスニペット

//src/main/scala/Sample.scala

import scala.util.Try

class Sample {
  private val x = 1
  def run() {
    return
  }
}

Q. 間違い?探し

このScalaのスニペットには、変更した方がいいところが幾つかあります。

分かりますか?

とあるScalaのスニペット

//src/main/scala/Sample.scala

import scala.util.Try

class Sample {
  private val x = 1
  def run() {
    return
  }
}

A. 回答

  • return
  • procedure syntax
  • unused private variable
  • unused import
//.scalafix.conf

rules = [
  DisableSyntax
  ProcedureSyntax
  RemoveUnused
]
DisableSyntax.keywords = [
  return
]

以下をカバーする.scalafix.conf

  • return
  • procedure syntax
  • unused private variable
  • unused import
[error] /Users/taisukeoe/Workspace/ScalafixSandbox/src/main/scala/Sample.scala:5:5: error: [DisableSyntax.return] return should be avoided, consider using if/else instead
[error]     return
[error]     ^^^^^^
  • Lint専用Rule。Rewriteはしない。
  • 様々な構文(Syntax)の使用を検出して、エラーを報告する
    • return, while, varなど
      • コメントにはマッチしない
    • 正規表現による検出も可能
  • procedure syntaxの戻り値を明記するようrewriteするRule
  • `scalafix --check` だと、期待される書き換えをエラーメッセージとして表示する
  • 使われていないimport、private変数、ローカル変数を削除するようrewriteするrule
  • scalacOptionsに以下の制約がある
    • `-Ywarn-unused` や、`-Wunused` (2.13.1のみ)を使う
      • 使われていないシンボルの検出に、エラーメッセージを利用している
    • `-Xfatal-warnings` を使わない。
      • scalafixの実行前にコンパイルする必要があるため、警告有りでもコンパイルできる必要がある
[error] /Users/taisukeoe/Workspace/ScalafixSandbox/src/main/scala/Sample.scala:6:5: error: [DisableSyntax.return] return should be avoided, consider using if/else instead
[error]     return
[error]     ^^^^^^
--- /Users/taisukeoe/Workspace/ScalafixSandbox/src/main/scala/Sample.scala
+++ <expected fix>
@@ -1,8 +1,8 @@ / Compile / scalafix 1s
-import scala.util.Try

+
 class Sample {
-  private val x = 1
-  def run() {
+  
+  def run(): Unit = {
     return
   }
 }

Lintの結果

sbt:myProject> scalafix --check
--- /Users/taisukeoe/Workspace/ScalafixSandbox/src/main/scala/Sample.scala
+++ <expected fix>
@@ -1,8 +1,8 @@
-import scala.util.Try
 
+ | => refactor / Compile / scalafix 0s
 class Sample {
-  private val x = 1
-  def run() {
+  
+  def run(): Unit = {
     return //scalafix:ok
   }
 }
[error] (refactor / Compile / scalafix) scalafix.sbt.ScalafixFailed: TestError
[error] Total time: 1 s, completed 2020/03/23 17:32:04
  • //scalafix:ok
    • 指定した式のみ抑制
  • //scalafix:off
    • //scalafix:on するまでのブロックを抑制
  • @SuppressWarnings(Array("scalafix:DisableSyntax.return"))
[error] /Users/taisukeoe/Workspace/ScalafixSandbox/src/main/scala/Sample.scala:6:5: error: [DisableSyntax.return] return should be avoided, consider using if/else instead
[error]     return
[error]     ^^^^^^
[error] (refactor / Compile / scalafix) scalafix.sbt.ScalafixFailed: LinterError
[error] Total time: 6 s, completed 2020/03/23 4:47:51

Rewriteの結果

sbt:myProject> scalafix
// src/main/scala/Sample.scala


class Sample {

  def run(): Unit = {
    return
  }
}

便利なRuleの例

  • RemoveUnused
    • 前述
  • ExplicitResultTypes
    • publicやprotectedなメンバに型注釈をつけて回る
      • 右辺が自明な場合(Literalなど)は、戻り値に型注釈をつけない
    • 現在の実装はScalaのCompilerに依存してしまっているため、2.12.11でしか動かない

Scalafix repo外のRule

scalacenter/scalafix に含まれないRuleは、なんらかの方法で指定する必要がある

 継続して使いたい外部Rule

  • コードの質を保つために有用なもの。
  • build.sbtのscalafixDependenciesに追記するのがオススメ。
scalafixDependencies in ThisBuild += "com.github.vovapolu" %% "scaluzzi" % "0.1.4"

Scalafix repo外のRule

  • scaluzzi の `MissingFinal`
    • case classにfinalをつけて回るrewrite
  • sort-imports の `SortImports`
    • importを、prefix(例:java, scala)ごとにブロックでまとめる
    • wildcard importの利用場所によっては、コンパイルが通らなくなるので注意

Scalafix repo外のRule

migration用rule

  • 基本的に、一度使えば終わり
  • build.sbtに追記せず使うことが多い

publishされているartifactを利用

  • maven repositoryにpublishされているもの
  • local repositoryにpublishLocalされているもの
sbt:myProject> scalafix dependency:RULE@GROUP:ARTIFACT:VERSION

Scalafix repo外のRule

Ruleのソースコードを利用

  • ローカルFile
  • GitHub
  • Http

それぞれで、Ruleのソースファイルを指定して利用可能

詳細: Run the Rule from source code

sbt:myProject> scalafix github:ORG/REPO/FILENAME?sha=BRANCH

Scalafix repo外のRule

scala-steward repositoryに登録されているmigration用のrulesは、自動でバージョンアップとともにRewriteするPullRequestが飛んでくる(!!)

参考: scala-steward Scalafix Migrations

 

libraryのupdateには対応しているが、scalaVersionのupdateには対応していない

  • https://github.com/fthomas/scala-steward/issues/145
  • https://github.com/fthomas/scala-steward/issues/589
  • https://twitter.com/fst9000/status/1141443347741061120

migration用のRuleの例

Scalafix 書き換えの仕組み

Scalafixの仕組みを学ぶ

 `Rule` と `Patch`

Rule

  • 書き換えおよびLintの「ルール」
  • `.scalafix.conf`に記述、もしくはコマンドライン引数に渡す
  • Ruleごとに以下のものがある
    • 利用に必要な追加のbuild設定
    • カスタマイズ用の設定

Rule

Ruleは大別して2種

  • SyntacticRule
  • SemanticRule

のどちらかを継承する

abstract class SyntacticRule(name: RuleName) extends Rule(name) {
  def fix(implicit doc: SyntacticDocument): Patch = Patch.empty
}

abstract class SemanticRule(name: RuleName) extends Rule(name) {
  def fix(implicit doc: SemanticDocument): Patch = Patch.empty
}

Rule

それぞれのfixメソッドにおいて

  • ソースコードの情報(Syntactic or SemanticDocument)を受け取り
  • どこをどのように書き換える/Lintする(Patch)のか決めて返す
abstract class SyntacticRule(name: RuleName) extends Rule(name) {
  def fix(implicit doc: SyntacticDocument): Patch = Patch.empty
}

abstract class SemanticRule(name: RuleName) extends Rule(name) {
  def fix(implicit doc: SemanticDocument): Patch = Patch.empty
}

Ruleの種類

SyntacticRule (構文的ルール)

  • ソースコードを、構文解析(だけ)で書き換えるRule
  • ソースの構文さえ合っていれば、compileできなくてもlintやrewriteができる
  • SyntacticRuleの例
    • DisableSyntax
    • LeakingImplicitClassVal
    • NoValInForComprehension
    • ProcedureSyntax

SyntacticRule (構文的ルール)

構文解析とは、文法に従って、文字列に対して行う次のような操作のこと

  • 文字のまとまり(字句/token)ごとに分割(Tokenize)する
    • このtokenは、ソースコード上の位置を覚えている  
  • 字句の列(tokens)から、木構造(抽象構文木 / Abstract Syntax Tree)を作る

SyntacticRule (構文的ルール)

構文解析とは、文法に従って、文字列に対して行う次のような操作のこと

  • 文字のまとまり(字句/token)ごとに分割(Tokenize)する
    • このtokenは、ソースコード上の位置を覚えている  
  • 字句の列(tokens)から、木構造(抽象構文木 / Abstract Syntax Tree)を作る
scala> import scala.meta._

scala> val stat = """def run(){println("run")}""".parse[Stat].get
stat: scala.meta.Stat = def run(){println("run")}

scala> val tokens = stat.tokens
tokens: scala.meta.tokens.Tokens = Tokens(, def,  , run, (, ), {, println, (, "run", ), }, )

SyntacticRule (構文的ルール)

構文解析とは、文法に従って、文字列に対して行う次のような操作のこと

  • 文字のまとまり(字句/token)ごとに分割(Tokenize)する
    • このtokenは、ソースコード上の位置を覚えている  
  • 字句の列(tokens)から、木構造(抽象構文木 / Abstract Syntax Tree)を作る
scala> val astString = stat.structure
astString: String = Defn.Def(Nil, Term.Name("run"), Nil, List(List()), Some(Type.Name("Unit")), Term.Block(List(Term.Apply(Term.Name("println"), List(Lit.String("run"))))))

SyntacticRule (構文的ルール)

木構造(抽象構文木 / Abstract Syntax Tree)を作る

Defn.Def( //メソッド定義
  Nil, //修飾子
  Term.Name("run"), //メソッド名
  Nil, //型パラメータ
  List(List()), //引数リスト
  Some(Type.Name("Unit")), //戻り値の型
  Term.Block( //メソッドボディ
    List( //文のリスト
      Term.Apply(
        Term.Name("println"), //printlnという項に対し
        List(Lit.String("run")) //"run" というStringリテラルを適用している
      )
    )
  )
)

SyntacticRule (構文的ルール)

戻り値(`decltpe`)の字句(token)を調べると.....

戻り値の型宣言があり、かつ型宣言のtokenは空、という扱い

 

scala> val returnTypeTokens = stat.asInstanceOf[Defn.Def].decltpe.map(_.tokens)
returnTypeTokens: Option[scala.meta.tokens.Tokens] = Some()

余談: 細かい仕様を覚える必要はないですが......

  • `def run(): Unit` だと、`decltpe.map(_.tokens)`は `Some(Unit)`
  • `def run() = {}` だと `decltpe.map(_.tokens)`は `None`

すなわち、ある文が「ProcedureSyntaxを使ったメソッド定義」であることは、構文だけ見れば確定できる

Syntactic APIではできないこと

今回のケースでいえば、以下のことは分からない。

  • `Unit` 型が、 `scala.Unit` を参照していること
  • `println("run")` が、`scala.Predef.println(x: Any): Unit`のメソッド呼び出しであること
  • メソッドボディのブロックが、どの型に推論されるかということ※

たとえば以下のメソッドの戻り値を補うRewriteは、Syntactic APIだけでは実現不可能

def run() = println("run")

よって戻り値の型を補うExplicitResultTypesは、SemanticRuleを継承して定義されています。

SemanticRule (意味論的ルール)

  • 構文解析および後述のSemanticDBの両方を利用
  • ものによっては、さらにCompilerのAPIを利用していることもある
    • SemanticRuleの例
    • RemoveUnused
    • ExplicitResultTypes
    • NoAutoTupling
  • など

SemanticRule (意味論的ルール)

SemanticDB

  • コンパイル時に生成される、意味論的な情報を保存したファイル
  • 大雑把には以下の情報を含むバイナリ。(仕様)
    • Language。現在はScalaとJavaをサポート。
    • SymbolInformation。Symbolとそのメタデータ(型、修飾子、アノテーションなど)。
    • SymbolOccurence。Symbolの参照の解決結果。
    • Diagnostic。コンパイラやLinterなどが生成した診断情報。
    • Synthetic。コンパイラが生成したTree。(Scalaでいえば、for式の展開結果など)。
  • Language Server Protocolに対応する意味論情報の共通フォーマットとして設計された
  • Semantic database · Issue \#605 · scalameta/scalameta

SemanticRule (意味論的ルール)

SemanticDBの生成

  • Scala 2.x
    • semanticdb-scalacコンパイラプラグインによって生成される
  • Scala 3.x
    • dotcの `-Ysemanticdb` フラグによって生成される


SemanticDBの利用

など

 

Scalafixの使い所

よく使われているScalaのLinter

コンパイラオプション

  • scalac options
    • `-Xlint`, `-Xfatal-warnings`, `-Ywarn-unused`, ...

コンパイラプラグイン

  • WartRemover
  • scapegoat

Scalafixと、既存のLinterとの比較

メリット

デメリット

Scalafixのメリット

 

  • Scalafixの警告の大半を半自動で修正可能
    • DXに良さみがある
    • 新規導入の際は、顕著に工数差が出る

 

  • 設計上、Scala 3もサポート可能
    • dottyは既にSemanticDBを生成可能。
    • あとはScala 3のソースコードを構文解析できれば……
  • 極一部の、コンパイラ内部に密結合しているRuleは対応が大変?
  • そもそもdottyではコンパイルエラーになるようなRuleもチラホラ

Scalafixのデメリット

  • Scalafixを利用するためのビルド設定が、コンパイル時間のoverheadとなる
    • 実開発で問題となるかどうかは、コードベース次第?
  • その他のLinterに比べて、Ruleは少なめ
  • scalac optionsとの相性問題
    • -Xfatal-warningsを使うには、ビルド定義を少し工夫する必要あり
  • Ruleごとに設定フォーマットが違う
    • (まだあまりないけど)大量のRuleを入れると、設定ファイルが爆発しそう?
  • まだScalafixは色々こなれていない&情報が不足気味
    • Scalafix, 外部rule, .scalafix.confのversion管理の仕組みが必要そう

Scalafixの導入の仕方

Scalafixの導入の仕方

SemanticDB生成設定をどうするか

3通りの方法を考えてみましょう

  1. 常に有効
  2. Scalafix直前に有効化
  3. 専用のサブプロジェクトで実行

 

1. 常に有効

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.12")
// build.sbt
scalacOptions ++= Seq("-Yrangepos", "-Ywarn-unused")
addCompilerPlugin(scalafixSemanticdb)
// .sbtrc
alias execScalafix=;scalafix;Test/scalafix
alias checkScalafix=;scalafix --check;Test/scalafix --check
alias strictCompile=;set Compile/compile/scalacOptions += "-Xfatal-warnings";compile
alias strictRecompile=;set Compile/compile/scalacOptions += "-Xfatal-warnings";~compile

1. 常に有効

Good

  •  compileの合間に、気軽にscalafixコマンドを叩ける
    • compileでsemanticDBが生成されているため

Bad

  • semanticdb生成用の設定が常に有効なため、compile時間が伸びる
    • プロジェクトのコードベースによるため、問題となるかどうかは要確認

2. Scalafixの直前に有効化

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.12")
// build.sbt
scalacOptions += "-Ywarn-unused"
// .sbtrc
alias execScalafix=;scalafixEnable;scalafix;Test/scalafix
alias checkScalafix=;scalafixEnable;scalafix --check;Test/scalafix --check
alias strictCompile=;set Compile/compile/scalacOptions += "-Xfatal-warnings";compile
alias strictRecompile=;set Compile/compile/scalacOptions += "-Xfatal-warnings";~compile

`scalafixEnable` は、SemanticDB系の設定を一時的に追加するsbtコマンド

2. Scalafixの直前に有効化

Good

  •  通常のcompile時には、overheadを回避できる

Bad

  • scalafix実行前の待ち時間が顕著に伸びる
    • scalafixEnableのたびにsbtがreloadされ、incremental compilationも切れるため

3. 専用のサブプロジェクトで実行

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.12")
// project/ScalafixSettings
object ScalafixSettings {
  private val unused = "-Ywarn-unused"

  lazy val adhoc: Seq[Setting[_]] = Seq(
    scalacOptions += unused,
    //Add external Scalafix Rules here.
    scalafixDependencies in ThisBuild ++= Seq(
      "com.github.vovapolu" %% "scaluzzi" % "0.1.4",
      "com.nequissimus" %% "sort-imports" % "0.3.2"
    )
  ) ++ Seq(Compile, Test).map(_ / console / scalacOptions -= unused)

  lazy val permanentlyAdded: Seq[Setting[_]] =
    adhoc ++ Seq(
      scalacOptions += "-Yrangepos",
      addCompilerPlugin(scalafixSemanticdb)
    )
}

3. 専用のサブプロジェクトで実行

// build.sbt
lazy val myProject = (project in file("."))
  .settings(common)
  .settings(ScalafixSettings.adhoc)

//For refactoring-purpose, semanticdb-scalac compiler plugin is always enabled
lazy val refactor = project
  .settings(common)
  .settings(
    for {
      cfg <- Seq(Compile, Test)
      ky <- Seq(
        sourceDirectory,
        resourceDirectory
      ) // You may need to include managed or unmanaged directories
    } yield cfg / ky := (myProject / cfg / ky).value
  )
  .settings(ScalafixSettings.permanentlyAdded)

3. 専用のサブプロジェクトで実行

Good

  • メインのcompileは、overheadなしで実行可能
  • scalafixタスクも、それなりに高速に実行できる
  • メインのscalacOptionsに、 `-Xfatal-warnings` を追加可能

Bad

  • ビルド定義が複雑
    • 複雑なマルチプロジェクト・ビルド定義に適用するのは厳しい可能性も?
    • 今後、もっといいやり方が見つかるかも

1~3. どれを選ぶべき?

私見だが、 1 > 3 >> 2の順で検討するのが良いと思う

  • お試し導入するなら1が楽
  • 本格的に使っていく&コンパイル時間に影響が出るなら、3のようなやり方を検討するのもアリ?
  • 1も3もうまくいかない場合、2を検討

余談: Scalafix CLI

  • cousierでscalafixのCLIアプリケーションをインストールできる
  • CLIにはscalafixDependenciesを指定できない
    • 外部Ruleはuri等含め、コマンドライン引数として渡す
    • .scalafix.confを使いにくい

リファクタリング目的で日常的に使うなら、sbt-scalafixがオススメです。

Scalafixの使い方Tips

その他のScalafix TIPS

  • scalafixの後に必ずscalafmtをかける
    • rewriteでフォーマットが崩れる可能性がある
  • scalaVersionの、最新minor versionを使う
    • 2.13.1
    • 2.12.11
    • 2.11.12
  • (特に初めてのRuleで)rewriteする前には:
    • scalafix --checkして差分を確認してから、scalafixする
    • WIPでもcommitしておくなど、rewrite前にすぐ戻れるようにしておく

異色Scalafixプロジェクト

simulacrum-scalafix

  •  simulacrumのscalafixポート
    • macroアノテーションでtypeclassのコード生成をするライブラリだった
    •  Scala 3でmacroアノテーションが使えなくなることを受けての実験

Scalafixまとめ

  • 日々の開発を少し楽にするリファクタリングツール
    • 警告を1コマンドでただちに直せる
  • migration用のruleで非互換の変更を反映する
    • 適切なRuleがあれば、機械的な変更はカバーできる
  • こなれてない部分は今後に期待
    • ポンテンシャルあるツールなので活用していきましょう

Scalafixで、Scalaプロジェクトのメンテナンスで楽しよう

By Taisuke Oe

Scalafixで、Scalaプロジェクトのメンテナンスで楽しよう

  • 1,723