🕶

clj-kondo Hooks入門

2020/12/09に公開

この記事は Clojure Advent Calendar 2020 の9日目に向けたものです。

TL;DR

clj-kondo 便利なので使ってなければ使ってね!

clj-kondo とは

clj-kondo とは Clojure のリンターです。

これまでも eastwood のようなリンターは存在していましたが、
clj-kondo が特徴的なのは GraalVM を用いてネイティブイメージ化されたものを配布しており、JVMを使ったツール類でよく言われるセットアップの手間(Javaランタイムのインストール)や起動の遅さをほぼ無視できる点です。

$ cat foo.clj
(ns foo
  (:require [clojure.string :as str]))

(defn -main []
  (let [s "hello"]
    (println "hello world")))

$ time clj-kondo --lint foo.clj
foo.clj:2:14: warning: namespace clojure.string is required but never used
foo.clj:5:9: warning: unused binding s
linting took 15ms, errors: 0, warnings: 2
clj-kondo --lint foo.clj  0.01s user 0.01s system 77% cpu 0.032 total
# 速い!!

単体の実行ファイルの他 LanguageServer も配布(これはJARでの配布)されており、エディタとの連携も容易になっており、
主要なエディタとの連携方法はドキュメントとして以下にまとめられています。

https://github.com/borkdude/clj-kondo/blob/master/doc/editor-integration.md

細かいミスの多い私個人としては Clojure での開発における必需品なってしまっているほどです。

マクロにおける問題点

そんな便利な clj-kondo ですが、リンターということで実際に式は評価していません。
そのためマクロを使った式の展開については v2020.06.12 以前のバージョンでは正しく処理できず、無駄に警告やエラーが出てしまっている状況でした。

例えば以下のようなアナフォリックマクロがあったとして、 tst の結果が it に束縛されることはマクロを展開しない限りわかりません。

foo.clj
(ns foo)

(defmacro aif
  ([tst then]
   `(aif ~tst ~then nil))
  ([tst then else]
   `(let [~'it ~tst]
      (if ~'it ~then ~else))))

(aif (+ 1 2)
  it
  "ng") ;; => 3

(aif nil
  it
  "ng") ;; => "ng"

そのため、そのまま clj-kondo を通すと unresolved symbol it とエラーになってしまいます。

$ clj-kondo --lint foo.clj
foo.clj:11:3: error: unresolved symbol it
linting took 15ms, errors: 1, warnings: 0

特にエディタと連携させて警告やエラーを表示している場合はわざと無視することを意識する必要が出てきてしまい、
正しい警告・エラーを見逃すことにもなりかねないので、既存の設定で「aif 内の it については unresolve symbol エラーを無視する」といったような定義をしてお茶を濁していました。

Hooks

そんな中 v2020.06.21Hooks という機能が追加されました。
これは指定されたコードがどのように展開されるのかを定義できるもので、まさに上記のようなマクロにおける問題点を解決するものです。

具体的には sci という Clojure インタプリタを使って展開前のコードを展開後のコードに変換していて、これは clojure.core/eval を使った式の評価をすることなく行われています。

変換するためのコードの書き方の詳細については以下のドキュメントに結構詳しくまとめられているので必要に応じて参照すると良いと思います。

https://github.com/borkdude/clj-kondo/blob/master/doc/hooks.md

変換するコードは rewrite-clj(ただし clj-kondo 向けに微修正されたもの) のノードとして処理する必要があり若干注意が必要ですが、Clojure のマクロを書いたことがある方であればそこまで混乱しないのでは?と思っています。

なお sci は babashka でも使われていて、いずれも borkdude 氏によるライブラリ・ツールです(感謝!)

Hooks の例

先程の aif の例では unresolved symbol it エラーになっていたので、ひとまず it に何か束縛されていればエラーは解決されるはずです。
aif マクロと同じように展開されるように書いても良いですが、一旦はズルをして簡単に書いてみます。

.clj-kondo/hooks/foo.clj
(ns hooks.foo
  (:require
   [clj-kondo.hooks-api :as api]))

(defn aif
  ;; このノードが変換前のコード
  [{:keys [:node]}]
  (let [;; 変換前のコードから tst と残りのコードを分配束縛
        [tst & body] (rest (:children node))
        ;; 変換後の新しいノードとして (let ...) を作成
        new-node (api/list-node
                  (list*
                   (api/token-node 'let)
                   ;; it に tst を束縛
                   (api/vector-node [(api/token-node 'it)
                                     tst])
                   ;; 簡単のために tst に続くものをそのまま let の中身とする
                   body))]
    ;; 変換後のノードを返す
    {:node new-node}))

この Hook では単に tstit で束縛しているだけなので、例えば (aif (+ 1 2) it "ng") というコードは (let [it (+ 1 2)] it "ng") に変換されることにご注意ください。

この Hook を有効にするためには .clj-kondo/config.edn:hooks に以下の定義を追加する必要があります。

.clj-kondo/config.edn
{:hooks {:analyze-call {foo/aif hooks.foo/aif}}}

その上で clj-kondo を改めて通してみると今まで出ていたエラーが消えていることが確認できるかと思います。

$ clj-kondo --lint foo.clj
linting took 17ms, errors: 0, warnings: 0

マクロの定義を元に正確に展開するように書いても良いのですが、リントが目的なので正しくリントできる形であれば別に問題ないところが Hooks の面白いところの1つではあるかなと個人的に思います。(それが原因で罠に嵌ることもありそうですが。。)
ちなみに拙作のライブラリである merr でも Hooks の定義は簡易的なものにしていて、今のところそれで不都合は発生していません。

https://github.com/liquidz/merr/blob/master/.clj-kondo/hooks/merr.clj

一応参考までにズルせずに元々の aif マクロと同様の結果に展開する Hook も参考情報として以下に書いておきます。
コード中のコメントにもある通り、Hook も試行錯誤することが多いと思うので、期待した結果にできているかどうかについては
println などを用いてデバッグすることが可能です。

.clj-kondo/hooks/foo.clj
(defn aif
  [{:keys [:node]}]
  (let [[tst then & [else]] (rest (:children node))
        it (api/token-node 'it)
        else (or else (api/token-node nil))
        if-node (api/list-node
                 (list*
                  (api/token-node 'if)
                  (list it then else)))
        new-node (api/list-node
                  (list*
                   (api/token-node 'let)
                   (api/vector-node [it tst])
                   (list if-node)))]
    ;; Hook のデバッグは適当に println を書いて clj-kondo コマンド実行するプリントデバッグが今のところ基本です
    ; (println new-node)
    {:node new-node}))

最後に

今回は自前のマクロを Hooks として定義しているので「マクロをそのまま sci が展開してくれれば良いのでは?」と思われてしまうかと思いますが、
例えば別ライブラリが提供しているマクロなどは元となるコードが手元に無いので Hooks がないと clj-kondo は展開後のコードを知ることができません。

かと言って別ライブラリで提供されているマクロに対する Hooks をすべて自分で用意するのも現実的ではないので、
以下のリポジトリにあるような有名所のライブラリへの Hooks を適宜参考・利用するのが良いと思います。
(v2020.11.07 からはクラスパス上の config を import する機能も提供されているので、ライブラリ側の対応でより使いやすくなってくると思います)

https://github.com/clj-kondo/config/tree/master/resources/clj-kondo.exports/clj-kondo

最近では malli での clj-kondo 連携も利用できるようになり
利用シーンもどんどん増えてきていると思っています。(この話も機会があればまとめたいです)
使わない手はないと思うので、まだ使っていない方は是非試してみていただければなと思います。

ではより良い Clojure ライフを!

Discussion