シェルスクリプトで標準出力と標準エラー出力に別々の色をフィルタでつける

画面にログを出力する時、標準出力と標準エラー出力に別々の色がついていると見やすいですよね？ 標準出力に出力する場合は `out` 関数、標準エラー出力で出力する場合は `error` 関数のよう別々の関数を使って色をつけて出力する方が簡単ですが、場合によっては後からフィルタで標準出力と標準エラー出力それぞれに色をつけたほうが楽な場合もあります。

この記事では題材として出力に色をつけていますが、一般化して言うと標準出力と標準エラー出力のそれぞれに対してパイプで異なるフィルタ（コマンド・シェル関数）を適用させたい場合の書き方です。

関連記事として「[POSIX準拠シェルのためのpipefailとPIPESTATUSの実装（改良版）](https://qiita.com/ko1nksm/items/e8309f58f91358f9f3c4)」も紹介しておきます。

## 0. 共通処理

最初に共通で使用する関数を定義しておきます。

```sh
#!/bin/sh

set -eu

ESC=$(printf '\033')

colorize() {
  while IFS= read -r line; do
    echo "${1}${line}${ESC}[m"
  done
}
red() { colorize "${ESC}[31m"; }
green() { colorize "${ESC}[32m"; }
yellow() { colorize "${ESC}[33m"; }

main() {
  echo "STDOUT"
  echo "STDERR" >&2
  return 123
}
```

## 1. 標準出力を緑にする

一般的な使い方なので説明は不要だと思いますが、基本として書いておきます。

```sh
main | green
```

## 2. 標準エラー出力だけを赤にする

標準出力はそのままで、標準エラー出力だけを赤にする方法です。

```sh
{ { main >&3; } 2>&1 | red >&2; } 3>&1
```

パイプで入力できるのは標準出力のみなので、標準エラー出力を標準出力に切り替える必要があります。しかしそうすると本来の標準出力の内容に混ざってしまうので先に標準出力を別のファイルディスクリプタ（以降 FD と省略します）に切り替えておく必要があります。

上記のコードでは

1. main 関数の標準出力 (FD1) を FD3 に切り替える
2. main 関数の標準エラー出力 (FD2) を 標準出力 (FD1) に切り替える
3. エラー内容を red にして元の標準エラー出力 (FD2) に戻す
4. 出力内容を元の標準出力 (FD1) に戻す

という処理を行っています。

## 2. 標準出力を緑に標準エラー出力を赤にする

1. と 2. の合体版です。main 関数の標準出力 (FD1) を FD3 に切り替える前に緑にするフィルタを適用するだけです。

```sh
{ { main | green >&3; } 2>&1 | red >&2; } 3>&1
```

## 3. 標準出力を緑に標準エラー出力を赤にし、終了ステータスも取得する

さて少しややこしくなってきました。1 と 2 には少し問題があり main 関数の終了ステータスが保持されません。せっかく色を付けてエラーをわかりやすくしたのに、終了ステータスがわからなくなってしまえば本末転倒になるでしょう。

```sh
{
  xs=$({ { { main &&:; echo $? >&4; } | green >&3; } 2>&1 | red >&2; } 4>&1)
} 3>&1
```

終了ステータスを取得するためには、標準出力退避用の FD3 とは別にもう一つ、終了ステータスを渡すためのファイルディスクリプタを使う必要があります。このコードでは FD4 を終了ステータス受け渡し用のファイルディスクリプタとして使用しています。終了ステータスを FD4 に出力しておいて、コマンド置換（`xs=$(...)`) で回収することによって終了ステータスを取得することができます。コマンド置換も標準出力しかキャプチャできないので FD4 を FD1 に切り替え（`4>&1`）ています。

なお `&&:`（`&&` と `:` コマンド）は `set -e` の状態でエラーが発生しても（終了ステータスを変えずに）処理を中断させないための方法です。`set -e` を使用してない場合は不要です。また main 関数が `exit` で終了する場合は `(main)` と `()` でくくらないと途中で中断されてしまうので注意してください。`exit` でなくても特定のエラー（例えば `set -u` の状態で未定義の変数を参照した場合）でも中断されてしまうので予期せぬエラーにも対応するのであれば `()` で括っていたほうがより確実です。

またコマンド置換ではなくて `read` を使って終了ステータスを取得する実装もあります。（ちなみに私が検索して最初に見つけたのはこのやり方でした。）

```sh

{
  { { { main &&:; echo $? >&4; } | green >&3; } 2>&1 | red >&2; } 4>&1 | { read -r xs; }
} 3>&1
```

この2つの違いは `xs` 変数のスコープです。前者は `xs` が処理終了後からも参照できますが後者ではサブシェル内に閉じてしまうので参照できません。（zsh や ksh、`shopt -s lastpipe` を有効にした bash などシェルやオプションによっては参照できることもあります。）コマンド置換の方が `read` よりも柔軟にコードを書けるので通常は前者を使ったほうが良いと思います。

## 4. 標準出力と標準エラー出力の他に警告用の出力を行い終了ステータスも取得する

通常は標準出力と標準エラー出力ぐらいしか使いませんが、例えば警告用の出力先があると便利かもしれません。また他に情報用やデバッグ用なども考えられます。これに対応するとさすがにコードが見づらくなるのでインデントを工夫して見やすくしています。またこれまでは FD3 を標準出力の一時退避用、FD4 を終了ステータス用にしていましたが、FD3 → FD8、FD4 → FD9 に変更します。これで FD3 ～ FD7 までを追加の出力用ファイルディスクリプタに使用することができます。

```sh
# 共通処理修正
main() {
  echo "STDOUT"
  echo "STDERR" >&2
  echo "FD3" >&3 # 警告用の出力を追加
  return 123
}
```

```sh
{
  xs=$(
   {
      # FD4 以降が必要な場合は下記と同様に { を追加する
      {
      {
        { main &&:; echo $? >&9; } | green >&8
      } 2>&1 | red >&2
      } 3>&1 | yellow >&3
      # FD4 以降が必要な場合は上記と同様な書き方で追加する
    } 9>&1
  )
} 8>&1
```

なお、FD3 に出力したものは最終的に画面かファイルに出力する必要があるので、`./script.sh 3>&2` のように FD3 を標準エラー出力に切り替えたりファイルに出力する必要があります。スクリプトの中で切り替えても良いです。

## 5. 標準出力、標準エラー出力、警告用出力に色を付け、それぞれのフィルタの終了ステータスも取得する

今回の例では必要ないと思いますが、フィルタがエラーになる場合はそれらの終了ステータスも取得したいでしょう。その場合の書き方です。

```sh
# 共通処理修正 終了ステータスを指定できるようにする
red() { colorize "${ESC}[31m"; return "$1"; }
green() { colorize "${ESC}[32m"; return "$1"; }
yellow() { colorize "${ESC}[33m"; return "$1"; }
```

```sh
{
  xs0=0 xs1=0 xs2=0 xs3=0 # 初期化（成功時の値）
  xs=$(
    {
      {
      {
        { main || echo "xs0=$?" >&9; } | { green 1 || echo "xs1=$?" >&9; } >&8
      } 2>&1 | { red 2 || echo "xs2=$?" >&9; } >&2
      } 3>&1 | { yellow 3 || echo "xs3=$?" >&9; } >&3
    } 9>&1
  )
  eval "$xs" # xs0, xs1, xs2, xs3 に終了ステータスがそれぞれ代入される
} 8>&1
```

今までは終了ステータス用のファイルディスクリプタ（今回は FD9 ）に終了ステータスの値をそのまま出力していましたが代わりに、`xs0=123`, `xs1=1`, `xs2=2`, `xs3=3` のように変数名も含めて出力します。その後この文字列を `eval` してそれぞれの変数に展開しています。

また今までのコードから少し変更していて `&&:;` ではなく `||` を使っています。これにより「処理の結果が成功でも失敗でも終了ステータスを `echo` する」だったのが「処理が失敗（0 以外を返した時）だけ終了ステータスを `echo` する」に変わります。成功した場合はなにも `echo` されないので処理の開始時に変数を初期化（`xs0=0 xs1=0 xs2=0 xs3=0`）しています。こうすることで僅かですがコードが短くなり、ShellCheck でコードをチェックした時に `xs0` の参照箇所で `SC2154: xs0 is referenced but not assigned.` という変数未定義の警告がでるのを自然な形で抑制することができます。ちょっとした工夫です。

## 注意点

このコードはほとんどの POSIX シェルで動作しますが、zsh 3.1.9 と (p)bosh 2020/10/07 とそれ以前ではシェルのバグにより動作しません。通常は必要にならないと思いますが、対応する場合は（一番外側以外の） `{ }` を `( )` に変更すると回避することができます。

## おまけ

おまけで、あるコマンドを実行しその出力に色をつけるコマンドを作ってみました。`colorize <cmd> [arguments...]` と実行すると`cmd` の標準出力を緑、標準エラー出力を赤にします。どちらに出力されているかを簡単に見分けたい時に便利かもしれません。

```sh:colorize
#!/bin/sh

set -eu

ESC=$(printf '\033')

colorize() {
  while IFS= read -r line; do
    echo "${1}${line}${ESC}[m"
  done
}
red() { colorize "${ESC}[31m"; }
green() { colorize "${ESC}[32m"; }

{
  xs=$({ { { "$@" &&:; echo $? >&4; } | green >&3; } 2>&1 | red >&2; } 4>&1)
} 3>&1
exit "$xs"
```