今年のコマンド使用頻度ランキングを算出して1年を振り返ってみよう

今年も終わりが近づいてきたので、自分がよく使うコマンドをランキング形式にして振り返ることができたら面白そうと思ったのでやってみました。

# 結論
各種コマンドの使い方がBashかZshか、GNU系のOS(Linux)かBSD系のOS(Macなど)かで微妙に変わってきます。
実行コマンド例は以下のとおりです。

## 累計でベスト10を出してみる

```bash
# Bash x BSDの場合 (※ $HISTTIMEFORMAT='%F %T')
history \
  | awk '{ for (i = 4; i < NF; i++) { printf("%s ", $i) } print $NF }' \
  | sed 's/|/\'$'\n/g' \
  | awk '{ print $1 }' \
  | sort \
  | uniq -c \
  | sort -r \
  | head
```

```zsh
# Zsh x GNUの場合
history 1 \
  | awk '{ for (i = 2; i < NF; i++) { printf("%s ", $i) } print $NF }' \
  | sed 's/|/\n/g' \
  | awk '{ print $1 }' \
  | sort \
  | uniq -c \
  | sort -r \
  | head
```
Bashの場合`history`に引数を指定する必要はなく、2行目の`awk`のiは**4**から始まります。(環境変数HISTTIMEFORMATが`%F %T`のとき)
Zshの場合`history`の引数に1が指定され、2行目の`awk`のiは**2**から始まります。
また、`awk`がBSDのものかGNUのものかによって4行目の`awk`の指定の仕方が異なります。

## 2019年に限定してベスト10を出してみる
単に`grep`を使うだけです。細かい期間指定をしたい場合は`egrep`を使いましょう。

```bash
# Bash x BSDの場合 (※ $HISTTIMEFORMAT='%F %T')
history \
  | grep 2019- \
  | awk '{ for (i = 4; i < NF; i++) { printf("%s ", $i) } print $NF }' \
  | sed 's/|/\'$'\n/g' \
  | awk '{ print $1 }' \
  | sort \
  | uniq -c \
  | sort -r \
  | head
```
Zshで期間指定する場合は`hisotry`に**-i**オプションを追加します。
3行目の`awk`のiの開始は4に変わっています。

```zsh
# Zsh x GNUの場合 (※ $HISTTIMEFORMAT='%F %T')
history -i 1 \
  | grep 2019- \
  | awk '{ for (i = 4; i < NF; i++) { printf("%s ", $i) } print $NF }' \
  | sed 's/|/\n/g' \
  | awk '{ print $1 }' \
  | sort \
  | uniq -c \
  | sort -r \
  | head
```

ちなみに私の自宅Macでの実行結果はこんな感じでした。
職場のMacでやってみるとまた結果が変わって面白いと思います。

```
 201 vi
 148 ll
 111 git
  96 cd
  87 gc
  79 kg
  73 rm
  72 k
  66 brew
  61 mv
```
ちなみに、aliasされたコマンドは`vi='nvim'`、`ll='ls -al'`、`gc='git commit'`、`kg='kubectl get'`、`k='kubectl'`です。

# 解説
上記のコマンドはほとんど同じですが微妙に違うところがあります。
BashかZshかで`history`の使い方や結果が異なりますし、BSDかGNUかで`sed`の使い方が異なります。

## history
まずはBashとZshで微妙に仕様が違う`history`について見ていきましょう。
Bashでそのままコマンドを打ってみるとこのように表示されます。

```
$ history
    1  2018-09-08 17:00  brew install ricty
    2  2018-09-08 17:05  cp -f /usr/local/opt/ricty/share/fonts/Ricty*.ttf ~/Library/Fonts/
    3  2018-09-08 18:29  brew search thunderbird
    ...

```
日付時刻付きで全件表示されます。
一方Zshで同様に打ってみると・・・

```
 2593  ./init.sh
 2594  ll ~/Library/Application\ Support/Code/User/
 2595  gc -m "Add VSCode keybindings"
...
 2610  git init
```
日付や時刻は表示されず最新16件のみ表示されます。
ただし、Bashの`history`でも日付や時刻が表示されない場合があります。
それは環境変数HISTTIMEFORMATが設定されていない場合です。
この場合は(途中で設定したとしても)期間ごとに絞り込むことはできないので注意してください。
以降はHISTTIMEFORMATに`%F %T`が設定されているという前提で話を進めます。

Bashはデフォルトで全件出ますし、HISTTIMEFORMATを指定していれば日付も出ますのでこれ以上詳しく話す必要もないでしょう。
Zshの`history`の場合、全件出すには引数を指定する必要があります。

```zsh
# 全件表示(1件目以降全て)
history 1
# 区間指定
history 10 20
```
また、先程説明したとおり日付時刻を表示に含めるには-iオプションを付け加えます。
Zshの`history`のオプションについてより詳しく知りたくば`man zshbuiltins`で`history`の項目と`fc -l`の項目を見てください。
そう、Zshの`history`は`fc -l`のaliasなのだそうです。

```
       history
              Same as fc -l.
```

## historyの結果をコマンド種別で集計する
一旦間を飛ばして、シンプルに`history`の結果をコマンドごとに集計するにはどうすればいいか考えてみます。
Bashの`history`で説明します。
`history`の1行ごとの出力は空白文字区切りで

```
 インデックス 日付 時刻 コマンド サブコマンドやオプションなど
```
となっているので、パイプなどを考慮しなければ`awk '{ print $4 }'`に渡せばよいことがわかります。
そうしたら、`uniq -c`で重複部分を消しつつカウントすればいいようですが、`uniq`は連続した重複しか消すことができないので、`uniq -c`する前に一旦`sort`します。

```bash
# Bash
$ history | awk '{ print $4 }' | sort | uniq -c
...
   1 c
   3 cal
  31 cat
 116 cd
  13 chmod
   5 code
   1 command
   7 cp
  60 curl
  10 cut
 ...
```
あとはこれを降順にソートし直せば頻度順になります。

## パイプした後のコマンドも集計に含めたい
パイプした後のコマンドも集計に含めたい場合どうすればいいでしょうか。
方法は色々あると思いますが、今回は`|`を改行コードに置換するという方法を取りました。
ここで`sed`がGNUかBSDのものかで置換の指定方法が若干異なります。

```zsh
# BSD sed
sed 's/|/\'$'\n/g'
# GNU sed
sed 's/|/\n/g'
```
BSDの`sed`で改行コードに変換する方法を調べていたら以下の記事が見つかりましたので、詳しく知りたければ読んでみてください

- [Newlines in sed on Mac | Caffeinated - Blog of Nathan Fiedler](http://nlfiedler.github.io/2010/12/05/newlines-in-sed-on-mac.html)


ただし、普通に改行するだけではコマンド部分とインデックスやサブコマンド以降と切り分けづらいです。
なので改行する前にコマンド部分以前をカット(コマンド部分以降を抽出)してしまいたいです。
それが

```bash
awk '{ for (i = 4; i < NF; i++) { printf("%s ", $i) } print $NF }'
```
の部分に当たります。
解説しておきながら実はawkの言語仕様については詳しくないので、forの中身が`for (i = n; i <= NF; i++)`でなかったり、通常最後列の文字列が入る$NFにfor pirntfした内容が入っている理由を知っている方がいたら教えていただけると助かります。

つまり、ここまでの流れをまとめると

```bash
# Bash x BSDの場合 (※ $HISTTIMEFORMAT='%F %T')
history \
  | awk '{ for (i = 4; i < NF; i++) { printf("%s ", $i) } print $NF }' \
  | sed 's/|/\'$'\n/g' \
  | awk '{ print $1 }' \
  | sort \
  | uniq -c \
  | sort -r \
  | head
```
のコマンドはおおよそ

```bash
コマンド履歴を表示する \
  | 余分な列をカットする \
  | パイプ後もカウントするよう改行コードに置換する \
  | 重複カウントしやすいようコマンド部分のみを抽出する \
  | 重複カウントしやすいようソートして重複数をカウントする \
  | 頻度順にソートする
```

という意味になります。

# おまけ
`history`の最大件数は環境変数HISTSIZEで決まっています。
コマンド履歴は今回のような使い方以外にもCtrl + Rでの検索時に重宝しますので、CUI操作をよくする人はより大きい数を設定しておくとより便利になります。

そして普段Macを使っている人でGNU sedを使いたい人は`brew install gnu-sed`して`alias sed='gsed'`を.bashrcか.zshrcに書き加えておきましょう。
その他GNUコマンドのインストール方法に関しては、こちらの記事がとてもよくまとまっていました。

- [テキスト処理のための標準的なコマンド群の macOS への導入手順](https://qiita.com/eumesy/items/3bb39fc783c8d4863c5f)  @eumesy