LoginSignup
3
1

Rust+LLVMでコンパイラを作る:#5 ファイルの分割と単体テスト

Last updated at Posted at 2023-12-31

2024-01-04追記: 本記事完了時点でのコードをこちらにまとめました

本連載のバックナンバー

サンプルコードはこちらのリポジトリにあります。

はじめに

お久しぶりです。本連載もちょうど一年ぶりの更新になってしまいましたが、久々に続きを書いてみようかと思います。

本連載では前回までに、整数型の四則演算を行える処理系を作成しました。

今回は前回までの予告通り、大きくなりすぎてしまったファイルの分割を行いたいと思います。

また、本記事で使用している外部ライブラリを最新のものに更新するほか、ファイルの分割を行う前に単体テストを追加し、こうしたリファクタリングの前後でコードが壊れないことを保証する方法を学んでおきたいと思います。

依存ライブラリの更新と追従

久々の記事更新となってしまったため、依存ライブラリの更新を行っておきたいと思います。

まずはLLVMのバージョン更新です。ここでは将来に備えて、LLVMは最新のものに更新しておきます。執筆時点(2023-12-30)では、LLVMの最新バージョンが17.0.6ですので、こちらをターゲットとしたいと思います。

次にLLVMのRust向けラッパーであるinkwellですが、こちらが master ブランチではLLVM17に対応しておりません。そのため、公式リポジトリに対するプルリクエストとして作成されたLLVM17用のフォークを使用します1

Cargo.toml を以下のように書き換えます。

-inkwell = { git = "https://github.com/TheDan64/inkwell", branch = "master", default-features = false, features = ["llvm14-0", "target-x86"] }
+inkwell = { git = "https://github.com/vadorovsky/inkwell", branch = "llvm-17", default-features = false, features = ["llvm17-0", "target-x86"] }

次に、コード本体をinkwellの更新に追従させます。

src/lib.rs を以下のように書き換えます。
ほとんどは返値が Result 型になった箇所への追従です。

@@ -229,3 +229,3 @@
                 .i8_type()
-                .ptr_type(inkwell::AddressSpace::Generic);
+                .ptr_type(inkwell::AddressSpace::default());
             let void_ty = self.context.void_type();
@@ -253,3 +253,3 @@
                 values::InstructionOpcode::BitCast,
-                format_str.as_pointer_value(),
+                format_str?.as_pointer_value(),
                 i8_ptr_ty,
@@ -263,4 +263,4 @@
             self.builder
-                .build_call(printf, &[format_str.into(), val_to_print.into()], "");
-            self.builder.build_return(None);
+                .build_call(printf, &[format_str?.into(), val_to_print.into()], "")?;
+            self.builder.build_return(None)?;

@@ -291,3 +291,3 @@
                             ""
-                            )
+                            )?
                         ),
@@ -298,3 +298,3 @@
                             ""
-                            )
+                            )?
                         ),
@@ -305,3 +305,3 @@
                             ""
-                            )
+                            )?
                         ),
@@ -312,3 +312,3 @@
                             ""
-                            )
+                            )?
                         ),
@@ -339,5 +339,5 @@

-            self.builder.build_call(*print_int, arg, "");
+            self.builder.build_call(*print_int, arg, "")?;

-            self.builder.build_return(Some(&val));
+            self.builder.build_return(Some(&val))?;

@@ -417,3 +417,3 @@

-        let mut output_path = PathBuf::from(source.clone());
+        let mut output_path = PathBuf::from(source);
         output_path.set_extension(ext);
@@ -442,3 +442,3 @@
         let src = read_file(source)?;
-        let out_dir = PathBuf::from(source.parent().unwrap_or(&source).clone());
+        let out_dir = PathBuf::from(source.parent().unwrap_or(&source));
         let mod_name = source.file_stem().and_then(|n| n.to_str()).unwrap_or("a");

前回記事と同様の手順で、このコードが期待通りに動作することを確認しましょう。

なおビルド時にリンカ周りのエラーが出た場合、LLVM_SYS_170_FFI_WORKAROUND という環境変数を定義して(適当な値を入れて)から cargo clean し、再ビルドしてみてください。

テスト追加

本記事ではこの後、ファイルの分割という比較的大きなリファクタリングを入れようと考えています。

本連載では、コードをなるべくシンプルかつ分かりやすくする点から、テストは基本的に省略する方向にするつもりでした。しかしテストを一つも書かずにリファクタリングするわけにもいかないので、Rustでの単体テストの追加方法の説明も兼ねて、現在実装している最低限の動作を行うテストを書いておきたいと思います。

lib.rs 中の pub mod driver {...}} の直前に、以下のコードを追加してください。

#[cfg(test)]
mod tests {
    use std::{env, fs::File, io::Write, path::Path, process::{Command, Output}};
    use anyhow::Result;
    use super::*;

    fn compile_and_run(name: &str, src: &str) -> Result<Output> {
        let test_dir = env::current_dir()?.join("test-data");
        let src_file = test_dir.join(format!("{name}.bonsai"));
        let mut f = File::options()
            .write(true)
            .create(true)
            .open(&src_file)?;

        f.write_all(src.as_bytes())?;
        let exe = compile(Path::new(&src_file))?;
        let output = Command::new(exe).output()?;
        Ok(output)
    }

    #[test]
    fn compiler_should_compile_basic_expression() -> Result<()> {
        let src = r#"
        6 * 7
        "#;

        let output = compile_and_run("basic_expression", src)?;
        let stdout = String::from_utf8(output.stdout)?;
        assert!(stdout.trim() == "result: 42");
        Ok(())
    }
}

このように、Rustの単体テストでは、#[cfg(test)] attributeをつけたモジュールの中に#[test] attributeをつけた関数を入れ、その中にテストを書いていくのが流儀となっています。

このうち、 #[cfg(test)] はこのモジュールがテスト時にしかコンパイルされないことを、
#[test] はこの関数がテスト用の関数であることを処理系に伝えます。詳しくはこちらを参照してください。

また、実装の直後にテストコード用のモジュールをつける以外にも、テストコード用のディレクトリ(慣例として tests)の中にテストをまとめて書くことも大変多いです。

次に プロジェクトのルートディレクトリ(src ディレクトリなどがある場所)に test-data というディレクトリを作成した後、プロジェクトのルートディレクトリで cargo test と実行してみてください。

test result: ok という表示が出れば無事テスト通過です。

なお再掲ですが、ビルド時にリンカ周りのエラーが出た場合、LLVM_SYS_170_FFI_WORKAROUND という環境変数を定義して(適当な値を入れて)から cargo clean し、再実行してみてください。

本記事では最低限のテストしか書きませんでしたが、もし余裕があればより細かなテスト(カッコを処理できることや、演算子の優先順位を正しく扱えることなど)を書いてみてください。

ファイルの分割

次に、現在の lib.rs が長くなってしまったため、ファイルを分割したいと思います。具体的には、現在 lib.rs 内にすべてのモジュールが混在していますが、この各モジュールをファイルに書き出します。

The Bookのこちらのページにある通り、Rust処理系は mod module_name; という宣言を見つけると、ファイル名がモジュール名となっているファイル(この例では module_name.rs )というファイルを読み込み、先ほどの宣言を mod module_name { /* 読み込んだファイルの中身 */} という形に書き換えるような挙動をします2

ここでは、Rustのこの仕様を利用してファイルの分割を行います。
具体的には、上記の挙動を利用するよう、 lib.rs の各 mod ~ {...} ごとに モジュール名.rs というファイルを作成し、中カッコの中身( {...} )をそのファイルにカットアンドペーストしてください。

最後に、残った空の中カッコ {}; に書き換えます。

この作業を行った結果、 src ディレクトリは

.
├── ast.rs
├── bin
│  └── bonsaic.rs
├── codegen.rs
├── driver.rs
├── ir.rs
├── irgen.rs
├── lib.rs
└── parser.rs

のような構成になり、元の lib.rs

mod ast;
mod parser;
mod ir;
mod irgen;
mod codegen;
pub mod driver;

のようなシンプルな内容になっているかと思います。

この状態で再度テストの実行を行い、テストが通過することを確認してください。

最後に

本記事では、依存ライブラリの更新を行った後、Rustでのテストの追加方法と、ファイルの分割方法を紹介し、それを本プロジェクトに適用しました。

次回は処理系の中身に戻って、エラーメッセージの改良を行ったり、 bool 型や if 文の導入等を行ったりできればと思います。

  1. 執筆時点(2023-12-30)では、Windows(MSYS2)やMac(homebrew)ではすでにLLVM17を利用可能なようです。しかし、Archなど、まだLLVM16しか利用できないパッケージリポジトリもあるようです。もしお使いの環境がLLVM16以下しか利用できない場合、inkwell本家の master ブランチを使用し、llvm14-0の代わりに利用するLLVMのバージョンに対応するfeatureを選択することにより、本連載のコードを動作させることが可能かと思われます。

  2. その他にも、モジュールと同名のディレクトリを用いるなどの規則もあります。詳しくはこちらを参照してください

3
1
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
1