Java/EclipseでDSLサポート (4) - スキャナエラーの表示

立て込んでてイマイチな進捗。

とりあえず今回は簡単なフィルタ - しげるメモで作ったDSLに対して、字句レベルのエラーを表示してみようと思います。

完成品がコレ。

簡単なパーサ作るところをJFlexとか使わないでだらだらと進めていくので、最後のほうだけ読むとEclipseの部分が見えてきます。

ListFilterクラスの定義

ここまで

という流れで進めてきたので、そのメソッド呼び出しを提供するクラスを作っておきます。

package com.example.ashigeru.dsl;

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

public class ListFilter {

    public static <T> Filtering<T> start(List<? extends T> list) {
        return new Filtering<T>(list);
    }

    public static class Filtering<T> {

        private List<? extends T> list;

        Filtering(List<? extends T> list) {
            this.list = list;
        }

        public Filtering<T> includes(String dsl) {
            List<? extends T> filtered = list; // 実際はフィルタをかける
            return new Filtering<T>(filtered);
        }

        public Filtering<T> excludes(String dsl) {
            List<? extends T> filtered = list; // 実際はフィルタをかける
            return new Filtering<T>(filtered);
        }

        public List<T> toList() {
            return new ArrayList<T>(list);
        }
    }
}

別にDSL作りたいワケじゃなくて、DSLIDEからサポートしたいだけなので、中身は空っぽです。
暇があったら中身も作りますよ。

ここで注意してほしいのは、DSLを引っ掛ける場所が

  • com.example.ashigeru.dsl.ListFilter.Filtering#includes(String)
  • com.example.ashigeru.dsl.ListFilter.Filtering#excludes(String)

といった感じに、ListFilterクラスじゃなくてListFilter.Filteringクラスであるところ。

コレまでと同様、メソッド呼び出しに一意なキーを使って引っ掛けるので、間違えると何もしなくなります。

構文解析の基礎

DSL作りたいワケじゃないですが、DSL構文解析まではIDE側でしなきゃならんので、簡単に構文解析ってどうやるのというのを書いておきます。

  1. 文字の列に字句解析をかけて、トークンの列に
  2. トークンの列を構文解析をかけて、抽象構文木

といった2つのパスがあります。

ソースコードの時点ではただの文字の列で取り扱いにくいため、最初に字句解析という処理を行います。

整数の四則演算に関する言語があった場合、たとえば次のような文があります。

10 + 20 * 30

こいつは、

  • '1', '0', ' ', '+', ' ', '2', '0', ' ', '*', ' ', '3', '0'

という文字の列なので、

  • "10" : number
  • "+" : operator
  • "20" : number
  • "*" : operator
  • "30" : number

というように、言語が認識できる「単語(≒トークン)」の列にしてやるのが字句解析です。

まだこのままでは意味のない単語の列なので、次の構文解析はこのトークンの列を構造的に並べてやります。

  +
 / \
10 *
  / \
 20 30

な感じ。

今回は、字句解析の部分で一度とめて、そこまでのエラーを表示する予定。

トークンの定義

まず、スキャナによって生成されるトークンを定義します。スキャナジェネレータとか使えば自動生成してくれるやつもありますが、とりあえずは手書きで。

package com.example.ashigeru.dsl;

public class ListFilterToken {
    
    /** このトークンの種類 */
    public final Kind kind;
    
    /** トークンを構成する文字列 */
    public final String image;
    
    /** このトークンの開始位置 */
    public final int offset;
    
    public ListFilterToken(Kind kind, String image, int offset) {
        super();
        this.kind = kind;
        this.image = image;
        this.offset = offset;
    }
    
    /** トークンの種類 */
    public enum Kind {
        
        /** プロパティ名 */
        PROPERTY,
        
        /** 数値 */
        NUMBER,
        
        /** 演算子 */
        OPERATOR,
    }
}

トークンは、種類、文字列、開始位置辺りを持ってればたいていは十分です。

スキャナの作成

文字列から先ほどのトークンの列を作るスキャナを作ります。

先に文法を確認しておくと、こんな感じでした。

    <PROGRAM> ::= <TERM> <OP> <TERM>

    <TERM> ::= <PROPERTY>
            |  <INTEGER>

      <OP> ::= "=="
            |  "!="
            |  ">"

<PROPERTY> ::= ["a"-"z", "A"-"Z"]["a"-"z", "A"-"Z", "0"-"9"]*

 <INTEGER> ::= "0"
            |  ["+", "-"]? ["1"-"9"]["0"-"9"]*
簡単なフィルタ - しげるメモ

ってことで、トークンとして定義しなきゃならないのは

  • OP
  • PROPERTY
  • INTEGER

の3つです。

それにしたがってスキャナを書いてみます。

package com.example.ashigeru.dsl;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.example.ashigeru.dsl.ListFilterToken.Kind;

public class ListFilterScanner {

    // プロパティ名
    private static final Pattern PATTERN_PROPERTY =
        Pattern.compile("[A-Za-z][A-Za-z0-9]*");

    // 数値
    private static final Pattern PATTERN_NUMBER =
        Pattern.compile("[\\+\\-]?(0|[1-9][0-9]*)");
    
    // 演算子
    private static final Pattern PATTERN_OPERATOR =
        Pattern.compile("==|!=|>");

    // すべての正規表現
    private static final Pattern PATTERN_DSL = Pattern.compile(
        PATTERN_PROPERTY.pattern() + "|" +
        PATTERN_NUMBER.pattern() + "|" +
        PATTERN_OPERATOR.pattern());
    
    public static List<ListFilterToken> scan(String source)
            throws ListFilterScannerException {
        List<ListFilterToken> tokens = new ArrayList<ListFilterToken>();
        Matcher m = PATTERN_DSL.matcher(source);
        int start = 0;
        while (m.find(start)) {
            // start ~ m.start : スキップした文字列
            checkSkipped(source, start, m.start());
            // m.start ~ m.end : 検出したトークンの場所
            String image = source.substring(m.start(), m.end());
            
            // トークンを追加
            Kind kind = computeTokenKindOf(image);
            tokens.add(new ListFilterToken(kind, image, m.start()));
            
            // 次の開始位置にセット
            start = m.end();
        }
        // start ~ source.length : スキップした文字列
        checkSkipped(source, start, source.length());
        
        return tokens;
    }

    // タグつき正規表現が使えないので、面倒だけど分類のために再マッチング
    private static Kind computeTokenKindOf(String image) {
        if (PATTERN_PROPERTY.matcher(image).matches()) {
            return Kind.PROPERTY;
        }
        if (PATTERN_NUMBER.matcher(image).matches()) {
            return Kind.NUMBER;
        }
        if (PATTERN_OPERATOR.matcher(image).matches()) {
            return Kind.OPERATOR;
        }
        throw new AssertionError(image);
    }

    // 空白文字のつもりでスキップしたけど、本当に空白だったのか調べる
    private static void checkSkipped(String source, int s, int e)
            throws ListFilterScannerException {
        int errorOffset = -1;
        int errorLength = 0;
        for (int i = s; i < e; i++) {
            char c = source.charAt(i);
            if (!Character.isWhitespace(c)) {
                // 空白じゃない
                if (errorLength == 0) {
                    // はじめてみた空白以外の文字なら、開始位置を記憶
                    errorOffset = i;
                }
                errorLength++;
            }
            else if (errorLength > 0) {
                // すでに空白以外の文字を見ていたら、
                // 次に空白がきたところでエラー検出を終了
                break;
            }
        }
        // エラーがあれば投げる
        if (errorLength > 0) {
            throw new ListFilterScannerException(
                errorOffset,
                errorLength,
                source.substring(errorOffset, errorOffset + errorLength));
        }
    }
}

本当は字句解析のテーブルを書いて効率よくやる方法はあるのですが、人類が読めないコードになりがちなのでこの辺りで止めておきます。

ここでのポイントは、「トークンを生成する際に、読めない文字を見つけたらエラーを投げる」というところです(checkSkipped)。このエラーはListFilterScannerExceptionというクラスで定義してあります。

package com.example.ashigeru.dsl;

public class ListFilterScannerException extends Exception {
    
    /** エラー開始位置 */
    public final int offset;
    
    /** エラーの長さ */
    public final int length;
    
    /** エラー文字列 */
    public final String image;

    public ListFilterScannerException(
            int offset, int length, String image) {
        super();
        this.offset = offset;
        this.length = length;
        this.image = image;
    }
}

ともかくこれで、ListFilterScanner.scan("")と呼び出せば

  • 正しいトークンの列ならそれが返ってくる
  • ダメな文字が含まれていたらエラーが返ってくる

といった感じになります。

IDEに組み込んでみる

Java/EclipseでDSLサポート (3) - DSLとして使われる文字列リテラルの判定 - しげるメモをまた少し変更して、この字句解析処理をIDEに組み込んでみます。

まず、引っ掛けるメソッドの定義。

/**
 * ListFilter.Filtering#includes(String) を表すキー
 */
public static final String INCLUDES =
        "com.example.ashigeru.dsl.ListFilter.Filtering" +
        "#includes(java.lang.String)";

/**
 * ListFilter.Filtering#excludes(String) を表すキー
 */
public static final String EXCLUDES =
        "com.example.ashigeru.dsl.ListFilter.Filtering" +
        "#excludes(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) {
            // includes, excludesだけを対象に
            String key = getKey(node);
            if (!INCLUDES.equals(key) && !EXCLUDES.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,
                    0,
                    arg.getLength());
                return true;
            }

            // 引数の検査
            StringLiteral string = (StringLiteral) arg;
            try {
                ListFilterScanner.scan(string.getLiteralValue());
            }
            // スキャナーの失敗
            catch (ListFilterScannerException e) {
                // MEMO 本来はエスケープシーケンスを考慮しないとずれる!
                problem(
                    IStatus.ERROR,
                    MessageFormat.format(
                        "不明なトークン\"{0}\"", e.image),
                    string,
                    e.offset + 1, // 文字列リテラル開始記号 " で + 1
                    e.length);
                return true;
            }

            // あとはOK
            return true;
        }

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

ポイントは、スキャナに文字列リテラルを食わせてエラーを拾っている箇所です。

// 引数の検査
StringLiteral string = (StringLiteral) arg;
try {
    ListFilterScanner.scan(string.getLiteralValue());
}
// スキャナーの失敗
catch (ListFilterScannerException e) {
    // MEMO 本来はエスケープシーケンスを考慮しないとずれる!
    problem(
        IStatus.ERROR,
        MessageFormat.format(
            "不明なトークン\"{0}\"", e.image),
        string,
        e.offset + 1, // 文字列リテラル開始記号 " で + 1
        e.length);
    return true;
}

ここのコメントにも書いたように、本来はエスケープシーケンス(\n, \u0041など)を考慮しないとエラー位置がずれてしまいます。実用レベルに持っていくなら、これはやらないとまずいですね。

ともあれ、これを実際に使ってみると↓のような感じになります。

次は、構文解析までやって構文エラーの抽出を。