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

前回は見つけた文字列リテラルを片っ端からDSLとみなして警告をつけていましたが、今回からちゃんとDSLっぽくなる予定。

まず、すごく簡単なDSLの仕組みを作ります。

public class MyDsl {
    
    private int value;
    
    private MyDsl(int value) {
        this.value = value;
    }

    /**
     * このDSLを評価した結果を返す。
     * @return 評価結果
     */
    public int eval() {
        return value;
    }

    /**
     * DSL記述を解析してオブジェクト化して返す。
     * @param dsl DSL記述
     * @return DSLオブジェクト
     * @throws IllegalArgumentException 引数が整数でない場合
     */
    public static MyDsl parse(String dsl) {
        if (dsl == null) {
            throw new IllegalArgumentException(dsl);
        }
        return new MyDsl(Integer.parseInt(dsl));
    }
}

MyDslの使い方はすごく簡単で(同時に無意味で)、こんな感じです。

MyDsl dsl = MyDsl.parse("255");
System.out.println(dsl.eval());

言語としてのMyDslは「整数を表す文字列」で、MyDsl#parseは引数に「整数を表す文字列リテラル」をとるものだと思ってください。最初から整数リテラル使えよというご意見はスルーの方向で。

さて、今回は「MyDsl#parseの引数だけをMyDslの記述とみなして、警告やエラーを貼り付ける」という方向で進めていきます。

メソッドを識別するしくみ

まず最初にやることは、ソースコードの中からMyDsl#parseというメソッドを探し当てることです。今回はデフォルトパッケージアクセスのstaticメソッドなのでそんなに大変ではないですが、普通の状態ではいろいろと考えなきゃならんことがあります。

  • MyDslクラスを単純名と完全限定名でアクセスされたらどうする
  • import static ...MyDsl.parse; されたらどうする
  • MyDsl my = ...; my.parse("1"); みたいに非staticでアクセスされたらどうする
  • ジェネリクスとかどうする

といった感じです。

こういった問題を解決するためにJDTではBindingという機構が提供されていて、「メソッド呼び出しの参照先に関する情報」というものを勝手に計算してくれます。これを使えば、どんな書き方をしていたとしても「参照先がMyDsl#parseであればよい」ということになります。

こまごまとしたことはとりあえずすっ飛ばして、「呼び出し先のメソッドを一意に特定する文字列」というものを生成するコードを書きます。Javaにはメソッドリテラルみたいな表現はありませんので、こういったキーとなる文字列を作っておくといろいろと便利です。

// 呼び出し対象のメソッドを一意に特定するキーを生成する
// <class-name> "#" <method-name> "(" ( <type> ( "," <type> )* )?  ")"
public static String getKey(MethodInvocation node) {
    // まず、MethodInvocationノードからIMethodBindingを取り出す
    IMethodBinding binding = node.resolveMethodBinding();
    if (binding == null) {
        return null;
    }
    StringBuilder buf = new StringBuilder();
    // メソッドを宣言する型を文字列へ変換して追加
    ITypeBinding erasure = binding.getDeclaringClass().getErasure();
    String typeName = erasure.getQualifiedName();
    buf.append(typeName);
    // 型とメソッド名の区切り文字を追加 ("#")
    buf.append('#');
    // メソッド名を追加
    buf.append(binding.getName());
    // メソッド名と引数一覧の区切り文字を追加 ("(")
    buf.append('(');
    // 引数の型一覧を追加
    ITypeBinding[] params = binding.getParameterTypes();
    if (params.length > 0) {
        buf.append(params[0].getErasure().getQualifiedName());
        for (int i = 1; i > params.length; i++) {
            buf.append(',');
            buf.append(params[i].getErasure().getQualifiedName());
        }
    }
    buf.append(')');
    return buf.toString();
}

こんな感じです。

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

ともあれ、これでメソッド呼び出しから、呼び出し対象のメソッドを一意にあらわす文字列を作れるようになりました。今回の場合は"MyDsl#parse(java.lang.String)"というキーでMyDslの構文解析を行う箇所を一意に特定できます。

メソッドを探す仕組み

先ほどの一意な文字列を作る仕組みを利用して、実際にMyDsl#parseを探します。

前回のMyCompilationParticipantを少し書き換えれば簡単に作れます。

最初に、MyDsl#parseをあらわす一意な文字列を定数としてくくりだしておきます。

/**
 * MyDsl#parse(String)メソッドをあらわすキー。
 */
public static final String TARGET_METHOD = "MyDsl#parse(java.lang.String)";

こいつを利用して、前回のcreateProblemsメソッドを書き換えます。

// ASTから文字列リテラルを探し出して、全部に警告を出す
private List<MyProblem> createProblems(final String fileName,
        final CompilationUnit ast) {
    final List<MyProblem> problems = new ArrayList<MyProblem>();

    ast.accept(new ASTVisitor() {
        @Override
        public boolean visit(MethodInvocation node) {
            // TARGET_METHODだけを対象に
            String key = getKey(node);
            if (!TARGET_METHOD.equals(key)) {
                return true;
            }

            // 引数の個数が違ったら失敗
            List<?> args = node.arguments();
            if (args.size() != 1) {
                return true;
            }
            Expression arg = (Expression) args.get(0);

            // 引数が文字列リテラルでない場合は警告
            if (arg.getNodeType() != ASTNode.STRING_LITERAL) {
                problem(IStatus.WARNING, "リテラルにして", arg);
                return true;
            }

            // 引数が整数でない場合はエラー
            StringLiteral string = (StringLiteral) arg;
            try {
                Integer.parseInt(string.getLiteralValue());
            }
            catch (NumberFormatException e) {
                problem(IStatus.ERROR, "整数にして", arg);
                return true;
            }

            // あとはOK
            return true;
        }

        // 問題を追加する
        private void problem(int kind, String message, ASTNode node) {
            MyProblem prom = new MyProblem(kind, message, fileName);
            int start = node.getStartPosition();
            prom.setSourceStart(start);
            prom.setSourceEnd(start + node.getLength() - 1);
            prom.setSourceLineNumber(ast.getLineNumber(start));
            problems.add(prom);
        }
    });
    return problems;
}

流れとしては

  1. すべてのMethodInvocation(メソッド呼び出し)を探す
  2. その中からMyDsl#parse呼び出しだけを探す
  3. 第一引数を取り出す
  4. 第一引数を気合で解析

といった泥臭い感じです。ただ、MethodInvocationを探した後は素直に書いたつもりです。

ためしに実行するとこんな感じに。

次はDSLをもう少し難しくして、そいつに警告出したりエラー出したりの予定。

週末が終わってしまった…