choのフィールド範囲記法「$3..」をどう設計したか
choのフィールド範囲記法($3..)を、列の再結合ではなく元の行の切り出しとして実装した話
前回は、リストを持たないchoで文字列を分割するために作った特殊形式 s/with について書きました。今回は、$3.. のように「あるフィールドから後ろをまとめて取り出す」記法について書きます。
あるフィールドから行末まで取りたい
テキスト処理をしていると、「3列目から後ろを全部取り出したい」という場面がよくあります。
たとえば、ps aux の出力からコマンド部分を取り出したい場合です。ps aux では、11列目の COMMAND から後ろが実行中のコマンドラインになります。
$ ps aux | head -n 4
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 22532 13356 ? Ss 10:20 0:01 /sbin/init
alice 1832 0.2 1.4 1337520 116220 ? Sl 10:24 0:12 emacs --no-window-system notes.txt
alice 2419 0.0 0.0 7720 3420 pts/0 S+ 10:31 0:00 sleep 100コマンドラインの引数の数はプロセスによって違うので、「11列目から最後の列まで」を取りたいのですが、終わりの列番号はあらかじめ決められません。
awkなら、11列目から NF までループで出力します。
{
for (i = 11; i <= NF; i++) {
printf "%s%s", $i, (i < NF ? OFS : ORS)
}
}しかしchoにはそもそもループがありません。
そこで「ここから後ろを全部取る」という操作を手軽に書けるように、フィールド参照の記法として $11.. を作りました。
$ ps aux | tail -n +2 | cho '(p $11..)' | head -n 3
/sbin/init
emacs --no-window-system notes.txt
sleep 100分けたフィールドを結合し直さない
最初に欲しかったのは、単に $11 から NF までを短く書く記法でした。ただ、いざ実装を考えたときに悩んだのが「フィールド間の区切りをどう扱うか」です。
choの p(print)は、引数に渡された複数の値を空白1つで区切って出力します。そのため、行の各列を順番に出力すると、元の行にあった複数の空白は1つに縮まってしまいます。
$ printf 'INFO request completed in 20 ms\n' |
cho '(p $2 $3 $4 $5 $6)'
request completed in 20 ms一方、$2.. を使った場合は元の空白がそのまま残ります。
$ printf 'INFO request completed in 20 ms\n' |
cho '(p $2..)'
request completed in 20 msこれは、分割したフィールドを集めてスペースで繋ぎ直しているのではなく、「2列目が始まる位置から行末までを、入力行の文字列からそのまま切り出している」からです。
空白が1つに潰れるくらいなら好みの問題かもしれませんが、-F で区切りに正規表現を指定した場合は、結合し直す方法自体が決められなくなります。
$ printf 'alpha,beta:gamma\n' |
cho -F '[,:]' '(p $2..)'
beta:gammaこの例では、カンマ(,)とコロン(:)の両方がフィールドの区切りになっています。一度バラバラに分割したフィールドを後から結合しようとしても、カンマとコロンのどちらで繋げばいいのかわかりません。元の行からそのまま切り出す仕組みにしておけば、実際に使われていたコロンが自然に残ります。
$3.. から範囲の記法へ
最初は $3- や $1-2 のような記法(cut コマンドの -f 3- のようなイメージ)も考えていました。ただ、- は引き算や負の数と見分けがつきにくく、S式の中で少し浮いて見えます。そこで、RustやRubyでおなじみの .. を使って $3.. と書くことにしました。
また、行末までの範囲だけでなく、先頭からの範囲や中間の範囲も取れるようにしています。
$ printf 'one two three four\n' | cho '(p $3..)'
three four
$ printf 'one two three four\n' | cho '(p $..2)'
one two
$ printf 'one two three four\n' | cho '(p $2..3)'
two three$3..:3列目から行末まで$..2:1列目から2列目まで$2..3:2列目から3列目まで
範囲指定は、区切り文字も含んだ1つの文字列を返します。フィールド間の空白の幅もそのまま残ります。
各フィールドの位置を記録する
この動作を実現するには、分割後の文字列だけでなく、各フィールドが元の行のどこにあったかを覚えておく必要があります。
choの内部では、入力行を単なる文字列の配列に分解するのではなく、元の行文字列と一緒に、各フィールドの開始・終了位置(バイト位置)のベクタを保持しています。実際の構造体から関係する部分を抜き出すと、次のようになっています。
struct Record<'line> {
line: &'line str,
field_spans: Vec<(usize, usize)>,
// ...
}先ほどのログを例にすると、各フィールドの位置関係は次のようになります。
INFO request completed in 20 ms
^^^^ ^^^^^^^ ^^^^^^^^^ ^^ ^^ ^^
$1 $2 $3 $4 $5 $6field_spans には、各フィールドの開始位置と終了位置が入ります。
$1 ( 0, 4) -> INFO
$2 ( 5, 12) -> request
$3 (16, 25) -> completed
$4 (26, 28) -> in
$5 (29, 31) -> 20
$6 (32, 34) -> msたとえば $2..3 を取り出すときは、2列目の開始位置(5)から3列目の終了位置(25)までを元の行からスライスします。その結果が request completed です。
$3.. であれば、3列目の開始位置(16)から行末までを切り出します。
フィールドを分割した時点で文字列として切り離してしまうと、元の位置や区切り文字の情報は失われてしまいます。行全体の文字列を残したまま位置だけを記録しておくことで、分割時に各フィールドの文字列を個別に確保せず、素直に実装できました。
CSVではあえて使えないようにした
この範囲指定ですが、CSVモード(--csv)では使えないようにしています。
$ printf 'name,city\nAlice,"Tokyo, Japan"\n' |
cho --csv --skip-header '(p $2..)'
cho: field ranges are not supported with --csvCSVでは、入力行に書かれた文字と、パースされたフィールドの値が異なることがあるためです。
たとえば上の例の2列目は、元の行では引用符を含んだ "Tokyo, Japan" ですが、フィールドの値としては Tokyo, Japan になってほしいはずです。
元の文字列から切り出すと引用符が残り、パース後の値を繋ぎ直すと元の表記を保持できません。
そのため、現在はCSVでの範囲指定をエラーにしています。
まとめ
「ここから後ろを全部取りたい」という操作のために、フィールドを結合し直すのではなく「元の行から切り出す」という形に落ち着きました。
取り出したい範囲を短く書けて、元の空白や区切り文字も残せるようになりました。