← 一覧へ戻る

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 $6

field_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 --csv

CSVでは、入力行に書かれた文字と、パースされたフィールドの値が異なることがあるためです。

たとえば上の例の2列目は、元の行では引用符を含んだ "Tokyo, Japan" ですが、フィールドの値としては Tokyo, Japan になってほしいはずです。

元の文字列から切り出すと引用符が残り、パース後の値を繋ぎ直すと元の表記を保持できません。

そのため、現在はCSVでの範囲指定をエラーにしています。

まとめ

「ここから後ろを全部取りたい」という操作のために、フィールドを結合し直すのではなく「元の行から切り出す」という形に落ち着きました。

取り出したい範囲を短く書けて、元の空白や区切り文字も残せるようになりました。