Java/EclipseでDSLサポート (6) - 意味エラーの表示

前回の「http://d.hatena.ne.jp/ashigeru/20080713/1215962299」までで構文エラーを検出できてますが、今回は構文が持つ意味にまで言及してみようと思います。

ListFilterに対して、次のようなHogeクラスのリストを食わせる前提で。

public class Hoge {
    public int a;
    public int b;
    public String message;
    public Hoge(int a, int b) {
        this.a = a;
        this.b = b;
    }
}

前回までので検査してみると、次のようなのはOKです。

今回が終わるとこうなります。

コンテキストを考える

今回の目標は、存在しないフィールドや使っちゃいけないフィールドが含まれていたらエラーを表示することです。

存在しないフィールドって言うのはHogeの中で存在しないというだけで、まずはその対象クラスが何であるかということを判定しなければいけません。これはソースコードを眺めていても見つからなくて、ちゃんと意味解析しないとダメです。

意味解析は非常に面倒なのですが、今回はEclipseのIBinding周りをつかってその大半をスキップできます。

サンプルプログラムとして使ったのがこんな呼び出し方ですが、start, includes, excludesはそれぞれメソッドチェインを使っています。

ListFilter.start(list)
    .includes("a == c")
    .excludes("b == message")
    .toList();

メソッドチェインを展開して毎回変数に保存すると、次のような感じになります。

Filtering<Hoge> s0 = ListFilter.start(list);
Filtering<Hoge> s1 = s0.includes("a == c");
Filtering<Hoge> s2 = s1.excludes("b == message");
List<Hoge> result = s2.toList();

これをよく眺めてみると、

Filtering<Hoge> s0 = ...
... = s0.includes("a == c");

となりますので、

Filtering<Hoge>.includes("a == c")

というメソッドを実際には呼び出していることが分かります。つまり、メソッドを宣言している型が分かれば、そのメソッドのDSLが利用可能なフィールドの一覧が分かることになります。

ここで、過去の記事を思い出してみると、こんなのがありました。

途中途中で ITypeBinding#getErasure() を使っているのは、普通にやるとジェネリクスのための記述が含まれてしまうためです。erasure変換すると、Java2SE-1.4のころのジェネリクスがなかった世界に持っていくことができます。

Java/EclipseでDSLサポート (3) - DSLとして使われる文字列リテラルの判定 - しげるメモ

このときはメソッドの一意なキーを作り出すためにわざわざ型パラメータを除去していましたが、今回はイレイジャ変換をかけないことによって、コンテキストとなる方を抽出します。

こんな感じ。

private static ITypeBinding getContextType(MethodInvocation node) {
    IMethodBinding binding = node.resolveMethodBinding();
    
    // Filtering<Hoge>.includes から Filtering<Hoge> をとりだす
    ITypeBinding declaring = binding.getDeclaringClass();
    
    // パラメータ化型じゃなければコンテキストは不明
    if (!declaring.isParameterizedType()) {
        return null;
    }
    
    // Filtering<Hoge> から Hoge を取り出す
    ITypeBinding t = declaring.getTypeArguments()[0];
    t = t.getErasure(); // ワイルドカードを消す (? extends Hoge -> Hoge)
    return t;
}

意味を検査する

今回検査したい対象を明確にしておきます。

  • 演算数(左)がプロパティ名ならば、
    • そのプロパティはコンテキスト型にフィールドとして存在している
    • そのフィールドはpublicで宣言されている
    • そのフィールドはint型で宣言されている
  • 演算数(右)がプロパティ名ならば、
    • 演算数(左)と同様に取り扱う

といった感じです。

まずは、左右の演算数がプロパティ名であるかどうかの検査から。

// 意味を検査する
private void checkSemantics(MethodInvocation node,
        StringLiteral string, ListFilterDsl dsl) {

    // コンテキスト型を取り出す
    ITypeBinding t = getContextType(node);
    if (t == null) {
        // コンテキストが分からなければ警告
        problem(
            IStatus.WARNING,
            "型弱いよ",
            node,
            node.getStartPosition(),
            node.getLength());
        return;
    }
    
    // 演算数に対して検査
    if (dsl.left.kind == Kind.PROPERTY) {
        checkProperty(string, dsl.left, t);
    }
    if (dsl.right.kind == Kind.PROPERTY) {
        checkProperty(string, dsl.right, t);
    }
}

これだけで、あとはcheckPropertyに委譲しています。

つぎに、checkPropertyの中で上記3つの検査を行います。

private void checkProperty(
        StringLiteral string,
        ListFilterToken token,
        ITypeBinding type) {
    IVariableBinding field = null;
    // 宣言されたフィールドから同じ名前のものを探す
    // 本当は継承まで考えるんだけど、今回はPOJOで。
    for (IVariableBinding f: type.getDeclaredFields()) {
        if (f.getName().equals(token.image)) {
            field = f;
            break;
        }
    }
    if (field == null) {
        // フィールドが見つからない!
        problem(
            IStatus.ERROR,
            MessageFormat.format(
                "フィールド{0}は{1}にない",
                token.image,
                type.getQualifiedName()),
            string,
            token.offset + 1, token.image.length());
    }
    else if (!Modifier.isPublic(field.getModifiers())) {
        // publicじゃない!
        problem(
            IStatus.ERROR,
            MessageFormat.format(
                "フィールド{1}#{0}はpublicじゃない",
                token.image,
                type.getQualifiedName()),
            string,
            token.offset + 1, token.image.length());
    }
    else if (!field.getType().getQualifiedName()
            .equals("int")) {
        // int型じゃない!
        problem(
            IStatus.ERROR,
            MessageFormat.format(
                "フィールド{1}#{0}はint型じゃない",
                token.image,
                type.getQualifiedName()),
            string,
            token.offset + 1, token.image.length());
    }
}

組み込む

最後にコンパイルプロセスに組み込みます。
いつもどおり、checkProblemsを部分的に差し替える感じで。

// 引数の検査
StringLiteral string = (StringLiteral) arg;
String literal = string.getLiteralValue();
try {
    List<ListFilterToken> tokens =
        ListFilterScanner.scan(literal);
    ListFilterDsl dsl =
        ListFilterParser.parse(tokens);
    checkSemantics(node, string, dsl);
}
// スキャナーの失敗
catch (ListFilterScannerException e) {
    ...
}
// パーサの失敗
catch (ListFilterParserException e) {
    ...
}

前回はListFilterParser.parseで止めていましたが、今回はその先のcheckSemanticsまで呼んでいます。

とりあえずここまででエラーや警告を出すところまで完了です。

結果としてこうなりました。

この仕組みが便利なのかどうなのか、簡単に組み込めるものなのか、フレームワーク化できるものなのかは未検証な状態です。
ただ、今まであまり目を向けられてこなかったけど、それなりに簡単にできそうなのでいろいろと考えてみたいと思います。

ここまでは「失敗を見つけたらエラー表示」というディフェンシブな感じでしたが、次回以降は「分からなかったらIDEが教える」というオートコンプリート関係のオフェンシブなところを掘っていきたいと思います。

...というところまで今週でやる予定だった。