🔍

Clojure で静的に例外処理をチェックしたい話

2021/12/09に公開

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

TL;DR

  • clj-kondo の解析データと rewrite-clj を使ってエラーチェックを静的に行うことを試みてます
    • 参考実装のみで実用には至っていません

Clojure でのエラーハンドリング

Clojure で最も一般的と思われるエラーハンドリングは try-catch でしょうか。
Java のライブラリ(もしくは Java ライブラリをラップした Clojure ライブラリ)を使うことも少なくないと思うので、try-catch を全く使わないということもないと思います。

(defn foo []
  (throw (ex-info "test" {})))

(try
  (foo)
  (catch Exception ex
    (.printStackTrace ex)))

Java であれば catch されていない throw があればコンパイル時にエラー扱いにしてくれますが、動的型付け言語である Clojure では当然ながら事前にそれを知ることはできず、不便と思っている方も多いかと思います。

個人で開発している liquidz/merr というエラーハンドリングのライブラリにおいても、エラーが漏れなく処理されているかを検知する方法がないものかと長年悩んでいました。

今回はそれをどうにか事前に、そして静的に知ることができないかという試みの記録です。

clj-kondo

言わずと知れた Clojure のリンタですが、clj-kondo の解析データを使っている方はあまり多くないのではないでしょうか?

https://github.com/clj-kondo/clj-kondo/tree/master/analysis

VSCode の Calva では clj-kondo をバンドルしていてその解析データを使った機能を提供していたりしていて、拙作の vim-iced でも使っていたりと Clojure 開発環境の開発者にはお馴染みの機能です。

この解析データには以下のような情報が含まれます。(オプションによるのでこれがすべてではありません)

  • :namespace-definitions: ネームスペースの定義情報
  • :namespace-usages: どのネームスペースがどこから利用(require)されているか
  • :var-definitions: var の定義情報
  • :var-usages: どのvarがどのネームスペース内のどのvarから利用されているか

今回注目するのは :var-usages です。
この情報を利用すると任意の関数の呼び出しの階層を調べることができます。

例として liquidz/antq:var-usages を載せます。

$ clj-kondo --lint src --config '{:output {:analysis true :format :edn}}' | jet --pretty --query ':analysis :var-usages 1'
{:fixed-arities #{1},
 :end-row 6,
 :name-end-col 23,
 :name-end-row 6,
 :name-row 6,
 :name sequential?,
 :filename "src/antq/util/xml.clj",
 :from antq.util.xml,
 :col 11,
 :name-col 12,
 :from-var xml-ns,
 :end-col 32,
 :arity 1,
 :row 6,
 :to clojure.core}

これは以下のような情報になります。

呼び出しの階層が調べられれば、その階層の最上位(一番最後に呼び出している箇所)から順にエラーハンドリングされているかどうかを調べることで目的を達成できそうです。

rewrite-clj

ではエラーハンドリングされているかどうかをいかにしてチェックするかですが、今回は rewrite-clj に注目しました。

https://github.com/clj-commons/rewrite-clj

rewrite-clj は Clojure/ClojureScript また EDN ファイルのインデントやコメントを保持したまま(元々のフォーマットを保持したまま)書き換えることを目的としたライブラリです。
なので本来の目的とは用途がちょっと異なりはするのですが、Zip API が使いやすい(慣れている)ので今回採用しました。
(用途的には tools.reader を使うのが正しいとは思いますが楽をするため今回は見送ります)

前述の clj-kondo の解析データからは関数の呼び出し階層が取得できているので、各階層がどのファイルのどの行、列にあるのかがわかります。
そして rewrite-clj では Zip API にて 場所の情報を取得することが可能です
これらを組み合わせると各階層で呼び出している場所を Zip API の location として取得することができ、その location を元に例えば関数呼び出しが try フォーム内にあるのかどうか、またその try フォームに catch フォームが含まれるかどうかを調べることができます。

(require '[rewrite-clj.zip :as z])

(defn form?
  [zloc sym-set]
  (and (z/list? zloc)
       (contains? sym-set (-> zloc z/down z/sexpr))))

(if-let [zloc (clj-kondoの解析結果から呼び出し場所のzlocを返す関数)]
  ;; 親のフォームに向かって try フォームを探す
  (if-let [zloc (z/find zloc z/up #(form? % #{'try}))]
    ;; try フォームがあったら、そのフォーム内に catch フォームがあれば true
    (some? (z/find (z/down zloc) z/right #(form? % #{'catch})))
    ;; try フォームがなければ false
    false)
  ;; 呼び出し場所の zloc が取れなければ見つからなかった扱い
  false)

サンプル実装

以上のことから簡単なサンプル実装を作ってみました。

git clone していただいて make tool-install を実行してもらうと Clojure CLI のツールとしてインストールされます (make tool-remove でアンインストールできます)
同リポジトリ内に example を用意しているので、その中で以下のコマンドを実行するとチェックが実行されます。

clojure -Terror-checker uncatched-exception

すると以下のような結果が出力されるかと思います。
フォーマットは ファイルパス:行:列: var名 です。
これは throwcatch されていない呼び出しが2つあることを示していて、呼び出し階層の上位から順に並べています。

./src/foo/core.clj:24:3: foo.core/-main
        ./src/foo/core.clj:6:3: foo.core/sample1
                ./src/foo/sub.clj:6:5: foo.sub/throwing
./src/foo/core.clj:26:3: foo.core/-main
        ./src/foo/core.clj:18:5: foo.core/sample3
                ./src/foo/sub.clj:6:5: foo.sub/throwing

あとは出力フォーマットを調整して mattn/efm-langserver などを使えば、コードを書きつつ catch されていない throw を検知することもできそうです。

サンプル実装の問題

ひとまず動くところまで実装したのみなので当然問題もたくさん残っています。

  • 明示的に throw されている例外しかチェックできていない
    • 例えば Java ライブラリ側で throw しているケースや、 NullPointerException が投げられるケースは今の実装ではチェック対象に含まれないので catch が漏れていても気付けません
      • clj-kondo の解析データにはメタデータを含めることができるので、例外を投げうる関数に ^{:throws 'Exception} のようなメタデータをつけておいてチェック対象に含めることは可能です
  • 厳密に throw された例外が catch されているかのチェックがない
    • 例えば java.io.IOExceptionthrow しているケースで (catch NullPointerException ...) があっても現状は catch の存在しかみていないのでパスしてしまいますが実際の挙動としては当然 catch されないのでNGです
  • マクロ内で catch しているケースは判定できない
    • 暫定対処としてサンプル実装では try 以外にも catch しているとみなすシンボルを指定できるようにはしています
  • パフォーマンスは考慮していない
    • 部分的に memoize を使ったりしていますが、サンプル実装なので処理の効率については考慮してません
    • 一応 make native-image にて GraalVM を使ったネイティブイメージを吐けるようにしていますが、大きいプロジェクトだとまだ遅いです(手元の業務アプリで10秒ほど)
  • 今回の throw + catch の例であれば特に問題はなかったですが、liquidz/merr のようにエラーを値として扱うライブラリの場合は、エラーを let で束縛しておいて別の場所でエラーハンドリングするケースがあり、そういったエラーハンドリングは今回のロジックでは検知できません
    • clj-kondo の解析データでは let 内で束縛されたものを扱う :locals, :local-usages もありますが、どんな値が束縛されているのかという情報までは無いので、一筋縄ではいきません

最後に

問題はまだいろいろと残っていますが、明示的な throw に対する catch 漏れに関しては検知することができたので小さな一歩は踏み出せたかなと思います。

実験のつもりだったのでどこまで改善していくかはわからないですが、liquidz/merr のチェックは欲しいのでコツコツ出来たら良いなと思っています。
Pull Request は大歓迎なので、これもチェックしたい、こうしたらもっと良いのでは?というアイデア/知見がありましたら Issue なりなんなりで報告いただけると幸いです!

Discussion