[Shell] find, xargs コマンドのおとしあなを詳説してみる

# 前口上

いま従事しているプロジェクトは Windows のみなのですが、このまえのプロジェクトは GNU/Linux の Bash と Windows にも Git Bash がいれられていて Bash をふかぼり研究してました。そのときにはまってたまたま発見した解決法を自分自身が納得するためにかきちらかした解説文がでてきたので加筆修正して投稿します。 (PowerShell もおもしろいが、やっぱつぎのプロジェクトは Shell がメインであるのがいい…)

# find, xargs コマンドではまったこと

まさに [こちらの記事](https://keens.github.io/blog/2017/10/17/fun_ikideshieruwotsukatteiruninnotamenoshierunyuumon/#xargs "雰囲気でシェルを使っている人のためのシェル入門 | κeenのHappy Hacκing Blog") でふれられていることなのですが、 (記事の例にそくしていうと) 意図としてはカレント ディレクトリーにある拡張子 `jpg` のファイルを拡張子 `jpeg` に変更しようと、つぎのようにかいてしまうようなことをして、しばしおもいなやんだわけです[^1]。

[^1]: `find` ならさしずめ `find -maxdepth 1 -name *.jpg -type f -exec mv {} "$(basename {} .jpg).jpeg" \;`

```sh
ls *.jpg | xargs -I@ mv @ "$(basename @ .jpg).jpeg"
```

`xargs -I@ mv ...` はパイプからうけとった `ls` の出力の一項目ずつを `@` に代入しては `mv ...` を実行するのをくりかえします。たとえば `ls *.jpg` の結果が

```
bar.jpg  baz.jpg  foo.jpg
```

だったとすると `xargs -I@ mv @ "$(basename @ .jpg).jpeg"` は

```sh
mv bar.jpg "$(basename bar.jpg .jpg).jpeg"
mv baz.jpg "$(basename baz.jpg .jpg).jpeg"
mv foo.jpg "$(basename foo.jpg .jpg).jpeg"
```

と展開、さらに `$()` のなかみが実行されて

```sh
mv bar.jpg "bar.jpeg"
mv baz.jpg "baz.jpeg"
mv foo.jpg "foo.jpeg"
```

となることを期待したわけです。なお、 `basename foo.jpg .jpg` とすると `foo.jpg` から `.jpg` を除去できます[^2]。

[^2]: `basename` コマンドにとって `foo.jpg` はたんなる文字列にすぎないので `foo.jpg` というファイルが実在していなくてもべつにエラーにはならない。

ところがどっこい、シェルはふつうダブル クオーテーションでかこまれた `$(...)` のなかみをさきに別プロセスとして実行してしまいます。実際にうちこんでみればわかるように `basename @ .jpg`  の結果は `@` です[^3]。つまり、ここでは `$(basename @ .jpg)` イコール `@` です。

[^3]: 文字列としての `@` には `.jpg` はついてないからべつになにも除去しないでそのまま。

したがって、いちばんうえのコマンドラインはじつはまず

```sh
ls *.jpg | xargs -I@ mv @ "@.jpeg"
```

になっていたのでした。ふたたび、たとえば `ls *.jpg` の結果が

```
bar.jpg  baz.jpg  foo.jpg
```

だとすると `xarg -I@ mv @ "@.jpeg"` は

```
mv bar.jpg bar.jpg.jpeg
mv baz.jpg baz.jpg.jpeg
mv foo.jpg foo.jpg.jpeg
```

となります。これは最初に意図したこととはあきらかにちがいます。

# ワーク アラウンド

それで、くだんの筆者さんは `for` や `while read` をつかうことになるとおっしゃってるんですが、たまたま業務でつかっていたシェル スクリプトの内容をよんでいて発見した手法なんですけど、この例のコマンドラインにそくしてかくと[^4]

[^4]: `find` なら `find -maxdepth 1 -name *.jpg -type f -exec sh -c 'mv {} "$(basename {} .jpg).jpeg"' \;`

```sh
ls *.jpg | xargs -I@ sh -c 'mv @ "$(basename @ .jpg).jpeg"'
```

`sh` も `-c` も `'mv @ ...'` もここではまだ `xargs -I@` への引数にすぎません。とくに `'mv @ ...'` はシングル クォーテーションでくくられているのでシェルはなにもせずそのまま `xargs` にわたします。したがって、みたび、たとえば `ls *.jpg` の結果が

```
bar.jpg  baz.jpg  foo.jpg
```

だとすると `xargs -I@ sh -c 'mv @ "$(basename @ .jpg).jpeg"'` は

```
sh -c 'mv bar.jpg "$(basename bar.jpg .jpg).jpeg"'
sh -c 'mv baz.jpg "$(basename baz.jpg .jpg).jpeg"'
sh -c 'mv foo.jpg "$(basename foo.jpg .jpg).jpeg"'
```

となります。 `sh -c '文字列'` は `文字列` をコマンドラインとして実行させるコマンドです。

なお、

```sh
ls *.jpg | xargs -I@ mv @ '$(basename @ .jpg).jpeg'
```

というのはうまくいきません。たしかに、シングル クオーテーションでかこまれているのでそのまま `xargs` にわたり

```
mv bar.jpg $(basename bar.jpg .jpg).jpeg
mv baz.jpg $(basename baz.jpg .jpg).jpeg
mv foo.jpg $(basename foo.jpg .jpg).jpeg
```

とはなりますが、ここではもはやあくまで `xargs` の処理するところであり、シェルの `$(...)` 展開のおよぶところではないので、 `foo.jpg` がもじどおり `$(basename foo.jpg .jpg).jpeg` というファイル名に変更されてしまいます。したがって、 `sh -c` が必要というわけです。

# 参考文献

- [雰囲気でシェルを使っている人のためのシェル入門 | κeenのHappy Hacκing Blog](https://keens.github.io/blog/2017/10/17/fun_ikideshieruwotsukatteiruninnotamenoshierunyuumon/ "雰囲気でシェルを使っている人のためのシェル入門 | κeenのHappy Hacκing Blog")