Eclipse のライブラリを流用して diff 系ツールを自作する方法

「また明日から 2 つのデータの差分を目視で見つける仕事が始まるお……」
「ほんとは diff を使って速度も精度も上げたいんだお……」
「でもこの PC には JDKEclipse しか入れちゃいけないお……」
「……だから Eclipse を使って diff っぽいツールを自作するお!!」


ほんとは WinMerge とか入れられればいいんですけどね。「WinMerge 入れられない! 入れにくい!!」な環境でなんとかしてみようと思ったわけです。

使うライブラリ

Eclipse には「比較エディタ」という機能があり、2 つのファイルの差分を見ることができます。このとき差分検出に使われているライブラリ (というかプラグイン) は、classpath を通せば、自分のアプリケーションからも使えるっぽいです。
Eclipse 3.2 の場合でいうと、plugins ディレクトリにあるこの辺の jar を classpath に足せば動くっぽい。

  • org.eclipse.compare_なんちゃら.jar
  • org.eclipse.equinox.common_なんちゃら.jar
  • org.eclipse.jface_なんちゃら.jar

1 つめのが使いたいライブラリで、残り 2 つは org.eclipse.compare が依存しているものです。「なんちゃら」の部分には、バージョン番号と日付が入ってますね。

使い方

大きく分けて、次の 3 つのステップを踏みます。

  1. IRangeComparator.html の実装クラスを作る
  2. RangeDifferencer のメソッド (findDifferences() または findRanges()) を呼んで、結果を受け取る。
  3. RangeDifferencer が返した RangeDifference[] を好きなように使う

これらのクラスはいずれも org.eclipse.compare.rangedifferencer パッケージに含まれています。

IRangeComparator の実装クラスを作る

diff はテキストデータを行単位で比較するツールですが、RangeDifferencer は、それ以外の比較も行えるよう、汎用的なつくりになっています。例えば、テキストデータを

段落単位で比較するとか、Java のソースをコメント除いて比較するとか、Excel ワークシートを行単位で比較するとかですね。
裏を返すと、実際のデータにどのようにアクセスするかは、こちらが教えてあげないといけないということで、IRangeComparator の実装クラスがそれにあたります。実装するメソッドは 3 つ。

メソッド 説明
int getRangeCount() 素数を取得する
boolean rangesEqual(int thisIndex, IRangeComparator other, int otherIndex) 要素の比較を行う
boolean skipRangeComparison(int length, int maxLength, IRangeComparator other) 処理が長くなりすぎそうなら true を返す

簡単なところで、java.util.List 同士を比較する場合でいうと、こんな感じに実装するみたい。

import java.util.List;

import org.eclipse.compare.rangedifferencer.IRangeComparator;

public class ListRangeComparator<T> implements IRangeComparator {
    private List<T> list;
    
    public ListRangeComparator(List<T> list) {
        this.list = list;
    }
    
    public int getRangeCount() {
        return list.size();
    }
    
    public boolean rangesEqual(int thisIndex, IRangeComparator other, int otherIndex) {
        return list.get(thisIndex).equals(((ListRangeComparator<T>)other).list.get(otherIndex));
    }
    
    public boolean skipRangeComparison(int length, int maxLength, IRangeComparator other) {
        return false;
    }
}

RangeDifferencer を呼んで RangeDifference[] を受け取る

RangeDifferencer には findDifferences() と findRanges() というメソッドがあります。前者は差異だけを返すのに対し、後者は差異がなかった部分も報告してくるらしいです。また、どちらのメソッドもオーバーロードされていて、進行状況の通知を受け取るかどうかと、比較するデータの数 (2 or 3) が選べます。
いちばん簡単な「進行状況の通知なしで 2 ファイル比較」だと、次のような感じです。上で作った IRangeComparator 実装クラスを 2 つ渡します。

public RangeDifference[] compare(List<T> left, List<T> right) {
    ListRangeComparator<T> cl = new ListRangeComparator<T>(left);
    ListRangeComparator<T> cr = new ListRangeComparator<T>(right);
    
    RangeDifference[] diffs = RangeDifferencer.findDifferences(cl, cr);
    return diffs;
}

ちなみに、ドキュメントやメソッド名なんかを見ると、2 つのデータを比較する場合は left と right、3 つのデータを比較する場合は left と right と ancestor という呼び方をしています。ancestor (先祖) ということは、3 データの比較はどれかひとつを「変更前のもの」として扱うわけですね。

RangeDifference[] を好きに使う

比較した結果が配列で返されます。RangeDifference には以下のメソッドがあって、差異が見つかった箇所の開始・終了位置を取得することができます。

メソッド 説明
int kind() 差異の種類
int ancestorStart()
int ancestorEnd()
int ancestorLength()
int leftStart()
int leftEnd()
int leftLength()
int rightStart()
int rightEnd()
int rightLength()
int maxLength()

まぁ、名前でだいたいわかりますよね。kind() の値は、findDifferences() で 2 ファイル比較の場合は一種類しか返ってきません。それ以外の場合には、状況に応じて「ここは変わってなかった」とか「ここは left と right がコンフリクトしてた」というような情報が得られます。
後は目的に応じて処理すればいいのですが、例えば 2 つの List に対して findDifferences() で差分を取得したとすると、次のようにすれば結果を diff -u っぽくを出力することができます。

public void printDifferences(List<T> left, List<T> right, RangeDifference[] diffs) {
    for (RangeDifference diff : diffs) {
        
        System.out.printf(
                "@@ +%1$d,%2$d -%3$d,%4$d @@%n",
                diff.leftStart() + 1,
                diff.leftLength(),
                diff.rightStart() + 1,
                diff.rightLength());
        
        printDifference('-', left, diff.leftStart(), diff.leftEnd());
        printDifference('+', right, diff.rightStart(), diff.rightEnd());
    }
}

private void printDifference(char c, List<T> list, int start, int end) {
    for (T item : list.subList(start, end)) {
        System.out.printf("%1$c%2$s%n", c, item.toString());
    }
}