3
3

Rustlingsのコードを読んでみる

Last updated at Posted at 2023-11-14

はじめに

最近Rustのモチベがあり、ちゃんとRustのコードを読んでみたい!ということでOSSのコードを読んでみることにした。RustlingsというRustの文法をクイズ形式で学習できるCUIアプリがある。今回はこれを読むことにする。(あまり大規模なものだと挫折しそうなので、小さめのものを選んだ)
Ruslingsがどのようなものかはこちらのブログに説明を譲る。今回はCUIアプリで具体的にどのような処理の流れが行われているかを調べてみる。

デバッグをしよう

今回はCUIアプリということでビルドする際にcargo install --path .とする必要がある。これを間違えてcargo buildとすると環境変数が~/.cargo/binの方を参照しているので.targetの方にバイナリファイルが生成されてしまい意味がないことになる。(1敗)
また、error[E0635]: unknown featureproc_macro_span_shrink` というエラーが出て困った。こちらに関しては

を参考にCargo.tomlを

serde = { version = "1.0.192", features = ["derive"] }

とserdeのversionを最新に書き換えたところ上手くいった。
色々デバッグしてみながらコードを読んでいく。main.rsのmain関数を読んでみるとargsが重要なカギを握っていそうなことが分かる。そこでArgsとSubcommandsに#[derive(Debug)]マクロを付けると、

println!("args: {:#?}", args);

でargsの中身を見ることができる。試しにrustlingsを実行してみると

args: Args {
    nocapture: false,
    command: None,
}

となる。
また、自分のような初心者にとってある変数がどのような型をしているのはなかなか難しい。それを知るのに役立つのがtype_of関数だ。

を参考に変数を入れると型を返すような関数を作ることができる。

目的を決める

コードを読むうえで先人たちの教えとして「目的意識をもってコードを読む」というものがある。すべてのコードを読むことを目指すと挫折する。今回は「rustlings runが動く仕組みを理解する」ことを目標にする。

プログラムを読む

パース

let args = Args::parse();

この時点でパースは完了している。
Clapがマジで偉いことがわかる。

struct Args {
    /// Show outputs from the test exercises
    #[arg(long)]
    nocapture: bool,
    #[command(subcommand)]
    command: Option<Subcommands>,
}

これによってnocaptureは"--nocapture"というコマンドのみを認識し、それ以外のcommandは全てcommandで処理される。ちなみに

#[arg(long)]

によって長い形式のオプション (--nocapture) のみ認識される。試しにrustlings --nocaptureを実行すると

args: Args {
    nocapture: true,
    command: None,
}

nocapturetrueになることが分かる。ほかのコマンドがcommandに格納されることは、rustlings hint primitive_types2を実行すると

args: Args {
    nocapture: false,
    command: Some(
        Hint {
            name: "primitive_types1",
        },
    ),
}

となることからもわかる。commandはSome型になっており、その中にHint型の構造体が格納されている。この構造体は

main.rs
enum Subcommands {
    Verify,
    /// Rerun `verify` when files were edited
    Watch {
        /// Show hints on success
        #[arg(long)]
        success_hints: bool,
    },
    ...
}

の部分で定義されている。

変数を読む

main.rs
    let toml_str = &fs::read_to_string("info.toml").unwrap();
    let exercises = toml::from_str::<ExerciseList>(toml_str).unwrap().exercises;
    let verbose = args.nocapture;

    let command = args.command.unwrap_or_else(|| {
        println!("{DEFAULT_OUT}\n");
        std::process::exit(0);
    });

fsモジュールでファイル入出力が可能。toml_strはinfo.tomlを読み込んだString型。ファイルの中身は以下のような設定が一問ごとに続いている。

info.toml
[[exercises]]
name = "intro1"
path = "exercises/intro/intro1.rs"
mode = "compile"
hint = """
Remove the I AM NOT DONE comment in the exercises/intro/intro1.rs file
to move on to the next exercise."""

恥ずかしながらDeserializeというワードを知らなかった。文字列からあるデータ構造に変換することを言うらしい。RustではSerdeモジュールで変換をすることが可能。structを定義するときに#[derive(Deserialize)]とする必要がある。

toml::from_str()::<ExerciseList>でStringからExerciseListにDeserializeすることができる。

excerise.rs
// The mode of the exercise.
#[derive(Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
    // Indicates that the exercise should be compiled as a binary
    Compile,
    // Indicates that the exercise should be compiled as a test harness
    Test,
    // Indicates that the exercise should be linted with clippy
    Clippy,
}

#[derive(Deserialize)]
pub struct ExerciseList {
    pub exercises: Vec<Exercise>,
}

// A representation of a rustlings exercise.
// This is deserialized from the accompanying info.toml file
#[derive(Deserialize, Debug)]
pub struct Exercise {
    // Name of the exercise
    pub name: String,
    // The path to the file containing the exercise's source code
    pub path: PathBuf,
    // The mode of the exercise (Test, Compile, or Clippy)
    pub mode: Mode,
    // The hint text associated with the exercise
    pub hint: String,
}

ExerciseListの定義はこのようになっている。Rustではpubがついている変数のみ別ファイルから参照可能となる。Pathを表せるstd::path::PathBufなんてあるんだ。

commandargs.commandの中身を読み込んでいる。

exerciseの取得

その下にmatch式があり、ここでそれぞれのコマンドに応じた処理が行われる。

main.rs
match command {
        Subcommands::List {
            paths,
            names,
            filter,
            unsolved,
            solved,
        } => {
            ...
    }

commandがrun型の時、すなわちrustlings run hogeと入力したときを見ていく。このとき

        Subcommands::Run { name } => {
            let exercise = find_exercise(&name, &exercises);

            run(exercise, verbose).unwrap_or_else(|_| std::process::exit(1));
        }

となっているのでfind_exercise(&name, &exercises)が呼ばれる。

main.rs
fn find_exercise<'a>(name: &str, exercises: &'a [Exercise]) -> &'a Exercise {
    if name.eq("next") {
        exercises
            .iter()
            .find(|e| !e.looks_done())
            .unwrap_or_else(|| {
                println!("🎉 Congratulations! You have done all the exercises!");
                println!("🔚 There are no more exercises to do next!");
                std::process::exit(1)
            })
    } else {
        exercises
            .iter()
            .find(|e| e.name == name)
            .unwrap_or_else(|| {
                println!("No exercise found for '{name}'!");
                std::process::exit(1)
            })
    }
}

nameがnextであるかによって処理の流れが変わる。もしnextでなければ、exercisesを順番に見ていって、nameが一致するようなものを返す。もしnextであれば、find(|e| !e.looks_done())self.state()State::Doneではない初めてのものを返す。

excerise.rs
pub fn looks_done(&self) -> bool {
        self.state() == State::Done
    }

self.state()の実装は以下のようになっている。

excerise.rs
    pub fn state(&self) -> State {
        let mut source_file = File::open(&self.path).unwrap_or_else(|e| {
            panic!(
                "We were unable to open the exercise file {}! {e}",
                self.path.display()
            )
        });
        let source = {
            let mut s = String::new();
            source_file.read_to_string(&mut s).unwrap_or_else(|e| {
                panic!(
                    "We were unable to read the exercise file {}! {e}",
                    self.path.display()
                )
            });
            s
        };

        let re = Regex::new(I_AM_DONE_REGEX).unwrap();

        if !re.is_match(&source) {
            return State::Done;
        }

        let matched_line_index = source
            .lines()
            .enumerate()
            .find_map(|(i, line)| if re.is_match(line) { Some(i) } else { None })
            .expect("This should not happen at all");

        let min_line = ((matched_line_index as i32) - (CONTEXT as i32)).max(0) as usize;
        let max_line = matched_line_index + CONTEXT;

        let context = source
            .lines()
            .enumerate()
            .filter(|&(i, _)| i >= min_line && i <= max_line)
            .map(|(i, line)| ContextLine {
                line: line.to_string(),
                number: i + 1,
                important: i == matched_line_index,
            })
            .collect();

        State::Pending(context)
    }

ソースを読んできて、Regexで正規表現にマッチするかの検索を行っている。

        let re = Regex::new(I_AM_DONE_REGEX).unwrap();

        if !re.is_match(&source) {
            return State::Done;
        }

で正規表現にマッチしなかったら(="// I AM NOT DONE"を消していたら)State::Doneを返す、という処理をしている。正規表現として

const I_AM_DONE_REGEX: &str = r"(?m)^\s*///?\s*I\s+AM\s+NOT\s+DONE";

と定義されている。

そして持ってきたexerciseに対してrun(exercise, verbose)が実行される。

exerciseの実行

run(exercise, verbose)の実装は以下。

exercise.rs
pub fn run(exercise: &Exercise, verbose: bool) -> Result<(), ()> {
    match exercise.mode {
        Mode::Test => test(exercise, verbose)?,
        Mode::Compile => compile_and_run(exercise)?,
        Mode::Clippy => compile_and_run(exercise)?,
    }
    Ok(())
}

どうやらmodeの値によって処理が異なるようだ。

exercise.rs
// The mode of the exercise.
#[derive(Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
    // Indicates that the exercise should be compiled as a binary
    Compile,
    // Indicates that the exercise should be compiled as a test harness
    Test,
    // Indicates that the exercise should be linted with clippy
    Clippy,
}

Modeの定義はこのようになっている。今回はcompile_and_runを見ていく。

run.rs
// Invoke the rust compiler on the path of the given exercise
// and run the ensuing binary.
// This is strictly for non-test binaries, so output is displayed
fn compile_and_run(exercise: &Exercise) -> Result<(), ()> {
    let progress_bar = ProgressBar::new_spinner();
    progress_bar.set_message(format!("Compiling {exercise}..."));
    progress_bar.enable_steady_tick(Duration::from_millis(100));

    let compilation_result = exercise.compile();
    let compilation = match compilation_result {
        Ok(compilation) => compilation,
        Err(output) => {
            progress_bar.finish_and_clear();
            warn!(
                "Compilation of {} failed!, Compiler error message:\n",
                exercise
            );
            println!("{}", output.stderr);
            return Err(());
        }
    };

    progress_bar.set_message(format!("Running {exercise}..."));
    let result = compilation.run();
    progress_bar.finish_and_clear();

    match result {
        Ok(output) => {
            println!("{}", output.stdout);
            success!("Successfully ran {}", exercise);
            Ok(())
        }
        Err(output) => {
            println!("{}", output.stdout);
            println!("{}", output.stderr);

            warn!("Ran {} with errors", exercise);
            Err(())
        }
    }
}

ProgressBarモジュール便利そう。compilation_resultがコンパイルの結果。compileは以下のように定義されている。

exercise.rs
pub fn compile(&self) -> Result<CompiledExercise, ExerciseOutput> {
        let cmd = match self.mode {
            Mode::Compile => Command::new("rustc")
                .args([self.path.to_str().unwrap(), "-o", &temp_file()])
                .args(RUSTC_COLOR_ARGS)
                .args(RUSTC_EDITION_ARGS)
                .args(RUSTC_NO_DEBUG_ARGS)
                .output(),
            Mode::Test => Command::new("rustc")
                .args(["--test", self.path.to_str().unwrap(), "-o", &temp_file()])
                .args(RUSTC_COLOR_ARGS)
                .args(RUSTC_EDITION_ARGS)
                .args(RUSTC_NO_DEBUG_ARGS)
                .output(),
            Mode::Clippy => {
                let cargo_toml = format!(
                    r#"[package]
name = "{}"
version = "0.0.1"
edition = "2021"
[[bin]]
name = "{}"
path = "{}.rs""#,
                    self.name, self.name, self.name
                );
                let cargo_toml_error_msg = if env::var("NO_EMOJI").is_ok() {
                    "Failed to write Clippy Cargo.toml file."
                } else {
                    "Failed to write 📎 Clippy 📎 Cargo.toml file."
                };
                fs::write(CLIPPY_CARGO_TOML_PATH, cargo_toml).expect(cargo_toml_error_msg);
                // To support the ability to run the clippy exercises, build
                // an executable, in addition to running clippy. With a
                // compilation failure, this would silently fail. But we expect
                // clippy to reflect the same failure while compiling later.
                Command::new("rustc")
                    .args([self.path.to_str().unwrap(), "-o", &temp_file()])
                    .args(RUSTC_COLOR_ARGS)
                    .args(RUSTC_EDITION_ARGS)
                    .args(RUSTC_NO_DEBUG_ARGS)
                    .output()
                    .expect("Failed to compile!");
                // Due to an issue with Clippy, a cargo clean is required to catch all lints.
                // See https://github.com/rust-lang/rust-clippy/issues/2604
                // This is already fixed on Clippy's master branch. See this issue to track merging into Cargo:
                // https://github.com/rust-lang/rust-clippy/issues/3837
                Command::new("cargo")
                    .args(["clean", "--manifest-path", CLIPPY_CARGO_TOML_PATH])
                    .args(RUSTC_COLOR_ARGS)
                    .output()
                    .expect("Failed to run 'cargo clean'");
                Command::new("cargo")
                    .args(["clippy", "--manifest-path", CLIPPY_CARGO_TOML_PATH])
                    .args(RUSTC_COLOR_ARGS)
                    .args(["--", "-D", "warnings", "-D", "clippy::float_cmp"])
                    .output()
            }
        }
        .expect("Failed to run 'compile' command.");

        if cmd.status.success() {
            Ok(CompiledExercise {
                exercise: self,
                _handle: FileHandle,
            })
        } else {
            clean();
            Err(ExerciseOutput {
                stdout: String::from_utf8_lossy(&cmd.stdout).to_string(),
                stderr: String::from_utf8_lossy(&cmd.stderr).to_string(),
            })
        }
    }

Commandモジュールでコマンドラインを通した命令を行うことができる。argでコマンドライン引数を与え、output()でプロセスを実行することができる。Clippyの場合はなんかいろいろする必要があって大変そう。

const RUSTC_COLOR_ARGS: &[&str] = &["--color", "always"];
const RUSTC_EDITION_ARGS: &[&str] = &["--edition", "2021"];
const RUSTC_NO_DEBUG_ARGS: &[&str] = &["-C", "strip=debuginfo"];

定数はこのようになっている。
--color always:コンパイル結果に色を付ける。
--edition 2021:editionを指定する。あまり意図が分からないが、editionの違いによって必ずしも互換性は保証されないようだ。

-C strip=debuginfo:デバッグシンボルのみを除去するコンパイルオプション。バイナリサイズを小さくすることができるらしい。

コンパイルの結果はcmd.statusによって分けられる。OKの場合はCompiledExerciseが返される。CompiledExerciseで定義されたrunを呼ぶと、Execerise内のrunが呼ばれる。

exercise.rs

impl<'a> CompiledExercise<'a> {
    // Run the compiled exercise
    pub fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> {
        self.exercise.run()
    }
}
exercise.rs
fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> {
        let arg = match self.mode {
            Mode::Test => "--show-output",
            _ => "",
        };
        let cmd = Command::new(temp_file())
            .arg(arg)
            .output()
            .expect("Failed to run 'run' command");

        let output = ExerciseOutput {
            stdout: String::from_utf8_lossy(&cmd.stdout).to_string(),
            stderr: String::from_utf8_lossy(&cmd.stderr).to_string(),
        };

        if cmd.status.success() {
            Ok(output)
        } else {
            Err(output)
        }
    }

String::from_utf8_lossyを使うとUTF-8として有効でない部分を「�」に変換することができる。

感想

ソフトウェアをいじってて実装どのようになっているんだろう?と思ったときに実際にコードを読んでみると色々学びがあるし、知的好奇心も満たされて楽しかった。関数がどのように呼び出されてるか眺めるだけでこのような流れで処理されているんだな~となるし、チュートリアルで学んだことが出てくるとこれ進研ゼミでやったところだ!となる。それだけでは学べないこともたくさん出てきた。そして、分からないことについての記事を書いている先人が本当に神のように思えてくる。7年前に書いた記事に助けられると時空を超えて助けられているという感じがあってとてもよい。この記事も誰かの助けになれたらうれしい。また、ここが間違っているといった指摘があればぜひお願いしたい。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3