はじめに
最近Rustのモチベがあり、ちゃんとRustのコードを読んでみたい!ということでOSSのコードを読んでみることにした。RustlingsというRustの文法をクイズ形式で学習できるCUIアプリがある。今回はこれを読むことにする。(あまり大規模なものだと挫折しそうなので、小さめのものを選んだ)
Ruslingsがどのようなものかはこちらのブログに説明を譲る。今回はCUIアプリで具体的にどのような処理の流れが行われているかを調べてみる。
デバッグをしよう
今回はCUIアプリということでビルドする際にcargo install --path .
とする必要がある。これを間違えてcargo build
とすると環境変数が~/.cargo/binの方を参照しているので.targetの方にバイナリファイルが生成されてしまい意味がないことになる。(1敗)
また、error[E0635]: unknown feature
proc_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,
}
とnocapture
がtrue
になることが分かる。ほかのコマンドがcommand
に格納されることは、rustlings hint primitive_types2
を実行すると
args: Args {
nocapture: false,
command: Some(
Hint {
name: "primitive_types1",
},
),
}
となることからもわかる。command
はSome型になっており、その中にHint型の構造体が格納されている。この構造体は
enum Subcommands {
Verify,
/// Rerun `verify` when files were edited
Watch {
/// Show hints on success
#[arg(long)]
success_hints: bool,
},
...
}
の部分で定義されている。
変数を読む
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型。ファイルの中身は以下のような設定が一問ごとに続いている。
[[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することができる。
// 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
なんてあるんだ。
command
はargs.command
の中身を読み込んでいる。
exerciseの取得
その下にmatch式があり、ここでそれぞれのコマンドに応じた処理が行われる。
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)
が呼ばれる。
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
ではない初めてのものを返す。
pub fn looks_done(&self) -> bool {
self.state() == State::Done
}
self.state()
の実装は以下のようになっている。
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)
の実装は以下。
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
の値によって処理が異なるようだ。
// 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
を見ていく。
// 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は以下のように定義されている。
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
が呼ばれる。
impl<'a> CompiledExercise<'a> {
// Run the compiled exercise
pub fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> {
self.exercise.run()
}
}
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年前に書いた記事に助けられると時空を超えて助けられているという感じがあってとてもよい。この記事も誰かの助けになれたらうれしい。また、ここが間違っているといった指摘があればぜひお願いしたい。