Rust 公式 linter の clippy に新しいルールを実装した

Rust 公式の linter,clippy に新しいルールを足すプルリクを出してマージされた時のメモです.

github.com

dbg! マクロ

Rust 1.32 で dbg! というマクロが追加されました.

これは値を1つ引数にとってその値を返すマクロで,受け取った値とソースコード上での位置を print します.

fn factorial(n: u32) -> u32 {
    if dbg!(n <= 1) {
        dbg!(1)
    } else {
        dbg!(n * factorial(n - 1))
    }
}

dbg!(factorial(4));

名前の通り,いわゆる print debug 用途のマクロなので,デバッグが終わってリポジトリに commit する前にはコードから dbg! マクロを削除しておくのがベストプラクティスです. 公式ドキュメントにも

Note that the macro is intended as a debugging tool and therefore you should avoid having uses of it in version control for longer periods.

と書いてあります.

ちょっとしたデバッグに非常に便利なのですが,値を move で受け取ってその値を返すだけなのでうっかり消し忘れるとテストを壊すこともなく気付けないケースがあることに気付きました.

dbg_macro ルール

そこで,コード内の dbg! マクロを検出できる dbg_macro ルールをつくりました.

#![deny(clippy::dbg_macro)]

fn main() {
    dbg!(42);
}

のようなコードに対して,

cargo clippy -- -W clippy::dbg_macro

のように実行すると

error: `dbg!` macro is intended as a debugging tool
 --> foo.rs:4:5
  |
4 |     dbg!(42);
  |     ^^^^^^^^
  |
help: ensure to avoid having uses of it in version control
  |
4 |     42;
  |     ^^

のように警告を出します.

デフォルトで有効になっているとデバッグ中にエディタが警告しまくってうるさいので,restriction カテゴリでデフォルトは無効になっています. restriction カテゴリは README には載っていませんが,「unimplemented! を使わない」や今回のルールのような特定の場面(production 前のチェックなど)で有効なルールや,「mem::forgetDrop を実装した型で使わない」といった万人向けではないキツめのルールが登録されています.

clippy のリポジトリを restriction で検索するとざっと見渡すことができます.

新しいルールの提案と追加

まずは issue で「こういうルールあると良いと思う」と提案し,特に反対もなくメイン開発者の upvote も付きました.'good first issue'(初めて contribute する人が取り組むと良い易しい issue)のラベルがついたので,自分で実装してみて,プルリクを出しました.あとは一般的なプルリクと同じで何度かレビューしてもらって OK が出てマージという流れでした.

clippy の実装

基本的にはまず CONTRIBUTING.md を読めば大体分かるようになっており,必読です.

ディレクトリ構成

  • src/*: clippy のコマンドライン部分とドライバ(実行の前段部分)の実装のみ
  • clippy_lints/: linter の各ルールの実装
    • clippy_lints/lib.rs: ルールすべてを import して登録しているところ
    • clippy_lints/*.rs: ルールの実装(lib.rs 以外)
    • clippy_lints/utils: ルールの実装に使うあれこれ
  • clippy_dev/: 開発時に使うツール群(ルールリストの更新など)
  • clippy_dummy/: テスト向け
  • tests/: pass を走らせる部分のテストや各ルールを適用して正しく警告が出るかどうかのテスト

新しいルールを足すには基本的に clippy_lints crate および tests/ に追加・修正を加えることになります.

Early Pass と Later Pass

clippy のルールは構文木syntax::ast)もしくは HIR(rustc::hir)に対する pass として実装します.HIR はパースした構文木コンパイラ向けの情報を足したもので,Rust RFC 1191に詳しく書いてあります.

自前の pass を実装して登録しておくと,clippy が構文木または HIR をトラバースしたときに各ノードにその pass を適用して,pass に実装したコールバックメソッドがマッチすると適宜呼び出されます.

構文木にマッチさせる pass を early pass,HIR にマッチさせる pass を later pass といい,下記のような順序で適用されます.

  1. parse して構文木を得る
  2. 構文木をトラバースして early pass を適用
  3. HIR への変換と型チェック
  4. HIR をトラバースして later pass を適用

early pass は構文木のみ,later pass は HIR,型情報,コンパイラコンフィグ(cfg!)などにアクセスできます.

構文木をパースしたり pass を適用するなどの処理は rustc コンパイラ本体に linter 向けの汎用的な API があり(rust/src/librustc/lint),rustc コンパイラ自体の unused 警告などもこれを利用しています(rust/src/librustc_lint).rust-clippy リポジトリではそれを利用してルール集とコマンドライン部分のみを実装しています.なので,clippy の実装時には rustcrustc_*syntax といった rustc コンパイラ API を知る必要があります.

具体的に early pass と later pass でどのパスを通しているかは rust/src/librustc/lint/mod.rs に実装があります.

https://github.com/rust-lang/rust/blob/master/src/librustc/lint/mod.rs

新しい Early Pass を追加する

まず clippy_lints/src 内にルール向けのソースファイルを作成します.ここでは clippy_lints/src/my_rule.rs としたとします.

declare_clippy_lint! {
    pub MY_RULE,
    style,
    "my rule for clippy"
}

第1引数が linter インスタンス,2引数目が perf, correctness, complexity, style などのカテゴリです. declare_clippy_lintrustc::lint が提供する declare_tool_lint! マクロの薄い wrapper になっていて,カテゴリを見てデフォルトの警告レベルをセットするなどの設定を行っています.

次に pass オブジェクトを定義します.

#[derive(Copy, Clone, Debug)]
pub struct MyRule;

impl LintPass for MyRule {
    fn get_lints(&self) -> LintArray {
        lint_array!(MY_RULE)
    }

    fn name(&self) -> &'static str {
        "MyRule"
    }
}

impl EarlyLintPass for MyRule {
    fn check_expr(&mut self, cx: &EarlyContext<'_>, e: &syntax::Expr) {
        // ここにチェック処理を実装
    }
}

rustc::lint::LintPass の実装ではその pass の共通情報を記述します.1つの pass で複数のルールをチェックすることができ,rustc::lint::lint_array! マクロで指定します.

early pass の実装本体は rustc::lint::EarlyLintPass を実装することで実装します.もともと EarlyLintPass にある check_* メソッドをオーバーライドすると,そのメソッドが対応する構文木ノードを visit したときに適用されます.rustc::lint::EarlyContext を通じて lint のコンテキスト情報を受け取ることができます. 上記では式に対する処理 check_expr をオーバーライドしていますが,一覧はrustc のソース内で確認できます.

構文木のパターンがチェックしたいパターンにマッチしているかをチェックし必要な情報を抜き出すには matchif let などのパターンマッチをネストさせまくることになるので,if_chain::if_chain! マクロが便利です. 例えば let x = EXPR; x を取り出す処理はこんな感じに書けます.

if_chain! {
    if let Some(retexpr) = it.next_back();
    if let ast::StmtKind::Expr(ref retexpr) = retexpr.node;
    if let Some(stmt) = it.next_back();
    if let ast::StmtKind::Local(ref local) = stmt.node;
    if let Some(ref initexpr) = local.init;
    if let ast::PatKind::Ident(_, ident, _) = local.pat.node;
    if let ast::ExprKind::Path(_, ref path) = retexpr.node;
    if !in_external_macro(cx.sess(), initexpr.span);
    then {
        // ここでマッチしたときの処理
    }
}

警告すべきコードをパターンマッチで見つけたら警告を出す処理を書きます.警告を出す方法は clippy_lints/src/utils に便利関数群があり,

  • 警告メッセージのみ: span_lint(), span_lint_node()
  • 警告メッセージとヘルプ: span_help_and_lint()
  • 警告メッセージとノート: span_note_and_lint()
  • 警告メッセージとヘルプと修正提案: span_lint_and_sugg()

などが使えます.span とはソースコード片のことで,開始位置・終了位置・コンテキスト情報がエンコードされた u32 の値で,ソースコードのうち警告を出す部分を指定するのに使えます.各構文木ノードは syntax::Spanned で wrap されて check_* メソッドに渡されるので,ノードに対応する span は簡単に取得できます.

span_lint_and_sugg() ではソースコードの修正提案を String で渡せます.rustc_errors::Applicability::MachineApplicable を指定することで,自動修正機能(おそらく rustfix?)で自動修正できます.渡した文字列で span で指定した範囲を置き換えます.span は utils::snippet() を使ってコード片(スニペット)として文字列化することができ,修正提案のための文字列が楽につくれるケースがあります.

最後に作成したルールを clippy に登録します.clippy_lints/src/lib.rs 内で下記のように対応するカテゴリに linter 情報を追加します.

// ...
pub mod my_rule;

// ...

reg.register_lint_group("clippy::style", Some("clippy_style"), vec![
    // ...
    my_rule::MY_RULE
    // ...
]);

// ...

最後につくった pass を登録するのですが、early pass の場合はここで注意が必要です。

// マクロ展開後で OK な pass は普通に early pass として登録
pub fn register_plugins(reg: &mut rustc_plugin::Registry<'_>, conf: &Conf) {
    // ...
    reg.register_early_lint_pass(box reference::Pass);
    // ...
}

// マクロに対する lint など,マクロの展開前でないと動かない pass はこっちに登録
pub fn register_pre_expansion_lints(...) {
    // ...
    store.register_pre_expansion_pass(Some(session), true, false, box dbg_macro::Pass);
}

マクロの構文木ノードにマッチさせる check_mac() などを使う場合は後者の register_pre_expansion_pass 側に pass を登録する必要があります. early pass の適用順序は

  1. pre_expansion_pass
  2. マクロの展開処理
  3. early_lint_pass

となっています。

新しい Later Pass を追加する

early pass と基本的には同じで,rustc::lint::EarlyLintPass<'a> の代わりに rustc::lint::LateLintPass<'a, 'tcx> を実装します.tcx は型情報のコンテキストの寿命を表しているようです.

impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule {
    fn check_expr(&mut self, cx: &LateContext<'a, 'tcx>, expr: &'tcx rustc::hir::Expr) {
        // ルールの実装
    }
}

オーバーライドできるメソッドはrustc のソース内で確認できます.

early pass と違い,check_expr などのメソッドに rustc::hir::* が渡され,型情報のコンテキストの寿命 tcx が制約として付きます. 型情報には LateContexttcx フィールドからアクセスできます.(e.g. cx.tcx.fn_sigコンパイラコンフィグ(cfg)は cx.tcx.sess.parse_sess.config にある rustc::session::config::Config な値にアクセスすることでチェックできるようです.構造体のサイズなどターゲット依存で変わるものをチェックする際に使われます.

また,later pass では call flow graph も rustc::cfg::CFG を使って取得できます.

impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule {
    fn check_fn(
        &mut self,
        cx: &LateContext<'a, 'tcx>,
        kind: intravisit::FnKind<'tcx>,
        decl: &'tcx FnDecl,
        body: &'tcx Body,
        span: Span,
        node_id: NodeId,
    ) {
        // call flow graph を生成
        let cfg = CFG::new(cx.tcx, body);
        // ...
    }
}

例えば cyclomatic complexity を計算する際に使われているようです.

pass の登録について,later pass の場合はマクロに対するテストかどうかで pass の登録先を分ける必要はありません.ある HIR ノードがマクロ展開結果生成されたものかは,そのノードの span を使って clippy_lints/src/utilsutils::is_expn_of で分かります.

テストの実装

テストは UI テスト(問題があるコードに実装したルールを適用して,結果として期待する警告の出力がコマンドライン出力として得られるかどうか)のみで行います.

CONTRIBUTING.md によると,実装前にまずは警告を出してほしいコードを tests/ui/my_rule.rs に書き,TESTNAME=ui/my_rule cargo test --test compile-test と実行すると,そのコードの構文木にマッチする linter 実装コードの雛形をつくってくれるらしいのですが,僕の場合は dbg! が展開された後の構文木にマッチするようなコードが吐かれてしまったため使えませんでした.

linter の実装が終わったら CLIPPY_TESTS=true cargo run --bin clippy-driver -- -L ./target/debug tests/ui/my_rule.rs で警告が意図通り出力されていることを確認し,その結果を多少整形して tests/ui/my_rule.stderr として保存します.最後に TESTNAME=ui/my_rule cargo test --test compile-testmy_rule 向けのテストを実行して結果を確認できます.

dbg_macro ルールの実装

今回実装したルールは dbg!(expr) マクロの使用箇所を検知して警告として表示し,dbg!(expr) の代わりに expr を修正提案として表示する小さなものです.

rustc::lint::EarlyLintPass として実装し,pre-expansion pass として登録します.マクロ呼出しにマッチする EarlyLintPass::check_mac メソッドをオーバーライドして実装し,渡された ast::Mac のパスが "dbg" かどうかチェックするだけです.

impl EarlyLintPass for Pass {
    fn check_mac(&mut self, cx: &EarlyContext<'_>, mac: &ast::Mac) {
        if mac.node.path == "dbg" {
            // 警告を表示
        }
    }
}

ast::Macsyntax::Spaned で wrap された型なので mac.span でマクロ呼出しのソースコード上での範囲(span)は簡単に取得できます.

あとは修正提案用の文字列を生成できれば終わりです. マクロは引数にトークン列を取るので,展開前は引数のトークン列のみが構文木ノードに格納されています(mac.node.tts). dbg!(expr) マクロの中身 expr の span が取得できれば,その範囲を utils::snippet でコード片化できます.

引数の span を取る方法は

  1. トークン列の最初のトークンの span の始まり位置とトークン列の最後のトークンの span の終わり位置からトークン列全体の範囲を表す span を生成する
  2. dbg!() は仕様として1つの値を取るので,トークン列を syntax::ast::Expr にパースした結果のノードから span を取り出す
  3. early pass をやめ,later pass にして dbg!() の引数部分から展開された HIR ノードを特定して span を取り出す

のざっくり3通りが考えられます.3. は大変そうだし 2. は式としてパースできなかった場合のエラーハンドリングやパースのコストがあるので,1. が一番良さそうです.

Rust のトークン列は syntax::tokenstream::TokenStream という型で表され,syntax::tokenstream::TokenTree の列で表現されています.どうやらこれらは proc macro の実装などで使う proc_macro::TokenStreamproc_macro::TokenTree とは別物のようです.

TokenStream.trees() で(clone した)TokenTreeイテレータを返せるので,そこから最初のトークンツリーと最後のトークンツリーを取得し,Span::to を使って最初のトークンツリーの span から最後のトークンツリーの span までの範囲を表す span を新たに生成します.

let mut cursor = mac.node.tts.trees();
let first = cursor.next().unwrap();
let last = cursor.last().unwrap_or(first);
let entire_span = first.span().to(last.span());

最後にスニペット文字列を span から生成すれば修正提案に使う文字列が生成できます.

let snip = utils::snippet_opt(cx, entire_span).unwrap().to_string();

最後に clippy_devcargo fmt を実行しておきます.clippy_devclippy_lints/src/lib.rs で登録されている pass をチェックしたり,CHANGELOG.md と README.md を更新したりしてくれます.

cd clippy_dev/
cargo run -- update_lints
cargo +nightly fmt --all

まとめ

dbg_macro ルールを Rust 公式 linter の clippy に追加しました.次の clippy のリリース時に入ると思うので,良ければ CI や Git の pre-commit もしくは pre-push フックなどで

cargo clippy -- -W clippy::dbg_macro

と実行して使ってみてください.

また clippy に新しいルールを追加する方法についても簡単に紹介しました.rustcrustc_*syntax あたりの nightly でしか使えない rustc コンパイラ API を使ったり読んだりする必要はなかなか無いので,実際使ったのはごく一部ですが良い機会だったと思います.ちなみにこれらの crate は rustc コンパイラの内部実装で頻繁に変更されるので,上記の紹介した内容もいずれ正しくなくなる可能性が高いです.