Scalafmtでコード整形自動化 〜Alignment編〜

Kana Otawara
nextbeat-engineering
13 min readOct 20, 2021

こんにちは、ネクストビートのエンジニアの太田原です。

保育施設向けICTシステム「キズナコネクト」の開発チームに所属しています!

弊社では、バックエンドの開発言語にScalaを使用しています。
今回は、Scalaのソースコード整形ツールである「Scalafmt」の設定をプロダクトコード向けにカスタマイズしてみました。
Scalafmtはいろいろな設定が行なえますが、本記事では「Alignment」に絞って紹介します。

本記事で対象とするScalafmtのバージョン:3.0.0

Alignmentの概要

例えば以下のようなvalの定義…

val hoge = 23
val fugafuga = "Hello

このように揃っていたら見やすくなりませんか?

val hoge     = 23
val fugafuga = "Hello"

というように、特定の文字列の左右に空けるスペースを縦方向で揃えるのを自動化できるのが、ScalafmtのAlignment設定です。
(弊社ではコードレビュー時に、この揃えが整っていないとみんなで👮警察👮していたのですが、これで仕事が楽になりますね。)

公式ドキュメントはこちらです。

なお、基本的なルールとして、空行を挟むとリセットされます。

val hoge     = 23
val fugafuga = "Hello" // ここまで揃うが
val aaa = "New line" // ここからリセット

Scalafmt側で用意しているalign.preset

Scalafmt側で用意しているpresetという設定があります。オプションはnone/some/more/most の4種類です。
具体的にどんなふうに揃うのかは公式ドキュメントにも記載があるのですが、若干全体像がつかみにくいので、表にしてみると以下のようになります。

各種別についての詳細は以下の通りです。

パターンマッチ=>を揃える

x match {
case 2 => 22
case 22 => 222
}

代入=を揃える

val x  = 2
val xx = 22
// 似た型も揃える
def name = column[String]("name")
def status = column[Int]("status")
val x = 1
val xx = 22

extends:縦にextendsを揃える

case object B  extends A
case object BB extends A

中置演算子->などの中置演算子を揃える

q  -> 22
qq -> 3

//:コメントの//を揃える

val a = 2  // comment1
val bb = 3 // comment2

sbtモジュール%を揃える

libraryDependencies ++= Seq(
"org.scala-lang" % "scala-compiler" % scalaVersion.value,
"com.lihaoyi" %% "sourcecode" % "0.1.1"
)

for式<-=を揃える

for {
x <- List()
yyy = 2
zzz <- new Integer {
def value = 3
}
} yield x

これら4つのpresetの中だと、既存のプロダクトコードに最も合っていそうなのは、一番揃えるmostでした。
ただ、presetだと微妙に手が届かず、

case classや、メソッド引数定義の、:の後ろの型定義を縦方向に揃える

case class Foo(
firstParam: Int,
secondParam: String,
thirdParam: Boolean
) {
def Foo(
firstParam: Int,
secondParam: String,
thirdParam: Boolean
) = ???
}

なども実現したかったので、より細かい設定方法を調べていきました。

任意の設定を行う(align.tokens、align.tokenCategory、align.treeCategory)

公式ドキュメント該当箇所

前述の、case classや、メソッド引数定義の、:の後ろの型定義を縦方向に揃えるalign.tokensで設定するなら以下のようになります。

."+"についてはドキュメントに記載あるため詳細は割愛しますが、presetですでにある設定に対し上書きしないように、任意のAlignment設定を追加する書き方です。)

align.tokens."+" = [
{
code = ":"
owners = [
{
regex = "Term\\.Param"
parents = [ "Ctor\\.Primary" ]
},
{
regex = "Term\\.Param",
parents = [ "Defn\\." ]
}
]
}
}]

codeは揃える基準になる文字列です。

ownersは基準になる文字列が属する抽象構文木(AST)を指定します。コード整形する対象かどうか判定する材料として使われます。

この抽象構文木の大まかな理解がScalafmtの設定を理解する上で必要なので、一度脇道にそれます。
例にあるTerm\\.Param などは、Scalametaという構文木の解析などができるライブラリ上での表現です。(念の為、\\は正規表現のエスケープ文字なので、Term.ParamがScalametaでの表現になっています。)
以下のドキュメントに、Scalaのソースコードの文法がScalametaの構文木だとどうなるかの例がリストアップされています。自分でScalafmtの設定をカスタマイズしたい場合、ここが参考になります。

trees/examples · Scalameta

また、ScalaFiddle PlaygroudAST Explorer というツールにコードを貼り付けると、構文木の解析を行ってくれます。

実際に、case class定義のコードを解析してみると、以下のようになります。
Term.Paramは各行のパラメータ定義、Ctor.Primaryはcase classのプライマリコンストラクタを表しています。

本題に戻り、ownersの設定についてはドキュメントでは以下のようになっています。

  • regex:任意。tokenの「所有者」と呼ばれる。tokenが属するうち最も近いツリーノードを正規表現で指定。
  • parents:リストで指定。regexで指定するような、tokenの「所有者」の親ツリーを指定。

この設定を読み取り、整形する対象かを判定するアルゴリズムは、公式ドキュメントに詳しく書いてありますが、文章よりも内部実装を追ったほうが掴めるところもあったので合わせて掲載します。
コアなところは以下かなと思います。

class Matcher(
val owner: Option[jurPattern],
val parents: Seq[jurPattern]
) {
def matches(tree: meta.Tree): Boolean =
owner.forall(check(tree)) &&
(parents.isEmpty || tree.parent.exists(x => parents.forall(check(x))))
}
@inline
private def check(tree: meta.Tree)(pattern: jurPattern): Boolean =
pattern.matcher(tree.productPrefix).find()

※jurPattenはimport java.util.regex.{Pattern => jurPattern}のように、rename importされたもの

ざっくり、def matches 内で、各構文木tree: meta.Treeに対し

  • regexで指定したパターンにその構文木がマッチするかどうか
  • parentsが空かどうか or 構文木の親tree.parentが、parentsで指定したすべてのパターンを満たすかどうか

のandをとり、trueならば整形する、という仕組みのようです。
以下の設定でなぜ整形できるかが、AST Explorerの結果と合わせると理解しやすいです。Ctor\\.Primaryの子にTerm\\.Paramが複数ぶら下がっているような構造でした。

{
regex = "Term\\.Param"
parents = [ "Ctor\\.Primary" ]
},

ここまでで、単一のtokenに対する設定ができるようになると思います。

更に、複数のtokenやownerをグループ化して整形させることもできます。

both tokens have the same token category; a token’s category is the value associated with its type in align.tokenCategory mapping or, if missing, its type

both owners have the same tree category; similarly, a tree’s category is the value for its type in align.treeCategory mapping or the type itself

引用元:https://scalameta.org/scalafmt/docs/configuration.html#alignment

どういうことか、具体例を見てみます。
例えば、preset=mostで設定されるfor文の<-=を揃える設定は、もし自分で書くとしたら以下のようになります。

align.tokens = [
{ code = "=", owners = [{ regex = "Enumerator\\.Val" }] }
{ code = "<-", owners = [{ regex = "Enumerator\\.Generator" }] },
]
align.tokenCategory.Equals = Assign
align.tokenCategory.LeftArrow = Assign
align.treeCategory {
"Enumerator.Val" = for
"Enumerator.Generator" = for
}

Equalsは=、LeftArrowは<-をscalameta上のクラスで表現したものであり、scalametaのscala.meta.tokensパッケージで定義されているもののようです。
このあたりはあまりドキュメントを発見できませんでしたが、内部実装はこのあたりで、以下の記事によるとscalametaのtokenizeというメソッドで任意のコードに対し解析できたりするようでした。

Enumerator.Valはfor文での=による宣言、Enumerator.Generatorはfor文での<-によるジェネレータです。

tokenもownerも、同じカテゴリになっているということで以下のように<-=両方を揃えることができるという仕組みです。

for {
x <- List()
yyy = 2
zzz <- new Integer {
def value = 3
}
} yield x

自分で設定するのは少しハードルが高いですが、もし違う文字列だがAlignmentの適用をさせたい、という場合は有効な方法です。

その他の設定値

その他は、あまり複雑な設定項目はなく、公式ドキュメントの説明も理解しやすい印象なので割愛します。

最終的に作った設定ファイル

preset=mostにしつつ、:=の周辺を揃える設定を追加しています。

align.preset = mostalign.tokens."+" = [
# 例として挙げた設定
{
code = ":"
owners = [
{
regex = "Term\\.Param"
parents = [ "Ctor\\.Primary" ]
},
{
regex = "Term\\.Param",
parents = [ "Defn\\." ]
}
]
},

{
code = "=",
owners = [
# preset=mostにおける、=の整形設定。これを自前でも書いておかないと、=に関する設定がすべて上書きされてしまうため
{
regex = "(Enumerator\\.Val|Defn\\.(Va(l|r)|GivenAlias|Def|Type))"
},
# case classの宣言のデフォルト値代入
{
regex = "Term\\.Param"
parents = [ "Ctor\\.Primary" ]
}
# applyメソッドやnewでのコンストラクタで、引数名を指定した代入での=を揃える
{
regex = "Term\\.Assign"
},
]
}
]

おわりに

この記事ではScalafmtのAlignmentについて、presetの詳しい内容と、任意の整形ルールの設定方法についてまとめました。

Scalafmtに関しては英語のものを含めてもそこまで情報がなく、公式ドキュメントや時には内部実装を追いつつ理解を進めていきました。
Alignmentはとても奥が深いなと思いました。

少しハードルは高いですが、一度設定してしまえば実装者・レビュワーともにコード整形の負荷が軽減され、より本質的な部分に時間を割くことができます。
また、きれいにフォーマットされたコードは見やすく、バグなどおかしな点にも気づきやすくなりそうだと思いました。
今後、新規開発ならプロジェクトを始めたタイミングで導入していきたいです。

Alignment以外にも様々な設定が行えるので、特にドキュメントだとわかりにくい部分はまた記事化を試みたいと思います。

We are hiring!

株式会社ネクストビートでは

「人口減少社会において必要とされるインターネット事業を創造し、ニッポンを元気にする。」
を理念に掲げ一緒に働く仲間を募集しております。

冒頭でも書いたとおり、バックエンド開発の言語としてScalaを採用しています。Scalaに興味がある、Scalaが好き、もっともっとScalaで生産性を上げられる知見がある、そんな方のご応募をお待ちしています!

--

--