dylint-custom-rust-lints

dylintでRustの自前lintを運用する

 
0
このエントリーをはてなブックマークに追加
Kazuki Moriyama
Kazuki Moriyama (森山 和樹)

dylintでRustの自前lintを運用する

Rustコードベースでは、まずClippyで既存lintを回すことが多い。ところが、自前lint向けの規約はClippy本体のlint一覧には載らない。

モジュール境界でnewtypeを強制したい、Clippy抑制には理由コメントを付けたい、といった要件は、clippy.tomlの閾値変更だけでは足りない。

clippy.tomlは既存Clippy lintの設定を変えるだけで、新しい走査ロジックは追加できない。pub fnの引数型やallow属性の直前行といった、型情報や属性に触れるルールは、自前lintとして実装する必要がある。

dylintでは、自前lintをlintライブラリとして用意する。
lintライブラリは、検査対象のアプリとは別に置くRust crateで、lintの登録処理とHIR走査ロジックを含む。
dylintは、このcrateをcdylib(Rustの動的ライブラリcrate-type)としてビルドし、cargo dylint実行時にロードする。
アプリ本体の通常ビルドにlintを焼き込まず、検査ルールだけを差し替え可能にする点が「動的」の意味になる。

この構成では、検査ルールをアプリ側crateの通常ビルドから切り離せる。

lintライブラリ側には、nightlyとrustc_private feature(rustc内部APIを使うための機能フラグ)が要る。一方で、アプリ側crateはstableのまま検査できる。

ディレクトリ構成の見取り図

設定ファイルとコマンドは、lintライブラリ側とアプリ側workspaceの2系統に分かれる。

例の構成では、lintライブラリとアプリ側workspaceを同階層のディレクトリに置く。

project-root/
├── lint-rules/                 # lintライブラリ(nightly固定)
│   ├── Cargo.toml              # cdylib + dylint_linting
│   ├── rust-toolchain.toml     # nightly + rustc-dev
│   ├── .cargo/config.toml      # linker = dylint-link
│   ├── src/lib.rs              # LateLintPass 実装
│   └── ui/                     # ui_test 用 (.rs / .stderr)
└── sample-workspace/           # アプリ側 workspace(stable のまま)
    ├── Cargo.toml              # workspace.metadata.dylint
    └── sample-crate/           # 検査対象 crate
        └── src/lib.rs

各ディレクトリの役割は次のとおり。

パス 役割 toolchain
lint-rules/ lintをcdylibとしてビルドし、register_lintsをexport nightly + rustc-dev
sample-workspace/ 検査対象の通常アプリ。metadataでlintライブラリを宣言 stable(通常のcargo build)
sample-workspace/sample-crate/ cargo dylintが実際に走査するcrate stable

表のとおり、lint-rulessample-workspacemembersに入れない。

lint-rulesをworkspace member(同じworkspaceに含めるcrate)にすると、cargo buildの対象になる。lint-rules/rust-toolchain.tomlでpinしたnightlyがworkspaceルートからのビルドでも選ばれ、アプリ側crateまでnightly解決が及ぶ。

実行の流れは次のとおり。

flowchart LR subgraph nightly_side [lint-rules / nightly] LR[lint-rules を cdylib ビルド] end subgraph stable_side [sample-workspace / stable] APP[sample-crate のソース] end LR -->|cdylib| DYL[cargo dylint] APP --> DYL DYL -->|検査結果| OUT[warning / error 出力]

実行の起点はsample-workspace/。同階層のlint-rules/でビルドしたcdylibをcargo dylintが読み込み、sample-crate/を走査する。コマンドとCargo.tomlの具体例は「dylintの最小構成」節で示す。

自前lintが必要になるケース

Clippyは、Rustコミュニティ全体に通用する汎用パターンの検出を得意とする。関数シグネチャやallow属性のようにリポジトリ規約へ依存する要件では、自前lintが必要になりやすい。典型例は次のとおり。

要件 Clippy既存lintだけでは
pub fnシグネチャのraw数値型をdomain newtype(意味ごとに分けた型)に限定 ルール自体が存在しない
#[allow(clippy::...)]に理由コメントを必須化 抑制の説明責任までは追わない
ファイル行数やテスト配置などのリポジトリ規約 組織固有のためClippy本体に載りにくい

表の典型例を満たす自前lintは、リポジトリ内にlintライブラリを置く。dylintはそのlintライブラリをcdylibとしてビルドし、通常のcargo checkと同じアプリ側crateに差し込む。

dylintの最小構成

dylint導入では、同階層に置いたlint-rules/sample-workspace/を次の3つのstepで接続する。

step 作業
1 ホストにcargo-dylint@5.0.0dylint-link@5.0.0をインストールする
2 lint-rules/をcdylibとして設定する
3 sample-workspace/からlintを実行する

step 1: ホストへのインストール

lint-rules/sample-workspace/の外(開発マシンまたはCIイメージ)で、cargo-dylintdylint-linkの2つを入れる。

cargo install cargo-dylint@5.0.0 dylint-link@5.0.0 --locked

5.0.0に固定する。対応Rustバージョンは「バージョンと対応Rust」節で扱う。

step 2: lint-rules/ をcdylibとして設定

lintライブラリはcdylibとしてビルドする。Cargo.toml(manifest)、toolchain、linker設定の3点をlint-rules/に置く。

Cargo.toml と rust-toolchain.toml

# lint-rules/Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
dylint_linting = "=5.0.0"

[dev-dependencies]
dylint_testing = "=5.0.0"

dylint_libraryマクロは、上のCargo.tomlで依存に入れたdylint_lintingに含まれる。
cdylibには、このマクロでdylint向けの必須シンボルを埋め込む。
このシンボルがないと、cargo dylintがlintライブラリとして読み込めない。

続いて rust-toolchain.toml を置く。

# lint-rules/rust-toolchain.toml
[toolchain]
channel = "nightly-YYYY-MM-DD"
components = ["llvm-tools-preview", "rustc-dev"]

rustc-devは、lintライブラリのビルドに必要なrustup component(追加コンポーネント)。channelのnightly日付は、dylint_linting 5系が想定するrustc API世代と揃える。詳細は「nightlyとdylint_lintingのバージョン」節で扱う。

.cargo/config.toml(linker指定)

dylint-linkは、cdylibをdylintが検索するファイル名でリンクする。通常のlinkerのままだと出力ファイル名が異なり、cargo dylint listにlintライブラリが現れない(「dylint-link」節も参照)。

[target.'cfg(all())']
rustflags = ["-C", "linker=dylint-link"]

step 3: sample-workspace/ からlintを実行

アプリ側workspaceにlintライブラリを宣言し、cargo dylintを走らせる。

Cargo.toml(lintライブラリの宣言)

manifest側では、lintライブラリの場所をlibrariesに書く。

# sample-workspace/Cargo.toml
[workspace]
members = ["sample-crate"]
resolver = "2"

[workspace.metadata.dylint]
libraries = [{ path = "<lint-library-path>" }]

[workspace.metadata.dylint]は、dylint向けのworkspace設定ブロックで、librariesにlintライブラリの場所を書く。

実行コマンド

sample-workspace/のルートにcdしてから、nightly toolchain上でcargo dylintを走らせる。アプリ側crateの通常ビルドはstableのままでよいが、dylint実行環境にはstep 1のCLIとnightlyが必要になる。

lintライブラリの指定は次の2通り。

cd sample-workspace

# manifest を使わず CLI で path 指定
cargo dylint --path <lint-library-path> -- --all-targets

# manifest の libraries を読む(--all)
cargo dylint --all -- --all-targets

CLIの--pathでlintライブラリを都度指定できる。manifestの[workspace.metadata.dylint]を読むときは--allを使う。

deny設定のlintがerrorになると、cargo dylintの終了コードは非ゼロになる。

ここまでが最小構成になる。

続いてlintライブラリの実装に入る。

バージョンと対応Rust

cargo-dylintのメジャーバージョンは、Rust MSRV(Minimum Supported Rust Version、対応する最小Rustバージョン)と連動する。MSRVより古いRustでは、cargo install自体が失敗する。

lintライブラリcrateとCLIのバージョンをずらすと、dylint実行時にABI不一致で落ちることもある。

HIRを走査するlintを書く

lintライブラリの実装はlint-rules/src/lib.rsに置く。

HIR(High-level Intermediate Representation)は、型検査後のrustc中間表現を指す。
ソースを文字列として探すのではなく、型が付いた構文木をコールバックで受け取って調べる。
dylintのlint APIでは、HIRを走査するrustcのlintトレイトLateLintPass(lint実装の単位)を同じ形で使える。
Clippy lint開発ガイドのdeclare_lintマクロやLateContextの使い方も流用できる。

1つのLateLintPass実装で複数のlintを扱っても、処理はコールバックごとに分ける。

実装するlintは次の2つ。

lint名 検査内容 レベル
PUB_FN_RAW_PRIMITIVE pub fnの引数型・戻り値型にu32i32などのraw数値型が出たら指摘する Warn
ALLOW_CLIPPY_NEEDS_REASON #[allow(clippy::...)]の直前に// clippy-allow-reason:コメントがなければ指摘する Deny

前者は関数定義を受け取るcheck_fnで検査する。
後者はmodfnstructなどのitem属性を受け取るcheck_itemで検査する。

登録手順は次の3つのstepに分かれる。

  1. declare_lintマクロでlint名とレベル(Warn/Deny)を宣言し / CIで落とすlintをDenyにする
  2. register_lintsでlint一覧とLateLintPassLintStoreへ登録する
  3. LateLintPasscheck_fn/check_item等でHIRを走査し / pub fn向け / 属性向けでコールバックを分ける

この分け方にしておくと、関数シグネチャの規約と属性まわりの規約を同じLateLintPass実装で扱っても、どのコールバックが何を検査するかをレビューしやすい。

以下はlintライブラリのlib.rs骨格のみ示す。先頭のrustc_private featureとextern crate群は省略する。

// HIR(型検査後の構文木)と lint API
use rustc_hir::intravisit::FnKind;
use rustc_hir::{Body, FnDecl, FnRetTy, Item, QPath, TyKind};
use rustc_lint::{LateContext, LateLintPass, LintContext};
use rustc_session::impl_lint_pass;
use rustc_span::def_id::LocalDefId;
use rustc_span::{Span, sym};

// dylint が cdylib から呼ぶ dylint_version 等のシンボルを自動生成
dylint_linting::dylint_library!();

// cargo dylint 実行時、rustc が最初に呼ぶ入口
#[unsafe(no_mangle)]
pub fn register_lints(sess: &rustc_session::Session, lint_store: &mut rustc_lint::LintStore) {
    // dylint.toml 等の設定を rustc セッションへ反映
    dylint_linting::init_config(sess);
    // 下の declare_lint! で宣言した lint 名を rustc に登録
    lint_store.register_lints(&[PUB_FN_RAW_PRIMITIVE, ALLOW_CLIPPY_NEEDS_REASON]);
    // HIR 走査用の LateLintPass インスタンスを登録
    lint_store.register_late_pass(|_| Box::<RepoPolicyLints>::default());
}

// lint 1: pub fn シグネチャに raw 数値型があれば Warn
rustc_session::declare_lint! {
    pub PUB_FN_RAW_PRIMITIVE,
    Warn,
    "raw numeric primitive in `pub fn` signature — use a newtype instead"
}

// lint 2: #[allow(clippy::...)] に理由コメントがなければ Deny(CI で落とす)
rustc_session::declare_lint! {
    pub ALLOW_CLIPPY_NEEDS_REASON,
    Deny,
    "`#[allow(clippy::...)]` must have a preceding `// clippy-allow-reason:` comment"
}

// 1 つの LateLintPass 実装が複数 lint を emit する
#[derive(Default)]
pub struct RepoPolicyLints;

impl_lint_pass!(RepoPolicyLints => [PUB_FN_RAW_PRIMITIVE, ALLOW_CLIPPY_NEEDS_REASON]);

impl<'tcx> LateLintPass<'tcx> for RepoPolicyLints {
    // 関数定義を HIR 上で 1 件ずつ受け取るコールバック
    fn check_fn(
        &mut self,
        cx: &LateContext<'tcx>,
        kind: FnKind<'tcx>,
        decl: &'tcx FnDecl<'tcx>,
        _body: &'tcx Body<'tcx>,
        _span: Span,
        def_id: LocalDefId,
    ) {
        // クロージャと非 pub fn は raw 型 lint の対象外
        if matches!(kind, FnKind::Closure) || !cx.tcx.local_visibility(def_id).is_public() {
            return;
        }
        // 引数型を走査
        for input_ty in decl.inputs {
            check_ty(cx, input_ty);
        }
        // 戻り値型があれば走査(-> () はスキップ)
        if let FnRetTy::Return(ret_ty) = decl.output {
            check_ty(cx, ret_ty);
        }
    }

    // mod / fn / struct 等のアイテムごとに呼ばれる。属性 lint 用
    fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx Item<'tcx>) {
        let attrs = cx.tcx.hir_attrs(item.hir_id());
        for attr in attrs {
            // #[allow(...)] 以外は無視
            if !attr.has_name(sym::allow) {
                continue;
            }
            // snippet に clippy:: が含まれる allow だけ対象
            let Ok(snippet) = cx.sess().source_map().span_to_snippet(attr.span()) else {
                continue;
            };
            if !snippet.contains("clippy::") {
                continue;
            }
            // 属性直前の非空行が // clippy-allow-reason: で始まっていれば OK
            if previous_non_empty_line(cx, attr.span())
                .is_some_and(|line| line.starts_with("// clippy-allow-reason:"))
            {
                continue;
            }
            // 理由コメントがなければ Deny lint を emit
            cx.span_lint(ALLOW_CLIPPY_NEEDS_REASON, attr.span(), |diag| {
                diag.primary_message(
                    "`#[allow(clippy::...)]` must have a preceding `// clippy-allow-reason:` comment",
                );
            });
        }
    }
}

// 型ノード(HIR 上の Ty)を見て、パス型が raw プリミティブなら warning
fn check_ty<'tcx>(cx: &LateContext<'tcx>, ty: &'tcx rustc_hir::Ty<'tcx>) {
    if let TyKind::Path(QPath::Resolved(_, path)) = &ty.kind {
        let Some(name) = path.segments.last().map(|s| s.ident.name.as_str()) else {
            return;
        };
        if matches!(name, "u32" | "i32" | "u64" | "u128" | "i128") {
            // ty.span に diagnostic を付ける(sample-crate 側の該当型位置)
            cx.span_lint(PUB_FN_RAW_PRIMITIVE, ty.span, |diag| {
                diag.primary_message(format!(
                    "raw type `{name}` in `pub fn` signature — use a newtype instead"
                ));
            });
        }
    }
}

// 属性 span の直前にある最後の非空行をソースマップから取得
fn previous_non_empty_line<'tcx>(cx: &LateContext<'tcx>, span: Span) -> Option<String> {
    let Ok(prev) = cx.sess().source_map().span_to_prev_source(span) else {
        return None;
    };
    prev.lines()
        .rev()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .map(ToString::to_string)
}

check_fnはpub fnシグネチャ上のraw数値型(u32/i32等の固定幅プリミティブ)にwarningを出す。lib.rs内のmatchesマクロでu32/i32等を列挙する。

check_itemは理由コメントのないallowにerrorを出す。

diagnosticをソース位置に付けるspan_lintは、message文字列を直接渡す古い形ではなく、diagnosticを組み立てるクロージャ形式を使う。

2本のlintを同時に確認するため、sample-workspace/sample-crate/src/lib.rsには次のコードを置く。
先頭のallowには理由コメントを付けず、load_widgetの引数にはraw数値型のu32を使う。

#[allow(clippy::struct_field_names)]
pub struct Widget {
    widget_id: u32,
}

pub fn load_widget(widget_id: u32) -> Widget {
    Widget { widget_id }
}

実行時のdiagnostic

前節の2通り(--path / --all)のいずれでも、次の自前lint diagnosticが得られる。どちらもnightly上のcargo dylintで実行する。

error: `#[allow(clippy::...)]` must have a preceding `// clippy-allow-reason:` comment
 --> sample-crate/src/lib.rs:1:1

warning: raw type `u32` in `pub fn` signature — use a newtype instead
 --> sample-crate/src/lib.rs:6:31

--path指定と--all指定の差分は、lintライブラリの解決経路だけ。上記ではdead_code等のrustc既定warningを省略している。

sample-workspace/sample-crate/src/lib.rsでは、引数位置のu32にraw数値型warningが付く。戻り値はWidgetなので、このサンプルではraw数値型warningの対象にならない。先頭のallowには理由コメントがないためerrorになる。

workspace metadataとCIへの載せ方

ここまでの--path <lint-library-path>指定は、手元で動作を確認するには十分。
ただしCIでは、lintライブラリの場所と実行コマンドをリポジトリ側に固定しておきたい。

そのために、workspace metadataでlintライブラリの場所を宣言し、CIではcargo dylint --allを実行する。
dylintはlintライブラリ側をnightlyでビルドし、アプリ側crateをstableのまま検査するため、CIではtoolchainとcfg(dylint_lib)の扱いも明示しておく。

metadataでlintライブラリを宣言

[workspace.metadata.dylint]を書いておけば、--pathを毎回手打ちする必要はない。

CIでの実行

CI例では次を実行する。

cargo dylint --all -- --all-targets

期待するdiagnosticは「実行時のdiagnostic」節と同じになる。

nightlyとstableの二系統

lintライブラリ側はnightly + rustc-dev、アプリ側はstable、という二系統を分離する。

CIでnightly toolchainを入れ忘れると、rustc_private不足でlintライブラリのビルドが失敗する。

cargo dylintはnightly上で実行する。アプリ側crateの通常ビルドはstableのままにできる。

unexpected_cfgsの宣言

Rust 1.80以降はunexpected_cfgs lintが既定有効になる。dylint運用では、dylintが自動で付けるcfgもこのlintの対象になる。

dylintが付与するcfg(dylint_lib)をmanifestで宣言しないと、ビルドログに unexpected_cfgs warning が毎回出る。
運用上は同じwarningがCIログに出続けるため、sample-workspace/Cargo.tomlに次を書く。

[workspace.lints.rust.unexpected_cfgs]
level = "warn"
check-cfg = ["cfg(dylint_lib, values(any()))"]

check-cfg行は、dylintが付与するcfg(dylint_lib)を許可する宣言。CIログで毎回同じwarningを見る状態を避けるため、この宣言もlint導入時に入れておく。

各member crate(例:sample-crate/)はlint設定をworkspaceから継承する。

[lints]
workspace = true

dylintはlintライブラリごとに--cfg=dylint_lib="LIBRARY_NAME"を付与する。条件付きコンパイル(#[cfg(...)]でlint名をallowするパターン)を使う場合、上記がないとunexpected_cfgs warningが出る。

ui_testでlint期待値を固定する

lintの挙動は、lint-rules/内でdylint_testing::ui_testにより.rs.stderrのペアとして固定できる。Clippyのuiテストと同じ形式になる。

ui_testlint-rules/ui/配下の各.rsをdylintでコンパイルし、出たdiagnosticが同名の.stderrと一致するかを検証する。rustc/dylintのバージョンを上げたとき、期待出力のずれをテストで先に検知できる。

lint-rules/
├── src/lib.rs
└── ui/
    ├── pub_fn_raw_primitive.rs      # 入力(違反コード)
    ├── pub_fn_raw_primitive.stderr  # 期待 diagnostic
    ├── allow_clippy_reason.rs
    └── allow_clippy_reason.stderr

テスト本体はlint-rules/src/lib.rs末尾に1本置く。dylint_testing::ui_testにcrate名とui/ディレクトリを渡す。

#[test]
fn ui() {
    // CARGO_PKG_NAME = "article_dylint_rules" → ui/ ディレクトリを走査
    dylint_testing::ui_test(env!("CARGO_PKG_NAME"), "ui");
}

例1: raw数値型warningのui_test

pub_fn_raw_primitive.rsは、pub fnのu32だけがlint対象になるui_test用fixture。

pub fn bad_u32(x: u32) -> u32 {
    x
}

pub fn ok_bool(x: bool) -> bool {
    x
}

fn private_ok(x: u32) -> u32 {
    x
}

fn main() {}

期待出力は.stderrに書く。$DIRLLはパス・行番号のプレースホルダで、実行環境差を吸収する。全文を固定するとパス変更のたびにテストが壊れるため、この形式を使う(Clippy ui_testと同型)。

warning: raw type `u32` in `pub fn` signature — use a newtype instead
  --> $DIR/pub_fn_raw_primitive.rs:1:19
   |
LL | pub fn bad_u32(x: u32) -> u32 {
   |                   ^^^
   |
   = note: `#[warn(pub_fn_raw_primitive)]` on by default

warning: raw type `u32` in `pub fn` signature — use a newtype instead
  --> $DIR/pub_fn_raw_primitive.rs:1:27
   |
LL | pub fn bad_u32(x: u32) -> u32 {
   |                           ^^^

warning: 2 warnings emitted

引数位置と戻り値位置の2件だけwarningになる(このfixtureは戻り値もu32)。非pub fnやbool引数はwarning対象外。

例2: allow理由不足errorのui_test

allow_clippy_reason.rsは、理由コメント付きallowを通し、無いallowだけerrorにするui_test用fixture。

// clippy-allow-reason: test fixture showing acceptable suppression.
#[allow(clippy::struct_field_names)]
struct Good {
    field_name: i32,
}

#[allow(clippy::struct_field_names)]
struct Bad {
    field_name: i32,
}

fn main() {
    let _ = Good { field_name: 1 };
    let _ = Bad { field_name: 2 };
}

期待出力は次のとおり。

error: `#[allow(clippy::...)]` must have a preceding `// clippy-allow-reason:` comment
  --> $DIR/allow_clippy_reason.rs:7:1
   |
LL | #[allow(clippy::struct_field_names)]
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[deny(allow_clippy_needs_reason)]` on by default

error: aborting due to 1 previous error

Good側の allow には lint が付かず、Bad側の allow 1件だけ error になる。

ui_testの実行

lintライブラリのテストはlint-rules/でnightly上から実行する。

cd lint-rules
cargo +nightly-YYYY-MM-DD test

2つのuiペアが成功すれば、2本のlintの期待出力を固定できる。

自前lintの手段とdylintが向くケース

「自前lintをCIで回したい」と言っても、手段ごとにできることと運用コストが異なる。
型や属性に触る組織向けlintでは、Clippy forkやコンパイラ外ツールよりdylintの分離構成が扱いやすい場合がある。

手段の選択肢

手段 典型例 向くこと 重いところ
Clippy既存lint + clippy.toml blacklisted_nameの設定 汎用パターンの検出 新規ルールは定義できない
Clippy本体への貢献 コミュニティ向けlintのPR 誰でも使えるlintの追加 組織固有ルールは通りにくい
Clippy fork 組織専用Clippyビルド Clippyと同じUXでlint追加 rustc/nightly追従を自前で引き受ける
proc-macro 特定マクロ呼び出しの禁止 呼び出し点に限定したコンパイル時チェック HIR全体走査は向かない。stableではwarningも制約あり
dylint 組織lintをcdylib配布 LateLintPassベースの自前lint lintライブラリ側のnightly運用
コンパイラ外ツール cargo-deny、semgrep、synスクリプト 依存ポリシー、パターン検索 rustc diagnosticとは別パイプライン

補足として、かつてはcompiler plugin attribute(コンパイラ拡張属性)でlintを差し込めたが、rustcからは削除されている。

cargo clippyにlintライブラリをプラグインとして読み込む公式APIもない。

Clippy maintainerはissue #6595で、lintをcargo clippyへ動的追加する設計は想定していない、と述べている。

この前提に立つと、型・属性に触る自前lintはcargo clippyへの追加ではなく、別コマンド経路として選ぶ方が整理しやすい。

dylintの特性

dylintは、rustc APIで別lintツールを書く経路を実用化する仕組みとして扱える。

dylint READMEでも「Clippyは静的なlint集合、dylintは動的ライブラリからlintを読む」と説明されている。

Clippyのlint実装モデルを保ちながら、実行経路と配布単位を分けられる点がdylintの特徴になる。

この分離により、Clippy lint開発の知見を保ちつつ、lintライブラリだけを切り出せる。

# 性質 運用での扱い
1 LateLintPass/HIR走査 2本lintをClippyガイドと同じ形で書ける
2 cargo clippyとは別コマンド lintライブラリをcdylibとして差し替え可能
3 検査対象crateとlintライブラリの分離 sample-workspaceをstableのままCI化できる
4 lint単位の配布 workspace metadataでlintライブラリを宣言
5 rustc diagnostic出力 deny lintをCI終了コードで扱える

表の中でも、手段選びで先に見るのはlintライブラリ側のnightly固定(rustc_private依存)と、ui_test節の.stderr回帰になる。バージョンを上げると期待出力がずれ、テストが落ちることがある。

表3行目の分離構成は、アプリ側crateをstableのまま保つための前提になる。

向く用途

たとえば、境界APIの引数にdomain newtypeを強制し、#[allow(clippy::...)]には理由コメントを求めたい場合を考える。
どちらも型や属性をHIR上で見るため、clippy.tomlの設定だけでは足りない。
さらにアプリ側workspaceから別crateのlintライブラリを参照したいなら、dylintの分離構成が効きやすい。

このように、次の条件が重なるとき、dylintは有力な選択肢と考えられる。

条件 具体例
rustcの型情報が必要 HIR上の型を見てAPI境界のルールを検査する
属性やitem単位の構造が必要 属性と対象itemを合わせて検査する
汎用lintでは表現できない clippy.tomlではルール新設不可
Clippy forkを避けたい forkはrustc追従が重い
lintを独立crateとして配布したい workspace metadataから別crateのlintライブラリを参照
Clippyと同じテスト文化 dylint_testing::ui_test.rs/.stderr固定

向かないケース

一方で、rustcのHIRを走査しなくても判定できるなら、dylintを入れる理由は弱いと考えられる。
たとえば依存ライセンスやCVEの検出は、Cargo.tomlとlockfileを見るだけで足りる。
この用途では、cargo-denyの方が扱いやすいことが多い。
特定マクロの禁止も、呼び出し点だけで完結するならproc-macroやcompile_errorマクロで閉じられる。

代表例は次のとおり。

用途 素直な代替
汎用lintのバグ・スタイル検出 Clippy既存lint / Clippy本体への貢献
マクロ呼び出し1箇所の禁止 proc-macro / compile_error!
依存ライセンス・CVE・manifestポリシー cargo-deny等
nightly運用自体を避けたい syn走査スクリプト / レビュー運用

Marker(自前lint向けの実験的framework)も同カテゴリの候補だが、執筆時点ではAPIが不安定と見る。

nightlyとバージョン同期・unexpected_cfgsなどの注意点

dylint運用でつまずきやすい点をまとめる。

nightlyとdylint_lintingのバージョン

lintライブラリはrustc_privateに依存するため、nightly日付とdylint_linting / dylint_testingのバージョンをセットで固定する必要がある。
nightly日付は、使うdylint_lintingが対応するrustc API世代に合わせる。
Cargo.tomlでは、dylint_linting = "=5.0.0"dylint_testing = "=5.0.0"のように完全一致で固定する。
"5.0.0"だけだとCargoのsemver解決で5系の新しい版を取り得るため、nightly日付やui_testの期待出力が意図せずずれる。

バージョンをずらすと、rustc APIのシグネチャ変更などでlintライブラリのコンパイルが落ちる。

通常のlinkerの代わりにdylint-linkを使うと、dylintが認識できるファイル名のcdylibが生成される。これを省略するとcargo dylint listでlintが現れない。

lintライブラリとアプリ側workspaceの分離

lint-rules/をアプリ側workspaceのmembersに含めると、通常のcargo buildでもlintライブラリがworkspace解決の対象になる。lintライブラリはnightlyとrustc_privateを必要とするため、アプリ側crateまでnightly toolchainの影響を受ける。

dylint実行だけnightlyに寄せ、通常のcargo buildはstableのままにしておくため、lint-rules/はworkspace外の別crateとして置く。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。