シェルの出力をインデントする（POSIX sh/bash/dash/zsh）

# 文字列やコマンドをインデントしながら出力したい

> `POSIX sh` `zsh` や `bash` などのシェルで、変数に代入したコマンドの実行結果を `echo` する際にインデントしたい。

`bash` なら以下のようなことが、`sh` でもしたい。

```bash
indent() { sed 's/^/  /'; }

echo "hoge fuga"
echo "foo bar" | indent

# Output:
# hoge fuga
#   foo bar
```

気付けば簡単なことだったのですが、[Qiita 記事に絞って「`bash` `文字列` `インデント`」でググって](https://www.google.co.jp/search?q=site:qiita.com+bash+%E6%96%87%E5%AD%97%E5%88%97+%E3%82%A4%E3%83%B3%E3%83%87%E3%83%B3%E3%83%88)もドンピシャの方法が出てこなかったので、未来の自分のための外付け記憶の神殿として。

- 複数行を同じ行（１行内）に表示させたい場合はこちら:
  - 「[複数行の出力を同じ１行に出力させる。出力をキャッチしながら改行を削除して同一行出力の処理](https://qiita.com/KEINOS/items/eba32d17c02c3de8190f)」 @ Qiita
- 単純に POSIX でタブを出力したい（`echo -e` が使えない）場合はこちら：

    ```sh:printfでタブなどのエスケープ文字が使える
    printf 'foo\tbar\n'
    ```

## TL; DR

> 【ポイント】 `IFS=` の設定と `-r` オプション

```bash:コマンド実行時の複数行出力をインデントする例（バックスラッシュ&タブ入り文字対応）
echo 'カレント・ディレクトリのファイル一覧'
indent='    '
ls -lah . |
  while IFS= read -r line; do
      echo "${indent}${line}"
  done
echo
```
- [オンラインで動作をみる](https://paiza.io/projects/D2KA8-vRpxExRbh4VBKRzw) @ paiza.IO

## TS; DR

自作のセットアップスクリプトなどで、`apt install` といった外部コマンドを実行する際に、その出力結果をインデントさせたかったのです。

ほら、CI や Docker のビルド時に「あれ？これは俺様スクリプトの出力？それとも `apt` の方？」とか悩んだ時にインデントされていると目視しやすいじゃないですか。あれです。

### 頑張るも失敗した

最初は以下のように `echo` 時、パイプ渡しで `sed` を使って行頭にタブやスペースを挿入する方法を最初考えていました。

```bash:データが取得済みの場合
echo "${RESULT}" | sed "s/^/\t/g" # ^ にマッチしたら "\t" に置き換える
echo "${RESULT}" | sed "s/^/  /g" # ^ にマッチしたら "  " に置き換える
```

以下のような感じで、`install_dependencies` の後にパッケージを指定すると小綺麗に出力させていました。

```bash:sample.sh
#!/usr/bin/env bash

function echo_indent () {
    MSG="${1}"
    echo -e "${MSG}\n" | sed s/^/$'\t'/g
}

function install_dependencies () {
    NAME_PACKAGE=$1
    echo -n "Installing ${NAME_PACKAGE} ... "
    # 実行結果を変数に代入
    RESULT=`sudo apt -y install ${NAME_PACKAGE} 2>&1`
    if [ $? -gt 0 ]; then
        echo 'NG'
        echo "Error occured while installing ${NAME_PACKAGE}."
        echo "Called from line:${BASH_LINENO[0]}"
        # 取得したエラー出力をインデントする
        echo_indent "${RESULT}"
        return ${BASH_LINENO[0]}
    fi
    echo 'OK'
    return 0
}

install_dependencies libusb-1.0-0
install_dependencies xlibusb-1.0-0 #わざと間違えてみる
```
```shell-session:実行結果
$ ./sample.sh
Installing libusb-1.0-0 ... OK
Installing xlibusb-1.0-0 ... NG
Error occured while installing xlibusb-1.0-0. (Called from line:22)
	
	WARNING: apt does not have a stable CLI interface yet. Use with caution in scripts.
	
	パッケージリストを読み込んでいます...
	依存関係ツリーを作成しています...
	状態情報を読み取っています...
	E: パッケージ xlibusb-1.0-0 が見つかりません
	E: 正規表現 'xlibusb-1.0-0' ではパッケージは見つかりませんでした
	
```
- [オンラインで動作確認](https://paiza.io/projects/ulRnT9Hyu0AQYsWv40lQkA) @ paiza.IO
- GNU bash, version 4.4.19(1)-release (x86_64-pc-linux-gnu)

### シンプルで力強く

しかし、自由度も足りなかったので、むしろ出力を `while` `read` で各行読み込み、インデントを各行の頭に加える方法がメンテナンス性やカスタム性が高まりました。

```bash:コマンド実行時の複数行出力をインデントする例
#!/bin/sh
# 関数化
indentStdin() {
  indent='    '
  while read line; do
      echo "${indent}${line}"
  done
  echo
}
```

### バックスラッシュ対策

シェルの `read` コマンドは、デフォルトでバックスラッシュを解釈（展開）してしまいます。つまり、"`\/hoge`" は "`/hoge`" と変換されて渡されてしまうのです。「そのまま」渡したい場合には `-r` オプションを指定します。

- [SC2162 "read without -r will mangle backslashes"](https://github.com/koalaman/shellcheck/wiki/SC2162) | ShellCheck @ GitHub

```bash:コマンド実行時の複数行出力をインデントする例
#!/bin/sh
# 関数化
indentStdin() {
  indent='    '
  while read -r line; do
      echo "${indent}${line}"
  done
  echo
}
```

### 行頭のスペースやタブ文字の消滅対策

渡された文字の頭にタブやスペースが入っているとトリムされてしまう（消える）現象が発生しました。

ずいぶん悩んだのですが、以下の記事が大変参考になりました。

- [インデントされたファイルを`while read`コマンドでループさせるとインデントが消えてしまう問題に対処](https://genzouw.com/entry/2020/02/07/201814/1919/) @ ゲンゾウ用ポストイット

これは `read` コマンドが「スペース」「タブ」「改行」を１行の区切りの文字として認識してしまうことが原因でした。

つまり、`read` コマンドは区切り文字を検知すると、それ以降の区切り文字までを１行データとして渡します。そのため、行頭のスペースやタブも「区切り」的な扱いになってしまうので消えてしまうのです。

そして、それらの区切り文字は環境変数の `IFS` に定義されています。

```bash
$ # よくわからない。空に見える。
$ echo -n "$IFS"
 	
$ # 3文字セットされているみたい
$ echo -n "$IFS" | wc -c
       3

$ # od でバイナリをチェックしてみる
$ echo -n "$IFS" | od -t x1
0000000    20  09  0a                                                    
0000003

$ # ASCII名で見てみる
$ echo -n "$IFS" | od -t a
0000000   sp  ht  nl                                                    
0000003

$ # キャラクターセットでみてみる
$ echo -n "$IFS" | od -t c
0000000       \t  \n                                                    
0000003
```

回避策としては `read` コマンドの直前に `IFS` 変数を空で定義（`IFS=`）して環境変数の値を一時的に乗っ取るのです。具体的には `IFS= read` とします。

```bash:コマンド実行時の複数行出力をインデントする例
#!/bin/sh
# 関数化
indentStdin() {
  indent='    '
  while IFS= read -r line; do
      echo "${indent}${line}"
  done
  echo
}

# サンプル

echo 'カレント・ディレクトリのファイル一覧'
ls -lah | indentStdin

```
- [オンラインで動作をみる](https://paiza.io/projects/D2KA8-vRpxExRbh4VBKRzw) @ paiza.IO
- GNU bash, バージョン 4.3.30(1)-release (arm-unknown-linux-gnueabihf)、Raspbian GNU/Linux 8 (jessie)

## 関連 Qiita 記事

- [複数行の出力を同じ１行に出力させる。出力をキャッチしながら改行を削除して同一行出力の処理](https://qiita.com/KEINOS/items/eba32d17c02c3de8190f) @ Qiita