はじめに
この記事はRust Advent Calendar 2019 11日目の記事です。
普段はフロントエンドエンジニアである私ですが、今年からRustを学び始め右も左もわからない状態からなんとか小さなOSSを作って公開することができました。
この記事では、今回作成したOSSの作成に関するお話をしようと思います。少しでもなにかのお役に立てれば幸いです。
作成したOSS
今回は以下のようなのOSSを作ってみました。
dresscodeはcatコマンドやtailコマンドといった出力に対して、キーワード単位で色付けをしてくれるツールです。例えばtailコマンドと併用する場合、実行結果は以下のようになります。
コード自体はとてもシンプルで、パッケージもすべて公式のものだけを使用しています。
当時の私はOSSの作成はおろかcliツールもまともに作ったことがなかったのでどうやって実装すればよいか、どうやって公開すればいいのかわかりませんでした。迷っていてもしょうがないと思い、とにかく手探り状態でdresscodeの開発に着手しました。
実装
dresscodeの処理はとてもシンプルです。実際の動作は
- コマンドの出力を標準入力として受け取る
- 受け取った標準入力を1行ずつキーワードごとに分割し配列化する
- 配列内の文字列の要素がキーワードと一致する場合は色付けして出力し、そうでない場合は普通に出力する
といったフローになります。
実際に標準入力の受け取りから色付けするまでのコードを見てみます。
###get_stdin
fn get_stdin(keywords: &Vec<String>) {
let stdin = io::stdin();
for l in stdin.lock().lines() {
let line = l.unwrap();
let splited_line = split_line_by_keywords(&line, keywords);
print_colored_line(splited_line, keywords);
}
}
ご覧の通り標準入力を1行ずつでループしながら処理を進めていることがわかります。内部では文字列をキーワード単位で分割しているsplit_line_by_keywords
とキーワードごとに色付けをしているprint_colored_line
が動いています。
###split_line_by_keywords
fn split_line_by_keywords<'a>(line: &'a String, keywords: &Vec<String>) -> Vec<&'a str> {
let mut matches: Vec<(usize, &str)> = vec![];
for kw in keywords {
if kw == "" {
continue;
}
let mut m: Vec<_> = line.match_indices(kw).collect();
if m.len() > 0 {
matches.append(&mut m);
}
}
matches.sort_by_key(|k| k.0);
let mut result: Vec<&str> = vec![];
let mut count: usize = 0;
for m in matches {
if m.0 != 0 {
result.push(&line[count..m.0]);
}
result.push(&line[m.0..(m.0 + m.1.len())]);
count = m.0 + m.1.len();
}
if count != line.len() {
result.push(&line[count..]);
}
result
}
まず、match_indicesを使用してキーワードが文字列のどこに存在するかを調べます。match_indeciesは文字列のうち、キーワードと一致した箇所のindexと一致したキーワードを配列で返してくれます。これをキーワードごとに繰り返し行い、文字列中のどの位置でどのキーワードが一致したかを結果として配列にまとめておきます。
例えば文字列abcdefgh
、キーワードfg、cd
の場合は
[(5, "fg")、(2, "cd")]
といった結果になります。
さらにこの配列をキーワードが一致したindexが小さい順にソートして、文字列をindexごとに分割していきます。上記に示した文字列とキーワードの場合は以下のような結果になります。
["ab", "cd", "e","fg", "h"]
ここまで準備ができたら、あとはキーワードごとに色を付けていきます。
###print_colored_line
fn print_colored_line(splited_line: Vec<&str>, keywords: &Vec<String>) {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let len = splited_line.len();
if len == 0 {
writeln!(&mut stdout, "{}", "").unwrap();
}
for i in 0..len {
for (ki, kw) in keywords.iter().enumerate() {
if splited_line[i] == kw {
let color = get_colors(ki);
stdout.set_color(ColorSpec::new().set_fg(color)).unwrap();
break;
} else {
stdout
.set_color(ColorSpec::new().set_fg(Some(Color::White)))
.unwrap();
}
}
if i != (len - 1) {
write!(&mut stdout, "{}", splited_line[i]).unwrap();
} else {
writeln!(&mut stdout, "{}", splited_line[i]).unwrap();
}
}
}
ここでは予め用意した色の種類とキーワードごとの配色を決めて、先の結果をキーワードごとに色付けしていきます。
キーワードに当てはまらない場合は白で配色されます。最後の文字列となる場合、例えば["ab", "cd", "e","fg", "h"]
の"h"
が出力されるときは改行を含めるwriteln!
、それ以外の場合はwrite!
を使用して出力します。
ちなみに配色の決定はget_colors
で実施しています。
###get_colors
fn get_colors(index: usize) -> Option<termcolor::Color> {
let color_val: usize = 6;
match index % color_val {
0 => return Some(Color::Magenta),
1 => return Some(Color::Cyan),
2 => return Some(Color::Green),
3 => return Some(Color::Red),
4 => return Some(Color::Yellow),
5 => return Some(Color::Blue),
_ => return None,
}
}
キーワードのindexごとに配色するメソッドです。「マゼンタ、シアン、緑、赤、黄色、青」の6色を用意しました。
開発中の出来事
match_indiciesの使い方
文字列をキーワードごとに分割する際にmatch_indicesを使いましたが、ここは最適解じゃないと思っています。というのも本当はキーワードと一致した最初のindexと最後のindexさえわかれば、文字列をindexごとに分割する処理がもっと簡単になるからです。とはいえ、これを解決するようなメソッドを見つけられずに結構苦労しました。最適なメソッドをご存じの方がいらっしゃれば、コメントにて教えていただければ幸いです。
ライフタイムと借用の概念
特にライフタイムの扱いに非常に悩まされました。split_line_by_keywords
でライフサイクルを明示しないと以下のようなエラーが出力されます。
missing lifetime specifier
expected lifetime parameter
help: this function's return type contains a borrowed value,
but the signature does not say whether it is borrowed from `line` or `keywords`rustc(E0106)
この関数ではString型の第一引数を借用で使用しており、返り値としてこの引数を仕様した値を返しているため、明示的なライフサイクルを必要としていました。この借用とライフサイクルの考えに馴染みがなく、なぜエラーとして認識されるのかに悩まされ続けて手が止まってしまいました。
コマンドライン引数の管理
clapがとても便利でした。dresscodeではコマンドライン引数として色付けするためのキーワードが必要があったっため、clapを利用してこれを管理するようにしました。実際には以下のようなコードを用意しました。
let matches = App::new("dresscode")
.author("wataru-script")
.about("Dress up stdin")
.arg(
Arg::with_name("keyword")
.value_name("KEYWORD")
.help("Keyword")
.multiple(true)
.required(false),
)
.get_matches();
こちらを用意することでコード内ではmatches.values_of_lossy("keyword")
としてコマンドライン引数を取得することができます。ちなみに上記コードを用意するだけでhelpオプションも実装されます。
$ dresscode --help
dresscode
wataru-script
Dress up stdin
USAGE:
dresscode [KEYWORD]...
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
ARGS:
<KEYWORD>... Keyword
すごい。ありがたい。
OSSとして公開する場合はこちらを使い方として表記するのも手だと思います。(実際にdresscodeでは上記をUsageとして使用しています。)
公開
crates.ioへの登録は多くの記事が出ているのでこちらでは省略します。
dresscodeのページはこちらになります。
最後に
今回作成したdresscodeは比較的かんたんなロジックで実装していますが、これをRustのコードにするためにかなりの時間を要しました。特に先にも記した借用とライフタイムの理解、そして型がばっちり決まらないという時間がかなり多かった印象です。そういった場合は一旦コードを書くことから離れて、ちゃんとRustを理解するという学習に専念することが大事だと思いました。もっと短い時間で実装できるように今後もRustの習得に励んでいきたいです。