Windowsでシェル芸を強行するデモ

# TL;DR

コマンドプロンプトでは快適にシェル芸できなかったので
wsh で動く unix っぽい名前のコマンドをチマチマ作っていた。

その結果、なんかそれっぽく動くようになったので、
最近仕事で見たデータなどをネタにデモなどしてみようかと思った。

この記事で使用しているコマンド群は、以下の URL から取得できます。
https://github.com/asterisk9101/wincmd

# はじまりはじまり

突然ですが以下のようなファイルがあります。

```bat
C:\> cat text01.log
colA,    colB
----,    ----
hoge,    1000
fuga,    1200
piyo,    3041

// comment 

C:\>
```

text01.log は空白区切りで２列を持つテーブル状のテキストファイルです。
最終行には `//` から始まる日本語のコメントが入力されています[^gyoumu]。

[^gyoumu]: 業務っぽいですね

textXX.log は連番になっており、01 から 30 まで 30 ファイル前後あるものとします。
また、このテーブルのレコード数は不定とします。

ここで、ファイルごとの colB を合計し、最大の colB 合計を持つファイルを取り出したいと思います。
突然過ぎて目的が呑み込めないかもしれませんが、とにかくそういう状況があったとします。

# Step1 不要な行を削除する

とりあえず 1 つのファイルの colB 合計を求めようとしてみます。

text01.log にはいくつか不要な情報が含まれています。
まず不要な行を削除しましょう。不要な行は、カンマを含まない行とヘッダになっている 1,2 行目です。

```bat
C:\> cat text01.log | findstr ","
colA,    colB
----,    ----
hoge,    1000
fuga,    1200
piyo,    3041

C:\> :: 行末にあった空白行とコメントを削除できました
C:\> 
C:\> 
C:\> cat text01.log | findstr "," | sed /e "1d;2d"
hoge,    1000
fuga,    1200
piyo,    3041

C:\> :: 1 行目と 2 行目も削除できました
C:\> 
```

`findstr` は、Unix でいうところの `grep` です。`","` を含む行だけ取り出しています。
続いて `sed` です。`sed` には様々な機能があり、引数に与えた命令によって動作が変わります。ここでは `d` 命令を使って 1 行目と 2 行目を削除しています。

この記事では、このようにコマンドを数珠つなぎにして処理していく方法で、目的の達成を試みます。いわゆるシェル芸です。

# Step2 2列目だけ取り出す

`colB` の合計を求めるのに `colA` は不要です。2 列目だけを取り出しましょう。
列を取り出すには `cut` を使います。ただし、`cut` は区切り文字を 1 文字しか指定できないので、前処理を挟みます。

```bat
C:\> cat text01.log | findstr "," | sed /e "1d;2d" | tr /d " "
hoge,1000
fuga,1200
piyo,3041

C:\> :: 空白が削除されました。
```

`tr` は文字を置換するコマンドです。ここでは `tr` に `/d` オプションを指定しているので、`" "` は置換されずに単に削除されました。
続いて `cut` を使って 2 列目だけを取り出します。

```bat
C:\> cat text01.log | findstr "," | sed /e "1d;2d" | tr /d " " | cut /d "," /f 2
1000
1200
3041

C:\> :: 2 列目だけ取り出せました。
```

`cut` の `/d` は区切り文字を指定するオプションで、ここではカンマを指定しています。続く `/f` オプションで 2 番目のフィールド（列）を選択しています。

元のテキストから欲しいデータだけ取り出すことができました。次は取り出したデータを使って計算していきます。

# Step3 合計を計算する

計算するには `eval` を使います[^eval]。

[^eval]: Unix の eval とは全く違った動きをします。どちらかというと `expr` です。リネームするかも。

`eval` を使うには `eval` が受け付けられるようにな形（式）を作らなければいけません。
再び `sed` を使用します。

```bat
C:\> cat text01.log | findstr "," | sed /e "1d;2d" | tr /d " " | cut /d "," /f 2 | sed /e "i+"
+
1000
+
1200
+
3041

C:\> :: 各行に + を挿入しました
```

`sed` の `i` 命令は、各入力行の前に別の行を挿入する命令です。ここでは `+` を挿入しています。

```bat
C:\> cat text01.log | findstr "," | sed /e "1d;2d" | tr /d " " | cut /d "," /f 2 | sed /e "i+" | xargs
+ 1000 + 1200 + 3041

C:\> :: 一行に集約された
```

`xargs` は入力を 1 行に集約するコマンドです。各行は空白区切りで一行に集約されています。

`xargs` はその名の通り、集約した入力を別のコマンドの引数(args)として、そのコマンドを実行することができます。以下のように使います。

```bat
C:\> cat text01.log | findstr "," | sed /e "1d;2d" | tr /d " " | cut /d "," /f 2 | sed /e "i+" | xargs eval
5241

C:\> :: xargs によって eval "+ 1000 + 1200 + 3041" が実行された
```

`eval` は数式を評価するコマンドで、式 `"+ 1000 + 1200 + 3041"` の計算結果が出力されています。

やっと colB の合計を求めることができました。ここまでで使用しているコマンドは 7 つです[^sed]。
欲しい情報は最大のcolB合計を持つファイルのファイル名ですので、ファイル名とセットで colB 合計を表示する方法を考えてみましょう。

[^sed]: sed は反則ですかそうですか

# Step4 ファイル名とセットで表示する

これは少し難しい話です。一連のコマンドでは実現できないような気がします。
2 つのコマンドの結果を並べて出力できると良い気がしますのでやってみましょう。

2 つのコマンドを一度に実行するには `&` を使います。

```bat
C:\> cat text01.log | findstr "," | sed /e "1d;2d" | tr /d " " | cut /d "," /f 2 | sed /e "i+" | xargs eval | tr /d "\r\n" & puts ,text01.log
5241,text01.log

C:\> :: カンマ区切りでファイル名と colB 合計が表示されました
```

`puts` は引数をそのまま出力するコマンドです。`puts` の結果と `eval` の結果を並べて表示するために `eval` の後ろで `tr` を使って改行を削除しています。

準備が整いました。次は、対象となる全てのファイルに対して、これまで組み立ててきたコマンドを実行します。

# Step5 複数のファイルに対してコマンドを実行する

このステップでは、これまでのステップと違って、複数の値に対してコマンドを実行する方法を考えます[^xargs]。
これまで組み上げてきたコマンドよりも上位の話ですね。

[^xargs]: 理解不足のせいか `xargs` では実行できませんでした。

なにはともあれ対象のファイルの一覧を取得します。

```bat
C:\> ls
text01.log
text02.log
text03.log
text04.log
...
text30.log

C:> :: 30 個のファイルの一覧が表示されます。
```

`ls` コマンドは、作業フォルダにあるファイルの一覧を表示するコマンドです[^dir]。このコマンドの出力を、対象ファイルのリストとして使いたいと思います。

[^dir]: 実体はただの `dir /b` コマンドです。

```bat
C:\> for /f "usebackq" %i in (`ls`) do @( echo %i )
text01.log
text02.log
text03.log
text04.log
...
text30.log

C:\> :: ls と同じ結果が表示されます。
```

`for` コマンドは `do` に続くコマンドを繰り返し実行するコマンドです[^for]。`%i` には `ls` の結果が一行ずつ代入されます。そのためカッコの中身に `echo %i` とだけ書いておくと、単純に `ls` を実行した場合と同じ結果が得られます。

> 作業フォルダに何種類かのファイルが混在している場合、単純な `ls` では力不足になることがあります。
> そのような場合 `ls | findstr 絞り込み条件 | for /f "usebackq" %i in ('cat のようなコマンド') do @( hogehoge %i ) ` とする方が汎用性があります。

[^for]: `for` コマンドは非常に多機能、というかコマンド作業に必須の機能をいくつも持っており、`for` が使えるようになるとバッチスクリプト中級者であると言われるほど重要なコマンドです。が、詳しい機能・文法は他の記事に譲ります。

では、`for` と `ls` を使って、前の Step で作ったコマンドを実行してみましょう。

```bat
C:\> for /f "usebackq" %i in (`ls`) do @( cat %i | findstr "," | sed /e "1d;2d" | tr /d " " | cut /d "," /f 2 | sed /e "i+" | xargs eval | tr /d "\r\n" & puts ,%i )
5241,text01.log
1932,text02.log
1983,text03.log
,text04.log
8453,text05.log
8818,text06.log
3340,text07.log
...

C:\> :: ファイル名と各 colB 合計が並んで表示されました！
```

無事 colB 合計とファイル名が並んで表示されました。text04.log は colB が空白になっているようですが普通に実行されるようです。

# Step6 結果を取得する

既に忘れているかも知れませんが、この記事の目的は最大のcolB 合計を持つファイルのファイル名を取得することです。
残り一息ですので頑張りましょう。

では先ほどの結果をソートします。`for` の結果は単純にパイプに渡せないようなのでカッコで括ってから次のコマンドに繋ぎます。

```bat
C:\> ( for /f "usebackq" %i in (`ls`) do @(略) ) | msort /t "," /k 1nr
8818,text06.log
8453,text05.log
5241,text01.log
3340,text07.log
1983,text03.log
1932,text02.log
,text04.log
...

C:\> :: カンマ区切りデータの 1 列目を数値としてソート
```

`msort` コマンドは、入力をソートします[^msort]。`/k` はソートキーを指定するオプションで、`1` 列目を数値(`n`)として逆順(`r`)にソートするように指定しています。`/t` オプションは列の区切り文字をカンマに指定しています。

[^msort]: コマンドプロンプト標準の sort コマンドはフィールドごとのソートができませんので、`msort` を使います。

最大の colB 合計を持つのは text06.txt のようです。では、ファイル名以外の出力を除去していきましょう。

```bat
C:\> ( for /f "usebackq" %i in (`ls`) do @(略) ) | msort /t "," /k 1nr | head /n 1
8818,text06.log

C:\> :: 最初の 1 行を取得できました
```

`head` は入力の先頭部分だけを表示するコマンドです。`/n` オプションにより入力の先頭 1 行だけを出力しています。
あとは以前にも出てきた `cut` コマンドで、出力の 2 列目を取り出すだけです。

```bat
C:\> ( for /f "usebackq" %i in (`ls`) do @(略) ) | msort /t "," /k 1nr | head /n 1 | cut /d "," /f 2
text06.log

C:\> :: 最大の colB 合計を持つファイルのファイル名を取得することができました！
```

# おしまい

長い道のり（コマンド）でしたが、コマンドだけで比較的複雑な処理ができることを確認できました。
コマンドプロンプト上では大仰に見えますが、バッチスクリプトにまとめてしまうとスマートに見えるようになるでしょう。

ここまでで使用した標準外のコマンドは 9 種類です[^cat]。Unix のシェルスクリプトに比べて貧弱と言われているコマンドプロンプトですが、いくつかのコマンドを追加してやることで、そこそこ使えることが理解頂けたかと思います。レガシー技術は嫌われることが多い気がしますが、制限が強い環境でも色々できるようにしておくと良いのではないでしょうか。

[^cat]: `cat` は使わなくてもよかった。。。