「diffの逆」って何だ?
diff
コマンドを使うと、ファイルの中で変更があった箇所を簡単に調べる事ができます。
ただ、たまにその逆の事をしたくなる場合があります。つまり、2つのファイルの中で変更があった部分は無視して、同じ行があったらそこを列挙して欲しい、という場面です。
例えば、多言語対応のための言語リソース(ロケール)について、未訳箇所は原語のままにするというルールで運用している場合に、原語のロケールと比較して未訳箇所を調べたいというような場合がこれにあたります。
menu.new.label=New File
menu.save.label=Save
menu.close.label=Close
menu.properties.label=Properties
menu.exit.label=Exit
button.new.label=New File
button.new.tooltip=Create new file
button.save.label=Save
button.save.tooltip=Save (Overwrite) this file
button.close.label=Close
button.close.tooltip=Close this file
button.properties.label=Properties
button.properties.tooltip=Show detailed information of this file
button.exit.label=Exit
button.exit.tooltip=Exit without save
menu.new.label=新規作成
menu.save.label=保存
menu.close.label=閉じる
menu.properties.label=Properties
menu.exit.label=終了
button.new.label=新規作成
button.new.tooltip=ファイルを新しく作る
button.save.label=保存
button.save.tooltip=ファイルを上書き保存する
button.close.label=閉じる
button.close.tooltip=ファイルを閉じる
button.properties.label=Properties
button.properties.tooltip=Show detailed information of this file
button.exit.label=終了
button.exit.tooltip=ファイルを保存せず終了する
こんな感じの2つのファイルがあったとして、menu.properties.label=Properties
などの箇所が未訳ということになりますが、あちこちに散らばっているこのような未訳箇所がどれだけあるかをパッと調べたい、ということです。
よく知られたやり方とその欠点
「inverted diff」「opposite diff」「reversed diff」「diffの逆」のようなキーワードで検索すると、以下のようなやり方が紹介されている事が多いです。
-
comm -1 -2 oldfile newfile
:2つの入力の共通行を調べるcomm
コマンドを使った例。ただし、これは両方のファイルの内容がソート済みである必要がある。 -
fgrep -x -f oldfile newfile
:指定文字列にマッチする行を出力するfgrep
1の-f
オプション2と-x
オプション3を使い、片方のファイル内の各行について、もう片方のファイルのいずれかの行と内容が完全一致する行を出力する。
他にもPerlを使う例もありますが、これらのやり方はいずれも、「2つのファイルで内容が同じ行を出力する」という物です。
それに対し、diff
では変更があった箇所を示すと同時にその前後の変更が無かった箇所も出力する、つまり前後の文脈を見る事ができます。「diffの逆」というなら、「変更が無かった箇所の前後の文脈」も見たくなって当然でしょう。
また、行単位で見れば内容が同じでも、出現位置が違うので全体としては意味が違う、という事もあります。前述の例はいずれも、このようなケースを検出できません。
真の「逆diff」
diffは、「ファイルを先頭から比較していき、内容が変化していない部分は飛ばして、内容が違う部分があったらそこを詳細に出力する」という事をします。
その反対となる逆diffには「ファイルを先頭から比較していき、内容が変化していない部分を詳細に出力して、内容が違う部分があったらそこは飛ばす」という事をして欲しいわけです。
ということで、それらしい事をやるシェルスクリプトを書いてみました。
#!/bin/bash
context_lines=3
while getopts c: OPT
do
case $OPT in
c)
context_lines=$OPTARG
shift 2
;;
esac
done
case $(uname) in
Darwin|*BSD|CYGWIN*)
esed="sed -E"
;;
*)
esed="sed -r"
;;
esac
oldfile="$1"
newfile="$2"
diff --new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L' \
<(cat "$oldfile" | tr '\r' '\n') \
<(cat "$newfile" | tr '\r' '\n') |
paste -s -d '\r' - |
$esed -e "s/^([-+][^\r]*\r)*(([-+][^\r]*\r){$context_lines})([^-+])/...\r\2\4/g" \
-e "s/(\r[^-+][^\r]*)((\r[-+][^\r]*){$context_lines})(\r[-+][^\r]*)+(\r?)$/\1\2\r...\5/g" \
-e "s/((\r[-+][^\r]*){$context_lines})(\r[-+][^\r]*)+((\r[-+][^\r]*){$context_lines})(\r[^-+]|$)/\1\r...\4\6/g" |
tr '\r' '\n'
実際にこれを先の例のファイルに対して実行すると、こんな結果になります。
$ ./inverted-diff.sh en-US.properties ja.properties
...
+menu.new.label=新規作成
+menu.save.label=保存
+menu.close.label=閉じる
menu.properties.label=Properties
-menu.exit.label=Exit
-button.new.label=New File
-button.new.tooltip=Create new file
...
+button.save.tooltip=ファイルを上書き保存する
+button.close.label=閉じる
+button.close.tooltip=ファイルを閉じる
button.properties.label=Properties
button.properties.tooltip=Show detailed information of this file
-button.exit.label=Exit
-button.exit.tooltip=Exit without save
+button.exit.label=終了
...
ちなみに、前後の行をもっと出力したい場合は./inverted-diff.sh -c 4 en-US.properties ja.properties
のように行数を-c
オプションで指定できるようにしてあります。
実装の解説
キモになるのは、省略せずに全行の差分を出力する方法です。diff
に--new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L'
という具合に「追加された行」「削除された行」「変更が無かった行」それぞれの出力フォーマットを個別に指定すると、変更が無かった部分が長く続いてもその部分が省略されていない出力結果を得る事ができます。
後の部分は、その出力に対して「変更が連続している部分をそれらしく省略する」という加工を施すための物です。以下、シス管系女子シリーズの読者の方向けに、本の中で解説していないテクニックについてもうちょっと詳しく解説しておきます。
-
while getopts
で名前付きの引数をオプションとして受け取る定番の方法を使って-c 4
のような行数指定を受け付けていますが、オプションの検出後にshift 2
で引数のリストの先頭から-c
と4
を取り除く事で、その後の$1
と$2
が確実に比較対象のファイルを示すようにしています。これは、名前付きオプションと名前無しの引数を併用する形のスクリプトを書く時に気をつけないといけないポイントです(shift 2
を忘れると引数の順番がズレてしまいます)。 -
case $(uname)
からのブロックは、環境によってsed
で拡張正規表現を使うためのオプションが違うという問題を回避するために自分がよく使っているやり方です。 -
<(cat "$oldfile" | tr '\r' '\n')
というのは、「cat "$oldfile" | tr '\r' '\n'
の実行結果の出力を内容としたファイルを、そこで指定した事にする」という書き方(bashのプロセス置換という機能)です。 -
sed
で複数行に渡る置換を行うために、一旦全行をpaste -s -d '\r' -
で「\r
区切りの1行の文字列」として結合しています。paste
を使った行の結合については別の記事で解説していますので、併せてご覧下さい。 - 拡張正規表現では、
(パターン){N}
と指定すると「そのパターンがN回繰り返された場合」を示す事ができます。これを使って、変更があった行が何行以上連続していたら間を省略する、ということをやっています。
残された課題
この実装ですが、やっつけ仕事なのでアラが色々あります。例えば以下のような具合です。
-
diff
の出力を一旦1行に繋げているので、入力が大きいとメモリを使いすぎる(多分)。 - 改行コードが
CR
かLF
かの違いが無視される。 - 改行コードが
CRLF
であるファイルは改行が二重に表示される。 -
diff -r
のような再帰的な複数ファイルの比較に対応していない。
自分はここまででやる気が尽きてしまいましたので、どなたかやる気に溢れた方の編集リクエストをお待ちしております。