ぶていのログでぶログ

思い出したが吉日

rfコマンドの紹介: Rubyでテキスト処理を便利に

この記事はRuby Advent Calendar 2023の21日目の記事です。


Rubyでワンライナーでテキストの処理をしたいと思ったことはありませんか?ありますよね! そういったときに便利に使えるCLIツールのrfを作ったので紹介します。

モチベーション

普段のオペレーションの中で、ファイルの特定の文字列を含む行を出力するにはgrepを使うと思います。 grepより複雑なことをやらせようとしたらawkを使うと思います。 また、テキストファイルの一部分のみを一括して置換したい場合はsedを使うと思います。 JSONやYAMLファイルをフィルタするときはjq/yqを使うと思います。

このようにやりたいことによってさまざまなツールを使いこなしていると思いますが、複数のツールを使うためにはそれぞれのツールの使い方を理解しないといけなくて大変です。 そんな時にふと、これらのことをRubyできたら便利なのでは?っと思いつきました。

Rubyには-a,-n,-pといったワンライナーで処理を書くためのオプションがあるので、最初はこれらを使えばよいと考えました。 実際に各ツールの代わりにRubyを使ってみたのですが、ワンライナーとして書くには回りくどくタイプ数が多くなってしまいました。 例えば、grep同等のことをしようとすると以下の様なコマンドになると思います。

# grep hello相当
$ cat file | ruby -ne 'puts $_ if /hello/'

これは極端な例ですが、grepに比べるとタイプ数が多くてちょっとめんどくさいです。 そこで、各ツールの書きっぷりを踏襲しつつ、Rubyで記述できて、そしてタイプ数が少なくフィルタルールをかけることを目指したrf というツールと作りました。

具体例

ここではrfを使って各ツールの置き換え例を見ていきます。 まずは、grepです。 引数に正規表現リテラルを指定すると同等のことが可能です。 もしくは、-g オプションを指定すると引数を正規表現パターンとして解釈し、grepと同じ動作になります。

# grepの例
$ echo hello | grep hello
hello

# grep同等の操作をするrfのコマンド
$ echo hello | rf /hello/
hello

# または-gオプションを指定します
$ echo hello | rf -g hello
hello

ディレクトリ内のファイルを再帰的に検索したい場合、grepと同様に-Rオプションを指定します。 ただし、今のところバイナリファイルに対しても処理を行うので、出力によってはターミナルの表示がおかしくなります。。 (grepの場合はbinary files matchと表示されてバイナリの出力はされないです)

$ grep -R hello path/to/directory
foo: hello world
bar: hello world

# rfの例
$ rf -R hello path/to/directory
foo: hello world
bar: hello world

次は、awkの例です。 例として以下のようなhostsファイルがあります。

$ cat hostsfile
192.168.0.1 hostA
192.168.0.2 hostB
192.168.0.3 hostC

192.168.0.1に対応するホスト名(2カラム目)を出力したいです。 rfコマンドにおいてはmatchメソッドという便利なメソッドがあります。 引数に指定した正規表現にマッチしたらブロックを実行し評価結果を返します。 _2 は入力行を空白で区切った時の2番目のフィールドです。awkの $2と同じような挙動になります。

$ awk '/192.168.0.1/{print $2}' hostsfile
hostA

# rfの例
$ rf 'match /192.168.0.1/{ _2 }' hostsfile
hostA

# mというエイリアスメソッドを使うとよりタイプ数を減らせる
$ rf 'm /192.168.0.1/{ _2 }' hostsfile
hostA

次は、sedでファイル置き換える例です。 rfでもsedのように-iオプションを使うことでファイルを置換することができます。 以下の例では、先に出てきたhostsfileのhostBをhostDに書き換えています。

$ sed -i 's/hostB/hostD/g' hostsfile
$ cat hostsfile
192.168.0.1 hostA
192.168.0.2 hostD
192.168.0.3 hostC

# rfの例
$ rf -i 'gsub(/hostB/, "hostD")' hostsfile
$ cat hostsfile
192.168.0.1 hostA
192.168.0.2 hostD
192.168.0.3 hostC

上記はシンプルな例でしたが、もう少し複雑な例を考えます。 192.168.0.3に該当するホスト名のhost部分をserverに書き換え、そのホスト名を表示したいです。 sedやawkでやるには少し面倒ですが、rfではシンプルに書けます。

$ sed -n '/192.168.0.3/{s/192.168.0.3 host\(.*\)/server\1/g;p}' hostsfile
serverC
$ awk '/192.168.0.3/{gsub(/host/,"server");print $2}' hostsfile
serverC

# rfの例
$ rf 'm /192.168.0.3 host(.+)/{ "server#{$1}" }' hostsfile
serverC

最後にJSONの例です。 ここではAWSのIPアドレスの範囲が書かれたip-ranges.jsonから、ap-northeast-1リージョンのIPv4アドレスの一覧を出力します。 rfでは-jオプションを指定するとJSONファイルを扱うことができます。 またrfにおいてはHashのキーへのアクセスは、JavaScriptのようにキーをメソッドとしてアクセスすることができます。 rfで書くとjqよりタイプ数が多くなってしまいます…これはなんとかしたいところですが今はあまりよいアイディアはないです。 ですが、jqの文法や関数を覚えなくてもRubyの知識でフィルタがかけるのがメリットだと考えています。

$ jq '.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
-- snip --

# rfの例
$ rf -j '_.prefixes.select{|p| p.region == "ap-northeast-1"}.map(&:ip_prefix).join("\n")' ip-ranges.json

ここでは紹介しませんでしたが、YAMLも-yオプションを指定すればJSONと同じようにフィルタを書けます。

インストール方法

ここまででrfの魅力は伝わったと思います。 実際にrfを使ってみたくなったのではないでしょうか? ここではインストール方法について説明します。

rfは現在、Linux x86_64/ARM64、Windows x86_64、MacOS x86_64/ARM64向けのバイナリを提供しています。 バイナリはGitHubリポジトリのreleaseページからダウンロードできます。 なお、MacOS向けのバイナリはCIでは動作確認していますが実機では確認していません。

github.com

また、asdf/rtxやHomeBrew向けのプラグインも用意しているので、それらを使ってインストールすることもできます。

github.com

github.com

rfの仕様

ここからはrfの仕様について解説します。

フィルタ

フィルタはrfの中核機能です。 フィルタの役割は大きく2つあり、まず1つ目は入力されたデータをパースしRubyのオブジェクトに変換し適切に分割することです。 この分割されたデータの塊をrfではレコードと呼んでいます。 レコードについては後述します。

2つ目は処理されたRubyのオブジェクトを入力された形式に整形することです。 これはどういうことかというと、入力がJSONであれば出力もJSONにしたほうが使い勝手いいと考えています。 そのため、rfでは処理されたRubyのオブジェクトをto_sしてputsしているのではなく、JSONであればto_jsonしてputsしています。

レコードとフィールド

レコードとはrfにおいて処理されるデータの単位です。 具体的にはテキストファイルであれば1行ごと、JSON/YAMLであれば配列の要素ごとです(入力が配列ではない場合、要素が1の配列として処理されます)。 レコードはさらにフィールドという単位で分割されます。 分割する単位はレコードのデータ型によって変わります。 Stringであれば半角スペースまたはタブで分割されます(-F オプションで変更可能)。 Arrayの場合は要素ごとに分割され、それ以外の場合は1つのフィールドとして処理されます。

コマンドと評価結果

rfにおける引数で指定するRubyのコード、rf /hello/であれば /hello/ の部分をrfではコマンドと呼んでいます。 コマンドはRubyのスクリプトして解釈され、レコードごとに実行されます。 これは以下のRubyコードと同じです。

# recordsに各レコードの値が格納されている
records.each do |record|
  eval(command) # コマンドを実行
end

コマンドを実行し、最後に評価された値で出力する内容が変わります。 大きく分けると4つです。

評価値 出力
true レコードの内容を出力(※1)
false, nil 何も出力しない(※1)
Regexp レコードを引数としてmatch?を呼び出しtrueならレコードの内容を出力し、falseなら何も出力しない
その他 評価値をフィルタに通して変換した結果を出力

※1 JSON/YAMLにおいてはオプションでJSON/YAMLのリテラルとして出力できる

-fオプションを指定することで引数ではなく、ファイルからコマンドを読み込むこともできます。

特殊変数

rfでは便利な特殊変数をいくつか定義しています。 以下にその一覧を列挙します。

変数名 内容
record _ $_ _0 入力されたレコード
fileds $F 各フィールドを格納したArray
_1 _2 ... 番号に対応したフィールドの値(_1なら$F[1]と等価)
$. NR 読み込んだレコードの数

定義済みメソッド

コマンドで使える便利なメソッドです。

メソッド名 内容
gsub / gsub! _.gsub / _.gsub! と等価
sub / sub! _.sub / _.sub! と等価
tr / tr! _.tr / _.tr! と等価
match / match? 後述
at_exit 後述

match / match? メソッド

gsubなどのメソッドはレコードのメソッドを呼び出しているだけでしたが、match / match?は違います。 具体例の項でも少し触れましたが、ここではもう少し詳しく説明します。 matchメソッドのシグネチャは以下の通りです。

match(condition) -> Object
match(condition) {|fields| ... } -> Object

condition にRegexpが渡された場合は、そのRegexpオブジェクトのmatchメソッドにレコードを渡して呼び出し評価します。 Stringが渡された場合は、正規表現パターンと仮定しRegexp.newにその文字列を渡し、Regexpが渡されたときと同様の処理を行います。 それ以外の型が渡された場合は、trueかfalseか判定します。

ブロックを渡さずに呼び出し、conditionの評価結果がtrueだった場合はレコードをそのまま返し、falseだった場合はnilを返します。 ブロックが渡されていて、conditionの評価結果がtrueだった場合はブロックを評価し評価結果を返し、falseだった場合はnilを返します。

使用例

match NR > 10 # 11行目以降はレコードを返す
match /hello/ # helloにマッチするレコードを返す
match "hello" # helloにマッチするレコードを返す
match /foo/ { "bar" } # fooにマッチするレコードの場合barを返す
match _2 =~ /foo/ { _3 } # 2番目のフィールドがfooにマッチするレコードの3番目のフィールドを返す

match?はmatchとほぼ同じですが、返却値がtrueかfalseになります。

at_exitメソッド

rfの実行終了時に何かを実行したいといったときに使えるのが at_exit です。 このメソッドは引数を取らず、ブロックのみを取ります。 例えば、1番目のフィールドをすべて足し合わせて、合計した値を出力したいといった場合に使います。

$ cat file
102
172
51
$ rf -q 's+=_; at_exit { puts s }' file
325

なお、CRubyにおいては BEGINブロック、ENDブロックがありますが、mrubyにおいてはサポートされていません*1

言語拡張

rfではワンライナーを書きやすくするために独自にRubyの言語拡張を行っています。

StringとInteger/Floatを直接計算、比較できる

CRubyだとエラーになってしまいますが、rfではエラーになりません。

# CRubyではTypeError
# 1 + "1"

# rfではエラーにならない
1 + "1"
#=> 2

なぜこのようにしているかというと、テキストを処理するときには各レコード、各フィールドはすべてStringになっています。 しかし、例えば特定のフィールドが数値になっていて、それをすべて足し合わせたいといったときに直接計算できないのは不便だからです。 先のat_exitメソッドの項で直接s変数に加算出来たのはこの拡張のおかげです(この拡張がない場合は、s+=_.to_iとしないといけません)。

Hashのキーをメソッド名として扱える

これは具体例の項でも書きましたが、便利なために拡張しています。 ただしRubyの仕様として、- などの記号はメソッド名に使用できないためそれらを含むキーにアクセスする場合は _["キー名"]とする必要があります。

未定義の変数にInteger/Float/String、Arrayへの要素追加ができる

CRubyでは以下のコードはエラーになります。

unknown += 1
#=> undefined method `+' for nil:NilClass (NoMethodError)

unknown += "foo"
#=> undefined method `+' for nil:NilClass (NoMethodError)

unknown <<= "foo"
#=> undefined method `<<' for nil:NilClass (NoMethodError)

rfではこれらはエラーになりません。 それぞれのunknown変数の中身は 1foo["foo"] になります。 この機能がない場合は ||= を使って一度変数を定義しないといけないので、タイプ数が増えてしまいます。 直接加算できるとタイプ数が減るというメリットがあります。 なお、今のところ +=<<=しか使えません。 それ以外の演算子については、ユースケースが思いつかなったので今のところ実装していません。

余談ですが、この機能を実装するまで +=<<=というメソッドがある、オーバーライドできると思っていたのですが実際にはありませんでした。 内部的にはどうも a+=ba=a+bとして処理されているため、+メソッドの結果をaに格納するという挙動になっているようです。 また、Hashにデータを格納する []=ですが、これはNilClassに実装できませんでした。 Rubyにおいてはselfは変更禁止になっていて、[]=メソッドの中でnilをHashに置き換えることはできないのです。

class NillClass
  def []=(key, value)
    self = {key => value}
    #=> Can't change the value of self
  end
end

おわりに

私の作っているrfコマンドの紹介をしました。 作った当初はここまでの機能はなかったのですが、日常的に使っていく中で不便だと思った部分を直していたら色々できるようになっていました。 まだまだ機能的に足らない部分がありますが、ぜひ気になったら使っていただけると嬉しいです。 また、使っていく上でなにか不具合があったり使いづらいところがあったらX(Twitter)GitHubのissueやdiscussionで報告してもらえると嬉しいです。

*1:パーサーに手を入れればいいはずなのでそのうちrfで使えるようにしたい