ぶていのログでぶログ

思い出したが吉日

Rubyのコードでplain text/JSON/YAMLを整形できるrfコマンドを作った

github.com

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の直接的な機能ではないけど)

細々と飽きずに開発していきたい。

*1:id:hiboma に確認してもらいました!thx!!

*2:まだDockerイメージしかなくてフロント部分がない

*3:正確にはString以外でもto_i/to_fを実装していたらInteger/Floatに直接計算できる

*4:$1,$2,$3...を使いたいところだったがRubyではすでに別の意味を持っているので避けたかった

*5:どのバージョンから使えるかはわからなかった。。

*6:rfがループ処理をしているので入力ごとにat_exitが呼び出されてしまうのであった…

*7:mruby単体でテストするの大変…