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作りたいワケじゃなくて、DSLをIDEからサポートしたいだけなので、中身は空っぽです。
暇があったら中身も作りますよ。
ここで注意してほしいのは、DSLを引っ掛ける場所が
- com.example.ashigeru.dsl.ListFilter.Filtering#includes(String)
- com.example.ashigeru.dsl.ListFilter.Filtering#excludes(String)
といった感じに、ListFilterクラスじゃなくてListFilter.Filteringクラスであるところ。
コレまでと同様、メソッド呼び出しに一意なキーを使って引っ掛けるので、間違えると何もしなくなります。
構文解析の基礎
DSL作りたいワケじゃないですが、DSLの構文解析まではIDE側でしなきゃならんので、簡単に構文解析ってどうやるのというのを書いておきます。
といった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など)を考慮しないとエラー位置がずれてしまいます。実用レベルに持っていくなら、これはやらないとまずいですね。
ともあれ、これを実際に使ってみると↓のような感じになります。
次は、構文解析までやって構文エラーの抽出を。