Scalafixを使って
Scalaプロジェクトの
メンテナンスで楽しよう
2020.3.23 Taisuke Oe
あなた誰?

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など
- コメントにはマッチしない
- 正規表現による検出も可能
- return, while, varなど
- procedure syntaxの戻り値を明記するようrewriteするRule
- `scalafix --check` だと、期待される書き換えをエラーメッセージとして表示する
- 使われていないimport、private変数、ローカル変数を削除するようrewriteするrule
- scalacOptionsに以下の制約がある
- `-Ywarn-unused` や、`-Wunused` (2.13.1のみ)を使う
- 使われていないシンボルの検出に、エラーメッセージを利用している
- `-Xfatal-warnings` を使わない。
- scalafixの実行前にコンパイルする必要があるため、警告有りでもコンパイルできる必要がある
- `-Ywarn-unused` や、`-Wunused` (2.13.1のみ)を使う
[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でしか動かない
- publicやprotectedなメンバに型注釈をつけて回る
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
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の利用
- ScalametaのSemantic API
- Metals
- Scalafix SemanticType
など
Scalafixの使い所
よく使われているScalaのLinter
コンパイラオプション
- scalac options
- `-Xlint`, `-Xfatal-warnings`, `-Ywarn-unused`, ...
コンパイラプラグイン
- WartRemover
- scapegoat
Scalafixと、既存のLinterとの比較
メリット
デメリット
Scalafixのメリット
- Scalafixの警告の大半を半自動で修正可能
- DXに良さみがある
- 新規導入の際は、顕著に工数差が出る
- 設計上、Scala 3もサポート可能
- dottyは既にSemanticDBを生成可能。
- あとはScala 3のソースコードを構文解析できれば……
- Scalametaが一部対応済みだが、Dotty最新版には追従していない
- 極一部の、コンパイラ内部に密結合しているRuleは対応が大変?
- そもそもdottyではコンパイルエラーになるようなRuleもチラホラ
Scalafixのデメリット
- Scalafixを利用するためのビルド設定が、コンパイル時間のoverheadとなる
- 実開発で問題となるかどうかは、コードベース次第?
- その他のLinterに比べて、Ruleは少なめ
- scalac optionsとの相性問題
- -Xfatal-warningsを使うには、ビルド定義を少し工夫する必要あり
- Ruleごとに設定フォーマットが違う
- (まだあまりないけど)大量のRuleを入れると、設定ファイルが爆発しそう?
- まだScalafixは色々こなれていない&情報が不足気味
- Scalafix, 外部rule, .scalafix.confのversion管理の仕組みが必要そう
Scalafixの導入の仕方
Scalafixの導入の仕方
SemanticDB生成設定をどうするか
3通りの方法を考えてみましょう
- 常に有効
- Scalafix直前に有効化
- 専用のサブプロジェクトで実行
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ポート
- macroアノテーションでtypeclassのコード生成をするライブラリだった
- Scala 3でmacroアノテーションが使えなくなることを受けての実験
Scalafixまとめ
- 日々の開発を少し楽にするリファクタリングツール
- 警告を1コマンドでただちに直せる
- migration用のruleで非互換の変更を反映する
- 適切なRuleがあれば、機械的な変更はカバーできる
- こなれてない部分は今後に期待
- ポンテンシャルあるツールなので活用していきましょう
Scalafixで、Scalaプロジェクトのメンテナンスで楽しよう
By Taisuke Oe
Scalafixで、Scalaプロジェクトのメンテナンスで楽しよう
- 1,878