2023/05/15追記: リポジトリのリンクを追加(thx: id:k1low !!)
モチベーション
私はcliでテキストを編集するときはawk/sedを使い、JSONはjq、YAMLはyqを使っている。
それぞれ単純な処理ならあまり苦労せず使えるのだが、複雑な処理をしたい場合スクリプトを組んだりしないといけない。
そういったときにRubyでガっと書いてしまいたいのだけど、ruby -ane '〜'
だとそれはそれで書くコードが長くなってめんどくさい。
そこで、コードゴルフしやすいようなメソッドなどを追加してコードが長くなるのを防ぎつつ、Rubyのコードでフィルタ処理をかけるようなcliツールがあると便利そう!というのがモチベーション。 また、mrubyで何かしらのcliツールを作りたいというのもモチベーションの1つ。
インストール方法
今回作成したrfはzigを使ってクロスコンパイルしているので、linux i386、x86_64以外にもArm、MacOSでも動作する*1。 Windows環境でも使えるようにクロスコンパイルしたかったが、いい感じにLinux上でWindows向けにmrubyをクロスコンパイルできなかったので一旦サポートを外した。
mrubyのコードをクロスコンパイルするために環境を作るのが少々手間なのだが、それらを便利にするために新たにmruby-buildというツールを作った*2のであとでブログに書くはず…。
使い方
rfでは現状plain text、JSON、YAMLファイルを扱うことができる。
デフォルトでは入力をplain textだと仮定して処理を行う、もし入力がJSONの場合には -j
、YAMLの場合は -y
を指定する。
オプションの後にフィルタ処理を書いたRubyコードを指定する。
そして、その後には読み込むファイルのパスを指定する。
パスの指定がない場合は、ファイルの代わりに標準入力からデータを読み込む。
以下のような感じでrfにオプションを指定する。
rf [-j|-y] 'Rubyのコード' [ファイルのパス]...
読み込まれたファイルは、plain textなら1行ずつ、JSON/YAMLの場合は1オブジェクトずつRubyのコードを実行する。 具体的には以下のようなRubyのコードを実行しているような感じだ。
# plain textの場合 File.open("テキストファイルのパス") do |file| file.each do |input| eval("Rubyのコード") end end # JSONの場合(YAMLの場合もほぼ同じなので省略) [JSON.load_file("JSONファイルのパス")].flatten(1).each do |input| eval("Rubyのコード") end
使い方: 既存ツールとの比較
以下に、実例と既存ツールとの対比を載せる。
テキストファイルの一部の文字列を置き換える(sed)
sed
$ echo "hello,world" | sed 's/hello/world/g' world,world
rf
$ echo "hello,world" | rf 'gsub(/hello/, "world")' world,world
- gsubなど一部のメソッドは、レシーバを指定しなくても入力に対して適用できる
テキストファイルの特定のパターンが含まれる行のみ出力する
grep
$ cat test_grep.txt hello world other world こんにちは hello $ grep hello test_grep.txt hello world こんにちは hello
rf
$ rf '/hello/' test_grep.txt hello world こんにちは hello # UTF-8な文字列もOK $ rf '/こんにちは/' test_grep.txt こんにちは hello
- Regexpクラスのオブジェクトを返却すると、入力に対してマッチした行のみ出力される
カンマ区切りのテキストファイルの2カラム目を全て足し合わせて結果を出力する
awk
$ cat test_sum.txt 1,2,3, 4,5,6, 7,8,9, $ awk -F, '{s+=$2}END{print s}' test_sum.txt 15
rf
$ rf -F, -n 's||=0;s+=_2; at_exit{ puts s }' test_sum.txt 15
- plain textのみ
-F
オプションで区切り文字を指定できる(デフォルトは空白文字) -n
オプションを指定すると自動出力が抑制される_2
は入力をsplitした結果の2番目を表している(rfの独自拡張)_2
はStringでsはIntegerで通常ではエラーになるが内部的にto_iしてから計算するようになっている(rfの独自拡張)at_exit
メソッドでrf終了時に実行するスクリプトを指定できる- 本当はawkのようにENDブロックにしたかったがmrubyの場合、パーサーレイヤで予約語になっていてnot supportedエラーになってしまう
at_exit
に指定できるブロックは最初に呼び出した1つのみ (rfの独自拡張)
JSONファイルの特定のキーだけ出力する
AWKのIPアドレス範囲から東京リージョン(ap-northeast-1)のIPアドレス範囲を抽出。
jq
❯ jq -r '.prefixes[] | select(.region == "ap-northeast-1") | .ip_prefix' ip-ranges.json 13.34.69.64/27 13.34.62.160/27 15.221.34.0/24 52.144.229.64/26 13.248.70.0/24 52.144.225.128/26 43.206.0.0/15 13.34.53.192/27 -- snip --
rf
$ rf -j -n 'puts _.prefixes.select{_1.region=="ap-northeast-1"}.map(&:ip_prefix)' ip-ranges.json 13.34.69.64/27 13.34.62.160/27 15.221.34.0/24 52.144.229.64/26 13.248.70.0/24 52.144.225.128/26 43.206.0.0/15 13.34.53.192/27 54.248.0.0/15 13.34.15.32/27 -- snip --
_
は入力を示している- Hashの要素はメソッドのようにアクセスができる(rfの独自拡張)
- YAMLでも同様な記述でかける
rfの独自拡張
先述したとおりrfではmrubyの言語仕様に独自の拡張を追加してコードを短くかけるようにしている。 この独自拡張は今のところデフォルトで有効になっているが、実行時のオプションで無効化できるようにしたいと思っている。 以下に独自拡張について説明する。
Integer/FloatとStringを直接計算できる
通常、Integer/FloatとStringは直接計算できないので、String#to_iやString#to_fで型を変換してから計算する必要がある。 rfに置いては入力が常にStringとなるため、カンマ区切りのテキストファイルの2カラム目を全て足し合わせて結果を出力するの項で説明したような集計をしようとするととても面倒になる。 そこで、rfでは独自拡張としてInteger/FloatにStringを直接計算する場合、to_i/to_fするようにしている*3。
# CRubyではエラー $ ruby -e 'puts 1 + "1"' -e:1:in `+': String can't be coerced into Integer (TypeError) puts 1 + "1" ^^^ from -e:1:in `<main>' # rfではエラーにはならない $ echo "" | ./build/bin/rf 'puts 1 + "1"' 2
_1, _2, _3...でN番目の要素にアクセスできる
awkでは$1, $2, $3...で入力の各フィールドにアクセスできる。 rfに置いても同様のことがしたく_1, _2, _3…でそれと同等な事ができるようにした*4。 ちなみに、_1, _2, _3 ... はrfがオリジナルではなくNumbered parameterというやつで、ブロックの引数の1番目、2番目...にアクセスできる特殊な変数である(私は最近知った)。 CRubyでは2.7から使えてmubyでも使える*5。
# Numbered parameterの使用例 (1..10).map { _1 * 2 } #=> [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
CRubyでは_1, _2, _3...は予約語になっていて定義ができないが、method_missing
を使えば実現できることがわかった。
def _1; puts "_1"; end #=> _1 is reserved for numbered parameter (SyntaxError) def method_missing(name, *) puts "_1" if name == :_1 end _1 #=> _1
mrubyではまだ予約語にはなっていなそうだったが、CRuby同様に将来的に予約語になる可能性を考えてmethod_missingで実装している。
at_exitメソッドでスクリプト終了時に実行する処理を指定できる
awkにおけるENDブロックと同等の機能が、rfに置いてはat_exitになっている。 CRubyにおいてはENDブロックが使えるのだが、mrubyにおいてはnot supportedエラーになってしまう(おそらく組み込み環境では終了の定義が難しいからだろう)。
END { puts "exit" } #=> line 1: END not supported
Kenerl.#at_exitも定義されていないようだったので、rfでは独自に追加している。 なお、CRubyにおけるat_exitは複数個のブロックを登録することができるが、rfにおいては今のところ最初に呼び出されたブロックのみの登録となっている*6。
Hashの要素にメソッドのようにアクセスできる
jqやyqではハッシュの要素に直接アクセスできる。
$ echo '{"foo":"bar"}' | jq -r '.foo' bar
同様のことをrfでもしたかったのでmethod_missingを駆使して実装した。
なお、Rubyの言語仕様的に空白を含むメソッド名を定義することはできないので、そういう場合はHash#[]
などを使う必要がある。
$ echo '{"foo bar":"bar"}' | rf -j '_."foo bar"' Error: line 1: syntax error, unexpected string literal $ echo '{"foo bar":"bar"}' | rf -j '_["foo bar"]' "bar"
なお、rfにおいてはUTF-8なフィールドにメソッドとしてアクセスできる。
$ echo '{"ほげ":"bar"}' | rf -j '_.ほげ' "bar"
今後の展望
rfはまだまだ機能不足で既存のツールを置き換えるには至っていない。 ドッグフーディングしていてもそう感じるので、今後いろいろな機能をつけていきたい。 今のところ考えている追加したい機能としては以下の通り。
- CSV/TSVのパース機能
- 出力の自動色付け機能
- テストコードの充実化*7
- asdf/rtxでのインストール対応(rfの直接的な機能ではないけど)
細々と飽きずに開発していきたい。