Xtextのソース整形のメモ。
|
エディター上では、Ctrl+Shift+Fでソースの整形が出来る。
Xtextのデフォルトでは、トークンとトークンの間を1つのスペースに置き換えるという単純な整形となっている。
(OneWhitespaceFormatterが使われている模様)
xtextファイルをビルドすると、Formatterクラスの空実装が作られる。
例えばDMDL.xtextの場合、DMDLFormatter.xtendとなる。(Xtext 2.4.2)
これはXtendという(Javaに似た)言語であり、ここにソース整形のルールを実装していく。
Xtext 2.4.2では、ソース整形クラスとして生成されるファイルはXtend用である。
個人的には新しい言語を覚えるよりJavaの方が分かり易いので、Javaで実装したい。
XtendはJavaに似ているので、Javaに変換するのも難しくない。
元のxtendファイルは、ファイル自体は削除せずに、中身を空にしておく。
中身が残っていると同じクラスがJavaとXtendの両方で定義されることになり、コンパイルエラーになる。
xtendファイル自体を消してしまうと、次にxtextファイルをビルドしたときにまた生成されてしまうので、ファイル自体は残しておく必要がある。
package jp.hishidama.xtext.dmdl_editor.formatting;
import jp.hishidama.xtext.dmdl_editor.services.DMDLGrammarAccess; import org.eclipse.xtext.Keyword; import org.eclipse.xtext.formatting.impl.AbstractDeclarativeFormatter; import org.eclipse.xtext.formatting.impl.FormattingConfig; import org.eclipse.xtext.util.Pair;
public class DMDLFormatter extends AbstractDeclarativeFormatter {
@Override
protected void configureFormatting(FormattingConfig c) {
DMDLGrammarAccess f = (DMDLGrammarAccess) getGrammarAccess();
〜
}
}
GrammarAccessの変数は、(Google Guiceを利用した)インジェクションによって初期化する方法もある。[2013-08-19]
import org.eclipse.xtext.xbase.lib.Extension; import com.google.inject.Inject;
public class DMDLFormatter extends AbstractDeclarativeFormatter {
@Inject
@Extension
private DMDLGrammarAccess grammarAccess;
@Override
protected void configureFormatting(FormattingConfig c) {
DMDLGrammarAccess f = grammarAccess;
〜
}
}
ソース整形の実装は、FormattingConfigに整形ルールをセットしていく。
(整形ルールの設定方法は、Javaをホスト言語とする内部DSLっぽい。さすがDSLを扱うXtextや(笑))
// 例:descriptionの直後は改行する c.setLinewrap().after(f.getDescriptionRule());
| 整形メソッド | 内容 |
|---|---|
| setLinewrap() | 改行する。(1行) |
| setLinewrap(行数) | 改行する。(指定行数) |
| setLinewrap(最小, デフォルト, 最大) | 改行する。(最小および最大行数) |
| setNoLinewrap() | 改行しない。(行を詰める) |
| setSpace(文字列) | 空白(文字列)を入れる。 |
| setNoSpace() | 空白を詰める。 |
| setIndentationIncrement() | インデントを開始する。 |
| setIndentationDecrement() | インデントを終了する。 |
| 位置指定メソッド | 整形ルールが適用される位置 |
|---|---|
before(トークン) |
トークンの前 |
after(トークン) |
トークンの後 |
around(トークン) |
トークンの前後 |
between(トークン, トークン) |
|
bounds(トークン, トークン) |
整形ルールを指定しなかった箇所は、トークンとトークンの間はスペース1個に整形される。
一行の最大桁数を指定できる。
ある行の桁数が指定桁数を超えたら、自動的に改行される。
c.setAutoLinewrap(120); // 一行が120桁を超えたら改行する
あるキーワード(文字)に関して、どの場所であっても同じ整形ルールになる場合は、そのキーワードを指定して整形ルールを定義できる。
// カンマの例
for (Keyword comma : f.findKeywords(",")) {
c.setNoSpace().before(comma); // 直前は詰める
}
// 丸括弧の例
for (Pair<Keyword, Keyword> pair : f.findKeywordPairs("(", ")")) {
c.setNoSpace().after(pair.getFirst()); // 開き括弧の直後は詰める
c.setNoSpace().before(pair.getSecond()); // 閉じ括弧の直前は詰める
}
// 波括弧の例
for (Pair<Keyword, Keyword> pair : f.findKeywordPairs("{", "}")) {
c.setIndentationIncrement().after(pair.getFirst()); // 開き括弧の後でインデントを増やす
c.setIndentationDecrement().before(pair.getSecond()); // 閉じ括弧の前でインデントを減らす
c.setLinewrap().after(pair.getFirst()); // 開き括弧の後は改行する
}
特定の解析ルールに対して整形ルールを定義する。
// descriptionルールの直後は改行する c.setLinewrap().after(f.getDescriptionRule());
解析ルール内の個別のトークンを指定することも出来る。
// attributeルールの'('の前は詰める
// attribute: '@' name=ID '(' elements=List ')'
c.setNoSpace().before(f.getAttributeAccess().getLeftParenthesisKeyword_2_0());
fooという解析ルールがあったら、f.getFooRule()で解析ルールのオブジェクトが取得できる。
f.getFooAccess()で個別のトークン(キーワード等)へアクセスする為のオブジェクトが取得できる。
f.getFooAccess()で取得したオブジェクトからもfindKeywords()やfindKeywordPairs()を使うことが出来る。
インデントの増減を指定することが出来る。[2013-09-11]
for (Pair<Keyword, Keyword> pair : f.findKeywordPairs("{", "}")) {
c.setIndentationIncrement().after(pair.getFirst()); // 開き括弧の後でインデントを増やす
c.setIndentationDecrement().before(pair.getSecond()); // 閉じ括弧の前でインデントを減らす
}
for (Pair<Keyword, Keyword> pair : f.findKeywordPairs("{", "}")) {
c.setIndentation(pair.getFirst(), pair.getSecond());
}
インデントを二重に定義すると、整形時のインデントも2倍になる。
for (Pair<Keyword, Keyword> pair : f.findKeywordPairs("{", "}")) {
c.setIndentation(pair.getFirst(), pair.getSecond());
c.setIndentation(pair.getFirst(), pair.getSecond()); //二重に定義
}
インデントに使われる文字は、デフォルトではテキストエディター一般の設定が使われる。
(特に何も設定していない場合はタブになる)
この文字は、Eclipseの設定(Preferences)で変更できる。
インデントの文字はIIndentationInformationインターフェースの実装クラスで指定する。
デフォルトではPreferenceStoreIndentationInformation(Eclipseの設定(Preferences)からインデント文字を取得する)が使われている。
独自のインデント文字指定を行いたい場合は、FormatterクラスのgetIndentInfo()をオーバーライドする。
import org.eclipse.xtext.formatting.IIndentationInformation;
@Override
protected IIndentationInformation getIndentInfo() {
return new IIndentationInformation() {
public String getIndentString() {
return " "; //インデント1つ分の文字列
}
};
}
自動生成されたFormatterのソースに、「コメントはこうしたら便利だよ」という例が載っている。
// コメント c.setLinewrap(0, 1, 2).before(f.getSL_COMMENTRule()); c.setLinewrap(0, 1, 2).before(f.getML_COMMENTRule()); c.setLinewrap(0, 1, 1).after (f.getML_COMMENTRule());
setLinewrap(最小行数, デフォルト行数, 最大行数)を使って指定している。
最小行数に0を指定しているので、エディター上で元々改行されていない場合はそのままとなる。
コメントの前は最大2行まで改行を許す。2行以上あると2行に縮められる。
複数行コメント(ML_COMMENT)の場合は、コメントの後に最大1回まで改行する。
単一行コメント(SL_COMMENT)は元々末尾に改行を含んでいるので、整形ルールは特に指定しない。
上記の様にプログラミングしてテスト実行は上手く行ったのだが、Eclipseプラグインの更新サイトを作ってそちら経由でインストールしたら、エラーが出た。[2013-08-19]
(Xtext 2.4.2、com.google.inject_3.0.0.v201203062045.jar)
org.eclipse.core.runtime.CoreException: Guice provision errors:
1) Error injecting constructor, java.lang.Error: Unresolved compilation problems:
comma cannot be resolved to a variable
Syntax error on token "}", { expected after this token
c cannot be resolved
c cannot be resolved
f cannot be resolved
c cannot be resolved
c cannot be resolved
period cannot be resolved to a variable
Syntax error on token "}", delete this token
Syntax error on token "(", ; expected
Syntax error on token ",", ; expected
Syntax error on token ")", ; expected
Syntax error, insert "}" to complete ClassBody
at jp.hishidama.xtext.dmdl_editor.formatting.DMDLFormatter.(Unknown Source)
while locating jp.hishidama.xtext.dmdl_editor.formatting.DMDLFormatter
while locating org.eclipse.xtext.formatting.IFormatter
〜
「comma」「c」「f」といった変数が解決できないというエラー?
DMDLFormatterのソースに問題があるらしいが、別に問題ないし!
public class DMDLFormatter extends AbstractDeclarativeFormatter {
@Override
protected void configureFormatting(FormattingConfig c) {
〜
// カンマ
for (Keyword comma : f.findKeywords(",")) {
c.setNoSpace().before(comma); // 直前は詰める
}
〜
}
}
試しにfindKeywords関連部分を削除してみたら動いた。
findKeywordPairsは動いているので、違いは可変長引数かどうかか?
また、AbstractDeclarativeFormatter以外のNullFormatterやOneWhitespaceFormatterに変えてみたら、エラーにならなかった。
見た感じの大きな違いは、AbstractDeclarativeFormatterはインジェクションを使っているが他は使っていない。
なので、DMDLFormatterUtilという新しいクラスを作ってconfigureFormatting()の中身を丸々移動してそちらを呼び出すようにしたら、ちゃんと動いた。
(一部だけを移すと、設定した整形ルールが中途半端に適用されている感じの動作になり、怪しい)
最初は、エラーメッセージからGoogle Guiceのバグかと思ったのだが、色々試した結果、UTF-8に関するビルドエラーと分かった。[2013-08-29]
コメントの「// カンマ」の「マ」がビルド時に文字化けし、おかしなコードとしてコンパイルされたもの。
コンパイルエラー状態でもErrorをスローするようなクラスが生成されるので、その通りに実行時にはErrorがスローされ、Google
Guiceはそれを拾ってエラーメッセージを表示していただけ。
「findKeywords関連部分を削除したら動いた」のは、コメントも削除していた為。
NullFormatterやOneWhitespaceFormatterに変えたときはconfigureFormatting()メソッドをオーバーライドするわけではないので、コメントを書いていなかった。
DMDLFormatterUtilというクラスに移したときにエラーにならなかった理由はよく分からないが、
「設定した整形ルールが中途半端に適用されている」状態になったのは、コメントの直後に1行だけ書かれている整形ルールはコメントに結合されて消えていたのだろう。
デフォルトのAbstractDeclarativeFormatter(FormattingConfig)を使う整形方法は、静的な整形ルールしか定義できない。[2013-08-28]
つまり、状況に応じた(条件によって異なる)整形ルールを定義することが出来ない。
(AbstractDeclarativeFormatterのさらに親である)BaseFormatter・IFormatterを自分で作ってやれば自由に整形ルールを作れるだろうが、さすがに大変そう。
ちょっとした動的ルールを追加するだけなら、ITokenStreamを利用する方法が使える。
Formatterは、Formatterで整形した文字列をITokenStreamに出力するようになっている。
そして、ITokenStreamはさらに別のITokenStreamを経由することが出来る。
そこで、AbstractDeclarativeFormatterの出力先を自分独自のITokenStreamにしてやれば、整形ルールの追加が出来ることになる。
例として、DMDLの丸括弧の条件付きルールを定義してみる。
DMDLでは、丸括弧内に引数を書く。引数の個数が1個以下なら丸括弧の前後は改行せず、2個以上なら改行する。
| 引数の個数 | 整形された例 | 判定方法 |
|---|---|---|
| 0 | @test() |
|
| 1 | @test(arg1 = "abc") |
|
| 2 | @test( |
「(」が来てから「)」が来るまでに「,」があったら、引数が2個以上あるという事になる。(DMDLでは、括弧はネストしない) |
public class DMDLFormatter extends AbstractDeclarativeFormatter {
〜
@Override
protected void configureFormatting(FormattingConfig c) {
〜
// 丸括弧
for (Pair<Keyword, Keyword> pair : f.findKeywordPairs("(", ")")) {
c.setLinewrap().after(pair.getFirst()); // 開き括弧の直後は改行する
c.setLinewrap().before(pair.getSecond()); // 閉じ括弧の直前は改行する
// 引数の個数に応じた改行有無はDMDLTokenStreamで実装
c.setIndentationIncrement().after(pair.getFirst());
c.setIndentationDecrement().before(pair.getSecond());
}
// カンマ
for (Keyword comma : f.findKeywords(",")) {
c.setNoSpace().before(comma); // 直前は詰める
c.setLinewrap().after(comma); // 直後は改行する
}
〜
}
@Override
public ITokenStream createFormatterStream(String indent, ITokenStream out, boolean preserveWhitespaces) {
return super.createFormatterStream(indent, new DMDLTokenStream(out), preserveWhitespaces);
}
@Override
public ITokenStream createFormatterStream(EObject context, String indent, ITokenStream out, boolean preserveWhitespaces) {
return super.createFormatterStream(context, indent, new DMDLTokenStream(out), preserveWhitespaces);
}
}
createFormatterStream()にTokenStream(out)を渡す際に、自分のDMDLTokenStreamを挟むようにしている。
import java.io.IOException; import java.util.ArrayList; import java.util.List; import org.eclipse.emf.ecore.EObject; import org.eclipse.xtext.formatting.impl.BaseTokenStream; import org.eclipse.xtext.parsetree.reconstr.ITokenStream;
class DMDLTokenStream extends BaseTokenStream {
private boolean block = false; private List<Token> tokenList = new ArrayList<Token>(); private boolean comma = false;
private class Token {
private final boolean hidden;
private final EObject grammarElement;
private final String value;
public Token(boolean hidden, EObject grammarElement, String value) {
this.hidden = hidden;
this.grammarElement = grammarElement;
this.value = value;
}
public boolean containsLf() {
return value.indexOf('\n') >= 0;
}
public void write() throws IOException {
outWrite(hidden, grammarElement, value);
}
}
public DMDLTokenStream(ITokenStream out) {
super(out);
}
@Override
public void writeHidden(EObject grammarElement, String value) throws IOException {
write(true, grammarElement, value);
}
@Override
public void writeSemantic(EObject grammarElement, String value) throws IOException {
write(false, grammarElement, value);
}
private void write(boolean hidden, EObject grammarElement, String value) throws IOException {
if ("(".equals(value)) {
// 開き括弧
outWrite(hidden, grammarElement, value);
block = true;
comma = false;
} else if (block && ")".equals(value)) {
// 閉じ括弧
if (comma) {
for (Token t : tokenList) {
t.write();
}
} else {
for (Token t : tokenList) {
// 改行コードを含んでいる文字列は出力しない
if (!t.containsLf()) {
t.write();
}
}
}
tokenList.clear();
outWrite(hidden, grammarElement, value);
block = false;
} else if (block) {
// 括弧内ではバッファーに保持していく
tokenList.add(new Token(hidden, grammarElement, value));
if (",".equals(value)) {
comma = true;
}
} else {
outWrite(hidden, grammarElement, value);
}
}
開き括弧が来たら、閉じ括弧が来るまでの間はバッファー(tokenList)に文字列を溜めておく。
閉じ括弧が来たら、それまでにカンマがあった場合(“引数が2個以上”を意味する)はバッファーの内容をそのまま全部出力する。
カンマが無かった場合(“引数が1個以下”を意味する)は詰めて出力するので、改行のある文字列をスキップする。
(厳密にはコメントも改行を含む可能性があるので、別途判定してコメントは常に出力する必要がある)
@Override
public void flush() throws IOException {
// 閉じ括弧で終わっていない場合にバッファーの内容を全て出力する
if (block) {
for (Token t : tokenList) {
t.write();
}
block = false;
}
tokenList.clear();
super.flush();
}
private void outWrite(boolean hidden, EObject grammarElement, String value) throws IOException {
if (hidden) {
out.writeHidden(grammarElement, value);
} else {
out.writeSemantic(grammarElement, value);
}
}
}
Stringを整形する例。[2013-09-03]
import com.google.inject.Injector; import org.eclipse.xtext.formatting.INodeModelFormatter; import org.eclipse.xtext.formatting.INodeModelFormatter.IFormattedRegion; import org.eclipse.xtext.parser.IParseResult; import jp.hishidama.xtext.dmdl_editor.parser.antlr.DMDLParser; import jp.hishidama.xtext.dmdl_editor.ui.internal.DMDLActivator;
public static String format(String src) {
Injector injector = DMDLActivator.getInstance().getInjector(DMDLActivator.JP_HISHIDAMA_XTEXT_DMDL_EDITOR_DMDL);
DMDLParser parser = injector.getInstance(DMDLParser.class);
IParseResult result = parser.parse(new StringReader(src));
INodeModelFormatter formatter = injector.getInstance(INodeModelFormatter.class);
IFormattedRegion r = formatter.format(result.getRootNode(), 0, src.length());
String text = r.getFormattedText();
return text;
}
ファイル保存前に自動的に整形させることも出来る。[2014-04-22]
まず、ファイルが保存される時に呼ばれるクラスを作成する。
import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jface.text.IDocument; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.handlers.IHandlerService; import org.eclipse.xtext.ui.editor.model.XtextDocumentProvider;
public class DMDLDocumentProvider extends XtextDocumentProvider {
private static final String XTEXT_FORMAT_ACTION_COMMAND_ID = "org.eclipse.xtext.ui.FormatAction";
@Override
protected void doSaveDocument(IProgressMonitor monitor, Object element, IDocument document, boolean overwrite) throws CoreException {
// auto-format
IHandlerService service = (IHandlerService) PlatformUI.getWorkbench().getService(IHandlerService.class);
try {
service.executeCommand(XTEXT_FORMAT_ACTION_COMMAND_ID, null);
} catch (Exception e) {
// TODO eをログ出力
}
// save
super.doSaveDocument(monitor, element, document, overwrite);
}
}
※実際には、保存時に常にソース整形するのではなく、プロパティーページによって保存時整形可否を設定できるようにするべきだろう。
参考: Eclipse Community Forumsのright place to add save actions?
そして、このクラスが使われるようにする。
import org.eclipse.xtext.ui.editor.model.XtextDocumentProvider;
public class DMDLUiModule extends jp.hishidama.xtext.dmdl_editor.ui.AbstractDMDLUiModule {
〜
public Class<? extends XtextDocumentProvider> bindXtextDocumentProvider() {
return DMDLDocumentProvider.class;
}
}