[シェル] 複数行の出力を同じ１行に出力させる。出力をキャッチしながら改行を削除して同一行出力の処理 [ash/bash/bourne/zsh シェル互換] 

# シェル・スクリプト内で実行したコマンドの出力を１行ごとに表示させたい

![スクリーンショット 2020-05-08 10.47.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78776/6686949e-8d85-35d7-b338-f5dfd6997660.png)

macOS や Linux のシェル・スクリプトで、解凍やダウンロードなどのコマンドを使う際、 `verbose`（詳細な出力）時に複数行で出力されます。

進捗確認／プログレスバー的に動いていることが確認できればいいので、**出力を改行させずに１行内にカウンターのように収めて表示させたい**のです。[「linux シェル 複数行 改行 削除 同一行」でググって](https://www.google.com/search?q=linux+%E3%82%B7%E3%82%A7%E3%83%AB+%E8%A4%87%E6%95%B0%E8%A1%8C+%E6%94%B9%E8%A1%8C+%E5%89%8A%E9%99%A4+%E5%90%8C%E4%B8%80%E8%A1%8C)も期待する結果が出なかったので、自分のググラビリティとして。（Ash/Bash/bourne/zsh シェル互換です）

- こんなイメージ 👇（従来の進捗表示と、１行で進捗表示）

<script id="asciicast-327781" src="https://asciinema.org/a/327781.js" async></script>

## TL; DR <sub><sup>（今北産業）</sup></sub>

1. `\r`（[キャリッジ・リターン](https://ja.wikipedia.org/wiki/%E3%82%AD%E3%83%A3%E3%83%AA%E3%83%83%E3%82%B8%E3%83%BB%E3%83%AA%E3%82%BF%E3%83%BC%E3%83%B3)、復帰コード）で、表示開始位置を先頭に戻してから表示する
2. コマンド出力をパイプで渡し、`read` で受け取りつつ `while` で各行ごとに改行の処理をしながら出力させる
3. 基本的な考え方
    ```shell
    your_command |
        while read line; do
            printf '\r%*s\r' ${lenLine:-${#line}}
            printf "%s" "$line"
            lenLine=${#line}
        done
    echo
    ```

### 具体例と応用例

```shell:具体例
#!/bin/sh

apt install wget git openssl |
    while read line; do
        printf '\r%*s\r' ${lenLine:-${#line}}
        printf "%s" "$line"
        lenLine=${#line}
    done
echo

# 全ての行を出力すると描画に時間がかかるため、n 行ごとに出力するとよい。TS; DR 参照

```

これを応用すれば、自分のスクリプト内で実行している**コマンドの実行結果をインデントさせたり `prefix` を付ける**ことができます。

```bash:コマンド実行時の出力結果をインデント出力する例
# "composer install --dry-run" で失敗した場合のみ出力結果（エラー内容）をインデント表示する
indent='    '
result=$(composer install --dry-run 2>&1)
status=$?
echo "${result}" |
  while read line; do
    echo "${indent}${line}"
  done
echo
exit $status
```

- 関連文献: [bash の変数内の文字列をインデントして出力する](https://qiita.com/KEINOS/items/0b52d0ca7d726b7d1b07) @ Qiita

## TS; DR

俺様シェル・スクリプト内で、アーカイブ・ファイルを解凍／展開する際、数十ギガバイトもあると<font color=darkorange>**動いているのかハングしているのか分からない**</font>ことがあります。

そこで `-v`（`verbose`）で詳細出力させるも、今度は出力行数が多すぎるので困ったのです。<font color=darkorange>**カウンター表示のように１行内に表示できないか**</font>悩みました。

[パイプ](https://ja.wikipedia.org/wiki/%E3%83%91%E3%82%A4%E3%83%97_(%E3%82%B3%E3%83%B3%E3%83%94%E3%83%A5%E3%83%BC%E3%82%BF))（`|`）を使うことは想定できたのですが、`sed` や `tr` コマンドを使っても全行取得してから処理しているようで、うまく動きませんでした。

ググっても Perl/PHP/Python といったプログラムを使った方法ばかりで、普通にシェルのスクリプトで行いたかったのです。しかもローカルの macOS だけでなく Docker の Alpine Linux でもデフォルトで動くシェル・スクリプトとして。つまり `bash`, `zsh` や `ash` シェルで動くタイプ。

答えは、**「パイプで受け取るデータをループで回しながら受け取る」という方法**でした。処理を考えてみればその通りなのですが、気づかなかったので備忘録として。

```shell:スクリプトの詳しい説明コメント付き
#!/bin/sh

# 出力行が多いコマンドの例。これを１行で表示させる
unzip megarchive.zip |
    # パイプで受け取った標準出力を１行ごとに処理
    while read line; do
        # 前の行を削除（前の行と同じ文字数の空白文字に、キャリッジリターン（\r）を前後に付けて削除。未設定の場合は現在の行の文字数）
        printf '\r%*s\r' ${lenLine:-${#line}}
        # １行ぶんのデータ表示
        printf "%s" "$line"
        # 次のループで行を削除できるように、行の長さを取得
        lenLine=${#line}
    done
echo
```

上記は表示量（行）が多い場合、**本来の速度より遅くなります**。これは再描画（出力した行を削除して出力）することによる CLI の制限です。そこで、全てを愚直に出力させずに、どうせ消されていく出力なので **n 行ごとに表示すると、かなり軽量化/高速化できます**。

```shell:画面描画に負荷の少ないバージョン
#!/bin/sh

# （counter が interval の区切りになるごとに描画させる）
counter=0
interval=100

unzip megarchive.zip |
    while read line; do
        # counter と interval の剰余（割ったときの余り）が 0 のときのみ描画で遅延防止
        if [ $((counter % interval )) -eq 0 ]; then
            printf '\r%*s\r' ${lenLine:-${#line}}
            printf "%s" "$line"
            lenLine=${#line}
        fi
        # カウンターをカウントアップ
        counter=$((counter + 1))
    done
echo
```

いずれにしても、ここでのポイントは、**１行ぶんの文字数を取得しておいて、次のループで消している**ところです。これをしないと、次の行が前の行より短いと、前の行の一部の表示が残ってしまうのです。

実は、上記のスクリプトに落ち着くまで、以下のようにコンソール（ターミナル）の画面の文字幅を取得して消していました。これは、出力を１行でなく進捗バーも表示するなど、数行で表示したい場合に使われる手法です。しかし、Docker などの Alpine では `tpup` を持たないため、`tput cols` で画面幅（画面文字数）が取得できないことがありました。

```shell:スクリプトの詳しい説明コメント付き
#!/bin/sh

# tput cols でターミナルの横幅（１行ぶんの文字数）を取得
$(tput cols 2>/dev/null 1>/dev/null) && {
    WIDTH_SCREEN=$(tput cols);
}
# Docker や CI など tput が使えない（画面幅を取得できない）場合は、WIDTH_SCREEN は
# 未定義になるので 80 文字をデフォルトでセット
WIDTH_SCREEN=${WIDTH_SCREEN:-80}

# 描画による処理遅延を抑えるための初期設定値
# （counter が interval の区切りになるごとに描画させる）
counter=0
interval=100

# your_command は複数行表示される任意のコマンド。tar/unzip/curl/wget などなんでもいい。
your_command |
    # その実行結果をパイプ（"|"）で while コマンドに渡し、１行ごとに read コマンドで
    # line 変数に読み込む
    while read line; do
        # counter と interval の剰余（割ったときの余り）が 0 のときのみ描画で遅延防止
        if [ $((counter % interval )) -eq 0 ]; then
            # 空行表示。
            #   "\r" でヘッダーを先頭に戻してから "%*s" で１行ぶん（$WIDTH_SCREEN ぶん）
            #   の空白文字を出力し、"\r" で再度ヘッダを先頭に戻す
            printf '\r%*s\r' $WIDTH_SCREEN
            # "read" でキャッチした１行ぶんのデータ "line" を、改行を付けずに出力
            printf "%s" "$line"
        fi
        # カウンターをカウントアップ
        counter=$((counter + 1))
   done
echo # 最後に改行させる

```

### ポイント

- パイプで受け取ったデータを１行ごと（`\n` 区切り）で処理したい場合は `read` コマンドで取得しつつ `while` ループで処理する。
- 改行コード（`\n`）を付けずにキャリッジリターン・コード（`\r`）で出力位置を行頭に戻してから出力すると、同じ行に表示できる。
- 空行（１行ぶんのスペースの文字列）を表示させてから出力すると、前の行のゴミが表示されない。
- 改行の「あり」「なし」が重要な場合、`printf` で出力させておくと、他のシェルでも安定して動く。
- １行ごとに描画（`echo` `printf`）すると処理がかなり遅くなるので、`mod`（`modulo`, 剰余算, `%`）で描画にインターバル（間隔）を設ける。

### 参考文献

- [How to add progress bar to a somearchive.tar.xz extract](https://stackoverflow.com/a/19372542/12102603) @ StackOverflow