Java/EclipseでDSLサポート (2) - コンパイルプロセスへの介入

次に、Eclipse JDTの拡張ポイントを利用してコンパイルプロセスに介入していきます。
調べながら同時に書いていくのでゆっくりと。

方針

全体像を示しておくと、こんな感じです。

  • DSL確実にコンパイルできない場合はエラーを表示する
    • DSLが静的に解析可能で、エラーであることが自明である場合
  • DSLコンパイルできない可能性がある場合は警告を表示する
    • DSLが挿入されるべき場所に静的に解析できない変数などが含まれている場合

これらを表示するタイミングは、EclipseのJDTと同じようにしたいと思います。つまり

  • コーディングの最中にエラーや警告を表示
  • 保存(ビルド)時にエラーや警告があれば、Problemsビューにそれらを表示

といった感じです。

いろいろと調べてみたところ、警告やエラー表示はそれなりに簡単にできる模様で、調べながら物を作っていく過程を紹介していきたいと思います。

今回分の方針は、コンパイルプロセスへの介入をどうやればいいのかを調べるため、かなり簡素化したつくりにします。

  • ソースコードに含まれるすべての文字列リテラルに警告を表示
  • コーディングの最中に警告を表示
  • 保存時にProblemsビューに同様の警告を表示

プラグインの作成

とりあえず、先ほどの機能を満たすプラグインを作ります。
依存プラグインはこの辺で。

マーカーの作成

Eclipseに警告やエラーを表示させる場合、「org.eclipse.core.resources.markers」という拡張ポイントを使ってマーカーと呼ばれるものを作成し、それをエディタに貼り付ける形になります。今回作るマーカーは「com.example.ashigeru.marker」という名前で、次のような記述にします。

   <extension
         id="com.example.ashigeru.marker"
         point="org.eclipse.core.resources.markers">
      <persistent
            value="true">
      </persistent>
      <!-- Problemsビューに表示させる -->
      <super
            type="org.eclipse.core.resources.problemmarker">
      </super>
      <!-- テキストエディタ特有 -->
      <super
            type="org.eclipse.core.resources.textmarker">
      </super>
   </extension>

マーカーは特に実装クラスなどが必要ではありませんが、JDTで使う場合には"org.eclipse.jdt.core.compiler.CategorizedProblem"というクラスとマッピングさせておくといろいろな場面で利用できます。

ということで、MyProblem (extends CategorizedProblem)というクラスを作って、先ほど作成したマーカーとマッピングさせておきます。

package com.example.ashigeru;

import org.eclipse.core.runtime.IStatus;
import org.eclipse.jdt.core.compiler.CategorizedProblem;
import org.eclipse.jdt.core.compiler.IProblem;

public class MyProblem extends CategorizedProblem {

    /** extension id とそろえる。 */
    public static final String MARKER_ID = "com.example.ashigeru.marker";

    private int status;

    private String message;

    private String fileName;

    private int sourceStart;

    private int sourceEnd;

    private int lineNumber;

    public MyProblem(int status, String message, String fileName) {
        super();
        this.status = status;
        this.message = message;
        this.fileName = fileName;
    }

    @Override
    public int getCategoryID() {
        return CategorizedProblem.CAT_UNSPECIFIED;
    }

    @Override
    public String getMarkerType() {
        return MARKER_ID;
    }

    public String[] getArguments() {
        return new String[0];
    }

    public int getID() {
        return IProblem.ExternalProblemNotFixable;
    }

    public String getMessage() {
        return message;
    }

    public char[] getOriginatingFileName() {
        return fileName.toCharArray();
    }

    public boolean isError() {
        return status == IStatus.ERROR;
    }

    public boolean isWarning() {
        return status == IStatus.WARNING;
    }

    public int getSourceStart() {
        return sourceStart;
    }

    public int getSourceEnd() {
        return sourceEnd;
    }

    public int getSourceLineNumber() {
        return lineNumber;
    }

    public void setSourceStart(int sourceStart) {
        this.sourceStart = sourceStart;
    }

    public void setSourceEnd(int sourceEnd) {
        this.sourceEnd = sourceEnd;
    }

    public void setSourceLineNumber(int lineNumber) {
        this.lineNumber = lineNumber;
    }
}

コンパイルプロセスへの介入

Eclipse3.2くらいから、Eclipse JDTにAPTの機能が組み込まれました。こいつの実装は、JDTのコンパイルプロセスに介入しているのですが、そのタイミングからorg.eclipse.jdt.coreプラグインにcompilationParticipantという拡張ポイントが整備され、これを利用してコンパイルプロセスへの介入を簡単にできるようになりました。

とりあえず、拡張ポイントをさらしておきます。

   <extension
         id="com.example.ashigeru.compilationParticipant"
         point="org.eclipse.jdt.core.compilationParticipant">
      <!- com.example.ashigeru.MyCompilationParticipantをつかう。Problemsを生成 ->
      <compilationParticipant
            class="com.example.ashigeru.MyCompilationParticipant"
            createsProblems="true"
            id="com.example.ashigeru.myCompilationParcipant"
            modifiesEnvironment="false">
         <!- 先ほど作成したマーカーcom.example.ashigeru.markerを使う ->
         <managedMarker
               markerType="com.example.ashigeru.marker">
         </managedMarker>
      </compilationParticipant>
   </extension>

この拡張ポイントでは、「org.eclipse.jdt.core.compiler.CompilationParticipant」というコンパイラに介入する処理をJDTにぶち込むことができます。こいつは主にAPTがうまく行くように作られており、

  • ソースコードを書いている最中にreconcile要求がかかると、書いている途中のソースコードに警告やエラーを出せる (CompilationParticipant#reconcile)
  • ビルド処理に介入できる (CompilationParticipant#buildStarting)

といったことができます。

上の記述では次のような制約の元に、コンパイルプロセスでいろんなことをやってOK。

  • createsProblems="true" : 警告やエラーを表示できる
  • modifiesEnvironment="false" : 環境に影響を与えられない
  • markerType="com.example.ashigeru.marker" : MyProblem ("com.example.ashigeru.marker") を使える

ということで、MyCompilationParticipantはこんな感じになりました。

package com.example.ashigeru;

import java.util.ArrayList;
import java.util.List;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.compiler.BuildContext;
import org.eclipse.jdt.core.compiler.CompilationParticipant;
import org.eclipse.jdt.core.compiler.ReconcileContext;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.StringLiteral;

public class MyCompilationParticipant extends CompilationParticipant {

    @Override
    public boolean isActive(IJavaProject project) {
        return true;
    }

    // 書いている途中の警告表示
    @Override
    public void reconcile(ReconcileContext context) {
        CompilationUnit ast;
        try {
            ast = context.getAST3();
            if (ast == null) {
                return;
            }
        }
        catch (JavaModelException e) {
            return;
        }

        String fileName = context.getWorkingCopy().getElementName();
        List<MyProblem> problems = createProblems(fileName, ast);
        context.putProblems(MyProblem.MARKER_ID,
            problems.toArray(new MyProblem[problems.size()]));
    }

    // ビルド時の警告表示
    @Override
    public void buildStarting(BuildContext[] files, boolean isBatch) {
        for (BuildContext context : files) {
            String fileNAme = context.getFile().getName();
            CompilationUnit ast = createAstFromFile(context.getFile());
            List<MyProblem> problems = createProblems(fileNAme, ast);
            context.recordNewProblems(
                problems.toArray(new MyProblem[problems.size()]));
        }
    }

    // IFileからASTを作る
    private CompilationUnit createAstFromFile(IFile file) {
        ICompilationUnit cu = JavaCore.createCompilationUnitFrom(file);
        ASTParser parser = ASTParser.newParser(AST.JLS3);
        parser.setSource(cu);
        parser.setResolveBindings(true);
        CompilationUnit ast = (CompilationUnit) parser
            .createAST(new NullProgressMonitor());
        return ast;
    }

    // ASTから文字列リテラルを探し出して、全部に警告を出す
    private List<MyProblem> createProblems(final String fileName,
            final CompilationUnit ast) {
        final List<MyProblem> problems = new ArrayList<MyProblem>();
        
        // すべてのStringLiteralに対して警告を出しておく
        ast.accept(new ASTVisitor() {
            @Override
            public boolean visit(StringLiteral node) {
                MyProblem prom = new MyProblem(IStatus.WARNING, node
                    .getEscapedValue(), fileName);
                int start = node.getStartPosition();
                prom.setSourceStart(start);
                prom.setSourceEnd(start + node.getLength());
                prom.setSourceLineNumber(ast.getLineNumber(start));
                problems.add(prom);
                return false;
            }
        });
        return problems;
    }
}

実行してみるとこんな感じ。

次は「すべての文字列リテラル」から「DSLとして利用される文字列リテラル」に範囲を絞って警告やエラーを出そうと思います。