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; } }