S-JIS[2013-08-18/2014-04-22] 変更履歴

Xtextソース整形

Xtextのソース整形のメモ。


概要

エディター上では、Ctrl+Shift+Fでソースの整形が出来る。

Xtextのデフォルトでは、トークンとトークンの間を1つのスペースに置き換えるという単純な整形となっている。
(OneWhitespaceFormatterが使われている模様)

xtextファイルをビルドすると、Formatterクラスの空実装が作られる。
例えばDMDL.xtextの場合、DMDLFormatter.xtendとなる。(Xtext 2.4.2)
これはXtendという(Javaに似た)言語であり、ここにソース整形のルールを実装していく。


Javaソースへの変換

Xtext 2.4.2では、ソース整形クラスとして生成されるファイルはXtend用である。
個人的には新しい言語を覚えるよりJavaの方が分かり易いので、Javaで実装したい。

XtendはJavaに似ているので、Javaに変換するのも難しくない。

元のxtendファイルは、ファイル自体は削除せずに、中身を空にしておく。
中身が残っていると同じクラスがJavaとXtendの両方で定義されることになり、コンパイルエラーになる。
xtendファイル自体を消してしまうと、次にxtextファイルをビルドしたときにまた生成されてしまうので、ファイル自体は残しておく必要がある。

DMDLFormatter.java(DMDLの例):

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

Formatterの実装方法

ソース整形の実装は、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()をオーバーライドする。

MyDslFormatter.java:

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)は元々末尾に改行を含んでいるので、整形ルールは特に指定しない。


UTF-8に関するエラー

上記の様にプログラミングしてテスト実行は上手く行ったのだが、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行だけ書かれている整形ルールはコメントに結合されて消えていたのだろう。


ITokenStreamの利用

デフォルトのAbstractDeclarativeFormatterFormattingConfig)を使う整形方法は、静的な整形ルールしか定義できない。[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(
    arg1 = "abc",
    arg2 = 123
)
(」が来てから「)」が来るまでに「,」があったら、引数が2個以上あるという事になる。
(DMDLでは、括弧はネストしない)

DMDLFormatter.java:

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を挟むようにしている。

DMDLTokenStream.java:

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]

まず、ファイルが保存される時に呼ばれるクラスを作成する。

uiプロジェクト/〜/DMDLDocumentProvider.java:

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?


そして、このクラスが使われるようにする。

uiプロジェクト/〜/DMDLUiModule.java:

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

Xtext目次へ戻る / Eclipseへ戻る / 技術メモへ戻る
メールの送信先:ひしだま