RustのOSSを探索(してコントリビュート)した話

dotenv-linter/dotenv-linter

Rustのdotenv-linter/dotenv-linterというOSSにPRを出してみました

2020/09/10 追記

OSSの作者から紹介されて嬉しかったかつ駄文をきれいにしなければいけない義務感を感じたので若干本文を更新しました

– 追記終わり

お盆は実家に帰るなと言われたのでダラダラとしていたのですが、This Week in Rustをみてたら、手伝って欲しいプロジェクト一覧の中に、比較的優しそうなdotenv-linter/dotenv-linterというプロジェクトを見つけたので、簡単なissueでPRを出してみました

PRはこちらです

あまり面白味のないPRでしたが、プロジェクト自体にいろいろ発見があったのでメモしておきます

Dockerfileの構成

キャッシュが効くようにCargo.tomlをcpしてビルドする構成は以前僕が書いていたものと同じだったので、ある程度この方法がデファクトスタンダードであることがわかって安心しました。

ただ、実行用のレイヤではベースイメージをscratchに指定することは知らなかったです。確かに実行するだけならscratchでいいですよね

GitHubActions

RustをGitHubActionsでさわる際には、actions-rsというのを利用すると簡単にビルドやテストを実行できるようです

gitlabと違い他プロジェクトでのCIを簡単に利用できるのは本当に便利ですね

ソースコード

これより以下に掲示するソースコードは全て以下のライセンス情報に則っています。

MIT License

Copyright (c) 2019 Grachev Mikhail

https://github.com/dotenv-linter/dotenv-linter/blob/master/LICENSE

このプロジェクトは、文字通りdotenvファイルをlintするだけです。チェックされるルールは以下の通りでこのうちいくつかは自動修正することも可能です。

✅ Duplicated Key ✅ Ending Blank Line ✅ Extra Blank Line ✅ Incorrect delimiter ✅ Key without value ✅ Leading character ✅ Lowercase key ✅ Quote character ✅ Space character ✅ Trailing whitespace ✅ Unordered Key

https://dotenv-linter.github.io/

以下はディレクトリ構成です。このぐらいなので全ファイルをさらっと読んでもそこまで大変ではなかったです

├── checks
│   ├── duplicated_key.rs
│   ├── ending_blank_line.rs
│   ├── extra_blank_line.rs
│   ├── incorrect_delimiter.rs
│   ├── key_without_value.rs
│   ├── leading_character.rs
│   ├── lowercase_key.rs
│   ├── quote_character.rs
│   ├── space_character.rs
│   ├── trailing_whitespace.rs
│   └── unordered_key.rs
├── checks.rs
├── common.rs
├── fixes
│   ├── extract_blank_line.rs
│   ├── key_without_value.rs
│   ├── lowercase_key.rs
│   ├── space_character.rs
│   └── trailing_whitespace.rs
├── fixes.rs
├── fs_utils.rs
├── lib.rs
└── main.rs

大まかには

  1. main.rs
  2. lib.rs
  3. checksモジュール(lintエラーを検出)
  4. fixsモジュール(オプションfixが指定されている場合のみ。ファイルを書き換えてエラーをなくす)

という順番で処理が行われています。checksモジュールとfixsモジュールではそれぞれチェックするルールごとにさらにモジュールに分かれています。

fixesモジュール

fixesモジュールでは、各ルールはFixトレートを実装します。Fixトレートは以下の通りです。関数fix_lineを上書き前提で提供していそうな感じですね。気になったのは上書き前提ならpanicさせるべきなような気がしました🤔

trait Fix {
    fn name(&self) -> &str;

    fn fix_warnings(
        &self,
        warnings: Vec<&mut Warning>,
        lines: &mut Vec<LineEntry>,
    ) -> Option<usize> {
        let mut count: usize = 0;
        for warning in warnings {
            let line = lines.get_mut(warning.line_number() - 1)?;
            if self.fix_line(line).is_some() {
                warning.mark_as_fixed();
                count += 1;
            }
        }

        Some(count)
    }

    fn fix_line(&self, _line: &mut LineEntry) -> Option<()> {
        None
    }
}

https://github.com/dotenv-linter/dotenv-linter/blob/master/src/fixes.rs

また、各ルールは、以下のように設定されています。Box<dyn T>は、 基底クラスorインタフェースとして扱いたい場合によく使いますね。エラーハンドリングでもみたことあります👀。

Boxって必要やったかなぁと思いましたが、外すとunsizedでビルドできませんでした。ビルド時にサイズを既知にしないといけないので、データをヒープにおくBoxがやっぱり必要ですね

fn fixlist() -> Vec<Box<dyn Fix>> {
    vec![
        // At first we run the fixers that handle a single line entry (they use default
        // implementation of the fix_warnings() function)
        Box::new(key_without_value::KeyWithoutValueFixer::default()),
        Box::new(lowercase_key::LowercaseKeyFixer::default()),
        Box::new(space_character::SpaceCharacterFixer::default()),
        Box::new(trailing_whitespace::TrailingWhitespaceFixer::default()),
        // Then we should run the fixers that handle the line entry collection at whole.
        // And at the end we should run the fixer for ExtraBlankLine check (because the previous
        // fixers can create additional extra blank lines).
        Box::new(extract_blank_line::ExtraBlankLineFixer::default()),
    ]
}

https://github.com/dotenv-linter/dotenv-linter/blob/master/src/fixes.rs

テスト

ユニットテストと結合テストはそれぞれ記述場所が分かれていました。ユニットテストはルールと同一ファイル内に、結合テストはsrcと並列のtestsディレクトリのなかにありました。

結合テストの場所はここが鉄板みたいですね.知らなかった..

https://doc.rust-jp.rs/book/second-edition/ch11-03-test-organization.html

テストの章を読んでいると、main.rsとlib.rsが分割されている理由を知りました。結合テストを書くにはlib.rsが必要なのですね

もしもプロジェクトがsrc/main.rsファイルのみを含み、src/lib.rsファイルを持たないバイナリクレートだったら、 testsディレクトリに結合テストを作成し、 extern crateを使用してsrc/main.rsファイルに定義された関数をインポートすることはできません。 ライブラリクレートのみが、他のクレートが呼び出して使用できる関数を晒せるのです; バイナリクレートはそれ単体で実行することを意味しています。

これは、バイナリを提供するRustのプロジェクトに、 src/lib.rsファイルに存在するロジックを呼び出す単純なsrc/main.rsファイルがある一因になっています。 この構造を使用して結合テストは、extern crateを使用して重要な機能を用いることでライブラリクレートをテストすることができます。 この重要な機能が動作すれば、src/main.rsファイルの少量のコードも動作し、その少量のコードはテストする必要がないわけです。

https://doc.rust-jp.rs/book/second-edition/ch11-03-test-organization.html#a%E3%83%90%E3%82%A4%E3%83%8A%E3%83%AA%E3%82%AF%E3%83%AC%E3%83%BC%E3%83%88%E7%94%A8%E3%81%AE%E7%B5%90%E5%90%88%E3%83%86%E3%82%B9%E3%83%88

また、CLIの実行結果をテストするクレートとしてassert_cmdというクレートがあるようです。

例えば以下の結合テストでは実行した結果が正常終了であるかつ意図した標準出力が発生することをテストしています。仕組みがよくわかりません….shを起動させずに直接該当のクレートにアクセスして実行するんでしょうか??🤔

// tests/fixes/extract_blank_line.rs
#[test]
fn extract_blank_line() {
    let testdir = TestDir::new();
    let testfile = testdir.create_testfile(".env", "ABC=DEF\n\n\nFOO=BAR\n");
    let expected_output = String::from(
        "Fixed warnings:\n\
        .env:3 ExtraBlankLine: Extra blank line detected\n",
    );
    testdir.test_command_fix_success(expected_output);

    assert_eq!(testfile.contents().as_str(), "ABC=DEF\n\nFOO=BAR\n");

    testdir.close();
}

https://github.com/diggymo/dotenv-linter/blob/extractBlankLineFixer/tests/fixes/extract_blank_line.rs

    /// Run the default CLI binary, with "-f", in this TestDir and check it succeeds.
    pub fn test_command_fix_success(&self, expected_output: String) {
        let mut cmd = Self::init_cmd();
        let canonical_current_dir = canonicalize(&self.current_dir).expect("canonical current dir");
        cmd.current_dir(&canonical_current_dir)
            .args(&["-f"])
            .assert()
            .success()
            .stdout(expected_output);
    }

https://github.com/dotenv-linter/dotenv-linter/blob/master/tests/common/mod.rs#L120

dedup

私が書いたルールは余分な改行をチェックするルールです。fixオプションがつけられた場合は余分な改行を消す処理が必要なので、ファイルの全行をなめて余分な行があれば消す処理を書きました。

// check and remove all blank lines.
let mut is_preview_line_blank = false;
lines.clone().iter().enumerate().for_each(|(i, line)| {
    let is_empty = line.is_empty();
    if is_empty && is_preview_line_blank {
        lines.remove(i - count);
        count += 1;
    }

    is_preview_line_blank = is_empty;
});

この実装はPRで指摘され、実はこんな面倒なことをしなくてもRustのVecには dedupという配列内の前後の要素で重複があるものを削除する関数がありました😇、Vec有能すぎません?笑

// check and remove all blank lines.
lines.dedup_by(|a, b| a.is_empty() && b.is_empty());

まとめ

  • 8/16現在、まだPRがレビューされてない… 8月末にマージされました!
  • Rustでの抽象化だいぶわかった
  • CLIの実行結果のテストの方法を知った
  • PRのコメントが優しい
tech  Rust 

See also