リンク切れしたシンボリックリンクを探す

id:eel3:20121112:1352651058 でシンボリックリンクを張りなおすMakefileを書いたのだが、デッドリンク(リンク切れしたシンボリックリンク)がそのまま残ってしまう問題があった。

この問題に対処するために、デッドリンクを探す方法を――『覚えて便利 いますぐ使える!シェルスクリプトシンプルレシピ54』から持ってくることにした。ただ、ちょっと理由があって、デッドリンクを消すのではなく列挙することにした。単純に列挙するだけなら、他のツールと組み合わせて色々なことができるからだ。

Version 1

初期バージョン。

#!/bin/sh
# deadlink: select dead symlinks from arguments
# bug:
#   ----
#   xxxx -> yyyy (File xxxx is symlink to yyyy.)
#   yyyy -> zzzz (File yyyy is deadlink! file zzzz is not exist.)
#   ----
#   In above case, this script assumes that file xxxx is deadlink.

PATH=/bin:/usr/bin

for i; do
    [ -L "$i" -a ! -e "$i" ] && echo "$i"
done

考え方は単純で、シンボリックリンクかつ存在しないファイルを探せばよい。-hや-Lではファイルがシンボリックリンクか否かのみチェックするだけで、シンボリックリンクを辿ることはない。一方でファイルの存在をチェックする-eはシンボリックリンクを辿る。もしデッドリンクだったなら、-hや-Lは真を返すが、-eはシンボリックリンクを辿るのに失敗して偽を返す。

ただ、この方法は「シンボリックリンクを指し示すシンボリックリンク」では意図しない挙動となるかもしれない。xxxxがyyyyを指し示すシンボリックリンクで、yyyyがzzzzを指し示すシンボリックリンクで、zzzzが存在しなかったと仮定する。zzzzが存在しないのでyyyyはデッドリンクだ。xxxxは、yyyyが存在するのでデッドリンクではないはずだが、シンボリックリンクを辿った先の最終地点であるzzzzが存在しないので、このスクリプトアルゴリズムではデッドリンクと見なされてしまう。

もっとも今回の最終目的はあくまで「デッドリンクを消す」だ。デッドリンクであるyyyyが消された時点でyyyyを指し示していたxxxxはデッドリンクになる――削除対象となるのだから、最初の時点でxxxxまでデッドリンクとして列挙されても問題はないだろう。

ちなみに使い方はこんな感じ。

$ deadlink *
xxxx
yyyy
$ deadlink * | xargs -d '\n' rm
$ _

引数として渡したファイル名に対してデッドリンクか否かチェックを行い、1行1ファイル名の形式で標準出力に表示する。この例ではカレントディレクトリのファイルをチェックした結果、デッドリンクとしてxxxxとyyyyが列挙されている。もしデッドリンクを削除したいのなら、xargs(1)を使ってrm(1)の引数に渡せばよい。

ところで、なぜ「引数に指定したフォルダの中身をチェックしてデッドリンクを列挙する」みたいな仕様にしなかったのか? 理由は、それをやろうとすると面倒だから。再帰的にチェックするか否かとか、再帰レベルを調整できるように云々とか、そんな機能は他のツールに任せれば十分だ。具体的にはfind(1)とか。

Version 2

最初に書いたdeadlinkの実装では、チェック対象のファイルを引数として渡す仕様になっていた。

仮にあるディレクトリ以下にて再帰的にデッドリンクをチェックしたい場合、どうするのか? find(1)とxargs(1)を使えばよい。

find ./work -type l -print0 | xargs -0 deadlink

シンボリックリンクか否かのチェックを二重に行っているが、気にしない方向で。find(1)は列挙したファイルを標準出力に書き出すので、xargs(1)を使ってdeadlinkの引数に渡すようにする。

ただxargs(1)を使う方法はファイル数が多くなったときに効率の面が気になる。というのも、引数として許される上限までファイルが列挙されないとdeadlinkが実行されないのだ。なので下手をすればfind(1)によるファイルの列挙が完了してからdeadlinkが実行されるだろうし、そうなる可能性は高いと思う。

しかし、もしdeadlinkが標準入力からファイル名を読み込みつつデッドリンクか否かのチェックを行うのなら、find(1)などによるファイルの列挙とdeadlinkによるチェックが並行して実行される。マルチコア環境では高速化が期待できる。実際にはfind(1)もdeadlinkもファイルシステムにアクセスするので、効果は低いかもしれないが。

そんな訳で標準入力からもファイル名を読み込むことが可能なように改造することにした。ただ元々の仕様も捨て難い。元の仕様を残すのなら「引数が無かったら標準入力から読み込む」という仕様では都合が悪い(メタキャラクタであるアスタリスクを引数に指定したら、ファイルが無かったので引数ゼロになった――なんて可能性があるので)。そこで引数が `-' の1つのみだった場合にのみ標準入力から読み込むようにしてみた。

#!/bin/sh
# deadlink: select dead symlinks from arguments
# bug:
#   ----
#   xxxx -> yyyy (File xxxx is symlink to yyyy.)
#   yyyy -> zzzz (File yyyy is deadlink! file zzzz is not exist.)
#   ----
#   In above case, this script assumes that file xxxx is deadlink.

PATH=/bin:/usr/bin

if [ $# -eq 1 -a "$1" = '-' ]; then
    # Trims IFS character from the beginning and end of the $i.
    while read i; do
        [ -L "$i" -a ! -e "$i" ] && echo "$i"
    done
else
    for i; do
        [ -L "$i" -a ! -e "$i" ] && echo "$i"
    done
fi

実はチェック対象のファイルが1つだけで、且つファイル名が `-' だった場合に困るのだが……そこは運用でカバー、ということで。

Version 3

標準入力からもファイル名を読み込むことが可能なようにしたものの、今度はwhile readを使っている点が気になる。高速化のために追加した部分なのに、低速な機能を使うというのも妙な話だ。

そこでwhile readによるループの部分をスクリプト言語で書くことにした。

最初はawkを使おうとしたのだが、system()を使ってtest(1)を呼び出す部分の外部コマンドの組み立てが面倒だった(特にファイル名のエスケープ処理が……)。そこでPerl5にしてみた。Perlが入っている環境は結構多いはずなので、問題になることは少ないだろう。

#!/bin/sh
# deadlink: select dead symlinks from arguments
# bug:
#   ----
#   xxxx -> yyyy (File xxxx is symlink to yyyy.)
#   yyyy -> zzzz (File yyyy is deadlink! file zzzz is not exist.)
#   ----
#   In above case, this script assumes that file xxxx is deadlink.

PATH=/bin:/usr/bin:/usr/local/bin

if [ $# -eq 1 -a "$1" = '-' ]; then
    perl -n -e 'chomp; -l $_ && ! -e $_ && print "$_\n";'
else
    for i; do
        [ -L "$i" -a ! -e "$i" ] && echo "$i"
    done
fi

うん、非常に短い。もういい加減sayを使うべきなんだろうけどprintを使用している。

最近はデフォルトでPerl5が入っていない(!)事態も起こりそうなので、頑張ってPythonでも書いてみた。何が「頑張って」なのかというと、インデント位置である。

#!/bin/sh
# deadlink: select dead symlinks from arguments
# bug:
#   ----
#   xxxx -> yyyy (File xxxx is symlink to yyyy.)
#   yyyy -> zzzz (File yyyy is deadlink! file zzzz is not exist.)
#   ----
#   In above case, this script assumes that file xxxx is deadlink.

PATH=/bin:/usr/bin:/usr/local/bin

if [ $# -eq 1 -a "$1" = '-' ]; then
    python -c '
import os, sys
for line in sys.stdin:
  i = line.strip()
  if os.path.islink(i) and not os.path.exists(i):
    print i'
else
    for i; do
        [ -L "$i" -a ! -e "$i" ] && echo "$i"
    done
fi

インデント位置で怒られたので、仕方なくPythonのコードを行頭から書き始めることにした。うーん、見栄えが良くない……。なおPython 2.7にてそれっぽく動作することは確認したが、Python 3で動作するか否かは不明だ。

(念のため書いておくと、Pythonのインデントに関する仕様は特に気にしていない。少なくとも、Python単体でコードを書く分には好ましいと思っている。シェルスクリプトに埋め込むという使い方が邪道気味なのだ)

個人的にはRubyが好みなのだが――PerlPythonは入っていないがRubyは入っている、という環境は考えにくい。むしろ逆にRubyが入ってない環境の方が多そうなので、Ruby版は書いていない。

しかし、ここまでくると、シェルスクリプトPerlPythonのコードを埋め込むのではなく、全てPerlPythonで書いてしまった方がスッキリするような気が……。

終わりに

最終的には、上記のPerl5版に手を加えて、こんな感じになった。

https://github.com/eel3/eel3-scripts/blob/master/bin/deadlink