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


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

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

気付けば簡単なことだったのですが、Qiita 記事に絞って「bash 文字列 インデント」でググってもドンピシャの方法が出てこなかったので、未来の自分のための外付け記憶の神殿として。

TL; DR

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

コマンド実行時の複数行出力をインデントする例(バックスラッシュ&タブ入り文字対応)
echo 'カレント・ディレクトリのファイル一覧'
indent='    '
ls -lah . |
  while IFS= read -r line; do
      echo "${indent}${line}"
  done
echo

TS; DR

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

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

頑張るも失敗した

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

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

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

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 #わざと間違えてみる
実行結果
$ ./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' ではパッケージは見つかりませんでした

シンプルで力強く

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

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

バックスラッシュ対策

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

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

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

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

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

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

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

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

$ # よくわからない。空に見える。
$ 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 とします。

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

# サンプル

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

関連 Qiita 記事