幅指定フォーマットの幅を推測する

Posted on:

昔からUnix系のコマンドやCLIでは幅を揃えて出力することがあります。 ls, ps, df, 等々…

このような出力はprintfのフォーマット指定により出力できます。 このフォーマット指定がわかれば、scanfにより読み取りが可能ですが、「出力内容だけ」から列を認識して読み取ることは簡単ではありません。

複数のスペースを区切り文字と見なす場合、出力を列に分割することは可能ですが、ヘッダーや値により不要に分割されてしまうことがあります。これを人間が読み取るのと同じように列の幅を推測するライブラリ/ツールを作りました。

guesswidth

Goで作ってます。

完璧に読み取るのは難しいですが、スペース区切りの正規表現よりは、より良い結果が得られると思います。 ​ 読み取れるのは、主にヘッダー行があり、ヘッダーの列の幅が、その後に続く行の値の幅を表している場合です。

psdfが当てはまります。lsはヘッダー行がないため、列の区切りは曖昧です。

psでは"PID"、“TTY”、“TIME”、“CMD"が下の値を表しているため、4つに分けることができます。

ps ps

この例では複数スペースでも分割できますが、ヘッダーや値にスペースが含まれると正しく分けることができません。guesswidthではそのような形式にも対応します。

guesswidthの使い方はパイプ|で渡すだけです。デフォルトでは|を区切り文字として挿入します。

ps
    PID TTY          TIME CMD 
1145448 pts/2    00:00:00 zsh
1158532 pts/2    00:00:00 ps

ps |guesswidth
    PID| TTY     |     TIME|CMD 
1145448| pts/2   | 00:00:00|zsh
1158532| pts/2   | 00:00:00|ps

「,」を区切り文字を使用して CSV として区切ることもできます。CSVでは余分なスペースを取り除きます。

ps |guesswidth csv
PID,TTY,TIME,CMD
1145448,pts/2,00:00:00,zsh
1158532,pts/2,00:00:00,ps

psdfdocker psやその他多くの出力に対応しています。lsはヘッダーがないため1行目の内容を基準に分割されます。(最初の行は合計表示なので基準から外す必要があります。)

ls -l|guesswidth --header 2
合計 7900||||||||
-rw-r--r--|  1| noborus| noborus|    1078| Mar| 14| 05:48|LICENSE
-rw-r--r--|  1| noborus| noborus|     526| Mar| 16| 05:23|Makefile
-rw-r--r--|  1| noborus| noborus|    1751| Mar| 21| 16:49|README.md

モダンなlsの代替であるexaではヘッダー行を追加することができるのでguesswidth向きと言えます。

$ exa -lh|guesswidth
Permissions| Size| User   | Date Modified|Name
drwxr-xr-x |    -| noborus| 14 Mar 16:30 |cmd
drwxr-xr-x |    -| noborus| 22 Mar 09:02 |dist
drwxr-xr-x |    -| noborus| 19 Mar 12:58 |docs
.rw-r--r-- | 3.0k| noborus| 21 Mar 16:39 |example_test.go
.rw-r--r-- |  285| noborus|  4 Apr 14:22 |go.mod
.rw-r--r-- | 1.2k| noborus|  4 Apr 14:22 |go.sum

どうやっているのか?

分割位置推測

分割方法は難しくありませんが、ちょっとめんどくさいので解説しておきます。

まず基準行(ヘッダー行)を決めます。この基準行がないと値の中には同じ位置にスペースがくるフォーマットがあるので、そこで(lsの例のように)誤って分割されてしまいます。

その基準行から区切り位置の候補を作ります。簡単にスペースであれば1(候補)、そうでなければ0(区切り位置とならない)に変換します。 簡単なのでpsを例にとります。

    PID TTY          TIME CMD
11110001000111111111100001000

次に値も同様にスペースが候補になりますが、ヘッダーで候補から外れた位置は除外してカウントアップします。

値1行目

1145448 pts/2    00:00:00 zsh
11110002000112222111100002000

値2行目

1158532 pts/2    00:00:00 ps
11110003000113333111100003000

区切り位置を探す特性上最初と最後のスペースは省きます。そして0(区切りではない)以外の数値の連続の中で一番大きい数値が区切り位置候補として可能性が高いことを示しています。 さらに大きい数字が同点で連続している場合は、判定が出来ないですが、printfの特性上左側にははみ出さず右側にはみ出すため、一番右側を区切り位置と推測します。

区切り位置だけの数字を残すと以下のようになります。

11110003000113333111100003000
                ↓
       3   1133331111    3
                ↓
       3        3        3

ヘッダー行だけでなく、値の行をなるべく多く読み取ってから判別すると精度が高くなります。

分割工程

分割位置が推測できれば、それで終わりかというと実はそうではありません。先に書いたように幅を決めて出力するフォーマットでありながら、その幅に収まらずはみ出すことがよくあります。

例えば、psでオプションを使用した場合は、より多くの情報を表示できますが、はみ出す項目が多くあります。

$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0 169004 11464 ?        Ss   Mar27   1:04 /sbin/init sp
root           2  0.0  0.0      0     0 ?        S    Mar27   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   Mar27   0:00 [rcu_gp]

..と縦位置がきれいに表示されていると思っていると、

noborus   619043  0.0  0.0  38992 28968 pts/4    Ss+  Apr04   0:02 zsh
noborus  1061523  2.2  1.8 34556112 591016 ?     SLl  Apr06  61:54 /opt/google/chrome/chrome

VSZRSSがchromeプロセスのようにメモリを多く使用するプロセスがはみ出しています。 そのため、区切り位置がスペースでなかったら、多くは右にずらしてスペースを探して区切り位置を補正します(実際には区切り位置の推測が間違って右にずれている可能性を考慮して左にずらして収まるかも試します)。

その他考慮

ヘッダー行が基準ではありますが、ヘッダー行の見出しにスペースが使われることがあります。 例えばdfでは最後のMounted onは一つの列です。

Filesystem     1K-blocks      Used Available Use% Mounted on

そのためヘッダー行の位置だけスペースがあって、その下の値にはスペースがない場合は数値の配列にした場合にカウントアップされずに1のままになるため、区切り位置はしきい値を2以上にすることで防止しています。

また、幅位置は、2幅取る文字(いわゆる日本語等の全角文字)を考慮しています。 Byte数 != 文字数 != rune数 != 幅位置です。 アルファベットと数字のみであれば全部同じですが、日本語や絵文字などが含まれることを考慮すると一気にややこしくなります。

ライブラリ使用

The guesswidthは元々別のツールで使用するために作ったものを独立させたものです。

拙作のtrdsqlにも組み込んだので、以下のように-iwidthを使用するといろんなフォーマットで出力できます。

ps aux|trdsql -iwidth -ojson "SELECT * FROM - WHERE \"COMMAND\" = 'ps aux'"
[
  {
    "USER": "noborus",
    "PID": "1166430",
    "%CPU": "0.0",
    "%MEM": "0.0",
    "VSZ": "13716",
    "RSS": "3520",
    "TTY": "pts/2",
    "STAT": "R+",
    "START": "17:56",
    "TIME": "0:00",
    "COMMAND": "ps aux"
  }
]

また、実はこちらが本命ですが、拙作のページャーov(v0.30.0以降)にも組み込んでいて、オプションを組み合わせることで以下のような表示ができます。

ps aux| ov

ps aux|ov ps aux|ov