Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

Clojureのフォーマッターcljstyleの使い方

こんにちは。開発本部の江口(@egs33)です。

弊社ではClojure製の製品を開発していますが、ClojureのフォーマッターとしてOSSのcljstyleを利用しています。 利用するにあたって機能の追加やバグの修正などのコントリビュートも行いました。

tech.toyokumo.co.jp 上の記事はフォーマッターの内部実装についての説明ですが、 この記事ではcljstyleの使い方を説明したいと思います。 また、この記事はバージョン0.12.0の情報をもとに書いています。 この記事はバージョン0.15.0の情報をもとに書いています。(2022年6月更新)

Clojureのフォーマッターの現状

近年コードを書いていくときは、golangのgoimportsやJavaScriptのESLintなどを使って共通したフォーマットを当てていくことが広く行われていると思います。 しかし似たようなことをClojureでやろうとした時に実用的なものがない状態が続いていました。

例えば「Clojure formatter」と検索して一番にヒットするのはcljfmtだと思います。 ですがこれはnsマクロの :require のソートをしてくれなかったり、Leiningenプラグインとして使用されることを想定しており、起動時間がかかってしまうため、エディタと連携した使用には耐えられないなどの問題がありました。 そのため、弊社製品にもフォーマッターを自動的に適用できない状態で、レビュー時にインデントを指摘せざるを得ない状況でした。

cljstyleとは

github.com cljstyleは前述したcljfmtをforkして作られた、オープンソースのClojureコードのフォーマッターです。 cljstyle自体もClojureで開発されていて、GraalVMでネイティブバイナリにビルドされているため高速に実行できます。

しかし、0.9.0の時点では弊社の製品に適用するには機能が足りなかったので、次のようなPRを書いて改善した上で、製品に適用しました。

Clojureのスタイルガイドによると、リストのインデントは1つであることが一般的ですが、元々のcljstyleにおいては2つ固定で設定不可能でした。 これを :list-indent-size で設定可能にするというPRを出しました(#21#25)。 また、コメントのインデントに関するバグを修正しました(#32)。

これらのPRがマージされた0.12.0から実際に弊社製品でも使用しています。

;; :list-indent-size 2 (default)
(foo
  bar
  baz)

;; :list-indent-size 1
(foo
 bar
 baz)

実行方法

まずはcljstyleをインストールします。 macの場合はHomebrewから

brew install --cask cljstyle

でインストールすることができます。

LinuxなどHomebrewを使っていない環境では https://github.com/greglook/cljstyle#installation を見てインストールします。

$ cljstyle version # cljstyleのバージョンの表示
mvxcvi/cljstyle 0.15.0 (1f58e2e7af4c193aa77ad0695f6c2b9ac2c5c5ec)
$ cljstyle check # カレントディレクトリ以下のフォーマットのチェック
$ cljstyle fix # カレントディレクトリ以下のフォーマットの修正

どちらも後ろにファイル名をつけることでそのファイルのみのフォーマットを行うことができます。 より詳しい使用方法はcljstyle -hで見ることができます。

cljstyleの設定

cljstyleは .cljstyleファイルにフォーマットの設定を記述します。 .cljstyle ファイルの内容は以下のような設定をClojureのmapとして記述したものです。

{:files {:extensions #{"clj" "cljs" "cljc" "cljx"}
         :pattern nil
         :ignore #{".git" ".hg"}}
 :rules {:indentation {:enabled? true
                       :list-indent 2
                       :indents {}}
         :whitespace {:enabled? true
                      :remove-surrounding? true
                      :remove-trailing? true
                      :insert-missing? true}
         :blank-lines {:enabled? true
                       :trim-consecutive? true
                       :max-consecutive 2
                       :insert-padding? true
                       :padding-lines 2}
         :eof-newline {:enabled? true}
         :comments {:enabled? true
                    :inline-prefix " "
                    :leading-prefix "; "}
         :vars {:enabled? true}
         :functions {:enabled? true}
         :types {:enabled? true
                 :types? true
                 :protocols? true
                 :reifies? true
                 :proxies? true}
         :namespaces {:enabled? true
                      :indent-size 2
                      :break-libs? true
                      :import-break-width 60}}}

ちなみに上の設定は :indent以外はデフォルトのルールです。

.cljstyle ファイルの場所

設定ファイルは各フォルダに対して適用させることができます。 例えば以下のようなフォルダ構成の場合、

a
├── .cljstyle
└── b
    ├── c
    │   ├── .cljstyle
    │   └── foo.clj
    └── d
        ├── .cljstyle
        └── e
            └── bar.clj

foo.clj のフォーマットをする場合には a/b/c/.cljstylea/.cljstyle が使用されます。 深いディレクトリの設定が優先されるのでこの場合はa/b/c/.cljstyle が優先されます。 同様に bar.cljのフォーマットをする場合、a/b/d/.cljstylea/.cljstyle を前者を優先してマージした設定が使用されます。

各設定の内容

以下では :files / :pattern のような表記は設定のmapの :files に対応するvalueの :pattern を示します。

:files / :extensions

デフォルト: #{"clj" "cljs" "cljc" "cljx"}

フォーマットの対象とするファイルの拡張子を指定します

:files / :pattern

デフォルト: nil

フォーマットの対象とするファイルを正規表現で指定します。:files / :extensions の指定と:files / :pattern の指定のどちらかに当てはまったファイルがフォーマットの対象になります。

:files / :ignore

デフォルト: #{".git" ".hg"}

文字列か正規表現の Setでフォーマットの対象としないファイルを指定します。 文字列の場合、一致するファイル名のファイルと、一致するディレクトリ名のディレクトリ以下のファイルは無視されます。 正規表現の場合、パスと一致するファイル、ディレクトリが無視されます。

:rules / :indentation / :list-indent

デフォルト: 2

リストのインデントサイズを指定します。

;; :list-indent-sizeが2の場合
(foo
  bar
  baz)

;; :list-indent-sizeが1の場合
(foo
 bar
 baz)

:rules / :indentation / :indents

コードをフォーマットする際のインデントのルールを指定します。 詳しくは後述します。

:rules / :whitespace / :remove-surrounding?

デフォルト: true

フォームの内部を囲む空白を ( foo ) => (foo) のように削除します。

:rules / :whitespace / :remove-trailing?

デフォルト: true

行の最後にある空白を削除します。

:rules / :whitespace / :insert-missing?

デフォルト: true

要素間に (foo(bar)) => (foo (bar))のように空白を追加します。

:rules / :blank-lines / :trim-consecutive?

デフォルト: true

:rules / :blank-lines / :max-consecutiveで指定される数より多い連続する空行を削除します。 :rules / :blank-lines / :max-consecutiveのデフォルトは 2です。

:rules / :blank-lines / :insert-padding?

デフォルト: true

トップレベルの複数行の非コメントフォーム間に、少なくとも :padding-lines行の空行をもつようにします。 :padding-linesのデフォルトは 2です。

:rules / :eof-newline / :enabled?

デフォルト: true

ファイル末尾に改行がない場合に改行を追加します。

:rules / :comments / :inline-prefix

デフォルト: " " インラインコメント(別のフォームに同じ行で続くコメント)のプリフィックスを指定します。

:rules / :comments / :leading-prefix

デフォルト: "; " コメントだけの行のコメントのプリフィックスを指定します。

:rules / :vars / :enabled?

デフォルト: true

defフォームが複数行になっているときとdoc-stringを持つ場合に改行を入れます。 具体的には以下のようになります。

;; before
(def x (f 1
          2))
(def y "doc" 100)

;; after
(def x
  (f 1
     2))
(def y
  "doc"
  100)

:rules / :functions / :enabled?

デフォルト: true

defndefn-のフォームの内部で改行させます。 また、 fnフォームが複数行にわたっている場合に改行させます。 具体的には以下のようになります。

;; before
(defn f [x] (fn [y] (+ x
                       y)))

;; after
(defn f
  [x]
  (fn [y]
    (+ x
       y)))

:rules / :types / :enabled?

デフォルト: true

defprotocol, deftype, defrecord, reify, proxyなどのフォームの内部で改行させます。 具体的には以下のようになります。

;; before
(defrecord Person [name address])

(defprotocol Bird
  (fly [p])
  (walk [p]))

;; after
(defrecord Person
  [name address])


(defprotocol Bird

  (fly [p])

  (walk [p]))

:rules / :types / :types?

デフォルト: true

deftypedefrecord で内部の改行をさせるかを指定します。

:rules / :types / :protocols?

デフォルト: true

defprotocol で内部の改行をさせるかを指定します。

:rules / :types / :reifies?

デフォルト: true

reify で内部の改行をさせるかを指定します。

:rules / :types / :proxies?

デフォルト: true

proxy で内部の改行をさせるかを指定します。

:rules / :namespaces / :enabled?

デフォルト: true

nsフォームをフォーマットします。 :requireフォームをアルファベット順に並び替えたりします。

;; before
(ns examples.ns
  "example"
  (:import
    java.util.Date
    (java.util.concurrent
      Executors
      LinkedBlockingQueue))
  (:require
    [clojure.set :as set]
    [clojure.string :as s :refer [blank?]]
    [clojure.java.shell :as sh]))

;; after
(ns examples.ns
  "example"
  (:require
    [clojure.java.shell :as sh]
    [clojure.set :as set]
    [clojure.string :as s :refer [blank?]])
  (:import
    java.util.Date
    (java.util.concurrent
      Executors
      LinkedBlockingQueue)))

:rules / :namespaces / :indent-size

デフォルト: 2

リストのインデントサイズを指定します。 :rules / :indentation / :list-indent と同じですが、nsのフォーマットはrequireのソートなど処理が別になっているため、設定も別れています。

:rules / :namespaces / :break-libs?

デフォルト: true

ns フォーム内の :require:import の直後に改行を入れるかを指定します。

;; :break-libs? true
(ns user
  (:require
   [clojure.core.async :as as]
   [clojure.string :as str])
  (:import
   (java.util
    ArrayList
    UUID)))

;; :break-libs? false
(ns user
  (:require [clojure.core.async :as as]
            [clojure.string :as str])
  (:import (java.util
            ArrayList
            UUID)))

:rules / :namespaces / :import-break-width

デフォルト: 60

単一クラスのインポートの文字数が :single-import-break-widthよりも長い場合 グループ化されます。 具体的には以下のようになります。

;; before
(ns example.namespace
  (:import
    java.io.InputStream))

;; after (:single-import-break-width 10)
(ns example.namespace
  (:import
    (java.io
      InputStream)))

インデントの設定

:indentsには以下のようなmapを設定します。

{if [[:block 1]]
 do [[:block 0]]
 fn [[:inner 0]]}

マップのkeyがインデントを適用するフォーム、valueがどのようなルールを適用するかを表します。

デフォルトのルールは cljstyle/indents.clj at main · greglook/cljstyle · GitHubにあります。

インデントを適用するフォーム

マップのkeyにはシンボルと正規表現を指定できます。

  • シンボルの場合
    • foo/barを指定した場合 (foo/bar 1 2)に適用されますが, (bar 1 2)(baz/bar 1 2)には適用されません。
    • fooを指定した場合 (foo 1 2)(a/foo 1 2)に適用されます。名前空間付きのルールがある場合はそちらが優先的に使用されます。
  • 正規表現の場合
    • 名前空間を含むフォームシンボルを文字列としたものにマッチするものに適用されます。 シンボルで指定されたルールがある場合はそちらが優先的に使用されます。

インデントのルール

cljstyleでは :inner, :block, :stair の3種類のルールがあります。 ルールを指定しない場合は通常の関数のインデントとなるので、引数を揃える以下のようなフォーマットになります。

(bar 1 ["a"
        "b"]
     3 4
     5)

(foo
  bar
  baz)

ルールはvectorで [[:inner 0] [:inner 1]]のように指定します。 この場合 [:inner 0][:inner 1]が適用されます。

:inner

:innerルールはパラメータを取り、パラメータで指定された深さのフォームのインデントを2スペースのインデントにします。

;; before
(foo 1
     (bar 2
          3)
     (bar 4
          5)
     [6
      7
      8])

;; after ({foo [[:inner 0]]})
(foo 1
  (bar 2
       3)
  (bar 4
       5)
  [6
   7
   8])

;; after ({foo [[:inner 1]]})
(foo 1
     (bar 2
       3)
     (bar 4
       5)
     [6
      7
      8])

;; after ({foo [[:inner 0] [:inner 1]]})
(foo 1
  (bar 2
    3)
  (bar 4
    5)
  [6
   7
   8])

また、もう一つパラメータを追加することで引数の指定した深さにおいて指定した順番にあるフォームを指定できます。

;; before
(foo (bar1 "1-1"
           "1-2"
           "1-3")
     (bar2 "2-1"
           "2-2"
           "2-3"))

;; after ({foo [[:inner 1 3]]})
(foo (bar1 "1-1"
           "1-2"
       "1-3")
     (bar2 "2-1"
           "2-2"
       "2-3"))

;; after ({foo [[:inner 1 2] [:inner 1 3]]})
(foo (bar1 "1-1"
       "1-2"
       "1-3")
     (bar2 "2-1"
       "2-2"
       "2-3"))

:block

:blockルールはパラメータを1つ取り、改行の前にパラメータで指定された数以下の引数がある場合に :innerルールのようにインデントします。

;; before
(bar
  1
  2
  3)

(bar 1
     2
     3)

(bar 1 2
     3)

(bar 1 2 3)

;; after ({bar [[:block 1]]})
(bar
  1
  2
  3)

(bar 1
  2
  3)

(bar 1 2
     3)

(bar 1 2 3)

:stair

:stairルールはパラメータを1つ取り、基本的に :blockインデントと同じように振る舞いますが、 引数をペアとしてインデントします。

;; before
(cond
  a? :a
  b? :b)

(cond
  a?
  :a
  b?
  :b)

;; after ({cond [[:stair 0]]})
(cond
  a? :a
  b? :b)

(cond
  a?
    :a
  b?
    :b)

上のように condcaseのような条件と式を複数受け取るようなフォームに使うことが想定されています。

Clojure Style Guide用の設定

github.com 以下のように設定すると上のClojure Style Guideに沿ったソースコードに(全てではないですが)フォーマットでき、弊社で利用しています。

{:rules {:indentation {:list-indent 1}
         :blank-lines {:max-consecutive 1
                       :padding-lines 1}
         :vars {:enabled? false}
         :functions {:enabled? false}
         :types {:enabled? false}
         :namespaces {:indent-size 1}}}

エディタとの連携

vim

cljstyle/integrations.md at main · greglook/cljstyle · GitHubにあります。

IntelliJ IDEA (Ultimate)

IntelliJ IDEA (Ultimate)のファイル監視機能で以下のように設定すると 保存時に自動でフォーマットされます。

ClojureScriptのファイルや .cljcファイルなどにも適用する場合は設定のFile TypeをClojureScriptにしたものなどの設定を別で作る必要があります。

最後に

cljstyleの設定方法についてなるべく詳しく説明してみました。 cljstyleでみなさんのClojure lifeがよりよくなることを願っています。

更新履歴

  • 2020/02/17 初版
  • 2022/06/15 cljstyle 0.15.0の設定ファイル形式に対応しました。

トヨクモ株式会社ではClojureを書きたいエンジニア、PHP/Vue.jsを書きたいエンジニア、技術が好きなエンジニアを募集しております。

よろしければ採用ページをご覧ください。