“$*”で表示したコマンドは別物になる|引用符と$?の見せ方

"$*"で表示したコマンドは別物になる|引用符と$?の見せ方

記事に載せるコマンドの実行例を見直していたら、実行したはずの find app -name "*.js" が find app -name *.js と、引用符の落ちた形で載っていました。

私は実行例を、Docker コンテナの中でスクリプトを走らせて自動で取っています。

スクリプトでは x の後ろにコマンドを書くと、表示用の行を出してから実行する小さなシェル関数を使っています。

同じ見直しで、終了コードを日本語の注記で出していたせいで、読者が打っても出てこない行が実行例に混ざっていることにも気づきました。

人に見せるコマンドは、表示した行をそのまま打てば同じ引数列になることを条件にして、往復テストで確かめるのが、今回たどり着いた答えでした。

表示どおりに打っても同じ結果にならなかった

食い違いは、引用符の落ちたコマンドと、打っても出てこない終了コードの行の 2 種類でした。

どちらも、表示した行をそのまま打っても同じ結果にならない、という同じ種類の問題です。

以下の実行例は、この記事のために手元の Git Bash(GNU bash 5.2.37、Git for Windows 2.48.1)で取り直したものです。

引用符が落ちたコマンド

旧版の関数は、表示する行を "$*" で組み立てていました。

x() {
  printf '$ %s\n' "$*"   # 表示(旧版)
  "$@"                   # 実行
}

実際の関数は目印を付けた行を出し、後段でそれを $ で始まる行に整えています。

ここでは、直接 $ で始まる行を出す形に縮めました。

app の下に main.js と lib/util.js があるディレクトリで x find app -name "*.js" を実行すると、表示はこうなります。

$ find app -name *.js
app/lib/util.js
app/main.js

結果は正しいので、出力だけを見ていても問題に気づけません。

困るのは、読者がこの表示行をそのまま打ったときです。

カレントに vite.config.js がある状態で打ち直すと、何も出ないまま終わりました。

$ find app -name *.js
$ echo $?
0

引用符の無い *.js は、find に渡る前にシェルがカレントのファイル名へ展開します。

結果として find app -name vite.config.js を実行したことになり、エラーも出さずに 0 件で終わりました。

バックスラッシュを含む引数でも、同じように食い違います。

x grep -F ".w-\[123px\]" out.css は、CSS に出力されたエスケープ済みのクラス名を、そのままの文字列で探すコマンドです。

旧版の表示は grep -F .w-\[123px\] out.css になり、打ち直すとシェルがバックスラッシュを取り除いてしまいます。

$ grep -F .w-\[123px\] out.css
$ echo $?
1

.w-[123px] という別の文字列を探すことになり、1 件も当たりません。

見直しの結果、旧版の表示で引用符が落ちていたのは実行例の 8 行(5 記事)でした。

内訳は find -name *.js が 2 行、find -name *.class と git reset --hard HEAD@{1} が 1 行ずつで、残りは grep -F .w-\[123px\] などの grep の行でした。

打っても出てこない終了コードの行

もう 1 つの問題は、終了コードの見せ方にありました。

旧版のスクリプトは、コマンドの後ろで echo "終了コード: $?" を実行していました。

$ grep -F w-[999px] out.css
終了コード: 1

読者が同じコマンドを打っても、「終了コード: 1」という行は画面に出てきません。

この注記は 178 記事に 481 行あり、そのうち 75 行は「(0 なら解消)」のような解説付きでした。

さらに 3 行は、直前に表示したコマンドの終了コードになっていませんでした。

2 行は表示していない openssl req と openssl x509 の後ろで、残りの 1 行はバックグラウンドで動かしたサーバーを wait した後ろで値を取っていました。

読者からは、見えているコマンドの結果にしか見えません。

"$*" は引数の境界と引用を捨てる

表示と実行は同じ引数列から作っているのに、表示だけが別物になるのには理由があります。

引用符はシェルがコマンド行を読む段階で外れるので、関数が受け取るのは中身だけです。

$ set -- find app -name "*.js"
$ echo "$#"
4
$ printf '[%s]\n' "$@"
[find]
[app]
[-name]
[*.js]
$ printf '[%s]\n' "$*"
[find app -name *.js]

"$@" は 4 つの引数を 4 つのまま渡し、"$*" は IFS の先頭の文字(既定では空白)でつないだ 1 つの文字列にします。

この動きは POSIX の Shell Command Language の Special Parameters の節に書かれています。

つないだ文字列からは、どこが引数の切れ目で、どの引数に引用符が要ったのかを読み取れません。

引用符は、表示する側で引数ごとに付け直すしかないわけです。

引数ごとに引用し直して表示する

新しい関数では、引数を 1 つずつ調べて、必要なものだけを引用符で囲みます。

決め方は次の 4 通りです。

引数の中身表示例
英数字と _ . / : = @ % + , - ~ だけ(~ の位置は後述)そのまま--name=foo・HEAD~1
空文字''''
二重引用符・ドル記号・バッククォート・バックスラッシュ・感嘆符のどれかを含む単引用符で囲む(' は '\'' に置き換える)'.w-\[123px\]'・'it'\''s $5!'
それ以外二重引用符で囲む"*.js"・"HEAD@{1}"・"hello world"

ここからは、実際に使っている関数を 2 つに分けて載せます。

最初の case で、そのまま出してよい引数かどうかを決めます。

_xq() {
  case $1 in
    '')
      _xr="''"
      return
      ;;
    \~* | *=\~* | *:\~*) ;;
    *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_./:=@%+,~-]*) ;;
    *)
      _xr=$1
      return
      ;;
  esac
  # 続きは次のブロック

許可する英字は [A-Za-z] のような範囲で書かず、1 文字ずつ並べています。

範囲表記の意味が決まっているのは POSIX ロケールだけで、ほかのロケールでは未規定とされているためです。

2 つ目の case で、引用符の種類を選びます。

  case $1 in
    *[\"\$\`\\!]*)
      _xr= _xt=$1
      while :; do
        case $_xt in
          *\'*)
            _xp=${_xt%%\'*}
            _xt=${_xt#*\'}
            _xr=$_xr$_xp\'\\\'\'
            ;;
          *)
            _xr=\'$_xr$_xt\'
            break
            ;;
        esac
      done
      ;;
    *) _xr=\"$1\" ;;
  esac
}

単引用符の中には単引用符を書けないので、' が出てくるたびに、いったん閉じてエスケープした ' を置き、また開く形に置き換えています。

表示する関数は、引数ごとに _xq を呼んで空白でつなぐだけです。

x() {
  _xl=
  for _xa in "$@"; do
    _xq "$_xa"
    _xl="$_xl${_xl:+ }$_xr"
  done
  printf '$ %s\n' "$_xl"
  "$@"
}

local や配列は POSIX sh に無いので使わず、作業用の変数はすべて _x で始まる名前にしました。

チルダは展開される位置だけ引用する

~ は、語の先頭にあるときと、代入の形をした語の = や : の直後にあるときに展開の対象になります。

手元で bash と dash に同じ行を渡すと、代入の形をした引数の扱いが分かれました。

$ HOME=/home/user bash -c "printf '%s\n' ~/foo HEAD~1 a=~/x PATH=/a:~/b"
/home/user/foo
HEAD~1
a=/home/user/x
PATH=/a:/home/user/b
$ HOME=/home/user dash -c "printf '%s\n' ~/foo HEAD~1 a=~/x PATH=/a:~/b"
/home/user/foo
HEAD~1
a=~/x
PATH=/a:~/b

bash は POSIX モードでないとき、コマンドの引数でも代入の形をした語のチルダを展開します。

Git for Windows の sh は bash を POSIX モードで動かしたもので、こちらは dash と同じ結果でした。

語の途中にある HEAD~1 は、どのシェルでも展開されていません。

~ を一律に引用すると HEAD~1 が "HEAD~1" と読みにくくなるので、語の先頭と =・: の直後にあるときだけ引用しています。

感嘆符は単引用符に回す

! は二重引用符の中でも、対話モードの bash では履歴展開の対象になります。

手元の対話モードの bash に echo "a!b" を打つと bash: !b: event not found で止まり、echo 'a!b' は a!b を出しました。

表示行は人が端末に貼り付けるものなので、! を含む引数は単引用符で囲む側に入れています。

printf %q は使わない

bash なら、printf '%q' が引数をシェルに渡し直せる形に引用してくれます。

printf の書式については、以前の記事でも触れました。

printf入門|echoとの違いと書式で出力をそろえる基本

ただ、実行例を取るコンテナの sh は dash や BusyBox ash で、%q は POSIX の printf の規定にありません。

手元の dash でも、次のように止まりました。

$ dash -c "printf '%q\n' 'a b*c'"
dash: 1: printf: %q: invalid directive

$'a\tb' のような書き方も、手元の dash では $a\tb という文字列のまま渡っています。

一方で Git for Windows の sh は中身が bash なので、%q がそのまま通ってしまいます。

「sh で動いた」は「POSIX sh で動く」の確認にならないので、dash のような別の実装でも動かして確かめました。

往復テストで確かめる

関数を直したら、表示した行をシェルに読ませ直して、元の引数に戻るかを確かめます。

. ./helpers.sh   # _xq を読み込む
n=0 ok=0
while IFS= read -r c; do
  n=$((n + 1))
  _xq "$c"
  eval "set -- $_xr"
  if [ "$#" -eq 1 ] && [ "$1" = "$c" ]; then ok=$((ok + 1)); r=OK; else r=NG; fi
  printf '%s\t%s\n' "$r" "$_xr"
done < cases.txt
printf 'total=%s ok=%s\n' "$n" "$ok"

cases.txt には、試したい引数を 1 行に 1 つずつ書いておきます。

引用が壊れていると eval がケースの中身をコマンドとして実行してしまうので、使い捨ての環境で回すのが前提です。

関数を作った当時は、.w-\[123px\]・*.js・HEAD@{1}・空白入り・$ 入り・' 入り・空文字・!・~ などの 27 ケースを用意しました。

これを dash(debian:bookworm-slim)・BusyBox ash(alpine:3)・bash(Git Bash と debian の bash)で回しています。

結果はすべて一致し、3 つのシェルが出す表示も一字一句同じでした。

この記事のために手元の Git Bash で 31 ケースに広げて回し直すと、bash・sh・dash のどれでも 31 件が一致しています。

OK	"*.js"
OK	'.w-\[123px\]'
OK	"HEAD@{1}"
OK	HEAD~1
OK	"~/foo"
OK	''
OK	'hi!'
OK	'it'\''s $5!'
OK	"日本語"
# 略(31 件のうち 9 件を抜粋)
total=31 ok=31

31 個の引数を 1 行に並べた表示を bash -c・sh -c・dash -c に渡して 1 引数ずつ出した結果も、元の引数列と同じでした。

3 つのシェルで作った表示どうしも、一字一句同じです。

終了コードは echo $? として見せる

終了コードは、読者が同じ端末で打ったときの画面に合わせて、$ echo $? の行と値で見せる形に変えました。

rc() {
  _rs=$?
  printf '$ %s\n' 'echo $?'
  echo "$_rs"
  return "$_rs"
}

表示したコマンドの直後に rc を置くと、出力は次のようになります。

$ grep -F "w-[999px]" out.css
$ echo $?
1

rc は受け取った終了コードをそのまま返すので、後ろに || を続けても動きは変わりません。

$? を取る場所を間違えない

$? は直前に終わったコマンドの値なので、取る場所が 1 つずれると別のコマンドの結果になります。

パイプの後ろで取ると、最後のコマンドの終了コードが返ってきます。

$ grep -F "w-[999px]" out.css | tail -n 1
$ echo $?
0

grep は 1 件も見つけていないのに、$? は tail の 0 です。

パイプの途中の失敗を拾う設定は、別の記事で詳しく書いています。

set -euo pipefailとは?bashの安全装置

今回の見直しでは、パイプの後ろや st=$? で保存した値を出している行は 0 件でした。

実際に食い違っていたのは、前に挙げた、表示していない準備コマンドの後ろで取っていた 3 行です。

そこで、$? は表示したコマンドの直後に取り、パイプの後ろ・別のコマンドの後ろ・表示していない準備コマンドの後ろでは取らないと決めました。

出力を絞るときはファイルに落としてから見せる

x の行にリダイレクトを付けると、表示の行までファイルに入ってしまいます。

x echo hello > hello.txt
cat hello.txt
$ echo hello
hello

リダイレクトは x という関数の呼び出し全体にかかるので、printf で出した表示の行も同じファイルに書き込まれます。

長い出力を絞って見せたいときは、表示しない準備として出力をファイルに落とし、そのファイルを読むコマンドを x で見せます。

ls -R app > files.txt 2>&1   # 表示しない準備
x grep -c '\.js$' files.txt
rc
$ grep -c '\.js$' files.txt
2
$ echo $?
0

既存の実行例を直すときの確かめ方

関数を直しても、公開済みの実行例は古い形のまま残ります。

既存の注記の行は、「終了コード: N」を $ echo $? と N の 2 行に置き換える機械変換で直しました。

スクリプト側にも、同じ注記を出す echo が 540 行ありました。

変換した 453 行については、確かめ方を 2 つ用意しています。

1 つ目は、変換の前後で、記事ごとに数値の並びが一致するかを機械で照合することです。

2 つ目は、旧形式の実行例から整形前の生の出力を組み立て直し、新しい整形処理に通した結果が変換結果と一致するかを見ることです。

数値の並びは記事ごとにすべて一致し、2 つ目の照合でも 176 記事でコードブロック単位に一字一句一致しました。

1 つ目は変えてはいけない値が変わっていないか、2 つ目は作り直した場合と同じ形になったかを見ているので、置換の書き間違いと整形のずれを別々に捕まえられます。

最後に

"$*" で組み立てた表示は、出力が正しい限り、誰にも気づかれないまま残ります。

表示は読者がコピーして打つ「入力」なので、出力と同じように再現できるかを確かめる対象です。

表示した行をそのまま打てば同じ引数列になるかを往復テストで確かめ、終了コードは表示したコマンドの直後に取ると決めておけば、ログや手順書の実行例を安心して貼れます。

以上です。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA