↑ Wombatのロゴは、よもぎたけのこさんに作成してもらいました
はじめに
こんにちは。
みなさんは、Rustで書かれたコマンドラインツール bat をご存じでしょうか?
bat は cat のようにファイルをターミナルに表示するコマンドラインツールですが、行数を表示してくれたり、シンタックスハイライティングしてたり、ページングを行ってくれたりします。
bat hello.rb
さて、Crystalでは、有力なシンタックスハイライティングのライブラリがありません。そこで、この bat をライブラリとして利用することを考えました。
BatはRustライブラリとしても利用できる
実はBatはRustのライブラリとして使用することもできます。それが PrettyPrinter です。
use bat::PrettyPrinter;
PrettyPrinter::new()
.input_from_bytes(b"<span style=\"color: #ff00cc\">Hello world!</span>\n")
.language("html")
.print()
.unwrap();
Batはシンタックスハイライティングには、Syntectというライブラリを使用しています。しかしSyntectは結構複雑なのでBatをライブラリとして利用した方が簡単だと考えました。
上記のコードを見ると、あくまでターミナルに出力する処理はRust側で行われていることがわかると思います。Batには元々文字列をシンタックスハイライティングしてくれるような関数は用意されていませんでした。
オープンソースの世界では腰の軽さと自ら手を動かす精神が求められます。そこでChatGPTに相談しながら、PrettyPrintに print_with_writer
関数を追加しました。
pub fn print_with_writer<W: Write>(&mut self, writer: Option<W>) -> Result<bool>
これによって下記のようにして文字列をシンタックスハイライトすることが可能になります。
use bat::PrettyPrinter;
fn main() {
let mut output_str = String::new();
PrettyPrinter::new()
.input_from_bytes(b"<span style=\"color: #ff00cc\">Hello world!</span>\n")
.language("html")
.print_with_writer(Some(&mut output_str))
.unwrap();
println!("{}", output_str);
}
プルリクエスト送り、無事にマージされました。v0.25.0 以降のbatでは、この print_with_writer
関数を使う事ができます。
BatをCから呼び出せるように、C言語のライブラリを作成する
さて、RustのライブラリをCrystalから直接呼び出すことはできません。そこで、BatをC言語から呼び出すための薄いラッパーライブラリを作成することにしました。そうすることによって、Crystalだけではなく、さまざまな言語からBatを簡単に利用することができるようになります。なぜならば、多くのプログラミング言語は、C言語のライブラリを呼び出すためのインターフェイスを持っているからです。
私はRustを読み書きできないので、コードはほとんどすべてChatGPTとCopilotに生成してもらいました。
今後もRustのライブラリに対して、薄いCのラッパーを作る機会はあると思うので、学んだことをいくつかメモしておきます。
Rustは低レベルのプログラミング言語ですが、C言語の方がRustよりもずっと抽象化の度合いが低いため、呼び出しのAPIをデザインするときに自由度が生じます。
たとえば、Pythonのような高レベルの言語からCのような低レベルの言語のライブラリを呼び出す場合は、Pythonのメソッドのシグネチャーは一意に定まります。具体的には libffi によるバインディング生成のようなものを想像してください。シグネチャーが一意に定まるからこそ、libffi によるバインディングが可能なのです。もちろん、そのあとに、オブジェクト志向に沿った高レベルのAPIをデザインするという作業が続きますが、ともかく呼び出しのレベルではメソッドのシグネチャは一意に定まります。
しかし、CからRustを呼び出すということは、低レベルな言語から高レベルのライブラリを呼び出すことになります。これはCからPythonを呼び出すという感じで、抽象度が下がるので、C側のインターフェースは一意に定まらず、どのようなAPIを指定するか、作者に一定の自由度が生じます。(細かいことを言うと現実のPythonにはC-APIがありますが、そういうのが隠蔽されている状態を想像してください)
だから、ChatGPTを使う場合でも、自分がどのようなAPIをデザインしたいのかよく考えて、それをAIに指定した方がよいでしょう。
- バージョンを表示する関数を追加。どのバージョンのbat-cを使ってるのか特定できるようにします。ここではプログラムのライフタイム全体で存在する静的メモリに定数として配置し、そのポインターを返すようにしています。
#[no_mangle]
pub extern "C" fn bat_c_version() -> *const c_char {
static VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
VERSION.as_ptr() as *const c_char
}
-
文字列のメモリの確保とリリースは問題になりやすいです。Rustのライブラリ側で文字列を確保する場合は、文字列のメモリを解放する関数も一緒にRust側で用意する必要があります。
-
Cのヘッダーファイルは手書きするのではなく、cbindgenというツールがあるので、それを使います。
-
今回は幸いにしてBatに送信したプルリクエストがマージされましたが、それまでは自分でbatのフォークを管理する必要がありました。そういう場合はフォークを、サブモジュールとして追加し、Cargo.tomlにローカルパスを追加します。なお、マージされた場合は、あとからサブモジュールごと削除することになります。
-
Cargo.toml の設定
- [lib] にライブラリの種類を指定します。以下の両方を設定しています
- cdylib で動的ファイルを生成します
- staticlib で静的ファイルを生成します
- rpath = true 動的ファイルの位置を相対パスで探せるようにしておきます
- [profile.release]
- lto (Link Time Optimization): リンク時最適化を有効化して、バイナリの最適化と高速化を図ります。
- codegen-units = 1: コード生成ユニットを1に設定して、最適化効果を最大化します
- debug, strip を設定すると多少ファイルサイズが小さくなるらしいですがあまり変わらないかも
- opt-level = 3 や opt-level = "z" を設定することも考えましたが、バランスを考えてそのままにしました。
- [lib] にライブラリの種類を指定します。以下の両方を設定しています
[package]
name = "bat-c"
version = "0.0.7"
edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
bat = "0.25.0"
[profile.dev]
rpath = true
[profile.release]
lto = "fat"
codegen-units = 1
rpath = true
debug = false
strip = true
- ライブラリのバージョン更新を自動化するために Renovate を導入します。これはあまり理解していないのですが、リポジトリに次のようなJSONファイルを追加すると動いてくれます。
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices",
"schedule:quarterly"
]
}
- Git でタグを付けると、自動的にリリース用のワークフローが発火する用にしておきます。
- あくまでCのライブラリなので、
cargo publish
はしないことにします。
bat-c を Crystal から呼び出す
ここまできたら、あとはCrystalから bat-c を呼び出すのは簡単です。ここでは、wombatというライブラリを作成しました。
ここで問題になるのは、ライブラリのダウンロードと配置です。
もしも bat-c
がしっかりと作られた需要の高いライブラリであれば、パッケージ化して、パッケージマネージャーからインストールするようなことも考えられますが今回はそうではありません。そこで、単純に GitHub Release から最新のライブラリをダウンロードできるようにします。
静的ライブラリと動的ライブラリがありますが、ここでは作者の独断で静的ライブラリを利用するようにします。せっかく静的ライブラリを作れるRustだし、CrystalはRubyやPythonと違って静的ライブラリを組み込めるのが大きな魅力だしね。
さて、Rubyであれば、Rakefileに好きなように処理を書けるのですが、Crystalはそこまでの柔軟さはありません。利用できそうな機構は、shards の post_install ぐらいであり、ここに静的ライブラリをダウンロードするためのスクリプトを発火するようにしておきます。
Crystalのコードを用いてライブラリをダウンロードしてもいいのですが、残念ながらCrystalの標準ライブラリはまだまだ弱くて、リダイレクトやプロクシ環境下では思うように動いてくれないことも多々あります。そこで、Curlを利用するシェルスクリプトと、Windows向けのバッチファイルを書いて、OSに応じてそれらが実行されるようにしました。
利用方法
サンプルコード
require "../src/wombat"
# Output the file content with syntax highlighting by calling the rust function
Wombat.pretty_print_file(__FILE__)
# Output the highlighted string of the input by calling the rust function
Wombat.pretty_print(input: %{fn main() { println!("Hello, world!"); }}, language: "Rust")
# Get the highlighted string of the input
puts Wombat.pretty_string(%{puts "Hello, World!"}, language: "Crystal", theme: "TwoDark")
GitHub Actionsのテストで実行したところ
さらに詳しく見たい人は、API ドキュメントを参照してください。
現時点であまりうまくいっていないところ、改善すべき点
やりたいことはだいたい実現できましたが、いくつか気になることがあります。1つ目に、作成されたライブラリのサイズが大きいこと。2つ目に、Rustのレベルで最初からCを意識した設計をしてもらうのがベストプラクティスであるのに、あとからCのラッパーを追加しているので、内部構造的には効率が悪くなっているのではないかということ。3つ目に、自分のRustやC力がないため、APIのデザインがひょっとするとうまくいっていないかもしれないなという点です。
しかし、当初の目的である、あまりメンテナンスをしなくてもいいような形でBatをライブラリとしてCrystalから呼び出すという目標はだいたい達成したし、CrystalからRustを呼び出す方法論を確立したことで、やれることが増えたと思います。
もちろん、個人が趣味で作っているライブラリなので、不便なところや不具合に気がつかれる場合もあると思います。その時は、ぜひissueやプルリクエストを送信して頂けると幸いです。
この記事は以上です。良い一日を!